#37 Apple's completion out of 100%: the owner's fixed meter as a live, techy progress pane and status line

An AI co-pilot for Roblox Studio. In the web app you describe a piece of your game, such as a shop screen, a pet system with eggs or a lava zone. A Studio plugin builds it in your own place and checks it.
Site: https://studpilot.app
StudPilot was called Apple, and before that Golem.
Pre-launch, private beta, free. Payments are switched off. The product is being rebuilt around reviewed building blocks, and it has not yet met the quality bar set for launch, so this repository makes no claim about how good the results are. The plan and its milestones are in planning/STUDPILOT-FINAL-PLAN.md.
You sign in on the web app and pair the Studio plugin with a 6-character code. You type a request. A Cloudflare Worker runs an agent loop on GLM 5.3 Flash (Cloudflare Workers AI) and sends typed operations to the plugin, which applies them to your open place. The agent checks its work with a play test, a UI layout check and a build audit, and the reply is meant to say only what those checks prove. Supabase provides sign-in and the project registry behind row-level security.
browser ── WebSocket ──▶ SessionDO ◀── long-poll ── Studio plugin
│ agent loop (alarm-driven steps)
│ checkpoints (gzipped snapshots in DO SQLite)
▼
Workers AI + Vectorize/D1 RAG
| Path | What |
|---|---|
apps/worker | The backend: API, Durable Objects, model gateway, retrieval, static serving |
apps/web | The web app (Vite and React) |
apps/site | The marketing site and docs (Astro) |
apps/studpilot-plugin | The Roblox Studio plugin (Luau). Release runbook: docs/PLUGIN-RELEASE.md |
packages/shared | The wire protocol and shared types |
packages/components | Luau components the agent installs into a place |
packages/corpus | The retrieval corpus pipeline over the Roblox creator docs. Licences: packages/corpus/PROVENANCE.md |
packages/asset-library | Openly licensed (CC0) UI and icon packs |
packages/evals | Eval and security test suites |
packages/sdk | Client for the public API |
packages/design | Design tokens |
infra | Deploy scripts and Supabase migrations |
scripts | Repository checks |
tests | Tests that cross app boundaries |
docs | Decisions, runbooks and recorded evidence |
planning | The plan, the build order and the proof for each milestone |
apps/plugin, apps/benchmark, apps/experiences and a few packages are older code that is still in the tree and is being removed.
You need Node 26 and pnpm 11 (the exact version is packageManager in package.json).
pnpm install
pnpm -r typecheck
pnpm -r test # every workspace
node --test tests/*.test.mjs # tests at the root that cross app boundaries
cd apps/worker && node --test # the worker suite alone: about 5.4k tests, a few minutes
Tests run offline. CI runs them with no secrets, and no test calls a paid service. A few site tests read the built site and use Chromium, so CI first runs pnpm --filter @studpilot/site build and pnpm exec playwright install chromium. The plugin build, node apps/studpilot-plugin/scripts/build.mjs, needs Luau's luau-analyze on your PATH.
AGENTS.md is the map of the repository and CLAUDE.md holds the working rules; read both before a first edit. Secrets live in a gitignored .env. Never commit one.
hooks/register.tsx 102 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// #37 Progress meter: the owner's fixed formula (25% agent, 20% knowledge & library, 15% visual, 10% UI,
4// 10% sound/animation/FX, 20% website) read from ~/.claude/apple-meter.json, which the agent updates when a
5// benchmark result lands. A pane with a large total, a segmented bar per domain (measured vs estimated), a
6// scanning shimmer, and the live bench progress; the status line carries the compact version. `/meter` opens it.
7const PANE = 'apple-meter'
8const WIDTH = 40
9
10type Domain = { id: string; name: string; weight: number; value: number; basis: 'measured' | 'estimated' }
11type Meter = { updatedAt: string; note?: string; domains: Domain[]; live?: { label: string; done: number; total: number; credits: number; budget: number } }
12
13let meter: Meter | null = null
14let frame = 0
15let problem = ''
16
17const total = (m: Meter) => m.domains.reduce((n, d) => n + (d.weight * d.value) / 100, 0)
18const bar = (pct: number, width: number, shimmerAt: number) => {
19 const filled = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
20 return Array.from({ length: width }, (_, i) => (i < filled ? (i === shimmerAt % Math.max(1, filled) ? '▓' : '█') : '░')).join('')
21}
22const heat = (pct: number) => (pct >= 90 ? '#3ddc97' : pct >= 60 ? '#7ee787' : pct >= 35 ? '#f2cc60' : pct >= 15 ? '#ff9e64' : '#ff6b81')
23
24async function load($: EngineInterface) {
25 const home = await $.env.get('HOME')
26 try {
27 meter = JSON.parse(await $.fs.read(`${home}/.claude/apple-meter.json`)) as Meter
28 problem = ''
29 } catch (err) {
30 problem = `no meter file yet (${String(err).slice(0, 60)})`
31 }
32 if (meter) {
33 const t = total(meter)
34 $.ui.status(`APPLE ${t.toFixed(1)}% ${bar(t, 12, -1)}${meter.live ? ` · bench ${meter.live.done}/${meter.live.total}` : ''}`)
35 }
36 $.ui.invalidate('ui.render')
37}
38
39export const register: Register = on => {
40 on('session.start', async ($, e, next) => {
41 await $.command.register({ name: 'meter', description: "Show Apple's completion out of 100% (the owner's meter)" })
42 const started = await next(e)
43 await load($)
44 $.clock.every(30_000, () => void load($))
45 $.clock.every(250, () => {
46 frame += 1
47 $.ui.invalidate('ui.render')
48 })
49 void $.ui.open({ id: PANE, title: 'APPLE · COMPLETION' })
50 return started
51 })
52
53 on('command.run', { command: 'meter' }, async $ => {
54 await load($)
55 await $.ui.open({ id: PANE, title: 'APPLE · COMPLETION' })
56 return { text: meter ? `Apple is ${total(meter).toFixed(1)}% complete by the fixed meter.` : problem }
57 })
58
59 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
60 const { Box, Text } = $.ui.resolve(e)
61 if (!meter) return <Box><Text dimColor>{problem || 'loading…'}</Text></Box>
62 const t = total(meter)
63 const width = Math.max(16, Math.min(WIDTH, (e.viewport?.columns ?? 60) - 22))
64 const ticks = '▏' + Array.from({ length: 10 }, (_, i) => `${(i + 1) * 10}`.padStart(Math.round(width / 10), '·')).join('')
65 const scan = '·'.repeat(frame % (width + 1)) + '◆' + '·'.repeat(Math.max(0, width - (frame % (width + 1))))
66 return (
67 <Box flexDirection="column">
68 <Box gap={1}>
69 <Text bold color={heat(t)}>{t.toFixed(1)}%</Text>
70 <Text dimColor>of 100 · product completion · fixed formula</Text>
71 </Box>
72 <Text color={heat(t)}>{bar(t, width, frame)}</Text>
73 <Text dimColor>{ticks.slice(0, width + 1)}</Text>
74 <Text color="#4fc3f7" dimColor>{scan.slice(0, width + 1)}</Text>
75 <Text> </Text>
76 {meter.domains.map(d => (
77 <Box key={d.id} flexDirection="column">
78 <Box gap={1}>
79 <Text bold>{d.name}</Text>
80 <Text dimColor>w{d.weight}% · {d.basis === 'measured' ? '● measured' : '○ estimated'}</Text>
81 </Box>
82 <Box gap={1}>
83 <Text color={heat(d.value)}>{bar(d.value, width - 8, frame + d.weight)}</Text>
84 <Text color={heat(d.value)}>{d.value.toFixed(1).padStart(5)}%</Text>
85 </Box>
86 </Box>
87 ))}
88 <Text> </Text>
89 {meter.live && (
90 <Box gap={1}>
91 <Text color="#4fc3f7">▶ {meter.live.label}</Text>
92 <Text dimColor>{meter.live.done}/{meter.live.total} items · {meter.live.credits}/{meter.live.budget} credits</Text>
93 </Box>
94 )}
95 {meter.live && <Text color="#4fc3f7">{bar((100 * meter.live.done) / Math.max(1, meter.live.total), width, frame)}</Text>}
96 {meter.note && <Text dimColor wrap="wrap">{meter.note}</Text>}
97 <Text dimColor>updated {new Date(meter.updatedAt).toLocaleString()}</Text>
98 </Box>
99 )
100 })
101}
102