sloptimize 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // sloptimize CLI — report | check | census | history | fix | doctor (SPEC §8.1)
4
+ // ============================================================
5
+ // Files-first: every verb reads `.sloptimize/` in the cwd (or --dir) and
6
+ // says what it cannot know instead of guessing. Exit codes are API:
7
+ // check: 0 all budgets pass · 1 breach · 4 no measurement / no budgets file
8
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const args = process.argv.slice(2);
13
+ const cmd = args[0];
14
+ if (cmd === '--version' || cmd === '-v') {
15
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
16
+ console.log(pkg.version);
17
+ process.exit(0);
18
+ }
19
+ const json = args.includes('--json');
20
+ const dirFlag = args.indexOf('--dir');
21
+ const DIR = dirFlag >= 0 ? args[dirFlag + 1] : '.sloptimize';
22
+
23
+ function readJson(name) {
24
+ const p = join(DIR, name);
25
+ if (!existsSync(p)) return null;
26
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
27
+ }
28
+ function readJsonl(name, limit = 50) {
29
+ const p = join(DIR, name);
30
+ if (!existsSync(p)) return [];
31
+ const lines = readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
32
+ return lines.slice(-limit).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
33
+ }
34
+ function out(obj, human) { console.log(json ? JSON.stringify(obj, null, 2) : human); }
35
+
36
+ if (cmd === 'report') {
37
+ const profile = readJson('profile.json');
38
+ // 80 lines, not 20: heartbeats (1/min while a session is armed) share the
39
+ // ledger and must not crowd the actual incidents out of the report window.
40
+ const hitches = readJsonl('perf.jsonl', 80);
41
+ const marks = hitches.filter((h) => h.type === 'usermark');
42
+ const auto = hitches.filter((h) => h.type === 'hitch');
43
+ const jitters = hitches.filter((h) => h.type === 'jitter');
44
+ const census = readJson('census.json');
45
+ if (json) { out({ profile, hitches: auto, usermarks: marks, jitters, census }); process.exit(0); }
46
+ if (!profile) { console.log('no profile.json — is the game running with the sloptimize runtime?'); process.exit(4); }
47
+ console.log(`profile @ ${profile.at} regime=${profile.regime ?? 'unknown'}`);
48
+ const beats = hitches.filter((h) => h.type === 'heartbeat');
49
+ const lastBeat = beats[beats.length - 1];
50
+ if (lastBeat) console.log(` feed: last heartbeat @ ${lastBeat.at} build=${lastBeat.build ?? '?'} phase=${lastBeat.phase ?? '?'} median ${lastBeat.medianFrameMs}ms p95 ${lastBeat.p95Ms}ms`);
51
+ if (profile.frame?.medianMs !== undefined) {
52
+ console.log(` frame median ${profile.frame.medianMs}ms p95 ${profile.frame.p95Ms}ms (~${profile.frame.fps}fps) inside-render ${profile.frame.insideRenderMs}ms`);
53
+ }
54
+ if (profile.render) console.log(` calls ${profile.render.calls} triangles ${profile.render.triangles} programs ${profile.memory?.programs}`);
55
+ console.log(` hitches recorded: ${auto.length} (showing last ${Math.min(auto.length, 20)}) usermarks: ${marks.length}`);
56
+ for (const h of auto.slice(-5)) {
57
+ console.log(` · ${h.at} ${h.frameMs}ms (median ${h.medianMs}) → ${h.classification?.[0]?.guess}: ${h.classification?.[0]?.evidence}`);
58
+ }
59
+ for (const m of marks.slice(-3)) {
60
+ const w = m.worstFrames?.[0];
61
+ console.log(` ★ usermark ${m.at} ${m.note ?? ''} — window ${m.window?.frames}f median ${m.window?.medianMs}ms; worst ${w?.frameMs}ms → ${w?.classification?.[0]?.guess}`);
62
+ }
63
+ if (jitters.length) {
64
+ // Coordinate jumps (SPEC §3.6): the unit or the camera landed off its own
65
+ // trajectory. Listed apart from hitches — a snap at 60fps is not a slow frame.
66
+ console.log(` jitters recorded: ${jitters.length} (showing last ${Math.min(jitters.length, 5)})`);
67
+ for (const j of jitters.slice(-5)) {
68
+ const shape = j.kind === 'oscillation' ? `oscillation ×${j.frames} amp ${j.amplitude}` : `snap ${j.units} [${(j.jump ?? []).join(', ')}]`;
69
+ console.log(` ↯ ${j.at} ${j.track} ${shape} in a ${j.dtMs}ms frame → ${j.classification?.[0]?.guess}: ${j.classification?.[0]?.evidence}`);
70
+ }
71
+ }
72
+ // The catalogue's head: which causes recur most (SPEC §3.7). The whole
73
+ // ledger, not the 80-line window — recurrence is the point.
74
+ const { buildIssues, agoText } = await import('../src/history.js');
75
+ const issues = buildIssues(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity) });
76
+ if (issues.length) {
77
+ console.log(` issues (${issues.length} footprints; top 5 by occurrences — \`sloptimize issues\` for all):`);
78
+ for (const i of issues.slice(0, 5)) console.log(` ${i.glyph} fp=${i.id} ×${i.count} ${i.label} [${i.phase}] last ${agoText(i.lastAgoMs)}${i.fixes.length ? ` fixes: ${i.fixes.length}` : ''}`);
79
+ }
80
+ if (census?.hints?.length) {
81
+ console.log(` census hints (${census.hints.length}):`);
82
+ for (const h of census.hints.slice(0, 8)) console.log(` · [${h.kind}] ${h.entity ?? ''} ${h.detail}`);
83
+ }
84
+ process.exit(0);
85
+ }
86
+
87
+ if (cmd === 'issues') {
88
+ // The issue catalogue (SPEC §3.7): every incident type grouped by
89
+ // footprint, with occurrences, first/last, builds, worst, and the fixes
90
+ // applied to it. `--from/--to` scope the count; `--all` includes robots.
91
+ const { buildIssues, agoText } = await import('../src/history.js');
92
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
93
+ const issues = buildIssues(readJsonl('perf.jsonl', Infinity), {
94
+ fixes: readJsonl('fixes.jsonl', Infinity), from: get('--from'), to: get('--to'), includeAutomated: args.includes('--all'),
95
+ });
96
+ if (json) { out(issues); process.exit(0); }
97
+ if (issues.length === 0) { console.log('no incidents on the ledger yet'); process.exit(4); }
98
+ const only = get('--fp');
99
+ for (const i of issues) {
100
+ if (only && i.id !== only) continue;
101
+ console.log(`${i.glyph} fp=${i.id} ×${String(i.count).padEnd(5)} ${i.label.padEnd(44)} [${i.phase}] last ${agoText(i.lastAgoMs).padEnd(8)} first ${i.first.slice(0, 16)} builds ${i.builds.length}${i.worst ? ` worst ${+i.worst.value.toFixed(1)}${i.worst.unit}` : ''}`);
102
+ if (only || issues.length <= 8) {
103
+ console.log(` key ${i.key}`);
104
+ if (i.sample) console.log(` last verdict: ${i.sample.guess} — ${i.sample.evidence}`);
105
+ for (const f of i.fixes) console.log(` ✔ ${f.at.slice(0, 10)} ${f.status ?? 'recorded'} ${f.title}${f.commit ? ` (${f.commit})` : ''}${f.pr?.url ? ` ${f.pr.url}` : ''}`);
106
+ if (i.fixes.length === 0) console.log(` no fix recorded — sloptimize fix propose --footprints ${i.id} --title "…"`);
107
+ }
108
+ }
109
+ process.exit(0);
110
+ }
111
+
112
+ if (cmd === 'check') {
113
+ const profile = readJson('profile.json');
114
+ const budgets = readJson('budgets.json'); // { "perf.budget.draw_calls": 300, ... }
115
+ if (!profile) { out({ error: 'no measurement' }, 'no profile.json to check against'); process.exit(4); }
116
+ if (!budgets || Object.keys(budgets).length === 0) {
117
+ out({ warning: 'no budgets declared', breached: [] }, 'no budgets declared (create .sloptimize/budgets.json) — passing with a warning');
118
+ process.exit(0);
119
+ }
120
+ const countersOnly = args.includes('--counters-only') || profile.regime === 'software';
121
+ const results = [];
122
+ const read = {
123
+ 'perf.budget.draw_calls': profile.render?.calls,
124
+ 'perf.budget.triangles': profile.render?.triangles,
125
+ 'perf.budget.frame_ms_p95': countersOnly ? undefined : profile.frame?.p95Ms,
126
+ 'perf.budget.programs': profile.memory?.programs,
127
+ };
128
+ let breached = 0;
129
+ for (const [k, budget] of Object.entries(budgets)) {
130
+ const v = read[k];
131
+ if (v === undefined) { results.push({ budget: k, value: null, limit: budget, verdict: countersOnly && k.includes('ms') ? 'skipped (counters-only)' : 'unmeasured' }); continue; }
132
+ const over = v > budget;
133
+ if (over) breached++;
134
+ results.push({ budget: k, value: v, limit: budget, verdict: over ? `over by ${(v / budget).toFixed(1)}x` : 'inside' });
135
+ }
136
+ out({ checked: results.length, breached, results },
137
+ results.map((r) => ` ${r.budget.padEnd(28)} ${String(r.value).padStart(10)} / ${r.limit} ${r.verdict}`).join('\n')
138
+ + `\nbudgets: ${results.length} checked, ${breached} breached`);
139
+ process.exit(breached > 0 ? 1 : 0);
140
+ }
141
+
142
+ if (cmd === 'census') {
143
+ const census = readJson('census.json');
144
+ if (!census) { console.log('no census.json — trigger a walk from the running game (__sloptimize.census())'); process.exit(4); }
145
+ if (json) { console.log(JSON.stringify(census, null, 2)); process.exit(0); }
146
+ console.log(`census @ ${census.at}: ${census.totals.meshes} meshes, ${census.totals.triangles} tris, ${census.totals.uniqueMaterials} materials, ${census.totals.uniqueGeometries} geometries`);
147
+ const rows = [...census.entities].sort((a, b) => b.triangles - a.triangles).slice(0, 15);
148
+ for (const e of rows) {
149
+ console.log(` ${String(e.id).padEnd(28)} meshes ${String(e.meshes).padStart(5)} tris ${String(e.triangles).padStart(9)} mats ${String(e.uniqueMaterials).padStart(3)} shadow-casters ${e.castShadow}`);
150
+ }
151
+ for (const h of census.hints ?? []) console.log(` hint [${h.kind}] ${h.entity ?? ''}: ${h.detail}`);
152
+ process.exit(0);
153
+ }
154
+
155
+ if (cmd === 'doctor') {
156
+ const profile = readJson('profile.json');
157
+ console.log('sloptimize doctor');
158
+ console.log(` data dir: ${DIR} ${existsSync(DIR) ? '(present)' : '(MISSING — runtime not wired or game not run)'}`);
159
+ console.log(` profile.json: ${profile ? `fresh as of ${profile.at}` : 'absent'}`);
160
+ console.log(` regime: ${profile?.regime ?? 'unknown'} — timing numbers from a software regime are flagged and never compared`);
161
+ console.log(' stated limits: no per-draw GPU timing; bisection ranks, never sums; workload repro not trajectory repro;');
162
+ console.log(' gpu:* instruments fire only under a real WebGPU backend — a WebGL2-fallback session reads them as zeros, honestly;');
163
+ console.log(' bench/gate (M3) not built yet in this install — verify fixes with counters (exact grade) + real-hardware sessions.');
164
+ process.exit(0);
165
+ }
166
+
167
+ if (cmd === 'hook-status') {
168
+ // ≤5 lines for a UserPromptSubmit hook, and SILENT (exit 0, no output)
169
+ // when nothing is new — ambient perf the way selection is ambient, and
170
+ // only when it matters (SPEC §8.1). Multi-dir: a game served from the main
171
+ // checkout and a worktree under active surgery both count.
172
+ const dirs = [];
173
+ for (let i = 0; i < args.length; i++) if (args[i] === '--dir' && args[i + 1]) dirs.push(args[i + 1]);
174
+ if (dirs.length === 0) dirs.push('.sloptimize');
175
+ const stateP = join(dirs[0], '.hook-state.json');
176
+ let state = {};
177
+ try { state = JSON.parse(readFileSync(stateP, 'utf8')); } catch { /* first run */ }
178
+ const lines = [];
179
+ for (const dir of dirs) {
180
+ const read = (n) => { try { return JSON.parse(readFileSync(join(dir, n), 'utf8')); } catch { return null; } };
181
+ const readL = (n) => { try { return readFileSync(join(dir, n), 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
182
+ const profile = read('profile.json');
183
+ const recs = readL('perf.jsonl');
184
+ const marks = recs.filter((r) => r.type === 'usermark');
185
+ const lastMark = marks[marks.length - 1];
186
+ const seenKey = `mark:${dir}`;
187
+ if (lastMark && state[seenKey] !== lastMark.at) {
188
+ state[seenKey] = lastMark.at;
189
+ const w = lastMark.worstFrames && lastMark.worstFrames[0];
190
+ lines.push(`sloptimize ★ NEW perf keyframe (${lastMark.note ?? 'Ctrl+F11'}) @ ${lastMark.at}: window ${lastMark.window?.frames}f median ${lastMark.window?.medianMs}ms; worst ${w?.frameMs}ms → ${w?.classification?.[0]?.guess} (${w?.classification?.[0]?.evidence}) [${dir}/perf.jsonl]`);
191
+ }
192
+ const budgets = read('budgets.json');
193
+ if (profile && budgets) {
194
+ const countersOnly = profile.regime !== 'hardware';
195
+ const readV = { 'perf.budget.draw_calls': profile.render?.calls, 'perf.budget.triangles': profile.render?.triangles, 'perf.budget.frame_ms_p95': countersOnly ? undefined : profile.frame?.p95Ms, 'perf.budget.programs': profile.memory?.programs };
196
+ const over = Object.entries(budgets).filter(([k, b]) => readV[k] !== undefined && readV[k] > b);
197
+ const overKey = `over:${dir}`;
198
+ const sig = over.map(([k]) => k).join(',');
199
+ if (over.length && state[overKey] !== sig) {
200
+ state[overKey] = sig;
201
+ lines.push(`sloptimize ⚠ budget breach (${profile.regime}): ` + over.map(([k, b]) => `${k} ${readV[k]}/${b}`).join(' '));
202
+ } else if (!over.length) state[overKey] = '';
203
+ }
204
+ // Liveness: heartbeats keep perf.jsonl fresh while a session is armed, so
205
+ // a stale ledger MEANS the feed is dark or the session is over — not
206
+ // merely idle. Said once per distinct last-record (state-deduped): the
207
+ // instrument going silently dark cost an hour of debugging blind
208
+ // (2026-08-24 — a runner restart dropped the ingest and nobody was told).
209
+ const lastRec = recs[recs.length - 1];
210
+ if (lastRec && lastRec.at) {
211
+ const ageMin = (Date.now() - Date.parse(lastRec.at)) / 60000;
212
+ const staleKey = `stale:${dir}`;
213
+ if (ageMin > 45) {
214
+ if (state[staleKey] !== lastRec.at) {
215
+ state[staleKey] = lastRec.at;
216
+ lines.push(`sloptimize ◌ feed quiet ${Math.round(ageMin)}min (last: ${lastRec.type} @ ${lastRec.at}) [${dir}] — session over, or the feed went dark (ingest disarmed?)`);
217
+ }
218
+ } else state[staleKey] = '';
219
+ }
220
+ }
221
+ try { const { writeFileSync, mkdirSync } = await import('node:fs'); mkdirSync(dirs[0], { recursive: true }); writeFileSync(stateP, JSON.stringify(state)); } catch { /* stateless is only chattier */ }
222
+ if (lines.length) console.log(lines.slice(0, 5).join('\n'));
223
+ process.exit(0);
224
+ }
225
+
226
+ // ── The fix loop, git only (src/proposals.mjs) ──────────────────────────────
227
+ // fix propose --title … [--issue … --solution … --files a,b --footprints id,id --branch … --no-push]
228
+ // fix merge <id> · fix reject <id> · fixes [--json] · policy · settings --automation propose|merge
229
+ const sub = args[1];
230
+ if ((cmd === 'fix' && ['propose', 'merge', 'reject', 'list'].includes(sub)) || cmd === 'fixes' || cmd === 'policy' || cmd === 'settings') {
231
+ const P = await import('../src/proposals.mjs');
232
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
233
+ const REPO = get('--repo') ?? process.cwd();
234
+ const fail = (e) => { console.error(`sloptimize ${cmd}${sub ? ` ${sub}` : ''}: ${e.message}`); process.exit(e.message === P.NOT_A_REPO ? 3 : 1); };
235
+ try {
236
+ if (cmd === 'policy') {
237
+ const s = P.readSettings(DIR);
238
+ out({ ...s, repo: P.isGitRepo(REPO) }, `automation: ${s.automation}${P.isGitRepo(REPO) ? '' : `\n${P.NOT_A_REPO}`}`);
239
+ process.exit(0);
240
+ }
241
+ if (cmd === 'settings') {
242
+ const level = get('--automation');
243
+ const s = level ? P.writeSettings(DIR, { automation: level }) : P.readSettings(DIR);
244
+ out(s, `automation: ${s.automation}`);
245
+ process.exit(0);
246
+ }
247
+ if (cmd === 'fixes' || sub === 'list') {
248
+ const l = P.listFixes(REPO, DIR);
249
+ if (json) { out(l); process.exit(0); }
250
+ if (!l.repo) { console.log(l.error); process.exit(3); }
251
+ if (l.fixes.length === 0) { console.log('no proposals yet — `sloptimize fix propose --title "…"` records one'); process.exit(0); }
252
+ for (const f of l.fixes) console.log(` ${f.status.padEnd(9)} ${f.at.slice(0, 16)} ${f.title}${f.branch ? ` [${f.branch} @ ${f.commit}${f.upToDate === false ? ', behind main' : ''}]` : ''} id=${f.id}`);
253
+ process.exit(0);
254
+ }
255
+ if (sub === 'propose') {
256
+ if (!get('--title')) { console.error('sloptimize fix propose: --title is required'); process.exit(2); }
257
+ const { buildFix } = await import('../src/history.js');
258
+ const records = readJsonl('perf.jsonl', Infinity);
259
+ const fix = P.proposeFix(REPO, DIR, {
260
+ title: get('--title'), issue: get('--issue'), solution: get('--solution'), branch: get('--branch'),
261
+ files: get('--files')?.split(','), push: !args.includes('--no-push'),
262
+ footprints: get('--footprints')?.split(',').filter(Boolean),
263
+ measure: () => { const f = buildFix(records, { title: get('--title'), before: get('--before'), after: get('--after') }); return { before: f.before, after: f.after }; },
264
+ });
265
+ out(fix, `proposed: ${fix.title}\n branch ${fix.branch} @ ${fix.commit}${fix.pushed ? ' (pushed)' : ''}\n id ${fix.id}${fix.before ? '' : '\n (no measured before/after yet — the numbers land when it is played)'}`);
266
+ process.exit(0);
267
+ }
268
+ const id = args[2];
269
+ if (!id) { console.error(`sloptimize fix ${sub}: <id> is required (see \`sloptimize fixes\`)`); process.exit(2); }
270
+ const r = sub === 'merge' ? P.mergeFix(REPO, DIR, id) : P.rejectFix(REPO, DIR, id);
271
+ out(r, `${r.status}: ${id}${r.mergeCommit ? ` → ${r.mergeCommit}` : ''}${r.pushed ? ' (pushed)' : ''}`);
272
+ process.exit(0);
273
+ } catch (e) { fail(e); }
274
+ }
275
+
276
+ if (cmd === 'history' || cmd === 'fix') {
277
+ // The timeline and the fix ledger (SPEC §8.5). `history` folds perf.jsonl
278
+ // into buckets + per-build windows; `fix` appends one report to
279
+ // fixes.jsonl whose before/after are MEASURED windows of that ledger —
280
+ // the agent names the issue, the solution and the commit; the numbers
281
+ // come from the recorder, never from the agent.
282
+ const { buildHistory, buildFix } = await import('../src/history.js');
283
+ const records = readJsonl('perf.jsonl', Infinity);
284
+ const fixes = readJsonl('fixes.jsonl', Infinity);
285
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
286
+ const fmt = (v, unit = '') => (v === undefined ? '—' : `${v}${unit}`);
287
+ const line = (s) => `p95 ${fmt(s.p95Ms, 'ms')} calls ${fmt(s.calls)} hitches ${s.hitches} (${fmt(s.hitchesPerHour)}/h, worst ${fmt(s.worstMs, 'ms')}${s.worstGuess ? ` ${s.worstGuess}` : ''})`;
288
+ if (cmd === 'fix') {
289
+ let commit = get('--commit');
290
+ if (!commit) {
291
+ try { const { execSync } = await import('node:child_process'); commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { /* not a repo */ }
292
+ }
293
+ if (!get('--title')) { console.error('sloptimize fix: --title is required'); process.exit(2); }
294
+ let fix;
295
+ try {
296
+ fix = buildFix(records, { title: get('--title'), issue: get('--issue'), solution: get('--solution'), commit,
297
+ files: get('--files')?.split(','), before: get('--before'), after: get('--after'),
298
+ footprints: get('--footprints')?.split(',').filter(Boolean) });
299
+ } catch (e) { console.error(`sloptimize fix: ${e.message}`); process.exit(4); }
300
+ const { appendFileSync, mkdirSync } = await import('node:fs');
301
+ mkdirSync(DIR, { recursive: true });
302
+ appendFileSync(join(DIR, 'fixes.jsonl'), JSON.stringify(fix) + '\n');
303
+ out(fix, `fix recorded: ${fix.title}${fix.commit ? ` (${fix.commit})` : ''}\n before ${fix.before.build ?? fix.before.from}: ${line(fix.before)}\n after ${fix.after.build ?? fix.after.from}: ${line(fix.after)}`);
304
+ process.exit(0);
305
+ }
306
+ const h = buildHistory(records, { fixes, buckets: Number(get('--buckets')) || 24 });
307
+ if (json) { out(h); process.exit(0); }
308
+ if (!h.span) { console.log('no measured records in perf.jsonl yet'); process.exit(4); }
309
+ console.log(`history ${h.span.from} → ${h.span.to} (${h.builds.length} builds, ${h.fixes.length} fixes)`);
310
+ for (const b of h.builds) console.log(` build ${b.build.padEnd(16)} ${b.from.slice(0, 16)} ${line(b)}`);
311
+ console.log(' buckets:');
312
+ for (const b of h.buckets) console.log(` ${b.from.slice(5, 16)} p95 ${String(fmt(b.p95Ms)).padStart(7)} calls ${String(fmt(b.calls)).padStart(5)} hitches ${String(b.hitches).padStart(3)} ${b.worstMs ? `worst ${b.worstMs}ms ${b.worstGuess ?? ''}` : ''}`);
313
+ for (const f of h.fixes) console.log(` ✔ ${f.at.slice(0, 10)} ${f.title}${f.commit ? ` (${f.commit})` : ''}: p95 ${fmt(f.before.p95Ms, 'ms')} → ${fmt(f.after.p95Ms, 'ms')}, hitches/h ${fmt(f.before.hitchesPerHour)} → ${fmt(f.after.hitchesPerHour)}`);
314
+ process.exit(0);
315
+ }
316
+
317
+ if (cmd === 'watch') {
318
+ // The push channel (SPEC §8.1.1): tail every --dir's perf.jsonl and print
319
+ // one line per record an agent should wake for. Never exits — arm it as a
320
+ // Claude Code Monitor (INTEGRATION.md §5) and just play.
321
+ const { runWatch } = await import('../src/watch.mjs');
322
+ const dirs = [];
323
+ for (let i = 0; i < args.length; i++) if (args[i] === '--dir' && args[i + 1]) dirs.push(args[i + 1]);
324
+ if (dirs.length === 0) dirs.push('.sloptimize');
325
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? Number(args[i + 1]) : undefined; };
326
+ await runWatch(dirs, { intervalMs: get('--interval') ? get('--interval') * 1000 : undefined, minHitchMs: get('--min-hitch-ms') });
327
+ }
328
+
329
+ if (cmd === 'attach') {
330
+ // Tier 0 (SPEC-attach): zero-integration attach. --launch <url> spawns a
331
+ // browser; bare attach uses an existing --remote-debugging-port session.
332
+ const { attach } = await import('../src/attach.mjs');
333
+ const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
334
+ const session = await attach({
335
+ launch: get('--launch'),
336
+ port: get('--port') ? Number(get('--port')) : undefined,
337
+ dir: get('--dir') ?? '.sloptimize',
338
+ headless: args.includes('--headless'),
339
+ });
340
+ console.log('[attach] recording — Ctrl+C to stop');
341
+ process.on('SIGINT', async () => { await session.close(); process.exit(0); });
342
+ await new Promise(() => {});
343
+ }
344
+
345
+ console.log('usage: sloptimize <report|issues|check|census|history|fix|doctor|hook-status|watch|attach> [--json] [--dir <path>]... [--counters-only] [--interval <s>] [--min-hitch-ms N] [--launch <url>] [--port N] [--headless]\n sloptimize fix --title "…" [--issue "…"] [--solution "…"] [--commit sha] [--files a,b] [--footprints id,id] [--before <build|ISO..ISO>] [--after <build|ISO..ISO>]\n sloptimize issues [--json] [--from ISO] [--to ISO] [--fp <id>] [--all]');
346
+ process.exit(2);
@@ -0,0 +1,32 @@
1
+ # v0 field deployment: mecharoyale (decision record)
2
+
3
+ The first real integration target diverges from the spec's assumed platform
4
+ in three ways, and v0 is shaped by meeting the game where it is:
5
+
6
+ 1. **No vite.** The game builds with esbuild and serves itself. The vite
7
+ plugin (§1) is therefore not the v0 transport: the in-page runtime posts
8
+ payloads to a dev-gated endpoint on the game's own server
9
+ (`POST /api/sloptimize/ingest`, active only under `ALLOW_DEBUG_SPAWN=1`),
10
+ which lands them in `.sloptimize/` exactly as the plugin would. Same
11
+ files, same schemas, different pipe. The vite plugin remains the plan for
12
+ vite hosts.
13
+ 2. **WebGPURenderer.** `renderer.info` exists with the same counters
14
+ (`render.drawCalls` not `render.calls`, `render.triangles`,
15
+ `memory.geometries/textures`; programs via the pipeline cache). The
16
+ integration maps names at the sampling site; the recorder is
17
+ renderer-agnostic numbers-in.
18
+ 3. **`@slopjs/inspector` is present but constrained** (`?debug&inspect`, own
19
+ bundle, pause refused for live multiplayer — the game's own integration
20
+ notes). v0 therefore ships M0+M1 (+§3.5 usermarks): recorder, census,
21
+ hints, CLI report/check/census/doctor. The bench/gate tier (M3) and
22
+ paused-world bisection (M4) wait until the pause story for a
23
+ client-authoritative multiplayer game is resolved; `doctor` says so.
24
+
25
+ Budgets: v0 reads `.sloptimize/budgets.json` (the game has no vite tunables
26
+ panel wired for `tune()`); moving to `tune('perf.budget.*')` when the
27
+ inspector's tunables surface is adopted by the host remains the spec path.
28
+
29
+ The host feeds frames from its ONE render loop (both of the game's frame
30
+ loops call a single `sloptimizeFrame()` after their render call), the census
31
+ walks the live match scene with the game's own entity groupings, and
32
+ Ctrl+F11 is wired per §3.5.
@@ -0,0 +1,230 @@
1
+ # Integrating sloptimize into a game (and its Claude Code session)
2
+
3
+ What the mecharoyale deployment wired, generalized. Five pieces; each is
4
+ small, and the first three are enough to be useful. This doc is the ONE-TIME
5
+ wiring; for how to use the result day to day (the operator's verbs, how new
6
+ and concurrent Claude Code sessions pick up the feed), see USAGE.md. The
7
+ jitter detector and the footprint/issue catalogue have their own step-by-step
8
+ cookbook with the traps spelled out: JITTER-AND-FOOTPRINTS.md — read it
9
+ after §1 here.
10
+
11
+ ## 1. The in-page runtime (the game feeds the recorder)
12
+
13
+ ```js
14
+ import { createRecorder, buildCensus } from 'sloptimize';
15
+ const rec = createRecorder({ budgetFrameMs: 16.7 });
16
+ ```
17
+
18
+ Once per frame, from wherever your loop already reads `renderer.info`,
19
+ hand the recorder numbers you already have (≤0.2ms, zero allocations):
20
+
21
+ ```js
22
+ rec.frame({
23
+ frameMs, // rAF-to-rAF delta (must bound insideRenderMs)
24
+ insideRenderMs, // wall time inside renderer.render
25
+ calls, triangles, // renderer.info.render.* (WebGPU: drawCalls)
26
+ programs, // program/pipeline count (WebGPU: pipeline cache size)
27
+ geometries, textures, // renderer.info.memory.*
28
+ spawned, // entities added this frame (0 if unknown)
29
+ paused: false,
30
+ });
31
+ ```
32
+
33
+ Hitches are detected, classified with evidence, and rate-limited
34
+ automatically.
35
+
36
+ Coordinate jitter (SPEC §3.6) is the second detector — the unit or the
37
+ camera landing off its own trajectory. Feed it once per RENDERED frame,
38
+ after the render (so a transient camera shake the host restores is not
39
+ sampled), with the same clock every frame:
40
+
41
+ ```js
42
+ import { createMotionMonitor } from 'sloptimize';
43
+ const motion = createMotionMonitor({
44
+ unit: 'm',
45
+ longFrameMs: 50, // YOUR sim's dt clamp: a frame past it cannot have its motion judged
46
+ tracks: {
47
+ unit: { floor: 0.1 }, // the view pivot: rotation-invariant
48
+ camera: { floor: 0.1, reach: 'boom', follows: 'unit' }, // the eye; reach = distance to the pivot
49
+ },
50
+ });
51
+ // per frame:
52
+ motion.sample('unit', pivot.x, pivot.y, pivot.z, performance.now(), { held: paused || !continuityExpected, phase });
53
+ motion.sample('camera', camera.x, camera.y, camera.z, performance.now(), { held: lookInputThisFrame || paused, reach, phase });
54
+ // on a camera-mode flip, a spectate-target change, a respawn, a session boundary:
55
+ motion.cut();
56
+ // drain beside the recorder's records — same pipe, same ledger:
57
+ post('records', [...rec.drainRecords(), ...motion.drainRecords()]);
58
+ ```
59
+
60
+ Every record's FOOTPRINT (SPEC §3.7) — the identity of its cause — is
61
+ stamped by the writer at post time, and the game's SITUATION rides in it.
62
+ Declare the facets that make two incidents two issues in your game
63
+ (categories only, never positions), refresh the canonical string once a
64
+ second, and hand it along:
65
+
66
+ ```js
67
+ import { canonicalContext, footprintOf } from 'sloptimize';
68
+ let ctx = '';
69
+ setInterval(() => { ctx = canonicalContext({ stance: 'helm', hull: 'elong-x', squad: 'duo', combat: 'no' }); }, 1000);
70
+ rec.frame({ …, ctx }); // stamped on hitches at mint
71
+ rec.usermark({ …, ctx });
72
+ motion.sample('unit', x, y, z, t, { held, phase, ctx });
73
+ // at post, for every record (host-built ones take the current ctx):
74
+ for (const r of records) { if (r.ctx === undefined && ctx) r.ctx = ctx; const fp = footprintOf(r); if (fp) r.footprint = fp; }
75
+ ```
76
+
77
+ `held` frames are not judged and re-seed the track (a mouse flick swings a
78
+ boom metres in one frame — intended; the reference runtime marks a frame
79
+ held when a mousemove/wheel/touchmove landed since the previous sample).
80
+ `cut()` is for the discontinuities the host MEANT; the reference runtime
81
+ derives them from a view-configuration key (pivot publisher · third-person
82
+ flag · spectated subject) and cuts whenever it changes, so no call site has
83
+ to remember. Bind a chord for the manual channel — capture FIRST, then
84
+ optionally ask the human for one line (see mecharoyale's
85
+ `dev/sloptimize-runtime.ts` for a complete reference including the note
86
+ overlay, held-input tracking, WebGPU regime detection, and the
87
+ GPU-process wrappers):
88
+
89
+ ```js
90
+ if (ctrlF11) { const mark = rec.usermark({ windowMs: 5000, note, world }); }
91
+ ```
92
+
93
+ The debugger itself ships in the package — `createPanel` (Session ·
94
+ Timeline · Fixes + the note box), dependency-free, inline-styled. Capture
95
+ first, then open it; it swallows every key at capture phase while open and
96
+ calls `onNote` exactly once on close:
97
+
98
+ ```js
99
+ import { createPanel } from 'sloptimize';
100
+ const panel = createPanel({
101
+ incidents: () => sessionIncidents, // rows the recorder drained this tab
102
+ feed: () => ({ state: 'ok' }), // or { state: 'dark', reason, buffered }
103
+ history: () => fetch('/api/sloptimize/ledger').then((r) => r.json())
104
+ .then(({ perf, fixes }) => ({ records: parseJsonl(perf), fixes: parseJsonl(fixes) })),
105
+ onNote: (note) => { if (note) { mark.note = `f12: ${note}`; post('records', [mark]); } },
106
+ });
107
+ panel.open();
108
+ ```
109
+
110
+ ## 2. The sink (files on disk)
111
+
112
+ - **Vite host**: the plugin (planned surface) lands payloads in `.sloptimize/`.
113
+ - **Any other host** (esbuild, custom server): add one dev-only endpoint —
114
+ `POST /api/sloptimize/ingest` `{kind: 'profile'|'records'|'census', payload}`
115
+ → writes `.sloptimize/profile.json`, appends `perf.jsonl`, writes
116
+ `census.json`. Gate it to your dev flag and 404 identically to unknown
117
+ routes otherwise (mecharoyale: `server/admin/sloptimize-ingest.ts`, ~100
118
+ lines + tests).
119
+ - **Activation**: don't gate the client on `location.hostname` — a dev
120
+ preview proxy looks like production. Probe the ingest endpoint at boot;
121
+ a 204 arms everything, anything else stays dark — and RETRY the probe on
122
+ a backoff (5s/30s/2min, then every 5min): an ingest that comes back
123
+ mid-session must re-light the instrument without a hard refresh.
124
+ - **Transport state, never silent**: once armed, a refused or failed post
125
+ flips the feed DARK — buffer outgoing posts (bounded, count drops), retry
126
+ on the same backoff, and SHOW the state (the reference runtime renders it
127
+ on its PERF chip and in the debugger header, with the reason). The first
128
+ deployment's "first 404 disables posting for the session" contract lost an
129
+ hour of real freezes to a server restart that dropped the ingest.
130
+ - **Self-sufficient records**: stamp `build` (the tab's bundle identity) and
131
+ `phase` (menu/boot/launch/match…) on every ledger line. The recorder
132
+ accepts `phase` per `frame()` sample and stamps hitches at mint time;
133
+ backfill the rest at post time. A record read in isolation weeks later
134
+ should not depend on the arm-probe that happened to precede it.
135
+ - **Heartbeat**: post a tiny `{type:'heartbeat', medianFrameMs, p95Ms,
136
+ calls, triangles, programs}` ledger line once a minute while armed
137
+ (directly — never through the recorder, so it costs none of the incident
138
+ budget). It makes a quiet file MEAN dark-or-closed instead of idle;
139
+ `sloptimize hook-status` warns once when the ledger goes stale (>45min).
140
+ The counters ride the beat because `profile.json` is overwritten every
141
+ 2s — without them the ledger has no draw-call HISTORY, and the debugger's
142
+ Timeline cannot draw "calls over time".
143
+ - **Ledger read-back** (for the debugger's Timeline/Fixes tabs): one
144
+ dev-gated `GET /api/sloptimize/ledger` → `{ perf, fixes }` — the last
145
+ ~2MB of `perf.jsonl` (first partial line dropped) and all of
146
+ `fixes.jsonl`, as raw JSONL strings. Same gate as the ingest, 404
147
+ otherwise. The page folds it with sloptimize's own `history.js`; the
148
+ server stays a file reader (mecharoyale: `readSloptimizeLedger`, ~20
149
+ lines + tests).
150
+ - **GPU-settle verdicts**: if your boot holds its reveal on
151
+ `queue.onSubmittedWorkDone()` (it should — pipeline compiles bill the
152
+ first submit that uses them, invisibly to every CPU-side recorder), post
153
+ `{type:'gpu-settle', tag, ms, settled}` when the wait was real (>50ms or
154
+ capped). That record is the on-hardware proof the freeze moved behind the
155
+ cover.
156
+ - **createStacks** (optional, on hitch records): capture `new Error().stack`
157
+ in your createRenderPipeline/createComputePipeline/createShaderModule
158
+ wrappers into a small ring (creates are rare — never do this per draw or
159
+ per write), and attach the top ~3 deduped tails to any hitch whose frame
160
+ window overlaps them, byte-capped (~2KB). Ship an UNREFERENCED external
161
+ sourcemap from the same build so the minified positions decode to source
162
+ file:line on the dev side without ever serving the map to players.
163
+
164
+ Flush cadence: post `profile` every ~2s, drain records with it.
165
+ Gitignore `.sloptimize/*` except `budgets.json`.
166
+
167
+ ## 3. The CLI (the agent's shell surface)
168
+
169
+ `sloptimize report|check|census|doctor --dir <game>/.sloptimize` — no
170
+ setup beyond the files existing. Declare budgets in
171
+ `.sloptimize/budgets.json`:
172
+
173
+ ```json
174
+ { "perf.budget.draw_calls": 400, "perf.budget.frame_ms_p95": 16.7 }
175
+ ```
176
+
177
+ `check` exits 0/1/4 — the termination condition for an agent loop.
178
+
179
+ ## 4. The Claude Code session (pull: ambient on every prompt)
180
+
181
+ `.claude/settings.json` in the game repo:
182
+
183
+ ```json
184
+ { "hooks": { "UserPromptSubmit": [ { "hooks": [ {
185
+ "type": "command",
186
+ "command": "node <path-to>/sloptimize/bin/sloptimize.mjs hook-status --dir .sloptimize 2>/dev/null || true",
187
+ "timeout": 10 } ] } ] } }
188
+ ```
189
+
190
+ Silent unless a NEW keyframe or a budget-breach edge exists; at most 5
191
+ lines. Copy the doctrine skill into `.claude/skills/sloptimize/` so any
192
+ future session inherits the playbook (report → classify → census → ONE
193
+ change → verify with counters; never claim a fix without a before/after).
194
+
195
+ ## 5. The push channel (auto mode: the agent is woken, nobody types)
196
+
197
+ `sloptimize watch` is the watcher (SPEC §8.1.1): a byte cursor over each
198
+ `--dir`'s `perf.jsonl`, polled every 20s, printing ONE line per record an
199
+ agent should act on — every usermark, every auto hitch ≥100ms
200
+ (`--min-hitch-ms`), a gpu-settle that hit its cap, any gpu-stall, every
201
+ coordinate jitter that is its own incident (`↯` — not a long-frame
202
+ catch-up, not a passenger of another track), and the feed going quiet /
203
+ coming back. Every line ends with the record's footprint and how many
204
+ times this ledger has seen that cause (`fp=a3f92c1d ×7`, SPEC §3.7):
205
+ `sloptimize issues --fp a3f92c1d` is its history and the fixes applied. Heartbeats, arm-probes and small hitches
206
+ stay silent. It starts at EOF (history is `report`'s job) and never exits.
207
+
208
+ Arm it as a Claude Code Monitor — stdout lines become wake events:
209
+
210
+ ```
211
+ Monitor({ command: 'node <path-to>/sloptimize/bin/sloptimize.mjs watch --dir .sloptimize',
212
+ description: 'sloptimize perf incidents', persistent: true })
213
+ ```
214
+
215
+ To make every session arm it WITHOUT anyone asking, add a `SessionStart`
216
+ hook that prints the instruction into the agent's context (mecharoyale's
217
+ `.claude/settings.json` is the reference; it skips ticket-runner jobs so a
218
+ session working an unrelated task is not pulled off it, unless
219
+ `SLOPTIMIZE_WATCH=1` says otherwise). The operator then just plays:
220
+ auto-detected hitches and Ctrl+F12 notes wake the agent with the
221
+ classification attached, and it starts the §8.2 playbook unprompted.
222
+
223
+ ## Porting cost, measured once
224
+
225
+ mecharoyale (149k-line client, esbuild, WebGPU, no vite): runtime file
226
+ ~250 lines, server endpoint ~100 + tests, build-resolution plugin ~15,
227
+ one `rec.frame(...)` call at the existing stats site, settings + skill
228
+ copies. One session end to end, including the mistakes this doc exists
229
+ to save you from (hostname gating, arming inside an on-demand function,
230
+ frameMs not bounding insideRenderMs).