SLOPSHOPPER

lintbrain-status

Shows in the prompt footer when your Extended Brain vault was last linted, and hints when it is time to run /LintBrain.

newspinnertoast
v0.1.0MITupdated 2026-10-14pmahli/lintbrain-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lintbrain-status
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ lintbrain-status │ ⏺ Read(src/auth.ts) │ lintbrain-status: set the vaultPath option │ ⎿ Read 6 lines │ (or EXTENDED_BRAIN_PATH) to show the last │ ⏺ Update(src/auth.ts) │ lint run. │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

lintbrain-status

A Claude Code mod that shows in the prompt footer when your Extended Brain vault was last linted, and tells you when it is time to run /LintBrain again.

focus & 🧹 Lint: 3 days ago
focus & 🧹 Lint: 12 days ago — /LintBrain
focus & 🧹 Lint: never

The entry sits with the dim mode labels at the right of the prompt (focus, memory paused, ...). It adds to them and never replaces one.

What it shows

Stamp fileFooter
Holds today's date🧹 Lint: today
1 to 7 days old (the limit, see staleAfterDays)🧹 Lint: 1 day ago, 🧹 Lint: 5 days ago, ...
Older than the limit🧹 Lint: 12 days ago — /LintBrain
Does not exist (never linted)🧹 Lint: never
Exists, but holds no valid date or cannot be read🧹 Lint: unknown — /LintBrain

The — /LintBrain part is the hint that the lint skill should run. A stamp dated in the future counts as today.

The stamp is read once, when the session starts. There is no polling: the value only changes when the lint skill runs, which is rare inside a session. Start a new session (or reload the mod) to see a fresh value.

The stamp file

The mod reads one file, .lintbrain, in the root of your vault. It holds a single line with an ISO date:

2026-10-07

Your lint skill writes it at the end of every successful run, with the vault folder as the working directory:

date +%Y-%m-%d > .lintbrain

The mod ships no lint skill. Any tool that writes that file works. Keep the file out of version control if you do not want a commit per run; it is local state.

Settings

OptionDefaultMeaning
vaultPathemptyAbsolute path of your vault, the folder that holds .lintbrain. A leading ~ is expanded.
staleAfterDays7From how many days without a run the footer adds — /LintBrain.

Left empty, vaultPath falls back to the EXTENDED_BRAIN_PATH environment variable. If neither is set, or the folder does not exist, the footer stays as it was and a toast says why.

Install

In a terminal session:

/plugin install lintbrain-status --marketplace pmahli/lintbrain-status

Answer y to add the marketplace, pick the user scope, then fill in vaultPath on the settings screen.

The /plugin command is not available in the Code tab of the desktop app, but a mod installed from a terminal at the user scope also loads there.

Develop

claude plugin validate .
claude plugin test .

hooks/lint.ts holds the pure helpers (date parsing, footer text), hooks/register.ts the two hooks: session.start reads the stamp into the session's $.state, and a ui.render hook on SessionMode appends the label.

License

MIT

