SLOPSHOPPER

leek-skills

Personal skills collection

newpanebandguardcommandprompt
★ 3v?MITupdated 2026-10-08leek/agent-skills
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · leek-skills
│ ┃ scratch ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] commit checks: /scratch-status │ ┃ Run /scratch to scan. ⏺ 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 │ │ › /scratch │ ⎿ leek-skills: No .scratch/ directory here. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · scratch
[ Refresh ] commit checks: /scratch-status Run /scratch to scan.
README

Agent Skills

Personal collection of agent skills for coding agents (Claude Code, Cursor, Grok, and others).

Skills follow the Agent Skills format.

skills.sh

Available Skills

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.

SkillInvocationWhat it does
architecture-satisfactionuserRun a bounded architectural refactor loop.
autopilotuserDrive an existing wayfinder map, spec, or set of tickets to completion through fresh top-level Claude Code, Codex, or Grok sessions.
browser-testuserTest 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-scoutuserSweep 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-reviewmodelReview 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-auditmodelAudit 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-designmodelShared vocabulary for designing deep modules in any codebase.
commituserCommit the work done in this session, skipping files a parallel session also touched.
dependency-audituserRun a Composer or npm dependency audit and propose a safe upgrade plan.
diagnosing-bugsmodelDiagnosis loop for hard bugs and performance regressions.
domain-modelingmodelBuild 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-failuresuserRead a branch's latest failed CI runs, triage each failure from its existing logs, and fix the real ones.
fix-github-issuesuserVerify, triage, fix, and close review-bot GitHub issues one finding at a time.
fix-nightwatch-issuesuserTriage open Laravel Nightwatch production issues by root cause, fix the legit ones, and resolve them with evidence.
fix-posthog-issuesuserTriage active PostHog error-tracking issues by root cause, fix the legit ones, and resolve or suppress the rest.
grill-meuserRun a grilling session: the user wants their plan, decision, or idea stress-tested one question at a time.
grill-with-docsuserRun a grilling session with domain-modeling alongside, capturing terms in GLOSSARY.md and decisions as ADRs while they land.
grillingmodelInterview the user one decision at a time until reaching shared understanding, resolving each branch of the decision tree with recommended options and trade-offs.
handoffuserCompact the current conversation into a handoff document for another agent to pick up.
housekeeperuserRun a conservative code-project housekeeping pass.
implementuserImplement one work item end to end: scope and claim it when trackable, TDD at agreed seams, commit, review, verify, and resolve it.
implement-specuserBuild 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-architectureuserScan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
laravel-herd-worktreesmodelUse when working with Laravel projects served by Laravel Herd alongside git worktrees on macOS.
paneluserConvene 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.
prmodelThe 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.
prototypemodelBuild a throwaway prototype to answer a design question.
releaseuserCommit this session's work, bump the version, tag, push, and publish a GitHub release with grouped notes.
repository-cleanupuserAudit and clean Git repository state, including the local databases old worktrees left behind.
researchmodelInvestigate a question against high-trust primary sources and capture the findings as a cited Markdown file.
retrouserLook 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-commentsmodelTriage, fix or rebut, then reply-and-resolve every review comment on a PR (including AI-review bot findings).
resolving-merge-conflictsmodel"Use when you need to resolve an in-progress git merge/rebase conflict."
scratch-cleanupuserSweep a dusty .scratch/: file research into docs/, remove finished efforts, and report the work still open.
scratch-statususerReport 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.
setupuserConfigure this repo for the engineering skills: the markdown issue tracker layout, triage label vocabulary, and domain doc layout.
sweepuserDig 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.
tddmodelThe red → green loop for any stack: seams, what a good test is, and the anti-patterns to refuse.
teachuserTeach the user a new skill or concept, within this workspace.
to-questionnaireuserTurn a decision the user can't fully answer into a Markdown questionnaire for someone else to fill in, async or over a meeting.
to-specmodelTurn 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-ticketsuserBreak 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.
triageuserMove issues and external PRs through a state machine of triage roles: categorise, verify, grill if needed, and write agent-ready briefs.
triage-github-pruserTriage a GitHub pull request end to end, from reviews and checks through fixes to a clean merge.
verifymodelExercise 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-whatuserStop; that last message did not land. Re-pitch it.
wayfinderuserPlan 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-skilluserNot sure which skill to reach for? Name the situation and get routed to one.
wizardmodelGenerate 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-agentsmodelReference for writing documents an agent consumes: skills, AGENTS.md/CLAUDE.md, and reference docs reached by a pointer.

Subagents (Claude Code)

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.

Mods (Claude Code)

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.
  • Workers band: above the prompt, every subagent a fan-out skill dispatched (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.

Installation

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.

Claude Code (plugin marketplace)

/plugin marketplace add leek/agent-skills
/plugin install leek-skills@leek-agent-skills

Codex (plugin marketplace)

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.

Claude Code (manual copy)

cp -r skills/<skill-name> ~/.claude/skills/

claude.ai

Upload the skill folder via skill settings, or paste the SKILL.md contents into a project's knowledge.

Claude API

See the Skills API Quickstart.

Skill Structure

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.

Creating a Skill

  1. Copy template/SKILL.md.example to skills/<skill-name>/SKILL.md.
  2. Fill in SKILL.md: follow the authoring principles in AGENTS.md and the writing-for-agents skill.
  3. Add the skill to .claude-plugin/marketplace.json under the leek-skills plugin.
  4. Update the Available Skills section above.
  5. Open a PR.

License

MIT

Source 4 files
hooks/mods/register.tsx 234 lines
1// 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}
234
hooks/mods/scratch-scan.ts 213 lines
1// 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}
213
hooks/mods/workers-model.ts 38 lines
1// 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' ? '◌' : '✗'
38
types/index.d.ts 55 lines
1// 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