SLOPSHOPPER

little-harvest

Every file that changes becomes a crop: Claude's edits and yours grow a farm in its own pane, tests bring sun or storms, and passing turns fill the barn.

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · little-harvest
│ ┃ Little Harvest ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ 0 plots 0 ripe 0 bushels Summer, autumn … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Tests failed: a storm wilts the pumpkins ✻ Worked for 42s · done 4:20 PM │ │ › /farm │ ⎿ little-harvest: Little Harvest is on. It comes back each session │ ⎿ little-harvest: Barn: 0 bushels this session, 0 in all. │ ⎿ little-harvest: Season: Summer, autumn in 2 min. │ ⎿ little-harvest: No plots yet. Every file that changes in this se │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Little Harvest
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0 plots 0 ripe 0 bushels Summer, autumn in 2 min Tests failed: a storm wilts the pumpkins
README

Little Harvest

Your files are the field. Every file that changes in a session becomes a plot on a small pixel farm, the seasons turn every five minutes, and the farmer never stands still.

Little Harvest preview

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install little-harvest@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How to use

The farm is hidden until you ask for it.

  • /farm shows the farm. Run it again to hide it.
  • /farm on and /farm off set it directly.
  • /farm stats lists every plot with its crop, stage, and lines changed, and leaves the farm as it is.

When you turn the farm on, it comes back in every new session until you turn it off. One game shows at a time: turning on another game from this marketplace (for example /dojo) hides the farm, and the farm stays off until you run /farm again. Closing the pane with its close mark also turns it off.

In the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1) the farm docks in a side column beside the transcript, with a log of what the farmer is doing under the scene. On the main screen it sits above the prompt. A pane that opens by itself at the start of a session needs a wide terminal (144 columns); if yours is narrower, run /farm to show it. The desktop app shows a one-line summary.

The farm is for watching. Nothing in it needs a key.

How it plays

FileCrop
TestsPumpkins
Source codeCorn
DocsSunflowers
StylesTulips
ConfigWheat
Anything elseCarrots

Real work moves the farm:

  • Each file Claude edits, and each file you change yourself (the farm reads git diff every 20 seconds), becomes a plot. Crops grow with the lines changed: seed, sprout, growing, then ripe. The farmer drops what they are doing and hoes that plot.
  • A failing test brings a storm, and test plots wilt until the next passing run. A passing test brings sun.
  • When a turn with a passing test ends, the ripe crops fly into the barn and the bushel count goes up. The count stays across sessions.

Between work, the farm keeps busy on its own. The farmer waters plots, hoes new rows, pulls weeds, carries hay from the barn, feeds the chickens, shoos crows off the field, fixes the scarecrow, and rests under the cherry tree. Chickens peck and wander, a crow lands now and then, and a cat naps on the fence. This is only for show: chores never grow crops or add bushels.

The seasons turn every five minutes, in step with the Samurai Dojo: winter (snow on the fence, the barn, and the crops), cherry blossom (falling petals), summer (fireflies), and autumn (falling leaves). The sky also follows your local time of day.

See the marketplace README for configuration, privacy, updates, and removal.

