SLOPSHOPPER

orchestration-view

A pane over the orchestrator/merger ledger: what is planned, building, waiting on you, parked and merging.

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orchestration-view
│ ┃ Orchestration ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing in flight. │ ┃ …1f2a3b4c5d6e7f8a9b0/orchestration/ledger.md ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /orch │ ⎿ orchestration-view: Nothing in flight. Ledger: a1b2c3d4e5f6a7b8c │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Orchestration
Nothing in flight. …3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/orchestration/ledger.md
README

dev-files

My dotfiles. Source of truth lives in this repo, not in ~/. Anything tracked here is symlinked into the spot it normally lives, so editing a config in its usual place is editing the repo. git status shows the change like any other edit.

Built so I'd stop having to remember which copy of a file was newer.

New machine

git clone --recurse-submodules https://github.com/isaac-scarrott/dev-files ~/git/dev-files
cd ~/git/dev-files
./install.sh

install.sh is idempotent. It points core.hooksPath at .githooks, inits submodules, runs scripts/vendor.sh to fetch upstream files, then walks the LINKS array and symlinks each entry into your home dir. Anything already sitting at a destination gets renamed to *.bak.<timestamp> before the symlink lands.

macOS only for now. The Library paths are baked in.

Layout

.claude/         claude code: skills, scripts, settings
.config/         editor + wm config (nvim, zed, aerospace, ghostty, alacritty)
.githooks/       pre-commit re-runs vendor.sh when the manifest changes
ide/             vscode + cursor settings.json (live under Library/Application Support/)
scripts/         vendor.sh, drift.sh, test-install.sh
.zshrc           shell
.tmux.conf       tmux
install.sh       bootstrap; also the canonical list of symlinks
vendor.manifest  upstream files to keep in sync

Two files live here for the symlink but git ignores them: .claude/CLAUDE.md (personal) and .config/nvim/lazy-lock.json (rewrites itself on every :Lazy update).

Vendoring upstream files

vendor.manifest pulls individual files from public repos so I don't have to register a whole repo as a submodule for each one:

# format: OWNER/REPO REF SRC_PATH DEST_PATH
mattpocock/skills main skills/productivity/grill-me/SKILL.md .claude/skills/grill-me/SKILL.md

Add a line, stage vendor.manifest, commit. The pre-commit hook re-runs scripts/vendor.sh and stages the fetched file before the commit lands. Swap main for a commit SHA if you want the version pinned.

humanizer gets the real submodule treatment because I want the whole repo, not one file out of it.

Drift

scripts/drift.sh checks for three kinds of drift:

  1. every entry in install.sh's LINKS resolves to the right path in this repo
  2. every vendored file matches its upstream byte-for-byte
  3. each submodule's pinned SHA matches upstream HEAD on its tracking branch
./scripts/drift.sh         # report only
./scripts/drift.sh --fix   # re-vendor, bump submodules, re-link

--fix refuses on a dirty tree so any change it makes lands in a reviewable diff.

Tests

./scripts/test-install.sh

Sandboxed in a mktemp -d HOME. Covers fresh install, idempotent re-run, backup of real files, and replacement of wrong-target symlinks. Runs in about a second using just bash and a tmpdir.

Gotchas

  • Editors that save by atomic replace (write a new file, rename over the old) turn a symlink into a real file the first time you save. Neovim, Zed, VS Code and Cursor all save in place, so they're fine. Worth knowing if a config ever quietly stops syncing.
  • core.hooksPath doesn't travel with a clone (security feature). install.sh sets it. Skip the install step and the manifest auto-refetch won't fire.
  • The /orchestrate command and the implement-ticket wrappers link to ~/git/claude-orchestrate. Clone that repo too, or install.sh skips those six links with a warning and the wrappers exit with "orchestrate package not installed".
  • Codex's ~/.codex/config.toml is the one config that isn't symlinked — Codex rewrites it itself and it holds secrets. scripts/gen-mcp.sh only maintains a marked MCP block inside it from mcp.manifest.json; rerun it after editing the manifest. Codex's AGENTS.md and skills are symlinked to the same canonical files Claude and OpenCode use.
