SLOPSHOPPER

lexi

Test-first workflow for coding agents, without the spec ceremony. A PreToolUse hook keeps source edits behind a mirror test and protects existing assertions…

newpanebandguardcommandtoast
★ 4v0.13.3MITupdated 2026-10-11savinofiore/lexi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lexi
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. │ ● lexi: [jev-router] TYPESAFE_API_KEY missing: add it under "env" in │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /subagents │ ⎿ lexi: Subagents pane opened. │ │ claude-opus-5-5 │ ctx ████▉░░░░░ 49% │ 5h ███░░░░░░░ 31% │ 97.4k tok │ $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
claude-opus-5-5 │ ctx ████▉░░░░░ 49% │ 5h ███░░░░░░░ 31% │ 97.4k tok │ $0.42
Pane · Subagents
No subagents yet.
README

lexi

Your coding agent writes the test first. Every time.

lexi reads the task, proposes unit tests, waits for your OK, then drives RED → GREEN.<br> No spec documents, nothing to clean up: the tests are the spec, and they stay in the repo.

Claude Code Pi License: MIT

Quick start · How it works · The guard · Commands · Examples


Why lexi

Agents say they do TDD. Then they write the code, write a test that matches it, and bend the assertion until it goes green. lexi makes that impossible, not just discouraged.

🧪 Test first, mechanicallyA guard blocks any edit to a source file whose test does not exist yet.
🔒 No bending tests to greenRewriting a committed assertion is blocked. Appending new tests is always fine.
🧭 Right flow for the taskBug, clear feature or open feature: the router picks the path, you confirm the tests.
✂️ Minimal GREENponytail keeps every implementation step as small as it can be.
🔁 Two runtimes, one flowSame skills and same guard on Claude Code and Pi (@earendil-works/pi-coding-agent).
🤖 Optional review with jevPicks model and effort per session, compacts context verbatim, and reviews the diff after the last GREEN with a verdict computed in code. Off until init turns it on.

What it looks like

/lexi:lexi in src/domain/discount.js add applyDiscount(total, pct) capped at 50%

  Proposed tests:
    1. applies a 10% discount to total
    2. caps discount at 50% of total
    3. rejects a negative percentage
  Confirm or add?  › ok

  test 1  RED ✗  →  GREEN ✓
  test 2  RED ✗  →  GREEN ✓
  test 3  RED ✗  →  GREEN ✓

  Report: 3 tests, 1 file touched, no untested code.

And when the agent tries to skip ahead:

lexi — write to `src/domain/discount.js` BLOCKED.
`src/domain/discount.js` is under `testable` and mirror test does not exist:
  tests/domain/discount.test.js

Quick start: Claude Code

The fast way. Open claude in your project and paste this prompt. It installs the suite (lexi, ponytail, caveman) and prepares the Jev key:

Install the lexi suite for Claude Code. Run these shell commands, stopping on the first failure:

  claude plugin marketplace add DietrichGebert/ponytail
  claude plugin marketplace add JuliusBrussee/caveman
  claude plugin marketplace add savinofiore/lexi
  claude plugin install lexi@lexi

Then check .claude/settings.local.json in this project: make sure git ignores it (git check-ignore;
if it is tracked, untrack it and add it to .gitignore). If env.TYPESAFE_API_KEY is missing there, add it
with the value "PASTE_YOUR_KEY_HERE" and keep every other setting. Never write the key in a committed
file, and never print its value. Finish by listing what got installed, and remind me to put my key there, restart the session, and run
/lexi:init answering yes to Jev.

In auto mode Claude Code may refuse to edit its own settings files ([Self-Modification]): that is by design. Add the key by hand as in step 2 below.

Then restart claude in the same folder (new plugins load only in a new session), accept the trust prompt, and run /lexi:init. Answer yes to Jev.

1. Install. Add the dependency marketplaces, then lexi. It brings ponytail and caveman with it.

/plugin marketplace add DietrichGebert/ponytail
/plugin marketplace add JuliusBrussee/caveman
/plugin marketplace add savinofiore/lexi
/plugin install lexi@lexi

From a shell: claude plugin marketplace add savinofiore/lexi && claude plugin install lexi@lexi.

2. Jev key (only for Jev). Put it in the project's .claude/settings.local.json, one key per project. Claude Code merges its env into every hook and command. Check that git ignores the file (git check-ignore .claude/settings.local.json); a tracked local file leaks the key on the next push:

{ "env": { "TYPESAFE_API_KEY": "..." } }

3. Verify. Restart the session and run /plugin. You should see:

  • lexi: 7 skills (init, lexi, bug, feature, tdd, grill, code-review), 1 PreToolUse hook and 1 hooks module

4. Opt the project in. Open claude in the project folder, accept the trust prompt (Claude Code reads project settings only in a trusted folder), then run /lexi:init.

5. Interface (optional). With CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 lexi also draws, above the prompt: the model and effort the Jev router chose, context and rate-limit bars with time to reset, tokens and cost (no statusLine command needed); lexi:GREEN/lexi:RED and the last gate run; a toast when the guard blocks an edit; and the running subagents, one color per type, with their step count and ✗ after a failed step. /subagents opens a pane that lists them (running first, with the model each runs on, ▶/✓/✗ and its steps) and opens each onto its tool calls, answer, duration and tokens.

  • .lexi.json with the gate, the paths and the testable list.
  • In .claude/settings.json: the lexi, ponytail and caveman marketplaces under extraKnownMarketplaces with autoUpdate: true, plus enabledPlugins: { "lexi@lexi": true }. Commit this file. Teammates who trust the folder are offered lexi, and later versions reach them with any new dependencies (third-party marketplaces do not auto-update by default).
  • If you enable Jev:
  • "jev": {} in .lexi.json;
  • CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=50 in .claude/settings.json. While Claude Code's plugin-hooks rollout is off server-side, the settings value is read too late: every teammate also needs export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in their shell profile (~/.zshrc), or the router stays off silently;
  • two project shims, .claude/skills/code-review/ and .claude/skills/review/. A project skill replaces the built-in one with the same name, so /code-review and /review run Jev's review in that project. Delete the two folders to get the built-in review back.

The next session confirms the router on its first prompt: [jev-router] session: ….


Quick start: Pi

1. Install. pi-subagents is optional: it runs the gate in an isolated subagent.

pi install npm:pi-subagents                       # optional
pi install git:github.com/JuliusBrussee/caveman
pi install git:github.com/savinofiore/lexi

2. Jev key (only for Jev). Same place as on Claude Code: env.TYPESAFE_API_KEY in the project's gitignored .claude/settings.local.json. A shell export (export TYPESAFE_API_KEY=...) also works and wins.

3. Opt the project in. Approve the folder (once, or pi -a for one run), then run:

/lexi-init

[!TIP] Use /lexi-init, not /skill:lexi-init: the bare skill command sends no request, and the model only answers that it is waiting for a task.

Key lookup. Pi has no env block. The router, compaction and review read the shell first, then env.TYPESAFE_API_KEY in the project's .claude/settings.local.json. ~/.claude/settings.json is not read: the key is per project, so both runtimes share the one file.

Extra init questions. Besides the gate and the testable paths, init asks two questions on Pi:

  • Gate subagent: with pi-subagents installed, which model runs the gate. It writes .pi/agents/lexi-gate.md and "gate_agent": true. Answer "inline" to keep the gate in the driving model.
  • Jev: on yes, which model per tier (pi --list-models). It writes "jev": { "tiers": … } in .lexi.json and compaction.modelOverrides in .pi/settings.json, so compaction runs at ~50%.

Package contents, declared in package.json's pi field:

  • pi/extensions/lexi-guard.ts: the guard, wrapping the same hooks/tdd_guard.py
  • pi/extensions/lexi-init.ts: the /lexi-init command
  • pi/extensions/caveman.ts: caveman's rules on every turn, off with "stop caveman" or "normal mode"
  • pi/extensions/jev-router/, pi/extensions/jev-compact/: jev, inert without a jev key in .lexi.json
  • pi/skills/: lexi-init, lexi-lexi, lexi-bug, lexi-feature, lexi-grill, lexi-tdd, jev-code-review

Local development: pi install /absolute/path/to/lexi (edits to pi/ and jev/ are live).


How it works

Describe the task with /lexi:lexi. The router picks one of three paths. You rarely have to type it: in a lexi project a one-line hint at session start points every code change at /lexi:lexi, and with Jev enabled each prompt is classified as bug, feature or open scope and sent straight to that skill.

flowchart LR
    T([your task]) --> R{router}
    R -->|bug| B[rewrite existing tests<br/>to RED: prove the bug]
    R -->|clear feature| F[propose 3–5 unit tests]
    R -->|open feature| G[grill: settle open<br/>decisions in rounds]
    G --> F
    F --> C{you confirm}
    C --> L[per test:<br/>RED → GREEN]
    B --> X[fix code to GREEN]
    L --> J[Jev review<br/>if enabled]
    X --> J
    J --> P([report])
  • GREEN is always minimal. ponytail asks whether the code needs to exist, is already here, is covered by the stdlib, or fits in one line.
  • Jev review runs only if the project enabled it. The verdict comes from the policy. You confirm each fix, and a fix under testable goes back through RED → GREEN.

Bug fix

lexi router <bug description>
  → bug skill
    → analyze, find root cause
    → identify existing tests that should fail if the bug exists
    → propose rewrites (how to make them RED) → wait for confirmation + additions
    → rewrite tests listed in .lexi/allow
    → RED: run gate
      ✅ fails for the bug reason   → proceed
      ❌ fails for a setup reason   → fix test
      ❌ passes                     → test does not prove the bug → go back
    → fix production code (minimal, ponytail governs)
    → GREEN: run gate
      ✅ passes                     → next
      ❌ fails after 3 attempts     → report, stop
      ❌ green without the fix      → fix was unnecessary → go back
    → empty .lexi/allow
    → Jev review (if enabled)
    → report: files touched, root cause fixed, tests stay as regression

