A live view of every OloEngine worktree, its PR's CI and review threads, and the cross-worktree build lock (who builds, who waits); notifies when a PR's CI…

OloEngine is a cross-platform (Windows / Linux) C++23 interactive-application and rendering engine. It started as a follow-along of Hazel but has since grown well past that scope into a full ECS-driven engine with a physically-based OpenGL 4.6 renderer, 2D/3D physics, dual C#/Lua scripting, networking, navigation, procedural terrain and environmental systems, a gameplay/AI stack, and an ImGui editor.
Metallic and dielectric PBR spheres (a roughness sweep) reflecting a procedural star-nest sky:

| PBR model | Animated models | 3D physics | FFT ocean |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| Target / directory | What it is |
|---|---|
OloEngine/ | The engine static library (OloEngine). All subsystems live under src/OloEngine/<Subsystem>/; platform glue under src/Platform/. |
OloEditor/ | ImGui-based editor (OloEditor). Panels under src/, runtime assets under assets/, a sample game under SandboxProject/. |
OloRuntime/ | Standalone game runtime (OloRuntime) that loads what the editor builds. |
OloServer/ | Headless dedicated server (OloServer) — the one target that also runs on WSL2. |
OloEngine-ScriptCore/ | C# scripting API library, built with dotnet build under any generator; C# scripting runs on Windows only. |
OloEngine-LuaScriptCore/ | Lua scripting core (all platforms). |
OloEngine/tests/ | GoogleTest suite (OloEngine-Tests). |
tools/ | Build-time tooling, notably OloHeaderTool (code generation — see Tooling). |
docs/ | Design docs, ADRs, build/testing guides, and agent rules — see Documentation. |
Supported platforms
msvc preset) or Visual Studio 2022.OloServer, but not OloEditor: WSL2's software renderer only exposes OpenGL 4.5, and the editor requires a native OpenGL 4.6 GPU.Requirements
CMakePresets.json (the msvc preset's Visual Studio 18 2026 generator only exists in 4.2+). The root CMakeLists.txt itself only requires 3.27, so a plain cmake -B build -G "Visual Studio 17 2022" still works without presets.jinja2 package (needed to build glad2).OLO_WITH_STEAM turns on when STEAMWORKS_SDK_ROOT points at a real SDK (a public/steam directory) and stays off otherwise — so without the SDK everything builds, runs and tests exactly as before, and with it installed you get the Steam path without passing a flag. The SDK itself is free — an ordinary Steam account and the Steamworks SDK Access Agreement, not the $100 Steam Direct publishing fee. Setup: docs/ops/build.md.Clone
git clone https://github.com/drsnuggles8/OloEngineBase
Most third-party dependencies are resolved from the vcpkg.json manifest and built once into vcpkg's machine-global binary cache, so a second worktree restores them in seconds instead of recompiling (issue #773). VCPKG_ROOT must point at a bootstrapped vcpkg clone — see docs/ops/build.md; a configure without it fails with a pointer there. A handful of dependencies are still fetched at configure time via FetchContent/CPM into OloEngine/vendor/ (never edit that directory — a CMake reconfigure wipes it). CMake also creates the build/ directory with the generated solution files.
CMake presets (CMakePresets.json):
| Preset | Generator / build dir | Purpose |
|---|---|---|
msvc | Visual Studio 18 2026 → build/ | Primary, default build dir |
clangcl | Ninja Multi-Config → build-clang/ | clang-cl warnings with the MSVC ABI |
clangcl-asan | Ninja Multi-Config → build-clang/ | Adds AddressSanitizer |
# Generate the Visual Studio solution
scripts\Win-GenerateProjectVS2026.bat # or Win-GenerateProjectVS2022.bat
# Build a target
cmake --build build --target OloEditor --config Debug --parallel
cmake --build build --target OloEngine-Tests --config Debug --parallel
cmake --build build --target OloRuntime --config Debug --parallel
cmake --build build --target OloServer --config Debug --parallel
# ClangCL (configure once, then build)
cmake --preset clangcl
cmake --build build-clang --target OloEngine-Tests --config Debug --parallel
Working directory matters.
OloEditor,OloRuntime, andOloServerresolve assets, shaders, and Mono assemblies relative toOloEditor/. Always run them withcwd = OloEditor/. The VS Code tasks already do this.
VS Code users — predefined tasks in .vscode/tasks.json wrap all of the above: build-oloeditor-debug, run-oloeditor-debug, build-tests-debug, run-tests-debug, configure-clangcl, etc. Run them from the Command Palette (Ctrl+Shift+P → Tasks: Run Task).
CLion users — open the repo folder, let CLion configure the CMake project, then set the working directory of the OloEditor run configuration to the OloEditor/ folder.
The full per-OS build matrix (including Linux and WSL specifics) lives in docs/ops/build.md.
Entity wrapper. The hottest update loops use EnTT owning groups for packed iteration.AssetManager.LocalizedTextComponent.OloServer provides the headless dedicated-server target.The suite (OloEngine-Tests, GoogleTest) spans two independent axes:
"Functional" tag) driving real Scene::OnUpdateRuntime ticks across Animation × Physics × Scripting × Networking × Audio × Asset × Nav × Save-game × Gameplay × AI seams.# Run everything, or a single test
build\OloEngine\tests\Debug\OloEngine-Tests.exe
build\OloEngine\tests\Debug\OloEngine-Tests.exe --gtest_filter=SuiteName.TestName
The rationale, per-layer reference, classification rules, and anti-patterns live in docs/testing.md and docs/agent-rules/testing-architecture.md.
These gate every change and run on GitHub-hosted runners.
| Workflow | Status | Covers |
|---|---|---|
| Windows | MSVC build + full suite via CTest | |
| Sanitizers | ASan (Windows/clang-cl), ASan+LSan / UBSan / TSan (Linux/Clang) | |
| SonarCloud Scan | Static analysis (see sonarqube-review-alignment.md) | |
| Pre-commit checks | Formatting, YAML, test-catalogue classification |
Cron times are UTC — GitHub's scheduler does not observe DST, so local times shift by an hour across the year. Every one of these is also workflow_dispatch-able from the Actions tab. Scheduled runs may start tens of minutes late under load.
| Workflow | Status | Cadence (UTC) | Runs on | Covers |
|---|---|---|---|---|
| GPU Conformance (AMD) | nightly 00:47 | self-hosted Linux / AMD Navi 10 | The renderer's visual, golden and perf layers against a real GPU — they skip everywhere else. See self-hosted-gpu-runner.md | |
| Cross-Vendor Conformance | nightly 03:47 | GitHub-hosted Windows | L1/3/5/6/8 against Mesa llvmpipe (software) | |
| Fuzz Smoke | nightly 04:13 | GitHub-hosted Windows | Parser / deserializer fuzzing | |
| Flaky repro (#281) | nightly 07:00 | GitHub-hosted Windows | Repeat-runs a known-flaky test to hunt issue #281 | |
| Video (FFmpeg backend) | weekly, Mon 06:00 | GitHub-hosted matrix | OLO_VIDEO_FFMPEG=ON build + decode tests | |
| reflection-status-check | monthly, 1st 09:07 | GitHub-hosted Linux | Reminder to re-check C++26 reflection toolchain support |
Hardware-GPU vendor coverage is AMD-only. The goldens in assets/tests/golden/ are baselined on NVIDIA, but only ever from a developer machine — there is no NVIDIA or Intel CI. llvmpipe is a third software implementation, not a second vendor, so it catches spec-compliance drift rather than driver divergence.
OloHeaderTool (tools/OloHeaderTool/) scans OloEngine/src/ and generates, at build time (via the GenerateBindings target):
OLO_PROPERTY annotations.AllComponents type list, the save-game capture/restore lists, the OnComponentAdded/OnComponentRemoved no-op handlers, and per-component scene serialize/deserialize blocks — all derived from the struct *Component definitions.This collapses what used to be several hand-maintained, easy-to-forget touch-points into codegen: an all-trivial component now round-trips through scenes, save-games, and scripting with little or no hand-written glue. See CLAUDE.md for the exact contract when adding or changing a component.
Most dependencies come from the vcpkg.json manifest, pinned as a whole by its builtin-baseline registry commit and built into vcpkg's binary cache. The ones marked (in-tree) below are still fetched via FetchContent/CPM into OloEngine/vendor/, each for a specific reason documented in that file's header comment — briefly: sol2 and Lua are pinned to a tested pair, stb's port is ~2 years behind on an image decoder, ImGuizmo's port would drag in a second Dear ImGui, and the single-header / forked / generator-driven ones have no port at all. OpenUSD and FFmpeg have their own build paths (see CLAUDE.md).
Core — entt (ECS) · glm (math) · spdlog (logging) · yaml-cpp (serialization) · nlohmann/json (tools / IPC) · choc (utilities, in-tree) · atomic_queue (lock-free MPMC queue) · meshoptimizer
Rendering — glad (in-tree) · glfw · assimp · stb (in-tree) · zlib
Physics — Jolt Physics (3D) · box2d (2D)
Networking — GameNetworkingSockets · libsodium · protobuf
Navigation — recastnavigation
Audio — miniaudio
Scripting — sol2 (in-tree) · lua (in-tree) · Mono (C# runtime, manually integrated)
UI & editor — imgui (in-tree) · imguizmo (in-tree)
Development & profiling — tracy · googletest · filewatch (in-tree)
We enforce a small set of formatting rules via pre-commit:
python -m pip install --user pre-commit
pre-commit install # enable the git hook (run in the repo root)
pre-commit run --all-files # optional: apply across the whole repo
Included hooks: trailing-whitespace, end-of-file-fixer, and clang-format for C/C++ (uses the repo-root .clang-format). An .editorconfig mirrors the whitespace rules for editors. The hooks ignore vendor/, build/, and IDE metadata folders. A GitHub Action runs pre-commit on every push and PR.
Everything under docs/ — start at docs/README.md for the full index. Highlights:
CLAUDE.md — contributor/agent playbook: build, test, and the component cross-binding contract.hooks/register.tsx 240 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import type { FleetPr, FleetSnapshot, FleetWorktree } from '../types'
5import {
6 liveQueue,
7 minutesSince,
8 orphanPrs,
9 parseLockStatus,
10 parsePrs,
11 parseWorktrees,
12 prByBranch,
13 prLine,
14 slotLine,
15 statusLine,
16 summaryMarkdown,
17 transitions,
18 waiterLine,
19 worktreeColor,
20 worktreeLine,
21} from './fleet'
22
23const PANE = 'olo-fleet'
24const REFRESH_MS = 3 * 60 * 1000
25const AFTER_PUBLISH_MS = 60 * 1000
26const SNAPSHOT = { plugin: 'olo-fleet', key: 'snapshot' } as const
27const snapshotAtom = atom(SNAPSHOT, null)
28
29const PR_QUERY =
30 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:50){nodes{' +
31 'number title headRefName isDraft mergeable url reviewThreads(first:100){nodes{isResolved}} ' +
32 'commits(last:1){nodes{commit{statusCheckRollup{state contexts(first:100){nodes{__typename ' +
33 '... on CheckRun{name status conclusion} ... on StatusContext{context state}}}}}}}}}}}'
34
35// One refresh at a time; a timer tick during a slow refresh is dropped, not queued.
36let isRefreshing = false
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: 'fleet',
42 description: 'Show every OloEngine worktree, its PR, CI and review threads, and who holds or waits for the build lock',
43 })
44 $.clock.every(REFRESH_MS, () => {
45 void refresh($)
46 })
47 void refresh($)
48 return next(e)
49 })
50
51 // /fleet always answers in its own transcript row (drawn by the CommandOutput hook
52 // below). The VS Code extension reports a mod's pane as placed but never draws it, so
53 // the pane is only a bonus for surfaces that do show one (the fullscreen terminal).
54 on('command.run', { command: 'fleet' }, async $ => {
55 void $.ui.open({ id: PANE, title: 'OloEngine fleet' })
56 await refresh($, { force: true })
57 const snap = (await $.state.get(SNAPSHOT)).value
58 return { text: snap === null || snap === undefined ? 'Could not read the fleet (git or gh failed).' : summaryMarkdown(snap, await $.clock.now()) }
59 })
60
61 // After a push or a new PR, CI starts: look again in a minute instead of waiting a full period.
62 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
63 const ran = await next(e)
64 const command = typeof e.command === 'string' ? e.command : ''
65 if (ran.deny === undefined && /\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|ready|merge)\b/.test(command)) {
66 $.clock.after(AFTER_PUBLISH_MS, () => {
67 void refresh($)
68 })
69 }
70 return ran
71 })
72
73 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) =>
74 drawFleet($, $.ui.resolve(e), await read($, snapshotAtom), Math.max(40, e.props.bodyColumns)),
75 )
76
77 // Draws /fleet's own transcript row as the full view; the markdown text stays what the
78 // model reads and what a surface that cannot draw the tree shows.
79 on('ui.render', { component: 'CommandOutput', props: { command: 'fleet' } }, async ($, e, next) => {
80 const snap = await read($, snapshotAtom)
81 if (snap === null || e.props.isErrored) {
82 return next(e)
83 }
84 return drawFleet($, $.ui.resolve(e), snap, Math.max(40, e.viewport?.columns ?? 120))
85 })
86}
87
88async function run($: EngineInterface, argv: string[], timeoutMs = 20_000): Promise<string | undefined> {
89 try {
90 const r = await $.process.run(argv, { timeoutMs })
91 return r.exitCode === 0 ? r.stdout : undefined
92 } catch {
93 return undefined
94 }
95}
96
97async function refresh($: EngineInterface, opts: { force?: boolean } = {}): Promise<void> {
98 if (isRefreshing) {
99 if (!opts.force) {
100 return
101 }
102 // A user asked: wait for the refresh already under way (bounded), then show its result.
103 for (let i = 0; i < 120 && isRefreshing; i += 1) {
104 await $.clock.sleep(500)
105 }
106 return
107 }
108 isRefreshing = true
109 try {
110 const errors: string[] = []
111 const commonDir = (await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], 5000))?.trim()
112 if (commonDir === undefined || commonDir.length === 0) {
113 return
114 }
115 const base = commonDir.replace(/[\\/]\.git[\\/]?$/, '')
116
117 const [porcelain, remote, branchOut, lockOut] = await Promise.all([
118 run($, ['git', '-C', base, 'worktree', 'list', '--porcelain'], 10_000),
119 run($, ['git', '-C', base, 'remote', 'get-url', 'origin'], 5000),
120 run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], 5000),
121 run($, ['pwsh', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/lock-status.ps1`, '-CommonDir', commonDir], 20_000),
122 ])
123
124 const listed = porcelain === undefined ? [] : parseWorktrees(porcelain)
125 if (porcelain === undefined) {
126 errors.push('git worktree list failed')
127 }
128 const dirtyCounts = await Promise.all(
129 listed.map(async w => {
130 const out = await run($, ['git', '-C', w.path, 'status', '--porcelain'], 30_000)
131 return out === undefined ? null : out.split('\n').filter(l => l.trim().length > 0).length
132 }),
133 )
134 const worktrees: FleetWorktree[] = listed.map((w, i) => ({ ...w, dirty: dirtyCounts[i] ?? null }))
135
136 // A failed or garbled GitHub reply keeps the last PRs known (marked stale) rather than
137 // an empty list, so the next good reply still compares against real CI states.
138 const previous = await $.state.get(SNAPSHOT)
139 let prs: FleetPr[] = previous.value?.prs ?? []
140 const repo = remote === undefined ? undefined : /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\s*$/.exec(remote)
141 if (repo?.[1] !== undefined && repo[2] !== undefined) {
142 const out = await run($, ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`, '-F', `owner=${repo[1]}`, '-F', `name=${repo[2]}`], 30_000)
143 if (out === undefined) {
144 errors.push('GitHub query failed (gh not authenticated, or offline); PR states are from the last good query')
145 } else {
146 try {
147 prs = parsePrs(out)
148 } catch {
149 errors.push('GitHub replied with something that is not the expected JSON (a proxy page?); PR states are from the last good query')
150 }
151 }
152 }
153
154 let lock = { slots: [] as FleetSnapshot['slots'], queue: [] as FleetSnapshot['queue'] }
155 if (lockOut === undefined) {
156 errors.push('build-lock status could not be read')
157 } else {
158 try {
159 lock = parseLockStatus(lockOut)
160 } catch {
161 errors.push('build-lock status was not JSON')
162 }
163 }
164
165 const snapshot: FleetSnapshot = { takenAt: await $.clock.now(), base, worktrees, prs, ...lock, errors }
166 await update($, snapshotAtom, () => snapshot)
167
168 $.ui.status(statusLine(snapshot, branchOut?.trim() ?? null))
169
170 for (const t of transitions(previous.value?.prs, prs)) {
171 const text =
172 t.to === 'SUCCESS'
173 ? `PR #${t.number} CI is green: ${t.title}`
174 : `PR #${t.number} CI failed${t.failing.length > 0 ? ` (${t.failing.slice(0, 3).join(', ')})` : ''}: ${t.title}`
175 $.ui.toast(text, { timeoutMs: 10_000 })
176 void $.ui.notify(text, { title: 'OloEngine CI' }).catch(() => undefined)
177 }
178 } catch {
179 // Every caller fires refresh and forgets it (`void refresh($)`): a rejection here would be
180 // unhandled. The next tick tries again.
181 } finally {
182 isRefreshing = false
183 }
184}
185
186
187async function drawFleet($: EngineInterface, el: ElementTable, snap: FleetSnapshot | null, width: number) {
188 const { Box, Text, Button } = el
189 if (snap === null) {
190 return (
191 <Box flexDirection="column">
192 <Text dimColor>Collecting worktrees, PRs and the build lock…</Text>
193 </Box>
194 )
195 }
196 const now = await $.clock.now()
197 const prs = prByBranch(snap.prs)
198 const clip = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
199
200 return (
201 <Box flexDirection="column">
202 <Text bold>Worktrees</Text>
203 {snap.worktrees.map(w => (
204 <Text key={`wt-${w.path}`} wrap="truncate" color={worktreeColor(w, prs.get(w.branch ?? ''))}>
205 {clip(worktreeLine(w, prs.get(w.branch ?? '')))}
206 </Text>
207 ))}
208 {orphanPrs(snap).map(p => (
209 <Text key={`pr-${p.number}`} wrap="truncate" dimColor>
210 {clip(` (no worktree) #${p.number} ${p.branch}: ${prLine(p)}`)}
211 </Text>
212 ))}
213 <Text> </Text>
214 <Text bold>Build lock</Text>
215 {snap.slots.map(s => (
216 <Text key={`slot-${s.name}`} wrap="truncate" color={s.held ? 'warning' : 'success'}>
217 {clip(` ${slotLine(s, now)}`)}
218 </Text>
219 ))}
220 {liveQueue(snap).map((q, i) => (
221 <Text key={`q-${q.enqueued}-${i}`} wrap="truncate" dimColor>
222 {clip(` ${waiterLine(q, i, now)}`)}
223 </Text>
224 ))}
225 {snap.queue.some(q => !q.alive) && (
226 <Text dimColor>{` ${snap.queue.filter(q => !q.alive).length} stale ticket(s) from dead processes (build-lock skips them)`}</Text>
227 )}
228 {snap.errors.map((err, i) => (
229 <Text key={`err-${i}`} color="error" wrap="truncate">
230 {clip(` ${err}`)}
231 </Text>
232 ))}
233 <Box>
234 <Text dimColor>{`Updated ${minutesSince(new Date(snap.takenAt).toISOString(), now) ?? 0} min ago `}</Text>
235 <Button key="refresh" label="Refresh" onPress={() => refresh($, { force: true })} />
236 </Box>
237 </Box>
238 )
239}
240hooks/fleet.ts 230 lines1// Pure parsing and summarising for the fleet view. No engine calls: the tests drive it.
2
3import type { FleetCheckState, FleetPr, FleetSlot, FleetSnapshot, FleetWaiter, FleetWorktree } from '../types'
4
5export function baseName(path: string): string {
6 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
7}
8
9/** `git worktree list --porcelain`. The first entry is the main worktree. */
10export function parseWorktrees(porcelain: string): Omit<FleetWorktree, 'dirty'>[] {
11 const out: Omit<FleetWorktree, 'dirty'>[] = []
12 for (const block of porcelain.replace(/\r\n?/g, '\n').split(/\n\n+/)) {
13 const path = /^worktree (.+)$/m.exec(block)?.[1]?.trim()
14 if (path === undefined) {
15 continue
16 }
17 const head = /^HEAD ([0-9a-f]+)$/m.exec(block)?.[1] ?? ''
18 const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]?.trim() ?? null
19 out.push({ path, name: baseName(path), branch, head: head.slice(0, 9), isBase: out.length === 0 })
20 }
21 return out
22}
23
24type RawContext = { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string }
25
26/** The GraphQL answer for open PRs (see PR_QUERY in register). */
27export function parsePrs(json: string): FleetPr[] {
28 const data = JSON.parse(json) as {
29 data?: { repository?: { pullRequests?: { nodes?: unknown[] } } }
30 }
31 const nodes = data.data?.repository?.pullRequests?.nodes ?? []
32 return nodes.flatMap(node => {
33 const n = node as {
34 number: number
35 title: string
36 headRefName: string
37 url: string
38 isDraft: boolean
39 mergeable: string
40 reviewThreads?: { nodes?: { isResolved: boolean }[] }
41 commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string; contexts?: { nodes?: RawContext[] } } | null } }[] }
42 }
43 if (typeof n.number !== 'number') {
44 return []
45 }
46 const rollup = n.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null
47 const contexts = rollup?.contexts?.nodes ?? []
48 const failing = contexts
49 .filter(c => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion ?? c.state ?? '')))
50 .map(c => c.name ?? c.context ?? '?')
51 const running = contexts.filter(c => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING').length
52 return [
53 {
54 number: n.number,
55 title: n.title,
56 branch: n.headRefName,
57 url: n.url,
58 isDraft: n.isDraft,
59 mergeable: n.mergeable,
60 checks: (rollup?.state as FleetCheckState | undefined) ?? 'NONE',
61 failing,
62 running,
63 unresolved: (n.reviewThreads?.nodes ?? []).filter(t => !t.isResolved).length,
64 },
65 ]
66 })
67}
68
69/** lock-status.ps1's JSON. */
70export function parseLockStatus(json: string): { slots: FleetSlot[]; queue: FleetWaiter[] } {
71 const raw = JSON.parse(json) as { slots?: FleetSlot[] | FleetSlot; queue?: FleetWaiter[] | FleetWaiter }
72 const list = <T>(v: T[] | T | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v])
73 return { slots: list(raw.slots), queue: list(raw.queue) }
74}
75
76export type Transition = { number: number; title: string; from: FleetCheckState; to: FleetCheckState; failing: string[] }
77
78/** CI results that settled since the last snapshot: worth a notification. */
79export function transitions(previous: readonly FleetPr[] | undefined, next: readonly FleetPr[]): Transition[] {
80 if (previous === undefined) {
81 return []
82 }
83 const before = new Map(previous.map(p => [p.number, p.checks]))
84 return next.flatMap(pr => {
85 const from = before.get(pr.number)
86 const settled = pr.checks === 'SUCCESS' || pr.checks === 'FAILURE' || pr.checks === 'ERROR'
87 return from !== undefined && from !== pr.checks && settled ? [{ number: pr.number, title: pr.title, from, to: pr.checks, failing: pr.failing }] : []
88 })
89}
90
91export function minutesSince(iso: string | null, now: number): number | undefined {
92 if (iso === null) {
93 return undefined
94 }
95 const t = Date.parse(iso)
96 return Number.isNaN(t) ? undefined : Math.max(0, Math.round((now - t) / 60_000))
97}
98
99/** "C:/repos/OloEngine-x/build-cached" from a build command, for naming who builds. */
100export function buildTree(command: string | null): string | null {
101 const m = command === null ? null : /--build\s+("?)([^"\s]+)\1/.exec(command)
102 return m?.[2] ?? null
103}
104
105export function describeHolder(slot: FleetSlot, now: number): string {
106 const who = slot.worktree ? baseName(slot.worktree) : 'unknown'
107 // A sub-worktree a session fanned out into builds under the parent session's name: say which tree.
108 const tree = buildTree(slot.command)
109 const treeOwner = tree !== null && /[\\/]/.test(tree) ? baseName(tree.replace(/[\\/]build[\w-]*$/, '')) : who
110 const where = treeOwner !== who ? ` (${treeOwner})` : ''
111 const config = /--config\s+(\w+)/.exec(slot.command ?? '')?.[1]
112 const mins = minutesSince(slot.acquired, now)
113 return `${who}${where}${config ? ` ${config}` : ''}${mins !== undefined ? `, ${mins} min` : ''}`
114}
115
116const CHECK_MARK: Record<FleetCheckState, string> = {
117 SUCCESS: 'green',
118 FAILURE: 'RED',
119 ERROR: 'RED',
120 PENDING: 'running',
121 EXPECTED: 'waiting',
122 NONE: 'no checks',
123}
124
125export function checkWord(state: FleetCheckState): string {
126 return CHECK_MARK[state]
127}
128
129/** The one-line status: this worktree's PR, then the build lock. */
130export function statusLine(snapshot: FleetSnapshot, currentBranch: string | null): string {
131 const parts: string[] = []
132 const mine = snapshot.prs.find(p => p.branch === currentBranch)
133 if (mine !== undefined) {
134 const threads = mine.unresolved > 0 ? `, ${mine.unresolved} open thread(s)` : ''
135 parts.push(`PR #${mine.number} ${checkWord(mine.checks)}${threads}`)
136 }
137 const held = snapshot.slots.filter(s => s.held).length
138 const waiting = snapshot.queue.filter(q => q.alive).length
139 parts.push(`build lock ${held}/${snapshot.slots.length} held${waiting > 0 ? `, ${waiting} waiting` : ''}`)
140 const red = snapshot.prs.filter(p => p.checks === 'FAILURE' || p.checks === 'ERROR').length
141 if (red > 0) {
142 parts.push(`${red} red PR(s)`)
143 }
144 return `fleet: ${parts.join(' · ')}`
145}
146
147/** Worktrees whose branch has an open PR, keyed by branch. */
148export function prByBranch(prs: readonly FleetPr[]): Map<string, FleetPr> {
149 return new Map(prs.map(p => [p.branch, p]))
150}
151
152export function prLine(pr: FleetPr): string {
153 const parts = [pr.checks === 'PENDING' && pr.running > 0 ? `CI running, ${pr.running} check(s) left` : `CI ${checkWord(pr.checks)}`]
154 if (pr.failing.length > 0) {
155 parts.push(`failing: ${pr.failing.slice(0, 3).join(', ')}${pr.failing.length > 3 ? ', …' : ''}`)
156 }
157 if (pr.unresolved > 0) {
158 parts.push(`${pr.unresolved} open thread(s)`)
159 }
160 if (pr.mergeable === 'CONFLICTING') {
161 parts.push('CONFLICTS with master')
162 }
163 if (pr.isDraft) {
164 parts.push('draft')
165 }
166 return parts.join(', ')
167}
168
169export function worktreeLine(w: FleetWorktree, pr: FleetPr | undefined): string {
170 const dirty = w.dirty === null ? '?' : w.dirty === 0 ? 'clean' : `${w.dirty} changed`
171 const branch = w.branch ?? `detached ${w.head}`
172 const prText = pr === undefined ? (w.isBase ? '' : 'no PR') : `PR #${pr.number}: ${prLine(pr)}`
173 return ` ${w.name} [${branch}] ${dirty}${prText ? ` ${prText}` : ''}`
174}
175
176export function worktreeColor(w: FleetWorktree, pr: FleetPr | undefined): 'error' | 'success' | 'warning' | 'text' {
177 if (pr?.checks === 'FAILURE' || pr?.checks === 'ERROR' || pr?.mergeable === 'CONFLICTING') {
178 return 'error'
179 }
180 if (pr?.unresolved !== undefined && pr.unresolved > 0) {
181 return 'warning'
182 }
183 return pr?.checks === 'SUCCESS' ? 'success' : 'text'
184}
185
186export function orphanPrs(snap: FleetSnapshot): FleetPr[] {
187 return snap.prs.filter(p => !snap.worktrees.some(w => w.branch === p.branch))
188}
189
190export function liveQueue(snap: FleetSnapshot): FleetWaiter[] {
191 return snap.queue.filter(q => q.alive)
192}
193
194export function slotLine(slot: FleetSlot, now: number): string {
195 return `${slot.name}: ${slot.held ? `held by ${describeHolder(slot, now)}` : 'free'}`
196}
197
198export function waiterLine(waiter: FleetWaiter, index: number, now: number): string {
199 const as: FleetSlot = { name: '', held: false, worktree: waiter.worktree, command: waiter.command, acquired: waiter.enqueued }
200 return `${index + 1}. ${describeHolder(as, now)} waiting`
201}
202
203/**
204 * The fleet as markdown: what /fleet answers where no pane can be placed. It is also
205 * the text the model reads for that command row, so it stays short.
206 */
207export function summaryMarkdown(snap: FleetSnapshot, now: number): string {
208 const prs = prByBranch(snap.prs)
209 const lines = ['**Worktrees**', '']
210 for (const w of snap.worktrees) {
211 lines.push(`- ${worktreeLine(w, prs.get(w.branch ?? '')).trim()}`)
212 }
213 for (const p of orphanPrs(snap)) {
214 lines.push(`- (no worktree) PR #${p.number} \`${p.branch}\`: ${prLine(p)}`)
215 }
216 lines.push('', '**Build lock**', '')
217 for (const s of snap.slots) {
218 lines.push(`- ${slotLine(s, now)}`)
219 }
220 liveQueue(snap).forEach((q, i) => lines.push(`- ${waiterLine(q, i, now)}`))
221 const stale = snap.queue.length - liveQueue(snap).length
222 if (stale > 0) {
223 lines.push(`- ${stale} stale ticket(s) from dead processes`)
224 }
225 for (const err of snap.errors) {
226 lines.push(`- error: ${err}`)
227 }
228 return lines.join('\n')
229}
230types/index.d.ts 56 lines1export type FleetCheckState = 'SUCCESS' | 'FAILURE' | 'ERROR' | 'PENDING' | 'EXPECTED' | 'NONE'
2
3export type FleetWorktree = {
4 path: string
5 name: string
6 branch: string | null
7 head: string
8 isBase: boolean
9 /** Lines of `git status --porcelain`; null when it could not be read in time. */
10 dirty: number | null
11}
12
13export type FleetPr = {
14 number: number
15 title: string
16 branch: string
17 url: string
18 isDraft: boolean
19 mergeable: string
20 checks: FleetCheckState
21 failing: string[]
22 running: number
23 unresolved: number
24}
25
26export type FleetSlot = {
27 name: string
28 held: boolean
29 worktree: string | null
30 command: string | null
31 acquired: string | null
32}
33
34export type FleetWaiter = {
35 worktree: string
36 command: string
37 enqueued: string
38 alive: boolean
39}
40
41export type FleetSnapshot = {
42 takenAt: number
43 base: string
44 worktrees: FleetWorktree[]
45 prs: FleetPr[]
46 slots: FleetSlot[]
47 queue: FleetWaiter[]
48 errors: string[]
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'olo-fleet': { snapshot: FleetSnapshot | null }
54 }
55}
56