diff --git a/.claude/commands/cleanup-worktree.md b/.claude/commands/cleanup-worktree.md index a2097568b..f910cd5e3 100644 --- a/.claude/commands/cleanup-worktree.md +++ b/.claude/commands/cleanup-worktree.md @@ -154,7 +154,10 @@ For each candidate that is **still OPEN** (`gh issue view --json state`): (additive, low-risk — no ask): gh issue comment --repo --body "Completed by # (merged ); worktree removed. Flagging for closure." then add it to a *close-recommendation list* and **close ONLY after I confirm** (closing - is the outward, decisive step — an explicit opt-in, like commit/push). + is the outward, decisive step — an explicit opt-in, like commit/push). Once I have + confirmed, close with a trailing `# OLO_USER_APPROVED` on the `gh issue close` line: the + `olo-command-guards` mod refuses an issue close without it (and logs the ones it lets + through), see `docs/agent-rules/claude-code-mods.md`. - **Umbrella / multi-item issue** the merge only partially advanced — one checklist item of many, e.g. a `#308`-style follow-ups tracker → comment WHICH item is now done, **leave it open**, and do NOT add it to the close list. Merging one item ≠ the issue is finished. diff --git a/.claude/mods/.claude-plugin/marketplace.json b/.claude/mods/.claude-plugin/marketplace.json new file mode 100644 index 000000000..d67d20312 --- /dev/null +++ b/.claude/mods/.claude-plugin/marketplace.json @@ -0,0 +1,37 @@ +{ + "name": "olo-mods", + "owner": { + "name": "OloEngine" + }, + "description": "OloEngine's Claude Code mods: the repo's agent rules held as code in the agent loop. See docs/agent-rules/claude-code-mods.md.", + "plugins": [ + { + "name": "olo-output-truth", + "source": "./olo-output-truth" + }, + { + "name": "olo-command-guards", + "source": "./olo-command-guards" + }, + { + "name": "olo-read-gates", + "source": "./olo-read-gates" + }, + { + "name": "olo-evidence", + "source": "./olo-evidence" + }, + { + "name": "olo-known-traps", + "source": "./olo-known-traps" + }, + { + "name": "olo-context-saver", + "source": "./olo-context-saver" + }, + { + "name": "olo-fleet", + "source": "./olo-fleet" + } + ] +} diff --git a/.claude/mods/olo-command-guards/.claude-plugin/plugin.json b/.claude/mods/olo-command-guards/.claude-plugin/plugin.json new file mode 100644 index 000000000..78103d7de --- /dev/null +++ b/.claude/mods/olo-command-guards/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "olo-command-guards", + "version": "0.1.0", + "description": "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) and flags timing runs on a busy GPU.", + "author": { + "name": "OloEngine" + } +} diff --git a/.claude/mods/olo-command-guards/hooks/guards.ts b/.claude/mods/olo-command-guards/hooks/guards.ts new file mode 100644 index 000000000..72b4905a9 --- /dev/null +++ b/.claude/mods/olo-command-guards/hooks/guards.ts @@ -0,0 +1,598 @@ +// Pure command analysis for the guards. No engine calls: the tests drive every rule. +// +// Text matching on a shell command is best effort by nature. Every rule here is biased +// the same way the repo's Python guards are: a false positive costs one re-issued call +// with a clear reason, a false negative costs the incident the rule was written after. + +/** The marker that lets a gated action through once the user approved that action. */ +export const APPROVAL_MARKER = 'OLO_USER_APPROVED' + +export type Separator = 'start' | '&&' | '||' | ';' | '|' | '\n' | '&' + +export type Statement = { separator: Separator; text: string; words: string[] } + +/** + * Splits a shell command into statements, remembering which separator led into each, + * and tokenises each one. Quote-aware (single, double, and PowerShell backtick escapes + * are left as literal characters), so a `;` inside a commit message is not a split. + */ +export function splitStatements(command: string): Statement[] { + const statements: Statement[] = [] + let current = '' + let separator: Separator = 'start' + let quote: '"' | "'" | undefined + let i = 0 + + const flush = (next: Separator) => { + if (current.trim().length > 0) { + statements.push({ separator, text: current.trim(), words: tokenize(current) }) + } + current = '' + separator = next + } + + while (i < command.length) { + const c = command[i] ?? '' + const two = command.slice(i, i + 2) + if (quote !== undefined) { + if (c === '\\' && quote === '"' && i + 1 < command.length) { + current += two + i += 2 + continue + } + if (c === quote) { + quote = undefined + } + current += c + i += 1 + continue + } + if (c === '"' || c === "'") { + quote = c + current += c + i += 1 + continue + } + if (c === '#' && (current.length === 0 || /\s$/.test(current))) { + // A comment runs to the end of the line; keep it in the text (markers live there) + // but out of the words. + const end = command.indexOf('\n', i) + const stop = end === -1 ? command.length : end + current += command.slice(i, stop) + i = stop + continue + } + if (two === '&&' || two === '||') { + flush(two) + i += 2 + continue + } + if (c === ';' || c === '\n' || c === '|' || (c === '&' && command[i + 1] !== '>' && command[i - 1] !== '>')) { + flush(c as Separator) + i += 1 + continue + } + current += c + i += 1 + } + flush('start') + return statements +} + +/** Words of one statement, quotes removed, a trailing `#` comment dropped. */ +export function tokenize(text: string): string[] { + const words: string[] = [] + let word = '' + let quote: '"' | "'" | undefined + let started = false + for (let i = 0; i < text.length; i += 1) { + const c = text[i] ?? '' + if (quote !== undefined) { + if (c === quote) { + quote = undefined + } else { + word += c + } + continue + } + if (c === '"' || c === "'") { + quote = c + started = true + continue + } + if (c === '#' && !started) { + break + } + if (/\s/.test(c)) { + if (started) { + words.push(word) + } + word = '' + started = false + continue + } + word += c + started = true + } + if (started) { + words.push(word) + } + return words +} + +/** The words after `git [-C dir] [-c k=v]...`, starting at the subcommand; undefined if not git. */ +export function gitArgs(words: string[]): string[] | undefined { + const first = (words[0] ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '') + if (first !== 'git') { + return undefined + } + let i = 1 + while (i < words.length) { + const w = words[i] ?? '' + if (w === '-C' || w === '-c' || w === '--git-dir' || w === '--work-tree') { + i += 2 + } else if (w.startsWith('--git-dir=') || w.startsWith('--work-tree=') || w === '--no-pager') { + i += 1 + } else { + break + } + } + return words.slice(i) +} + +export type Violation = { + rule: string + /** True for CLAUDE.md's "gated in every context" actions: the approval marker lets them through. */ + isGated: boolean + /** The statement that broke the rule carries the approval marker in its own trailing comment. */ + isApproved: boolean + reason: string +} + +/** + * The trailing comment of one statement (`# ...`, outside any quotes), or ''. The approval + * marker counts only here: inside a quoted argument (a PR body, an issue comment) it is + * data, and on one statement of a chain it approves that statement alone. + */ +export function commentOf(text: string): string { + let quote: '"' | "'" | undefined + for (let i = 0; i < text.length; i += 1) { + const c = text[i] ?? '' + if (quote !== undefined) { + if (c === quote) { + quote = undefined + } + continue + } + if (c === '"' || c === "'") { + quote = c + continue + } + if (c === '#' && (i === 0 || /\s/.test(text[i - 1] ?? ''))) { + return text.slice(i + 1) + } + } + return '' +} + +function isApprovedStatement(statement: Statement): boolean { + return commentOf(statement.text).includes(APPROVAL_MARKER) +} + +const PROTECTED_BRANCHES = new Set(['master', 'main']) + +type Found = Omit + +function pushViolations(args: string[], branch: string | undefined): Found[] { + const rest = args.slice(1) + const flags = rest.filter(w => w.startsWith('-')) + const positional = rest.filter(w => !w.startsWith('-')) + const out: Found[] = [] + const suggestion = branch && !PROTECTED_BRANCHES.has(branch) ? `git push -u origin ${branch}` : 'git push -u origin feature/' + + const isForce = + flags.some(f => f === '-f' || f === '--force' || f.startsWith('--force-with-lease') || f === '--force-if-includes') || + positional.slice(1).some(r => r.startsWith('+')) + if (isForce) { + out.push({ + rule: 'force-push', + isGated: true, + reason: 'A force push is gated in every context (CLAUDE.md, Committing and publishing): ask the user first.', + }) + } + + const destinations = positional.slice(1).map(r => { + const dest = r.includes(':') ? r.slice(r.lastIndexOf(':') + 1) : r + return dest.replace(/^\+/, '').replace(/^refs\/heads\//, '') + }) + const pushesHeadFromProtected = positional.length >= 2 && destinations.includes('HEAD') && branch !== undefined && PROTECTED_BRANCHES.has(branch) + if (destinations.some(d => PROTECTED_BRANCHES.has(d)) || pushesHeadFromProtected) { + out.push({ + rule: 'push-to-master', + isGated: true, + reason: 'Pushing to master is gated in every context (CLAUDE.md, Committing and publishing): work lands through a PR.', + }) + } + + const isWholeRepoForm = flags.some(f => f === '--tags' || f === '--all' || f === '--mirror' || f === '--delete' || f === '-d') + if (positional.length < 2 && !isWholeRepoForm) { + out.push({ + rule: 'bare-push', + isGated: false, + 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}\`.`, + }) + } + return out +} + +export function exeName(word: string | undefined): string { + return (word ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase() +} + +// Launchers that run the command after them, with the options of theirs that take a value. +// `env -S ''` splits its value into the command it runs. +const LAUNCHERS: Record = { + env: { withValue: ['-u', '--unset', '-C', '--chdir', '-P'] }, + timeout: { withValue: ['-s', '--signal', '-k', '--kill-after'], positional: 1 }, + nice: { withValue: ['-n', '--adjustment'] }, + time: { withValue: ['-o', '--output', '-f', '--format'] }, + call: { withValue: [] }, + command: { withValue: [] }, + builtin: { withValue: [] }, + exec: { withValue: ['-a'] }, +} + +/** + * A statement's words from the command it runs. Drops `&`, `VAR=value` assignments and the + * launchers above with their own options (`env -i -u X`, `timeout -s KILL 60`, `nice -n 5`), + * so `env -i GH_TOKEN=x gh pr merge` is still `gh pr merge`. Normalising only exposes the + * command that runs: it never removes a word the rules would have refused. + */ +export function commandWords(words: readonly string[]): string[] { + let rest = [...words] + for (;;) { + const w = rest[0] ?? '' + if (w === '&' || /^\w+=/.test(w)) { + rest = rest.slice(1) + continue + } + const launcher = LAUNCHERS[exeName(w)] + if (launcher === undefined) { + return rest + } + let i = 1 + while (i < rest.length && (rest[i] ?? '').startsWith('-') && rest[i] !== '-') { + const flag = rest[i] ?? '' + // env -S '' [more]: the string is split into env's own arguments, and may + // itself start with env options (`env -S '-i gh pr merge 1'` runs `gh pr merge 1`), so + // the expanded words go back through env, not straight to the command. + const split = + exeName(w) !== 'env' + ? undefined + : flag === '-S' || flag === '--split-string' + ? { value: rest[i + 1] ?? '', after: i + 2 } + : flag.startsWith('--split-string=') + ? { value: flag.slice('--split-string='.length), after: i + 1 } + : /^-S./.test(flag) + ? { value: flag.slice(2), after: i + 1 } + : undefined + if (split !== undefined) { + rest = [w, ...rest.slice(1, i), ...tokenize(split.value), ...rest.slice(split.after)] + i = -1 + break + } + i += launcher.withValue.includes(flag) ? 2 : 1 + } + if (i === -1) { + continue + } + if (rest[i] === '-') { + i += 1 + } + rest = rest.slice(i + (launcher.positional ?? 0)) + } +} + +// PowerShell options that take a value (the rest are switches). +const PWSH_WITH_VALUE = new Set([ + '-executionpolicy', '-ep', '-ex', '-workingdirectory', '-wd', '-windowstyle', '-w', '-configurationname', '-config', + '-outputformat', '-o', '-of', '-inputformat', '-if', '-in', '-version', '-v', '-settingsfile', '-custompipename', '-psconsolefile', +]) +// bash/sh options that take a value. +const SH_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']) +// A script that runs its own -Command parameter: build-lock.ps1 runs the build it is handed. +const RUNS_ITS_COMMAND = /^build-lock\.ps1$/i + +/** + * The command a shell wrapper runs, read from the wrapper's OWN options only (words after a + * script operand are the script's arguments, not the shell's): `pwsh/powershell -Command|-c + * `; `bash/sh/zsh/dash` with a single-dash flag group containing `c` (`-c`, `-lc`, `-ec`) + * then ``; `cmd /c|/k `. The one script whose `-Command` IS what runs is + * build-lock.ps1. Undefined when the statement is no such wrapper. + */ +export function innerCommand(words: readonly string[]): string | undefined { + const name = exeName(words[0]) + if (name === 'pwsh' || name === 'powershell') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + const lower = w.toLowerCase() + if (lower === '-command' || lower === '-c') { + return words.slice(i + 1).join(' ') + } + if (lower === '-file' || lower === '-f') { + const script = words[i + 1] ?? '' + if (!RUNS_ITS_COMMAND.test(script.replace(/^.*[\\/]/, ''))) { + return undefined + } + const at = words.findIndex((x, j) => j > i + 1 && /^-command$/i.test(x)) + return at >= 0 ? words[at + 1] : undefined + } + if (lower === '-encodedcommand' || lower === '-e' || lower === '-ec') { + return undefined + } + if (!w.startsWith('-')) { + // A bare operand: pwsh runs it as a script file, Windows PowerShell as a command. + return name === 'powershell' ? words.slice(i).join(' ') : undefined + } + if (PWSH_WITH_VALUE.has(lower)) { + i += 1 + } + } + return undefined + } + if (name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + if (w === '--' || w === '-') { + return undefined + } + if (SH_WITH_VALUE.has(w)) { + i += 1 + continue + } + if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) { + return words[i + 1] + } + if (!/^[-+]/.test(w)) { + // The script operand: every word after it is the script's. + return undefined + } + } + return undefined + } + if (name === 'cmd') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + if (/^\/[ck]$/i.test(w)) { + return words.slice(i + 1).join(' ') + } + if (!w.startsWith('/')) { + return undefined + } + } + return undefined + } + return undefined +} + +/** + * Every rule a command breaks. `branch` is the current branch, when known, for the + * suggestion and for `git push origin HEAD` from master. A shell wrapper is looked into: + * its own trailing comment approves what it runs. + */ +export function checkCommand(command: string, branch?: string): Violation[] { + const all: Violation[] = [] + for (const statement of splitStatements(command)) { + const out: Found[] = [] + const words = commandWords(statement.words) + const inner = innerCommand(words) + if (inner !== undefined) { + const outerApproved = isApprovedStatement(statement) + all.push(...checkCommand(inner, branch).map(v => ({ ...v, isApproved: v.isApproved || outerApproved }))) + continue + } + const args = gitArgs(words) + const sub = args?.[0] + + if (args !== undefined && sub === 'commit' && args.includes('--amend') && statement.separator !== 'start' && statement.separator !== '&&') { + out.push({ + rule: 'chained-amend', + isGated: false, + reason: + `\`git commit --amend\` runs after \`${statement.separator === '\n' ? 'a newline' : statement.separator}\`, so it runs even when the command before it failed. ` + + '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.', + }) + } + + if (args !== undefined && (sub === 'push' || sub === 'commit') && args.includes('--no-verify')) { + out.push({ + rule: 'no-verify', + isGated: true, + reason: 'Skipping hooks with --no-verify needs the user to have asked for it: fix what the hook reports instead.', + }) + } + + if (args !== undefined && sub === 'push') { + out.push(...pushViolations(args, branch)) + } + + if (args !== undefined && sub === 'reset' && args.includes('--hard')) { + out.push({ + rule: 'reset-hard', + isGated: true, + 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.', + }) + } + + const first = (words[0] ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '') + if (first === 'gh' && words[1] === 'pr' && words[2] === 'merge') { + out.push({ rule: 'pr-merge', isGated: true, reason: 'Merging a PR is the user\'s call in every context (CLAUDE.md).' }) + } + if (first === 'gh' && words[1] === 'issue' && words[2] === 'close') { + out.push({ + rule: 'issue-close', + isGated: true, + reason: 'Closing an issue is the user\'s call (CLAUDE.md): post the evidence, recommend closure, let the user close.', + }) + } + + if (statement.separator === '|' && /^(?:Set-Content|sc|Add-Content|ac|Out-File)$/i.test(words[0] ?? '') && words.some(w => /^-NoNewline$/i.test(w))) { + out.push({ + rule: 'set-content-nonewline', + isGated: false, + reason: + '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. ' + + 'It flattened a GLSL file and a scene here (#1360). Use Bash redirection (`git show :path > path`) or the Write/Edit tools.', + }) + } + + const approved = isApprovedStatement(statement) + all.push(...out.map(v => ({ ...v, isApproved: approved }))) + } + return all +} + +/** What the guard refuses: everything ungated, and a gated rule unless its own statement carries the marker. */ +export function refusals(command: string, branch?: string): Violation[] { + return checkCommand(command, branch).filter(v => !v.isGated || !v.isApproved) +} + +export function formatRefusal(violations: readonly Violation[]): string { + const gated = violations.some(v => v.isGated) + const lines = violations.map(v => `- ${v.reason}`) + if (gated) { + lines.push( + `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.`, + ) + } + return `olo-command-guards refused this command:\n${lines.join('\n')}` +} + +function isPublishing(s: Statement): boolean { + const words = commandWords(s.words) + const inner = innerCommand(words) + if (inner !== undefined) { + return publishes(inner) + } + const args = gitArgs(words) + return (args?.[0] === 'push' && !args.includes('--dry-run') && !args.includes('-n')) || (exeName(words[0]) === 'gh' && words[1] === 'pr' && words[2] === 'create') +} + +/** Does the command publish work (a push, a new PR)? Local checks must have finished first. */ +export function publishes(command: string): boolean { + return splitStatements(command).some(isPublishing) +} + +/** Every publishing statement carries the approval marker in its own (or its wrapper's) trailing comment. */ +export function publishingApproved(command: string): boolean { + const publishing = splitStatements(command).filter(isPublishing) + return ( + publishing.length > 0 && + publishing.every(s => { + if (isApprovedStatement(s)) { + return true + } + const inner = innerCommand(commandWords(s.words)) + return inner !== undefined && publishingApproved(inner) + }) + ) +} + +// ------------------------------------------------------------ timing runs --- + +const TIMING_FLAGS = /--olo-perf-rebase|--olo-capture-manifest|--olo-perf\b/ +const TIMING_FILTER = /--gtest_filter=\S*(?:Perf|Timing|Cost|Baseline|Benchmark|Budget|Throughput|Latency)/i + +/** A run whose numbers mean something only on a quiet GPU. */ +export function isTimingRun(command: string): boolean { + return /OloEngine-Tests|OloEditor|OloRuntime/i.test(command) && (TIMING_FLAGS.test(command) || TIMING_FILTER.test(command)) +} + +export type GpuSample = { clockMHz: number; powerW: number; utilization: number } + +/** Parses `nvidia-smi --query-gpu=clocks.gr,power.draw,utilization.gpu --format=csv,noheader,nounits`. */ +export function parseGpuSample(stdout: string): GpuSample | undefined { + const line = stdout.split(/\r?\n/).find(l => l.trim().length > 0) + if (line === undefined) { + return undefined + } + const [clock, power, util] = line.split(',').map(v => Number.parseFloat(v.trim())) + if (clock === undefined || power === undefined || util === undefined || [clock, power, util].some(Number.isNaN)) { + return undefined + } + return { clockMHz: clock, powerW: power, utilization: util } +} + +// Heavy processes that contend with a measurement, by image name. +const CONTENDERS = /^(?:OloEngine-Tests|OloEditor|OloRuntime|OloServer|ninja|lld-link|clang-cl|cl|link|MSBuild|blender)\.exe$/i + +/** Image names from `tasklist /FO CSV /NH` that contend with a measurement. */ +export function contenders(tasklistCsv: string): string[] { + const out = new Map() + for (const line of tasklistCsv.split(/\r?\n/)) { + const name = /^"([^"]+)"/.exec(line)?.[1] + if (name !== undefined && CONTENDERS.test(name)) { + out.set(name, (out.get(name) ?? 0) + 1) + } + } + return [...out].map(([name, count]) => (count > 1 ? `${name} x${count}` : name)) +} + +/** + * Whether the box looked busy before a timing run. At its 210 MHz floor the 4090 reads + * ~30% utilisation from the compositor alone, so utilisation is not the signal: the + * graphics clock (busy above ~1 GHz) and the power draw (busy above ~60 W) are. + * Memory: check-for-other-gpu-load-before-trusting-timings. + */ +export function loadNote(sample: GpuSample | undefined, others: string[]): string | undefined { + const busyGpu = sample !== undefined && (sample.clockMHz > 1000 || sample.powerW > 60) + if (!busyGpu && others.length === 0) { + return undefined + } + const parts = [ + busyGpu && sample ? `the GPU was busy before this run started (graphics clock ${sample.clockMHz} MHz, ${sample.powerW} W)` : '', + others.length > 0 ? `other heavy processes were running: ${others.join(', ')}` : '', + ].filter(Boolean) + return ( + `olo-command-guards: this is a timing run, and ${parts.join('; ')}. ` + + 'GPU and frame timings from it are contended: do not record or compare them as a baseline. Pixels and correctness results are unaffected. ' + + '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).' + ) +} + +// ----------------------------------------------------- local checks running --- + +// Not cmake: a configure is no check, and an IDE's cmake would pin the guard shut. +const LOCAL_CHECK_IMAGES = /^(?:ninja|OloEngine-Tests|clang-cl|lld-link|ctest)\.exe$/i + +export type ProcessRow = { name: string; pid: number; commandLine: string; executablePath: string } + +/** Parses the JSON of `Get-CimInstance Win32_Process | Select Name,ProcessId,CommandLine,ExecutablePath | ConvertTo-Json`. */ +export function parseProcessRows(json: string): ProcessRow[] { + if (json.trim().length === 0) { + return [] + } + const raw: unknown = JSON.parse(json) + const list = Array.isArray(raw) ? raw : [raw] + return list.flatMap(r => { + if (typeof r !== 'object' || r === null) { + return [] + } + const o = r as Record + return [ + { + name: String(o.Name ?? ''), + pid: Number(o.ProcessId ?? 0), + commandLine: String(o.CommandLine ?? ''), + executablePath: String(o.ExecutablePath ?? ''), + }, + ] + }) +} + +/** Builds and test runs that belong to the worktree at `root` (paths compared case-insensitively, either slash). */ +export function localChecksRunning(rows: readonly ProcessRow[], root: string): ProcessRow[] { + const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase() + const prefix = `${norm(root).replace(/\/$/, '')}/` + return rows.filter(r => LOCAL_CHECK_IMAGES.test(r.name) && (norm(r.commandLine).includes(prefix) || norm(r.executablePath).startsWith(prefix))) +} diff --git a/.claude/mods/olo-command-guards/hooks/hooks.json b/.claude/mods/olo-command-guards/hooks/hooks.json new file mode 100644 index 000000000..5fcba7ce3 --- /dev/null +++ b/.claude/mods/olo-command-guards/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.ts"] } diff --git a/.claude/mods/olo-command-guards/hooks/register.ts b/.claude/mods/olo-command-guards/hooks/register.ts new file mode 100644 index 000000000..f1e810b69 --- /dev/null +++ b/.claude/mods/olo-command-guards/hooks/register.ts @@ -0,0 +1,132 @@ +import type { EngineInterface, Register } from 'claude-code' + +import { + checkCommand, + contenders, + formatRefusal, + isTimingRun, + loadNote, + localChecksRunning, + parseGpuSample, + parseProcessRows, + publishes, + publishingApproved, + refusals, +} from './guards' + +const SHELL_TOOLS = ['Bash', 'PowerShell'] as const +const AUDIT_KEY = 'approved-gated-actions' +const AUDIT_KEEP = 200 + +const PROCESS_QUERY = + "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'\" " + + '| Select-Object Name,ProcessId,CommandLine,ExecutablePath | ConvertTo-Json -Compress' + +export const register: Register = on => { + on('tool.call', { tool: SHELL_TOOLS }, async ($, e, next) => { + const command = typeof e.command === 'string' ? e.command : '' + if (command.length === 0) { + return next(e) + } + + const branch = /\bpush\b/.test(command) ? await currentBranch($) : undefined + const refused = refusals(command, branch) + if (refused.length > 0) { + $.ui.toast(`olo-command-guards refused: ${refused.map(v => v.rule).join(', ')}`, { timeoutMs: 6000 }) + return { deny: formatRefusal(refused) } + } + + const approvedGated = checkCommand(command, branch).filter(v => v.isGated && v.isApproved) + if (approvedGated.length > 0) { + await audit($, command, approvedGated.map(v => v.rule)) + } + + // Memory no-pr-before-local-checks-finish: push and open the PR only once every + // local build and test run of this worktree has finished. + if (publishes(command) && !publishingApproved(command)) { + const running = await runningLocalChecks($) + if (running.length > 0) { + const list = running.map(r => `${r.name} (pid ${r.pid})`).join(', ') + return { + deny: + `olo-command-guards refused this command:\n- Local checks of this worktree are still running: ${list}. ` + + '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). ' + + 'Wait for them; draft the PR body meanwhile.', + } + } + } + + if (!isTimingRun(command)) { + return next(e) + } + const note = await loadBeforeTiming($) + const ran = await next(e) + return note === undefined || ran.deny !== undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] } + }).catch(($, e, next) => { + // The guard itself failed (a process query, a parse). Fall back to the pure text + // rules alone, which need nothing from the engine, rather than refusing every + // shell command or letting a gated one through unchecked. + if (next.called) { + return next(e) + } + const command = typeof e.command === 'string' ? e.command : '' + const refused = refusals(command) + return refused.length > 0 ? { deny: formatRefusal(refused) } : next(e) + }) +} + +async function currentBranch($: EngineInterface): Promise { + try { + const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 }) + return r.exitCode === 0 ? r.stdout.trim() : undefined + } catch { + return undefined + } +} + +async function runningLocalChecks($: EngineInterface) { + try { + const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }) + if (top.exitCode !== 0) { + return [] + } + const ps = await $.process.run(['pwsh', '-NoProfile', '-NonInteractive', '-Command', PROCESS_QUERY], { timeoutMs: 20_000 }) + if (ps.exitCode !== 0) { + return [] + } + return localChecksRunning(parseProcessRows(ps.stdout), top.stdout.trim()) + } catch { + // Fail open: a missing pwsh must not block every push. + return [] + } +} + +async function loadBeforeTiming($: EngineInterface): Promise { + try { + const [gpu, tasks] = await Promise.all([ + $.process.run(['nvidia-smi', '--query-gpu=clocks.gr,power.draw,utilization.gpu', '--format=csv,noheader,nounits'], { timeoutMs: 10_000 }), + $.process.run(['tasklist', '/FO', 'CSV', '/NH'], { timeoutMs: 10_000 }), + ]) + const sample = gpu.exitCode === 0 ? parseGpuSample(gpu.stdout) : undefined + const others = tasks.exitCode === 0 ? contenders(tasks.stdout) : [] + const note = loadNote(sample, others) + if (note !== undefined) { + $.ui.toast('olo-command-guards: timing run on a busy box; its timings are contended', { timeoutMs: 8000 }) + } + return note + } catch { + return undefined + } +} + +async function audit($: EngineInterface, command: string, rules: string[]): Promise { + try { + const at = new Date(await $.clock.now()).toISOString() + const previous = await $.store.get(AUDIT_KEY) + const list = Array.isArray(previous) ? previous : [] + await $.store.set(AUDIT_KEY, [...list, { at, rules, command: command.slice(0, 500) }].slice(-AUDIT_KEEP)) + $.ui.toast(`olo-command-guards: user-approved ${rules.join(', ')} (logged)`, { timeoutMs: 6000 }) + } catch { + // Logging is best effort; the approval itself stands. + } +} diff --git a/.claude/mods/olo-command-guards/tests/guards.test.ts b/.claude/mods/olo-command-guards/tests/guards.test.ts new file mode 100644 index 000000000..ccb1483c7 --- /dev/null +++ b/.claude/mods/olo-command-guards/tests/guards.test.ts @@ -0,0 +1,186 @@ +import { expect, test } from 'claude-code/testing' + +import { + checkCommand, + commandWords, + commentOf, + contenders, + isTimingRun, + loadNote, + localChecksRunning, + parseGpuSample, + parseProcessRows, + publishes, + publishingApproved, + refusals, + splitStatements, + tokenize, +} from '../hooks/guards' + +const rules = (command: string, branch?: string) => checkCommand(command, branch).map(v => v.rule) + +test('the statement splitter respects quotes and records separators', () => { + const s = splitStatements('git commit -m "a; b && c" && git push -u origin feature/x; echo done') + expect(s.map(x => x.separator)).toEqual(['start', '&&', ';']) + expect(s[0]?.words).toEqual(['git', 'commit', '-m', 'a; b && c']) + expect(tokenize("echo 'x y' # trailing")).toEqual(['echo', 'x y']) +}) + +test('commands this repo runs every day pass untouched', () => { + const ordinary = [ + 'git -C /c/repos/OloEngineBaseBase worktree remove /c/repos/OloEngine-foo', + 'git push -u origin feature/integrated-aaa-acceptance-scene-1259', + 'git -C "C:\\repos\\x" worktree add "C:\\repos\\OloEngine-y" -b feature/y --no-track origin/master', + 'git log --oneline origin/master..feature/x', + 'git commit --amend --no-edit', + 'git add -u && git commit --amend --no-edit', + 'gh issue edit 1359 --body-file body.md', + 'gh pr create --title t --body-file b.md', + 'cmake --build build-cached --target OloEngine-Tests 2>&1 | tail -5', + 'git show abc:path/File.glsl > path/File.glsl', + 'Get-Content x.txt | Select-String foo', + 'echo "git push" && grep -c "git reset --hard" notes.md', + ] + for (const command of ordinary) { + expect({ command, rules: rules(command, 'feature/x') }).toEqual({ command, rules: [] }) + } +}) + +test('a chained --amend after ; || or a newline is refused, && is fine', () => { + expect(rules('git revert -q abc ; git commit --amend -m x')).toEqual(['chained-amend']) + expect(rules('git revert abc || git commit --amend')).toEqual(['chained-amend']) + expect(rules('git revert abc\ngit commit --amend')).toEqual(['chained-amend']) + expect(rules('git revert --no-edit abc && git commit --amend -m x')).toEqual([]) +}) + +test('bare pushes are refused with the branch named; whole-repo forms are not bare', () => { + const [bare] = checkCommand('git push', 'feature/foo') + expect(bare?.rule).toBe('bare-push') + expect(bare?.reason).toContain('git push -u origin feature/foo') + expect(rules('git push origin')).toEqual(['bare-push']) + expect(rules('git push -u')).toEqual(['bare-push']) + expect(rules('git push origin --tags')).toEqual([]) + expect(rules('git push origin --delete feature/old')).toEqual([]) +}) + +test('pushes to master and force pushes are gated', () => { + expect(rules('git push origin master')).toEqual(['push-to-master']) + expect(rules('git push origin HEAD:master')).toEqual(['push-to-master']) + expect(rules('git push origin HEAD:refs/heads/main')).toEqual(['push-to-master']) + expect(rules('git push origin HEAD', 'master')).toEqual(['push-to-master']) + expect(rules('git push --force-with-lease origin feature/x')).toEqual(['force-push']) + expect(rules('git push origin +feature/x')).toEqual(['force-push']) + expect(checkCommand('git push -f origin master').every(v => v.isGated)).toBe(true) +}) + +test('other gated actions: reset --hard, --no-verify, gh pr merge, gh issue close', () => { + expect(rules('git reset --hard origin/master')).toEqual(['reset-hard']) + expect(rules('git commit --no-verify -m x')).toEqual(['no-verify']) + expect(rules('gh pr merge 1577 --squash')).toEqual(['pr-merge']) + expect(rules('gh issue close 1528 --reason completed')).toEqual(['issue-close']) +}) + +test('the approval marker lets gated actions through, never ungated ones', () => { + expect(refusals('gh issue close 1528 # OLO_USER_APPROVED')).toEqual([]) + expect(refusals('git push origin master # OLO_USER_APPROVED').map(v => v.rule)).toEqual([]) + expect(refusals('git push # OLO_USER_APPROVED').map(v => v.rule)).toEqual(['bare-push']) + expect(refusals('git revert x ; git commit --amend # OLO_USER_APPROVED').map(v => v.rule)).toEqual(['chained-amend']) +}) + +test('prefixes and shell wrappers do not hide a command from the rules', () => { + expect(rules('GH_TOKEN=x gh pr merge 1585')).toEqual(['pr-merge']) + expect(rules('env gh issue close 1')).toEqual(['issue-close']) + expect(rules('timeout 60 git push origin master')).toEqual(['push-to-master']) + expect(rules('pwsh -NoProfile -Command "git push origin master"')).toEqual(['push-to-master']) + expect(rules("bash -c 'git revert x ; git commit --amend'")).toEqual(['chained-amend']) + expect(rules('cmd /c git reset --hard')).toEqual(['reset-hard']) + // The wrapper's own trailing comment approves what it runs; a quoted mention does not. + expect(refusals('pwsh -Command "gh pr merge 1" # OLO_USER_APPROVED')).toEqual([]) + expect(refusals('pwsh -Command "gh pr merge 1 --body OLO_USER_APPROVED"').map(v => v.rule)).toEqual(['pr-merge']) + expect(publishes('GH_TOKEN=x gh pr create --fill')).toBe(true) + expect(publishes('pwsh -Command "git push -u origin feature/x"')).toBe(true) + expect(publishingApproved('pwsh -Command "git push -u origin feature/x" # OLO_USER_APPROVED')).toBe(true) + // Launcher options and combined shell flags. + expect(rules("bash -ec 'git reset --hard'")).toEqual(['reset-hard']) + expect(rules("bash -o pipefail -euxc 'gh pr merge 1'")).toEqual(['pr-merge']) + expect(rules('env -i gh pr merge 1')).toEqual(['pr-merge']) + expect(rules('env -u HOME -i GH_TOKEN=x gh issue close 1')).toEqual(['issue-close']) + expect(rules("env -S 'gh pr merge 1'")).toEqual(['pr-merge']) + expect(rules('timeout -s KILL 60 git push origin master')).toEqual(['push-to-master']) + expect(rules('nice -n 10 git reset --hard')).toEqual(['reset-hard']) + expect(rules('env')).toEqual([]) + // Options inside env -S's split string are env's own (GNU env), not the command. + expect(rules("env -S '-i gh pr merge 1'")).toEqual(['pr-merge']) + expect(rules("env -S '-u HOME GH_TOKEN=x git push origin master'")).toEqual(['push-to-master']) + expect(rules("env --split-string='-i git reset --hard'")).toEqual(['reset-hard']) + // Words after a script operand belong to the script, not to the shell. + expect(rules("bash deploy.sh -c 'git reset --hard'")).toEqual([]) + expect(rules("pwsh -File tools/x.ps1 -Command 'git reset --hard'")).toEqual([]) + expect(rules("bash -o pipefail -c 'git reset --hard'")).toEqual(['reset-hard']) + // build-lock.ps1 is the one script that runs its -Command. + expect(rules("pwsh -NoProfile -File .claude/skills/run-oloengine/build-lock.ps1 -Command 'git push origin master'")).toEqual(['push-to-master']) + // Windows PowerShell runs a bare operand as a command; pwsh runs it as a script file. + expect(rules("powershell 'git reset --hard'")).toEqual(['reset-hard']) + expect(commandWords(['env', '-i', '-u', 'X', 'A=1', 'git', 'status'])).toEqual(['git', 'status']) + // Ordinary wrapped commands stay quiet. + expect(rules('pwsh -NoProfile -File .claude/skills/run-oloengine/build-lock.ps1 -Command "cmake --build build-cached"')).toEqual([]) +}) + +test('the marker counts only in its own statement\'s trailing comment, never inside quotes', () => { + expect(refusals('gh issue close 1 --comment "OLO_USER_APPROVED"').map(v => v.rule)).toEqual(['issue-close']) + expect(refusals("gh issue close 1 --comment 'see # OLO_USER_APPROVED'").map(v => v.rule)).toEqual(['issue-close']) + expect(refusals('git push origin master -o "OLO_USER_APPROVED"').map(v => v.rule)).toEqual(['push-to-master']) + // One marker on a chain approves the statement it ends, not the ones before it. + expect(refusals('gh issue close 1 && git reset --hard # OLO_USER_APPROVED').map(v => v.rule)).toEqual(['issue-close']) + expect(commentOf('gh issue close 1 --comment "a # b" # OLO_USER_APPROVED')).toBe(' OLO_USER_APPROVED') + expect(publishingApproved('gh pr create --body "OLO_USER_APPROVED"')).toBe(false) + expect(publishingApproved('git push -u origin feature/x # OLO_USER_APPROVED')).toBe(true) +}) + +test('a pipe into Set-Content -NoNewline is refused; a plain Set-Content is not', () => { + expect(rules('git show abc:a.glsl | Set-Content -NoNewline a.glsl')).toEqual(['set-content-nonewline']) + expect(rules('Get-Content a | set-content -nonewline b')).toEqual(['set-content-nonewline']) + expect(rules("Set-Content -Path a.txt -Value 'x' -NoNewline")).toEqual([]) + expect(rules('Get-Content a | Set-Content b')).toEqual([]) +}) + +test('publishing commands are recognised; dry runs are not', () => { + expect(publishes('git push -u origin feature/x')).toBe(true) + expect(publishes('gh pr create --fill')).toBe(true) + expect(publishes('git push --dry-run origin feature/x')).toBe(false) + expect(publishes('gh pr view 12')).toBe(false) +}) + +test('timing runs are recognised by flag or filter', () => { + expect(isTimingRun('build\\OloEngine\\tests\\Debug\\OloEngine-Tests.exe --gtest_filter=FoliageCostBaselineTest.*')).toBe(true) + expect(isTimingRun('OloEngine-Tests.exe --olo-capture-manifest=m.json')).toBe(true) + expect(isTimingRun('OloEngine-Tests.exe --gtest_filter=MotionVectorMath.*')).toBe(false) + expect(isTimingRun('grep PerfRegression notes.md')).toBe(false) +}) + +test('GPU load: utilisation alone is not busy; clock or power is', () => { + const idle = parseGpuSample('210, 16.4, 31\n') + expect(idle).toEqual({ clockMHz: 210, powerW: 16.4, utilization: 31 }) + expect(loadNote(idle, [])).toBeUndefined() + expect(loadNote(parseGpuSample('2520, 180.2, 97'), [])).toContain('2520 MHz') + expect(loadNote(idle, ['ninja.exe'])).toContain('ninja.exe') + expect(parseGpuSample('')).toBeUndefined() +}) + +test('contenders are counted from tasklist CSV', () => { + const csv = ['"System","4","Services","0","1 K"', '"ninja.exe","10","Console","1","9 K"', '"ninja.exe","11","Console","1","9 K"', '"WowB.exe","12","Console","1","9 K"'].join('\r\n') + expect(contenders(csv)).toEqual(['ninja.exe x2']) +}) + +test('local checks are attributed to this worktree only, not a sibling with a longer name', () => { + const rows = parseProcessRows( + JSON.stringify([ + { Name: 'ninja.exe', ProcessId: 1, CommandLine: 'ninja -C C:\\repos\\OloEngine-foo\\build-cached', ExecutablePath: 'C:\\tools\\ninja.exe' }, + { Name: 'ninja.exe', ProcessId: 2, CommandLine: 'ninja -C C:\\repos\\OloEngine-foo-bar\\build-cached', ExecutablePath: 'C:\\tools\\ninja.exe' }, + { Name: 'OloEngine-Tests.exe', ProcessId: 3, CommandLine: 'x', ExecutablePath: 'C:\\repos\\OloEngine-foo\\build\\OloEngine-Tests.exe' }, + ]), + ) + expect(localChecksRunning(rows, 'C:/repos/OloEngine-foo').map(r => r.pid)).toEqual([1, 3]) + expect(parseProcessRows('')).toEqual([]) + expect(parseProcessRows(JSON.stringify({ Name: 'ctest.exe', ProcessId: 9, CommandLine: null, ExecutablePath: null }))[0]?.pid).toBe(9) +}) diff --git a/.claude/mods/olo-command-guards/tests/hooks.test.ts b/.claude/mods/olo-command-guards/tests/hooks.test.ts new file mode 100644 index 000000000..19c703bf4 --- /dev/null +++ b/.claude/mods/olo-command-guards/tests/hooks.test.ts @@ -0,0 +1,39 @@ +import { expect, mock, test } from 'claude-code/testing' + +// The test's tool.call hook plays the engine: any call that reaches it "ran". +const ran = { result: { stdout: 'ok', stderr: '', interrupted: false }, text: 'ok' } + +test('a push to master is refused before it runs', async ($, on) => { + let reached = false + on('tool.call', () => { + reached = true + return ran + }) + const r = await $.tool.call({ tool: 'Bash', command: 'git push origin master' }) + expect(reached).toBe(false) + expect(r.isError === true || r.deny !== undefined).toBe(true) + expect(String(r.deny ?? r.text)).toContain('Pushing to master is gated') +}) + +test('an approved gated action runs and is logged', async ($, on) => { + mock.clock(on, { now: Date.UTC(2026, 9, 10) }) + const written: unknown[] = [] + // The test's own store: empty on read, recording each write. + on('store.get', () => ({ value: undefined })) + on('store.set', ($, e) => { + written.push(e) + return { value: undefined } + }) + on('tool.call', () => ran) + const r = await $.tool.call({ tool: 'Bash', command: 'gh issue close 1528 --reason completed # OLO_USER_APPROVED' }) + expect(r.deny).toBeUndefined() + expect(r.text).toBe('ok') + expect(JSON.stringify(written)).toContain('approved-gated-actions') + expect(JSON.stringify(written)).toContain('issue-close') +}) + +test('an ordinary command passes straight through', async ($, on) => { + on('tool.call', () => ran) + const r = await $.tool.call({ tool: 'PowerShell', command: 'git status' }) + expect(r.text).toBe('ok') +}) diff --git a/.claude/mods/olo-context-saver/.claude-plugin/plugin.json b/.claude/mods/olo-context-saver/.claude-plugin/plugin.json new file mode 100644 index 000000000..b82ca1cda --- /dev/null +++ b/.claude/mods/olo-context-saver/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "olo-context-saver", + "version": "0.1.0", + "description": "Collapses ninja progress lines and passing gtest RUN/OK pairs in large OloEngine build and test logs before they are stored in the conversation; every warning, error and failure stays verbatim.", + "author": { + "name": "OloEngine" + } +} diff --git a/.claude/mods/olo-context-saver/hooks/compress.ts b/.claude/mods/olo-context-saver/hooks/compress.ts new file mode 100644 index 000000000..98553ccb7 --- /dev/null +++ b/.claude/mods/olo-context-saver/hooks/compress.ts @@ -0,0 +1,79 @@ +// Collapses the lines of a big build or test log that carry no information once you know +// how many there were: ninja's progress lines and gtest's passing RUN/OK pairs. Every other +// line (warnings, errors, failures, a test's own output, summaries) is kept verbatim and +// in order, so nothing a reader acts on is lost. + +export const MIN_CHARS = 8000 +const MIN_SAVING = 0.25 + +const GUTTER = /^ *\d+\t/ +const NINJA_PROGRESS = /^\[\d+\/\d+\] (?:Building|Linking|Generating|Running|Copying|Automatic|Re-running|Scanning|Creating|Install|Performing|No install)\b/ +const GTEST_RUN = /^\[ RUN \] (\S+)/ +const GTEST_OK = /^\[ OK \] (\S+)/ +const GTEST_SEPARATOR = /^\[----------\] / + +export type Compressed = { text: string; progress: number; passing: number; separators: number } + +export function compressLog(text: string): Compressed | undefined { + if (text.length < MIN_CHARS) { + return undefined + } + const lines = text.split('\n') + // Matched without CR, Read's line gutter and ANSI colour (gtest colours its markers); the + // lines kept are written back as they came. + const bare = lines.map(l => l.replace(/\r$/, '').replace(GUTTER, '').replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')) + const out: string[] = [] + let progress = 0 + let passing = 0 + let separators = 0 + + for (let i = 0; i < lines.length; i += 1) { + const line = bare[i] ?? '' + + // A run of ninja progress lines: keep the first and the last, count the middle. + if (NINJA_PROGRESS.test(line)) { + let j = i + while (j + 1 < lines.length && NINJA_PROGRESS.test(bare[j + 1] ?? '')) { + j += 1 + } + const runLength = j - i + 1 + if (runLength > 2) { + out.push(lines[i] ?? '', `… ${runLength - 2} ninja progress lines …`, lines[j] ?? '') + progress += runLength - 2 + } else { + out.push(...lines.slice(i, j + 1)) + } + i = j + continue + } + + // A test that passed with no output of its own: RUN immediately followed by its OK. + const run = GTEST_RUN.exec(line) + if (run !== null && GTEST_OK.exec(bare[i + 1] ?? '')?.[1] === run[1]) { + passing += 1 + i += 1 + continue + } + + if (GTEST_SEPARATOR.test(line)) { + separators += 1 + continue + } + + out.push(lines[i] ?? '') + } + + // Say where the collapsed passing tests were, once, not per line. + const removedLines = progress + passing * 2 + separators + if (removedLines === 0) { + return undefined + } + const parts = [ + progress > 0 ? `${progress} ninja progress lines` : '', + passing > 0 ? `${passing} passing tests (their RUN/OK lines)` : '', + separators > 0 ? `${separators} gtest suite separators` : '', + ].filter(Boolean) + out.push(`[olo-context-saver: collapsed ${parts.join(', ')}; every warning, error, failure and summary line above is verbatim]`) + const result = out.join('\n') + return result.length <= text.length * (1 - MIN_SAVING) ? { text: result, progress, passing, separators } : undefined +} diff --git a/.claude/mods/olo-context-saver/hooks/hooks.json b/.claude/mods/olo-context-saver/hooks/hooks.json new file mode 100644 index 000000000..5fcba7ce3 --- /dev/null +++ b/.claude/mods/olo-context-saver/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.ts"] } diff --git a/.claude/mods/olo-context-saver/hooks/register.ts b/.claude/mods/olo-context-saver/hooks/register.ts new file mode 100644 index 000000000..8af5e773c --- /dev/null +++ b/.claude/mods/olo-context-saver/hooks/register.ts @@ -0,0 +1,51 @@ +import type { Register } from 'claude-code' + +import { compressLog } from './compress' + +// Tool results that carry build and test logs. +const LOG_TOOLS = new Set(['Bash', 'PowerShell', 'Read']) + +type TextBlock = { type: 'text'; text: string } +type ToolResultBlock = { type: 'tool_result'; tool_use_id?: string; content?: string | TextBlock[]; is_error?: boolean } + +export const register: Register = on => { + // The row a tool result is stored as: what the model reads from now on. Hooks on + // tool.call (olo-output-truth among them) have already seen the full text by then. + on('session.append', ($, e, next) => { + const origin = e.origin as { kind?: string; tool?: string } + if (e.door !== 'tool-result' || origin.kind !== 'tool' || !LOG_TOOLS.has(String(origin.tool))) { + return next(e) + } + let changed = false + const content = e.message.content.map(block => { + if (block.type !== 'tool_result') { + return block + } + const result = block as ToolResultBlock + if (typeof result.content === 'string') { + const squeezed = compressLog(result.content) + if (squeezed === undefined) { + return block + } + changed = true + return { ...result, content: squeezed.text } + } + if (Array.isArray(result.content)) { + const parts = result.content.map(part => { + if (part.type !== 'text') { + return part + } + const squeezed = compressLog(part.text) + if (squeezed === undefined) { + return part + } + changed = true + return { ...part, text: squeezed.text } + }) + return { ...result, content: parts } + } + return block + }) + return changed ? next({ ...e, message: { ...e.message, content: content as typeof e.message.content } }) : next(e) + }) +} diff --git a/.claude/mods/olo-context-saver/tests/compress.test.ts b/.claude/mods/olo-context-saver/tests/compress.test.ts new file mode 100644 index 000000000..cad8d929b --- /dev/null +++ b/.claude/mods/olo-context-saver/tests/compress.test.ts @@ -0,0 +1,88 @@ +import { expect, mock, test } from 'claude-code/testing' + +import { compressLog, MIN_CHARS } from '../hooks/compress' + +const progress = (n: number) => + Array.from({ length: n }, (_, i) => `[${i + 1}/${n}] Building CXX object OloEngine/CMakeFiles/OloEngine.dir/src/OloEngine/Renderer/File${i}.cpp.obj`) + +const passingTests = (n: number) => + Array.from({ length: n }, (_, i) => [`[ RUN ] Suite.Case${i}`, `[ OK ] Suite.Case${i} (${i} ms)`]).flat() + +test('short output is left alone', () => { + expect(compressLog('[1/2] Building x\n[2/2] Linking y')).toBeUndefined() +}) + +test('ninja progress collapses, warnings between runs stay verbatim and in place', () => { + const log = [...progress(150), 'C:/x/Foo.cpp(12,3): warning: unused variable \'a\' [-Wunused-variable]', ...progress(150), 'ninja: build stopped: subcommand failed.'].join('\n') + expect(log.length).toBeGreaterThan(MIN_CHARS) + const out = compressLog(log) + expect(out?.progress).toBe(296) + const lines = out?.text.split('\n') ?? [] + expect(lines).toContain("C:/x/Foo.cpp(12,3): warning: unused variable 'a' [-Wunused-variable]") + expect(lines).toContain('ninja: build stopped: subcommand failed.') + expect(lines.indexOf("C:/x/Foo.cpp(12,3): warning: unused variable 'a' [-Wunused-variable]")).toBe(3) +}) + +test('passing tests collapse; failures, their output and summaries stay', () => { + const log = [ + 'Note: Google Test filter = Suite.*', + '[==========] Running 202 tests from 1 test suite.', + '[----------] 202 tests from Suite', + ...passingTests(100), + '[ RUN ] Suite.Broken', + 'x.cpp(10): error: Expected equality of these values', + '[ FAILED ] Suite.Broken (3 ms)', + '[ RUN ] Suite.Chatty', + 'some output the test printed', + '[ OK ] Suite.Chatty (1 ms)', + ...passingTests(100).map(l => l.replace('Case', 'Late')), + '[----------] 202 tests from Suite (900 ms total)', + '[==========] 202 tests from 1 test suite ran. (900 ms total)', + '[ PASSED ] 201 tests.', + '[ FAILED ] 1 test, listed below:', + '[ FAILED ] Suite.Broken', + ].join('\n') + const out = compressLog(log) + expect(out?.passing).toBe(200) + for (const kept of [ + '[==========] Running 202 tests from 1 test suite.', + 'x.cpp(10): error: Expected equality of these values', + '[ FAILED ] Suite.Broken (3 ms)', + 'some output the test printed', + '[ OK ] Suite.Chatty (1 ms)', + '[ PASSED ] 201 tests.', + '[ FAILED ] Suite.Broken', + ]) { + expect(out?.text).toContain(kept) + } + expect(out?.text).not.toContain('Suite.Case5 ') +}) + +test('a Read listing with line numbers is recognised', () => { + const log = progress(300).map((l, i) => `${String(i + 1).padStart(6)}\t${l}`).join('\n') + expect(compressLog(log)?.progress).toBe(298) +}) + +test('a big log with nothing to collapse is left alone', () => { + const log = Array.from({ length: 400 }, (_, i) => `C:/x/F${i}.cpp(1): warning: something worth reading number ${i}`).join('\n') + expect(compressLog(log)).toBeUndefined() +}) + +test('the stored tool-result row is rewritten; other tools and doors are not', async ($, on) => { + mock.session(on) + const big = progress(400).join('\n') + const row = (tool: string, door: 'tool-result' | 'note', uuid: string) => + $.session.append({ + door, + origin: { kind: 'tool', tool }, + uuid, + message: { type: 'user', role: 'user', content: [{ type: 'tool_result', tool_use_id: `t-${uuid}`, content: big }] }, + } as Parameters[0]) + + const bash = JSON.stringify(await row('Bash', 'tool-result', 'a')) + expect(bash).toContain('ninja progress lines') + expect(bash.length).toBeLessThan(big.length / 4) + + const grep = JSON.stringify(await row('Grep', 'tool-result', 'b')) + expect(grep).not.toContain('ninja progress lines') +}) diff --git a/.claude/mods/olo-evidence/.claude-plugin/plugin.json b/.claude/mods/olo-evidence/.claude-plugin/plugin.json new file mode 100644 index 000000000..c2bbd8fe1 --- /dev/null +++ b/.claude/mods/olo-evidence/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "olo-evidence", + "version": "0.1.0", + "description": "Records every OloEngine build, test run and visual check per branch, shows it with /evidence, and refuses `gh pr create` when the record does not back the PR (failing or unfinished runs, unbacked Debug/Release claims, renderer changes with no frame looked at).", + "author": { + "name": "OloEngine" + } +} diff --git a/.claude/mods/olo-evidence/hooks/hooks.json b/.claude/mods/olo-evidence/hooks/hooks.json new file mode 100644 index 000000000..5fcba7ce3 --- /dev/null +++ b/.claude/mods/olo-evidence/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.ts"] } diff --git a/.claude/mods/olo-evidence/hooks/ledger.ts b/.claude/mods/olo-evidence/hooks/ledger.ts new file mode 100644 index 000000000..b20ff9961 --- /dev/null +++ b/.claude/mods/olo-evidence/hooks/ledger.ts @@ -0,0 +1,433 @@ +// Pure core of the evidence ledger: recognising build and test invocations, reading their +// outcome from a log, and judging a branch's evidence against what a PR claims. + +import type { EvidenceLedger, EvidenceOutcome, EvidenceRun, EvidenceRunKind, EvidenceVisual } from '../types' +import { commandWords, commentOf, exeName, innerCommand, splitStatements, type Statement } from './shell' + +// ------------------------------------------------------------ invocations --- + +export type Invocation = { kind: EvidenceRunKind; config: string; scope: string } + +const CONFIGS = /^(?:Debug|Release|RelWithDebInfo|MinSizeRel|Dist)$/i + +function cap(s: string): string { + return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1).toLowerCase() +} + +/** The word after `flag`, or the value of `flag=value`. */ +function flagValue(words: string[], ...flags: string[]): string | undefined { + for (let i = 0; i < words.length; i += 1) { + const w = words[i] ?? '' + for (const f of flags) { + if (w === f) { + return words[i + 1] + } + if (w.startsWith(`${f}=`)) { + return w.slice(f.length + 1) + } + } + } + return undefined +} + +function config(value: string | undefined): string { + return value !== undefined && CONFIGS.test(value) ? cap(value) : '?' +} + +/** + * Every build and test run a shell command starts (a `build && test` line is two). Only a + * statement whose COMMAND is the build or test counts: `rg OloEngine-Tests`, `git log --grep + * ctest` or a PR body naming the exe start nothing, and `--target OloEngine-Tests` is a target. + * A shell wrapper (`pwsh -Command`, build-lock.ps1's form, `bash -c`, `cmd /c`) is looked into. + */ +export function parseInvocations(command: string): Invocation[] { + const out: Invocation[] = [] + for (const statement of splitStatements(command)) { + const words = commandWords(statement.words) + const name = exeName(words[0]) + const inner = innerCommand(words) + + if (inner !== undefined) { + out.push(...parseInvocations(inner)) + } else if (name === 'cmake' && words[1] === '--build') { + const tree = (words[2] ?? '').replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() ?? '' + const at = words.indexOf('--target') + const targets: string[] = [] + for (let i = at + 1; at >= 0 && i < words.length && !(words[i] ?? '-').startsWith('-'); i += 1) { + targets.push(words[i] ?? '') + } + out.push({ kind: 'build', config: config(flagValue(words, '--config')), scope: `${tree}${targets.length > 0 ? ` ${targets.join(' ')}` : ''}` }) + } else if (name === 'oloengine-tests') { + if (words.includes('--gtest_list_tests')) { + continue + } + // A path segment names the config (build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe). + const fromPath = /[\\/](Debug|Release|RelWithDebInfo|MinSizeRel|Dist)[\\/][^\\/]*$/i.exec(words[0] ?? '')?.[1] + out.push({ kind: 'test', config: config(fromPath), scope: flagValue(words, '--gtest_filter') ?? '(all)' }) + } else if (name === 'ctest') { + if (words.includes('-N') || words.includes('--show-only')) { + continue + } + const regex = flagValue(words, '-R', '--tests-regex') + out.push({ kind: 'test', config: config(flagValue(words, '-C', '--build-config')), scope: regex ? `ctest -R ${regex}` : 'ctest (all)' }) + } + } + return out +} + +/** Where a command sends its output: the last file redirect or tee target. */ +export function redirectTarget(command: string): string | null { + const targets: string[] = [] + for (const m of command.matchAll(/(?:^|[^0-9&>])(?:\*|[12])?>>?\s*("?)([^\s"|;&<>]+)\1/g)) { + targets.push(m[2] ?? '') + } + for (const m of command.matchAll(/\|\s*(?:tee(?:\s+-a)?|Tee-Object(?:\s+-(?:FilePath|Append))*|Out-File(?:\s+-(?:FilePath|Append|Encoding\s+\S+))*)\s+("?)([^\s"|;&<>]+)\1/gi)) { + targets.push(m[2] ?? '') + } + const real = targets.filter(t => t.length > 0 && !/^(?:&\d|\/dev\/null|\$null|nul)$/i.test(t)) + return real.length === 0 ? null : real[real.length - 1]! +} + +/** A backgrounded shell command's output file, from the tool's own reply. */ +export function backgroundOutputPath(toolText: string): string | null { + return /Output is being written to:\s*(\S+\.output)/.exec(toolText)?.[1] ?? null +} + +// ---------------------------------------------------------------- outcome --- + +const NINJA_STOPPED = /^ninja: build stopped: (.*)$/m +const NINJA_FAILED = /^FAILED: /m +const MSBUILD_FAILED = /^\s*Build FAILED\.\s*$|^\s*[1-9]\d* Error\(s\)\s*$/m +const BUILD_DONE = /^\[build-lock\] released|^ninja: no work to do\.|^\s*Build succeeded\.|^\[(\d+)\/\1\] Linking/m +const GTEST_HEADER = /^\[==========\] Running (\d+) tests? from \d+ test suites?\./gm +const GTEST_DONE = /^\[==========\] \d+ tests? from \d+ test suites? ran\./m + +function unique(items: string[]): string[] { + return [...new Set(items)] +} + +export function buildOutcome(log: string): EvidenceOutcome { + const stopped = NINJA_STOPPED.exec(log) + if (stopped || NINJA_FAILED.test(log) || MSBUILD_FAILED.test(log)) { + const first = log.split('\n').find(l => /: (?:fatal )?error[: ]|error (?:C|LNK)\d{4}/.test(l))?.trim() + return { state: 'failed', detail: first ? `failed: ${first.slice(0, 160)}` : `failed${stopped ? `: ${stopped[1]}` : ''}`, failed: [], ran: 0, skipped: 0 } + } + if (BUILD_DONE.test(log)) { + return { state: 'passed', detail: 'built', failed: [], ran: 0, skipped: 0 } + } + return { state: /\[build-lock\] (?:acquired|waiting|queued)|^\[\d+\/\d+\]/m.test(log) ? 'running' : 'unknown', detail: 'no result in the log yet', failed: [], ran: 0, skipped: 0 } +} + +const CTEST_SUMMARY = /^\s*(\d+)% tests passed, (\d+) tests? failed out of (\d+)/m + +/** ctest's own summary, which it prints without -V where no gtest header appears. */ +function ctestOutcome(log: string): EvidenceOutcome | undefined { + const m = CTEST_SUMMARY.exec(log) + if (m === null) { + return undefined + } + const failedCount = Number(m[2]) + const total = Number(m[3]) + const list = log.slice(log.indexOf('The following tests FAILED:')) + const names = failedCount > 0 ? unique([...list.matchAll(/^\s*\d+ - (\S+) \(/gm)].map(x => x[1] ?? '')) : [] + if (total === 0) { + return { state: 'failed', detail: 'ctest ran no tests', failed: [], ran: 0, skipped: 0 } + } + return failedCount > 0 + ? { state: 'failed', detail: `${failedCount} failed of ${total} (ctest)`, failed: names, ran: total, skipped: 0 } + : { state: 'passed', detail: `${total} passed (ctest)`, failed: [], ran: total, skipped: 0 } +} + +export function testOutcome(log: string): EvidenceOutcome { + if (/^No tests were found!!!\s*$/m.test(log)) { + return { state: 'failed', detail: 'ctest found no tests', failed: [], ran: 0, skipped: 0 } + } + const ctest = ctestOutcome(log) + if (ctest !== undefined) { + return ctest + } + const starts = [...log.matchAll(GTEST_HEADER)] + if (starts.length === 0) { + return { state: 'unknown', detail: 'no gtest output in the log', failed: [], ran: 0, skipped: 0 } + } + let ran = 0 + let skipped = 0 + let zero = 0 + let open = false + const failed: string[] = [] + starts.forEach((m, i) => { + const seg = log.slice(m.index ?? 0, starts[i + 1]?.index ?? log.length) + if (Number(m[1]) === 0) { + zero += 1 + return + } + const runs = unique([...seg.matchAll(/^\[ RUN \] (\S+)/gm)].map(x => x[1] ?? '')) + ran += runs.length + skipped += unique([...seg.matchAll(/^\[ SKIPPED \] (\S+)/gm)].map(x => x[1] ?? '')).filter(n => runs.includes(n)).length + failed.push(...[...seg.matchAll(/^\[ FAILED \] ([^\s,]+\.[^\s,]+)/gm)].map(x => x[1] ?? '')) + if (!GTEST_DONE.test(seg)) { + open = true + } + }) + const names = unique(failed) + if (zero > 0 && ran === 0) { + return { state: 'failed', detail: 'the filter selected no tests', failed: [], ran: 0, skipped: 0 } + } + if (names.length > 0) { + return { state: 'failed', detail: `${names.length} failed of ${ran}`, failed: names, ran, skipped } + } + if (open) { + return { state: 'running', detail: `ended inside a test after ${ran} (still running, or it died)`, failed: [], ran, skipped } + } + if (ran > 0 && skipped === ran) { + return { state: 'failed', detail: `all ${ran} skipped: nothing verified`, failed: [], ran, skipped } + } + return { state: 'passed', detail: `${ran - skipped} passed${skipped > 0 ? `, ${skipped} skipped` : ''}`, failed: [], ran, skipped } +} + +/** NULs (the engine logger), CRs and ANSI colour sequences (gtest with --gtest_color) out. */ +export function cleanLog(text: string): string { + return text + .replace(/\u0000/g, '') + .replace(/\u001b\[[0-9;]*[A-Za-z]/g, '') + .replace(/\r\n?/g, '\n') +} + +export function outcomeOf(kind: EvidenceRunKind, log: string): EvidenceOutcome { + const clean = cleanLog(log) + return kind === 'build' ? buildOutcome(clean) : testOutcome(clean) +} + +/** Does this output carry a result at all (so it is worth keeping as the captured outcome)? */ +export function carriesResult(kind: EvidenceRunKind, text: string): boolean { + return outcomeOf(kind, text).state !== 'unknown' +} + +// ---------------------------------------------------------------- visuals --- + +// No leading \b: an MCP tool name puts `__` (word characters) right before `olo_`. +const LIVE_CAPTURE = /olo_(?:screenshot|render_capture_target|render_probe_pixel|probe_pixel|render_frame_breakdown)\b/ +const LIVE_CHECK = /olo_(?:shader_errors|log_tail)\b/ + +/** What a tool call contributes to the visual-evidence record, if anything. */ +export function visualFrom(tool: string, input: { file_path?: string; command?: string }): EvidenceVisual['kind'] | null { + if (tool === 'Read' && typeof input.file_path === 'string' && /\.png$/i.test(input.file_path)) { + return 'png-viewed' + } + const text = `${tool} ${input.command ?? ''}` + if (LIVE_CAPTURE.test(text)) { + return 'live-capture' + } + if (LIVE_CHECK.test(text)) { + return 'live-check' + } + return null +} + +// --------------------------------------------------------------- judgement --- + +export type Judged = { run: EvidenceRun; outcome: EvidenceOutcome } + +/** The outcome a run stands at: its log when that can be read, else what was captured. */ +export function judge(run: EvidenceRun, log: string | undefined): Judged { + const fromLog = log === undefined ? undefined : outcomeOf(run.kind, log) + const outcome = + fromLog !== undefined && fromLog.state !== 'unknown' + ? fromLog + : run.captured ?? { state: 'unknown' as const, detail: log === undefined ? 'no output recorded' : 'no result in its output', failed: [], ran: 0, skipped: 0 } + return { run, outcome } +} + +export function runKey(run: EvidenceRun): string { + return `${run.kind}|${run.config}|${run.scope}` +} + +/** The latest run of each distinct (kind, config, scope): earlier ones are superseded. */ +export function latestPerKey(judged: readonly Judged[]): Judged[] { + const latest = new Map() + for (const j of [...judged].sort((a, b) => a.run.at - b.run.at)) { + latest.set(runKey(j.run), j) + } + return [...latest.values()] +} + +const RENDER_PATH = /^(?:OloEngine\/src\/OloEngine\/(?:Renderer|RHI)\/|OloEngine\/src\/Platform\/(?:OpenGL|Vulkan)\/|OloEditor\/assets\/shaders\/)|\.(?:glsl|vert|frag|comp|geom|tesc|tese|mesh|task|rgen|rchit|rahit|rmiss)$/ +const CODE_PATH = /\.(?:cpp|h|hpp|inl|cs|glsl|vert|frag|comp|cmake)$|CMakeLists\.txt$/ +const SAYS_NO_FRAME = /could ?not inspect a frame|cannot inspect a frame|no frame could be inspected|not visually verified|could not run the editor/i + +export const LEDGER_MARKER = 'OLO_LEDGER_INCOMPLETE' +export const APPROVAL_MARKER = 'OLO_USER_APPROVED' + +export type PrProblem = { rule: string; message: string; waivedBy: string[] } + +/** + * What stands between this branch's evidence and the PR. `body` is the PR text ('' when + * unknown), `changed` the files the branch changes against master. + */ +export function prProblems(judged: readonly Judged[], visuals: readonly EvidenceVisual[], changed: readonly string[], body: string): PrProblem[] { + const problems: PrProblem[] = [] + const latest = latestPerKey(judged) + const codeChanged = changed.some(f => CODE_PATH.test(f)) + + // A run counts as evidence only once its outcome is known: one whose log is gone, or that + // printed no result, proves nothing either way. + const known = judged.filter(j => j.outcome.state !== 'unknown') + if (codeChanged && known.length === 0) { + problems.push({ + rule: 'no-runs', + message: + judged.length === 0 + ? 'The branch changes code, and no local build or test run is recorded for it. Build and run the relevant tests first (CLAUDE.md, Definition of done).' + : 'The branch changes code, and no recorded build or test run has a known outcome (their logs are gone or printed no result). Run them again so the result is read.', + waivedBy: [LEDGER_MARKER, APPROVAL_MARKER], + }) + } + + for (const { run, outcome } of latest) { + if (outcome.state === 'unknown' && known.length > 0) { + problems.push({ + rule: 'unproven-run', + message: `The latest ${run.kind} ${run.config} ${run.scope} has no known outcome (${outcome.detail}). Re-run it, or say in the PR body where its result is.`, + waivedBy: [LEDGER_MARKER, APPROVAL_MARKER], + }) + } + if (outcome.state === 'running') { + problems.push({ + rule: 'still-running', + message: `${run.kind} ${run.config} ${run.scope} has not finished (${outcome.detail}). Push and open the PR only after every local check has finished.`, + waivedBy: [APPROVAL_MARKER], + }) + } + if (outcome.state === 'failed') { + const unexplained = outcome.failed.filter(name => !body.includes(name)) + const explained = outcome.failed.length > 0 && unexplained.length === 0 + if (!explained) { + problems.push({ + rule: 'failed-run', + message: + `The latest ${run.kind} ${run.config} ${run.scope} ${outcome.detail}` + + `${unexplained.length > 0 ? ` (${unexplained.slice(0, 6).join(', ')})` : ''}. Re-run it green, or, if a failure is pre-existing and unrelated, name each failing test in the PR body with its evidence.`, + waivedBy: [APPROVAL_MARKER], + }) + } + } + } + + for (const config of ['Debug', 'Release'] as const) { + if (claims(body, config) && !latest.some(j => j.run.config === config && j.outcome.state === 'passed')) { + problems.push({ + rule: `claims-${config.toLowerCase()}`, + message: `The PR body claims a ${config} result, but no ${config} build or test run passed in the record. A claim needs its run (memory: no-pr-before-local-checks-finish).`, + waivedBy: [LEDGER_MARKER, APPROVAL_MARKER], + }) + } + } + + if (changed.some(f => RENDER_PATH.test(f)) && !SAYS_NO_FRAME.test(body)) { + const missing = [ + visuals.some(v => v.kind === 'png-viewed') ? '' : 'no evidence PNG was looked at (Read it)', + visuals.some(v => v.kind === 'live-capture') ? '' : 'no live editor capture (olo_screenshot / olo_render_capture_target)', + ].filter(Boolean) + if (missing.length > 0) { + problems.push({ + rule: 'visual-evidence', + message: + `The branch changes renderer or shader code, and ${missing.join(' and ')}. CLAUDE.md: a rendering change is not done on unit tests alone. ` + + 'If you cannot inspect a frame, say so in the PR body ("could not inspect a frame").', + waivedBy: [APPROVAL_MARKER], + }) + } + } + return problems +} + +// A claim is a line that names the configuration AND a verdict; naming Debug in prose +// ("a Debug-only failure would cost a CI round") claims nothing. +const VERDICT = /\b(?:pass(?:ed|es|ing)?|green|verified|ran|built|succeeded)\b|✅|\b0 fail/i + +export function claims(body: string, config: 'Debug' | 'Release'): boolean { + const named = new RegExp(`\\b${config}\\b`) + return body.split('\n').some(line => named.test(line) && VERDICT.test(line)) +} + +/** + * The problems no marker waives. `markers` is the trailing comment of the `gh pr` statement + * (see prStatement): a marker inside a quoted PR body or another statement is data. + */ +export function unwaived(problems: readonly PrProblem[], markers: string): PrProblem[] { + return problems.filter(p => !p.waivedBy.some(marker => markers.includes(marker))) +} + +/** + * The `gh pr create` (or `gh pr edit` that sets the body) statement of a command, if any: + * through prefixes (`GH_TOKEN=x`, `env`) and shell wrappers (`pwsh -Command`, `bash -c`, + * `cmd /c`). Its words are the gh command's own; its comment is the gh statement's plus the + * wrapper's, so a marker on either counts and a quoted one on neither. + */ +export function prStatement(command: string): Statement | undefined { + for (const s of splitStatements(command)) { + const words = commandWords(s.words) + const inner = innerCommand(words) + if (inner !== undefined) { + const found = prStatement(inner) + if (found !== undefined) { + return { ...found, text: `${found.text} # ${commentOf(s.text)}` } + } + continue + } + if (exeName(words[0]) !== 'gh' || words[1] !== 'pr') { + continue + } + if (words[2] === 'create' || (words[2] === 'edit' && words.some(x => /^(?:--body|--body-file|-b|-F)(?:=|$)/.test(x)))) { + return { ...s, words } + } + } + return undefined +} + +/** Where that statement's PR body comes from: a file, inline text, or neither. */ +export function prBodySource(statement: Statement): { file?: string; inline?: string } { + const file = flagValue(statement.words, '--body-file', '-F') + if (file !== undefined) { + return { file } + } + const inline = flagValue(statement.words, '--body', '-b') + return inline !== undefined ? { inline } : {} +} + +export function markersOf(statement: Statement): string { + return commentOf(statement.text) +} + +// ---------------------------------------------------------------- display --- + +function ago(at: number, now: number): string { + const min = Math.max(0, Math.round((now - at) / 60_000)) + return min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago` +} + +const MARK: Record = { passed: 'PASS', failed: 'FAIL', running: 'RUNNING', unknown: '?' } + +export function summaryMarkdown(judged: readonly Judged[], visuals: readonly EvidenceVisual[], problems: readonly PrProblem[], branch: string, now: number): string { + const lines = [`**Evidence for \`${branch}\`**`, ''] + const latest = new Set(latestPerKey(judged)) + const recent = [...judged].sort((a, b) => b.run.at - a.run.at).slice(0, 25) + if (recent.length === 0) { + lines.push('- no build or test run recorded yet') + } + for (const j of recent) { + const superseded = latest.has(j) ? '' : ' (superseded)' + lines.push(`- ${MARK[j.outcome.state]} ${j.run.kind} ${j.run.config} ${j.run.scope}: ${j.outcome.detail}, ${ago(j.run.at, now)}${superseded}`) + } + const count = (k: EvidenceVisual['kind']) => visuals.filter(v => v.kind === k).length + lines.push('', `**Visual**: ${count('png-viewed')} PNG(s) looked at, ${count('live-capture')} live capture(s), ${count('live-check')} live log/shader check(s)`, '') + lines.push(problems.length === 0 ? '**Ready for a PR** as far as the record shows.' : '**Before a PR:**') + for (const p of problems) { + lines.push(`- ${p.message}`) + } + return lines.join('\n') +} + +export function emptyLedger(): EvidenceLedger { + return { runs: [], visuals: [] } +} diff --git a/.claude/mods/olo-evidence/hooks/register.ts b/.claude/mods/olo-evidence/hooks/register.ts new file mode 100644 index 000000000..f716838f7 --- /dev/null +++ b/.claude/mods/olo-evidence/hooks/register.ts @@ -0,0 +1,282 @@ +import type { EngineInterface, Register } from 'claude-code' + +import type { EvidenceLedger, EvidenceRun, EvidenceVisual } from '../types' +import { + APPROVAL_MARKER, + backgroundOutputPath, + carriesResult, + emptyLedger, + judge, + type Judged, + LEDGER_MARKER, + markersOf, + outcomeOf, + parseInvocations, + prBodySource, + prProblems, + prStatement, + redirectTarget, + summaryMarkdown, + unwaived, + visualFrom, +} from './ledger' + +const KEEP_RUNS = 200 +const KEEP_VISUALS = 200 +const FS_READ_LIMIT = 4 * 1024 * 1024 +const TAIL_BYTES = 3_000_000 +const IDENTITY_TTL_MS = 60_000 + +// A command that can move HEAD or the checkout: the cached branch identity is dropped after it. +const MOVES_HEAD = /\bgit\b[^\n]*\b(?:switch|checkout|worktree|rebase|merge|reset|pull|branch)\b|(?:^|[;&|]\s*)(?:cd|pushd|Set-Location)\s/ + +// Writes to one branch's ledger are serialised: parallel tool calls must not lose entries. +let writes: Promise = Promise.resolve() +let identityCache: { at: number; key: string; branch: string; top: string } | undefined + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ name: 'evidence', description: "This branch's recorded builds, test runs and visual checks, and what still stands before a PR" }) + return next(e) + }) + + on('command.run', { command: 'evidence' }, async $ => { + const id = await identity($) + if (id === undefined) { + return { text: 'Not in a git checkout: no evidence ledger.' } + } + const ledger = await load($, id.key) + const judged = await judgeAll($, ledger) + const changed = await changedFiles($) + const problems = prProblems(judged, ledger.visuals, changed, '') + return { text: summaryMarkdown(judged, ledger.visuals, problems, id.branch, await $.clock.now()) } + }) + + // Record runs and visual checks; gate PR creation on the record. + on('tool.call', async ($, e, next) => { + const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : '' + + if (command.length > 0) { + const refusal = await prGate($, command) + if (refusal !== undefined) { + $.ui.toast('olo-evidence: the PR is not backed by the record yet', { timeoutMs: 8000 }) + return { deny: refusal } + } + } + + const invocations = command.length > 0 ? parseInvocations(command) : [] + const redirect = invocations.length > 0 ? redirectTarget(command) : null + if (redirect !== null) { + // The new run is about to overwrite this log: earlier runs judged from it keep the + // outcome it holds NOW, or they would be judged by the new run's output. + await settle($, () => freezeRunsOn($, redirect)) + } + + const ran = await next(e) + if (ran.deny !== undefined) { + return ran + } + + // Recording is best effort and must never fail the call it records: the tool has run. + await settle($, async () => { + if (MOVES_HEAD.test(command)) { + identityCache = undefined + } + const visual = visualFrom(e.tool, { file_path: e.tool === 'Read' ? e.file_path : undefined, command }) + if (visual !== null && ran.isError !== true) { + await record($, ledger => ({ + ...ledger, + visuals: [...ledger.visuals, { at: 0, kind: visual, what: e.tool === 'Read' ? e.file_path : command.slice(0, 200) }].slice(-KEEP_VISUALS), + })) + } + if (invocations.length > 0) { + const text = typeof ran.text === 'string' ? ran.text : '' + const logPath = backgroundOutputPath(text) ?? redirect + await record($, ledger => ({ + ...ledger, + runs: [ + ...ledger.runs, + ...invocations.map( + (inv, i): EvidenceRun => ({ + id: `${e.tool_use_id}-${i}`, + at: 0, + kind: inv.kind, + command: command.slice(0, 300), + config: inv.config, + scope: inv.scope, + logPath, + captured: carriesResult(inv.kind, text) ? outcomeOf(inv.kind, text) : null, + }), + ), + ].slice(-KEEP_RUNS), + })) + } + }) + return ran + }) + // Only the gate runs before next(e); if it throws, the call goes ahead (fail open), and + // where next was already called, next(e) replays its result without running anything twice. + .catch(($, e, next) => next(e)) +} + +/** Runs best-effort work; a failure is swallowed so the tool call it rides on is unaffected. */ +async function settle($: EngineInterface, work: () => Promise): Promise { + try { + await work() + } catch { + // The record misses one entry; the session goes on. + } +} + +async function prGate($: EngineInterface, command: string): Promise { + const statement = prStatement(command) + if (statement === undefined) { + return undefined + } + const markers = markersOf(statement) + if (markers.includes(APPROVAL_MARKER)) { + return undefined + } + const id = await identity($) + if (id === undefined) { + return undefined + } + const ledger = await load($, id.key) + const judged = await judgeAll($, ledger) + const changed = await changedFiles($) + const source = prBodySource(statement) + const body = source.file !== undefined ? await readSmall($, source.file) ?? '' : source.inline ?? '' + const problems = unwaived(prProblems(judged, ledger.visuals, changed, body), markers) + if (problems.length === 0) { + return undefined + } + const waivers = [...new Set(problems.flatMap(p => p.waivedBy))] + return [ + `olo-evidence refused this: the record for ${id.branch} does not back the PR yet.`, + ...problems.map(p => `- ${p.message}`), + `Run /evidence to see the record.${waivers.includes(LEDGER_MARKER) ? ` If those runs happened outside this record (an earlier session), end the gh command with a comment carrying ${LEDGER_MARKER} and say so in the PR body.` : ''}`, + ].join('\n') +} + +/** Earlier runs that read their outcome from `path` take what it says now, and stop reading it. */ +async function freezeRunsOn($: EngineInterface, path: string): Promise { + const id = await identity($) + if (id === undefined) { + return + } + const same = (p: string | null) => p !== null && p.replace(/\\/g, '/').toLowerCase() === path.replace(/\\/g, '/').toLowerCase() + const ledger = await load($, id.key) + if (!ledger.runs.some(r => same(r.logPath))) { + return + } + const log = await readTail($, path) + await record($, current => ({ + ...current, + runs: current.runs.map(run => { + if (!same(run.logPath)) { + return run + } + const outcome = log === undefined ? undefined : outcomeOf(run.kind, log) + return { ...run, logPath: null, captured: outcome !== undefined && outcome.state !== 'unknown' ? outcome : run.captured } + }), + })) +} + +async function judgeAll($: EngineInterface, ledger: EvidenceLedger): Promise { + return Promise.all(ledger.runs.map(async run => judge(run, run.logPath === null ? undefined : await readTail($, run.logPath)))) +} + +async function changedFiles($: EngineInterface): Promise { + try { + const r = await $.process.run(['git', 'diff', '--name-only', 'origin/master...HEAD'], { timeoutMs: 20_000 }) + return r.exitCode === 0 ? r.stdout.split('\n').map(l => l.trim()).filter(Boolean) : [] + } catch { + return [] + } +} + +async function identity($: EngineInterface): Promise<{ key: string; branch: string; top: string } | undefined> { + const now = await $.clock.now() + if (identityCache !== undefined && now - identityCache.at < IDENTITY_TTL_MS) { + return identityCache + } + try { + const [top, branch] = await Promise.all([ + $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }), + $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 }), + ]) + if (top.exitCode !== 0 || branch.exitCode !== 0) { + return undefined + } + const t = top.stdout.trim() + const b = branch.stdout.trim() + identityCache = { at: now, key: `ledger:${t.toLowerCase()}|${b}`, branch: b, top: t } + return identityCache + } catch { + return undefined + } +} + +async function load($: EngineInterface, key: string): Promise { + const stored = (await $.store.get(key)) as EvidenceLedger | undefined + return stored !== undefined && Array.isArray(stored.runs) && Array.isArray(stored.visuals) ? stored : emptyLedger() +} + +/** Applies `change` to this branch's ledger, stamping new entries with the time. */ +async function record($: EngineInterface, change: (ledger: EvidenceLedger) => EvidenceLedger): Promise { + const id = await identity($) + if (id === undefined) { + return + } + const now = await $.clock.now() + const step = writes.then(async () => { + const before = await load($, id.key) + const after = change(before) + const stamp = (xs: T[]) => xs.map(x => (x.at === 0 ? { ...x, at: now } : x)) + await $.store.set(id.key, { runs: stamp(after.runs), visuals: stamp(after.visuals) as EvidenceVisual[] }) + }) + writes = step.catch(() => undefined) + await step +} + +async function readSmall($: EngineInterface, path: string): Promise { + try { + const stat = await $.fs.stat(path) + return stat.kind === 'file' && stat.size <= FS_READ_LIMIT ? await $.fs.read(path) : undefined + } catch { + return undefined + } +} + +/** + * A log's text: whole when `$.fs.read` takes it (up to 4 MiB), else its last few MB through + * this mod's scripts/tail.ps1 (pwsh is always present here), else `tail` where one exists. + */ +async function readTail($: EngineInterface, path: string): Promise { + try { + const stat = await $.fs.stat(path) + if (stat.kind !== 'file') { + return undefined + } + if (stat.size <= FS_READ_LIMIT) { + return await $.fs.read(path) + } + } catch { + return undefined + } + const attempts: string[][] = [ + ['pwsh', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/tail.ps1`, '-Path', path, '-Bytes', String(TAIL_BYTES)], + ['tail', '-c', String(TAIL_BYTES), path], + ] + for (const argv of attempts) { + try { + const r = await $.process.run(argv, { timeoutMs: 15_000 }) + if (r.exitCode === 0) { + return r.stdout + } + } catch { + // Try the next way. + } + } + return undefined +} diff --git a/.claude/mods/olo-evidence/hooks/shell.ts b/.claude/mods/olo-evidence/hooks/shell.ts new file mode 100644 index 000000000..117ef23f3 --- /dev/null +++ b/.claude/mods/olo-evidence/hooks/shell.ts @@ -0,0 +1,286 @@ +// Quote-aware shell statement splitting, the same as olo-command-guards' (mods are +// independent plugins, so each carries its own copy; keep the two in step). + +export type Separator = 'start' | '&&' | '||' | ';' | '|' | '\n' | '&' + +export type Statement = { separator: Separator; text: string; words: string[] } + +/** + * Splits a shell command into statements, remembering which separator led into each, + * and tokenises each one. Quote-aware (single, double, and PowerShell backtick escapes + * are left as literal characters), so a `;` inside a commit message is not a split. + */ +export function splitStatements(command: string): Statement[] { + const statements: Statement[] = [] + let current = '' + let separator: Separator = 'start' + let quote: '"' | "'" | undefined + let i = 0 + + const flush = (next: Separator) => { + if (current.trim().length > 0) { + statements.push({ separator, text: current.trim(), words: tokenize(current) }) + } + current = '' + separator = next + } + + while (i < command.length) { + const c = command[i] ?? '' + const two = command.slice(i, i + 2) + if (quote !== undefined) { + if (c === '\\' && quote === '"' && i + 1 < command.length) { + current += two + i += 2 + continue + } + if (c === quote) { + quote = undefined + } + current += c + i += 1 + continue + } + if (c === '"' || c === "'") { + quote = c + current += c + i += 1 + continue + } + if (c === '#' && (current.length === 0 || /\s$/.test(current))) { + // A comment runs to the end of the line; keep it in the text (markers live there) + // but out of the words. + const end = command.indexOf('\n', i) + const stop = end === -1 ? command.length : end + current += command.slice(i, stop) + i = stop + continue + } + if (two === '&&' || two === '||') { + flush(two) + i += 2 + continue + } + if (c === ';' || c === '\n' || c === '|' || (c === '&' && command[i + 1] !== '>' && command[i - 1] !== '>')) { + flush(c as Separator) + i += 1 + continue + } + current += c + i += 1 + } + flush('start') + return statements +} + +/** Words of one statement, quotes removed, a trailing `#` comment dropped. */ +export function tokenize(text: string): string[] { + const words: string[] = [] + let word = '' + let quote: '"' | "'" | undefined + let started = false + for (let i = 0; i < text.length; i += 1) { + const c = text[i] ?? '' + if (quote !== undefined) { + if (c === quote) { + quote = undefined + } else { + word += c + } + continue + } + if (c === '"' || c === "'") { + quote = c + started = true + continue + } + if (c === '#' && !started) { + break + } + if (/\s/.test(c)) { + if (started) { + words.push(word) + } + word = '' + started = false + continue + } + word += c + started = true + } + if (started) { + words.push(word) + } + return words +} + +export function commentOf(text: string): string { + let quote: '"' | "'" | undefined + for (let i = 0; i < text.length; i += 1) { + const c = text[i] ?? '' + if (quote !== undefined) { + if (c === quote) { + quote = undefined + } + continue + } + if (c === '"' || c === "'") { + quote = c + continue + } + if (c === '#' && (i === 0 || /\s/.test(text[i - 1] ?? ''))) { + return text.slice(i + 1) + } + } + return '' +} + +export function exeName(word: string | undefined): string { + return (word ?? '').replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase() +} + +// Launchers that run the command after them, with the options of theirs that take a value. +// `env -S ''` splits its value into the command it runs. +const LAUNCHERS: Record = { + env: { withValue: ['-u', '--unset', '-C', '--chdir', '-P'] }, + timeout: { withValue: ['-s', '--signal', '-k', '--kill-after'], positional: 1 }, + nice: { withValue: ['-n', '--adjustment'] }, + time: { withValue: ['-o', '--output', '-f', '--format'] }, + call: { withValue: [] }, + command: { withValue: [] }, + builtin: { withValue: [] }, + exec: { withValue: ['-a'] }, +} + +/** + * A statement's words from the command it runs. Drops `&`, `VAR=value` assignments and the + * launchers above with their own options (`env -i -u X`, `timeout -s KILL 60`, `nice -n 5`), + * so `env -i GH_TOKEN=x gh pr merge` is still `gh pr merge`. Normalising only exposes the + * command that runs: it never removes a word the rules would have refused. + */ +export function commandWords(words: readonly string[]): string[] { + let rest = [...words] + for (;;) { + const w = rest[0] ?? '' + if (w === '&' || /^\w+=/.test(w)) { + rest = rest.slice(1) + continue + } + const launcher = LAUNCHERS[exeName(w)] + if (launcher === undefined) { + return rest + } + let i = 1 + while (i < rest.length && (rest[i] ?? '').startsWith('-') && rest[i] !== '-') { + const flag = rest[i] ?? '' + // env -S '' [more]: the string is split into env's own arguments, and may + // itself start with env options (`env -S '-i gh pr merge 1'` runs `gh pr merge 1`), so + // the expanded words go back through env, not straight to the command. + const split = + exeName(w) !== 'env' + ? undefined + : flag === '-S' || flag === '--split-string' + ? { value: rest[i + 1] ?? '', after: i + 2 } + : flag.startsWith('--split-string=') + ? { value: flag.slice('--split-string='.length), after: i + 1 } + : /^-S./.test(flag) + ? { value: flag.slice(2), after: i + 1 } + : undefined + if (split !== undefined) { + rest = [w, ...rest.slice(1, i), ...tokenize(split.value), ...rest.slice(split.after)] + i = -1 + break + } + i += launcher.withValue.includes(flag) ? 2 : 1 + } + if (i === -1) { + continue + } + if (rest[i] === '-') { + i += 1 + } + rest = rest.slice(i + (launcher.positional ?? 0)) + } +} + +// PowerShell options that take a value (the rest are switches). +const PWSH_WITH_VALUE = new Set([ + '-executionpolicy', '-ep', '-ex', '-workingdirectory', '-wd', '-windowstyle', '-w', '-configurationname', '-config', + '-outputformat', '-o', '-of', '-inputformat', '-if', '-in', '-version', '-v', '-settingsfile', '-custompipename', '-psconsolefile', +]) +// bash/sh options that take a value. +const SH_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']) +// A script that runs its own -Command parameter: build-lock.ps1 runs the build it is handed. +const RUNS_ITS_COMMAND = /^build-lock\.ps1$/i + +/** + * The command a shell wrapper runs, read from the wrapper's OWN options only (words after a + * script operand are the script's arguments, not the shell's): `pwsh/powershell -Command|-c + * `; `bash/sh/zsh/dash` with a single-dash flag group containing `c` (`-c`, `-lc`, `-ec`) + * then ``; `cmd /c|/k `. The one script whose `-Command` IS what runs is + * build-lock.ps1. Undefined when the statement is no such wrapper. + */ +export function innerCommand(words: readonly string[]): string | undefined { + const name = exeName(words[0]) + if (name === 'pwsh' || name === 'powershell') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + const lower = w.toLowerCase() + if (lower === '-command' || lower === '-c') { + return words.slice(i + 1).join(' ') + } + if (lower === '-file' || lower === '-f') { + const script = words[i + 1] ?? '' + if (!RUNS_ITS_COMMAND.test(script.replace(/^.*[\\/]/, ''))) { + return undefined + } + const at = words.findIndex((x, j) => j > i + 1 && /^-command$/i.test(x)) + return at >= 0 ? words[at + 1] : undefined + } + if (lower === '-encodedcommand' || lower === '-e' || lower === '-ec') { + return undefined + } + if (!w.startsWith('-')) { + // A bare operand: pwsh runs it as a script file, Windows PowerShell as a command. + return name === 'powershell' ? words.slice(i).join(' ') : undefined + } + if (PWSH_WITH_VALUE.has(lower)) { + i += 1 + } + } + return undefined + } + if (name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + if (w === '--' || w === '-') { + return undefined + } + if (SH_WITH_VALUE.has(w)) { + i += 1 + continue + } + if (/^-[A-Za-z]*c[A-Za-z]*$/.test(w)) { + return words[i + 1] + } + if (!/^[-+]/.test(w)) { + // The script operand: every word after it is the script's. + return undefined + } + } + return undefined + } + if (name === 'cmd') { + for (let i = 1; i < words.length; i += 1) { + const w = words[i] ?? '' + if (/^\/[ck]$/i.test(w)) { + return words.slice(i + 1).join(' ') + } + if (!w.startsWith('/')) { + return undefined + } + } + return undefined + } + return undefined +} diff --git a/.claude/mods/olo-evidence/scripts/tail.ps1 b/.claude/mods/olo-evidence/scripts/tail.ps1 new file mode 100644 index 000000000..ac4c5f6d9 --- /dev/null +++ b/.claude/mods/olo-evidence/scripts/tail.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS + Prints the last -Bytes bytes of a file as UTF-8, for mods reading a log too big for $.fs.read. + +.DESCRIPTION + `$.process.run` runs an argument vector with no shell, so `tail` is only there when some + install put a tail.exe on PATH. pwsh is always present on this box (the build lock needs it). + Opens with FileShare.ReadWrite so a log still being written is readable. A cut inside a + multi-byte character at the start is dropped by the decoder, which is harmless for a log. +#> +param( + [Parameter(Mandatory = $true)][string] $Path, + [Parameter(Mandatory = $true)][long] $Bytes +) + +$ErrorActionPreference = 'Stop' +$fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') +try { + $take = [Math]::Min($fs.Length, $Bytes) + [void] $fs.Seek(-$take, 'End') + $buffer = New-Object byte[] $take + $read = 0 + while ($read -lt $take) { + $n = $fs.Read($buffer, $read, $take - $read) + if ($n -le 0) { break } + $read += $n + } + $stdout = [Console]::OpenStandardOutput() + $stdout.Write($buffer, 0, $read) + $stdout.Flush() +} finally { + $fs.Dispose() +} diff --git a/.claude/mods/olo-evidence/tests/hooks.test.ts b/.claude/mods/olo-evidence/tests/hooks.test.ts new file mode 100644 index 000000000..291278b10 --- /dev/null +++ b/.claude/mods/olo-evidence/tests/hooks.test.ts @@ -0,0 +1,122 @@ +import { expect, mock, test } from 'claude-code/testing' +import type { On } from 'claude-code' + +// Beneath the plugin: git answers for a feature branch that changed a shader, a store in +// memory, and a tool layer that "runs" whatever it is asked. +function world(on: On, stdoutFor: (argv: string) => string) { + const store = new Map() + on('store.get', ($, e) => ({ value: store.get(e.key) })) + on('store.set', ($, e) => { + store.set(e.key, e.value) + return { value: undefined } + }) + on('process.run', ($, e) => { + const argv = e.argv.join(' ') + const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }) + if (argv.includes('--show-toplevel')) return ok('C:/repos/OloEngine-x\n') + if (argv.includes('--abbrev-ref')) return ok('feature/x\n') + if (argv.includes('diff --name-only')) return ok('OloEditor/assets/shaders/include/PBRCommon.glsl\n') + return ok(stdoutFor(argv)) + }) + return store +} + +const FAILED_TESTS = [ + '[==========] Running 2 tests from 1 test suite.', + '[ RUN ] Water.A', + '[ OK ] Water.A (1 ms)', + '[ RUN ] Water.B', + '[ FAILED ] Water.B (1 ms)', + '[==========] 2 tests from 1 test suite ran. (2 ms total)', +].join('\n') + +test('a failing run is recorded and blocks gh pr create until a green re-run and a frame', async ($, on) => { + mock.clock(on, { now: Date.UTC(2026, 9, 10, 12) }) + world(on, () => '') + let output = FAILED_TESTS + on('tool.call', () => ({ result: { stdout: output, stderr: '', interrupted: false }, text: output })) + + await $.tool.call({ tool: 'Bash', command: 'build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe --gtest_filter=Water.*' }) + const refused = await $.tool.call({ tool: 'Bash', command: 'gh pr create --title t --body "Debug run green."' }) + const why = String(refused.deny ?? refused.text) + expect(why).toContain('olo-evidence refused') + expect(why).toContain('Water.B') + expect(why).toContain('renderer or shader code') + + output = FAILED_TESTS.replace('[ FAILED ] Water.B (1 ms)', '[ OK ] Water.B (1 ms)') + await $.tool.call({ tool: 'Bash', command: 'build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe --gtest_filter=Water.*' }) + await $.tool.call({ tool: 'Read', file_path: 'C:/repos/OloEngine-x/OloEditor/assets/tests/visual/Water_GL_Forward.png' }) + await $.tool.call({ tool: 'Bash', command: 'curl -s localhost:7391/mcp -d olo_screenshot' }) + + output = 'https://github.com/o/r/pull/1' + const allowed = await $.tool.call({ tool: 'Bash', command: 'gh pr create --title t --body "Debug run green."' }) + expect(allowed.deny).toBeUndefined() + expect(allowed.text).toBe('https://github.com/o/r/pull/1') +}) + +test('ordinary commands are untouched and record nothing', async ($, on) => { + mock.clock(on, { now: 1 }) + const store = world(on, () => '') + on('tool.call', () => ({ result: { stdout: 'ok', stderr: '', interrupted: false }, text: 'ok' })) + const r = await $.tool.call({ tool: 'Bash', command: 'git status' }) + expect(r.text).toBe('ok') + expect(store.size).toBe(0) +}) + +test('a run that shares a log file with a later run keeps the outcome the log held for it', async ($, on) => { + mock.clock(on, { now: Date.UTC(2026, 9, 10, 12) }) + world(on, () => '') + // The log file both runs redirect into; the "tool" writes it when it runs. + // Keyed the way the engine may hand a path on: either slash, any case. + const key = (p: string) => p.replace(/\\/g, '/').toLowerCase() + const files = new Map() + const seen: string[] = [] + on('fs.stat', ($, e) => { + seen.push(e.path) + const text = files.get(key(e.path)) + return text === undefined ? { deny: 'ENOENT' } : { value: { kind: 'file', size: text.length, mtimeMs: 0, isLink: false } } + }) + on('fs.read', ($, e) => { + const text = files.get(key(e.path)) + return text === undefined ? { deny: 'ENOENT' } : { value: text } + }) + let nextLog = FAILED_TESTS + on('tool.call', ($, e) => { + const command = (e as { command?: string }).command ?? '' + if (command.includes('> C:/t/run.log')) { + files.set(key('C:/t/run.log'), nextLog) + } + return { result: { stdout: '', stderr: '', interrupted: false }, text: '' } + }) + + await $.tool.call({ tool: 'Bash', command: 'build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe --gtest_filter=Water.* > C:/t/run.log 2>&1' }) + nextLog = FAILED_TESTS.replace('[ FAILED ] Water.B (1 ms)', '[ OK ] Water.B (1 ms)') + await $.tool.call({ tool: 'Bash', command: 'build-cached/OloEngine/tests/Release/OloEngine-Tests.exe --gtest_filter=Water.* > C:/t/run.log 2>&1' }) + + // The Debug run failed; the Release run's passing log must not stand in for it. + const refused = await $.tool.call({ tool: 'Bash', command: 'gh pr create --title t --body "Debug run green." # OLO_LEDGER_INCOMPLETE' }) + const why = String(refused.deny ?? refused.text) + expect(why).toContain('Debug') + expect(why).toContain('Water.B') +}) + +test('a failure while recording never re-runs or fails the call it records', async ($, on) => { + mock.clock(on, { now: 1 }) + on('store.get', () => ({ value: undefined })) + on('store.set', () => { + throw new Error('store unavailable') + }) + on('process.run', ($, e) => { + const argv = e.argv.join(' ') + const out = argv.includes('--show-toplevel') ? 'C:/repos/x\n' : argv.includes('--abbrev-ref') ? 'feature/x\n' : '' + return { value: { exitCode: 0, stdout: out, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } } + }) + let calls = 0 + on('tool.call', () => { + calls += 1 + return { result: { stdout: 'ok', stderr: '', interrupted: false }, text: 'ok' } + }) + const r = await $.tool.call({ tool: 'Bash', command: 'cmake --build build-cached --config Debug' }) + expect(r.text).toBe('ok') + expect(calls).toBe(1) +}) diff --git a/.claude/mods/olo-evidence/tests/ledger.test.ts b/.claude/mods/olo-evidence/tests/ledger.test.ts new file mode 100644 index 000000000..034ef7690 --- /dev/null +++ b/.claude/mods/olo-evidence/tests/ledger.test.ts @@ -0,0 +1,218 @@ +import { expect, test } from 'claude-code/testing' + +import type { EvidenceRun, EvidenceVisual } from '../types' +import { + backgroundOutputPath, + buildOutcome, + claims, + judge, + latestPerKey, + markersOf, + outcomeOf, + parseInvocations, + prBodySource, + prProblems, + prStatement, + redirectTarget, + summaryMarkdown, + testOutcome, + unwaived, + visualFrom, +} from '../hooks/ledger' + +const LOCKED_BUILD = + "pwsh -NoProfile -File .claude/skills/run-oloengine/build-lock.ps1 -Command 'cmake --build build-cached --target OloEngine-Tests OloEditor --config Debug --parallel 6' > C:/t/build.log 2>&1" + +test('invocations: a locked build, a test run, both on one line, ctest', () => { + expect(parseInvocations(LOCKED_BUILD)).toEqual([{ kind: 'build', config: 'Debug', scope: 'build-cached OloEngine-Tests OloEditor' }]) + expect(parseInvocations('build-cached\\OloEngine\\tests\\Release\\OloEngine-Tests.exe --gtest_filter=Foliage*.* 2>&1 | tee t.log')).toEqual([ + { kind: 'test', config: 'Release', scope: 'Foliage*.*' }, + ]) + expect(parseInvocations('cmake --build build-cached --config Debug && build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe').map(i => i.kind)).toEqual(['build', 'test']) + expect(parseInvocations('ctest --test-dir build -C Debug -R Water')).toEqual([{ kind: 'test', config: 'Debug', scope: 'ctest -R Water' }]) + expect(parseInvocations('OloEngine-Tests.exe --gtest_list_tests --gtest_filter=X.*')).toEqual([]) + expect(parseInvocations('ctest -N')).toEqual([]) + expect(parseInvocations('git log --oneline')).toEqual([]) +}) + +test('invocations: a statement must RUN the build or test, not mention it', () => { + expect(parseInvocations('rg -n OloEngine-Tests docs')).toEqual([]) + expect(parseInvocations('git log --grep ctest')).toEqual([]) + expect(parseInvocations('gh pr create --body "ran build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe and ctest -C Debug"')).toEqual([]) + expect(parseInvocations('grep "cmake --build" notes.md')).toEqual([]) + expect(parseInvocations('timeout 600 build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe --gtest_filter=A.*')).toEqual([ + { kind: 'test', config: 'Debug', scope: 'A.*' }, + ]) + expect(parseInvocations('& "C:/r/build-cached/OloEngine/tests/Release/OloEngine-Tests.exe" "--gtest_filter=B.*"')).toEqual([ + { kind: 'test', config: 'Release', scope: 'B.*' }, + ]) +}) + +test('ctest summaries are read, passing and failing', () => { + const failing = [ + '80% tests passed, 1 tests failed out of 5', + '', + 'The following tests FAILED:', + '\t 3 - Water.Foam (Failed)', + 'Errors while running CTest', + ].join('\n') + expect(testOutcome(failing)).toEqual(expect.objectContaining({ state: 'failed', failed: ['Water.Foam'], ran: 5 })) + expect(testOutcome('100% tests passed, 0 tests failed out of 12').state).toBe('passed') +}) + +test('colour sequences, CRs and NULs do not hide a result', () => { + const coloured = '\u001b[0;32m[==========] \u001b[mRunning 1 test from 1 test suite.\r\n[ RUN ] A.B\r\n\u001b[0;31m[ FAILED ] \u001b[mA.B (1 ms)\r\n[==========] 1 test from 1 test suite ran.\u0000' + expect(outcomeOf('test', coloured)).toEqual(expect.objectContaining({ state: 'failed', failed: ['A.B'] })) +}) + +test('the PR statement, its body source and its markers come from the parsed command', () => { + const s = prStatement('cd x && gh pr create --title "a; b" --body-file C:/t/body.md # OLO_LEDGER_INCOMPLETE') + expect(s === undefined ? undefined : prBodySource(s)).toEqual({ file: 'C:/t/body.md' }) + expect(s === undefined ? '' : markersOf(s)).toContain('OLO_LEDGER_INCOMPLETE') + const quoted = prStatement('gh pr create --body "this mentions OLO_USER_APPROVED"') + expect(quoted === undefined ? 'missing' : markersOf(quoted)).toBe('') + expect(prStatement('gh pr view 1585')).toBeUndefined() + expect(prStatement('gh pr edit 1585 --add-label x')).toBeUndefined() + expect(prStatement('gh pr edit 1585 --body-file b.md')).toBeDefined() +}) + +test('the PR statement is found through prefixes and shell wrappers', () => { + for (const command of [ + 'GH_TOKEN=x gh pr create --fill', + 'env gh pr create --fill', + 'pwsh -NoProfile -Command "gh pr create --body-file C:/t/b.md"', + "bash -c 'gh pr create --fill'", + 'cmd /c gh pr create --fill', + "bash -ec 'gh pr create --fill'", + 'env -i GH_TOKEN=x gh pr create --fill', + "env -S 'gh pr create --fill'", + "env -S '-i gh pr create --fill'", + 'timeout -s KILL 60 gh pr create --fill', + ]) { + expect({ command, found: prStatement(command) !== undefined }).toEqual({ command, found: true }) + } + const wrapped = prStatement('pwsh -Command "gh pr create --body-file C:/t/b.md" # OLO_LEDGER_INCOMPLETE') + expect(wrapped === undefined ? undefined : prBodySource(wrapped)).toEqual({ file: 'C:/t/b.md' }) + expect(wrapped === undefined ? '' : markersOf(wrapped)).toContain('OLO_LEDGER_INCOMPLETE') + const quoted = prStatement('pwsh -Command "gh pr create --body OLO_USER_APPROVED"') + expect(quoted === undefined ? 'missing' : markersOf(quoted)).not.toContain('OLO_USER_APPROVED') + // Invocations look through the same wrappers. + expect(parseInvocations("bash -c 'build-cached/OloEngine/tests/Debug/OloEngine-Tests.exe --gtest_filter=A.*'")).toEqual([ + { kind: 'test', config: 'Debug', scope: 'A.*' }, + ]) +}) + +test('words after a script operand are the script arguments, not a command', () => { + expect(parseInvocations("bash script.sh -c 'ctest -C Release'")).toEqual([]) + expect(parseInvocations("pwsh -File tools/x.ps1 -Command 'cmake --build build-cached'")).toEqual([]) + expect(prStatement("bash script.sh -c 'gh pr create --fill'")).toBeUndefined() + // build-lock.ps1 runs its -Command: still a build. + expect(parseInvocations(LOCKED_BUILD).map(i => i.kind)).toEqual(['build']) +}) + +test('redirects: the file a run writes to, not 2>&1 or /dev/null', () => { + expect(redirectTarget(LOCKED_BUILD)).toBe('C:/t/build.log') + expect(redirectTarget('x 2>&1 | tee -a C:/t/run.log')).toBe('C:/t/run.log') + expect(redirectTarget('x | Tee-Object -FilePath out.txt')).toBe('out.txt') + expect(redirectTarget('x > /dev/null 2>&1')).toBeNull() + expect(redirectTarget('x')).toBeNull() + expect(backgroundOutputPath('Command running in background with ID: b1. Output is being written to: C:\\T\\tasks\\b1.output')).toBe('C:\\T\\tasks\\b1.output') +}) + +test('build outcomes: failed, built, still going', () => { + expect(buildOutcome("FAILED: x.obj\nFoo.cpp(3,1): error: nope\nninja: build stopped: subcommand failed.\n[build-lock] released (pid=1)").state).toBe('failed') + expect(buildOutcome('[2055/2055] Linking CXX executable OloEngine-Tests.exe\n[build-lock] released (pid=1)').state).toBe('passed') + expect(buildOutcome('ninja: no work to do.').state).toBe('passed') + expect(buildOutcome('[build-lock] acquired (pid=1)\n[12/2055] Building CXX object a.obj').state).toBe('running') +}) + +test('test outcomes: pass, fail, zero, died, all skipped', () => { + const head = (n: number) => `[==========] Running ${n} tests from 1 test suite.` + expect(testOutcome(`${head(1)}\n[ RUN ] A.B\n[ OK ] A.B (1 ms)\n[==========] 1 test from 1 test suite ran. (1 ms total)`).state).toBe('passed') + expect(testOutcome(`${head(1)}\n[ RUN ] A.B\n[ FAILED ] A.B (1 ms)\n[==========] 1 test from 1 test suite ran. (1 ms total)`)).toEqual( + expect.objectContaining({ state: 'failed', failed: ['A.B'] }), + ) + expect(testOutcome('[==========] Running 0 tests from 0 test suites.\n[==========] 0 tests from 0 test suites ran.').detail).toContain('selected no tests') + expect(testOutcome(`${head(5)}\n[ RUN ] A.B`).state).toBe('running') + expect(testOutcome(`${head(1)}\n[ RUN ] A.B\n[ SKIPPED ] A.B (0 ms)\n[==========] 1 test from 1 test suite ran.`).detail).toContain('skipped') +}) + +test('visuals: PNGs read, live captures and log checks', () => { + expect(visualFrom('Read', { file_path: 'C:/r/OloEditor/assets/tests/visual/Water_GL.png' })).toBe('png-viewed') + expect(visualFrom('Bash', { command: 'curl -s localhost:7391/mcp -d \'{"name":"olo_screenshot"}\'' })).toBe('live-capture') + expect(visualFrom('mcp__olo__olo_render_capture_target', {})).toBe('live-capture') + expect(visualFrom('Bash', { command: 'call olo_shader_errors' })).toBe('live-check') + expect(visualFrom('Read', { file_path: 'a.cpp' })).toBeNull() +}) + +const run = (over: Partial): EvidenceRun => ({ + id: 'r', + at: 1, + kind: 'test', + command: '', + config: 'Debug', + scope: 'A.*', + logPath: null, + captured: null, + ...over, +}) +const passed = { state: 'passed' as const, detail: '3 passed', failed: [], ran: 3, skipped: 0 } +const failedAB = { state: 'failed' as const, detail: '1 failed of 3', failed: ['A.B'], ran: 3, skipped: 0 } + +test('a later run supersedes an earlier one with the same key', () => { + const judged = [judge(run({ at: 1, captured: failedAB }), undefined), judge(run({ at: 2, captured: passed }), undefined)] + expect(latestPerKey(judged).map(j => j.outcome.state)).toEqual(['passed']) +}) + +test('PR problems: no runs, failing runs, unbacked claims, missing visual evidence', () => { + const none = prProblems([], [], ['OloEngine/src/OloEngine/Scene/Scene.cpp'], '') + expect(none.map(p => p.rule)).toEqual(['no-runs']) + + const failing = [judge(run({ captured: failedAB }), undefined)] + expect(prProblems(failing, [], ['a.cpp'], '').map(p => p.rule)).toEqual(['failed-run']) + // Naming the failing test in the body (as pre-existing) clears it. + expect(prProblems(failing, [], ['a.cpp'], 'A.B fails on master too (#1234)').map(p => p.rule)).toEqual([]) + + const green = [judge(run({ captured: passed }), undefined)] + expect(prProblems(green, [], ['a.cpp'], 'Verified on Debug and Release.').map(p => p.rule)).toEqual(['claims-release']) + + const shader = ['OloEditor/assets/shaders/include/PBRCommon.glsl'] + const views: EvidenceVisual[] = [{ at: 1, kind: 'png-viewed', what: 'x.png' }] + expect(prProblems(green, views, shader, '').map(p => p.rule)).toEqual(['visual-evidence']) + expect(prProblems(green, [...views, { at: 1, kind: 'live-capture', what: 'olo_screenshot' }], shader, '')).toEqual([]) + expect(prProblems(green, [], shader, 'I could not inspect a frame: no GPU in this session.')).toEqual([]) +}) + +test('runs with no known outcome are not evidence', () => { + const unknown = judge(run({ scope: 'Lost.*', logPath: 'C:/gone.log' }), undefined) + expect(unknown.outcome.state).toBe('unknown') + // Only unknown runs: the branch has no evidence at all. + const alone = prProblems([unknown], [], ['a.cpp'], '') + expect(alone.map(p => p.rule)).toEqual(['no-runs']) + expect(alone[0]?.message).toContain('no recorded build or test run has a known outcome') + // Beside a known run, the unknown one is reported as unproven. + const mixed = prProblems([judge(run({ captured: passed }), undefined), unknown], [], ['a.cpp'], '') + expect(mixed.map(p => p.rule)).toEqual(['unproven-run']) +}) + +test('markers waive only what they cover', () => { + const problems = prProblems([], [], ['OloEditor/assets/shaders/a.glsl'], 'Debug') + expect(unwaived(problems, 'gh pr create OLO_LEDGER_INCOMPLETE').map(p => p.rule)).toEqual(['visual-evidence']) + expect(unwaived(problems, 'gh pr create OLO_USER_APPROVED')).toEqual([]) +}) + +test('the summary lists runs newest first, marks superseded ones, and states readiness', () => { + const judged = [judge(run({ at: 1, captured: failedAB }), undefined), judge(run({ at: 2, captured: passed }), undefined)] + const md = summaryMarkdown(judged, [], [], 'feature/x', 3 * 60_000) + expect(md.indexOf('PASS')).toBeLessThan(md.indexOf('FAIL')) + expect(md).toContain('(superseded)') + expect(md).toContain('Ready for a PR') +}) + +test('a claim is a configuration and a verdict on one line, not a mention', () => { + expect(claims('Full Debug suite: 7,170 tests, 0 failures, green.', 'Debug')).toBe(true) + expect(claims('| Release | GL Forward | passed |', 'Release')).toBe(true) + expect(claims('A PR body that claimed a Debug run that never happened.', 'Debug')).toBe(false) + expect(claims('Debug-only failures reach master because CI never builds Debug.', 'Debug')).toBe(false) + expect(claims('Release notes.\nTests passed.', 'Release')).toBe(false) +}) diff --git a/.claude/mods/olo-evidence/types/index.d.ts b/.claude/mods/olo-evidence/types/index.d.ts new file mode 100644 index 000000000..58618ff97 --- /dev/null +++ b/.claude/mods/olo-evidence/types/index.d.ts @@ -0,0 +1,35 @@ +export type EvidenceRunKind = 'build' | 'test' + +export type EvidenceOutcome = { + state: 'passed' | 'failed' | 'running' | 'unknown' + /** One line: what the log says. */ + detail: string + failed: string[] + ran: number + skipped: number +} + +export type EvidenceRun = { + id: string + at: number + kind: EvidenceRunKind + command: string + config: string + /** Builds: the tree and targets. Tests: the gtest filter or ctest regex. */ + scope: string + /** Where the output went (a redirect, or a background task's output file). */ + logPath: string | null + /** The outcome read from the tool's own output, when it carried one. */ + captured: EvidenceOutcome | null +} + +export type EvidenceVisual = { + at: number + kind: 'png-viewed' | 'live-capture' | 'live-check' + what: string +} + +export type EvidenceLedger = { + runs: EvidenceRun[] + visuals: EvidenceVisual[] +} diff --git a/.claude/mods/olo-fleet/.claude-plugin/plugin.json b/.claude/mods/olo-fleet/.claude-plugin/plugin.json new file mode 100644 index 000000000..8d66a6f58 --- /dev/null +++ b/.claude/mods/olo-fleet/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "olo-fleet", + "version": "0.1.0", + "description": "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 settles.", + "author": { + "name": "OloEngine" + }, + "types": "./types/index.d.ts" +} diff --git a/.claude/mods/olo-fleet/hooks/fleet.ts b/.claude/mods/olo-fleet/hooks/fleet.ts new file mode 100644 index 000000000..c8dfedb3c --- /dev/null +++ b/.claude/mods/olo-fleet/hooks/fleet.ts @@ -0,0 +1,229 @@ +// Pure parsing and summarising for the fleet view. No engine calls: the tests drive it. + +import type { FleetCheckState, FleetPr, FleetSlot, FleetSnapshot, FleetWaiter, FleetWorktree } from '../types' + +export function baseName(path: string): string { + return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path +} + +/** `git worktree list --porcelain`. The first entry is the main worktree. */ +export function parseWorktrees(porcelain: string): Omit[] { + const out: Omit[] = [] + for (const block of porcelain.replace(/\r\n?/g, '\n').split(/\n\n+/)) { + const path = /^worktree (.+)$/m.exec(block)?.[1]?.trim() + if (path === undefined) { + continue + } + const head = /^HEAD ([0-9a-f]+)$/m.exec(block)?.[1] ?? '' + const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]?.trim() ?? null + out.push({ path, name: baseName(path), branch, head: head.slice(0, 9), isBase: out.length === 0 }) + } + return out +} + +type RawContext = { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string } + +/** The GraphQL answer for open PRs (see PR_QUERY in register). */ +export function parsePrs(json: string): FleetPr[] { + const data = JSON.parse(json) as { + data?: { repository?: { pullRequests?: { nodes?: unknown[] } } } + } + const nodes = data.data?.repository?.pullRequests?.nodes ?? [] + return nodes.flatMap(node => { + const n = node as { + number: number + title: string + headRefName: string + url: string + isDraft: boolean + mergeable: string + reviewThreads?: { nodes?: { isResolved: boolean }[] } + commits?: { nodes?: { commit?: { statusCheckRollup?: { state?: string; contexts?: { nodes?: RawContext[] } } | null } }[] } + } + if (typeof n.number !== 'number') { + return [] + } + const rollup = n.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null + const contexts = rollup?.contexts?.nodes ?? [] + const failing = contexts + .filter(c => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(String(c.conclusion ?? c.state ?? ''))) + .map(c => c.name ?? c.context ?? '?') + const running = contexts.filter(c => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING').length + return [ + { + number: n.number, + title: n.title, + branch: n.headRefName, + url: n.url, + isDraft: n.isDraft, + mergeable: n.mergeable, + checks: (rollup?.state as FleetCheckState | undefined) ?? 'NONE', + failing, + running, + unresolved: (n.reviewThreads?.nodes ?? []).filter(t => !t.isResolved).length, + }, + ] + }) +} + +/** lock-status.ps1's JSON. */ +export function parseLockStatus(json: string): { slots: FleetSlot[]; queue: FleetWaiter[] } { + const raw = JSON.parse(json) as { slots?: FleetSlot[] | FleetSlot; queue?: FleetWaiter[] | FleetWaiter } + const list = (v: T[] | T | undefined): T[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]) + return { slots: list(raw.slots), queue: list(raw.queue) } +} + +export type Transition = { number: number; title: string; from: FleetCheckState; to: FleetCheckState; failing: string[] } + +/** CI results that settled since the last snapshot: worth a notification. */ +export function transitions(previous: readonly FleetPr[] | undefined, next: readonly FleetPr[]): Transition[] { + if (previous === undefined) { + return [] + } + const before = new Map(previous.map(p => [p.number, p.checks])) + return next.flatMap(pr => { + const from = before.get(pr.number) + const settled = pr.checks === 'SUCCESS' || pr.checks === 'FAILURE' || pr.checks === 'ERROR' + return from !== undefined && from !== pr.checks && settled ? [{ number: pr.number, title: pr.title, from, to: pr.checks, failing: pr.failing }] : [] + }) +} + +export function minutesSince(iso: string | null, now: number): number | undefined { + if (iso === null) { + return undefined + } + const t = Date.parse(iso) + return Number.isNaN(t) ? undefined : Math.max(0, Math.round((now - t) / 60_000)) +} + +/** "C:/repos/OloEngine-x/build-cached" from a build command, for naming who builds. */ +export function buildTree(command: string | null): string | null { + const m = command === null ? null : /--build\s+("?)([^"\s]+)\1/.exec(command) + return m?.[2] ?? null +} + +export function describeHolder(slot: FleetSlot, now: number): string { + const who = slot.worktree ? baseName(slot.worktree) : 'unknown' + // A sub-worktree a session fanned out into builds under the parent session's name: say which tree. + const tree = buildTree(slot.command) + const treeOwner = tree !== null && /[\\/]/.test(tree) ? baseName(tree.replace(/[\\/]build[\w-]*$/, '')) : who + const where = treeOwner !== who ? ` (${treeOwner})` : '' + const config = /--config\s+(\w+)/.exec(slot.command ?? '')?.[1] + const mins = minutesSince(slot.acquired, now) + return `${who}${where}${config ? ` ${config}` : ''}${mins !== undefined ? `, ${mins} min` : ''}` +} + +const CHECK_MARK: Record = { + SUCCESS: 'green', + FAILURE: 'RED', + ERROR: 'RED', + PENDING: 'running', + EXPECTED: 'waiting', + NONE: 'no checks', +} + +export function checkWord(state: FleetCheckState): string { + return CHECK_MARK[state] +} + +/** The one-line status: this worktree's PR, then the build lock. */ +export function statusLine(snapshot: FleetSnapshot, currentBranch: string | null): string { + const parts: string[] = [] + const mine = snapshot.prs.find(p => p.branch === currentBranch) + if (mine !== undefined) { + const threads = mine.unresolved > 0 ? `, ${mine.unresolved} open thread(s)` : '' + parts.push(`PR #${mine.number} ${checkWord(mine.checks)}${threads}`) + } + const held = snapshot.slots.filter(s => s.held).length + const waiting = snapshot.queue.filter(q => q.alive).length + parts.push(`build lock ${held}/${snapshot.slots.length} held${waiting > 0 ? `, ${waiting} waiting` : ''}`) + const red = snapshot.prs.filter(p => p.checks === 'FAILURE' || p.checks === 'ERROR').length + if (red > 0) { + parts.push(`${red} red PR(s)`) + } + return `fleet: ${parts.join(' · ')}` +} + +/** Worktrees whose branch has an open PR, keyed by branch. */ +export function prByBranch(prs: readonly FleetPr[]): Map { + return new Map(prs.map(p => [p.branch, p])) +} + +export function prLine(pr: FleetPr): string { + const parts = [pr.checks === 'PENDING' && pr.running > 0 ? `CI running, ${pr.running} check(s) left` : `CI ${checkWord(pr.checks)}`] + if (pr.failing.length > 0) { + parts.push(`failing: ${pr.failing.slice(0, 3).join(', ')}${pr.failing.length > 3 ? ', …' : ''}`) + } + if (pr.unresolved > 0) { + parts.push(`${pr.unresolved} open thread(s)`) + } + if (pr.mergeable === 'CONFLICTING') { + parts.push('CONFLICTS with master') + } + if (pr.isDraft) { + parts.push('draft') + } + return parts.join(', ') +} + +export function worktreeLine(w: FleetWorktree, pr: FleetPr | undefined): string { + const dirty = w.dirty === null ? '?' : w.dirty === 0 ? 'clean' : `${w.dirty} changed` + const branch = w.branch ?? `detached ${w.head}` + const prText = pr === undefined ? (w.isBase ? '' : 'no PR') : `PR #${pr.number}: ${prLine(pr)}` + return ` ${w.name} [${branch}] ${dirty}${prText ? ` ${prText}` : ''}` +} + +export function worktreeColor(w: FleetWorktree, pr: FleetPr | undefined): 'error' | 'success' | 'warning' | 'text' { + if (pr?.checks === 'FAILURE' || pr?.checks === 'ERROR' || pr?.mergeable === 'CONFLICTING') { + return 'error' + } + if (pr?.unresolved !== undefined && pr.unresolved > 0) { + return 'warning' + } + return pr?.checks === 'SUCCESS' ? 'success' : 'text' +} + +export function orphanPrs(snap: FleetSnapshot): FleetPr[] { + return snap.prs.filter(p => !snap.worktrees.some(w => w.branch === p.branch)) +} + +export function liveQueue(snap: FleetSnapshot): FleetWaiter[] { + return snap.queue.filter(q => q.alive) +} + +export function slotLine(slot: FleetSlot, now: number): string { + return `${slot.name}: ${slot.held ? `held by ${describeHolder(slot, now)}` : 'free'}` +} + +export function waiterLine(waiter: FleetWaiter, index: number, now: number): string { + const as: FleetSlot = { name: '', held: false, worktree: waiter.worktree, command: waiter.command, acquired: waiter.enqueued } + return `${index + 1}. ${describeHolder(as, now)} waiting` +} + +/** + * The fleet as markdown: what /fleet answers where no pane can be placed. It is also + * the text the model reads for that command row, so it stays short. + */ +export function summaryMarkdown(snap: FleetSnapshot, now: number): string { + const prs = prByBranch(snap.prs) + const lines = ['**Worktrees**', ''] + for (const w of snap.worktrees) { + lines.push(`- ${worktreeLine(w, prs.get(w.branch ?? '')).trim()}`) + } + for (const p of orphanPrs(snap)) { + lines.push(`- (no worktree) PR #${p.number} \`${p.branch}\`: ${prLine(p)}`) + } + lines.push('', '**Build lock**', '') + for (const s of snap.slots) { + lines.push(`- ${slotLine(s, now)}`) + } + liveQueue(snap).forEach((q, i) => lines.push(`- ${waiterLine(q, i, now)}`)) + const stale = snap.queue.length - liveQueue(snap).length + if (stale > 0) { + lines.push(`- ${stale} stale ticket(s) from dead processes`) + } + for (const err of snap.errors) { + lines.push(`- error: ${err}`) + } + return lines.join('\n') +} diff --git a/.claude/mods/olo-fleet/hooks/hooks.json b/.claude/mods/olo-fleet/hooks/hooks.json new file mode 100644 index 000000000..5a63f6495 --- /dev/null +++ b/.claude/mods/olo-fleet/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/.claude/mods/olo-fleet/hooks/register.tsx b/.claude/mods/olo-fleet/hooks/register.tsx new file mode 100644 index 000000000..994bb9cf8 --- /dev/null +++ b/.claude/mods/olo-fleet/hooks/register.tsx @@ -0,0 +1,239 @@ +import { atom, read, update } from 'claude-code' +import type { ElementTable, EngineInterface, Register } from 'claude-code' + +import type { FleetPr, FleetSnapshot, FleetWorktree } from '../types' +import { + liveQueue, + minutesSince, + orphanPrs, + parseLockStatus, + parsePrs, + parseWorktrees, + prByBranch, + prLine, + slotLine, + statusLine, + summaryMarkdown, + transitions, + waiterLine, + worktreeColor, + worktreeLine, +} from './fleet' + +const PANE = 'olo-fleet' +const REFRESH_MS = 3 * 60 * 1000 +const AFTER_PUBLISH_MS = 60 * 1000 +const SNAPSHOT = { plugin: 'olo-fleet', key: 'snapshot' } as const +const snapshotAtom = atom(SNAPSHOT, null) + +const PR_QUERY = + 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){pullRequests(states:OPEN,first:50){nodes{' + + 'number title headRefName isDraft mergeable url reviewThreads(first:100){nodes{isResolved}} ' + + 'commits(last:1){nodes{commit{statusCheckRollup{state contexts(first:100){nodes{__typename ' + + '... on CheckRun{name status conclusion} ... on StatusContext{context state}}}}}}}}}}}' + +// One refresh at a time; a timer tick during a slow refresh is dropped, not queued. +let isRefreshing = false + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: 'fleet', + description: 'Show every OloEngine worktree, its PR, CI and review threads, and who holds or waits for the build lock', + }) + $.clock.every(REFRESH_MS, () => { + void refresh($) + }) + void refresh($) + return next(e) + }) + + // /fleet always answers in its own transcript row (drawn by the CommandOutput hook + // below). The VS Code extension reports a mod's pane as placed but never draws it, so + // the pane is only a bonus for surfaces that do show one (the fullscreen terminal). + on('command.run', { command: 'fleet' }, async $ => { + void $.ui.open({ id: PANE, title: 'OloEngine fleet' }) + await refresh($, { force: true }) + const snap = (await $.state.get(SNAPSHOT)).value + return { text: snap === null || snap === undefined ? 'Could not read the fleet (git or gh failed).' : summaryMarkdown(snap, await $.clock.now()) } + }) + + // After a push or a new PR, CI starts: look again in a minute instead of waiting a full period. + on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => { + const ran = await next(e) + const command = typeof e.command === 'string' ? e.command : '' + if (ran.deny === undefined && /\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|ready|merge)\b/.test(command)) { + $.clock.after(AFTER_PUBLISH_MS, () => { + void refresh($) + }) + } + return ran + }) + + on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => + drawFleet($, $.ui.resolve(e), await read($, snapshotAtom), Math.max(40, e.props.bodyColumns)), + ) + + // Draws /fleet's own transcript row as the full view; the markdown text stays what the + // model reads and what a surface that cannot draw the tree shows. + on('ui.render', { component: 'CommandOutput', props: { command: 'fleet' } }, async ($, e, next) => { + const snap = await read($, snapshotAtom) + if (snap === null || e.props.isErrored) { + return next(e) + } + return drawFleet($, $.ui.resolve(e), snap, Math.max(40, e.viewport?.columns ?? 120)) + }) +} + +async function run($: EngineInterface, argv: string[], timeoutMs = 20_000): Promise { + try { + const r = await $.process.run(argv, { timeoutMs }) + return r.exitCode === 0 ? r.stdout : undefined + } catch { + return undefined + } +} + +async function refresh($: EngineInterface, opts: { force?: boolean } = {}): Promise { + if (isRefreshing) { + if (!opts.force) { + return + } + // A user asked: wait for the refresh already under way (bounded), then show its result. + for (let i = 0; i < 120 && isRefreshing; i += 1) { + await $.clock.sleep(500) + } + return + } + isRefreshing = true + try { + const errors: string[] = [] + const commonDir = (await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], 5000))?.trim() + if (commonDir === undefined || commonDir.length === 0) { + return + } + const base = commonDir.replace(/[\\/]\.git[\\/]?$/, '') + + const [porcelain, remote, branchOut, lockOut] = await Promise.all([ + run($, ['git', '-C', base, 'worktree', 'list', '--porcelain'], 10_000), + run($, ['git', '-C', base, 'remote', 'get-url', 'origin'], 5000), + run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], 5000), + run($, ['pwsh', '-NoProfile', '-NonInteractive', '-File', `${$.plugin.root}/scripts/lock-status.ps1`, '-CommonDir', commonDir], 20_000), + ]) + + const listed = porcelain === undefined ? [] : parseWorktrees(porcelain) + if (porcelain === undefined) { + errors.push('git worktree list failed') + } + const dirtyCounts = await Promise.all( + listed.map(async w => { + const out = await run($, ['git', '-C', w.path, 'status', '--porcelain'], 30_000) + return out === undefined ? null : out.split('\n').filter(l => l.trim().length > 0).length + }), + ) + const worktrees: FleetWorktree[] = listed.map((w, i) => ({ ...w, dirty: dirtyCounts[i] ?? null })) + + // A failed or garbled GitHub reply keeps the last PRs known (marked stale) rather than + // an empty list, so the next good reply still compares against real CI states. + const previous = await $.state.get(SNAPSHOT) + let prs: FleetPr[] = previous.value?.prs ?? [] + const repo = remote === undefined ? undefined : /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\s*$/.exec(remote) + if (repo?.[1] !== undefined && repo[2] !== undefined) { + const out = await run($, ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`, '-F', `owner=${repo[1]}`, '-F', `name=${repo[2]}`], 30_000) + if (out === undefined) { + errors.push('GitHub query failed (gh not authenticated, or offline); PR states are from the last good query') + } else { + try { + prs = parsePrs(out) + } catch { + errors.push('GitHub replied with something that is not the expected JSON (a proxy page?); PR states are from the last good query') + } + } + } + + let lock = { slots: [] as FleetSnapshot['slots'], queue: [] as FleetSnapshot['queue'] } + if (lockOut === undefined) { + errors.push('build-lock status could not be read') + } else { + try { + lock = parseLockStatus(lockOut) + } catch { + errors.push('build-lock status was not JSON') + } + } + + const snapshot: FleetSnapshot = { takenAt: await $.clock.now(), base, worktrees, prs, ...lock, errors } + await update($, snapshotAtom, () => snapshot) + + $.ui.status(statusLine(snapshot, branchOut?.trim() ?? null)) + + for (const t of transitions(previous.value?.prs, prs)) { + const text = + t.to === 'SUCCESS' + ? `PR #${t.number} CI is green: ${t.title}` + : `PR #${t.number} CI failed${t.failing.length > 0 ? ` (${t.failing.slice(0, 3).join(', ')})` : ''}: ${t.title}` + $.ui.toast(text, { timeoutMs: 10_000 }) + void $.ui.notify(text, { title: 'OloEngine CI' }).catch(() => undefined) + } + } catch { + // Every caller fires refresh and forgets it (`void refresh($)`): a rejection here would be + // unhandled. The next tick tries again. + } finally { + isRefreshing = false + } +} + + +async function drawFleet($: EngineInterface, el: ElementTable, snap: FleetSnapshot | null, width: number) { + const { Box, Text, Button } = el + if (snap === null) { + return ( + + Collecting worktrees, PRs and the build lock… + + ) + } + const now = await $.clock.now() + const prs = prByBranch(snap.prs) + const clip = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s) + + return ( + + Worktrees + {snap.worktrees.map(w => ( + + {clip(worktreeLine(w, prs.get(w.branch ?? '')))} + + ))} + {orphanPrs(snap).map(p => ( + + {clip(` (no worktree) #${p.number} ${p.branch}: ${prLine(p)}`)} + + ))} + + Build lock + {snap.slots.map(s => ( + + {clip(` ${slotLine(s, now)}`)} + + ))} + {liveQueue(snap).map((q, i) => ( + + {clip(` ${waiterLine(q, i, now)}`)} + + ))} + {snap.queue.some(q => !q.alive) && ( + {` ${snap.queue.filter(q => !q.alive).length} stale ticket(s) from dead processes (build-lock skips them)`} + )} + {snap.errors.map((err, i) => ( + + {clip(` ${err}`)} + + ))} + + {`Updated ${minutesSince(new Date(snap.takenAt).toISOString(), now) ?? 0} min ago `} +