A side pane that keeps a beginner on Matt Pocock's flow, from grilling to retro: /mod-pocock

Matt Nicosia's skill library, published as a Claude Code plugin marketplace.
This repo is the source of truth. Do not treat ~/.claude/skills as the canonical copy.
claude plugin marketplace add mattnicosia/bldg-skills
claude plugin install bldg-construction@bldg-skills
Install any plugin from the table below by name. A plugin is the install unit: one command brings in every skill inside it, and claude plugin update keeps it current. Nothing is copied into a second location, so nothing drifts.
| Plugin | Skills | Whose | What it is |
|---|---|---|---|
alex-hormozi | 8 | Alex Hormozi | Skills built on Alex Hormozi's frameworks: grand slam offers, lead magnets, lead machines and money models, with their interview companions. Mirror maintained by Matt Nicosia. |
bldg-agent-ops | 2 | Running agents: session worksheets and workflow scaffolding. | |
bldg-coding | 7 | Coding skills: review, debugging, architecture and the caveman pass. | |
bldg-construction | 17 | Construction estimating: drawing indexing, scope of work generation in Excel and PDF, bid leveling, proposals and job folder setup. | |
bldg-creative | 2 | Creative work: image prompting, voice, and the gauntlet loop. | |
bldg-distills | 12 | Distilled one-page methods pulled out of longer sources. | |
bldg-documents | 1 | Document generation and release. | |
bldg-house | 4 | House build skills: project and site level-ups, page teardowns, and the cloud sync auditor. | |
bldg-maker-school | 46 | The Maker School library: growth, offers, funnels, outreach and operating skills. | |
bldg-marketing | 25 | Marketing: funnels, positioning, SEO, ads, content and speed to lead. | |
bldg-social | 4 | Social: posting, scheduling and the scroll-stop technique. | |
bldg-strategy | 7 | Strategy: idea scoring, prediction, competitive work and planning. | |
bldg-website-design | 9 | Website design and build: frontend design, motion, cloning and upgrades. | |
corey-ganim | 3 | Corey Ganim | Corey Ganim's Build With AI set, v3.1.1, from Return My Time. Claude Cowork workspace onboarding, an AI readiness self-assessment on the Audit-Optimize-Automate framework, and a session context loader. The self-assessment is the paid, community-only version. |
matt-pocock | 37 | Matt Pocock | Matt Pocock's skill collection, mirrored at 1.3.1. TypeScript, testing, code review and the productivity set. Mirror maintained by Matt Nicosia; upstream docs in upstream/. |
pocock-guide | mod | A side pane for beginners on Matt Pocock's flow: it shows the step you are on, what it is for, and one Next step button. It nudges you to grill an idea before asking for code. Install it beside matt-pocock, the v1.3.1 mirror above. The official marketplace's mattpocock-skills is older and lacks /implement-spec, /pr and /retro. It also stops Claude from running destructive git (pushing to main, force pushing, reset --hard, clean -f, branch -D, checkout ., restore ., gh pr merge); turn that off with the gitGuard option on a reviewer's machine. Open it with /mod-pocock. |
The plugins with a name in the Whose column are other people's work, mirrored here and named after their author so it is obvious at a glance whose thinking you are running. Each keeps its own plugin so it can be refreshed as a unit, and whatever upstream docs came with it live in that plugin's upstream/ folder.
corey-ganim is the paid, community version of Build With AI 3.1.1 from Return My Time. Its upstream repo no longer resolves publicly, so this mirror may be the only reachable copy.
Plugins are a Claude Code mechanism. For Codex, Cursor or anything else that reads a SKILL.md folder, install.sh is the bridge:
./install.sh --list # every skill, as <plugin>/<skill>
./install.sh --library --link # symlink them all into the local agent skill dirs
Use --link, not the default copy. A copy drifts from the repo; a symlink cannot.
bldg-skills/
.claude-plugin/marketplace.json # the marketplace: every plugin, its path and category
plugins/<plugin>/
.claude-plugin/plugin.json # name, description, version
skills/<skill>/SKILL.md # the skills themselves
archive/ # non-skill files that came with the category
library/_ARCHIVE/ # retired skills, deliberately not published
install.sh
plugins/bldg-construction/skills/sow-generator/references/trade-checklists.md are unverified placeholders, converted from metric source material written for another market. Replace them with BLDG Estimating job history and mark them VERIFIED. Until then any output using them must be labeled ESTIMATED -- unverified ratio.library/_ARCHIVE holds retired skills and is not part of any plugin.hooks/register.tsx 1395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Choice, Decision, Env, Flow, Nudge, PullRequest, Repo, StepId, Summary, Tab } from '../types'
5
6const PANE = 'pocock-guide'
7const TITLE = 'Pocock flow'
8
9const tab = atom({ plugin: 'pocock-guide', key: 'tab' } as const, 'start' as Tab)
10const EMPTY_FLOW: Flow = { root: '', done: [], current: null, last: '', isBugFix: false }
11const flow = atom({ plugin: 'pocock-guide', key: 'flow' } as const, EMPTY_FLOW)
12const nudge = atom({ plugin: 'pocock-guide', key: 'nudge' } as const, null as Nudge)
13const env = atom({ plugin: 'pocock-guide', key: 'env' } as const, { isRead: false, hasSkills: true, isOutdated: false, hasPstackGuide: false } as Env)
14const summary = atom({ plugin: 'pocock-guide', key: 'summary' } as const, null as Summary)
15const decision = atom({ plugin: 'pocock-guide', key: 'decision' } as const, null as Decision)
16
17const NO_REPO: Repo = { isRead: false, error: null, name: '', branch: '', dirty: 0, stale: [], worktrees: 0, prs: [], isSetUp: true }
18const repo = atom({ plugin: 'pocock-guide', key: 'repo' } as const, NO_REPO)
19
20const W = 460
21const SANS = `-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',Helvetica,Arial,sans-serif`
22const MONO = `ui-monospace,'SF Mono',Menlo,Consolas,monospace`
23
24// Apple's light palette: one vivid fill per step that carries white bold text,
25// and a deeper ink of each for text on a tint.
26const C = {
27 grill: '#0071E3',
28 spec: '#5E5CE6',
29 tickets: '#AF52DE',
30 build: '#F56300',
31 review: '#1DA851',
32 pr: '#0A9BC1',
33 retro: '#E8335F',
34 gray: '#8E8E93',
35 red: '#E5342C',
36 slate: '#48484A',
37}
38
39const INK: Record<string, string> = {
40 [C.grill]: '#0058B0',
41 [C.spec]: '#3F3DB8',
42 [C.tickets]: '#8133A8',
43 [C.build]: '#B54700',
44 [C.review]: '#137A3A',
45 [C.pr]: '#06708C',
46 [C.retro]: '#B8163F',
47 [C.gray]: '#5B5B60',
48 [C.red]: '#B3261E',
49 [C.slate]: '#1D1D1F',
50}
51
52// ---------- The flow ----------
53
54type Step = { id: StepId; name: string; color: string; cmd: string; what: string; you: string; why: string }
55
56const STEPS: Step[] = [
57 {
58 id: 'grill',
59 name: 'Grill',
60 color: C.grill,
61 cmd: '/grill-with-docs',
62 what: 'Claude asks you questions about the idea, one round at a time, until it has no gaps.',
63 you: 'Answer each question. Say "I don\'t know" when you don\'t, and Claude will look it up.',
64 why: 'Code built on a fuzzy idea gets rebuilt. Ten minutes of questions first is the cheapest fix there is.',
65 },
66 {
67 id: 'spec',
68 name: 'Spec',
69 color: C.spec,
70 cmd: '/to-spec',
71 what: 'Claude turns the grilling into one written plan and files it on the issue tracker.',
72 you: 'Read it. Ask for changes now, while they cost nothing.',
73 why: 'Without a written plan, each new chat starts from a guess about what you meant.',
74 },
75 {
76 id: 'tickets',
77 name: 'Tickets',
78 color: C.tickets,
79 cmd: '/to-tickets',
80 what: 'Claude cuts the spec into small tickets. Each one works end to end and says which tickets go first.',
81 you: 'Check the order makes sense, and that each ticket is small enough for one chat.',
82 why: 'A big job in one chat runs out of room. Small tickets each get a fresh, sharp chat.',
83 },
84 {
85 id: 'build',
86 name: 'Build',
87 color: C.build,
88 cmd: '/implement',
89 what: 'Claude builds it test-first: a test that fails, then the code that makes it pass.',
90 you: 'Watch the tests go red, then green. Clear the chat before each new ticket.',
91 why: 'A test written first proves the code does the job, now and after every later change.',
92 },
93 {
94 id: 'review',
95 name: 'Review',
96 color: C.review,
97 cmd: '/code-review',
98 what: 'Two reviewers check the change: does it follow the repo\'s rules, and does it do what was asked.',
99 you: 'Read both lists. Ask Claude to fix the ones that matter.',
100 why: 'Mistakes are cheap to fix before a pull request and expensive after it ships.',
101 },
102 {
103 id: 'pr',
104 name: 'PR',
105 color: C.pr,
106 cmd: '/pr',
107 what: 'Claude opens a pull request with a picture of the change and proof that it works.',
108 you: 'Read it, then share the link with whoever reviews your work.',
109 why: 'A pull request is how someone else checks the work before it joins the main code.',
110 },
111 {
112 id: 'retro',
113 name: 'Retro',
114 color: C.retro,
115 cmd: '/retro',
116 what: 'Claude looks back at the session and suggests changes to the setup, so the next build goes better.',
117 you: 'Pick which suggestions to keep. Run it before you clear the chat.',
118 why: 'Each retro turns a mistake into a check or a rule, so it does not happen twice.',
119 },
120]
121
122const ORDER: StepId[] = STEPS.map(s => s.id)
123const stepOf = (id: StepId) => STEPS.find(s => s.id === id) ?? STEPS[0]!
124
125const SKILL_STEP: Record<string, StepId> = {
126 'grill-with-docs': 'grill',
127 'grill-me': 'grill',
128 grilling: 'grill',
129 'domain-modeling': 'grill',
130 prototype: 'grill',
131 wayfinder: 'grill',
132 'to-spec': 'spec',
133 'to-tickets': 'tickets',
134 triage: 'tickets',
135 implement: 'build',
136 'implement-spec': 'build',
137 tdd: 'build',
138 'diagnosing-bugs': 'build',
139 'code-review': 'review',
140 pr: 'pr',
141 retro: 'retro',
142}
143
144// /implement and /implement-spec run /code-review themselves, so they finish two steps.
145const REVIEWS_ITSELF = new Set(['implement', 'implement-spec'])
146
147const advance = (f: Flow, name: string, step: StepId): Flow => {
148 const isFresh = step === 'grill' && (f.current === 'pr' || f.current === 'retro')
149 const base = isFresh ? { ...f, done: [], isBugFix: false } : f
150 const reached: StepId[] = REVIEWS_ITSELF.has(name) ? [step, 'review'] : [step]
151 const done = ORDER.filter(s => base.done.includes(s) || reached.includes(s))
152 return { ...base, done, current: reached.at(-1) ?? step, last: name, isBugFix: base.isBugFix || name === 'diagnosing-bugs' }
153}
154
155// A step left out on the way to where you are. Spec and tickets are optional for a
156// small job, and a bug fix starts at Build, so only these two count.
157const skippedOf = (f: Flow): StepId[] => {
158 if (!f.current) return []
159 const at = ORDER.indexOf(f.current)
160 const out: StepId[] = []
161 if (!f.isBugFix && at >= ORDER.indexOf('build') && !f.done.includes('grill')) out.push('grill')
162 if (at >= ORDER.indexOf('pr') && f.done.includes('build') && !f.done.includes('review')) out.push('review')
163 return out
164}
165
166// Buttons that only talk send at once. Buttons that start work fill the prompt,
167// so Enter is the go.
168type Move = { key: string; label: string; text: string; isSend: boolean; why: string }
169
170const NEXT_TICKET: Move = {
171 key: 'move-next-ticket',
172 label: 'Build the next ticket',
173 text: '/implement ',
174 isSend: false,
175 why: 'Clear the chat first, then add the ticket number after /implement.',
176}
177const CLEAR: Move = { key: 'move-clear', label: 'Clear the chat', text: '/clear', isSend: false, why: 'Empties the chat so the next ticket starts fresh.' }
178
179// Next step only offers what the flow allows from where you are.
180const nextFor = (f: Flow): { main: Move; others: Move[] } => {
181 const hasTickets = f.done.includes('tickets')
182 switch (f.current) {
183 case null:
184 return {
185 main: { key: 'move-grill', label: 'Grill your idea', text: '/grill-with-docs ', isSend: false, why: 'Type your idea after the command in your own words. Rough is fine.' },
186 others: [{ key: 'move-bug', label: 'Something is broken instead', text: '/diagnosing-bugs ', isSend: false, why: '' }],
187 }
188 case 'grill':
189 return {
190 main: { key: 'move-spec', label: 'Write the spec', text: '/to-spec', isSend: false, why: 'Do this in the same chat, so the spec builds on the whole grilling.' },
191 others: [
192 { key: 'move-small', label: 'Small job? Build it here', text: '/implement what we just agreed', isSend: false, why: '' },
193 { key: 'move-prototype', label: 'Need to see it run first?', text: '/handoff I want to answer this with a throwaway prototype: <the question>', isSend: false, why: '' },
194 ],
195 }
196 case 'spec':
197 return {
198 main: { key: 'move-tickets', label: 'Cut it into tickets', text: '/to-tickets', isSend: false, why: 'Stay in this chat. Clear it only after the tickets exist.' },
199 others: [],
200 }
201 case 'tickets':
202 return {
203 main: { key: 'move-build', label: 'Build the first ticket', text: '/implement ', isSend: false, why: 'Clear the chat first. Then add the first ticket after /implement.' },
204 others: [CLEAR, { key: 'move-all', label: 'Build every ticket in one run', text: '/implement-spec', isSend: false, why: '' }],
205 }
206 case 'build':
207 return {
208 main: { key: 'move-review', label: 'Review the change', text: '/code-review since main', isSend: false, why: 'Two reviewers read the diff before anyone else sees it.' },
209 others: [],
210 }
211 case 'review':
212 return {
213 main: { key: 'move-pr', label: 'Open the pull request', text: 'open a pull request for this branch. use /pr to write the body.', isSend: false, why: 'It shows the change and the proof that it works.' },
214 others: hasTickets ? [CLEAR, NEXT_TICKET] : [],
215 }
216 case 'pr':
217 return {
218 main: { key: 'move-retro', label: 'Look back with a retro', text: '/retro', isSend: false, why: 'Run it now, before you clear the chat, so it can see the whole session.' },
219 others: hasTickets ? [CLEAR, NEXT_TICKET] : [],
220 }
221 case 'retro':
222 return {
223 main: { key: 'move-again', label: 'Start the next idea', text: '/grill-with-docs ', isSend: false, why: 'The path starts over at Grill.' },
224 others: hasTickets ? [CLEAR, NEXT_TICKET] : [],
225 }
226 }
227}
228
229const hintFor = (f: Flow) => {
230 if (f.current === 'grill' || f.current === 'spec') {
231 return 'Keep grilling, spec and tickets in this one chat. Do not clear or compact until the tickets exist.'
232 }
233 if (f.current === 'tickets' || (f.done.includes('tickets') && f.current !== null)) {
234 return 'Clear the chat before each ticket. The ticket holds everything Claude needs.'
235 }
236 return ''
237}
238
239// ---------- Drawing helpers ----------
240
241type Item = { label: string; tip: string }
242
243const esc = (s: string) =>
244 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
245
246const wrap = (s: string, max: number) => {
247 const out: string[] = []
248 let line = ''
249 for (const word of s.split(' ')) {
250 if (line && line.length + word.length + 1 > max) {
251 out.push(line)
252 line = word
253 } else {
254 line = line ? `${line} ${word}` : word
255 }
256 }
257 if (line) out.push(line)
258 return out
259}
260
261const cut = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s)
262
263const text = (x: number, y: number, cls: string, s: string, extra = '') =>
264 `<text x="${x}" y="${y}" class="${cls}"${extra}>${esc(s)}</text>`
265
266const lines = (x: number, y: number, cls: string, rows: string[], step: number, extra = '') =>
267 rows.map((row, i) => text(x, y + i * step, cls, row, extra)).join('')
268
269const chipW = (label: string) => Math.round(label.length * 12.5 * 0.6 + 20)
270
271const chip = (x: number, y: number, it: Item, color: string) => {
272 const w = chipW(it.label)
273 return `<g class="chip"><title>${esc(it.tip)}</title><rect x="${x}" y="${y}" width="${w}" height="24" rx="12" fill="${color}" fill-opacity=".13"/><text x="${x + w / 2}" y="${y + 16.5}" text-anchor="middle" fill="${INK[color] ?? color}" font-size="12.5" font-weight="600" font-family="${MONO}">${esc(it.label)}</text></g>`
274}
275
276const flowChips = (x0: number, y0: number, maxX: number, items: Item[], color: string) => {
277 let x = x0
278 let y = y0
279 let svg = ''
280 for (const it of items) {
281 const w = chipW(it.label)
282 if (x > x0 && x + w > maxX) {
283 x = x0
284 y += 30
285 }
286 svg += chip(x, y, it, color)
287 x += w + 6
288 }
289 return { svg, bottom: y + 24 }
290}
291
292const down = (cx: number, y: number) => `<path class="ar" d="M${cx - 6} ${y} l6 7 l6 -7z"/>`
293
294const check = (cx: number, cy: number) =>
295 `<path d="M${cx - 4.5} ${cy} l3 3.2 l6 -6.5" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
296
297const doc = (h: number, body: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 ${W} ${h}" preserveAspectRatio="xMidYMin meet">
298<style>
299svg{background:#F5F5F7}
300.bg{fill:#F5F5F7}.card{fill:#FFFFFF;filter:url(#lift)}.ink{fill:#1D1D1F}.mut{fill:#6E6E73}.ln{stroke:#D2D2D7}.ar{fill:#AEAEB2}
301text{font-family:${SANS};letter-spacing:-.01em}
302.h{font-size:20px;font-weight:700;letter-spacing:-.02em}.sub{font-size:12.5px}.lab{font-size:13px;font-weight:700}.body{font-size:12.5px}.sm{font-size:11.5px}.mono{font-family:${MONO}}
303.chip:hover rect{fill-opacity:.24}
304</style>
305<defs><filter id="lift" x="-10%" y="-10%" width="120%" height="140%"><feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity=".07"/><feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity=".05"/></filter></defs>
306<rect class="bg" width="${W}" height="${h}"/>
307${body}</svg>`
308
309// A white card with a colored title and wrapped body lines; returns its height.
310const noteCard = (y: number, title: string, body: string, color: string) => {
311 const rows = wrap(body, 60)
312 const h = 34 + rows.length * 16 + 8
313 const svg =
314 `<rect x="16" y="${y}" width="${W - 32}" height="${h}" rx="10" fill="${color}" fill-opacity=".1" stroke="${color}" stroke-opacity=".45"/>` +
315 `<text x="30" y="${y + 22}" font-size="13" font-weight="700" fill="${INK[color] ?? color}">${esc(title)}</text>` +
316 lines(30, y + 42, 'body ink', rows, 16)
317 return { svg, h }
318}
319
320// ---------- Now: where you are on the path ----------
321
322const nowSvg = (f: Flow) => {
323 let b = ''
324 const at = f.current ? ORDER.indexOf(f.current) : -1
325 const skipped = skippedOf(f)
326 b += text(20, 34, 'h ink', 'You are here')
327 b += text(W - 20, 34, 'sm mut', at >= 0 ? `step ${at + 1} of 7` : 'not started', ' text-anchor="end"')
328 const x0 = 34
329 const gap = (W - 2 * x0) / (STEPS.length - 1)
330 const cy = 76
331 b += `<line class="ln" stroke-width="2" x1="${x0}" x2="${W - x0}" y1="${cy}" y2="${cy}"/>`
332 STEPS.forEach((s, i) => {
333 const x = x0 + i * gap
334 const isNow = i === at
335 const isDone = f.done.includes(s.id) && !isNow
336 const isSkipped = skipped.includes(s.id)
337 if (isNow) {
338 b += `<circle cx="${x}" cy="${cy}" r="19" fill="${s.color}" fill-opacity=".2"/>`
339 b += `<circle cx="${x}" cy="${cy}" r="14" fill="${s.color}"/>`
340 b += `<text x="${x}" y="${cy + 4.5}" text-anchor="middle" fill="#fff" font-size="12.5" font-weight="700">${i + 1}</text>`
341 } else if (isSkipped) {
342 b += `<circle cx="${x}" cy="${cy}" r="11" fill="#fff" stroke="${C.build}" stroke-width="2" stroke-dasharray="3 2.5"/>`
343 b += `<text x="${x}" y="${cy + 4.5}" text-anchor="middle" fill="${INK[C.build]}" font-size="13" font-weight="800">!</text>`
344 } else if (isDone) {
345 b += `<circle cx="${x}" cy="${cy}" r="11" fill="${s.color}"/>${check(x, cy)}`
346 } else {
347 b += `<circle cx="${x}" cy="${cy}" r="10" fill="#E5E5EA"/>`
348 b += `<text x="${x}" y="${cy + 4}" text-anchor="middle" font-size="11" font-weight="600" class="mut">${i + 1}</text>`
349 }
350 const tone = isNow ? ` fill="${INK[s.color]}" font-weight="700"` : isSkipped ? ` fill="${INK[C.build]}" font-weight="600"` : ' class="mut"'
351 b += `<text x="${x}" y="${cy + 36}" text-anchor="middle" font-size="11.5"${tone}>${s.name}</text>`
352 })
353 let y = 132
354 if (f.current) {
355 const s = stepOf(f.current)
356 const what = wrap(s.what, 56)
357 const you = wrap(s.you, 56)
358 const h = 106 + (what.length + you.length) * 16
359 b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="12"/>`
360 b += `<rect x="16" y="${y + 14}" width="4" height="22" rx="2" fill="${s.color}"/>`
361 b += text(30, y + 31, 'ink', s.name, ' font-size="17" font-weight="700"')
362 b += chip(W - 28 - chipW(s.cmd), y + 13, { label: s.cmd, tip: `The skill for the ${s.name} step` }, s.color)
363 let ty = y + 58
364 b += `<text x="30" y="${ty}" font-size="11" font-weight="700" fill="${INK[s.color]}" letter-spacing=".04em">WHAT IT IS</text>`
365 b += lines(30, ty + 17, 'body ink', what, 16)
366 ty += 17 + what.length * 16 + 10
367 b += `<text x="30" y="${ty}" font-size="11" font-weight="700" fill="${INK[s.color]}" letter-spacing=".04em">WHAT YOU DO</text>`
368 b += lines(30, ty + 17, 'body ink', you, 16)
369 y += h + 12
370 } else {
371 const card = noteCard(y, 'Every job starts with an idea', 'Press Next step and type your idea in plain words. Claude will ask you questions until it is clear, then you build it step by step.', C.grill)
372 b += card.svg
373 y += card.h + 12
374 }
375 for (const id of skipped) {
376 const s = stepOf(id)
377 const card = noteCard(y, `You skipped ${s.name}`, s.why, C.build)
378 b += card.svg
379 y += card.h + 12
380 }
381 const hint = hintFor(f)
382 if (hint) {
383 const card = noteCard(y, 'Keep the chat clean', hint, C.grill)
384 b += card.svg
385 y += card.h + 12
386 }
387 const next = nextFor(f)
388 const why = next.main.why ? wrap(next.main.why, 50) : []
389 const h = 82 + why.length * 16
390 b += `<rect x="16" y="${y}" width="${W - 32}" height="${h}" rx="12" fill="${C.review}" fill-opacity=".13" stroke="${C.review}" stroke-opacity=".5"/>`
391 b += `<circle cx="48" cy="${y + 38}" r="20" fill="${C.review}"/><path d="M38 ${y + 38} h16 m-6 -7 l7 7 l-7 7" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
392 b += `<text x="80" y="${y + 26}" font-size="11.5" font-weight="700" fill="${INK[C.review]}" letter-spacing=".04em">NEXT STEP</text>`
393 b += text(80, y + 47, 'ink', cut(next.main.label, 36), ' font-size="17" font-weight="700"')
394 b += `<text x="80" y="${y + 67}" font-size="12.5" font-family="${MONO}" class="ink">${esc(cut(next.main.text.trim(), 44))}</text>`
395 b += lines(80, y + 86, 'sm mut', why, 16)
396 y += h + 8
397 return { source: doc(y + 8, b), next }
398}
399
400const nowMd = (f: Flow) => {
401 const skipped = skippedOf(f)
402 const strip = STEPS.map(s =>
403 s.id === f.current ? `**[${s.name}]**` : skipped.includes(s.id) ? `!${s.name}` : f.done.includes(s.id) ? `✓${s.name}` : s.name,
404 ).join(' → ')
405 const s = f.current ? stepOf(f.current) : null
406 const next = nextFor(f)
407 return [
408 '### You are here',
409 strip,
410 s ? `**${s.name}** (\`${s.cmd}\`)\n\nWhat it is: ${s.what}\n\nWhat you do: ${s.you}` : 'Nothing started yet. Every job starts with an idea.',
411 ...skipped.map(id => `**You skipped ${stepOf(id).name}.** ${stepOf(id).why}`),
412 hintFor(f) ? `_${hintFor(f)}_` : '',
413 `**Next step:** ${next.main.label}: \`${next.main.text.trim()}\`${next.main.why ? `\n\n${next.main.why}` : ''}`,
414 ]
415 .filter(Boolean)
416 .join('\n\n')
417}
418
419// ---------- Guide: the main flow ----------
420
421const flowSvg = () => {
422 let b = ''
423 b += text(20, 34, 'h ink', 'From idea to shipped')
424 b += text(20, 54, 'sub mut', 'Seven steps, in order. Each one has a skill you run.')
425 let y = 72
426 const card = (s: Step, i: number) => {
427 const what = wrap(s.what, 56)
428 const h = 40 + what.length * 16 + 6
429 let svg = `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
430 svg += `<circle cx="40" cy="${y + 22}" r="13" fill="${s.color}"/>`
431 svg += `<text x="40" y="${y + 26.5}" text-anchor="middle" fill="#fff" font-size="12.5" font-weight="700">${i + 1}</text>`
432 svg += text(62, y + 27, 'lab ink', s.name, ' font-size="14.5"')
433 svg += chip(W - 28 - chipW(s.cmd), y + 10, { label: s.cmd, tip: s.what }, s.color)
434 svg += lines(62, y + 48, 'body mut', what, 16)
435 return { svg, h }
436 }
437 const side = (title: string, body: string, color: string) => {
438 const rows = wrap(body, 60)
439 const h = 26 + rows.length * 15 + 8
440 const svg =
441 `<rect x="44" y="${y}" width="${W - 60}" height="${h}" rx="9" fill="${color}" fill-opacity=".08" stroke="${color}" stroke-opacity=".5" stroke-dasharray="4 3"/>` +
442 `<text x="58" y="${y + 18}" font-size="12" font-weight="700" fill="${INK[color] ?? color}">${esc(title)}</text>` +
443 lines(58, y + 35, 'sm ink', rows, 15)
444 return { svg, h }
445 }
446 const band = (label: string, color: string) => {
447 b += `<rect x="16" y="${y}" width="${W - 32}" height="26" rx="13" fill="${color}" fill-opacity=".14"/>`
448 b += `<text x="${W / 2}" y="${y + 17.5}" text-anchor="middle" font-size="12" font-weight="700" fill="${INK[color] ?? color}">${esc(label)}</text>`
449 y += 26 + 10
450 }
451 band('Steps 1 to 3: one chat, no clearing', C.grill)
452 STEPS.forEach((s, i) => {
453 if (i === 3) band('Clear the chat before each ticket', C.tickets)
454 const c = card(s, i)
455 b += c.svg
456 y += c.h + 6
457 if (s.id === 'grill') {
458 b += down(W / 2, y)
459 y += 12
460 const d = side('Stuck on a question only running code can answer?', '/handoff out, /prototype a throwaway answer in a new chat, then /handoff back what you learned.', C.grill)
461 b += d.svg
462 y += d.h + 6
463 const small = side('Small job that fits in one chat?', 'Skip spec and tickets. Run /implement right after grilling.', C.build)
464 b += small.svg
465 y += small.h + 6
466 }
467 if (i < STEPS.length - 1) {
468 b += down(W / 2, y)
469 y += 14
470 }
471 })
472 y += 14
473 b += text(20, y, 'sm mut', 'Hover a command to see what it does.')
474 return doc(y + 16, b)
475}
476
477// ---------- Guide: other ways in ----------
478
479const RAMPS: { when: string; cmd: string; what: string; joins: StepId }[] = [
480 { when: 'Something is broken', cmd: '/diagnosing-bugs', what: 'Finds a command that shows the bug failing, then fixes it with a test.', joins: 'build' },
481 { when: 'Bugs and requests piling up', cmd: '/triage', what: 'Sorts issues other people filed into clear tickets an agent can take.', joins: 'tickets' },
482 { when: 'A huge project, too big to see', cmd: '/wayfinder', what: 'Maps the big decisions as tickets and settles them one by one.', joins: 'spec' },
483 { when: 'The code is getting messy', cmd: '/improve-codebase-architecture', what: 'Finds parts of the code worth reshaping. Picking one gives you an idea.', joins: 'grill' },
484]
485
486const rampsSvg = () => {
487 let b = ''
488 b += text(20, 34, 'h ink', 'Other ways in')
489 b += text(20, 54, 'sub mut', 'Not every job starts with an idea. These join the path partway.')
490 let y = 72
491 for (const r of RAMPS) {
492 const s = stepOf(r.joins)
493 const what = wrap(r.what, 58)
494 const h = 74 + what.length * 16
495 b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
496 b += text(30, y + 24, 'lab ink', r.when, ' font-size="14.5"')
497 b += chip(30, y + 34, { label: r.cmd, tip: r.what }, C.slate)
498 b += lines(30, y + 76, 'body mut', what, 16)
499 const label = `joins at ${s.name}`
500 const lw = label.length * 6.6 + 22
501 b += `<rect x="${W - 28 - lw}" y="${y + 10}" width="${lw}" height="22" rx="11" fill="${s.color}"/>`
502 b += `<text x="${W - 28 - lw / 2}" y="${y + 25}" text-anchor="middle" fill="#fff" font-size="11.5" font-weight="700">${esc(label)}</text>`
503 y += h + 10
504 }
505 y += 8
506 const pre = noteCard(y, 'Before your first build in a repo', 'Run /setup-matt-pocock-skills once. It tells the other skills where tickets live and where to write the glossary.', C.red)
507 b += pre.svg
508 y += pre.h
509 return doc(y + 16, b)
510}
511
512// ---------- Guide: skills for any time ----------
513
514const ANYTIME: { name: string; color: string; items: Item[] }[] = [
515 {
516 name: 'Think it through',
517 color: C.grill,
518 items: [
519 { label: '/grill-me', tip: 'The same interview as /grill-with-docs, for a plan with no repo. It saves nothing.' },
520 { label: '/research', tip: 'A background agent reads the sources and leaves a cited notes file in the repo.' },
521 { label: '/to-questionnaire', tip: 'Writes questions for someone else to answer, when the gap is in their head.' },
522 ],
523 },
524 {
525 name: 'Learn and ask',
526 color: C.spec,
527 items: [
528 { label: '/wait-what', tip: 'That last message did not land. Claude says it again, in plain words.' },
529 { label: '/teach', tip: 'Learn a concept over several sessions, in this folder.' },
530 ],
531 },
532 {
533 name: 'Words and shape',
534 color: C.tickets,
535 items: [
536 { label: '/domain-modeling', tip: 'Sort out a fuzzy word, or record a hard-to-undo decision.' },
537 { label: '/codebase-design', tip: 'Shape a part of the code: a lot of work behind a small, simple surface.' },
538 ],
539 },
540 {
541 name: 'Steps only you can do',
542 color: C.build,
543 items: [{ label: '/wizard', tip: 'A script that walks you through keys, dashboards and secrets, and saves each value.' }],
544 },
545]
546
547const BETWEEN: [string, string][] = [
548 ['Continue', 'Stay in this chat. The safe first choice.'],
549 ['/clear', 'Empty the chat when nothing in it matters next.'],
550 ['/handoff', 'Write a file for a new folder, tool or person.'],
551 ['Subagent', 'Send one small job to its own chat.'],
552 ['/compact', 'Squeeze the chat when it gets long.'],
553]
554
555const moreSvg = () => {
556 let b = ''
557 b += text(20, 34, 'h ink', 'Skills for any time')
558 b += text(20, 54, 'sub mut', 'Off the path. Use them whenever they help.')
559 let y = 84
560 for (const g of ANYTIME) {
561 b += `<rect x="20" y="${y - 11}" width="4" height="14" rx="2" fill="${g.color}"/>`
562 b += text(30, y, 'lab ink', g.name)
563 const f = flowChips(20, y + 10, W - 16, g.items, g.color)
564 b += f.svg
565 y = f.bottom + 30
566 }
567 b += text(20, y, 'lab ink', 'Between two steps, pick one')
568 b += text(20, y + 17, 'sm mut', 'Try them top to bottom. Stop at the first that fits.')
569 y += 30
570 b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${BETWEEN.length * 34 + 10}" rx="10"/>`
571 BETWEEN.forEach(([k, v], i) => {
572 const ry = y + 10 + i * 34
573 b += `<rect x="28" y="${ry + 4}" width="86" height="22" rx="11" fill="${C.slate}" fill-opacity=".1"/>`
574 b += `<text x="71" y="${ry + 19.5}" text-anchor="middle" font-size="12" font-weight="700" font-family="${MONO}" class="ink">${esc(k)}</text>`
575 b += text(126, ry + 19.5, 'body ink', v)
576 })
577 y += BETWEEN.length * 34 + 10
578 return doc(y + 18, b)
579}
580
581// ---------- Guide: words you will hear ----------
582
583const WORDS: [string, string][] = [
584 ['Grilling', 'Claude asks you questions, one round at a time, until the idea has no gaps. Facts are Claude\'s job. Decisions are yours.'],
585 ['Spec', 'One written plan for the whole job, made from the grilling.'],
586 ['Ticket', 'One small piece of the spec, small enough to build in one chat.'],
587 ['Tracer bullet', 'A ticket that cuts a thin slice through every layer, from screen to data, so something works end to end early.'],
588 ['Blocking edge', 'A note on a ticket that says which tickets must finish first.'],
589 ['Red-green', 'Write a test that fails (red), then the code that makes it pass (green). One small slice at a time.'],
590 ['Deep module', 'A part of the code that does a lot of work behind a small, simple surface.'],
591 ['Context window', 'Everything Claude can see in this chat. /clear empties it.'],
592 ['Smart zone', 'The first part of a chat, about 150k tokens, where Claude still thinks sharply. Past it, answers get worse.'],
593 ['GLOSSARY.md', 'The file where grilling writes down the words you agreed on.'],
594 ['ADR', 'A short note that records a decision that is hard to undo, and why it was made.'],
595 ['Pull request', 'A request to add your branch to the main code, with proof it works, for someone to review.'],
596]
597
598const wordsSvg = () => {
599 let b = ''
600 b += text(20, 34, 'h ink', 'Words you will hear')
601 b += text(20, 54, 'sub mut', 'Lost in a reply? Type /wait-what and Claude says it again.')
602 let y = 70
603 WORDS.forEach(([word, meaning], i) => {
604 const rows = wrap(meaning, 60)
605 const h = 36 + rows.length * 16
606 const color = STEPS[i % STEPS.length]!.color
607 b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
608 b += `<rect x="28" y="${y + 13}" width="4" height="15" rx="2" fill="${color}"/>`
609 b += text(40, y + 25, 'lab', word, ` fill="${INK[color]}" font-size="13.5"`)
610 b += lines(40, y + 44, 'body ink', rows, 16)
611 y += h + 8
612 })
613 return doc(y + 10, b)
614}
615
616const GUIDE: { id: Tab; label: string; svg: string; alt: string; md: string }[] = [
617 {
618 id: 'flow',
619 label: 'Main path',
620 svg: flowSvg(),
621 alt: 'The seven steps from idea to shipped, with the skill for each',
622 md: `### From idea to shipped\n\n${STEPS.map((s, i) => `${i + 1}. **${s.name}** \`${s.cmd}\`: ${s.what}`).join('\n')}\n\nKeep steps 1 to 3 in one chat. Clear the chat before each ticket.`,
623 },
624 {
625 id: 'ramps',
626 label: 'Other ways in',
627 svg: rampsSvg(),
628 alt: 'Starting points that join the main path partway',
629 md: `### Other ways in\n\n${RAMPS.map(r => `- **${r.when}**: \`${r.cmd}\`. ${r.what} Joins at ${stepOf(r.joins).name}.`).join('\n')}\n\nBefore your first build in a repo, run \`/setup-matt-pocock-skills\`.`,
630 },
631 {
632 id: 'more',
633 label: 'Any time',
634 svg: moreSvg(),
635 alt: 'Skills you can use at any point, and what to do between steps',
636 md: `### Skills for any time\n\n${ANYTIME.map(g => `**${g.name}:** ${g.items.map(i => `\`${i.label}\``).join(', ')}`).join('\n\n')}\n\n**Between two steps:** ${BETWEEN.map(([k, v]) => `${k} (${v})`).join(' ')}`,
637 },
638 {
639 id: 'words',
640 label: 'Words',
641 svg: wordsSvg(),
642 alt: 'Plain meanings for the words the skills use',
643 md: `### Words you will hear\n\n${WORDS.map(([w, m]) => `- **${w}**: ${m}`).join('\n')}`,
644 },
645]
646
647// The desktop draws an Svg in a frame it cannot size from the markup, so pass
648// the pixel size: the pane's width, guessed at 8px a cell.
649const fit = (source: string, columns: number) => {
650 const width = Math.round(Math.min(Math.max(columns * 8, 300), 1100))
651 const h = Number(/viewBox="0 0 \d+ ([\d.]+)"/.exec(source)?.[1] ?? 600)
652 return { width, height: Math.round((h * width) / W) }
653}
654
655// ---------- Is the setup there? ----------
656
657const SKILL_PLUGINS = ['mattpocock-skills@', 'matt-pocock@']
658// The official marketplace pins a commit from before v1.3.1, so the mirror is the one to install.
659const OUTDATED = 'mattpocock-skills@claude-plugins-official'
660const INSTALL = 'claude plugin marketplace add mattnicosia/bldg-skills && claude plugin install matt-pocock@bldg-skills'
661
662async function readEnv($: EngineInterface) {
663 const home = (await $.env.get('HOME')) ?? ''
664 let keys: string[] = []
665 try {
666 const raw = JSON.parse(String(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`))) as { plugins?: Record<string, unknown> }
667 keys = Object.keys(raw.plugins ?? {})
668 } catch {
669 keys = []
670 }
671 // skills.sh copies the skill files into the user's or the project's skills folder instead of a plugin.
672 const isCopied =
673 (await $.fs.exists(`${home}/.claude/skills/grill-with-docs/SKILL.md`).catch(() => false)) ||
674 (await $.fs.exists('.claude/skills/grill-with-docs/SKILL.md').catch(() => false))
675 const found = keys.filter(k => SKILL_PLUGINS.some(p => k.startsWith(p)))
676 const value: Env = {
677 isRead: true,
678 hasSkills: isCopied || found.length > 0,
679 isOutdated: !isCopied && found.length > 0 && found.every(k => k === OUTDATED),
680 hasPstackGuide: keys.some(k => k.startsWith('pstack-guide@')),
681 }
682 await update($, env, () => value)
683 return value
684}
685
686// ---------- Start tab: the repo at a glance ----------
687
688const STALE_DAYS = 14
689
690const git = async ($: EngineInterface, args: string[]) => {
691 try {
692 const r = await $.process.run(['git', ...args], { timeoutMs: 10000 })
693 return r.exitCode === 0 ? r.stdout.trim() : null
694 } catch {
695 return null
696 }
697}
698
699// In a worktree, --show-toplevel names the worktree folder; the shared git dir
700// sits inside the main checkout, which carries the repo's name.
701const mainCheckout = async ($: EngineInterface) => {
702 const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
703 return common === null ? null : common.replace(/\/\.git\/?$/, '')
704}
705
706const ciOf = (checks: { status?: string; conclusion?: string; state?: string }[]): PullRequest['ci'] => {
707 if (checks.length === 0) return 'none'
708 const bad = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED'])
709 if (checks.some(c => bad.has(c.conclusion ?? '') || bad.has(c.state ?? ''))) return 'fail'
710 if (checks.some(c => (c.status && c.status !== 'COMPLETED') || c.state === 'PENDING')) return 'pending'
711 return 'pass'
712}
713
714// Reads the repo once per press, never while drawing, so the pane stays still.
715async function readRepo($: EngineInterface) {
716 const top = await git($, ['rev-parse', '--show-toplevel'])
717 if (top === null) {
718 await update($, repo, () => ({ ...NO_REPO, isRead: true, error: 'This session is not in a git repo.' }))
719 return
720 }
721 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '?'
722 const status = (await git($, ['status', '--porcelain'])) ?? ''
723 const base = ((await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) ?? 'origin/main').replace(/^origin\//, '')
724 const merged = new Set(((await git($, ['branch', '--merged', base, '--format=%(refname:short)'])) ?? '').split('\n').filter(Boolean))
725 const heads = ((await git($, ['for-each-ref', '--format=%(refname:short)|%(committerdate:unix)', 'refs/heads'])) ?? '').split('\n').filter(Boolean)
726 // A worktree entry is a `worktree <path>` line, then `branch refs/heads/<name>`.
727 const trees = ((await git($, ['worktree', 'list', '--porcelain'])) ?? '').split('\n\n').map(entry => ({
728 path: /^worktree (.+)$/m.exec(entry)?.[1] ?? '',
729 branch: /^branch refs\/heads\/(.+)$/m.exec(entry)?.[1] ?? '',
730 }))
731 // A branch checked out in any worktree is someone's live work, never stale.
732 const inUse = new Set(trees.map(t => t.branch).filter(Boolean))
733 const others = trees.filter((t, i) => i > 0 && t.path && t.path !== top)
734 const cutoff = Date.now() / 1000 - STALE_DAYS * 86400
735 const stale = heads
736 .map(line => {
737 const [name = '', when = '0'] = line.split('|')
738 return { name, when: Number(when) }
739 })
740 .filter(h => h.name !== base && !inUse.has(h.name) && (merged.has(h.name) || h.when < cutoff))
741 .map(h => h.name)
742 let prs: PullRequest[] = []
743 let error: string | null = null
744 try {
745 const r = await $.process.run(['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--limit', '8', '--json', 'number,title,isDraft,statusCheckRollup'], { timeoutMs: 15000 })
746 if (r.exitCode === 0) {
747 prs = (JSON.parse(r.stdout) as { number: number; title: string; isDraft: boolean; statusCheckRollup: { status?: string; conclusion?: string; state?: string }[] }[]).map(p => ({
748 number: p.number,
749 title: p.title,
750 isDraft: p.isDraft,
751 ci: ciOf(p.statusCheckRollup ?? []),
752 }))
753 } else error = 'Could not read pull requests with gh.'
754 } catch {
755 error = 'Could not read pull requests with gh.'
756 }
757 // /setup-matt-pocock-skills always writes this file.
758 const isSetUp = await $.fs.exists(`${top}/docs/agents/issue-tracker.md`).catch(() => true)
759 const name = ((await mainCheckout($)) ?? top).split('/').pop() ?? top
760 await update($, repo, () => ({ isRead: true, error, name, branch, dirty: status ? status.split('\n').length : 0, stale, worktrees: others.length, prs, isSetUp }))
761}
762
763const startSvg = (r: Repo) => {
764 const failing = r.prs.filter(p => p.ci === 'fail').length
765 const running = r.prs.filter(p => p.ci === 'pending').length
766 const tiles: { n: number; label: string; note: string; color: string }[] = [
767 {
768 n: r.prs.length,
769 label: r.prs.length === 1 ? 'Open PR' : 'Open PRs',
770 note: r.prs.length === 0 ? 'none open' : failing ? `${failing} failing` : running ? `${running} running` : 'all green',
771 color: r.prs.length === 0 ? C.gray : failing ? C.red : running ? C.build : C.review,
772 },
773 { n: r.dirty, label: 'Uncommitted', note: r.dirty ? 'files changed' : 'clean', color: r.dirty ? C.build : C.review },
774 { n: r.stale.length, label: 'Stale', note: r.stale.length ? 'branches' : 'none', color: r.stale.length ? C.build : C.review },
775 { n: r.worktrees, label: 'Worktrees', note: r.worktrees ? 'others open' : 'just this one', color: r.worktrees ? C.grill : C.gray },
776 ]
777 const isClean = r.prs.length === 0 && r.dirty === 0 && r.stale.length === 0 && r.worktrees === 0
778 let b = ''
779 b += text(20, 36, 'h ink', cut(r.name || 'No repo', 26))
780 const bw = Math.min(r.branch.length * 7 + 24, 210)
781 b += `<rect x="${W - 20 - bw}" y="18" width="${bw}" height="24" rx="12" fill="${C.slate}" fill-opacity=".08"/>`
782 b += `<text x="${W - 32}" y="34.5" text-anchor="end" font-size="12" font-family="${MONO}" class="mut">${esc(cut(r.branch, 27))}</text>`
783 b += text(20, 58, 'sub mut', isClean ? 'Nothing open. You can start something new.' : 'Finish what is open before starting something new.')
784 const tw = (W - 32 - 3 * 8) / 4
785 tiles.forEach((t, i) => {
786 const x = 16 + i * (tw + 8)
787 b += `<rect class="card" x="${x}" y="74" width="${tw}" height="92" rx="12"/>`
788 b += `<rect x="${x + 14}" y="86" width="22" height="4" rx="2" fill="${t.color}"/>`
789 b += `<text x="${x + 14}" y="118" font-size="32" font-weight="700" fill="${t.n ? (INK[t.color] ?? t.color) : '#AEAEB2'}" letter-spacing="-.02em">${t.n}</text>`
790 b += `<text x="${x + 14}" y="140" font-size="12" font-weight="600" class="ink">${esc(t.label)}</text>`
791 b += `<circle cx="${x + 18}" cy="154" r="3.5" fill="${t.color}"/>`
792 b += `<text x="${x + 26}" y="158" font-size="11" class="mut">${esc(t.note)}</text>`
793 })
794 return doc(184, b)
795}
796
797type Act = { key: string; label: string; text: string; isSend: boolean }
798
799const prActs = (pr: PullRequest): Act[] =>
800 pr.ci === 'fail'
801 ? [{ key: `fix-${pr.number}`, label: 'Fix the checks', isSend: false, text: `pull request #${pr.number} has failing checks. tell me why in plain words, then fix them test-first. don't merge.` }]
802 : [{ key: `review-${pr.number}`, label: 'Review it', isSend: false, text: `/code-review pull request #${pr.number} against its base branch` }]
803
804const SETUP: Act = { key: 'start-setup', label: 'Set up this repo', isSend: true, text: '/setup-matt-pocock-skills' }
805
806const CATCH_UP: Act = {
807 key: 'start-catchup',
808 label: 'Catch me up',
809 isSend: true,
810 text: "catch me up in plain words for a beginner: what this repo does, what was built recently, any open branches or pull requests, and which step of the flow (grill, spec, tickets, build, review, PR, retro) the work is at. keep it short. end with a multiple-choice question of what to do next, with finishing open work first.",
811}
812
813const STARTS: Act[] = [
814 { key: 'start-idea', label: 'I have an idea', isSend: false, text: '/grill-with-docs ' },
815 { key: 'start-bug', label: 'Something is broken', isSend: false, text: '/diagnosing-bugs ' },
816 {
817 key: 'start-ticket',
818 label: 'Pick up a ticket',
819 isSend: true,
820 text: "list this repo's open tickets that are ready for an agent, using the issue tracker named in docs/agents/issue-tracker.md, and ask me to pick one with a multiple-choice question. when I pick, run /implement on it.",
821 },
822 CATCH_UP,
823]
824
825// ---------- Guard rail: grill before building ----------
826
827const BUILD_ASK = /\b(build|add|implement|make|create|change|refactor|rewrite|update|wire up|hook up|code|feature|new (page|screen|button|endpoint|component))\b/i
828const BUG_ASK = /\b(bug|broken|error|crash(es|ed|ing)?|fails?|failing|doesn'?t work|not working|uncaught exception|(throws?|threw|raises?|raised) (an? )?exception|exception (is |was )?(thrown|raised)|stack trace)\b/i
829const QUESTION = /^(how|what|why|where|when|who|which|is|are|does|can you explain|explain|tell me)\b/i
830
831const nudgeFor = (prompt: string, f: Flow): Nudge => {
832 const said = prompt.trim()
833 if (!said || said.startsWith('/') || QUESTION.test(said)) return null
834 // Only before the path starts, or once the last job ran its retro.
835 if (f.current !== null && f.current !== 'retro') return null
836 if (BUG_ASK.test(said)) return { text: said, kind: 'bug' }
837 if (BUILD_ASK.test(said)) return { text: said, kind: 'grill' }
838 return null
839}
840
841const NUDGE_NOTE: Record<NonNullable<Nudge>['kind'], string> = {
842 grill:
843 '(pocock-guide: the person is new and follows Matt Pocock\'s flow: grill, spec, tickets, build test-first, review, PR, retro. ' +
844 'They asked for a code change before any grilling. Start your reply with one plain sentence suggesting they run /grill-with-docs first, and why. ' +
845 'Then do what they asked. If the change is large, ask whether to grill first before you write code.)',
846 bug:
847 '(pocock-guide: the person is new and follows Matt Pocock\'s flow. They described something broken. ' +
848 'Start your reply with one plain sentence suggesting /diagnosing-bugs, which finds a command that shows the bug failing before it fixes anything. ' +
849 'Then help with what they asked, and reproduce the bug before you change code.)',
850}
851
852// ---------- Guard rail: no destructive git ----------
853
854type Blocked = { what: string; why: string } | null
855
856const MAIN = /^(refs\/heads\/)?(main|master)$/
857
858// One shell segment, tokens after `git` (global -C and -c options dropped).
859const gitArgs = (segment: string) => {
860 const words = segment.trim().split(/\s+/).filter(Boolean)
861 const at = words.findIndex(w => w === 'git')
862 if (at < 0 || words.slice(0, at).some(w => !/^(sudo|command|env|\w+=\S*)$/.test(w))) return null
863 const rest = words.slice(at + 1)
864 while (rest[0] === '-C' || rest[0] === '-c') rest.splice(0, 2)
865 return rest
866}
867
868const pushBlock = (args: string[], branch: string): Blocked => {
869 const flags = args.filter(a => a.startsWith('-'))
870 const [, ...refspecs] = args.filter(a => !a.startsWith('-'))
871 if (flags.some(f => f === '-f' || f === '--force' || f.startsWith('--force-with-lease') || (/^-[a-z]+$/.test(f) && f.includes('f'))) || refspecs.some(r => r.startsWith('+'))) {
872 return { what: 'git push --force', why: 'A force push overwrites work on GitHub that may not exist anywhere else.' }
873 }
874 if (flags.includes('--all') || flags.includes('--mirror')) return { what: 'git push --all', why: 'It pushes every branch, main included.' }
875 const targets = refspecs.length ? refspecs.map(r => (r.includes(':') ? r.split(':').pop() ?? '' : r === 'HEAD' ? branch : r)) : [branch]
876 if (targets.some(t => MAIN.test(t))) return { what: 'git push to main', why: 'Main is the shared code. Changes reach it through a reviewed pull request.' }
877 return null
878}
879
880const gitBlock = (args: string[], branch: string): Blocked => {
881 const [verb = '', ...rest] = args
882 const flags = rest.filter(a => a.startsWith('-'))
883 const paths = rest.filter(a => !a.startsWith('-'))
884 if (verb === 'push') return pushBlock(rest, branch)
885 if (verb === 'reset' && flags.includes('--hard')) return { what: 'git reset --hard', why: 'It throws away every uncommitted change with no undo.' }
886 if (verb === 'clean' && flags.some(f => f === '--force' || /^-[a-zA-Z]*f/.test(f))) return { what: 'git clean -f', why: 'It deletes files git does not track, with no undo.' }
887 if (verb === 'branch' && (flags.includes('-D') || ((flags.includes('-d') || flags.includes('--delete')) && (flags.includes('-f') || flags.includes('--force'))))) {
888 return { what: 'git branch -D', why: 'It deletes a branch even when its work was never merged.' }
889 }
890 if (verb === 'checkout' && paths.includes('.')) return { what: 'git checkout .', why: 'It throws away every uncommitted change with no undo.' }
891 if (verb === 'restore' && paths.includes('.') && !(flags.includes('--staged') && !flags.includes('--worktree') && !flags.includes('-W'))) {
892 return { what: 'git restore .', why: 'It throws away every uncommitted change with no undo.' }
893 }
894 return null
895}
896
897// Checks every segment of a shell command. `branch` is the current branch, for a
898// bare `git push`; it is only read when a segment needs it.
899const guardBlock = async (command: string, branch: () => Promise<string>): Promise<Blocked> => {
900 for (const segment of command.split(/&&|\|\||;|\||\n/)) {
901 if (/^\s*gh\s+pr\s+merge\b/.test(segment)) return { what: 'gh pr merge', why: 'Merging adds the change to main. The person who reviews your work merges it.' }
902 const args = gitArgs(segment)
903 if (!args) continue
904 const found = gitBlock(args, args[0] === 'push' ? await branch() : '')
905 if (found) return found
906 }
907 return null
908}
909
910// ---------- Decision card and turn summary ----------
911
912const looksLikeChoice = (answer: string) => {
913 const tail = answer.slice(-1500)
914 const listed = (tail.match(/^\s*(?:\*\*)?([A-H]|[1-9])[.):](?:\*\*)?\s+\S/gm) ?? []).length
915 return listed >= 2 || /\?\s*$/.test(answer.trim()) || /\b(reply with|which (one|option)|should I|do you want|want me to|pick one|say the word)\b/i.test(tail)
916}
917
918const DECIDE_PROMPT = (answer: string) =>
919 'A beginner is working with an AI coding agent through Matt Pocock\'s flow: grill the idea, write a spec, cut tickets, build test-first, review, open a PR, run a retro. ' +
920 'The agent just finished a turn. Summarize it in short everyday words for the beginner, and if it ends by asking them to make a choice, extract it. ' +
921 'The flow favors: grill before building, a spec and tickets for anything bigger than one chat, a failing test before the fix, review before a PR, and finishing open work before starting new work.\n\n' +
922 'Reply with JSON only, no prose, in this shape:\n' +
923 '{"done":"<what this turn did or found, one or two plain sentences>","next":"<what the beginner should do next, one sentence>","isWaiting":<true if the agent stopped and needs the person to answer or act>,"isChoice":true,"question":"<one short line>","options":[{"key":"<what the person would type, e.g. B or yes>","label":"<3 to 6 words>"}],"pick":"<key the flow favors>","why":"<one plain sentence>"}\n' +
924 'For a yes or no question use keys "yes" and "no". At most 6 options. If the turn does not ask for a choice, set isChoice false and leave out question, options, pick and why.\n\n' +
925 `The turn's final reply:\n${answer.slice(-6000)}`
926
927const PR_LINK = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g
928
929const jsonOf = (s: string) => JSON.parse(s.slice(s.indexOf('{'), s.lastIndexOf('}') + 1)) as Record<string, unknown>
930
931const parseSummary = (said: string, answer: string): Summary => {
932 const links = [...new Set(answer.match(PR_LINK) ?? [])].slice(0, 4)
933 try {
934 const raw = jsonOf(said)
935 if (!raw.done) return null
936 return { done: String(raw.done), next: String(raw.next ?? ''), links, isWaiting: raw.isWaiting === true }
937 } catch {
938 return null
939 }
940}
941
942const parseDecision = (said: string): Decision => {
943 try {
944 const raw = jsonOf(said)
945 const options: Choice[] = ((raw.options ?? []) as Partial<Choice>[])
946 .filter(o => o.key && o.label)
947 .slice(0, 6)
948 .map(o => ({ key: String(o.key), label: String(o.label) }))
949 if (!raw.isChoice || options.length === 0) return null
950 const pick = options.some(o => o.key === raw.pick) ? String(raw.pick) : ''
951 return { question: String(raw.question ?? 'Your call'), options, pick, why: String(raw.why ?? '') }
952 } catch {
953 return null
954 }
955}
956
957const decisionSvg = (d: NonNullable<Decision>) => {
958 let b = ''
959 b += `<circle cx="26" cy="27" r="5" fill="${C.build}"/>`
960 b += `<text x="38" y="31" font-size="12" font-weight="700" fill="${INK[C.build]}" letter-spacing=".04em">CLAUDE IS ASKING YOU</text>`
961 const q = wrap(d.question, 44).slice(0, 2)
962 b += lines(20, 56, 'ink', q, 22, ' font-size="18" font-weight="700" letter-spacing="-.02em"')
963 let y = 56 + q.length * 22 - 4
964 for (const o of d.options) {
965 const isPick = o.key === d.pick
966 const h = 46
967 b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="12"${isPick ? ` stroke="${C.grill}" stroke-width="2"` : ''}/>`
968 b += `<circle cx="40" cy="${y + h / 2}" r="13" fill="${isPick ? C.grill : '#E5E5EA'}"/>`
969 b += `<text x="40" y="${y + h / 2 + 4.5}" text-anchor="middle" font-size="12.5" font-weight="700" fill="${isPick ? '#fff' : '#1D1D1F'}">${esc(cut(o.key, 3))}</text>`
970 b += `<text x="62" y="${y + h / 2 + (isPick ? -1 : 4.5)}" font-size="13.5" font-weight="${isPick ? 700 : 500}" class="ink">${esc(cut(o.label, 40))}</text>`
971 if (isPick) b += `<text x="62" y="${y + h / 2 + 14}" font-size="10.5" font-weight="700" fill="${INK[C.grill]}">★ what the flow suggests</text>`
972 y += h + 8
973 }
974 if (d.why) {
975 y += 8
976 const why = wrap(d.why, 62).slice(0, 3)
977 b += lines(20, y, 'body mut', why, 17)
978 y += why.length * 17
979 }
980 return doc(y + 10, b)
981}
982
983// ---------- Flow progress, kept per repo ----------
984
985const flowKey = (root: string) => `flow:${root}`
986
987// The flow belongs to the repo, not the chat, so a cleared chat or a new session
988// in the same repo picks up where the last one stopped.
989async function loadFlow($: EngineInterface) {
990 const root = (await mainCheckout($)) ?? (await $.session.cwd())
991 const saved = (await $.store.get(flowKey(root))) as Flow | undefined
992 const value: Flow = saved && Array.isArray(saved.done) ? { ...EMPTY_FLOW, ...saved, root } : { ...EMPTY_FLOW, root }
993 await update($, flow, () => value)
994}
995
996async function setFlow($: EngineInterface, fn: (f: Flow) => Flow) {
997 const next = await update($, flow, fn)
998 if (next.root) await $.store.set(flowKey(next.root), next)
999}
1000
1001async function useSkill($: EngineInterface, raw: string) {
1002 const name = raw.split(':').pop() ?? raw
1003 const step = SKILL_STEP[name]
1004 if (!step) return
1005 const now = await read($, flow)
1006 if (now.last === name && now.current === (REVIEWS_ITSELF.has(name) ? 'review' : step)) return
1007 await setFlow($, f => advance(f, name, step))
1008 await update($, nudge, () => null)
1009}
1010
1011export const register: Register = (on, options) => {
1012 const isGuarded = (options as { gitGuard?: boolean }).gitGuard !== false
1013 // The pane opens on its own only where pstack-guide does not, so Matt's own
1014 // sessions keep pstack-guide. /mod-pocock opens it anywhere.
1015 let isActive = false
1016 let isNudgeOff = false
1017
1018 on('session.start', async ($, e, next) => {
1019 await $.command.register({
1020 name: 'mod-pocock',
1021 description: "Open a guide that walks you through Matt Pocock's flow, step by step",
1022 })
1023 const found = await readEnv($)
1024 await loadFlow($)
1025 isActive = !found.hasPstackGuide
1026 if (isActive) void $.ui.open({ id: PANE, title: TITLE, focus: true })
1027 void readRepo($)
1028
1029 return next(e)
1030 })
1031
1032 on('command.run', { command: 'mod-pocock' }, async $ => {
1033 isActive = true
1034 const f = await read($, flow)
1035 await update($, tab, t => (f.current && t === 'start' ? 'now' : t))
1036 await readEnv($)
1037 await $.ui.open({ id: PANE, title: TITLE })
1038 void readRepo($)
1039
1040 return { text: 'Pocock flow pane opened.' }
1041 })
1042
1043 on('command.run', async ($, e, next) => {
1044 await useSkill($, e.command)
1045 const result = await next(e)
1046 // /clear may start the session state over; the repo's saved flow brings it back.
1047 if (e.command === 'clear' && !(await read($, flow)).root) await loadFlow($)
1048 return result
1049 })
1050
1051 on('skill.prompt', async ($, e, next) => {
1052 await useSkill($, e.skill)
1053 return next(e)
1054 })
1055
1056 on('tool.call', async ($, e, next) => {
1057 const result = await next(e)
1058 if (e.agentId) return result
1059 const args = e as unknown as Record<string, unknown>
1060 if (e.tool === 'Skill' && typeof args.skill === 'string') await useSkill($, args.skill)
1061 if (e.tool === 'Bash' && typeof args.command === 'string' && /\bgh pr create\b/.test(args.command)) {
1062 await setFlow($, f => advance(f, 'gh pr create', 'pr'))
1063 }
1064 return result
1065 })
1066
1067 // Runs before the call, for subagents too, so a blocked command never starts.
1068 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1069 if (!isGuarded) return next(e)
1070 const found = await guardBlock(e.command, async () => (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '')
1071 if (!found) return next(e)
1072 $.ui.toast(`Pocock flow: blocked ${found.what}.`)
1073 return { deny: `pocock-guide blocked ${found.what}. ${found.why} If it is really needed, ask the person who reviews your work to run it, and tell the user so in plain words.` }
1074 })
1075
1076 // A prompt that asks for code before any grilling gets a card in the pane and a
1077 // note for the model. Nothing is blocked.
1078 on('prompt.submit', async ($, e, next) => {
1079 await update($, decision, () => null)
1080 await update($, summary, s => (s ? { ...s, isWaiting: false } : s))
1081 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
1082 // Only where the pane is active, so a reviewer's own sessions get no beginner nudges.
1083 const found = isActive && isPerson && !isNudgeOff && (await read($, env)).hasSkills ? nudgeFor(e.text, await read($, flow)) : null
1084 await update($, nudge, () => found)
1085 if (!found) return next(e)
1086 $.ui.toast(found.kind === 'bug' ? 'Pocock flow: try /diagnosing-bugs. The pane has a button.' : 'Pocock flow: grill it first? The pane has a button.')
1087 return next({ ...e, context: [...(e.context ?? []), NUDGE_NOTE[found.kind]] })
1088 })
1089
1090 // The desktop spends the first click on an unfocused pane on focus, so a
1091 // button needs two clicks. When a main turn ends the pane asks for the keyboard.
1092 on('turn.complete', async ($, e, next) => {
1093 const result = await next(e)
1094 if ((e as { agentId?: string }).agentId !== undefined) return result
1095 if (isActive) void $.ui.open({ id: PANE, title: TITLE, focus: true })
1096 if (!(await read($, flow)).root) await loadFlow($)
1097 const answer = e.answer.trim()
1098 if (e.isAborted || !answer) {
1099 await update($, decision, () => null)
1100 return result
1101 }
1102 // A short reply is its own summary, so it costs no model call.
1103 if (answer.length < 280 && !looksLikeChoice(answer)) {
1104 await update($, decision, () => null)
1105 await update($, summary, () => ({ done: answer, next: '', links: [...new Set(answer.match(PR_LINK) ?? [])], isWaiting: false }))
1106 return result
1107 }
1108 void (async () => {
1109 const ask = await $.model.complete({ model: 'haiku', prompt: DECIDE_PROMPT(answer), timeoutMs: 20000 })
1110 const found = ask.isAnswered && looksLikeChoice(answer) ? parseDecision(ask.text) : null
1111 const said = ask.isAnswered ? parseSummary(ask.text, answer) : null
1112 await update($, decision, () => found)
1113 await update($, summary, () => (said ? { ...said, isWaiting: said.isWaiting || found !== null } : null))
1114 if (found) $.ui.toast('Pocock flow: Claude is asking you to choose. The pane has a button for each option.')
1115 else if (said?.isWaiting) $.ui.toast('Pocock flow: Claude is waiting on you. The pane says what for.')
1116 })()
1117 return result
1118 })
1119
1120 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1121 const current = await read($, tab)
1122 const f = await read($, flow)
1123 const setup = await read($, env)
1124 const { Box, Button, Markdown, Text, Code } = $.ui.resolve(e)
1125 const isDesktop = e.surface !== 'terminal'
1126
1127 const guide = GUIDE.find(g => g.id === current)
1128 const go = (id: Tab) => () => {
1129 void update($, tab, () => id)
1130 if (id === 'start') void readRepo($)
1131 }
1132 const top: { id: Tab; label: string; key: string; isOn: boolean }[] = [
1133 { id: 'start', label: 'Start', key: 'tab-start', isOn: current === 'start' },
1134 { id: 'now', label: 'Now', key: 'tab-now', isOn: current === 'now' },
1135 { id: guide ? current : 'flow', label: 'Guide', key: 'tab-guide', isOn: guide !== undefined },
1136 ]
1137 const nav = (
1138 <Box flexDirection="column" gap={1}>
1139 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1140 {top.map(t => (
1141 <Button key={t.key} label={t.label} variant={t.isOn ? 'primary' : 'secondary'} onPress={go(t.id)} />
1142 ))}
1143 </Box>
1144 {guide ? (
1145 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1146 {GUIDE.map(g => (
1147 <Button key={`tab-${g.id}`} label={g.id === current ? `• ${g.label}` : g.label} plain onPress={go(g.id)} />
1148 ))}
1149 </Box>
1150 ) : null}
1151 </Box>
1152 )
1153
1154 const act = (a: Act | Move, variant: 'primary' | 'secondary' = 'secondary') => (
1155 <Button
1156 key={a.key}
1157 label={a.label}
1158 variant={variant}
1159 onPress={() => {
1160 if (!a.isSend) return void $.prompt.fill({ text: a.text })
1161 // The engine refuses a submitted prompt that starts with a slash, so a
1162 // slash command runs as a command.
1163 const slash = /^\/(\S+)\s*([\s\S]*)$/.exec(a.text)
1164 void (slash ? $.command.run({ command: slash[1] ?? '', args: slash[2] ?? '' }) : $.prompt.submit({ text: a.text, asUser: true }))
1165 }}
1166 />
1167 )
1168
1169 const asked = await read($, nudge)
1170 const nudgeCard =
1171 asked === null ? null : (
1172 <Box key="nudge" flexDirection="column" gap={1} borderStyle="round" borderColor="#F56300" paddingX={1}>
1173 <Text bold color="#F56300">
1174 {asked.kind === 'bug' ? 'Sounds like something is broken' : 'Grill it first?'}
1175 </Text>
1176 <Text>
1177 {asked.kind === 'bug'
1178 ? '/diagnosing-bugs finds a command that shows the bug failing, then fixes it with a test. Claude will still help with what you asked.'
1179 : 'You asked for code before any grilling. A few minutes of questions now saves a rebuild later. Claude will still do what you asked.'}
1180 </Text>
1181 <Box flexDirection="row" flexWrap="wrap" gap={1}>
1182 <Button
1183 key="nudge-go"
1184 label={asked.kind === 'bug' ? 'Diagnose it' : 'Grill it first'}
1185 variant="primary"
1186 onPress={() => {
1187 void $.prompt.fill({ text: `${asked.kind === 'bug' ? '/diagnosing-bugs' : '/grill-with-docs'} ${asked.text}` })
1188 }}
1189 />
1190 <Button
1191 key="nudge-off"
1192 label="Not this time"
1193 onPress={() => {
1194 isNudgeOff = true
1195 void update($, nudge, () => null)
1196 }}
1197 />
1198 </Box>
1199 </Box>
1200 )types/index.d.ts 53 lines1export type Tab = 'start' | 'now' | 'flow' | 'ramps' | 'more' | 'words'
2
3export type StepId = 'grill' | 'spec' | 'tickets' | 'build' | 'review' | 'pr' | 'retro'
4
5export type Flow = {
6 // The repo this progress belongs to, so a new session in it picks up where the last one stopped.
7 root: string
8 done: StepId[]
9 current: StepId | null
10 // The skill that last moved the flow, without its plugin prefix.
11 last: string
12 isBugFix: boolean
13}
14
15export type Nudge = { text: string; kind: 'grill' | 'bug' } | null
16
17// isOutdated: the only copy is the official marketplace's, which predates v1.3.1 and lacks /implement-spec, /pr and /retro.
18export type Env = { isRead: boolean; hasSkills: boolean; isOutdated: boolean; hasPstackGuide: boolean }
19
20export type PullRequest = { number: number; title: string; ci: 'fail' | 'pending' | 'pass' | 'none'; isDraft: boolean }
21
22export type Repo = {
23 isRead: boolean
24 error: string | null
25 name: string
26 branch: string
27 dirty: number
28 stale: string[]
29 worktrees: number
30 prs: PullRequest[]
31 isSetUp: boolean
32}
33
34export type Choice = { key: string; label: string }
35
36export type Decision = { question: string; options: Choice[]; pick: string; why: string } | null
37
38export type Summary = { done: string; next: string; links: string[]; isWaiting: boolean } | null
39
40declare module 'claude-code' {
41 interface PluginState {
42 'pocock-guide': {
43 tab: Tab
44 flow: Flow
45 nudge: Nudge
46 env: Env
47 repo: Repo
48 decision: Decision
49 summary: Summary
50 }
51 }
52}
53