A pane beside the chat showing the current mexxi /wettaq run (what it is doing, how far along, which gates passed, its port and PR), and a band suggesting a…

Coding agents write code fast. mexxi makes them prove it works.
A coding agent is an AI tool that works in your repository: it reads the code, runs commands and makes changes. Give the one you already use, Claude Code, Codex or OpenCode, a task in plain words. You get back one pull request that passes your repository's own checks, with screenshots from a real browser and a plain call on whether it is safe to merge. You still decide: mexxi never merges. It runs on your machine, with the agent and subscription you already have, and needs no service or account of its own.
mexxi is Maltese for "to lead, to manage". The names explains the rest of the Maltese.
Coding agents can now take a whole task: read the code, make the change and open a pull request. That moves the slow part of the job. Writing the code is quick; trusting it is not.
What an agent hands back on its own is hard to trust:
So you either read every line, which costs the time the agent saved, or merge on trust.
mexxi moves the checking into the run, so the pull request arrives with its proof. The ideas behind mexxi explains each one.
npm run verify by default, must pass before work starts and again at the end. ibni, the command-line tool the agent runs, runs those checks itself and records the exact code they passed on, and it refuses to push any other code. A later change has to be checked and recorded again.To see the difference, compare two pull requests for the same request in a small demo app: without mexxi and with mexxi. Reviewing a mexxi pull request explains each part and what to check in a minute.
You need one of Claude Code, Codex or OpenCode, Node.js 22.13 or newer, and Git. You also need the forge's CLI, signed in, to open the pull request: gh for GitHub or glab for GitLab.
Three tools are optional. The Playwright MCP server lets the agent drive a browser itself; without it, the pull request's evidence says which checks did not run. The review tool OpenCodeReview (ocr) picks what the reviewer looks at. ffmpeg makes GIFs.
Paste this into your agent to install mexxi. It checks the prerequisites, asks what it cannot detect, and shows what it will change before it changes anything:
Set up mexxi for me. Read https://raw.githubusercontent.com/alexportelli12/mexxi/main/skills/ibda/SKILL.md and follow it.
Then paste this in a repository to prepare it. /lesti opens one pull request that adds what mexxi needs, such as the verify command, and fixes the checks that already fail.
Make this repository ready for mexxi: run /lesti here.
From the repository's default branch, type this in your agent's chat to ask for a change with /wettaq, the command that builds it:
/wettaq show the last sync time on the settings page
The setup guide covers installing by hand and what each prerequisite is for.
Coding agents load their instructions as commands you type, agents they can hand part of a task to, and skills they load only when a task needs them. The glossary explains each term.
/wettaq builds a change, /ittestja tests a UI by hand in a browser, and /lesti gets a repository ready. Three more look after the workflow itself: /fassal writes UX principles, /sewwi changes the workflow and /irrifletti looks back at sessions.ibni, which takes each task's worktree from prepare to publish, with step timings./irrifletti measures how recent sessions went, privately, on your machine.The same files serve Claude Code, Codex and OpenCode; Host details says what each one adds.
The tools are named in Maltese, the author's native language, like Verifika. Each name is an everyday word for the tool's job: /wettaq means "to carry out", /lesti "to get something ready" and ibni "to build". The names is a small dictionary of all of them.
The details live in the docs, generated from this repository so they cannot drift from it:
ibni CLI reference and every configuration key.Go for it. I made this repository public to share the workflow I use day to day. It is opinionated around how I like to work, so take the parts that fit and change the rest.
mexxi is released under the MIT licence. To send a change back, read CONTRIBUTING.md first.
The frontend-design and skill-creator skills are adapted from Anthropic's skills repository, under the Apache License 2.0. Each folder keeps its licence in LICENSE.txt.
hooks/register.tsx 214 lines1// The run pane: a pane beside the chat that follows the ibni workspace this
2// session works in, read from `ibni info` and `ibni stats` whenever a Bash
3// call runs ibni and after every turn, plus a band above the prompt once the
4// PR is delivered or the context window runs high (the context nudge).
5// Claude Code only; ibni's metadata stays the record every host shares.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { RunPaneSnapshot } from '../types'
10import type { StageState } from './journey'
11import { duration, headline, journey, lastEventAt, progress, snapshotFrom } from './journey'
12import { bar, confidenceTone, nudge, nudgePercentFrom, sessionIdFrom, since, taskTitle, triageRanking } from './run'
13
14const PANE = 'run-pane'
15
16// One mark and one theme colour per stage state, so the journey reads at a
17// glance in any theme: green done, accent for the stage in progress, red for
18// the one that needs you, quiet for what has not happened.
19const MARK: Record<StageState, { glyph: string; color: string }> = {
20 done: { glyph: '✓', color: 'success' },
21 current: { glyph: '●', color: 'claude' },
22 failed: { glyph: '✗', color: 'error' },
23 pending: { glyph: '○', color: 'inactive' },
24 skipped: { glyph: '–', color: 'inactive' },
25}
26const run = atom({ plugin: 'run-pane', key: 'run' } as const, null)
27const contextPercent = atom({ plugin: 'run-pane', key: 'contextPercent' } as const, null)
28const nudgePercent = atom({ plugin: 'run-pane', key: 'nudgePercent' } as const, 70)
29const dismissed = atom({ plugin: 'run-pane', key: 'dismissed' } as const, null)
30
31async function ibniJson($: EngineInterface, subcommand: string, sessionId: string): Promise<unknown> {
32 const { exitCode, stdout, stderr } = await $.process.run(['ibni', subcommand, '--session', sessionId])
33 if (exitCode !== 0) throw new Error(stderr.trim().split('\n')[0] || `ibni ${subcommand} exited ${exitCode}`)
34 return JSON.parse(stdout)
35}
36
37async function refresh($: EngineInterface, sessionId: string): Promise<void> {
38 let snapshot: RunPaneSnapshot
39 try {
40 snapshot = snapshotFrom(sessionId, await ibniJson($, 'info', sessionId), await ibniJson($, 'stats', sessionId))
41 } catch (error) {
42 const previous = await read($, run)
43 snapshot = { ...(previous?.sessionId === sessionId ? previous : { sessionId, steps: [] }), error: String((error as Error).message ?? error) }
44 }
45 await update($, run, () => snapshot)
46}
47
48async function configText($: EngineInterface): Promise<string | undefined> {
49 const file = (await $.env.get('MEXXI_CONFIG')) ?? `${(await $.env.get('HOME')) ?? ''}/.config/mexxi/config.json`
50 try {
51 return await $.fs.read(file)
52 } catch {
53 return undefined
54 }
55}
56
57export const register: Register = on => {
58 on('session.start', async ($, e, next) => {
59 await $.command.register({ name: 'run-pane', description: 'Show the current mexxi run: where it is, what passed, what needs you, and its PR' })
60 const text = await configText($)
61 await update($, nudgePercent, () => nudgePercentFrom(text))
62 return next(e)
63 })
64
65 on('command.run', { command: 'run-pane' }, async $ => {
66 const current = await read($, run)
67 if (current) await refresh($, current.sessionId)
68 await $.ui.open({ id: PANE, title: 'mexxi run' })
69 return { text: current ? `Run pane opened for ibni session ${current.sessionId}.` : 'Run pane opened; it fills once this session runs ibni.' }
70 })
71
72 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
73 const ran = await next(e)
74 const sessionId = sessionIdFrom(e.command, ran.text)
75 if (sessionId) {
76 const isNew = (await read($, run))?.sessionId !== sessionId
77 await refresh($, sessionId)
78 if (isNew) void $.ui.open({ id: PANE, title: 'mexxi run' })
79 }
80 return ran
81 }).catch(($, e, next) => next(e))
82
83 on('turn.complete', async ($, e, next) => {
84 const done = await next(e)
85 const { context } = await $.session.usage()
86 await update($, contextPercent, () => context.percent ?? null)
87 const current = await read($, run)
88 if (current) await refresh($, current.sessionId)
89 return done
90 })
91
92 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
93 const { Box, Text, Link } = $.ui.resolve(e)
94 const current = await read($, run)
95 if (!current) {
96 return (
97 <Box flexDirection="column" gap={1}>
98 <Text bold>No run yet</Text>
99 <Text dimColor>This pane follows a /wettaq run. It fills in once ibni prepares or adopts a workspace.</Text>
100 </Box>
101 )
102 }
103 const now = await $.clock.now()
104 const status = headline(current)
105 const { task, repository } = taskTitle(current)
106 const stages = journey(current)
107 const { done, total } = progress(stages)
108 const started = since(current.createdAt, now)
109 const lastEvent = lastEventAt(current)
110 const onCurrent = lastEvent ? duration(now - Date.parse(lastEvent)) : undefined
111 const triage = triageRanking(current)
112 const questionWidth = Math.max(0, ...triage.map(row => row.question.length))
113 const answerWidth = Math.max(0, ...triage.map(row => row.answer.length))
114 const port = current.port === null || current.port === undefined ? undefined : `port ${current.port}`
115 return (
116 <Box flexDirection="column" gap={1}>
117 {/* The card: what a glance needs, in the colour of where the run stands. */}
118 <Box borderStyle="round" borderColor={status.tone} paddingX={1} flexDirection="column">
119 <Text>
120 <Text backgroundColor={status.tone} color="inverseText" weight="bold">{` ${status.label} `}</Text>
121 {started ? <Text dimColor>{` started ${started}`}</Text> : null}
122 </Text>
123 <Text weight="bold" wrap="truncate-end">{task}</Text>
124 <Text dimColor>{[repository, port].filter(Boolean).join(' · ') || ' '}</Text>
125 <Text>
126 {stages.map(stage => (
127 <Text color={MARK[stage.state].color}>{stage.state === 'pending' || stage.state === 'skipped' ? '▱' : '▰'}</Text>
128 ))}
129 <Text dimColor>{` ${done} of ${total}`}</Text>
130 </Text>
131 </Box>
132
133 <Box flexDirection="column" paddingX={1}>
134 {stages.map(stage => (
135 <Text>
136 <Text color={MARK[stage.state].color}>{MARK[stage.state].glyph}</Text>
137 {' '}
138 <Text
139 weight={stage.state === 'current' || stage.state === 'failed' ? 'semibold' : 'regular'}
140 color={stage.state === 'failed' ? 'error' : stage.state === 'current' ? 'claude' : undefined}
141 dimColor={stage.state === 'pending' || stage.state === 'skipped'}
142 >
143 {stage.label}
144 </Text>
145 {stage.detail ? <Text dimColor>{` ${stage.detail}`}</Text> : null}
146 {stage.state === 'current' && onCurrent ? <Text color="claude">{` ${onCurrent}`}</Text> : null}
147 </Text>
148 ))}
149 </Box>
150
151 {current.prUrl ? (
152 <Box paddingX={1}>
153 <Text>
154 <Text color="success" weight="bold">{'↗ '}</Text>
155 <Link href={current.prUrl}>{`Open PR #${current.prUrl.split('/').at(-1)}`}</Link>
156 </Text>
157 </Box>
158 ) : null}
159
160 {/* Jev's call, ranked by how sure it was. Shadow only: it is recorded
161 beside the agent's own choices and changes nothing. */}
162 {triage.length > 0 ? (
163 <Box borderStyle="round" borderColor="suggestion" paddingX={1} flexDirection="column" gap={1}>
164 <Text>
165 <Text backgroundColor="suggestion" color="inverseText" weight="bold">{' JEV TRIAGE '}</Text>
166 <Text dimColor>{' shadow'}</Text>
167 </Text>
168 <Box flexDirection="column">
169 {triage.map(row => {
170 const cells = bar(row.percent)
171 return (
172 <Text tabular>
173 <Text>{`${row.question.padEnd(questionWidth)} `}</Text>
174 <Text weight="bold">{`${row.answer.padEnd(answerWidth)} `}</Text>
175 <Text color={confidenceTone(row.percent)}>{cells.filled}</Text>
176 <Text dimColor>{cells.empty}</Text>
177 <Text color={confidenceTone(row.percent)}>{` ${String(row.percent).padStart(3)}%`}</Text>
178 </Text>
179 )
180 })}
181 </Box>
182 <Text dimColor wrap="wrap">Recorded only; changes nothing.</Text>
183 </Box>
184 ) : null}
185
186 {current.error ? (
187 <Box borderStyle="round" borderColor="warning" paddingX={1} flexDirection="column">
188 <Text color="warning" weight="semibold">ibni didn't answer</Text>
189 <Text>{current.error}</Text>
190 </Box>
191 ) : null}
192 </Box>
193 )
194 })
195
196 // The band: one quiet line above the prompt, only while Claude is idle, so
197 // it never competes with the spinner.
198 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
199 if (e.props.hasSurvey || e.props.isWorking) return next(e)
200 const shown = nudge(await read($, run), await read($, contextPercent), await read($, nudgePercent))
201 if (!shown || (await read($, dismissed)) === shown.key) return next(e)
202 const { Box, Button, Text } = $.ui.resolve(e)
203 return (
204 <Box gap={1}>
205 <Text wrap="truncate-end">
206 <Text backgroundColor={shown.tone} color="inverseText" weight="bold">{` ${shown.badge} `}</Text>
207 <Text dimColor>{` ${shown.text}`}</Text>
208 </Text>
209 <Button key="dismiss" label="Dismiss" onPress={() => update($, dismissed, () => shown.key)} />
210 </Box>
211 )
212 })
213}
214hooks/journey.ts 173 lines1// How a run reads, from ibni's own record of it: the snapshot of `ibni info`
2// and `ibni stats`, its journey from workspace to pull request, the headline
3// and how far along it is. Pure and free of Claude Code imports, so the run
4// pane and segwi's web app (scripts/segwi/web) draw a run the same way.
5// Index access uses brackets so the web app's stricter compiler accepts it.
6
7// The same shape as RunPaneSnapshot in ../types, which must stay
8// self-contained for Claude Code, so it cannot import these; TypeScript
9// matches the two structurally; change them together.
10export type RunStep = {
11 step: string
12 outcome: string
13 durationMs?: number
14 endedAt?: string
15}
16
17export type RunGate = {
18 rounds: number
19 findings: number
20}
21
22// The fields of `ibni info` and `ibni stats` a run is drawn from; triage is
23// Jev's latest answers (stats' triage.answers), null until one is answered.
24export type RunSnapshot = {
25 sessionId: string
26 branch?: string
27 state?: string
28 worktreePath?: string
29 port?: number | null
30 prUrl?: string | null
31 createdAt?: string
32 steps: RunStep[]
33 verify?: { attempts: number; passed: number; failed: number; history: { outcome: string; at?: string }[] }
34 review?: RunGate & { crossRounds?: number }
35 qa?: RunGate
36 triage?: Record<string, unknown> | null
37 error?: string
38}
39
40type Json = Record<string, unknown>
41
42export function record(value: unknown): Json {
43 return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Json) : {}
44}
45
46// One snapshot from both answers; stats wins where both carry a field, since
47// it is read second. Triage rows are stats' latest Jev answers, absent until
48// a triage call is answered.
49export function snapshotFrom(sessionId: string, info: unknown, stats: unknown): RunSnapshot {
50 const merged: Json = { ...record(info), ...record(stats) }
51 const steps = Array.isArray(merged['steps']) ? merged['steps'].map(record) : []
52 const triage = record(record(stats)['triage'])['answers']
53 const review = record(merged['review'])
54 return {
55 sessionId,
56 branch: typeof merged['branch'] === 'string' ? merged['branch'] : undefined,
57 state: typeof merged['state'] === 'string' ? merged['state'] : undefined,
58 worktreePath: typeof merged['worktreePath'] === 'string' ? merged['worktreePath'] : undefined,
59 port: typeof merged['port'] === 'number' ? merged['port'] : null,
60 prUrl: typeof merged['prUrl'] === 'string' ? merged['prUrl'] : null,
61 createdAt: typeof merged['createdAt'] === 'string' ? merged['createdAt'] : undefined,
62 steps: steps.map(step => ({
63 step: String(step['step'] ?? '?'),
64 outcome: String(step['outcome'] ?? '?'),
65 durationMs: typeof step['durationMs'] === 'number' ? step['durationMs'] : undefined,
66 endedAt: typeof step['endedAt'] === 'string' ? step['endedAt'] : undefined,
67 })),
68 verify: merged['verify'] as RunSnapshot['verify'],
69 review: merged['review']
70 ? {
71 rounds: Number(review['rounds'] ?? 0),
72 findings: Number(review['findings'] ?? 0),
73 crossRounds: Number(record(record(review['byReviewer'])['cross'])['rounds'] ?? 0),
74 }
75 : undefined,
76 qa: merged['qa'] as RunSnapshot['qa'],
77 triage: triage && typeof triage === 'object' ? (triage as Record<string, unknown>) : null,
78 }
79}
80
81// How a run reads: a headline, then the run's journey from workspace to pull
82// request, each stage done, current, failed, pending or skipped. Derived from
83// ibni's own record only, so a run reads the same whichever host started it.
84export type Tone = 'success' | 'error' | 'warning' | 'claude'
85export type StageState = 'done' | 'current' | 'failed' | 'pending' | 'skipped'
86export type Stage = { key: string; label: string; state: StageState; detail?: string }
87
88const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : /s$/.test(word) ? 'es' : 's'}`
89
90export function isDelivered(run: RunSnapshot | null): boolean {
91 return Boolean(run && (run.prUrl || run.state === 'pr-open'))
92}
93
94export function journey(run: RunSnapshot): Stage[] {
95 const delivered = isDelivered(run)
96 const verify = run.verify
97 const lastVerify = verify?.history.at(-1)?.outcome
98 const lastDelivery = [...run.steps].reverse().find(step => step.step === 'publish' || step.step === 'mark-pr')
99 const ready = run.steps.some(step => (step.step === 'prepare' || step.step === 'adopt') && step.outcome === 'succeeded') || Boolean(run.worktreePath)
100 const gate = (key: string, label: string, gateRun: RunSnapshot['qa'], extra = ''): Stage =>
101 gateRun && gateRun.rounds > 0
102 ? { key, label, state: 'done', detail: `${plural(gateRun.rounds, 'pass')} · ${plural(gateRun.findings, 'finding')}${extra}` }
103 : { key, label, state: delivered ? 'skipped' : 'pending', detail: delivered ? 'not run' : undefined }
104
105 const stages: Stage[] = [
106 { key: 'workspace', label: 'Workspace ready', state: ready ? 'done' : 'pending' },
107 { key: 'build', label: 'Build the change', state: verify?.attempts || lastDelivery || delivered ? 'done' : 'pending' },
108 verify && verify.attempts > 0
109 ? {
110 key: 'verify',
111 label: 'Verify',
112 state: lastVerify === 'pass' ? 'done' : 'failed',
113 detail: `${lastVerify === 'pass' ? 'green' : 'red'} · ${verify.passed} of ${plural(verify.attempts, 'run')} passed`,
114 }
115 : { key: 'verify', label: 'Verify', state: 'pending' },
116 gate('qa', 'Rendered QA', run.qa),
117 gate('review', 'Independent review', run.review, run.review?.crossRounds ? ' · cross-family' : ''),
118 run.prUrl
119 ? { key: 'pr', label: 'Pull request', state: 'done', detail: `#${run.prUrl.split('/').at(-1)} open` }
120 : lastDelivery && lastDelivery.outcome !== 'succeeded'
121 ? { key: 'pr', label: 'Pull request', state: 'failed', detail: `${lastDelivery.step} ${lastDelivery.outcome.replace(/-/g, ' ')}` }
122 : { key: 'pr', label: 'Pull request', state: delivered ? 'done' : 'pending' },
123 ]
124 // The first stage not yet done is the one in progress, unless one failed.
125 if (stages.some(stage => stage.state === 'failed')) return stages
126 const next = stages.findIndex(stage => stage.state === 'pending')
127 return stages.map((stage, index) => (index === next ? { ...stage, state: 'current' } : stage))
128}
129
130// The capsule at the top: one or two words a person reads at a glance, in
131// the colour of where the run stands. Uppercase, like a status line segment.
132const DOING: Record<string, string> = {
133 workspace: 'PREPARING',
134 build: 'BUILDING',
135 verify: 'VERIFYING',
136 qa: 'TESTING UI',
137 review: 'IN REVIEW',
138 pr: 'PUBLISHING',
139}
140
141export function headline(run: RunSnapshot): { label: string; tone: Tone } {
142 if (run.error) return { label: 'IBNI OFFLINE', tone: 'warning' }
143 const stages = journey(run)
144 if (stages.some(stage => stage.state === 'failed')) return { label: 'NEEDS ATTENTION', tone: 'error' }
145 if (isDelivered(run)) return { label: 'PR OPEN', tone: 'success' }
146 if (run.state === 'published') return { label: 'PUBLISHED', tone: 'success' }
147 const current = stages.find(stage => stage.state === 'current')
148 return { label: current ? DOING[current.key] ?? 'IN PROGRESS' : 'IN PROGRESS', tone: 'claude' }
149}
150
151// How far along the run is: stages done or skipped, out of all of them.
152export function progress(stages: Stage[]): { done: number; total: number } {
153 return { done: stages.filter(stage => stage.state === 'done' || stage.state === 'skipped').length, total: stages.length }
154}
155
156// When ibni last recorded anything for the run: the stage in progress has
157// been running since then.
158export function lastEventAt(run: RunSnapshot): string | undefined {
159 const times = [...run.steps.map(step => step.endedAt), ...(run.verify?.history ?? []).map(entry => entry.at)].filter(
160 (at): at is string => typeof at === 'string' && !Number.isNaN(Date.parse(at)),
161 )
162 return times.sort().at(-1) ?? run.createdAt
163}
164
165// A duration the way a status line shows one: 45s, 12m, 1h 05m.
166export function duration(ms: number): string {
167 const seconds = Math.max(0, Math.round(ms / 1000))
168 if (seconds < 60) return `${seconds}s`
169 const minutes = Math.round(seconds / 60)
170 if (minutes < 60) return `${minutes}m`
171 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
172}
173hooks/run.ts 106 lines1// What the run pane reads from ibni and how it words it, apart from the hooks
2// so tests can feed recorded `ibni info` / `ibni stats` answers directly. How a
3// run's journey reads lives in journey.ts, which segwi's web app shares.
4import { isDelivered, record } from './journey'
5import type { Tone } from './journey'
6import type { RunPaneSnapshot } from '../types'
7
8const IBNI = /(?:^|[\s;&|(/])(?:ibni|implementation-workspace)\s+([a-z-]+)/
9
10// The ibni session a Bash command worked on: its --session argument, or the
11// sessionId `prepare` and `adopt` print when they create or take one over.
12export function sessionIdFrom(command: string, output: string | undefined): string | null {
13 const match = IBNI.exec(command)
14 if (!match) return null
15 const flag = /--session(?:=|\s+)['"]?([A-Za-z0-9_-]+)/.exec(command)
16 if (flag?.[1]) return flag[1]
17 if (match[1] !== 'prepare' && match[1] !== 'adopt') return null
18 return /"sessionId"\s*:\s*"([A-Za-z0-9_-]+)"/.exec(output ?? '')?.[1] ?? null
19}
20
21// The task's short name and the repository it runs in, read from the branch
22// and the worktree path (<root>/<repository>-<hash>/<slug>).
23export function taskTitle(run: RunPaneSnapshot): { task: string; repository?: string } {
24 const task = (run.branch ?? run.sessionId).replace(/^agent\//, '')
25 const folder = run.worktreePath?.split('/').at(-2)
26 return { task, repository: folder?.replace(/-[0-9a-f]{10}$/, '') }
27}
28
29// "started 12m ago", from the workspace's createdAt.
30export function since(iso: string | undefined, now: number): string | undefined {
31 const at = iso ? Date.parse(iso) : Number.NaN
32 if (Number.isNaN(at)) return undefined
33 const minutes = Math.max(0, Math.round((now - at) / 60000))
34 if (minutes < 1) return 'just now'
35 if (minutes < 60) return `${minutes}m ago`
36 const hours = Math.floor(minutes / 60)
37 return hours < 24 ? `${hours}h ${minutes % 60}m ago` : `${Math.floor(hours / 24)}d ago`
38}
39
40// Jev's answers, most confident first, each with the question asked in plain
41// words and its confidence as a percentage. Questions Jev did not answer, or
42// answered without a confidence, are left out.
43// Each question as a short label, so the panel stays narrow.
44const QUESTIONS: Record<string, string> = {
45 oneWayDoor: '1-way',
46 userFacing: 'users',
47 existingUi: 'UI',
48 size: 'size',
49 reviewValue: 'review',
50}
51
52export type TriageRow = { key: string; question: string; answer: string; percent: number }
53
54export function triageRanking(run: RunPaneSnapshot): TriageRow[] {
55 const answers = run.triage ?? {}
56 return Object.entries(answers)
57 .map(([key, value]) => {
58 const item = record(value)
59 const confidence = Number(item.confidence)
60 if (!('answer' in item) || !Number.isFinite(confidence)) return null
61 return { key, question: QUESTIONS[key] ?? key, answer: String(item.answer), percent: Math.round(Math.min(1, Math.max(0, confidence)) * 100) }
62 })
63 .filter((row): row is TriageRow => row !== null)
64 .sort((a, b) => b.percent - a.percent || a.question.localeCompare(b.question))
65}
66
67// A ten-cell bar for a percentage: filled cells, then empty ones.
68export function bar(percent: number, width = 10): { filled: string; empty: string } {
69 const cells = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
70 return { filled: '▰'.repeat(cells), empty: '▱'.repeat(width - cells) }
71}
72
73// How sure Jev was, as a theme colour: green when sure, accent when fairly
74// sure, amber when it was close to guessing.
75export function confidenceTone(percent: number): Tone {
76 return percent >= 80 ? 'success' : percent >= 60 ? 'claude' : 'warning'
77}
78
79// The band's one-line hint, or null: a short badge for what happened and
80// what to do about it. Its key changes with the reason, so dismissing one
81// does not silence the next. A new session picks a pull request back up when
82// /wettaq is given its link (it adopts the workspace itself), so the hint
83// names that rather than an ibni command.
84export type Nudge = { key: string; badge: string; tone: Tone; text: string }
85
86export function nudge(run: RunPaneSnapshot | null, percent: number | null, threshold: number): Nudge | null {
87 if (run && isDelivered(run)) {
88 const pr = run.prUrl ? `PR #${run.prUrl.split('/').at(-1)}` : 'PR'
89 return { key: `delivered:${run.sessionId}`, badge: `✓ ${pr} delivered`, tone: 'success', text: 'Follow-ups go in a fresh session: give /wettaq the PR link.' }
90 }
91 if (percent !== null && percent >= threshold) {
92 return { key: `context:${threshold}`, badge: `${percent}% context`, tone: 'warning', text: 'Finish this run here, then start the next task in a fresh session.' }
93 }
94 return null
95}
96
97// claude.contextNudgePercent from the mexxi config.json text, or the default.
98export function nudgePercentFrom(configText: string | undefined, fallback = 70): number {
99 try {
100 const value = record(record(JSON.parse(configText ?? '{}')).claude).contextNudgePercent
101 return typeof value === 'number' && value >= 1 && value <= 100 ? value : fallback
102 } catch {
103 return fallback
104 }
105}
106types/index.d.ts 45 lines1// What the run pane keeps for the session: the ibni workspace it follows, the
2// last `ibni info` and `ibni stats` answers, the context window's fill, and
3// which band the person dismissed.
4
5export type RunPaneStep = {
6 step: string
7 outcome: string
8 durationMs?: number
9 endedAt?: string
10}
11
12export type RunPaneGate = {
13 rounds: number
14 findings: number
15}
16
17// The fields of `ibni info` and `ibni stats` the pane draws; triage is Jev's
18// latest answers (stats' triage.answers), null until a triage call is answered.
19export type RunPaneSnapshot = {
20 sessionId: string
21 branch?: string
22 state?: string
23 worktreePath?: string
24 port?: number | null
25 prUrl?: string | null
26 createdAt?: string
27 steps: RunPaneStep[]
28 verify?: { attempts: number; passed: number; failed: number; history: { outcome: string }[] }
29 review?: RunPaneGate & { crossRounds?: number }
30 qa?: RunPaneGate
31 triage?: Record<string, unknown> | null
32 error?: string
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'run-pane': {
38 run: RunPaneSnapshot | null
39 contextPercent: number | null
40 nudgePercent: number
41 dismissed: string | null
42 }
43 }
44}
45