Personal skills collection

Personal collection of agent skills for coding agents (Claude Code, Cursor, Grok, and others).
Skills follow the Agent Skills format.
Skills marked user are user-invoked only (/name); the rest the agent can reach for on its own. Many are adapted from Matt Pocock's skills, tuned for Laravel/PHP repos.
The engineering pipeline is one straight line (decide → spec → tickets → build) over one directory per effort:
.scratch/<slug>/
map.md # /wayfinder: the decisions (skip it for small, clear work)
decisions/ # /wayfinder: one file per decision ticket
spec.md # /to-spec: exactly one, written once
tickets/ # /to-tickets: one file per build ticket
/implement then builds one file from tickets/ per fresh session. triage is the other on-ramp. There is no external issue tracker; every artifact is a markdown file.
| Skill | Invocation | What it does |
|---|---|---|
architecture-satisfaction | user | Run a bounded architectural refactor loop. |
autopilot | user | Drive an existing wayfinder map, spec, or set of tickets to completion through fresh top-level Claude Code, Codex, or Grok sessions. |
browser-test | user | Test a web app from a start URL and a plain-language prompt: a planner splits it into cases, cheap parallel browser workers run them, and one verdict table comes back. Read-only unless you confirm a write. |
chatter-scout | user | Sweep the web for new chatter on a topic, skip what earlier runs saw, verify the rest in cheap parallel subagents, and write one ranked, cited findings file for a blog post or digest. |
code-review | model | Review the changes since a fixed point (commit, branch, tag, or merge-base) along three axes: Correctness (would it break: bugs, failure modes, security, missing tests), Standards (does the diff follow this repo's documented conventions, including colocated CLAUDE.md rules?) and Spec (does it implement what the originating ticket/PRD asked?). |
code-smells-audit | model | Audit a codebase, path, glob, or branch diff for classic code smells using the 56-smell Luzkan catalog, with detection heuristics tuned to PHP/Laravel and TS/React: sweep nine occurrence lenses, adversarially verify every candidate against the smell's card definition, emit a ranked markdown findings report. |
codebase-design | model | Shared vocabulary for designing deep modules in any codebase. |
commit | user | Commit the work done in this session, skipping files a parallel session also touched. |
dependency-audit | user | Run a Composer or npm dependency audit and propose a safe upgrade plan. |
diagnosing-bugs | model | Diagnosis loop for hard bugs and performance regressions. |
domain-modeling | model | Build and sharpen a project's domain model: challenge terms against the glossary, resolve fuzzy language, stress-test concepts with concrete scenarios, and record GLOSSARY.md entries and ADRs the moment decisions land. |
fix-ci-failures | user | Read a branch's latest failed CI runs, triage each failure from its existing logs, and fix the real ones. |
fix-github-issues | user | Verify, triage, fix, and close review-bot GitHub issues one finding at a time. |
fix-nightwatch-issues | user | Triage open Laravel Nightwatch production issues by root cause, fix the legit ones, and resolve them with evidence. |
fix-posthog-issues | user | Triage active PostHog error-tracking issues by root cause, fix the legit ones, and resolve or suppress the rest. |
grill-me | user | Run a grilling session: the user wants their plan, decision, or idea stress-tested one question at a time. |
grill-with-docs | user | Run a grilling session with domain-modeling alongside, capturing terms in GLOSSARY.md and decisions as ADRs while they land. |
grilling | model | Interview the user one decision at a time until reaching shared understanding, resolving each branch of the decision tree with recommended options and trade-offs. |
handoff | user | Compact the current conversation into a handoff document for another agent to pick up. |
housekeeper | user | Run a conservative code-project housekeeping pass. |
implement | user | Implement one work item end to end: scope and claim it when trackable, TDD at agreed seams, commit, review, verify, and resolve it. |
implement-spec | user | Build a whole spec fast: parallel implementer subagents work the ticket frontier, each in its own worktree, merging onto one integration branch; then one review, one verification pass, and one PR. Cuts the tickets first when the spec has none. |
improve-codebase-architecture | user | Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick. |
laravel-herd-worktrees | model | Use when working with Laravel projects served by Laravel Herd alongside git worktrees on macOS. |
panel | user | Convene a panel of agent CLIs (grok, claude, codex, agy) on a prompt or a code review, each in its own headless subagent, then report one consensus grouped by how much they agree. |
pr | model | The pull request body format, built for fast human review: the smallest visual of the change, before/after evidence, and a one-way or two-way door call with its blast radius. |
prototype | model | Build a throwaway prototype to answer a design question. |
release | user | Commit this session's work, bump the version, tag, push, and publish a GitHub release with grouped notes. |
repository-cleanup | user | Audit and clean Git repository state, including the local databases old worktrees left behind. |
research | model | Investigate a question against high-trust primary sources and capture the findings as a cited Markdown file. |
retro | user | Look back over AI-coding sessions (this one, or your recent Claude Code + OpenAI Codex logs) for corrections, errored or retried commands, rediscovered setup, and missing checks or information, then propose where each fix belongs (a deterministic check, a review rule, a CLAUDE.md/AGENTS.md pointer, a skill, a hook, a tool or config fix, or a content idea). |
resolve-review-comments | model | Triage, fix or rebut, then reply-and-resolve every review comment on a PR (including AI-review bot findings). |
resolving-merge-conflicts | model | "Use when you need to resolve an in-progress git merge/rebase conflict." |
scratch-cleanup | user | Sweep a dusty .scratch/: file research into docs/, remove finished efforts, and report the work still open. |
scratch-status | user | Report what is still open across .scratch/: every effort's open tickets and decisions, what blocks it, how stale it is, and the next skill to run. Read-only. |
setup | user | Configure this repo for the engineering skills: the markdown issue tracker layout, triage label vocabulary, and domain doc layout. |
sweep | user | Dig for one problem nobody has noticed yet, prove it, fix it, and open one PR per problem. Every run finds something new: earlier PRs (open, merged, or declined) are excluded, never a reason to stop. |
tdd | model | The red → green loop for any stack: seams, what a good test is, and the anti-patterns to refuse. |
teach | user | Teach the user a new skill or concept, within this workspace. |
to-questionnaire | user | Turn a decision the user can't fully answer into a Markdown questionnaire for someone else to fill in, async or over a meeting. |
to-spec | model | Turn the current conversation into a spec (PRD) and save it as markdown under .scratch/: no interview, just synthesis, with Laravel test seams chosen from the tdd ranking rules rather than asked about. |
to-tickets | user | Break a spec into tracer-bullet vertical-slice tickets with explicit blocking edges, take one green light on the breakdown, and save one markdown file per ticket beside the spec. |
triage | user | Move issues and external PRs through a state machine of triage roles: categorise, verify, grill if needed, and write agent-ready briefs. |
triage-github-pr | user | Triage a GitHub pull request end to end, from reviews and checks through fixes to a clean merge. |
verify | model | Exercise a change end to end in the running application (hit the route, run the command, click through the page) and report what actually happened. |
wait-what | user | Stop; that last message did not land. Re-pitch it. |
wayfinder | user | Plan work too big for one agent session as a map of decision tickets in markdown under .scratch/, then resolve them one per session until the route to the destination is clear. |
which-skill | user | Not sure which skill to reach for? Name the situation and get routed to one. |
wizard | model | Generate an interactive bash wizard that walks a human through steps only they can perform: provisioning, dashboard credentials, CI secrets, one-off migrations or cutovers. |
writing-for-agents | model | Reference for writing documents an agent consumes: skills, AGENTS.md/CLAUDE.md, and reference docs reached by a pointer. |
Installing the plugin also loads the subagents in agents/ as leek-skills:<name>. The skills dispatch them where they already fan out work: code-review → standards-reviewer + spec-reviewer, codebase-design (design-it-twice) → module-designer, improve-codebase-architecture → deepening-scout, retro → session-miner, panel → panelist, browser-test → browser-test-planner + browser-tester + browser-writer, chatter-scout → chatter-sweeper + chatter-verifier, code-smells-audit → smell-sweeper, fix-github-issues + triage-github-pr → finding-verifier, triage-github-pr → pr-reviewer; research and dependency-audit fork into researcher and dependency-auditor. The one plugin hook, hooks/hooks.json, guards browser-tester and browser-test-planner so they can only run read-only browser commands. Other harnesses ignore the directory and the skills fall back to inline or generic sub-agents, as before. Conventions in AGENTS.md.
The plugin also ships a mod (hooks/mods/), which loads with the plugin in the Claude Code terminal and Desktop Code tab:
/scratch [effort]: a live pane of every .scratch/ effort (open tickets, blockers, claims, staleness, next command), opened instantly, even mid-turn, and refreshed whenever a tool writes under .scratch/. scratch-status adds the commit checks.implement-spec, autopilot, browser-test, panel, chatter-scout, code-review, ...), with its tool count, latest tool, elapsed time, and outcome.Other harnesses, and Claude Code surfaces that don't draw, run the same skills without it.
npx skills (any agent)Install the whole collection:
npx skills add leek/agent-skills
Install a single skill:
npx skills add https://github.com/leek/agent-skills --skill code-review
Re-run either command to update.
/plugin marketplace add leek/agent-skills
/plugin install leek-skills@leek-agent-skills
Codex reads the same .claude-plugin/marketplace.json:
codex plugin marketplace add leek/agent-skills
codex plugin add leek-skills@leek-agent-skills
Codex lists the model-invoked skills as leek-skills:<name> and loads the plugin's hooks/hooks.json. It does not load agents/ or the mod; the skills fall back to Codex's own sub-agents (spawn_agent) or run inline. Pick one route per machine: if you already installed with npx skills, the plugin lists those skills a second time.
cp -r skills/<skill-name> ~/.claude/skills/
Upload the skill folder via skill settings, or paste the SKILL.md contents into a project's knowledge.
See the Skills API Quickstart.
Each skill is a folder under skills/:
skills/
<skill-name>/
SKILL.md # required: frontmatter + instructions
scripts/ # optional: helper scripts
references/ # optional: supporting docs
The SKILL.md frontmatter requires only two fields (Claude Code reads a few optional ones such as argument-hint and allowed-tools; other harnesses ignore them, see AGENTS.md):
---
name: my-skill
description: One sentence, what it does and when Claude should use it.
---
# My Skill
Instructions here.
See template/SKILL.md.example for a starting point and AGENTS.md for full conventions.
template/SKILL.md.example to skills/<skill-name>/SKILL.md.SKILL.md: follow the authoring principles in AGENTS.md and the writing-for-agents skill..claude-plugin/marketplace.json under the leek-skills plugin.MIT
hooks/mods/register.tsx 234 lines1// leek-skills' one Claude Code mod module. Each feature is optional polish over a skill
2// that already works without it; see AGENTS.md "Mods". Every hook that touches $ lives
3// in this file (the engine never follows $ across an import); the imports are pure.
4//
5// /scratch: a live pane of every .scratch/ effort, opened without a Claude turn. Where
6// nothing draws (VS Code chat, claude -p) the command's text reply carries the table.
7//
8// Workers band: above the prompt, every subagent a fan-out skill dispatched (implement-spec,
9// autopilot, browser-test, panel, chatter-scout, code-review), with its tool count, last
10// tool, and outcome. Where nothing draws, the skills report their workers as before.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { ScratchEffort, Worker } from '../../types'
16import { isClosed, scan, summary, tally } from './scratch-scan'
17import { KEEP, describeTool, duration, isFailed, mark, ordered } from './workers-model'
18
19const PANE = 'scratch'
20const board = atom({ plugin: 'leek-skills', key: 'scratch' } as const, null)
21const workers = atom({ plugin: 'leek-skills', key: 'workers' } as const, [])
22const isWorkersHidden = atom({ plugin: 'leek-skills', key: 'isWorkersHidden' } as const, false)
23
24const ago = (ms: number) => {
25 const days = Math.floor(ms / 86_400_000)
26 const hours = Math.floor(ms / 3_600_000)
27
28 return days >= 1 ? `${days}d ago` : hours >= 1 ? `${hours}h ago` : 'just now'
29}
30
31// The slug /scratch was last given; empty means every effort.
32let only = ''
33
34async function refresh($: EngineInterface) {
35 const root = `${await $.session.cwd()}/.scratch`
36 if (!(await $.fs.exists(root))) {
37 return null
38 }
39 const reader = { list: (path: string) => $.fs.list(path), read: (path: string) => $.fs.read(path) }
40 const result = await scan(reader, root, await $.clock.now(), only)
41 await update($, board, () => result)
42
43 return result
44}
45
46export const register: Register = on => {
47 on('session.start', async ($, e, next) => {
48 await $.command.register({
49 name: 'scratch',
50 description: 'Live board of .scratch/ efforts: open tickets, blockers, next step',
51 argumentHint: '[effort-slug]',
52 immediate: true,
53 })
54
55 return next(e)
56 })
57
58 on('command.run', { command: 'scratch' }, async ($, e) => {
59 only = e.args.trim()
60 const result = await refresh($)
61 if (result === null) {
62 return { text: 'No .scratch/ directory here.' }
63 }
64 const opened = await $.ui.open({ id: PANE, title: only === '' ? 'Scratch' : `Scratch: ${only}` })
65
66 // The pane carries the board; the text table is only for surfaces that cannot draw it.
67 return { text: opened.isPlaced ? tally(result) : summary(result) }
68 })
69
70 on('ui.close', { id: PANE }, async ($, e, next) => {
71 await update($, board, () => null)
72
73 return next(e)
74 }).catch(($, e, next) => next(e))
75
76 // Observe only: count a subagent's tool calls, and rescan the open board after a write
77 // under .scratch/. One tool.call hook serves both features.
78 on('tool.call', async ($, e, next) => {
79 const tool = String(e.tool)
80 // A tool's parameters sit on the event itself (e.command, e.file_path), not under an input key.
81 const input = e as unknown as Record<string, unknown>
82 const id = e.agentId
83 if (id !== undefined) {
84 const line = describeTool(tool, input)
85 await update($, workers, list =>
86 list.some(w => w.id === id) ? list.map(w => (w.id === id ? { ...w, tools: w.tools + 1, lastTool: line } : w)) : list,
87 )
88 }
89
90 const result = await next(e)
91
92 const isScratchWrite =
93 tool !== 'Read' && [input.file_path, input.command].some(v => typeof v === 'string' && v.includes('.scratch/'))
94 if (isScratchWrite && (await read($, board)) !== null) {
95 await refresh($)
96 }
97
98 return result
99 }).catch(($, e, next) => next(e))
100
101 on('agent.spawn', async ($, e, next) => {
102 const result = await next(e)
103 if (result.agentId !== undefined) {
104 const worker: Worker = {
105 id: result.agentId,
106 type: e.subagentType,
107 description: e.description,
108 startedAt: await $.clock.now(),
109 endedAt: 0,
110 tools: 0,
111 lastTool: '',
112 outcome: 'running',
113 }
114 await update($, workers, list => [...list, worker].slice(-KEEP))
115 await update($, isWorkersHidden, () => false)
116 }
117
118 return result
119 }).catch(($, e, next) => next(e))
120
121 on('turn.complete', async ($, e, next) => {
122 const id = e.agentId
123 if (id !== undefined) {
124 const now = await $.clock.now()
125 await update($, workers, list => list.map(w => (w.id === id ? { ...w, outcome: e.reason, endedAt: now } : w)))
126 }
127
128 return next(e)
129 })
130
131 // A new prompt with nothing running starts a clean slate.
132 on('prompt.submit', async ($, e, next) => {
133 await update($, workers, list => (list.some(w => w.outcome === 'running') ? list : []))
134
135 return next(e)
136 }).catch(($, e, next) => next(e))
137
138 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139 const list = await read($, workers)
140 if (e.props.hasSurvey || list.length === 0 || (await read($, isWorkersHidden))) {
141 return next(e)
142 }
143
144 const { Box, Text, Button } = $.ui.resolve(e)
145 const now = await $.clock.now()
146 const running = list.filter(w => w.outcome === 'running').length
147 const failed = list.filter(isFailed).length
148 const color = (w: Worker) => (w.outcome === 'running' ? 'suggestion' : w.outcome === 'answer' ? 'success' : 'error')
149
150 return (
151 <Box flexDirection="column" width={e.props.bodyColumns}>
152 <Box gap={1}>
153 <Text bold>
154 Workers {list.length - running}/{list.length} done
155 </Text>
156 {failed > 0 && <Text color="error">{failed} failed</Text>}
157 <Button key="hide" label="hide" plain dimColor onPress={() => update($, isWorkersHidden, () => true)} />
158 </Box>
159 {ordered(list, e.props.maxRows - 1).map(w => (
160 <Text key={w.id} wrap="truncate-end">
161 <Text color={color(w)}>{mark(w)} </Text>
162 <Text>{w.type.replace(/^leek-skills:/, '')}</Text>
163 <Text dimColor>
164 {' '}
165 {w.description} · {w.tools} tools · {duration((w.endedAt || now) - w.startedAt)}
166 {w.outcome === 'running' && w.lastTool !== '' ? ` · ${w.lastTool}` : ''}
167 </Text>
168 </Text>
169 ))}
170 </Box>
171 )
172 })
173
174 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
175 const { Box, Text, Button } = $.ui.resolve(e)
176 const current = await read($, board)
177 const now = await $.clock.now()
178
179 const row = (effort: ScratchEffort) => {
180 const open = effort.files.filter(f => !isClosed(f))
181 const color = effort.state === 'mismatched' ? 'warning' : effort.state === 'done' ? undefined : 'success'
182 const sign =
183 effort.state === 'done' ? '✓' : effort.state === 'mismatched' ? '!' : effort.frontier.length > 0 ? '●' : '○'
184 const work = open.filter(f => f.kind === 'ticket' || f.kind === 'decision')
185
186 return (
187 <Box key={effort.slug} flexDirection="column" marginBottom={1}>
188 <Text wrap="truncate-end">
189 <Text color={color} bold>
190 {sign} {effort.slug}
191 </Text>
192 <Text dimColor>
193 {' '}
194 {open.length} open · {effort.files.length - open.length} closed · {ago(now - effort.lastChangeMs)}
195 </Text>
196 {effort.isStale && <Text color="warning"> · stale</Text>}
197 </Text>
198 {effort.state === 'open' &&
199 work.slice(0, 8).map(f => {
200 const isReady = effort.frontier.includes(f.path)
201 const tag =
202 f.claimedBy !== '' ? ` [${f.claimedBy}]` : !isReady && f.blockedBy.length > 0 ? ` [blocked by ${f.blockedBy.join(', ')}]` : ''
203
204 return (
205 <Text key={f.path} wrap="truncate-end" dimColor={!isReady}>
206 {' '}
207 {f.number} {f.title}
208 {tag}
209 </Text>
210 )
211 })}
212 {effort.state === 'open' && work.length > 8 && <Text dimColor>{` … ${work.length - 8} more`}</Text>}
213 <Text wrap="truncate-end" color={effort.state === 'mismatched' ? 'warning' : 'suggestion'}>
214 {' → '}
215 {effort.next}
216 </Text>
217 </Box>
218 )
219 }
220
221 return (
222 <Box flexDirection="column" width={e.props.bodyColumns}>
223 <Box gap={1}>
224 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
225 <Text dimColor>commit checks: /scratch-status</Text>
226 </Box>
227 {current === null && <Text dimColor>Run /scratch to scan.</Text>}
228 {current !== null && current.efforts.length === 0 && <Text dimColor>No efforts in .scratch/.</Text>}
229 {(current?.efforts ?? []).map(row)}
230 </Box>
231 )
232 })
233}
234hooks/mods/scratch-scan.ts 213 lines1// Reads .scratch/ into a board: the deterministic half of the scratch-status skill.
2// Commit-ancestry checks stay in the skill; this only reads frontmatter and mtimes.
3
4import type { ScratchBoard, ScratchEffort, ScratchFile, ScratchKind } from '../../types'
5
6export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number }
7
8export type Reader = {
9 list: (path: string) => Promise<readonly Entry[]>
10 read: (path: string) => Promise<string>
11}
12
13const CLOSED = new Set(['closed', 'wontfix'])
14const STALE_MS = 30 * 24 * 60 * 60 * 1000
15const TICKET_DIRS: Record<string, ScratchKind> = {
16 decisions: 'decision',
17 tickets: 'ticket',
18 issues: 'ticket',
19}
20
21export const isClosed = (file: ScratchFile) => CLOSED.has(file.status)
22
23export function parseFile(path: string, kind: ScratchKind, text: string): ScratchFile {
24 const name = path.split('/').pop() ?? path
25 const number = kind === 'decision' || kind === 'ticket' ? (name.match(/^(\d+)/)?.[1] ?? '') : ''
26 const front = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ''
27 const field = (key: string) =>
28 front.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'))?.[1]?.trim().replace(/^["']|["']$/g, '') ?? ''
29
30 let status = field('status')
31 if (status === '') {
32 const head = text.split('\n').slice(0, 30).join('\n')
33 status = head.match(/^\*\*Status:\*\*\s*(.+)$/m)?.[1] ?? head.match(/^Status:\s*(.+)$/m)?.[1] ?? ''
34 }
35
36 return {
37 path,
38 kind,
39 number,
40 title: field('title') || text.match(/^#\s+(.+)$/m)?.[1]?.trim() || name.replace(/\.md$/, ''),
41 status: (status.trim().replace(/[`*]/g, '').split(/\s/)[0] ?? '').toLowerCase(),
42 claimedBy: field('claimed-by'),
43 blockedBy: parseList(front, 'blocked-by'),
44 }
45}
46
47function parseList(front: string, key: string): string[] {
48 const inline = front.match(new RegExp(`^${key}:[ \\t]*\\[(.*)\\]`, 'm'))
49 if (inline) {
50 return (inline[1] ?? '').split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
51 }
52 const block = front.match(new RegExp(`^${key}:[ \\t]*\\r?\\n((?:[ \\t]*-.*\\r?\\n?)+)`, 'm'))
53 if (block) {
54 return (block[1] ?? '').split('\n').map(s => s.replace(/^\s*-\s*/, '').trim()).filter(Boolean)
55 }
56 const bare = front.match(new RegExp(`^${key}:[ \\t]*(\\S.*)$`, 'm'))?.[1]
57
58 return bare ? bare.split(',').map(s => s.trim()).filter(Boolean) : []
59}
60
61export function classify(slug: string, files: ScratchFile[], lastChangeMs: number, now: number): ScratchEffort {
62 const at = (path: string) => `.scratch/${slug}/${path}`
63 const byKind = (kind: ScratchKind) => files.filter(f => f.kind === kind)
64 const open = files.filter(f => !isClosed(f))
65 const isBlocked = (file: ScratchFile) =>
66 file.blockedBy.some(n => {
67 const blocker = files.find(f => f.kind === file.kind && f.number === n.padStart(file.number.length, '0'))
68 return blocker === undefined || !isClosed(blocker)
69 })
70 const frontier = open
71 .filter(f => (f.kind === 'ticket' || f.kind === 'decision') && f.claimedBy === '' && !isBlocked(f))
72 .sort((a, b) => a.number.localeCompare(b.number))
73 .map(f => f.path)
74
75 const base = { slug, files, lastChangeMs, frontier, isStale: false }
76 const mismatch = findMismatch(files)
77 if (mismatch !== '') {
78 return { ...base, state: 'mismatched', next: mismatch }
79 }
80 if (open.length === 0) {
81 return { ...base, state: 'done', next: '/scratch-cleanup' }
82 }
83
84 const map = byKind('map')[0]
85 const spec = byKind('spec')[0]
86 const tickets = byKind('ticket')
87 const decisions = byKind('decision')
88 const first = frontier[0]
89 let next = ''
90 if (first !== undefined) {
91 next = first.startsWith('decisions/') ? `/grill-me ${at(first)}` : `/implement ${at(first)}`
92 } else if (byKind('request').some(f => !isClosed(f))) {
93 next = '/triage'
94 } else if (map && !isClosed(map) && decisions.length === 0) {
95 next = `/wayfinder ${at(map.path)}`
96 } else if (map && !isClosed(map) && decisions.every(isClosed) && !spec) {
97 next = '/to-spec'
98 } else if (spec && tickets.length === 0) {
99 next = `/to-tickets ${at(spec.path)}`
100 } else {
101 const claimed = open.filter(f => f.claimedBy !== '').map(f => `${f.number || f.path} by ${f.claimedBy}`)
102 next = claimed.length > 0 ? `waiting on ${claimed.join(', ')}` : 'everything open is blocked'
103 }
104
105 return { ...base, state: 'open', isStale: now - lastChangeMs > STALE_MS, next }
106}
107
108function findMismatch(files: ScratchFile[]): string {
109 const unread = files.find(f => f.status === '')
110 if (unread) {
111 return `no readable status: ${unread.path}`
112 }
113 const spec = files.find(f => f.kind === 'spec')
114 const tickets = files.filter(f => f.kind === 'ticket')
115 if (spec && !isClosed(spec) && spec.status !== 'in-progress' && tickets.length > 0 && tickets.every(isClosed)) {
116 return `spec still ${spec.status} while every ticket is closed`
117 }
118 const map = files.find(f => f.kind === 'map')
119 if (map && !isClosed(map) && spec) {
120 return 'map still open though spec.md exists'
121 }
122
123 return ''
124}
125
126const RANK = { open: 0, mismatched: 3, done: 4 } as const
127
128function rank(effort: ScratchEffort): number {
129 if (effort.state !== 'open') {
130 return RANK[effort.state]
131 }
132 if (effort.isStale) {
133 return 2
134 }
135
136 return effort.frontier.length > 0 ? 0 : 1
137}
138
139export async function scan(fs: Reader, root: string, now: number, only = ''): Promise<ScratchBoard> {
140 const efforts: ScratchEffort[] = []
141 const dirs = (await fs.list(root)).filter(
142 d => d.kind === 'dir' && d.name !== 'research' && !d.name.startsWith('.') && (only === '' || d.name === only),
143 )
144
145 for (const dir of dirs) {
146 const base = `${root}/${dir.name}`
147 const found: { path: string; kind: ScratchKind; mtimeMs: number }[] = []
148 for (const entry of await fs.list(base)) {
149 if (entry.kind === 'file' && entry.name.endsWith('.md')) {
150 const kind: ScratchKind = entry.name === 'map.md' ? 'map' : entry.name === 'spec.md' ? 'spec' : 'request'
151 found.push({ path: entry.name, kind, mtimeMs: entry.mtimeMs })
152 } else if (entry.kind === 'dir' && TICKET_DIRS[entry.name] !== undefined) {
153 const kind = TICKET_DIRS[entry.name] as ScratchKind
154 for (const inner of await fs.list(`${base}/${entry.name}`)) {
155 if (inner.kind === 'file' && inner.name.endsWith('.md')) {
156 found.push({ path: `${entry.name}/${inner.name}`, kind, mtimeMs: inner.mtimeMs })
157 }
158 }
159 }
160 }
161
162 const files: ScratchFile[] = []
163 for (const f of found) {
164 const parsed = parseFile(f.path, f.kind, await fs.read(`${base}/${f.path}`))
165 // A loose markdown file with no status is a note, not a triage request.
166 if (f.kind !== 'request' || parsed.status !== '') {
167 files.push(parsed)
168 }
169 }
170 if (files.length > 0) {
171 const lastChangeMs = Math.max(...found.map(f => f.mtimeMs))
172 efforts.push(classify(dir.name, files, lastChangeMs, now))
173 }
174 }
175
176 efforts.sort((a, b) => rank(a) - rank(b) || b.lastChangeMs - a.lastChangeMs)
177
178 return { root, scannedAt: now, efforts }
179}
180
181// One line of counts: the /scratch reply when the pane is drawn.
182export function tally(board: ScratchBoard): string {
183 if (board.efforts.length === 0) {
184 return 'No efforts in .scratch/.'
185 }
186 const count = (fn: (e: ScratchEffort) => boolean) => board.efforts.filter(fn).length
187
188 return (
189 `${count(e => e.state === 'open')} open, ${count(e => e.isStale)} stale, ` +
190 `${count(e => e.state === 'mismatched')} mismatched, ${count(e => e.state === 'done')} done. ` +
191 'Commit checks: /scratch-status.'
192 )
193}
194
195// The whole board as a Markdown table: the /scratch reply where no pane is drawn.
196export function summary(board: ScratchBoard): string {
197 if (board.efforts.length === 0) {
198 return 'No efforts in .scratch/.'
199 }
200 const rows = board.efforts.map(e => {
201 const open = e.files.filter(f => !isClosed(f)).map(f => f.number || f.path).join(', ')
202 return `| \`${e.slug}\` | ${e.state}${e.isStale ? ' (stale)' : ''} | ${open} | ${e.next} |`
203 })
204
205 return [
206 '| Effort | State | Open | Next |',
207 '|---|---|---|---|',
208 ...rows,
209 '',
210 tally(board),
211 ].join('\n')
212}
213hooks/mods/workers-model.ts 38 lines1// Pure helpers for the workers band: what a subagent's tool call reads as, and the row order.
2
3import type { Worker } from '../../types'
4
5export const KEEP = 50
6
7export function describeTool(tool: string, input: Record<string, unknown>): string {
8 const pick = ['command', 'file_path', 'pattern', 'url', 'query', 'description']
9 .map(k => input[k])
10 .find(v => typeof v === 'string') as string | undefined
11 const short = (pick ?? '').replace(/\s+/g, ' ').trim()
12
13 return short === '' ? tool : `${tool}: ${short}`
14}
15
16export function duration(ms: number): string {
17 if (ms < 60_000) {
18 return `${Math.round(ms / 1000)}s`
19 }
20
21 return `${Math.floor(ms / 60_000)}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
22}
23
24export const isFailed = (w: Worker) => w.outcome === 'error' || w.outcome === 'refusal' || w.outcome === 'aborted'
25
26/** Running first, then failures, then finished, newest first, cut to the rows the band has. */
27export function ordered(list: readonly Worker[], rows: number): Worker[] {
28 const newest = (fn: (w: Worker) => boolean) => list.filter(fn).reverse()
29
30 return [...list.filter(w => w.outcome === 'running'), ...newest(isFailed), ...newest(w => w.outcome === 'answer')].slice(
31 0,
32 Math.max(1, rows),
33 )
34}
35
36export const mark = (w: Worker) =>
37 w.outcome === 'running' ? '◐' : w.outcome === 'answer' ? '✓' : w.outcome === 'aborted' ? '◌' : '✗'
38types/index.d.ts 55 lines1// The leek-skills mod's $.state contract (hooks/mods/).
2
3export type ScratchKind = 'map' | 'spec' | 'decision' | 'ticket' | 'request'
4
5export type ScratchFile = {
6 /** Path relative to the effort, e.g. `tickets/02-login.md`. */
7 path: string
8 kind: ScratchKind
9 /** Leading ticket number (`02`), or empty for map, spec, and requests. */
10 number: string
11 title: string
12 /** Lowercased status, or empty when the file has none. */
13 status: string
14 claimedBy: string
15 blockedBy: string[]
16}
17
18export type ScratchEffort = {
19 slug: string
20 files: ScratchFile[]
21 lastChangeMs: number
22 state: 'open' | 'done' | 'mismatched'
23 isStale: boolean
24 /** Open files whose blockers are all closed, unclaimed, ticket or decision. */
25 frontier: string[]
26 next: string
27}
28
29export type ScratchBoard = {
30 root: string
31 scannedAt: number
32 efforts: ScratchEffort[]
33}
34
35export type Worker = {
36 id: string
37 type: string
38 description: string
39 startedAt: number
40 endedAt: number
41 tools: number
42 lastTool: string
43 outcome: 'running' | 'answer' | 'aborted' | 'error' | 'refusal'
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 'leek-skills': {
49 scratch: ScratchBoard | null
50 workers: Worker[]
51 isWorkersHidden: boolean
52 }
53 }
54}
55