Kor Code cockpit: status line, /kor views and drill-downs over the Kor daemon's live status, with the fixture behind /kor demo.

Kor turns Linear or GitHub issues into verified pull requests. You write an issue with acceptance criteria. Kor plans it, runs Claude Code on it in an isolated git worktree, checks the result against your repository's checks and your acceptance criteria, and reports progress on the issue in its selected tracker. You run it from Mission Control, a web app the Kor daemon serves on your own machine. With a fresh installer config’s ship: merge, Kor independently reviews and merges verified pull requests when GitHub checks are green and Jev gives at least 90% probability of a safe merge. You can choose manual approval and merging instead.
You install Kor once, and it works on any number of projects at the same time. Each project is a Git checkout on your machine, mapped to Linear or GitHub Issues (optionally a GitHub Project v2), and runs from different projects proceed in parallel (working on several projects).

Issue (Todo, in Linear or GitHub)
│ Start or Queue in Mission Control, kor run LEE-123, or the label "kor"
▼
Plan Jev judges readiness, risk, the model, the skills and the tools
▼
Work Claude Code runs in a git worktree through your CLIProxyAPI gateway,
with only the permissions you allow
▼
Verify your repository checks run, then Jev judges each acceptance criterion;
a failure goes back to the session, up to two repair attempts
▼
Approve you review the diff and the evidence (with ship: approve)
▼
Ship commit on kor/LEE-123, your delivery gate, a pull request
▼
Merge different-family review, green GitHub checks, Jev probability ≥ 0.90
▼
Tracker comments throughout; In Review when the PR opens, Done after merge
Jev is the decision service Kor calls through the TypeSafe API. It makes every per-task choice: whether the issue is ready, which verified model runs it, which skills and playbook the session gets, and whether each acceptance criterion is met.
In the runs behind these screenshots, each attempt at a small documentation change took 14 to 18 seconds from start to verified and cost under one cent. Failed checks and unmet criteria go through verification and bounded repair. If repair cannot resolve them, the run stops as blocked with a reason. Jev judges recorded permission denials alongside the diff and tool results; a recovered denial does not automatically block a verified change.
Each project chooses its tracker in Settings → Add project, or with kor install --tracker github --github-repository OWNER/REPO. Existing projects keep Linear by default. GitHub supports intake labels, priority, Project v2 Status mappings, comments, PR links, image attachments and home-box issue creation. Mission Control previews private tracker images through the authenticated daemon. Linear is optional when every configured project uses GitHub. See tracker configuration and setup for permissions, plain Issues and Project v2 examples (LEE-1323).
http://127.0.0.1:8317.origin, Git user.name and user.email, GitHub write access through GitHub CLI (gh auth login) or GH_TOKEN / GITHUB_TOKEN, plus verified models from two different families for automatic review. The cautious approve and auto modes use the no-mistakes CLI; repositories initialised for its pipeline also need its daemon running. An explicit "not initialized" response uses direct PR delivery. gh-axi is optional: GitHub connections, pull request lists and PR creation use the API. Tokens need repository contents write and pull request write access.PR writing reuses the task route, including a cross-provider override. If the overridden model cannot write the PR, Kor publishes an evidence-based fallback with a clear note; it does not select a replacement model.
curl -fsSL https://raw.githubusercontent.com/korallis/kor-code/main/scripts/install.sh | bash
The installer checks the requirements, clones Kor into ~/.local/share/kor-code, builds the CLI, daemon and Mission Control, and installs kor into ~/.local/bin. It also asks for one project to start with: the Git checkout path and its tracker. Linear projects ask for a team key; GitHub projects infer owner/repository from origin or accept --github-repository. That is only the first entry in Kor's config. You can add more projects afterwards in Mission Control or in config.yaml (working on several projects), and you do not rerun the installer for them. To answer up front, or without a terminal:
curl -fsSL https://raw.githubusercontent.com/korallis/kor-code/main/scripts/install.sh | bash -s -- --pilot ~/code/my-project --linear-team APP
If ~/.local/bin is not on your PATH, add export PATH="$HOME/.local/bin:$PATH" to your shell profile. On a machine with no gateway key, the installer prints a new one as an api-keys line: add that line to CLIProxyAPI's config yourself and reload the proxy. Kor never edits the proxy. Keep the checkout on disk; the kor command and Mission Control run from it.
The first project cannot be skipped: with no --pilot and no terminal the installer stops and asks you to pass one, and the path must be a Git checkout root. The tracker and (for Linear) team key are only asked for when ~/.config/kor/config.yaml does not exist yet; rerunning the installer to update reuses the first project and never touches an existing config.
--no-systemd skips the systemd user unit, KOR_HOME moves the checkout and KOR_GATEWAY_URL points at another gateway. The user guide covers forks, private mirrors and a manual build.
1. Set your repository policy in ~/.config/kor/config.yaml. The installer writes a scaffold with your first project (its checkout, tracker settings, default branch and ship: merge) under repos:. Add the checks that decide whether work is accepted and the tools sessions may use:
repos:
my-project:
path: /home/you/code/my-project
linearTeam: APP
defaultBranch: main
ship: merge # guarded automatic merge; auto or approve leave merging to you
checks:
- pnpm test
toolAllowlist: [Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch, Agent]
limits:
total: 6
perRepository: 4
launchProfile: lean
pollIntervalSeconds: 60
intakeLabel: kor
Sessions get only what toolAllowlist allows, and an unscoped Read also allows file search. See Configure a repository.
repos: is a map with one entry per project, so Kor works on as many as you list, and Mission Control's Projects page shows each one. The installer only wrote the first. Add another in either of two ways:
config.yaml.bak. A project added here starts with ship: approve; set its checks and tool allowlist under the project's Repository settings.config.yaml: add another entry under repos:, then stop and restart the daemon (kor ui starts it again) so it rereads the file.Two projects look like this. Each entry needs its own checkout and its own linearTeam (or linearProject) when using Linear. GitHub projects instead set tracker: github and github.repository (configuration). These mappings route issues to the right repository:
repos:
web-app:
path: /home/you/code/web-app
linearTeam: WEB
defaultBranch: main
ship: approve
checks:
- pnpm test
toolAllowlist:
- Read
- Edit
- "Bash(pnpm test*)"
api-service:
path: /home/you/code/api-service
linearTeam: API
defaultBranch: main
ship: approve
checks:
- ./mvnw test
toolAllowlist:
- Read
- Edit
- "Bash(./mvnw test*)"
limits:
total: 2
perRepository: 1
Run kor doctor afterwards: it prints one block of checks for each project.
Runs from different projects work in parallel, each in its own worktree. limits.total caps how many runs are active across all projects, and limits.perRepository caps how many one project may have active. With the settings above, a web-app run and an api-service run go at the same time, and a second web-app issue waits for the first. Raise total and perRepository (for example 4 and 2) to run more at once; in Mission Control they are Runs at once and Runs per repository under Settings. Waiting runs do not hold a slot.
2. Check the setup. This is kor doctor in the throwaway home used for these docs:
$ kor doctor
PASS Kor config: /tmp/kor-rf/home/.config/kor/config.yaml
PASS Linear key: available
PASS TypeSafe key: available
PASS CLIProxyAPI with Kor key: reachable
FAIL CLIProxyAPI management access: management key missing
FAIL quota-axi: unavailable
PASS Claude Code: 2.1.300 (Claude Code)
FAIL no-mistakes daemon: not running
PASS repo pilot: /tmp/kor-rf/pilot (main)
PASS repo pilot shipping target: github.com
PASS repo pilot git identity: user.name and user.email configured
PASS repo pilot GitHub API: authenticated with repository write access; PR publication uses the API
PASS repo pilot push access: push dry run passed; no remote refs changed
PASS repo pilot tools: Bash, Edit, Glob, Grep, Read, Write (effective permission base names; automatic search requires unscoped Read); Jev selects optional tools per task; availability does not grant command permissions
FAIL doctor: one or more checks failed. Hint: fix the reported checks and rerun `kor doctor`
The management key and quota-axi are optional: without them Mission Control cannot show the gateway's account slots and quota windows. no-mistakes is required for repositories using its pipeline. The CLI must be installed for shipping; a repository explicitly reported as uninitialised uses direct PR delivery even when the daemon is stopped. Everything else must pass. What each line means.
3. Open Mission Control:
kor ui
This starts the daemon if it is not running and opens Mission Control in your browser, already signed in (under WSL, in the Windows browser). If the browser cannot open, it prints the address; paste the token from ~/.local/state/kor/ui-token into the connect screen.
4. Connect. Settings shows Linear, GitHub, the gateway and Jev. Keys in ~/.config/linear/env, ~/.config/typesafe/env and ~/.config/kor/gateway.env show as connected already; otherwise paste a key there. Saved keys are write-only and never sent back to the browser.

5. Let Kor verify models. On the first daemon start, Jev chooses a strong and a cheap coding model per available family from the live gateway catalogue. Kor probes them in the background for streaming, tool calls and a final JSON answer. Home and Models show progress. A work request submitted before the first model passes is saved, shows that it is waiting, and continues automatically. Verify models now starts or retries verification with one click.
Automatic probes reserve at most $0.10 each within a persisted $2 daily budget (reset at midnight UTC). Kor checks catalogue changes every minute, refreshes selected models after seven days and retries failures after 24 hours. kor doctor reports FAIL Verified models until at least one model passes. Manual probes remain available:
kor models probe --dry-run # list the chat and coding models the gateway offers
kor models probe --model MODEL-ID # probe one; a few cents
kor models list # the verified models
Verified models are the candidates Jev chooses from for each task.
Put acceptance criteria in the issue description under a heading named Acceptance criteria (or Acceptance), one bullet per criterion. Every line under that heading counts as a criterion, so keep notes above it. Check a ticket before you queue it:
$ kor linear lint LEE-1166
LEE-1166: blocked; hasScreenshot=false
acceptance-criteria: Add explicit acceptance criteria to the issue.
Issues labelled UI also need a screenshot or mockup; kor linear attach-image or kor github attach-image uploads one to the selected tracker. Then start the work in one of three ways: Start on Home's Next up, Queue an issue on Agents (or Queue for Kor in a project), or label a Todo issue kor and let the daemon pick it up on its next poll.

Jev 1.14 evaluates each ticket separately. Deterministic readiness checks still block missing acceptance, incomplete dependencies and missing UI screenshots. With those checks passing, Jev blocks only when it finds missing essential scope with at least 0.90 probability. Uncertainty can begin bounded implementation; acceptance still needs positive evidence at 0.70 and merge at 0.90. A real missing-scope veto still blocks when another Jev question is unavailable.
Routing first selects the reasoning class, then a verified model with explicit role criteria. Model profiles distinguish backend implementation, UI integration, research and correctness review. Probe cost/latency remain observations; unknown context limits are not invented. Uncertain class/model decisions and Jev outages use the configured default route when it is verified and profiled. Only an unavailable or unprofiled default needs a verified conservative replacement. Configured reasoning effort is preserved for both Jev choices and fallbacks. Mission Control shows the effective class and fallback reason alongside the raw decisions. See the Jev audit for measurements and the preflight contract for policy.
Automatic routing currently requires an exact maintained model profile. If all verified models are unprofiled, dispatch stops: select a supported model as the default, or add a profile as described in model compatibility.
Once an execution is ready, Request changes, operator answers and the intake label retain the verdict while issue scope, dependencies, eligible models and routing policy stay unchanged. UI screenshot requirements still apply. The intake label matches regardless of letter case.
Every screen updates in place over the daemon's live connection, including changes from the CLI and other open tabs, which share one event stream. Settings, queue changes and agent output stream as soon as Kor changes them. Linear issues reuse intake observations on the configured daemon poll schedule; GitHub reads are shared across tabs and refresh no faster than that schedule or once a minute. Quota keeps its one-minute throttle. LIVE means the event stream is connected and has supplied a snapshot. If it drops, the app marks the displayed data as stale, reconnects automatically with backoff, and resyncs missed changes and terminal output. You do not need to refresh the page.
Home shows what needs you, what is ready to ship, running agents, spend and Next up. A run that needs an answer lands in Needs you: type the answer and Send and retry writes it to the issue and runs again. Ctrl+K (⌘K) opens Ask Jev anywhere. Execution failures also appear in Needs you with Retry, and Kor posts the reason and a kor:blocked label to the tracker. Cancel run dismisses a failed run from Needs you. Retrying reuses the recorded route while task inputs and eligible models are unchanged; Kor's failure label does not trigger another route decision. Ask Jev accepts requests up to 2,000 characters. Give it work, for example “Kor-Code: fix the release label and add acceptance criteria.” Jev identifies the project, Kor drafts a scoped issue in that project’s tracker, labels it kor and queues a run. A free slot starts it through the usual Jev route, model and skill selection. The reply links the issue and run. “Approve and ship LEE-123”, “retry LEE-123”, “queue LEE-123” and “make LEE-123 high priority” carry out those actions. Stopping work asks for confirmation. Questions read the live snapshot without creating issues or changing runs; recommendation buttons perform their named action. An unreachable or uncertain Jev is identified plainly, with no action. Every Jev choice is recorded in the decision audit.
When adding a project in Settings, enter its checkout path and select its tracker. Supply its Linear team and project, or GitHub repository and optional Project v2. Enter a full checkout path starting with / or ~/; Mission Control rejects relative paths instead of joining the install directory. Absolute paths stay as entered. For Linear, give repositories on the same team different project mappings: ambiguous intake is refused and shown on Home.
Agents streams each run's real output, its seven stages, tokens and cost, with Stop run, Retry and a Note for Jev.

