SLOPSHOPPER

korus-fleet

Read-only: an opt-in status line with seat and usage, and a /fleet pane listing RUNNING seat records from the fleet script's -Json output

newpanecommandstatusprocesstimer
★ 1v0.2.0MITupdated 2026-10-02MEFORORG/korus/plugins/korus-fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · korus-fleet
│ ┃ Fleet board ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refreshing ] │ ┃ Reading scripts/coord/fleet.ps1 (about 30 ⏺ Read(src/auth.ts) │ ┃ s)... ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ ⎿ korus-fleet: Fleet board opened. It refreshes every 5 minutes wh │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet board
[ Refreshing ] Reading scripts/coord/fleet.ps1 (about 30 s)...
README

KORUS

Keep One Repo, Unblock Sessions. A working method for running several AI coding sessions against one codebase without them colliding, losing work, or quietly agreeing with each other.

Status: early, and the spec is being written now

KORUS grew out of tooling for a single project and has been revised repeatedly under real use. Its properties were discovered, not designed -- most of what is known about it came from running it and measuring what broke.

This repository exists to change that. It uses Spec Kit for spec-driven development: the constitution first, then the spec, then the plan.

Nothing here is stable yet. Findings are still being consolidated from three days of measured operation, and some published guidance has already been shown wrong.

Start here: the constitution

The KORUS Constitution is the document to read first. It holds the rules a session, a seat, a gate or a later spec may not break, and every article names the evidence behind it so a reader can check rather than trust.

Alongside it are the seat playbooks in roles/, the working guides in docs/, and the specs in specs/. The constitution outranks all of them: where a playbook and an article disagree, the article wins and the playbook is the bug.

Thirteen articles, in short:

INo session is the only reader of its own work
IIPublish readings, not conclusions
IIIA gate that cannot check identity must make refusal legible
IVEvery claim names the condition it did not vary
VNo rule may manufacture its own evidence
VIA number without its instrument is not a measurement
VIIWaiting is a design cost and it is measured
VIIIThe account roster is assigned by the Owner, and no design may infer it
IXBuilt for Claude Code, and no design may require a particular surface
XA seat that cannot be measured cannot be steered
XIA seat's job is to write something down
XIIThe shared write surface is the boundary that binds, not the account
XIIIWork reaches the model through Claude Code, never through the API

It is at v1.16.0 and it expects to be wrong in places. Most articles rest on a small number of observations, several from a single night of operation, and the document says so. Amendments require evidence, and retired text stays with the reason it was retired.

What problem it solves

Run more than one AI session on one repository and four things go wrong:

  1. They collide. Two sessions in one checkout overwrite each other's work.
  2. They lose work. A session ends and its context, findings and half-finished branches go with it.
  3. They agree wrongly. Two sessions that share a hidden condition reach the same wrong answer and their agreement reads as confirmation.
  4. They cannot be told apart. A stalled session and a working one look identical from outside.

KORUS is the set of roles, gates and instruments that address these.

Two design facts that came from measuring rather than guessing

Both on the repository this tooling was developed in. Over 30 days, 166 sessions ran with their working directory in the shared primary checkout. Both percentages below are shares of the Edit/Write calls those sessions made, not of every write on the machine:

  • A banner asking sessions to use worktrees does not work. 6,075 of those calls (44%) landed in the primary's own tree. If a convention matters, enforce it with a hook. A reminder produces no evidence either way.
  • Gate on the write's target path, never the session's working directory. Another 4,010 of them (29%) landed inside a sibling worktree by absolute path, which is already correct behaviour that a directory-keyed gate would have denied every one of.

This page is the record for both figures. Neither has been re-measured, and nothing in this repository can recompute them.

The shape

Work is divided among seats, each a session with one job:

SeatWhat it doesLifetime
ManagerPlans, runs workers, holds the owner's attentionlong-lived
BuilderTakes one brief, does the work, opens a pull requestone turn
StewardWrites files other seats readcron, no model calls
LanderDecides merge orderlong-lived

Nine further seats were tried and retired: the Console on 2026-09-10, and another on 2026-09-12 that is deliberately left unnamed here, with nothing replacing it. Why each went is part of the record this repository holds.

Repository layout