Feature with clear scope

lexi router <clear feature description>
  → feature skill
    → propose 3-5 unit tests (no widget tests)
      "applies 10% discount", "caps at 50%", "rejects negative", …
    → wait for confirmation (approve, add any I missed)
    → confirm seams (public boundaries under test)
    → per test, in order:
      1. RED: write ONE test, run gate
         ✅ fails for the test reason → proceed
         ❌ fails for a setup reason  → fix test
         ❌ passes                    → test asserts nothing → verify
      2. GREEN: minimal production code (ponytail governs)
      3. run gate → passes → next test
    → Jev review (if enabled)
    → report: tests written, production files touched, untested code and why

Feature with open scope

lexi router <vague feature description>
  → scope is open (several defensible designs)
    → grill skill
      → Q1 with recommendation → your answer
      → Q2 with recommendation → your answer
      → … (blocked questions held for the next round)
      → frontier empty → done
  → back to router → feature skill → same as clear scope

Manual flow (legacy)

lexi router <task>
  → propose seams manually (skips feature)
    seam: function → mirror test file
    tests: list in order
    breaking: which existing tests change
    outside testable: what has no test
  → confirm seams → RED→GREEN cycle

The guard

A hook (Claude Code) or extension (Pi) checks every edit once .lexi.json exists. No command needed. Shell commands are checked too, for the files they write through >, >>, tee or sed -i (an append counts as adding a test, > and sed -i as rewriting one).

RuleWhy
A source file under testable cannot be written before its mirror test existsTest first, mechanically
A committed test cannot have its assertions rewrittenNo bending a test to reach green

Everything outside testable (UI, design tokens, generated code, platform bindings) is free to edit.

❌ BLOCKED
lexi — edit to `tests/domain/total.test.js` BLOCKED.
Test file is tracked by git and edit replaces existing content.
Rewriting an assertion to reach green is what this guard stops.

✅ ALLOWED
src/components/card.js           ← outside testable
tests/domain/new.test.js         ← untracked, about to be written
tests/domain/total.test.js       ← appending a new case, existing ones intact

Breaking changes

Sometimes an existing test is really obsolete. Name it when the flow proposes tests (breaking: tests/domain/cart.test.js, 'total is the sum of line items'). Once you agree, the file is released:

echo tests/domain/cart.test.js >> .lexi/allow   # release one test file
# rewrite the test, run the gate
: > .lexi/allow                                 # cleared when the gate passes

One breaking test per slice, never in bulk. A test found "obsolete" mid-implementation is not pre-approved: the agent stops and asks. .lexi/allow is per-task scratch, gitignored by init.

Escape hatches

  • .lexi/allow: one test path per line, releases rewrites on those files only.
  • LEXI_OFF=1: disables the guard for the session. Set it outside the agent, for emergencies.

Commands

Claude CodePiUse when
/lexi:lexi <task>/skill:lexi-lexi <task>Start here. Any task, feature or bug: routes to the right flow
/lexi:init/lexi-initOnce per project, and again after an update
/lexi:bug <task>/skill:lexi-bug <task>A bug report with broken behavior
/lexi:feature <task>/skill:lexi-feature <task>A feature with clear scope
/lexi:grill <task>/skill:lexi-grill <task>A feature with open scope (feature can call it)
/lexi:tdd/skill:lexi-tddReference: seams, assertions, mocking, anti-patterns
/lexi:code-review/skill:jev-code-reviewJev review of the working tree, a ref, a PR or a diff

With Jev on, Claude Code's /code-review and /review also route to Jev, and the flows call the review on their own after the last GREEN.


Configuration

init writes .lexi.json. Same file and format on both runtimes:

{
  "gate": "flutter test",
  "source": "lib/",
  "tests": "test/",
  "test_suffix": "_test.dart",
  "testable": ["lib/models/", "lib/repositories/", "lib/utils/"]
}

Mirror rule: <source><rel>.<ext> maps to <tests><rel><test_suffix>. For co-located tests, set tests equal to source.

Optional keyEffect
"jev": {}Turns on the review after the last GREEN (both runtimes) and, on Pi, the router and compaction. See jev for tiers and the conventions file .lexi/review.json. "jev": false records a "no" so updates stop asking
"gate_agent": truePi only: runs the gate through the lexi-gate subagent defined in .pi/agents/lexi-gate.md. Absent: the gate runs inline. Claude Code ignores it
{ "gate": "flutter test", "source": "lib/", "tests": "test/",
  "test_suffix": "_test.dart", "testable": ["lib/models/", "lib/repositories/"] }

{ "gate": "npx vitest run", "source": "src/", "tests": "src/",
  "test_suffix": ".test.ts", "testable": ["src/domain/", "src/lib/"] }

{ "gate": "pytest -q tests/unit", "source": "app/", "tests": "tests/unit/",
  "test_suffix": "_test.py", "testable": ["app/services/", "app/parsers/"] }

The Vitest line is co-located: src/cart/total.ts maps to src/cart/total.test.ts.


Examples

Examples use Claude Code syntax. On Pi, use /skill:lexi-*.

/lexi:lexi discount calculation is negative for high percentages

Root cause: the formula is (pct * total) - total instead of total - (pct * total).

The existing test applies 10% discount is rewritten to prove the bug:

  • Old: expect(applyDiscount(100, 10)).toBe(90)
  • New: expect(applyDiscount(100, 10)).toBe(-10)

RED: gate fails, bug confirmed. Fix:

export const applyDiscount = (total, pct) => total - (pct / 100) * total;

GREEN: gate passes. .lexi/allow is emptied. The tests stay as regression.

/lexi:lexi in src/domain/discount.js add applyDiscount(total, pct) capped at 50%

Proposed tests:

  • applies a 10% discount to total
  • caps discount at 50% of total
  • rejects a negative percentage

You confirm. Then three loops: test 1 RED → minimal code GREEN, test 2 RED → cap logic GREEN, test 3 RED → validation GREEN. Report: three tests, one file touched, no untested code.

/lexi:lexi support promo codes at checkout

Scope is open, so the grill skill runs first:

  1. Source of truth: hardcoded table, API or payment provider? → API (a table cannot expire, a provider locks you in)
  2. Stacking: one code or many? → One (stacking is a pricing engine nobody asked for)
  3. What a code discounts: total, line items or shipping? → Total (line items need a category model that does not exist)
  4. Rejected code: throw or result? → Result (the UI shows "expired" and "not found" differently)
  5. Held for round 2: API down (needs 1), second code entered (needs 2 and 3)

Back to the router, then the feature skill proposes:

  • rejects unknown code → { ok: false, reason: 'not_found' }
  • rejects expired code → { ok: false, reason: 'expired' }
  • applies valid code to total
  • second code replaces first
  • API unreachable → no discount, checkout still works

Breaking: total is the sum of line items will change. You confirm. Five RED→GREEN loops. Report: five tests, three files touched.


Updating

An update never breaks a project that is already set up. The guard reads only its own keys in .lexi.json, and every new step stays off until the project opts in.

# Claude Code
/plugin marketplace update lexi
/plugin update lexi@lexi

# Pi
pi update git:github.com/savinofiore/lexi

Then rerun init in each project (/lexi:init or /lexi-init). On an existing .lexi.json it keeps gate, testable and the rest, and asks only what the new version added. On Pi it also installs ponytail or caveman if missing. Skipping this step is fine: the project keeps working as before, without the new features.

Coming from jev or lexi-mods (lexi < 0.12.0)

Since 0.12.0 lexi ships the Jev layer and the interface itself. jev and lexi-mods are now empty stubs that only say so. Move over once:

/plugin uninstall jev@lexi
/plugin uninstall lexi-mods@lexi
/plugin marketplace update lexi
/plugin update lexi@lexi

Restart claude, then run /lexi:init in each project. On an existing .lexi.json it rewrites the review shims (.claude/skills/code-review and review) to call /lexi:code-review and drops jev@lexi and lexi-mods@lexi from .claude/settings.json. Commit the result.

What was renamed: /jev:code-review is /lexi:code-review, /lexi-mods:agents is /subagents (the engine owns /agents). Nothing else changes: same .lexi.json, same key, same flags.

Coming from lexi < 0.9.0: move the Jev key

Before 0.9.0 the Jev key lived globally (~/.claude/settings.json or a shell export). From 0.9.0 it lives per project in the gitignored .claude/settings.local.json.

  1. Delete the old key: env.TYPESAFE_API_KEY in ~/.claude/settings.json and any export TYPESAFE_API_KEY= line in ~/.zshrc, ~/.zprofile, ~/.bashrc, ~/.bash_profile, ~/.profile.
  2. In each project that uses Jev, put the key in .claude/settings.local.json ({ "env": { "TYPESAFE_API_KEY": "..." } }) and check git check-ignore .claude/settings.local.json.
  3. Add export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 to your shell profile: the router stays off without it.
  4. Fully quit the editor (Cmd+Q) and the terminal, reopen, restart claude. Editors built on VS Code read the shell environment once at launch, so an old exported key survives until the app restarts.

Get the key over a separate channel (a password manager), never in chat. If an old global key was shared, revoke it.


Requirements