Source 3 files
hooks/register.ts 75 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { LintLabel } from '../types'
5import {
6  dayNumber,
7  describe,
8  expandHome,
9  readStamp,
10  stampPath,
11  staleAfter,
12} from './lint'
13import type { Reading } from './lint'
14
15const lint = atom({ plugin: 'lintbrain-status', key: 'label' } as const, null)
16
17// The vault folder: the option, else EXTENDED_BRAIN_PATH, with `~` expanded.
18// Empty when neither names one.
19async function vaultFolder($: EngineInterface, options: PluginOptions): Promise<string> {
20  const option = typeof options.vaultPath === 'string' ? options.vaultPath.trim() : ''
21  const raw = option !== '' ? option : ((await $.env.get('EXTENDED_BRAIN_PATH')) ?? '').trim()
22
23  return raw === '' ? '' : expandHome(raw, await $.env.get('HOME'))
24}
25
26async function readFooter($: EngineInterface, options: PluginOptions): Promise<LintLabel> {
27  const vault = await vaultFolder($, options)
28
29  if (vault === '') {
30    $.ui.toast('lintbrain-status: set the vaultPath option (or EXTENDED_BRAIN_PATH) to show the last lint run.')
31    return null
32  }
33
34  if (!(await $.fs.exists(vault))) {
35    $.ui.toast(`lintbrain-status: vault folder not found: ${vault}`)
36    return null
37  }
38
39  const file = stampPath(vault)
40  let reading: Reading
41
42  if (!(await $.fs.exists(file))) {
43    reading = { kind: 'never' }
44  } else {
45    try {
46      const today = dayNumber(new Date(await $.clock.now()))
47      reading = readStamp(await $.fs.read(file), today)
48    } catch {
49      // The file is there but cannot be read (a cloud file not downloaded yet, say).
50      reading = { kind: 'unknown' }
51    }
52  }
53
54  return describe(reading, staleAfter(options.staleAfterDays))
55}
56
57export const register: Register = (on, options) => {
58  // Read once per session; the stamp only changes when the lint skill runs.
59  on('session.start', async ($, e, next) => {
60    const footer = await readFooter($, options)
61    await update($, lint, () => footer)
62
63    return next(e)
64  })
65
66  // The footer's dim mode labels sit at the right of the prompt.
67  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
68    const footer = await read($, lint)
69
70    return footer === null
71      ? next(e)
72      : next({ ...e, props: { ...e.props, modes: [...e.props.modes, footer] } })
73  })
74}
75
hooks/lint.ts 92 lines
1export const STAMP_FILE = '.lintbrain'
2export const DEFAULT_STALE_DAYS = 7
3
4const DAY_MS = 86_400_000
5const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
6
7/** What the stamp file says: no file, a file that holds no date, or a day count. */
8export type Reading =
9  | { kind: 'never' }
10  | { kind: 'unknown' }
11  | { kind: 'ago'; days: number }
12
13/**
14 * The calendar day a stamp names, as whole days since 1970-01-01, or undefined
15 * when the first non-blank line is no real ISO date (`2026-02-31` is none).
16 */
17export function parseStamp(text: string): number | undefined {
18  const line = text
19    .split(/\r?\n/)
20    .map(part => part.trim())
21    .find(part => part !== '')
22  const match = line === undefined ? null : ISO_DATE.exec(line)
23
24  if (match === null) return undefined
25
26  const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
27  const ms = Date.UTC(year, month - 1, day)
28  const date = new Date(ms)
29  // Date.UTC rolls an impossible day over into the next month; a real one round-trips.
30  const isReal =
31    date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
32
33  return isReal ? ms / DAY_MS : undefined
34}
35
36/** A moment's local calendar day, as whole days since 1970-01-01. */
37export function dayNumber(date: Date): number {
38  return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS
39}
40
41/**
42 * `text` is the stamp file's content, undefined when the file does not exist;
43 * `today` a `dayNumber`. A stamp in the future (clock skew) counts as today.
44 */
45export function readStamp(text: string | undefined, today: number): Reading {
46  if (text === undefined) return { kind: 'never' }
47
48  const day = parseStamp(text)
49
50  return day === undefined ? { kind: 'unknown' } : { kind: 'ago', days: Math.max(0, today - day) }
51}
52
53export function formatAge(days: number): string {
54  if (days === 0) return 'today'
55
56  return days === 1 ? '1 day ago' : `${days} days ago`
57}
58
59/** The footer text. The /LintBrain hint follows a stamp older than `staleAfterDays`. */
60export function describe(reading: Reading, staleAfterDays: number): string {
61  switch (reading.kind) {
62    case 'never':
63      return '🧹 Lint: never'
64    case 'unknown':
65      return '🧹 Lint: unknown — /LintBrain'
66    case 'ago':
67      return reading.days > staleAfterDays
68        ? `🧹 Lint: ${formatAge(reading.days)} — /LintBrain`
69        : `🧹 Lint: ${formatAge(reading.days)}`
70  }
71}
72
73/** The configured threshold when it is a usable number of days, else the default. */
74export function staleAfter(value: unknown): number {
75  return typeof value === 'number' && Number.isFinite(value) && value >= 0
76    ? Math.floor(value)
77    : DEFAULT_STALE_DAYS
78}
79
80/** Expands a leading `~`; `home` is undefined where the environment has none. */
81export function expandHome(path: string, home: string | undefined): string {
82  if (home === undefined || home === '') return path
83  if (path === '~') return home
84
85  return path.startsWith('~/') ? `${home.replace(/\/+$/, '')}${path.slice(1)}` : path
86}
87
88/** The stamp file inside a vault folder. */
89export function stampPath(vault: string): string {
90  return `${vault.replace(/\/+$/, '')}/${STAMP_FILE}`
91}
92
types/index.d.ts 12 lines
1/** The footer text for the last lint run, or null when there is nothing to show. */
2export type LintLabel = string | null
3
4declare module 'claude-code' {
5  interface PluginState {
6    'lintbrain-status': {
7      /** Read once when the session starts; null hides the footer entry. */
8      label: LintLabel
9    }
10  }
11}
12