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>…

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.
| Want to | Do |
|---|---|
| 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 loaded | ALLAN_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.
| Feature | Status | What it does | Fields | ||
|---|---|---|---|---|---|
| mods | built | /mods lists and switches everything | |||
| voice | built | Keeps Allan's reply rules in the system prompt (survives compaction); `/style normal \ | plain \ | visual` per session | voice, voiceStyle |
| commit | built | Strips 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) | ||
| pulse | built | Band 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 INDEX | pulse, pulseStaleDays | ||
| handover | next | Context meter and alerts, auto compaction guard, /handover, /close | |||
| relay | planned | /relay <session> and /sessions | |||
| askmode | planned | A question turn cannot edit files or touch production | |||
| release | planned | /promote with clean tree check, paired agents, version cap pruning | |||
| watch | planned | Background log, Neon and canary checks with a status light | |||
| testguard | planned | Protected recipients during tests; /secret | |||
| outbox | planned | Client message drafts kept in MESSAGES.md with copy buttons |
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.
claude plugin validate allan claude plugin test allan
hooks/register.ts 42 lines1import { 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}
42hooks/features/commit.ts 80 lines1import 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}
80hooks/features/mods.ts 63 lines1import 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}
63hooks/features/pulse.tsx 163 lines1import { 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}
163hooks/features/voice.ts 59 lines1import { 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}
59hooks/lib/switch.ts 30 lines1import 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}
30hooks/lib/memory.ts 81 lines1// 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}
81hooks/lib/project.ts 38 lines1export 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}
38types/index.d.ts 18 lines1export 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