WhatNeeded forCheck
Python 3 as python3The guard on both runtimes, and jev's reviewpython3 --version
ponytailClaude Code: governs every GREEN step. Installed with lexi. Pi has no port: lexi's Pi skills carry the same ladder inline/plugin
cavemanTerse output in every session. Claude Code: installed with lexi. Pi: /lexi-init installs it if missing, and lexi warns at session start when it is missing/plugin / pi list
pi-subagentsPi only, optional: the gate in an isolated subagentpi list
TYPESAFE_API_KEYjev only, optionaljev requirements
Claude Code ≥ 2.1.276jev's router and compaction hooksclaude --version
Claude Code ≥ 2.1.296the interface, optionalclaude --version

[!WARNING] Without Python every guarded edit fails instead of being checked. macOS and most Linux distributions already have it. On Windows, install it and check that python3 --version answers. If python3 opens the Microsoft Store, turn off the python3.exe alias (Settings > Apps > Advanced app settings > App execution aliases) and copy python.exe to python3.exe next to it.


Known limits

  • The guard matches Edit/Write/MultiEdit/NotebookEdit/Bash (Claude Code) and edit/write/bash (Pi). In a shell command it reads >, >>, tee and sed -i with a regex, not a shell parser: cp, mv, python -c and paths held in variables still walk past it.
  • "Rewrite vs append" is substring containment, not a real diff. A hand-crafted edit could fool it.
  • The guard is friction plus an audit trail, not a wall. An agent can write .lexi/allow itself: the skill says to ask first, and the file records what was released.
  • ponytail has no Pi port yet: Pi's skills carry the same rules inline.

Contributing

python3 hooks/tdd_guard_test.py
python3 skills_frontmatter_test.py   # every SKILL.md: valid frontmatter, name = folder
python3 versions_test.py             # every copy of a plugin version matches
python3 hooks/session_hint_test.py
python3 tests/pressure/judge_test.py