.specify/memory/constitution.md   the rules nothing may break
.specify/                         Spec Kit scaffold: templates and scripts
specs/                            one directory per feature: spec.md, plan.md, tasks.md

Related

  • claude-multisession -- the earlier public home for KORUS docs and scripts. Its site deploy was disabled on 2026-09-08, and korus publishes the site now. The findings moved here too, so the clause saying they live there is retired.

Licence

See LICENSE.

Source 2 files
hooks/register.tsx 263 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, PluginOptions, Register } from 'claude-code'
3
4import type { FleetBoard, FleetJson, FleetRow } from '../types'
5
6// Read-only. It reads one file and runs one script; it never writes to the repository, the
7// coordination directory, or any peer, and calls no file-write API.
8
9const PANE = 'korus-fleet'
10const STATUS_EVERY_MS = 60_000
11// The pilot timed one run of the MessageFoundry fleet script at about 26 s (not re-measured
12// here), so the board refreshes rarely and only while the pane is open.
13const BOARD_EVERY_MS = 5 * 60_000
14const FLEET_TIMEOUT_MS = 120_000
15
16// Where scripts/coord/seat.ps1 -Declare writes the seat. Git-ignored by `*.local.*`.
17const SEAT_MARKER = '.claude/seat.local.txt'
18// The userConfig default, repeated here so a load with no options still has a path.
19const DEFAULT_FLEET_SCRIPT = 'scripts/coord/fleet.ps1'
20
21const board = atom({ plugin: 'korus-fleet', key: 'board' } as const, null)
22const isRefreshing = atom({ plugin: 'korus-fleet', key: 'isRefreshing' } as const, false)
23
24type Api = Engine
25
26// The status line is opt-in (owner instruction 2026-10-02). The userConfig field `statusLine`
27// defaults to false, and only a stored true turns it on.
28function isStatusLineOn(options: PluginOptions): boolean {
29  return options['statusLine'] === true
30}
31
32function fleetScriptFrom(options: PluginOptions): string {
33  const value = options['fleetScript']
34  return typeof value === 'string' && value.trim() !== '' ? value.trim() : DEFAULT_FLEET_SCRIPT
35}
36
37async function readSeat($: Api): Promise<string> {
38  try {
39    const text = await $.fs.read(SEAT_MARKER)
40    return text.trim() || 'undeclared'
41  } catch {
42    return 'no seat marker'
43  }
44}
45
46// Short labels for the rate-limit kinds the session reports (owner instruction 2026-10-02).
47// Any other kind is shown as the session names it.
48const LIMIT_LABELS: ReadonlyMap<string, string> = new Map([
49  ['five_hour', '5h'],
50  ['seven_day', '7d'],
51])
52
53function limitLabel(kind: string): string {
54  return LIMIT_LABELS.get(kind) ?? kind
55}
56
57async function refreshStatus($: Api): Promise<void> {
58  const parts = [`seat ${await readSeat($)}`]
59  try {
60    const usage = await $.session.usage()
61    if (usage.context.percent !== undefined) {
62      parts.push(`ctx ${Math.round(usage.context.percent)}%`)
63    }
64    for (const limit of usage.rateLimits) {
65      parts.push(`${limitLabel(limit.kind)} ${Math.round(limit.percentUsed)}%`)
66    }
67  } catch (err) {
68    parts.push(`usage unread: ${err instanceof Error ? err.name : 'error'}`)
69  }
70  $.ui.status(parts.join(' | '))
71}
72
73// The board consumes the FleetJson contract (types/index.d.ts) and nothing else. Anything that
74// does not have that shape is reported as a contract mismatch rather than drawn half-read.
75function isNullableString(value: unknown): boolean {
76  return value === undefined || value === null || typeof value === 'string'
77}
78
79function isFleetJson(value: unknown): value is FleetJson {
80  if (typeof value !== 'object' || value === null) return false
81  const v = value as { receipt?: unknown; rows?: unknown }
82  if (typeof v.receipt !== 'object' || v.receipt === null || !Array.isArray(v.rows)) return false
83  const r = v.receipt as { renderedAtUtc?: unknown; liveSessionsInRepo?: unknown; stopConditions?: unknown }
84  if (typeof r.renderedAtUtc !== 'string' || typeof r.liveSessionsInRepo !== 'number') return false
85  const stops = r.stopConditions
86  if (!isNullableString(stops) && !(Array.isArray(stops) && stops.every(s => typeof s === 'string'))) {
87    return false
88  }
89  return v.rows.every(row => {
90    if (typeof row !== 'object' || row === null) return false
91    const x = row as { Seat?: unknown; Box?: unknown; Branch?: unknown; State?: unknown; AgeHours?: unknown }
92    return (
93      typeof x.Box === 'string' &&
94      typeof x.State === 'string' &&
95      isNullableString(x.Seat) &&
96      isNullableString(x.Branch) &&
97      (x.AgeHours === null || typeof x.AgeHours === 'number')
98    )
99  })
100}
101
102// A script may print its stop conditions as one string or as a list, and a healthy run's list is
103// empty. Both an empty list and an empty string mean "none".
104function stopText(stops: FleetJson['receipt']['stopConditions']): string | null {
105  const text = Array.isArray(stops) ? stops.join('; ') : (stops ?? '')
106  return text.trim() === '' ? null : text
107}
108
109async function loadBoard($: Api, script: string): Promise<FleetBoard> {
110  const failed = async (error: string): Promise<FleetBoard> => ({
111    renderedAt: new Date(await $.clock.now()).toISOString(),
112    liveSessions: 0,
113    stopConditions: null,
114    warning: null,
115    running: [],
116    error,
117  })
118  let stdout: string
119  let warning: string | null = null
120  try {
121    if (!(await $.fs.exists(script))) {
122      return failed(`fleet script not found: ${script}. Set the korus-fleet fleetScript option to its path.`)
123    }
124    const ran = await $.process.run(['pwsh', '-NoProfile', '-File', script, '-Json'], {
125      timeoutMs: FLEET_TIMEOUT_MS,
126    })
127    if (ran.exitCode !== 0 && ran.stdout.trim() === '') {
128      return failed(`${script} exited ${ran.exitCode} with no output`)
129    }
130    if (ran.exitCode !== 0) {
131      warning = `${script} exited ${ran.exitCode}; the rows below may be incomplete`
132    }
133    stdout = ran.stdout
134  } catch (err) {
135    return failed(`${script} did not run: ${err instanceof Error ? err.message : String(err)}`)
136  }
137  let parsed: unknown
138  try {
139    parsed = JSON.parse(stdout)
140  } catch {
141    return failed(`${script} output was not JSON`)
142  }
143  if (!isFleetJson(parsed)) {
144    return failed(`${script} output does not match the fleet JSON contract`)
145  }
146  // A row with no age sorts last rather than first, so it never reads as the newest.
147  const sortAge = (h: number | null): number => (h === null ? Number.POSITIVE_INFINITY : h)
148  const running: FleetRow[] = parsed.rows
149    .filter(row => row.State === 'RUNNING')
150    .sort((a, b) => sortAge(a.AgeHours) - sortAge(b.AgeHours))
151    .map(row => ({
152      seat: row.Seat ?? null,
153      box: row.Box,
154      branch: row.Branch ?? null,
155      ageHours: row.AgeHours,
156    }))
157  return {
158    renderedAt: parsed.receipt.renderedAtUtc,
159    liveSessions: parsed.receipt.liveSessionsInRepo,
160    stopConditions: stopText(parsed.receipt.stopConditions),
161    warning,
162    running,
163    error: null,
164  }
165}
166
167// The guard is module memory, set before the first await, so two presses close together cannot
168// both pass it. The isRefreshing atom only drives the button label. A module reload starts with
169// the guard clear, so a refresh cut off by a reload never blocks the next one.
170let inFlight = false
171
172async function refreshBoard($: Api, script: string): Promise<void> {
173  if (inFlight) return
174  inFlight = true
175  try {
176    await update($, isRefreshing, () => true)
177    const next = await loadBoard($, script)
178    await update($, board, () => next)
179  } finally {
180    inFlight = false
181    await update($, isRefreshing, () => false)
182  }
183}
184
185async function isPaneShown($: Api): Promise<boolean> {
186  const panes = await $.ui.panes()
187  return panes.some(pane => pane.id === PANE && pane.isShown)
188}
189
190function age(hours: number | null): string {
191  if (hours === null) return '?'
192  return hours < 1 ? `${Math.round(hours * 60)}m` : `${hours.toFixed(1)}h`
193}
194
195export const register: Register = (on, options) => {
196  const script = fleetScriptFrom(options)
197  const showStatus = isStatusLineOn(options)
198
199  on('session.start', async ($, e, next) => {
200    await $.command.register({
201      name: 'fleet',
202      description: 'Open the KORUS fleet board (read-only)',
203    })
204    if (showStatus) {
205      void refreshStatus($)
206      $.clock.every(STATUS_EVERY_MS, () => void refreshStatus($))
207    } else {
208      // Off: clear any line a load with the option on drew before this reload, and read no usage.
209      $.ui.status(undefined)
210    }
211    $.clock.every(BOARD_EVERY_MS, () => {
212      void isPaneShown($)
213        .then(shown => (shown ? refreshBoard($, script) : undefined))
214        .catch(() => undefined)
215    })
216    return next(e)
217  })
218
219  on('turn.complete', async ($, e, next) => {
220    const done = await next(e)
221    if (showStatus) void refreshStatus($)
222    return done
223  })
224
225  on('command.run', { command: 'fleet' }, async $ => {
226    await $.ui.open({ id: PANE, title: 'Fleet board' })
227    void refreshBoard($, script)
228    return { text: 'Fleet board opened. It refreshes every 5 minutes while open.' }
229  })
230
231  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
232    const { Box, Text, Button } = $.ui.resolve(e)
233    const current = await read($, board)
234    const busy = await read($, isRefreshing)
235
236    return (
237      <Box flexDirection="column">
238        <Box flexDirection="row">
239          <Button key="refresh" label={busy ? 'Refreshing' : 'Refresh'} onPress={() => void refreshBoard($, script)} />
240        </Box>
241        {current === null && <Text dimColor>{busy ? `Reading ${script} (about 30 s)...` : 'No reading yet.'}</Text>}
242        {current !== null && current.error !== null && <Text color="red">{current.error}</Text>}
243        {current !== null && current.error === null && (
244          <Box flexDirection="column">
245            <Text dimColor>
246              rendered {current.renderedAt} | live in repo {current.liveSessions} | RUNNING records {current.running.length}
247            </Text>
248            {current.warning !== null && <Text color="yellow">WARNING: {current.warning}</Text>}
249            {current.stopConditions !== null && (
250              <Text color="yellow">STOP CONDITION: {current.stopConditions}</Text>
251            )}
252            {current.running.map(row => (
253              <Text>
254                {(row.seat ?? 'UNDECLARED').padEnd(10)} {age(row.ageHours).padStart(6)} {row.box}
255              </Text>
256            ))}
257          </Box>
258        )}
259      </Box>
260    )
261  })
262}
263
types/index.d.ts 48 lines
1// The input contract: what the fleet script prints with -Json. The board reads these fields and
2// nothing else. docs/PLUGINS.md states the same contract for a reader without this file.
3export type FleetJsonRow = {
4  Seat: string | null
5  Box: string
6  Branch: string | null
7  State: string
8  // null where the script could not read the record's age.
9  AgeHours: number | null
10}
11
12export type FleetJson = {
13  receipt: {
14    renderedAtUtc: string
15    liveSessionsInRepo: number
16    // One string or a list of them; an empty list or string means none.
17    stopConditions?: string | string[] | null
18  }
19  rows: FleetJsonRow[]
20}
21
22// What the pane draws, derived from FleetJson.
23export type FleetRow = {
24  seat: string | null
25  box: string
26  branch: string | null
27  ageHours: number | null
28}
29
30export type FleetBoard = {
31  renderedAt: string
32  liveSessions: number
33  stopConditions: string | null
34  // Set when the script exited non-zero but still printed a board.
35  warning: string | null
36  running: FleetRow[]
37  error: string | null
38}
39
40declare module 'claude-code' {
41  interface PluginState {
42    'korus-fleet': {
43      board: FleetBoard | null
44      isRefreshing: boolean
45    }
46  }
47}
48