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.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/sloptimize.mjs +346 -0
- package/docs/DESIGN-mecharoyale-v0.md +32 -0
- package/docs/INTEGRATION.md +230 -0
- package/docs/JITTER-AND-FOOTPRINTS.md +236 -0
- package/docs/SPEC-attach.md +176 -0
- package/docs/SPEC.md +845 -0
- package/docs/USAGE.md +227 -0
- package/hooks/hooks.json +16 -0
- package/mcp/server.mjs +127 -0
- package/package.json +64 -0
- package/skills/install/SKILL.md +143 -0
- package/skills/sloptimize/SKILL.md +74 -0
- package/src/attach.mjs +197 -0
- package/src/census.js +193 -0
- package/src/classify.js +70 -0
- package/src/footprint.js +170 -0
- package/src/history.js +273 -0
- package/src/index.js +8 -0
- package/src/inject-body.js +152 -0
- package/src/motion.js +345 -0
- package/src/panel.js +530 -0
- package/src/proposals.mjs +268 -0
- package/src/recorder.js +235 -0
- package/src/watch.mjs +242 -0
|
@@ -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).
|