Source 38 files
hooks/register.tsx 119 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { noop } from './shared/noop'
4import { configureStage, installStage } from './shared/pixel/stage'
5import { plural } from './shared/text/meter'
6import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
7import { CROP_LABELS, stageForLines } from './sim/crops'
8import { farm, parseNumstat, projectRoot, setProjectRoot, userChanges } from './sim/ledger'
9import { installWork } from './work'
10
11const LIFETIME_KEY = 'lifetimeBushels'
12const GIT_POLL_MS = 20000
13const HOUR_POLL_MS = 60000
14
15function seasonLine(): string {
16  const next = farm.calendar.next()
17  const minutes = Math.max(1, Math.ceil(next.remainingMs / 60000))
18  return `${farm.calendar.season().label}, ${next.label.toLowerCase()} in ${minutes} min`
19}
20
21function summary(): string {
22  const { plots, ripe, lifetimeBushels } = farm.summary()
23  return `${plural({ count: plots, word: 'plot' })}  ${ripe} ripe  ${plural({ count: lifetimeBushels, word: 'bushel' })}  ${seasonLine()}`
24}
25
26function plotLines(): string[] {
27  const plots = farm.plots()
28  if (plots.length === 0) {
29    return ['No plots yet. Every file that changes in this session becomes a plot.']
30  }
31  return [
32    'Plots:',
33    ...plots.map(plot => {
34      const stage = stageForLines(plot.lines) + (plot.isWilted ? ', wilted' : '')
35      return `  ${CROP_LABELS[plot.crop].padEnd(9)} ${stage.padEnd(16)} ${plot.path} (${plural({ count: plot.lines, word: 'line' })})`
36    }),
37  ]
38}
39
40function report(): string[] {
41  const { sessionBushels, lifetimeBushels } = farm.summary()
42  return [
43    `Barn: ${plural({ count: sessionBushels, word: 'bushel' })} this session, ${lifetimeBushels} in all.`,
44    `Season: ${seasonLine()}.`,
45    ...plotLines(),
46  ]
47}
48
49async function syncHour($: EngineInterface): Promise<void> {
50  const date = new Date(await $.clock.now())
51  farm.setHour(date.getHours() + date.getMinutes() / 60)
52}
53
54async function pollGit($: EngineInterface): Promise<void> {
55  const result = await $.process
56    .run(['git', 'diff', '--numstat', 'HEAD'], { cwd: projectRoot(), timeoutMs: 3000 })
57    .catch(() => undefined)
58  if (!result || result.exitCode !== 0) {
59    return
60  }
61  userChanges(parseNumstat(result.stdout)).forEach(change => farm.tend({ ...change, by: 'you' }))
62}
63
64export const register: Register = on => {
65  configureStage({
66    game: 'little-harvest',
67    title: 'Little Harvest',
68    command: {
69      name: 'farm',
70      description: 'Show or hide Little Harvest (on, off, stats lists every plot)',
71      report,
72    },
73    rows: STAGE_ROWS,
74    minColumns: MIN_COLUMNS,
75    maxColumns: MAX_COLUMNS,
76    frameMs: FRAME_MS,
77    scene: {
78      begin: epochMs => farm.begin(epochMs),
79      resize: columns => farm.resize(columns),
80      tick: dtMs => farm.tick(dtMs),
81      frame: () => farm.frame(),
82      summary,
83      log: () => farm.log(),
84    },
85  })
86  installStage(on)
87  installWork(on)
88
89  on('session.start', async ($, e, next) => {
90    setProjectRoot(e.cwd)
91    farm.restore({ lifetimeBushels: Number((await $.store.get(LIFETIME_KEY)) ?? 0) })
92    await syncHour($)
93    await pollGit($)
94    $.clock.every(HOUR_POLL_MS, () => {
95      syncHour($).catch(noop)
96    })
97    $.clock.every(GIT_POLL_MS, () => {
98      pollGit($).catch(noop)
99    })
100    return next(e)
101  })
102
103  on('turn.start', ($, e, next) => {
104    farm.beginTurn()
105    return next(e)
106  })
107
108  on('turn.complete', async ($, e, next) => {
109    if (e.agentId === undefined) {
110      const harvested = farm.endTurn()
111      if (harvested > 0) {
112        await $.store.set(LIFETIME_KEY, farm.summary().lifetimeBushels)
113        $.ui.toast(`Harvested ${harvested} ripe ${harvested === 1 ? 'crop' : 'crops'} into the barn`)
114      }
115    }
116    return next(e)
117  })
118}
119
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/shared/pixel/stage.tsx 199 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { noop } from '../noop'
4import type { Bitmap } from './bitmap'
5import { encodeCells } from './cells'
6
7export type StageScene = {
8  begin: (epochMs: number) => void
9  resize: (columns: number) => void
10  tick: (dtMs: number) => void
11  frame: () => Bitmap
12  summary: () => string
13  log: () => readonly string[]
14}
15
16export type StageOptions = {
17  game: string
18  title: string
19  command: { name: string; description: string; report: () => readonly string[] }
20  rows: number
21  minColumns: number
22  maxColumns: number
23  frameMs: number
24  scene: StageScene
25}
26
27const POLL_MS = 1000
28const PANE_COLUMNS = 62
29const ACTIVE_FILE = '.claude/mods/active-game'
30
31let stage: StageOptions | undefined
32let activePath = ''
33let isClockRunning = false
34let isPaneOpen = false
35let isRasterMounted = false
36let lastCells = ''
37let lastSummary = ''
38let sincePollMs = 0
39
40export function configureStage(options: StageOptions): void {
41  stage = options
42}
43
44export function isStageShown(): boolean {
45  return isPaneOpen
46}
47
48function wantsShow(options: { args: string; isActive: boolean }): boolean {
49  if (options.args === 'on') {
50    return true
51  }
52  return options.args === 'off' ? false : !options.isActive
53}
54
55async function readActive($: EngineInterface): Promise<string> {
56  if (activePath === '') {
57    return ''
58  }
59  return (await $.fs.read(activePath).catch(() => '')).trim()
60}
61
62export function installStage(on: On): void {
63  on('session.start', { isInteractive: true }, async ($, e, next) => {
64    const options = stage
65    if (!options) {
66      return next(e)
67    }
68    await $.command.register({ name: options.command.name, description: options.command.description, immediate: true })
69    activePath = `${(await $.env.get('HOME')) ?? '.'}/${ACTIVE_FILE}`
70    options.scene.begin(await $.clock.now())
71    isPaneOpen = (await $.ui.panes()).some(pane => pane.id === options.game)
72    const active = await readActive($)
73    if (active === options.game && !isPaneOpen) {
74      const opened = await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
75      isPaneOpen = true
76      if (!opened.isPlaced) {
77        $.ui.toast(`${options.title} waits for a wider terminal. Run /${options.command.name} to show it now.`)
78      }
79    }
80    if (isClockRunning) {
81      return next(e)
82    }
83    isClockRunning = true
84    $.clock.every(options.frameMs, () => {
85      options.scene.tick(options.frameMs)
86      sincePollMs += options.frameMs
87      if (sincePollMs >= POLL_MS) {
88        sincePollMs = 0
89        if (isPaneOpen && options.scene.summary() !== lastSummary) {
90          $.ui.invalidate('ui.render')
91        }
92        readActive($)
93          .then(current => (isPaneOpen && current !== options.game ? $.ui.close({ id: options.game }) : undefined))
94          .catch(noop)
95      }
96      if (!isPaneOpen || !isRasterMounted) {
97        return
98      }
99      const cells = encodeCells(options.scene.frame())
100      if (cells === lastCells) {
101        return
102      }
103      lastCells = cells
104      $.ui
105        .blit({ requestId: options.game, key: `${options.game}:stage`, cells })
106        .then(result => {
107          if (result.deny !== undefined) {
108            isRasterMounted = false
109          }
110        })
111        .catch(noop)
112    })
113    return next(e)
114  })
115
116  on('command.run', { command: stage?.command.name ?? '' }, async ($, e, next) => {
117    const options = stage
118    if (!options || e.command !== options.command.name) {
119      return next(e)
120    }
121    const args = e.args.trim().toLowerCase()
122    if (args === 'stats') {
123      return { text: options.command.report().join('\n') }
124    }
125    if (args !== '' && args !== 'on' && args !== 'off') {
126      return { text: [`Usage: /${options.command.name} [on|off|stats]`, ...options.command.report()].join('\n') }
127    }
128    const pane = (await $.ui.panes()).find(candidate => candidate.id === options.game)
129    const isActive = (await readActive($)) === options.game && pane?.isPlaced === true
130    if (wantsShow({ args, isActive })) {
131      await $.fs.write(activePath, options.game)
132      await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
133      isPaneOpen = true
134      return {
135        text: [`${options.title} is on. It comes back each session until you run /${options.command.name} again.`, ...options.command.report()].join('\n'),
136      }
137    }
138    await $.fs.write(activePath, '')
139    await $.ui.close({ id: options.game })
140    isPaneOpen = false
141    return { text: `${options.title} is off. Run /${options.command.name} to bring it back.` }
142  })
143
144  on('ui.close', async ($, e, next) => {
145    const options = stage
146    if (!options || e.id !== options.game) {
147      return next(e)
148    }
149    const closed = await next(e)
150    isPaneOpen = false
151    isRasterMounted = false
152    const active = await readActive($)
153    if (e.origin.kind === 'person' && active === options.game) {
154      await $.fs.write(activePath, '')
155    }
156    return closed
157  })
158
159  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
160    const options = stage
161    if (!options || e.requestId !== options.game) {
162      return next(e)
163    }
164    lastSummary = options.scene.summary()
165    const logRows = e.props.placement === 'dock' ? Math.max(0, e.props.scroll.bodyRows - options.rows - 3) : 0
166    const lines = options.scene.log().slice(0, logRows)
167    if (e.surface !== 'terminal' || e.props.bodyColumns < options.minColumns) {
168      const { Box, Text } = $.ui.resolve(e)
169      isRasterMounted = false
170      return (
171        <Box flexDirection="column">
172          <Text>{lastSummary}</Text>
173          {e.surface === 'terminal' ? <Text dimColor>Widen this pane to see the scene.</Text> : null}
174        </Box>
175      )
176    }
177    const { Box, Raster, Text } = $.ui.resolve(e)
178    const columns = Math.min(e.props.bodyColumns, options.maxColumns)
179    options.scene.resize(columns)
180    isRasterMounted = true
181    lastCells = encodeCells(options.scene.frame())
182    return (
183      <Box flexDirection="column">
184        <Raster key={`${options.game}:stage`} columns={columns} rows={options.rows} cells={lastCells} />
185        <Text dimColor wrap="truncate">{lastSummary}</Text>
186        {lines.length > 0 ? (
187          <Box flexDirection="column" marginTop={1}>
188            {lines.map((line, index) => (
189              <Text key={`log-${index}`} dimColor={index > 0} wrap="truncate">
190                {line}
191              </Text>
192            ))}
193          </Box>
194        ) : null}
195      </Box>
196    )
197  })
198}
199
hooks/shared/text/meter.ts 34 lines
1const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
2const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
3
4export function meter(options: { value: number; max: number; width: number }): string {
5  const ratio = options.max <= 0 ? 0 : Math.min(1, Math.max(0, options.value / options.max))
6  const eighths = Math.round(ratio * options.width * 8)
7  const full = Math.floor(eighths / 8)
8  const partial = EIGHTHS[eighths % 8] ?? ''
9  const filled = '█'.repeat(full) + partial
10  return filled + ' '.repeat(Math.max(0, options.width - full - (partial ? 1 : 0)))
11}
12
13export function sparkline(values: readonly number[]): string {
14  const peak = Math.max(0, ...values)
15  if (peak === 0) {
16    return SPARKS[0]?.repeat(values.length) ?? ''
17  }
18  return values
19    .map(value => SPARKS[Math.min(SPARKS.length - 1, Math.floor((value / peak) * (SPARKS.length - 1)))])
20    .join('')
21}
22
23export function truncate(options: { text: string; width: number }): string {
24  const characters = Array.from(options.text.replace(/\s+/g, ' ').trim())
25  if (characters.length <= options.width) {
26    return characters.join('')
27  }
28  return characters.slice(0, Math.max(0, options.width - 1)).join('') + '…'
29}
30
31export function plural(options: { count: number; word: string }): string {
32  return `${options.count} ${options.word}${options.count === 1 ? '' : 's'}`
33}
34
hooks/sim/constants.ts 37 lines
1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const MIN_COLUMNS = 44
5export const MAX_COLUMNS = 72
6
7export const HORIZON_Y = 11
8export const MOUND_Y = 12
9export const CROP_BASE_Y = 12
10export const FEET_Y = 15
11export const FENCE_RAIL_Y = 8
12
13export const TREE_X = 6
14export const TREE_GROUND_Y = 15
15export const TREE_CROWN = { x: TREE_X - 7, y: TREE_GROUND_Y - 13, width: 15, height: 8 }
16export const FIELD_LEFT = 15
17export const PLOT_WIDTH = 7
18export const BARN_WIDTH = 17
19export const BARN_MARGIN = 2
20
21export const FARMER_SPEED_PIXELS_PER_SECOND = 18
22export const HOE_MS = 2400
23export const SWING_MS = 400
24export const WALK_FRAME_MS = 220
25export const CHEER_MS = 1600
26
27export const STORM_MS = 7000
28export const SUN_RAYS_MS = 3000
29export const HARVEST_MS = 2400
30export const POP_MS = 900
31
32export const SPROUT_LINES = 1
33export const GROWING_LINES = 10
34export const RIPE_LINES = 30
35
36export const LOG_LIMIT = 8
37
hooks/sim/crops.ts 51 lines
1import { GROWING_LINES, RIPE_LINES, SPROUT_LINES } from './constants'
2import type { CropKind, GrowthStage } from './types'
3
4const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\/|[._-](test|spec)\.[a-z0-9]+$/i
5const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$|(^|\/)(docs?|LICENSE|README)[^/]*$/i
6const STYLE_PATH = /\.(css|scss|sass|less|styl|pcss)$/i
7const CONFIG_PATH = /\.(json|jsonc|ya?ml|toml|ini|env|lock|config\.[cm]?[jt]s)$|(^|\/)\.[a-z]+rc$|(^|\/)(Dockerfile|Makefile)$/i
8const SOURCE_PATH = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|scala|ex|exs|lua|dart|vue|svelte|sh|sql|zig)$/i
9
10export const CROP_LABELS: Record<CropKind, string> = {
11  pumpkin: 'pumpkin',
12  corn: 'corn',
13  sunflower: 'sunflower',
14  tulip: 'tulip',
15  wheat: 'wheat',
16  carrot: 'carrot',
17}
18
19export function cropForPath(path: string): CropKind {
20  if (TEST_PATH.test(path)) {
21    return 'pumpkin'
22  }
23  if (DOC_PATH.test(path)) {
24    return 'sunflower'
25  }
26  if (STYLE_PATH.test(path)) {
27    return 'tulip'
28  }
29  if (CONFIG_PATH.test(path)) {
30    return 'wheat'
31  }
32  return SOURCE_PATH.test(path) ? 'corn' : 'carrot'
33}
34
35export function stageForLines(lines: number): GrowthStage {
36  if (lines >= RIPE_LINES) {
37    return 'ripe'
38  }
39  if (lines >= GROWING_LINES) {
40    return 'growing'
41  }
42  return lines >= SPROUT_LINES ? 'sprout' : 'seed'
43}
44
45const TEST_COMMAND =
46  /(^|[\s;&|(])(npm|pnpm|yarn|bun)\s+(run\s+)?(test|check|vitest|jest)\b|(^|[\s;&|(])(npx\s+)?(vitest|jest|mocha|ava|playwright\s+test|pytest|tox|rspec|phpunit)\b|(^|[\s;&|(])(go|cargo|deno|swift|dotnet|mix|gradle|mvn)\s+test\b|(^|[\s;&|(])node\s+(--test\b|\S*\.test\.[cm]?[jt]s)|(^|[\s;&|(])claude\s+plugin\s+test\b|(^|[\s;&|(])make\s+(test|check)\b|python3?\s+-m\s+(pytest|unittest)\b/
47
48export function isTestCommand(command: string): boolean {
49  return TEST_COMMAND.test(command)
50}
51
hooks/sim/ledger.ts 56 lines
1import { createFarm } from './farm'
2
3export const farm = createFarm()
4
5let root = ''
6let baseline: Map<string, number> | undefined
7let claudeLines = new Map<string, number>()
8
9export function setProjectRoot(path: string): void {
10  root = path.replace(/\/$/, '')
11}
12
13export function projectRoot(): string {
14  return root
15}
16
17export function relativePath(path: string): string {
18  return root !== '' && path.startsWith(root + '/') ? path.slice(root.length + 1) : path
19}
20
21export function countPatchLines(hunks: readonly { lines: readonly string[] }[]): number {
22  return hunks.reduce((total, hunk) => total + hunk.lines.filter(line => line.startsWith('+') || line.startsWith('-')).length, 0)
23}
24
25export function noteClaudeChange(options: { path: string; lines: number }): void {
26  claudeLines.set(options.path, (claudeLines.get(options.path) ?? 0) + options.lines)
27  farm.tend({ ...options, by: 'claude' })
28}
29
30export function parseNumstat(text: string): Map<string, number> {
31  const counts = new Map<string, number>()
32  text
33    .split('\n')
34    .map(line => line.split('\t'))
35    .forEach(([added, removed, ...rest]) => {
36      const path = rest.join('\t')
37      if (path !== '' && added !== undefined && removed !== undefined) {
38        counts.set(path, (Number(added) || 0) + (Number(removed) || 0))
39      }
40    })
41  return counts
42}
43
44export function userChanges(counts: Map<string, number>): { path: string; lines: number }[] {
45  const previous = baseline
46  const claude = claudeLines
47  baseline = counts
48  claudeLines = new Map()
49  if (!previous) {
50    return []
51  }
52  return [...counts.entries()]
53    .map(([path, total]) => ({ path, lines: total - (previous.get(path) ?? 0) - (claude.get(path) ?? 0) }))
54    .filter(change => change.lines > 0)
55}
56
hooks/work.ts 37 lines
1import type { On } from 'claude-code'
2
3import { isTestCommand } from './sim/crops'
4import { countPatchLines, farm, noteClaudeChange, relativePath } from './sim/ledger'
5
6function lineCount(text: string): number {
7  return text === '' ? 0 : text.replace(/\n$/, '').split('\n').length
8}
9
10export function installWork(on: On): void {
11  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
12    const ran = await next(e)
13    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
14      noteClaudeChange({ path: relativePath(e.file_path), lines: Math.max(1, countPatchLines(ran.result.structuredPatch)) })
15    }
16    return ran
17  })
18
19  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
20    const ran = await next(e)
21    if (ran.deny === undefined && ran.isError !== true && ran.result.staged !== true) {
22      const patched = countPatchLines(ran.result.structuredPatch)
23      const lines = patched > 0 ? patched : lineCount(e.content)
24      noteClaudeChange({ path: relativePath(e.file_path), lines: Math.max(1, lines) })
25    }
26    return ran
27  })
28
29  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
30    const ran = await next(e)
31    if (ran.deny === undefined && isTestCommand(e.command)) {
32      farm.testRan({ isPassing: ran.isError !== true && !ran.result.interrupted })
33    }
34    return ran
35  })
36}
37
hooks/shared/pixel/bitmap.ts 83 lines
1export type Color = number
2
3export type Bitmap = {
4  width: number
5  height: number
6  pixels: (Color | null)[]
7}
8
9export type Tint = (color: Color) => Color
10
11export function createBitmap(options: { width: number; height: number }): Bitmap {
12  const { width, height } = options
13  return { width, height, pixels: Array.from({ length: width * height }, () => null) }
14}
15
16export function setPixel(options: {
17  bitmap: Bitmap
18  x: number
19  y: number
20  color: Color
21}): void {
22  const { bitmap, color } = options
23  const x = Math.round(options.x)
24  const y = Math.round(options.y)
25  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
26    return
27  }
28  bitmap.pixels[y * bitmap.width + x] = color
29}
30
31export function getPixel(options: { bitmap: Bitmap; x: number; y: number }): Color | null {
32  const { bitmap, x, y } = options
33  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
34    return null
35  }
36  return bitmap.pixels[y * bitmap.width + x] ?? null
37}
38
39export function fillRect(options: {
40  bitmap: Bitmap
41  x: number
42  y: number
43  width: number
44  height: number
45  color: Color
46}): void {
47  const { bitmap, color } = options
48  for (let row = 0; row < options.height; row += 1) {
49    for (let column = 0; column < options.width; column += 1) {
50      setPixel({ bitmap, x: options.x + column, y: options.y + row, color })
51    }
52  }
53}
54
55export function stamp(options: {
56  target: Bitmap
57  source: Bitmap
58  x: number
59  y: number
60  isFlipped?: boolean
61  tint?: Tint
62}): void {
63  const { target, source, tint } = options
64  source.pixels.forEach((color, index) => {
65    if (color === null) {
66      return
67    }
68    const column = index % source.width
69    const row = Math.floor(index / source.width)
70    const offset = options.isFlipped ? source.width - 1 - column : column
71    setPixel({
72      bitmap: target,
73      x: options.x + offset,
74      y: options.y + row,
75      color: tint ? tint(color) : color,
76    })
77  })
78}
79
80export function clearBitmap(bitmap: Bitmap): void {
81  bitmap.pixels.fill(null)
82}
83
hooks/shared/pixel/cells.ts 44 lines
1import { getPixel } from './bitmap'
2import type { Bitmap } from './bitmap'
3
4const TERMINAL_DEFAULT = 0x01000000
5const SPACE = 0x20
6const UPPER_HALF_BLOCK = 0x2580
7const LOWER_HALF_BLOCK = 0x2584
8
9export function cellRowsOf(bitmap: Bitmap): number {
10  return Math.floor(bitmap.height / 2)
11}
12
13export function toCellWords(bitmap: Bitmap): Uint32Array {
14  const rows = cellRowsOf(bitmap)
15  const words = new Uint32Array(bitmap.width * rows * 3)
16  for (let row = 0; row < rows; row += 1) {
17    for (let column = 0; column < bitmap.width; column += 1) {
18      const upper = getPixel({ bitmap, x: column, y: row * 2 })
19      const lower = getPixel({ bitmap, x: column, y: row * 2 + 1 })
20      const offset = (row * bitmap.width + column) * 3
21      if (upper === null && lower === null) {
22        words.set([SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT], offset)
23      } else if (upper === null && lower !== null) {
24        words.set([LOWER_HALF_BLOCK, lower, TERMINAL_DEFAULT], offset)
25      } else if (upper !== null) {
26        words.set([UPPER_HALF_BLOCK, upper, lower ?? TERMINAL_DEFAULT], offset)
27      }
28    }
29  }
30  return words
31}
32
33function toBinaryString(bytes: Uint8Array): string {
34  let binary = ''
35  bytes.forEach(byte => {
36    binary += String.fromCharCode(byte)
37  })
38  return binary
39}
40
41export function encodeCells(bitmap: Bitmap): string {
42  return btoa(toBinaryString(new Uint8Array(toCellWords(bitmap).buffer)))
43}
44
hooks/sim/types.ts 95 lines
1export type CropKind = 'pumpkin' | 'corn' | 'sunflower' | 'tulip' | 'wheat' | 'carrot'
2
3export type GrowthStage = 'seed' | 'sprout' | 'growing' | 'ripe'
4
5export type Plot = {
6  path: string
7  crop: CropKind
8  lines: number
9  isWilted: boolean
10  plantedOrder: number
11  touchedOrder: number
12}
13
14export type FarmerTask =
15  | 'idle'
16  | 'hoe'
17  | 'water'
18  | 'weed'
19  | 'lift'
20  | 'drop'
21  | 'feed'
22  | 'shoo'
23  | 'rest'
24  | 'inspect'
25  | 'cheer'
26
27export type FarmerMode = 'walk' | FarmerTask
28
29export type FarmerStep = {
30  task: FarmerTask
31  x: number
32  durationMs: number
33  isCarrying: boolean
34  isWork: boolean
35  isFacingLeft?: boolean
36}
37
38export type Farmer = {
39  x: number
40  mode: FarmerMode
41  modeMs: number
42  clockMs: number
43  isFacingLeft: boolean
44  isCarrying: boolean
45  step: FarmerStep | undefined
46}
47
48export type ChickenMode = 'walk' | 'peck' | 'stand'
49
50export type Chicken = {
51  x: number
52  targetX: number
53  mode: ChickenMode
54  modeMs: number
55  limitMs: number
56  isFacingLeft: boolean
57  isBrown: boolean
58}
59
60export type CrowMode = 'away' | 'arrive' | 'perch' | 'leave'
61
62export type Crow = {
63  mode: CrowMode
64  modeMs: number
65  limitMs: number
66  fromX: number
67  fromY: number
68  x: number
69  y: number
70  perchX: number
71}
72
73export type CatMode = 'nap' | 'stretch' | 'walk'
74
75export type Cat = { x: number; targetX: number; mode: CatMode; modeMs: number; limitMs: number; isFacingLeft: boolean }
76
77export type WeatherKind = 'clear' | 'storm' | 'sunny'
78
79export type TestWeather = { kind: WeatherKind; ms: number }
80
81export type Pop = { fromX: number; toX: number; ageMs: number; color: number }
82
83export type Harvest = { count: number; ageMs: number }
84
85export type Cloud = { x: number; y: number; width: number; speed: number }
86
87export type PlotView = { plot: Plot; x: number }
88
89export type FarmSummary = {
90  plots: number
91  ripe: number
92  sessionBushels: number
93  lifetimeBushels: number
94}
95
hooks/sim/farm.ts 200 lines
1import { BARN_DOOR_X } from '../art/barn'
2import { PRODUCE_COLORS } from '../art/crops'
3import { drawScene } from '../draw/scene'
4import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
5import type { Bitmap } from '../shared/pixel/bitmap'
6import { createWeather } from '../shared/pixel/weather'
7import {
8  BARN_MARGIN,
9  BARN_WIDTH,
10  CHEER_MS,
11  FIELD_LEFT,
12  HOE_MS,
13  LOG_LIMIT,
14  MAX_COLUMNS,
15  PLOT_WIDTH,
16  STAGE_HEIGHT,
17  TREE_CROWN,
18  TREE_X,
19} from './constants'
20import { createCalendar } from './calendar'
21import { createCritters } from './critters'
22import { CROP_LABELS } from './crops'
23import { createDirector } from './director'
24import { createEffects } from './effects'
25import { advanceFarmer, assignStep, createFarmer, isFree } from './farmer'
26import { createField } from './field'
27import type { FarmerTask, Plot } from './types'
28
29export function createFarm(options: { seed?: number } = {}) {
30  const seed = options.seed ?? 7
31  const field = createField()
32  const farmer = createFarmer()
33  const director = createDirector(seed)
34  const critters = createCritters(seed + 1)
35  const weather = createWeather({ seed: seed + 2 })
36  const effects = createEffects()
37  let hour = 12
38  let columns = MAX_COLUMNS
39  let bitmap: Bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
40  const calendar = createCalendar()
41  let lines: string[] = []
42
43  function note(line: string): void {
44    lines = [line, ...lines].slice(0, LOG_LIMIT)
45  }
46
47  function barnX(): number {
48    return columns - BARN_WIDTH
49  }
50
51  function work(step: { task: FarmerTask; x: number; durationMs: number }): void {
52    director.clear()
53    assignStep({ farmer, step: { ...step, isCarrying: false, isWork: true, isFacingLeft: true } })
54  }
55
56  function popFor(popOptions: { plot: Plot; x: number | undefined; isToBarn: boolean; delayMs: number }): void {
57    if (popOptions.x === undefined) {
58      return
59    }
60    const fromX = popOptions.x + 2
61    const toX = popOptions.isToBarn ? barnX() + BARN_DOOR_X : fromX
62    effects.pop({ fromX, toX, ageMs: -popOptions.delayMs, color: PRODUCE_COLORS[popOptions.plot.crop] })
63  }
64
65  function planAmbient(): void {
66    const plan = director.next({
67      plots: field.visible(columns).map(view => ({ x: view.x, label: CROP_LABELS[view.plot.crop] })),
68      fieldLeft: FIELD_LEFT,
69      fieldRight: barnX() - BARN_MARGIN,
70      barnX: barnX(),
71      treeX: TREE_X,
72      chickensX: critters.chickensX(),
73      crowX: critters.perchedCrowX(),
74      scarecrowX: field.scarecrowX(columns),
75    })
76    assignStep({ farmer, step: plan.step })
77    if (plan.line) {
78      note(plan.line)
79    }
80    if (plan.step.task === 'feed') {
81      critters.scatterFeed({ x: plan.step.x, durationMs: 6000 })
82    }
83  }
84
85  return {
86    resize(nextColumns: number): void {
87      if (nextColumns !== columns) {
88        columns = nextColumns
89        bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
90      }
91    },
92
93    begin(startMs: number): void {
94      calendar.begin(startMs)
95    },
96
97    setHour(nextHour: number): void {
98      hour = ((nextHour % 24) + 24) % 24
99    },
100
101    restore(saved: { lifetimeBushels: number }): void {
102      field.restore(saved)
103    },
104
105    tend(change: { path: string; lines: number; by?: 'claude' | 'you' }): void {
106      const { plot, x, didRipen } = field.tend({ ...change, columns })
107      if (x !== undefined) {
108        work({ task: 'hoe', x: x + PLOT_WIDTH - 1, durationMs: HOE_MS })
109      }
110      if (didRipen) {
111        popFor({ plot, x, isToBarn: false, delayMs: 0 })
112      }
113      note(`${change.by === 'you' ? 'You changed' : 'Claude tended'} ${change.path}, ${change.lines} ${change.lines === 1 ? 'line' : 'lines'}`)
114    },
115
116    testRan(result: { isPassing: boolean }): void {
117      effects.testRan(result)
118      field.testRan(result)
119      note(result.isPassing ? 'Tests passed: sun on the field' : 'Tests failed: a storm wilts the pumpkins')
120    },
121
122    beginTurn(): void {
123      field.beginTurn()
124    },
125
126    endTurn(): number {
127      const ripe = field.harvest(columns)
128      if (ripe.length === 0) {
129        return 0
130      }
131      ripe.forEach((entry, index) => popFor({ ...entry, isToBarn: true, delayMs: index * 120 }))
132      effects.harvested(ripe.length)
133      work({ task: 'cheer', x: farmer.x, durationMs: CHEER_MS })
134      note(`Harvested ${ripe.length} ${ripe.length === 1 ? 'crop' : 'crops'} into the barn`)
135      return ripe.length
136    },
137
138    tick(dtMs: number): void {
139      const newSeason = calendar.advance(dtMs)
140      if (newSeason) {
141        note(`${newSeason.label} comes to the farm`)
142      }
143      const season = calendar.season()
144      advanceFarmer({ farmer, dtMs })
145      if (isFree(farmer)) {
146        planAmbient()
147      }
148      if (farmer.mode === 'shoo' && farmer.modeMs > 300) {
149        critters.shooCrow()
150      }
151      critters.advance({
152        dtMs,
153        columns,
154        barnX: barnX(),
155        fieldLeft: FIELD_LEFT,
156        perchSpots: field.visible(columns).map(view => view.x + 1),
157      })
158      weather.advance({ dtMs, season, width: columns, groundY: STAGE_HEIGHT, sources: [TREE_CROWN] })
159      effects.advance({ dtMs, columns })
160    },
161
162    plots(): Plot[] {
163      return field.plots()
164    },
165
166    summary() {
167      return field.summary()
168    },
169
170    calendar,
171
172    activity(): { task: FarmerTask; isWork: boolean } {
173      return { task: farmer.step?.task ?? 'idle', isWork: farmer.step?.isWork === true }
174    },
175
176    log(): readonly string[] {
177      return lines
178    },
179
180    frame(): Bitmap {
181      clearBitmap(bitmap)
182      drawScene({
183        bitmap,
184        hour,
185        season: calendar.season(),
186        effects: effects.view(),
187        views: field.visible(columns),
188        scarecrowX: field.scarecrowX(columns),
189        farmer,
190        critters,
191        weather,
192        bushels: field.summary().lifetimeBushels,
193      })
194      return bitmap
195    },
196  }
197}
198
199export type Farm = ReturnType<typeof createFarm>
200