Issues is the decision workspace for a run: the issue, Jev's reasoning, the diff, the checks and each acceptance judgement, with Approve and ship, Request changes (R) and Explore alternatives (E).

Projects shows each repository's tracker issues with their runs, health and pull requests, and its repository settings. Models shows verified models, the gateway's account slots and the routing policy. Jev Brain shows the decision audit, calibration and what Jev has learned about skills.
Kor ships skills (coding and PR standards, Linear/GitHub tracker guidance, project setup, principles) and playbooks (procedures such as fix-bug or change-docs). Before each session Jev picks the skills and at most one playbook within a token budget; the run's Plan tab shows what each attempt received. The bundled hard-problem-resolution playbook handles accuracy, speed and competing technical approaches with measured research, an independent challenge and a recorded Jev decision before implementation and re-measurement. See how it works. Project setup shares the same documentation, screen mockups and image-coverage gates across trackers. Set repos.<key>.tracker to github for GitHub guidance; omitted values default to linear. GitHub projects use using-github and writing-github-tickets; Linear projects use their Linear counterparts. Jev filters tracker-specific guidance by the repository's tracker, including pins and overrides. The shared screen attachment skill supports both. See GitHub project planning for Projects v2 fields, milestones, dependencies and verified issue images (LEE-1323).
Keep your own guidance in a private pack outside the repository:
kor skills init # creates ~/.config/kor/skills
kor skills import --from ./my-skills --dry-run --skip-invalid
kor skills import --from ./my-skills --skip-invalid
kor skills doctor # the merged catalogue and its token costs
By default (skills.jev: full), Jev sees private skill descriptions and selection hints through TypeSafe so it can choose your skills automatically; bodies and references stay local. Opt out in config.yaml with skills: { jev: names-only }, or mark an individual skill metadata.kor.visibility: local-only. You can still pin private skills per repository with skills.pins. Import reports copy status without suggesting a visibility override. Doctor reports each body-budget warning once; skills over the 2,000-token pin limit can still be listed. Imported skills can reach sessions without adding a license or removing unrelated frontmatter. Hooks, forked execution and shell interpolation are refused; bundled scripts must use the session's permitted tools. The inline interpolation marker must start a line or follow whitespace; ordinary Markdown non-null assertions and template literals ending in ! pass. Fenced commands introduced by three backticks followed by ! are also refused. If a private skill shares a bundled name, Kor keeps the bundled skill active and warns. To use your version, add this to the private skill's frontmatter:
metadata:
kor:
overrides: public
kor skills guard checks private-only names and deny.txt entries against the public tier; existing bundled names are already public. More in Skills.
Explore alternatives lists every decision Jev made with its other candidates. Pick one, such as a different verified model, and Kor applies it from the next attempt if policy allows. The same dialog edits the next session's skills, playbook and tools, estimating the per-turn token cost of each change using the latest plan. Jev replans at session start, so the final total may differ; Sessions received records the guidance actually delivered. Every override is audited, commented on the Linear issue and can be reverted. Repository deny lists and tool policy always win.

