SLOPSHOPPER

kor-code-cockpit

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

newpanecommandtoaststatusnetwork
v0.2.0MITupdated 2026-10-16korallis/kor-code/packages/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kor-code-cockpit
│ ┃ Kor cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ KOR CODE cockpit UNAVAILABLE │ ┃ Legend: [LIVE] daemon, fresh · [STALE] last ⏺ Read(src/auth.ts) │ ┃ 1: Cockpit 2: Issue 3: Project 4: Decisions ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ── Execution ⎿ Added 2 lines, removed 1 line │ ┃ [UNAVAILABLE] daemon answered HTTP 0; no sna ⏺ Bash(bun test) │ ┃ [LIVE] Claude Code 2.1.289 ⎿ 3 pass, 1 fail │ ┃ │ ┃ ── Current issue ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [UNAVAILABLE] daemon down: see Execution │ ┃ [UNAVAILABLE] daemon down: see Execution ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ── Decision stream (latest 6) › /kor │ ┃ [UNAVAILABLE] daemon down: see Execution ⎿ kor-code-cockpit: Kor cockpit: cockpit [UNAVAILABLE from none] │ ┃ │ ┃ ── Subscription pool │ ┃ [UNAVAILABLE] daemon down: see Execution │ ┃ │ ┃ ── Engineering quality │ ┃ [UNAVAILABLE] daemon down: see Execution │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ kor-code-cockpit: [UNAVAILABLE] Kor · daemon down: /kor for why · [LIVE] CC 2.1.289

Draws

Pane · Kor cockpit
KOR CODE cockpit UNAVAILABLE Legend: [LIVE] daemon, fresh · [STALE] last known, with… 1: Cockpit 2: Issue 3: Project 4: Decisions 5: Agents 6: Acc ── Execution [UNAVAILABLE] daemon answered HTTP 0; no snapshot at /Users/ [LIVE] Claude Code 2.1.289 ── Current issue [UNAVAILABLE] daemon down: see Execution [UNAVAILABLE] daemon down: see Execution ── Decision stream (latest 6) [UNAVAILABLE] daemon down: see Execution ── Subscription pool [UNAVAILABLE] daemon down: see Execution ── Engineering quality [UNAVAILABLE] daemon down: see Execution
README

Kor Code

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).

Mission Control's Command Centre: one run needing an answer, two verified runs ready to ship, and the Jev learned card

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).

Requirements

  • Linux (built and tested on Ubuntu under WSL2) with Node 24+, Corepack and Git.
  • Claude Code 2.1.299 or newer, and a browser.
  • A running CLIProxyAPI gateway holding your model accounts. Kor expects it at http://127.0.0.1:8317.
  • A Linear personal API key for projects that use Linear. Kor reaches Linear only through its API: there is no webhook or Linear app to install.
  • A TypeSafe API key, for Jev.
  • To ship: a GitHub 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.

Install

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.

First run

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.

Work on several projects

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:

  • In Mission Control: open Settings, find the Projects card, enter a project key (any name you like), its checkout path and tracker. Enter a Linear team and project ID, or a GitHub repository with an optional Project v2, then choose Add project and Save configuration. The daemon validates the file, applies it without a restart once no work is running and keeps the old one as config.yaml.bak. A project added here starts with ship: approve; set its checks and tool allowlist under the project's Repository settings.
  • In 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.

Settings: Linear, GitHub, Gateway and Jev connected

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.

Daily use

Write and start an issue

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.

The Queue an issue dialog

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.

Work in Mission Control

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.

Agents: a verified run's live output, stages, acceptance and cost

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).

The decision workspace for a verified run waiting for approval

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.

Skills and your private pack

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.

Override Jev

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.

Explore alternatives estimates guidance from the latest plan, with a pinned skill in an isolated acceptance run

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.

Ship

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

Source 7 files
hooks/register.tsx 383 lines
1// 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}
383
hooks/install-scope.ts 3 lines
1// The installer replaces this constant in its private marketplace copy.
2export const installationPilot: string | undefined = undefined
3
hooks/fixture.ts 308 lines
1// 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}
308
hooks/live.ts 329 lines
1// 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}
329
hooks/model.ts 75 lines
1// 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}
75
hooks/views.ts 319 lines
1// 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}
319
types/index.d.ts 44 lines
1// 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