Source 2 files
hooks/register.tsx 214 lines
1import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
2
3import {
4  age,
5  earlier,
6  isStale,
7  label,
8  parseLedger,
9  sections,
10  standing,
11  statusLine,
12  summary,
13  type Bucket,
14  type Row,
15} from './ledger'
16
17const PANE = 'orchestration'
18const POLL_MS = 4000
19
20/** Rows drawn per section before the rest become a count. Everything waiting on the owner is drawn. */
21const MOST = 12
22
23let path: string | undefined
24let drawnAt = -1
25let announced = new Set<string>()
26let isDismissed = false
27
28function openPane($: EngineInterface): Promise<UiOpenResult> {
29  return $.ui.open({ id: PANE, title: 'Orchestration' })
30}
31
32async function ledgerPath($: EngineInterface): Promise<string | undefined> {
33  if (path !== undefined) return path
34  // ORCHESTRATION_LEDGER reads a ledger somewhere other than the repo's own (tests, a second repo).
35  const given = await $.env.get('ORCHESTRATION_LEDGER')
36  if (given !== undefined && given !== '') return (path = given)
37  const git = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir']).catch(
38    () => undefined,
39  )
40  if (git === undefined || git.exitCode !== 0) return undefined
41  return (path = `${git.stdout.trim()}/orchestration/ledger.md`)
42}
43
44async function readRows($: EngineInterface): Promise<{ rows: Row[]; mtimeMs: number; at: string | undefined }> {
45  const at = await ledgerPath($)
46  if (at === undefined) return { rows: [], mtimeMs: -1, at }
47  const stat = await $.fs.stat(at).catch(() => undefined)
48  if (stat === undefined) return { rows: [], mtimeMs: -1, at }
49  const text = await $.fs.read(at).catch(() => '')
50
51  return { rows: parseLedger(text).rows, mtimeMs: stat.mtimeMs, at }
52}
53
54/** What a toast is keyed by. The head is in it, so a row that comes back on new work is said again. */
55function toastKey(row: Row): string {
56  return `${label(row)}@${row.head}`
57}
58
59/** Reads the ledger, pins the counts under the prompt, and says when a row starts waiting on the owner. */
60async function poll($: EngineInterface): Promise<void> {
61  const { rows, mtimeMs } = await readRows($)
62  const now = await $.clock.now()
63  $.ui.status(statusLine(rows, now))
64
65  const waiting = sections(rows, now).find(section => section.name === 'input')?.rows ?? []
66  for (const row of waiting) {
67    if (!announced.has(toastKey(row))) $.ui.toast(`${label(row)} needs you — ${summary(row) || row.state}`)
68  }
69  announced = new Set(waiting.map(toastKey))
70
71  if (rows.length > 0 && !isDismissed) {
72    const panes = await $.ui.panes()
73    if (!panes.some(pane => pane.id === PANE)) await openPane($)
74  }
75  if (mtimeMs !== drawnAt) {
76    drawnAt = mtimeMs
77    $.ui.invalidate('ui.render')
78  }
79}
80
81/** A column as wide as its widest cell, within the room the pane can spare for it. */
82function columnWidth(cells: readonly string[], least: number, most: number): number {
83  return Math.min(most, Math.max(least, ...cells.map(cell => cell.length)))
84}
85
86export const register: Register = on => {
87  on('session.start', async ($, e, next) => {
88    await $.command.register({
89      name: 'orch',
90      description: 'Orchestration: what is planned, being worked on, waiting on you, parked, merging and landed',
91    })
92    const started = await next(e)
93    void poll($).catch(() => {})
94    $.clock.every(POLL_MS, () => void poll($).catch(() => {}))
95
96    return started
97  })
98
99  on('command.run', { command: 'orch' }, async $ => {
100    isDismissed = false
101    const { rows, at } = await readRows($)
102    const now = await $.clock.now()
103    await openPane($)
104    if (at === undefined) return { text: 'No repository here, so no ledger to watch.' }
105    if (rows.length === 0) return { text: `Nothing in flight. Ledger: ${at}` }
106
107    const lines = sections(rows, now).map(
108      section => `${section.title}: ${section.rows.map(row => `${label(row)} ${summary(row)}`.trim()).join(', ')}`,
109    )
110    const over = earlier(rows, now)
111
112    return { text: [`Ledger: ${at}`, ...lines, ...(over === '' ? [] : [`earlier: ${over}`])].join('\n') }
113  })
114
115  // A pane the person closes stays closed for the session.
116  on('ui.close', async ($, e, next) => {
117    if (e.id === PANE && e.origin.kind === 'person') isDismissed = true
118
119    return next(e)
120  })
121
122  // Starting either skill is a reason to show the pane, before the first row lands.
123  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
124    const skill = (e as { skill?: string }).skill
125    if (skill === 'orchestrator' || skill === 'merger') {
126      isDismissed = false
127      void openPane($).catch(() => {})
128    }
129
130    return next(e)
131  })
132
133  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
134    const { Box, Text } = $.ui.resolve(e)
135    const { rows, at } = await readRows($)
136    const now = await $.clock.now()
137    const drawn = sections(rows, now)
138    const over = earlier(rows, now)
139
140    if (drawn.length === 0) {
141      return (
142        <Box flexDirection="column">
143          <Text dimColor>{over === '' ? 'Nothing in flight.' : `Nothing in flight · ${over} earlier`}</Text>
144          <Text dimColor wrap="truncate-start">
145            {at ?? 'no repository here'}
146          </Text>
147        </Box>
148      )
149    }
150
151    // One set of column widths across every section, so the pane reads as one table.
152    const columns = Math.max(28, e.props.bodyColumns)
153    const quarter = Math.floor(columns / 4)
154    const allRows = drawn.flatMap(section => section.rows)
155    const idWidth = columnWidth(allRows.map(label), 4, quarter)
156    const standingWidth = columnWidth(allRows.map(standing), 0, quarter)
157    const ageWidth = columnWidth(allRows.map(row => age(row, now)), 2, 5)
158
159    function drawSection(section: Bucket) {
160      const needsYou = section.name === 'input'
161      const settled = section.name === 'parked' || section.name === 'landed'
162      const shown = needsYou ? section.rows : section.rows.slice(0, MOST)
163      const more = section.rows.length - shown.length
164
165      function idColor(row: Row): string | undefined {
166        if (needsYou && isStale(row, now)) return 'error'
167        if (needsYou) return section.color
168        return undefined
169      }
170
171      return (
172        <Box key={section.name} flexDirection="column" marginBottom={1}>
173          <Box flexDirection="row" gap={1}>
174            <Text color={section.color} bold>
175              ▌{section.title}
176            </Text>
177            <Text dimColor>{String(section.rows.length)}</Text>
178          </Box>
179          {shown.map(row => (
180            <Box key={`${section.name}-${row.pr}`} flexDirection="row" gap={1}>
181              <Box width={idWidth} flexShrink={0}>
182                <Text color={idColor(row)} wrap="truncate-end">
183                  {label(row)}
184                </Text>
185              </Box>
186              <Box flexGrow={1} overflow="hidden">
187                <Text wrap="truncate-end" dimColor={settled}>
188                  {summary(row)}
189                </Text>
190              </Box>
191              <Box width={standingWidth} flexShrink={0}>
192                <Text dimColor wrap="truncate-end">
193                  {standing(row)}
194                </Text>
195              </Box>
196              <Box width={ageWidth} flexShrink={0} justifyContent="flex-end">
197                <Text dimColor>{age(row, now)}</Text>
198              </Box>
199            </Box>
200          ))}
201          {more > 0 && <Text dimColor>{`  +${more} more`}</Text>}
202        </Box>
203      )
204    }
205
206    return (
207      <Box flexDirection="column">
208        {drawn.map(section => drawSection(section))}
209        {over !== '' && <Text dimColor>{`${over} earlier`}</Text>}
210      </Box>
211    )
212  })
213}
214
hooks/ledger.ts 174 lines
1/** Reading the orchestrator/merger ledger (see orchestrator/protocol/LEDGER.md). */
2
3export const COLUMNS = ['pr', 'holder', 'head', 'base', 'state', 'owner', 'note', 'what', 'since'] as const
4
5export type Row = Record<(typeof COLUMNS)[number], string>
6
7export type BucketName = 'input' | 'working' | 'merging' | 'parked' | 'planned' | 'landed' | 'done'
8
9export type Bucket = { name: BucketName; title: string; color: string; rows: Row[] }
10
11/** The sections drawn row by row, in the order the pane stacks them. `done` is not one of them:
12 *  work that finished longer ago than the landed window is a line of counts at the foot. */
13const SECTIONS: readonly { name: BucketName; title: string; color: string }[] = [
14  { name: 'input', title: 'NEEDS YOU', color: 'warning' },
15  { name: 'working', title: 'WORKING', color: 'claude' },
16  { name: 'merging', title: 'MERGING', color: 'merged' },
17  { name: 'landed', title: 'LANDED', color: 'success' },
18  { name: 'parked', title: 'PARKED', color: 'inactive' },
19  { name: 'planned', title: 'PLANNED', color: 'subtle' },
20]
21
22/** How each section reads in the status line, where the pane's titles are too loud. */
23const STATUS_WORDS: Record<BucketName, string> = {
24  input: 'need you',
25  working: 'building',
26  merging: 'merging',
27  landed: 'landed',
28  parked: 'parked',
29  planned: 'planned',
30  done: 'done',
31}
32
33const MINUTE = 60_000
34
35/** How far back the landed section reaches. */
36export const RECENT_MS = 4 * 60 * MINUTE
37
38/** A row waiting on the owner for longer than this is drawn as overdue. */
39export const STALE_MS = 20 * MINUTE
40
41/** A verdict the owner has actually given. Anything else in that column — a question, a routing
42 *  note such as "owner merges", an empty cell — leaves the row theirs to answer, since a row the
43 *  pane shows too loudly costs less than one it hides. */
44const VERDICT = /^(approved|standing|ship|merge it|go ahead|yes\b)/i
45
46/** Sessions that hold a PR on everyone's behalf; any other holder is a builder worth naming. */
47const COORDINATORS = new Set(['', '-', 'orchestrator', 'merger', 'owner'])
48
49export function parseLedger(text: string): { rows: Row[]; decisions: string[] } {
50  const rows: Row[] = []
51  const decisions: string[] = []
52  let section: 'rows' | 'decisions' = 'rows'
53  for (const line of text.split('\n')) {
54    if (line.startsWith('## Decisions')) {
55      section = 'decisions'
56      continue
57    }
58    if (section === 'decisions') {
59      if (line.startsWith('- ')) decisions.push(line.slice(2).trim())
60      continue
61    }
62    if (!line.startsWith('|') || line.startsWith('|---')) continue
63    const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim())
64    if (cells[0] === 'pr' || cells.every(cell => /^-*$/.test(cell))) continue
65    const row = Object.fromEntries(COLUMNS.map((column, index) => [column, cells[index] ?? ''])) as Row
66    if (row.pr !== '') rows.push(row)
67  }
68  return { rows, decisions }
69}
70
71/** Which section a row belongs in. An unknown state counts as work in flight, never as nothing. */
72export function bucketOf(row: Row, now: number): BucketName {
73  switch (row.state.toLowerCase()) {
74    case 'planned':
75      return 'planned'
76    case 'input':
77    case 'question':
78      return 'input'
79    case 'held':
80    case 'blocked':
81      return 'parked'
82    case 'review':
83      return VERDICT.test(row.owner) ? 'merging' : 'input'
84    case 'ready':
85    case 'queued':
86    case 'merged':
87    case 'deploying':
88      return 'merging'
89    case 'landed':
90    case 'deployed':
91    case 'closed':
92    case 'done':
93    case 'abandoned':
94      return isRecent(row, now) ? 'landed' : 'done'
95    default:
96      return 'working'
97  }
98}
99
100/** Whether a finished row is young enough for the landed section. An undated row never is. */
101function isRecent(row: Row, now: number): boolean {
102  const held = since(row, now)
103  return held !== undefined && held <= RECENT_MS
104}
105
106/** The sections to draw, newest change first inside each, empty ones left out. */
107export function sections(rows: readonly Row[], now: number): Bucket[] {
108  function elapsed(row: Row): number {
109    return since(row, now) ?? Number.POSITIVE_INFINITY
110  }
111
112  return SECTIONS.map(section => ({
113    ...section,
114    rows: rows.filter(row => bucketOf(row, now) === section.name).sort((a, b) => elapsed(a) - elapsed(b)),
115  })).filter(section => section.rows.length > 0)
116}
117
118/** Work that finished before the landed window, as counts: `9 landed · 2 closed`. */
119export function earlier(rows: readonly Row[], now: number): string {
120  const counts = new Map<string, number>()
121  for (const row of rows.filter(row => bucketOf(row, now) === 'done')) {
122    const state = row.state.toLowerCase()
123    counts.set(state, (counts.get(state) ?? 0) + 1)
124  }
125
126  return [...counts].map(([state, count]) => `${count} ${state}`).join(' · ')
127}
128
129/** A row's id as the pane labels it: `#6861` for a PR, the slug itself for planned work. */
130export function label(row: Row): string {
131  return /^\d+$/.test(row.pr) ? `#${row.pr}` : row.pr
132}
133
134/** What the row is, in words: its own summary, else its note, else its head. */
135export function summary(row: Row): string {
136  return row.what || row.note || row.head || ''
137}
138
139/** The right-hand detail: the state, and the builder holding it when that isn't a coordinator. */
140export function standing(row: Row): string {
141  const holder = COORDINATORS.has(row.holder.toLowerCase()) ? '' : row.holder
142  return [row.state, holder].filter(part => part !== '').join(' ')
143}
144
145export function since(row: Row, now: number): number | undefined {
146  if (row.since === '') return undefined
147  const at = Date.parse(row.since)
148  return Number.isNaN(at) ? undefined : Math.max(0, now - at)
149}
150
151export function age(row: Row, now: number): string {
152  const held = since(row, now)
153  if (held === undefined) return ''
154  const minutes = Math.floor(held / MINUTE)
155  if (minutes < 1) return 'now'
156  if (minutes < 60) return `${minutes}m`
157  const hours = Math.floor(minutes / 60)
158  if (hours < 24) return `${hours}h`
159  return `${Math.floor(hours / 24)}d`
160}
161
162export function isStale(row: Row, now: number): boolean {
163  const held = since(row, now)
164  return held !== undefined && held > STALE_MS
165}
166
167/** The status-line summary: the sections that have rows, in the pane's order. */
168export function statusLine(rows: readonly Row[], now: number): string | undefined {
169  const drawn = sections(rows, now)
170  if (drawn.length === 0) return undefined
171
172  return `orch: ${drawn.map(section => `${section.rows.length} ${STATUS_WORDS[section.name]}`).join(' · ')}`
173}
174