Pressure tests check the skill prose, not the code: each scenario in tests/pressure/scenarios/ pushes a headless agent to break a rule (skip the red test, rewrite an assertion, guess a cause, claim done without the gate, obey a wrong review), and Jev judges the transcript. They cost a real session each, so they are not in the gate: run `python3 tests/pressu

Source 12 files
engine/index.ts 28 lines
1import type { Register } from 'claude-code'
2import { compact } from './jev/compact.ts'
3import { onAgentSpawn as routeAgent, onPromptSubmit, onSessionEnd, onSessionStart as resetRouter, onTurnStep } from './jev/router.ts'
4import { PANE, onAgentToolCall, onAgentsCommand, onAgentSpawn as listAgent, onPane, onSessionStart as resetAgents, onTurnComplete } from './ui/agents.tsx'
5import { onAbovePrompt } from './ui/band.tsx'
6import { GUARDED, onToolCall } from './ui/gate.ts'
7import { onMeasure } from './ui/usage.tsx'
8
9// An event takes one hook without a matcher, so the router and the interface split session.start and agent.spawn by matcher.
10// A plugin gets exactly one hooks module: this one wires the Jev layer (router, compaction) and the interface (usage row, gate, subagents).
11export const register: Register = (on) => {
12  on('session.start', resetRouter)
13  on('session.start', { isInteractive: true }, resetAgents)
14  on('session.end', onSessionEnd)
15  on('prompt.submit', onPromptSubmit)
16  on('turn.step', onTurnStep)
17  on('agent.spawn', { fork: false }, routeAgent)
18  on('agent.spawn', listAgent)
19  on('session.compact', compact)
20  on('command.run', { command: 'subagents' }, onAgentsCommand)
21  on('tool.call', GUARDED, onToolCall)
22  on('tool.call', onAgentToolCall)
23  on('turn.complete', onTurnComplete)
24  on('session.measure', onMeasure)
25  on('ui.render', { component: 'AbovePrompt' }, onAbovePrompt)
26  on('ui.render', { component: 'Pane', requestId: PANE }, onPane)
27}
28
engine/jev/compact.ts 190 lines
1import type { EngineInterface, Hook, SessionCompactInput, SessionCompactResult, SessionMessage, ToolResultSummary, ToolUseSummary } from 'claude-code'
2import type { CallInfo, JevState as State, Question, StateCall as Call, StateEntry as Entry, Verdict } from '../../jev/shared/compact-policy.ts'
3import {
4  bar,
5  describeCall,
6  fitState,
7  isOpen,
8  JEV_MODEL,
9  JEV_TIMEOUT_MS,
10  JEV_URL,
11  kilo,
12  MAX_STATE_TOKENS,
13  MIN_REDUCTION,
14  parseAnswers,
15  percent,
16  toBatches,
17  truncated,
18  verdictOf,
19} from '../../jev/shared/compact-policy.ts'
20
21// Claude Code side only: thresholds and questions live in shared/compact-policy.ts, shared with Pi.
22const RESULT_VISIBLE_MS = 30_000 // how long the outcome stays in the status line
23
24type Next = Parameters<Hook<'session.compact'>>[2]
25type Candidate = { use: ToolUseSummary; result: ToolResultSummary }
26type Plan = { messages: SessionMessage[]; candidates: Candidate[]; verdicts: Map<string, Verdict>; before: number; after: number; count: number }
27
28export async function compact($: EngineInterface, e: SessionCompactInput, next: Next): Promise<SessionCompactResult> {
29  const startedAt = await $.clock.now()
30  const plan = await planCompaction($, e, next.signal).catch((error: unknown) => `internal error: ${String(error)}`)
31  if (typeof plan === 'string') return delegate($, e, next, plan)
32  report($, plan, (await $.clock.now()) - startedAt)
33  return { messages: plan.messages }
34}
35
36// Every reason not to compact becomes a string: the caller defers to the native summary.
37async function planCompaction($: EngineInterface, e: SessionCompactInput, signal: AbortSignal): Promise<Plan | string> {
38  const key = await $.env.get('TYPESAFE_API_KEY')
39  if (!key) return 'TYPESAFE_API_KEY missing'
40  progress($, 10, 'finding candidates')
41  const candidates = findCandidates(e.messages)
42  if (!candidates.length) return 'no candidate tool call'
43  const state = fitState(buildState(e), e.messages.length)
44  if (!state) return `state over ${MAX_STATE_TOKENS} tokens`
45  const answers = await askJev($, { key, state, calls: candidates.map(infoOf) }, signal)
46  if (typeof answers === 'string') return answers
47  progress($, 90, 'rebuilding')
48  const verdicts = new Map(candidates.map((candidate) => [candidate.use.tool_use_id, verdictOf(answers, infoOf(candidate))]))
49  const messages = rebuild(e.messages, verdicts)
50  const before = charsOf(e.messages)
51  const after = charsOf(messages)
52  if (1 - after / before < MIN_REDUCTION) return `reduction ${percent(1 - after / before)} below ${percent(MIN_REDUCTION)}`
53  return { messages, candidates, verdicts, before, after, count: e.messages.length }
54}
55
56const infoOf = ({ use, result }: Candidate): CallInfo => ({ id: use.tool_use_id, tool: use.tool, resultChars: result.text.length })
57
58// Candidate: a call with its result, both outside the immune messages.
59const findCandidates = (messages: readonly SessionMessage[]): Candidate[] => {
60  const open = messages.filter((_, i) => isOpen(i, messages.length))
61  const results = new Map(open.flatMap((m) => m.toolResults ?? []).map((r) => [r.tool_use_id, r]))
62  return open.flatMap((m) => (m.role === 'assistant' ? m.toolUses : [])).flatMap((use) => {
63    const result = results.get(use.tool_use_id)
64    return result ? [{ use, result }] : []
65  })
66}
67
68// Structure and history without result contents: only a note with outcome and length.
69const buildState = ({ messages, instructions }: SessionCompactInput): State => {
70  const results = new Map(messages.flatMap((m) => m.toolResults ?? []).map((r) => [r.tool_use_id, r]))
71  const note = (id: string) => {
72    const r = results.get(id)
73    return r ? `${r.isError ? 'error' : 'ok'}, ${r.text.length} chars` : 'no result'
74  }
75  const conversation = messages.flatMap((m, i): Entry[] => {
76    const calls = m.toolUses.map((u) => ({ id: u.tool_use_id, tool: u.tool, input: u.input, result: note(u.tool_use_id) }))
77    if (!m.text && !calls.length) return []
78    return [{ i, role: m.role, text: m.text, ...(calls.length ? { tool_calls: calls } : {}) }]
79  })
80  const goal = messages.filter((m) => m.role === 'user' && m.text).slice(-3).map((m) => m.text)
81  return { goal, ...(instructions ? { instructions } : {}), conversation }
82}
83
84// Batches run in parallel under one timeout: an in-flight fetch does not consume the hook budget,
85// waiting on the clock does, so the timeout stays under 10 s.
86async function askJev($: EngineInterface, ask: { key: string; state: State; calls: CallInfo[] }, signal: AbortSignal): Promise<Record<string, number> | string> {
87  const batches = toBatches(ask.state, ask.calls)
88  const timer = new AbortController()
89  signal.addEventListener('abort', () => timer.abort())
90  const timeout = $.clock.sleep(JEV_TIMEOUT_MS, { signal: timer.signal }).then(() => 'timeout', () => 'timeout')
91  const status = { done: 0, total: batches.length, calls: ask.calls.length }
92  showBatches($, status)
93  const requests = Promise.all(batches.map((questions) => askBatch($, { ...ask, questions }, status)))
94  const outcome = await Promise.race([requests, timeout])
95  timer.abort()
96  if (typeof outcome === 'string') return outcome
97  return outcome.find((answer): answer is string => typeof answer === 'string') ?? Object.assign({}, ...outcome)
98}
99
100async function askBatch($: EngineInterface, ask: { key: string; state: State; questions: Record<string, Question> }, status: { done: number; total: number; calls: number }): Promise<Record<string, number> | string> {
101  const response = await $.http
102    .fetch(JEV_URL, {
103      method: 'POST',
104      headers: { authorization: `Bearer ${ask.key}`, 'content-type': 'application/json' },
105      body: JSON.stringify({ model: JEV_MODEL, state: ask.state, questions: ask.questions }),
106    })
107    .catch((error: unknown) => `network error: ${String(error)}`)
108  status.done++
109  showBatches($, status)
110  if (typeof response === 'string') return response
111  return response.ok ? parseAnswers(response.text, Object.keys(ask.questions)) : `HTTP ${response.status}`
112}
113
114// Call and result share one verdict: never a tool_use without its tool_result.
115const reshape = <T extends { tool_use_id: string; text?: string; result?: unknown }>(blocks: readonly T[], verdicts: Map<string, Verdict>): T[] =>
116  blocks.flatMap((block) => {
117    const verdict = verdicts.get(block.tool_use_id)
118    if (verdict === 'drop') return []
119    if (verdict !== 'truncate' || block.text === undefined) return [block]
120    const { result: _stored, ...rest } = block
121    return [{ ...rest, text: truncated(block.text) } as T]
122  })
123
124// Without a handle the engine rebuilds each message from role, text and blocks; with the handle it
125// would ignore the edits and reload the old chain on --resume.
126const rebuild = (messages: readonly SessionMessage[], verdicts: Map<string, Verdict>): SessionMessage[] =>
127  joinParallelCalls(
128    messages.flatMap(({ handle: _handle, ...message }) => {
129      const toolUses = reshape(message.toolUses, verdicts)
130      const toolResults = message.toolResults && reshape(message.toolResults, verdicts)
131      const rebuilt: SessionMessage = { ...message, toolUses, ...(toolResults ? { toolResults } : {}) }
132      return rebuilt.text || toolUses.length || toolResults?.length ? [rebuilt] : []
133    }),
134  )
135
136// Parallel calls of one response arrive as consecutive assistant messages, one per block. Rebuilt,
137// they would get new ids and on --resume the loader would no longer rejoin them
138// (ensureToolResultPairing): they go back to one message, like the original response.
139const joinParallelCalls = (messages: SessionMessage[]): SessionMessage[] => {
140  const joined: SessionMessage[] = []
141  for (const message of messages) {
142    const previous = joined[joined.length - 1]
143    if (message.role !== 'assistant' || previous?.role !== 'assistant' || !previous.toolUses.length) joined.push(message)
144    else joined[joined.length - 1] = { ...previous, text: [previous.text, message.text].filter(Boolean).join('\n\n'), toolUses: [...previous.toolUses, ...message.toolUses] }
145  }
146  return joined
147}
148
149const charsOf = (messages: readonly SessionMessage[]) =>
150  messages.reduce((sum, m) => sum + m.text.length + m.toolUses.reduce((s, u) => s + JSON.stringify(u.input).length, 0) + (m.toolResults ?? []).reduce((s, r) => s + r.text.length, 0), 0)
151
152function progress($: EngineInterface, pct: number, phase: string): void {
153  $.ui.status(`jev-compact ${bar(pct)} ${pct}% · ${phase}`)
154}
155
156function showBatches($: EngineInterface, { done, total, calls }: { done: number; total: number; calls: number }): void {
157  progress($, 20 + Math.round((60 * done) / total), `asking Jev (${done}/${total} batches, ${calls} calls)`)
158}
159
160// The outcome stays readable for RESULT_VISIBLE_MS: Jev answers in a second, the bar alone would vanish.
161function settle($: EngineInterface, outcome: string): void {
162  $.ui.status(`jev-compact ${outcome}`)
163  $.ui.toast(`jev-compact ${outcome}`, { timeoutMs: 8_000 })
164  $.clock.after(RESULT_VISIBLE_MS, () => $.ui.status(undefined))
165}
166
167function report($: EngineInterface, plan: Plan, ms: number): void {
168  const counts = { keep: 0, truncate: 0, drop: 0 }
169  for (const verdict of plan.verdicts.values()) counts[verdict]++
170  const saved = plan.before - plan.after
171  const reduction = percent(saved / plan.before)
172  settle($, `${bar(100)} 100% · -${reduction} (${kilo(saved)} chars) · ${counts.truncate} truncated, ${counts.drop} dropped, ${counts.keep} kept`)
173  $.ui.log(`Jev compacted the context: -${reduction} (${kilo(saved)} of ${kilo(plan.before)} chars) in ${ms}ms`)
174  $.ui.log(`${plan.verdicts.size} tool calls judged → ${counts.keep} kept, ${counts.truncate} truncated, ${counts.drop} dropped · messages ${plan.count} → ${plan.messages.length}`)
175  for (const candidate of plan.candidates) {
176    const verdict = plan.verdicts.get(candidate.use.tool_use_id) ?? 'keep'
177    if (verdict !== 'keep') $.ui.log(describeCall(infoOf(candidate), verdict, candidate.use.input))
178  }
179}
180
181async function delegate($: EngineInterface, e: SessionCompactInput, next: Next, reason: string): Promise<SessionCompactResult> {
182  $.ui.log(`Jev did not compact (${reason}): native Claude Code summary`)
183  progress($, 90, 'native summary')
184  try {
185    return await next(e)
186  } finally {
187    settle($, `Jev not used (${reason}) → native summary`)
188  }
189}
190
engine/jev/router.ts 175 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, EventName, Hook } from 'claude-code'
3import { flowContext, flowSkill, FLOW_QUESTIONS, isRoutable, parseFlow } from '../../jev/shared/flow-policy.ts'
4import type { Answer, Effort } from '../../jev/shared/router-policy.ts'
5import {
6  aliasOfRank,
7  causeOf,
8  EFFORTS,
9  effortRankOf,
10  isMoveAllowed,
11  isRisky,
12  JEV_MODEL,
13  JEV_URL,
14  parseAnswer,
15  QUESTIONS,
16  sessionTierRank,
17  shortModel,
18  targetEffortRank,
19  targetTierRank,
20  TIER_ALIAS,
21  tierRankOfModel,
22  TIMEOUT_MS,
23} from '../../jev/shared/router-policy.ts'
24
25// Claude Code side only: thresholds and questions live in shared/router-policy.ts, shared with Pi.
26const MODEL_ID: Record<string, string> = { sonnet: 'claude-sonnet-5-5', opus: 'claude-opus-5-5' }
27const DEBUG = { to: 'debug' } as const
28
29// The usage row reads this: the model and effort the router settled on, since the session's own are not the ones answering.
30const routing = atom({ plugin: 'lexi', key: 'routing' } as const, null)
31
32type HookArgs<N extends EventName> = Parameters<Hook<N>>
33type Plan = { fromModel: string; fromEffort: unknown; model?: string; effort?: Effort }
34
35let apiKey: string | undefined | null = null // null: not read yet
36let sessionAnswer: Answer | undefined
37let sessionPlan: Plan | undefined
38let hasWarnedUnavailable = false
39
40// session.start does not fire on /clear (session.end with reason 'clear' does): reset on both.
41export async function onSessionStart($: EngineInterface, e: HookArgs<'session.start'>[1], next: HookArgs<'session.start'>[2]) {
42  await resetSession($)
43  return next(e)
44}
45
46export async function onSessionEnd($: EngineInterface, e: HookArgs<'session.end'>[1], next: HookArgs<'session.end'>[2]) {
47  await resetSession($)
48  return next(e)
49}
50
51async function resetSession($: EngineInterface) {
52  sessionAnswer = undefined
53  sessionPlan = undefined
54  await update($, routing, () => null)
55}
56
57// One hook per event: the model is chosen once per session, the lexi flow on every prompt.
58export async function onPromptSubmit($: EngineInterface, e: HookArgs<'prompt.submit'>[1], next: HookArgs<'prompt.submit'>[2]) {
59  if (sessionAnswer) $.ui.log('[jev-router] reusing the first prompt\'s choice', DEBUG)
60  // ponytail: sequential, so the first prompt can wait up to two Jev timeouts; run both at once if it shows.
61  else sessionAnswer = await classify($, 'prompt', { prompt: e.text })
62  const context = await routeFlow($, e.text)
63  return next(context ? { ...e, context: [...(e.context ?? []), context] } : e)
64}
65
66// In a lexi project (`.lexi.json` is the opt-in), Jev names the flow and its order rides along as context.
67async function routeFlow($: EngineInterface, prompt: string): Promise<string | undefined> {
68  if (!isRoutable(prompt) || !(await $.fs.read('.lexi.json').then(() => true, () => false))) return undefined
69  const key = await readApiKey($)
70  if (!key) return undefined
71  const outcome = await postJev($, { model: JEV_MODEL, state: { prompt }, questions: FLOW_QUESTIONS }, key)
72  const answer = typeof outcome === 'string' ? outcome : parseFlow(outcome.text)
73  if (typeof answer === 'string') {
74    $.ui.log(`[jev-flow] jev unavailable: ${answer}, no routing for this prompt`, DEBUG)
75    return undefined
76  }
77  // Shown like the router's line, so the person sees which skill Jev sent the prompt to; `none` stays in debug.
78  const skill = flowSkill(answer.flow, answer.confidence)
79  $.ui.log(`[jev-flow] ${answer.flow} (${answer.confidence.toFixed(2)})${skill ? ` → ${skill}` : ''}`, skill ? {} : DEBUG)
80  return flowContext(answer.flow, answer.confidence)
81}
82
83export async function* onTurnStep($: EngineInterface, e: HookArgs<'turn.step'>[1], next: HookArgs<'turn.step'>[2]) {
84  if (e.agentId !== undefined || !sessionAnswer) return yield* next(e)
85  if (!sessionPlan) {
86    sessionPlan = startSessionPlan($, sessionAnswer, e.model, e.effort)
87    await update($, routing, () => ({ model: shortModel(sessionPlan?.model ?? e.model), effort: String(sessionPlan?.effort ?? e.effort ?? 'default') }))
88  }
89  const isModelOurs = sessionPlan.model && e.model === sessionPlan.fromModel
90  const isEffortOurs = sessionPlan.effort && e.effort === sessionPlan.fromEffort
91  return yield* next({
92    ...e,
93    ...(isModelOurs ? { model: sessionPlan.model } : {}),
94    ...(isEffortOurs ? { effort: sessionPlan.effort } : {}),
95  })
96}
97
98export async function onAgentSpawn($: EngineInterface, e: HookArgs<'agent.spawn'>[1], next: HookArgs<'agent.spawn'>[2]) {
99  if (e.fork) return next(e)
100  const label = `subagent ${e.subagentType}`
101  const answer = await classify($, label, { prompt: e.prompt, description: e.description, agentType: e.subagentType })
102  if (!answer) return next(e)
103  const current = e.model ?? e.parentModel
104  const rank = targetTierRank(answer)
105  const alias = isMoveAllowed(tierRankOfModel(current), rank, answer.tierConfidence, isRisky(answer)) ? aliasOfRank(rank) : undefined
106  $.ui.log(`[jev-router] ${label}: ${alias ? `model ${shortModel(current)} → ${alias}` : 'unchanged'} (${causeOf(answer)})`, DEBUG)
107  const started = await next(alias ? { ...e, model: alias } : e)
108  if (started.model) $.ui.log(`[jev-router] ${label}: ${shortModel(started.model)}`)
109  return started
110}
111
112function startSessionPlan($: EngineInterface, answer: Answer, model: string, effort: unknown): Plan {
113  const tierRank = sessionTierRank(answer)
114  const effortRank = targetEffortRank(answer)
115  const moveModel = isMoveAllowed(tierRankOfModel(model), tierRank, answer.tierConfidence, isRisky(answer))
116  const moveEffort = isMoveAllowed(effortRankOf(effort), effortRank, answer.effortConfidence, isRisky(answer))
117  const plan: Plan = { fromModel: model, fromEffort: effort }
118  if (moveModel) plan.model = MODEL_ID[aliasOfRank(tierRank)] ?? aliasOfRank(tierRank)
119  if (moveEffort) plan.effort = EFFORTS[effortRank]
120  const changes = [
121    plan.model && `model ${shortModel(model)} → ${shortModel(plan.model)}`,
122    plan.effort && `effort ${String(effort ?? 'default')} → ${plan.effort}`,
123  ].filter(Boolean)
124  const summary = `${shortModel(plan.model ?? model)} · effort ${String(plan.effort ?? effort ?? 'default')}`
125  $.ui.log(`[jev-router] ${changes.length ? changes.join(', ') : `unchanged — staying on ${summary}`} (${causeOf(answer)})`, DEBUG)
126  $.ui.log(`[jev-router] session: ${summary}`)
127  return plan
128}
129
130async function classify($: EngineInterface, label: string, state: Record<string, string>): Promise<Answer | undefined> {
131  const key = await readApiKey($)
132  if (!key) return undefined
133  const result = await askJev($, state, key)
134  if (typeof result === 'string') {
135    // First failure goes to the transcript (bad key, blocked network), later ones to debug only.
136    $.ui.log(`[jev-router] jev unavailable (${label}): ${result}, retrying next time`, hasWarnedUnavailable ? DEBUG : {})
137    hasWarnedUnavailable = true
138    return undefined
139  }
140  const effortName = EFFORTS[result.effort] ?? result.effort
141  $.ui.log(`[jev-router] classification (${label}): ${result.tier} → ${TIER_ALIAS[result.tier]} (${result.tierConfidence.toFixed(2)}) · effort ${effortName} (${result.effortConfidence.toFixed(2)}) · risk ${result.risk.toFixed(2)} · ${result.ms}ms`, DEBUG)
142  return result
143}
144
145async function readApiKey($: EngineInterface): Promise<string | undefined> {
146  if (apiKey !== null) return apiKey
147  apiKey = await $.env.get('TYPESAFE_API_KEY')
148  if (apiKey) $.ui.log(`[jev-router] ready — ${JEV_URL}, timeout ${TIMEOUT_MS}ms`, DEBUG)
149  else $.ui.log('[jev-router] TYPESAFE_API_KEY missing: add it under "env" in .claude/settings.local.json or export it in the shell. Router off.')
150  return apiKey
151}
152
153async function askJev($: EngineInterface, state: Record<string, string>, key: string): Promise<Answer | string> {
154  const outcome = await postJev($, { model: JEV_MODEL, state, questions: QUESTIONS }, key)
155  return typeof outcome === 'string' ? outcome : parseAnswer(outcome.text, outcome.ms)
156}
157
158// Any failure (timeout, non-2xx, exception) becomes a reason string, never a throw.
159async function postJev($: EngineInterface, body: object, key: string): Promise<{ text: string; ms: number } | string> {
160  const startedAt = await $.clock.now()
161  const timer = new AbortController()
162  const timeout = $.clock.sleep(TIMEOUT_MS, { signal: timer.signal }).then(() => 'timeout', () => 'timeout')
163  const request = $.http
164    .fetch(JEV_URL, {
165      method: 'POST',
166      headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
167      body: JSON.stringify(body),
168    })
169    .then((response) => (response.ok ? response : `HTTP ${response.status}`), (error: unknown) => `error ${String(error)}`)
170  const outcome = await Promise.race([request, timeout])
171  timer.abort()
172  if (typeof outcome === 'string') return outcome
173  return { text: outcome.text, ms: (await $.clock.now()) - startedAt }
174}
175
engine/ui/agents.tsx 117 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook, MatchedHook } from 'claude-code'
3import type { AgentRun } from '../types'
4import { shortModel } from '../../jev/shared/router-policy.ts'
5import { colorOf } from './palette.ts'
6
7export const PANE = 'lexi-agents' as const
8const TITLE = 'Subagents'
9
10const agents = atom({ plugin: 'lexi', key: 'agents' } as const, [])
11const selected = atom({ plugin: 'lexi', key: 'selected' } as const, null)
12
13// shortcut: keeps the last 50 subagents of the session; raise it if a long session needs more.
14const KEEP = 50
15
16type Spawned = { id: string; type: string; description: string; model?: string }
17
18const startRun = (list: readonly AgentRun[], { id, type, description, model }: Spawned): AgentRun => ({
19  id, type, description,
20  ...(model === undefined ? {} : { model: shortModel(model) }),
21  label: `${type} #${list.filter((run) => run.type === type).length + 1}`,
22  color: colorOf(type),
23  isDone: false,
24  steps: [],
25})
26
27export const onAgentSpawn: Hook<'agent.spawn'> = async ($, e, next) => {
28  const started = await next(e)
29  const id = started.agentId
30  if (id === undefined) return started
31  const isFirst = (await read($, agents)).length === 0
32  // Not awaited: on a narrow terminal the engine holds an unasked pane until there is room.
33  if (isFirst) void $.ui.open({ id: PANE, title: TITLE })
34  await update($, agents, (list) => [...list, startRun(list, { id, type: e.subagentType, description: e.description, model: started.model })].slice(-KEEP))
35  return started
36}
37
38// The argument that says what a call did, cut to one short line.
39const INPUT_KEYS = ['command', 'file_path', 'pattern', 'path', 'url', 'query', 'description'] as const
40const inputOf = (e: Readonly<Record<string, unknown>>): string => {
41  const value = INPUT_KEYS.map((key) => e[key]).find((one) => typeof one === 'string')
42  return typeof value === 'string' ? value.split('\n')[0]!.slice(0, 60) : ''
43}
44
45// One step of a subagent's timeline; calls of the main loop carry no agentId and are skipped.
46export const onAgentToolCall: Hook<'tool.call'> = async ($, e, next) => {
47  const ran = await next(e)
48  if (e.agentId === undefined) return ran
49  const step = { tool: e.tool, input: inputOf(e), isError: ran.deny !== undefined || ran.isError === true }
50  await update($, agents, (list) => list.map((run) => (run.id === e.agentId ? { ...run, steps: [...run.steps, step] } : run)))
51  return ran
52}
53
54export const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
55  await $.command.register({ name: 'subagents', description: 'Show the subagents of this session, colored by type' })
56  return next(e)
57}
58
59export const onAgentsCommand: MatchedHook<'command.run', { command: 'subagents' }> = async ($) => {
60  await $.ui.open({ id: PANE, title: TITLE })
61  return { text: 'Subagents pane opened.' }
62}
63
64export const onTurnComplete: Hook<'turn.complete'> = async ($, e, next) => {
65  const done = await next(e)
66  if (e.agentId === undefined) return done
67  const tokens = e.usage === undefined ? undefined : e.usage.input_tokens + e.usage.output_tokens
68  const finished = { isDone: true, durationMs: e.durationMs, tokens, answer: e.answer }
69  await update($, agents, (list) => list.map((run) => (run.id === e.agentId ? { ...run, ...finished } : run)))
70  return done
71}
72
73export const stepsOf = (run: AgentRun): string => `${run.steps.length} step${run.steps.length === 1 ? '' : 's'}`
74
75// A failed step outranks the run's own state: that is what a glance at the list should catch.
76export const hasFailedStep = (run: AgentRun): boolean => run.steps.some((step) => step.isError)
77const glyphOf = (run: AgentRun): string => (hasFailedStep(run) ? '✗' : run.isDone ? '✓' : '▶')
78
79const statusOf = (run: AgentRun): string =>
80  run.isDone ? `done · ${((run.durationMs ?? 0) / 1000).toFixed(1)}s · ${run.tokens ?? 0} tok` : 'running'
81
82type Elements = ReturnType<EngineInterface['ui']['resolve']>
83type Select = (id: string | null) => Promise<unknown>
84
85const listView = (ui: Elements, list: readonly AgentRun[], select: Select) => (
86  <ui.Box flexDirection="column">
87    {list.length === 0 && <ui.Text dimColor>No subagents yet.</ui.Text>}
88    {[...list.filter((run) => !run.isDone), ...list.filter((run) => run.isDone)].map((run) => (
89      <ui.Box>
90        <ui.Text color={run.color}>{glyphOf(run)} {run.label} · {run.model === undefined ? '' : `${run.model} · `}{statusOf(run)} · {stepsOf(run)} · {run.description} </ui.Text>
91        <ui.Button key={run.id} label="open" onPress={() => select(run.id)} />
92      </ui.Box>
93    ))}
94  </ui.Box>
95)
96
97const detailView = (ui: Elements, run: AgentRun, select: Select) => (
98  <ui.Box flexDirection="column">
99    <ui.Text color={run.color}>{run.label} · {statusOf(run)}</ui.Text>
100    <ui.Text dimColor>{run.description}</ui.Text>
101    {run.steps.map((step) => (
102      <ui.Text color={step.isError ? 'error' : undefined}>{step.isError ? '✗' : '✓'} {step.tool} {step.input}</ui.Text>
103    ))}
104    <ui.Text>{run.answer ?? '…'}</ui.Text>
105    <ui.Button key="back" label="back" onPress={() => select(null)} />
106  </ui.Box>
107)
108
109export const onPane: MatchedHook<'ui.render', { component: 'Pane'; requestId: typeof PANE }> = async ($, e) => {
110  const ui = $.ui.resolve(e)
111  const list = await read($, agents)
112  const id = await read($, selected)
113  const run = list.find((one) => one.id === id)
114  const select: Select = (next) => update($, selected, () => next)
115  return run === undefined ? listView(ui, list, select) : detailView(ui, run, select)
116}
117
engine/ui/band.tsx 49 lines
1import { atom, read } from 'claude-code'
2import type { MatchedHook } from 'claude-code'
3import type { AgentRun, GateRun } from '../types'
4import { hasFailedStep, stepsOf } from './agents.tsx'
5import { usageRow } from './usage.tsx'
6import type { Elements } from './usage.tsx'
7
8// The engine's state scan wants each atom spelled in the file that reads it: gate.ts, agents.tsx and usage.tsx write these.
9const lastGate = atom({ plugin: 'lexi', key: 'lastGate' } as const, null)
10const agents = atom({ plugin: 'lexi', key: 'agents' } as const, [])
11const usage = atom({ plugin: 'lexi', key: 'usage' } as const, null)
12
13// One short row: the rest are counted, the pane lists them all.
14const SHOWN = 4
15
16const secondsOf = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
17
18const gateRow = (ui: Elements, run: GateRun, age: number) => (
19  <ui.Box>
20    <ui.Text color={run.isGreen ? 'success' : 'error'}>gate {run.isGreen ? 'GREEN' : 'RED'}</ui.Text>
21    <ui.Text dimColor> · {secondsOf(run.durationMs)} · {age}s ago</ui.Text>
22  </ui.Box>
23)
24
25const agentsRow = (ui: Elements, running: readonly AgentRun[]) => (
26  <ui.Box>
27    {running.slice(0, SHOWN).map((run) => <ui.Text color={run.color}>{hasFailedStep(run) ? '✗' : '●'} {run.label} · {stepsOf(run)} </ui.Text>)}
28    {running.length > SHOWN && <ui.Text dimColor>+{running.length - SHOWN} more</ui.Text>}
29  </ui.Box>
30)
31
32// Above the prompt, whatever the terminal's width: the session's usage, the last gate run and the subagents running now.
33export const onAbovePrompt: MatchedHook<'ui.render', { component: 'AbovePrompt' }> = async ($, e, next) => {
34  const run = await read($, lastGate)
35  const running = (await read($, agents)).filter((one) => !one.isDone)
36  const now = await read($, usage)
37  if ((now === null && run === null && running.length === 0) || e.props.hasSurvey) return next(e)
38  const ui = $.ui.resolve(e)
39  const at = await $.clock.now()
40  const age = run === null ? 0 : Math.round((at - run.at) / 1000)
41  return (
42    <ui.Box flexDirection="column">
43      {now !== null && usageRow(ui, now, at)}
44      {run !== null && gateRow(ui, run, age)}
45      {running.length > 0 && agentsRow(ui, running)}
46    </ui.Box>
47  )
48}
49
engine/ui/gate.ts 37 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, MatchedHook } from 'claude-code'
3
4const lastGate = atom({ plugin: 'lexi', key: 'lastGate' } as const, null)
5
6// The first line of lexi's guard refusal (hooks/tdd_guard.py): `lexi — edit to `x` BLOCKED.`
7const GUARD_LINE = /^lexi — .* BLOCKED\.$/m
8
9// The project's gate from `.lexi.json`; undefined outside a lexi project, so the mod stays quiet.
10const gateOf = async ($: EngineInterface): Promise<string | undefined> => {
11  const text = await $.fs.read('.lexi.json').then(String, () => undefined)
12  try {
13    const gate: unknown = text === undefined ? undefined : JSON.parse(text).gate
14    return typeof gate === 'string' && gate !== '' ? gate : undefined
15  } catch {
16    return undefined
17  }
18}
19
20// The tools lexi's guard covers (hooks/hooks.json): the gate runs in Bash, a block lands on any of them.
21export const GUARDED = { tool: /^(Bash|Edit|Write|NotebookEdit)$/ }
22
23export const onToolCall: MatchedHook<'tool.call', typeof GUARDED> = async ($, e, next) => {
24  const gate = e.tool === 'Bash' ? await gateOf($) : undefined
25  const startedAt = await $.clock.now()
26  const ran = await next(e)
27  if (ran.deny !== undefined) return ran
28  const blocked = ran.isError ? ran.text?.match(GUARD_LINE)?.[0] : undefined
29  if (blocked) $.ui.toast(blocked)
30  if (e.tool !== 'Bash' || gate === undefined || !e.command.includes(gate)) return ran
31  const at = await $.clock.now()
32  const isGreen = !ran.isError
33  await update($, lastGate, () => ({ isGreen, durationMs: at - startedAt, at }))
34  $.ui.status(isGreen ? 'lexi:GREEN' : 'lexi:RED')
35  return ran
36}
37
engine/ui/usage.tsx 79 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Hook } from 'claude-code'
3import type { Usage } from '../types'
4
5export type Elements = ReturnType<EngineInterface['ui']['resolve']>
6
7const usage = atom({ plugin: 'lexi', key: 'usage' } as const, null)
8const routing = atom({ plugin: 'lexi', key: 'routing' } as const, null)
9
10// The usage gauges warm as a window fills.
11export const GREEN = '#81c784'
12export const YELLOW = '#fff176'
13export const ORANGE = '#ffb74d'
14export const RED = '#e57373'
15const COST = '#4fc3f7'
16
17const BAR_WIDTH = 10
18const EIGHTHS = ' ▏▎▍▌▋▊▉'
19const WINDOWS: Readonly<Record<string, string>> = { five_hour: '5h', seven_day: 'wk', spend_limit: 'spend' }
20
21const warmthOf = (percent: number): string => (percent >= 90 ? RED : percent >= 75 ? ORANGE : percent >= 50 ? YELLOW : GREEN)
22
23// The filled cells, to an eighth of a cell, and the empty track after them.
24const barOf = (percent: number): [string, string] => {
25  const cells = (Math.max(0, Math.min(100, percent)) / 100) * BAR_WIDTH
26  const whole = Math.floor(cells)
27  const partial = whole < BAR_WIDTH ? EIGHTHS[Math.floor((cells - whole) * 8)]!.trim() : ''
28  return ['█'.repeat(whole) + partial, '░'.repeat(BAR_WIDTH - whole - partial.length)]
29}
30
31// Time left to a reset, as 1d12h, 3h29m or 7m; empty once it has passed.
32const countdownOf = (resetsAt: string | undefined, now: number): string => {
33  const left = resetsAt === undefined ? 0 : Math.floor((Date.parse(resetsAt) - now) / 60_000)
34  if (!(left > 0)) return ''
35  const days = Math.floor(left / 1440)
36  const hours = Math.floor((left % 1440) / 60)
37  if (days > 0) return `${days}d${hours}h`
38  return hours > 0 ? `${hours}h${String(left % 60).padStart(2, '0')}m` : `${left}m`
39}
40
41const tokensOf = (tokens: number): string =>
42  tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M tok` : tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}k tok` : `${tokens} tok`
43
44const gauge = (ui: Elements, label: string, percent: number, countdown = '') => {
45  const [filled, track] = barOf(percent)
46  return (
47    <ui.Box>
48      <ui.Text dimColor>{label} </ui.Text>
49      <ui.Text color={warmthOf(percent)}>{filled}</ui.Text>
50      <ui.Text dimColor>{track}</ui.Text>
51      <ui.Text color={warmthOf(percent)}> {Math.round(percent)}%</ui.Text>
52      {countdown !== '' && <ui.Text dimColor> ⟳{countdown}</ui.Text>}
53    </ui.Box>
54  )
55}
56
57// shortcut: countdowns move only when the engine measures (each turn); add a $.clock tick if they must run live.
58export const usageRow = (ui: Elements, now: Usage, at: number) => {
59  const limits = now.rateLimits.filter((limit) => WINDOWS[limit.kind] !== undefined)
60  const segments = [
61    <ui.Text bold>{now.model}</ui.Text>,
62    gauge(ui, 'ctx', now.context.percent ?? 0),
63    ...limits.map((limit) => gauge(ui, WINDOWS[limit.kind]!, limit.percentUsed, countdownOf(limit.resetsAt, at))),
64    ...(now.context.tokens === undefined ? [] : [<ui.Text dimColor>{tokensOf(now.context.tokens)}</ui.Text>]),
65    ...(now.usd === undefined ? [] : [<ui.Text color={COST}>${now.usd.toFixed(2)}</ui.Text>]),
66  ]
67  return <ui.Box>{segments.flatMap((one, index) => (index === 0 ? [one] : [<ui.Text dimColor> │ </ui.Text>, one]))}</ui.Box>
68}
69
70// The engine's own status-line figures, pushed after each turn and on each rate-limit move.
71export const onMeasure: Hook<'session.measure'> = async ($, e, next) => {
72  const routed = await read($, routing)
73  // The router swaps the model per step, so the session's own model is not the one answering.
74  const model = routed === null ? await $.session.model() : `${routed.model} · ${routed.effort}`
75  const { tokens, percent } = e.context
76  await update($, usage, () => ({ model, context: { tokens, percent }, rateLimits: [...e.rateLimits], usd: e.cost?.usd }))
77  return next(e)
78}
79
jev/shared/compact-policy.ts 110 lines
1// Compaction policy: questions to Jev, thresholds, verdict and formatting. No runtime dependency, so
2// the Claude Code hook (`jev/hooks/compact.ts`) and the Pi extension (`pi/extensions/jev-compact/`)
3// share the same numbers.
4
5export const KEEP_THRESHOLD = 0.5 // minimum probability to keep
6export const PRESERVE_RECENT = 6 // last messages never touched (the first is immune too)
7export const TRUNCATE_HEAD = 300 // what is left of a truncated result
8export const MAX_STATE_TOKENS = 25_000 // cap on the state sent to Jev
9export const MAX_REQUEST_TOKENS = 30_000 // cap on one request (state + questions)
10export const MIN_REDUCTION = 0.3 // below this, defer to native (rewriting the cache does not pay off)
11export const JEV_TIMEOUT_MS = 8_000
12export const JEV_MODEL = 'jev-latest'
13export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
14export const ARG_KEYS = ['file_path', 'command', 'pattern', 'url', 'query', 'path']
15
16export type Verdict = 'keep' | 'truncate' | 'drop'
17export type Question = { type: 'noul'; instructions: string; criteria: { true: string; false: string } }
18// The minimum needed to ask the questions and read the verdict: each runtime maps its own tool
19// call representation onto it.
20export type CallInfo = { id: string; tool: string; resultChars: number }
21
22export const isOpen = (i: number, total: number) => i > 0 && i < total - PRESERVE_RECENT
23
24// The state Jev reads: structure and history without tool result contents. Each runtime builds it
25// from its own messages; the JSON shape is the same.
26export type StateCall = { id: string; tool: string; input?: unknown; result: string }
27export type StateEntry = { i: number; role: string; text: string; tool_calls?: StateCall[] }
28export type JevState = { goal: string[]; instructions?: string; conversation: StateEntry[] }
29
30// Shrinks in steps until it fits: call inputs, then long texts, then whole messages, always from
31// the oldest unprotected ones. undefined if that is not enough.
32export const fitState = (state: JevState, total: number): JevState | undefined => {
33  const open = state.conversation.filter((entry) => isOpen(entry.i, total))
34  const lengths = new Map(open.map((entry) => [entry, entry.text.length]))
35  const clipInputs = () => state.conversation.forEach((entry) => entry.tool_calls?.forEach((c) => (c.input = clip(JSON.stringify(c.input), 200))))
36  const shorten = (entry: StateEntry) => () => (entry.text = headTail(entry.text))
37  const collapse = (entry: StateEntry) => () => {
38    entry.text = `[… ${lengths.get(entry)} chars]`
39    entry.tool_calls?.forEach((c) => (c.input = undefined))
40  }
41  for (const step of [() => {}, clipInputs, ...open.map(shorten), ...open.map(collapse)]) {
42    step()
43    if (tokensOf(state) <= MAX_STATE_TOKENS) return state
44  }
45  return undefined
46}
47
48export const questionsFor = ({ id, tool, resultChars }: CallInfo): Record<string, Question> => ({
49  [`keep_call_${id}`]: {
50    type: 'noul',
51    instructions: `Tool call \`${id}\` (\`${tool}\`) must stay in the history: knowing it was made, with its input, still matters for what the assistant does next.`,
52    criteria: { true: 'The assistant will still refer to this call or its input.', false: 'Step superseded: exploration finished, empty search, file later rewritten, nothing that steers the work.' },
53  },
54  [`keep_result_${id}`]: {
55    type: 'noul',
56    instructions: `The full output of tool call \`${id}\` (\`${tool}\`, ${resultChars} characters) must stay verbatim: the assistant still needs its content and re-running the tool would not do.`,
57    criteria: { true: 'Needed word for word and not recoverable by re-running the tool (exact error, non-repeatable output, changed state).', false: 'Superseded or recoverable by re-running the tool: file later edited or re-readable, listing, search, output already summarised.' },
58  },
59})
60
61// Batches under MAX_REQUEST_TOKENS: each one resends the full state.
62export const toBatches = (state: unknown, calls: readonly CallInfo[]): Record<string, Question>[] => {
63  const room = MAX_REQUEST_TOKENS - tokensOf(state)
64  const batches: Record<string, Question>[] = [{}]
65  let used = 0
66  for (const call of calls) {
67    const questions = questionsFor(call)
68    if (used > 0 && used + tokensOf(questions) > room) (batches.push({}), (used = 0))
69    Object.assign(batches[batches.length - 1] ?? {}, questions)
70    used += tokensOf(questions)
71  }
72  return batches
73}
74
75export const parseAnswers = (text: string, ids: string[]): Record<string, number> | string => {
76  let answers: Record<string, { noul?: unknown } | undefined> | undefined
77  try {
78    answers = (JSON.parse(text) as { answers?: typeof answers } | null)?.answers
79  } catch {
80    return 'answer is not JSON'
81  }
82  const entries = ids.flatMap((id) => {
83    const noul = answers?.[id]?.noul
84    return typeof noul === 'number' && Number.isFinite(noul) ? [[id, noul] as const] : []
85  })
86  return entries.length === ids.length ? Object.fromEntries(entries) : 'answer without valid noul'
87}
88
89export const verdictOf = (answers: Record<string, number>, { id, resultChars }: CallInfo): Verdict => {
90  if ((answers[`keep_result_${id}`] ?? 1) >= KEEP_THRESHOLD) return 'keep'
91  if ((answers[`keep_call_${id}`] ?? 1) < KEEP_THRESHOLD) return 'drop'
92  return resultChars > TRUNCATE_HEAD ? 'truncate' : 'keep'
93}
94
95export const truncated = (text: string) => `${text.slice(0, TRUNCATE_HEAD)}\n[… ${text.length - TRUNCATE_HEAD} chars removed]`
96
97export const tokensOf = (value: unknown) => JSON.stringify(value).length / 4
98export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
99export const headTail = (text: string) => (text.length > 800 ? `${text.slice(0, 400)} […] ${text.slice(-400)}` : text)
100export const percent = (ratio: number) => `${Math.round(ratio * 100)}%`
101export const kilo = (chars: number) => (chars >= 1000 ? `${(chars / 1000).toFixed(1)}k` : `${chars}`)
102export const bar = (pct: number) => '█'.repeat(Math.round(pct / 10)) + '░'.repeat(10 - Math.round(pct / 10))
103export const argumentOf = (input: Record<string, unknown>) =>
104  clip((ARG_KEYS.map((key) => input[key]).find((v): v is string => typeof v === 'string') ?? JSON.stringify(input)).replace(/\s+/g, ' ').trim(), 70)
105
106export const describeCall = ({ tool, resultChars }: CallInfo, verdict: Verdict, input: Record<string, unknown>) =>
107  verdict === 'drop'
108    ? `✗ dropped    ${tool} · ${argumentOf(input)}  (-${kilo(resultChars + JSON.stringify(input).length)} chars)`
109    : `✂ truncated  ${tool} · ${argumentOf(input)}  (${kilo(resultChars)} → ${TRUNCATE_HEAD} chars)`
110
jev/shared/flow-policy.ts 57 lines
1// Flow policy: which lexi skill a prompt goes to. No runtime dependency, so the Claude Code hook
2// (`jev/hooks/flow.ts`) and the Pi extension (`pi/extensions/jev-flow.ts`) share it.
3
4export const FLOWS = ['bug', 'feature', 'open', 'none'] as const
5export type Flow = (typeof FLOWS)[number]
6export type FlowAnswer = { flow: Flow; confidence: number }
7
8export const FLOW_QUESTIONS = {
9  flow: {
10    type: 'choice',
11    instructions: 'In a project that writes a failing test before any code change, which flow fits this prompt?',
12    criteria: {
13      bug: 'Existing behaviour is broken: an error, a crash, a wrong result, a failing test, a regression the user reports.',
14      feature: 'A new or changed behaviour whose design the prompt already settles: what to build is stated, only how remains.',
15      open: 'A new or changed behaviour with several defensible designs or unstated product intent: decisions come before tests can be named.',
16      none: 'No change to production behaviour: a question, an explanation, docs, config, a review, a pure refactor, or a reply that continues the current task ("ok", "go on").',
17    },
18  },
19}
20type JevResponse = { answers?: { flow?: { choice?: string; confidence?: number } } }
21
22export const parseFlow = (text: string): FlowAnswer | string => {
23  let body: JevResponse
24  try {
25    body = JSON.parse(text) as JevResponse
26  } catch {
27    return 'unreadable JSON'
28  }
29  const answer = body.answers?.flow
30  const flow = FLOWS.find((name) => name === answer?.choice)
31  if (!flow) return 'answer without a valid flow'
32  return { flow, confidence: answer?.confidence ?? 0 }
33}
34
35export const MIN_CONFIDENCE = 0.6
36const SKILL_OF: Record<Exclude<Flow, 'none'>, string> = { bug: 'bug', feature: 'feature', open: 'grill' }
37
38// `skillOf` names a skill for the runtime: `lexi:bug` on Claude Code, `lexi-bug` on Pi.
39type SkillOf = (name: string) => string
40const LEXI_SKILL: SkillOf = (name) => `lexi:${name}`
41
42// The skill the flow sends a prompt to: its own when confident, lexi's router when not, none for `none`.
43export const flowSkill = (flow: Flow, confidence: number, skillOf = LEXI_SKILL): string | undefined => {
44  if (flow === 'none') return undefined
45  return skillOf(confidence < MIN_CONFIDENCE ? 'lexi' : SKILL_OF[flow])
46}
47
48export const flowContext = (flow: Flow, confidence: number, skillOf = LEXI_SKILL): string | undefined => {
49  const skill = flowSkill(flow, confidence, skillOf)
50  if (!skill) return undefined
51  if (confidence < MIN_CONFIDENCE) return `lexi flow (Jev): unsure (${flow}, ${confidence.toFixed(2)}). If this prompt asks for a code change, load the skill "${skill}" first: it routes.`
52  return `lexi flow (Jev): ${flow}, confidence ${confidence.toFixed(2)}. Load the skill "${skill}" before anything else. If that is clearly wrong for this prompt, say why in one line and load "${skillOf('lexi')}" instead.`
53}
54
55// A slash command already names what runs: classifying it would only second-guess the user.
56export const isRoutable = (prompt: string): boolean => !prompt.trimStart().startsWith('/')
57
jev/shared/router-policy.ts 84 lines
1// Router policy: questions to Jev, thresholds and decision. No runtime dependency, so the Claude
2// Code hook (`jev/hooks/router.ts`) and the Pi extension (`pi/extensions/jev-router/`) share it.
3
4export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
5export const JEV_MODEL = 'jev-latest'
6export const TIMEOUT_MS = 1500
7export const UP_CONFIDENCE = 0.3
8export const DOWN_CONFIDENCE = 0.6
9export const RISK_THRESHOLD = 0.7
10export const TIERS = ['trivial', 'fast', 'balanced', 'deep'] as const
11// Opus 5.5 beats Fable 5.1 at every cost point, so `deep` is Opus at a higher effort.
12export const TIER_ALIAS: Record<Tier, string> = { trivial: 'haiku', fast: 'sonnet', balanced: 'opus', deep: 'opus' }
13export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
14export const MIN_EFFORT = 1 // medium: Sonnet 5.5's start for agentic coding; at low it can call a change done unchecked
15export const DEEP_EFFORT = 2 // high
16export const RISKY_EFFORT = 3 // xhigh: max scores lower and costs more on Opus 5.5
17
18export const QUESTIONS = {
19  tier: {
20    type: 'choice',
21    instructions: 'Which is the cheapest tier that can complete this coding task well?',
22    criteria: {
23      trivial: 'Read-only work, a single shell command, or a mechanical edit whose exact change is already described: read/search/summarise files, run one command and report its output, answer from context, fix a typo, rename a symbol, apply a described change.',
24      fast: 'Small edit that still needs judgement: fix a bug whose cause is already known, add tests for a change in one or two files, a short change in one or two files that is not spelled out.',
25      balanced: 'Ordinary engineering: implement a well-specified change across several files, fix a bug with clear symptoms that still has to be located in the code, review a diff.',
26      deep: 'Hard or high-stakes: architecture and design, debugging a failure whose cause is unknown, security, data migrations, concurrency, anything touching production or money.',
27    },
28  },
29  effort: {
30    type: 'score',
31    instructions: 'How much step-by-step reasoning does this task need?',
32    criteria: ['almost none', 'some', 'a lot', 'as much as possible'],
33  },
34  risky: { type: 'noul', instructions: 'Carrying out this task would itself change production, move real money, or alter data that cannot be restored. Writing or testing code that deals with such things, without running it against the real system, does not count.' },
35}
36
37export type Tier = (typeof TIERS)[number]
38export type SessionTier = Exclude<Tier, 'trivial'>
39export type Effort = (typeof EFFORTS)[number]
40export type Answer = { tier: Tier; tierConfidence: number; effort: number; effortConfidence: number; risk: number; ms: number }
41type JevAnswers = { tier?: { choice?: string; confidence?: number }; effort?: { score?: number; confidence?: number }; risky?: { noul?: number } }
42type JevResponse = { answers?: JevAnswers }
43
44export const parseAnswer = (text: string, ms: number): Answer | string => {
45  let body: JevResponse
46  try {
47    body = JSON.parse(text) as JevResponse
48  } catch {
49    return 'unreadable JSON'
50  }
51  const answers = body.answers
52  const tier = TIERS.find((name) => name === answers?.tier?.choice)
53  const score = answers?.effort?.score
54  // Jev's score is continuous (0.78 = between "almost none" and "some"): take the nearest level.
55  if (!tier || typeof score !== 'number' || !(score >= 0 && score <= 3)) return 'answer without a valid tier/effort'
56  const effort = Math.round(score)
57  const confidenceOf = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
58  return { tier, tierConfidence: confidenceOf(answers?.tier?.confidence), effort, effortConfidence: confidenceOf(answers?.effort?.confidence), risk: confidenceOf(answers?.risky?.noul), ms }
59}
60
61// An unknown current value (-1: haiku, a numeric effort, an unrecognised id) counts as below
62// every level, so any move from it is a raise: it can only go up.
63export const isMoveAllowed = (from: number, to: number, confidence: number, isForced: boolean): boolean => {
64  if (to === from) return false
65  if (isForced) return to > from
66  return confidence >= (to > from ? UP_CONFIDENCE : DOWN_CONFIDENCE)
67}
68
69export const isRisky = (answer: Answer) => answer.risk > RISK_THRESHOLD
70// An off-scale level ('off', 'minimal', a number) ranks -1: below all, so it can only go up.
71export const effortRankOf = (level: unknown) => (EFFORTS as readonly unknown[]).indexOf(level)
72export const targetTierRank = (answer: Answer) => (isRisky(answer) ? TIERS.length - 1 : TIERS.indexOf(answer.tier))
73export const targetEffortRank = (answer: Answer) => {
74  if (isRisky(answer)) return Math.max(answer.effort, RISKY_EFFORT)
75  return Math.max(answer.effort, answer.tier === 'deep' ? DEEP_EFFORT : MIN_EFFORT)
76}
77// The session holds its model to the end: haiku is for one-shot subagents only.
78export const sessionTierRank = (answer: Answer) => Math.max(targetTierRank(answer), TIERS.indexOf('fast'))
79export const aliasOfRank = (rank: number) => TIER_ALIAS[TIERS[rank] ?? 'deep']
80export const tierRankOfModel = (model: string) => TIERS.findIndex((tier) => model.includes(TIER_ALIAS[tier]))
81export const shortModel = (model: string) => Object.values(TIER_ALIAS).find((alias) => model.includes(alias)) ?? model
82export const causeOf = (answer: Answer) =>
83  isRisky(answer) ? `risk ${answer.risk.toFixed(2)}` : `${answer.tier}, confidence ${answer.tierConfidence.toFixed(2)}`
84
engine/ui/palette.ts 8 lines
1// Distinct on dark and light terminals; a type keeps its color across sessions.
2export const PALETTE: readonly string[] = ['#4fc3f7', '#ffb74d', '#81c784', '#ba68c8', '#e57373', '#fff176', '#4db6ac', '#f06292']
3
4// djb2: a stable string hash, so the same subagent type always lands on the same color.
5const hashOf = (text: string): number => [...text].reduce((hash, char) => ((hash * 33) ^ char.charCodeAt(0)) >>> 0, 5381)
6
7export const colorOf = (type: string): string => PALETTE[hashOf(type) % PALETTE.length] ?? PALETTE[0]!
8
engine/types/index.d.ts 37 lines
1// The last run of the project's gate, as the status line and the band show it.
2export type GateRun = { isGreen: boolean; durationMs: number; at: number }
3
4// One tool call a subagent made: the tool, a short form of its input, whether it errored.
5export type AgentStep = { tool: string; input: string; isError: boolean }
6
7export type AgentRun = {
8  id: string
9  type: string
10  label: string
11  color: string
12  description: string
13  model?: string
14  isDone: boolean
15  steps: AgentStep[]
16  durationMs?: number
17  tokens?: number
18  answer?: string
19}
20
21// The engine's status-line figures at its last measurement, as the band's usage row draws them.
22export type Usage = {
23  model: string
24  context: { tokens?: number; percent?: number }
25  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
26  usd?: number
27}
28
29// The model and effort the Jev router settled on for the session, as the usage row shows them.
30export type Routing = { model: string; effort: string }
31
32declare module 'claude-code' {
33  interface PluginState {
34    'lexi': { lastGate: GateRun | null; agents: AgentRun[]; selected: string | null; usage: Usage | null; routing: Routing | null }
35  }
36}
37