Linear labels do the same per issue: kor:skill/NAME, kor:skill/-NAME, kor:playbook/NAME, kor:tool/NAME, kor:tool/-NAME.
The Claude Code status line counts overrides for the run it follows. Jev Brain reports operator-routed runs under override; they do not enter the Jev versus baseline paired comparison.
New installs use ship: merge: Kor commits and opens a PR, asks Jev to choose one verified reviewer from a different model family than every writer, and waits for green GitHub checks and clean branch protection status. Jev then judges the review, checks and change summary; automatic merge needs a probability of at least 0.90. Blocking findings or lower probabilities go back for repair within the run's existing two-repair budget. Otherwise the PR stays open with a reason. Mission Control shows the review, merge decision and override controls. An owner can override Jev's merge/wait choice; review and GitHub safeguards still apply. Kor never force-pushes or bypasses branch protection. Linear reaches Done after GitHub confirms the merge.
For more control, choose ship: auto to open verified PRs automatically and me
hooks/register.tsx 383 lines1// The Kor Code cockpit mod: a pinned status line, the /kor views in a pane,
2// and /kor-status as the sandbox's deterministic load proof. `/kor` draws the
3// daemon's live status (live.ts); `/kor demo` draws the fixture execution.
4// Live overrides go to the daemon, which checks policy, audits and comments
5// on Linear; the cockpit itself writes no settings, files or remote records.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import { installationPilot } from './install-scope'
11
12import type { KorOverride, KorProvenance, KorView } from '../types'
13import { DECISIONS, demoChoice, demoData, EVENTS } from './fixture'
14import { fetchStatus, fromStatus, liveSource, type LiveSource } from './live'
15import type { CockpitData } from './model'
16import { buildView, columnWidths, LABEL, legend, padCells, parseView, renderText, statusLine, tag, VIEWS, type LiveFacts, type Tone } from './views'
17
18const PANE = 'kor-cockpit'
19const view = atom({ plugin: 'kor-code-cockpit', key: 'view' } as const, { id: 'cockpit' })
20const mode = atom({ plugin: 'kor-code-cockpit', key: 'mode' } as const, 'live')
21const cursor = atom({ plugin: 'kor-code-cockpit', key: 'cursor' } as const, null)
22const overrides = atom({ plugin: 'kor-code-cockpit', key: 'overrides' } as const, [])
23
24const TAG_COLOR: Record<KorProvenance, string> = { live: 'success', stale: 'warning', fixture: 'suggestion', unavailable: 'inactive' }
25const TONE_COLOR: Record<Tone, string> = { ok: 'success', warn: 'warning', bad: 'error', dim: 'inactive', accent: 'suggestion' }
26const ACTIONS = [
27 { action: 'apply', label: 'Apply', hotkey: 'a' },
28 { action: 'override', label: 'Override', hotkey: 'o' },
29 { action: 'evidence', label: 'Gather evidence', hotkey: 'e' },
30 { action: 'reevaluate', label: 'Re-evaluate', hotkey: 'r' },
31] as const
32const VIEW_NAMES = 'cockpit|issue|project|decisions|decision <id>|agents|agent <id>|accounts|context|linear|overrides'
33const USAGE = [
34 `Usage: /kor [${VIEW_NAMES}]`,
35 ' /kor override <decision|route> <choice> sent to the daemon: policy-checked, audited, commented on Linear',
36 ' /kor revert <override>',
37 ` /kor demo [${VIEW_NAMES}|replay|apply|override|evidence|reevaluate <decision> [choice]|revert <override>]`,
38].join('\n')
39/** How often the status line and an open pane re-read the daemon. */
40const POLL_MS = 5000
41/** How long a status read waits before the daemon counts as not answering. */
42const STATUS_TIMEOUT_MS = 2000
43
44let replay: Timer | undefined
45let poll: Timer | undefined
46/** The last live read; the pane draws it, the poll refreshes it. */
47let latest: CockpitData | undefined
48
49function object(value: unknown): Record<string, unknown> | undefined {
50 return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : undefined
51}
52
53/** Keep execution sessions from mutating remotes or destroying their worktree. */
54function policyDecision(toolName: string, input: unknown): string | undefined {
55 if (toolName !== 'Bash') return undefined
56 const command = object(input)?.command
57 if (typeof command !== 'string') return undefined
58 const blocked = [
59 /(?:^|\s)git\s+push(?:\s|$)/,
60 /(?:^|\s)git\s+reset\s+--hard(?:\s|$)/,
61 /(?:^|\s)git\s+clean\s+-[^\n]*f/,
62 /(?:^|\s)git\s+branch\s+-D(?:\s|$)/,
63 /(?:^|\s)rm\s+-[^\n]*r[^\n]*f/,
64 ].some(pattern => pattern.test(command))
65 return blocked ? 'Kor policy denied a destructive or remote-mutating command.' : undefined
66}
67
68/** Inherited local settings must not activate Kor in a linked worktree. */
69async function active($: EngineInterface): Promise<boolean> {
70 if ((await $.env.get('KOR_COCKPIT')) === 'off') return false
71 if (installationPilot === undefined) return true
72 const directory = await $.fs.stat(await $.session.cwd(), { resolve: true }).catch(() => undefined)
73 return directory?.realPath === installationPilot
74}
75
76async function liveFacts($: EngineInterface): Promise<LiveFacts> {
77 return { claudeCode: (await $.session.version()).version }
78}
79
80async function source($: EngineInterface): Promise<LiveSource> {
81 return liveSource({
82 statusUrl: await $.env.get('KOR_STATUS_URL'),
83 stateDir: await $.env.get('KOR_STATE_DIR'),
84 xdgStateHome: await $.env.get('XDG_STATE_HOME'),
85 home: await $.env.get('HOME'),
86 })
87}
88
89/** Reads the daemon now and keeps the result for the pane. */
90async function readLive($: EngineInterface): Promise<CockpitData> {
91 const fetched = await fetchStatus(await source($), {
92 // The host's fetch can wait about 30 s on a closed loopback port; a daemon answers in milliseconds.
93 fetch: url => Promise.race([$.http.fetch(url), $.clock.sleep(STATUS_TIMEOUT_MS).then(() => Promise.reject(new Error('status timeout')))]),
94 read: path => $.fs.read(path),
95 })
96 latest = fromStatus(fetched, await $.clock.now())
97 return latest
98}
99
100async function data($: EngineInterface, fresh = false): Promise<CockpitData> {
101 if ((await read($, mode)) === 'demo') return demoData(await read($, cursor), await read($, overrides))
102 return !fresh && latest ? latest : readLive($)
103}
104
105async function refreshStatus($: EngineInterface, fresh = false): Promise<void> {
106 if (!await active($)) return
107 $.ui.status(statusLine(await data($, fresh), await liveFacts($)))
108}
109
110async function show($: EngineInterface, next: KorView, nextMode?: 'live' | 'demo'): Promise<void> {
111 if (nextMode) await update($, mode, () => nextMode)
112 await update($, view, () => next)
113}
114
115/** A demo action: a session-local record on a fixture decision. */
116async function demoAct($: EngineInterface, action: KorOverride['action'], decisionId: string, wanted?: string): Promise<string> {
117 const decision = DECISIONS.find(one => one.decisionId === decisionId || one.kind === decisionId)
118 if (!decision) return `No decision ${decisionId} in the fixture.`
119 const list = await read($, overrides)
120 const current = demoChoice(decision, list).choice
121 const choice = action === 'override' ? (wanted ?? decision.candidates[(decision.candidates.indexOf(current) + 1) % decision.candidates.length] ?? current) : current
122 if (action === 'override' && !decision.candidates.includes(choice)) return `${choice} is not a candidate of ${decision.decisionId}: ${decision.candidates.join(' | ')}`
123 const at = new Date(await $.clock.now()).toISOString().slice(11, 19)
124 const record: KorOverride = { id: `ovr_${list.length + 1}`, decisionId: decision.decisionId, action, choice, at, isReverted: false }
125 await update($, overrides, all => [...all, record])
126 await refreshStatus($)
127 return `${record.id}: ${action} ${decision.decisionId} → ${choice} [${LABEL.fixture}; demo, session-local, not sent to the daemon, Jev, the gateway or Linear]`
128}
129
130async function demoRevert($: EngineInterface, id: string): Promise<string> {
131 const list = await read($, overrides)
132 const record = list.find(one => one.id === id)
133 if (!record) return `No demo override ${id}.`
134 if (record.isReverted) return `${id} is already reverted.`
135 await update($, overrides, all => all.map(one => (one.id === id ? { ...one, isReverted: true } : one)))
136 await refreshStatus($)
137 return `${id} reverted; ${record.decisionId} is back to ${demoChoice(DECISIONS.find(one => one.decisionId === record.decisionId)!, await read($, overrides)).choice} [${LABEL.fixture}]`
138}
139
140/** The daemon's base URL: the status URL without its last path segment. */
141function daemonBase(live: LiveSource): string | undefined {
142 return live.statusUrl?.replace(/\/[^/]*$/, '')
143}
144
145/**
146 * Sends an override to the daemon. The daemon applies it only when policy
147 * permits, records it in the audit and comments on the Linear issue; the
148 * cockpit shows the daemon's answer and never applies anything itself.
149 */
150async function liveOverride($: EngineInterface, target: string, choice: string | undefined): Promise<{ text: string; exitCode?: number }> {
151 const live = await source($)
152 const base = daemonBase(live)
153 if (!base) return { text: `[${LABEL.unavailable}] overrides need the daemon; KOR_STATUS_URL is off.`, exitCode: 1 }
154 const current = await readLive($)
155 const decision = current.decisions.value.find(one => one.decisionId === target || one.kind === target)
156 const executionId = current.execution.value?.id
157 if (current.execution.provenance !== 'live' || !executionId) return { text: `[${LABEL[current.execution.provenance]}] no live execution to override (${current.execution.why ?? 'the daemon reports none'}).`, exitCode: 1 }
158 // A live override always names its choice (Jev 0.88): nothing is guessed on the user's behalf.
159 const wanted = choice
160 if (!wanted) return { text: `Usage: /kor override <decision|route> <choice>${decision ? `; ${decision.decisionId} candidates: ${decision.candidates.join(' | ')}` : ''}`, exitCode: 2 }
161 const body = JSON.stringify({ executionId, ...(decision ? { decisionId: decision.decisionId } : {}), target, choice: wanted, origin: 'cockpit' })
162 try {
163 const response = await $.http.fetch(`${base}/overrides`, { method: 'POST', headers: { 'content-type': 'application/json' }, body })
164 const answer = object(JSON.parse(response.text || '{}')) ?? {}
165 await readLive($)
166 $.ui.invalidate('ui.render')
167 await refreshStatus($)
168 const detail = [answer.id ? `${String(answer.id)}:` : '', `${target} → ${wanted}`, String(answer.status ?? (response.ok ? 'applied' : 'rejected')), answer.reason ? `(${String(answer.reason)})` : '', answer.auditId ? `audit ${String(answer.auditId)}` : '', answer.linearCommentId ? 'Linear comment queued' : ''].filter(Boolean).join(' ')
169 return response.ok ? { text: `[${LABEL.live}] ${detail}` } : { text: `[${LABEL.live}] daemon refused: ${detail}`, exitCode: 1 }
170 } catch {
171 return { text: `[${LABEL.unavailable}] the daemon did not answer at ${base}/overrides; nothing was applied.`, exitCode: 1 }
172 }
173}
174
175async function liveRevert($: EngineInterface, id: string): Promise<{ text: string; exitCode?: number }> {
176 const base = daemonBase(await source($))
177 if (!base) return { text: `[${LABEL.unavailable}] overrides need the daemon; KOR_STATUS_URL is off.`, exitCode: 1 }
178 try {
179 const response = await $.http.fetch(`${base}/overrides/${encodeURIComponent(id)}/revert`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ origin: 'cockpit' }) })
180 const answer = object(JSON.parse(response.text || '{}')) ?? {}
181 await readLive($)
182 $.ui.invalidate('ui.render')
183 await refreshStatus($)
184 return { text: `[${LABEL.live}] ${id} ${String(answer.status ?? (response.ok ? 'reverted' : 'refused'))}${answer.reason ? ` (${String(answer.reason)})` : ''}`, ...(response.ok ? {} : { exitCode: 1 }) }
185 } catch {
186 return { text: `[${LABEL.unavailable}] the daemon did not answer at ${base}; nothing was reverted.`, exitCode: 1 }
187 }
188}
189
190async function startReplay($: EngineInterface): Promise<void> {
191 replay?.cancel()
192 await update($, mode, () => 'demo')
193 await update($, cursor, () => 0)
194 await refreshStatus($)
195 replay = $.clock.every(1500, async () => {
196 const shown = await read($, cursor)
197 const nextCursor = shown === null || shown + 1 >= EVENTS.length ? null : shown + 1
198 await update($, cursor, () => nextCursor)
199 await refreshStatus($)
200 if (nextCursor === null) replay?.cancel()
201 })
202}
203
204/** Every replay frame as text: the headless proof that the execution is visible end to end. */
205async function replayText($: EngineInterface): Promise<string> {
206 const list = await read($, overrides)
207 const live = await liveFacts($)
208 const frames = EVENTS.map((event, index) => `${String(index + 1).padStart(2)}. ${event.at} ${event.stage.padEnd(11)} ${event.text}\n status line: ${statusLine(demoData(index + 1, list), live)}`)
209 const final = demoData(null, list)
210 return [`Replay of ${EVENTS.length} fixture events [${LABEL.fixture}]`, ...frames, '', renderText(buildView({ id: 'cockpit' }, final, live), final)].join('\n')
211}
212
213/** `/kor demo …`: the fixture execution, with session-local actions. */
214async function demo($: EngineInterface, args: string[], isHeadless: boolean): Promise<{ text: string; exitCode?: number }> {
215 const [verb = '', first, ...rest] = args
216 if (verb === 'replay') {
217 if (isHeadless) return { text: await replayText($) }
218 await show($, { id: 'cockpit' }, 'demo')
219 await $.ui.open({ id: PANE, title: 'Kor cockpit', focus: true, rows: 48 })
220 await startReplay($)
221 return { text: `Replaying ${EVENTS.length} fixture events in the Kor cockpit pane and status line [${LABEL.fixture}].` }
222 }
223 if (verb === 'revert') return first ? { text: await demoRevert($, first) } : { text: USAGE, exitCode: 2 }
224 const action = ACTIONS.find(one => one.action === verb)?.action
225 if (action) return first ? { text: await demoAct($, action, first, rest.length ? rest.join(' ') : undefined) } : { text: USAGE, exitCode: 2 }
226 const target = parseView(args.join(' '))
227 if (!target) return { text: USAGE, exitCode: 2 }
228 await show($, target, 'demo')
229 await refreshStatus($)
230 const shown = demoData(await read($, cursor), await read($, overrides))
231 if (isHeadless) return { text: renderText(buildView(target, shown, await liveFacts($)), shown) }
232 await $.ui.open({ id: PANE, title: 'Kor cockpit', focus: true, rows: 48 })
233 return { text: `Kor cockpit demo: ${target.id}${target.target ? ` ${target.target}` : ''} [${LABEL.fixture} data]` }
234}
235
236/** Re-reads the daemon on a timer while the session runs, so labels age honestly. */
237function startPoll($: EngineInterface): void {
238 poll?.cancel()
239 poll = $.clock.every(POLL_MS, async () => {
240 if (!await active($)) return
241 if ((await read($, mode)) === 'demo') return
242 await readLive($)
243 $.ui.invalidate('ui.render')
244 await refreshStatus($)
245 })
246}
247
248export const register: Register = on => {
249 on('classic.PreToolUse', async ($, e, next) => {
250 if (!await active($)) return next(e)
251 const reason = policyDecision(e.tool, e)
252 return reason ? { deny: reason } : next(e)
253 }).catch(() => ({ deny: 'Kor policy hook failed closed.' }))
254
255 on('session.start', async ($, e, next) => {
256 if (!await active($)) return next(e)
257 await $.command.register({ name: 'kor', description: 'Kor Code cockpit: live daemon status; /kor demo for the fixture', argumentHint: '[view] [id] | override <decision> <choice> | demo …' })
258 await $.command.register({ name: 'kor-status', description: 'Show the Kor Code cockpit status line' })
259 // The first read must not hold the session's start while a stopped daemon times out.
260 void refreshStatus($, true)
261 if ((await $.env.get('KOR_COCKPIT')) !== 'off') startPoll($)
262 return next(e)
263 })
264
265 on('command.run', { command: 'kor-status' }, async ($, e, next) => {
266 if (!await active($)) return next(e)
267 const line = statusLine(await data($, true), await liveFacts($))
268 return { text: `Kor Code cockpit plugin loaded (session-local; no configuration changes).\n${line}`, exitCode: 0 }
269 })
270
271 on('command.run', { command: 'kor' }, async ($, e, next) => {
272 if (!await active($)) return next(e)
273 const isHeadless = e.origin.kind === 'sdk'
274 const args = e.args.trim().split(/\s+/).filter(Boolean)
275 const [verb = '', first, ...rest] = args
276 if (verb === 'demo') return demo($, args.slice(1), isHeadless)
277 if (verb === 'override') return first ? liveOverride($, first, rest.length ? rest.join(' ') : undefined) : { text: USAGE, exitCode: 2 }
278 if (verb === 'revert') return first ? liveRevert($, first) : { text: USAGE, exitCode: 2 }
279 const target = parseView(e.args)
280 if (!target) return { text: USAGE, exitCode: 2 }
281 replay?.cancel()
282 await show($, target, 'live')
283 const current = await readLive($)
284 await refreshStatus($)
285 if (isHeadless) return { text: renderText(buildView(target, current, await liveFacts($)), current) }
286 await $.ui.open({ id: PANE, title: 'Kor cockpit', focus: true, rows: 48 })
287 return { text: `Kor cockpit: ${target.id}${target.target ? ` ${target.target}` : ''} [${LABEL[current.execution.provenance]} from ${current.origin}]` }
288 })
289
290 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
291 if (!await active($)) return next(e)
292 const { Box, Button, Text } = $.ui.resolve(e)
293 const current = await read($, view)
294 const isDemo = (await read($, mode)) === 'demo'
295 const shown = await read($, cursor)
296 const model = await data($)
297 const built = buildView(current, model, await liveFacts($))
298 const columns = e.props.bodyColumns
299 const headline = model.execution
300 const badge = isDemo ? ` ${LABEL.fixture} DEMO ` : ` ${headline.provenance === 'stale' && headline.age ? `${LABEL.stale} ${headline.age}` : LABEL[headline.provenance]} `
301
302 const nav = (
303 <Box flexWrap="wrap" columnGap={1}>
304 {VIEWS.map(one => (
305 <Button key={`nav-${one.id}`} label={one.label} hotkey={one.hotkey} plain variant={one.id === current.id ? 'primary' : 'secondary'} onPress={() => show($, { id: one.id })} />
306 ))}
307 {isDemo ? (
308 <Button key="replay" label={shown === null ? 'Replay' : `Replaying ${shown}/${EVENTS.length}`} hotkey="p" plain onPress={() => startReplay($)} />
309 ) : (
310 <Button key="refresh" label="Refresh" hotkey="f" plain onPress={() => readLive($).then(() => $.ui.invalidate('ui.render'))} />
311 )}
312 <Button key="mode" label={isDemo ? 'Live' : 'Demo'} hotkey="m" plain onPress={() => { replay?.cancel(); return show($, current, isDemo ? 'live' : 'demo').then(() => refreshStatus($, true)) }} />
313 <Button key="close" label="Close" hotkey="q" plain role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
314 </Box>
315 )
316
317 const sections = built.sections.map((section, sectionIndex) => {
318 const widths = columnWidths(section.rows)
319 return (
320 <Box key={`section-${sectionIndex}`} flexDirection="column" marginTop={1}>
321 <Text bold color="claude" wrap="truncate-end">── {section.title}</Text>
322 {section.rows.map((row, rowIndex) => {
323 const label = <Text color={TAG_COLOR[row.provenance]}>{tag(row.provenance)}</Text>
324 const body = padCells(row.cells, widths)
325 const color = row.tone ? TONE_COLOR[row.tone] : undefined
326 const destination = row.open
327 return destination ? (
328 <Button key={`row-${sectionIndex}-${rowIndex}`} plain onPress={() => show($, destination)}>
329 {label}
330 <Text color={color ?? 'suggestion'} underline>{body.slice(0, Math.max(10, columns - 14))}</Text>
331 </Button>
332 ) : (
333 <Box key={`row-${sectionIndex}-${rowIndex}`}>
334 {label}
335 <Text color={color} dimColor={row.tone === 'dim'} wrap="truncate-end">{body}</Text>
336 </Box>
337 )
338 })}
339 </Box>
340 )
341 })
342
343 const decision = current.id === 'decision' ? model.decisions.value.find(one => one.decisionId === current.target) : undefined
344 const drill = decision ? (
345 <Box columnGap={1} marginTop={1} flexWrap="wrap">
346 {isDemo
347 ? ACTIONS.map(one => (
348 <Button key={`act-${one.action}`} label={one.label} hotkey={one.hotkey} plain onPress={() => demoAct($, one.action, decision.decisionId).then(text => $.ui.toast(text))} />
349 ))
350 : decision.candidates.filter(one => one !== decision.choice).map(candidate => (
351 <Button key={`override-${candidate}`} label={`Override → ${candidate}`} plain onPress={() => liveOverride($, decision.decisionId, candidate).then(result => $.ui.toast(result.text))} />
352 ))}
353 <Button key="back" label="Back" hotkey="b" plain onPress={() => show($, { id: 'decisions' })} />
354 </Box>
355 ) : null
356
357 const reverts = current.id === 'overrides' || current.id === 'decision'
358 ? model.overrides.value.filter(one => ['active', 'applied'].includes(one.status) && (current.id === 'overrides' || one.decisionId === current.target))
359 : []
360
361 return (
362 <Box flexDirection="column" width={columns}>
363 <Box columnGap={1}>
364 <Text bold>{built.title}</Text>
365 <Text color={isDemo ? 'suggestion' : TAG_COLOR[headline.provenance]} inverse>{badge}</Text>
366 {isDemo && shown !== null && <Text color="suggestion">replay {shown}/{EVENTS.length}</Text>}
367 </Box>
368 <Text dimColor wrap="truncate-end">{legend(model)}</Text>
369 {nav}
370 {sections}
371 {drill}
372 {reverts.length > 0 && (
373 <Box columnGap={1} marginTop={1} flexWrap="wrap">
374 {reverts.map(one => (
375 <Button key={`revert-${one.id}`} label={`Revert ${one.id}`} plain onPress={() => (isDemo ? demoRevert($, one.id) : liveRevert($, one.id).then(result => result.text)).then(text => $.ui.toast(text))} />
376 ))}
377 </Box>
378 )}
379 </Box>
380 )
381 })
382}
383hooks/install-scope.ts 3 lines1// The installer replaces this constant in its private marketplace copy.
2export const installationPilot: string | undefined = undefined
3hooks/fixture.ts 308 lines1// One fixture execution, from discovery to Linear synchronisation, shown by
2// `/kor demo`. Every value here is drawn with the FIXTURE label; `/kor` itself
3// draws the daemon's live status (live.ts).
4
5import type { KorOverride } from '../types'
6import { type CockpitData, type Override, type Part, type Stage } from './model'
7
8export type ExecutionStatus = 'queued' | 'evaluating' | 'running' | 'waiting' | 'verifying' | 'repairing' | 'complete' | 'failed' | 'escalated'
9export type AgentStatus = 'waiting' | 'working' | 'done'
10export type AcceptanceStatus = 'pending' | 'pass' | 'fail' | 'unresolved'
11export type AccountState = 'available' | 'quota-limited' | 'unknown'
12export type AccountRoute = 'preferred' | 'standby' | 'eligible' | 'blocked'
13
14/** The shape of core's DecisionBundle; a hooks module imports only files inside its plugin. */
15export type Decision = {
16 decisionId: string
17 kind: string
18 executionId: string
19 question: string
20 choice: string
21 candidates: string[]
22 confidence: number
23 reasons: string[]
24 evidenceRefs: string[]
25 policyChecks: Array<{ name: string; result: 'pass' | 'fail' | 'unresolved' }>
26}
27
28export type Agent = { id: string; role: string }
29export type Account = { provider: string; slot: string; state: AccountState; route: AccountRoute; lastEvent: string }
30export type Acceptance = { id: string; text: string }
31
32/** One controller event; its optional fields change the execution as it is replayed. */
33export type ExecutionEvent = {
34 at: string
35 stage: Stage
36 text: string
37 detail?: string
38 decisionId?: string
39 status?: ExecutionStatus
40 agent?: { id: string; status: AgentStatus }
41 acceptance?: { id: string; status: AcceptanceStatus; evidence: string }
42 account?: { slot: string; state: AccountState; route: AccountRoute; lastEvent: string }
43 linear?: string
44 failover?: string
45}
46
47export const PROJECT = {
48 id: 'project_fixture_matilda',
49 name: 'Matilda v2',
50 team: 'ENG',
51 repository: 'github.com/example/matilda',
52 milestone: 'Performance pass',
53 url: 'https://linear.app/example/project/matilda-v2',
54 issues: [
55 { id: 'FIX-120', title: 'Profile cold start', state: 'Done', note: 'completed by exec_fixture_00' },
56 { id: 'FIX-123', title: 'Investigate performance regression', state: 'In Progress', note: 'exec_fixture_01' },
57 { id: 'FIX-124', title: 'Warm the search cache', state: 'Todo', note: 'blocked by FIX-123' },
58 { id: 'FIX-125', title: 'Tidy chart colours', state: 'Backlog', note: 'skipped: no acceptance conditions' },
59 ],
60 documents: [
61 { title: 'Performance budget', purpose: 'p95 targets per screen' },
62 { title: 'Matilda v2 brief', purpose: 'product intent and constraints' },
63 ],
64}
65
66export const ISSUE = {
67 id: 'FIX-123',
68 title: 'Investigate performance regression',
69 url: 'https://linear.app/example/issue/FIX-123',
70 priority: 'High',
71 labels: ['performance', 'frontend'],
72 intent: 'The dashboard renders slower since the chart refactor; find the cause and restore the budget.',
73 acceptance: [
74 { id: 'regression-covered', text: 'A test reproduces the slow render path' },
75 { id: 'p95-restored', text: 'p95 dashboard render is under 120 ms' },
76 { id: 'no-new-deps', text: 'No new runtime dependency' },
77 ] satisfies Acceptance[],
78}
79
80export const EXECUTION = {
81 id: 'exec_fixture_01',
82 repository: PROJECT.repository,
83 worktree: '/worktrees/exec_fixture_01',
84 branch: 'kor/fix-123-performance',
85 session: 'agent_session_fixture_01',
86 pullRequest: 'https://github.com/example/matilda/pull/42',
87}
88
89export const AGENTS: Agent[] = [
90 { id: 'jev', role: 'orchestrating' },
91 { id: 'investigator', role: 'repository investigation' },
92 { id: 'frontend', role: 'render-path change' },
93 { id: 'reviewer', role: 'review and acceptance' },
94]
95
96export const ACCOUNTS: Account[] = [
97 { provider: 'Claude', slot: 'claude-01', state: 'available', route: 'eligible', lastEvent: 'no active session' },
98 { provider: 'Claude', slot: 'claude-02', state: 'available', route: 'standby', lastEvent: 'waiting' },
99 { provider: 'Claude', slot: 'claude-03', state: 'available', route: 'eligible', lastEvent: 'no active session' },
100 { provider: 'ChatGPT', slot: 'gpt-01', state: 'available', route: 'eligible', lastEvent: 'no active session' },
101 { provider: 'ChatGPT', slot: 'gpt-02', state: 'available', route: 'eligible', lastEvent: 'no active session' },
102 { provider: 'Grok', slot: 'grok-01', state: 'available', route: 'eligible', lastEvent: 'no active session' },
103 { provider: 'Devin', slot: 'devin-01', state: 'unknown', route: 'blocked', lastEvent: 'compatibility test required' },
104]
105
106export const CONTEXT = [
107 { source: 'CLAUDE.md', purpose: 'repository rules', relevance: 0.94 },
108 { source: 'docs/perf.md', purpose: 'performance budget and bench command', relevance: 0.91 },
109 { source: 'src/render/cache.ts', purpose: 'existing memo cache to reuse', relevance: 0.88 },
110 { source: 'src/charts/Dashboard.tsx', purpose: 'render path changed by the refactor', relevance: 0.86 },
111 { source: 'dec_fx_route', purpose: 'prior decision: keep the warm cache', relevance: 0.72 },
112 { source: 'FIX-123 acceptance', purpose: 'three conditions to evidence', relevance: 1 },
113]
114
115export const CONTEXT_DROPPED = [
116 { source: 'build log 10:31', reason: 'stale tool output' },
117 { source: '.env.example', reason: 'redacted: credential-shaped values' },
118]
119
120export const DECISIONS: Decision[] = [
121 {
122 decisionId: 'dec_fx_select',
123 kind: 'select',
124 executionId: EXECUTION.id,
125 question: 'Which ready issue should run next?',
126 choice: 'FIX-123',
127 candidates: ['FIX-123', 'FIX-124', 'FIX-125'],
128 confidence: 0.93,
129 reasons: ['FIX-123 is high priority and has acceptance conditions.', 'FIX-124 is blocked by FIX-123.', 'FIX-125 has no acceptance conditions.'],
130 evidenceRefs: ['snapshot snap_fx_07'],
131 policyChecks: [{ name: 'acceptance conditions present', result: 'pass' }, { name: 'dependencies clear', result: 'pass' }],
132 },
133 {
134 decisionId: 'dec_fx_classify',
135 kind: 'classify',
136 executionId: EXECUTION.id,
137 question: 'What kind of task is this?',
138 choice: 'performance investigation',
139 candidates: ['performance investigation', 'feature', 'refactor'],
140 confidence: 0.9,
141 reasons: ['The intent names a regression against a budget.', 'Acceptance asks for a measured p95.'],
142 evidenceRefs: ['FIX-123 description', 'docs/perf.md'],
143 policyChecks: [{ name: 'risk class', result: 'pass' }],
144 },
145 {
146 decisionId: 'dec_fx_route',
147 kind: 'route',
148 executionId: EXECUTION.id,
149 question: 'Which route fits this performance investigation?',
150 choice: 'Claude Sonnet · medium effort',
151 candidates: ['Claude Sonnet · medium effort', 'Claude Opus · high effort', 'GPT · medium effort'],
152 confidence: 0.86,
153 reasons: ['Repository context is large and the cache is warm.', 'The task needs code changes and test execution.', 'The evidence does not justify a stronger route.'],
154 evidenceRefs: ['gateway catalogue (fixture)', 'cache locality: claude-01'],
155 policyChecks: [{ name: 'route in catalogue', result: 'pass' }, { name: 'tool compatibility', result: 'pass' }],
156 },
157 {
158 decisionId: 'dec_fx_context',
159 kind: 'context',
160 executionId: EXECUTION.id,
161 question: 'Which context sources does the session need?',
162 choice: '6 sources, 2 dropped',
163 candidates: ['6 sources, 2 dropped', 'whole repository index'],
164 confidence: 0.81,
165 reasons: ['Repository rules and the perf budget are always relevant.', 'An existing memo cache can be reused.', 'Stale build output would mislead the agent.'],
166 evidenceRefs: ['context manifest ctx_fx_01'],
167 policyChecks: [{ name: 'redaction applied', result: 'pass' }],
168 },
169 {
170 decisionId: 'dec_fx_agents',
171 kind: 'agent',
172 executionId: EXECUTION.id,
173 question: 'Does this need a specialist agent?',
174 choice: 'add frontend agent',
175 candidates: ['add frontend agent', 'investigator alone'],
176 confidence: 0.77,
177 reasons: ['The cause is in the chart render path.', 'The investigator found a reusable cache.'],
178 evidenceRefs: ['investigator finding f_fx_02'],
179 policyChecks: [{ name: 'concurrency limit', result: 'pass' }],
180 },
181 {
182 decisionId: 'dec_fx_verify',
183 kind: 'verify',
184 executionId: EXECUTION.id,
185 question: 'How deep should verification go?',
186 choice: 'tests + bench + review',
187 candidates: ['tests + bench + review', 'tests only'],
188 confidence: 0.88,
189 reasons: ['Acceptance asks for a measured p95.', 'Render-path changes are review-worthy.'],
190 evidenceRefs: ['FIX-123 acceptance', 'risk policy (fixture)'],
191 policyChecks: [{ name: 'review required by risk', result: 'pass' }],
192 },
193]
194
195export const EVENTS: ExecutionEvent[] = [
196 { at: '10:40', stage: 'discover', text: 'Snapshot snap_fx_07 read', detail: '4 issues, 1 ready', status: 'queued' },
197 { at: '10:41', stage: 'discover', text: 'FIX-123 selected', detail: 'FIX-124 blocked, FIX-125 skipped', decisionId: 'dec_fx_select' },
198 { at: '10:42', stage: 'preflight', text: 'Task classified', detail: 'performance investigation', decisionId: 'dec_fx_classify', status: 'evaluating', agent: { id: 'jev', status: 'working' } },
199 { at: '10:43', stage: 'preflight', text: 'Sonnet / medium selected', detail: 'cache retained', decisionId: 'dec_fx_route', account: { slot: 'claude-01', state: 'available', route: 'preferred', lastEvent: 'session affinity retained' } },
200 { at: '10:44', stage: 'context', text: 'Repository rules selected', detail: '6 sources, 2 dropped', decisionId: 'dec_fx_context' },
201 { at: '10:44', stage: 'context', text: 'Existing implementation found', detail: 'src/render/cache.ts' },
202 { at: '10:45', stage: 'execute', text: 'Investigator started', detail: 'worktree /worktrees/exec_fixture_01', status: 'running', agent: { id: 'investigator', status: 'working' }, linear: 'Agent session started' },
203 { at: '10:46', stage: 'execute', text: 'Frontend agent started', detail: 'specialist for the render path', decisionId: 'dec_fx_agents', agent: { id: 'frontend', status: 'working' } },
204 { at: '10:47', stage: 'supervise', text: 'Failover claude-01 → claude-02', detail: 'quota response before the stream started', account: { slot: 'claude-01', state: 'quota-limited', route: 'blocked', lastEvent: 'quota response' }, failover: 'FIX-123 · claude-01 → claude-02 · reason: quota response · replay: not needed, no output had streamed' },
205 { at: '10:47', stage: 'supervise', text: 'Session moved to claude-02', detail: 'affinity rebound by policy', account: { slot: 'claude-02', state: 'available', route: 'preferred', lastEvent: 'session affinity rebound' } },
206 { at: '10:49', stage: 'supervise', text: 'Change set ready', detail: '3 files changed', agent: { id: 'investigator', status: 'done' } },
207 { at: '10:50', stage: 'verify', text: 'Tests running', detail: 'tests + bench + review', decisionId: 'dec_fx_verify', status: 'verifying', agent: { id: 'reviewer', status: 'working' } },
208 { at: '10:52', stage: 'verify', text: 'regression-covered passed', detail: 'perf.render.spec reproduces the slow path', acceptance: { id: 'regression-covered', status: 'pass', evidence: 'test perf.render.spec' } },
209 { at: '10:53', stage: 'verify', text: 'p95-restored passed', detail: 'bench p95 104 ms', acceptance: { id: 'p95-restored', status: 'pass', evidence: 'bench p95 104 ms' }, agent: { id: 'frontend', status: 'done' } },
210 { at: '10:53', stage: 'verify', text: 'no-new-deps passed', detail: 'lockfile unchanged', acceptance: { id: 'no-new-deps', status: 'pass', evidence: 'git diff pnpm-lock.yaml empty' } },
211 { at: '10:54', stage: 'verify', text: 'Review: no blocking findings', detail: 'reviewer approved', agent: { id: 'reviewer', status: 'done' } },
212 { at: '10:55', stage: 'synchronise', text: 'Pull request opened', detail: EXECUTION.pullRequest, linear: 'PR link attached' },
213 { at: '10:56', stage: 'synchronise', text: 'Linear updated', detail: 'evidence written, state → In Review', status: 'complete', agent: { id: 'jev', status: 'done' }, linear: 'Evidence and outcome written' },
214]
215
216/** The choice in force for a demo decision: the latest unreverted session override, else Jev's. */
217export function demoChoice(decision: Decision, overrides: KorOverride[]): { choice: string; override?: KorOverride } {
218 const override = overrides.findLast(one => one.decisionId === decision.decisionId && !one.isReverted && one.action === 'override')
219 return override ? { choice: override.choice, override } : { choice: decision.choice }
220}
221
222const fixture = <T>(value: T): Part<T> => ({ value, provenance: 'fixture' })
223
224/** The fixture execution as it stood after the first `cursor` events (all of them when null). */
225export function demoData(cursor: number | null, overrides: KorOverride[]): CockpitData {
226 const shown = EVENTS.slice(0, cursor ?? EVENTS.length)
227 const agents = new Map(AGENTS.map(agent => [agent.id, { ...agent, status: 'waiting' }]))
228 const acceptance = new Map(ISSUE.acceptance.map(item => [item.id, { ...item, status: 'pending', evidence: 'none yet' }]))
229 const accounts = new Map(ACCOUNTS.map(account => [account.slot, { ...account }]))
230 const failovers: string[] = []
231 const linear: Array<{ at: string; text: string }> = []
232 let status: ExecutionStatus = 'queued'
233 for (const event of shown) {
234 if (event.status) status = event.status
235 if (event.agent) {
236 const agent = agents.get(event.agent.id)
237 if (agent) agent.status = event.agent.status
238 }
239 if (event.acceptance) {
240 const item = acceptance.get(event.acceptance.id)
241 if (item) Object.assign(item, { status: event.acceptance.status, evidence: event.acceptance.evidence })
242 }
243 if (event.account) {
244 const account = accounts.get(event.account.slot)
245 if (account) Object.assign(account, event.account)
246 }
247 if (event.failover) failovers.push(event.failover)
248 if (event.linear) linear.push({ at: event.at, text: event.linear })
249 }
250 const route = DECISIONS.find(decision => decision.decisionId === 'dec_fx_route')
251 const routeChoice = route ? demoChoice(route, overrides) : undefined
252 const stage: Stage = shown.at(-1)?.stage ?? 'discover'
253 const isComplete = shown.length === EVENTS.length
254 const decisions = DECISIONS.map(decision => {
255 const { choice, override } = demoChoice(decision, overrides)
256 return override ? { ...decision, choice, source: `override ${override.id}; Jev chose ${decision.choice}` } : decision
257 })
258 const records: Override[] = overrides.map(one => ({
259 id: one.id,
260 decisionId: one.decisionId,
261 action: one.action,
262 choice: one.choice,
263 at: one.at,
264 status: one.isReverted ? 'reverted' : 'active',
265 detail: 'session-local demo record; never sent to the daemon, Jev, the gateway or Linear',
266 }))
267 return {
268 mode: 'demo',
269 origin: 'hooks/fixture.ts',
270 execution: fixture({
271 id: EXECUTION.id,
272 status,
273 stage,
274 route: routeChoice?.choice ?? 'none',
275 routeSource: routeChoice?.override ? `override ${routeChoice.override.id}` : 'jev',
276 worktree: EXECUTION.worktree,
277 branch: EXECUTION.branch,
278 session: EXECUTION.session,
279 pullRequest: linear.some(item => item.text === 'PR link attached') ? EXECUTION.pullRequest : '',
280 attempts: 1,
281 updatedAt: shown.at(-1)?.at ?? '',
282 error: '',
283 }),
284 issue: fixture({ id: ISSUE.id, title: ISSUE.title, url: ISSUE.url, priority: ISSUE.priority, labels: ISSUE.labels, intent: ISSUE.intent, state: isComplete ? 'In Review' : 'In Progress' }),
285 project: fixture({ name: PROJECT.name, team: PROJECT.team, repository: PROJECT.repository, milestone: PROJECT.milestone, url: PROJECT.url, issues: PROJECT.issues, documents: PROJECT.documents }),
286 decisions: fixture(decisions),
287 events: fixture(shown.map(event => ({ at: event.at, stage: event.stage, text: event.text, ...(event.detail ? { detail: event.detail } : {}), ...(event.decisionId ? { decisionId: event.decisionId } : {}), ...(event.agent ? { agentId: event.agent.id } : {}) }))),
288 agents: fixture([...agents.values()]),
289 acceptance: fixture([...acceptance.values()]),
290 accounts: fixture([...accounts.values()]),
291 failovers: fixture(failovers),
292 context: fixture({ sources: CONTEXT, dropped: CONTEXT_DROPPED }),
293 linear: fixture({
294 records: [
295 { name: 'Issue', value: ISSUE.url },
296 { name: 'Project', value: PROJECT.url },
297 { name: 'Agent session', value: EXECUTION.session },
298 { name: 'Pull request', value: linear.some(item => item.text === 'PR link attached') ? EXECUTION.pullRequest : 'not opened yet' },
299 ],
300 writes: linear,
301 }),
302 overrides: fixture(records),
303 queue: fixture(PROJECT.issues.map(issue => ({ id: issue.note.startsWith('exec_') ? issue.note : '', issueId: issue.id, status: issue.state, route: '', updatedAt: '' }))),
304 replay: { shown: shown.length, total: EVENTS.length, isComplete },
305 overridePath: 'session-local (demo): never sent to the daemon, Jev, the gateway or Linear',
306 }
307}
308hooks/live.ts 329 lines1// Reads the Kor daemon's status for the cockpit: the loopback HTTP endpoint
2// first, the snapshot files second. A hooks module imports only files inside
3// its plugin, so the payload is checked here by hand rather than with the
4// daemon's own types; anything missing or malformed is drawn UNAVAILABLE.
5//
6// Labels: a value the daemon answered over HTTP is LIVE while it is younger
7// than the freshness window; older, or read from a snapshot file because the
8// daemon did not answer, it is STALE with its age; absent, UNAVAILABLE.
9
10import {
11 ageText,
12 unavailable,
13 type Account,
14 type Acceptance,
15 type Agent,
16 type CockpitData,
17 type ContextSource,
18 type Decision,
19 type Event,
20 type Execution,
21 type Issue,
22 type Override,
23 type Part,
24 type Project,
25} from './model'
26
27export const DEFAULT_STATUS_URL = 'http://127.0.0.1:8327/status'
28/** Used when the payload names no poll interval of its own. */
29const DEFAULT_FRESH_SECONDS = 60
30
31export type LiveSource = { statusUrl: string | undefined; snapshotsDir: string }
32export type Fetched = { payload: unknown; origin: string; isDaemonAnswer: boolean } | { payload: undefined; origin: string; why: string }
33
34type Json = Record<string, unknown>
35const object = (value: unknown): Json | undefined => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : undefined)
36const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
37const text = (value: unknown, fallback = ''): string => (typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : fallback)
38const number = (value: unknown, fallback = 0): number => (typeof value === 'number' && Number.isFinite(value) ? value : fallback)
39/** The first of `keys` the record has, so the reader accepts both spellings the daemon may settle on. */
40const pick = (record: Json | undefined, ...keys: string[]): unknown => {
41 for (const key of keys) if (record && record[key] !== undefined && record[key] !== null) return record[key]
42 return undefined
43}
44const time = (value: unknown): string => {
45 const raw = text(value)
46 return /^\d{4}-\d\d-\d\dT(\d\d:\d\d:\d\d)/.exec(raw)?.[1] ?? raw
47}
48
49/** Where the cockpit looks: env overrides first, then the runtime paths `kor` itself uses. */
50export function liveSource(env: { statusUrl?: string; stateDir?: string; xdgStateHome?: string; home?: string }): LiveSource {
51 const statusUrl = env.statusUrl === 'off' ? undefined : (env.statusUrl || DEFAULT_STATUS_URL)
52 const stateDir = env.stateDir || `${env.xdgStateHome || `${env.home ?? ''}/.local/state`}/kor`
53 return { statusUrl, snapshotsDir: `${stateDir.replace(/\/$/, '')}/snapshots` }
54}
55
56export type Reader = {
57 fetch: (url: string) => Promise<{ ok: boolean; status: number; text: string }>
58 read: (path: string) => Promise<string>
59}
60
61/** One status payload: the daemon over HTTP, else its last snapshot index on disk. */
62export async function fetchStatus(source: LiveSource, reader: Reader): Promise<Fetched> {
63 let why = 'daemon status not configured (KOR_STATUS_URL=off)'
64 if (source.statusUrl) {
65 try {
66 const response = await reader.fetch(source.statusUrl)
67 if (response.ok) return { payload: JSON.parse(response.text), origin: source.statusUrl, isDaemonAnswer: true }
68 why = `daemon answered HTTP ${response.status}`
69 } catch {
70 why = `daemon unreachable at ${source.statusUrl}`
71 }
72 }
73 const index = `${source.snapshotsDir}/index.json`
74 try {
75 return { payload: JSON.parse(await reader.read(index)), origin: index, isDaemonAnswer: false }
76 } catch {
77 return { payload: undefined, origin: 'none', why: `${why}; no snapshot at ${index}` }
78 }
79}
80
81function executionsOf(payload: Json): Json[] {
82 return list(pick(payload, 'executions', 'runs')).map(object).filter((one): one is Json => one !== undefined)
83}
84
85/** The execution the cockpit follows: one still running, else the most recently active; the daemon lists runs oldest first. */
86function current(executions: Json[]): Json | undefined {
87 const active = ['running', 'evaluating', 'verifying', 'repairing', 'waiting', 'ready', 'queued', 'shipping', 'review']
88 const updated = (one: Json) => Math.max(Date.parse(text(pick(one, 'updatedAt', 'heartbeatAt', 'startedAt'))) || 0, ...list(pick(one, 'decisions')).map(decision => Date.parse(text(pick(object(decision), 'recordedAt'))) || 0))
89 const sorted = executions.map((one, index) => ({ one, index })).sort((left, right) => updated(right.one) - updated(left.one) || right.index - left.index).map(entry => entry.one)
90 return sorted.find(one => active.includes(text(pick(one, 'status', 'state')))) ?? sorted[0]
91}
92
93/** The lifecycle stage a run status stands for, until the daemon reports stages itself. */
94const STAGE_OF: Record<string, string> = { queued: 'discover', ready: 'discover', evaluating: 'preflight', running: 'execute', waiting: 'supervise', verifying: 'verify', repairing: 'verify', shipping: 'synchronise', review: 'synchronise', complete: 'synchronise' }
95
96function routeText(value: unknown): { route: string; source: string } {
97 const route = object(value)
98 if (!route) return { route: text(value, 'none'), source: 'jev' }
99 const model = text(pick(route, 'model'), 'none')
100 const effort = text(pick(route, 'reasoningLevel', 'effort'))
101 return { route: effort ? `${model} · ${effort}` : model, source: text(pick(route, 'source', 'provenance'), 'jev') }
102}
103
104function decisionOf(value: unknown): Decision | undefined {
105 const one = object(value)
106 const decisionId = text(pick(one, 'decisionId', 'id'))
107 if (!one || !decisionId) return undefined
108 const response = object(pick(one, 'response')) ?? one
109 const override = object(pick(one, 'override'))
110 return {
111 decisionId,
112 kind: text(pick(response, 'kind', 'decisionKind'), text(pick(one, 'kind'))),
113 question: text(pick(response, 'question')),
114 choice: text(pick(response, 'choice')),
115 candidates: list(pick(response, 'candidates')).map(item => text(item)),
116 confidence: number(pick(response, 'confidence')),
117 reasons: list(pick(response, 'reasons')).map(item => text(item)),
118 evidenceRefs: list(pick(response, 'evidenceRefs')).map(item => text(item)),
119 policyChecks: list(pick(response, 'policyChecks')).map(object).filter((item): item is Json => item !== undefined).map(item => ({ name: text(item.name), result: text(item.result) })),
120 ...(override ? { source: `override ${text(pick(override, 'id'))}`.trim() } : pick(one, 'source', 'provenance') ? { source: text(pick(one, 'source', 'provenance')) } : response.escalated === true ? { source: 'escalated' } : {}),
121 }
122}
123
124function accountOf(value: unknown): Account | undefined {
125 const one = object(value)
126 if (!one) return undefined
127 const status = text(pick(one, 'status', 'state'), 'unknown')
128 const remaining = pick(one, 'remainingQuota')
129 const reset = text(pick(one, 'resetAt'))
130 return {
131 provider: text(pick(one, 'provider', 'type'), 'unknown'),
132 // Identities stay opaque: the daemon sends a hash, and only its head is drawn.
133 slot: text(pick(one, 'slot', 'id', 'hash'), '?').replace(/^account-/, '').slice(0, 10),
134 state: status,
135 route: text(pick(one, 'route'), status === 'available' ? 'eligible' : 'blocked'),
136 lastEvent: text(pick(one, 'lastEvent'), [remaining !== undefined ? `quota ${text(remaining)}` : '', reset ? `resets ${time(reset)}` : ''].filter(Boolean).join(' · ') || 'no event'),
137 }
138}
139
140/** Builds the cockpit's data from one status payload; `now` is the session clock in ms. */
141export function fromStatus(fetched: Fetched, now: number): CockpitData {
142 // The whole reason once, on the execution; every other part says it short.
143 const empty = (why: string, short = 'daemon down: see Execution'): CockpitData => ({
144 mode: 'live',
145 origin: fetched.origin,
146 execution: unavailable(undefined, why),
147 issue: unavailable(undefined, short),
148 project: unavailable(undefined, short),
149 decisions: unavailable([], short),
150 events: unavailable([], short),
151 agents: unavailable([], short),
152 acceptance: unavailable([], short),
153 accounts: unavailable([], short),
154 failovers: unavailable([], short),
155 context: unavailable({ sources: [], dropped: [] }, short),
156 linear: unavailable({ records: [], writes: [] }, short),
157 overrides: unavailable([], short),
158 queue: unavailable([], short),
159 overridePath: 'unavailable: overrides need the daemon (kor serve)',
160 })
161 if (fetched.payload === undefined) return empty('why' in fetched ? fetched.why : 'no status')
162 // HTTP answers an envelope { provenance, snapshot, reason }; the snapshot file is the bare snapshot.
163 const envelope = object(fetched.payload)
164 if (envelope && pick(envelope, 'provenance') === 'unavailable') return empty(`daemon reports: ${text(pick(envelope, 'reason'), 'no snapshot yet')}`, 'daemon has no snapshot: see Execution')
165 const payload = object(pick(envelope, 'snapshot')) ?? envelope
166 if (!payload) return empty(`malformed status from ${fetched.origin}`, 'malformed status: see Execution')
167 const isDaemonAnswer = 'isDaemonAnswer' in fetched && fetched.isDaemonAnswer
168 const daemon = object(pick(payload, 'daemon'))
169 const freshSeconds = number(pick(payload, 'pollIntervalSeconds') ?? pick(daemon, 'pollIntervalSeconds', 'heartbeatSeconds'), DEFAULT_FRESH_SECONDS)
170 const generatedAt = text(pick(payload, 'observedAt', 'generatedAt', 'updatedAt'))
171
172 /** The label for a value observed at `observedAt` (the payload's own time when absent). */
173 const part = <T>(value: T, observedAt?: string): Part<T> => {
174 const at = Date.parse(observedAt || generatedAt)
175 const seconds = Number.isFinite(at) ? Math.max(0, (now - at) / 1000) : Number.POSITIVE_INFINITY
176 const age = Number.isFinite(seconds) ? ageText(seconds) : 'unknown'
177 if (isDaemonAnswer && seconds <= freshSeconds) return { value, provenance: 'live' }
178 return { value, provenance: 'stale', age, why: isDaemonAnswer ? `older than ${freshSeconds}s` : 'daemon did not answer; last snapshot' }
179 }
180 const missing = <T>(value: T, what: string): Part<T> => unavailable(value, `${what} not reported by the daemon`)
181
182 const executions = executionsOf(payload)
183 const exec = current(executions)
184 const issueRecord = object(pick(exec, 'issue'))
185 const issueId = text(pick(issueRecord, 'identifier', 'id'), text(pick(exec, 'issueKey', 'issueId')))
186 const observedAt = text(pick(exec, 'observedAt', 'updatedAt'))
187 const { route, source: nestedSource } = routeText(pick(exec, 'route'))
188 const routeSource = text(pick(exec, 'routeSource'), nestedSource)
189 const execution: Part<Execution | undefined> = exec
190 ? part({
191 id: text(pick(exec, 'executionId', 'id'), '?'),
192 status: text(pick(exec, 'status', 'state'), 'unknown'),
193 stage: text(pick(exec, 'stage'), STAGE_OF[text(pick(exec, 'status', 'state'))] ?? text(pick(exec, 'status', 'state'), 'unknown')),
194 route,
195 routeSource,
196 worktree: text(pick(exec, 'worktree')),
197 branch: text(pick(exec, 'branch')),
198 session: text(pick(exec, 'sessionId', 'session')),
199 pullRequest: text(pick(exec, 'pullRequestUrl', 'pullRequest')),
200 attempts: number(pick(exec, 'attempts', 'attempt'), 1),
201 updatedAt: time(text(pick(exec, 'heartbeatAt'), observedAt)),
202 error: text(pick(exec, 'error', 'lastError')),
203 }, observedAt)
204 : part(undefined)
205
206 const issue: Part<Issue | undefined> = exec && issueId
207 ? part({
208 id: issueId,
209 title: text(pick(issueRecord, 'title'), text(pick(exec, 'issueTitle', 'title'), 'title not read from Linear yet')),
210 url: text(pick(issueRecord, 'url'), text(pick(exec, 'issueUrl'))),
211 priority: text(pick(issueRecord, 'priority', 'priorityLabel'), 'unknown'),
212 labels: list(pick(issueRecord, 'labels')).map(label => text(pick(object(label), 'name') ?? label)),
213 intent: text(pick(issueRecord, 'description', 'intent')).split('\n')[0] ?? '',
214 state: text(pick(issueRecord, 'state', 'stateName'), text(pick(exec, 'issueState', 'linearState'))),
215 }, observedAt)
216 : exec ? missing(undefined, 'the issue') : part(undefined)
217
218 // Decisions arrive per run, or (the daemon's contract) as one labelled list matched by the run's decisionIds.
219 const allDecisions = object(pick(payload, 'decisions'))
220 const runDecisionIds = list(pick(exec, 'decisionIds')).map(item => text(item))
221 const decisionsRaw = pick(exec, 'decisions') ?? (allDecisions && exec ? list(pick(allDecisions, 'value')).filter(one => runDecisionIds.includes(text(pick(object(one), 'decisionId'))) || text(pick(object(one), 'executionId')) === text(pick(exec, 'executionId'))) : undefined)
222 const decisionValues = list(decisionsRaw).map(decisionOf).filter((one): one is Decision => one !== undefined)
223 const decisions = decisionValues.length || pick(exec, 'decisions') !== undefined ? part(decisionValues, observedAt) : exec ? unavailable([] as Decision[], 'no Jev decisions recorded for this execution yet') : part([] as Decision[])
224 const eventValues: Event[] = list(pick(exec, 'events')).map(object).filter((one): one is Json => one !== undefined).map(one => ({
225 at: time(pick(one, 'at', 'occurredAt', 'recordedAt')),
226 stage: text(pick(one, 'stage')),
227 text: text(pick(one, 'text', 'summary', 'kind', 'type')),
228 ...(pick(one, 'detail') ? { detail: text(pick(one, 'detail')) } : {}),
229 ...(pick(one, 'decisionId') ? { decisionId: text(pick(one, 'decisionId')) } : {}),
230 ...(pick(one, 'agentId') ? { agentId: text(pick(one, 'agentId')) } : {}),
231 }))
232 // A daemon that records decisions but no event stream still gets a decision stream.
233 const decisionTimes = new Map(list(decisionsRaw).map(object).map(one => [text(pick(one, 'decisionId')), time(pick(one, 'recordedAt'))]))
234 const streamed = eventValues.length || !decisionValues.length ? eventValues : decisionValues.map(one => ({ at: decisionTimes.get(one.decisionId) ?? '', stage: 'preflight', text: `${one.kind || 'decision'}: ${one.choice}`, detail: `confidence ${one.confidence.toFixed(2)}${one.source === 'escalated' ? ' · escalated' : ''}`, decisionId: one.decisionId }))
235 const events = pick(exec, 'events') !== undefined || decisionValues.length ? part(streamed, observedAt) : exec ? unavailable([] as Event[], 'no Jev decisions recorded for this execution yet') : part([] as Event[])
236
237 const agentList = list(pick(exec, 'agents')).map(object).filter((one): one is Json => one !== undefined).map(one => ({ id: text(pick(one, 'id', 'name'), '?'), role: text(pick(one, 'role', 'description')), status: text(pick(one, 'status'), 'unknown') }))
238 const sessionAgent: Agent[] = exec && !agentList.length ? [{ id: 'session', role: `Claude Code session${execution.value?.attempts ? `, attempt ${execution.value.attempts}` : ''}`, status: ['running', 'repairing'].includes(execution.value?.status ?? '') ? 'working' : ['complete', 'review'].includes(execution.value?.status ?? '') ? 'done' : 'waiting' }] : []
239 const agents = exec ? part([...agentList, ...sessionAgent], observedAt) : part([] as Agent[])
240
241 const acceptanceRaw = pick(exec, 'acceptance', 'findings', 'criteria')
242 const acceptanceValues: Acceptance[] = list(acceptanceRaw).map(object).filter((one): one is Json => one !== undefined).map(one => ({
243 id: text(pick(one, 'id', 'conditionId'), '?'),
244 text: text(pick(one, 'text', 'description')),
245 status: text(pick(one, 'status'), 'pending'),
246 evidence: text(pick(one, 'evidence', 'note'), list(pick(one, 'evidenceIds')).map(item => text(item)).join(', ') || 'none yet'),
247 }))
248 const acceptance = acceptanceRaw !== undefined ? part(acceptanceValues, observedAt) : exec ? missing([] as Acceptance[], 'acceptance findings') : part([] as Acceptance[])
249
250 // The account pool is a labelled value: its own provenance (a failed gateway read is stale or unavailable) bounds ours.
251 const gateway = object(pick(payload, 'accounts', 'gateway'))
252 const gatewayAt = text(pick(gateway, 'observedAt'))
253 const gatewayProvenance = text(pick(gateway, 'provenance'))
254 const accountsRaw = pick(gateway, 'value', 'accounts', 'slots')
255 const accountValues = list(accountsRaw).map(accountOf).filter((one): one is Account => one !== undefined)
256 const accountReason = text(pick(gateway, 'reason', 'error'))
257 const accounts: Part<Account[]> = accountsRaw === undefined
258 ? missing([] as Account[], 'gateway account facts')
259 : gatewayProvenance === 'unavailable' || (accountReason && accountValues.length === 0)
260 ? unavailable([] as Account[], accountReason || 'the daemon could not read the gateway')
261 : gatewayProvenance === 'stale' || accountReason
262 ? { value: accountValues, provenance: 'stale', age: Number.isFinite(Date.parse(gatewayAt)) ? ageText(Math.max(0, (now - Date.parse(gatewayAt)) / 1000)) : 'unknown', why: accountReason || 'gateway read is stale' }
263 : part(accountValues, gatewayAt)
264 const failoverRaw = pick(exec, 'failovers')
265 const failovers = failoverRaw !== undefined ? part(list(failoverRaw).map(item => text(pick(object(item), 'text', 'summary') ?? item)), observedAt) : missing([] as string[], 'failovers')
266
267 const manifest = object(pick(exec, 'context', 'contextManifest'))
268 const context = manifest
269 ? part({
270 sources: list(pick(manifest, 'sources')).map(object).filter((one): one is Json => one !== undefined).map((one): ContextSource => ({ source: text(pick(one, 'path', 'source', 'id')), purpose: text(pick(one, 'purpose')), relevance: number(pick(one, 'relevance')) })),
271 dropped: list(pick(manifest, 'droppedSources', 'dropped')).map(object).filter((one): one is Json => one !== undefined).map(one => ({ source: text(pick(one, 'path', 'source', 'id')), reason: text(pick(one, 'reason', 'purpose'), 'dropped') })),
272 }, observedAt)
273 : exec ? missing({ sources: [], dropped: [] }, 'the context manifest') : part({ sources: [], dropped: [] })
274
275 const writes = list(pick(exec, 'linearWrites', 'linear')).map(object).filter((one): one is Json => one !== undefined).map(one => ({ at: time(pick(one, 'at', 'recordedAt')), text: text(pick(one, 'text', 'summary', 'kind')) }))
276 const linear = exec
277 ? part({
278 records: [
279 { name: 'Issue', value: issue.value?.url || issueId || 'unknown' },
280 { name: 'Branch', value: execution.value?.branch || 'none' },
281 { name: 'Pull request', value: execution.value?.pullRequest || 'not opened yet' },
282 ],
283 writes,
284 }, observedAt)
285 : part({ records: [], writes: [] })
286
287 const ownOverrides = pick(exec, 'overrides')
288 const overrideValues: Override[] = list(ownOverrides ?? pick(payload, 'overrides')).map(object).filter((one): one is Json => one !== undefined)
289 .filter(one => ownOverrides !== undefined || text(pick(one, 'executionId')) === execution.value?.id).map(one => ({
290 id: text(pick(one, 'id', 'overrideId'), '?'),
291 decisionId: text(pick(one, 'target', 'decisionId')),
292 action: text(pick(one, 'action'), 'override'),
293 choice: text(pick(one, 'choice', 'model')),
294 at: time(pick(one, 'at', 'recordedAt', 'createdAt')),
295 status: text(pick(one, 'status'), 'applied'),
296 detail: [text(pick(one, 'reason', 'detail')), pick(one, 'linearComment') && pick(one, 'linearComment') !== 'skipped' ? `Linear comment ${text(pick(one, 'linearComment'))}` : ''].filter(Boolean).join(' · '),
297 }))
298 const overrides = pick(payload, 'overrides') !== undefined || pick(exec, 'overrides') !== undefined ? part(overrideValues) : missing([] as Override[], 'overrides')
299
300 const repoKey = text(pick(exec, 'repository', 'repo'))
301 const project: Part<Project | undefined> = exec
302 ? part({ name: text(pick(object(pick(exec, 'project')), 'name'), repoKey), team: issueId.split('-')[0] ?? '', repository: repoKey, milestone: '', url: '', issues: [], documents: [] }, observedAt)
303 : part(undefined)
304 const queue = part(
305 executions
306 .map(one => ({ id: text(pick(one, 'executionId', 'id')), issueId: text(pick(object(pick(one, 'issue')), 'identifier', 'id'), text(pick(one, 'issueId'))), status: text(pick(one, 'status', 'state')), route: routeText(pick(one, 'route')).route, updatedAt: time(pick(one, 'updatedAt', 'startedAt')) }))
307 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
308 )
309
310 return {
311 mode: 'live',
312 origin: fetched.origin,
313 execution,
314 issue,
315 project,
316 decisions,
317 events,
318 agents,
319 acceptance,
320 accounts,
321 failovers,
322 context,
323 linear,
324 overrides,
325 queue,
326 overridePath: isDaemonAnswer ? 'sent to the daemon: policy-checked, audited, commented on Linear' : 'unavailable: the daemon did not answer, so overrides cannot be sent',
327 }
328}
329hooks/model.ts 75 lines1// The one shape every cockpit view draws from. The demo fills it from the
2// fixture; the live reader fills it from the daemon's status (HTTP first, the
3// snapshot files second) and labels each part LIVE, STALE or UNAVAILABLE.
4
5import type { KorProvenance, KorView } from '../types'
6
7export type Stage = 'discover' | 'preflight' | 'context' | 'execute' | 'supervise' | 'verify' | 'synchronise'
8export const STAGES: Stage[] = ['discover', 'preflight', 'context', 'execute', 'supervise', 'verify', 'synchronise']
9
10export type PolicyCheck = { name: string; result: string }
11export type Decision = {
12 decisionId: string
13 kind: string
14 question: string
15 choice: string
16 candidates: string[]
17 confidence: number
18 reasons: string[]
19 evidenceRefs: string[]
20 policyChecks: PolicyCheck[]
21 /** `fallback` when Jev was unreachable and the default route stood in. */
22 source?: string
23}
24export type Event = { at: string; stage: string; text: string; detail?: string; decisionId?: string; agentId?: string }
25export type Agent = { id: string; role: string; status: string }
26export type Acceptance = { id: string; text: string; status: string; evidence: string }
27export type Account = { provider: string; slot: string; state: string; route: string; lastEvent: string }
28export type ContextSource = { source: string; purpose: string; relevance: number }
29export type LinearWrite = { at: string; text: string }
30/** An override as the cockpit shows it: a daemon record when live, a session record in the demo. */
31export type Override = { id: string; decisionId: string; action: string; choice: string; at: string; status: string; detail: string }
32
33export type Issue = { id: string; title: string; url: string; priority: string; labels: string[]; intent: string; state: string }
34export type Project = { name: string; team: string; repository: string; milestone: string; url: string; issues: Array<{ id: string; title: string; state: string; note: string }>; documents: Array<{ title: string; purpose: string }> }
35export type Execution = { id: string; status: string; stage: string; route: string; routeSource: string; worktree: string; branch: string; session: string; pullRequest: string; attempts: number; updatedAt: string; error: string }
36
37/** A part of the cockpit with the provenance it is drawn with; `age` says how old a stale value is. */
38export type Part<T> = { value: T; provenance: KorProvenance; age?: string; why?: string }
39
40export type CockpitData = {
41 mode: 'demo' | 'live'
42 /** Where the live data came from, for the legend: `http://…/status`, a snapshot file, or nothing. */
43 origin: string
44 execution: Part<Execution | undefined>
45 issue: Part<Issue | undefined>
46 project: Part<Project | undefined>
47 decisions: Part<Decision[]>
48 events: Part<Event[]>
49 agents: Part<Agent[]>
50 acceptance: Part<Acceptance[]>
51 accounts: Part<Account[]>
52 failovers: Part<string[]>
53 context: Part<{ sources: ContextSource[]; dropped: Array<{ source: string; reason: string }> }>
54 linear: Part<{ records: Array<{ name: string; value: string }>; writes: LinearWrite[] }>
55 overrides: Part<Override[]>
56 /** Other executions the daemon knows, newest first, for the project view. */
57 queue: Part<Array<{ id: string; issueId: string; status: string; route: string; updatedAt: string }>>
58 /** Demo replay progress: events shown and the total; absent when live. */
59 replay?: { shown: number; total: number; isComplete: boolean }
60 /** What the cockpit can do with an override in this mode. */
61 overridePath: string
62}
63
64export function unavailable<T>(value: T, why: string): Part<T> {
65 return { value, provenance: 'unavailable', why }
66}
67
68/** Seconds as the short age a STALE label carries: 42s, 7m, 3h, 2d. */
69export function ageText(seconds: number): string {
70 if (seconds < 90) return `${Math.max(0, Math.round(seconds))}s`
71 if (seconds < 90 * 60) return `${Math.round(seconds / 60)}m`
72 if (seconds < 36 * 3600) return `${Math.round(seconds / 3600)}h`
73 return `${Math.round(seconds / 86400)}d`
74}
75hooks/views.ts 319 lines1// Builds every cockpit view as plain rows, each carrying its provenance, so
2// the pane and the text output draw the same values with the same labels.
3// The views know nothing of where the data came from: the demo and the live
4// reader both hand them a CockpitData (model.ts).
5
6import type { KorProvenance, KorView, KorViewId } from '../types'
7import { STAGES, type CockpitData, type Decision, type Part } from './model'
8
9export type Tone = 'ok' | 'warn' | 'bad' | 'dim' | 'accent'
10export type Row = { cells: string[]; provenance: KorProvenance; tone?: Tone; open?: KorView }
11export type Section = { title: string; rows: Row[] }
12export type ViewModel = { title: string; sections: Section[] }
13
14/** What the session itself reports; always drawn as live. */
15export type LiveFacts = { claudeCode: string }
16
17export const VIEWS: Array<{ id: KorViewId; label: string; hotkey: string }> = [
18 { id: 'cockpit', label: 'Cockpit', hotkey: '1' },
19 { id: 'issue', label: 'Issue', hotkey: '2' },
20 { id: 'project', label: 'Project', hotkey: '3' },
21 { id: 'decisions', label: 'Decisions', hotkey: '4' },
22 { id: 'agents', label: 'Agents', hotkey: '5' },
23 { id: 'accounts', label: 'Accounts', hotkey: '6' },
24 { id: 'context', label: 'Context', hotkey: '7' },
25 { id: 'linear', label: 'Linear', hotkey: '8' },
26 { id: 'overrides', label: 'Overrides', hotkey: '9' },
27]
28
29export const LABEL: Record<KorProvenance, string> = { live: 'LIVE', stale: 'STALE', fixture: 'FIXTURE', unavailable: 'UNAVAILABLE' }
30
31const row = (part: Part<unknown>, cells: string[], tone?: Tone, open?: KorView): Row => ({ cells, provenance: part.provenance, ...(tone ? { tone } : {}), ...(open ? { open } : {}) })
32
33/** The rows of a part, or one row saying why there are none. */
34function rowsOf<T>(part: Part<T>, build: (value: T) => Row[], empty: string): Row[] {
35 if (part.provenance === 'unavailable') return [{ cells: [part.why ?? 'not reported by the daemon'], provenance: 'unavailable', tone: 'dim' }]
36 const rows = build(part.value)
37 return rows.length ? rows : [row(part, [empty], 'dim')]
38}
39
40/** A section title with the age of a stale part, so the label says how old it is. */
41function titled(title: string, part: Part<unknown>): string {
42 if (part.provenance !== 'stale' || !part.age) return title
43 // The daemon-unreachable reason is the legend's; any other reason (a failed gateway read) is the section's own.
44 return `${title} · stale, observed ${part.age} ago${part.why && !part.why.startsWith('daemon did not answer') && !part.why.startsWith('older than') ? ` (${part.why})` : ''}`
45}
46
47export const statusTone = (status: string): Tone | undefined =>
48 ['pass', 'done', 'complete', 'completed', 'available', 'preferred', 'applied', 'merged'].includes(status)
49 ? 'ok'
50 : ['fail', 'failed', 'quota', 'quota-limited', 'blocked', 'rejected', 'escalated', 'authentication', 'unavailable'].includes(status)
51 ? 'bad'
52 : ['pending', 'waiting', 'unknown', 'standby', 'reverted', 'queued'].includes(status)
53 ? 'dim'
54 : undefined
55
56function header(data: CockpitData, live: LiveFacts): Section {
57 const { execution, issue, events } = data
58 const exec = execution.value
59 const stageIndex = exec ? STAGES.indexOf(exec.stage as (typeof STAGES)[number]) + 1 : 0
60 const progress = data.replay ? ` · ${data.replay.shown}/${data.replay.total} events` : ''
61 return {
62 title: titled('Execution', execution),
63 rows: [
64 ...rowsOf(execution, value => (value ? [
65 row(execution, ['Execution', `${value.id} · ${issue.value?.id ?? '?'} · ${value.status.toUpperCase()}`], value.status === 'complete' ? 'ok' : statusTone(value.status) ?? 'accent', { id: 'issue' }),
66 row(execution, ['Stage', `${stageIndex > 0 ? `${stageIndex}/${STAGES.length} ` : ''}${value.stage}${progress}`]),
67 row(execution, ['Route', `${value.route}${value.routeSource && value.routeSource !== 'jev' ? ` (${value.routeSource})` : ''}`], value.routeSource === 'fallback' ? 'warn' : undefined, { id: 'decisions' }),
68 ...(value.updatedAt ? [row(execution, ['Updated', value.updatedAt])] : []),
69 ...(value.error ? [row(execution, ['Last error', value.error], 'bad')] : []),
70 ] : []), 'no execution: the daemon has not picked up an issue'),
71 ...(events.provenance === 'unavailable' && execution.provenance !== 'unavailable' ? [row(events, ['Events', events.why ?? 'not reported'], 'dim')] : []),
72 { cells: ['Claude Code', live.claudeCode], provenance: 'live' },
73 ],
74 }
75}
76
77function decisionRows(data: CockpitData): Row[] {
78 const { events, decisions } = data
79 return rowsOf(events, list => list.map(event => {
80 const decision = event.decisionId ? decisions.value.find(one => one.decisionId === event.decisionId) : undefined
81 const detail = decision?.source?.startsWith('override') ? `${decision.choice} (override)` : (event.detail ?? '')
82 return row(events, [event.at, event.text, detail], decision ? 'accent' : undefined, decision ? { id: 'decision', target: decision.decisionId } : undefined)
83 }), 'no events yet')
84}
85
86export function buildView(view: KorView, data: CockpitData, live: LiveFacts): ViewModel {
87 const { execution, issue, project, decisions, agents, acceptance, accounts, failovers, context, linear, overrides, queue } = data
88 const issueId = issue.value?.id ?? 'no issue'
89 switch (view.id) {
90 case 'cockpit':
91 return {
92 title: data.mode === 'demo' ? 'KOR CODE cockpit · demo' : 'KOR CODE cockpit',
93 sections: [
94 header(data, live),
95 {
96 title: titled(['Current issue', project.value?.name, issue.value?.id].filter(Boolean).join(' · '), issue),
97 rows: [
98 ...rowsOf(issue, value => (value ? [row(issue, [value.title], undefined, { id: 'issue' })] : []), 'no current issue'),
99 ...rowsOf(agents, list => list.map(agent => row(agents, [agent.id, agent.status], statusTone(agent.status), { id: 'agent', target: agent.id })), 'no agents reported'),
100 ],
101 },
102 { title: titled('Decision stream (latest 6)', data.events), rows: decisionRows(data).slice(-6) },
103 { title: titled('Subscription pool', accounts), rows: rowsOf(accounts, () => poolSummary(data), 'no account slots reported') },
104 { title: titled('Engineering quality', acceptance), rows: rowsOf(acceptance, list => list.map(item => row(acceptance, [item.id, item.status], statusTone(item.status))), 'no acceptance conditions') },
105 ],
106 }
107 case 'issue':
108 return {
109 title: `Issue ${issueId}`,
110 sections: [
111 {
112 title: titled(issue.value?.title ?? 'Issue', issue),
113 rows: rowsOf(issue, value => (value ? [
114 row(issue, ['State', value.state]),
115 row(issue, ['Priority', value.priority]),
116 row(issue, ['Labels', value.labels.join(', ') || 'none']),
117 row(issue, ['Intent', value.intent]),
118 row(issue, ['Link', value.url]),
119 ...(execution.value ? [row(execution, ['Execution', `${execution.value.id} · ${execution.value.status} · attempt ${execution.value.attempts}`])] : []),
120 ] : []), 'no current issue'),
121 },
122 { title: titled('Acceptance conditions', acceptance), rows: rowsOf(acceptance, list => list.map(item => row(acceptance, [item.id, item.status, item.evidence], statusTone(item.status))), 'no acceptance conditions') },
123 { title: 'Next action', rows: [nextAction(data)] },
124 ],
125 }
126 case 'project':
127 return {
128 title: `Project ${project.value?.name ?? ''}`.trim(),
129 sections: [
130 {
131 title: titled('Project', project),
132 rows: rowsOf(project, value => (value ? [row(project, ['Team', value.team]), row(project, ['Repository', value.repository]), ...(value.milestone ? [row(project, ['Milestone', value.milestone])] : [])] : []), 'no project mapped'),
133 },
134 {
135 title: titled('Issues', project),
136 rows: rowsOf(project, value => (value?.issues ?? []).map(one => row(project, [one.id, one.state, one.title, one.note], one.id === issue.value?.id ? 'accent' : undefined, one.id === issue.value?.id ? { id: 'issue' } : undefined)), 'no issues listed'),
137 },
138 {
139 title: titled('Executions', queue),
140 rows: rowsOf(queue, list => list.filter(one => one.id).map(one => row(queue, [one.id, one.issueId, one.status, one.route, one.updatedAt], statusTone(one.status))), 'no executions recorded'),
141 },
142 ...(project.value?.documents.length ? [{ title: titled('Documents', project), rows: project.value.documents.map(doc => row(project, [doc.title, doc.purpose])) }] : []),
143 ],
144 }
145 case 'decisions':
146 return { title: 'Decision stream', sections: [{ title: titled(`${data.replay ? `${data.replay.shown} of ${data.replay.total}` : data.events.value.length} events · press a decision for its reasons`, data.events), rows: decisionRows(data) }] }
147 case 'decision':
148 return decisionView(view.target, data)
149 case 'agents':
150 return {
151 title: 'Agents',
152 sections: [{ title: titled(`${agents.value.filter(agent => agent.status === 'working').length} working`, agents), rows: rowsOf(agents, list => list.map(agent => row(agents, [agent.id, agent.status, agent.role], statusTone(agent.status), { id: 'agent', target: agent.id })), 'no agents reported') }],
153 }
154 case 'agent': {
155 const agent = agents.value.find(one => one.id === view.target)
156 if (!agent) return missing(`agent ${view.target ?? '(none)'}`, data)
157 const shown = data.events.value.filter(event => event.agentId === agent.id)
158 return {
159 title: `Agent ${agent.id}`,
160 sections: [
161 { title: titled(agent.role, agents), rows: [row(agents, ['Status', agent.status], statusTone(agent.status)), ...(execution.value ? [row(execution, ['Worktree', execution.value.worktree]), row(execution, ['Branch', execution.value.branch])] : [])] },
162 { title: titled('Events', data.events), rows: rowsOf(data.events, () => shown.map(event => row(data.events, [event.at, event.text, event.detail ?? ''])), 'no events yet') },
163 ],
164 }
165 }
166 case 'accounts':
167 return {
168 title: 'Provider accounts',
169 sections: [
170 {
171 title: titled('Account slots', accounts),
172 rows: rowsOf(accounts, list => (list.length ? [row(accounts, ['Provider', 'Slot', 'State', 'Route', 'Last event'], 'dim'), ...list.map(account => row(accounts, [account.provider, account.slot, account.state, account.route, account.lastEvent], statusTone(account.state)))] : []), 'no account slots reported'),
173 },
174 { title: titled('Failover', failovers), rows: rowsOf(failovers, list => list.map(text => row(failovers, [text], 'warn')), 'none') },
175 ],
176 }
177 case 'context':
178 return {
179 title: 'Context manifest',
180 sections: [
181 { title: titled('Sources', context), rows: rowsOf(context, value => value.sources.map(item => row(context, [item.relevance.toFixed(2), item.source, item.purpose])), 'no context manifest recorded') },
182 { title: titled('Dropped', context), rows: rowsOf(context, value => value.dropped.map(item => row(context, [item.source, item.reason], 'dim')), 'none dropped') },
183 ],
184 }
185 case 'linear':
186 return {
187 title: 'Linear links',
188 sections: [
189 { title: titled('Records', linear), rows: rowsOf(linear, value => value.records.map(record => row(linear, [record.name, record.value])), 'no Linear records') },
190 { title: titled('Writes to Linear', linear), rows: rowsOf(linear, value => value.writes.map(item => row(linear, [item.at, item.text])), 'none yet') },
191 ],
192 }
193 case 'overrides':
194 return {
195 title: 'Overrides',
196 sections: [
197 {
198 title: titled(data.overridePath, overrides),
199 rows: rowsOf(overrides, list => list.map(one => row(overrides, [one.at, one.id, one.decisionId, `${one.action}: ${one.choice}`, one.status, one.detail], statusTone(one.status) ?? 'warn', { id: 'decision', target: one.decisionId })), 'none: open a decision and press o, or /kor override route <model>'),
200 },
201 ],
202 }
203 }
204}
205
206function decisionView(target: string | undefined, data: CockpitData): ViewModel {
207 const { decisions, overrides } = data
208 const decision: Decision | undefined = decisions.value.find(one => one.decisionId === target || one.kind === target)
209 if (!decision) return missing(`decision ${target ?? '(none)'}`, data)
210 const made = data.events.value.some(event => event.decisionId === decision.decisionId) || data.mode === 'live'
211 const overridden = decision.source?.startsWith('override')
212 const actions = overrides.value.filter(one => one.decisionId === decision.decisionId)
213 return {
214 title: `Jev decision ${decision.decisionId}`,
215 sections: [
216 {
217 title: titled(made ? 'Decided' : 'Not reached yet in this replay', decisions),
218 rows: [
219 row(decisions, ['Question', decision.question]),
220 row(decisions, ['Choice', decision.source && decision.source !== 'jev' ? `${decision.choice} (${decision.source})` : decision.choice], overridden || decision.source === 'fallback' ? 'warn' : 'accent'),
221 row(decisions, ['Confidence', decision.confidence.toFixed(2)]),
222 row(decisions, ['Candidates', decision.candidates.join(' | ') || 'none recorded']),
223 ],
224 },
225 { title: titled('Reasons', decisions), rows: decision.reasons.length ? decision.reasons.map(reason => row(decisions, [`• ${reason}`])) : [row(decisions, ['none recorded'], 'dim')] },
226 { title: titled('Evidence', decisions), rows: decision.evidenceRefs.length ? decision.evidenceRefs.map(ref => row(decisions, [ref])) : [row(decisions, ['none recorded'], 'dim')] },
227 { title: titled('Policy checks', decisions), rows: decision.policyChecks.length ? decision.policyChecks.map(check => row(decisions, [check.name, check.result], statusTone(check.result))) : [row(decisions, ['none recorded'], 'dim')] },
228 {
229 title: titled('Actions on this decision', overrides),
230 rows: rowsOf(overrides, () => actions.map(one => row(overrides, [one.at, one.id, `${one.action}: ${one.choice}`, one.status], statusTone(one.status) ?? 'warn')), data.mode === 'demo' ? 'none: a apply · o override · e gather evidence · r re-evaluate' : 'none: /kor override <decision|route> <choice> sends one to the daemon'),
231 },
232 ],
233 }
234}
235
236function poolSummary(data: CockpitData): Row[] {
237 const { accounts } = data
238 const providers = [...new Set(accounts.value.map(account => account.provider))]
239 return providers.map(provider => {
240 const slots = accounts.value.filter(account => account.provider === provider)
241 const available = slots.filter(account => account.state === 'available').length
242 return row(accounts, [provider, `${available} / ${slots.length} available`], available === slots.length ? 'ok' : available === 0 ? 'bad' : 'warn', { id: 'accounts' })
243 })
244}
245
246function nextAction(data: CockpitData): Row {
247 const { execution, acceptance } = data
248 const exec = execution.value
249 if (!exec) return row(execution, [execution.provenance === 'unavailable' ? 'start the daemon: kor serve' : 'label a Todo issue kor, or run kor run <issue>'], 'accent')
250 if (['complete', 'review'].includes(exec.status)) return row(execution, [exec.pullRequest ? `review ${exec.pullRequest}` : 'none: verified outcome written to Linear'], 'accent')
251 if (['blocked', 'escalated', 'failed'].includes(exec.status)) return row(execution, [`answer Kor on ${data.issue.value?.url ?? 'the issue'}`], 'bad')
252 const pending = acceptance.value.find(item => item.status === 'pending')
253 return row(execution, [exec.stage === 'verify' && pending ? `evidence ${pending.id}` : `continue ${exec.stage}`], 'accent')
254}
255
256function missing(what: string, data: CockpitData): ViewModel {
257 return { title: 'Not found', sections: [{ title: `No ${what} in the ${data.mode === 'demo' ? 'fixture' : 'daemon status'}`, rows: [] }] }
258}
259
260/** The one-line status the cockpit pins under the prompt. */
261export function statusLine(data: CockpitData, live: LiveFacts): string {
262 const { execution, issue, agents, overrides } = data
263 const exec = execution.value
264 const label = execution.provenance === 'stale' && execution.age ? `${LABEL.stale} ${execution.age}` : LABEL[execution.provenance]
265 const active = overrides.value.filter(one => one.status === 'active' || one.status === 'applied').length
266 const working = agents.value.filter(agent => agent.status === 'working').length
267 const parts = exec
268 ? [
269 `[${label}] Kor ${issue.value?.id ?? exec.id}`,
270 `${exec.stage} ${STAGES.indexOf(exec.stage as (typeof STAGES)[number]) + 1}/${STAGES.length}`,
271 exec.status,
272 exec.routeSource && exec.routeSource !== 'jev' ? `${exec.route} (${exec.routeSource.split(' ')[0]})` : exec.route,
273 ...(data.mode === 'demo' ? [`${working} agent${working === 1 ? '' : 's'} working`] : []),
274 ...(active ? [`${active} override${active === 1 ? '' : 's'}`] : []),
275 ]
276 : [`[${label}] Kor`, execution.provenance === 'unavailable' ? 'daemon down: /kor for why' : 'idle: no execution']
277 return `${parts.join(' · ')} · [${LABEL.live}] CC ${live.claudeCode}`
278}
279
280/** The legend under every view title: what each label means in this mode. */
281export function legend(data: CockpitData): string {
282 return data.mode === 'demo'
283 ? `Legend: [${LABEL.fixture}] demo fixture · [${LABEL.live}] read from this session`
284 : `Legend: [${LABEL.live}] daemon, fresh · [${LABEL.stale}] last known, with its age · [${LABEL.unavailable}] not reported or daemon down · source ${data.origin}`
285}
286
287/** The view as monospaced text, for headless runs and the transcript. */
288export function renderText(model: ViewModel, data: CockpitData): string {
289 const lines = [`${model.title}`, legend(data)]
290 for (const section of model.sections) {
291 lines.push('', `── ${section.title}`)
292 const widths = columnWidths(section.rows)
293 for (const one of section.rows) lines.push(` ${tag(one.provenance)} ${padCells(one.cells, widths)}`)
294 }
295 return lines.join('\n')
296}
297
298/** The provenance label, padded so it leads every row and survives truncation. */
299export function tag(provenance: KorProvenance): string {
300 return `[${LABEL[provenance]}]`.padEnd(14)
301}
302
303export function columnWidths(rows: Row[]): number[] {
304 const widths: number[] = []
305 for (const one of rows) one.cells.slice(0, -1).forEach((cell, index) => (widths[index] = Math.min(32, Math.max(widths[index] ?? 0, cell.length))))
306 return widths
307}
308
309export function padCells(cells: string[], widths: number[]): string {
310 return cells.map((cell, index) => (index < cells.length - 1 ? cell.padEnd(widths[index] ?? 0) : cell)).join(' ')
311}
312
313export function parseView(args: string): KorView | undefined {
314 const [name = 'cockpit', target] = args.trim().split(/\s+/).filter(Boolean)
315 const id = VIEWS.find(view => view.id === name || view.label.toLowerCase() === name)?.id ?? (name === 'decision' || name === 'agent' ? name : undefined)
316 if (!id) return undefined
317 return target ? { id, target } : { id }
318}
319types/index.d.ts 44 lines1// The cockpit's state contract: every value the hooks module keeps in `$.state`.
2
3/** Mirrors core's Provenance (a hooks module imports only files inside its plugin); `fixture` is the README's "Illustrative" and only `/kor demo` draws it. */
4export type KorProvenance = 'live' | 'stale' | 'fixture' | 'unavailable'
5
6export type KorViewId =
7 | 'cockpit'
8 | 'issue'
9 | 'project'
10 | 'decisions'
11 | 'decision'
12 | 'agents'
13 | 'agent'
14 | 'accounts'
15 | 'context'
16 | 'linear'
17 | 'overrides'
18
19/** One cockpit view; `target` names the decision or agent a drill-down shows. */
20export type KorView = { id: KorViewId; target?: string }
21
22/** A demo action on a fixture decision; reverting keeps the record visible. Live overrides are the daemon's records. */
23export type KorOverride = {
24 id: string
25 decisionId: string
26 action: 'apply' | 'override' | 'evidence' | 'reevaluate'
27 choice: string
28 at: string
29 isReverted: boolean
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'kor-code-cockpit': {
35 view: KorView
36 /** `live` draws the daemon's status; `demo` draws the fixture execution. */
37 mode: 'live' | 'demo'
38 /** Events of the fixture execution shown; null shows all of them. */
39 cursor: number | null
40 overrides: KorOverride[]
41 }
42 }
43}
44