SLOPSHOPPER

allan

Allan's workflow mods, built from his own session logs. Every feature has its own on/off row in /config and is switched with /mods on|off <feature>…

newbandguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-06lua-erissatallan/claude-mods/allan
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · allan
› 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 › /mods ⎿ allan: Allan's mods ⎿ allan: on voice voiceStyle=normal ⎿ allan: on commit commitIdentity=block commitEmail=allan@luaimplementation.ai commitInternalIds=warn ⎿ allan: on pulse pulseStaleDays=7 ⎿ allan: ⎿ allan: /mods off <feature> · /mods on <feature> · /mods set <field> <value> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Allan's Claude Code mods, built from his own session logs (see M-Kopa/MODS-PLAN-2026-10-04.md). One plugin, allan, with one feature per mod. Every feature can be switched on or off.

Switching

Want toDo
See every feature and its state/mods
Turn one off or on/mods off <feature> · /mods on <feature>
Change a setting/mods set <field> <value> (for example /mods set commitIdentity warn)
Same, by menu/config, rows named allan.<field>
Turn the whole plugin off/plugin disable allan@allan-mods
Emergency: start with nothing loadedALLAN_MODS_OFF=1 claude
Ask Claude"turn off watch": it runs /mods off watch or sets pluginConfigs.allan.watch to false in ~/.claude/settings.json

Guards fail open: if a mod's own code breaks, the call it was checking goes ahead.

Features

FeatureStatusWhat it doesFields
modsbuilt/mods lists and switches everything
voicebuiltKeeps Allan's reply rules in the system prompt (survives compaction); `/style normal \plain \visual` per sessionvoice, voiceStyle
commitbuiltStrips Claude attribution from commits and PRs; refuses a push that would leave through a non github.com-lua remote or a non work email; flags internal ids (D12, H22, Claude_Memory) in PR text and in files under docs/ or Business_Documents/commit, commitIdentity (off/warn/block), commitEmail, commitInternalIds (off/warn/block)
pulsebuiltBand above the prompt: session name, project, baton, parked count and oldest age (yellow when any is stale), handoffs addressed to this session's surface. /pending answers from INDEX and HANDOFFS with no model call; /park "what" "next step" and /unpark <n> write INDEXpulse, pulseStaleDays
handovernextContext meter and alerts, auto compaction guard, /handover, /close
relayplanned/relay <session> and /sessions
askmodeplannedA question turn cannot edit files or touch production
releaseplanned/promote with clean tree check, paired agents, version cap pruning
watchplannedBackground log, Neon and canary checks with a status light
testguardplannedProtected recipients during tests; /secret
outboxplannedClient message drafts kept in MESSAGES.md with copy buttons

Install (all sessions)

claude plugin marketplace add ~/Desktop/Desktop/Work/claude-mods claude plugin install allan@allan-mods

Edits to this folder reach every session on /reload-plugins.

Develop

claude plugin validate allan claude plugin test allan

