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

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.
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.
.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).
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.
scripts/drift.sh checks for three kinds of drift:
install.sh's LINKS resolves to the right path in this repo./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.
./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.
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./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/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.hooks/register.tsx 214 lines1import 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}
214hooks/ledger.ts 174 lines1/** 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