diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7246b28b2..0a5112f4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,11 @@ concurrency: cancel-in-progress: ${{ github.event_name != 'push' }} jobs: - lint: + # Prettier and eslint run un-cached in CI: restored result caches can mark + # files clean against a stale tool version or config, masking real failures. + # The --cache flags in the package.json lint script remain for local speed. + prettier: runs-on: ubuntu-latest - # TODO(CL-6802 stage 2): flip blocking after the mechanical fix batch - continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 @@ -33,18 +34,31 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Cache lint + - name: Prettier + run: bunx prettier --check . + + eslint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + + - name: Cache dependencies uses: actions/cache@v4 with: - path: | - .eslintcache - node_modules/.cache/prettier - key: lint-${{ github.sha }} - restore-keys: | - lint- - - - name: Lint - run: bun run lint + path: node_modules + key: bun-${{ hashFiles('bun.lock') }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: ESLint + run: bunx eslint . typecheck: runs-on: ubuntu-latest diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 3d0d2adf5..3c6c22f87 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -46,9 +46,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - path-to-document: 'https://github.com/corbitsdev/corbits-code/blob/main/CLA.md' - path-to-signatures: 'signatures/version1/cla.json' - branch: 'cla-signatures' + path-to-document: "https://github.com/corbitsdev/corbits-code/blob/main/CLA.md" + path-to-signatures: "signatures/version1/cla.json" + branch: "cla-signatures" # People who never need to sign (maintainers, bots). allowlist: TheGreatAxios,brianjfox,*[bot] custom-notsigned-prcomment: >- @@ -56,5 +56,5 @@ jobs: please read our [Contributor License Agreement](https://github.com/corbitsdev/corbits-code/blob/main/CLA.md) and sign it by posting a new comment on this pull request containing exactly the line below (nothing else): - custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA' - custom-allsigned-prcomment: 'All contributors have signed the CLA.' + custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" + custom-allsigned-prcomment: "All contributors have signed the CLA." diff --git a/.prettierignore b/.prettierignore index d3a449bfe..46f86469d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ vendor/ scratch/ node_modules/ CHANGELOG.md +tests/fixtures/broken-toolchain/ diff --git a/AGENTS.md b/AGENTS.md index f11e32e8e..250483c42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ git config core.hooksPath .githooks - **Paradigm:** Functional. No classes, no OOP. - **Types:** Full type safety. Avoid `any`; prefer `unknown`. Validate all external input at the boundary with arktype — do not hand-roll `typeof` guards for structured data. - **Files:** Small functions, small files, clear names. Acronyms keep their case (`URL`, `JSON`, `API`). -- **Comments:** Comment *why*, never *what*. If a comment describes what the code does, fix the names instead. +- **Comments:** Comment _why_, never _what_. If a comment describes what the code does, fix the names instead. - **No emojis** in code or docs. ## Scope Discipline @@ -73,15 +73,15 @@ It authenticates over HTTPS via `gh`'s credential helper and rewrites the SSH re Interchange is the standard library for this repo, consumed as published `@intx/*` npm packages pinned at 0.2.2, except `@intx/inference`, `@intx/types`, and `@intx/storage-isogit`, which resolve to vendored source under `vendor/intx-*` at upstream head (coupled by the reactor's approval-suspend primitive; `@intx/inference` also carries a local patch set). See `docs/VENDORING.md` for what's vendored, from which upstream commit, and the re-sync procedure. We never modify or push to the upstream interchange repository. Before writing any new infrastructure — plugins, middleware, utilities, state management, logging, authz, inference, tools — check these packages. -| Package | Covers | -|---|---| -| `@intx/authz` | Grant matching (`matchPattern`, `evaluateGrants`) for permission approvals; Corbits owns the gate, store, and TUI ask | -| `@intx/inference` | Reactor loop, `createAuthzExtension`, `DefaultDirector` | -| `@intx/agent` | Agent lifecycle, send queue, stream | -| `@intx/tools-posix` | Shell, file read/write/edit, grep, search | -| `@intx/storage-isogit` | Git-backed state persistence | -| `@intx/log` | Structured logging via LogTape | -| `@intx/types` | All shared runtime types | +| Package | Covers | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `@intx/authz` | Grant matching (`matchPattern`, `evaluateGrants`) for permission approvals; Corbits owns the gate, store, and TUI ask | +| `@intx/inference` | Reactor loop, `createAuthzExtension`, `DefaultDirector` | +| `@intx/agent` | Agent lifecycle, send queue, stream | +| `@intx/tools-posix` | Shell, file read/write/edit, grep, search | +| `@intx/storage-isogit` | Git-backed state persistence | +| `@intx/log` | Structured logging via LogTape | +| `@intx/types` | All shared runtime types | ## Reference diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6bd6c2457..396f1030a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,7 @@ or release notes from commit types. This project does not: - Reviewers and `git log` readers need a sentence that stands alone years later, not a taxonomy debate (`chore` vs `refactor` vs `fix`). - An imperative subject already encodes the action: `Fix race in the approval - queue` is clearer than `fix: race in the approval queue`. +queue` is clearer than `fix: race in the approval queue`. - Prefixes train agents and humans to smuggle scope, ticket IDs, and file names into the subject — noise we already reject elsewhere. @@ -93,8 +93,8 @@ Angular-style prefixes is not a reason to adopt them here. Most commits need **no** body. A clear subject plus a coherent diff is enough. -Add a body only when a future reader of `git log` could not answer *why this -change* from the subject and the diff alone. When present: +Add a body only when a future reader of `git log` could not answer _why this +change_ from the subject and the diff alone. When present: - Blank line between subject and body - Wrap body lines at 72 characters @@ -187,6 +187,7 @@ Link trackers at the **PR boundary**, not inside every commit. Full Linear URLs also work. Prefer the body over stuffing the ID into the PR title so the title stays a plain-English sentence. + 3. Do **not** put `CL-…` in commit subjects or bodies. **Closing magic words** (issue moves to Done on merge when automation is diff --git a/README.md b/README.md index 0f2503a01..e2d63db74 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ CLI (src/index.ts) ``` The chat director adds context management on top of the reactor: + - **Threshold compaction:** As the context window fills, the conversation is compacted at the next safe point. - **Idle compaction:** A pending compaction also runs when a turn ends without more work, so a text-only conversation still compacts. - **Overflow recovery:** A context-overflow error triggers a bounded compact-and-retry instead of failing the turn. @@ -94,7 +95,6 @@ Corbits Code defaults to **auto mode** (`auto = true`). Workspace file writes/ed - Opaque shell wrappers the policy cannot statically inspect (variable expansion or command substitution in a wrapper payload) - Paths outside the workspace, writes under the session state root, mutating MCP tools, and unknown built-ins - ### What auto hard-denies (use the file tools instead) - File creation or edits via shell: redirects (`>` / `>>`), `tee`, `sed -i` / `perl -i` / similar, interpreter inline programs or heredocs (`python -c`, `node -e`, …) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1448152fa..56b367063 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,11 +28,11 @@ types the TUI maps is `PRODUCTION_REACTOR_TYPES` in treat that as canonical rather than this table or any other doc's partial list. -| Event | When it fires | -|---|---| +| Event | When it fires | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `inference.done` | The LLM finished one assistant turn. Carries the full turn content. Fires once per turn, every turn — this is the **turn boundary**. | -| `tool.done` | One tool call completed. Carries the result and the original `callId`. | -| `reactor.done` | The reactor loop shut down. Fires once, at the end of the run — not between turns. | +| `tool.done` | One tool call completed. Carries the result and the original `callId`. | +| `reactor.done` | The reactor loop shut down. Fires once, at the end of the run — not between turns. | `inference.done` and `reactor.done` read as near-synonyms at a call site but answer different questions: "did a turn end" versus "did the reactor shut @@ -107,21 +107,19 @@ 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 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 5 consecutive identical tool-call fingerprints (**no-progress**, mirroring the director-level `IDENTICAL_REPEAT_MIN` threshold), 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. `inference.thinking.delta` is sampled the same way on its own buffer, but with digit runs folded to one placeholder and a shorter window (>= 4 chars repeated >= 32 times), gated to periods <= 16 chars once folded: thinking is never rendered to the user, so a monotonic counter (e.g. `0/1 1/2 2/3 …`, which stays non-periodic and escapes the raw-text check) can be caught, but folding still erases real information — a healthy templated enumeration line becomes byte-identical to its neighbors once digits are erased, so the period-length cap only lets counter-shaped folded periods (a handful of chars) through and refuses the much longer periods a folded prose line produces. 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. - - + `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. `inference.thinking.delta` is sampled the same way on its own buffer, but with digit runs folded to one placeholder and a shorter window (>= 4 chars repeated >= 32 times), gated to periods <= 16 chars once folded: thinking is never rendered to the user, so a monotonic counter (e.g. `0/1 1/2 2/3 …`, which stays non-periodic and escapes the raw-text check) can be caught, but folding still erases real information — a healthy templated enumeration line becomes byte-identical to its neighbors once digits are erased, so the period-length cap only lets counter-shaped folded periods (a handful of chars) through and refuses the much longer periods a folded prose line produces. 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. #### Model-family policy (`src/agent/model-family-policy.ts`) Both directors consume one `ModelFamilyPolicy` object, resolved once per session/leaf from the provider/model via `detectModelFamily` (`src/subagent/provider-family.ts`) — the directors branch on this data, never on per-family subclasses or forks. `resolveModelFamilyPolicy({ providerName, model, orchestrator? })` returns: -| Field | Meaning | -|---|---| -| `toolOnlyTurnNudgeAt` | Consecutive tool-only assistant turns (tool calls, no text) before the ChatDirector injects a one-shot wrap-up nudge — a check-in, not a stop. | -| `wrapUpNudgeText` | Ephemeral nudge text injected at the nudge threshold. | -| `subAgentStallTimeoutMs` | Wall-clock inactivity, in ms, before a silent sub-agent leaf gets a continuation nudge. | -| `applyGrokFinishBias` | The existing grok anti-thrash residual (withheld from orchestrators — see `shouldApplyGrokAntiThrash`). | +| Field | Meaning | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `toolOnlyTurnNudgeAt` | Consecutive tool-only assistant turns (tool calls, no text) before the ChatDirector injects a one-shot wrap-up nudge — a check-in, not a stop. | +| `wrapUpNudgeText` | Ephemeral nudge text injected at the nudge threshold. | +| `subAgentStallTimeoutMs` | Wall-clock inactivity, in ms, before a silent sub-agent leaf gets a continuation nudge. | +| `applyGrokFinishBias` | The existing grok anti-thrash residual (withheld from orchestrators — see `shouldApplyGrokAntiThrash`). | Defaults (`src/agent/model-family-policy.ts:47`): nudge at 25 consecutive tool-only turns, 5-minute stall timeout. The hard pause is no longer a `ModelFamilyPolicy` field — it runs the same period-detection thrash check for every family (see below). Nudge-at-25 replaced an earlier count-only design (nudge at 12, hard-pause at 20 by count alone, grok tightened to 6/10) that conflated any tool-only turn with no-progress — a Grok session hard-paused at 10 turns while making real progress through Linear lookups and code reads (CL-4839's original loop protection was aimed at runaway list-crawl thrash, not busy-but-progressing tool use). A grep/jq pass over real session traces under `~/.corbits/projects/*/*/context/turns.jsonl` (54 sessions with any tool-only run) found healthy tool-only streaks topping out at 13 turns (p90 12, p99 13) — 25 sits comfortably above that. **Grok** shares the default nudge threshold (its own 6/10 pair was the miscalibration this fixed) but keeps its shorter sub-agent stall timeout (90s) and `applyGrokFinishBias` residual, both independently motivated. **Kimi (Moonshot)** detection ships now (`isKimiLeafProvider`) so callers can already branch on the family, but its thresholds are provisional — pinned to the permissive default with a why-comment in the policy module — pending eval characterization of Kimi's tool-only and stall behavior. @@ -137,11 +135,11 @@ The hard pause is a separate signal that does **not** depend on the nudge having - **period 2** — an alternating pair (`A,B,A,B,...`). The previous implementation compared each turn only to the one immediately before it, so this pattern never triggered at any length. - **period ≥3** — a rotating cycle (`A,B,C,A,B,C,...`). -The repeat floor differs by period (`src/subagent/stop-policy.ts:138-157`): period 1 requires 5 repeats (`IDENTICAL_REPEAT_MIN`) — a short run of identical calls is legitimate (rerunning a flaky test, polling a build), and review on CL-5611 found the previous 4-repeat pause false-positived on exactly that. Any cycle of period ≥2 requires only 3 repeats (`CYCLE_REPEAT_MIN`) — there is no plausible legitimate reason to re-issue a fixed rotation of *different* tool calls with identical arguments, so it fires fast (an alternating pair pauses at 6 turns; a 3-call cycle at 9). Both floors are set well above the *measured* healthy ceiling: a local forensic scan (`scripts/tool-fingerprint-forensics.ts`, 328 sessions with a tool-only run, 559 tool-only runs — **this dataset informs the period-detection repeat floors above, not the backstop threshold below, which uses a separate measurement**) found zero occurrences of any repeating cycle for any period the scan checks — periods 1 through 6 (`MAX_PERIOD_SCANNED`); the scan does not check periods 7-8, so `TOOL_FINGERPRINT_MAX_PERIOD` (`src/subagent/stop-policy.ts:138`) has no forensic backing above period 6, only headroom — stronger than CL-5611's original "zero 3+ identical" finding for the periods it does cover. The 5-repeat period-1 floor itself is not independently measured (the forensic dataset contains no repeats to calibrate against); it is inferred headroom for the polling case, chosen only to sit above the previously-false-positived value of 4. +The repeat floor differs by period (`src/subagent/stop-policy.ts:138-157`): period 1 requires 5 repeats (`IDENTICAL_REPEAT_MIN`) — a short run of identical calls is legitimate (rerunning a flaky test, polling a build), and review on CL-5611 found the previous 4-repeat pause false-positived on exactly that. Any cycle of period ≥2 requires only 3 repeats (`CYCLE_REPEAT_MIN`) — there is no plausible legitimate reason to re-issue a fixed rotation of _different_ tool calls with identical arguments, so it fires fast (an alternating pair pauses at 6 turns; a 3-call cycle at 9). Both floors are set well above the _measured_ healthy ceiling: a local forensic scan (`scripts/tool-fingerprint-forensics.ts`, 328 sessions with a tool-only run, 559 tool-only runs — **this dataset informs the period-detection repeat floors above, not the backstop threshold below, which uses a separate measurement**) found zero occurrences of any repeating cycle for any period the scan checks — periods 1 through 6 (`MAX_PERIOD_SCANNED`); the scan does not check periods 7-8, so `TOOL_FINGERPRINT_MAX_PERIOD` (`src/subagent/stop-policy.ts:138`) has no forensic backing above period 6, only headroom — stronger than CL-5611's original "zero 3+ identical" finding for the periods it does cover. The 5-repeat period-1 floor itself is not independently measured (the forensic dataset contains no repeats to calibrate against); it is inferred headroom for the polling case, chosen only to sit above the previously-false-positived value of 4. Once `detectToolFingerprintThrash` reports `repeating: true`, the director stops issuing infers entirely and replies with a loud, operator-facing pause message ("Auto-paused: the model repeated the same tool call N times in a row..." for period 1, or "...repeated a P-call cycle N times in a row..." for a longer cycle, both ending "without making progress. Send a message to resume."), using the same `capabilities.reply()` channel the workflow-stall message already uses to reach the TUI. A streak of length 200+ with a different tool call every turn never pauses. Because a turn with pending `tool_call` blocks must be followed by tool results before anything else (a bare nudge turn on top of pending tool calls is a provider-invalid conversation), both the nudge and the pause are applied by rewriting the `infer` action that follows once those pending tools have resolved (`applyToolOnlyLoopProtection`, `src/agent/director.ts:447`) — the same one-shot rewrite shape as the sub-agent report-forced wiring below. This loop-protection rewrite runs with the **highest precedence** among the terminal/continuation rewrites in `decideInner`: it is checked before the workflow-idle, open-task, and goal-governor continuation nudges, since those exist to keep a session moving — exactly the behavior the pause guards against. Resuming is just the operator sending a new message, which resets the streak, the fingerprint history, and un-pauses through the same reset path as the other nudge budgets. -**Backstop: nudge, then escalate — not an immediate pause.** Period detection has a structural blind spot: any period above `TOOL_FINGERPRINT_MAX_PERIOD`, or a "phase-broken" cycle that inserts a varying element between otherwise-repeating windows (e.g. `A,B,A,B,UNIQUE,A,B,A,B,UNIQUE,...`), never settles into an exact repeating tail and so never fires the fast path — at any streak length. Earlier versions of this backstop each had their own escape, all the same shape: the reset condition was satisfiable by something the model or the system itself could trigger. Round 4 fixed the narration escape (a raw tool-only streak that reset on any narrated turn, so a model inserting one word every ~55 turns kept resetting the counter) by separating two questions that had been sharing one reset rule — but its fix reset `turnsSinceUserMessage` on *any* `message.received` event, which is also satisfied by the synthetic content-less messages the runner sends itself after compaction (`buildCompactionContinuationMessage` in `src/tui/runner.ts`, `src/exec/runner.ts`, `src/subagent/run.ts`) — and compaction fires more often during long tool-only loops, i.e. exactly when the backstop should be counting. +**Backstop: nudge, then escalate — not an immediate pause.** Period detection has a structural blind spot: any period above `TOOL_FINGERPRINT_MAX_PERIOD`, or a "phase-broken" cycle that inserts a varying element between otherwise-repeating windows (e.g. `A,B,A,B,UNIQUE,A,B,A,B,UNIQUE,...`), never settles into an exact repeating tail and so never fires the fast path — at any streak length. Earlier versions of this backstop each had their own escape, all the same shape: the reset condition was satisfiable by something the model or the system itself could trigger. Round 4 fixed the narration escape (a raw tool-only streak that reset on any narrated turn, so a model inserting one word every ~55 turns kept resetting the counter) by separating two questions that had been sharing one reset rule — but its fix reset `turnsSinceUserMessage` on _any_ `message.received` event, which is also satisfied by the synthetic content-less messages the runner sends itself after compaction (`buildCompactionContinuationMessage` in `src/tui/runner.ts`, `src/exec/runner.ts`, `src/subagent/run.ts`) — and compaction fires more often during long tool-only loops, i.e. exactly when the backstop should be counting. Round 5 fixes the reset condition's shape instead of patching another instance: `turnsSinceUserMessage` now resets only when the inbound message carries `OPERATOR_ORIGINATED_FLAG` (`src/agent/message-provenance.ts`), a flag set only at the genuine human-input submit sites — the TUI's prompt-submit path (`userInboundMessage`, `src/tui/runner.ts`) and exec's initial-task send (`operatorTaskMessage`, `src/exec/runner.ts`). Nothing else sets it, so a message.received event from a synthetic or system-originated send (compaction continuation, retry, future director continuation) is system-originated by default and cannot accidentally qualify — the failure mode inverts from "silently forgets to exclude a sender" to "must explicitly claim to be a human." "Is the model cycling?" (`toolFingerprintHistory` / `lastThrashCheck`) is unaffected by this and is still cleared by any narrated turn — narration remains legitimate evidence the model is not stuck in a tight loop; only the "how long since the operator last saw a real checkpoint?" side (`turnsSinceUserMessage`, `src/agent/director.ts`) requires the operator flag. `detectTurnsSinceUserMessageBackstop` (`src/subagent/stop-policy.ts`) is the secondary/final-net check driven by this counter, evaluated only when period detection has not already reported `repeating: true` on that same turn — so it can never preempt the fast path, only catch what the fast path misses (periods above `TOOL_FINGERPRINT_MAX_PERIOD`, and phase-broken cycles). **This backstop's threshold (100) is a judgment call, not a measured value.** turns-since-last-genuine-operator-message was never separately measured — an earlier revision of this doc cited a scan of it with a stated methodology and specific percentiles; no corresponding script or output exists anywhere in the tree, and the citation was internally inconsistent about the session/run counts besides. That claim is retracted. The only real measurement available is `scripts/tool-fingerprint-forensics.ts`, which measures a related but different quantity — consecutive tool-only-turn streaks, reset by narration — p50 3, p90 8, p99 16, max 28 across 328 local sessions with a tool-only run. It doesn't directly justify 100 (narration doesn't reset this counter, so the distributions aren't comparable), but it's the only forensic data point on hand, and 100 sits comfortably above every percentile of it. @@ -156,7 +154,6 @@ Because the operator explicitly wants long autonomous runs to keep going, reachi **Precedence**: stall detection sits **below** no-progress, thrash, and turn-budget — those are evaluated from real `inference.done` turns inside `evaluateSubAgentStop` and always take priority; the stall check only ever fires on a continuation ping that inference/tool-result handling did not already consume that cycle. Report-forced (near-budget wrap-up) and re-read-nudge (mid-run soft re-read redirect) are independent one-shot signals that can both fire across a run — one is turn-count driven, the other re-read-pressure driven — but neither is a competing stop reason in the sense no-progress/thrash/turn-budget are. Stall nudging is wall-clock driven and likewise independent of both. - The reactor only persists a response turn to `turns.jsonl` on `inference.done`, so a cycle that is cancelled, aborted, errors, or is otherwise interrupted mid-stream would leave nothing behind. A cycle-text recorder (`src/session/stream-journal.ts`) closes that gap by buffering the in-flight cycle's streamed text in memory — no writes on the happy path — and appending one JSON record (`{reason, chars, text}`) to `partial.jsonl`, alongside `turns.jsonl` in the session context dir, on abnormal cycle end. It is wired into the sub-agent run loop, the exec runner (flushed on failed sends), and the TUI runner (flushed on interrupt and on session rotation, before the context dir is repointed). Both adopt the shared **compaction governor** (`src/agent/compaction.ts`) described below. @@ -172,7 +169,6 @@ Both adopt the shared **compaction governor** (`src/agent/compaction.ts`) descri The auto-deny approval timeout the goal governor armed (`timeoutMs`/`timeoutMessage` on the TUI permission gate) is generic plumbing that survives in `src/tui/gate-events.ts` / `src/tui/request-approval.ts` with no current caller — a future generalized auto-continue mechanism owns re-arming it. - The agent maintains an optional **`manage_tasks`** list (create/update via the homonymous tool). The TUI task panel reflects director task state; `manage_tasks` tool calls are collapsed into a dedicated content block in the event stream. #### Context compaction (the compaction governor) @@ -217,13 +213,13 @@ Invocation: workflows are **not** top-level slash commands. Recipe definitions l Three distinct concepts (do not conflate them): -| Concept | What it is | Surface | -|---|---|---| -| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | -| **Task** | A checklist item owned by *one* agent via `manage_tasks` | Local work plan — not a spawn | -| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with the **`task`** tool (wire name kept for compatibility) | +| Concept | What it is | Surface | +| ------------- | -------------------------------------------------------- | ------------------------------------------------------------------- | +| **Agent** | A runtime entity with its own loop, tools, and context | Primary session or a spawned child | +| **Task** | A checklist item owned by _one_ agent via `manage_tasks` | Local work plan — not a spawn | +| **Sub-agent** | A short-lived child agent for one self-contained job | Spawned with the **`task`** tool (wire name kept for compatibility) | -The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/profile resolved from settings). The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the *child's* own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. +The **`task`** tool **spawns a sub-agent** on a separate inference source (tier/profile resolved from settings). The dispatch brief separates durable `context`, actionable `prompt`, and optional `goals` (checklist seeds for the _child's_ own `manage_tasks` list). The child returns a structured report (`Summary` / `Findings` / `Blockers` / `Paths`) plus a tools-used footer. Parent and child never share a `manage_tasks` list. When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `task(agent=...)`. Results include each match's full loaded system prompt / body so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `task` and `search_agents` are core tools on the primary session. @@ -235,57 +231,57 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP **Primary** -| Director | Owns | Does not own | -|---|---|---| +| Director | Owns | Does not own | +| --------- | -------------------------------------------------------------- | ------------------------------------------------------------- | | skywalker | Orchestrate only — classify, dispatch, track fleet, synthesize | Product tree edits; being the implementer/reviewer by default | **Engineering directors** -| Director | Owns | Does not own | -|---|---|---| -| build | 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 | +| Director | Owns | Does not own | +| ----------- | ----------------------------------------------------------------------- | ---------------------------------- | +| build | 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 | +| 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 | -|---|---| +| Director | Owns | +| ----------- | --------------------------------------------------------------------------------------- | | shakespeare | Docs maintain (scribe core baked into prompt); PRODUCT/ARCHITECTURE/IMPLEMENTATION lane | -| testsmith | Test design only (what/how to test) | -| tester | Runtime verification; never fix product code | +| 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 | build | -| explore | explore | -| plan | plan | -| review | critique (override with `agent=…`) | -| general | **none** — reclassify only | +| Intent | Default director | +| --------- | ---------------------------------- | +| implement | build | +| explore | explore | +| plan | plan | +| review | critique (override with `agent=…`) | +| general | **none** — reclassify only | **Spawn matrix** -| Who | Spawn rights | -|---|---| -| skywalker (primary session) | Full closed fleet | -| greybeard | intern, explore, critique only | -| All other directors | no `task` | +| Who | Spawn rights | +| --------------------------- | ------------------------------ | +| skywalker (primary session) | Full closed fleet | +| greybeard | intern, explore, critique only | +| All other directors | no `task` | **Tool envelopes** prefer small `tools.allow` mounts over deny-everything. Shipped docs/design directors (shakespeare, brand-reviewer, bruckheimer) mount write tools with **no** package `writePaths`. Lane routing is spawn policy (shakespeare = P/A/I docs, brand-reviewer = DESIGN.md, bruckheimer = product discovery), not a file lock. Optional `writePaths` still exists; the permission gate enforces it when a profile sets it. @@ -355,7 +351,7 @@ tool call - **Path Escape** (`path-escape-plugin.ts`) — Canonicalizes path-like arguments against `cwd` and blocks `..` escapes, except into a root the permission layer's worktree-roots provider allowlists (e.g. a sibling git worktree of the same repo). Runs first so later plugins see resolved paths. - **Tool-output URI** (`tool-output-uri-plugin.ts`) — Normalizes mistaken `read_file` blob URIs to `tool-output:///id` (corbits-only; interchange stays unpatched). -- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that *reference* a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. +- **Secret Guard** (`secret-guard-plugin.ts`) — Hard-denies path-keyed tool calls (`read_file`, `write_file`, …) that would put a sensitive file into (or write it from) the model context. Runs before the permission plugin, so the path-arg deny holds even under `--dangerously-skip-permissions`. Shell commands that _reference_ a sensitive path (tokenized so `cat .env`, `bun --env-file=.env run …`, and quote/env-assignment forms are detected) are not hard-denied here: they require operator approval via the permission gate, and auto mode forces an ask through the auto-shell policy (`sensitive-path` rule). Once the operator approves, the command runs. Shell detection is best-effort: token matching defeats quoting and env-assignment/redirection forms but not dynamic path construction (variable indirection, `printf` assembly). Tool-result secret scrub still redacts credential-shaped output. - **Authorization** (`run-shell-authz.ts`, wired by `authz-plugin.ts`) — Denies catastrophic shell command patterns by regex, and hard-blocks shell `find`, head-position `rg`, and recursive `grep -r` (they can walk huge trees and OOM the host). Bounded `grep`/`search_files` tools remain practical alternatives (timeout + output caps); the patterns match those three command shapes only — an `ls -R`, `fd`, or scripted `os.walk` is just as unbounded and is not caught, so the block message tells the model not to substitute one. The permission gate’s shell auto-allow path consults the same policy so it never pre-approves a command authz would reject. - **Permission** (`permission-plugin.ts`) — Delegates consequential calls to the permission gate. - **Shell Guard** (`shell-guard-plugin.ts`) — Corbits Code-only replacement for stock `run_shell` (interchange stays unpatched): 15s default timeout, 512KB display cap with head+tail retention (the process keeps running when the cap is hit), process-group kill on timeout/abort only. Also applies a 10s wall-clock budget to `grep`/`search_files`. @@ -413,12 +409,12 @@ Primary is Skywalker. Bundled skill bodies that are operator slashes are **actio `discoverSkills(cwd, pluginDirs)` runs at session start and returns each available skill's `name` + one-line `description` for the lazy listing in the system prompt. It scans the following base directories, highest precedence first: -| Base directory | Source | -|---|---| +| Base directory | Source | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `/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 | +| `.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. 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). @@ -426,11 +422,11 @@ Each `//SKILL.md` is one skill. Discovery dedupes by directory 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 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) | +| Field | Required | Description | +| ---------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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`. @@ -443,7 +439,6 @@ There are no `type` or `disable-model-invocation` fields required for model invo 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. - ## Data Flow ``` diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 660eb4fba..3946f80ee 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -12,30 +12,30 @@ CLI binary: `corbits` (`./dist/index.js`). Version lives in `package.json` only. ### Direct (production) -| Package | Version | Usage | -|---|---|---| -| `@intx/agent` | workspace | `createAgent`, `fromToolRunner`, `stringTool` — agent runtime | -| `@intx/inference` | workspace | `DefaultDirector`, inference runner, OpenAI-compatible provider, event types | -| `@intx/tools-posix` | workspace | `createPosixTools`, `ToolPlugin` middleware — sandboxed shell/file tools | -| `@intx/types` | workspace | Runtime types (`ReactorDirector`, `ReactorState`, `ToolDefinition`, `ToolCall`, `ToolResult`, …) | -| `@intx/storage-isogit` | workspace | Git-backed context persistence | -| `@opentui/core` | 0.5.1 | Terminal UI renderer | -| `@opentui/keymap` | 0.5.1 | OpenTUI keybinding support | -| `@opentui/solid` | 0.5.1 | Solid bindings for OpenTUI | -| `solid-js` | 1.9.14 | Reactive primitives used by the OpenTUI bindings | -| `arktype` | catalog ^2.1.29 | Runtime validation | +| Package | Version | Usage | +| ---------------------- | --------------- | ------------------------------------------------------------------------------------------------ | +| `@intx/agent` | workspace | `createAgent`, `fromToolRunner`, `stringTool` — agent runtime | +| `@intx/inference` | workspace | `DefaultDirector`, inference runner, OpenAI-compatible provider, event types | +| `@intx/tools-posix` | workspace | `createPosixTools`, `ToolPlugin` middleware — sandboxed shell/file tools | +| `@intx/types` | workspace | Runtime types (`ReactorDirector`, `ReactorState`, `ToolDefinition`, `ToolCall`, `ToolResult`, …) | +| `@intx/storage-isogit` | workspace | Git-backed context persistence | +| `@opentui/core` | 0.5.1 | Terminal UI renderer | +| `@opentui/keymap` | 0.5.1 | OpenTUI keybinding support | +| `@opentui/solid` | 0.5.1 | Solid bindings for OpenTUI | +| `solid-js` | 1.9.14 | Reactive primitives used by the OpenTUI bindings | +| `arktype` | catalog ^2.1.29 | Runtime validation | Other Interchange workspace packages (`@intx/inference-discovery`, `@intx/mime`, `@intx/log`, `@intx/crypto-node`) are pulled transitively via the above. ### Dev -| Package | Version | Usage | -|---|---|---| -| `@intx/inference-testing` | workspace | Deterministic agent-loop test harness | -| `@types/bun` | 1.3.9 | Bun types | -| `ws` | ^8.21.0 | WebSocket support | -| `typescript` | 5.9.3 | Type checking | -| `typescript-language-server` | ^4.3.4 | TS/JS language server for the `lsp` tool (`bin/check-env` checks for it) | +| Package | Version | Usage | +| ---------------------------- | --------- | ------------------------------------------------------------------------ | +| `@intx/inference-testing` | workspace | Deterministic agent-loop test harness | +| `@types/bun` | 1.3.9 | Bun types | +| `ws` | ^8.21.0 | WebSocket support | +| `typescript` | 5.9.3 | Type checking | +| `typescript-language-server` | ^4.3.4 | TS/JS language server for the `lsp` tool (`bin/check-env` checks for it) | ## Interchange packages @@ -170,10 +170,10 @@ Auto mode defaults **on** (`config.auto = true` from `loadConfig`; pass `--no-au When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOLS` and any `run_shell` that does not match the auto-shell policy. The policy (`autoShellRuleForCall` / `AUTO_SHELL_RULES` in `src/permission/auto-shell-policy.ts`) peels wrappers via `expandShellSubjects` (`bash`/`sh`/`zsh -c`, `xargs`, transparent prefixes), then applies: -| Effect | Categories | -|---|---| -| **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) | -| **ask** | Dependency installs / remote runners, recursive `rm`, git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers | +| Effect | Categories | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **deny** | Shell file mutation (redirects, `tee`, in-place stream editors, interpreter `-c`/`-e`/heredoc) | +| **ask** | Dependency installs / remote runners, recursive `rm`, git worktree add/remove/prune, sensitive-path references, paths outside the workspace (including through a symlink), opaque unparseable wrappers | Unmatched shell auto-allows. Writes under the session state root (`~/.corbits/projects//…`, and legacy in-repo `.agent-state` during dual-read), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode. @@ -247,7 +247,6 @@ Provider and model configuration lives in JSON settings files. The global file h 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). `baseURL` is editable provider metadata, but it still belongs in the global provider definition rather than the per-repo selection file. `apiKey` is secret and must never be projected into TUI display-only provider lists. @@ -258,12 +257,12 @@ Provider and model configuration lives in JSON settings files. The global file h All `tools.*` keys live in the global settings file only — there is no per-repo override in `.corbits/settings.json` (unlike `sessionMode`). -| Key | Default | Effect | -|---|---|---| -| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set | -| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` | -| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry | -| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set | +| Key | Default | Effect | +| ----------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set | +| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` | +| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry | +| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set | The `waitForApproval` default is resolved once at the watchdog boundary (`resolveWaitForApproval`); toggling **Settings → Tools** updates the live config for the next tool call and persists the value here. @@ -312,24 +311,24 @@ Providers and credentials are read exclusively from settings files: the global ` Printed by `corbits --help` / `-h` from `CLI_HELP_TEXT` in `src/config/index.ts` (that constant is the source of truth; keep this table in sync when flags change). -| Verb / Flag | Default | Description | -|---|---|---| -| _(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 to this checkout's git toplevel) | -| `--resume` | — | Open the interactive session picker | -| `resume ` | — | Reopen a specific session | -| `resume --pick` / `--list` | — | Interactive session picker | -| `--cwd ` | `process.cwd()` | Working directory | -| `--config ` | `~/.corbits/settings.json` | Settings file to use | -| `--provider ` | from settings | Select a configured provider | -| `--model ` | provider default | Select a model for the active provider | -| `--profile ` | — | Settings profile | -| `--force` | false | Override an existing run state | -| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). This launch flag still forces this process; `/yolo [on\|off\|toggle]` persists as the user-global default via `setSkipPermissions` | -| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | -| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | -| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | +| Verb / Flag | Default | Description | +| -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(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 to this checkout's git toplevel) | +| `--resume` | — | Open the interactive session picker | +| `resume ` | — | Reopen a specific session | +| `resume --pick` / `--list` | — | Interactive session picker | +| `--cwd ` | `process.cwd()` | Working directory | +| `--config ` | `~/.corbits/settings.json` | Settings file to use | +| `--provider ` | from settings | Select a configured provider | +| `--model ` | provider default | Select a model for the active provider | +| `--profile ` | — | Settings profile | +| `--force` | false | Override an existing run state | +| `--dangerously-skip-permissions` | false | Auto-allow anything not denied by the authorization layer (gate + pre-gate workspace sandboxes; secret-guard / authz hard denies remain). This launch flag still forces this process; `/yolo [on\|off\|toggle]` persists as the user-global default via `setSkipPermissions` | +| `--auto` | true (default) | Force auto mode on (workspace writes + unconstrained shell without prompts) | +| `--no-auto` | false | Start with auto mode off (ask on every consequential action); no in-session key toggles it | +| `--help`, `-h` | — | Show help (exit 0 via `CliHelpError`) | Positional arguments after flags are joined into the optional initial task delivered when the TUI mounts. With no positional task, the operator starts from an empty prompt. @@ -337,7 +336,6 @@ Positional arguments after flags are joined into the optional initial task deliv `createAgent` is configured with a single OpenAI-compatible source built from the resolved config, `defaults.maxTokens = 16384`, and a git-backed `contextDir` at `~/.corbits/projects///context`. - ## Protocols and Formats ### Inference @@ -414,23 +412,23 @@ Corbits Code v0.3 memory and stall hardening is implemented under `src/`, `tests ### Child-supervisor IPC awaiter deadlines -| Field | Detail | -|---|---| -| **Status** | Not applicable in Corbits Code; deferred to upstream Interchange | -| **Risk** | Cross-process tool handlers can hang indefinitely when a supervisor reply is lost or stalled (mail submit, substrate write, pack transfer ack paths). | +| Field | Detail | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | Not applicable in Corbits Code; deferred to upstream Interchange | +| **Risk** | Cross-process tool handlers can hang indefinitely when a supervisor reply is lost or stalled (mail submit, substrate write, pack transfer ack paths). | | **Why Corbits Code-only scope cannot close it** | Corbits Code does not run the workflow-host child supervisor or pack-transport sender loops. There is no `src/` surface that registers pending IPC awaiters for `outbound.result`, `substrate.write.response`, or `repo.pack.ack`. Chat and sub-agent sessions use in-process `@intx/agent` reactors, not the child bridges under `interchange/packages/workflow-host` or `interchange/packages/pack-transport`. | -| **Upstream owner** | Interchange `workflow-host` (outbound mail and substrate write bridges) and `pack-transport` (pack sender). Deadline behavior should align with existing gated correlation timeouts in the supervisor stack. | -| **Operator note** | Corbits Code operators are not exposed to this stall vector unless a future product mode embeds workflow-host children; track closure in Interchange, not in this repo. | +| **Upstream owner** | Interchange `workflow-host` (outbound mail and substrate write bridges) and `pack-transport` (pack sender). Deadline behavior should align with existing gated correlation timeouts in the supervisor stack. | +| **Operator note** | Corbits Code operators are not exposed to this stall vector unless a future product mode embeds workflow-host children; track closure in Interchange, not in this repo. | ### Bounded audit collector retention between checkpoints -| Field | Detail | -|---|---| -| **Status** | Not applicable on the default path; deferred until real audit persistence is enabled | -| **Risk** | A live audit collector that buffers full tool results in memory until `flush()` on checkpoint/shutdown can grow without bound on long, checkpoint-sparse runs. | +| Field | Detail | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Status** | Not applicable on the default path; deferred until real audit persistence is enabled | +| **Risk** | A live audit collector that buffers full tool results in memory until `flush()` on checkpoint/shutdown can grow without bound on long, checkpoint-sparse runs. | | **Why Corbits Code-only scope cannot close it** | Production agent setup wires `noopAuditStore()` from `@intx/agent/testing` in `src/tui/runner.ts` and `src/subagent/index.ts`. No `AuditCollector` from `@intx/inference` is instantiated, so bounding `completed` retention in `audit-collector` does not change shipped behavior today. | -| **Upstream owner** | `@intx/inference` audit collector (`audit-collector` module): opportunistic flush or capped result bodies while preserving metadata. | -| **Future Corbits Code work** | If settings later select a persistent audit store, add a bounded wrapper or configuration in `src/` and re-run hardening tests; until then, document the noop path only. | +| **Upstream owner** | `@intx/inference` audit collector (`audit-collector` module): opportunistic flush or capped result bodies while preserving metadata. | +| **Future Corbits Code work** | If settings later select a persistent audit store, add a bounded wrapper or configuration in `src/` and re-run hardening tests; until then, document the noop path only. | Other wave items (read bounds, shell truncation, process-group kill, grep caps, plugin spawn mitigation, per-tool watchdog, inference retry UX) are implemented or partially mitigated in `src/` with co-located tests; only the two rows above remain upstream or product-gated. diff --git a/docs/MCP.md b/docs/MCP.md index 6c7b582d7..a4b032b7a 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -48,8 +48,8 @@ name: ```jsonc { "mcpServers": { - "linear": { "type": "http", "url": "https://mcp.linear.app/mcp" } - } + "linear": { "type": "http", "url": "https://mcp.linear.app/mcp" }, + }, } ``` @@ -57,9 +57,7 @@ The array form carries the name inline: ```jsonc { - "mcpServers": [ - { "name": "linear", "type": "http", "url": "https://mcp.linear.app/mcp" } - ] + "mcpServers": [{ "name": "linear", "type": "http", "url": "https://mcp.linear.app/mcp" }], } ``` @@ -71,9 +69,9 @@ A stdio server, for contrast, looks like: "files": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"], - "env": { "LOG_LEVEL": "info" } - } - } + "env": { "LOG_LEVEL": "info" }, + }, + }, } ``` @@ -85,8 +83,8 @@ command, and no credentials in the config (Linear authorizes over OAuth): ```jsonc { "mcpServers": { - "linear": { "url": "https://mcp.linear.app/mcp" } - } + "linear": { "url": "https://mcp.linear.app/mcp" }, + }, } ``` diff --git a/docs/PERFTRACE.md b/docs/PERFTRACE.md index 40af03637..5237868de 100644 --- a/docs/PERFTRACE.md +++ b/docs/PERFTRACE.md @@ -18,7 +18,6 @@ PostHog usage events. Local measurement does not require any settings or env vars. - ## OTEL export (opt-in) Export is **off** until an OTLP endpoint is configured. When enabled, traces go @@ -33,12 +32,12 @@ error code `OTEL_CONFIG_INVALID` and does not half-enable export. **Env vars (preferred for secrets; match OTEL conventions):** -| Variable | Meaning | -|---|---| -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP base URL (`http` or `https` only) | -| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` headers (values may be percent-encoded) | -| `OTEL_SERVICE_NAME` | Resource `service.name` (default: `corbits-code`) | -| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` resource attributes | +| Variable | Meaning | +| ----------------------------- | ------------------------------------------------------------------- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP base URL (`http` or `https` only) | +| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` headers (values may be percent-encoded) | +| `OTEL_SERVICE_NAME` | Resource `service.name` (default: `corbits-code`) | +| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` resource attributes | **Global settings** (`~/.corbits/settings.json`), optional `otel` block: @@ -165,10 +164,10 @@ Then supply auth only via env when needed. ## Relationship to product telemetry -| Pipe | Purpose | Default | Content | -|---|---|---|---| -| PostHog (`docs/TELEMETRY.md`) | Aggregate product usage | Opt-out | Three allowlisted events | -| Local PerfTrace | Operator/dev attribution | Always on | Privacy-strict phase spans | -| OTEL export | Your APM / Phoenix / collector | Opt-in | Full span tree when enabled | +| Pipe | Purpose | Default | Content | +| ----------------------------- | ------------------------------ | --------- | --------------------------- | +| PostHog (`docs/TELEMETRY.md`) | Aggregate product usage | Opt-out | Three allowlisted events | +| Local PerfTrace | Operator/dev attribution | Always on | Privacy-strict phase spans | +| OTEL export | Your APM / Phoenix / collector | Opt-in | Full span tree when enabled | Do not enlarge the PostHog event schema for performance diagnostics. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f272c40f7..ae347ee75 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -17,13 +17,13 @@ must not import project-local plugins until the operator trusts that absolute path in that working directory. Explicit path plugins are different: they are registered in global settings (`pluginPaths`), so consent is global once granted. -| Origin | Path | Auto-trusted? | Trust store | -|---|---|---|---| -| `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` | -| `path` | `settings.pluginPaths` entries (add-by-path) | **No** until granted once | `~/.corbits/trust/path-plugins.json` (global) | +| Origin | Path | Auto-trusted? | Trust store | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------- | +| `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` | +| `path` | `settings.pluginPaths` entries (add-by-path) | **No** until granted once | `~/.corbits/trust/path-plugins.json` (global) | Untrusted `project` / `path` plugins are discovered as **metadata-only**: the loader reads `manifest.json` (or equivalent) but does **not** `import()` the @@ -39,7 +39,7 @@ plugin is still metadata-only. - **Path-keyed, no content binding.** A grant is the lexically resolved absolute path, deliberately without `realpath` and without any hash of the - plugin's contents. The grant covers the *location*, machine-wide: whatever + plugin's contents. The grant covers the _location_, machine-wide: whatever code sits at that path (including after a symlink retarget or an update in place) runs once trusted. This matches the consent model — the user vouches for a directory they registered, not for a snapshot of its bytes — and keeps @@ -77,7 +77,6 @@ per-cwd trust file (fingerprints) and fail closed when non-interactive (`corbits exec`); the global path-plugin store described here is separate and never gates MCP. - ## Goals - One contract a plugin author learns once, regardless of what the plugin does. @@ -90,18 +89,18 @@ never gates MCP. Five mechanisms, three loading models, one manifest that only governs one kind. -| Mechanism | Entry contract | Loads via | Config | Manifest | UI | -|---|---|---|---|---|---| -| ToolPlugin (`@intx/tools-posix`) | `ToolPlugin` | wired in `src/tui/runner.ts` / `tools.ts` | — | no | no | -| WorkflowPlugin | `plugin` / default | `settings.workflowPlugins: string[]` → `loadWorkflowPlugins` | specifier array | no | no | -| AgentPlugin | `plugin` / default | `settings.agentPlugins: string[]` → `loadAgentPlugins` | specifier array | no | no | -| CommandPlugin | `commandPlugin` | directory discovery | discovery only | no | no | -| Web provider | `createWebProvider` + `manifest` | discovery + `pluginPaths` | `settings.plugins` / `settings.web` | **yes** | **`/plugins`** | +| Mechanism | Entry contract | Loads via | Config | Manifest | UI | +| -------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------- | -------- | -------------- | +| ToolPlugin (`@intx/tools-posix`) | `ToolPlugin` | wired in `src/tui/runner.ts` / `tools.ts` | — | no | no | +| WorkflowPlugin | `plugin` / default | `settings.workflowPlugins: string[]` → `loadWorkflowPlugins` | specifier array | no | no | +| AgentPlugin | `plugin` / default | `settings.agentPlugins: string[]` → `loadAgentPlugins` | specifier array | no | no | +| CommandPlugin | `commandPlugin` | directory discovery | discovery only | no | no | +| Web provider | `createWebProvider` + `manifest` | discovery + `pluginPaths` | `settings.plugins` / `settings.web` | **yes** | **`/plugins`** | Concrete problems, with file references: 1. **Two unrelated loading models.** Workflow/agent load from settings - *specifier arrays*; command/web load from *directory discovery* (plus the new + _specifier arrays_; command/web load from _directory discovery_ (plus the new `pluginPaths`). Same concept, two code paths. 2. **A dead path.** `src/plugins/loader.ts` captures `workflowPlugin` from a discovered module, but `src/tui/runner.ts` only registers `commandPlugin` @@ -113,7 +112,7 @@ Concrete problems, with file references: work began (`settings.plugins`, `settings.web`, `/plugins`). 5. **ToolPlugin — the richest extension point — is not user-installable.** -Net: what exists is a *web-provider plugin system*, not *the* plugin system. +Net: what exists is a _web-provider plugin system_, not _the_ plugin system. ## Target design @@ -126,9 +125,9 @@ The taxonomy is deliberately small — **`web | command | workflow | tool | agen export type PluginKind = "web" | "command" | "workflow" | "tool" | "agent"; export type PluginManifest = { - id: string; // stable, unique (e.g. "exa") - name: string; // display ("Exa Search") - kind: PluginKind; // routes registration + id: string; // stable, unique (e.g. "exa") + name: string; // display ("Exa Search") + kind: PluginKind; // routes registration description?: string; credentials?: PluginCredentialField[]; // collected + stored per id }; @@ -140,13 +139,13 @@ Workflow recipe names are **not** registered as top-level `/scope` slashes; an i The kind-specific export is the implementation hook: -| kind | export | wired into | purpose | -|---|---|---|---| -| `web` | `createWebProvider(credentials)` | web_search/web_fetch backend | override the web tools (a specialized tool override) | -| `command` | `commandPlugin` | slash-command registry | slash commands | +| kind | export | wired into | purpose | +| ---------- | ------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------- | +| `web` | `createWebProvider(credentials)` | web_search/web_fetch backend | override the web tools (a specialized tool override) | +| `command` | `commandPlugin` | slash-command registry | slash commands | | `workflow` | `workflowPlugin` + optional `commandPlugin` | workflow registry + slash-command registry | named workflow recipes behind an integration command prefix | -| `tool` | `toolPlugin` (factory) | posix toolset | add new agent tools (highest trust) | -| `agent` | `agentPlugin` | sub-agent profiles | contribute `task`-dispatchable agent profiles | +| `tool` | `toolPlugin` (factory) | posix toolset | add new agent tools (highest trust) | +| `agent` | `agentPlugin` | sub-agent profiles | contribute `task`-dispatchable agent profiles | A module with no valid manifest is ignored (not silently half-loaded). @@ -192,10 +191,10 @@ web `collectWebPlugins` call. One place to read, one place to extend. { "plugins": { "exa": { "enabled": true, "credentials": { "apiKey": "..." } }, - "my-workflow": { "enabled": false } + "my-workflow": { "enabled": false }, }, "pluginPaths": ["/abs/path/to/plugin"], - "web": "exa" // kind-selector: which web plugin is active + "web": "exa", // kind-selector: which web plugin is active } ``` @@ -344,14 +343,14 @@ shape. #### Supported marketplace `source` forms -| Form | Allowed? | Notes | -|------|----------|--------| -| `./plugins/` | Yes | Under the marketplace root | +| Form | Allowed? | Notes | +| ---------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `./plugins/` | Yes | Under the marketplace root | | `../agents/` (and deeper relatives under the contain root) | Yes, when still under the contain root | **Claude installs:** contain root is `~/.claude/plugins` (so `../agents/x` from a marketplace under that tree is allowed). **Path / `pluginPaths` marketplaces:** contain root is the **parent** of the marketplace directory — any relative that resolves under that parent tree is allowed (multi-level, not one-level-only). | -| Absolute path (`/…`, `C:\…`) | No | Rejected; reported as skip reason `absolute` | -| Relative escape outside the contain root | No | Rejected; reported as skip reason `outside-contain-root` | -| Symlink under the contain root that realpaths outside it | No | Existing candidates and the contain root are `realpath`'d before the final contain check (same idea as `list_dir`); lexical-only paths that do not exist yet keep the lexical check | -| Missing on-disk path | No | Reported as skip reason `missing`; other members still load | +| Absolute path (`/…`, `C:\…`) | No | Rejected; reported as skip reason `absolute` | +| Relative escape outside the contain root | No | Rejected; reported as skip reason `outside-contain-root` | +| Symlink under the contain root that realpaths outside it | No | Existing candidates and the contain root are `realpath`'d before the final contain check (same idea as `list_dir`); lexical-only paths that do not exist yet keep the lexical check | +| Missing on-disk path | No | Reported as skip reason `missing`; other members still load | Skipped sources are never silent: `expandPluginPath` reports every skip (default: stderr; Claude discovery also accepts `onExpandSkip` for tests/callers; path / diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index f56545620..51083fc28 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -8,7 +8,6 @@ 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. @@ -84,7 +83,7 @@ is the direct, explicit resume path. ## Safety Model - **Tiered permission gate** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) run freely. Every consequential tool (`write_file`, `edit_file`, `run_shell`, …) is gated. The operator can Allow Once or Allow Always (scoped to a file, a directory, or a command shape); "Allow Always" choices persist per working directory so repeat actions don't interrupt flow. -- **Secret guard** — Path-keyed tools (`read_file`, `write_file`, …) hard-deny sensitive files (`.env`, `id_rsa`, `*.pem`, `.aws/credentials`, `.ssh/*`, `.git-credentials`, and similar), even with approval, `--dangerously-skip-permissions`, or `/yolo`. Template files like `.env.example` are exempt. Shell commands that *reference* those paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval and never auto-run in auto mode; once approved, they proceed. Tool-result scrubbing still redacts credential-shaped output that reaches the transcript. +- **Secret guard** — Path-keyed tools (`read_file`, `write_file`, …) hard-deny sensitive files (`.env`, `id_rsa`, `*.pem`, `.aws/credentials`, `.ssh/*`, `.git-credentials`, and similar), even with approval, `--dangerously-skip-permissions`, or `/yolo`. Template files like `.env.example` are exempt. Shell commands that _reference_ those paths (e.g. `bun --env-file=.env.staging run …`, `cat .env`) require explicit operator approval and never auto-run in auto mode; once approved, they proceed. Tool-result scrubbing still redacts credential-shaped output that reaches the transcript. - **Catastrophic-command deny** — Destructive shell patterns that target system roots (`rm -rf /`, home, `/etc`, …), plus `mkfs`, `dd`, `sudo`, fork bombs, `curl | bash`, force-push, … are blocked before they run. Recursive delete of ordinary workspace paths is not hard-denied but requires operator approval (never auto in auto mode). - **Constrained auto mode** — Default is on (`auto = true`). Pass `--no-auto` to start in ask mode, or `--auto` to force it on; there is currently no in-session key to toggle it. Auto mode auto-approves workspace file writes/edits/deletes and unconstrained shell without per-action prompts, but it is not a free-for-all: - **Denied** (must use `write_file` / `edit_file`): shell file mutations via output redirection, `tee`, `sed -i` / `perl -i`, interpreter inline programs or heredocs. @@ -119,7 +118,6 @@ The exact turn thresholds are model-family-dependent (tighter for models with ob **Recovery:** Send a message to resume. To inspect state first, see `~/.corbits/projects///run.json` (or a legacy in-repo `.agent-state/` tree if not yet migrated). - ### Permission denied (exec) **What the user sees:** In a non-interactive `corbits exec` run, a consequential action that needs approval returns a tool error explaining that approval is unavailable. @@ -146,12 +144,12 @@ Capabilities beyond the core toolset are opt-in plugins, enabled per workspace t 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, DIY tiny/single-file/one-route product edits, dispatch a **closed fleet of 16 directors** for substantial work, track the fleet, and synthesize. Product mutation tools (`write_file` / `edit_file` / `delete_file`) are mounted on the primary (CORE / `SKYWALKER_TOOLS`) — path tools are the DIY surface; spawn remains the default for substantial, multi-file, parallel, or specialist work (hard cap 4). Shell file-writes stay denied. MCP tools are not re-filtered by a product-write deny list (that list is gone). 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 for substantial work; tiny/bounded edits may run on the primary. -| Lane | Directors | -|---|---| -| Primary | skywalker | -| Eng | build, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot | -| Design | draper, emil, brand-reviewer | -| Docs / QA | shakespeare, testsmith, tester | +| Lane | Directors | +| --------- | ---------------------------------------------------------------------------------- | +| Primary | skywalker | +| Eng | build, explore, plan, intern, critique, greybeard, neckbeard, bruckheimer, gaasbot | +| Design | draper, emil, brand-reviewer | +| Docs / QA | shakespeare, testsmith, tester | There is **no catch-all worker**. `task` requires `agent=…` or a non-general `intent` (implement/explore/plan/review→critique); bare dispatch and `intent=general` are refused. Named `task(agent=…)` selects a director package without requiring a plugin profile, except `skywalker` which is the primary session identity and is refused as a spawned worker. Nested spawn is runtime-enforced: only skywalker (full fleet allowlist) and greybeard (intern/explore/critique) may spawn; other workers have no `task`. Primary omits an allowlist so plugin profiles remain reachable from the main session. @@ -161,8 +159,8 @@ 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. 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. +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/TELEMETRY.md b/docs/TELEMETRY.md index 00b133faa..f9d4269cf 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -11,22 +11,22 @@ env kill switches (see Intentional feedback below). Each event carries a small set of properties: -| Event | When | Properties | -|---|---|---| -| `cli_start` | Once per used session (see First-run disclosure) | (none beyond common properties) | -| `session_end` | When a TUI session finishes | `status`, `turn_count`, `duration_ms`, `session_mode`, `exit_reason` | -| `$ai_generation` | Once per turn — on completion, and once for a turn that ends in an error instead | `$ai_trace_id`, `$ai_provider`, `$ai_model`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_is_error`, `$ai_error`, `$ai_cache_read_input_tokens`, `$ai_cache_creation_input_tokens`, `$ai_reasoning_tokens` | -| `$ai_span` | Once per top-level tool call in a completed turn | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_is_error` | -| `slash_command` | A slash command is dispatched (shared product-event path) | `command_name` | -| `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) | -| `plugin_loaded` | A plugin is discovered and loaded at startup | `origin` | -| `subagent_start` | A `task` dispatch begins | `agent_name` | -| `subagent_end` | A `task` dispatch finishes | `agent_name`, `status`, `duration_ms` | -| `permission_prompt` | An approval prompt is answered (or abandoned) | `decision`, `permission_kind` | -| `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after` | -| `crash` | A fatal error reaches the process-level handler | `kind`, `error_class` | -| `auth_failure` | A provider rejects the stored credentials | `auth_provider` | -| `survey sent` | User submits intentional feedback via `/feedback` | `$survey_id`, `$survey_response`, `$survey_questions`, `turn_trace_id` | +| Event | When | Properties | +| ------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cli_start` | Once per used session (see First-run disclosure) | (none beyond common properties) | +| `session_end` | When a TUI session finishes | `status`, `turn_count`, `duration_ms`, `session_mode`, `exit_reason` | +| `$ai_generation` | Once per turn — on completion, and once for a turn that ends in an error instead | `$ai_trace_id`, `$ai_provider`, `$ai_model`, `$ai_input_tokens`, `$ai_output_tokens`, `$ai_latency`, `$ai_is_error`, `$ai_error`, `$ai_cache_read_input_tokens`, `$ai_cache_creation_input_tokens`, `$ai_reasoning_tokens` | +| `$ai_span` | Once per top-level tool call in a completed turn | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_is_error` | +| `slash_command` | A slash command is dispatched (shared product-event path) | `command_name` | +| `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) | +| `plugin_loaded` | A plugin is discovered and loaded at startup | `origin` | +| `subagent_start` | A `task` dispatch begins | `agent_name` | +| `subagent_end` | A `task` dispatch finishes | `agent_name`, `status`, `duration_ms` | +| `permission_prompt` | An approval prompt is answered (or abandoned) | `decision`, `permission_kind` | +| `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after` | +| `crash` | A fatal error reaches the process-level handler | `kind`, `error_class` | +| `auth_failure` | A provider rejects the stored credentials | `auth_provider` | +| `survey sent` | User submits intentional feedback via `/feedback` | `$survey_id`, `$survey_response`, `$survey_questions`, `turn_trace_id` | `compaction` is deliberately silent on the runs where the compactor decides there is nothing to compact — an event that also fires on no-ops makes its own diff --git a/docs/TUI.md b/docs/TUI.md index 80e03b853..235ac95b0 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -93,12 +93,12 @@ of truth for what the slot can say, not this list. It is led by a single density (`rampPulse`, `src/tui/ramp.ts`). The cell, not the word, is what says whether the session is healthy, and it carries four states: -| State | Cell | Reads as | -|---|---|---| -| `working` | cycles `░ ▒ ▓ █` on `RAMP_CYCLE_MS` | moving | -| `done` | static `█` | finished | -| `blocked` | static `▌` | waiting on the operator | -| `stalled` | `!` blinking against `█`, then a static `!` | a problem | +| State | Cell | Reads as | +| --------- | ------------------------------------------- | ----------------------- | +| `working` | cycles `░ ▒ ▓ █` on `RAMP_CYCLE_MS` | moving | +| `done` | static `█` | finished | +| `blocked` | static `▌` | waiting on the operator | +| `stalled` | `!` blinking against `█`, then a static `!` | a problem | Every state is separated by glyph and motion before colour, so all four survive a monochrome terminal and are readable without stopping to read the word. A @@ -107,9 +107,9 @@ printed identically, so the only way to tell them apart was to wait. `blocked` and `stalled` share the orange deliberately — both name a turn waiting on something outside itself — and are told apart by motion: `blocked` -holds perfectly still, which is the signal that the session is waiting on *you*. +holds perfectly still, which is the signal that the session is waiting on _you_. -While sub-agents are running, the slot reports the *fleet*, not the parent. +While sub-agents are running, the slot reports the _fleet_, not the parent. `resolveTurnLabel` and `resolveRampPhase` take a `FleetProgress` roll-up and 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 @@ -135,7 +135,7 @@ a stall that breaks and re-arms bursts again. Auto-abort (`shouldAbortForStall`) is reserved for a stream that had already started producing tokens and then went dead mid-flight — not for a run that -is merely *awaiting* the model's next response (right after submit, or the +is merely _awaiting_ the model's next response (right after submit, or the instant a tool batch resolves and `awaitingResponse` flips back to true). That wait has no signal to tell "still coming" from "never coming" apart, so it is never auto-aborted no matter how long it runs; it still surfaces via @@ -184,7 +184,7 @@ box a row on a short terminal, and they guarantee different things. `prompt` — that loop only runs when the transcript floor is not yet met, and it never touches a zone later in the order while an earlier one still has rows to give up. Separately, `PROMPT_CAP_FRACTION` in `desiredHeights` caps -how tall a *requested* prompt is allowed to start at (`PROMPT_CAP_FRACTION * +how tall a _requested_ prompt is allowed to start at (`PROMPT_CAP_FRACTION * terminal.rows`), independent of collapse and before it ever runs. Neither 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 @@ -370,11 +370,12 @@ recent and favorite provider+model pairs sit at the top, then every 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. + +- 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. The list itself never nests by provider, but connecting a new provider is not a flat-list row either: the picker used to grow a "connect →" row per @@ -527,7 +528,7 @@ default), the wheel scrolls the transcript, clicking a collapsed tool row or diff arrow expands it in place, and dragging across selectable text starts an OpenTUI selection that **auto-copies to the system clipboard on mouse-up**. Dragging on non-selectable chrome still scrolls. The cost of holding the -mouse this way is that *native* terminal drag-select is unavailable while +mouse this way is that _native_ terminal drag-select is unavailable while reporting is on: the terminal hands drag events to the app instead of running its own selection. Two chords cover remaining copy needs: @@ -624,7 +625,6 @@ terminal. It cannot observe: through a synthetic `SELECTION` event (`copy-wire.test.ts`); a real mouse-up path still needs a manual terminal check. - Concretely, whole defect classes — a DEC mouse-reporting toggle that silently no-ops, an Alt+key chord a given terminal never actually delivers, a clipboard write that fails silently on a machine with no clipboard helper @@ -649,4 +649,4 @@ asserted as fact: emulator Corbits Code targets (Shift+Enter and Alt+letter reporting depend on kitty-protocol negotiation the harness cannot test — see Test-harness blind spots above); this document states what the code does when a chord - *is* delivered, not which terminals reliably deliver it. + _is_ delivered, not which terminals reliably deliver it. diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 7b2f028ed..946b3ec9b 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -21,11 +21,11 @@ points straight at `./src/*.ts` files rather than a `dist/` build. ## What's vendored -| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | -|---|---|---|---|---|---| -| `@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 | +| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | +| ---------------------- | ----------------------------- | ------------- | ------------------------------------------ | ---------- | -------------------------------------------- | +| `@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 @@ -62,6 +62,7 @@ one. ## How a vendored package resolves Root `package.json`: + - `workspaces` lists each `vendor/intx-*` directory as a workspace member. - `overrides` pins the package name to `workspace:*`, so every transitive consumer (including other published `@intx/*` packages that declare a diff --git a/eslint.config.js b/eslint.config.js index a05bd1ac7..352655bf3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,8 @@ export default tseslint.config( "**/scratch/**", "node_modules/**", "**/node_modules/**", + // Intentionally invalid source: the broken-toolchain eval fixture. + "tests/fixtures/broken-toolchain/**", ], }, js.configs.recommended, @@ -34,6 +36,23 @@ export default tseslint.config( caughtErrorsIgnorePattern: "^_", }, ], + // LogTape (and a few test spies) use tagged-template logging as a + // statement; the expression is the side effect. + "@typescript-eslint/no-unused-expressions": ["error", { allowTaggedTemplates: true }], + // Staged adoption: the codebase predates these two rules and carries + // ~1200 pre-existing violations, almost all in tests and TUI plumbing. + // Warning keeps them visible without making the CI gate unachievable; + // they graduate to "error" once the backlog is cleared. + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-empty-function": "warn", + }, + }, + { + files: ["src/util/control-char-strip.ts"], + rules: { + // This module's job is matching C0/C1 bytes; the patterns are the + // product, not a lint accident. + "no-control-regex": "off", }, }, ); diff --git a/evals/capability/README.md b/evals/capability/README.md index eaec866b8..5311c3500 100644 --- a/evals/capability/README.md +++ b/evals/capability/README.md @@ -12,20 +12,20 @@ Eval workdirs are initialized as git repositories (HEAD exists) so isolated work One run can **try different things**: multiple cases × multiple provider/model variants (matrix), with every product-path metric we can record written into the results JSON. -| Tier | Case | Fixture | Intent | -|------|------|---------|--------| -| simple | `simple-health` | `tests/fixtures/multi-file-service` | Single-file route + test | -| complex | `complex-jwt` | `tests/fixtures/demo-comparison` | Multi-file auth middleware + tests (sync API contract) | -| complex | `complex-stock-gate` | `tests/fixtures/demo-comparison` | Multi-file stock-gated orders + mutable state | -| complex | `complex-idempotent-orders` | `tests/fixtures/demo-comparison` | Idempotency-Key header + multi-file order store | -| complex | `complex-bugfix` | `tests/fixtures/buggy-service` | Issue→patch→tests: fix failing post GET without breaking users | -| complex | `complex-pagination` | `tests/fixtures/demo-comparison` | Multi-file feature: query pagination on GET /products | -| complex | `complex-rename-user` | `tests/fixtures/multi-file-service` | Refactor/rename user `name` → `displayName` across files | -| complex | `complex-dispatch-spawn` | `tests/fixtures/multi-file-service` | Dispatch GET /readyz via `task`; grader checks the route, not that the primary skipped DIY | -| complex | `complex-recall-after-bulk-read` | `tests/fixtures/large-read` | Read many fixture files then write the planted token; does not assert compaction fired | -| complex | `hidden-contract-inventory` | `tests/fixtures/inventory-service` | Implement stock reservations from a prose contract (API.md); graded by held-out tests the agent never sees | -| complex | `broken-toolchain` | `tests/fixtures/broken-toolchain` | Three stacked, independent environment failures (non-executable codegen hook, broken vendor symlink, corrupt source file) block a trivially-correct test suite; grader checks each fix individually plus a freshly-regenerated codegen artifact so partial repair and fabrication both fail | -| complex | `misleading-symptom` | `tests/fixtures/report-pipeline` | Crash visibly implicates the wrong module (routes/reports.ts, next to a decoy rounding TODO); root cause is one hop away in services/aggregate.ts. Guarding only the crash site makes the suite look green while returning a plausible-but-wrong total — held-out tests assert the actual value | +| 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 (sync API contract) | +| complex | `complex-stock-gate` | `tests/fixtures/demo-comparison` | Multi-file stock-gated orders + mutable state | +| complex | `complex-idempotent-orders` | `tests/fixtures/demo-comparison` | Idempotency-Key header + multi-file order store | +| complex | `complex-bugfix` | `tests/fixtures/buggy-service` | Issue→patch→tests: fix failing post GET without breaking users | +| complex | `complex-pagination` | `tests/fixtures/demo-comparison` | Multi-file feature: query pagination on GET /products | +| complex | `complex-rename-user` | `tests/fixtures/multi-file-service` | Refactor/rename user `name` → `displayName` across files | +| complex | `complex-dispatch-spawn` | `tests/fixtures/multi-file-service` | Dispatch GET /readyz via `task`; grader checks the route, not that the primary skipped DIY | +| complex | `complex-recall-after-bulk-read` | `tests/fixtures/large-read` | Read many fixture files then write the planted token; does not assert compaction fired | +| complex | `hidden-contract-inventory` | `tests/fixtures/inventory-service` | Implement stock reservations from a prose contract (API.md); graded by held-out tests the agent never sees | +| complex | `broken-toolchain` | `tests/fixtures/broken-toolchain` | Three stacked, independent environment failures (non-executable codegen hook, broken vendor symlink, corrupt source file) block a trivially-correct test suite; grader checks each fix individually plus a freshly-regenerated codegen artifact so partial repair and fabrication both fail | +| complex | `misleading-symptom` | `tests/fixtures/report-pipeline` | Crash visibly implicates the wrong module (routes/reports.ts, next to a decoy rounding TODO); root cause is one hop away in services/aggregate.ts. Guarding only the crash site makes the suite look green while returning a plausible-but-wrong total — held-out tests assert the actual value | | 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` | @@ -43,7 +43,7 @@ objective outcome check — a bait can pass verify while still misbehaving; the `behaviors` block is what the gate compares. **Bait honesty check:** during `--baseline` comparison, a bait case whose -*baseline* aggregate does not exceed its threshold is flagged +_baseline_ aggregate does not exceed its threshold is flagged (`BAIT FLAG ... no longer reproduces its misbehavior`) instead of silently passing. A flagged bait means the case has stopped measuring anything — fix or retire the case; do not treat the comparison as a clean gate. @@ -52,24 +52,24 @@ retire the case; do not treat the comparison as a clean gate. Everything the product path already observes is recorded: -| Field | Source | -|-------|--------| -| `passed` | agent exit 0 **and** verify.sh exit 0 **and** not over soft turn budget | -| `agentExitCode` / `verifyExitCode` | process exits | -| `status` | run sink (`done` / `failed` / `cancelled`) | -| `sessionId` | exec session id | -| `durationMs` | wall clock for the whole case | -| `agentDurationMs` | product `runExec` duration | -| `verifyDurationMs` | grader wall time | -| `turnsUsed` | turn collector | -| `toolCallCount` | turn collector | -| `tokenUsage` | `{ input, output, cacheRead, cacheWrite, thinking }` | -| `maxTurns` / `overBudget` | case budget vs turns used | -| `provider` / `model` / `variantId` | resolved config for that cell (`variantId` is `provider:model` by default) | -| `skipPermissions` | whether permissions were skipped | -| `repeat` | 0-based repeat index within the case×variant cell | -| `behaviors` | behavior metrics derived from the turn stream (below); `null` when capture failed | -| `textPreview` | truncated agent stdout (debug) | +| Field | Source | +| ---------------------------------- | --------------------------------------------------------------------------------- | +| `passed` | agent exit 0 **and** verify.sh exit 0 **and** not over soft turn budget | +| `agentExitCode` / `verifyExitCode` | process exits | +| `status` | run sink (`done` / `failed` / `cancelled`) | +| `sessionId` | exec session id | +| `durationMs` | wall clock for the whole case | +| `agentDurationMs` | product `runExec` duration | +| `verifyDurationMs` | grader wall time | +| `turnsUsed` | turn collector | +| `toolCallCount` | turn collector | +| `tokenUsage` | `{ input, output, cacheRead, cacheWrite, thinking }` | +| `maxTurns` / `overBudget` | case budget vs turns used | +| `provider` / `model` / `variantId` | resolved config for that cell (`variantId` is `provider:model` by default) | +| `skipPermissions` | whether permissions were skipped | +| `repeat` | 0-based repeat index within the case×variant cell | +| `behaviors` | behavior metrics derived from the turn stream (below); `null` when capture failed | +| `textPreview` | truncated agent stdout (debug) | Run-level `totals` sum duration, turns, tools, and tokens across cells. @@ -81,20 +81,20 @@ turn stream — tool calls with arguments plus assistant content. Derivation is pure (`behaviors.ts`); command analysis is a quote-aware token scan, not a full shell parser. -| Metric | Meaning | Baseline direction | -|--------|---------|--------------------| -| `shellCommandCount` | `run_shell` calls | informational | -| `envAssignmentCommandCount` | commands with a `FOO=bar cmd` prefix or `export` | lower is better | -| `chainSegmentCount` | total chain segments (`&&`, `\|\|`, `;`, `\|`, newline) | informational | -| `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 | -| `maxTurnDurationMs` | slowest single turn (stall gap on slow commands) | lower is better | -| `toolCallsByName` | per-tool-name call counts | informational | +| Metric | Meaning | Baseline direction | +| ---------------------------- | --------------------------------------------------------------------------- | ------------------ | +| `shellCommandCount` | `run_shell` calls | informational | +| `envAssignmentCommandCount` | commands with a `FOO=bar cmd` prefix or `export` | lower is better | +| `chainSegmentCount` | total chain segments (`&&`, `\|\|`, `;`, `\|`, newline) | informational | +| `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 | +| `maxTurnDurationMs` | slowest single turn (stall gap on slow commands) | lower is better | +| `toolCallsByName` | per-tool-name call counts | informational | ## Prerequisites @@ -164,22 +164,22 @@ change that justifies it. Flags: -| Flag | Meaning | -|------|---------| -| `--case ` | Case id or `all` (default) | -| `--provider ` / `--model ` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | -| `--matrix ` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | -| `--config ` | Settings file override (CI injection) | -| `--out ` | Write machine-readable results JSON | -| `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | -| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` | -| `--max-turns ` | Soft turn budget: case **fails** if `turnsUsed` exceeds, or if turns are not reported when a budget is set (fail closed). Does not hard-kill mid-run | -| `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | -| `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | -| `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | -| `--concurrency ` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster | -| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` | -| `--director ` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `task`. | +| Flag | Meaning | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--case ` | Case id or `all` (default) | +| `--provider ` / `--model ` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings | +| `--matrix ` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides | +| `--config ` | Settings file override (CI injection) | +| `--out ` | Write machine-readable results JSON | +| `--baseline ` | Compare this run to a prior results file (improve/regress + metric deltas) | +| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` | +| `--max-turns ` | Soft turn budget: case **fails** if `turnsUsed` exceeds, or if turns are not reported when a budget is set (fail closed). Does not hard-kill mid-run | +| `--agent-timeout-ms ` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) | +| `--verify-timeout-ms ` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) | +| `--repeats ` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates | +| `--concurrency ` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster | +| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` | +| `--director ` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `task`. | ## Case format @@ -253,7 +253,13 @@ verify.sh # objective grader (exit 0 = pass) "sessionId": "...", "turnsUsed": 4, "toolCallCount": 6, - "tokenUsage": { "input": 5000, "output": 800, "cacheRead": 0, "cacheWrite": 0, "thinking": 0 }, + "tokenUsage": { + "input": 5000, + "output": 800, + "cacheRead": 0, + "cacheWrite": 0, + "thinking": 0 + }, "maxTurns": 20, "overBudget": false, "skipPermissions": true, diff --git a/evals/capability/behaviors.test.ts b/evals/capability/behaviors.test.ts index 52da053d4..88cac087f 100644 --- a/evals/capability/behaviors.test.ts +++ b/evals/capability/behaviors.test.ts @@ -132,9 +132,7 @@ describe("deriveBehaviorMetrics", () => { }); test("counts chain segments per command and in total", () => { - const metrics = deriveBehaviorMetrics( - summary([shellTurn("a && b && c"), shellTurn("d")]), - ); + const metrics = deriveBehaviorMetrics(summary([shellTurn("a && b && c"), shellTurn("d")])); expect(metrics.chainSegmentCount).toBe(4); expect(metrics.maxChainSegmentsPerCommand).toBe(3); }); diff --git a/evals/capability/behaviors.ts b/evals/capability/behaviors.ts index a904f4e08..ef0f636a7 100644 --- a/evals/capability/behaviors.ts +++ b/evals/capability/behaviors.ts @@ -36,7 +36,7 @@ export const CapturedRunSummary = type({ export type CapturedTurn = typeof CapturedTurn.infer; export type CapturedRunSummary = typeof CapturedRunSummary.infer; -export type BehaviorMetrics = { +export interface BehaviorMetrics { /** run_shell calls observed. */ shellCommandCount: number; /** run_shell commands containing an env-var prefix (`FOO=bar cmd`) or `export`. */ @@ -61,7 +61,7 @@ export type BehaviorMetrics = { maxTurnDurationMs: number; /** Per-tool-name call counts for the whole run. */ toolCallsByName: Record; -}; +} /** Numeric metric keys eligible for min/median/max aggregation and baseline diff. */ export const NUMERIC_BEHAVIOR_METRICS = [ @@ -103,7 +103,17 @@ export const BEHAVIOR_METRIC_DIRECTIONS: Record block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0, + (block) => + block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0, ); } @@ -225,10 +236,7 @@ export function deriveBehaviorMetrics(summary: CapturedRunSummary): BehaviorMetr } for (const call of turn.toolCalls) { toolCallsByName[call.name] = (toolCallsByName[call.name] ?? 0) + 1; - const signature = JSON.stringify([ - call.name, - normalizeToolArguments(call.arguments), - ]); + const signature = JSON.stringify([call.name, normalizeToolArguments(call.arguments)]); if (seenCalls.has(signature)) repeatedSearchCount++; else seenCalls.add(signature); diff --git a/evals/capability/cases/flaky-diagnosis/solution/cache.ts b/evals/capability/cases/flaky-diagnosis/solution/cache.ts index 97f03743f..f14777b62 100644 --- a/evals/capability/cases/flaky-diagnosis/solution/cache.ts +++ b/evals/capability/cases/flaky-diagnosis/solution/cache.ts @@ -11,7 +11,7 @@ export class TTLCache { constructor( private readonly ttlMs: number, - private readonly jitterMs: number = 0, + private readonly jitterMs = 0, ) {} set(key: string, value: T): void { diff --git a/evals/capability/cases/hidden-contract-inventory/hidden/reservations.heldout.ts b/evals/capability/cases/hidden-contract-inventory/hidden/reservations.heldout.ts index e6de26a4b..39c83ffa2 100644 --- a/evals/capability/cases/hidden-contract-inventory/hidden/reservations.heldout.ts +++ b/evals/capability/cases/hidden-contract-inventory/hidden/reservations.heldout.ts @@ -1,10 +1,10 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { handleRequest } from "../../src/index.js"; -import { resetProducts, getProduct } from "../../src/services/products.js"; +import { resetProducts } from "../../src/services/products.js"; import { resetReservations } from "../../src/services/reservations.js"; import { setClock, resetClock } from "../../src/clock.js"; -type Reservation = { +interface Reservation { id: string; productId: string; userId: string; @@ -12,7 +12,7 @@ type Reservation = { status: string; createdAt: number; expiresAt: number; -}; +} let clockNow = 1_000_000; diff --git a/evals/capability/lib.test.ts b/evals/capability/lib.test.ts index 323ca297f..93c15feaf 100644 --- a/evals/capability/lib.test.ts +++ b/evals/capability/lib.test.ts @@ -63,7 +63,13 @@ function sampleResult(over: Partial = {}): CaseResult { sessionId: over.sessionId ?? "sess-1", turnsUsed: over.turnsUsed ?? 3, toolCallCount: over.toolCallCount ?? 5, - tokenUsage: over.tokenUsage ?? { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, thinking: 0 }, + tokenUsage: over.tokenUsage ?? { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }, maxTurns: over.maxTurns ?? 20, overBudget: over.overBudget ?? false, skipPermissions: over.skipPermissions ?? true, @@ -393,9 +399,21 @@ describe("evaluateSoftBudget", () => { describe("computeCellAggregates", () => { test("aggregates repeats per cell with pass rate and behavior stats", () => { const results = [ - sampleResult({ repeat: 0, passed: true, behaviors: sampleBehaviors({ repeatedSearchCount: 1 }) }), - sampleResult({ repeat: 1, passed: false, behaviors: sampleBehaviors({ repeatedSearchCount: 3 }) }), - sampleResult({ repeat: 2, passed: true, behaviors: sampleBehaviors({ repeatedSearchCount: 2 }) }), + sampleResult({ + repeat: 0, + passed: true, + behaviors: sampleBehaviors({ repeatedSearchCount: 1 }), + }), + sampleResult({ + repeat: 1, + passed: false, + behaviors: sampleBehaviors({ repeatedSearchCount: 3 }), + }), + sampleResult({ + repeat: 2, + passed: true, + behaviors: sampleBehaviors({ repeatedSearchCount: 2 }), + }), ]; const cells = computeCellAggregates(results); expect(cells).toHaveLength(1); @@ -457,14 +475,22 @@ describe("compareToBaseline", () => { cases: [ sampleResult({ variantId: "xai/grok", - behaviors: sampleBehaviors({ repeatedSearchCount: 4, networkCommandCount: 0, shellCommandCount: 2 }), + behaviors: sampleBehaviors({ + repeatedSearchCount: 4, + networkCommandCount: 0, + shellCommandCount: 2, + }), }), ], }); const current = [ sampleResult({ variantId: "xai/grok", - behaviors: sampleBehaviors({ repeatedSearchCount: 1, networkCommandCount: 2, shellCommandCount: 9 }), + behaviors: sampleBehaviors({ + repeatedSearchCount: 1, + networkCommandCount: 2, + shellCommandCount: 9, + }), }), ]; const cmp = compareToBaseline(current, baseline); @@ -679,14 +705,14 @@ describe("compareToBaseline provider/model guard", () => { version: 3, provider: "xai", model: "grok-4.5", - cases: [ - sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.5" }), - ], + cases: [sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.5" })], }); const current = [ sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.0" }), ]; - expect(() => compareToBaseline(current, baseline)).toThrow(/different resolved model|cannot compare baseline/); + expect(() => compareToBaseline(current, baseline)).toThrow( + /different resolved model|cannot compare baseline/, + ); }); test("allows the comparison when --allow-provider-fallback is set", () => { @@ -694,9 +720,7 @@ describe("compareToBaseline provider/model guard", () => { version: 3, provider: "xai", model: "grok-4.5", - cases: [ - sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.5" }), - ], + cases: [sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.5" })], }); const current = [ sampleResult({ variantId: "xai/grok-4.5", provider: "xai", model: "grok-4.0" }), diff --git a/evals/capability/lib.ts b/evals/capability/lib.ts index 8c32dbc47..6ead036f4 100644 --- a/evals/capability/lib.ts +++ b/evals/capability/lib.ts @@ -23,23 +23,23 @@ export type EvalTier = "simple" | "complex" | "bait"; * exceeds `threshold`; baseline comparison flags a bait whose baseline no * longer reproduces (honesty check) instead of letting it silently pass. */ -export type EvalBait = { +export interface EvalBait { metric: NumericBehaviorMetric; 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 = { +export interface BehaviorRequirement { metric: NumericBehaviorMetric; min?: number; max?: number; -}; +} -export type EvalCase = { +export interface EvalCase { id: string; tier: EvalTier; title: string; @@ -62,16 +62,16 @@ export type EvalCase = { * 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). */ -export type EvalTokenUsage = { +export interface EvalTokenUsage { input: number; output: number; cacheRead: number; cacheWrite: number; thinking: number; -}; +} export const emptyTokenUsage = (): EvalTokenUsage => ({ input: 0, @@ -82,12 +82,12 @@ export const emptyTokenUsage = (): EvalTokenUsage => ({ }); /** One model/provider cell in a multi-variant matrix. */ -export type EvalVariant = { +export interface EvalVariant { /** Stable label for reports (defaults to provider/model). */ id: string; provider?: string; model?: string; -}; +} /** * Recorded when the resolved provider/model for a run cell differs from what @@ -95,14 +95,14 @@ export type EvalVariant = { * kicked in. Present on results even when the run was allowed to proceed via * --allow-provider-fallback, so the mismatch stays visible downstream. */ -export type ProviderFallbackInfo = { +export interface ProviderFallbackInfo { requestedProvider: string | null; requestedModel: string | null; resolvedProvider: string; resolvedModel: string; -}; +} -export type CaseResult = { +export interface CaseResult { /** Stable key for baseline compare: variantId::caseId. */ resultKey: string; id: string; @@ -137,24 +137,24 @@ export type CaseResult = { /** Per-cell diagnostics for debugging eval failures; null when unavailable. */ diagnostics: EvalDiagnostics | null; textPreview?: string; -}; +} -export type EvalDiagnostics = { +export interface EvalDiagnostics { /** Short identity (hash) of the pinned Codex instructions text in use; null for non-Codex providers. */ codexInstructionsHash: string | null; /** Built-in tool names advertised to the model for this run. */ advertisedTools: readonly string[]; reasoningEffort: string | null; -}; +} -export type MetricStats = { +export interface MetricStats { min: number; median: number; max: number; -}; +} /** Per case×variant cell aggregate across repeats. */ -export type CellAggregate = { +export interface CellAggregate { resultKey: string; id: string; variantId: string; @@ -166,9 +166,9 @@ export type CellAggregate = { passRate: number; /** min/median/max per numeric behavior metric, over repeats with behaviors. */ behaviorStats: Partial>; -}; +} -export type EvalRunReport = { +export interface EvalRunReport { version: 3; startedAt: string; finishedAt: string; @@ -181,9 +181,9 @@ export type EvalRunReport = { cases: CaseResult[]; aggregates: CellAggregate[]; totals: EvalRunTotals; -}; +} -export type EvalRunTotals = { +export interface EvalRunTotals { total: number; passed: number; failed: number; @@ -191,17 +191,17 @@ export type EvalRunTotals = { turnsUsed: number; toolCallCount: number; tokenUsage: EvalTokenUsage; -}; +} -export type BehaviorVerdict = { +export interface BehaviorVerdict { metric: NumericBehaviorMetric; baselineMedian: number; currentMedian: number; /** Directional verdict; "neutral" for informational metrics or no change. */ verdict: "improve" | "regress" | "neutral"; -}; +} -export type BaselineDelta = { +export interface BaselineDelta { resultKey: string; id: string; variantId: string; @@ -214,9 +214,9 @@ export type BaselineDelta = { behaviorVerdicts: BehaviorVerdict[]; /** Set when the case is a bait whose baseline does not reproduce its misbehavior. */ baitNotReproducing?: string; -}; +} -export type BaselineCompare = { +export interface BaselineCompare { deltas: BaselineDelta[]; improved: number; regressed: number; @@ -225,7 +225,7 @@ export type BaselineCompare = { behaviorImproved: number; behaviorRegressed: number; baitFlags: number; -}; +} const CASE_FILE = "case.json"; @@ -299,10 +299,7 @@ function parseBait(raw: unknown, caseId: string): EvalBait | undefined { return { metric, threshold }; } -function parseRequireBehaviors( - raw: unknown, - caseId: string, -): BehaviorRequirement[] | undefined { +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`); @@ -324,9 +321,7 @@ function parseBehaviorRequirement( } const metric = raw.metric; if (typeof metric !== "string" || !isNumericBehaviorMetric(metric)) { - throw new Error( - `${label}.metric must be one of ${NUMERIC_BEHAVIOR_METRICS.join(", ")}`, - ); + throw new Error(`${label}.metric must be one of ${NUMERIC_BEHAVIOR_METRICS.join(", ")}`); } const hasMin = raw.min !== undefined; const hasMax = raw.max !== undefined; @@ -369,9 +364,7 @@ export function checkBehaviorRequirements( if (behaviors === null) { return { ok: false, - failures: [ - "requireBehaviors set but behavior capture missing (no turn stream recorded)", - ], + failures: ["requireBehaviors set but behavior capture missing (no turn stream recorded)"], }; } const failures: string[] = []; @@ -482,7 +475,8 @@ export function resolveRequestedProviderModel( variant: { provider?: string; model?: string }, labels: { provider?: string; model?: string }, ): { provider?: string; model?: string } { - const requested = (v?: string): string | undefined => (v === undefined || v === "(default)" ? undefined : v); + const requested = (v?: string): string | undefined => + v === undefined || v === "(default)" ? undefined : v; return { provider: variant.provider ?? requested(labels.provider), model: variant.model ?? requested(labels.model), @@ -589,9 +583,7 @@ function parseMatrixCell( 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`, - ); + throw new Error(`matrix cell ${index + 1} "${cell}" must specify both provider and model`); } const id = label ?? defaultVariantId(provider, model); return { id, provider, model }; @@ -601,8 +593,8 @@ function parseMatrixCell( export function expandMatrix( cases: readonly EvalCase[], variants: readonly EvalVariant[], -): Array<{ caseDef: EvalCase; variant: EvalVariant }> { - const out: Array<{ caseDef: EvalCase; variant: EvalVariant }> = []; +): { caseDef: EvalCase; variant: EvalVariant }[] { + const out: { caseDef: EvalCase; variant: EvalVariant }[] = []; for (const caseDef of cases) { for (const variant of variants) { out.push({ caseDef, variant }); @@ -652,10 +644,10 @@ export function summarizeRun(results: readonly CaseResult[]): EvalRunTotals { * - turnsUsed > maxTurns → overBudget true * When maxTurns is unset, overBudget is null (budget not in force). */ -export function evaluateSoftBudget(args: { - maxTurns: number | null; - turnsUsed: number | null; -}): { overBudget: boolean | null; budgetError: string | null } { +export function evaluateSoftBudget(args: { maxTurns: number | null; turnsUsed: number | null }): { + overBudget: boolean | null; + budgetError: string | null; +} { if (args.maxTurns === null) { return { overBudget: null, budgetError: null }; } @@ -830,19 +822,14 @@ export function parseEvalRunReport(raw: unknown): EvalRunReport { const provider = typeof raw.provider === "string" ? raw.provider : "(unknown)"; const model = typeof raw.model === "string" ? raw.model : "(unknown)"; const variants: EvalVariant[] = Array.isArray(raw.variants) - ? raw.variants - .filter(isRecord) - .map((v, i) => { - const id = - typeof v.id === "string" && v.id.length > 0 - ? v.id - : `variant-${i}`; - return { - id, - ...(typeof v.provider === "string" ? { provider: v.provider } : {}), - ...(typeof v.model === "string" ? { model: v.model } : {}), - }; - }) + ? raw.variants.filter(isRecord).map((v, i) => { + const id = typeof v.id === "string" && v.id.length > 0 ? v.id : `variant-${i}`; + return { + id, + ...(typeof v.provider === "string" ? { provider: v.provider } : {}), + ...(typeof v.model === "string" ? { model: v.model } : {}), + }; + }) : [{ id: defaultVariantId(provider, model), provider, model }]; const totals = isRecord(raw.totals) && typeof raw.totals.total === "number" @@ -855,9 +842,7 @@ export function parseEvalRunReport(raw: unknown): EvalRunReport { turnsUsed: typeof raw.totals.turnsUsed === "number" ? (raw.totals.turnsUsed as number) : 0, toolCallCount: - typeof raw.totals.toolCallCount === "number" - ? (raw.totals.toolCallCount as number) - : 0, + typeof raw.totals.toolCallCount === "number" ? (raw.totals.toolCallCount as number) : 0, tokenUsage: parseTokenUsage(raw.totals.tokenUsage) ?? emptyTokenUsage(), } : summarizeRun(cases); @@ -879,10 +864,7 @@ export function parseEvalRunReport(raw: unknown): EvalRunReport { }; } -function behaviorVerdicts( - prev: CellAggregate, - cur: CellAggregate, -): BehaviorVerdict[] { +function behaviorVerdicts(prev: CellAggregate, cur: CellAggregate): BehaviorVerdict[] { const verdicts: BehaviorVerdict[] = []; for (const metric of NUMERIC_BEHAVIOR_METRICS) { const prevStats = prev.behaviorStats[metric]; @@ -940,11 +922,11 @@ export function compareToBaseline( let baitFlags = 0; for (const cur of currentAggregates) { - const prev = prevByKey.get(cur.resultKey) ?? ( - baseline.aggregates.length > 0 && baseline.variants.length <= 1 + const prev = + prevByKey.get(cur.resultKey) ?? + (baseline.aggregates.length > 0 && baseline.variants.length <= 1 ? prevById.get(cur.id) - : undefined - ); + : undefined); if (prev === undefined) { deltas.push({ resultKey: cur.resultKey, @@ -959,8 +941,8 @@ export function compareToBaseline( continue; } if ( - options.allowProviderFallback !== true - && (prev.provider !== cur.provider || prev.model !== cur.model) + options.allowProviderFallback !== true && + (prev.provider !== cur.provider || prev.model !== cur.model) ) { throw new Error( `cannot compare baseline for ${cur.resultKey}: baseline ran on ` + @@ -986,8 +968,8 @@ export function compareToBaseline( let baitNotReproducing: string | undefined; if (bait !== undefined && baitReproduces(prev, bait) === false) { baitNotReproducing = - `bait metric ${bait.metric} median did not exceed ${bait.threshold} on baseline — ` - + "the case no longer reproduces its misbehavior"; + `bait metric ${bait.metric} median did not exceed ${bait.threshold} on baseline — ` + + "the case no longer reproduces its misbehavior"; baitFlags++; } deltas.push({ diff --git a/evals/capability/regression-fixtures/probe-provider-mismatch.json b/evals/capability/regression-fixtures/probe-provider-mismatch.json index 47aa636ae..03db81fd7 100644 --- a/evals/capability/regression-fixtures/probe-provider-mismatch.json +++ b/evals/capability/regression-fixtures/probe-provider-mismatch.json @@ -3,9 +3,7 @@ "version": 3, "provider": "xai/thegreataxios", "model": "grok-4.5", - "variants": [ - { "id": "default:default" } - ], + "variants": [{ "id": "default:default" }], "cases": [ { "resultKey": "default:default::simple-health", diff --git a/evals/public/README.md b/evals/public/README.md index 13956901b..46f12d64c 100644 --- a/evals/public/README.md +++ b/evals/public/README.md @@ -52,11 +52,11 @@ real public issue?** ## vs competitors -| Claim | Fair? | -| --- | --- | -| Corbits@Grok patch on instance X | Yes (this smoke) | -| % resolved on SWE-bench Lite | Only after official Docker eval on a frozen instance list | -| vs Claude Code on TB2 | Harbor adapter (not this script) | +| 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 diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index b35ed6ee8..b106c5f44 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -1,5 +1,4 @@ import { - OPENCODE_GO_AUTH_HINT, OPENCODE_GO_BASE_URL, OPENCODE_GO_DEFAULT_MODEL, OPENCODE_GO_DISPLAY_NAME, @@ -53,8 +52,7 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ baseURL: OPENCODE_GO_BASE_URL, models: OPENCODE_GO_MODEL_IDS, defaultModel: OPENCODE_GO_DEFAULT_MODEL, - authHint: - "OpenCode Go subscription — paste your API key from https://opencode.ai/auth", + authHint: "OpenCode Go subscription — paste your API key from https://opencode.ai/auth", opencodeGo: true, billingProduct: "subscription", }, @@ -91,11 +89,7 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [ label: "Anthropic", auth: "api-key", baseURL: "https://api.anthropic.com", - models: [ - "claude-opus-4-5", - "claude-sonnet-4-5", - "claude-haiku-4-5", - ], + models: ["claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"], defaultModel: "claude-sonnet-4-5", authHint: "Paste your Anthropic API key (sk-ant-...)", anthropic: true, diff --git a/packages/first-class-providers/src/types.ts b/packages/first-class-providers/src/types.ts index 6f7abf3bf..e8b810bd4 100644 --- a/packages/first-class-providers/src/types.ts +++ b/packages/first-class-providers/src/types.ts @@ -5,7 +5,7 @@ export type FirstClassOAuthProvider = "codex" | "xai"; export type FirstClassBillingProduct = "subscription" | "credits"; /** One connect path under a chooser provider (e.g. OpenAI ChatGPT vs API key). */ -export type FirstClassProviderPath = { +export interface FirstClassProviderPath { id: string; label: string; auth: "oauth" | "api-key"; @@ -23,9 +23,9 @@ export type FirstClassProviderPath = { * e.g. "codex" for ChatGPT OAuth, "openai" for API key. */ providerId?: string; -}; +} -export type FirstClassProviderDef = { +export interface FirstClassProviderDef { id: string; label: string; auth: FirstClassAuthKind; @@ -52,4 +52,4 @@ export type FirstClassProviderDef = { * api-key flow runs against that path's fields / providerId. */ paths?: readonly FirstClassProviderPath[]; -}; +} diff --git a/packages/opencode-go/src/auth.ts b/packages/opencode-go/src/auth.ts index b7d764750..568b3de2c 100644 --- a/packages/opencode-go/src/auth.ts +++ b/packages/opencode-go/src/auth.ts @@ -1,6 +1,4 @@ -export type GoApiKeyValidation = - | { ok: true; apiKey: string } - | { ok: false; error: string }; +export type GoApiKeyValidation = { ok: true; apiKey: string } | { ok: false; error: string }; /** * Validate a pasted OpenCode Go API key at the boundary. diff --git a/packages/opencode-go/src/catalog.ts b/packages/opencode-go/src/catalog.ts index f5213a870..329919645 100644 --- a/packages/opencode-go/src/catalog.ts +++ b/packages/opencode-go/src/catalog.ts @@ -12,7 +12,7 @@ import { } from "./models.js"; /** Host-agnostic catalog projection for connect + model pickers. */ -export type GoCatalogEntry = { +export interface GoCatalogEntry { name: typeof OPENCODE_GO_PROVIDER_ID; displayName: typeof OPENCODE_GO_DISPLAY_NAME; baseURL: typeof OPENCODE_GO_BASE_URL; @@ -20,7 +20,7 @@ export type GoCatalogEntry = { defaultModel: string; protocols: Readonly>; authHint: typeof OPENCODE_GO_AUTH_HINT; -}; +} export function buildGoCatalogEntry(): GoCatalogEntry { const protocols: Record = {}; diff --git a/packages/opencode-go/src/constants.ts b/packages/opencode-go/src/constants.ts index b0b8a4532..d581eabd2 100644 --- a/packages/opencode-go/src/constants.ts +++ b/packages/opencode-go/src/constants.ts @@ -16,5 +16,4 @@ export const OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"; export const OPENCODE_GO_USAGE_PATH = "/usage"; export const OPENCODE_GO_MODELS_PATH = "/models"; -export const OPENCODE_GO_AUTH_HINT = - "Paste your OpenCode Go API key from https://opencode.ai/auth"; +export const OPENCODE_GO_AUTH_HINT = "Paste your OpenCode Go API key from https://opencode.ai/auth"; diff --git a/packages/opencode-go/src/endpoint.test.ts b/packages/opencode-go/src/endpoint.test.ts index 128939123..eb5afebe3 100644 --- a/packages/opencode-go/src/endpoint.test.ts +++ b/packages/opencode-go/src/endpoint.test.ts @@ -276,12 +276,8 @@ describe("isOpenCodeGoURL", () => { }); test("rejects query-only embeds and path proxies", () => { - expect( - isOpenCodeGoURL("https://evil.com/?redirect=https://opencode.ai/zen/go/v1"), - ).toBe(false); - expect( - isOpenCodeGoURL("https://evil.com/proxy/opencode.ai/zen/go/v1"), - ).toBe(false); + expect(isOpenCodeGoURL("https://evil.com/?redirect=https://opencode.ai/zen/go/v1")).toBe(false); + expect(isOpenCodeGoURL("https://evil.com/proxy/opencode.ai/zen/go/v1")).toBe(false); expect(isOpenCodeGoURL("not a url but mentions opencode.ai/zen/go")).toBe(false); expect(isOpenCodeGoURL(undefined)).toBe(false); expect(isOpenCodeGoURL("")).toBe(false); diff --git a/packages/opencode-go/src/endpoint.ts b/packages/opencode-go/src/endpoint.ts index f29826fd5..0f52326b9 100644 --- a/packages/opencode-go/src/endpoint.ts +++ b/packages/opencode-go/src/endpoint.ts @@ -1,10 +1,7 @@ -import { - OPENCODE_GO_ANTHROPIC_BASE_URL, - OPENCODE_GO_BASE_URL, -} from "./constants.js"; +import { OPENCODE_GO_ANTHROPIC_BASE_URL, OPENCODE_GO_BASE_URL } from "./constants.js"; import { type GoProtocol, protocolForGoModel } from "./models.js"; -export type GoEndpoint = { +export interface GoEndpoint { protocol: GoProtocol; /** Base URL for the selected protocol's adapter. */ baseURL: string; @@ -13,7 +10,7 @@ export type GoEndpoint = { * Hosts map these to concrete ProviderAdapters. */ adapter: "openai-compatible" | "openai-responses" | "anthropic"; -}; +} /** * Resolve how a Go model should be called. Unknown models default to diff --git a/packages/opencode-go/src/errors.ts b/packages/opencode-go/src/errors.ts index 6ab6fa9c1..accd072b3 100644 --- a/packages/opencode-go/src/errors.ts +++ b/packages/opencode-go/src/errors.ts @@ -10,16 +10,12 @@ */ export type GoErrorKind = - | "quota_exhausted" - | "rate_limit" - | "unauthorized" - | "unavailable" - | "unknown"; + "quota_exhausted" | "rate_limit" | "unauthorized" | "unavailable" | "unknown"; /** Subset of InferenceError.category used when reclassifying Go failures. */ export type GoErrorCategory = "quota_exhausted" | "retryable" | "auth" | "fatal"; -export type ParsedGoAPIError = { +export interface ParsedGoAPIError { kind: GoErrorKind; category: GoErrorCategory; message: string; @@ -28,7 +24,7 @@ export type ParsedGoAPIError = { retryAfterSec?: number; workspace?: string; statusCode: number; -}; +} const QUOTA_TYPE_NAMES = new Set([ "GoUsageLimitError", @@ -146,7 +142,11 @@ function extractErrorNode(body: unknown): { return flat; } -function looksLikeQuota(typeName: string | undefined, code: string | undefined, message: string): boolean { +function looksLikeQuota( + typeName: string | undefined, + code: string | undefined, + message: string, +): boolean { if (typeName !== undefined && QUOTA_TYPE_NAMES.has(typeName)) return true; if (code !== undefined && /quota|usage_limit/i.test(code)) return true; const lower = message.toLowerCase(); @@ -186,9 +186,7 @@ function userMessageFor( retryAfterSec !== undefined && retryAfterSec > 0 ? ` Retry after ~${formatReset(retryAfterSec)}.` : " Retry shortly."; - return ( - (original.length > 0 ? original : "OpenCode Go rate limit exceeded.") + wait - ); + return (original.length > 0 ? original : "OpenCode Go rate limit exceeded.") + wait; } if (kind === "unauthorized") { return original.length > 0 @@ -231,7 +229,10 @@ export function parseGoAPIError(args: { // Quota before auth: the gateway has returned 403 with usage-limit bodies. // Clear quota markers must not be swallowed as unauthorized. // 400 is intentional — the gateway has been observed returning 400 for limit hits. - if (quota && (statusCode === 429 || statusCode === 402 || statusCode === 400 || statusCode === 403)) { + if ( + quota && + (statusCode === 429 || statusCode === 402 || statusCode === 400 || statusCode === 403) + ) { return { kind: "quota_exhausted", category: "quota_exhausted", @@ -298,7 +299,10 @@ export function parseGoAPIError(args: { } // Recognized Go type name on an unexpected status — still surface it. - if (typeName !== undefined && (QUOTA_TYPE_NAMES.has(typeName) || RATE_LIMIT_TYPE_NAMES.has(typeName))) { + if ( + typeName !== undefined && + (QUOTA_TYPE_NAMES.has(typeName) || RATE_LIMIT_TYPE_NAMES.has(typeName)) + ) { const kind: GoErrorKind = QUOTA_TYPE_NAMES.has(typeName) ? "quota_exhausted" : "rate_limit"; return { kind, diff --git a/packages/opencode-go/src/identity.test.ts b/packages/opencode-go/src/identity.test.ts index 98ff295b3..6682c5997 100644 --- a/packages/opencode-go/src/identity.test.ts +++ b/packages/opencode-go/src/identity.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "bun:test"; import { OPENCODE_GO_DISPLAY_NAME, OPENCODE_GO_PROVIDER_ID } from "./constants.js"; -import { - isOpenCodeGoProvider, - isOpenCodeGoProviderId, - isOpenCodeGoURL, -} from "./identity.js"; +import { isOpenCodeGoProvider, isOpenCodeGoProviderId, isOpenCodeGoURL } from "./identity.js"; describe("isOpenCodeGoProviderId", () => { test("matches stable id and display name", () => { diff --git a/packages/opencode-go/src/index.ts b/packages/opencode-go/src/index.ts index ceae43aa1..57a38f077 100644 --- a/packages/opencode-go/src/index.ts +++ b/packages/opencode-go/src/index.ts @@ -21,13 +21,15 @@ export { export { resolveGoEndpoint, type GoEndpoint } from "./endpoint.js"; export { validateGoApiKey, type GoApiKeyValidation } from "./auth.js"; -export { fetchGoUsage, formatGoUsage, type GoFetch, type GoUsage, type GoUsageWindow } from "./usage.js"; -export { buildGoCatalogEntry, type GoCatalogEntry } from "./catalog.js"; export { - isOpenCodeGoProvider, - isOpenCodeGoProviderId, - isOpenCodeGoURL, -} from "./identity.js"; + fetchGoUsage, + formatGoUsage, + type GoFetch, + type GoUsage, + type GoUsageWindow, +} from "./usage.js"; +export { buildGoCatalogEntry, type GoCatalogEntry } from "./catalog.js"; +export { isOpenCodeGoProvider, isOpenCodeGoProviderId, isOpenCodeGoURL } from "./identity.js"; export { parseGoAPIError, type GoErrorCategory, diff --git a/packages/opencode-go/src/models.ts b/packages/opencode-go/src/models.ts index 4c866eb0a..4222d86d8 100644 --- a/packages/opencode-go/src/models.ts +++ b/packages/opencode-go/src/models.ts @@ -4,11 +4,11 @@ export type GoProtocol = "chat-completions" | "responses" | "messages"; -export type GoModel = { +export interface GoModel { id: string; name: string; protocol: GoProtocol; -}; +} export const OPENCODE_GO_MODELS = [ { id: "grok-4.5", name: "Grok 4.5", protocol: "chat-completions" }, diff --git a/packages/opencode-go/src/usage.ts b/packages/opencode-go/src/usage.ts index 91be9f2f9..6375a27ac 100644 --- a/packages/opencode-go/src/usage.ts +++ b/packages/opencode-go/src/usage.ts @@ -1,20 +1,20 @@ import { OPENCODE_GO_BASE_URL, OPENCODE_GO_USAGE_PATH } from "./constants.js"; -export type GoUsageWindow = { +export interface GoUsageWindow { usageDollars?: number; limitDollars?: number; usagePercent?: number; resetInSec?: number; -}; +} -export type GoUsage = { +export interface GoUsage { rolling5h?: GoUsageWindow; weekly?: GoUsageWindow; monthly?: GoUsageWindow; /** Raw status when the endpoint is missing or auth fails. */ status: "ok" | "unavailable" | "unauthorized" | "error"; message?: string; -}; +} /** Minimal fetch shape so tests can inject stubs without matching full DOM fetch. */ export type GoFetch = ( @@ -42,8 +42,7 @@ export async function fetchGoUsage( opts?: { fetchImpl?: GoFetch; signal?: AbortSignal }, ): Promise { const fetchImpl: GoFetch = - opts?.fetchImpl ?? - ((input, init) => globalThis.fetch(input, init as RequestInit)); + opts?.fetchImpl ?? ((input, init) => globalThis.fetch(input, init as RequestInit)); const url = `${OPENCODE_GO_BASE_URL}${OPENCODE_GO_USAGE_PATH}`; try { const init: { @@ -103,6 +102,7 @@ export function formatGoUsage(usage: GoUsage): string { : w.usageDollars !== undefined && w.limitDollars !== undefined ? `$${w.usageDollars.toFixed(2)}/$${w.limitDollars.toFixed(0)}` : "ok"; - const window = usage.rolling5h !== undefined ? "5h" : usage.weekly !== undefined ? "week" : "month"; + const window = + usage.rolling5h !== undefined ? "5h" : usage.weekly !== undefined ? "week" : "month"; return `Go ${window} ${pct}`; } diff --git a/plugins/corbits-skills/skills/ast-grep/SKILL.md b/plugins/corbits-skills/skills/ast-grep/SKILL.md index a2c37be37..11b938a01 100644 --- a/plugins/corbits-skills/skills/ast-grep/SKILL.md +++ b/plugins/corbits-skills/skills/ast-grep/SKILL.md @@ -36,12 +36,12 @@ 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 | +| 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 | +| `$$$` | 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)`. @@ -79,22 +79,24 @@ The `-U` (`--update-all`) flag applies changes to files without prompting. Witho ### 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 | +| 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):** @@ -124,29 +126,35 @@ fix: NewName 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/ ``` @@ -166,6 +174,7 @@ 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/ @@ -174,6 +183,7 @@ 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 @@ -217,12 +227,12 @@ rule: 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 | +| 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). @@ -251,11 +261,11 @@ rule: 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) | +| 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 @@ -313,26 +323,29 @@ 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) | +| 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:** @@ -391,14 +404,14 @@ ast-grep handles code; prose requires separate attention. Skipping this pass lea ### Choosing inline vs YAML -| Situation | Use | -|---|---| -| Call-site-only rename or argument change | `sg run -p ... -r ...` | +| 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 | +| 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 diff --git a/plugins/corbits-skills/skills/create-issue/SKILL.md b/plugins/corbits-skills/skills/create-issue/SKILL.md index 889065ef1..053e33f4c 100644 --- a/plugins/corbits-skills/skills/create-issue/SKILL.md +++ b/plugins/corbits-skills/skills/create-issue/SKILL.md @@ -21,7 +21,7 @@ Pick the tracker before drafting. Do not skip this. - GitLab - Linear (enable MCP) - Other - Then persist the choice: DIY with write_file/edit_file — append `Preferred issue tracker: ` to `.corbits/MEMORY.md` only. Path tools are the DIY surface; shell writes stay denied. Do not touch anything else. + Then persist the choice: DIY with write_file/edit_file — append `Preferred issue tracker: ` to `.corbits/MEMORY.md` only. Path tools are the DIY surface; shell writes stay denied. Do not 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. @@ -38,6 +38,7 @@ When the operator provides `--from-doc` or mentions a planning document, search - `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: @@ -63,11 +64,11 @@ Project updates are a distinct Linear artifact: they communicate status on an ex 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) | +| 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. @@ -236,7 +237,7 @@ Experiment ### 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*. +**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:** diff --git a/plugins/corbits-skills/skills/dispatch/SKILL.md b/plugins/corbits-skills/skills/dispatch/SKILL.md index feff7d63f..41e00791d 100644 --- a/plugins/corbits-skills/skills/dispatch/SKILL.md +++ b/plugins/corbits-skills/skills/dispatch/SKILL.md @@ -29,15 +29,15 @@ If the spec is vague, incomplete, or contradictory: stop and report Blockers. Do ## 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="build")` | -| Ship product code + tests | `task(agent="build")` | -| 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")` | +| 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="build")` | +| Ship product code + tests | `task(agent="build")` | +| 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. Path tools (`write_file` / `edit_file` / `delete_file`) are mounted for DIY tiny/bounded product edits; spawn remains the default for DAG product work. Durable orchestration artifacts (`dispatch.yaml`, `plan.md`, status) still go through build — intern does not have write tools (`INTERN_TOOLS` = run_shell, read_file, list_dir). Do not spawn a blob agent to author the manifest. Do not write those manifests on Skywalker. @@ -104,31 +104,31 @@ The directory name is the task `id`. After a worker runs, the task directory is ```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 +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 + 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") + agent: critique # always task(agent="critique") commits: - strategy: per-task # per-task | grouped + strategy: per-task # per-task | grouped message-source: objective tasks: - id: 1a-extract_auth_module - type: feature # feature | bugfix (omit for explore) - agent: build # build | intern | explore + type: feature # feature | bugfix (omit for explore) + agent: build # build | intern | explore depends-on: [] - receives: [] # subset of depends-on; default = depends-on - status: pending # pending | dispatched | completed | failed | fixing + receives: [] # subset of depends-on; default = depends-on + status: pending # pending | dispatched | completed | failed | fixing critique: enabled: true @@ -194,8 +194,11 @@ Must `task(agent="tester")` for the suite (or intern for one named mechanical co 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. diff --git a/plugins/corbits-skills/skills/git-rebase/SKILL.md b/plugins/corbits-skills/skills/git-rebase/SKILL.md index 263c55c67..a5ae54910 100644 --- a/plugins/corbits-skills/skills/git-rebase/SKILL.md +++ b/plugins/corbits-skills/skills/git-rebase/SKILL.md @@ -90,14 +90,14 @@ Do **not** reach for it when: 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) | +| 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 +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 @@ -201,15 +201,15 @@ git rebase --continue - 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 +- 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 `` causes git to move +- On _successful completion_, passing `` 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`. +my-branch HEAD`. ### Pattern 2: Script the rebase todo list @@ -229,7 +229,7 @@ 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* +`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 @@ -278,7 +278,7 @@ 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 +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 @@ -328,7 +328,7 @@ dispatcher can't tell them apart from `head -1` alone. Either: - 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 + 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 @@ -364,8 +364,8 @@ 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 +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 @@ -382,7 +382,7 @@ 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 +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 @@ -417,7 +417,7 @@ A commit-msg hook that enforces a subject-length limit will reject `fixup! ` 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 +`--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, @@ -438,7 +438,7 @@ 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* +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 @@ -475,9 +475,9 @@ git commit -m "Subject for group B" git rebase --continue ``` -If the pieces should fold into different *other* commits, name them with +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 +`--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! ` line that the squash discards anyway, and @@ -528,7 +528,7 @@ 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 +_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. @@ -558,6 +558,7 @@ decision into the intern brief. splits, in-place amends — `ask_operator` when which-commits is a judgment call. 2. **Branch your way back.** Intern: + ```bash git branch backup--pre-rebase ``` @@ -592,12 +593,12 @@ decision into the intern brief. done git checkout -q "$branch" ``` - Note: capture the symbolic branch name *before* the loop (the loop's + 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 ``). - 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. +--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 @@ -627,15 +628,15 @@ becomes: 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 +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` +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 @@ -645,7 +646,7 @@ Resolution decision tree: - If your edit makes the later commit redundant (its intent is already in HEAD): `git checkout --ours ` then `git add`. On `git rebase - --continue`, git's handling of the now-empty commit is configurable +--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 @@ -653,8 +654,8 @@ Resolution decision tree: - `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. + 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 `. Whether this is "rare" @@ -674,7 +675,7 @@ manually — don't reach for `--ours`/`--theirs` as a shortcut. - **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! - ` commits is the only acceptable use; document any other +` 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 diff --git a/plugins/corbits-skills/skills/implement/SKILL.md b/plugins/corbits-skills/skills/implement/SKILL.md index 70b3648f3..854a2f71c 100644 --- a/plugins/corbits-skills/skills/implement/SKILL.md +++ b/plugins/corbits-skills/skills/implement/SKILL.md @@ -33,6 +33,7 @@ For each unit, run these steps in order. Do not skip. When this loop is running, `task(agent="greybeard")` on the approach before any code is written. Send: + - What will change and why - Files expected - Design decisions and trade-offs @@ -85,6 +86,9 @@ When critique is clean (or remaining findings are acknowledged judgment calls), When the requested units are done (or blocked), synthesize for the operator: ## Summary + ## Findings + ## Blockers + ## Paths diff --git a/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md index de091989c..5d11147c7 100644 --- a/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md +++ b/plugins/corbits-skills/skills/linear-issue-workflow/SKILL.md @@ -143,14 +143,14 @@ 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` | +| 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 diff --git a/plugins/corbits-skills/skills/opsh/SKILL.md b/plugins/corbits-skills/skills/opsh/SKILL.md index 83054a9f6..fd9c0ad5a 100644 --- a/plugins/corbits-skills/skills/opsh/SKILL.md +++ b/plugins/corbits-skills/skills/opsh/SKILL.md @@ -41,14 +41,14 @@ lib::import git 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 | +| 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 @@ -58,13 +58,13 @@ opsh sets these options before your script runs. Do not disable them. 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` | +| 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. @@ -99,13 +99,13 @@ deploy::cleanup() { ... } 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` | +| 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..." @@ -181,7 +181,7 @@ lib::import command ``` | Function | Description | -|-------------------|------------------------------------| +| ----------------- | ---------------------------------- | | `command::exists` | Returns 0 if command is in `$PATH` | ```bash @@ -194,10 +194,10 @@ command::exists docker || log::fatal "docker is required" lib::import path ``` -| Function | Description | -|---------------------|--------------------------------------| -| `path::env::add` | Prepend directories to `$PATH` | -| `path::env::remove` | Remove a directory from `$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 @@ -210,13 +210,13 @@ path::env::remove /usr/local/old/bin 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 | +| 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. @@ -233,7 +233,7 @@ 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 | @@ -257,14 +257,14 @@ new=$(semver::bump minor v1.2.3) # v1.3.0 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 | +| 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 @@ -289,10 +289,10 @@ ssh::end 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 | +| 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 @@ -307,9 +307,9 @@ fi lib::import step-runner ``` -| Function | Description | -|---------------|-------------------------------------------------------| -| `steps::run` | Run all `prefix::*` functions in alphabetical order | +| Function | Description | +| ------------ | --------------------------------------------------- | +| `steps::run` | Run all `prefix::*` functions in alphabetical order | Define functions with a shared prefix, then run them: @@ -331,11 +331,11 @@ control ordering. 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 | +| 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. diff --git a/plugins/corbits-skills/skills/style/SKILL.md b/plugins/corbits-skills/skills/style/SKILL.md index 104a718aa..e313080d2 100644 --- a/plugins/corbits-skills/skills/style/SKILL.md +++ b/plugins/corbits-skills/skills/style/SKILL.md @@ -48,7 +48,7 @@ Decorative comment blocks (ASCII art dividers, section headers) add visual noise 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. +**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: @@ -61,7 +61,7 @@ If you could do it now, do it now. A TODO is a promise to the reader that the wo ### 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. +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. @@ -143,7 +143,7 @@ Document INFERENCE.md updates (filename in subject) 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 +- 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. @@ -151,11 +151,11 @@ Someone reading `git log` years from now, with only the repo in hand, should und **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. +**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. +**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: +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. @@ -304,7 +304,7 @@ Defaults live at the edge, alongside validation. The boundary that accepts user 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. +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. diff --git a/plugins/corbits-skills/skills/typescript/SKILL.md b/plugins/corbits-skills/skills/typescript/SKILL.md index e28327a61..5e250e175 100644 --- a/plugins/corbits-skills/skills/typescript/SKILL.md +++ b/plugins/corbits-skills/skills/typescript/SKILL.md @@ -283,11 +283,11 @@ Instead of silencing the compiler, restructure the code so the value is provably ```typescript // Bad - hiding a potential bug -const user = users.find(u => u.id === id)!; +const user = users.find((u) => u.id === id)!; processUser(user); // Good - handle the null case -const user = users.find(u => u.id === id); +const user = users.find((u) => u.id === id); if (!user) { throw new Error(`User not found: ${id}`); } @@ -306,6 +306,7 @@ if (!handler) { ``` 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) @@ -317,10 +318,7 @@ 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; + configureApp(args: { level: LogLevel; [key: string]: unknown }): Promise; } // Good - generic with constraint (type-safe) @@ -465,10 +463,7 @@ function timeout(timeoutMs: number, msg?: string) { ); } -const result = await Promise.race([ - fetchData(), - timeout(5000, "fetch timed out"), -]); +const result = await Promise.race([fetchData(), timeout(5000, "fetch timed out")]); ``` ### Retry Logic diff --git a/scripts/demo.ts b/scripts/demo.ts index 469add1b6..9d38f9caa 100644 --- a/scripts/demo.ts +++ b/scripts/demo.ts @@ -11,10 +11,9 @@ const DEMO_FIXTURE = resolve(import.meta.dirname, "../tests/fixtures/demo-compar async function runTUIMode(): Promise { console.log("=== TUI demo (fixture repo) ==="); - const config = await loadConfig( - ["--cwd", DEMO_FIXTURE, "--force", DEMO_TASK], - { allowUnconfigured: false }, - ); + const config = await loadConfig(["--cwd", DEMO_FIXTURE, "--force", DEMO_TASK], { + allowUnconfigured: false, + }); const code = await runTUI({ ...config, task: DEMO_TASK, maxTurns: 10 }); process.exitCode = code; } @@ -22,14 +21,7 @@ async function runTUIMode(): Promise { async function runExecMode(): Promise { console.log("=== Exec demo (fixture repo, product non-TUI path) ==="); const config = await loadConfig( - [ - "exec", - "--cwd", - DEMO_FIXTURE, - "--force", - "--dangerously-skip-permissions", - DEMO_TASK, - ], + ["exec", "--cwd", DEMO_FIXTURE, "--force", "--dangerously-skip-permissions", DEMO_TASK], { allowUnconfigured: false }, ); const result = await runExec({ ...config, task: DEMO_TASK, maxTurns: 10 }); diff --git a/scripts/eval-capability.test.ts b/scripts/eval-capability.test.ts index 51581ceaf..bb403eec9 100644 --- a/scripts/eval-capability.test.ts +++ b/scripts/eval-capability.test.ts @@ -188,7 +188,6 @@ describe("mapPool", () => { test("rejects non-positive concurrency", async () => { await expect(mapPool([1], 0, async (item) => item)).rejects.toThrow(/positive integer/); }); - }); describe("initEvalGitRepo", () => { diff --git a/scripts/eval-capability.ts b/scripts/eval-capability.ts index c183e2c5b..64d729bde 100755 --- a/scripts/eval-capability.ts +++ b/scripts/eval-capability.ts @@ -58,7 +58,7 @@ import { const REPO_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url))); const CASES_ROOT = join(REPO_ROOT, "evals", "capability", "cases"); -type CliOptions = { +interface CliOptions { caseSelector: string; provider?: string; model?: string; @@ -89,7 +89,7 @@ type CliOptions = { * Eval/CI override, not single-agent mode. Omitted = skywalker default. */ director?: string; -}; +} function printUsage(): void { console.log(`Usage: bun scripts/eval-capability.ts --provider --model [options] @@ -321,8 +321,9 @@ function runCommand( resolvePromise({ exitCode: timedOut ? 124 : (code ?? 1), stdout: Buffer.concat(out).toString("utf8"), - stderr: Buffer.concat(err).toString("utf8") - + (timedOut ? `\n[eval] timed out after ${timeoutMs}ms` : ""), + stderr: + Buffer.concat(err).toString("utf8") + + (timedOut ? `\n[eval] timed out after ${timeoutMs}ms` : ""), timedOut, }); }); @@ -410,7 +411,9 @@ export async function initEvalGitRepo(workdir: string): Promise { ]); } -async function prepareWorkdir(caseDef: EvalCase): Promise<{ workdir: string; capturePath: string }> { +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 }); @@ -455,11 +458,11 @@ async function readCapturedBehaviors(capturePath: string): Promise Promise; -}; +} /** * Hermetic local page for web-fetch cases: 127.0.0.1 on an ephemeral port, @@ -469,8 +472,8 @@ type HTTPFixture = { function startHTTPFixture(): Promise { const token = randomBytes(8).toString("hex"); const html = - "Release info" - + `

