SLOPSHOPPER

mats

Resources for MATS fellows

newbandtoastprocess
★ 26v0.5.9no licenseupdated 2026-10-09Crazytieguy/alignment-hive/plugins/mats
A shopper browsing a rack in a slop shop
README

mats

Resources for MATS (ML Alignment Theory Scholars) fellows using Claude Code.

What This Plugin Does

Literature review (/mats:lit-review) — Staged literature review pipeline. Searches arXiv, Semantic Scholar, and Google Scholar; collects LessWrong and Alignment Forum posts. Downloads papers, converts to markdown, generates summaries with relevance scoring, and produces a final report. Resumable if interrupted. Works for any research topic, not just AI safety.

Requirements

  • uv for running the literature review Python scripts.
Source 3 files
hooks/register.tsx 60 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { noticeRows } from './notice-band'
5import type { Notice, NoticeAction } from './notice-band'
6
7const PLUGIN = 'mats'
8const notices = atom({ plugin: 'mats', key: 'notices' } as const, [] as Notice[])
9const dismissed = atom({ plugin: 'mats', key: 'dismissed' } as const, [] as string[])
10
11/** Runs the plugin's session-start check and keeps its rows; none when it fails. */
12async function check($: EngineInterface, projectDir: string): Promise<void> {
13  try {
14    // The hive plugin draws the same install row, so mats leaves it to hive. No API lists
15    // plugins; the hive plugin's commands are its mark.
16    if ((await $.command.list()).some(command => command.name.startsWith('hive:'))) return
17    const { exitCode, stdout } = await $.process.run(['bash', `${$.plugin.root}/scripts/check.sh`], {
18      env: { CLAUDE_PLUGIN_ROOT: $.plugin.root, CLAUDE_PROJECT_DIR: projectDir },
19    })
20    const found = exitCode === 0 && stdout.trim() ? (JSON.parse(stdout) as { notices: Notice[] }).notices : []
21    await update($, notices, () => found)
22  } catch {
23    // A check that fails shows nothing rather than a wrong row.
24  }
25}
26
27export const register: Register = on => {
28  // The SessionStart hook's lifecycle: startup and /clear, awaited.
29  on('classic.SessionStart', async ($, e, next) => {
30    if (e.source === 'startup' || e.source === 'clear') await check($, e.cwd)
31    return next(e)
32  })
33
34  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
35    const [below, found, hidden] = await Promise.all([next(e), read($, notices), read($, dismissed)])
36    if (e.props.hasSurvey) return below
37
38    const rows = (found as Notice[]).filter(notice => !hidden.includes(notice.id))
39    if (rows.length === 0) return below
40
41    const onAction = async (_notice: Notice, action: NoticeAction) => {
42      // The install script asks questions, so it runs in a terminal of the person's own.
43      if (action.kind !== 'copy' || !action.command) return
44      const copied = await $.ui.copy({ text: action.command, surface: e.surface })
45      $.ui.toast(copied.isCopied ? 'Copied: paste it into a terminal' : `Run in a terminal: ${action.command}`)
46    }
47    const onDismiss = (notice: Notice) => update($, dismissed, list => [...list, notice.id])
48
49    const ui = $.ui.resolve(e)
50    const drawn = noticeRows(ui, PLUGIN, rows, { onAction, onDismiss })
51
52    return (
53      <ui.Box flexDirection="column">
54        {drawn}
55        {below}
56      </ui.Box>
57    )
58  })
59}
60
hooks/notice-band.tsx 92 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3// One plugin's rows in the band above the prompt, drawn without `$`: each
4// plugin's own `ui.render` hook reads its state and stacks this over what the
5// plugins beneath drew, so third-party plugins on the band keep working.
6//
7// A plugin cannot import another's files, so each plugin that shows notices
8// carries this file; plugins/notice-band.test.ts keeps the copies identical.
9// The `notices` its check prints are this file's `Notice` as JSON.
10
11export type Severity = 'urgent' | 'problem' | 'action' | 'info'
12
13export type NoticeAction = {
14  id: string
15  label: string
16  // command: a slash command; copy: `command` goes on the clipboard, for a
17  // terminal command that is interactive; plugin: the plugin's own handler decides.
18  kind: 'command' | 'copy' | 'plugin'
19  command?: string
20}
21
22export type Notice = {
23  id: string
24  severity: Severity
25  // The whole message: rows wrap rather than hide what the person needs to act.
26  text: string
27  actions?: NoticeAction[]
28  // Set on "updated" notices: a dismissal holds until the plugin's next
29  // version. Without it, a dismissal holds for this session only.
30  version?: string
31  // What a pressed button is doing ("restarting…"): drawn after the text in
32  // place of the buttons, so the row never looks inert. Set by the plugin.
33  busy?: string
34}
35
36export type NoticeHandlers = {
37  onAction: (notice: Notice, action: NoticeAction) => unknown
38  onDismiss: (notice: Notice) => unknown
39}
40
41const MARK: Record<Severity, { rank: number; glyph: string; color: string }> = {
42  urgent: { rank: 0, glyph: '✖', color: 'error' },
43  problem: { rank: 1, glyph: '!', color: 'warning' },
44  action: { rank: 2, glyph: '›', color: 'suggestion' },
45  info: { rank: 3, glyph: '↑', color: 'ide' },
46}
47
48export function noticeRows(
49  ui: ReturnType<EngineInterface['ui']['resolve']>,
50  plugin: string,
51  notices: Notice[],
52  { onAction, onDismiss }: NoticeHandlers,
53): RenderElement[] {
54  const { Box, Button, Text } = ui
55
56  return [...notices]
57    .sort((a, b) => MARK[a.severity].rank - MARK[b.severity].rank)
58    .map(notice => {
59      const mark = MARK[notice.severity]
60      const key = `${plugin}:${notice.id}`
61
62      const buttons = (notice.busy ? [] : (notice.actions ?? [])).map((action, index) => (
63        <Button
64          key={`${key}:${action.id}`}
65          label={action.label}
66          variant={index === 0 ? 'primary' : 'secondary'}
67          onPress={() => onAction(notice, action)}
68        />
69      ))
70      // An info row is a state, not a request: the band's own collapse hides it.
71      if (notice.severity !== 'info' && !notice.busy) {
72        buttons.push(<Button key={`${key}:dismiss`} label="Dismiss" dimColor onPress={() => onDismiss(notice)} />)
73      }
74
75      // The buttons wrap under the text where the row does not fit.
76      return (
77        <Box key={key} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
78          <Text>
79            <Text color={mark.color} bold>
80              {mark.glyph}{' '}
81            </Text>
82            <Text bold>{plugin}</Text> {notice.busy ? `${notice.text} · ${notice.busy}` : notice.text}
83          </Text>
84          {/* Grows to fill its line, so the buttons stay right-aligned when they wrap. */}
85          <Box flexDirection="row" flexGrow={1} justifyContent="flex-end" columnGap={1}>
86            {buttons}
87          </Box>
88        </Box>
89      )
90    })
91}
92
types/index.d.ts 7 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // `notices`: the check's rows, hooks/notice-band.tsx's Notice as parsed JSON.
4    mats: { notices: unknown[]; dismissed: string[] }
5  }
6}
7