Source 9 files
hooks/register.ts 42 lines
1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { commit } from './features/commit'
5import { mods, modsCommand } from './features/mods'
6import { pulse, pulseCommands } from './features/pulse'
7import { asStyle, styleCommand, voice, voiceText } from './features/voice'
8import { isOn, kill, textOf } from './lib/switch'
9
10// The same session value voice.ts writes from /style.
11const style = atom({ plugin: 'allan', key: 'style' } as const, null)
12
13// One plugin, many features. Events with no matcher (session start, the system prompt) are
14// hooked once here, with pure helpers from the features. Matched events (a tool, a command)
15// are hooked inside each feature.
16export const register: Register = (on, options) => {
17  on('session.start', async ($, e, next) => {
18    kill.isOn = (await $.env.get('ALLAN_MODS_OFF')) === '1'
19    if (kill.isOn) {
20      $.ui.toast("Allan's mods are off for this session (ALLAN_MODS_OFF=1).")
21      return next(e)
22    }
23    await $.command.register(modsCommand)
24    await $.command.register(styleCommand)
25    for (const spec of pulseCommands) await $.command.register(spec)
26    return next(e)
27  })
28
29  on('prompt.compose', async ($, e, next) => {
30    const composed = await next(e)
31    if (!isOn(options, 'voice')) return composed
32    const chosen = (await read($, style)) ?? asStyle(textOf(options, 'voiceStyle', 'normal'))
33    return { sections: [...composed.sections, { id: 'allan:voice', text: voiceText(chosen), scope: 'session' as const }] }
34  })
35
36  // Each feature registers on its own, so one failing never takes the others down.
37  try { mods(on, options) } catch { /* reported by the engine */ }
38  try { voice(on, options) } catch { /* reported by the engine */ }
39  try { commit(on, options) } catch { /* reported by the engine */ }
40  try { pulse(on, options) } catch { /* reported by the engine */ }
41}
42
hooks/features/commit.ts 80 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { isOn, modeOf, textOf, type Options } from '../lib/switch'
4
5const PUSH = /(^|[;&|]\s*|\s)git\b(?:\s+-C\s+\S+)?[^;&|\n]*?\bpush\b/
6const PR_TEXT = /\bgh\s+pr\s+(create|comment|review|edit)\b/
7const INTERNAL = /\b(?:D|H|CC|CR|O)\d{1,3}\b|Claude_Memory|HANDOFFS(?:\.md)?\b/
8const OUTWARD_PATH = /\/(docs|Business_Documents)\//
9
10/** The folder a git command runs in: `git -C <dir>`, a leading `cd <dir> &&`, else the session cwd. */
11async function dirOf($: EngineInterface, command: string): Promise<string> {
12  const c = command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
13  if (c?.[1] !== undefined) return c[1].replace(/^["']|["']$/g, '')
14  const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/)
15  if (cd?.[1] !== undefined) return cd[1].replace(/^["']|["']$/g, '').replace(/^~/, (await $.env.get('HOME')) ?? '~')
16  return $.session.cwd()
17}
18
19function remoteOf(command: string): string {
20  const after = command.slice(command.search(/\bpush\b/) + 4).trim().split(/\s+/)
21  return after.find(t => t !== '' && !t.startsWith('-')) ?? 'origin'
22}
23
24async function git($: EngineInterface, dir: string, args: string[]): Promise<string> {
25  const r = await $.process.run(['git', '-C', dir, ...args])
26  return r.exitCode === 0 ? r.stdout.trim() : ''
27}
28
29export function commit(on: On, options: Options): void {
30  on('attribution.text', ($, e, next) => {
31    if (!isOn(options, 'commit')) return next(e)
32    return e.kind === 'commit' || e.kind === 'pr' ? { text: '' } : next(e)
33  })
34
35  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
36    if (!isOn(options, 'commit')) return next(e)
37    const command = String(e.command ?? '')
38
39    const identity = modeOf(options, 'commitIdentity', 'block')
40    if (identity !== 'off' && PUSH.test(command)) {
41      const dir = await dirOf($, command)
42      const url = await git($, dir, ['remote', 'get-url', remoteOf(command)])
43      const email = await git($, dir, ['config', 'user.email'])
44      const wantEmail = textOf(options, 'commitEmail', 'allan@luaimplementation.ai')
45      const problems = [
46        url !== '' && !url.includes('github.com-lua') ? `remote is ${url} (not github.com-lua)` : '',
47        email !== '' && email !== wantEmail ? `user.email is ${email} (not ${wantEmail})` : '',
48      ].filter(Boolean)
49      if (problems.length > 0) {
50        const why = `allan/commit: this push would leave under the wrong identity: ${problems.join('; ')}.`
51        if (identity === 'block') {
52          return { deny: `${why} Fix the remote or user.email in ${dir}, or allow it with /mods set commitIdentity warn.` }
53        }
54        $.ui.toast(why)
55      }
56    }
57
58    const ids = modeOf(options, 'commitInternalIds', 'warn')
59    if (ids !== 'off' && PR_TEXT.test(command) && INTERNAL.test(command)) {
60      const why = `allan/commit: this PR text carries an internal memory reference (${command.match(INTERNAL)?.[0] ?? ''}). Clients and reviewers should not see those.`
61      if (ids === 'block') return { deny: `${why} Rewrite it, or /mods set commitInternalIds warn.` }
62      $.ui.toast(why)
63    }
64    return next(e)
65  }).catch(($, e, next) => next(e))
66
67  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
68    if (!isOn(options, 'commit')) return next(e)
69    const ids = modeOf(options, 'commitInternalIds', 'warn')
70    const path = String(e.file_path ?? '')
71    const text = String(e.content ?? '')
72    if (ids !== 'off' && OUTWARD_PATH.test(path) && INTERNAL.test(text)) {
73      const why = `allan/commit: ${path} is client facing and carries an internal reference (${text.match(INTERNAL)?.[0] ?? ''}).`
74      if (ids === 'block') return { deny: `${why} Rewrite it, or /mods set commitInternalIds warn.` }
75      $.ui.toast(why)
76    }
77    return next(e)
78  }).catch(($, e, next) => next(e))
79}
80
hooks/features/mods.ts 63 lines
1import type { On } from 'claude-code'
2
3import { FEATURES, type Options } from '../lib/switch'
4
5const FIELDS = new Set(Object.values(FEATURES).flat())
6
7function listing(options: Options): string {
8  const o = options as Record<string, unknown>
9  const lines = Object.entries(FEATURES).map(([name, fields]) => {
10    const state = o[name] === false ? 'off' : 'on '
11    const extra = fields
12      .filter(f => f !== name)
13      .map(f => `${f}=${String(o[f] ?? 'default')}`)
14      .join('  ')
15    return `${state}  ${name.padEnd(10)} ${extra}`
16  })
17  return [
18    "Allan's mods",
19    ...lines,
20    '',
21    '/mods off <feature>  ·  /mods on <feature>  ·  /mods set <field> <value>',
22    'Everything off at once: start Claude with ALLAN_MODS_OFF=1, or /plugin disable allan@allan-mods.',
23  ].join('\n')
24}
25
26export const modsCommand = {
27  name: 'mods',
28  description: "List Allan's mods, or switch one: /mods off watch · /mods on watch · /mods set commitIdentity warn",
29  argumentHint: '[on|off|set] [feature|field] [value]',
30}
31
32export function mods(on: On, options: Options): void {
33  on('command.run', { command: 'mods' }, async ($, e) => {
34    const [verb, target, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
35    if (verb === undefined) return { text: listing(options) }
36
37    if ((verb === 'on' || verb === 'off') && target === undefined) {
38      return { text: `Which feature? For example /mods ${verb} voice. Features: ${Object.keys(FEATURES).join(', ')}.` }
39    }
40
41    if ((verb === 'on' || verb === 'off') && target !== undefined) {
42      if (!(target in FEATURES)) return { text: `No feature named "${target}". /mods lists them.` }
43      const wanted = verb === 'on'
44      const current = (options as Record<string, unknown>)[target] !== false
45      if (current === wanted) return { text: `${target} is already ${verb}.` }
46      // Changing a row reloads this plugin, so reply first and let the change land after.
47      void $.config.set({ key: `allan.${target}`, value: wanted })
48      return { text: `${target} is now ${verb}.` }
49    }
50
51    if (verb === 'set' && target !== undefined && rest.length > 0) {
52      if (!FIELDS.has(target)) return { text: `No field named "${target}". /mods lists them.` }
53      const raw = rest.join(' ')
54      const value = raw === 'true' ? true : raw === 'false' ? false : raw
55      if ((options as Record<string, unknown>)[target] === value) return { text: `${target} is already ${raw}.` }
56      void $.config.set({ key: `allan.${target}`, value })
57      return { text: `${target} = ${raw}.` }
58    }
59
60    return { text: `Not understood: /mods ${e.args}\n\n${listing(options)}` }
61  })
62}
63
hooks/features/pulse.tsx 163 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { PulseView } from '../../types'
5import { addParked, isForSurface, openHandoffs, parseParkArgs, removeParked, surfaceOf } from '../lib/memory'
6import { batonOf, parentOf, parkedOf, type Project } from '../lib/project'
7import { isOn, type Options } from '../lib/switch'
8
9const view = atom({ plugin: 'allan', key: 'pulse' } as const, null)
10const sessionName = atom({ plugin: 'allan', key: 'session' } as const, null)
11
12export const pulseCommands = [
13  { name: 'pending', description: 'What is pending here: baton, parked items with ages, open handoffs for this session. Reads the files, no model call.' },
14  { name: 'park', description: 'Park a thread before you pivot: /park "what" "next step"', argumentHint: '"what" "next step"' },
15  { name: 'unpark', description: 'Remove parked item n (as numbered by /pending): /unpark 2', argumentHint: '<n>' },
16]
17
18function staleDaysOf(options: Options): number {
19  const v = (options as Record<string, unknown>)['pulseStaleDays']
20  return typeof v === 'number' && v > 0 ? v : 7
21}
22
23/** Walks up from the cwd to the nearest folder holding Claude_Memory/INDEX.md or lua.skill.yaml. */
24async function findProject($: EngineInterface): Promise<Project | null> {
25  let dir: string | null = await $.session.cwd()
26  while (dir !== null) {
27    const hasMemory = await $.fs.exists(`${dir}/Claude_Memory/INDEX.md`)
28    const hasAgent = await $.fs.exists(`${dir}/lua.skill.yaml`)
29    if (hasMemory || hasAgent) {
30      return { root: dir, name: dir.slice(dir.lastIndexOf('/') + 1), memoryDir: hasMemory ? `${dir}/Claude_Memory` : null, hasAgent }
31    }
32    dir = parentOf(dir)
33  }
34  return null
35}
36
37async function readOr($: EngineInterface, path: string): Promise<string> {
38  return (await $.fs.exists(path)) ? $.fs.read(path) : ''
39}
40
41async function refresh($: EngineInterface, options: Options): Promise<void> {
42  if (!isOn(options, 'pulse')) {
43    await update($, view, () => null)
44    return
45  }
46  const project = await findProject($)
47  if (project === null || project.memoryDir === null) {
48    await update($, view, () => null)
49    return
50  }
51  const index = await readOr($, `${project.memoryDir}/INDEX.md`)
52  const handoffs = await readOr($, `${project.memoryDir}/HANDOFFS.md`)
53  const session = await read($, sessionName)
54  const parked = parkedOf(index, await $.clock.now())
55  const surface = session === null ? '' : surfaceOf(session)
56  const next: PulseView = {
57    session,
58    project: project.name,
59    baton: batonOf(index),
60    parked: parked.length,
61    oldestDays: parked.reduce((m, p) => Math.max(m, p.ageDays), 0),
62    staleCount: parked.filter(p => p.ageDays > staleDaysOf(options)).length,
63    handoffs: openHandoffs(handoffs).filter(r => isForSurface(r, surface)).length,
64  }
65  await update($, view, () => next)
66}
67
68export function pulse(on: On, options: Options): void {
69  // Also fires after each reload of this plugin, so the band is filled without waiting for a prompt.
70  on('session.start', { isInteractive: true }, async ($, e, next) => {
71    await refresh($, options)
72    return next(e)
73  })
74
75  on('classic.SessionStart', async ($, e, next) => {
76    if (typeof e.session_title === 'string' && e.session_title !== '') await update($, sessionName, () => e.session_title ?? null)
77    await refresh($, options)
78    return next(e)
79  })
80
81  on('classic.UserPromptSubmit', async ($, e, next) => {
82    if (typeof e.session_title === 'string' && e.session_title !== '') await update($, sessionName, () => e.session_title ?? null)
83    await refresh($, options)
84    return next(e)
85  })
86
87  on('turn.complete', async ($, e, next) => {
88    const done = await next(e)
89    await refresh($, options)
90    return done
91  })
92
93  on('command.run', { command: 'pending' }, async $ => {
94    const project = await findProject($)
95    if (project === null || project.memoryDir === null) return { text: 'No Claude_Memory/INDEX.md above this folder.' }
96    const index = await readOr($, `${project.memoryDir}/INDEX.md`)
97    const handoffs = await readOr($, `${project.memoryDir}/HANDOFFS.md`)
98    const session = await read($, sessionName)
99    const parked = parkedOf(index, await $.clock.now())
100    const mine = openHandoffs(handoffs).filter(r => isForSurface(r, session === null ? '' : surfaceOf(session)))
101    const lines = [
102      `${project.name}${session === null ? '' : ` · ${session}`}`,
103      `▶ ${batonOf(index) ?? 'no baton line in INDEX.md'}`,
104      '',
105      parked.length === 0 ? '⏳ Nothing parked.' : `⏳ Parked (${parked.length}):`,
106      ...parked.map((p, i) => `  ${i + 1}. ${p.ageDays}d · ${p.text}`),
107      '',
108      session === null
109        ? 'Handoffs: session has no name yet (/rename it to see its handoffs).'
110        : mine.length === 0 ? `No open handoffs addressed to ${surfaceOf(session)}.` : `Open handoffs for ${surfaceOf(session)} (${mine.length}):`,
111      ...mine.slice(0, 15).map(r => `  ${r.id} · from ${r.from} · ${r.title}`),
112    ]
113    return { text: lines.join('\n') }
114  })
115
116  on('command.run', { command: 'park' }, async ($, e) => {
117    const parsed = parseParkArgs(e.args)
118    if (parsed === null) return { text: 'Usage: /park "what" "next step"' }
119    const project = await findProject($)
120    if (project === null || project.memoryDir === null) return { text: 'No Claude_Memory/INDEX.md above this folder.' }
121    const path = `${project.memoryDir}/INDEX.md`
122    const date = new Date(await $.clock.now()).toISOString().slice(0, 10)
123    const who = (await read($, sessionName)) ?? 'unnamed session'
124    const line = `- ${date} · ${parsed.what}${parsed.next === '' ? '' : ` · next step: ${parsed.next}`} · (${who})`
125    await $.fs.write(path, addParked(await $.fs.read(path), line))
126    await refresh($, options)
127    return { text: `Parked in ${project.name}/Claude_Memory/INDEX.md:\n${line}` }
128  })
129
130  on('command.run', { command: 'unpark' }, async ($, e) => {
131    const n = Number.parseInt(e.args.trim(), 10)
132    if (!Number.isFinite(n) || n < 1) return { text: 'Usage: /unpark <n>, numbered as /pending lists them.' }
133    const project = await findProject($)
134    if (project === null || project.memoryDir === null) return { text: 'No Claude_Memory/INDEX.md above this folder.' }
135    const path = `${project.memoryDir}/INDEX.md`
136    const done = removeParked(await $.fs.read(path), n)
137    if (done === null) return { text: `There is no parked item ${n}. /pending lists them.` }
138    await $.fs.write(path, done.text)
139    await refresh($, options)
140    return { text: `Unparked: ${done.removed}` }
141  })
142
143  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
144    const v = await read($, view)
145    if (v === null || e.props.hasSurvey || !isOn(options, 'pulse')) return next(e)
146    const { Box, Text } = $.ui.resolve(e)
147    const parts = [
148      v.session ?? 'unnamed',
149      v.project,
150      v.baton === null ? null : `▶ ${v.baton.slice(0, 40)}`,
151      v.parked === 0 ? null : `⏳ ${v.parked} parked (oldest ${v.oldestDays}d)`,
152      v.handoffs === 0 ? null : `${v.handoffs} handoff${v.handoffs === 1 ? '' : 's'} for you`,
153    ].filter((p): p is string => p !== null)
154    return (
155      <Box>
156        <Text dimColor={v.staleCount === 0} color={v.staleCount > 0 ? 'yellow' : undefined}>
157          {parts.join('  ·  ')}
158        </Text>
159      </Box>
160    )
161  })
162}
163
hooks/features/voice.ts 59 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { VoiceStyle } from '../../types'
5import { isOn, textOf, type Options } from '../lib/switch'
6
7const style = atom({ plugin: 'allan', key: 'style' } as const, null)
8
9const BASE = [
10  "How to write replies for Allan (his standing preferences, kept here so they survive compaction):",
11  '- Lead with the answer or the outcome. Then only the detail he needs to act. Aim for half the length you would naturally write.',
12  '- Plain words, short sentences. He juggles several client projects and often reads on his phone.',
13  '- When a task is non-trivial to implement, walk through the build steps; he knows the terms but has not built much end to end.',
14  '- Minimize em dashes and hyphens in prose, code identifiers and anything sent outward.',
15  '- Never put internal memory references (decision or handoff ids like D12, H22, Claude_Memory paths) into anything a client or another company will read.',
16  '- Any message he must paste into another session goes between a START line and an END line that name the target session.',
17].join('\n')
18
19const EXTRA: Record<VoiceStyle, string> = {
20  normal: '',
21  plain:
22    '- Style for this session: explain as if to a bright high school student. Keep every crucial detail; drop jargon or explain it in one clause.',
23  visual:
24    '- Style for this session: where a flow, sequence or structure is involved, add one small ASCII diagram (under 12 lines) before the prose.',
25}
26
27function asStyle(value: string): VoiceStyle {
28  return value === 'plain' || value === 'visual' ? value : 'normal'
29}
30
31export const styleCommand = {
32  name: 'style',
33  description: 'Reply style for this session: /style normal · /style plain (high school tone) · /style visual (ASCII diagrams)',
34  argumentHint: '[normal|plain|visual]',
35}
36
37/** The voice section's text for a style (pure: register.ts reads the state and options). */
38export function voiceText(chosen: VoiceStyle): string {
39  return [BASE, EXTRA[chosen]].filter(Boolean).join('\n')
40}
41
42export { asStyle }
43
44export function voice(on: On, options: Options): void {
45  on('command.run', { command: 'style' }, async ($, e) => {
46    const wanted = e.args.trim()
47    if (wanted === '') {
48      const now = (await read($, style)) ?? asStyle(textOf(options, 'voiceStyle', 'normal'))
49      return { text: `Reply style: ${now}${isOn(options, 'voice') ? '' : ' (voice is off: /mods on voice)'}` }
50    }
51    if (wanted !== 'normal' && wanted !== 'plain' && wanted !== 'visual') {
52      return { text: 'Styles: normal, plain, visual.' }
53    }
54    await update($, style, () => wanted)
55    return { text: `Reply style for this session: ${wanted}.` }
56  })
57
58}
59
hooks/lib/switch.ts 30 lines
1import type { Register } from 'claude-code'
2
3export type Options = Parameters<Register>[1]
4export type Mode = 'off' | 'warn' | 'block'
5
6/** Set from ALLAN_MODS_OFF at session start; when true every feature passes through. */
7export const kill = { isOn: false }
8
9/** A feature is on unless its userConfig row is false or the kill switch is set. */
10export function isOn(options: Options, feature: string): boolean {
11  return !kill.isOn && (options as Record<string, unknown>)[feature] !== false
12}
13
14export function modeOf(options: Options, field: string, fallback: Mode): Mode {
15  const value = (options as Record<string, unknown>)[field]
16  return value === 'off' || value === 'warn' || value === 'block' ? value : fallback
17}
18
19export function textOf(options: Options, field: string, fallback: string): string {
20  const value = (options as Record<string, unknown>)[field]
21  return typeof value === 'string' && value.length > 0 ? value : fallback
22}
23
24/** Every feature and the userConfig fields it owns, in build order. */
25export const FEATURES: Record<string, readonly string[]> = {
26  voice: ['voice', 'voiceStyle'],
27  commit: ['commit', 'commitIdentity', 'commitEmail', 'commitInternalIds'],
28  pulse: ['pulse', 'pulseStaleDays'],
29}
30
hooks/lib/memory.ts 81 lines
1// Pure readers and writers for a project's Claude_Memory files. No $ here: callers read and write the files.
2
3export type HandoffRow = { id: string; from: string; to: string; title: string; status: string }
4
5/** The surface a session name belongs to: "Code-Inquiry-7" -> "code-inquiry", "Code-2" -> "code". */
6export function surfaceOf(session: string): string {
7  return session.trim().replace(/[-\s]*\d+$/, '').toLowerCase()
8}
9
10/** Open rows of the HANDOFFS queue tables: `| ID | FROM → TO | title | ... | [ ] status |`. */
11export function openHandoffs(handoffs: string): HandoffRow[] {
12  const rows: HandoffRow[] = []
13  for (const line of handoffs.split('\n')) {
14    if (!line.startsWith('|')) continue
15    const cells = line.split('|').slice(1, -1).map(c => c.trim())
16    if (cells.length < 4) continue
17    const route = cells[1] ?? ''
18    if (!route.includes('→')) continue
19    const status = cells[cells.length - 1] ?? ''
20    if (!/\[\s\]|\[~\]|\[\?\]/.test(status)) continue
21    const [from = '', to = ''] = route.split('→').map(s => s.replace(/\*/g, '').trim())
22    rows.push({ id: (cells[0] ?? '').replace(/\*/g, ''), from, to, title: (cells[2] ?? '').replace(/\*\*/g, '').slice(0, 120), status })
23  }
24  return rows
25}
26
27export function isForSurface(row: HandoffRow, surface: string): boolean {
28  const to = row.to.toLowerCase()
29  if (surface === '') return false
30  return to === surface || to.startsWith(`${surface} `) || to.startsWith(`${surface}(`) || to.includes(surface)
31}
32
33/** Adds one line under "## ⏳ Parked", creating the section if missing and dropping a "- (none)" placeholder. */
34export function addParked(index: string, line: string): string {
35  const lines = index.split('\n')
36  let at = lines.findIndex(l => /^##\s*⏳\s*Parked/.test(l))
37  if (at < 0) {
38    const firstSection = lines.findIndex((l, i) => i > 0 && /^##\s/.test(l))
39    const insertAt = firstSection < 0 ? lines.length : firstSection
40    lines.splice(insertAt, 0, '## ⏳ Parked', line, '')
41    return lines.join('\n')
42  }
43  let end = at + 1
44  while (end < lines.length && !/^##\s/.test(lines[end] ?? '')) end++
45  const body = lines.slice(at + 1, end).filter(l => !/^-\s*\(none\)\s*$/.test(l))
46  let lastItem = -1
47  body.forEach((l, i) => { if (/^-\s/.test(l)) lastItem = i })
48  const insertIn = lastItem >= 0 ? lastItem + 1 : body.findIndex(l => l.trim() !== '' && !l.startsWith('>')) >= 0 ? body.length : body.length
49  const trimmedTail = body.slice(insertIn)
50  const newBody = [...body.slice(0, insertIn), line, ...trimmedTail]
51  return [...lines.slice(0, at + 1), ...newBody, ...lines.slice(end)].join('\n')
52}
53
54/** Removes the nth (1-based) dated item under "## ⏳ Parked"; returns null when there is no such item. */
55export function removeParked(index: string, n: number): { text: string; removed: string } | null {
56  const lines = index.split('\n')
57  const at = lines.findIndex(l => /^##\s*⏳\s*Parked/.test(l))
58  if (at < 0) return null
59  let seen = 0
60  for (let i = at + 1; i < lines.length; i++) {
61    const l = lines[i] ?? ''
62    if (/^##\s/.test(l)) break
63    if (/^-\s*\d{4}-\d{2}-\d{2}/.test(l)) {
64      seen++
65      if (seen === n) {
66        lines.splice(i, 1)
67        return { text: lines.join('\n'), removed: l }
68      }
69    }
70  }
71  return null
72}
73
74/** Splits `/park "what" "next step"` or `/park what | next step`. */
75export function parseParkArgs(args: string): { what: string; next: string } | null {
76  const quoted = [...args.matchAll(/"([^"]+)"/g)].map(m => m[1] ?? '')
77  if (quoted.length >= 1) return { what: quoted[0] ?? '', next: quoted[1] ?? '' }
78  const [what = '', next = ''] = args.split('|').map(s => s.trim())
79  return what === '' ? null : { what, next }
80}
81
hooks/lib/project.ts 38 lines
1export type Parked = { date: string; ageDays: number; text: string }
2
3export type Project = {
4  root: string
5  name: string
6  memoryDir: string | null
7  hasAgent: boolean
8}
9
10export function parentOf(dir: string): string | null {
11  if (dir === '/' || dir === '') return null
12  const cut = dir.replace(/\/+$/, '').lastIndexOf('/')
13  return cut <= 0 ? '/' : dir.slice(0, cut)
14}
15
16export function batonOf(index: string): string | null {
17  const line = index.split('\n').find(l => /NEXT UP/i.test(l))
18  if (line === undefined) return null
19  const m = line.replace(/\*/g, '').match(/NEXT UP\s*[:\-—]?\s*(.+)$/i)
20  return m?.[1]?.trim() ?? null
21}
22
23export function parkedOf(index: string, now: number): Parked[] {
24  const start = index.search(/^##\s*⏳\s*Parked/m)
25  if (start < 0) return []
26  const rest = index.slice(start).split('\n').slice(1)
27  const out: Parked[] = []
28  for (const line of rest) {
29    if (/^##\s/.test(line)) break
30    const m = line.match(/^-\s*(\d{4}-\d{2}-\d{2})\s*·\s*(.+)$/)
31    if (m === null) continue
32    const date = m[1] ?? ''
33    const ageDays = Math.max(0, Math.floor((now - Date.parse(`${date}T00:00:00Z`)) / 86_400_000))
34    out.push({ date, ageDays, text: (m[2] ?? '').trim() })
35  }
36  return out
37}
38
types/index.d.ts 18 lines
1export type VoiceStyle = 'normal' | 'plain' | 'visual'
2
3export type PulseView = {
4  session: string | null
5  project: string
6  baton: string | null
7  parked: number
8  oldestDays: number
9  staleCount: number
10  handoffs: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    allan: { style: VoiceStyle | null; pulse: PulseView | null; session: string | null }
16  }
17}
18