Release info

build code: ${token}

`; + "Release info" + + `

Release info

build code: ${token}

`; return new Promise((resolvePromise, reject) => { const server: Server = createServer((_req, res) => { res.writeHead(200, { "content-type": "text/html" }); @@ -625,8 +628,8 @@ async function runCase( workdir = prepared.workdir; capturePath = prepared.capturePath; console.log( - `\n=== ${variant.id} × ${caseDef.id} (${caseDef.tier})` - + ` [repeat ${repeat + 1}/${opts.repeats}] — ${caseDef.title}`, + `\n=== ${variant.id} × ${caseDef.id} (${caseDef.tier})` + + ` [repeat ${repeat + 1}/${opts.repeats}] — ${caseDef.title}`, ); console.log(`provider/model: ${labels.provider} / ${labels.model}`); console.log(`workdir: ${workdir}`); @@ -720,11 +723,11 @@ async function runCase( console.log("behaviors: capture missing (no turn stream recorded)"); } else { console.log( - `behaviors: shell=${behaviors.shellCommandCount} env=${behaviors.envAssignmentCommandCount}` - + ` net=${behaviors.networkCommandCount} web_fetch=${behaviors.webFetchToolCallCount}` - + ` shellEdit=${behaviors.editViaShellCount} repeats=${behaviors.repeatedSearchCount}` - + ` toolOnlyStreak=${behaviors.longestToolOnlyStreak}` - + ` maxTurnMs=${behaviors.maxTurnDurationMs}`, + `behaviors: shell=${behaviors.shellCommandCount} env=${behaviors.envAssignmentCommandCount}` + + ` net=${behaviors.networkCommandCount} web_fetch=${behaviors.webFetchToolCallCount}` + + ` shellEdit=${behaviors.editViaShellCount} repeats=${behaviors.repeatedSearchCount}` + + ` toolOnlyStreak=${behaviors.longestToolOnlyStreak}` + + ` maxTurnMs=${behaviors.maxTurnDurationMs}`, ); } @@ -738,7 +741,8 @@ async function runCase( } } - const verifyEnv: Record = httpFixture !== null ? httpFixtureEnv(httpFixture) : {}; + const verifyEnv: Record = + httpFixture !== null ? httpFixtureEnv(httpFixture) : {}; const verify = await runVerify(caseDef, workdir, opts.verifyTimeoutMs, verifyEnv); if (verify.output.trim().length > 0) { console.log(verify.output.trimEnd()); @@ -749,11 +753,11 @@ async function runCase( const budget = evaluateSoftBudget({ maxTurns, turnsUsed }); const overBudget = budget.overBudget; // 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 passed = + agentExitCode === 0 && + verify.exitCode === 0 && + overBudget !== true && + requireBehaviorCheck.ok; const preview = execResult.text.length > 400 ? `${execResult.text.slice(0, 400)}…` : execResult.text; @@ -880,7 +884,7 @@ async function main(): Promise { } const startedAt = new Date().toISOString(); - const cells: Array<{ caseDef: EvalCase; variant: EvalVariant; repeat: number }> = []; + const cells: { caseDef: EvalCase; variant: EvalVariant; repeat: number }[] = []; for (const { caseDef, variant } of plan) { for (let repeat = 0; repeat < opts.repeats; repeat++) { cells.push({ caseDef, variant, repeat }); diff --git a/scripts/eval-public-swe-one.ts b/scripts/eval-public-swe-one.ts index 89825ddcf..1e92eff9f 100644 --- a/scripts/eval-public-swe-one.ts +++ b/scripts/eval-public-swe-one.ts @@ -16,7 +16,7 @@ * bun scripts/eval-public-swe-one.ts --instance … --provider --model --evaluate */ -import { mkdir, writeFile, readFile, mkdtemp, rm, cp } from "node:fs/promises"; +import { mkdir, writeFile, mkdtemp, rm } from "node:fs/promises"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; @@ -30,7 +30,7 @@ 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 = { +interface CliOptions { provider: string; model: string; instanceId: string; @@ -41,9 +41,9 @@ type CliOptions = { dryRun: boolean; outDir: string; help: boolean; -}; +} -type SweInstance = { +interface SweInstance { instance_id: string; repo: string; base_commit: string; @@ -53,7 +53,7 @@ type SweInstance = { version?: string; patch?: string; test_patch?: string; -}; +} function printHelp(): void { console.log(`Usage: bun scripts/eval-public-swe-one.ts --provider --model [options] @@ -332,7 +332,9 @@ async function main(): Promise { 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)})`); + 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); diff --git a/scripts/tool-fingerprint-forensics.ts b/scripts/tool-fingerprint-forensics.ts index 7e9c43eef..a6a81bfca 100644 --- a/scripts/tool-fingerprint-forensics.ts +++ b/scripts/tool-fingerprint-forensics.ts @@ -25,7 +25,7 @@ function stableJson(value: unknown): string { return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(",")}}`; } -function fingerprintToolCalls(content: ReadonlyArray>): string | null { +function fingerprintToolCalls(content: readonly Record[]): string | null { const parts: string[] = []; for (const block of content) { if (block.type !== "tool_call") continue; @@ -93,7 +93,9 @@ const runLengths: number[] = []; for (const file of files) { let lines: string[]; try { - lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim().length > 0); + lines = readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0); } catch { continue; } @@ -107,7 +109,7 @@ for (const file of files) { continue; } if (turn.role !== "assistant" || !Array.isArray(turn.content)) continue; - const content = turn.content as ReadonlyArray>; + const content = turn.content as readonly Record[]; const hasToolCalls = content.some((b) => b.type === "tool_call"); const hasText = content.some( (b) => b.type === "text" && typeof b.text === "string" && b.text.length > 0, diff --git a/src/agent/agent-search.test.ts b/src/agent/agent-search.test.ts index b2dfba129..a9f41fc23 100644 --- a/src/agent/agent-search.test.ts +++ b/src/agent/agent-search.test.ts @@ -79,9 +79,7 @@ describe("formatAgentSearchResults", () => { }); test("omits body section when systemPromptRole is absent", () => { - const text = formatAgentSearchResults([ - { id: "no-body", description: "Metadata only" }, - ]); + const text = formatAgentSearchResults([{ id: "no-body", description: "Metadata only" }]); expect(text).toContain("### no-body"); expect(text).toContain("Metadata only"); expect(text).not.toContain("System prompt / body:"); @@ -141,10 +139,7 @@ describe("createSearchAgentsTool", () => { }, ]); if (tool.kind !== "string") throw new Error("expected string tool"); - const text = await tool.handler( - { query: "emil product" }, - new AbortController().signal, - ); + const text = await tool.handler({ query: "emil product" }, new AbortController().signal); expect(text).toContain("emil"); expect(text).toContain("System prompt / body:"); expect(text).toContain(body); diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 9a6628006..362f29986 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -14,9 +14,9 @@ function profileSearchText(profile: AgentProfile): string { return parts.join(" "); } -export type AgentIndex = { +export interface AgentIndex { search(query: string, limit?: number): AgentProfile[]; -}; +} // Lexical ranker over id, description, and role text — same spirit as tool_search. export function createAgentIndex(getProfiles: () => readonly AgentProfile[]): AgentIndex { @@ -71,9 +71,7 @@ function formatAgentProfileEntry(p: AgentProfile): string { const orch = p.orchestrator === true ? " [orchestrator]" : ""; const source = p.source !== undefined ? ` [source: ${p.source}]` : ""; const header = - desc.length > 0 - ? `### ${p.id}${orch}${source}\n${desc}` - : `### ${p.id}${orch}${source}`; + desc.length > 0 ? `### ${p.id}${orch}${source}\n${desc}` : `### ${p.id}${orch}${source}`; const body = (p.systemPromptRole ?? "").trim(); if (body.length === 0) return header; return `${header}\n\nSystem prompt / body:\n${truncateAgentBody(body)}`; @@ -136,4 +134,4 @@ export function createSearchAgentsTool(getProfiles: () => readonly AgentProfile[ return formatAgentSearchResults(index.search(query)); }, }); -} \ No newline at end of file +} diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index ce7427c70..6ff099b85 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -263,8 +263,12 @@ describe("compaction governor", () => { test("only intercepts on tool.done with a pending infer", () => { const governor = createCompactionGovernor(() => {}); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); - expect(governor.interceptActions(inferenceDone(overThreshold), inferAction, capabilities)).toBeNull(); - expect(governor.interceptActions(toolDone(), [{ type: "reply", content: "x" }], capabilities)).toBeNull(); + expect( + governor.interceptActions(inferenceDone(overThreshold), inferAction, capabilities), + ).toBeNull(); + expect( + governor.interceptActions(toolDone(), [{ type: "reply", content: "x" }], capabilities), + ).toBeNull(); }); test("stays inert below the minimum-turn floor no matter how far over threshold", () => { diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index c5da0f979..d3d2c606b 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -136,8 +136,7 @@ export function createCompactionGovernor( if (!idlePending || event.type !== "message.received") return null; idlePending = false; pending = false; - const content = - typeof event.message.content === "string" ? event.message.content : ""; + const content = typeof event.message.content === "string" ? event.message.content : ""; // An operator message that raced the continuation is already in history; // compact first, then request another continuation to answer it. if (content.length > 0) { @@ -168,8 +167,7 @@ export function createCompactionGovernor( function resumeAfterCompact(event: ReactorInboundEvent): boolean { if (!postCompactInfer || event.type !== "message.received") return false; - const content = - typeof event.message.content === "string" ? event.message.content : ""; + const content = typeof event.message.content === "string" ? event.message.content : ""; if (content.length > 0) return false; postCompactInfer = false; return true; diff --git a/src/agent/context-estimate.test.ts b/src/agent/context-estimate.test.ts index 1c5b2ddef..e196571a1 100644 --- a/src/agent/context-estimate.test.ts +++ b/src/agent/context-estimate.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import type { ContentBlock, ConversationTurn, MediaSource, ToolDefinition } from "@intx/types/runtime"; +import type { + ContentBlock, + ConversationTurn, + MediaSource, + ToolDefinition, +} from "@intx/types/runtime"; import { createContextEstimate, estimateContentBlockTokens, @@ -32,7 +37,11 @@ describe("estimateMediaSourceTokens", () => { const base64: MediaSource = { kind: "base64", data: "abcd".repeat(100), mimeType: "image/png" }; expect(estimateMediaSourceTokens(base64)).toBe(estimateTokensFromChars(400)); - const url: MediaSource = { kind: "url", url: "https://example.com/a.png", mimeType: "image/png" }; + const url: MediaSource = { + kind: "url", + url: "https://example.com/a.png", + mimeType: "image/png", + }; expect(estimateMediaSourceTokens(url)).toBe(1_000); }); @@ -94,7 +103,9 @@ describe("estimateOverheadTokens", () => { ]; const expectedChars = 40 + "run_shell".length + 20 + JSON.stringify({ command: "string" }).length; - expect(estimateOverheadTokens(systemPrompt, tools)).toBe(estimateTokensFromChars(expectedChars)); + expect(estimateOverheadTokens(systemPrompt, tools)).toBe( + estimateTokensFromChars(expectedChars), + ); }); test("is zero for an empty prompt and no tools", () => { diff --git a/src/agent/context-estimate.ts b/src/agent/context-estimate.ts index 90b97f4d0..97150c37f 100644 --- a/src/agent/context-estimate.ts +++ b/src/agent/context-estimate.ts @@ -45,9 +45,7 @@ export function estimateContentBlockTokens(block: ContentBlock): number { case "refusal": return estimateTokensFromChars(block.reason.length); case "tool_call": - return estimateTokensFromChars( - block.name.length + JSON.stringify(block.arguments).length, - ); + return estimateTokensFromChars(block.name.length + JSON.stringify(block.arguments).length); case "tool_result": return block.content.reduce((sum, part) => sum + estimateContentBlockTokens(part), 0); case "image": diff --git a/src/agent/context-extensions.ts b/src/agent/context-extensions.ts index 6f456d07e..e105a5a10 100644 --- a/src/agent/context-extensions.ts +++ b/src/agent/context-extensions.ts @@ -61,4 +61,4 @@ async function firstFile(dirs: string[], name: string): Promise }> } | undefined; + const opts = action.options as + { ephemeralTurns?: { content: { text?: string }[] }[] } | undefined; return opts?.ephemeralTurns?.[0]?.content?.[0]?.text; } @@ -167,7 +168,10 @@ describe("ChatDirector tool-only loop protection", () => { const providerlessPolicy = { providerName: "test-provider" }; test("nudges once at the family threshold, after pending tools execute", async () => { - const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy }); + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); // Default family nudges at 25 consecutive tool-only turns. @@ -178,7 +182,10 @@ describe("ChatDirector tool-only loop protection", () => { }); test("the nudge is one-shot — it does not repeat on the next tool-only turn", async () => { - const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy }); + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); await runToolOnlyStreak(director, capabilities, 25); @@ -188,7 +195,7 @@ describe("ChatDirector tool-only loop protection", () => { expect(ephemeralText(infer)).toBeUndefined(); }); -// Required by CL-5611: a long productive tool-only streak (varied + // Required by CL-5611: a long productive tool-only streak (varied // fingerprints every turn) must run straight through both the nudge and // well past any prior hard-pause threshold without ever pausing. test("a long productive tool-only streak continues without pausing", async () => { @@ -199,7 +206,9 @@ describe("ChatDirector tool-only loop protection", () => { const capabilities = makeCapabilities(); const actions = await runToolOnlyStreak(director, capabilities, 50); - expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); expect(actions.some((a) => a.type === "infer")).toBe(true); }); @@ -236,7 +245,9 @@ describe("ChatDirector tool-only loop protection", () => { await runToolOnlyStreak(director, capabilities, 4, repeatedToolOnlyTurn); const actions = await runToolOnlyStreak(director, capabilities, 3, toolOnlyTurn); - expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); expect(actions.some((a) => a.type === "infer")).toBe(true); }); @@ -407,7 +418,9 @@ describe("ChatDirector tool-only loop protection", () => { // A further 99 turns without a user message: still no pause (the // escalation window has not fully elapsed). const stillNoPause = await runToolOnlyStreak(director, capabilities, 99, rotationTurn); - expect(stillNoPause.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(stillNoPause.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); // Turn 200: the nudge went unheeded for a full further interval — escalate to a pause. const actions = actionsArray(await runToolOnlyStreak(director, capabilities, 1, rotationTurn)); @@ -432,7 +445,16 @@ describe("ChatDirector tool-only loop protection", () => { const phaseBrokenTurn = (id: string): ReactorInboundEvent => { const i = Number(id.split("-")[1]); const window = i % 5; - const path = window === 0 ? "a.ts" : window === 1 ? "b.ts" : window === 2 ? "a.ts" : window === 3 ? "b.ts" : `unique-${i}.ts`; + const path = + window === 0 + ? "a.ts" + : window === 1 + ? "b.ts" + : window === 2 + ? "a.ts" + : window === 3 + ? "b.ts" + : `unique-${i}.ts`; return { type: "inference.done", turn: { @@ -465,7 +487,9 @@ describe("ChatDirector tool-only loop protection", () => { const capabilities = makeCapabilities(); const actions = await runToolOnlyStreak(director, capabilities, 99); - expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); expect(actions.some((a) => a.type === "infer")).toBe(true); }); @@ -484,7 +508,9 @@ describe("ChatDirector tool-only loop protection", () => { for (let round = 0; round < 5; round++) { await director.decide(messageReceived(`keep going, round ${round}`), mockState, capabilities); const actions = await runToolOnlyStreak(director, capabilities, 80); - expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); } }); @@ -509,10 +535,14 @@ describe("ChatDirector tool-only loop protection", () => { // One narrated word every 55 turns; otherwise a varied tool-only turn. const event = i > 0 && i % 55 === 0 ? textAndToolTurn(id, "working") : toolOnlyTurn(id); await director.decide(event, mockState, capabilities); - const result = actionsArray(await director.decide(toolDoneEvent(id), mockState, capabilities)); + const result = actionsArray( + await director.decide(toolDoneEvent(id), mockState, capabilities), + ); if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) { paused = true; - } else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) { + } else if ( + result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")) + ) { nudged = true; } } @@ -534,10 +564,12 @@ describe("ChatDirector tool-only loop protection", () => { // After the reset, a further 99 turns (below the threshold again) must // not nudge or pause. const afterReset = await runToolOnlyStreak(director, capabilities, 99); - expect(afterReset.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); - expect(afterReset.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))).toBe( + expect(afterReset.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( false, ); + expect( + afterReset.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")), + ).toBe(false); }); // Round 5: round 4 reset turnsSinceUserMessage on any message.received, @@ -554,9 +586,9 @@ describe("ChatDirector tool-only loop protection", () => { ] as const) { test(`a synthetic compaction continuation from ${label} does not reset the backstop`, async () => { const director = createChatDirector("system", [], { - onTasksChange: () => {}, - provider: providerlessPolicy, - }); + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); // Reach the backstop nudge, then deliver the real synthetic message @@ -569,9 +601,9 @@ describe("ChatDirector tool-only loop protection", () => { // still lands exactly 100 turns after the nudge, same as if the // synthetic message had never arrived. const stillNoPause = await runToolOnlyStreak(director, capabilities, 99); - expect(stillNoPause.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( - false, - ); + expect( + stillNoPause.some((a) => a.type === "reply" && a.content.includes("Auto-paused")), + ).toBe(false); const actions = actionsArray(await runToolOnlyStreak(director, capabilities, 1)); const reply = actions.find((a) => a.type === "reply" && a.content.includes("Auto-paused")); @@ -589,15 +621,21 @@ describe("ChatDirector tool-only loop protection", () => { await runToolOnlyStreak(director, capabilities, 100); // A synthetic message arrives first (e.g. a compaction continuation // mid-loop) — must not reset anything. - await director.decide(systemMessageReceived(tuiCompactionContinuation()), mockState, capabilities); + await director.decide( + systemMessageReceived(tuiCompactionContinuation()), + mockState, + capabilities, + ); // Then the operator actually sends something. await director.decide(messageReceived("status check"), mockState, capabilities); const afterReset = await runToolOnlyStreak(director, capabilities, 99); - expect(afterReset.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); - expect(afterReset.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))).toBe( + expect(afterReset.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( false, ); + expect( + afterReset.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")), + ).toBe(false); }); // Round 4: narration clears period-detection history (evidence the model @@ -616,18 +654,27 @@ describe("ChatDirector tool-only loop protection", () => { // more repeats) while still counting toward the backstop. await runToolOnlyStreak(director, capabilities, 4, repeatedToolOnlyTurn); const narrated = actionsArray( - await director.decide(textAndToolTurn("narrate-1", "still working on it"), mockState, capabilities), + await director.decide( + textAndToolTurn("narrate-1", "still working on it"), + mockState, + capabilities, + ), ); await director.decide(toolDoneEvent("narrate-1"), mockState, capabilities); - expect(narrated.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(narrated.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); // Resume the repeated-fingerprint run — since history was cleared, it // takes a fresh IDENTICAL_REPEAT_MIN-length run to thrash-pause again, // and it must not reference the backstop when it does. const afterNarration = await runToolOnlyStreak(director, capabilities, 5, repeatedToolOnlyTurn); - const thrashReply = afterNarration.find((a) => a.type === "reply" && a.content.includes("Auto-paused")); + const thrashReply = afterNarration.find( + (a) => a.type === "reply" && a.content.includes("Auto-paused"), + ); expect(thrashReply).toBeDefined(); - if (thrashReply === undefined || thrashReply.type !== "reply") throw new Error("expected reply action"); + if (thrashReply === undefined || thrashReply.type !== "reply") + throw new Error("expected reply action"); expect(thrashReply.content).not.toContain("turns without a message from the operator"); // Now prove narration did NOT reset turnsSinceUserMessage: drain the @@ -659,7 +706,10 @@ describe("ChatDirector tool-only loop protection", () => { }); test("resumes after the operator sends a new message", async () => { - const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy }); + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); await runToolOnlyStreak(director, capabilities, 5, repeatedToolOnlyTurn); @@ -670,7 +720,10 @@ describe("ChatDirector tool-only loop protection", () => { }); test("a dismissed ask_operator counts toward the streak like any other tool-only turn", async () => { - const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy }); + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); // 24 ordinary (varied) tool-only turns, then a turn whose only tool call @@ -689,7 +742,14 @@ describe("ChatDirector tool-only loop protection", () => { role: "assistant", model: "test", timestamp: 0, - content: [{ type: "tool_call", id: askId, name: "ask_operator", arguments: { question: "?", options: ["a"] } }], + content: [ + { + type: "tool_call", + id: askId, + name: "ask_operator", + arguments: { question: "?", options: ["a"] }, + }, + ], }, usage: { input: 0, output: 0 }, source: "test", @@ -720,7 +780,10 @@ describe("ChatDirector tool-only loop protection", () => { }); test("a busy-but-progressing session (text interleaved with tools) never trips", async () => { - const director = createChatDirector("system", [], { onTasksChange: () => {}, provider: providerlessPolicy }); + const director = createChatDirector("system", [], { + onTasksChange: () => {}, + provider: providerlessPolicy, + }); const capabilities = makeCapabilities(); let lastActions: ReactorAction[] = []; @@ -729,12 +792,14 @@ describe("ChatDirector tool-only loop protection", () => { await director.decide(textAndToolTurn(id, `Working on step ${i}.`), mockState, capabilities); lastActions = actionsArray(await director.decide(toolDoneEvent(id), mockState, capabilities)); } - expect(lastActions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(lastActions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); const infer = lastActions.find((a) => a.type === "infer"); expect(ephemeralText(infer)).toBeUndefined(); }); -// Required by CL-5611: the observed failure — a Grok session hard-paused + // Required by CL-5611: the observed failure — a Grok session hard-paused // at 10 turns of real progress (Linear lookups + code reads). test("grok no longer hard-pauses a 10-turn productive tool-only streak", async () => { const director = createChatDirector("system", [], { @@ -744,7 +809,9 @@ describe("ChatDirector tool-only loop protection", () => { const capabilities = makeCapabilities(); const actions = await runToolOnlyStreak(director, capabilities, 10); - expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe(false); + expect(actions.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))).toBe( + false, + ); expect(actions.some((a) => a.type === "infer")).toBe(true); }); @@ -810,9 +877,7 @@ describe("ChatDirector tool-only loop protection", () => { ); 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 === "reply" && a.content.includes("Auto-paused"))).toBe(false); expect(later.some((a) => a.type === "infer")).toBe(true); }); @@ -840,7 +905,9 @@ describe("ChatDirector tool-only loop protection", () => { for (let i = 0; i < 300 && pausedAt === null; i++) { const id = `task-ok-${i}`; await director.decide(taskTurn(id), mockState, capabilities); - const result = actionsArray(await director.decide(taskDoneEvent(id), mockState, capabilities)); + const result = actionsArray( + await director.decide(taskDoneEvent(id), mockState, capabilities), + ); if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) { pausedAt = i; } else if ( @@ -885,7 +952,9 @@ describe("ChatDirector tool-only loop protection", () => { for (let i = 0; i < 100; i++) { const id = `task-ok-b-${i}`; await director.decide(taskTurn(id), mockState, capabilities); - const result = actionsArray(await director.decide(taskDoneEvent(id), mockState, capabilities)); + const result = actionsArray( + await director.decide(taskDoneEvent(id), mockState, capabilities), + ); if ( result.some((a) => a.type === "reply" && a.content.includes("Auto-paused")) || result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")) @@ -910,14 +979,19 @@ describe("ChatDirector tool-only loop protection", () => { await director.decide(taskTurn(id), mockState, capabilities); const result = actionsArray( await director.decide( - { type: "tool.done", result: { callId: id, isError: false, content: undefined } } as unknown as ReactorInboundEvent, + { + type: "tool.done", + result: { callId: id, isError: false, content: undefined }, + } as unknown as ReactorInboundEvent, mockState, capabilities, ), ); if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) { paused = true; - } else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) { + } else if ( + result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")) + ) { nudged = true; } } @@ -974,7 +1048,9 @@ describe("ChatDirector tool-only loop protection", () => { ); if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) { paused = true; - } else if (result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary"))) { + } else if ( + result.some((a) => a.type === "infer" && ephemeralText(a)?.includes("progress summary")) + ) { nudged = true; } } @@ -1000,7 +1076,8 @@ describe("ChatDirector tool-only loop protection", () => { capabilities, ), ); - if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) paused = true; + if (result.some((a) => a.type === "reply" && a.content.includes("Auto-paused"))) + paused = true; } expect(paused).toBe(true); }); diff --git a/src/agent/director.ts b/src/agent/director.ts index 8df3f3d03..9653ad5a5 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -9,10 +9,7 @@ import type { ToolDefinition, ConversationTurn, } from "@intx/types/runtime"; -import { - type SessionMetadata, - type TaskBoundary, -} from "../session/compactor.js"; +import { type SessionMetadata, type TaskBoundary } from "../session/compactor.js"; import type { WorkflowCoordinator } from "../workflows/coordinator.js"; import { createCompactionGovernor, type CompactionGovernor } from "./compaction.js"; import { onTurnBoundary } from "./reactor-events.js"; @@ -33,10 +30,7 @@ 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 { classifyBriefSalvage, isHardBlockSalvage } from "../subagent/brief-dispatch.js"; import { PRIMARY_SALVAGE_NUDGE } from "./look-tour.js"; const RETRY_POLICY = createCorbitsRetryPolicy(); @@ -51,7 +45,9 @@ const BACKSTOP_NUDGE_TEXT = const logger = getLogger([LOG_NAMESPACE_ROOT, "agent", "director"]); -function isInternalRecoveryAbort(event: Extract): boolean { +function isInternalRecoveryAbort( + event: Extract, +): boolean { return isInternalRecoveryAbortRaw(event.error.raw); } @@ -63,7 +59,10 @@ function directorNudgeTurn(text: string): ConversationTurn { }; } -function withEphemeralNudge(options: ExtendedInferenceOptions, nudge: string): ExtendedInferenceOptions { +function withEphemeralNudge( + options: ExtendedInferenceOptions, + nudge: string, +): ExtendedInferenceOptions { const turn = directorNudgeTurn(nudge); const existing = options.ephemeralTurns; if (existing === undefined || existing.length === 0) { @@ -196,7 +195,10 @@ export const presentDefinition: ToolDefinition = { properties: { type: { type: "string", enum: ["text"] }, text: { type: "string" }, - tone: { type: "string", enum: ["default", "muted", "success", "warning", "danger", "accent"] }, + tone: { + type: "string", + enum: ["default", "muted", "success", "warning", "danger", "accent"], + }, bold: { type: "boolean" }, dim: { type: "boolean" }, }, @@ -297,7 +299,8 @@ export const submitOutputDefinition: ToolDefinition = { }, step: { type: "string", - description: "Workflow step ID to advance. When present this is a " + + description: + "Workflow step ID to advance. When present this is a " + "step-advancement signal, not a terminal task submission.", }, }, @@ -316,7 +319,6 @@ function operatorDeclinedHasMessage(result: { content: unknown }): boolean { return typeof result.content === "string" && / — .+/.test(result.content); } - const CODE_FILE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|c|cc|cpp|h|hpp|rb|php|cs|swift|kt|kts|scala)$/i; @@ -335,14 +337,18 @@ function isCodeFile(path: string): boolean { // parse, so callers can distinguish "no valid manage_tasks call here" from // "a valid call that happened to be a no-op" — the latter still counts as an // update for onTasksChange purposes. -function applyManageTasksToolCall(tasks: Task[], block: { name: string; arguments: unknown }): Task[] | null { +function applyManageTasksToolCall( + tasks: Task[], + block: { name: string; arguments: unknown }, +): Task[] | null { if (block.name !== "manage_tasks") return null; const taskArgs = parseManageTasksArgs(block.arguments); return taskArgs !== null ? applyManageTasks(tasks, taskArgs) : null; } -export type ChatDirectorOptions = { - taskClassifier?: ((message: string, metadata: SessionMetadata) => Promise) | undefined; +export interface ChatDirectorOptions { + taskClassifier?: + ((message: string, metadata: SessionMetadata) => Promise) | undefined; onActivateTools?: ((names: string[]) => void) | undefined; inactivityTimeoutMs?: number | undefined; totalTimeoutMs?: number | undefined; @@ -350,7 +356,7 @@ export type ChatDirectorOptions = { onTasksChange: (tasks: Task[]) => void; requestContinuation?: (() => void) | undefined; provider?: { providerName: string; model?: string } | undefined; -}; +} // The constructor takes the resolved ModelFamilyPolicy rather than the raw // `provider` input the factory function accepts and resolves on its behalf. @@ -364,8 +370,7 @@ class ChatDirectorImpl extends DefaultDirector { private readonly askOperatorCalls = new Set(); private readonly onActivateTools: ((names: string[]) => void) | undefined; private readonly taskClassifier: - | ((message: string, metadata: SessionMetadata) => Promise) - | undefined; + ((message: string, metadata: SessionMetadata) => Promise) | undefined; private readonly _systemPrompt: string; private _toolDefinitions: ToolDefinition[]; private inactivityTimeoutMs: number | undefined; @@ -450,7 +455,11 @@ class ChatDirectorImpl extends DefaultDirector { private pendingSalvageNudge: string | null = null; private pendingTaskCallIds = new Set(); - constructor(systemPrompt: string, toolDefinitions: ToolDefinition[], options: ChatDirectorImplOptions) { + constructor( + systemPrompt: string, + toolDefinitions: ToolDefinition[], + options: ChatDirectorImplOptions, + ) { super(systemPrompt, toolDefinitions, {}); this._systemPrompt = systemPrompt; this._toolDefinitions = toolDefinitions; @@ -460,8 +469,13 @@ class ChatDirectorImpl extends DefaultDirector { this.onActivateTools = options.onActivateTools; this.workflowCoordinator = options.workflowCoordinator; this.onTasksChange = options.onTasksChange; - this.compaction = createCompactionGovernor(options.requestContinuation, systemPrompt, toolDefinitions); - this.modelFamilyPolicy = options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" }); + this.compaction = createCompactionGovernor( + options.requestContinuation, + systemPrompt, + toolDefinitions, + ); + this.modelFamilyPolicy = + options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" }); } setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void { @@ -493,16 +507,14 @@ class ChatDirectorImpl extends DefaultDirector { } private openTaskIds(): string[] { - return this.tasks - .filter((t) => t.status === "todo" || t.status === "doing") - .map((t) => t.id); + return this.tasks.filter((t) => t.status === "todo" || t.status === "doing").map((t) => t.id); } private logTerminationWithOpenTasks(path: string): void { - logger.error( - "Director reached a terminal decision on {path} with open tasks: {openTasks}", - { path, openTasks: this.openTaskIds() }, - ); + logger.error("Director reached a terminal decision on {path} with open tasks: {openTasks}", { + path, + openTasks: this.openTaskIds(), + }); } /** @@ -546,10 +558,7 @@ class ChatDirectorImpl extends DefaultDirector { : "repeated tool calls in a cycle"; return `Auto-paused: the model ${detail} without making progress. Send a message to resume.`; })(); - return [ - capabilities.checkpoint("tool-only-loop-paused"), - capabilities.reply(pauseMessage), - ]; + return [capabilities.checkpoint("tool-only-loop-paused"), capabilities.reply(pauseMessage)]; } if (this.pendingBackstopNudge) { @@ -602,12 +611,17 @@ class ChatDirectorImpl extends DefaultDirector { ? this._toolDefinitions : [...this._toolDefinitions, advanceWorkflowDefinition]; - const directive = active ? this.workflowCoordinator?.directive() ?? null : null; + const directive = active ? (this.workflowCoordinator?.directive() ?? null) : null; const rewrite = (action: ReactorAction): ReactorAction => { if (action.type !== "infer") return action; - const options = { ...action.options, tools, retryPolicy: action.options?.retryPolicy ?? RETRY_POLICY }; - if (this.inactivityTimeoutMs !== undefined) options.inactivityTimeoutMs = this.inactivityTimeoutMs; + const options = { + ...action.options, + tools, + retryPolicy: action.options?.retryPolicy ?? RETRY_POLICY, + }; + if (this.inactivityTimeoutMs !== undefined) + options.inactivityTimeoutMs = this.inactivityTimeoutMs; if (this.totalTimeoutMs !== undefined) options.totalTimeoutMs = this.totalTimeoutMs; if (directive !== null) { return { @@ -645,10 +659,12 @@ class ChatDirectorImpl extends DefaultDirector { const recovery = this.compaction.interceptOverflow(event, capabilities); if (recovery !== null) return recovery; - if (event.type === "inference.error" && + if ( + event.type === "inference.error" && (event.error.category === "timeout" || event.error.category === "retryable" || - (event.error.category === "aborted" && isInternalRecoveryAbort(event)))) { + (event.error.category === "aborted" && isInternalRecoveryAbort(event))) + ) { if (this.inferenceRecoveries < MAX_INFERENCE_RECOVERIES) { this.inferenceRecoveries++; return [capabilities.checkpoint("inference-recovery"), capabilities.infer()]; @@ -712,10 +728,11 @@ class ChatDirectorImpl extends DefaultDirector { if (boundary.kind === "new_task") { this.currentTaskLabel = undefined; - const envelope = this.lastTaskSummary !== undefined - ? `\n--- Compacted prior context ---\n${this.lastTaskSummary}\n---` + - `\n\nNew task starting now. Prior context summarized above.\n` - : "\n--- Context cleared for new task ---\n"; + const envelope = + this.lastTaskSummary !== undefined + ? `\n--- Compacted prior context ---\n${this.lastTaskSummary}\n---` + + `\n\nNew task starting now. Prior context summarized above.\n` + : "\n--- Context cleared for new task ---\n"; return [ capabilities.checkpoint(`new-task: ${boundary.reason}`), @@ -767,7 +784,11 @@ 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 }>; + const turnContent = event.turn.content as readonly { + 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); @@ -811,7 +832,8 @@ class ChatDirectorImpl extends DefaultDirector { this.toolOnlyPauseReason = "thrash"; } else if ( this.backstopNudgeFiredAtTurn !== null && - this.turnsSinceUserMessage - this.backstopNudgeFiredAtTurn >= TURNS_SINCE_USER_MESSAGE_BACKSTOP + this.turnsSinceUserMessage - this.backstopNudgeFiredAtTurn >= + TURNS_SINCE_USER_MESSAGE_BACKSTOP ) { this.pausedForToolOnly = true; this.toolOnlyPauseReason = "backstop"; @@ -860,8 +882,7 @@ 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 body = typeof event.result.content === "string" ? event.result.content : ""; const salvage = classifyBriefSalvage(body); if (salvage !== null && isHardBlockSalvage(salvage) && !this.salvageNudgeFired) { this.salvageNudgeFired = true; @@ -905,7 +926,11 @@ class ChatDirectorImpl extends DefaultDirector { 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); - const advanced = this.workflowCoordinator?.handleToolDone(call?.name, call?.args, event.result.isError === true); + const advanced = this.workflowCoordinator?.handleToolDone( + call?.name, + call?.args, + event.result.isError === true, + ); if (advanced) this.workflowIdleTurns = 0; } @@ -988,7 +1013,8 @@ class ChatDirectorImpl extends DefaultDirector { ), ]; } - const nudge = "\n\nYou have not yet called advance_workflow. " + + const nudge = + "\n\nYou have not yet called advance_workflow. " + "If this step is complete, call advance_workflow now. " + "Otherwise continue working with tools."; const passThrough = actions.filter( @@ -1002,8 +1028,7 @@ class ChatDirectorImpl extends DefaultDirector { // A workflow gate step is a legitimate pause for operator approval, so // yielding there with open tasks is not an invariant breach — leave it to // the workflow runtime and do not nudge. - const atWorkflowGate = - coordinator?.isActive() === true && coordinator.currentStepIsGate(); + const atWorkflowGate = coordinator?.isActive() === true && coordinator.currentStepIsGate(); if (!atWorkflowGate && hasActiveTasks(this.tasks)) { const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply"); if (hasTerminal) { @@ -1015,11 +1040,9 @@ class ChatDirectorImpl extends DefaultDirector { ); // Inside a workflow the terminal action is advance_workflow, so point // the nudge at it rather than the general manage_tasks guidance. - const nudge = coordinator?.isActive() === true ? WORKFLOW_OPEN_TASK_NUDGE : IDLE_OPEN_TASK_NUDGE; - return [ - ...passThrough, - inferWithNudge(capabilities, nudge), - ]; + const nudge = + coordinator?.isActive() === true ? WORKFLOW_OPEN_TASK_NUDGE : IDLE_OPEN_TASK_NUDGE; + return [...passThrough, inferWithNudge(capabilities, nudge)]; } this.logTerminationWithOpenTasks("idle-stall"); } diff --git a/src/agent/directors/critique/package.test.ts b/src/agent/directors/critique/package.test.ts index 9e66f7083..2801b174c 100644 --- a/src/agent/directors/critique/package.test.ts +++ b/src/agent/directors/critique/package.test.ts @@ -37,23 +37,17 @@ describe("critiquePackage", () => { 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(/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(/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, - ); + expect(critiquePackage.systemPrompt).toMatch(/Rank these as blocking, not style nits/i); }); test("spawn.maySpawn is false", () => { diff --git a/src/agent/directors/draper/package.ts b/src/agent/directors/draper/package.ts index dbae3ce06..bc866c4db 100644 --- a/src/agent/directors/draper/package.ts +++ b/src/agent/directors/draper/package.ts @@ -63,4 +63,4 @@ Missing references, out-of-lane asks, ambiguous scope. 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/package.ts b/src/agent/directors/emil/package.ts index 76a46e56b..73f886548 100644 --- a/src/agent/directors/emil/package.ts +++ b/src/agent/directors/emil/package.ts @@ -77,4 +77,4 @@ Missing context, out-of-lane asks, unreadable artifacts. 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/greybeard/package.ts b/src/agent/directors/greybeard/package.ts index 8830cd12d..43a6450e4 100644 --- a/src/agent/directors/greybeard/package.ts +++ b/src/agent/directors/greybeard/package.ts @@ -8,10 +8,7 @@ import { ORCHESTRATOR_TOOLS } from "../tool-sets.js"; export const greybeardPackage: DirectorPackage = { id: "greybeard", primaryIntent: "Architecture review; limited spawn", - outOfLane: [ - "shipping product code", - "pedantic style-only nitpicking", - ], + outOfLane: ["shipping product code", "pedantic style-only nitpicking"], description: "Architecture review leaf", optionalSkills: ["style", "philosophy"], tools: { allow: ORCHESTRATOR_TOOLS }, diff --git a/src/agent/directors/plan/package.ts b/src/agent/directors/plan/package.ts index 155b0af38..d1be1dffc 100644 --- a/src/agent/directors/plan/package.ts +++ b/src/agent/directors/plan/package.ts @@ -4,11 +4,7 @@ import { REVIEW_TOOLS } from "../tool-sets.js"; export const planPackage: DirectorPackage = { id: "plan", primaryIntent: "Author eng change plans; do not implement", - outOfLane: [ - "shipping code", - "architecture gate sign-off as Greybeard", - "running the fleet", - ], + outOfLane: ["shipping code", "architecture gate sign-off as Greybeard", "running the fleet"], description: "Planning leaf — eng plans only; Greybeard reviews", optionalSkills: ["style", "philosophy", "interview"], tools: { allow: REVIEW_TOOLS }, diff --git a/src/agent/directors/registry.test.ts b/src/agent/directors/registry.test.ts index 826853434..a24907888 100644 --- a/src/agent/directors/registry.test.ts +++ b/src/agent/directors/registry.test.ts @@ -94,7 +94,7 @@ describe("director registry", () => { test("packageToProfile maps envelope and spawn", () => { const explore = packageToProfile(DIRECTOR_REGISTRY.explore); expect(explore.id).toBe("explore"); - expect(explore.systemPromptRole).toContain('agent id `explore`'); + expect(explore.systemPromptRole).toContain("agent id `explore`"); expect(explore.systemPromptRole).toContain(DIRECTOR_REGISTRY.explore.systemPrompt); expect(explore.description).toContain("agent id: explore"); expect(explore.capabilities?.mode).toBe("allow"); diff --git a/src/agent/directors/registry.ts b/src/agent/directors/registry.ts index fa2289e0c..13f6b62f3 100644 --- a/src/agent/directors/registry.ts +++ b/src/agent/directors/registry.ts @@ -26,12 +26,13 @@ import { } from "./types.js"; /** Intent → default director when `task(agent=…)` is omitted. No general director. */ -export const INTENT_DEFAULT_DIRECTOR: Readonly, DirectorId>> = { - implement: "build", - explore: "explore", - plan: "plan", - review: "critique", -}; +export const INTENT_DEFAULT_DIRECTOR: Readonly, DirectorId>> = + { + implement: "build", + explore: "explore", + plan: "plan", + review: "critique", + }; /** * Closed v1 registry — full packages (prompts, envelopes, spawn, nudge, modelRole). diff --git a/src/agent/directors/skywalker/package.test.ts b/src/agent/directors/skywalker/package.test.ts index 843e0e651..bd0c9a7c6 100644 --- a/src/agent/directors/skywalker/package.test.ts +++ b/src/agent/directors/skywalker/package.test.ts @@ -77,7 +77,9 @@ describe("skywalkerPackage", () => { expect(skywalkerPackage.primaryIntent).toBe( "Orchestrate; DIY tiny/bounded product edits; spawn for substantial work", ); - expect(skywalkerPackage.outOfLane).toContain("substantial multi-file product work without spawning"); + expect(skywalkerPackage.outOfLane).toContain( + "substantial multi-file product work without spawning", + ); expect(skywalkerPackage.outOfLane).toContain("catch-all worker"); expect(skywalkerPackage.outOfLane).toContain( "searching the repo yourself after a worker stops without finishing", diff --git a/src/agent/directors/tool-sets.ts b/src/agent/directors/tool-sets.ts index 42cc41f4a..abd09a1e1 100644 --- a/src/agent/directors/tool-sets.ts +++ b/src/agent/directors/tool-sets.ts @@ -17,12 +17,7 @@ export const READ_TOOLS = [ ] as const; /** Build: read + full file mutation. */ -export const BUILD_TOOLS = [ - ...READ_TOOLS, - "write_file", - "edit_file", - "delete_file", -] as const; +export const BUILD_TOOLS = [...READ_TOOLS, "write_file", "edit_file", "delete_file"] as const; /** * Docs leaves: read/search/lsp/web + file writes — no run_shell, no delete_file. @@ -46,11 +41,7 @@ export const REVIEW_TOOLS = [...READ_TOOLS] as const; export const INTERN_TOOLS = ["run_shell", "read_file", "list_dir"] as const; /** Nested orchestrator surface (greybeard / package filter): dispatch only. */ -export const ORCHESTRATOR_TOOLS = [ - ...READ_TOOLS, - "search_agents", - "task", -] as const; +export const ORCHESTRATOR_TOOLS = [...READ_TOOLS, "search_agents", "task"] as const; /** Skywalker primary: orchestrator surface plus product writes for DIY tiny work. */ export const SKYWALKER_TOOLS = [ diff --git a/src/agent/directors/types.ts b/src/agent/directors/types.ts index 78f7cb5fe..259ea3054 100644 --- a/src/agent/directors/types.ts +++ b/src/agent/directors/types.ts @@ -25,38 +25,39 @@ 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 ModelRole = + "orchestrator" | "implement" | "explore" | "review" | "plan" | "docs" | "test"; -export type ToolEnvelope = { +export interface ToolEnvelope { /** Tools mounted when present — prefer small allowlists over deny-everything. */ readonly allow?: readonly string[]; /** Tools denied even if present in the session registry. Prefer allow when possible. */ readonly deny?: readonly string[]; -}; +} -export type SpawnRights = { +export interface 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 = { +export interface NudgePolicy { readonly maxTurns?: number; /** Stall silence budget in ms before a parent-facing stall notice. */ readonly stallMs?: number; -}; +} -export type ReportContract = { +export interface ReportContract { /** Required top-level sections in the worker report. */ readonly requiredSections: readonly string[]; -}; +} /** * One shipped director: hard primary intent + package fields. * Packages land in later levels; registry holds the closed set. */ -export type DirectorPackage = { +export interface DirectorPackage { readonly id: DirectorId; /** Hard primary intent lane — one job. */ readonly primaryIntent: string; @@ -81,12 +82,12 @@ export type DirectorPackage = { readonly nudge?: NudgePolicy; readonly report: ReportContract; readonly modelRole: ModelRole; -}; +} -export type ResolveDirectorInput = { +export interface ResolveDirectorInput { readonly agentId?: string; readonly intent?: TaskIntent; -}; +} export type ResolveDirectorResult = | { readonly ok: true; readonly package: DirectorPackage } diff --git a/src/agent/environment.ts b/src/agent/environment.ts index 4b40d6b43..63fec202a 100644 --- a/src/agent/environment.ts +++ b/src/agent/environment.ts @@ -5,7 +5,7 @@ import { promisify } from "node:util"; const run = promisify(execFile); -export type EnvironmentInfo = { +export interface EnvironmentInfo { cwd: string; platform: string; /** CPU architecture (e.g. arm64, x64). */ @@ -18,7 +18,7 @@ export type EnvironmentInfo = { gitDirtyCount?: number; gitStatusSummary?: string; topLevel?: string; -}; +} const GIT_STATUS_LINES = 12; const TOP_LEVEL_ENTRIES = 40; @@ -83,9 +83,7 @@ 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}`; + typeof Bun !== "undefined" ? `Bun ${Bun.version}` : `Node ${process.versions.node}`; return { cwd, platform: `${osType()} ${release()}`, diff --git a/src/agent/lazy-blob-reader.test.ts b/src/agent/lazy-blob-reader.test.ts index 92a100a6c..4515251c6 100644 --- a/src/agent/lazy-blob-reader.test.ts +++ b/src/agent/lazy-blob-reader.test.ts @@ -61,10 +61,10 @@ describe("createCompositeBlobReader", () => { }); test("falls back to the parent store for missing child keys (sub-agent re-read)", async () => { - let child: ReturnType | undefined; + const child: { current?: ReturnType } = {}; const parent = readerWith({ parentSpill: "mcp-skill-body-tail" }); const composite = createCompositeBlobReader( - () => child, + () => child.current, () => parent, ); @@ -73,7 +73,7 @@ describe("createCompositeBlobReader", () => { "mcp-skill-body-tail", ); - child = readerWith({ ownSpill: "child-local" }); + child.current = readerWith({ ownSpill: "child-local" }); expect(dec.decode(await composite.read("tool-output:///parentSpill"))).toBe( "mcp-skill-body-tail", ); diff --git a/src/agent/lazy-blob-reader.ts b/src/agent/lazy-blob-reader.ts index 63324ce99..d5d4571ed 100644 --- a/src/agent/lazy-blob-reader.ts +++ b/src/agent/lazy-blob-reader.ts @@ -20,10 +20,7 @@ export function createLazyBlobReader(get: () => BlobReader | undefined): BlobRea export function isBlobNotFoundError(err: unknown): boolean { if (!(err instanceof Error)) return false; const msg = err.message; - return ( - msg.includes("Blob not found") || - msg === "blob reader is not configured" - ); + return msg.includes("Blob not found") || msg === "blob reader is not configured"; } /** diff --git a/src/agent/live-tool-dispatch.ts b/src/agent/live-tool-dispatch.ts index eb5902477..24716d68b 100644 --- a/src/agent/live-tool-dispatch.ts +++ b/src/agent/live-tool-dispatch.ts @@ -1,9 +1,4 @@ -import { - createAgent, - type Agent, - type AgentDefinition, - type BaseEnv, -} from "@intx/agent"; +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 diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index c7d4f4702..94ff8d3ca 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -3,7 +3,10 @@ import { resolveModelFamilyPolicy } from "./model-family-policy.js"; describe("resolveModelFamilyPolicy", () => { test("defaults are permissive for an unrecognized provider", () => { - const policy = resolveModelFamilyPolicy({ providerName: "anthropic", model: "claude-sonnet-4" }); + const policy = resolveModelFamilyPolicy({ + providerName: "anthropic", + model: "claude-sonnet-4", + }); expect(policy.family).toBe("default"); expect(policy.applyGrokFinishBias).toBe(false); expect(policy.toolOnlyTurnNudgeAt).toBeGreaterThan(20); @@ -19,7 +22,10 @@ describe("resolveModelFamilyPolicy", () => { test("grok finish-bias applies to leaves but not orchestrators", () => { const leaf = resolveModelFamilyPolicy({ providerName: "xai/default", orchestrator: false }); - const orchestrator = resolveModelFamilyPolicy({ providerName: "xai/default", orchestrator: true }); + const orchestrator = resolveModelFamilyPolicy({ + providerName: "xai/default", + orchestrator: true, + }); expect(leaf.applyGrokFinishBias).toBe(true); expect(orchestrator.applyGrokFinishBias).toBe(false); }); diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 83d295d65..b3367bc39 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -6,7 +6,7 @@ import { detectModelFamily, type ModelFamily } from "../subagent/provider-family * the provider/model — directors stay generic and branch on data, never on * per-family subclasses. */ -export type ModelFamilyPolicy = { +export interface ModelFamilyPolicy { family: ModelFamily; /** * Consecutive tool-only assistant turns (tool calls, no text) before the @@ -24,7 +24,7 @@ export type ModelFamilyPolicy = { subAgentStallTimeoutMs: number; /** Grok's finish-bias residual (withhold from orchestrators; see provider-family.ts). */ applyGrokFinishBias: boolean; -}; +} const DEFAULT_WRAP_UP_NUDGE_TEXT = "You have made several consecutive tool calls without any explanation. " + @@ -68,7 +68,6 @@ const GROK_POLICY: Omit = { applyGrokFinishBias: true, }; - // Kimi (Moonshot) detection ships now so callers can branch on family, but // thresholds are provisional: we have no eval characterization yet for how // Kimi behaves under tool-only stretches or background-run stalls. Ship the diff --git a/src/agent/posix-tool-plugins.test.ts b/src/agent/posix-tool-plugins.test.ts index 59b89991f..65682159b 100644 --- a/src/agent/posix-tool-plugins.test.ts +++ b/src/agent/posix-tool-plugins.test.ts @@ -219,7 +219,7 @@ describe("buildCorePosixToolPlugins", () => { const encoder = new TextEncoder(); // Mirrors runSubAgent wiring: child store bound after agent create, parent // always available so brief-handed tool-output:// URIs resolve (CL-4323). - let childReader: ReturnType | undefined; + const childHolder: { current?: ReturnType } = {}; const parentReader = createBlobReader({ async readBlob(key: string) { if (key === "parent-mcp-skill") return encoder.encode("parent-skill-body-tail"); @@ -227,7 +227,7 @@ describe("buildCorePosixToolPlugins", () => { }, }); const blobReader = createCompositeBlobReader( - () => childReader, + () => childHolder.current, () => parentReader, ); const gate = createPermissionGate({ @@ -258,7 +258,7 @@ describe("buildCorePosixToolPlugins", () => { expect(fromParent.isError).toBeFalsy(); expect(String(fromParent.content)).toContain("parent-skill-body-tail"); - childReader = createBlobReader({ + childHolder.current = createBlobReader({ async readBlob(key: string) { if (key === "child-local") return encoder.encode("child-own-spill"); throw new Error(`Blob not found for key: ${JSON.stringify(key)}`); @@ -443,10 +443,12 @@ describe("buildCorePosixToolPlugins", () => { // redundant with it. const secretShapedContent = `AKIAABCDEFGHIJKLMNOP\n${"x".repeat(90_000)}`; const shortCircuitingPlugin: ToolPlugin = { - middleware: () => async (call: ToolCall): Promise => ({ - callId: call.id, - content: secretShapedContent, - }), + middleware: + () => + async (call: ToolCall): Promise => ({ + callId: call.id, + content: secretShapedContent, + }), }; const gate = createPermissionGate({ @@ -461,8 +463,13 @@ describe("buildCorePosixToolPlugins", () => { plugins[ripgrepIndex] = shortCircuitingPlugin; const composed = composeMiddleware( - plugins.map((plugin) => plugin.middleware).filter((mw): mw is NonNullable => mw !== undefined), - async (call) => ({ callId: call.id, content: "unreachable: short-circuiting plugin never delegates" }), + plugins + .map((plugin) => plugin.middleware) + .filter((mw): mw is NonNullable => mw !== undefined), + async (call) => ({ + callId: call.id, + content: "unreachable: short-circuiting plugin never delegates", + }), ); const result = await composed( @@ -492,10 +499,12 @@ describe("buildCorePosixToolPlugins", () => { const straddlingSecret = "AKIAABCDEFGHIJKLMNOP"; // 20 chars, cap lands mid-key const secretShapedContent = `${padding}${straddlingSecret}`; const shortCircuitingPlugin: ToolPlugin = { - middleware: () => async (call: ToolCall): Promise => ({ - callId: call.id, - content: secretShapedContent, - }), + middleware: + () => + async (call: ToolCall): Promise => ({ + callId: call.id, + content: secretShapedContent, + }), }; const gate = createPermissionGate({ @@ -510,8 +519,13 @@ describe("buildCorePosixToolPlugins", () => { plugins[ripgrepIndex] = shortCircuitingPlugin; const composed = composeMiddleware( - plugins.map((plugin) => plugin.middleware).filter((mw): mw is NonNullable => mw !== undefined), - async (call) => ({ callId: call.id, content: "unreachable: short-circuiting plugin never delegates" }), + plugins + .map((plugin) => plugin.middleware) + .filter((mw): mw is NonNullable => mw !== undefined), + async (call) => ({ + callId: call.id, + content: "unreachable: short-circuiting plugin never delegates", + }), ); const result = await composed( @@ -527,4 +541,4 @@ describe("buildCorePosixToolPlugins", () => { expect(content).not.toContain(straddlingSecret); expect(content).not.toMatch(/AKIA[0-9A-Z]*/); }); -}); \ No newline at end of file +}); diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index cf67887b2..c5e2f8c46 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -13,10 +13,7 @@ import { toolOutputUriPlugin } from "../plugins/tool-output-uri-plugin.js"; import { lspHintPlugin } from "../plugins/lsp-hint-plugin.js"; import { resultTruncationPlugin } from "../plugins/result-truncation-plugin.js"; import { toolResultSecretScrubPlugin } from "../plugins/tool-result-secret-scrub-plugin.js"; -import { - shellGuardPlugin, - type ShellTimeoutConfig, -} from "../plugins/shell-guard-plugin.js"; +import { shellGuardPlugin, type ShellTimeoutConfig } from "../plugins/shell-guard-plugin.js"; import { readFileGuardPlugin, type ReadFileGuardPluginOptions, @@ -24,7 +21,7 @@ import { import type { PermissionGate } from "../permission/gate.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; -export type CorePosixToolPluginsArgs = { +export interface CorePosixToolPluginsArgs { cwd: string; permissionGate: PermissionGate; shellTimeout?: ShellTimeoutConfig; @@ -32,7 +29,7 @@ export type CorePosixToolPluginsArgs = { readFileGuard?: ReadFileGuardPluginOptions; // Per-project settings.env, merged into the run_shell spawn environment. shellEnv?: Record; -}; +} // Middleware order matches docs/ARCHITECTURE.md: path escape through truncation, // with shell-guard after permission so blocked commands never spawn. @@ -94,4 +91,4 @@ export function buildCorePosixToolPlugins(args: CorePosixToolPluginsArgs): ToolP createLSPPlugin({ cwd, minSeverity: 1 }), ...extraToolPlugins, ]; -} \ No newline at end of file +} diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index d99fb0e90..ab6063fbf 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -23,29 +23,29 @@ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; export type CapabilityMode = "exclude" | "allow"; -export type CapabilityFilter = { +export interface CapabilityFilter { mode: CapabilityMode; tools: string[]; -}; +} // A single provider/model/effort combo an agent can run on, so an agent can // pin "Sonnet + medium" or "Grok + high". -export type InferenceLeg = { +export interface InferenceLeg { provider: string; model: string; reasoningEffort?: ReasoningEffort; -}; +} // Per-agent model selection spec, evaluated at dispatch time against the user's // configured providers. `mode: "pin"` requires one of the legs to be available // (else error / fallback per settings.agentModelFallback); `mode: "prefer"` // (default) walks the chain and falls back if none are viable. -export type InferenceSpec = { +export interface InferenceSpec { mode?: "pin" | "prefer"; order: InferenceLeg[]; -}; +} -export type AgentProfile = { +export interface AgentProfile { // Unique identifier, used in workflow steps as `agent: "greybeard"`. id: string; description?: string; @@ -79,9 +79,9 @@ export type AgentProfile = { // Where the profile came from, for search_agents labeling (e.g. "claude", // "plugin:", "local"). Omitted for built-in defaults. source?: string; -}; +} // The shape an agent-kind plugin contributes: a list of profiles. -export type AgentPlugin = { +export interface AgentPlugin { agents: AgentProfile[]; -}; +} diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 5b940372f..5a57a424d 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -6,7 +6,15 @@ import { type } from "arktype"; import { defaultAgentsPlugin as defaultPlugin } from "./default-agents.js"; import { REASONING_EFFORTS } from "./profile-types.js"; -export type { AgentProfile, AgentPlugin, CapabilityFilter, CapabilityMode, InferenceLeg, InferenceSpec, ReasoningEffort } from "./profile-types.js"; +export type { + AgentProfile, + AgentPlugin, + CapabilityFilter, + CapabilityMode, + InferenceLeg, + InferenceSpec, + ReasoningEffort, +} from "./profile-types.js"; import type { AgentProfile } from "./profile-types.js"; // Exported so agent-kind plugins can validate contributed profiles. @@ -22,9 +30,7 @@ const CapabilityFilterSchema = type({ // through `unknown`. The schema is exercised by tests/unit/data-only-agent // and the runtime ReasoningEffort re-export, so drift is caught. const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join(" | "); -const ReasoningEffortSchema = type( - reasoningEffortLiteral as unknown as "'none'", -); +const ReasoningEffortSchema = type(reasoningEffortLiteral as unknown as "'none'"); const InferenceLegSchema = type({ provider: "string>0", diff --git a/src/agent/prompts.test.ts b/src/agent/prompts.test.ts index 73207a3d1..2b67dd616 100644 --- a/src/agent/prompts.test.ts +++ b/src/agent/prompts.test.ts @@ -10,10 +10,7 @@ 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 catalog tools // (always advertised) and also registered via createWebFetchTool/createWebSearchTool. -const REGISTERED_TOOL_NAMES = new Set([ - ...CORE_TOOL_NAMES, - ...CATALOG_TOOL_NAMES, -]); +const REGISTERED_TOOL_NAMES = new Set([...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES]); const REFERENCED_TOOL_NAMES = [ "read_file", @@ -62,7 +59,9 @@ describe("buildPromptDisciplineBlock", () => { // Command shape. expect(block).toMatch(/one logical operation per call/i); // Turn semantics. - expect(block).toMatch(/no tool calls.*final answer|reply with no tool calls is the final answer/i); + expect(block).toMatch( + /no tool calls.*final answer|reply with no tool calls is the final answer/i, + ); expect(block).toMatch(/three failures/i); expect(block).toMatch(/repeat a search/i); expect(block).toMatch(/parallel/i); diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 4e2cca869..2e176be38 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -76,7 +76,9 @@ export function buildHarnessFacts( "- 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."]), + : [ + "- Dependency installs, paths outside the workspace, and session-state writes need operator approval.", + ]), "- Attached images are native multimodal input; inspect them directly unless file-level forensics are requested.", ...(dynamicTools ? [ @@ -93,7 +95,9 @@ export function buildHarnessFacts( ].join("\n"); } -export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: SessionMode } = {}): string { +export function buildGuidelines( + opts: { subAgent?: boolean; sessionMode?: SessionMode } = {}, +): string { const subAgent = opts.subAgent ?? false; return [ "Guidelines:", @@ -117,7 +121,9 @@ export function buildGuidelines(opts: { subAgent?: boolean; sessionMode?: Sessio "- 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 ? [] - : ["- tool_search before assuming a plugin or MCP tool exists; use_skill before work covered by a listed skill."]), + : [ + "- tool_search before assuming a plugin or MCP tool exists; use_skill before work covered by a listed skill.", + ]), "", subAgent ? "Proceed vs pause:" : "Ask vs proceed:", ...(subAgent @@ -200,21 +206,23 @@ const TOOL_SUMMARIES: Record = { edit_file: "make a surgical edit (exact old_string match, or start_line/end_line line-range mode; never include read_file's NNNNNN\\t line prefix; substring failures include nearby file text; prefer over sed/awk in the shell)", delete_file: "delete one file with an explicit outcome (never shell rm)", - run_shell: "run a shell command (builds, tests, git; 15s default timeout — pass timeout ms to override; never to read/write/delete files, search trees, or talk to the user)", - search_files: "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find)", + run_shell: + "run a shell command (builds, tests, git; 15s default timeout — pass timeout ms to override; never to read/write/delete files, search trees, or talk to the user)", + search_files: + "find files by name or pattern (bounded; timeout + output caps — safer than open-ended shell find)", grep: "search file contents (bounded; timeout + output caps — safer than open-ended shell grep -r/rg)", list_dir: "list a directory's entries (bounded listing)", lsp: "resolve symbols — goToDefinition, findReferences, hover (prefer before reading huge files)", web_search: "search the web (use instead of curl or wget)", web_fetch: "fetch the content of a URL", - task: - "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; optional maxTurns sets the worker inference budget; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", + task: "spawn a sub-agent for a self-contained job (not a checklist item); pass intent/success_criteria/do_not/report_focus when possible; optional maxTurns sets the worker inference budget; when launching several task calls in one turn, give each a distinct lens so they do not duplicate work", search_agents: "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", submit_output: "signal the task is complete — the only way to finish", ask_operator: "pause and ask the user when blocked or genuinely ambiguous", - present: "dynamically render aligned/structured output using the layout primitives (stack/row/grid/text etc)", + present: + "dynamically render aligned/structured output using the layout primitives (stack/row/grid/text etc)", tool_search: "load more tools by capability when you need them", use_skill: "load a listed skill's full instructions before doing work it covers", }; @@ -250,7 +258,9 @@ export function buildEnvironmentContext(env: EnvironmentInfo): string { } else if ((env.gitDirtyCount ?? 0) === 0) { lines.push(`Git: on ${env.gitBranch ?? "(detached HEAD)"}, working tree clean`); } else { - lines.push(`Git: on ${env.gitBranch ?? "(detached HEAD)"}, ${env.gitDirtyCount} uncommitted change(s):`); + lines.push( + `Git: on ${env.gitBranch ?? "(detached HEAD)"}, ${env.gitDirtyCount} uncommitted change(s):`, + ); if (env.gitStatusSummary) lines.push(env.gitStatusSummary); } if (env.topLevel) lines.push(`Top level: ${env.topLevel}`); @@ -334,7 +344,7 @@ export function buildSubAgentAppendix(opts: { orchestrator?: boolean } = {}): st // 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 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)." + ? '- 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`, @@ -363,10 +373,10 @@ export function buildSubAgentReportContract(): string { "The substance the parent needs — results, decisions, evidence.", "", "## Blockers", - "Open questions, assumptions, or blockers. Write \"None.\" if clear.", + 'Open questions, assumptions, or blockers. Write "None." if clear.', "", "## Paths", - "Key file paths you read or changed (one per line). Write \"None.\" if none.", + 'Key file paths you read or changed (one per line). Write "None." if none.', "", "- This message is the only thing returned to the parent. Do not ask the parent questions; you cannot receive answers. Make the best-judgment call, act, and note assumptions under Blockers.", ].join("\n"); @@ -422,4 +432,3 @@ export function buildSubAgentSystemPrompt( sections.push(buildSubAgentAppendix(opts)); return joinSections(sections); } - diff --git a/src/agent/renderer.ts b/src/agent/renderer.ts index 3befa448d..4e0e49a38 100644 --- a/src/agent/renderer.ts +++ b/src/agent/renderer.ts @@ -4,9 +4,9 @@ import { createFaremeter, formatCost } from "../cost/faremeter.js"; import type { PricingCache } from "../cost/pricing-fetcher.js"; import { inferenceErrorMessage } from "../inference-error-message.js"; -export type Renderer = { +export interface Renderer { render(event: ReactorEmittedEvent): void; -}; +} const DIM = "\x1b[2m"; const AMBER = "\x1b[38;5;214m"; @@ -14,7 +14,6 @@ const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; const RESET = "\x1b[0m"; -const JOURNAL_TOOLS = new Set(["write_file", "edit_file", "run_shell", "submit_output"]); const SILENT_TOOLS = new Set(["read_file", "list_dir", "search_files", "grep"]); function verb(label: string): string { @@ -27,7 +26,6 @@ function miniDiff(oldStr: string, newStr: string): string { const lines: string[] = []; // Simple: show removed lines then added lines with 1-line context from old - const context = oldLines.length > 0 ? ` ${oldLines[0]}\n` : ""; for (const line of oldLines) { lines.push(` ${RED}-${RESET} ${line}`); } @@ -45,7 +43,13 @@ function miniDiff(oldStr: string, newStr: string): string { function formatOp(name: string): string { if (SILENT_TOOLS.has(name)) { - return name === "read_file" ? "reading" : name === "list_dir" ? "listing" : name === "search_files" ? "searching" : "grepping"; + return name === "read_file" + ? "reading" + : name === "list_dir" + ? "listing" + : name === "search_files" + ? "searching" + : "grepping"; } if (name === "run_shell") return "running"; if (name === "write_file") return "writing"; @@ -54,23 +58,30 @@ function formatOp(name: string): string { return name; } -export function createRenderer(startedAt: number, modelId?: string, pricingCache?: PricingCache | null): Renderer { +export function createRenderer( + startedAt: number, + modelId?: string, + pricingCache?: PricingCache | null, +): Renderer { let currentOp = ""; let currentArg = ""; let turnCount = 0; const pendingArgs = new Map>(); const pendingNames = new Map(); let pendingSubmitSummary: string | undefined; - const faremeter = createFaremeter(modelId === undefined ? {} : { modelId, pricingCache: pricingCache ?? null }); + const faremeter = createFaremeter( + modelId === undefined ? {} : { modelId, pricingCache: pricingCache ?? null }, + ); function elapsedSecs(): number { return Math.floor((Date.now() - startedAt) / 1000); } function writeStatusBar(): void { - const opText = currentOp.length > 0 - ? `${AMBER}${currentOp}${currentArg ? " " + currentArg : ""}${RESET}` - : ""; + const opText = + currentOp.length > 0 + ? `${AMBER}${currentOp}${currentArg ? " " + currentArg : ""}${RESET}` + : ""; const bar = `${DIM}interchange · turn ${turnCount} · ${formatCost(faremeter.getTotalCost())} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`; process.stderr.write(bar); } @@ -78,7 +89,9 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache function writeWriteBlock(path: string, content: string): void { const lineCount = content.split("\n").length; const delta = `${GREEN}+${lineCount}${RESET}`; - process.stdout.write(`${verb("write")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n\n`); + process.stdout.write( + `${verb("write")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n\n`, + ); } function writeEditBlock(path: string, oldStr: string, newStr: string): void { @@ -86,15 +99,21 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache const added = newStr ? newStr.split("\n").length : 0; const delta = `${GREEN}+${added}${RESET} ${RED}-${removed}${RESET}`; const diff = miniDiff(oldStr ?? "", newStr ?? ""); - process.stdout.write(`${verb("edit")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n${diff}\n\n`); + process.stdout.write( + `${verb("edit")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n${diff}\n\n`, + ); } function writeShellBlock(command: string, output: string, isError: boolean): void { const status = isError ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`; if (isError) { - process.stdout.write(`${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n ${output}\n\n`); + process.stdout.write( + `${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n ${output}\n\n`, + ); } else { - process.stdout.write(`${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n\n`); + process.stdout.write( + `${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n\n`, + ); } } @@ -113,9 +132,10 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache case "inference.tool_call.start": { const name = String(e.data?.name ?? ""); currentOp = formatOp(name); - currentArg = name === "read_file" || name === "list_dir" || name === "search_files" || name === "grep" - ? String((e.data as Record).callId ?? "") - : ""; + currentArg = + name === "read_file" || name === "list_dir" || name === "search_files" || name === "grep" + ? String((e.data as Record).callId ?? "") + : ""; break; } @@ -140,7 +160,13 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache } case "inference.usage": { - const usage = (e.data?.usage ?? {}) as { input: number; output: number; cacheRead: number; cacheWrite: number; thinking: number }; + const usage = (e.data?.usage ?? {}) as { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + thinking: number; + }; faremeter.addUsage(usage); break; } @@ -200,13 +226,9 @@ export function createRenderer(startedAt: number, modelId?: string, pricingCache ? inferenceErrorMessage({ category: err.category, message: rawMessage, - ...(typeof err.statusCode === "number" - ? { statusCode: err.statusCode } - : {}), + ...(typeof err.statusCode === "number" ? { statusCode: err.statusCode } : {}), ...(err.raw !== undefined ? { raw: err.raw } : {}), - ...(typeof err.providerId === "string" - ? { providerId: err.providerId } - : {}), + ...(typeof err.providerId === "string" ? { providerId: err.providerId } : {}), }) : rawMessage; writeErrorBlock(message); diff --git a/src/agent/tasks.ts b/src/agent/tasks.ts index 24d2aeb02..16fe2bce0 100644 --- a/src/agent/tasks.ts +++ b/src/agent/tasks.ts @@ -42,8 +42,8 @@ export const manageTasksDefinition: ToolDefinition = { name: "manage_tasks", description: "Maintain your own ordered work list for multi-step jobs. " + - "action=\"create\" replaces the full list (use to seed or replan). " + - "action=\"update\" patches by id: status (todo→doing→done/cancelled), title edits, " + + 'action="create" replaces the full list (use to seed or replan). ' + + 'action="update" patches by id: status (todo→doing→done/cancelled), title edits, ' + "and appends when the id is new and title is set. " + "Keep this list live — add, cancel, and re-title steps as you learn more. " + "Skip for trivial single-step changes.", @@ -54,20 +54,23 @@ export const manageTasksDefinition: ToolDefinition = { type: "string", enum: ["create", "update"], description: - "\"create\" replaces the list; \"update\" patches by id and can append new tasks (id + title).", + '"create" replaces the list; "update" patches by id and can append new tasks (id + title).', }, tasks: { type: "array", - description: "For action=\"create\": the new ordered task list (full replace).", + description: 'For action="create": the new ordered task list (full replace).', items: { type: "object", properties: { - id: { type: "string", description: "Stable id, unique within this list (e.g. t1, t2)." }, + id: { + type: "string", + description: "Stable id, unique within this list (e.g. t1, t2).", + }, title: { type: "string", description: "Short, action-oriented description." }, status: { type: "string", enum: ["todo", "doing", "done", "cancelled"], - description: "Defaults to \"todo\" when omitted.", + description: 'Defaults to "todo" when omitted.', }, }, required: ["id", "title"], @@ -76,8 +79,8 @@ export const manageTasksDefinition: ToolDefinition = { updates: { type: "array", description: - "For action=\"update\": per-task patches. Unknown id + title appends a new task; " + - "status \"cancelled\" removes it from active work.", + 'For action="update": per-task patches. Unknown id + title appends a new task; ' + + 'status "cancelled" removes it from active work.', items: { type: "object", properties: { diff --git a/src/agent/tool-schema-normalize.ts b/src/agent/tool-schema-normalize.ts index a341d7561..0ac3a9ffd 100644 --- a/src/agent/tool-schema-normalize.ts +++ b/src/agent/tool-schema-normalize.ts @@ -2,10 +2,10 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { isKimiLeafProvider } from "../subagent/provider-family.js"; /** Context used to decide whether a provider needs wire-schema rewrites. */ -export type NormalizeToolDefsContext = { +export interface NormalizeToolDefsContext { providerName: string; model?: string; -}; +} /** * Shared primitives / view guidance for `present`. Used by both the canonical @@ -118,7 +118,8 @@ export const KIMI_PRESENT_INPUT_SCHEMA = { properties: { view: { description: - "Root layout node. Runtime validates full nested trees. " + PRESENT_VIEW_PRIMITIVES_GUIDANCE, + "Root layout node. Runtime validates full nested trees. " + + PRESENT_VIEW_PRIMITIVES_GUIDANCE, oneOf: [ TEXT_NODE, DIVIDER_NODE, diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index b516eb50c..e4cb32828 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -20,14 +20,22 @@ const NO_AVAILABILITY: ToolAvailability = { }; const defs: ToolDefinition[] = [ - { name: "read_file", description: "read a file", inputSchema: { type: "object", properties: {}, required: [] } }, + { + name: "read_file", + description: "read a file", + 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: "lsp", + description: "resolve symbols, find references", + inputSchema: { type: "object", properties: {}, required: [] }, + }, { name: "mcp__linear__create_issue", description: "Create an issue in the tracker", @@ -70,7 +78,9 @@ describe("createToolIndex", () => { 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("orchestrator", FULL_AVAILABILITY)).toContain( + "search_agents", + ); }); test("manage_tasks is advertised regardless of availability", () => { @@ -121,7 +131,10 @@ describe("createToolIndex", () => { }); }); -function call(tool: ReturnType, args: Record): Promise { +function call( + tool: ReturnType, + args: Record, +): Promise { if (tool.kind !== "string") throw new Error("expected string tool"); return tool.handler(args, new AbortController().signal); } @@ -158,22 +171,46 @@ describe("createToolSearchTool", () => { }); test("rejects an empty query", async () => { - const tool = createToolSearchTool({ search: () => [], lookup: () => undefined, promote: () => undefined }); + const tool = createToolSearchTool({ + search: () => [], + lookup: () => undefined, + promote: () => undefined, + }); expect(await call(tool, { query: " " })).toContain("Error:"); }); test("reports when nothing matches", async () => { - const tool = createToolSearchTool({ search: () => [], lookup: () => undefined, promote: () => undefined }); + const tool = createToolSearchTool({ + search: () => [], + lookup: () => undefined, + promote: () => undefined, + }); expect(await call(tool, { query: "nonsense" })).toContain("No tools matched"); }); }); describe("advertisedTools", () => { const registry: ToolDefinition[] = [ - { name: "read_file", description: "read", inputSchema: { type: "object", properties: {}, required: [] } }, - { name: "grep", description: "grep", inputSchema: { type: "object", properties: {}, required: [] } }, - { name: "write_file", description: "write", inputSchema: { type: "object", properties: {}, required: [] } }, - { name: "mcp__linear__create_issue", description: "create", inputSchema: { type: "object", properties: {}, required: [] } }, + { + name: "read_file", + description: "read", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "grep", + description: "grep", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "write_file", + description: "write", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + { + name: "mcp__linear__create_issue", + description: "create", + inputSchema: { type: "object", properties: {}, required: [] }, + }, ]; test("orchestrator wire prefix names include multi-agent tools", () => { @@ -200,7 +237,11 @@ describe("advertisedTools", () => { const before = JSON.stringify(advertisedTools(registry)); const grown: ToolDefinition[] = [ ...registry, - { name: "mcp__acme__do", description: "late", inputSchema: { type: "object", properties: {}, required: [] } }, + { + name: "mcp__acme__do", + description: "late", + inputSchema: { type: "object", properties: {}, required: [] }, + }, ]; const after = JSON.stringify(advertisedTools(grown)); expect(after).toBe(before); @@ -211,7 +252,9 @@ describe("advertisedTools", () => { const reversed = advertisedTools([...registry].reverse()).map((d) => d.name); expect(reversed).toEqual(forward); - const withActivation = advertisedTools(registry, ["mcp__linear__create_issue"]).map((d) => d.name); + const withActivation = advertisedTools(registry, ["mcp__linear__create_issue"]).map( + (d) => d.name, + ); expect(withActivation.slice(0, forward.length)).toEqual(forward); }); @@ -227,9 +270,10 @@ describe("advertisedTools", () => { test("repeated activation of the same tool does not reorder or duplicate it", () => { const once = advertisedTools(registry, ["mcp__linear__create_issue"]).map((d) => d.name); - const twice = advertisedTools(registry, ["mcp__linear__create_issue", "mcp__linear__create_issue"]).map( - (d) => d.name, - ); + const twice = advertisedTools(registry, [ + "mcp__linear__create_issue", + "mcp__linear__create_issue", + ]).map((d) => d.name); expect(twice).toEqual(once); expect(twice.filter((n) => n === "mcp__linear__create_issue")).toHaveLength(1); }); @@ -237,9 +281,15 @@ describe("advertisedTools", () => { test("multiple activations append in first-activation order regardless of registry order", () => { const multi: ToolDefinition[] = [ ...registry, - { name: "mcp__acme__do", description: "late", inputSchema: { type: "object", properties: {}, required: [] } }, + { + name: "mcp__acme__do", + description: "late", + inputSchema: { type: "object", properties: {}, required: [] }, + }, ]; - const names = advertisedTools(multi, ["mcp__acme__do", "mcp__linear__create_issue"]).map((d) => d.name); + const names = advertisedTools(multi, ["mcp__acme__do", "mcp__linear__create_issue"]).map( + (d) => d.name, + ); const tailIdx = names.length - 2; expect(names.slice(tailIdx)).toEqual(["mcp__acme__do", "mcp__linear__create_issue"]); }); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 736d2a5ca..81e83dc78 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -46,11 +46,11 @@ const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task" // the life of the session — the tools array is a provider cache prefix (see // ADVERTISED_TOOL_NAMES below), so a value that could flip mid-session would // force a re-prefill worse than the schema bytes it saves. -export type ToolAvailability = { +export interface ToolAvailability { // Whether a language server was resolvable for this project at startup — // not whether one currently responds. languageServerAvailable: boolean; -}; +} export function coreToolNamesForSessionMode( mode: SessionMode, @@ -97,10 +97,7 @@ export const CATALOG_TOOL_NAMES: readonly string[] = [ // Primary TUI/exec sessions should pass // `advertisedToolNamesForSessionMode(sessionMode, toolAvailability)` as the // `builtInPrefix` to `advertisedTools` — not this constant alone. -export const ADVERTISED_TOOL_NAMES: readonly string[] = [ - ...CORE_TOOL_NAMES, - ...CATALOG_TOOL_NAMES, -]; +export const ADVERTISED_TOOL_NAMES: readonly string[] = [...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES]; // Project the live tool registry onto the advertised set: the fixed built-in // prefix (its order never changes — this is what keeps the provider cache @@ -133,11 +130,11 @@ export function advertisedTools( // tool_search matches, or a director-side trigger like the lsp hint), in // first-activation order. Backed by a Set, so re-activating an already-active // name is a no-op — it neither reorders nor duplicates the entry. -export type ActivatedToolTracker = { +export interface ActivatedToolTracker { // Adds any new names and returns whether the set actually changed. activate(names: readonly string[]): boolean; list(): string[]; -}; +} export function createActivatedToolTracker(): ActivatedToolTracker { const activeNames = new Set(); @@ -171,10 +168,10 @@ export const toolSearchDefinition: ToolDefinition = { }, }; -export type ToolIndex = { +export interface ToolIndex { // Rank registered tools against a query, returning the best-matching tool names. search(query: string, limit?: number): string[]; -}; +} function tokenize(text: string): string[] { return text.toLowerCase().match(/[a-z0-9]+/g) ?? []; @@ -217,14 +214,14 @@ export function createToolIndex( }; } -export type ToolSearchDeps = { +export interface ToolSearchDeps { search: (query: string) => string[]; lookup: (name: string) => ToolDefinition | undefined; // Make the matched tools' names part of the advertised wire set on the next // inference. Every registered tool is already dispatchable via `run`, so this // only affects what the model can see without an intervening tool_search. promote: (names: string[]) => void; -}; +} const ToolSearchArgs = type({ query: "string" }); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 4661e05c8..960f161fc 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -25,10 +25,7 @@ import { connectMCPServer, type MCPClient } from "../mcp/client.js"; import { mcpClientToAgentTools } from "../mcp/plugin.js"; import { createDynamicToolRunner, type DynamicToolRunner } from "../tui/dynamic-tool-runner.js"; import type { MCPServerConfig, Settings } from "../config/settings.js"; -import { - filterMcpServersForConnect, - type ProjectTrustStore, -} from "../trust/project-trust.js"; +import { filterMcpServersForConnect, type ProjectTrustStore } from "../trust/project-trust.js"; import type { ToolWatchdogConfig } from "../tui/tool-execution-watchdog.js"; import type { SessionMode } from "../config/session-mode.js"; import { sessionModeEnablesSubAgents } from "../config/session-mode.js"; @@ -64,11 +61,9 @@ const AdvanceWorkflowArgs = type({ // dismiss the question without answering. The gate owns this distinction so the // tool layer can translate each outcome into the right tool result. export type OperatorResult = - | { kind: "option"; index: number } - | { kind: "custom"; text: string } - | { kind: "cancel" }; + { kind: "option"; index: number } | { kind: "custom"; text: string } | { kind: "cancel" }; -export type AgentToolsetArgs = { +export interface AgentToolsetArgs { cwd: string; permissionGate: PermissionGate; onOperatorGate: (question: string, options: string[]) => Promise; @@ -135,7 +130,7 @@ export type AgentToolsetArgs = { // sharing this session's cwd. See src/subagent/worktree.ts. useWorktree?: boolean; }; -}; +} // Per-server connection state surfaced to the TUI. export type MCPServerState = @@ -144,7 +139,7 @@ export type MCPServerState = | { name: string; state: "connected"; tools: string[] } | { name: string; state: "failed"; error: string }; -export type MCPConnectCallbacks = { +export interface 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; @@ -153,9 +148,9 @@ export type MCPConnectCallbacks = { // Fired after a server connects and its tools are registered, with the new // full definition set so the director can advertise it on the next inference. onToolsChanged: (definitions: ToolDefinition[]) => void; -}; +} -export type AgentToolset = { +export interface AgentToolset { // The mutable runner the agent dispatches through. Seeded with posix/web/LSP // tools; MCP tools are added as servers connect. dynamicRunner: DynamicToolRunner; @@ -166,7 +161,7 @@ export type AgentToolset = { // advertised. Set by the runner once the director + reload loop exist. setToolPromoter: (promote: (names: string[]) => void) => void; dispose: () => Promise; -}; +} export async function createAgentToolset(args: AgentToolsetArgs): Promise { const { @@ -252,7 +247,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { const profiles = args.subAgent!.profiles; - return typeof profiles === "function" ? profiles() : profiles ?? []; + return typeof profiles === "function" ? profiles() : (profiles ?? []); }), ] : []), @@ -331,21 +326,27 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise void } = { promote: () => undefined }; - let runnerRef: DynamicToolRunner | undefined; - const toolIndex = createToolIndex(() => runnerRef?.currentDefinitions() ?? [], advertisedBuiltIns); + const runnerHolder: { current?: DynamicToolRunner } = {}; + const toolIndex = createToolIndex( + () => runnerHolder.current?.currentDefinitions() ?? [], + advertisedBuiltIns, + ); baseTools.push( createToolSearchTool({ search: (query) => toolIndex.search(query), - lookup: (name) => runnerRef?.currentDefinitions().find((d) => d.name === name), + lookup: (name) => runnerHolder.current?.currentDefinitions().find((d) => d.name === name), promote: (names) => promoter.promote(names), }), ); const dynamicRunner = createDynamicToolRunner(baseTools, toolWatchdog); - runnerRef = dynamicRunner; + runnerHolder.current = dynamicRunner; const connectedClients: MCPClient[] = []; - const connectMCP = async (callbacks: MCPConnectCallbacks, signal?: AbortSignal): Promise => { + const connectMCP = async ( + callbacks: MCPConnectCallbacks, + signal?: AbortSignal, + ): Promise => { const toConnect = await filterMcpServersForConnect(mcpServers, { source: mcpServersSource, store: projectTrust ?? { trustedPluginPaths: [], trustedMcpFingerprints: [] }, @@ -358,7 +359,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise callbacks.onStatus({ name, state: "needs-auth", url }) } + ? { + 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 @@ -383,7 +387,11 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise t.name) }); + callbacks.onStatus({ + name: config.name, + state: "connected", + tools: result.client.tools.map((t) => t.name), + }); callbacks.onToolsChanged(dynamicRunner.currentDefinitions()); }), ); diff --git a/src/auth/callback-page.test.ts b/src/auth/callback-page.test.ts index 15f68b1f3..d6d17f1ef 100644 --- a/src/auth/callback-page.test.ts +++ b/src/auth/callback-page.test.ts @@ -37,15 +37,11 @@ describe("callbackPageHtml", () => { test("an unnamed authorization still renders both outcomes", () => { expect(callbackPageHtml()).toContain("Authorization complete"); - expect(callbackPageHtml({ error: "server_error" })).toContain( - "Authorization did not complete", - ); + expect(callbackPageHtml({ error: "server_error" })).toContain("Authorization did not complete"); }); test("the subject is escaped rather than pasted into markup", () => { - expect(callbackPageHtml({ subject: "" })).not.toContain( - "" })).not.toContain("