SLOPSHOPPER

build-guide

A side pane that shows the session repo's target map on top and pstack and Matt Pocock's flow side by side on the same work, from the first idea to a merged…

newpaneguardcommandtoastprompt
★ 1v0.2.1MITupdated 2026-10-13mattnicosia/bldg-skills/plugins/build-guide
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · build-guide
│ ┃ Build guide ✕ › fix the failing auth tes╭────────────────────────────────────────╮ │ ┃ [ Start ] [ Now ] [ Guide ] │ build-guide │ │ ┃ ⏺ Read(src/auth.ts) │ Build guide: blocked git push --force. │ │ ┃ No target map for this project yet. [ Start ⎿ Read 6 lines ╰────────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ Last turn │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ Done. I made refresh reject expired │ ⎿ Denied by build-guide: build-guide blocked git push --for │ ┃ │ claims, added an audit call, and created │ │ ┃ │ src/audit.ts. One test still fails │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ (revokes on logout), which looks │ │ ┃ │ unrelated to this change. │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ › /mod-guide │ ┃ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 · ⎿ build-guide: Build guide opened. │ ┃ feat/auth-refresh │ ┃ │ ┃ Could not read pull requests with gh. │ ┃ │ ┃ Open work │ ┃ │ ┃ 2 uncommitted files on feat/auth-refresh │ ┃ [ Look at them ] │ ┃ │ ┃ Start something │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Build guide
[ Start ] [ Now ] [ Guide ] No target map for this project yet. [ Start the target map ] ╭──────────────────────────────────────────────────────────╮ │ 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 pstack ╭─────────────────────────────────────────────────────────── │ pstack not installed │ │ The buttons here run pstack, so install it first. Run │ this in a terminal, then start a new session: │ │ claude plugin marketplace add lifeofladi/pstack-claude && │ │ [ Check again ] ╰─────────────────────────────────────────────────────────── [ Catch me up ] Start buttons send at once and ask you to pick. Open-work buttons fill your prompt, so press Enter to send. pocock ╭─────────────────────────────────────────────────────────── │ Matt Pocock's skills not installed │ │ The buttons here run Matt Pocock's skills, so install it │ first. Run this in a terminal, then start a new session: │ │ claude plugin marketplace add mattnicosia/bldg-skills && c │ │ [ Check again ] ╰─────────────────────────────────────────────────────────── [ 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/.
build-guidemodA side pane for building with Claude. A strip at the top shows the target map of the session's repo, read from its targets/<product>.json (jobs left under each flag, stuck jobs, and a pill when your current work crosses a job off); press it for the full map, and Refresh the map runs the check script from the target-map mod in matt-mods (or $TARGET_MAP_SCRIPT). A repo with no targets file shows a button that fills your prompt to start one. The Now tab shows pstack and Matt Pocock's flow side by side on the same work: one stage bar, a column per method with its steps and what it would do now, and a next-move card per method with a button that fills your prompt. Neither method toasts or adds notes to your prompts. Start shows the repo's open PRs, uncommitted files, stale branches and worktrees, each with a button (labelled per method where pstack and pocock differ), plus a row of one-press starts for each method. After each turn a card says what Claude did, and when Claude asks you to choose, a card puts a button on each option. Pocock's flow also needs matt-pocock, the v1.3.1 mirror above. It 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-guide, or /mod-guide pocock to pick the method the Guide tab shows.

pocock-guide is now build-guide. Install the new one and remove the old one:

claude plugin install build-guide@bldg-skills
claude plugin uninstall pocock-guide

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 13 files
hooks/register.tsx 889 lines
1// The build guide pane. Everything a method needs done (a read, a write, a
2// model call, a toast) happens here: the engine follows `$` only into functions
3// of this file, so the methods stay data and pure functions.
4//
5// Every method hears every signal and keeps its own state. When one method's
6// own move changes the shared stage, the others move to match (stages.ts), and
7// that counts as one move. No method toasts or adds a note to a prompt: its
8// advice shows in its own column on the Now tab.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Decision, Env, MethodId, SharedId, Stage, States, Summary, Target } from '../types'
14import { fit } from './draw'
15import { guardBlock } from './guard'
16import { during, skillsOf, type Act, type AnyMethod, type Card, type Out } from './method'
17import { detect, isMethod, methodOf, METHODS } from './methods'
18import { barLines, columnMd, nextCards, pairSvg, type Side } from './pair'
19import { NO_REPO, openWork, prsOf, staleOf, startSvg, treesOf } from './repo'
20import { ownOf, sharedOf, SHARED } from './stages'
21import { commandFor, crossesOff, EMPTY, mapLines, mapSvg, missingText, parseStatus, pickTargets, productOf, SCRIPT_UNKNOWN, scriptFrom, severalText, slug, startPrompt, STATUS_DIR, stripLines, stripSvg } from './target'
22import { ASK, decidePrompt, decisionSvg, linksIn, looksLikeChoice, parseDecision, parseSummary } from './turn'
23
24const PANE = 'build-guide'
25const TITLE = 'Build guide'
26// The method the person last picked in the Guide tab, kept across sessions.
27const PICKED = 'method'
28
29const method = atom({ plugin: 'build-guide', key: 'method' } as const, null as MethodId | null)
30const tab = atom({ plugin: 'build-guide', key: 'tab' } as const, 'start')
31const env = atom({ plugin: 'build-guide', key: 'env' } as const, { isRead: false, plugins: [], copied: [] } as Env)
32const repo = atom({ plugin: 'build-guide', key: 'repo' } as const, NO_REPO)
33const summary = atom({ plugin: 'build-guide', key: 'summary' } as const, null as Summary)
34const decision = atom({ plugin: 'build-guide', key: 'decision' } as const, null as Decision)
35const states = atom({ plugin: 'build-guide', key: 'states' } as const, {} as States)
36const stage = atom({ plugin: 'build-guide', key: 'stage' } as const, { at: null, by: null, moves: 0 } as Stage)
37const target = atom({ plugin: 'build-guide', key: 'target' } as const, { phase: 'unread' } as Target)
38const isTargetOpen = atom({ plugin: 'build-guide', key: 'isTargetOpen' } as const, false)
39
40const picked = async ($: EngineInterface) => methodOf(await read($, method))
41
42const stateIn = (all: States, m: AnyMethod): unknown => (all as Record<string, unknown>)[m.id] ?? m.empty
43
44type Moved = { by: MethodId; at: SharedId }
45
46// Runs a reducer on each method's state (or only one's), keeps the results, moves
47// the other methods when one changed the shared stage, and does the work asked for.
48async function step($: EngineInterface, reduce: (m: AnyMethod, s: unknown) => Out<unknown> | undefined, only?: AnyMethod) {
49  const outs = new Map<MethodId, Out<unknown>>()
50  const event: { moved: Moved | null } = { moved: null }
51  const all = await update($, states, a => {
52    outs.clear()
53    event.moved = null
54    const next: Record<string, unknown> = { ...a }
55    for (const m of only ? [only] : METHODS) {
56      const was = stateIn(next as States, m)
57      const out = reduce(m, was)
58      if (!out) continue
59      outs.set(m.id, out)
60      next[m.id] = out.s
61      const before = sharedOf(m.id, m.stageOf(was))
62      const after = sharedOf(m.id, m.stageOf(out.s))
63      if (!event.moved && after !== null && after !== before) event.moved = { by: m.id, at: after }
64    }
65    const moved = event.moved
66    if (moved) {
67      for (const o of METHODS) {
68        const s = stateIn(next as States, o)
69        const own = ownOf(o.id, moved.at)
70        if (o.id !== moved.by && own && sharedOf(o.id, o.stageOf(s)) !== moved.at) next[o.id] = o.moveTo(s, own)
71      }
72    }
73    return next as States
74  })
75  const moved = event.moved
76  if (moved) await update($, stage, s => ({ at: moved.at, by: moved.by, moves: s.moves + 1 }))
77  if (outs.size > 0) await save($, all)
78  for (const [id, out] of outs) {
79    const m = methodOf(id)
80    const path = out.read
81    if (path && m.hooks.file) {
82      let content: string | null = null
83      try {
84        content = String(await $.fs.read(path))
85      } catch {
86        content = null
87      }
88      await step($, (x, s) => x.hooks.file?.(s, path, content), m)
89    }
90    const ask = out.ask
91    if (ask && m.hooks.answer) void advise($, m, ask)
92  }
93  return outs
94}
95
96// One Haiku call per step: the method's own question, and a line from every
97// method on what it would have the person do now.
98async function advise($: EngineInterface, m: AnyMethod, ask: { key: string; prompt: string }) {
99  const all = await read($, states)
100  const prompt =
101    `${ask.prompt}\n\nThen write one line for each method below. Start the line with the method name and a colon, ` +
102    'and say in under 18 plain words what that method would have the user do now. No dashes, no markdown.\n' +
103    METHODS.map(x => `${x.label}: ${x.brief(stateIn(all, x))}`).join('\n')
104  let said: string | null = null
105  try {
106    const r = await $.model.complete({ model: 'haiku', prompt, timeoutMs: 20000 })
107    said = r.isAnswered ? r.text : null
108  } catch {
109    said = null
110  }
111  await step($, (x, s) => ({ s: x.hooks.answer?.(s, ask.key, said) ?? s }), m)
112}
113
114async function save($: EngineInterface, all: States) {
115  for (const m of METHODS) {
116    const keep = m.persist
117    const root = keep?.rootOf(stateIn(all, m))
118    if (keep && root) await $.store.set(`${m.id}:${root}`, keep.save(stateIn(all, m)))
119  }
120}
121
122const git = async ($: EngineInterface, args: string[]) => {
123  try {
124    const r = await $.process.run(['git', ...args], { timeoutMs: 10000 })
125    return r.exitCode === 0 ? r.stdout.trim() : null
126  } catch {
127    return null
128  }
129}
130
131// In a worktree, --show-toplevel names the worktree folder; the shared git dir
132// sits inside the main checkout, which carries the repo's name.
133async function mainCheckout($: EngineInterface) {
134  const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
135  return common === null ? null : common.replace(/\/\.git\/?$/, '')
136}
137
138// Reads the repo once per press, never while drawing, so the pane stays still.
139async function readRepo($: EngineInterface) {
140  const top = await git($, ['rev-parse', '--show-toplevel'])
141  if (top === null) {
142    await update($, repo, () => ({ ...NO_REPO, isRead: true, error: 'This session is not in a git repo.' }))
143    return
144  }
145  const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '?'
146  const status = (await git($, ['status', '--porcelain'])) ?? ''
147  const base = ((await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) ?? 'origin/main').replace(/^origin\//, '')
148  const merged = (await git($, ['branch', '--merged', base, '--format=%(refname:short)'])) ?? ''
149  const heads = (await git($, ['for-each-ref', '--format=%(refname:short)|%(committerdate:unix)', 'refs/heads'])) ?? ''
150  const trees = treesOf((await git($, ['worktree', 'list', '--porcelain'])) ?? '')
151  const others = trees.filter((t, i) => i > 0 && t.path && t.path !== top)
152  const stale = staleOf(heads, merged, trees, base, Date.now() / 1000)
153  let prs: ReturnType<typeof prsOf> = []
154  let error: string | null = null
155  try {
156    const r = await $.process.run(['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--limit', '8', '--json', 'number,title,headRefName,isDraft,statusCheckRollup,mergeable,reviewDecision'], { timeoutMs: 15000 })
157    if (r.exitCode === 0) prs = prsOf(r.stdout)
158    else error = 'Could not read pull requests with gh.'
159  } catch {
160    error = 'Could not read pull requests with gh.'
161  }
162  // Set up when every method that names a setup file finds it.
163  let isSetUp = true
164  for (const m of METHODS) {
165    const file = m.setup?.file
166    if (file && !(await $.fs.exists(`${top}/${file}`).catch(() => true))) isSetUp = false
167  }
168  const name = ((await mainCheckout($)) ?? top).split('/').pop() ?? top
169  await update($, repo, () => ({ isRead: true, error, name, branch, dirty: status ? status.split('\n').length : 0, stale, worktrees: others.length, prs, isSetUp }))
170}
171
172async function readEnv($: EngineInterface) {
173  const home = (await $.env.get('HOME')) ?? ''
174  let plugins: string[] = []
175  try {
176    const raw = JSON.parse(String(await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`))) as { plugins?: Record<string, unknown> }
177    plugins = Object.keys(raw.plugins ?? {})
178  } catch {
179    plugins = []
180  }
181  // skills.sh copies skill files into the user's or the project's skills folder instead of a plugin.
182  const copied: MethodId[] = []
183  for (const m of METHODS) {
184    const has =
185      (await $.fs.exists(`${home}/.claude/skills/${m.requires.skill}/SKILL.md`).catch(() => false)) ||
186      (await $.fs.exists(`.claude/skills/${m.requires.skill}/SKILL.md`).catch(() => false))
187    if (has) copied.push(m.id)
188  }
189  const value: Env = { isRead: true, plugins, copied }
190  await update($, env, () => value)
191  return value
192}
193
194// The check script: $TARGET_MAP_SCRIPT, else the one the installed target-map mod ships.
195async function targetScript($: EngineInterface) {
196  const set = await $.env.get('TARGET_MAP_SCRIPT')
197  if (set) return set
198  const home = (await $.env.get('HOME')) ?? ''
199  const installed = await $.fs.read(`${home}/.claude/plugins/installed_plugins.json`).then(String, () => '')
200  return scriptFrom(installed)
201}
202
203// The session repo's targets file: the repo root, the product and the files.
204async function targetsOf($: EngineInterface) {
205  const root = await git($, ['rev-parse', '--show-toplevel'])
206  if (root === null) return null
207  const names = await $.fs.list(`${root}/targets`).then(es => es.filter(x => x.kind === 'file').map(x => x.name), () => [] as string[])
208  const { file, others } = pickTargets(names)
209  return { root, file, others }
210}
211
212// The session repo's target map, read at the start, on /mod-guide, after each turn and on a press.
213async function readTarget($: EngineInterface) {
214  const found = await targetsOf($)
215  if (!found || !found.file) {
216    const name = found ? ((await mainCheckout($)) ?? found.root).split('/').pop() ?? found.root : 'product'
217    await update($, target, () => ({ phase: 'none', ask: startPrompt(slug(name)) }) as Target)
218    return
219  }
220  const { root, file, others } = found
221  const home = (await $.env.get('HOME')) ?? ''
222  let text: string | null = null
223  try {
224    text = String(await $.fs.read(`${home}/${STATUS_DIR}/${productOf(file)}.status.json`))
225  } catch {
226    text = null
227  }
228  const command = commandFor((await targetScript($)) ?? SCRIPT_UNKNOWN, root)
229  await update($, target, () => (text === null ? { phase: 'missing', command } : parseStatus(text, severalText(file, others))))
230}
231
232// Runs the check script for the session's repo, then rereads the map. When the
233// script is not found or fails, the prompt gets the command instead.
234async function refreshTarget($: EngineInterface) {
235  const found = await targetsOf($)
236  if (!found || !found.file) return readTarget($)
237  const script = await targetScript($)
238  const command = commandFor(script ?? SCRIPT_UNKNOWN, found.root)
239  const r = script ? await $.process.run(['node', script, '--repo', found.root], { cwd: found.root, timeoutMs: 120000 }).catch(() => null) : null
240  if (!r || r.exitCode !== 0) await $.prompt.fill({ text: `Run ${command}, then reopen the build guide.` })
241  await readTarget($)
242}
243
244// A method that keeps progress per repo loads it once its state names no repo:
245// at the start, and after /clear starts the session's state over. The shared
246// stage then takes the loaded position, and the other methods follow it; a
247// load is not a move.
248async function loadSaved($: EngineInterface) {
249  for (const m of METHODS) {
250    const keep = m.persist
251    if (!keep || keep.rootOf(stateIn(await read($, states), m))) continue
252    const root = (await mainCheckout($)) ?? (await $.session.cwd())
253    const saved = await $.store.get(`${m.id}:${root}`)
254    await update($, states, a => ({ ...a, [m.id]: keep.load(stateIn(a, m), saved, root) }))
255  }
256  if ((await read($, stage)).at !== null) return
257  const all = await read($, states)
258  const from = METHODS.find(m => sharedOf(m.id, m.stageOf(stateIn(all, m))) !== null)
259  const at = from ? sharedOf(from.id, from.stageOf(stateIn(all, from))) : null
260  if (!from || !at) return
261  await update($, states, a => {
262    const next: Record<string, unknown> = { ...a }
263    for (const o of METHODS) {
264      const own = ownOf(o.id, at)
265      if (o.id !== from.id && own) next[o.id] = o.moveTo(stateIn(a, o), own)
266    }
267    return next as States
268  })
269  await update($, stage, s => ({ ...s, at, by: from.id }))
270}
271
272async function startOver($: EngineInterface) {
273  const all = await update($, states, a => Object.fromEntries(METHODS.map(m => [m.id, m.reset(stateIn(a, m))])) as States)
274  await update($, stage, s => ({ ...s, at: null, by: null }))
275  await save($, all)
276}
277
278const registered = new Set<string>()
279
280async function registerTools($: EngineInterface) {
281  for (const m of METHODS) {
282    for (const t of m.tools ?? []) {
283      if (registered.has(t.name)) continue
284      registered.add(t.name)
285      await $.tool.register({ name: t.name, description: t.description, inputSchema: t.inputSchema as never })
286    }
287  }
288}
289
290// Makes `id` the method the Guide tab shows.
291async function pick($: EngineInterface, id: MethodId) {
292  const m = methodOf(id)
293  await $.store.set(PICKED, m.id)
294  await update($, method, () => m.id)
295  // A guide page of the old method becomes the new method's first page.
296  await update($, tab, t => (t === 'start' || t === 'now' || m.guide.some(g => g.id === t) ? t : (m.guide[0]?.id ?? 'start')))
297}
298
299// The engine refuses a submitted prompt that starts with a slash, so a slash
300// command runs as a command and anything else goes in as the user's message.
301function send($: EngineInterface, text: string) {
302  const slash = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
303  return slash ? $.command.run({ command: slash[1] ?? '', args: slash[2] ?? '' }) : $.prompt.submit({ text, asUser: true })
304}
305
306function press($: EngineInterface, a: Act) {
307  return a.isSend ? send($, a.text ?? '') : $.prompt.fill({ text: a.text ?? '' })
308}
309
310function installCard(m: AnyMethod, e: Env): Card | null {
311  const status = skillsOf(m, e)
312  if (status === 'ok') return null
313  const isOld = status === 'outdated'
314  return {
315    key: 'install',
316    title: isOld ? `${m.requires.name}: your copy is out of date` : `${m.requires.name} not installed`,
317    body: `${isOld ? `${m.requires.outdatedNote ?? ''} ` : `The buttons here run ${m.requires.name}, so install it first. `}Run this in a terminal, then start a new session:`,
318    color: '#E5342C',
319    code: m.requires.install,
320    note: isOld ? (m.requires.removeOld ? `Then remove the old copy with: ${m.requires.removeOld}` : undefined) : undefined,
321    acts: [],
322  }
323}
324
325// The plain-words reply of the first method whose skills are installed.
326const explainOf = (e: Env) => (METHODS.find(m => skillsOf(m, e) !== 'missing') ?? METHODS[0]!).explain
327
328// Every pressable element of the pane's last drawing, by key, so a focus that
329// lands on one can run it.
330const pressable = new Map<string, () => void>()
331const ranOnFocus = new Map<string, number>()
332let hasKeys = false
333
334function collect(node: unknown) {
335  if (Array.isArray(node)) return node.forEach(collect)
336  if (!node || typeof node !== 'object') return
337  const el = node as { key?: unknown; props?: { onPress?: unknown; children?: unknown } }
338  if (typeof el.key === 'string' && typeof el.props?.onPress === 'function') {
339    const run = el.props.onPress as (arg?: unknown) => void
340    pressable.set(el.key, () => run({}))
341  }
342  collect(el.props?.children)
343}
344
345export const register: Register = (on, options) => {
346  // With the guard off nothing stops a merge, so the pane may offer one.
347  const isGuarded = (options as { gitGuard?: boolean }).gitGuard !== false
348
349  on('session.start', async ($, e, next) => {
350    await $.command.register({
351      name: 'mod-guide',
352      description: "Open the build guide: pstack and Matt Pocock's flow side by side, under the target map of this repo. Add pstack or pocock to pick the one the Guide tab shows.",
353    })
354    const found = await readEnv($)
355    const saved = await $.store.get(PICKED)
356    await update($, method, m => m ?? (isMethod(saved) ? saved : detect(found)))
357    await registerTools($)
358    await loadSaved($)
359    await readTarget($)
360    // Opens on its own at session start, so the Start tab is the first thing seen.
361    void $.ui.open({ id: PANE, title: TITLE, focus: true })
362    void readRepo($)
363
364    return next(e)
365  })
366
367  on('command.run', { command: 'mod-guide' }, async ($, e) => {
368    const asked = e.args.trim().toLowerCase()
369    if (isMethod(asked)) await pick($, asked)
370    else await readEnv($)
371    await readTarget($)
372    const all = await read($, states)
373    const isBusy = METHODS.some(m => m.isBusy(stateIn(all, m)))
374    await update($, tab, t => (isBusy && t === 'start' ? 'now' : t))
375    await $.ui.open({ id: PANE, title: TITLE })
376    void readRepo($)
377
378    return { text: isMethod(asked) ? `Build guide opened. The Guide tab shows ${asked}.` : 'Build guide opened.' }
379  })
380
381  on('command.run', async ($, e, next) => {
382    const outs = await step($, (m, s) => m.hooks.command?.(s, e.command, e.args))
383    const args = [...outs.values()].map(o => (o as { args?: string }).args).find(a => a !== undefined && a !== e.args) ?? e.args
384    const result = await next(args === e.args ? e : { ...e, args })
385    if (e.command === 'clear') await loadSaved($)
386    return result
387  })
388
389  on('skill.prompt', async ($, e, next) => {
390    const result = await next(e)
391    const outs = await step($, (m, s) => m.hooks.skill?.(s, e.skill, result.text))
392    const said = [...outs.values()].map(o => (o as { text?: string }).text).find(t => t !== undefined && t !== result.text) ?? result.text
393    return said === result.text ? result : { ...result, text: said }
394  })
395
396  // The methods' own tools, served whatever the Guide tab shows.
397  for (const m of METHODS) {
398    for (const t of m.tools ?? []) {
399      on('tool.call', { tool: `mcp__build-guide__${t.name}` }, async ($, e) => {
400        const outs = await step($, (_x, s) => t.run(s, e as unknown as Record<string, unknown>), m)
401        return { result: (outs.get(m.id) as { result?: string } | undefined)?.result ?? '' }
402      })
403    }
404  }
405
406  on('tool.call', async ($, e, next) => {
407    const result = await next(e)
408    if (e.agentId) return result
409    const said = (result as { text?: unknown }).text
410    await step($, (m, s) => m.hooks.tool?.(s, e.tool, e as unknown as Record<string, unknown>, typeof said === 'string' ? said : ''))
411    return result
412  })
413
414  // Runs before the call, for subagents too, so a blocked command never starts.
415  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
416    if (!isGuarded) return next(e)
417    const found = await guardBlock(e.command, async () => (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? '')
418    if (!found) return next(e)
419    $.ui.toast(`Build guide: blocked ${found.what}.`)
420    return { deny: `build-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.` }
421  })
422
423  on('turn.step', async function* ($, e, next) {
424    if ((e as { agentId?: string }).agentId === undefined) {
425      const effort = e.effort === undefined ? '' : String(e.effort)
426      await step($, (m, s) => {
427        const change = m.hooks.step
428        return change ? { s: change(s, e.model, effort) } : undefined
429      })
430    }
431
432    return yield* next(e)
433  })
434
435  // Anything the person sends answers or replaces the open decision. The
436  // methods see the prompt; none of them changes it.
437  on('prompt.submit', async ($, e, next) => {
438    await update($, decision, () => null)
439    await update($, summary, s => (s ? { ...s, isWaiting: false } : s))
440    const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
441    const setup = await read($, env)
442    await step($, (m, s) => m.hooks.prompt?.(s, e.text, isPerson, skillsOf(m, setup) !== 'missing'))
443    return next(e)
444  })
445
446  // The desktop spends the first click on an unfocused pane on focus, so a
447  // button needs two clicks. When a main turn ends the pane asks for the
448  // keyboard; the surface refuses while text is typed.
449  on('turn.complete', async ($, e, next) => {
450    const result = await next(e)
451    if ((e as { agentId?: string }).agentId !== undefined) return result
452    void $.ui.open({ id: PANE, title: TITLE, focus: true })
453    await loadSaved($)
454    await readTarget($)
455    const answer = e.answer.trim()
456    if (e.isAborted || !answer) {
457      await update($, decision, () => null)
458      return result
459    }
460    // A short reply is its own summary, so it costs no model call.
461    if (answer.length < 280 && !looksLikeChoice(answer)) {
462      await update($, decision, () => null)
463      await update($, summary, () => ({ done: answer, next: '', links: linksIn(answer), isWaiting: false }))
464      return result
465    }
466    void (async () => {
467      const ask = await $.model.complete({ model: 'haiku', prompt: decidePrompt(METHODS, answer), timeoutMs: 20000 })
468      const found = ask.isAnswered && looksLikeChoice(answer) ? parseDecision(ask.text) : null
469      const said = ask.isAnswered ? parseSummary(ask.text, answer) : null
470      await update($, decision, () => found)
471      await update($, summary, () => (said ? { ...said, isWaiting: said.isWaiting || found !== null } : null))
472      if (found) $.ui.toast('Build guide: Claude is asking you to choose. The pane has a button for each option.')
473      else if (said?.isWaiting) $.ui.toast('Build guide: Claude is waiting on you. The pane says what for.')
474    })()
475    return result
476  })
477
478  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
479    const pm = await picked($)
480    const all = await read($, states)
481    const current = await read($, tab)
482    const setup = await read($, env)
483    const r = await read($, repo)
484    const ui = $.ui.resolve(e)
485    const { Box, Button, Markdown, Text, Code } = ui
486    const isDesktop = e.surface !== 'terminal' && 'Svg' in ui
487    // Drawn only where the surface has Svg; elsewhere the caller draws text.
488    const picture = (key: string, source: string, alt: string) => {
489      if (!('Svg' in ui)) return <Text key={key}>{alt}</Text>
490      const { Svg } = ui
491      return <Svg key={key} source={source} alt={alt} {...fit(source, e.props.bodyColumns)} />
492    }
493
494    const button = (a: Act, isPrimary = a.isPrimary === true) => (
495      <Button
496        key={a.key}
497        label={a.label}
498        variant={isPrimary ? 'primary' : 'secondary'}
499        onPress={() => {
500          void press($, a)
501        }}
502      />
503    )
504    const row = (acts: Act[]) => (
505      <Box flexDirection="row" flexWrap="wrap" gap={1}>
506        {acts.map(a => button(a))}
507      </Box>
508    )
509    const lines = (key: string, rows: string[], color?: string) => (
510      <Box key={key} flexDirection="column">
511        {rows.map((t, i) => (
512          <Text key={`${key}-${i}`} color={color}>
513            {t}
514          </Text>
515        ))}
516      </Box>
517    )
518
519    // ---------- Band 1: the target strip ----------
520
521    const map = await read($, target)
522    const isOpen = await read($, isTargetOpen)
523    const pr = r.prs.find(p => p.branch !== '' && p.branch === r.branch)
524    const work = [...METHODS.flatMap(m => m.focus?.(stateIn(all, m)) ?? []), pr?.title ?? '', r.branch].filter(Boolean)
525    const refreshMap = <Button key="target-refresh" label="Refresh the map" plain onPress={() => void refreshTarget($)} />
526    const toggle = (
527      <Box key="target-row" flexDirection="row" gap={1}>
528        <Button
529          key="target-toggle"
530          label={isOpen ? 'Hide the full map' : 'Show the full map'}
531          plain
532          onPress={() => {
533            void readTarget($)
534            void update($, isTargetOpen, o => !o)
535          }}
536        />
537        {refreshMap}
538      </Box>
539    )
540    const strip = (() => {
541      if (map.phase === 'unread') return null
542      if (map.phase === 'none')
543        return (
544          <Box key="target" flexDirection="row" flexWrap="wrap" gap={1}>
545            <Text>{EMPTY}</Text>
546            <Button key="start-target-map" label="Start the target map" onPress={() => void $.prompt.fill({ text: map.ask })} />
547          </Box>
548        )
549      if (map.phase === 'missing')
550        return (
551          <Box key="target" flexDirection="column">
552            <Text>{missingText(map.command)}</Text>
553            <Box key="target-row" flexDirection="row" gap={1}>{refreshMap}</Box>
554          </Box>
555        )
556      if (map.phase === 'bad') return <Text key="target">{`The target map file could not be read: ${map.reason}.`}</Text>
557      const s = map.data
558      const matched = crossesOff(s, work)
559      const alt = `${s.goal}: ${s.flags.map(f => `${f.name} ${f.jobs.length ? `${f.jobs.filter(j => j.state !== 'moved').length} left` : (f.note ?? '')}`).join(', ')}`
560      return (
561        <Box key="target" flexDirection="column">
562          {isDesktop
563            ? picture(isOpen ? 'target-map' : 'target-strip', isOpen ? mapSvg(s, Date.now()) : stripSvg(s, matched), alt)
564            : lines('target-lines', isOpen ? mapLines(s, Date.now()) : stripLines(s, matched))}
565          {map.note ? <Text key="target-note" dimColor>{map.note}</Text> : null}
566          {toggle}
567        </Box>
568      )
569    })()
570
571    const go = (id: string) => () => {
572      void update($, tab, () => id)
573      if (id === 'start') void readRepo($)
574    }
575    const page = pm.guide.find(g => g.id === current)
576    const top: { id: string; label: string; key: string; isOn: boolean }[] = [
577      { id: 'start', label: 'Start', key: 'tab-start', isOn: current === 'start' },
578      { id: 'now', label: 'Now', key: 'tab-now', isOn: current === 'now' },
579      { id: page ? current : (pm.guide[0]?.id ?? 'start'), label: 'Guide', key: 'tab-guide', isOn: page !== undefined },
580    ]
581    const nav = (
582      <Box flexDirection="row" flexWrap="wrap" gap={1}>
583        {top.map(t => (
584          <Button key={t.key} label={t.label} variant={t.isOn ? 'primary' : 'secondary'} onPress={go(t.id)} />
585        ))}
586      </Box>
587    )
588
589    const drawCard = (c: Card, extra?: JSX.Element) => (
590      <Box key={c.key} flexDirection="column" gap={1} borderStyle="round" borderColor={c.color} paddingX={1}>
591        <Text bold color={c.color}>
592          {c.title}
593        </Text>
594        <Text>{c.body}</Text>
595        {c.code ? <Code source={c.code} /> : null}
596        {c.note ? <Text dimColor>{c.note}</Text> : null}
597        {c.acts.length > 0 ? row(c.acts) : null}
598        {extra ?? null}
599      </Box>
600    )
601
602    const open = await read($, decision)
603    const said = await read($, summary)
604    const explain = explainOf(setup)
605    // A waiting turn with no options read from it still gets one-press answers.
606    const fallback: Act[] = [
607      { key: 'reply-yes', label: 'Yes, go ahead', text: 'yes, go ahead', isSend: true, isPrimary: true },
608      { key: 'reply-no', label: 'Not yet', text: 'no, hold off for now', isSend: true },
609      { ...explain, key: 'reply-plain' },
610    ]
611    const summaryCard =
612      said === null ? null : (
613        <Box key="summary" flexDirection="column" borderStyle="round" borderColor={said.isWaiting ? '#F56300' : '#D2D2D7'} paddingX={1}>
614          <Text bold color={said.isWaiting ? '#F56300' : undefined}>
615            {said.isWaiting ? 'Needs you' : 'Last turn'}
616          </Text>
617          <Markdown text={[said.done, said.next ? `**Next:** ${said.next}` : '', said.links.map(l => `[PR #${l.split('/').pop()}](${l})`).join(' · ')].filter(Boolean).join('\n\n')} />
618          {said.isWaiting && open === null ? row(fallback) : null}
619        </Box>
620      )
621
622    const choose = (key: string, label: string) => (
623      <Button
624        key={`choose-${key}`}
625        label={label}
626        variant={key === open?.pick ? 'primary' : 'secondary'}
627        onPress={() => {
628          void $.prompt.submit({ text: key, asUser: true })
629        }}
630      />
631    )
632    const decisionCard =
633      open === null ? null : isDesktop ? (
634        <Box key="decision" flexDirection="column" gap={1}>
635          {picture('decision-svg', decisionSvg(open, ASK), `${ASK.title}: ${open.question}`)}
636          <Box flexDirection="row" flexWrap="wrap" gap={1}>
637            {open.options.map(o => choose(o.key, o.key === open.pick ? `★ ${o.key}` : o.key))}
638          </Box>
639        </Box>
640      ) : (
641        <Box key="decision" flexDirection="column" gap={1} borderStyle="round" paddingX={1}>
642          <Text bold>
643            {ASK.title}: {open.question}
644          </Text>
645          {open.pick ? (
646            <Text>
647              {ASK.pick}: {open.options.find(o => o.key === open.pick)?.label ?? open.pick}. {open.why}
648            </Text>
649          ) : open.why ? (
650            <Text dimColor>{open.why}</Text>
651          ) : null}
652          <Box flexDirection="row" flexWrap="wrap" gap={1}>
653            {open.options.map(o => choose(o.key, `${o.key} · ${o.label}${o.tier ? ` (${o.tier})` : ''}`))}
654          </Box>
655        </Box>
656      )
657
658    const tree = await (async () => {
659      if (current === 'start') {
660        // Both methods' starts, one labelled group each. A key carries its
661        // method's id, since both methods name a start-ticket.
662        const own = (m: AnyMethod, a: Act): Act => ({ ...a, key: `${m.id}:${a.key}` })
663        const groups = METHODS.map(m => {
664          const install = installCard(m, setup)
665          const isMissing = skillsOf(m, setup) === 'missing'
666          const needsSetup = m.setup !== undefined && r.isRead && !r.isSetUp && install === null
667          const usable = m.starts.filter(a => !a.needs || setup.plugins.some(k => k.startsWith(a.needs!)))
668          const starts = isMissing ? m.plainStarts : needsSetup && m.setup ? [{ ...m.setup.act, isPrimary: true }, ...usable.map(a => ({ ...a, isPrimary: false }))] : usable
669          return { m, install: install ? { ...install, key: `install-${m.id}` } : null, needsSetup, starts: starts.map(a => own(m, a)) }
670        })
671        // Open work: one row per item. Where the methods' buttons differ, pstack's
672        // set comes first and pocock's after, each labelled; a method whose skills
673        // are missing offers none.
674        const usable = METHODS.filter(m => skillsOf(m, setup) !== 'missing')
675        const offering = usable.length > 0 ? usable : METHODS.slice(0, 1)
676        const perMethod = offering.map(m => ({ m, rows: openWork(r, m, !isGuarded) }))
677        const work = (perMethod[0]?.rows ?? []).map(w => {
678          const sets = perMethod.map(x => ({ m: x.m, acts: x.rows.find(y => y.key === w.key)?.acts ?? [] }))
679          const isSame = sets.every(x => JSON.stringify(x.acts.map(a => a.text)) === JSON.stringify(sets[0]?.acts.map(a => a.text)))
680          return { key: w.key, label: w.label, sets: isSame ? sets.slice(0, 1).map(x => ({ ...x, isLabelled: false })) : sets.map(x => ({ ...x, isLabelled: true })) }
681        })
682        return (
683          <Box flexDirection="column" gap={1}>
684            {nav}
685            {strip}
686            {summaryCard}
687            {decisionCard}
688            {isDesktop && r.isRead && r.name ? (
689              picture('dashboard', startSvg(r), `${r.name} on ${r.branch}: ${r.prs.length} open PRs, ${r.dirty} uncommitted files, ${r.stale.length} stale branches, ${r.worktrees} other worktrees`)
690            ) : (
691              <Text bold>{r.isRead ? (r.name ? `${r.name} · ${r.branch}` : 'No repo') : 'Reading the repo…'}</Text>
692            )}
693            {r.error ? <Text dimColor>{r.error}</Text> : null}
694            {work.length > 0 ? <Text bold>Open work</Text> : null}
695            {work.map(w => (
696              <Box key={w.key} flexDirection="column">
697                <Text>{w.label}</Text>
698                {w.sets.map(x => (
699                  <Box key={`${w.key}-${x.m.id}`} flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
700                    {x.isLabelled ? <Text dimColor>{x.m.label}</Text> : null}
701                    {x.acts.map(a => button(x.isLabelled ? own(x.m, a) : a))}
702                  </Box>
703                ))}
704              </Box>
705            ))}
706            <Text bold>Start something</Text>
707            {groups.map(g => (
708              <Box key={`starts-${g.m.id}`} flexDirection="column" gap={1}>
709                <Text bold color="#6E6E73">
710                  {g.m.label}
711                </Text>
712                {g.install
713                  ? drawCard(
714                      g.install,
715                      <Box flexDirection="row" gap={1}>
716                        <Button key={`check-again-${g.m.id}`} label="Check again" onPress={() => readEnv($)} />
717                      </Box>,
718                    )
719                  : null}
720                {g.needsSetup && g.m.setup ? <Text color="#E5342C">{g.m.setup.warn}</Text> : null}
721                {row(g.starts)}
722                <Text dimColor>{g.m.startNote}</Text>
723              </Box>
724            ))}
725            <Box flexDirection="row" gap={1}>
726              <Button key="refresh" label="Refresh" onPress={() => readRepo($)} />
727            </Box>
728          </Box>
729        )
730      }
731
732      if (current === 'now') {
733        const at = (await read($, stage)).at
734        const shared = at ?? METHODS.map(m => sharedOf(m.id, m.stageOf(stateIn(all, m)))).find(x => x !== null) ?? null
735        // Haiku's lines for the running step, from whichever method asked for them.
736        const would: Record<string, string> = Object.assign({}, ...METHODS.map(m => m.would?.(stateIn(all, m)) ?? {}))
737        const sides: Side[] = METHODS.map(m => {
738          const s = stateIn(all, m)
739          return { id: m.id, label: m.label, own: m.stageOf(s), view: m.column(s, would[m.id]) }
740        })
741        const where = SHARED.find(x => x.id === shared)?.name ?? 'not started'
742        const pair = isDesktop ? (
743          picture('now', pairSvg(shared, sides), `Stage: ${where}. ${sides.map(x => `${x.label}: ${x.view.stage}. ${x.view.would}`).join(' ')}`)
744        ) : (
745          <Box key="now" flexDirection="column" gap={1}>
746            {lines('bar', barLines(sides))}
747            <Box flexDirection="row" gap={1}>
748              {sides.map(x => (
749                <Box key={`col-${x.id}`} flexDirection="column" width={`${Math.floor(100 / sides.length)}%`}>
750                  <Markdown text={columnMd(x)} />
751                </Box>
752              ))}
753            </Box>
754          </Box>
755        )
756
757        // ---------- Band 3: next move ----------
758        const cards = nextCards(METHODS.map(m => ({ id: m.id, label: m.label, move: m.next(stateIn(all, m)) })))
759        const moveCards = (
760          <Box key="next" flexDirection="row" flexWrap="wrap" gap={1}>
761            {cards.map(c => {
762              const missing = c.ids.map(methodOf).filter(m => skillsOf(m, setup) === 'missing')
763              return (
764                <Box key={`next-card-${c.key}`} flexDirection="column" borderStyle="round" borderColor="#1DA851" paddingX={1} flexGrow={1} width={cards.length > 1 ? '48%' : '100%'}>
765                  <Text bold color="#137A3A">
766                    {c.title}
767                  </Text>
768                  <Text bold>{c.move.label}</Text>
769                  <Code source={c.move.text.trim() || ' '} />
770                  {c.move.why ? <Text dimColor>{c.move.why}</Text> : null}
771                  {missing.length > 0 ? (
772                    <Box flexDirection="column">
773                      <Text color="#E5342C">{`${missing.map(m => m.requires.name).join(' and ')} not installed. Run:`}</Text>
774                      <Code source={missing.map(m => m.requires.install).join('\n')} />
775                    </Box>
776                  ) : (
777                    button({ key: `next-${c.key}`, label: 'Put in prompt', text: c.move.text, isPrimary: true })
778                  )}
779                </Box>
780              )
781            })}
782          </Box>
783        )
784        // A finished task list outranks a flow position: its next moves replace the quick replies.
785        const phases = METHODS.map(m => m.phase(stateIn(all, m)))
786        const isDone = phases.includes('done')
787        const replies = isDone ? METHODS.filter((_, i) => phases[i] === 'done').flatMap(m => m.after) : phases.includes('running') ? during(explain) : []
788        const owned = map.phase === 'ok' ? map.data.decisions : []
789        return (
790          <Box flexDirection="column" gap={1}>
791            {nav}
792            {strip}
793            {pair}
794            <Text bold>Next move</Text>
795            {moveCards}
796            {summaryCard}
797            {decisionCard}
798            {replies.length > 0 ? <Text dimColor>{isDone ? 'Steps done. Pick what comes next.' : 'These send at once and wait their turn.'}</Text> : null}
799            {replies.length > 0 ? row(replies) : null}
800            {owned.length > 0 ? <Text bold>Decisions you own</Text> : null}
801            {owned.length > 0 ? lines('owned', owned.map(d => `• ${d.length > 110 ? `${d.slice(0, 109).trimEnd()}…` : d}`)) : null}
802            <Box flexDirection="row" gap={1}>
803              <Button key="reset" label="Start over" onPress={() => startOver($)} />
804            </Box>
805          </Box>
806        )
807      }
808
809      const picker = (
810        <Box flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
811          <Text dimColor>Method</Text>
812          {METHODS.map(x => (
813            <Button
814              key={`pick-${x.id}`}
815              label={x.label}
816              variant={x.id === pm.id ? 'primary' : 'secondary'}
817              onPress={() => {
818                void pick($, x.id)
819              }}
820            />
821          ))}
822        </Box>
823      )
824      const pages = (
825        <Box flexDirection="row" flexWrap="wrap" gap={1}>
826          {pm.guide.map(g => (
827            <Button key={`tab-${g.id}`} label={g.id === current ? `• ${g.label}` : g.label} plain onPress={go(g.id)} />
828          ))}
829        </Box>
830      )
831      const shown = page ?? pm.guide[0]
832      if (!shown) return <Box flexDirection="column">{nav}</Box>
833      const drawing = isDesktop && shown.svg ? picture(`guide-${shown.id}`, shown.svg, shown.alt) : <Markdown text={shown.md} />
834      return (
835        <Box flexDirection="column" gap={1}>
836          {nav}
837          {strip}
838          {picker}
839          {pages}
840          {shown.recipes ? null : drawing}
841          {shown.note ? <Text dimColor>{shown.note}</Text> : null}
842          {(shown.rows ?? []).map((x, i) => (
843            <Box key={`row-${i}`} flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
844              {x.label ? <Text bold>{x.label}</Text> : null}
845              {x.acts.map(a => button(a))}
846            </Box>
847          ))}
848          {shown.recipes ? <Text bold>Recipes: press one to put it in your prompt</Text> : null}
849          {(shown.recipes ?? []).map(x => (
850            <Box key={`recipe-${x.title}`} flexDirection="column">
851              <Text bold>{x.title}</Text>
852              <Code source={x.prompt} />
853              {button({ key: `use-${x.title}`, label: 'Put in prompt', text: x.prompt })}
854            </Box>
855          ))}
856          {shown.recipes ? drawing : null}
857        </Box>
858      )
859    })()
860    pressable.clear()
861    collect(tree)
862    hasKeys = e.props.isFocused
863    return tree
864  })
865
866  // The desktop spends the first click on a pane that lacks the keyboard on
867  // giving it the keyboard, so the press never arrives. That click still moves
868  // the focus ring onto the button, so run the button from there, and drop the
869  // press if one follows.
870  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
871    const moved = await next(e)
872    const run = e.element ? pressable.get(e.element) : undefined
873    if (!hasKeys && e.origin.kind === 'person' && e.element && run && !moved.deny) {
874      ranOnFocus.set(e.element, Date.now())
875      run()
876    }
877    return moved
878  })
879
880  on('ui.press', { requestId: PANE }, async ($, e, next) => {
881    const at = ranOnFocus.get(e.element)
882    if (at !== undefined && Date.now() - at < 1500) {
883      ranOnFocus.delete(e.element)
884      return { element: e.element }
885    }
886    return next(e)
887  })
888}
889
hooks/draw.ts 127 lines
1// Drawing helpers every method's pictures share. Each picture is one SVG string,
2// 460 wide, in Apple's light palette.
3
4export const W = 460
5export const SANS = `-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',Helvetica,Arial,sans-serif`
6export const MONO = `ui-monospace,'SF Mono',Menlo,Consolas,monospace`
7
8// One vivid fill per hue that carries white bold text, and a deeper ink of each
9// for text on a tint. Methods name their steps after these hues.
10export const C = {
11  blue: '#0071E3',
12  indigo: '#5E5CE6',
13  purple: '#AF52DE',
14  orange: '#F56300',
15  green: '#1DA851',
16  teal: '#0A9BC1',
17  pink: '#E8335F',
18  gray: '#8E8E93',
19  red: '#E5342C',
20  slate: '#48484A',
21}
22
23export const INK: Record<string, string> = {
24  [C.blue]: '#0058B0',
25  [C.indigo]: '#3F3DB8',
26  [C.purple]: '#8133A8',
27  [C.orange]: '#B54700',
28  [C.green]: '#137A3A',
29  [C.teal]: '#06708C',
30  [C.pink]: '#B8163F',
31  [C.gray]: '#5B5B60',
32  [C.red]: '#B3261E',
33  [C.slate]: '#1D1D1F',
34}
35
36export const inkOf = (color: string) => INK[color] ?? color
37
38// A chip is a command you type (mono), a playbook (▸, sans) or a name you say (sans).
39export type Item = { label: string; tip: string; kind?: 'cmd' | 'play' | 'name' }
40
41export const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
42
43export const wrap = (s: string, max: number) => {
44  const out: string[] = []
45  let line = ''
46  for (const word of s.split(' ')) {
47    if (line && line.length + word.length + 1 > max) {
48      out.push(line)
49      line = word
50    } else {
51      line = line ? `${line} ${word}` : word
52    }
53  }
54  if (line) out.push(line)
55  return out
56}
57
58export const cut = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s)
59
60export const text = (x: number, y: number, cls: string, s: string, extra = '') => `<text x="${x}" y="${y}" class="${cls}"${extra}>${esc(s)}</text>`
61
62export const lines = (x: number, y: number, cls: string, rows: string[], step: number, extra = '') =>
63  rows.map((row, i) => text(x, y + i * step, cls, row, extra)).join('')
64
65const chipLabel = (it: Item) => (it.kind === 'play' ? `▸ ${it.label}` : it.label)
66const isMono = (it: Item) => !it.kind || it.kind === 'cmd'
67
68export const labelOf = chipLabel
69
70export const chipW = (it: Item) => Math.round(chipLabel(it).length * 12.5 * (isMono(it) ? 0.6 : 0.56) + 20)
71
72export const chip = (x: number, y: number, it: Item, color: string) => {
73  const w = chipW(it)
74  const font = isMono(it) ? ` font-family="${MONO}"` : ''
75  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="${inkOf(color)}" font-size="12.5" font-weight="600"${font}>${esc(chipLabel(it))}</text></g>`
76}
77
78// Chips laid left to right, wrapping at maxX.
79export const chips = (x0: number, y0: number, maxX: number, items: Item[], color: string) => {
80  let x = x0
81  let y = y0
82  let svg = ''
83  for (const it of items) {
84    const w = chipW(it)
85    if (x > x0 && x + w > maxX) {
86      x = x0
87      y += 30
88    }
89    svg += chip(x, y, it, color)
90    x += w + 6
91  }
92  return { svg, bottom: y + 24 }
93}
94
95export const down = (cx: number, y: number) => `<path class="ar" d="M${cx - 6} ${y} l6 7 l6 -7z"/>`
96
97export const 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">
98<style>
99svg{background:#F5F5F7}
100.bg{fill:#F5F5F7}.card{fill:#FFFFFF;filter:url(#lift)}.ink{fill:#1D1D1F}.mut{fill:#6E6E73}.ln{stroke:#D2D2D7}.ar{fill:#AEAEB2}
101text{font-family:${SANS};letter-spacing:-.01em}
102.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}}
103.chip:hover rect{fill-opacity:.24}
104</style>
105<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>
106<rect class="bg" width="${W}" height="${h}"/>
107${body}</svg>`
108
109// A tinted card with a colored title and wrapped body lines; returns its height.
110export const noteCard = (y: number, title: string, body: string, color: string) => {
111  const rows = wrap(body, 60)
112  const h = 34 + rows.length * 16 + 8
113  const svg =
114    `<rect x="16" y="${y}" width="${W - 32}" height="${h}" rx="10" fill="${color}" fill-opacity=".1" stroke="${color}" stroke-opacity=".45"/>` +
115    `<text x="30" y="${y + 22}" font-size="13" font-weight="700" fill="${inkOf(color)}">${esc(title)}</text>` +
116    lines(30, y + 42, 'body ink', rows, 16)
117  return { svg, h }
118}
119
120// The desktop draws an Svg in a frame it cannot size from the markup, so pass
121// the pixel size: the pane's width, guessed at 8px a cell.
122export const fit = (source: string, columns: number) => {
123  const width = Math.round(Math.min(Math.max(columns * 8, 300), 1100))
124  const h = Number(/viewBox="0 0 \d+ ([\d.]+)"/.exec(source)?.[1] ?? 600)
125  return { width, height: Math.round((h * width) / W) }
126}
127
hooks/guard.ts 58 lines
1// Guard rail: no destructive git. Checked before a Bash call runs.
2
3export type Blocked = { what: string; why: string } | null
4
5const MAIN = /^(refs\/heads\/)?(main|master)$/
6
7// One shell segment, tokens after `git` (global -C and -c options dropped).
8const gitArgs = (segment: string) => {
9  const words = segment.trim().split(/\s+/).filter(Boolean)
10  const at = words.findIndex(w => w === 'git')
11  if (at < 0 || words.slice(0, at).some(w => !/^(sudo|command|env|\w+=\S*)$/.test(w))) return null
12  const rest = words.slice(at + 1)
13  while (rest[0] === '-C' || rest[0] === '-c') rest.splice(0, 2)
14  return rest
15}
16
17const pushBlock = (args: string[], branch: string): Blocked => {
18  const flags = args.filter(a => a.startsWith('-'))
19  const [, ...refspecs] = args.filter(a => !a.startsWith('-'))
20  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('+'))) {
21    return { what: 'git push --force', why: 'A force push overwrites work on GitHub that may not exist anywhere else.' }
22  }
23  if (flags.includes('--all') || flags.includes('--mirror')) return { what: 'git push --all', why: 'It pushes every branch, main included.' }
24  const targets = refspecs.length ? refspecs.map(r => (r.includes(':') ? r.split(':').pop() ?? '' : r === 'HEAD' ? branch : r)) : [branch]
25  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.' }
26  return null
27}
28
29const gitBlock = (args: string[], branch: string): Blocked => {
30  const [verb = '', ...rest] = args
31  const flags = rest.filter(a => a.startsWith('-'))
32  const paths = rest.filter(a => !a.startsWith('-'))
33  if (verb === 'push') return pushBlock(rest, branch)
34  if (verb === 'reset' && flags.includes('--hard')) return { what: 'git reset --hard', why: 'It throws away every uncommitted change with no undo.' }
35  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.' }
36  if (verb === 'branch' && (flags.includes('-D') || ((flags.includes('-d') || flags.includes('--delete')) && (flags.includes('-f') || flags.includes('--force'))))) {
37    return { what: 'git branch -D', why: 'It deletes a branch even when its work was never merged.' }
38  }
39  if (verb === 'checkout' && paths.includes('.')) return { what: 'git checkout .', why: 'It throws away every uncommitted change with no undo.' }
40  if (verb === 'restore' && paths.includes('.') && !(flags.includes('--staged') && !flags.includes('--worktree') && !flags.includes('-W'))) {
41    return { what: 'git restore .', why: 'It throws away every uncommitted change with no undo.' }
42  }
43  return null
44}
45
46// Checks every segment of a shell command. `branch` is the current branch, for a
47// bare `git push`; it is only read when a segment needs it.
48export const guardBlock = async (command: string, branch: () => Promise<string>): Promise<Blocked> => {
49  for (const segment of command.split(/&&|\|\||;|\||\n/)) {
50    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.' }
51    const args = gitArgs(segment)
52    if (!args) continue
53    const found = gitBlock(args, args[0] === 'push' ? await branch() : '')
54    if (found) return found
55  }
56  return null
57}
58
hooks/method.ts 169 lines
1// What a method is. A method is data and pure functions over its own state; the
2// pane (register.tsx) does every read, write, call and toast for it. The engine
3// follows `$` only into functions of the file that holds the hooks, so nothing
4// here touches `$`.
5
6import type { Env, MethodId, PullRequest } from '../types'
7
8// A button. It puts `text` in the prompt, or sends it at once when `isSend`
9// (a slash command runs as a command).
10export type Act = {
11  key: string
12  label: string
13  text?: string
14  isSend?: boolean
15  isPrimary?: boolean
16  // A plugin key prefix the button calls; the button hides when it is not installed.
17  needs?: string
18}
19
20// A bordered card: the install warning.
21export type Card = { key: string; title: string; body: string; color: string; code?: string; note?: string; acts: Act[] }
22
23export type GuidePage = {
24  id: string
25  label: string
26  // Drawn on the desktop; `md` stands in for it in the terminal.
27  svg?: string
28  alt: string
29  md: string
30  // Buttons under the picture, one row each, with an optional label in front.
31  note?: string
32  rows?: { label?: string; acts: Act[] }[]
33  recipes?: { title: string; prompt: string }[]
34}
35
36// One method's column on the Now tab, as data; pair.ts draws it.
37export type Row = { label: string; mark: 'done' | 'now' | 'todo' | 'skipped'; tag?: string }
38export type Section = { title: string; body: string; tone?: 'warn' | 'bad' | 'good' }
39export type Column = {
40  // The method's own stage, as it names it: "Build", "Step 4 of 7 · Build".
41  stage: string
42  title: string
43  rows: Row[]
44  sections: Section[]
45  // What the method would have the person do now: Haiku's line, else the stage's own text.
46  would: string
47}
48
49// The one thing the method would do next, and the prompt that does it.
50export type Move = { label: string; text: string; why: string }
51
52// The state a reducer hands back, and the work it asks the pane to do. A method
53// never toasts or adds a note to a prompt: its advice shows in its own column.
54export type Out<S> = {
55  s: S
56  // Read this file, then hand it to `hooks.file`.
57  read?: string
58  // Ask Haiku this, then hand the answer to `hooks.answer` with the key. The pane
59  // adds every method's position and asks for one line per method.
60  ask?: { key: string; prompt: string }
61}
62
63// Where the method's skills come from, so the pane can say when they are missing.
64export type Requires = {
65  name: string
66  // Prefixes of installed_plugins.json keys that count as installed.
67  plugins: string[]
68  // A skill whose SKILL.md in a skills folder also counts, for copies made without a plugin.
69  skill: string
70  // A plugin key that is installed but too old to use, when it is the only copy.
71  outdated?: string
72  outdatedNote?: string
73  install: string
74  removeOld?: string
75}
76
77export type Method<S> = {
78  id: MethodId
79  label: string
80  requires: Requires
81  // Put in front of a work prompt the shared code writes, such as the clean-up one.
82  lead: string
83  starts: Act[]
84  // The starts that still work when the method's skills are missing.
85  plainStarts: Act[]
86  startNote: string
87  // A file the repo must have before the method's skills work, and the button that makes it.
88  setup?: { file: string; act: Act; warn: string }
89  prActs: (pr: PullRequest, canMerge: boolean) => Act[]
90  // The reply that says the last message again in plain words.
91  explain: Act
92  // Quick replies once every step is done.
93  after: Act[]
94  // How the after-turn reader is told what this method favors.
95  decide: { who: string; favors: string }
96  guide: GuidePage[]
97
98  empty: S
99  // Progress kept per repo in the store, so a new session in the repo picks it up.
100  persist?: { rootOf: (s: S) => string; save: (s: S) => unknown; load: (s: S, saved: unknown, root: string) => S }
101
102  // Its own stage now (an entry of its STAGE_MAP list), and the state moved to
103  // another of its stages when the other method moves.
104  stageOf: (s: S) => string | null
105  moveTo: (s: S, stage: string) => S
106  // Where it is, in one line, for the Haiku prompt.
107  brief: (s: S) => string
108  // Haiku's lines for the running step, by method id, once they came back.
109  would?: (s: S) => Record<string, string> | undefined
110  // Words naming the work under way, for matching it to a target map job.
111  focus?: (s: S) => string[]
112  column: (s: S, would: string | undefined) => Column
113  next: (s: S) => Move
114  phase: (s: S) => 'idle' | 'running' | 'done'
115  reset: (s: S) => S
116  isBusy: (s: S) => boolean
117  hooks: {
118    // A slash command the person ran; returns the args to pass on.
119    command?: (s: S, command: string, args: string) => Out<S> & { args: string }
120    // A skill's text as the model will read it; returns the text to pass on.
121    skill?: (s: S, skill: string, text: string) => Out<S> & { text: string }
122    // A main-agent tool call that finished.
123    tool?: (s: S, tool: string, args: Record<string, unknown>, output: string) => Out<S>
124    // A model step of the main agent: the model and effort it ran on.
125    step?: (s: S, model: string, effort: string) => S
126    // A prompt about to be sent. It changes the method's own state and nothing else.
127    prompt?: (s: S, text: string, isPerson: boolean, hasSkills: boolean) => Out<S>
128    file?: (s: S, path: string, content: string | null) => Out<S>
129    answer?: (s: S, key: string, text: string | null) => S
130  }
131  // Tools the model can call, served as mcp__build-guide__<name>.
132  tools?: { name: string; description: string; inputSchema: Record<string, unknown>; run: (s: S, args: Record<string, unknown>) => Out<S> & { result: string } }[]
133}
134
135// Fills the prompt: the person reads it, adds what it asks for, and presses Enter.
136export const fill = (key: string, label: string, text: string): Act => ({ key, label, text })
137// Sends at once.
138export const say = (key: string, label: string, text: string): Act => ({ key, label, text, isSend: true })
139
140// Whether the method's skills are installed, and new enough.
141export const skillsOf = (m: AnyMethod, e: Env): 'ok' | 'missing' | 'outdated' => {
142  if (!e.isRead) return 'ok'
143  const found = e.plugins.filter(k => m.requires.plugins.some(p => k.startsWith(p)))
144  const isCopied = e.copied.includes(m.id)
145  if (!isCopied && found.length === 0) return 'missing'
146  if (!isCopied && m.requires.outdated && found.every(k => k === m.requires.outdated)) return 'outdated'
147  return 'ok'
148}
149
150// Merging cannot be undone, so the button fills the prompt and the check comes first.
151export const mergeAct = (n: number): Act =>
152  fill(
153    `merge-${n}`,
154    'Merge it',
155    `merge pr #${n}. first run gh pr view ${n} --json commits,files and confirm every commit and file is mine and belongs to this change; if any is not, stop and tell me. confirm the checks are green, it merges cleanly and it does what its ticket asked. then merge it the way this repo merges (look at recent merged PRs) and delete the branch. tell me what you merged.`,
156  )
157
158// Quick replies while work is running. They send at once and wait their turn.
159// `explain` is the plain-words reply of a method whose skills are installed.
160export const during = (explain: Act): Act[] => [
161  say('q-continue', 'Continue', 'continue'),
162  say('q-evidence', 'Show me the evidence', 'show me the evidence for what you just did: the real command, output or screenshot.'),
163  { ...explain, key: 'q-plain' },
164  say('q-stop', 'Stop and explain', "stop after this step. tell me where you are, what's left, and anything you need from me."),
165]
166
167// A method whatever its state, for the list the pane draws.
168export type AnyMethod = Method<any>
169
hooks/methods/index.ts 18 lines
1import type { Env, MethodId } from '../../types'
2import type { AnyMethod } from '../method'
3import { pocock } from './pocock'
4import { pstack } from './pstack'
5
6// The Start and Now tabs show these in order. The Guide tab shows the one
7// picked, which starts as the first whose skills are installed, or FALLBACK.
8export const METHODS: AnyMethod[] = [pstack, pocock]
9
10// The beginner's flow: the safer start for someone with nothing installed.
11const FALLBACK: MethodId = 'pocock'
12
13export const methodOf = (id: MethodId | null | undefined): AnyMethod => METHODS.find(m => m.id === id) ?? METHODS.find(m => m.id === FALLBACK)!
14
15export const isMethod = (id: unknown): id is MethodId => METHODS.some(m => m.id === id)
16
17export const detect = (e: Env): MethodId => METHODS.find(m => e.plugins.some(k => m.requires.plugins.some(p => k.startsWith(p))))?.id ?? FALLBACK
18
hooks/pair.ts 177 lines
1// The Now tab's two bands: the shared stage bar with every method's columns
2// under it, and the next-move cards. Methods hand over data (Column, Move);
3// this file draws it, so a third method is one more column.
4
5import type { MethodId, SharedId } from '../types'
6import { C, cut, doc, esc, inkOf, MONO, text, W, wrap } from './draw'
7import type { Column, Move, Row, Section } from './method'
8import { SHARED, sharedIndex, STAGE_MAP } from './stages'
9
10export type Side = { id: MethodId; label: string; own: string | null; view: Column }
11
12const TONE: Record<NonNullable<Section['tone']>, string> = { warn: C.orange, bad: C.red, good: C.green }
13
14const LABEL_W = 60
15
16// ---------- Stage bar ----------
17
18const barSvg = (at: SharedId | null, sides: Side[]) => {
19  const now = sharedIndex(at)
20  const x0 = 16 + LABEL_W
21  const gap = 4
22  const sw = (W - 16 - x0 - gap * (SHARED.length - 1)) / SHARED.length
23  let b = text(16, 34, 'h ink', 'You are here')
24  SHARED.forEach((s, i) => {
25    const x = x0 + i * (sw + gap)
26    const isNow = i === now
27    const isPast = now >= 0 && i < now
28    b += `<rect x="${x}" y="${isNow ? 52 : 54}" width="${sw}" height="${isNow ? 12 : 8}" rx="4" fill="${isNow || isPast ? s.color : '#E5E5EA'}" fill-opacity="${isPast ? 0.4 : 1}"/>`
29    if (isNow) b += `<path d="M${x + sw / 2 - 6} 41 h12 l-6 8 z" fill="${s.color}"/>`
30  })
31  let y = 84
32  for (const side of sides) {
33    b += `<text x="16" y="${y}" font-size="11.5" font-weight="700" class="ink">${esc(side.label)}</text>`
34    let tallest = 1
35    SHARED.forEach((s, i) => {
36      const x = x0 + i * (sw + gap) + sw / 2
37      const names = STAGE_MAP[side.id].filter(e => e.shared === s.id)
38      tallest = Math.max(tallest, names.length)
39      names.forEach((e, k) => {
40        const isOwn = e.stage === side.own
41        const tone = isOwn ? ` fill="${inkOf(s.color)}" font-weight="700"` : ' class="mut"'
42        b += `<text x="${x}" y="${y + k * 13}" text-anchor="middle" font-size="${isOwn ? 11 : 10.5}"${tone}>${esc(e.name)}</text>`
43      })
44    })
45    y += tallest * 13 + 10
46  }
47  return { svg: b, bottom: y - 6 }
48}
49
50// ---------- Columns ----------
51
52const markSvg = (cx: number, cy: number, mark: Row['mark'], color: string) => {
53  if (mark === 'done') return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="${C.green}"/><path d="M${cx - 3.2} ${cy} l2.2 2.4 l4.4 -4.8" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
54  if (mark === 'now') return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="none" stroke="${color}" stroke-width="2.2"/><circle cx="${cx}" cy="${cy}" r="2.8" fill="${color}"/>`
55  if (mark === 'skipped') return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="#fff" stroke="${C.orange}" stroke-width="1.6" stroke-dasharray="2.5 2"/><text x="${cx}" y="${cy + 3.5}" text-anchor="middle" font-size="9" font-weight="800" fill="${inkOf(C.orange)}">!</text>`
56  return `<circle cx="${cx}" cy="${cy}" r="6.5" fill="none" class="ln" stroke-width="1.6"/>`
57}
58
59// One column's content from its top; returns the drawing and where it ends.
60const columnBody = (x: number, y: number, cw: number, side: Side, color: string) => {
61  const v = side.view
62  const chars = Math.floor((cw - 24) / 6.1)
63  let b = ''
64  b += `<text x="${x + 12}" y="${y + 22}" font-size="14" font-weight="700" class="ink">${esc(side.label)}</text>`
65  const pill = cut(v.stage, 22)
66  const pw = Math.round(pill.length * 6 + 16)
67  b += `<rect x="${x + cw - 10 - pw}" y="${y + 9}" width="${pw}" height="20" rx="10" fill="${color}" fill-opacity=".14"/>`
68  b += `<text x="${x + cw - 10 - pw / 2}" y="${y + 23}" text-anchor="middle" font-size="10.5" font-weight="700" fill="${inkOf(color)}">${esc(pill)}</text>`
69  let ty = y + 46
70  for (const line of wrap(v.title, chars - 2).slice(0, 2)) {
71    b += `<text x="${x + 12}" y="${ty}" font-size="12" font-weight="600" class="ink">${esc(line)}</text>`
72    ty += 15
73  }
74  ty += 6
75  for (const r of v.rows) {
76    b += markSvg(x + 20, ty, r.mark, color)
77    const tone = r.mark === 'now' ? ' font-weight="700" class="ink"' : r.mark === 'todo' ? ' class="mut"' : r.mark === 'skipped' ? ` fill="${inkOf(C.orange)}"` : ' class="ink"'
78    b += `<text x="${x + 33}" y="${ty + 4}" font-size="11.5"${tone}>${esc(cut(r.label, chars - 4))}</text>`
79    ty += 18
80    if (r.tag) {
81      b += `<text x="${x + 33}" y="${ty}" font-size="9.5" font-family="${MONO}" class="mut">${esc(cut(r.tag, chars))}</text>`
82      ty += 12
83    }
84  }
85  for (const s of v.sections) {
86    ty += 8
87    const tone = s.tone ? TONE[s.tone] : C.gray
88    b += `<text x="${x + 12}" y="${ty}" font-size="10" font-weight="700" fill="${inkOf(tone)}" letter-spacing=".03em">${esc(s.title.toUpperCase())}</text>`
89    ty += 14
90    for (const line of wrap(s.body, chars)) {
91      b += `<text x="${x + 12}" y="${ty}" font-size="11"${s.tone === 'bad' ? ` fill="${inkOf(C.red)}"` : ' class="ink"'}>${esc(line)}</text>`
92      ty += 14
93    }
94  }
95  return { svg: b, bottom: ty }
96}
97
98const wouldBox = (x: number, y: number, cw: number, side: Side, color: string) => {
99  const rows = wrap(side.view.would, Math.floor((cw - 28) / 5.9))
100  const h = 26 + rows.length * 14 + 6
101  let b = `<rect x="${x + 6}" y="${y}" width="${cw - 12}" height="${h}" rx="9" fill="${color}" fill-opacity=".12"/>`
102  b += `<text x="${x + 14}" y="${y + 17}" font-size="9.5" font-weight="700" fill="${inkOf(color)}" letter-spacing=".03em">${esc(`WHAT ${side.label.toUpperCase()} WOULD DO NOW`)}</text>`
103  rows.forEach((line, i) => {
104    b += `<text x="${x + 14}" y="${y + 33 + i * 14}" font-size="11" class="ink">${esc(line)}</text>`
105  })
106  return { svg: b, h }
107}
108
109// The stage bar, then one column per method side by side, each ending in what
110// that method would do now.
111export const pairSvg = (at: SharedId | null, sides: Side[]) => {
112  const bar = barSvg(at, sides)
113  const color = SHARED[sharedIndex(at)]?.color ?? C.gray
114  const gap = 12
115  const cw = (W - 32 - gap * (sides.length - 1)) / sides.length
116  const top = bar.bottom + 12
117  const bodies = sides.map((s, i) => columnBody(16 + i * (cw + gap), top, cw, s, color))
118  const boxTop = Math.max(...bodies.map(x => x.bottom)) + 10
119  const boxes = sides.map((s, i) => wouldBox(16 + i * (cw + gap), boxTop, cw, s, color))
120  const bottom = boxTop + Math.max(...boxes.map(x => x.h)) + 8
121  let b = bar.svg
122  sides.forEach((_, i) => {
123    b += `<rect class="card" x="${16 + i * (cw + gap)}" y="${top}" width="${cw}" height="${bottom - top}" rx="12"/>`
124  })
125  b += bodies.map(x => x.svg).join('') + boxes.map(x => x.svg).join('')
126  return doc(bottom + 12, b)
127}
128
129// ---------- Terminal ----------
130
131export const barLines = (sides: Side[]) =>
132  sides.map(side => {
133    const cells = SHARED.map(s =>
134      STAGE_MAP[side.id]
135        .filter(e => e.shared === s.id)
136        .map(e => (e.stage === side.own ? `[${e.name}]` : e.name))
137        .join('/'),
138    )
139    return `${side.label.padEnd(7)}${cells.join(' · ')}`
140  })
141
142const MARK: Record<Row['mark'], string> = { done: '[x]', now: '[>]', todo: '[ ]', skipped: '[!]' }
143
144export const columnMd = (side: Side) => {
145  const v = side.view
146  return [
147    `**${side.label}** · ${v.stage}`,
148    `_${v.title}_`,
149    v.rows.map(r => `${MARK[r.mark]} ${r.label}${r.tag ? ` (${r.tag})` : ''}`).join('\n'),
150    ...v.sections.map(s => `**${s.title}:** ${s.body}`),
151    `**What ${side.label} would do now:** ${v.would}`,
152  ]
153    .filter(Boolean)
154    .join('\n\n')
155}
156
157// ---------- Next move ----------
158
159// The command a move runs: its leading slash command, or its whole text.
160const commandOf = (text: string) => {
161  const t = text.trim().toLowerCase()
162  return /^\/\S+/.exec(t)?.[0] ?? t
163}
164
165export const agree = (a: Move, b: Move) => commandOf(a.text) !== '' && commandOf(a.text) === commandOf(b.text)
166
167export type NextCard = { key: string; title: string; move: Move; ids: MethodId[] }
168
169// One card per method, or one wide card when every method proposes the same command.
170export const nextCards = (moves: { id: MethodId; label: string; move: Move }[]): NextCard[] => {
171  const [first] = moves
172  if (first && moves.length > 1 && moves.every(m => agree(m.move, first.move))) {
173    return [{ key: 'both', title: 'Both agree', move: first.move, ids: moves.map(m => m.id) }]
174  }
175  return moves.map(m => ({ key: m.id, title: m.label, move: m.move, ids: [m.id] }))
176}
177
hooks/repo.ts 137 lines
1// The repo at a glance: parsing what git and gh print, and drawing it. The
2// pane runs the commands.
3
4import type { PrStage, PullRequest, Repo } from '../types'
5import { C, cut, doc, esc, inkOf, MONO, text, W } from './draw'
6import { fill, type Act, type AnyMethod } from './method'
7
8export const NO_REPO: Repo = { isRead: false, error: null, name: '', branch: '', dirty: 0, stale: [], worktrees: 0, prs: [], isSetUp: true }
9
10const STALE_DAYS = 14
11
12const ciOf = (checks: { status?: string; conclusion?: string; state?: string }[]): PullRequest['ci'] => {
13  if (checks.length === 0) return 'none'
14  const bad = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED'])
15  if (checks.some(c => bad.has(c.conclusion ?? '') || bad.has(c.state ?? ''))) return 'fail'
16  if (checks.some(c => (c.status && c.status !== 'COMPLETED') || c.state === 'PENDING')) return 'pending'
17  return 'pass'
18}
19
20// Green checks are one of several gates. A PR is ready to merge only when it is
21// out of draft, merges cleanly with main, has passing checks and has no review
22// asking for changes. GitHub reports mergeable as UNKNOWN until it has worked
23// it out, so that waits rather than counting as clean.
24const stageOf = (ci: PullRequest['ci'], isDraft: boolean, mergeable?: string, review?: string): PrStage => {
25  if (mergeable === 'CONFLICTING') return 'conflict'
26  if (ci === 'fail') return 'fail'
27  if (isDraft) return 'draft'
28  if (ci === 'pending') return 'running'
29  if (review === 'CHANGES_REQUESTED') return 'changes'
30  if (mergeable !== 'MERGEABLE') return 'checking'
31  return 'ready'
32}
33
34export const STAGE_TEXT: Record<PrStage, string> = {
35  conflict: 'conflicts with main',
36  fail: 'checks failing',
37  draft: 'draft',
38  running: 'checks running',
39  changes: 'changes requested',
40  checking: 'GitHub still checking',
41  ready: 'ready to merge',
42}
43
44// A worktree entry is a `worktree <path>` line, then `branch refs/heads/<name>`.
45export const treesOf = (porcelain: string) =>
46  porcelain.split('\n\n').map(entry => ({
47    path: /^worktree (.+)$/m.exec(entry)?.[1] ?? '',
48    branch: /^branch refs\/heads\/(.+)$/m.exec(entry)?.[1] ?? '',
49  }))
50
51// Branches merged into the base, or untouched for two weeks. A branch checked
52// out in any worktree is someone's live work, never stale.
53export const staleOf = (heads: string, merged: string, trees: { branch: string }[], base: string, nowSec: number) => {
54  const done = new Set(merged.split('\n').filter(Boolean))
55  const inUse = new Set(trees.map(t => t.branch).filter(Boolean))
56  const cutoff = nowSec - STALE_DAYS * 86400
57  return heads
58    .split('\n')
59    .filter(Boolean)
60    .map(line => {
61      const [name = '', when = '0'] = line.split('|')
62      return { name, when: Number(when) }
63    })
64    .filter(h => h.name !== base && !inUse.has(h.name) && (done.has(h.name) || h.when < cutoff))
65    .map(h => h.name)
66}
67
68export const prsOf = (json: string): PullRequest[] => {
69  type Raw = { number: number; title: string; headRefName?: string; isDraft: boolean; statusCheckRollup?: { status?: string; conclusion?: string; state?: string }[]; mergeable?: string; reviewDecision?: string }
70  return (JSON.parse(json) as Raw[]).map(p => {
71    const ci = ciOf(p.statusCheckRollup ?? [])
72    return { number: p.number, title: p.title, branch: p.headRefName ?? '', isDraft: p.isDraft, ci, stage: stageOf(ci, p.isDraft, p.mergeable, p.reviewDecision) }
73  })
74}
75
76// The Start tab's header: the repo at a glance, one tile per kind of open work.
77export const startSvg = (r: Repo) => {
78  const blocked = r.prs.filter(p => p.stage === 'fail' || p.stage === 'conflict').length
79  const ready = r.prs.filter(p => p.stage === 'ready').length
80  const running = r.prs.filter(p => p.ci === 'pending').length
81  const tiles: { n: number; label: string; note: string; color: string }[] = [
82    {
83      n: r.prs.length,
84      label: r.prs.length === 1 ? 'Open PR' : 'Open PRs',
85      note: r.prs.length === 0 ? 'none open' : blocked ? `${blocked} blocked` : ready ? `${ready} ready to merge` : running ? `${running} running` : 'in progress',
86      color: r.prs.length === 0 ? C.gray : blocked ? C.red : running ? C.orange : C.green,
87    },
88    { n: r.dirty, label: 'Uncommitted', note: r.dirty ? 'files changed' : 'clean', color: r.dirty ? C.orange : C.green },
89    { n: r.stale.length, label: 'Stale', note: r.stale.length ? 'branches' : 'none', color: r.stale.length ? C.orange : C.green },
90    { n: r.worktrees, label: 'Worktrees', note: r.worktrees ? 'others open' : 'just this one', color: r.worktrees ? C.blue : C.gray },
91  ]
92  const isClean = r.prs.length === 0 && r.dirty === 0 && r.stale.length === 0 && r.worktrees === 0
93  let b = ''
94  b += text(20, 36, 'h ink', cut(r.name || 'No repo', 26))
95  const bw = Math.min(r.branch.length * 7 + 24, 210)
96  b += `<rect x="${W - 20 - bw}" y="18" width="${bw}" height="24" rx="12" fill="${C.slate}" fill-opacity=".08"/>`
97  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>`
98  b += text(20, 58, 'sub mut', isClean ? 'Nothing open. You can start something new.' : 'Finish what is open before starting something new.')
99  const tw = (W - 32 - 3 * 8) / 4
100  tiles.forEach((t, i) => {
101    const x = 16 + i * (tw + 8)
102    b += `<rect class="card" x="${x}" y="74" width="${tw}" height="92" rx="12"/>`
103    b += `<rect x="${x + 14}" y="86" width="22" height="4" rx="2" fill="${t.color}"/>`
104    b += `<text x="${x + 14}" y="118" font-size="32" font-weight="700" fill="${t.n ? inkOf(t.color) : '#AEAEB2'}" letter-spacing="-.02em">${t.n}</text>`
105    b += `<text x="${x + 14}" y="140" font-size="12" font-weight="600" class="ink">${esc(t.label)}</text>`
106    b += `<circle cx="${x + 18}" cy="154" r="3.5" fill="${t.color}"/>`
107    b += `<text x="${x + 26}" y="158" font-size="11" class="mut">${esc(t.note)}</text>`
108  })
109  return doc(184, b)
110}
111
112// One row per piece of open work, each with the buttons that move it on.
113export const openWork = (r: Repo, m: AnyMethod, canMerge: boolean): { key: string; label: string; acts: Act[] }[] => [
114  ...r.prs.map(pr => ({
115    key: `pr-${pr.number}`,
116    label: `PR #${pr.number} · ${STAGE_TEXT[pr.stage]} · ${cut(pr.title, 48)}`,
117    acts: m.prActs(pr, canMerge),
118  })),
119  ...(r.dirty > 0
120    ? [{
121        key: 'dirty',
122        label: `${r.dirty} uncommitted ${r.dirty === 1 ? 'file' : 'files'} on ${r.branch}`,
123        acts: [fill('look-dirty', 'Look at them', "show me the uncommitted changes in this repo in plain words: what they are, and whether they look like mine or another session's. don't change anything.")],
124      }]
125    : []),
126  ...(r.stale.length > 0 || r.worktrees > 0
127    ? [{
128        key: 'stale',
129        label: [
130          r.stale.length > 0 ? `${r.stale.length} stale ${r.stale.length === 1 ? 'branch' : 'branches'}` : '',
131          r.worktrees > 0 ? `${r.worktrees} other ${r.worktrees === 1 ? 'worktree' : 'worktrees'} (another session may be in ${r.worktrees === 1 ? 'it' : 'them'})` : '',
132        ].filter(Boolean).join(', '),
133        acts: [fill('clean-up', 'Clean up', `${m.lead}list the stale branches, worktrees and open pull requests in this repo and give each a keep, merge or delete call with a reason, in plain words. don't delete, close, merge, push or switch branches until I say yes.`)],
134      }]
135    : []),
136]
137
hooks/stages.ts 36 lines
1// The stage map: each method's own stages, in order, and the shared stage each
2// one sits under. The shared stages are pstack's six. A new method adds its
3// entries here and its object in methods/.
4
5import type { MethodId, SharedId } from '../types'
6import { C } from './draw'
7
8export const SHARED: { id: SharedId; name: string; color: string }[] = [
9  { id: 'understand', name: 'Understand', color: C.blue },
10  { id: 'design', name: 'Design', color: C.indigo },
11  { id: 'build', name: 'Build', color: C.orange },
12  { id: 'verify', name: 'Verify', color: C.green },
13  { id: 'ship', name: 'Ship', color: C.teal },
14  { id: 'overnight', name: 'Overnight', color: C.pink },
15]
16
17export const STAGE_MAP: Record<MethodId, { stage: string; name: string; shared: SharedId }[]> = {
18  pstack: SHARED.map(s => ({ stage: s.id, name: s.name, shared: s.id })),
19  pocock: [
20    { stage: 'grill', name: 'Grill', shared: 'understand' },
21    { stage: 'spec', name: 'Spec', shared: 'design' },
22    { stage: 'tickets', name: 'Tickets', shared: 'design' },
23    { stage: 'build', name: 'Build', shared: 'build' },
24    { stage: 'review', name: 'Review', shared: 'verify' },
25    { stage: 'pr', name: 'PR', shared: 'ship' },
26    { stage: 'retro', name: 'Retro', shared: 'overnight' },
27  ],
28}
29
30export const sharedOf = (m: MethodId, stage: string | null): SharedId | null => (stage === null ? null : (STAGE_MAP[m].find(e => e.stage === stage)?.shared ?? null))
31
32// The method's first own stage under a shared one: entering Design moves pocock to Spec.
33export const ownOf = (m: MethodId, shared: SharedId): string | null => STAGE_MAP[m].find(e => e.shared === shared)?.stage ?? null
34
35export const sharedIndex = (id: SharedId | null) => SHARED.findIndex(s => s.id === id)
36
hooks/target.ts 339 lines
1// The target strip: the session repo's target map at a glance, above every tab.
2// The repo keeps targets/<product>.json and the check script writes
3// ~/.claude/target-map/<product>.status.json; this file finds, parses and draws it.
4// statusSvg, effect, ago and the dot are copied from matt-mods/target-map
5// (hooks/register.tsx) so this public repo does not depend on a private one.
6
7import type { Flag, Job, Road, Status, Target } from '../types'
8import { C, cut, esc, inkOf, SANS, W, wrap } from './draw'
9
10export const STATUS_DIR = '.claude/target-map'
11export const SCRIPT = 'bin/target-map-status.mjs'
12// The target-map mod as matt-mods installs it, which ships the check script.
13export const TARGET_MAP_PLUGIN = 'target-map@matt-mods'
14export const SCRIPT_UNKNOWN = '~/.claude/plugins/cache/matt-mods/target-map/<version>/bin/target-map-status.mjs'
15
16// ---------- Which map: the per-repo lookup, copied from matt-mods/target-map (hooks/lookup.ts) ----------
17
18// The product is the targets file's name. With several, the first by name.
19export const pickTargets = (names: string[]) => {
20  const files = names.filter(n => n.endsWith('.json')).sort()
21  return { file: files[0] ?? null, others: files.slice(1) }
22}
23
24export const productOf = (file: string) => file.replace(/\.json$/, '')
25
26// A product name for a repo with no targets file yet: "BLDG OS" is bldg-os.
27export const slug = (name: string) =>
28  name
29    .toLowerCase()
30    .replace(/[^a-z0-9]+/g, '-')
31    .replace(/^-+|-+$/g, '') || 'product'
32
33export const startPrompt = (product: string) =>
34  `Start a target map for this repo: ask me what finished means for this product and which tools or outcomes measure it, then write targets/${product}.json in the shape of BLDG OS's targets/bldg-os.json and run the target-map check.`
35
36export const EMPTY = 'No target map for this project yet.'
37
38const q = (s: string) => (/^[\w./~<>-]+$/.test(s) ? s : `"${s}"`)
39
40export const commandFor = (script: string, root: string) => `node ${q(script)} --repo ${q(root)}`
41
42export const missingText = (command: string) => `No target map status yet. Run ${command}`
43
44export const severalText = (file: string, others: string[]) =>
45  others.length ? `Several targets files: showing ${file}, not ${others.join(', ')}.` : ''
46
47// The script's path in the plugin cache, from installed_plugins.json.
48export const scriptFrom = (installed: string) => {
49  try {
50    const raw = JSON.parse(installed) as { plugins?: Record<string, { installPath?: string }[]> }
51    const path = raw.plugins?.[TARGET_MAP_PLUGIN]?.[0]?.installPath
52    return path ? `${path}/${SCRIPT}` : null
53  } catch {
54    return null
55  }
56}
57
58export const parseStatus = (text: string, note = ''): Target => {
59  try {
60    const data = JSON.parse(text) as Status
61    if (!Array.isArray(data.flags)) return { phase: 'bad', reason: 'it has no flags list' }
62    return { phase: 'ok', data: { ...data, roads: data.roads ?? [], decisions: data.decisions ?? [] }, note }
63  } catch (err) {
64    return { phase: 'bad', reason: err instanceof Error ? err.message : String(err) }
65  }
66}
67
68export const left = (f: Flag) => f.jobs.filter(j => j.state !== 'moved').length
69
70// A flag with no jobs never says "0 left": it has nothing to count yet.
71export const leftText = (f: Flag) => (f.jobs.length === 0 ? '' : left(f) === 0 ? 'all moved' : `${left(f)} left`)
72
73const allJobs = (s: Status) => s.flags.flatMap(f => f.jobs)
74
75export const stuckOf = (s: Status) => allJobs(s).filter(j => j.stuck)
76
77// ---------- Does the work under way cross a job off? ----------
78
79const STOP = new Set(['and', 'the', 'for', 'off', 'with', 'from', 'into', 'one', 'all', 'per', 'its', 'our', 'that', 'this', 'not', 'new'])
80
81// Lowercase words of three letters or more, with a plural s dropped, so
82// "Daily reports" meets "add-daily-report".
83export const wordsOf = (s: string) =>
84  s
85    .toLowerCase()
86    .split(/[^a-z0-9]+/)
87    .filter(w => w.length >= 3 && !STOP.has(w))
88    .map(w => (w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w))
89
90// A job not yet moved matches when its id appears in the texts, or every word
91// of its short name or its full name does. Texts are the current task, the
92// playbook, the PR title and the branch.
93export const crossesOff = (s: Status, texts: string[]): Job[] => {
94  const said = texts.join(' ')
95  const have = new Set(wordsOf(said))
96  return allJobs(s).filter(j => {
97    if (j.state === 'moved') return false
98    if (new RegExp(`(^|[^a-z0-9])${j.id.replace(/[^\w]/g, '')}([^a-z0-9]|$)`, 'i').test(said)) return true
99    return [j.short, j.name].some(label => {
100      const need = wordsOf(label)
101      return need.length > 0 && need.every(w => have.has(w))
102    })
103  })
104}
105
106const pillText = (jobs: Job[]) => `Crosses off ${jobs.map(j => `${j.id} ${j.short}`).join(', ')}`
107
108const stuckText = (jobs: Job[]) => `Stuck: ${jobs.map(j => `${j.id} ${j.short}, since ${j.since}`).join('; ')}`
109
110// ---------- The strip ----------
111
112const DOT_X = 150
113const DOT_STEP = 17
114
115const dot = (x: number, y: number, j: Job, isMatched = false) => {
116  const body =
117    j.state === 'moved'
118      ? `<circle cx="${x}" cy="${y}" r="5.5" fill="${C.green}"/>`
119      : j.state === 'gray'
120        ? `<circle cx="${x}" cy="${y}" r="5.5" fill="#fff" stroke="${C.gray}" stroke-width="1.5" stroke-dasharray="2 2"/>`
121        : `<circle cx="${x}" cy="${y}" r="5.5" fill="#fff" stroke="${C.gray}" stroke-width="1.6"/>`
122  const stuck = j.stuck ? `<circle cx="${x}" cy="${y}" r="8.5" fill="none" stroke="${C.red}" stroke-width="1.8"/>` : ''
123  const match = isMatched ? `<circle cx="${x}" cy="${y}" r="8.5" fill="none" stroke="${C.indigo}" stroke-width="1.8"/>` : ''
124  return `<g><title>${esc(`${j.id} ${j.name}`)}</title>${stuck}${match}${body}</g>`
125}
126
127const style = `<style>
128svg{background:#F5F5F7}
129.card{fill:#FFFFFF}.ink{fill:#1D1D1F}.mut{fill:#6E6E73}.ok{fill:${C.green}}
130text{font-family:${SANS};letter-spacing:-.01em}
131.h{font-size:20px;font-weight:700;letter-spacing:-.02em}.sub{font-size:12.5px}.lab{font-size:13.5px;font-weight:700}.sm{font-size:12px}.tiny{font-size:9.5px}
132</style>`
133
134const svgDoc = (h: number, body: string) =>
135  `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 ${W} ${h}" preserveAspectRatio="xMidYMin meet">
136${style}
137<rect width="${W}" height="${h}" fill="#F5F5F7"/>
138${body}</svg>`
139
140// One row per flag: its name, a dot per job and how many are left. A flag with
141// no jobs shows its note. Under the rows, the stuck line and the crosses-off pill.
142export const stripSvg = (s: Status, matched: Job[]) => {
143  const ids = new Set(matched.map(j => j.id))
144  const stuck = stuckOf(s)
145  let b = ''
146  let y = 14
147  const top = y
148  y += 22
149  b += `<text x="28" y="${y}" font-size="11" font-weight="700" fill="${inkOf(C.indigo)}" letter-spacing=".04em">TARGET</text>`
150  b += `<text x="82" y="${y}" class="sm ink" font-weight="600">${esc(cut(s.goal || s.product, 52))}</text>`
151  y += 10
152  for (const f of s.flags) {
153    y += 22
154    b += `<text x="28" y="${y + 4}" class="sm ink" font-weight="600">${esc(cut(f.name, 17))}</text>`
155    if (f.jobs.length === 0) {
156      b += `<text x="${DOT_X - 6}" y="${y + 4}" class="sm mut">${esc(cut(f.note ?? 'No jobs listed yet.', 44))}</text>`
157      continue
158    }
159    const room = Math.floor((W - 28 - 70 - DOT_X) / DOT_STEP) + 1
160    f.jobs.slice(0, room).forEach((j, i) => {
161      b += dot(DOT_X + i * DOT_STEP, y, j, ids.has(j.id))
162    })
163    b += `<text x="${W - 28}" y="${y + 4}" text-anchor="end" class="sm ${left(f) === 0 ? 'ok' : 'ink'}" font-weight="700">${esc(leftText(f))}</text>`
164  }
165  y += 14
166  if (stuck.length > 0) {
167    y += 16
168    b += `<circle cx="32" cy="${y - 4}" r="3.5" fill="${C.red}"/>`
169    b += `<text x="42" y="${y}" class="sm" fill="${inkOf(C.red)}" font-weight="600">${esc(cut(stuckText(stuck), 62))}</text>`
170    y += 8
171  }
172  if (matched.length > 0) {
173    y += 8
174    const label = cut(pillText(matched), 60)
175    const w = Math.min(W - 56, Math.round(label.length * 6.4 + 24))
176    b += `<rect x="28" y="${y}" width="${w}" height="24" rx="12" fill="${C.indigo}" fill-opacity=".13"/>`
177    b += `<text x="40" y="${y + 16}" font-size="12" font-weight="700" fill="${inkOf(C.indigo)}">${esc(label)}</text>`
178    y += 32
179  }
180  const card = `<rect x="16" y="${top}" width="${W - 32}" height="${y - top}" rx="12" class="card"/>`
181  return svgDoc(y + 10, card + b)
182}
183
184const mark = (j: Job) => (j.state === 'moved' ? '●' : j.state === 'gray' ? '◌' : '○') + (j.stuck ? '!' : '')
185
186// The strip as text lines, for the terminal.
187export const stripLines = (s: Status, matched: Job[]) => [
188  `Target: ${s.goal || s.product}`,
189  ...s.flags.map(f => (f.jobs.length === 0 ? `${f.name}: ${f.note ?? 'no jobs listed yet'}` : `${f.name}  ${f.jobs.map(mark).join('')}  ${leftText(f)}`)),
190  ...(stuckOf(s).length > 0 ? [stuckText(stuckOf(s))] : []),
191  ...(matched.length > 0 ? [pillText(matched)] : []),
192]
193
194// ---------- The full map, copied from target-map ----------
195
196const ids = (v: string[] | string | undefined): string[] => (Array.isArray(v) ? v : (v ?? '').split(/[\s,]+/).filter(Boolean))
197
198export const ago = (iso: string, now: number) => {
199  const t = Date.parse(iso)
200  if (Number.isNaN(t)) return 'at an unknown time'
201  const min = Math.max(0, Math.round((now - t) / 60000))
202  if (min < 1) return 'just now'
203  if (min < 60) return `${min} min ago`
204  const hr = Math.round(min / 60)
205  if (hr < 48) return `${hr} hour${hr === 1 ? '' : 's'} ago`
206  return `${Math.round(hr / 24)} days ago`
207}
208
209// Before and after counts for one road: it takes off the jobs it crosses off
210// that are not yet moved.
211export const effect = (flags: Flag[], road: Road) => {
212  const crossed = new Set(ids(road.crossesOff))
213  return flags
214    .filter(f => f.jobs.length > 0)
215    .map(f => {
216      const before = left(f)
217      const after = before - f.jobs.filter(j => crossed.has(j.id) && j.state !== 'moved').length
218      return { flag: f.name, before, after }
219    })
220}
221
222const effectText = (flags: Flag[], road: Road) =>
223  effect(flags, road)
224    .map(x => (x.before === x.after ? `${x.flag} no change` : `${x.flag} ${x.before} → ${x.after}`))
225    .join(', ')
226
227const MAP = { green: C.green, red: C.red, gray: C.gray, ink: '#1D1D1F', ln: '#D2D2D7', blue: C.blue, purple: C.indigo }
228
229const mapDot = (x: number, y: number, j: Job) => {
230  const body =
231    j.state === 'moved'
232      ? `<circle cx="${x}" cy="${y}" r="7" fill="${MAP.green}"/>`
233      : j.state === 'gray'
234        ? `<circle cx="${x}" cy="${y}" r="7" fill="#fff" stroke="${MAP.gray}" stroke-width="1.8" stroke-dasharray="2.5 2.5"/>`
235        : `<circle cx="${x}" cy="${y}" r="7" fill="#fff" stroke="${MAP.gray}" stroke-width="2"/>`
236  const stuck = j.stuck ? `<circle cx="${x}" cy="${y}" r="11.5" fill="none" stroke="${MAP.red}" stroke-width="2.2"/>` : ''
237  return `<g>${stuck}${body}</g>`
238}
239
240const dotX = (i: number, n: number) => (n <= 1 ? W / 2 : 40 + (i * (W - 80)) / (n - 1))
241
242const jobLabel = (x: number, y: number, s: string, above: boolean) => {
243  const rows = wrap(s, 14).slice(0, 2)
244  const top = above ? y - 21 - (rows.length - 1) * 11 : y + 28
245  return rows.map((r, i) => `<text x="${x}" y="${top + i * 11}" text-anchor="middle" class="tiny mut">${esc(r)}</text>`).join('')
246}
247
248// The whole map: every flag with its jobs, the roads under them, the stuck jobs
249// and the decisions you own.
250export const mapSvg = (s: Status, now: number) => {
251  let y = 0
252  let b = ''
253  const goal = wrap(s.goal || s.product, 34)
254  b += goal.map((g, i) => `<text x="16" y="${34 + i * 24}" class="h ink">${esc(g)}</text>`).join('')
255  y = 34 + (goal.length - 1) * 24 + 20
256  b += `<text x="16" y="${y}" class="sub mut">target set ${esc(s.targetSetOn)} · checked ${esc(ago(s.checkedAt, now))}</text>`
257  y += 18
258
259  let roadsBg = ''
260  for (const f of s.flags) {
261    const n = f.jobs.length
262    const roads = s.roads.filter(r => f.jobs.some(j => j.id === r.from))
263    const rowH = n === 0 ? 74 : 144
264    b += `<rect x="12" y="${y}" width="${W - 24}" height="${rowH}" rx="12" class="card"/>`
265    b += `<text x="26" y="${y + 26}" class="lab ink">${esc(f.name)}</text>`
266    b +=
267      n === 0
268        ? `<text x="${W - 26}" y="${y + 26}" text-anchor="end" class="lab" fill="${MAP.gray}">add tools to start</text>`
269        : `<text x="${W - 26}" y="${y + 26}" text-anchor="end" class="lab ${left(f) === 0 ? 'ok' : 'ink'}">${left(f)} job${left(f) === 1 ? '' : 's'} left</text>`
270    if (n === 0) {
271      b += wrap(f.note ?? 'No jobs listed yet.', 62)
272        .slice(0, 2)
273        .map((r, i) => `<text x="26" y="${y + 48 + i * 15}" class="sm mut">${esc(r)}</text>`)
274        .join('')
275    } else {
276      const ly = y + 88
277      b += `<line x1="${dotX(0, n)}" y1="${ly}" x2="${dotX(n - 1, n)}" y2="${ly}" stroke="${MAP.ln}" stroke-width="2"/>`
278      f.jobs.forEach((j, i) => {
279        const x = dotX(i, n)
280        b += jobLabel(x, ly, `${j.id} ${j.short}`, i % 2 === 0)
281        b += mapDot(x, ly, j)
282      })
283      // Roads leave from their dot, drawn dashed behind the boxes that follow.
284      let off = 0
285      roads.forEach(r => {
286        const i = f.jobs.findIndex(j => j.id === r.from)
287        const x = dotX(i, n)
288        const stub = ids(r.crossesOff).length === 0
289        if (stub) off += 24
290        const boxTop = y + rowH + 14 + off
291        off += 72
292        const endY = stub ? boxTop - 22 : boxTop
293        roadsBg += `<path d="M${x} ${ly + 12} V${endY}" fill="none" stroke="${MAP.purple}" stroke-width="2" stroke-dasharray="5 4"/>`
294        if (stub) roadsBg += `<circle cx="${x}" cy="${endY}" r="3.5" fill="#fff" stroke="${MAP.red}" stroke-width="1.6"/>`
295      })
296    }
297    y += rowH
298    roads.forEach(r => {
299      if (ids(r.crossesOff).length === 0) y += 24
300      const crossed = ids(r.crossesOff)
301      const unlocks = ids(r.unlocks)
302      b += `<rect x="12" y="${y + 14}" width="${W - 24}" height="62" rx="10" fill="#fff" stroke="${MAP.purple}" stroke-width="1.6" stroke-dasharray="5 4"/>`
303      b += `<text x="26" y="${y + 34}" class="lab" fill="${MAP.purple}">Road ${esc(r.id)}: ${esc(wrap(r.name, 42)[0] ?? '')}</text>`
304      b += `<text x="26" y="${y + 51}" class="sm ink" ${crossed.length === 0 ? `fill="${MAP.red}"` : ''}>${crossed.length === 0 ? 'crosses off nothing' : `crosses off ${esc(crossed.join(', '))}`}, unlocks ${esc(unlocks.join(', ') || 'nothing')}</text>`
305      b += `<text x="26" y="${y + 67}" class="sm mut">${esc(effectText(s.flags, r))}</text>`
306      y += 72
307    })
308    y += 14
309  }
310
311  const stuck = stuckOf(s)
312  const stuckRows = stuck.flatMap(j => wrap(`${j.id} ${j.name}, since ${j.since}`, 56))
313  const sh = 14 + Math.max(1, stuckRows.length) * 16 + 8
314  b += `<rect x="12" y="${y}" width="${W - 24}" height="${sh}" rx="10" fill="${MAP.red}" fill-opacity="${stuck.length ? '.1' : '0.04'}"/>`
315  b += `<text x="26" y="${y + 20}" class="sm" font-weight="700" fill="${stuck.length ? MAP.red : MAP.gray}">STUCK</text>`
316  b += stuck.length ? stuckRows.map((r, i) => `<text x="76" y="${y + 20 + i * 16}" class="sm ink">${esc(r)}</text>`).join('') : `<text x="76" y="${y + 20}" class="sm mut">nothing stuck</text>`
317  y += sh + 14
318
319  const dec = s.decisions.flatMap((d, i) => wrap(d, 56).map((r, k) => ({ r, first: k === 0, i })))
320  const dh = 44 + Math.max(1, dec.length) * 16
321  b += `<rect x="12" y="${y}" width="${W - 24}" height="${dh}" rx="12" class="card"/>`
322  b += `<text x="26" y="${y + 24}" class="lab ink">Decisions you own</text>`
323  b += dec.length
324    ? dec.map((d, i) => `${d.first ? `<circle cx="32" cy="${y + 44 + i * 16}" r="2.5" fill="${MAP.blue}"/>` : ''}<text x="42" y="${y + 48 + i * 16}" class="sm ink">${esc(d.r)}</text>`).join('')
325    : `<text x="26" y="${y + 48}" class="sm mut">none open</text>`
326  y += dh + 16
327
328  return svgDoc(y, roadsBg + b)
329}
330
331// The full map as text lines, for the terminal.
332export const mapLines = (s: Status, now: number) => [
333  s.goal,
334  `target set ${s.targetSetOn} · checked ${ago(s.checkedAt, now)}`,
335  ...s.flags.map(f => (f.jobs.length === 0 ? `${f.name}: ${f.note ?? 'add tools to start'}` : `${f.name}: ${leftText(f)}. ${f.jobs.map(j => `${mark(j)} ${j.id} ${j.short}`).join('  ')}`)),
336  `STUCK ${stuckOf(s).map(j => `${j.id} since ${j.since}`).join('; ') || 'none'}`,
337  ...s.roads.map(r => `Road ${r.id} ${r.name}: crosses off ${ids(r.crossesOff).join(', ') || 'nothing'}, unlocks ${ids(r.unlocks).join(', ') || 'nothing'}. ${effectText(s.flags, r)}`),
338]
339
hooks/turn.ts 98 lines
1// After a main turn: a summary of what it did, and a decision card when it ends
2// by asking the person to choose.
3
4import type { Choice, Decision, Summary } from '../types'
5import { C, cut, doc, esc, inkOf, lines, MONO, W, wrap } from './draw'
6import type { AnyMethod } from './method'
7
8// Cheap gate before any model call: the reply's tail asks something, or lists
9// lettered or numbered options.
10export const looksLikeChoice = (answer: string) => {
11  const tail = answer.slice(-1500)
12  const listed = (tail.match(/^\s*(?:\*\*)?([A-H]|[1-9])[.):](?:\*\*)?\s+\S/gm) ?? []).length
13  return (
14    listed >= 2 ||
15    /\?\s*$/.test(answer.trim()) ||
16    /\b(reply with|which (one|option)|should I|do you want|want me to|pick one|say the word|say yes|when you say|confirm|go ahead|let me know|tell me (which|if|when))\b/i.test(tail)
17  )
18}
19
20// Every method's voice at once: no method leads the decision.
21export const ASK = { title: 'Decision waiting', pick: 'suggested pick' }
22
23export const decidePrompt = (methods: AnyMethod[], answer: string) =>
24  `An agent finished a turn. The user follows ${methods.map(m => m.decide.who).join(' and ')}. Summarize the turn in short everyday words for the user, and if it ends by asking the user to make a choice, extract it. ` +
25  `The methods favor: ${methods.map(m => m.decide.favors).join(' Also: ')}\n\n` +
26  'Reply with JSON only, no prose, in this shape:\n' +
27  '{"done":"<what this turn did or found, one or two plain sentences>","next":"<what happens next or what the user should do, one sentence>","isWaiting":<true if the agent stopped and needs the user to answer or act>,"isChoice":true,"question":"<one short line>","options":[{"key":"<what the user would type, e.g. B or yes>","label":"<3 to 6 words>","tier":"<model and effort if the reply gave one, else empty>"}],"pick":"<key the methods favor>","why":"<one plain sentence>"}\n' +
28  'A request to confirm before acting ("when you say yes I will...", "shall I go ahead") is a yes or no choice: use keys "yes" and "no", and add a third option when the reply names one. 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' +
29  `The turn's final reply:\n${answer.slice(-6000)}`
30
31export const PR_LINK = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g
32
33export const linksIn = (answer: string) => [...new Set(answer.match(PR_LINK) ?? [])].slice(0, 4)
34
35const jsonOf = (s: string) => JSON.parse(s.slice(s.indexOf('{'), s.lastIndexOf('}') + 1)) as Record<string, unknown>
36
37export const parseSummary = (said: string, answer: string): Summary => {
38  try {
39    const raw = jsonOf(said)
40    if (!raw.done) return null
41    return { done: String(raw.done), next: String(raw.next ?? ''), links: linksIn(answer), isWaiting: raw.isWaiting === true }
42  } catch {
43    return null
44  }
45}
46
47export const parseDecision = (said: string): Decision => {
48  try {
49    const raw = jsonOf(said)
50    const options: Choice[] = ((raw.options ?? []) as Partial<Choice>[])
51      .filter(o => o.key && o.label)
52      .slice(0, 6)
53      .map(o => ({ key: String(o.key), label: String(o.label), tier: String(o.tier ?? '') }))
54    if (!raw.isChoice || options.length === 0) return null
55    const pick = options.some(o => o.key === raw.pick) ? String(raw.pick) : ''
56    return { question: String(raw.question ?? 'Your call'), options, pick, why: String(raw.why ?? '') }
57  } catch {
58    return null
59  }
60}
61
62const tierColor = (tier: string) =>
63  /fable/i.test(tier) ? C.indigo : /opus/i.test(tier) ? C.blue : /haiku/i.test(tier) ? C.gray : /sonnet/i.test(tier) ? C.green : C.slate
64
65// The decision card as a picture: one tile per option, the method's pick outlined.
66export const decisionSvg = (d: NonNullable<Decision>, ask: typeof ASK) => {
67  let b = ''
68  b += `<circle cx="26" cy="27" r="5" fill="${C.orange}"/>`
69  b += `<text x="38" y="31" font-size="12" font-weight="700" fill="${inkOf(C.orange)}" letter-spacing=".04em">${esc(ask.title.toUpperCase())}</text>`
70  const q = wrap(d.question, 44).slice(0, 2)
71  b += lines(20, 56, 'ink', q, 22, ' font-size="18" font-weight="700" letter-spacing="-.02em"')
72  let y = 56 + q.length * 22 - 4
73  for (const o of d.options) {
74    const isPick = o.key === d.pick
75    const h = 46
76    b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="12"${isPick ? ` stroke="${C.blue}" stroke-width="2"` : ''}/>`
77    b += `<circle cx="40" cy="${y + h / 2}" r="13" fill="${isPick ? C.blue : '#E5E5EA'}"/>`
78    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>`
79    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, o.tier ? 34 : 40))}</text>`
80    if (isPick) b += `<text x="62" y="${y + h / 2 + 14}" font-size="10.5" font-weight="700" fill="${inkOf(C.blue)}">★ ${esc(ask.pick)}</text>`
81    if (o.tier) {
82      const t = cut(o.tier, 18)
83      const tw = Math.round(t.length * 6.2 + 16)
84      const c = tierColor(o.tier)
85      b += `<rect x="${W - 28 - tw}" y="${y + h / 2 - 10}" width="${tw}" height="20" rx="10" fill="${c}" fill-opacity=".13"/>`
86      b += `<text x="${W - 28 - tw / 2}" y="${y + h / 2 + 4}" text-anchor="middle" font-size="10.5" font-weight="600" font-family="${MONO}" fill="${inkOf(c)}">${esc(t)}</text>`
87    }
88    y += h + 8
89  }
90  if (d.why) {
91    y += 8
92    const why = wrap(d.why, 62).slice(0, 3)
93    b += lines(20, y, 'body mut', why, 17)
94    y += why.length * 17
95  }
96  return doc(y + 10, b)
97}
98
hooks/methods/pocock.ts 580 lines
1// Matt Pocock's flow: grill, spec, tickets, build, review, PR, retro.
2
3import type { Flow, Nudge, PocockState, PullRequest, StepId } from '../../types'
4import { C, chip, chips, chipW, doc, down, esc, inkOf, lines, noteCard, text, W, wrap, type Item } from '../draw'
5import { fill, mergeAct, say, type Act, type Column, type GuidePage, type Method, type Move, type Out, type Section } from '../method'
6
7const EMPTY_FLOW: Flow = { root: '', done: [], current: null, last: '', isBugFix: false }
8const EMPTY: PocockState = { flow: EMPTY_FLOW, nudge: null }
9
10// `now` is what the flow would have you do while on the step, when Haiku has not said.
11type Step = { id: StepId; name: string; color: string; cmd: string; what: string; you: string; why: string; now: string }
12
13const STEPS: Step[] = [
14  {
15    id: 'grill',
16    name: 'Grill',
17    color: C.blue,
18    cmd: '/grill-with-docs',
19    what: 'Claude asks you questions about the idea, one round at a time, until it has no gaps.',
20    you: 'Answer each question. Say "I don\'t know" when you don\'t, and Claude will look it up.',
21    why: 'Code built on a fuzzy idea gets rebuilt. Ten minutes of questions first is the cheapest fix there is.',
22    now: "Keep answering Claude's questions until the idea has no gaps left.",
23  },
24  {
25    id: 'spec',
26    name: 'Spec',
27    color: C.indigo,
28    cmd: '/to-spec',
29    what: 'Claude turns the grilling into one written plan and files it on the issue tracker.',
30    you: 'Read it. Ask for changes now, while they cost nothing.',
31    why: 'Without a written plan, each new chat starts from a guess about what you meant.',
32    now: "Read the spec on the tracker and ask for changes before any code exists.",
33  },
34  {
35    id: 'tickets',
36    name: 'Tickets',
37    color: C.purple,
38    cmd: '/to-tickets',
39    what: 'Claude cuts the spec into small tickets. Each one works end to end and says which tickets go first.',
40    you: 'Check the order makes sense, and that each ticket is small enough for one chat.',
41    why: 'A big job in one chat runs out of room. Small tickets each get a fresh, sharp chat.',
42    now: "Check the tickets are small and in order, then clear the chat for the first one.",
43  },
44  {
45    id: 'build',
46    name: 'Build',
47    color: C.orange,
48    cmd: '/implement',
49    what: 'Claude builds it test-first: a test that fails, then the code that makes it pass.',
50    you: 'Watch the tests go red, then green. Clear the chat before each new ticket.',
51    why: 'A test written first proves the code does the job, now and after every later change.',
52    now: "Watch the test fail, then pass. Stay on this one ticket.",
53  },
54  {
55    id: 'review',
56    name: 'Review',
57    color: C.green,
58    cmd: '/code-review',
59    what: "Two reviewers check the change: does it follow the repo's rules, and does it do what was asked.",
60    you: 'Read both lists. Ask Claude to fix the ones that matter.',
61    why: 'Mistakes are cheap to fix before a pull request and expensive after it ships.',
62    now: "Read both review lists and have Claude fix the findings that matter.",
63  },
64  {
65    id: 'pr',
66    name: 'PR',
67    color: C.teal,
68    cmd: '/pr',
69    what: 'Claude opens a pull request with a picture of the change and proof that it works.',
70    you: 'Read it, then share the link with whoever reviews your work.',
71    why: 'A pull request is how someone else checks the work before it joins the main code.',
72    now: "Read the pull request, then send the link to whoever reviews your work.",
73  },
74  {
75    id: 'retro',
76    name: 'Retro',
77    color: C.pink,
78    cmd: '/retro',
79    what: 'Claude looks back at the session and suggests changes to the setup, so the next build goes better.',
80    you: 'Pick which suggestions to keep. Run it before you clear the chat.',
81    why: 'Each retro turns a mistake into a check or a rule, so it does not happen twice.',
82    now: "Keep the setup changes worth keeping, then start the next idea.",
83  },
84]
85
86const ORDER: StepId[] = STEPS.map(s => s.id)
87const stepOf = (id: StepId) => STEPS.find(s => s.id === id) ?? STEPS[0]!
88
89const SKILL_STEP: Record<string, StepId> = {
90  'grill-with-docs': 'grill',
91  'grill-me': 'grill',
92  grilling: 'grill',
93  'domain-modeling': 'grill',
94  prototype: 'grill',
95  wayfinder: 'grill',
96  'to-spec': 'spec',
97  'to-tickets': 'tickets',
98  triage: 'tickets',
99  implement: 'build',
100  'implement-spec': 'build',
101  tdd: 'build',
102  'diagnosing-bugs': 'build',
103  'code-review': 'review',
104  pr: 'pr',
105  retro: 'retro',
106}
107
108// /implement and /implement-spec run /code-review themselves, so they finish two steps.
109const REVIEWS_ITSELF = new Set(['implement', 'implement-spec'])
110
111export const advance = (f: Flow, name: string, step: StepId): Flow => {
112  const isFresh = step === 'grill' && (f.current === 'pr' || f.current === 'retro')
113  const base = isFresh ? { ...f, done: [], isBugFix: false } : f
114  const reached: StepId[] = REVIEWS_ITSELF.has(name) ? [step, 'review'] : [step]
115  const done = ORDER.filter(s => base.done.includes(s) || reached.includes(s))
116  return { ...base, done, current: reached.at(-1) ?? step, last: name, isBugFix: base.isBugFix || name === 'diagnosing-bugs' }
117}
118
119// A step left out on the way to where you are. Spec and tickets are optional for a
120// small job, and a bug fix starts at Build, so only these two count.
121const skippedOf = (f: Flow): StepId[] => {
122  if (!f.current) return []
123  const at = ORDER.indexOf(f.current)
124  const out: StepId[] = []
125  if (!f.isBugFix && at >= ORDER.indexOf('build') && !f.done.includes('grill')) out.push('grill')
126  if (at >= ORDER.indexOf('pr') && f.done.includes('build') && !f.done.includes('review')) out.push('review')
127  return out
128}
129
130type FlowMove = Act & { why: string }
131
132const NEXT_TICKET: FlowMove = { key: 'move-next-ticket', label: 'Build the next ticket', text: '/implement ', why: 'Clear the chat first, then add the ticket number after /implement.' }
133const CLEAR: FlowMove = { key: 'move-clear', label: 'Clear the chat', text: '/clear', why: 'Empties the chat so the next ticket starts fresh.' }
134
135// Next step only offers what the flow allows from where you are. Each fills the
136// prompt, so Enter is the go.
137const nextFor = (f: Flow): { main: FlowMove; others: FlowMove[] } => {
138  const hasTickets = f.done.includes('tickets')
139  switch (f.current) {
140    case null:
141      return {
142        main: { key: 'move-grill', label: 'Grill your idea', text: '/grill-with-docs ', why: 'Type your idea after the command in your own words. Rough is fine.' },
143        others: [{ key: 'move-bug', label: 'Something is broken instead', text: '/diagnosing-bugs ', why: '' }],
144      }
145    case 'grill':
146      return {
147        main: { key: 'move-spec', label: 'Write the spec', text: '/to-spec', why: 'Do this in the same chat, so the spec builds on the whole grilling.' },
148        others: [
149          { key: 'move-small', label: 'Small job? Build it here', text: '/implement what we just agreed', why: '' },
150          { key: 'move-prototype', label: 'Need to see it run first?', text: '/handoff I want to answer this with a throwaway prototype: <the question>', why: '' },
151        ],
152      }
153    case 'spec':
154      return { main: { key: 'move-tickets', label: 'Cut it into tickets', text: '/to-tickets', why: 'Stay in this chat. Clear it only after the tickets exist.' }, others: [] }
155    case 'tickets':
156      return {
157        main: { key: 'move-build', label: 'Build the first ticket', text: '/implement ', why: 'Clear the chat first. Then add the first ticket after /implement.' },
158        others: [CLEAR, { key: 'move-all', label: 'Build every ticket in one run', text: '/implement-spec', why: '' }],
159      }
160    case 'build':
161      return { main: { key: 'move-review', label: 'Review the change', text: '/code-review since main', why: 'Two reviewers read the diff before anyone else sees it.' }, others: [] }
162    case 'review':
163      return {
164        main: { key: 'move-pr', label: 'Open the pull request', text: 'open a pull request for this branch. use /pr to write the body.', why: 'It shows the change and the proof that it works.' },
165        others: hasTickets ? [CLEAR, NEXT_TICKET] : [],
166      }
167    case 'pr':
168      return { main: { key: 'move-retro', label: 'Look back with a retro', text: '/retro', why: 'Run it now, before you clear the chat, so it can see the whole session.' }, others: hasTickets ? [CLEAR, NEXT_TICKET] : [] }
169    case 'retro':
170      return { main: { key: 'move-again', label: 'Start the next idea', text: '/grill-with-docs ', why: 'The path starts over at Grill.' }, others: hasTickets ? [CLEAR, NEXT_TICKET] : [] }
171  }
172}
173
174const hintFor = (f: Flow) => {
175  if (f.current === 'grill' || f.current === 'spec') return 'Keep grilling, spec and tickets in this one chat. Do not clear or compact until the tickets exist.'
176  if (f.current === 'tickets' || (f.done.includes('tickets') && f.current !== null)) return 'Clear the chat before each ticket. The ticket holds everything Claude needs.'
177  return ''
178}
179
180// ---------- Now: where you are on the path ----------
181
182const NUDGE_TEXT: Record<NonNullable<Nudge>['kind'], Section> = {
183  grill: { title: 'Grill it first?', body: 'You asked for code before any grilling. A few minutes of questions now saves a rebuild later.', tone: 'warn' },
184  bug: { title: 'Sounds like something is broken', body: '/diagnosing-bugs finds a command that shows the bug failing, then fixes it with a test.', tone: 'warn' },
185}
186
187const column = (s: PocockState, would: string | undefined): Column => {
188  const f = s.flow
189  const at = f.current ? ORDER.indexOf(f.current) : -1
190  const skipped = skippedOf(f)
191  const step = f.current ? stepOf(f.current) : null
192  const sections: Section[] = []
193  if (s.nudge) sections.push(NUDGE_TEXT[s.nudge.kind])
194  if (step) sections.push({ title: 'What it is', body: step.what }, { title: 'What you do', body: step.you })
195  else sections.push({ title: 'Every job starts with an idea', body: 'Grill it in plain words. Claude asks questions until it is clear, then you build it step by step.' })
196  for (const id of skipped) sections.push({ title: `You skipped ${stepOf(id).name}`, body: stepOf(id).why, tone: 'warn' })
197  const hint = hintFor(f)
198  if (hint) sections.push({ title: 'Keep the chat clean', body: hint })
199  return {
200    stage: step ? `Step ${at + 1} of 7 · ${step.name}` : 'Not started',
201    title: step ? `${step.name} · ${step.cmd}` : 'No step yet',
202    rows: STEPS.map(x => ({
203      label: x.name,
204      mark: x.id === f.current ? 'now' : skipped.includes(x.id) ? 'skipped' : f.done.includes(x.id) ? 'done' : 'todo',
205      tag: x.id === f.current ? x.cmd : undefined,
206    })),
207    sections,
208    would: would ?? step?.now ?? 'Grill the idea with /grill-with-docs before any code is written.',
209  }
210}
211
212const nextOf = (s: PocockState): Move => {
213  // A request for code before any grilling makes grilling it the next move.
214  if (s.nudge) {
215    const cmd = s.nudge.kind === 'bug' ? '/diagnosing-bugs' : '/grill-with-docs'
216    return { label: s.nudge.kind === 'bug' ? 'Diagnose it first' : 'Grill it first', text: `${cmd} ${s.nudge.text}`, why: NUDGE_TEXT[s.nudge.kind].body }
217  }
218  const next = nextFor(s.flow).main
219  return { label: next.label, text: next.text ?? '', why: next.why }
220}
221
222// ---------- Guide pages ----------
223
224const flowSvg = () => {
225  let b = ''
226  b += text(20, 34, 'h ink', 'From idea to shipped')
227  b += text(20, 54, 'sub mut', 'Seven steps, in order. Each one has a skill you run.')
228  let y = 72
229  const card = (s: Step, i: number) => {
230    const what = wrap(s.what, 56)
231    const h = 40 + what.length * 16 + 6
232    let svg = `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
233    svg += `<circle cx="40" cy="${y + 22}" r="13" fill="${s.color}"/>`
234    svg += `<text x="40" y="${y + 26.5}" text-anchor="middle" fill="#fff" font-size="12.5" font-weight="700">${i + 1}</text>`
235    svg += text(62, y + 27, 'lab ink', s.name, ' font-size="14.5"')
236    svg += chip(W - 28 - chipW({ label: s.cmd, tip: '' }), y + 10, { label: s.cmd, tip: s.what }, s.color)
237    svg += lines(62, y + 48, 'body mut', what, 16)
238    return { svg, h }
239  }
240  const side = (title: string, body: string, color: string) => {
241    const rows = wrap(body, 60)
242    const h = 26 + rows.length * 15 + 8
243    const svg =
244      `<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"/>` +
245      `<text x="58" y="${y + 18}" font-size="12" font-weight="700" fill="${inkOf(color)}">${esc(title)}</text>` +
246      lines(58, y + 35, 'sm ink', rows, 15)
247    return { svg, h }
248  }
249  const band = (label: string, color: string) => {
250    b += `<rect x="16" y="${y}" width="${W - 32}" height="26" rx="13" fill="${color}" fill-opacity=".14"/>`
251    b += `<text x="${W / 2}" y="${y + 17.5}" text-anchor="middle" font-size="12" font-weight="700" fill="${inkOf(color)}">${esc(label)}</text>`
252    y += 26 + 10
253  }
254  band('Steps 1 to 3: one chat, no clearing', C.blue)
255  STEPS.forEach((s, i) => {
256    if (i === 3) band('Clear the chat before each ticket', C.purple)
257    const c = card(s, i)
258    b += c.svg
259    y += c.h + 6
260    if (s.id === 'grill') {
261      b += down(W / 2, y)
262      y += 12
263      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.blue)
264      b += d.svg
265      y += d.h + 6
266      const small = side('Small job that fits in one chat?', 'Skip spec and tickets. Run /implement right after grilling.', C.orange)
267      b += small.svg
268      y += small.h + 6
269    }
270    if (i < STEPS.length - 1) {
271      b += down(W / 2, y)
272      y += 14
273    }
274  })
275  y += 14
276  b += text(20, y, 'sm mut', 'Hover a command to see what it does.')
277  return doc(y + 16, b)
278}
279
280const RAMPS: { when: string; cmd: string; what: string; joins: StepId }[] = [
281  { when: 'Something is broken', cmd: '/diagnosing-bugs', what: 'Finds a command that shows the bug failing, then fixes it with a test.', joins: 'build' },
282  { when: 'Bugs and requests piling up', cmd: '/triage', what: 'Sorts issues other people filed into clear tickets an agent can take.', joins: 'tickets' },
283  { 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' },
284  { 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' },
285]
286
287const rampsSvg = () => {
288  let b = ''
289  b += text(20, 34, 'h ink', 'Other ways in')
290  b += text(20, 54, 'sub mut', 'Not every job starts with an idea. These join the path partway.')
291  let y = 72
292  for (const r of RAMPS) {
293    const s = stepOf(r.joins)
294    const what = wrap(r.what, 58)
295    const h = 74 + what.length * 16
296    b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
297    b += text(30, y + 24, 'lab ink', r.when, ' font-size="14.5"')
298    b += chip(30, y + 34, { label: r.cmd, tip: r.what }, C.slate)
299    b += lines(30, y + 76, 'body mut', what, 16)
300    const label = `joins at ${s.name}`
301    const lw = label.length * 6.6 + 22
302    b += `<rect x="${W - 28 - lw}" y="${y + 10}" width="${lw}" height="22" rx="11" fill="${s.color}"/>`
303    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>`
304    y += h + 10
305  }
306  y += 8
307  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)
308  b += pre.svg
309  y += pre.h
310  return doc(y + 16, b)
311}
312
313const ANYTIME: { name: string; color: string; items: Item[] }[] = [
314  {
315    name: 'Think it through',
316    color: C.blue,
317    items: [
318      { label: '/grill-me', tip: 'The same interview as /grill-with-docs, for a plan with no repo. It saves nothing.' },
319      { label: '/research', tip: 'A background agent reads the sources and leaves a cited notes file in the repo.' },
320      { label: '/to-questionnaire', tip: 'Writes questions for someone else to answer, when the gap is in their head.' },
321    ],
322  },
323  {
324    name: 'Learn and ask',
325    color: C.indigo,
326    items: [
327      { label: '/wait-what', tip: 'That last message did not land. Claude says it again, in plain words.' },
328      { label: '/teach', tip: 'Learn a concept over several sessions, in this folder.' },
329    ],
330  },
331  {
332    name: 'Words and shape',
333    color: C.purple,
334    items: [
335      { label: '/domain-modeling', tip: 'Sort out a fuzzy word, or record a hard-to-undo decision.' },
336      { label: '/codebase-design', tip: 'Shape a part of the code: a lot of work behind a small, simple surface.' },
337    ],
338  },
339  { name: 'Steps only you can do', color: C.orange, items: [{ label: '/wizard', tip: 'A script that walks you through keys, dashboards and secrets, and saves each value.' }] },
340]
341
342const BETWEEN: [string, string][] = [
343  ['Continue', 'Stay in this chat. The safe first choice.'],
344  ['/clear', 'Empty the chat when nothing in it matters next.'],
345  ['/handoff', 'Write a file for a new folder, tool or person.'],
346  ['Subagent', 'Send one small job to its own chat.'],
347  ['/compact', 'Squeeze the chat when it gets long.'],
348]
349
350const moreSvg = () => {
351  let b = ''
352  b += text(20, 34, 'h ink', 'Skills for any time')
353  b += text(20, 54, 'sub mut', 'Off the path. Use them whenever they help.')
354  let y = 84
355  for (const g of ANYTIME) {
356    b += `<rect x="20" y="${y - 11}" width="4" height="14" rx="2" fill="${g.color}"/>`
357    b += text(30, y, 'lab ink', g.name)
358    const f = chips(20, y + 10, W - 16, g.items, g.color)
359    b += f.svg
360    y = f.bottom + 30
361  }
362  b += text(20, y, 'lab ink', 'Between two steps, pick one')
363  b += text(20, y + 17, 'sm mut', 'Try them top to bottom. Stop at the first that fits.')
364  y += 30
365  b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${BETWEEN.length * 34 + 10}" rx="10"/>`
366  BETWEEN.forEach(([k, v], i) => {
367    const ry = y + 10 + i * 34
368    b += `<rect x="28" y="${ry + 4}" width="86" height="22" rx="11" fill="${C.slate}" fill-opacity=".1"/>`
369    b += `<text x="71" y="${ry + 19.5}" text-anchor="middle" font-size="12" font-weight="700" class="ink mono">${esc(k)}</text>`
370    b += text(126, ry + 19.5, 'body ink', v)
371  })
372  y += BETWEEN.length * 34 + 10
373  return doc(y + 18, b)
374}
375
376const WORDS: [string, string][] = [
377  ['Grilling', "Claude asks you questions, one round at a time, until the idea has no gaps. Facts are Claude's job. Decisions are yours."],
378  ['Spec', 'One written plan for the whole job, made from the grilling.'],
379  ['Ticket', 'One small piece of the spec, small enough to build in one chat.'],
380  ['Tracer bullet', 'A ticket that cuts a thin slice through every layer, from screen to data, so something works end to end early.'],
381  ['Blocking edge', 'A note on a ticket that says which tickets must finish first.'],
382  ['Red-green', 'Write a test that fails (red), then the code that makes it pass (green). One small slice at a time.'],
383  ['Deep module', 'A part of the code that does a lot of work behind a small, simple surface.'],
384  ['Context window', 'Everything Claude can see in this chat. /clear empties it.'],
385  ['Smart zone', 'The first part of a chat, about 150k tokens, where Claude still thinks sharply. Past it, answers get worse.'],
386  ['GLOSSARY.md', 'The file where grilling writes down the words you agreed on.'],
387  ['ADR', 'A short note that records a decision that is hard to undo, and why it was made.'],
388  ['Pull request', 'A request to add your branch to the main code, with proof it works, for someone to review.'],
389]
390
391const wordsSvg = () => {
392  let b = ''
393  b += text(20, 34, 'h ink', 'Words you will hear')
394  b += text(20, 54, 'sub mut', 'Lost in a reply? Type /wait-what and Claude says it again.')
395  let y = 70
396  WORDS.forEach(([word, meaning], i) => {
397    const rows = wrap(meaning, 60)
398    const h = 36 + rows.length * 16
399    const color = STEPS[i % STEPS.length]!.color
400    b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${h}" rx="10"/>`
401    b += `<rect x="28" y="${y + 13}" width="4" height="15" rx="2" fill="${color}"/>`
402    b += text(40, y + 25, 'lab', word, ` fill="${inkOf(color)}" font-size="13.5"`)
403    b += lines(40, y + 44, 'body ink', rows, 16)
404    y += h + 8
405  })
406  return doc(y + 10, b)
407}
408
409const GUIDE: GuidePage[] = [
410  {
411    id: 'flow',
412    label: 'Main path',
413    svg: flowSvg(),
414    alt: 'The seven steps from idea to shipped, with the skill for each',
415    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.`,
416    note: 'Press a step to put its command in your prompt.',
417    rows: [{ acts: STEPS.map((s, i) => fill(`cmd-${s.id}`, `${i + 1} ${s.name}`, `${s.cmd} `)) }],
418  },
419  {
420    id: 'ramps',
421    label: 'Other ways in',
422    svg: rampsSvg(),
423    alt: 'Starting points that join the main path partway',
424    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\`.`,
425    rows: [{ acts: RAMPS.map(r => fill(`ramp-${r.cmd}`, r.when, `${r.cmd} `)) }],
426  },
427  {
428    id: 'more',
429    label: 'Any time',
430    svg: moreSvg(),
431    alt: 'Skills you can use at any point, and what to do between steps',
432    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(' ')}`,
433  },
434  {
435    id: 'words',
436    label: 'Words',
437    svg: wordsSvg(),
438    alt: 'Plain meanings for the words the skills use',
439    md: `### Words you will hear\n\n${WORDS.map(([w, m]) => `- **${w}**: ${m}`).join('\n')}`,
440  },
441]
442
443// ---------- Start ----------
444
445const SETUP = say('start-setup', 'Set up this repo', '/setup-matt-pocock-skills')
446
447const CATCH_UP = say(
448  'start-catchup',
449  'Catch me up',
450  '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.',
451)
452
453const STARTS: Act[] = [
454  { ...fill('start-idea', 'I have an idea', '/grill-with-docs '), isPrimary: true },
455  fill('start-bug', 'Something is broken', '/diagnosing-bugs '),
456  say(
457    'start-ticket',
458    'Pick up a ticket',
459    "list this repo's open ready-for-agent tickets, 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.",
460  ),
461  CATCH_UP,
462]
463
464const prActs = (pr: PullRequest, canMerge: boolean): Act[] => {
465  const n = pr.number
466  const review = fill(`review-${n}`, 'Review it', `/code-review pull request #${n} against its base branch`)
467  switch (pr.stage) {
468    case 'conflict':
469      return [fill(`conflict-${n}`, 'Fix the conflict', `pull request #${n} conflicts with main. in its own worktree, rebase it onto main, resolve the conflict in plain words as you go, run the tests and push. don't merge.`)]
470    case 'fail':
471      return [fill(`fix-${n}`, 'Fix the checks', `pull request #${n} has failing checks. tell me why in plain words, then fix them test-first. don't merge.`)]
472    case 'changes':
473      return [fill(`changes-${n}`, 'Answer the review', `pull request #${n} has review comments asking for changes. read them, fix what is right test-first, reply to what is not, and push. don't merge.`)]
474    case 'ready':
475      return canMerge ? [mergeAct(n), review] : [review]
476    default:
477      return [review]
478  }
479}
480
481// ---------- Grill before building ----------
482// Shown in this method's column as advice. Nothing is blocked or sent.
483
484const 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
485const 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
486const QUESTION = /^(how|what|why|where|when|who|which|is|are|does|can you explain|explain|tell me)\b/i
487
488export const nudgeFor = (prompt: string, f: Flow): Nudge => {
489  const said = prompt.trim()
490  if (!said || said.startsWith('/') || QUESTION.test(said)) return null
491  // Only before the path starts, or once the last job ran its retro.
492  if (f.current !== null && f.current !== 'retro') return null
493  if (BUG_ASK.test(said)) return { text: said, kind: 'bug' }
494  if (BUILD_ASK.test(said)) return { text: said, kind: 'grill' }
495  return null
496}
497
498// ---------- Following the flow ----------
499
500const nameOf = (raw: string) => raw.split(':').pop() ?? raw
501
502// A skill of the flow moves it, and answers any open nudge.
503const useSkill = (s: PocockState, raw: string): PocockState => {
504  const name = nameOf(raw)
505  const step = SKILL_STEP[name]
506  if (!step) return s
507  if (s.flow.last === name && s.flow.current === (REVIEWS_ITSELF.has(name) ? 'review' : step)) return s
508  return { ...s, flow: advance(s.flow, name, step), nudge: null }
509}
510
511const explain = say('q-plain', 'Say it plainly', '/wait-what')
512
513export const pocock: Method<PocockState> = {
514  id: 'pocock',
515  label: 'pocock',
516  requires: {
517    name: "Matt Pocock's skills",
518    plugins: ['mattpocock-skills@', 'matt-pocock@'],
519    skill: 'grill-with-docs',
520    // The official marketplace pins a commit from before v1.3.1, so the mirror is the one to install.
521    outdated: 'mattpocock-skills@claude-plugins-official',
522    outdatedNote: 'The copy from the official marketplace is older than v1.3.1 and has no /implement-spec, /pr or /retro, which later steps need.',
523    install: 'claude plugin marketplace add mattnicosia/bldg-skills && claude plugin install matt-pocock@bldg-skills',
524    removeOld: 'claude plugin uninstall mattpocock-skills',
525  },
526  lead: '',
527  starts: STARTS,
528  plainStarts: [CATCH_UP],
529  startNote: 'Idea and broken fill your prompt: type what you mean after the command, then press Enter. The others send at once.',
530  setup: { file: 'docs/agents/issue-tracker.md', act: SETUP, warn: 'This repo is not set up for the skills yet. Press Set up this repo once, first.' },
531  prActs,
532  explain,
533  after: [],
534  decide: {
535    who: "Matt Pocock's flow (grill the idea, write a spec, cut tickets, build test-first, review, open a PR, run a retro) with a beginner",
536    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.',
537  },
538  guide: GUIDE,
539
540  empty: EMPTY,
541  // The flow belongs to the repo, not the chat, so a cleared chat or a new
542  // session in the same repo picks up where the last one stopped.
543  persist: {
544    rootOf: s => s.flow.root,
545    save: s => s.flow,
546    load: (s, saved, root) => {
547      const f = saved as Flow | undefined
548      return { ...s, flow: f && Array.isArray(f.done) ? { ...EMPTY_FLOW, ...f, root } : { ...EMPTY_FLOW, root } }
549    },
550  },
551
552  stageOf: s => s.flow.current,
553  moveTo: (s, stage) => {
554    const step = ORDER.find(x => x === stage)
555    return step ? { ...s, flow: { ...advance(s.flow, '', step), last: s.flow.last }, nudge: null } : s
556  },
557  brief: s => (s.flow.current ? `step ${stepOf(s.flow.current).name} (${stepOf(s.flow.current).cmd})` : 'not started, no idea grilled yet'),
558  column,
559  next: nextOf,
560  phase: s => (s.flow.current !== null && s.flow.current !== 'retro' ? 'running' : 'idle'),
561  reset: s => ({ ...s, flow: { ...EMPTY_FLOW, root: s.flow.root }, nudge: null }),
562  isBusy: s => s.flow.current !== null,
563
564  hooks: {
565    command: (s, command, args) => ({ s: useSkill(s, command), args }),
566    skill: (s, skill, text) => ({ s: useSkill(s, skill), text }),
567    tool: (s, tool, args): Out<PocockState> => {
568      let next = s
569      if (tool === 'Skill' && typeof args.skill === 'string') next = useSkill(next, args.skill)
570      if (tool === 'Bash' && typeof args.command === 'string' && /\bgh pr create\b/.test(args.command)) next = { ...next, flow: advance(next.flow, 'gh pr create', 'pr') }
571      return { s: next }
572    },
573    // A request for code before any grilling shows in this column and nowhere else.
574    prompt: (s, said, isPerson, hasSkills) => {
575      const found = isPerson && hasSkills ? nudgeFor(said, s.flow) : null
576      return { s: found ? { ...s, nudge: found } : s.nudge === null ? s : { ...s, nudge: null } }
577    },
578  },
579}
580
hooks/methods/pstack.ts 1053 lines
1// pstack: say the goal and how to check it, and /poteto-mode picks the playbook.
2
3import type { Advice, Live, Power, PullRequest, SharedId, Task } from '../../types'
4import { C, chip, chips, chipW, cut, doc, down, esc, labelOf, lines, MONO, text, W, wrap, type Item } from '../draw'
5import { fill, mergeAct, say, type Act, type Column, type GuidePage, type Method, type Out, type Row, type Section } from '../method'
6import { SHARED } from '../stages'
7
8const EMPTY_LIVE: Live = { playbook: null, stage: null, advice: {}, power: { model: '', effort: '' }, steps: [], tasks: [], skills: [], prOpened: false, prMerged: false }
9
10// ---------- Map ----------
11
12const STAGES: { name: string; color: string; items: Item[] }[] = [
13  {
14    name: 'Understand',
15    color: C.blue,
16    items: [
17      { label: '/how', tip: 'How the code works today: runtime flow, key types, the non-obvious parts.' },
18      { label: '/why', tip: 'Why it is shaped this way. Digs through git, issues, docs and chat, and cites everything.' },
19      { label: '/teach', tip: 'Runs /how and /why and turns them into one plain explanation.' },
20      { label: '/recall', tip: 'Catches you up on your own recent work on a topic.' },
21    ],
22  },
23  {
24    name: 'Design',
25    color: C.indigo,
26    items: [
27      { label: '/architect', tip: 'Settles types and boundaries before any code, starting from how callers use it.' },
28      { label: '/arena', tip: 'Several agents try the same brief. A judge scores them; the best base gets the best parts grafted in.' },
29      { label: '/swarm', tip: 'One worker per slice or check, then one PASS / ISSUES / BLOCKED report.' },
30      { label: '/interrogate', tip: 'Other models try to break it. Findings sorted into Act on, Consider, Noted, Dismissed.' },
31    ],
32  },
33  {
34    name: 'Build',
35    color: C.orange,
36    items: [
37      { label: 'Bug fix', kind: 'play', tip: 'Reproduce first, trace to the cause, then fix and rerun.' },
38      { label: 'Feature', kind: 'play', tip: 'Name the data shape first, then build, then prove it.' },
39      { label: 'Refactor', kind: 'play', tip: 'Record current behavior, move the structure, prove nothing changed.' },
40      { label: 'Perf', kind: 'play', tip: 'Profile, fix the measured cause, show before and after.' },
41      { label: '/tdd', tip: 'Smallest failing test first, then the fix, then rerun.' },
42      { label: '/unslop', tip: 'Strips filler from the diff, PR text and commit messages.' },
43      { label: '/no-comments', tip: 'A fresh reviewer that did not write the code deletes needless comments.' },
44    ],
45  },
46  {
47    name: 'Verify',
48    color: C.green,
49    items: [
50      { label: '/blast-radius', tip: 'What else this change could break, proved by running code.' },
51      { label: '/create-verification-skill', tip: 'Writes a skill that lets any agent launch and drive your app.' },
52      { label: '/maintain-verification-skill', tip: 'Audits that skill against the app and fixes drift.' },
53    ],
54  },
55  {
56    name: 'Ship',
57    color: C.teal,
58    items: [
59      { label: 'Open a PR', kind: 'play', tip: 'Small ordered commits from a worktree, cleaned diff, evidence in the description.' },
60      { label: 'Babysit', kind: 'play', tip: 'Gets a PR green: conflicts, then review threads, then CI. Never merges.' },
61      { label: 'Shipping', kind: 'play', tip: 'Verifies each PR live, then lands only the verified run from the bottom.' },
62    ],
63  },
64  {
65    name: 'Overnight',
66    color: C.pink,
67    items: [
68      { label: '/figure-it-out', tip: 'Designs the phases for big or unattended work and keeps a decision log.' },
69      { label: '/loop', tip: 'Claude Code’s wake-up loop. Re-checks the finish condition until it passes.' },
70      { label: '/show-me-your-work', tip: 'The decision log you audit in the morning.' },
71      { label: 'Autopilot', kind: 'play', tip: 'Runs a queue of PRs. Full merges them; Stack leaves one verified stack for you.' },
72    ],
73  },
74]
75
76const ANYTIME: Item[] = [
77  { label: '/bro', tip: 'Restates the last reply in plain words.' },
78  { label: '/reflect', tip: 'Reviews this chat and turns lessons into edits to skills.' },
79  { label: '/automate-me', tip: 'Captures how you work into your own -mode skill.' },
80  { label: '/setup-pstack', tip: 'Picks which models each role uses.' },
81]
82
83const mapSvg = () => {
84  let b = ''
85  b += text(20, 34, 'h ink', 'pstack in one picture')
86  b += text(20, 54, 'sub mut', 'Say the goal and how to check it. It picks the playbook.')
87  b += `<rect class="card" x="16" y="68" width="${W - 32}" height="60" rx="10"/>`
88  b += `<text x="30" y="93" font-size="13.5" font-weight="700" font-family="${MONO}"><tspan class="ink">/poteto-mode</tspan><tspan fill="${C.blue}"> &lt;goal&gt;.</tspan><tspan fill="${C.green}"> &lt;how to check&gt;.</tspan></text>`
89  b += text(30, 115, 'sm mut', 'e.g. "users get two emails after a retry. repro first, then fix and verify."')
90  let y = 148
91  STAGES.forEach((stage, i) => {
92    const f = chips(126, y + 6, W - 16, stage.items, stage.color)
93    const h = f.bottom - y + 6
94    if (i > 0) b += down(65, y - 11)
95    b += `<rect x="16" y="${y}" width="98" height="${h}" rx="8" fill="${stage.color}"/>`
96    b += `<text x="65" y="${y + h / 2 + 4.5}" text-anchor="middle" fill="#fff" class="lab">${stage.name}</text>`
97    b += `<rect class="card" x="120" y="${y}" width="${W - 136}" height="${h}" rx="8"/>`
98    b += f.svg
99    y += h + 14
100  })
101  b += text(20, y + 14, 'lab mut', 'Anytime')
102  const any = chips(126, y, W - 16, ANYTIME, C.gray)
103  y = any.bottom + 22
104  b += any.svg
105  b += `<line class="ln" x1="16" x2="${W - 16}" y1="${y}" y2="${y}"/>`
106  y += 22
107  b += chip(20, y - 16, { label: '/name', tip: 'A command you type.' }, C.slate)
108  b += text(84, y, 'sm mut', 'you type it')
109  b += chip(170, y - 16, { label: 'Playbook', kind: 'play', tip: 'A playbook.' }, C.slate)
110  b += text(270, y, 'sm mut', 'poteto-mode picks it')
111  y += 26
112  b += text(20, y, 'sm mut', 'Hover any chip to see what it does.')
113  return doc(y + 18, b)
114}
115
116// ---------- Router ----------
117
118const ROUTES: { name: string; color: string; say: string[] }[] = [
119  { name: 'Investigation', color: C.blue, say: ['"how does X work?"', '"don\'t change code yet."'] },
120  { name: 'Bug fix', color: C.red, say: ['"X breaks after Y."', '"repro first, then fix."'] },
121  { name: 'Feature', color: C.orange, say: ['"add a --json flag."', '"text output stays the same."'] },
122  { name: 'Refactor', color: C.indigo, say: ['"move parsing to one module."', '"zero behavior change."'] },
123  { name: 'Perf', color: C.teal, say: ['"startup takes 1.8s."', '"show before and after."'] },
124  { name: 'figure-it-out', color: C.pink, say: ['"migrate every caller."', '"I\'m stepping away."'] },
125]
126
127const MORE: Item[] = [
128  { label: 'Hillclimb', kind: 'play', tip: 'Push one number toward a target, one hypothesis at a time.' },
129  { label: 'Runtime forensics', kind: 'play', tip: 'Diagnose a live symptom.' },
130  { label: 'Trace forensics', kind: 'play', tip: 'Read a captured trace or profile.' },
131  { label: 'Prototype', kind: 'play', tip: 'A throwaway build to answer a question.' },
132  { label: 'Visual parity', kind: 'play', tip: 'Make a screen match a reference.' },
133  { label: 'Authoring a skill', kind: 'play', tip: 'Write or change a SKILL.md with validation and review.' },
134  { label: 'Eval', kind: 'play', tip: 'Measure a skill against test cases.' },
135  { label: 'Autonomous run', kind: 'play', tip: 'Keep going alone until the finish condition passes.' },
136  { label: 'Opening a PR', kind: 'play', tip: 'Small ordered commits, cleaned diff, evidence.' },
137  { label: 'Babysit', kind: 'play', tip: 'Drive a PR to green. Never merges.' },
138  { label: 'Shipping', kind: 'play', tip: 'Verify each PR, land the verified run.' },
139  { label: 'Autopilot full', kind: 'play', tip: 'A queue of independent PRs, merged by morning.' },
140  { label: 'Autopilot stack', kind: 'play', tip: 'A verified stack you land yourself.' },
141  { label: 'Orchestrate', kind: 'play', tip: 'A multi-day program run by a coordinator that never writes code.' },
142  { label: 'Session pickup', kind: 'play', tip: 'Take over a branch someone left mid-flight.' },
143  { label: 'Pause safely', kind: 'play', tip: 'Stop in a state someone can resume.' },
144  { label: 'Multi-phase plan', kind: 'play', tip: 'Break a large change into checked phases.' },
145  { label: 'Worktree cleanup', kind: 'play', tip: 'Prune worktrees the evidence says are safe.' },
146]
147
148const PHRASES: Item[] = [
149  { label: '"new task"', kind: 'name', tip: 'Re-match a playbook instead of continuing the last one.' },
150  { label: '"don\'t change code yet"', kind: 'name', tip: 'Pins the job to Investigation: read and report only.' },
151  { label: '"repro first"', kind: 'name', tip: 'It must reproduce the bug before touching code.' },
152  { label: '"show me the evidence"', kind: 'name', tip: 'The reply must carry the real commands and output.' },
153  { label: '"fresh worktree"', kind: 'name', tip: 'Keeps parallel agents out of each other’s files.' },
154  { label: '"continue"', kind: 'name', tip: 'The mode is sticky. One word keeps the playbook going.' },
155  { label: '"I\'m going to bed"', kind: 'name', tip: 'Stops asking questions and keeps working.' },
156]
157
158const routeSvg = () => {
159  let b = ''
160  b += text(20, 34, 'h ink', 'How your words pick a playbook')
161  b += text(20, 54, 'sub mut', 'Never list skills in your prompt. The playbook orders them.')
162  const cx = W / 2
163  b += `<rect class="card" x="${cx - 90}" y="70" width="180" height="32" rx="16"/>`
164  b += text(cx, 91, 'lab ink', 'Your prompt', ' text-anchor="middle"')
165  b += down(cx, 108)
166  b += `<rect x="${cx - 90}" y="120" width="180" height="34" rx="17" fill="${C.slate}" stroke="${C.indigo}" stroke-width="2"/>`
167  b += `<text x="${cx}" y="142" text-anchor="middle" fill="#fff" font-size="14" font-weight="700" font-family="${MONO}">/poteto-mode</text>`
168  b += text(cx + 98, 134, 'sm mut', 'reads the 21')
169  b += text(cx + 98, 148, 'sm mut', 'principles first')
170  b += down(cx, 160)
171  const cw = (W - 32 - 12) / 2
172  let y = 174
173  ROUTES.forEach((r, i) => {
174    const x = 16 + (i % 2) * (cw + 12)
175    const top = y + Math.floor(i / 2) * 78
176    b += `<rect class="card" x="${x}" y="${top}" width="${cw}" height="68" rx="9"/>`
177    b += `<path d="M${x} ${top + 9} a9 9 0 0 1 9 -9 h${cw - 18} a9 9 0 0 1 9 9 v17 h-${cw} z" fill="${r.color}"/>`
178    b += `<text x="${x + 12}" y="${top + 18}" fill="#fff" class="lab">${esc(r.name)}</text>`
179    b += lines(x + 12, top + 43, 'body ink', r.say, 17, ' font-style="italic"')
180  })
181  y += 3 * 78
182  b += down(cx, y)
183  y += 12
184  b += `<rect x="16" y="${y}" width="${W - 32}" height="34" rx="9" fill="${C.green}"/>`
185  b += `<text x="${cx}" y="${y + 22}" text-anchor="middle" fill="#fff" class="lab">Verify and report, with the evidence</text>`
186  y += 62
187  b += text(20, y, 'lab ink', '18 more playbooks it can pick')
188  const more = chips(20, y + 10, W - 16, MORE, C.gray)
189  b += more.svg
190  y = more.bottom + 30
191  b += text(20, y, 'lab ink', 'Words that steer it')
192  const steer = chips(20, y + 10, W - 16, PHRASES, C.blue)
193  b += steer.svg
194  return doc(steer.bottom + 20, b)
195}
196
197// ---------- Design ----------
198
199const RUNGS: { level: number; when: string; use: string[]; note?: string }[] = [
200  { level: 5, when: 'Contested design that is costly to reverse', use: ['/architect', '/interrogate'] },
201  { level: 4, when: 'Crosses functions or moves ownership', use: ['/architect'], note: 'brings /arena with it' },
202  { level: 3, when: 'One decision: a name, a format, an algorithm', use: ['/arena'] },
203  { level: 2, when: 'A small finished change you are unsure of', use: ['/interrogate'] },
204  { level: 1, when: 'Most changes', use: [], note: 'nothing extra' },
205]
206
207const box = (x: number, y: number, w: number, label: string, color: string) =>
208  `<rect x="${x}" y="${y}" width="${w}" height="24" rx="6" fill="${color}"/><text x="${x + w / 2}" y="${y + 16}" text-anchor="middle" fill="#fff" font-size="11.5" font-weight="600">${esc(label)}</text>`
209
210const designSvg = () => {
211  let b = ''
212  b += text(20, 34, 'h ink', 'How much design does it need?')
213  b += text(20, 54, 'sub mut', 'Climb only as high as the risk. poteto-mode climbs on its own.')
214  let y = 72
215  b += text(20, y + 8, 'sm mut', 'more scrutiny ↑')
216  y += 18
217  for (const r of RUNGS) {
218    b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="54" rx="9"/>`
219    for (let i = 0; i < 5; i++) {
220      b += `<rect x="${28 + i * 9}" y="${y + 34 - i * 5}" width="6" height="${8 + i * 5}" rx="1.5" fill="${i < r.level ? C.indigo : '#E5E5EA'}"/>`
221    }
222    b += lines(84, y + 23, 'body ink', wrap(r.when, 30), 16)
223    let cy = y + (r.use.length > 1 ? 3 : 15)
224    for (const u of r.use) {
225      b += chip(W - 28 - chipW({ label: u, tip: '' }), cy, { label: u, tip: u }, C.indigo)
226      cy += 25
227    }
228    if (r.note) b += text(W - 28, y + (r.use.length ? 48 : 31), 'sm mut', r.note, ' text-anchor="end"')
229    y += 60
230  }
231  y += 18
232  const pw = (W - 32 - 12) / 2
233  const panel = (x: number, title: string, sub: string) =>
234    `<rect class="card" x="${x}" y="${y}" width="${pw}" height="196" rx="10"/>` +
235    `<text x="${x + 12}" y="${y + 22}" font-size="13.5" font-weight="700" font-family="${MONO}" class="ink">${esc(title)}</text>` +
236    text(x + 12, y + 39, 'sm mut', sub)
237  const ax = 16
238  b += panel(ax, '/arena', 'one brief, many tries')
239  b += box(ax + pw / 2 - 40, y + 52, 80, 'one brief', C.slate)
240  for (let i = 0; i < 3; i++) {
241    const bx = ax + 14 + i * ((pw - 28 - 48) / 2)
242    b += `<line class="ln" stroke-width="1.5" x1="${ax + pw / 2}" y1="${y + 76}" x2="${bx + 24}" y2="${y + 92}"/>`
243    b += box(bx, y + 92, 48, `try ${'ABC'[i]}`, C.indigo)
244    b += `<line class="ln" stroke-width="1.5" x1="${bx + 24}" y1="${y + 116}" x2="${ax + pw / 2}" y2="${y + 132}"/>`
245  }
246  b += box(ax + pw / 2 - 40, y + 132, 80, 'judge', C.gray)
247  b += down(ax + pw / 2, y + 160)
248  b += box(ax + 14, y + 168, pw - 28, 'best base + best parts', C.green)
249  const sx = 16 + pw + 12
250  b += panel(sx, '/swarm', 'split the work, one report')
251  for (let i = 0; i < 3; i++) {
252    const bx = sx + 14 + i * ((pw - 28 - 52) / 2)
253    b += box(bx, y + 52, 52, `slice ${i + 1}`, C.teal)
254    b += down(bx + 26, y + 82)
255    b += box(bx, y + 92, 52, 'check', C.gray)
256    b += `<line class="ln" stroke-width="1.5" x1="${bx + 26}" y1="${y + 116}" x2="${sx + pw / 2}" y2="${y + 132}"/>`
257  }
258  b += box(sx + pw / 2 - 50, y + 132, 100, 'one report', C.slate)
259  const tags: [string, string][] = [['PASS', C.green], ['ISSUES', C.orange], ['BLOCKED', C.red]]
260  let tx = sx + 14
261  for (const [label, color] of tags) {
262    const w = label.length * 7 + 14
263    b += box(tx, y + 168, w, label, color)
264    tx += w + 6
265  }
266  y += 196 + 28
267  b += text(20, y, 'lab ink', '/interrogate sorts every finding')
268  b += text(20, y + 17, 'sm mut', 'It applies nothing on its own. Read the dismissals too.')
269  const buckets: [string, string][] = [['Act on', C.red], ['Consider', C.orange], ['Noted', C.gray], ['Dismissed', C.slate]]
270  const bw = (W - 32 - 18) / 4
271  buckets.forEach(([label, color], i) => {
272    b += `<rect x="${16 + i * (bw + 6)}" y="${y + 28}" width="${bw}" height="34" rx="8" fill="${color}"/>`
273    b += `<text x="${16 + i * (bw + 6) + bw / 2}" y="${y + 50}" text-anchor="middle" fill="#fff" class="lab">${label}</text>`
274  })
275  return doc(y + 80, b)
276}
277
278// ---------- Overnight ----------
279
280const CONTRACT: [string, string][] = [
281  ['Goal', 'migrate every caller to the new parser'],
282  ['Done means', 'zero old callers, all fixtures pass'],
283  ['Fresh worktree', 'keeps it off your other work'],
284  ['Permission', 'commit freely; no merge, deploy or post'],
285  ['Escape hatch', '"if truly stuck, stop and write up why"'],
286]
287
288const LOOP = ['Check the finish line', 'Smallest change', 'Verify for real', 'Keep or discard', 'Log one decision']
289
290const BIG: Item[] = [
291  { label: 'Autopilot full', kind: 'play', tip: 'Independent PRs merged by morning. A fresh verifier clears every merge.' },
292  { label: 'Autopilot stack', kind: 'play', tip: 'One verified stack. Nothing merges; you land it.' },
293  { label: 'Orchestrate', kind: 'play', tip: 'A multi-day program. A coordinator runs subagents and never writes code.' },
294]
295
296const overnightSvg = () => {
297  let b = ''
298  b += text(20, 34, 'h ink', 'Leave it running overnight')
299  b += text(20, 54, 'sub mut', 'Five lines make it safe to walk away.')
300  let y = 70
301  b += `<rect class="card" x="16" y="${y}" width="${W - 32}" height="${CONTRACT.length * 32 + 14}" rx="10"/>`
302  CONTRACT.forEach(([k, v], i) => {
303    const ry = y + 14 + i * 32
304    b += `<circle cx="38" cy="${ry + 11}" r="11" fill="${C.pink}"/>`
305    b += `<text x="38" y="${ry + 15.5}" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">${i + 1}</text>`
306    b += text(58, ry + 15.5, 'lab ink', k)
307    b += text(166, ry + 15.5, 'body mut', v)
308  })
309  y += CONTRACT.length * 32 + 14 + 30
310  b += text(20, y, 'lab ink', 'What it does all night')
311  const cx = W / 2
312  const cy = y + 150
313  const r = 118
314  b += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${C.pink}" stroke-width="2.5" stroke-opacity=".55"/>`
315  const angle = (i: number) => -90 + i * 72
316  for (let i = 0; i < LOOP.length; i++) {
317    const mid = ((angle(i) + 36) * Math.PI) / 180
318    const px = cx + r * Math.cos(mid)
319    const py = cy + r * Math.sin(mid)
320    b += `<path d="M-6,-6 L6,0 L-6,6z" fill="${C.pink}" transform="translate(${px.toFixed(1)} ${py.toFixed(1)}) rotate(${(angle(i) + 36 + 90).toFixed(1)})"/>`
321  }
322  LOOP.forEach((label, i) => {
323    const a = (angle(i) * Math.PI) / 180
324    const px = cx + r * Math.cos(a)
325    const py = cy + r * Math.sin(a)
326    const w = label.length * 7 + 22
327    b += `<rect x="${(px - w / 2).toFixed(1)}" y="${(py - 14).toFixed(1)}" width="${w}" height="28" rx="14" fill="${i === 3 ? C.slate : C.pink}"/>`
328    b += `<text x="${px.toFixed(1)}" y="${(py + 4.5).toFixed(1)}" text-anchor="middle" fill="#fff" font-size="12" font-weight="700">${esc(label)}</text>`
329  })
330  b += `<text x="${cx}" y="${cy - 4}" text-anchor="middle" fill="${C.pink}" font-size="16" font-weight="700" font-family="${MONO}">/loop</text>`
331  b += text(cx, cy + 15, 'sm mut', 'until "done" is true', ' text-anchor="middle"')
332  y = cy + r + 40
333  b += `<rect x="16" y="${y}" width="${W - 32}" height="52" rx="10" fill="${C.red}" fill-opacity=".12" stroke="${C.red}" stroke-opacity=".5"/>`
334  b += text(30, y + 21, 'lab ink', '"Work on this for 4 hours" is not a finish line.')
335  b += text(30, y + 39, 'body mut', 'Give it a check that can pass or fail.')
336  y += 76
337  b += text(20, y, 'lab ink', 'Next morning')
338  b += chip(20, y + 12, { label: '/show-me-your-work catch me up', tip: 'Summarizes the night from the decision log. A second model reviews it first.' }, C.pink)
339  b += text(20, y + 58, 'body ink', 'Read its Attention section first,')
340  b += text(20, y + 75, 'body ink', 'then only the log rows it points at.')
341  y += 106
342  b += text(20, y, 'lab ink', 'A whole queue instead of one task?')
343  const big = chips(20, y + 10, W - 16, BIG, C.pink)
344  b += big.svg
345  return doc(big.bottom + 20, b)
346}
347
348// ---------- Principles ----------
349
350const GROUPS: { name: string; color: string; items: Item[] }[] = [
351  {
352    name: 'How much to build',
353    color: C.blue,
354    items: [
355      { label: 'Laziness protocol', kind: 'name', tip: 'Prefer deletion and the smallest change that solves it.' },
356      { label: 'Foundational thinking', kind: 'name', tip: 'Choose the core data structures before writing logic.' },
357      { label: 'Redesign from first principles', kind: 'name', tip: 'Fit a new requirement in as if it had been there from day one.' },
358      { label: 'Subtract before you add', kind: 'name', tip: 'Remove dead weight before building on top of it.' },
359      { label: 'Minimize reader load', kind: 'name', tip: 'Collapse layers and hidden state a reader must hold in their head.' },
360      { label: 'Outcome-oriented execution', kind: 'name', tip: 'Go straight to the target design; skip throwaway in-between states.' },
361      { label: 'Experience first', kind: 'name', tip: 'Choose the user’s result over what is easy to build.' },
362      { label: 'Exhaust the design space', kind: 'name', tip: 'With no precedent, build two or three competing prototypes.' },
363      { label: 'Build the lever', kind: 'name', tip: 'Write the script that does or proves the work, so anyone can rerun it.' },
364    ],
365  },
366  {
367    name: 'Where state and rules live',
368    color: C.indigo,
369    items: [
370      { label: 'Model the domain', kind: 'name', tip: 'Put a repeated rule in one structure, not scattered ifs.' },
371      { label: 'Boundary discipline', kind: 'name', tip: 'Validate at the edge, then trust internal types.' },
372      { label: 'Type system discipline', kind: 'name', tip: 'Make illegal states impossible to write.' },
373      { label: 'Make operations idempotent', kind: 'name', tip: 'Running it twice ends in the same state as once.' },
374      { label: 'Migrate callers, then delete legacy', kind: 'name', tip: 'Move every caller and delete the old API in one wave.' },
375      { label: 'Separate before serializing', kind: 'name', tip: 'Remove the sharing before adding locks.' },
376    ],
377  },
378  {
379    name: 'What counts as proof',
380    color: C.green,
381    items: [
382      { label: 'Prove it works', kind: 'name', tip: 'Check the real app or output, not a green build.' },
383      { label: 'Fix root causes', kind: 'name', tip: 'Reproduce and trace to the cause before changing code.' },
384      { label: 'Sequence verifiable units', kind: 'name', tip: 'Finish each small step with a check before the next.' },
385    ],
386  },
387  {
388    name: 'Parallel work',
389    color: C.teal,
390    items: [
391      { label: 'Guard the context window', kind: 'name', tip: 'Send bulk reading to subagents; keep findings in the main chat.' },
392      { label: 'Never block on the human', kind: 'name', tip: 'Do the reversible work, then show the result.' },
393    ],
394  },
395  { name: 'Meta', color: C.pink, items: [{ label: 'Encode lessons in structure', kind: 'name', tip: 'Advice you gave twice becomes a lint, check or script.' }] },
396]
397
398const principlesSvg = () => {
399  let b = ''
400  b += text(20, 34, 'h ink', 'Principles are names you say')
401  b += text(20, 54, 'sub mut', 'Mid-task, say the name to steer it. Hover for the rule.')
402  b += `<rect class="card" x="16" y="68" width="${W - 32}" height="34" rx="10"/>`
403  b += `<text x="30" y="90" font-size="13" font-family="${MONO}" class="ink">"apply prove it works. show me the real output."</text>`
404  let y = 130
405  for (const g of GROUPS) {
406    b += `<rect x="20" y="${y - 11}" width="4" height="14" rx="2" fill="${g.color}"/>`
407    b += text(30, y, 'lab ink', `${g.name}  ·  ${g.items.length}`)
408    const f = chips(20, y + 10, W - 16, g.items, g.color)
409    b += f.svg
410    y = f.bottom + 32
411  }
412  b += lines(20, y - 6, 'body mut', wrap('Each reply names the principle it applied and the decision it changed. A name with no decision behind it was name-dropped.', 62), 17)
413  return doc(y + 34, b)
414}
415
416// ---------- Recipes ----------
417
418// The session's own model writes these plans, since it can read the repo and
419// the ticket. The mod only hands it the prompt.
420const PLAN_OVERNIGHT =
421  "/poteto-mode plan an overnight run for <ticket>. don't start it. read the ticket and the repo, find the real commands that prove it's done, and write the overnight prompt: goal, done means, fresh worktree, permissions, escape hatch, and \"open a PR but do not merge, deploy, or post\". then list anything that would stop it overnight: permission prompts, missing tests, the Mac sleeping. wait for my go."
422const PLAN_TASK = "/poteto-mode help me write the prompt for: <rough goal in your words>. read the repo, find how we'd prove it works, give me the prompt. don't start."
423const OVERNIGHT_RUN =
424  "/poteto-mode im going to bed. <goal> in a fresh worktree off main. done means <checks that pass or fail>. keep a decision log. don't ask me before committing. open a PR when done, but do not merge, deploy, or post anything. /loop until done. if you're truly stuck after a few hours, stop and write up why."
425
426const RECIPES: { title: string; prompt: string }[] = [
427  { title: 'Plan an overnight run', prompt: PLAN_OVERNIGHT },
428  { title: 'Plan any task', prompt: PLAN_TASK },
429  { title: 'Fix a bug', prompt: '/poteto-mode <symptom>. repro first. if there is a cheap test path, /tdd it. then fix and verify.' },
430  { title: 'Add a feature', prompt: '/poteto-mode add <behavior>. <what must not change> stays the same. show me the evidence.' },
431  { title: 'Understand code', prompt: 'use /how to understand <area>. then use /why to find out why it changed recently.' },
432  { title: 'Second opinion on a plan', prompt: 'ask /arena for a second opinion on this thread and our approach' },
433  { title: 'Skeptical review', prompt: "/interrogate the whole branch, skeptically. don't change anything yet. no nitpicks unless it's a real bug." },
434  { title: 'Open a PR', prompt: '/poteto-mode open the pr. small ordered commits, evidence in the description.' },
435  { title: 'Get a PR green', prompt: '/poteto-mode babysit this pr. get it green.' },
436  { title: 'Overnight run', prompt: OVERNIGHT_RUN },
437  { title: 'Morning audit', prompt: '/show-me-your-work catch me up on what you did last night' },
438  { title: 'Catch me up', prompt: '/recall catch me up on <topic>' },
439  { title: 'Say it plainly', prompt: '/bro' },
440]
441
442const PITFALLS: [string, string][] = [
443  ['"use /how, then /architect, then /arena"', 'State the goal. The playbook orders the steps.'],
444  ['"make it better"', 'Give a check that can pass or fail.'],
445  ['"the build passed, so it works"', 'Show the real command, flow or output.'],
446  ['Two agents in one worktree', 'One worktree per attempt.'],
447  ['/arena to cover many slices', '/swarm for slices. /arena for one brief.'],
448  ['Accepting every review comment', 'Let /interrogate sort and dismiss with reasons.'],
449]
450
451const pitfallsSvg = () => {
452  let b = ''
453  b += text(20, 30, 'h ink', 'Mistakes everyone makes once')
454  const cw = (W - 32 - 10) / 2
455  b += text(28, 56, 'lab', 'Instead of', ` fill="${C.red}"`)
456  b += text(16 + cw + 22, 56, 'lab', 'Do this', ` fill="${C.green}"`)
457  let y = 66
458  for (const [bad, good] of PITFALLS) {
459    const left = wrap(bad, 30)
460    const right = wrap(good, 30)
461    const h = Math.max(left.length, right.length) * 16 + 18
462    b += `<rect x="16" y="${y}" width="${cw}" height="${h}" rx="8" fill="${C.red}" fill-opacity=".1"/>`
463    b += `<rect x="${16 + cw + 10}" y="${y}" width="${cw}" height="${h}" rx="8" fill="${C.green}" fill-opacity=".12"/>`
464    b += lines(28, y + 22, 'body ink', left, 16)
465    b += lines(16 + cw + 22, y + 22, 'body ink', right, 16)
466    y += h + 8
467  }
468  return doc(y + 10, b)
469}
470
471// ---------- You are here ----------
472
473const PLAYBOOK_STAGE: Record<string, number> = {
474  investigation: 0,
475  'runtime-forensics': 0,
476  'trace-forensics': 0,
477  'session-pickup': 0,
478  prototype: 1,
479  'multi-phase-plan': 1,
480  'bug-fix': 2,
481  feature: 2,
482  refactoring: 2,
483  'perf-issue': 2,
484  hillclimb: 2,
485  'visual-parity': 2,
486  'authoring-a-skill': 2,
487  eval: 3,
488  'opening-a-pr': 4,
489  babysit: 4,
490  shipping: 4,
491  'worktree-cleanup': 4,
492  'pause-safely': 5,
493  'autonomous-run': 5,
494  'autopilot-full': 5,
495  'autopilot-stack': 5,
496  orchestrate: 5,
497}
498
499const SKILL_STAGE: Record<string, number> = {
500  how: 0,
501  why: 0,
502  teach: 0,
503  recall: 0,
504  architect: 1,
505  arena: 1,
506  swarm: 1,
507  interrogate: 1,
508  tdd: 2,
509  unslop: 2,
510  'no-comments': 2,
511  'blast-radius': 3,
512  'create-verification-skill': 3,
513  'maintain-verification-skill': 3,
514  'figure-it-out': 5,
515  'show-me-your-work': 5,
516}
517
518const TRACKED = new Set([...Object.keys(SKILL_STAGE), 'poteto-mode', 'bro', 'reflect', 'automate-me'])
519const BUILD = new Set(['bug-fix', 'feature', 'refactoring', 'perf-issue', 'hillclimb', 'visual-parity', 'authoring-a-skill'])
520const FINDINGS = new Set(['investigation', 'runtime-forensics', 'trace-forensics'])
521const LONG_RUNS = new Set(['autonomous-run', 'autopilot-full', 'autopilot-stack', 'orchestrate', 'figure-it-out'])
522
523const playbookName = (slug: string) => (slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ')).replace(/\bpr\b/, 'PR')
524
525const parseSteps = (md: string) =>
526  md
527    .split('\n')
528    .map(line => /^\d+\.\s+(.*)$/.exec(line)?.[1])
529    .filter((s): s is string => Boolean(s))
530    .map(s => s.replace(/[*`]/g, '').split(/(?<=[a-z)])[.;:]\s/)[0] ?? s)
531    .map(s => cut(s.split(' ').slice(0, 8).join(' '), 48))
532
533const stageAt = (n: number | undefined): SharedId | null => (n === undefined ? null : (SHARED[n]?.id ?? null))
534
535// A step that starts moves the stage when its words name one. Matched in order,
536// so "Reproduce the duplicate write" is reading, not building.
537const TASK_STAGE: [RegExp, SharedId][] = [
538  [/\b(open (the|a) pr|pull request|push|land|babysit|ship it)\b/i, 'ship'],
539  [/\b(verify|review|prove|blast[- ]radius|interrogate|audit|evidence)\b/i, 'verify'],
540  [/\b(design|architect|spec|plan|decide|ticket|tickets)\b/i, 'design'],
541  [/\b(read|explore|map|trace|investigate|reproduce|repro|understand|recall|find)\b/i, 'understand'],
542  [/\b(implement|build|fix|write|add|refactor|code|test)\b/i, 'build'],
543]
544
545export const taskStage = (subject: string): SharedId | null => TASK_STAGE.find(([re]) => re.test(subject))?.[1] ?? null
546
547type Next = { label: string; prompt: string; why: string }
548
549const nextMove = (l: Live): { now: Next; then: string[] } => {
550  const open = l.tasks.filter(t => t.status !== 'completed')
551  const pb = l.playbook ?? ''
552  const used = new Set(l.skills)
553  if (!l.playbook && l.tasks.length === 0 && l.skills.length === 0) {
554    return {
555      now: { label: 'Start with a goal and a check', prompt: '/poteto-mode <goal>. <how you will know it is done>.', why: 'poteto-mode picks the playbook from your words.' },
556      then: ['It reads the principles', 'then lists the playbook steps'],
557    }
558  }
559  const current = open.find(t => t.status === 'in_progress') ?? open[0]
560  if (current) {
561    return { now: { label: 'Let it keep going', prompt: 'continue', why: `It is on: ${current.subject}` }, then: open.filter(t => t !== current).slice(0, 2).map(t => t.subject) }
562  }
563  if (l.prMerged || pb === 'shipping') {
564    return { now: { label: 'Start the next task', prompt: '/poteto-mode new task. <goal>. <how to check>.', why: 'Say "new task" so it picks a fresh playbook.' }, then: [] }
565  }
566  if (pb === 'babysit') {
567    return { now: { label: 'Land it', prompt: '/poteto-mode land the stack.', why: 'Babysit stops at green. Shipping verifies each PR, then lands it.' }, then: ['Start the next task'] }
568  }
569  if (l.prOpened || pb === 'opening-a-pr') {
570    return { now: { label: 'Get the PR green', prompt: '/poteto-mode babysit this pr. get it green.', why: 'It works through conflicts, review threads and CI. It never merges.' }, then: ['Land it'] }
571  }
572  if (FINDINGS.has(pb)) {
573    return { now: { label: 'Act on what it found', prompt: '/poteto-mode new task. fix <what it found>. repro first, then fix and verify.', why: 'An investigation only reports. The fix is a new task.' }, then: ['Open the PR'] }
574  }
575  if (LONG_RUNS.has(pb) || used.has('figure-it-out')) {
576    return { now: { label: 'Audit the run', prompt: '/show-me-your-work catch me up on what you did', why: 'Read its Attention section first.' }, then: ['Open the PR'] }
577  }
578  if (BUILD.has(pb) && !used.has('blast-radius')) {
579    return { now: { label: 'Check what else it could break', prompt: '/blast-radius', why: 'It proves the one fact the change is safe because of.' }, then: ['Open the PR', 'Get it green'] }
580  }
581  if (BUILD.has(pb)) {
582    return { now: { label: 'Open the PR', prompt: '/poteto-mode open the pr. small ordered commits, evidence in the description.', why: 'It cleans the diff and puts the evidence in the description.' }, then: ['Get it green', 'Land it'] }
583  }
584  return { now: { label: 'Review it skeptically', prompt: "/interrogate the whole branch, skeptically. don't change anything yet.", why: 'Other models try to break it before it ships.' }, then: ['Open the PR'] }
585}
586
587// ---------- Step advice ----------
588
589type Family = 'Haiku' | 'Sonnet' | 'Opus' | 'Fable'
590type Effort = 'Low' | 'Medium' | 'High'
591type Tier = { model: Family; effort: Effort; why: string }
592
593// Matched in order against a step's words. The roles follow pstack's model
594// roles: medium is the default, high is for judgment, design and hard bugs,
595// low is for mechanical edits.
596const TIER_RULES: [RegExp, Tier][] = [
597  [/\b(rename|sweep|bump|typo|format|lint|move files?|delete dead)\b/i, { model: 'Haiku', effort: 'Low', why: 'a mechanical edit' }],
598  [/\b(spec|write[- ]?up|write the spec|describe|summar|pr description|docs?|explain|reply|rule on|for your ruling)\b/i, { model: 'Fable', effort: 'High', why: 'judgment and prose' }],
599  [/\b(decide|decision|choose|weigh|trade-?offs?|polic(y|ies)|authori[sz]|permission|security|sealed|custody|provenance)\b/i, { model: 'Fable', effort: 'High', why: 'a call that is costly to unwind' }],
600  [/\b(architect|design|root cause|cause|binary-?search|concurren|race|algorithm|schema|data shape)\b/i, { model: 'Opus', effort: 'High', why: 'design or a hard bug' }],
601  [/\b(read|explore|map|trace|investigate|find|list|inventory|gather|look|check|audit|recall)\b/i, { model: 'Sonnet', effort: 'Medium', why: 'reading and mapping' }],
602]
603const DEFAULT_TIER: Tier = { model: 'Sonnet', effort: 'Medium', why: 'routine build work' }
604
605const tierFor = (subject: string): Tier => TIER_RULES.find(([re]) => re.test(subject))?.[1] ?? DEFAULT_TIER
606
607const FAMILY_RANK: Record<Family, number> = { Haiku: 1, Sonnet: 2, Opus: 3, Fable: 3 }
608const EFFORT_RANK: Record<string, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
609
610const familyOf = (model: string): Family | null => {
611  const m = model.toLowerCase()
612  if (m.includes('fable')) return 'Fable'
613  if (m.includes('opus')) return 'Opus'
614  if (m.includes('sonnet')) return 'Sonnet'
615  if (m.includes('haiku')) return 'Haiku'
616  return null
617}
618
619const capital = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s)
620
621type Fit = { kind: 'under' | 'over' | 'match' | 'unknown'; text: string }
622
623const powerFit = (need: Tier, power: Power): Fit => {
624  const family = familyOf(power.model)
625  const effort = EFFORT_RANK[power.effort.toLowerCase()]
626  if (!family || effort === undefined) return { kind: 'unknown', text: `Needs ${need.model} · ${need.effort}. The session's model shows after its next step.` }
627  const now = `${family} · ${capital(power.effort)}`
628  const needEffort = EFFORT_RANK[need.effort.toLowerCase()] ?? 2
629  const wrongFamily = need.model === 'Fable' && family !== 'Fable'
630  if (wrongFamily || FAMILY_RANK[family] < FAMILY_RANK[need.model] || effort < needEffort) {
631    return { kind: 'under', text: `Needs ${need.model} · ${need.effort} for ${need.why}. This session is on ${now}. Switch now if this step matters.` }
632  }
633  if (FAMILY_RANK[family] > FAMILY_RANK[need.model] || effort > needEffort) {
634    return { kind: 'over', text: `${need.model} · ${need.effort} is enough for ${need.why}. This session is on ${now}, which costs more.` }
635  }
636  return { kind: 'match', text: `${now} fits ${need.why}.` }
637}
638
639const ADVICE_PROMPT = (l: Live, step: string) =>
640  'You coach the user, who runs AI coding agents but does not write code. ' +
641  'An agent is working through a pstack playbook, a checklist it follows step by step. ' +
642  'Write about the step it is on now.\n' +
643  'Line 1 starts with "WHAT:" and says in plain words what the agent is doing in this step and why it matters.\n' +
644  'Each line is one sentence under 22 words. Use short everyday words. No dashes, no markdown, no lists.\n\n' +
645  `Playbook: ${l.playbook ? playbookName(l.playbook) : 'none named'}\n` +
646  `All steps:\n${l.tasks.map(t => `- ${t.subject}`).join('\n')}\n` +
647  `Step now: ${step}`
648
649// WHAT is the step; every other "name: line" is one method's line.
650export const parseAdvice = (said: string): Advice | null => {
651  const what = /WHAT:\s*(.+)/i.exec(said)?.[1]?.trim()
652  if (!what) return null
653  const would: Record<string, string> = {}
654  for (const line of said.split('\n')) {
655    const found = /^[\s*-]*([a-z][\w-]*)\s*:\s*(.+)$/i.exec(line)
656    const name = found?.[1]?.toLowerCase()
657    if (found && name && name !== 'what' && name !== 'move') would[name] = (found[2] ?? '').trim()
658  }
659  return { what, would, isReady: true }
660}
661
662const norm = (l: Live): Live => ({ ...EMPTY_LIVE, ...l, stage: l.stage ?? null, advice: l.advice ?? {}, power: l.power ?? EMPTY_LIVE.power })
663
664// A step the agent is under-powered for turns the next move into a switch.
665const moveFor = (l: Live) => {
666  const running = l.tasks.find(t => t.status === 'in_progress')
667  const need = running ? tierFor(running.subject) : null
668  const base = nextMove(l)
669  if (need && powerFit(need, l.power).kind === 'under') {
670    return { now: { label: `Switch to ${need.model} · ${need.effort}`, prompt: 'continue', why: 'Stop it if this step matters, pick the model in the selector, then say continue.' }, then: base.then }
671  }
672  return base
673}
674
675// What pstack would do at each stage when Haiku has not said, or cannot.
676const WOULD: Record<SharedId, string> = {
677  understand: 'Map how the code works with /how and /why before changing anything.',
678  design: 'Settle the types and boundaries with /architect, and /arena for one hard call.',
679  build: 'Follow the playbook: a repro or failing test first, then the smallest fix.',
680  verify: 'Prove it on the real app or output, and run /blast-radius on the change.',
681  ship: 'Open small ordered commits as a PR with the evidence, and babysit it green.',
682  overnight: 'Give it a finish line it can check, then audit the night with /show-me-your-work.',
683}
684const WOULD_IDLE = 'Say the goal and how to check it to /poteto-mode, and let it pick the playbook.'
685
686const running = (l: Live) => l.tasks.find(t => t.status === 'in_progress')
687
688const column = (raw: Live, would: string | undefined): Column => {
689  const l = norm(raw)
690  const stage = SHARED.find(x => x.id === l.stage)
691  const rows: Row[] =
692    l.tasks.length > 0
693      ? l.tasks.slice(0, 10).map(t => {
694          const need = tierFor(t.subject)
695          return { label: t.subject, mark: t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'now' : 'todo', tag: `${need.model} · ${need.effort}` }
696        })
697      : l.steps.slice(0, 10).map(x => ({ label: x, mark: 'todo' as const }))
698  const sections: Section[] = []
699  const task = running(l)
700  if (task) {
701    const advice = l.advice[task.id]
702    sections.push({ title: "What's happening", body: advice?.what ?? 'Working out advice for this step…' })
703    const fit = powerFit(tierFor(task.subject), l.power)
704    sections.push({ title: 'Power', body: fit.text, tone: fit.kind === 'under' ? 'bad' : fit.kind === 'match' ? 'good' : undefined })
705  } else if (rows.length === 0) {
706    sections.push({ title: 'Nothing running yet', body: l.playbook ? 'Waiting for its task list.' : 'Start a task with /poteto-mode and this fills in.' })
707  }
708  const finished = l.tasks.filter(t => t.status === 'completed').length
709  return {
710    stage: stage?.name ?? 'Not started',
711    title: l.playbook ? `${playbookName(l.playbook)} playbook` : l.tasks.length ? `Task list · ${finished} of ${l.tasks.length} done` : 'No playbook yet',
712    rows,
713    sections,
714    would: would ?? (l.stage ? WOULD[l.stage] : WOULD_IDLE),
715  }
716}
717
718const brief = (raw: Live) => {
719  const l = norm(raw)
720  const task = running(l)
721  return `stage ${l.stage ?? 'not started'}${l.playbook ? `, ${playbookName(l.playbook)} playbook` : ''}${task ? `, step "${task.subject}"` : ''}`
722}
723
724// ---------- Guide pages ----------
725
726const GUIDE: GuidePage[] = [
727  {
728    id: 'map',
729    label: 'Map',
730    svg: mapSvg(),
731    alt: 'pstack stages from understand to overnight, with the commands for each',
732    md: `### pstack in one picture\n\n\`/poteto-mode <goal>. <how to check>.\`\n\n${STAGES.map(s => `**${s.name}:** ${s.items.map(labelOf).join(', ')}`).join('\n\n')}\n\n**Anytime:** ${ANYTIME.map(labelOf).join(', ')}`,
733    note: 'Press a command to put it in your prompt, then add what it is for.',
734    rows: [...STAGES, { name: 'Anytime', color: C.gray, items: ANYTIME }]
735      .map(st => ({ label: st.name, acts: st.items.filter(it => it.label.startsWith('/')).map(it => fill(`cmd-${it.label}`, it.label, `${it.label} `)) }))
736      .filter(r => r.acts.length > 0),
737  },
738  {
739    id: 'route',
740    label: 'Router',
741    svg: routeSvg(),
742    alt: 'How poteto-mode routes a prompt to a playbook',
743    md: `### How your words pick a playbook\n\n${ROUTES.map(r => `- **${r.name}**: ${r.say.join(' ')}`).join('\n')}\n\nThen: verify and report, with the evidence.\n\n**Words that steer:** ${PHRASES.map(p => p.label).join(', ')}`,
744  },
745  {
746    id: 'design',
747    label: 'Design',
748    svg: designSvg(),
749    alt: 'A ladder of how much design work a change needs',
750    md: `### How much design does it need?\n\n${RUNGS.map(r => `- ${r.when}: ${r.use.join(' then ') || r.note}`).join('\n')}\n\n/arena tries one brief many ways. /swarm splits the work and returns one report.`,
751  },
752  {
753    id: 'overnight',
754    label: 'Overnight',
755    svg: overnightSvg(),
756    alt: 'The overnight contract and the loop it runs',
757    md: `### Leave it running overnight\n\n${CONTRACT.map(([k, v], i) => `${i + 1}. **${k}**: ${v}`).join('\n')}\n\nLoop: ${LOOP.join(' → ')}\n\nNext morning: \`/show-me-your-work catch me up\`, read Attention first.`,
758    rows: [{ acts: [{ ...fill('plan-overnight', "Plan tonight's run", PLAN_OVERNIGHT), isPrimary: true }, fill('overnight-run', 'Start a run', OVERNIGHT_RUN)] }],
759  },
760  {
761    id: 'principles',
762    label: 'Principles',
763    svg: principlesSvg(),
764    alt: 'The 21 pstack principles in five groups',
765    md: `### Principles are names you say\n\n${GROUPS.map(g => `**${g.name}:** ${g.items.map(i => i.label).join(', ')}`).join('\n\n')}`,
766  },
767  {
768    id: 'recipes',
769    label: 'Recipes',
770    svg: pitfallsSvg(),
771    alt: 'Common pstack mistakes and what to do instead',
772    md: `### Mistakes everyone makes once\n\n${PITFALLS.map(([bad, good]) => `- Instead of ${bad}: ${good}`).join('\n')}`,
773    recipes: RECIPES,
774  },
775]
776
777// ---------- Start ----------
778
779const PLAN_ANY =
780  'ask me what I want to do with a multiple-choice question of likely next jobs in this repo, plus an other option. then write the /poteto-mode prompt with a finish line we can check, show it to me, and wait for my go.'
781
782const STARTS: Act[] = [
783  say(
784    'start-recall',
785    'Catch me up',
786    '/recall catch me up on my recent work in this repo: open branches and PRs, the last few sessions, and what I was about to build next. keep it short. end with a multiple-choice question of what to continue, with finishing open work first.',
787  ),
788  say(
789    'start-assess',
790    'Assess the project',
791    "/poteto-mode assess this whole project, read only. say in plain words what it does and what state it is in: open work, tests, known bugs, open tickets, and the weakest parts of the code. then rank the 5 moves that would take it furthest for the least work, in this order of priority: data loss and correctness bugs first, then finishing open work, then AI model routing, then architecture that will fight the next year, then UX friction, then new features. within a tier, favor the most value for the least work, each with how we would check it worked and the model and effort it needs. finishing open work counts as a move. end with a multiple-choice question of which to take on. don't change anything.",
792  ),
793  say(
794    'start-ticket',
795    'Pick a ticket',
796    "list this repo's open ready-for-agent tickets and ask me to pick one with a multiple-choice question. when I pick, start /poteto-mode implement <that ticket>, with the ticket's acceptance check as done means. open a PR when done, don't merge.",
797  ),
798  {
799    ...say(
800      'start-levelup',
801      'Level up (new model)',
802      "run the bldg-house:project-level-up skill on this repo. a new frontier model is out: use the model this session runs on unless I name another. don't write any files; give me the review pack and the ranked patch queue, then ask which to take on with a multiple-choice question.",
803    ),
804    needs: 'bldg-house@',
805  },
806  say(
807    'start-bug',
808    'Fix a bug',
809    "I want to fix a bug. ask me what's broken with a multiple-choice question built from recent errors, failing tests and open issues you can find, plus an other option. then start /poteto-mode on it: repro first, then fix and verify.",
810  ),
811  say('start-understand', 'Explain part of the app', 'ask me which part of this repo I want to understand, as a multiple-choice question of its main areas. then use /teach on it, in plain words.'),
812  say('start-any', 'Plan any task', PLAN_ANY),
813  say(
814    'start-night',
815    'Plan tonight',
816    "plan an overnight run. first ask me which ticket, as a multiple-choice question of this repo's ready-for-agent tickets. then read it and the repo, find the real commands that prove it's done, and write the overnight prompt: goal, done means, fresh worktree, permissions, escape hatch, and \"open a PR but do not merge, deploy, or post\". list anything that would stop it overnight. wait for my go.",
817  ),
818]
819
820const PLAIN_CATCH_UP = say(
821  'start-catchup',
822  'Catch me up',
823  'catch me up in plain words: what this repo does, what was built recently, and any open branches or pull requests. keep it short. end with a multiple-choice question of what to do next, with finishing open work first.',
824)
825
826const review = (n: number) => fill(`review-${n}`, 'Review it', `/interrogate pr #${n}, skeptically. don't change anything yet. no nitpicks unless it's a real bug.`)
827
828// Every button here fills the prompt: they start work, so pressing Enter is the go-ahead.
829const prActs = (pr: PullRequest, canMerge: boolean): Act[] => {
830  const n = pr.number
831  switch (pr.stage) {
832    case 'conflict':
833      return [fill(`conflict-${n}`, 'Fix the conflict', `/poteto-mode pr #${n} conflicts with main. in its own worktree, rebase it onto main, resolve the conflict, run the checks and push. don't merge.`)]
834    case 'fail':
835      return [fill(`green-${n}`, 'Get it green', `/poteto-mode babysit pr #${n}. get it green. don't merge.`)]
836    case 'draft':
837      return [
838        fill(`ready-${n}`, 'Finish the draft', `pr #${n} is a draft. check it against its ticket and show me what is left. if nothing is, show me the evidence and mark it ready for review with gh pr ready. don't merge.`),
839        review(n),
840      ]
841    case 'changes':
842      return [fill(`changes-${n}`, 'Answer the review', `/poteto-mode pr #${n} has review comments asking for changes. read them, fix what is right, reply to what is not, push. don't merge.`)]
843    case 'ready':
844      return canMerge ? [mergeAct(n), review(n)] : [review(n)]
845    default:
846      return [review(n)]
847  }
848}
849
850const AFTER: Act[] = [
851  fill('q-blast', 'Check what else breaks', '/blast-radius'),
852  fill('q-review', 'Skeptical review', "/interrogate the whole branch, skeptically. don't change anything yet. no nitpicks unless it's a real bug."),
853  fill('q-pr', 'Open the PR', '/poteto-mode open the pr. small ordered commits, evidence in the description. put Fixes <ticket> in the body.'),
854  say('q-new', 'New task', PLAN_ANY),
855]
856
857// ---------- Following the run ----------
858
859const TASK_CREATE = 'mcp__build-guide__task_create'
860const TASK_UPDATE = 'mcp__build-guide__task_update'
861
862// Desktop sessions have no TaskCreate or TaskUpdate, so poteto-mode keeps its
863// list in reply text and the tracker sees nothing. This note points it at the
864// mod's own task tools, which the tracker serves and records.
865const TASK_NOTE =
866  '\n\n(build-guide: if TaskCreate and TaskUpdate are not available in this session, use ' +
867  `${TASK_CREATE} and ${TASK_UPDATE} as the task list instead, with the same subject, taskId and status fields. ` +
868  `If they are not listed, load them with ToolSearch select:${TASK_CREATE},${TASK_UPDATE}.)`
869
870const PLAYBOOK_PATH = /(\/[^\s'"]*poteto-mode\/playbooks\/([a-z-]+)\.md)/
871
872const isPoteto = (name: string) => /(^|:)poteto-mode$/.test(name)
873const nameOf = (raw: string) => raw.split(':').pop() ?? raw
874
875// Set when a typed /poteto-mode already carried the note, so the skill text
876// that follows does not repeat it.
877let isNoted = false
878
879// A skill moves the stage only when no playbook names one: the playbook leads.
880const useSkill = (l: Live, raw: string): Live => {
881  const name = nameOf(raw)
882  if (!TRACKED.has(name) || l.skills.at(-1) === name) return l
883  const isLed = l.playbook !== null && l.playbook in PLAYBOOK_STAGE
884  const stage = isLed ? l.stage : (stageAt(SKILL_STAGE[name]) ?? l.stage)
885  return { ...l, stage, skills: [...l.skills.filter(s => s !== name), name].slice(-8) }
886}
887
888// The first playbook a run opens is the main one. Playbooks it opens later (Feature
889// ends by reading Opening a PR) only take over once every task is done.
890const opensPlaybook = (l: Live, slug: string) => {
891  if (l.playbook === slug) return false
892  const isDone = l.tasks.length > 0 && l.tasks.every(t => t.status === 'completed')
893  return !l.playbook || isDone
894}
895
896const addTask = (l: Live, subject: string, id?: string): Live => {
897  const isFresh = l.tasks.length > 0 && l.tasks.every(t => t.status === 'completed')
898  const tasks = isFresh ? [] : l.tasks
899  return { ...l, tasks: [...tasks, { id: id ?? String(tasks.length + 1), subject, status: 'pending' as const }] }
900}
901
902// A step that starts moves the stage its words name, and gets advice from Haiku, once.
903const updateTask = (l: Live, id: string, status: unknown, subject: string | undefined): Out<Live> => {
904  const started = status === 'in_progress' ? l.tasks.find(t => t.id === id) : undefined
905  const next: Live = {
906    ...l,
907    stage: started ? (taskStage(subject ?? started.subject) ?? l.stage) : l.stage,
908    tasks: l.tasks
909      .filter(t => !(t.id === id && status === 'deleted'))
910      .map((t): Task => (t.id !== id ? t : { ...t, subject: subject ?? t.subject, status: status === 'in_progress' || status === 'completed' || status === 'pending' ? status : t.status })),
911  }
912  const task = next.tasks.find(t => t.id === id)
913  if (status !== 'in_progress' || !task || next.advice[id]) return { s: next }
914  return { s: next, ask: { key: id, prompt: ADVICE_PROMPT(next, task.subject) } }
915}
916
917const explain = say('q-plain', 'Say it plainly', '/bro')
918
919export const pstack: Method<Live> = {
920  id: 'pstack',
921  label: 'pstack',
922  requires: {
923    name: 'pstack',
924    plugins: ['pstack@'],
925    skill: 'poteto-mode',
926    install: 'claude plugin marketplace add lifeofladi/pstack-claude && claude plugin install pstack@pstack-claude',
927  },
928  lead: '/poteto-mode ',
929  starts: STARTS,
930  plainStarts: [PLAIN_CATCH_UP],
931  startNote: 'Start buttons send at once and ask you to pick. Open-work buttons fill your prompt, so press Enter to send.',
932  prActs,
933  explain,
934  after: AFTER,
935  decide: {
936    who: 'the pstack method',
937    favors: 'finish open work before starting new work, reproduce before fixing, the smallest change that can be verified, proof over claims, and the cheapest model tier that does the job.',
938  },
939  guide: GUIDE,
940
941  empty: EMPTY_LIVE,
942
943  stageOf: raw => norm(raw).stage,
944  moveTo: (raw, stage) => ({ ...norm(raw), stage: SHARED.find(x => x.id === stage)?.id ?? norm(raw).stage }),
945  brief,
946  would: raw => {
947    const l = norm(raw)
948    const task = running(l)
949    return task ? l.advice[task.id]?.would : undefined
950  },
951  focus: raw => {
952    const l = norm(raw)
953    const task = running(l) ?? l.tasks.at(-1)
954    return [task?.subject ?? '', l.playbook ? playbookName(l.playbook) : ''].filter(Boolean)
955  },
956  column,
957  next: raw => {
958    const next = moveFor(norm(raw)).now
959    return { label: next.label, text: next.prompt, why: next.why }
960  },
961  phase: raw => {
962    const l = norm(raw)
963    return l.tasks.length === 0 ? 'idle' : l.tasks.every(t => t.status === 'completed') ? 'done' : 'running'
964  },
965  reset: raw => ({ ...EMPTY_LIVE, power: norm(raw).power }),
966  isBusy: l => norm(l).tasks.some(t => t.status !== 'completed'),
967
968  hooks: {
969    command: (raw, command, args) => {
970      const l = useSkill(norm(raw), command)
971      if (!isPoteto(command)) return { s: l, args }
972      isNoted = true
973      return { s: { ...l, playbook: null, steps: [] }, args: args + TASK_NOTE }
974    },
975    skill: (raw, skill, said) => {
976      const l = useSkill(norm(raw), skill)
977      if (!isPoteto(skill)) return { s: l, text: said }
978      const wasNoted = isNoted
979      isNoted = false
980      return { s: l, text: wasNoted ? said : said + TASK_NOTE }
981    },
982    tool: (raw, tool, args, output) => {
983      let l = norm(raw)
984      const path =
985        tool === 'Read' && typeof args.file_path === 'string' ? args.file_path : tool === 'Bash' && typeof args.command === 'string' ? PLAYBOOK_PATH.exec(args.command)?.[1] : undefined
986      const slug = path ? PLAYBOOK_PATH.exec(path)?.[2] : undefined
987      const read = path && slug && opensPlaybook(l, slug) ? path : undefined
988      if (tool === 'Skill' && typeof args.skill === 'string') l = useSkill(l, args.skill)
989      if (tool === 'TaskCreate' && typeof args.subject === 'string') l = addTask(l, args.subject, /#(\d+)/.exec(output)?.[1])
990      if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
991        const todos = args.todos as { content?: unknown; status?: unknown }[]
992        l = {
993          ...l,
994          tasks: todos.map((t, i): Task => ({ id: String(i + 1), subject: String(t.content ?? ''), status: t.status === 'completed' || t.status === 'in_progress' ? t.status : 'pending' })),
995        }
996      }
997      if (tool === 'Bash' && typeof args.command === 'string') {
998        if (/\bgh pr create\b/.test(args.command)) l = { ...l, prOpened: true, stage: 'ship' }
999        if (/\bgh pr merge\b/.test(args.command)) l = { ...l, prMerged: true, stage: 'ship' }
1000      }
1001      if (tool === 'TaskUpdate' && args.taskId !== undefined) {
1002        return { ...updateTask(l, String(args.taskId), args.status, typeof args.subject === 'string' ? args.subject : undefined), read }
1003      }
1004      return { s: l, read }
1005    },
1006    file: (raw, path, content) => {
1007      const l = norm(raw)
1008      const slug = PLAYBOOK_PATH.exec(path)?.[2]
1009      if (!slug || !opensPlaybook(l, slug)) return { s: l }
1010      return { s: { ...l, playbook: slug, stage: stageAt(PLAYBOOK_STAGE[slug]) ?? l.stage, steps: content === null ? [] : parseSteps(content) } }
1011    },
1012    answer: (raw, id, said) => {
1013      const l = norm(raw)
1014      const advice = (said === null ? null : parseAdvice(said)) ?? { what: 'No advice came back for this step.', would: {}, isReady: false }
1015      return { ...l, advice: { ...l.advice, [id]: advice } }
1016    },
1017    step: (raw, model, effort) => {
1018      const l = norm(raw)
1019      return l.power.model === model && l.power.effort === effort ? raw : { ...l, power: { model, effort } }
1020    },
1021  },
1022
1023  tools: [
1024    {
1025      name: 'task_create',
1026      description: 'Adds one step to the task list. Use it in place of TaskCreate when TaskCreate is not available. Returns the new task id.',
1027      inputSchema: { type: 'object', properties: { subject: { type: 'string' }, description: { type: 'string' } }, required: ['subject'] },
1028      run: (raw, args) => {
1029        const l = norm(raw)
1030        const subject = String(args.subject ?? '')
1031        const isFresh = l.tasks.length > 0 && l.tasks.every(t => t.status === 'completed')
1032        const id = String((isFresh ? 0 : l.tasks.length) + 1)
1033        return { s: addTask(l, subject, id), result: `Task #${id} created: ${subject}` }
1034      },
1035    },
1036    {
1037      name: 'task_update',
1038      description: 'Moves a task to in_progress, completed or deleted, or renames it. Use it in place of TaskUpdate when TaskUpdate is not available.',
1039      inputSchema: {
1040        type: 'object',
1041        properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'deleted'] }, subject: { type: 'string' } },
1042        required: ['taskId'],
1043      },
1044      run: (raw, args) => {
1045        const l = norm(raw)
1046        const id = String(args.taskId ?? '')
1047        if (!l.tasks.some(t => t.id === id)) return { s: l, result: `No task #${id}. Create it with ${TASK_CREATE} first.` }
1048        return { ...updateTask(l, id, args.status, typeof args.subject === 'string' ? args.subject : undefined), result: `Task #${id} updated.` }
1049      },
1050    },
1051  ],
1052}
1053