SLOPSHOPPER

emcc-core

EMCC's shared base: the $.emcc noun (evidence of tests, builds, lints and edits; badges; profile; kill switch) and one status band above the prompt.

newbandguardcommand
★ 1v0.2.1MITupdated 2026-10-14Xabilimon1/emcc-claude-code-mods/mods/emcc-core
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · emcc-core
› 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 ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /emcc ⎿ emcc-core: EMCC profile: standard ⎿ emcc-core: Evidence: test FAIL 1 ⎿ emcc-core: Badges: none EMCC halted test FAIL 1 [ Resume ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
EMCC halted test FAIL 1 [ Resume ] ⟨Claude Code's own drawing⟩
README

emcc-core

The base EMCC mods build on. Tested with Claude Code 2.1.287, terminal and desktop.

What you get

  • One band above the prompt with the latest result of each kind of check (test pass, lint FAIL 1), the badges other EMCC mods post, and a Halt button. It stays hidden until there is something to show.
  • /emcc prints the profile, the evidence and the badges.
  • /emcc-halt turns every EMCC gate off for the session, or back on. The band's Halt button does the same.
  • Profile (/config, or claude plugin install emcc-core@emcc --config profile=strict): standard (gates report what they let through; goalpost-guard still asks when there is no screen), strict (gates deny) or off.

For mod authors: $.emcc

emcc-core may not be installed, and claude plugin validate refuses a mod that reads $.emcc as a value (if ($.emcc)), so call a method and catch: with emcc-core missing, the call throws.

async function emccProfile($: EngineInterface): Promise<EmccProfile | null> {
  try {
    return await $.emcc.profile()
  } catch {
    return null // emcc-core is not installed
  }
}

// in a hook
const profile = await emccProfile($)
if (profile === 'strict') {
  const test = await $.emcc.evidenceLatest({ kind: 'test' })
  if (!test?.isOk) {
    return { deny: 'Run the tests first.' }
  }
}
MethodAnswers
evidenceLatest({ kind })The newest evidence of test, build, lint, typecheck or edit, or null
evidenceSince({ at })All evidence at or after at (ms, as $.clock.now() reads)
badge({ id, text?, tone? })Shows or replaces your slot in the band; without text removes it. Not while drawing
profile()standard, strict or off (off also while halted)
isHalted()Whether the kill switch is on
flag({ source, rule, file?, text })Records a lowered bar for other mods to show. Not while drawing
flagsSince({ at })All flags raised at or after at (ms, as $.clock.now() reads); the newest 200 are kept for the session
kindsOf({ command })The evidence kinds a shell command runs, as the log classifies it

The types are in types/index.d.ts; /plugin-types copies them for a mod that depends on emcc-core.

How evidence is read. A finished Bash command is classified by its text (npm test, pytest, tsc, eslint, cargo build…, see hooks/evidence.ts). Its outcome comes from what the engine reported: no error is exit 0, an error is the Exit code N the model read. Commands sent to the background are not recorded, since their outcome is not known yet. Edits come from Edit, Write and NotebookEdit. The newest 200 are kept for the session.

What it hooks and calls

  • Hooks: engine.create (adds $.emcc), session.start, command.run (emcc, emcc-halt), tool.call (Bash, Edit, Write, NotebookEdit, after they run, never changing them), ui.render (AbovePrompt)
  • Calls: $.clock.now, $.command.register, $.state.get, $.state.set, $.ui.resolve
  • No network, no file access, no processes.

Tests

claude plugin test mods/emcc-core

12 tests: the classifier, the evidence bus, profile and kill switch, badges, and the band on both terminal and desktop.

Source 3 files
hooks/register.tsx 255 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { EmccBadge, EmccEvidence, EmccEvidenceKind, EmccFlag, EmccProfile, EmccTone } from '../types'
5import { exitCodeOf, kindsOf } from './evidence'
6
7const evidence = atom({ plugin: 'emcc-core', key: 'evidence' } as const, [])
8const badges = atom({ plugin: 'emcc-core', key: 'badges' } as const, [])
9const isHalted = atom({ plugin: 'emcc-core', key: 'isHalted' } as const, false)
10
11const KEEP = 200
12const SHOWN: readonly EmccEvidenceKind[] = ['test', 'typecheck', 'lint', 'build']
13// Anthropic's palette: olive for what holds, clay for what needs a look,
14// stone for everything secondary.
15const OLIVE = '#788c5d'
16const CLAY = '#d97757'
17const STONE = '#b0aea5'
18
19const TONE_COLOR: Record<EmccTone, string | undefined> = {
20  neutral: undefined,
21  good: OLIVE,
22  warn: CLAY,
23  bad: CLAY,
24}
25
26const PROFILES: readonly EmccProfile[] = ['standard', 'strict', 'off']
27
28function configuredProfile(value: unknown): EmccProfile {
29  return PROFILES.find(profile => profile === value) ?? 'standard'
30}
31
32function latestOf(list: readonly EmccEvidence[], kind: EmccEvidenceKind): EmccEvidence | null {
33  for (let index = list.length - 1; index >= 0; index -= 1) {
34    const item = list[index]
35    if (item?.kind === kind) {
36      return item
37    }
38  }
39
40  return null
41}
42
43function summaryOf(item: EmccEvidence): string {
44  return item.isOk ? `${item.kind} pass` : `${item.kind} FAIL ${item.exitCode ?? ''}`.trim()
45}
46
47async function record($: EngineInterface, item: EmccEvidence): Promise<void> {
48  await update($, evidence, list => [...list, item].slice(-KEEP))
49}
50
51export const register: Register = (on, options) => {
52  const configured = configuredProfile(options.profile)
53
54  on('engine.create', async (_$, e, next) => {
55    const beneath = await next(e)
56
57    const emcc: EngineInterface['emcc'] = {
58      evidenceLatest: async ({ kind }) => {
59        const { value = [] } = await beneath.state.get({ plugin: 'emcc-core', key: 'evidence' })
60
61        return latestOf(value, kind)
62      },
63      evidenceSince: async ({ at }) => {
64        const { value = [] } = await beneath.state.get({ plugin: 'emcc-core', key: 'evidence' })
65
66        return value.filter(item => item.at >= at)
67      },
68      badge: async ({ id, text, tone }) => {
69        for (let attempt = 0; attempt < 5; attempt += 1) {
70          const held = await beneath.state.get({ plugin: 'emcc-core', key: 'badges' })
71          const others = (held.value ?? []).filter(slot => slot.id !== id)
72          const slot: EmccBadge = { id, text: text ?? '', tone: tone ?? 'neutral' }
73          const shown = text === undefined || text === '' ? others : [...others, slot]
74          const written = await beneath.state.set({ plugin: 'emcc-core', key: 'badges' }, shown, {
75            ifVersion: held.version,
76          })
77          if (written.isSet) {
78            return
79          }
80        }
81        throw new Error('emcc-core: the badges kept changing; try again')
82      },
83      profile: async () => {
84        const { value = false } = await beneath.state.get({ plugin: 'emcc-core', key: 'isHalted' })
85
86        return value ? 'off' : configured
87      },
88      isHalted: async () => {
89        const { value = false } = await beneath.state.get({ plugin: 'emcc-core', key: 'isHalted' })
90
91        return value
92      },
93      flag: async ({ source, rule, file, text }) => {
94        const raised: EmccFlag = { source, rule, ...(file === undefined ? {} : { file }), text, at: await beneath.clock.now() }
95        for (let attempt = 0; attempt < 5; attempt += 1) {
96          const held = await beneath.state.get({ plugin: 'emcc-core', key: 'flags' })
97          const written = await beneath.state.set(
98            { plugin: 'emcc-core', key: 'flags' },
99            [...(held.value ?? []), raised].slice(-KEEP),
100            { ifVersion: held.version },
101          )
102          if (written.isSet) {
103            return
104          }
105        }
106        throw new Error('emcc-core: the flags kept changing; try again')
107      },
108      flagsSince: async ({ at }) => {
109        const { value = [] } = await beneath.state.get({ plugin: 'emcc-core', key: 'flags' })
110
111        return value.filter(item => item.at >= at)
112      },
113      kindsOf: async ({ command }) => kindsOf(command),
114    }
115
116    const added = { emcc }
117
118    return { ...added, ...beneath }
119  })
120
121  on('session.start', async ($, e, next) => {
122    await $.command.register({ name: 'emcc', description: 'Show the EMCC profile, evidence and badges' })
123    await $.command.register({ name: 'emcc-halt', description: 'Turn every EMCC gate off for this session, or back on' })
124
125    return next(e)
126  })
127
128  on('command.run', { command: 'emcc' }, async $ => {
129    const list = await read($, evidence)
130    const profile = (await read($, isHalted)) ? 'off' : configured
131    const facts = SHOWN.map(kind => latestOf(list, kind))
132      .filter((item): item is EmccEvidence => item !== null)
133      .map(summaryOf)
134    const slots = (await read($, badges)).map(slot => `${slot.id}: ${slot.text}`)
135    const lines = [
136      `EMCC profile: ${profile}${(await read($, isHalted)) ? ' (halted)' : ''}`,
137      `Evidence: ${facts.length > 0 ? facts.join(', ') : 'none yet'}`,
138      `Badges: ${slots.length > 0 ? slots.join(', ') : 'none'}`,
139    ]
140
141    return { text: lines.join('\n') }
142  })
143
144  on('command.run', { command: 'emcc-halt' }, async $ => {
145    const now = await update($, isHalted, halted => !halted)
146
147    return { text: now ? 'EMCC halted: every gate is off until /emcc-halt again.' : 'EMCC resumed.' }
148  })
149
150  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
151    const ran = await next(e)
152    const kinds = kindsOf(e.command)
153    const isBackground = ran.deny === undefined && !ran.isError && ran.result.backgroundTaskId !== undefined
154    if (ran.deny !== undefined || kinds.length === 0 || isBackground) {
155      return ran
156    }
157    const exitCode = exitCodeOf(ran.isError === true, ran.text)
158    const at = await $.clock.now()
159    for (const kind of kinds) {
160      await record($, { kind, at, command: e.command.slice(0, 200), exitCode, isOk: exitCode === 0 })
161    }
162
163    return ran
164  })
165
166  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
167    const ran = await next(e)
168    if (ran.deny === undefined && ran.isError !== true) {
169      await record($, { kind: 'edit', at: await $.clock.now(), file: e.file_path })
170    }
171
172    return ran
173  })
174
175  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
176    const ran = await next(e)
177    if (ran.deny === undefined && ran.isError !== true) {
178      await record($, { kind: 'edit', at: await $.clock.now(), file: e.file_path })
179    }
180
181    return ran
182  })
183
184  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
185    const ran = await next(e)
186    if (ran.deny === undefined && ran.isError !== true) {
187      await record($, { kind: 'edit', at: await $.clock.now(), file: e.notebook_path })
188    }
189
190    return ran
191  })
192
193  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
194    const halted = await read($, isHalted)
195    if (e.props.hasSurvey || (configured === 'off' && !halted)) {
196      return next(e)
197    }
198    const list = await read($, evidence)
199    const facts = SHOWN.map(kind => latestOf(list, kind)).filter((item): item is EmccEvidence => item !== null)
200    const slots = await read($, badges)
201    if (!halted && facts.length === 0 && slots.length === 0) {
202      return next(e)
203    }
204    // One band for every plugin: what draws beneath (headspace, witness) keeps the rows EMCC's one row leaves.
205    const below = await next({ ...e, props: { ...e.props, maxRows: Math.max(1, e.props.maxRows - 1) } })
206    const { Box, Button, Text } = $.ui.resolve(e)
207
208    const row = (
209      <Box>
210        <Text color={CLAY} bold>
211          EMCC{' '}
212        </Text>
213        <Text color={STONE}>{halted ? 'halted' : configured} </Text>
214        {facts.map(item => (
215          <Text color={item.isOk ? OLIVE : CLAY} bold={!item.isOk}>
216            {summaryOf(item)}{' '}
217          </Text>
218        ))}
219        {slots.map(slot => (
220          <Text color={TONE_COLOR[slot.tone]} bold={slot.tone === 'bad'}>
221            {slot.text}{' '}
222          </Text>
223        ))}
224        <Button
225          key="halt"
226          label={halted ? 'Resume' : 'Halt'}
227          onPress={() => update($, isHalted, value => !value)}
228        />
229      </Box>
230    )
231    if (isBlank(below)) {
232      return row
233    }
234
235    return (
236      <Box flexDirection="column">
237        {row}
238        {below}
239      </Box>
240    )
241  })
242}
243
244/** Whether a tree beneath draws nothing a person would see (no tree, or empty Boxes and Texts). */
245function isBlank(tree: RenderElement | null | undefined): boolean {
246  if (tree === null || tree === undefined) {
247    return true
248  }
249  if (tree.type !== 'Box' && tree.type !== 'Text') {
250    return false
251  }
252
253  return (tree.children ?? []).every(child => (typeof child === 'string' ? child === '' : typeof child === 'object' && child !== null && 'type' in child && isBlank(child as RenderElement)))
254}
255
hooks/evidence.ts 40 lines
1import type { EmccCommandKind } from '../types'
2
3const PATTERNS: ReadonlyArray<readonly [EmccCommandKind, RegExp]> = [
4  [
5    'test',
6    /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|vitest|jest|pytest|python3?\s+-m\s+(?:pytest|unittest)|go\s+test|cargo\s+(?:test|nextest)|ctest|mocha|playwright\s+test|rspec|phpunit|dotnet\s+test|deno\s+test|claude\s+plugin\s+test)\b/,
7  ],
8  ['typecheck', /\b(?:tsc|vue-tsc|mypy|pyright|cargo\s+check|flow\s+check)\b/],
9  [
10    'lint',
11    /\b(?:eslint|ruff|flake8|pylint|biome\s+(?:check|lint)|cargo\s+clippy|golangci-lint|stylelint|prettier\s+--check|claude\s+plugin\s+validate)\b/,
12  ],
13  [
14    'build',
15    /\b(?:(?:npm|pnpm|yarn|bun)\s+run\s+build|cargo\s+build|go\s+build|make|cmake\s+--build|ninja|vite\s+build|next\s+build|gradlew?\s+build|mvn\s+(?:package|install)|dotnet\s+build)\b/,
16  ],
17]
18
19/**
20 * The kinds a shell command runs, in the order checked: a chain such as
21 * `npm run build && npm test` is both a build and a test.
22 */
23export function kindsOf(command: string): EmccCommandKind[] {
24  return PATTERNS.filter(([, pattern]) => pattern.test(command)).map(([kind]) => kind)
25}
26
27/**
28 * The exit code of a finished command, from what the engine reported: 0 when
29 * it did not error, the "Exit code N" the model read when it did, 1 when the
30 * error carries no code.
31 */
32export function exitCodeOf(isError: boolean, text: string | undefined): number {
33  if (!isError) {
34    return 0
35  }
36  const match = /Exit code (\d+)/.exec(text ?? '')
37
38  return match ? Number(match[1]) : 1
39}
40
types/index.d.ts 125 lines
1/**
2 * The `$.emcc` noun as every caller sees it: EMCC's shared base for mods.
3 *
4 * emcc-core adds the noun in its `engine.create` step. Another mod calls it
5 * (`await $.emcc.profile()`) after checking that it exists, since emcc-core
6 * may not be installed. Nothing here is imported, so it stands on its own.
7 */
8
9/**
10 * What a piece of evidence is about: a command of one of four kinds whose
11 * outcome is known, or a file the session edited.
12 */
13export type EmccEvidenceKind = 'test' | 'build' | 'lint' | 'typecheck' | 'edit'
14
15/**
16 * The kinds a shell command can run: every evidence kind but `edit`.
17 */
18export type EmccCommandKind = Exclude<EmccEvidenceKind, 'edit'>
19
20/**
21 * One observed fact. Commands carry their outcome; edits carry the file.
22 * `at` is the session clock in milliseconds, as `$.clock.now()` reads it.
23 */
24export type EmccEvidence = {
25  kind: EmccEvidenceKind
26  at: number
27  command?: string
28  exitCode?: number
29  isOk?: boolean
30  file?: string
31}
32
33/**
34 * How strict the EMCC gates are. `off` also stands while the session is
35 * halted, so a mod that reads `profile()` alone respects the kill switch.
36 */
37export type EmccProfile = 'standard' | 'strict' | 'off'
38
39/**
40 * A mod's slot in the one EMCC band above the prompt.
41 */
42export type EmccTone = 'neutral' | 'good' | 'warn' | 'bad'
43
44export type EmccBadge = { id: string; text: string; tone: EmccTone }
45
46/**
47 * A lowered bar a mod noticed: what rule, where, and one line for people.
48 * `at` is the session clock in milliseconds, set by emcc-core.
49 */
50export type EmccFlag = {
51  source: string
52  rule: string
53  file?: string
54  text: string
55  at: number
56}
57
58export type Emcc = {
59  /**
60   * The newest evidence of a kind, or null when there is none yet.
61   * @example
62   * const test = await $.emcc.evidenceLatest({ kind: 'test' })
63   */
64  evidenceLatest: (input: { kind: EmccEvidenceKind }) => Promise<EmccEvidence | null>
65
66  /**
67   * Every evidence recorded at or after `at`, oldest first.
68   * @example
69   * const since = await $.emcc.evidenceSince({ at: turnStartedAt })
70   */
71  evidenceSince: (input: { at: number }) => Promise<EmccEvidence[]>
72
73  /**
74   * Shows, replaces or (without `text`) removes a mod's slot in the band.
75   * Call it from a hook or a handler, never while drawing.
76   * @example
77   * await $.emcc.badge({ id: 'redgreen', text: 'RED', tone: 'bad' })
78   */
79  badge: (input: { id: string; text?: string; tone?: EmccTone }) => Promise<void>
80
81  /**
82   * The active profile: the configured one, or `off` while halted.
83   */
84  profile: () => Promise<EmccProfile>
85
86  /**
87   * Whether the kill switch is on (`/emcc-halt` or the band's Halt button).
88   */
89  isHalted: () => Promise<boolean>
90
91  /**
92   * Records a lowered bar for other mods to show. Call it from a hook or a
93   * handler, never while drawing.
94   * @example
95   * await $.emcc.flag({ source: 'goalpost-guard', rule: 'tests.expected', file, text: 'changes the values in 1 assertion' })
96   */
97  flag: (input: { source: string; rule: string; file?: string; text: string }) => Promise<void>
98
99  /**
100   * Every flag raised at or after `at`, in the order they were recorded.
101   */
102  flagsSince: (input: { at: number }) => Promise<EmccFlag[]>
103
104  /**
105   * The evidence kinds a shell command runs, as the log classifies it:
106   * `npm run build && npm test` is `['test', 'build']`, `git status` is `[]`.
107   */
108  kindsOf: (input: { command: string }) => Promise<EmccCommandKind[]>
109}
110
111declare module 'claude-code' {
112  interface EngineInterface {
113    emcc: Emcc
114  }
115
116  interface PluginState {
117    'emcc-core': {
118      evidence: EmccEvidence[]
119      badges: EmccBadge[]
120      flags: EmccFlag[]
121      isHalted: boolean
122    }
123  }
124}
125