SLOPSHOPPER

progress-meter

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

newpanecommandstatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-meter
│ ┃ APPLE · COMPLETION ✕ › fix the failing auth test and add an audit log call │ ┃ no meter file yet (Error: ENOENT: no such │ ┃ file /Users/dev/.claude/apple-meter.j) ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /meter │ ⎿ progress-meter: no meter file yet (Error: ENOENT: no such file / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · APPLE · COMPLETION
no meter file yet (Error: ENOENT: no such file /Users/dev/.claude/apple-meter.j)
README

StudPilot

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.

Status

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.

How it works

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

Repository layout

PathWhat
apps/workerThe backend: API, Durable Objects, model gateway, retrieval, static serving
apps/webThe web app (Vite and React)
apps/siteThe marketing site and docs (Astro)
apps/studpilot-pluginThe Roblox Studio plugin (Luau). Release runbook: docs/PLUGIN-RELEASE.md
packages/sharedThe wire protocol and shared types
packages/componentsLuau components the agent installs into a place
packages/corpusThe retrieval corpus pipeline over the Roblox creator docs. Licences: packages/corpus/PROVENANCE.md
packages/asset-libraryOpenly licensed (CC0) UI and icon packs
packages/evalsEval and security test suites
packages/sdkClient for the public API
packages/designDesign tokens
infraDeploy scripts and Supabase migrations
scriptsRepository checks
testsTests that cross app boundaries
docsDecisions, runbooks and recorded evidence
planningThe 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.

Run the tests

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.

Working on it

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.

Source 1 files
hooks/register.tsx 102 lines
1import 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