SLOPSHOPPER

pocock-guide

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

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pocock-guide
│ ┃ Pocock flow ✕ › fix the failing auth tes╭────────────────────────────────────────╮ │ ┃ [ Start ] [ Now ] [ Guide ] │ pocock-guide │ │ ┃ ⏺ Read(src/auth.ts) │ Pocock flow: blocked git push --force. │ │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines ╰────────────────────────────────────────╯ │ ┃ │ Matt Pocock's skills are not installed ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ The buttons here run his skills, so ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ install them first. Run this in a ⎿ Denied by pocock-guide: pocock-guide blocked git push --f │ ┃ │ terminal, then start a new session: │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ claude plugin marketplace add mattnicosia/ │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ [ Check again ] │ ┃ ╰─────────────────────────────────────────── › /mod-pocock │ ┃ ⎿ pocock-guide: Pocock flow pane opened. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Last turn │ │ ┃ │ Done. I made refresh reject expired │ │ ┃ │ claims, added an audit call, and created │ │ ┃ │ src/audit.ts. One test still fails │ │ ┃ │ (revokes on logout), which looks │ │ ┃ │ unrelated to this change. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · │ ┃ feat/auth-refresh ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pocock flow
[ Start ] [ Now ] [ Guide ] ╭─────────────────────────────────────────────────────────── │ Matt Pocock's skills are not installed │ │ The buttons here run his skills, so install them first. │ Run this in a terminal, then start a new session: │ │ claude plugin marketplace add mattnicosia/bldg-skills && c │ │ [ Check again ] ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Last turn │ │ Done. I made refresh reject expired claims, added an │ │ audit call, and created src/audit.ts. One test still │ │ fails (revokes on logout), which looks unrelated to this │ │ change. │ ╰──────────────────────────────────────────────────────────╯ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · feat/auth-refresh Could not read pull requests with gh. Open work 2 uncommitted files on feat/auth-refresh [ Look at them ] Start something [ Catch me up ] Idea and broken fill your prompt: type what you mean after the command, then press Enter. The others send at once. [ Refresh ]
README

BLDG Skills

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.

Install

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.

Plugins

PluginSkillsWhoseWhat it is
alex-hormozi8Alex HormoziSkills 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-ops2Running agents: session worksheets and workflow scaffolding.
bldg-coding7Coding skills: review, debugging, architecture and the caveman pass.
bldg-construction17Construction estimating: drawing indexing, scope of work generation in Excel and PDF, bid leveling, proposals and job folder setup.
bldg-creative2Creative work: image prompting, voice, and the gauntlet loop.
bldg-distills12Distilled one-page methods pulled out of longer sources.
bldg-documents1Document generation and release.
bldg-house4House build skills: project and site level-ups, page teardowns, and the cloud sync auditor.
bldg-maker-school46The Maker School library: growth, offers, funnels, outreach and operating skills.
bldg-marketing25Marketing: funnels, positioning, SEO, ads, content and speed to lead.
bldg-social4Social: posting, scheduling and the scroll-stop technique.
bldg-strategy7Strategy: idea scoring, prediction, competitive work and planning.
bldg-website-design9Website design and build: frontend design, motion, cloning and upgrades.
corey-ganim3Corey GanimCorey 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-pocock37Matt PocockMatt 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-guidemodA 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.

Other tools

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.

Layout

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

Notes

  • Ratio figures in 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.
Source 2 files
hooks/register.tsx 1395 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 lines
1export 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