Feedback mod dev loop: design-doc gate, one compile per turn, build/test/deploy status, open-work pane

a minecraft tech mod about building and controlling physical processes, not unlocking recipe-specific machines.
machines don't know when a recipe is done. a furnace, a mixer, a stamp, whatever, it keeps doing its physical action for as long as it has power and input. it has no idea whether the thing inside is finished.
other tech mods already have machines that idle forever. that's not the new part. the new part is what happens if you let it keep going: continued operation past completion acts on the already-finished output. it doesn't just waste power, it degrades, burns, overworks, or ruins the thing you just made.
so you've actually got two problems, always:
everything in the mod serves one of those two jobs. sensing is one system, control is another, actuation is a third, and none of them know about each other. a sensor doesn't control anything. an actuator doesn't know anything. the controller is a switch, not a dial, it can only start or stop a supply.
not a tier ladder where the tier-3 furnace is a faster tier-2 furnace. a machine states a physical operation ("apply heat," "apply force"), never a recipe. there's no if item == copper ingot anywhere, sorting and processing work off real properties like density, magnetism, and particle size instead.
precision never locks a recipe behind a tier. if you have the raw physical ability, you can attempt it by hand, badly, at a tiny rate, forever. automation makes things practical. it doesn't unlock them.
mechanical, thermal, and chemical power are all usable on their own, none of them have to funnel through electricity first.
design-first. the philosophy was written before the code. feedback_philosophy.md is the why-document, and it's still being actively revised as the build finds things the design got wrong. slice 1 (copper tooling, then a heat-controlled steel process) is the current target, see TODO.md for what's actually done versus in progress.
platform is neoforge, minecraft 1.21.1.
./gradlew build # jar to build/libs
./gradlew runClient # dev client
code is GPL-3.0-or-later. assets are all rights reserved. see LICENSING.md.
i'm not gonna sit here and lie to you. i, soundgoodizerfan, just flat out don't code java at all. i've made modPACKS, but never a mod. so yeah, i think on paper, that means this is vibecoded. but i have been going out of my way to commit to optimization, infrastructure, and actual fun in the mod. i haven't just been, like, sitting here one-shotting feature after feature. i HAVE actually been sitting here, watching every output. making sure things are coded in a smart way (as far as i can tell), and making sure that the game mechanics are ACTUALLY fun to practice, not just fun on paper. still though, if you're a java dev looking at this repo, and you think you'd do better than claude is, PLEASE message me. i dont LIKE that this mod is vibecoded, but i dont want to learn java for this because then the code will probably suck.
hooks/register.tsx 238 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Build, Check, DocsRead } from '../types'
5
6// Feedback's dev loop, in place of three settings shell hooks:
7// - the design-doc gate: no edit under src/main/ until this loop (main or a
8// subagent, each on its own) has Read feedback_philosophy.md and
9// feedback_mechanics.md -- CLAUDE.md calls them required reading;
10// - one compileJava per turn instead of per edit, at Stop/SubagentStop, its
11// errors handed back as a block so the model fixes them before it stops;
12// - compile / game test / deploy state on the status line;
13// - a band with the gate's state and an /open-work pane (TODO.md items and
14// the [OPEN] count per design document).
15
16const PLUGIN = 'feedback-dev'
17const PANE = 'open-work'
18const EDITS = new Set(['Edit', 'Write', 'MultiEdit'])
19const DOCS = { philosophy: 'feedback_philosophy.md', mechanics: 'feedback_mechanics.md' } as const
20
21const docs = atom({ plugin: 'feedback-dev', key: 'docs' } as const, {})
22const dirty = atom({ plugin: 'feedback-dev', key: 'dirty' } as const, {})
23const build = atom({ plugin: 'feedback-dev', key: 'build' } as const, {})
24const todo = atom({ plugin: 'feedback-dev', key: 'todo' } as const, '')
25
26type $ = EngineInterface
27
28const loop = (agent: string | undefined) => agent ?? 'main'
29
30const filePath = (input: unknown) => {
31 const path = (input as { file_path?: unknown } | null)?.file_path
32 return typeof path === 'string' ? path : ''
33}
34
35const ago = (now: number, at: number) => {
36 const minutes = Math.round((now - at) / 60000)
37 return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`
38}
39
40// Same filter compile-check.sh used: the error lines and two after each.
41const errors = (out: string) => {
42 const lines = out.split('\n')
43 const kept: string[] = []
44 lines.forEach((line, i) => {
45 if (/error:|warning: \[|FAILED|What went wrong/.test(line)) kept.push(...lines.slice(i, i + 3))
46 })
47 return kept.slice(0, 40).join('\n')
48}
49
50// The repo root, or '' outside the Feedback repo (every hook then passes).
51let root = ''
52
53
54const showStatus = async ($: $) => {
55 const now = await $.clock.now()
56 const b: Build = await read($, build)
57 const check = (name: string, c: Check | undefined) =>
58 c ? `${name} ${c.ok ? 'ok' : 'FAIL'} ${ago(now, c.at)}` : `${name} -`
59 const parts = [
60 b.compile === 'running' ? 'compile ...' : check('compile', b.compile),
61 check('tests', b.tests),
62 `deploy ${b.deploy ?? '-'}`,
63 ]
64 $.ui.status(parts.join(' · '))
65}
66
67const setBuild = async ($: $, patch: Partial<Build>) => {
68 await update($, build, b => ({ ...b, ...patch }))
69 await showStatus($)
70}
71
72// Stale = any source or build file newer than the jar in the test pack.
73const refreshDeploy = async ($: $) => {
74 const mods = `${root}/../feedback-test-pack/mods`
75 const jar = (await $.fs.exists(mods))
76 ? (await $.fs.list(mods)).find(f => /^feedback-.*\.jar$/.test(f.name))
77 : undefined
78 if (!jar) return setBuild($, { deploy: 'none' })
79 const { stdout } = await $.process.run(
80 ['find', 'src', 'build.gradle', 'gradle.properties', '-newer', `${mods}/${jar.name}`, '-type', 'f', '-print', '-quit'],
81 { cwd: root },
82 )
83 await setBuild($, { deploy: stdout.trim() ? 'stale' : 'fresh' })
84}
85
86const refreshTodo = async ($: $) => {
87 const counts: string[] = []
88 for (const f of await $.fs.list(root)) {
89 if (!f.name.endsWith('.md') || f.name === 'TODO.md' || f.name === 'CLAUDE.md') continue
90 const n = (await $.fs.read(`${root}/${f.name}`)).split('[OPEN]').length - 1
91 if (n > 0) counts.push(`${f.name.replace(/^feedback_|\.md$/g, '')} ${n}`)
92 }
93 // Headings and open items with their indented continuation lines.
94 const items: string[] = []
95 let inItem = false
96 for (const line of (await $.fs.read(`${root}/TODO.md`)).split('\n')) {
97 if (/^#{2,}\s/.test(line)) { items.push('', line); inItem = false }
98 else if (/^\s*- \[ \]/.test(line)) { items.push(line); inItem = true }
99 else if (inItem && /^\s+\S/.test(line)) items.push(line)
100 else inItem = false
101 }
102 await update($, todo, () => `**[OPEN] in docs:** ${counts.join(' · ')}\n${items.join('\n')}`)
103}
104
105const compile = async ($: $, agent: string) => {
106 const roots = (await read($, dirty))[agent] ?? []
107 if (roots.length === 0) return undefined
108 await update($, dirty, d => ({ ...d, [agent]: [] }))
109 await setBuild($, { compile: 'running' })
110 const failures: string[] = []
111 for (const r of roots) {
112 const run = await $.process.run(['./gradlew', 'compileJava', '-q', '--console=plain'], {
113 cwd: r,
114 timeoutMs: 300000,
115 })
116 if (run.exitCode !== 0) failures.push(`compileJava failed in ${r}:\n${errors(run.stdout + run.stderr)}`)
117 }
118 await setBuild($, { compile: { ok: failures.length === 0, at: await $.clock.now() } })
119 await refreshDeploy($)
120 return failures.length ? failures.join('\n\n') : undefined
121}
122
123const stop = async ($: $, agent: string) => {
124 const failed = await compile($, agent)
125 return failed ? { block: failed } : undefined
126}
127
128export const register: Register = on => {
129 on('session.start', async ($, e, next) => {
130 root = await $.session.cwd()
131 if (!(await $.fs.exists(`${root}/${DOCS.philosophy}`))) {
132 root = ''
133 return next(e)
134 }
135 await $.command.register({ name: PANE, description: 'Open work: TODO.md items and [OPEN] counts' })
136 await refreshTodo($)
137 await refreshDeploy($)
138 return next(e)
139 })
140
141 on('command.run', { command: PANE }, async $ => {
142 if (!root) return { text: 'Not in the Feedback repo.' }
143 await refreshTodo($)
144 await $.ui.open({ id: PANE, title: 'Open work' })
145 return { text: 'Open work pane opened.' }
146 })
147
148 // Record design-doc reads and edited source roots, per loop.
149 on('classic.PostToolUse', async ($, e, next) => {
150 if (root) {
151 const agent = loop(e.agent_id)
152 const path = filePath(e.tool_input)
153 if (e.tool_name === 'Read') {
154 for (const [doc, file] of Object.entries(DOCS)) {
155 if (path.endsWith(`/${file}`)) {
156 await update($, docs, d => ({
157 ...d,
158 [agent]: { philosophy: false, mechanics: false, ...d[agent], [doc]: true },
159 }))
160 }
161 }
162 } else if (EDITS.has(e.tool_name) && path.endsWith('.java') && path.includes('/src/')) {
163 const r = path.slice(0, path.indexOf('/src/'))
164 await update($, dirty, d => ({ ...d, [agent]: [...new Set([...(d[agent] ?? []), r])] }))
165 }
166 }
167 return next(e)
168 })
169
170 on('tool.check', async ($, e, next) => {
171 const path = filePath(e.input)
172 if (!root || !EDITS.has(e.tool) || !path.includes('/src/main/')) return next(e)
173 const read_: DocsRead | undefined = (await read($, docs))[loop(e.agentId)]
174 const missing = Object.entries(DOCS)
175 .filter(([doc]) => !read_?.[doc as keyof DocsRead])
176 .map(([, file]) => file)
177 if (missing.length === 0) return next(e)
178 return {
179 decision: 'deny',
180 reason: `Blocked: this session hasn't Read ${missing.join(', ')} yet. CLAUDE.md: these are authoritative and required before any implementation in this repo. Read them (Read tool, not a summary), then retry the edit.`,
181 }
182 }).catch(() => ({ decision: 'deny', reason: `${PLUGIN}: the design-doc gate failed.` }))
183
184 on('classic.Stop', async ($, e, next) => {
185 const result = await next(e)
186 const blocked = root ? await stop($, 'main') : undefined
187 return blocked ? { ...result, ...blocked } : result
188 })
189
190 on('classic.SubagentStop', async ($, e, next) => {
191 const result = await next(e)
192 const blocked = root ? await stop($, loop(e.agent_id)) : undefined
193 return blocked ? { ...result, ...blocked } : result
194 })
195
196 // Builds, game tests and deploys run by hand through Bash.
197 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
198 const ran = await next(e)
199 if (!root || ran.deny !== undefined) return ran
200 const ok = ran.isError !== true
201 const at = await $.clock.now()
202 if (/runGameTestServer/.test(String(e.command))) await setBuild($, { tests: { ok, at } })
203 else if (/gradlew\b.*\b(build|compileJava)\b/.test(String(e.command))) await setBuild($, { compile: { ok, at } })
204 if (/build-and-deploy\.sh|gradlew/.test(String(e.command))) await refreshDeploy($)
205 return ran
206 })
207
208 on('turn.complete', async ($, e, next) => {
209 if (root && e.agentId === undefined) {
210 await refreshTodo($)
211 await refreshDeploy($)
212 }
213 return next(e)
214 })
215
216 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217 if (!root || e.props.hasSurvey) return next(e)
218 const { Box, Text } = $.ui.resolve(e)
219 const main = (await read($, docs)).main
220 const mark = (ok: boolean | undefined) => (ok ? 'read' : 'NOT READ')
221 if (main?.philosophy && main.mechanics) {
222 return <Box><Text dimColor>design docs read</Text></Box>
223 }
224 return (
225 <Box>
226 <Text color="yellow">
227 design docs: philosophy {mark(main?.philosophy)} · mechanics {mark(main?.mechanics)} -- src/main edits blocked
228 </Text>
229 </Box>
230 )
231 })
232
233 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
234 const { Markdown } = $.ui.resolve(e)
235 return <Markdown text={(await read($, todo)) || 'Loading...'} />
236 })
237}
238types/index.d.ts 19 lines1export type Check = { ok: boolean; at: number }
2export type DocsRead = { philosophy: boolean; mechanics: boolean }
3export type Build = {
4 compile?: Check | 'running'
5 tests?: Check
6 deploy?: 'fresh' | 'stale' | 'none'
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'feedback-dev': {
12 docs: Record<string, DocsRead>
13 dirty: Record<string, string[]>
14 build: Build
15 todo: string
16 }
17 }
18}
19