Enforces OloEngine's git/publish rules (no bare or master pushes, no chained --amend, gated actions need the user's approval, no push while local checks run)…

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.ts 169 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 checkCommand,
5 contenders,
6 formatRefusal,
7 isTimingRun,
8 issueBodyFiles,
9 loadNote,
10 localChecksRunning,
11 parseGpuSample,
12 parseProcessRows,
13 PULL_VOTE_RE,
14 publishes,
15 publishingApproved,
16 pullVoteViolation,
17 refusals,
18 unverifiableBodyViolation,
19 type Violation,
20} from './guards'
21
22const SHELL_TOOLS = ['Bash', 'PowerShell'] as const
23const AUDIT_KEY = 'approved-gated-actions'
24const AUDIT_KEEP = 200
25
26const PROCESS_QUERY =
27 "Get-CimInstance Win32_Process -Filter \"Name='ninja.exe' OR Name='OloEngine-Tests.exe' OR Name='clang-cl.exe' OR Name='lld-link.exe' OR Name='ctest.exe'\" " +
28 '| Select-Object Name,ProcessId,CommandLine,ExecutablePath | ConvertTo-Json -Compress'
29
30export const register: Register = on => {
31 on('tool.call', { tool: SHELL_TOOLS }, async ($, e, next) => {
32 const command = typeof e.command === 'string' ? e.command : ''
33 if (command.length === 0) {
34 return next(e)
35 }
36
37 const branch = /\bpush\b/.test(command) ? await currentBranch($) : undefined
38 const bodyVotes = await bodyFilePullVotes($, command)
39 const refused = [...refusals(command, branch), ...bodyVotes.filter(v => !v.isApproved)]
40 if (refused.length > 0) {
41 $.ui.toast(`olo-command-guards refused: ${refused.map(v => v.rule).join(', ')}`, { timeoutMs: 6000 })
42 return { deny: formatRefusal(refused) }
43 }
44
45 const approvedGated = [...checkCommand(command, branch), ...bodyVotes].filter(v => v.isGated && v.isApproved)
46 if (approvedGated.length > 0) {
47 await audit($, command, approvedGated.map(v => v.rule))
48 }
49
50 // Memory no-pr-before-local-checks-finish: push and open the PR only once every
51 // local build and test run of this worktree has finished.
52 if (publishes(command) && !publishingApproved(command)) {
53 const running = await runningLocalChecks($)
54 if (running.length > 0) {
55 const list = running.map(r => `${r.name} (pid ${r.pid})`).join(', ')
56 return {
57 deny:
58 `olo-command-guards refused this command:\n- Local checks of this worktree are still running: ${list}. ` +
59 'Push and open the PR only after every local build and test run has finished and passed (the user asked for this: no CI overlapping a running local build). ' +
60 'Wait for them; draft the PR body meanwhile.',
61 }
62 }
63 }
64
65 if (!isTimingRun(command)) {
66 return next(e)
67 }
68 const note = await loadBeforeTiming($)
69 const ran = await next(e)
70 return note === undefined || ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
71 }).catch(($, e, next) => {
72 // The guard itself failed (a process query, a parse). Fall back to the pure text
73 // rules alone, which need nothing from the engine, rather than refusing every
74 // shell command or letting a gated one through unchecked.
75 if (next.called) {
76 return next(e)
77 }
78 const command = typeof e.command === 'string' ? e.command : ''
79 const refused = refusals(command)
80 return refused.length > 0 ? { deny: formatRefusal(refused) } : next(e)
81 })
82}
83
84/**
85 * A `gh issue create|edit --body-file` hides the body from the text rules, so read each file and
86 * apply the `fun_by: user` rule to it. Fails closed: a body the hook cannot inspect (unreadable,
87 * changed earlier in the same command, behind a `cd`) is refused, because letting it through would
88 * make the user-owned Pull decision depend on how the path happened to resolve.
89 */
90async function bodyFilePullVotes($: EngineInterface, command: string): Promise<Violation[]> {
91 const out: Violation[] = []
92 for (const file of issueBodyFiles(command)) {
93 if (file.unverifiable !== undefined) {
94 out.push({ ...unverifiableBodyViolation(file.path, file.unverifiable), isApproved: file.isApproved })
95 continue
96 }
97 let text = file.written ?? ''
98 try {
99 text += `\n${await $.fs.read(file.path)}`
100 } catch {
101 // A file an earlier statement creates in this command does not exist yet; its content is `written`.
102 if (file.written === undefined) {
103 out.push({ ...unverifiableBodyViolation(file.path, 'the hook could not read it'), isApproved: file.isApproved })
104 continue
105 }
106 }
107 if (PULL_VOTE_RE.test(text)) {
108 out.push({ ...pullVoteViolation(), isApproved: file.isApproved })
109 }
110 }
111 return out
112}
113
114async function currentBranch($: EngineInterface): Promise<string | undefined> {
115 try {
116 const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
117 return r.exitCode === 0 ? r.stdout.trim() : undefined
118 } catch {
119 return undefined
120 }
121}
122
123async function runningLocalChecks($: EngineInterface) {
124 try {
125 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
126 if (top.exitCode !== 0) {
127 return []
128 }
129 const ps = await $.process.run(['pwsh', '-NoProfile', '-NonInteractive', '-Command', PROCESS_QUERY], { timeoutMs: 20_000 })
130 if (ps.exitCode !== 0) {
131 return []
132 }
133 return localChecksRunning(parseProcessRows(ps.stdout), top.stdout.trim())
134 } catch {
135 // Fail open: a missing pwsh must not block every push.
136 return []
137 }
138}
139
140async function loadBeforeTiming($: EngineInterface): Promise<string | undefined> {
141 try {
142 const [gpu, tasks] = await Promise.all([
143 $.process.run(['nvidia-smi', '--query-gpu=clocks.gr,power.draw,utilization.gpu', '--format=csv,noheader,nounits'], { timeoutMs: 10_000 }),
144 $.process.run(['tasklist', '/FO', 'CSV', '/NH'], { timeoutMs: 10_000 }),
145 ])
146 const sample = gpu.exitCode === 0 ? parseGpuSample(gpu.stdout) : undefined
147 const others = tasks.exitCode === 0 ? contenders(tasks.stdout) : []
148 const note = loadNote(sample, others)
149 if (note !== undefined) {
150 $.ui.toast('olo-command-guards: timing run on a busy box; its timings are contended', { timeoutMs: 8000 })
151 }
152 return note
153 } catch {
154 return undefined
155 }
156}
157
158async function audit($: EngineInterface, command: string, rules: string[]): Promise<void> {
159 try {
160 const at = new Date(await $.clock.now()).toISOString()
161 const previous = await $.store.get(AUDIT_KEY)
162 const list = Array.isArray(previous) ? previous : []
163 await $.store.set(AUDIT_KEY, [...list, { at, rules, command: command.slice(0, 500) }].slice(-AUDIT_KEEP))
164 $.ui.toast(`olo-command-guards: user-approved ${rules.join(', ')} (logged)`, { timeoutMs: 6000 })
165 } catch {
166 // Logging is best effort; the approval itself stands.
167 }
168}
169hooks/guards.ts 718 lines1// Pure command analysis for the guards. No engine calls: the tests drive every rule.
2//
3// Text matching on a shell command is best effort by nature. Every rule here is biased
4// the same way the repo's Python guards are: a false positive costs one re-issued call
5// with a clear reason, a false negative costs the incident the rule was written after.
6
7/** The marker that lets a gated action through once the user approved that action. */
8export const APPROVAL_MARKER = 'OLO_USER_APPROVED'
9
10export type Separator = 'start' | '&&' | '||' | ';' | '|' | '\n' | '&'
11
12export type Statement = { separator: Separator; text: string; words: string[] }
13
14/**
15 * Splits a shell command into statements, remembering which separator led into each,
16 * and tokenises each one. Quote-aware (single, double, and PowerShell backtick escapes
17 * are left as literal characters), so a `;` inside a commit message is not a split.
18 */
19export function splitStatements(command: string): Statement[] {
20 const statements: Statement[] = []
21 let current = ''
22 let separator: Separator = 'start'
23 let quote: '"' | "'" | undefined
24 let i = 0
25
26 const flush = (next: Separator) => {
27 if (current.trim().length > 0) {
28 statements.push({ separator, text: current.trim(), words: tokenize(current) })
29 }
30 current = ''
31 separator = next
32 }
33
34 while (i < command.length) {
35 const c = command[i] ?? ''
36 const two = command.slice(i, i + 2)
37 if (quote !== undefined) {
38 if (c === '\\' && quote === '"' && i + 1 < command.length) {
39 current += two
40 i += 2
41 continue
42 }
43 if (c === quote) {
44 quote = undefined
45 }
46 current += c
47 i += 1
48 continue
49 }
50 if (c === '"' || c === "'") {
51 quote = c
52 current += c
53 i += 1
54 continue
55 }
56 if (c === '#' && (current.length === 0 || /\s$/.test(current))) {
57 // A comment runs to the end of the line; keep it in the text (markers live there)
58 // but out of the words.
59 const end = command.indexOf('\n', i)
60 const stop = end === -1 ? command.length : end
61 current += command.slice(i, stop)
62 i = stop
63 continue
64 }
65 if (two === '&&' || two === '||') {
66 flush(two)
67 i += 2
68 continue
69 }
70 if (c === ';' || c === '\n' || c === '|' || (c === '&' && command[i + 1] !== '>' && command[i - 1] !== '>')) {
71 flush(c as Separator)
72 i += 1
73 continue
74 }
75 current += c
76 i += 1
77 }
78 flush('start')
79 return statements
80}
81
82/** Words of one statement, quotes removed, a trailing `#` comment dropped. */
83export function tokenize(text: string): string[] {
84 const words: string[] = []
85 let word = ''
86 let quote: '"' | "'" | undefined
87 let started = false
88 for (let i = 0; i < text.length; i += 1) {
89 const c = text[i] ?? ''
90 if (quote !== undefined) {
91 if (c === quote) {
92 quote = undefined
93 } else {
94 word += c
95 }
96 continue
97 }
98 if (c === '"' || c === "'") {
99 quote = c
100 started = true
101 continue
102 }
103 if (c === '#' && !started) {
104 break
105 }
106 if (/\s/.test(c)) {
107 if (started) {
108 words.push(word)
109 }
110 word = ''
111 started = false
112 continue
113 }
114 word += c
115 started = true
116 }
117 if (started) {
118 words.push(word)
119 }
120 return words
121}
122
123/** The words after `git [-C dir] [-c k=v]...`, starting at the subcommand; undefined if not git. */
124export function gitArgs(words: string[]): string[] | undefined {
125 const first = (words[0] ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '')
126 if (first !== 'git') {
127 return undefined
128 }
129 let i = 1
130 while (i < words.length) {
131 const w = words[i] ?? ''
132 if (w === '-C' || w === '-c' || w === '--git-dir' || w === '--work-tree') {
133 i += 2
134 } else if (w.startsWith('--git-dir=') || w.startsWith('--work-tree=') || w === '--no-pager') {
135 i += 1
136 } else {
137 break
138 }
139 }
140 return words.slice(i)
141}
142
143export type Violation = {
144 rule: string
145 /** True for CLAUDE.md's "gated in every context" actions: the approval marker lets them through. */
146 isGated: boolean
147 /** The statement that broke the rule carries the approval marker in its own trailing comment. */
148 isApproved: boolean
149 reason: string
150}
151
152/**
153 * The trailing comment of one statement (`# ...`, outside any quotes), or ''. The approval
154 * marker counts only here: inside a quoted argument (a PR body, an issue comment) it is
155 * data, and on one statement of a chain it approves that statement alone.
156 */
157export function commentOf(text: string): string {
158 let quote: '"' | "'" | undefined
159 for (let i = 0; i < text.length; i += 1) {
160 const c = text[i] ?? ''
161 if (quote !== undefined) {
162 if (c === quote) {
163 quote = undefined
164 }
165 continue
166 }
167 if (c === '"' || c === "'") {
168 quote = c
169 continue
170 }
171 if (c === '#' && (i === 0 || /\s/.test(text[i - 1] ?? ''))) {
172 return text.slice(i + 1)
173 }
174 }
175 return ''
176}
177
178function isApprovedStatement(statement: Statement): boolean {
179 return commentOf(statement.text).includes(APPROVAL_MARKER)
180}
181
182const PROTECTED_BRANCHES = new Set(['master', 'main'])
183
184type Found = Omit<Violation, 'isApproved'>
185
186function pushViolations(args: string[], branch: string | undefined): Found[] {
187 const rest = args.slice(1)
188 const flags = rest.filter(w => w.startsWith('-'))
189 const positional = rest.filter(w => !w.startsWith('-'))
190 const out: Found[] = []
191 const suggestion = branch && !PROTECTED_BRANCHES.has(branch) ? `git push -u origin ${branch}` : 'git push -u origin feature/<slug>'
192
193 const isForce =
194 flags.some(f => f === '-f' || f === '--force' || f.startsWith('--force-with-lease') || f === '--force-if-includes') ||
195 positional.slice(1).some(r => r.startsWith('+'))
196 if (isForce) {
197 out.push({
198 rule: 'force-push',
199 isGated: true,
200 reason: 'A force push is gated in every context (CLAUDE.md, Committing and publishing): ask the user first.',
201 })
202 }
203
204 const destinations = positional.slice(1).map(r => {
205 const dest = r.includes(':') ? r.slice(r.lastIndexOf(':') + 1) : r
206 return dest.replace(/^\+/, '').replace(/^refs\/heads\//, '')
207 })
208 const pushesHeadFromProtected = positional.length >= 2 && destinations.includes('HEAD') && branch !== undefined && PROTECTED_BRANCHES.has(branch)
209 if (destinations.some(d => PROTECTED_BRANCHES.has(d)) || pushesHeadFromProtected) {
210 out.push({
211 rule: 'push-to-master',
212 isGated: true,
213 reason: 'Pushing to master is gated in every context (CLAUDE.md, Committing and publishing): work lands through a PR.',
214 })
215 }
216
217 const isWholeRepoForm = flags.some(f => f === '--tags' || f === '--all' || f === '--mirror' || f === '--delete' || f === '-d')
218 if (positional.length < 2 && !isWholeRepoForm) {
219 out.push({
220 rule: 'bare-push',
221 isGated: false,
222 reason: `Never a bare \`git push\`: under push.default it can land on master, which has happened here. Name the remote and the branch: \`${suggestion}\`.`,
223 })
224 }
225 return out
226}
227
228export function exeName(word: string | undefined): string {
229 return (word ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase()
230}
231
232// Launchers that run the command after them, with the options of theirs that take a value.
233// `env -S '<cmd>'` splits its value into the command it runs.
234const LAUNCHERS: Record<string, { withValue: string[]; positional?: number }> = {
235 env: { withValue: ['-u', '--unset', '-C', '--chdir', '-P'] },
236 timeout: { withValue: ['-s', '--signal', '-k', '--kill-after'], positional: 1 },
237 nice: { withValue: ['-n', '--adjustment'] },
238 time: { withValue: ['-o', '--output', '-f', '--format'] },
239 call: { withValue: [] },
240 command: { withValue: [] },
241 builtin: { withValue: [] },
242 exec: { withValue: ['-a'] },
243}
244
245/**
246 * A statement's words from the command it runs. Drops `&`, `VAR=value` assignments and the
247 * launchers above with their own options (`env -i -u X`, `timeout -s KILL 60`, `nice -n 5`),
248 * so `env -i GH_TOKEN=x gh pr merge` is still `gh pr merge`. Normalising only exposes the
249 * command that runs: it never removes a word the rules would have refused.
250 */
251export function commandWords(words: readonly string[]): string[] {
252 let rest = [...words]
253 for (;;) {
254 const w = rest[0] ?? ''
255 if (w === '&' || /^\w+=/.test(w)) {
256 rest = rest.slice(1)
257 continue
258 }
259 const launcher = LAUNCHERS[exeName(w)]
260 if (launcher === undefined) {
261 return rest
262 }
263 let i = 1
264 while (i < rest.length && (rest[i] ?? '').startsWith('-') && rest[i] !== '-') {
265 const flag = rest[i] ?? ''
266 // env -S '<string>' [more]: the string is split into env's own arguments, and may
267 // itself start with env options (`env -S '-i gh pr merge 1'` runs `gh pr merge 1`), so
268 // the expanded words go back through env, not straight to the command.
269 const split =
270 exeName(w) !== 'env'
271 ? undefined
272 : flag === '-S' || flag === '--split-string'
273 ? { value: rest[i + 1] ?? '', after: i + 2 }
274 : flag.startsWith('--split-string=')
275 ? { value: flag.slice('--split-string='.length), after: i + 1 }
276 : /^-S./.test(flag)
277 ? { value: flag.slice(2), after: i + 1 }
278 : undefined
279 if (split !== undefined) {
280 rest = [w, ...rest.slice(1, i), ...tokenize(split.value), ...rest.slice(split.after)]
281 i = -1
282 break
283 }
284 i += launcher.withValue.includes(flag) ? 2 : 1
285 }
286 if (i === -1) {
287 continue
288 }
289 if (rest[i] === '-') {
290 i += 1
291 }
292 rest = rest.slice(i + (launcher.positional ?? 0))
293 }
294}
295
296// PowerShell options that take a value (the rest are switches).
297const PWSH_WITH_VALUE = new Set([
298 '-executionpolicy', '-ep', '-ex', '-workingdirectory', '-wd', '-windowstyle', '-w', '-configurationname', '-config',
299 '-outputformat', '-o', '-of', '-inputformat', '-if', '-in', '-version', '-v', '-settingsfile', '-custompipename', '-psconsolefile',
300])
301// bash/sh options that take a value.
302const SH_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file'])
303// A script that runs its own -Command parameter: build-lock.ps1 runs the build it is handed.
304const RUNS_ITS_COMMAND = /^build-lock\.ps1$/i
305
306/**
307 * The command a shell wrapper runs, read from the wrapper's OWN options only (words after a
308 * script operand are the script's arguments, not the shell's): `pwsh/powershell -Command|-c
309 * <cmd>`; `bash/sh/zsh/dash` with a single-dash flag group containing `c` (`-c`, `-lc`, `-ec`)
310 * then `<cmd>`; `cmd /c|/k <cmd...>`. The one script whose `-Command` IS what runs is
311 * build-lock.ps1. Undefined when the statement is no such wrapper.
312 */
313export function innerCommand(words: readonly string[]): string | undefined {
314 const name = exeName(words[0])
315 if (name === 'pwsh' || name === 'powershell') {
316 for (let i = 1; i < words.length; i += 1) {
317 const w = words[i] ?? ''
318 const lower = w.toLowerCase()
319 if (lower === '-command' || lower === '-c') {
320 return words.slice(i + 1).join(' ')
321 }
322 if (lower === '-file' || lower === '-f') {
323 const script = words[i + 1] ?? ''
324 if (!RUNS_ITS_COMMAND.test(script.replace(/^.*[\\/]/, ''))) {
325 return undefined
326 }
327 const at = words.findIndex((x, j) => j > i + 1 && /^-command$/i.test(x))
328 return at >= 0 ? words[at + 1] : undefined
329 }
330 if (lower === '-encodedcommand' || lower === '-e' || lower === '-ec') {
331 return undefined
332 }
333 if (!w.startsWith('-')) {
334 // A bare operand: pwsh runs it as a script file, Windows PowerShell as a command.
335 return name === 'powershell' ? words.slice(i).join(' ') : undefined
336 }
337 if (PWSH_WITH_VALUE.has(lower)) {
338 i += 1
339 }
340 }
341 return undefined
342 }
343 if (name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash') {
344 for (let i = 1; i < words.length; i += 1) {
345 const w = words[i] ?? ''
346 if (w === '--' || w === '-') {
347 return undefined
348 }
349 if (SH_WITH_VALUE.has(w)) {
350 i += 1
351 continue
352 }
353 if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) {
354 return words[i + 1]
355 }
356 if (!/^[-+]/.test(w)) {
357 // The script operand: every word after it is the script's.
358 return undefined
359 }
360 }
361 return undefined
362 }
363 if (name === 'cmd') {
364 for (let i = 1; i < words.length; i += 1) {
365 const w = words[i] ?? ''
366 if (/^\/[ck]$/i.test(w)) {
367 return words.slice(i + 1).join(' ')
368 }
369 if (!w.startsWith('/')) {
370 return undefined
371 }
372 }
373 return undefined
374 }
375 return undefined
376}
377
378/**
379 * Every rule a command breaks. `branch` is the current branch, when known, for the
380 * suggestion and for `git push origin HEAD` from master. A shell wrapper is looked into:
381 * its own trailing comment approves what it runs.
382 */
383export function checkCommand(command: string, branch?: string): Violation[] {
384 const all: Violation[] = []
385 const statements = splitStatements(command)
386 for (const [index, statement] of statements.entries()) {
387 const out: Found[] = []
388 const words = commandWords(statement.words)
389 const inner = innerCommand(words)
390 if (inner !== undefined) {
391 const outerApproved = isApprovedStatement(statement)
392 all.push(...checkCommand(inner, branch).map(v => ({ ...v, isApproved: v.isApproved || outerApproved })))
393 continue
394 }
395 const args = gitArgs(words)
396 const sub = args?.[0]
397
398 if (args !== undefined && sub === 'commit' && args.includes('--amend') && statement.separator !== 'start' && statement.separator !== '&&') {
399 out.push({
400 rule: 'chained-amend',
401 isGated: false,
402 reason:
403 `\`git commit --amend\` runs after \`${statement.separator === '\n' ? 'a newline' : statement.separator}\`, so it runs even when the command before it failed. ` +
404 'A failed `git revert -q` followed by `; git commit --amend` rewrote an already-pushed commit here (#1405). Chain with `&&`, or better, pass the message to the first command.',
405 })
406 }
407
408 if (args !== undefined && (sub === 'push' || sub === 'commit') && args.includes('--no-verify')) {
409 out.push({
410 rule: 'no-verify',
411 isGated: true,
412 reason: 'Skipping hooks with --no-verify needs the user to have asked for it: fix what the hook reports instead.',
413 })
414 }
415
416 if (args !== undefined && sub === 'push') {
417 out.push(...pushViolations(args, branch))
418 }
419
420 if (args !== undefined && sub === 'reset' && args.includes('--hard')) {
421 out.push({
422 rule: 'reset-hard',
423 isGated: true,
424 reason: '`git reset --hard` is gated in every context (CLAUDE.md): it discards work. Prefer `git stash` or a new branch, or ask the user.',
425 })
426 }
427
428 const first = (words[0] ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '')
429 if (first === 'gh' && words[1] === 'pr' && words[2] === 'merge') {
430 out.push({ rule: 'pr-merge', isGated: true, reason: 'Merging a PR is the user\'s call in every context (CLAUDE.md).' })
431 }
432 if (first === 'gh' && words[1] === 'issue' && words[2] === 'close') {
433 out.push({
434 rule: 'issue-close',
435 isGated: true,
436 reason: 'Closing an issue is the user\'s call (CLAUDE.md): post the evidence, recommend closure, let the user close.',
437 })
438 }
439 // The write's own text, including a heredoc body on the lines after it, so an approved vote
440 // on one issue write never taints an unapproved write elsewhere in the same command.
441 if (isIssueWrite(words) && PULL_VOTE_RE.test(issueWriteScope(statements, index))) {
442 out.push(pullVoteViolation())
443 }
444 const script = words.findIndex(w => /(?:^|[\\/])issue_scores\.py$/.test(w))
445 if (script >= 0 && words[script + 1] === 'pull' && !words.includes('--dry-run')) {
446 out.push(pullVoteViolation())
447 }
448
449 if (statement.separator === '|' && /^(?:Set-Content|sc|Add-Content|ac|Out-File)$/i.test(words[0] ?? '') && words.some(w => /^-NoNewline$/i.test(w))) {
450 out.push({
451 rule: 'set-content-nonewline',
452 isGated: false,
453 reason:
454 'A pipe into `Set-Content -NoNewline` joins every line into ONE: the pipe hands over an array of lines and -NoNewline drops the separator between them. ' +
455 'It flattened a GLSL file and a scene here (#1360). Use Bash redirection (`git show <sha>:path > path`) or the Write/Edit tools.',
456 })
457 }
458
459 const approved = isApprovedStatement(statement)
460 all.push(...out.map(v => ({ ...v, isApproved: approved })))
461 }
462 return all
463}
464
465/**
466 * `fun_by: user` in an olo-score block is the user's confirmation that a Fun >= 8 should jump the
467 * queue (docs/process/issue-scoring.md §3.4). Agents scored Fun on the issues they filed, and on
468 * 2026-10-15 that put five fresh agent-filed issues at the front of /start-work's rank. So writing
469 * the field is gated like closing an issue: `issue_scores.py pull` or an issue body carrying it.
470 */
471export const PULL_VOTE_RE = /\bfun_by\s*[:=]\s*["']?user\b/i
472
473export function pullVoteViolation(): Found {
474 return {
475 rule: 'pull-vote',
476 isGated: true,
477 reason:
478 'Writing `fun_by: user` (or running `issue_scores.py pull`) records the USER\'s Pull-override; an agent never sets it on its own (docs/process/issue-scoring.md §3.4). ' +
479 'Score Fun honestly and leave the confirmation to the user.',
480 }
481}
482
483function isIssueWrite(words: readonly string[]): boolean {
484 const first = exeName(words[0])
485 return first === 'gh' && words[1] === 'issue' && (words[2] === 'create' || words[2] === 'edit')
486}
487
488/** An issue write's own text: its statement plus the lines up to the next issue write (a heredoc body). */
489function issueWriteScope(statements: readonly Statement[], index: number): string {
490 const parts = [statements[index]?.text ?? '']
491 for (const statement of statements.slice(index + 1)) {
492 if (isIssueWrite(commandWords(statement.words))) {
493 break
494 }
495 parts.push(statement.text)
496 }
497 return parts.join('\n')
498}
499
500/** Commands whose output to a file is spelled out in the command itself (arguments, heredoc, here-string). */
501const VISIBLE_WRITERS = new Set(['cat', 'tee', 'echo', 'printf', 'set-content', 'add-content', 'out-file', 'sc', 'ac', 'write-output'])
502const DIRECTORY_CHANGERS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl'])
503
504export type IssueBodyFile = {
505 /** The path as the shell will read it: a Git Bash `/c/...` path becomes `C:/...`. */
506 path: string
507 isApproved: boolean
508 /** Set when an earlier statement writes the file with its content in the command: that text, to check instead of the stale file. */
509 written?: string
510 /** Set when the hook cannot know what `gh` will read; the write is refused rather than let through unchecked. */
511 unverifiable?: string
512}
513
514function shellPath(path: string): string {
515 const drive = /^\/([a-zA-Z])\/(.*)$/.exec(path)
516 return drive ? `${(drive[1] ?? '').toUpperCase()}:/${drive[2] ?? ''}` : path
517}
518
519/**
520 * The `--body-file` / `-F` of every `gh issue create|edit` in the command, with each statement's
521 * approval and what the hook can know about the file's content when `gh` reads it. The hook reads
522 * the file BEFORE the command runs, so an earlier statement that changes it (`cp vote.md body.md &&
523 * gh issue edit 1 --body-file body.md`) or moves the working directory makes the read meaningless.
524 */
525export function issueBodyFiles(command: string): IssueBodyFile[] {
526 const out: IssueBodyFile[] = []
527 const statements = splitStatements(command)
528 for (const [index, statement] of statements.entries()) {
529 const words = commandWords(statement.words)
530 const inner = innerCommand(words)
531 if (inner !== undefined) {
532 const outerApproved = isApprovedStatement(statement)
533 out.push(...issueBodyFiles(inner).map(f => ({ ...f, isApproved: f.isApproved || outerApproved })))
534 continue
535 }
536 if (!isIssueWrite(words)) {
537 continue
538 }
539 const earlier = statements.slice(0, index)
540 for (let i = 3; i < words.length; i += 1) {
541 const w = words[i] ?? ''
542 const raw = w === '--body-file' || w === '-F' ? words[i + 1] : w.startsWith('--body-file=') ? w.slice('--body-file='.length) : undefined
543 if (raw === undefined || raw === '-' || raw.length === 0) {
544 continue
545 }
546 const file: IssueBodyFile = { path: shellPath(raw), isApproved: isApprovedStatement(statement) }
547 const isAbsolute = /^(?:[A-Za-z]:[\\/]|[\\/])/.test(raw)
548 const moved = earlier.find(s => DIRECTORY_CHANGERS.has(exeName(commandWords(s.words)[0])))
549 const touching = earlier.filter(s => s.text.includes(raw))
550 const hidden = touching.find(s => !VISIBLE_WRITERS.has(exeName(commandWords(s.words)[0])))
551 if (!isAbsolute && moved !== undefined) {
552 file.unverifiable = `\`${exeName(commandWords(moved.words)[0])}\` earlier in the command moves the shell away from the directory the hook resolves \`${raw}\` against`
553 } else if (hidden !== undefined) {
554 file.unverifiable = `\`${exeName(commandWords(hidden.words)[0])}\` earlier in the command changes it after the hook would read it`
555 } else if (touching.length > 0) {
556 file.written = earlier.map(s => s.text).join('\n')
557 }
558 out.push(file)
559 }
560 }
561 return out
562}
563
564export function unverifiableBodyViolation(path: string, why: string): Found {
565 return {
566 rule: 'pull-vote-unverifiable',
567 isGated: true,
568 reason:
569 `The hook cannot check \`${path}\` for \`fun_by: user\`: ${why}. ` +
570 'Write the body file in its own command (an absolute Windows path is safest), then run the issue write on its own.',
571 }
572}
573
574/** What the guard refuses: everything ungated, and a gated rule unless its own statement carries the marker. */
575export function refusals(command: string, branch?: string): Violation[] {
576 return checkCommand(command, branch).filter(v => !v.isGated || !v.isApproved)
577}
578
579export function formatRefusal(violations: readonly Violation[]): string {
580 const gated = violations.some(v => v.isGated)
581 const lines = violations.map(v => `- ${v.reason}`)
582 if (gated) {
583 lines.push(
584 `If the user explicitly approved THIS action in this conversation, re-issue it with the literal marker ${APPROVAL_MARKER} in a trailing comment (it is logged). Otherwise ask them.`,
585 )
586 }
587 return `olo-command-guards refused this command:\n${lines.join('\n')}`
588}
589
590function isPublishing(s: Statement): boolean {
591 const words = commandWords(s.words)
592 const inner = innerCommand(words)
593 if (inner !== undefined) {
594 return publishes(inner)
595 }
596 const args = gitArgs(words)
597 return (args?.[0] === 'push' && !args.includes('--dry-run') && !args.includes('-n')) || (exeName(words[0]) === 'gh' && words[1] === 'pr' && words[2] === 'create')
598}
599
600/** Does the command publish work (a push, a new PR)? Local checks must have finished first. */
601export function publishes(command: string): boolean {
602 return splitStatements(command).some(isPublishing)
603}
604
605/** Every publishing statement carries the approval marker in its own (or its wrapper's) trailing comment. */
606export function publishingApproved(command: string): boolean {
607 const publishing = splitStatements(command).filter(isPublishing)
608 return (
609 publishing.length > 0 &&
610 publishing.every(s => {
611 if (isApprovedStatement(s)) {
612 return true
613 }
614 const inner = innerCommand(commandWords(s.words))
615 return inner !== undefined && publishingApproved(inner)
616 })
617 )
618}
619
620// ------------------------------------------------------------ timing runs ---
621
622const TIMING_FLAGS = /--olo-perf-rebase|--olo-capture-manifest|--olo-perf\b/
623const TIMING_FILTER = /--gtest_filter=\S*(?:Perf|Timing|Cost|Baseline|Benchmark|Budget|Throughput|Latency)/i
624
625/** A run whose numbers mean something only on a quiet GPU. */
626export function isTimingRun(command: string): boolean {
627 return /OloEngine-Tests|OloEditor|OloRuntime/i.test(command) && (TIMING_FLAGS.test(command) || TIMING_FILTER.test(command))
628}
629
630export type GpuSample = { clockMHz: number; powerW: number; utilization: number }
631
632/** Parses `nvidia-smi --query-gpu=clocks.gr,power.draw,utilization.gpu --format=csv,noheader,nounits`. */
633export function parseGpuSample(stdout: string): GpuSample | undefined {
634 const line = stdout.split(/\r?\n/).find(l => l.trim().length > 0)
635 if (line === undefined) {
636 return undefined
637 }
638 const [clock, power, util] = line.split(',').map(v => Number.parseFloat(v.trim()))
639 if (clock === undefined || power === undefined || util === undefined || [clock, power, util].some(Number.isNaN)) {
640 return undefined
641 }
642 return { clockMHz: clock, powerW: power, utilization: util }
643}
644
645// Heavy processes that contend with a measurement, by image name.
646const CONTENDERS = /^(?:OloEngine-Tests|OloEditor|OloRuntime|OloServer|ninja|lld-link|clang-cl|cl|link|MSBuild|blender)\.exe$/i
647
648/** Image names from `tasklist /FO CSV /NH` that contend with a measurement. */
649export function contenders(tasklistCsv: string): string[] {
650 const out = new Map<string, number>()
651 for (const line of tasklistCsv.split(/\r?\n/)) {
652 const name = /^"([^"]+)"/.exec(line)?.[1]
653 if (name !== undefined && CONTENDERS.test(name)) {
654 out.set(name, (out.get(name) ?? 0) + 1)
655 }
656 }
657 return [...out].map(([name, count]) => (count > 1 ? `${name} x${count}` : name))
658}
659
660/**
661 * Whether the box looked busy before a timing run. At its 210 MHz floor the 4090 reads
662 * ~30% utilisation from the compositor alone, so utilisation is not the signal: the
663 * graphics clock (busy above ~1 GHz) and the power draw (busy above ~60 W) are.
664 * Memory: check-for-other-gpu-load-before-trusting-timings.
665 */
666export function loadNote(sample: GpuSample | undefined, others: string[]): string | undefined {
667 const busyGpu = sample !== undefined && (sample.clockMHz > 1000 || sample.powerW > 60)
668 if (!busyGpu && others.length === 0) {
669 return undefined
670 }
671 const parts = [
672 busyGpu && sample ? `the GPU was busy before this run started (graphics clock ${sample.clockMHz} MHz, ${sample.powerW} W)` : '',
673 others.length > 0 ? `other heavy processes were running: ${others.join(', ')}` : '',
674 ].filter(Boolean)
675 return (
676 `olo-command-guards: this is a timing run, and ${parts.join('; ')}. ` +
677 'GPU and frame timings from it are contended: do not record or compare them as a baseline. Pixels and correctness results are unaffected. ' +
678 'Check `nvidia-smi` and the process list again and re-run timing on a quiet box (memory: check-for-other-gpu-load-before-trusting-timings).'
679 )
680}
681
682// ----------------------------------------------------- local checks running ---
683
684// Not cmake: a configure is no check, and an IDE's cmake would pin the guard shut.
685const LOCAL_CHECK_IMAGES = /^(?:ninja|OloEngine-Tests|clang-cl|lld-link|ctest)\.exe$/i
686
687export type ProcessRow = { name: string; pid: number; commandLine: string; executablePath: string }
688
689/** Parses the JSON of `Get-CimInstance Win32_Process | Select Name,ProcessId,CommandLine,ExecutablePath | ConvertTo-Json`. */
690export function parseProcessRows(json: string): ProcessRow[] {
691 if (json.trim().length === 0) {
692 return []
693 }
694 const raw: unknown = JSON.parse(json)
695 const list = Array.isArray(raw) ? raw : [raw]
696 return list.flatMap(r => {
697 if (typeof r !== 'object' || r === null) {
698 return []
699 }
700 const o = r as Record<string, unknown>
701 return [
702 {
703 name: String(o.Name ?? ''),
704 pid: Number(o.ProcessId ?? 0),
705 commandLine: String(o.CommandLine ?? ''),
706 executablePath: String(o.ExecutablePath ?? ''),
707 },
708 ]
709 })
710}
711
712/** Builds and test runs that belong to the worktree at `root` (paths compared case-insensitively, either slash). */
713export function localChecksRunning(rows: readonly ProcessRow[], root: string): ProcessRow[] {
714 const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
715 const prefix = `${norm(root).replace(/\/$/, '')}/`
716 return rows.filter(r => LOCAL_CHECK_IMAGES.test(r.name) && (norm(r.commandLine).includes(prefix) || norm(r.executablePath).startsWith(prefix)))
717}
718