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…

Matt Nicosia's skill library, published as a Claude Code plugin marketplace.
This repo is the source of truth. Do not treat ~/.claude/skills as the canonical copy.
claude plugin marketplace add mattnicosia/bldg-skills
claude plugin install bldg-construction@bldg-skills
Install any plugin from the table below by name. A plugin is the install unit: one command brings in every skill inside it, and claude plugin update keeps it current. Nothing is copied into a second location, so nothing drifts.
| Plugin | Skills | Whose | What it is |
|---|---|---|---|
alex-hormozi | 8 | Alex Hormozi | Skills built on Alex Hormozi's frameworks: grand slam offers, lead magnets, lead machines and money models, with their interview companions. Mirror maintained by Matt Nicosia. |
bldg-agent-ops | 2 | Running agents: session worksheets and workflow scaffolding. | |
bldg-coding | 7 | Coding skills: review, debugging, architecture and the caveman pass. | |
bldg-construction | 17 | Construction estimating: drawing indexing, scope of work generation in Excel and PDF, bid leveling, proposals and job folder setup. | |
bldg-creative | 2 | Creative work: image prompting, voice, and the gauntlet loop. | |
bldg-distills | 12 | Distilled one-page methods pulled out of longer sources. | |
bldg-documents | 1 | Document generation and release. | |
bldg-house | 4 | House build skills: project and site level-ups, page teardowns, and the cloud sync auditor. | |
bldg-maker-school | 46 | The Maker School library: growth, offers, funnels, outreach and operating skills. | |
bldg-marketing | 25 | Marketing: funnels, positioning, SEO, ads, content and speed to lead. | |
bldg-social | 4 | Social: posting, scheduling and the scroll-stop technique. | |
bldg-strategy | 7 | Strategy: idea scoring, prediction, competitive work and planning. | |
bldg-website-design | 9 | Website design and build: frontend design, motion, cloning and upgrades. | |
corey-ganim | 3 | Corey Ganim | Corey Ganim's Build With AI set, v3.1.1, from Return My Time. Claude Cowork workspace onboarding, an AI readiness self-assessment on the Audit-Optimize-Automate framework, and a session context loader. The self-assessment is the paid, community-only version. |
matt-pocock | 37 | Matt Pocock | Matt Pocock's skill collection, mirrored at 1.3.1. TypeScript, testing, code review and the productivity set. Mirror maintained by Matt Nicosia; upstream docs in upstream/. |
build-guide | mod | A 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.
Plugins are a Claude Code mechanism. For Codex, Cursor or anything else that reads a SKILL.md folder, install.sh is the bridge:
./install.sh --list # every skill, as <plugin>/<skill>
./install.sh --library --link # symlink them all into the local agent skill dirs
Use --link, not the default copy. A copy drifts from the repo; a symlink cannot.
bldg-skills/
.claude-plugin/marketplace.json # the marketplace: every plugin, its path and category
plugins/<plugin>/
.claude-plugin/plugin.json # name, description, version
skills/<skill>/SKILL.md # the skills themselves
archive/ # non-skill files that came with the category
library/_ARCHIVE/ # retired skills, deliberately not published
install.sh
plugins/bldg-construction/skills/sow-generator/references/trade-checklists.md are unverified placeholders, converted from metric source material written for another market. Replace them with BLDG Estimating job history and mark them VERIFIED. Until then any output using them must be labeled ESTIMATED -- unverified ratio.library/_ARCHIVE holds retired skills and is not part of any plugin.hooks/register.tsx 889 lines1// 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}
889hooks/draw.ts 127 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
127hooks/guard.ts 58 lines1// 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}
58hooks/method.ts 169 lines1// 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>
169hooks/methods/index.ts 18 lines1import 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
18hooks/pair.ts 177 lines1// 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}
177hooks/repo.ts 137 lines1// 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]
137hooks/stages.ts 36 lines1// 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)
36hooks/target.ts 339 lines1// 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]
339hooks/turn.ts 98 lines1// 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}
98hooks/methods/pocock.ts 580 lines1// 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}
580hooks/methods/pstack.ts 1053 lines1// 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}"> <goal>.</tspan><tspan fill="${C.green}"> <how to check>.</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