A pane with a plan selector (project plans, then global), each plan's status and percentage, and the chosen plan's goal and overall progress, read from the…

A small collection of reusable SKILL.md instructions for coding agents. The skills are plain Markdown, version-controlled, and portable across compatible agent tools.
This README is the reference: what the skills are, which platforms they support, and how to install them. The [wiki][wiki] answers the other question — why the project is built this way: why a Markdown repository contains Rust crates, why the dev shell compiles bash 3.2 from source, which design choices were made and what they cost, how to verify any of it yourself, and what does not work yet.
[wiki]: https://github.com/tschallacka/ai-skills/wiki
Pick whichever fits how you work. Any of these opens the same interactive installer, where you choose the skills and the agent destination(s).
npx — installs and runs in one step, nothing left on PATH afterward:
npx --yes --package @tschallacka/ai-skills ai-skills-install
Linux / macOS — the one-command installer, no npm required:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
Windows — the identical command, run inside Git Bash or WSL2 (both provide the POSIX sh it needs; there is no separate PowerShell/cmd installer):
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
See "One-command installer" below for the full list of install destinations, and "npm installation" for a persistent ai-skills-install command on PATH instead of npx.
| Skill | Purpose | Documentation |
|---|---|---|
| Planning | Durable, resumable plans: goals, ordered steps, verification, progress trackers, handoff notes — plus a single-file HTML overview of any plan, and a live served version that updates in place. Steps complete with git-diff atomicity evidence, not decorative checkboxes. | docs |
| Bug report | A defect register in one JSON file: every entry carries its reproduction, observed vs expected, the mechanism, and the verification that fails without the fix. bugs add/bugs update write through shared validation; a closure without proof is refused. | docs |
| Todo | A work queue that outlives the conversation, in one JSON file: nested tasks, every closed item carries its evidence. todo add/todo update keep the register sound; read recipes print user-ready output. | docs |
| Brainstorm | Shapes an under-specified idea into a recorded, agreed picture (brainstorm.md) before planning, with an adversarial completion pass and a plan-vs-implement gate. | docs |
| Post-implementation review | After-the-fact review of built code with concrete proposed fixes, in three passes: implementer self-analysis, an independent solutions agent, and a critical-feedback agent that ranks every fix. | docs |
| Project-specific deviations | Records confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging. | docs |
| Resource-limited testing | Runs heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism. | docs |
| Chat | RFC-1459 IRC-over-TLS message bus for agents: a rust server a standard TLS IRC client can join, a rust client with UDP discovery and TOFU cert pinning, channels, and additive history/delta reads. | docs |
| Interactive shell | Operates a full-screen terminal program an agent has never seen - nano, mc, lynx, a pager, a menu: a rust PTY wrapper publishing each screen change as one JSONL event, compact row views and deltas to keep context small, element discovery, and a unix-socket client for keys, combos, pastes, mouse and resize. POSIX only. | docs |
| Git worktrees | Parallel agents in one repository: isolated worktree verification, per-agent trees, and merging back in a conflict-aware order without trampling the main checkout. | docs |
| Git merge resolving | Conflicts resolved by what each side changed rather than by ours/theirs: reading intent from history, unions that look like choices, regenerated output, and attributing post-merge failures to the side that caused them. | docs |
| Merge request etiquette | Descriptions a reviewer can act on, in the author's voice: a one-paragraph TLDR, the defect/cause/change body, derived from the branch's commits, and the one case where a collapsible section earns its place. | docs |
| Text etiquette | Shorthand and a clipped register for an agent's prose - chat, dev talk, and its own thinking: facts first, a shared shorthand with an ask-don't-guess rule, praise capped at gj, and the people-please prose banned. Plain english on request. | docs |
| Question etiquette | Numbered questions, lettered options, never a bullet: a reply like Q7b is unambiguous, a partial answer names exactly which numbers are still open, and a lettered list always ends with "none of these, I'll say it myself." | docs |
| AI text editor | Server-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport. | docs |
| www | A brake the human can pull, and one the agent pulls on itself when it is thrashing: stop, answer what do we have / what are the values / what are we trying to achieve, in order, then continue with one reasoned step or a numbered question. | docs |
| CI failures | What actually failed in a CI run or pipeline, from a run/pipeline id, a PR/MR number, or a branch: GitHub and GitLab detected from the git remote, named rather than chosen silently, with just the failing lines extracted per job. | docs |
| Decisions | A register of non-blocking questions raised mid-work: Q# ids, lettered options, priority and the branch they came from as context, so a question can be stubbed and left open without blocking a turn. | docs |
| rjq | Parses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq. | docs |
| Tailpipe | A less/tail for agents: pipe a command's output into a named, server-held stream with chat-style message ids; a reader lists/reads/searches/tails it from anywhere, an MCP adapter offers the same as typed tools, and a board mod shows it to a human. Idle streams are gzip-snapshotted after 15 minutes. | docs |
Use a skill only when its frontmatter trigger matches the task or when the user explicitly requests it. Each skill documents when not to activate.
"Portable" elsewhere in this repository means portable across agent tools (Claude Code, Codex, OpenCode, OpenClaw, Cline). Operating-system support is separate and stated here:
Those tools do not identify the calling agent the same way, which matters to every per-agent feature here. src/agent-session-key/HARNESS-IDENTITY.md records what each one provides, how it was measured, and the procedure for contributing a harness that is not yet listed.
| Supported | |
|---|---|
| Linux | any distribution, bash 4 or 5, GNU userland |
| macOS | 11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required |
| Windows | Git for Windows' bash with its bundled coreutils, checked by CI legs, or WSL2, which is a Linux install. The evidence and the conventions are in .agents/MAINTAINER.md 1.16, which is in a full git checkout and not part of the installed package |
The one-command install needs a POSIX sh (bootstrap.sh has no bash-only constructs), plus curl, tar, awk, and standard coreutils; the installed skills' own helper scripts need bash, POSIX coreutils, awk, sed, grep, and git. The planning skill additionally needs rjq, and on macOS resource-limited-testing needs memlimit; the installer checks for both up front and prints a per-platform install hint rather than failing partway through. Those two are the only extra runtime dependencies any skill has — in particular python3 is not required by anything that gets installed, only by this repository's own benchmark harness. CODE-STYLE.md is the contract these scripts are held to; what CI proves on each platform is mapped in .agents/MAINTAINER.md section 3 (a full git checkout has both; the installed package has neither).
One skill is genuinely OS-scoped: resource-limited-testing enforces a hard RAM cap only on Linux, via a transient systemd --user cgroup v2 scope. On macOS it uses memlimit (MIT, by Jelle Besseling), which refuses allocations past the cap instead of killing the process — a best-effort cap on real resident memory over the process tree, not a cgroup-equivalent guarantee; SKILL.md lists what it does and does not promise. On Apple Silicon macOS the installer declares memlimit a soft requirement: without it the skill still installs and the run warns that the RAM cap is not enforced. The wrapper then degrades to nice plus cpulimit (CPU throttling only); on a Linux session without a user systemd instance it falls back to ulimit -v. memlimit is not asked for on Intel Macs at all — it does not support them. Every other skill behaves identically on both.
Run this command and choose the skills and agent destination interactively:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
The installer can install all the skills or one skill, and supports these global skill roots:
| Destination | Agent or standard |
|---|---|
~/.agents/skills | Universal Agent Skills root; recommended shared destination |
~/.codex/skills | Codex CLI |
~/.claude/skills | Claude Code |
~/.config/opencode/skills | OpenCode |
~/.openclaw/skills | OpenClaw managed skills |
~/.cline/skills | Cline |
The universal root is also discovered by OpenCode and OpenClaw. Installing the same skill into multiple roots can create duplicate definitions or precedence conflicts, so choose only the roots you need.
The interactive installer checks which supported agents are present and omits roots for agents it cannot detect. Custom roots are saved in ~/.config/tsch-ai-skills/custom-locations and are offered again when they still exist.
Install the package globally to expose the installer command. The npm package keeps the skills in this repository and links ai-skills-install directly to installer/bootstrap.sh, which fetches the matching compiled installer release for your platform on first run:
npm install -g @tschallacka/ai-skills
ai-skills-install
For a one-off run without a global install:
npx --yes --package @tschallacka/ai-skills ai-skills-install
The npm package does not install skills automatically as an npm lifecycle side-effect; run the installer command when you are ready to choose a target.
Run the installer again against the same root. It compares every managed file with the repository copy and reports one of three outcomes per destination: Up to date (nothing differed), Installed (files were written), or Skipped (you declined, or the destination needs manual review).
Each installed skill contains a .version marker identifying its tag, branch, and commit. If an installed file differs from the repository version, the installer asks before replacing it. For a managed version transition that the user approves — the .version marker differs, so the change came from a new release rather than from you — old files are replaced without backups; the previous version can be restored by running the installer against its tag with AI_SKILLS_REF. Unmanaged changes still receive <file>.bak backups (.bak.1, .bak.2, … if one already exists). A symlinked skill is skipped for manual review rather than following the link and modifying an unexpected location.
Interactively, choose that one skill at the menu. Headless, name it:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --skill planning --target "$HOME/.codex/skills"
--skill may be given more than once, and each value may itself be a comma-separated list, so --skill planning --skill brainstorm and --skill planning,brainstorm install the same two. Repeats are collapsed, menu numbers work (--skill 1,4), and all selects everything wherever it appears.
Use --all, --skill, --target and --yes when the choices are already known. --target takes a single root, so installing into two roots is two runs.
# Install all skills into the shared Agent Skills root
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills"
# Unattended replacement: managed version transitions replace without backups,
# unmanaged changed files are still backed up as <file>.bak
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills" --yes
Every run ends with a summary block on stdout saying what was installed, what was not, and why; the progress and diagnostics go to stderr, so installer install … > summary.txt keeps the outcome and 2>/dev/null keeps it readable. A blocked skill is reported once — not once per root — with the commands that finish the job. This is what an --all run on a machine without rjq prints:
== Summary ==
Installed: /home/u/.agents/skills/project-specifics
Installed: /home/u/.agents/skills/resource-limited-testing
Installed: /home/u/.agents/skills/brainstorm
Installed: /home/u/.agents/skills/post-implementation-review
Skipped: planning — a hard requirement is missing, nothing was written
To install planning once its requirements are met:
1. install rjq:
sudo apt-get install -y rjq
2. replay this run:
installer install --skill planning --target /home/u/.agents/skills --yes
The install step is chosen for the detected platform and package manager, and the replay line carries the same target and flags as the run that printed it, naming the installer binary bootstrap.sh downloaded to run it. The exit status is non-zero, because four of five skills is a partial install and CI must not read it as success.
Dependencies are declared per skill, so one unsatisfiable dependency never stops the other skills from installing. Each skill ships a requires.tsv naming what it needs, on which platform and architecture, and how badly:
| Strength | Effect |
|---|---|
hard | The skill does not work without the tool. It is not installed, the run explains why and prints the replay commands, and the exit status is non-zero. |
soft | The skill works in a degraded form. It is installed, with a warning naming the tool and the capability that is lost. The exit status is unaffected. |
Currently:
planning requires rjq (hard, every platform) — without it validate-plan.sh refuses to run and the plan gates stop firing.resource-limited-testing names memlimit (soft, Apple Silicon macOS only) — see Supported platforms above for what the degraded path still does.No other skill has a runtime dependency.
| Code | Meaning |
|---|---|
| 0 | Everything requested was installed. Soft warnings do not change this. |
| 1 | A requested skill was blocked by a hard requirement, or any other error. |
| 2 | install-skill only: approval declined, nothing was written. |
| 3 | install-skill only: an unsafe collision (an existing file that is not a managed version upgrade, or a symlink). |
Codes 2 and 3 belong to the machine-facing install-skill subcommand that the planning skill's own tooling uses; the interactive, install --all, and install --skill paths only ever return 0 or 1.
Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:
| Key | Action |
|---|---|
↑/k, ↓/j, PageUp, PageDown, Home, End | move the cursor |
Enter / Space | toggle the skill under the cursor |
Tab / Shift-Tab | switch focus between the skill list and the info pane |
a / n | select all / select none |
d, r, m (info pane focused) | show dependency hints, re-verify requirements, cycle a skill's integration mode |
i | confirm and install the current selection |
q / Escape | quit without installing |
With neither --target nor --agent given, it also prompts to choose an auto-detected agent root, a saved custom directory, a new custom directory, or a for every listed root.
Review the installer before running it if you do not trust the source. Skills are instructions that may guide agents to run commands or access files.
install.sh retired in favor of a compiled Rust installer (src/installer/); installer/bootstrap.sh is the small, pure-POSIX-sh entry point (#!/usr/bin/env sh, no bash-only constructs) that detects the platform, downloads the matching release, and hands off to it — see CONTRIBUTING.md before editing either.
installer/bootstrap.sh always downloads a release archive, so it is not how a checkout installs its own local files. Build the installer and point it at the checkout with --source instead:
cargo build --release -p installer
./target/release/installer interactive --source .
--package dev (also read from $PACKAGE_SELECTION) ships the MODE: DEV files too — tests, maintainer docs — instead of filtering them out, for installing a working development copy rather than the prod set.
installer/bootstrap.sh itself accepts AI_SKILLS_REPO_URL (a different owner/repo to resolve GitHub's "latest release" redirect against) and AI_SKILLS_RELEASE_URL (an exact archive URL, bypassing that redirect entirely — how RELEASE.md verifies one specific tag).
benchmark/planning/runtime/ makes the worker/reviewer/analyzer launch, session-id extraction, and token telemetry pluggable per CLI: the active agent defaults to codex and is selected with BENCHMARK_AGENT (opencode, claude), with a shared launcher (lib-agent.sh) owning all setsid/timeout/process-group control. See benchmark/planning/runtime/README.md for the contract and first-time setup.resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.Distributed under the MIT License.
hooks/register.tsx 823 lines1import type { Register } from 'claude-code'
2import { update } from 'claude-code'
3
4import type { Drill, PlanPick } from '../types'
5
6// The plan board: the chosen plan's summary, its goals as a numbered list, and
7// the drill-down under them. A goal opens its steps, a step opens its details
8// and its testing notes. A `[browse plans]` button opens a selector over the
9// project's plans, then the global ones. Progress is read from the planning
10// skill's own files on every draw. The pick, the browse toggle and the drill
11// are kept in $.state, so a redraw keeps them.
12//
13// The board is a pane in the terminal. Desktop has no pane, so the same summary
14// is also the text the agent's `show_plan_board` tool returns, which the chat
15// shows. Toggled by `enabled` in settings.json pluginConfigs["plan-board"].options.
16
17const PANE = 'plan-board'
18const TOOL = 'show_plan_board'
19const READ = 'read_plan_board'
20const SCROLL = 'scroll_plan_board'
21const OPEN = 'open_plan_board'
22const selection = { plugin: 'plan-board', key: 'selection' } as const
23const browsing = { plugin: 'plan-board', key: 'browsing' } as const
24const drill = { plugin: 'plan-board', key: 'drill' } as const
25const NO_DRILL: Drill = { goal: null, step: null }
26
27// A row of a progress table: its first cell as `name`, its last as `status`, and
28// every cell kept so a step's name can be read from its Stepname column.
29type Row = { name: string; status: string; cells: string[] }
30type Table = { isGoals: boolean; headers: string[]; rows: Row[] }
31
32// The part of `$.fs` the progress reading needs.
33type Fs = {
34 exists: (path: string) => Promise<boolean>
35 list: (path?: string) => Promise<{ name: string; kind: string }[]>
36 read: (path: string) => Promise<string>
37 stat: (path: string) => Promise<{ mtimeMs: number }>
38}
39
40// Where the plans are: the session's own `.plans`, and the global folder.
41type Roots = { project: string; global: string }
42
43type Progress = {
44 isGoals: boolean
45 goalRows: Row[]
46 stepsByGoal: Table[]
47 doneGoals: number
48 doneSteps: number
49 totalSteps: number
50 percent: number
51}
52
53type Entry = {
54 dir: string
55 name: string
56 scope: 'project' | 'global'
57 mtimeMs: number
58 progress: Progress
59}
60
61// The planning skill writes a Markdown table per progress.md: a header row, a
62// separator, then one row per goal (with a Goalname column) or per step. The
63// status is the last column; a row counts as done when it says complete (or
64// carries the check mark) and not "incomplete".
65function tableOf(text: string): Table {
66 const lines = text.split('\n').filter(line => line.startsWith('|'))
67 const headers = (lines[0] ?? '').split('|').slice(1, -1).map(cell => cell.trim())
68 const isGoals = headers.includes('Goalname')
69 const rows = lines
70 .slice(1)
71 .filter(line => !/^\|\s*-/.test(line))
72 .map(line => {
73 const cells = line.split('|').slice(1, -1).map(cell => cell.trim())
74 return { name: cells[0] ?? '', status: cells[cells.length - 1] ?? '', cells }
75 })
76 return { isGoals, headers, rows }
77}
78
79// A step's name: its Stepname cell where the table has one, else its first cell.
80function stepName(table: Table, row: Row): string {
81 const index = table.headers.indexOf('Stepname')
82 return index >= 0 ? (row.cells[index] ?? row.name) : row.name
83}
84
85function isDone(status: string): boolean {
86 const text = status.toLowerCase()
87 if (text.includes('incomplete') || text.includes('not ')) return false
88 return text.includes('✅') || text.includes('complete')
89}
90
91function percent(done: number, total: number): number {
92 return total === 0 ? 0 : Math.round((done * 100) / total)
93}
94
95// A line of Markdown as the pane draws it: the style it takes and its text.
96type MarkdownLine = { style: 'heading' | 'quote' | 'body' | 'blank'; text: string }
97
98// Inline markers the pane cannot draw: bold, italic and code become their plain
99// text, so `**word**` reads as `word`.
100function plain(text: string): string {
101 return text
102 .replace(/\*\*(.+?)\*\*/g, '$1')
103 .replace(/__(.+?)__/g, '$1')
104 .replace(/`([^`]+)`/g, '$1')
105 .replace(/(^|\s)\*(\S[^*]*)\*/g, '$1$2')
106}
107
108// The Markdown of a planning step, line by line. Headings are bold, the `§`
109// lines the planning skill writes for each section are dimmed, bullets and
110// checkboxes become `•`, `☐` and `☑`, and a rule becomes a dashed line.
111function markdownLines(markdown: string): MarkdownLine[] {
112 return markdown.split('\n').map((raw): MarkdownLine => {
113 const line = raw.trimEnd()
114 if (line.trim() === '') return { style: 'blank', text: '' }
115 if (/^\s*-{3,}\s*$/.test(line)) return { style: 'quote', text: '─'.repeat(24) }
116 const heading = /^#{1,6}\s+(.*)$/.exec(line)
117 if (heading) return { style: 'heading', text: plain(heading[1] ?? '') }
118 if (line.startsWith('§')) return { style: 'quote', text: plain(line) }
119 const task = /^(\s*)[-*]\s+\[( |x|X)\]\s+(.*)$/.exec(line)
120 if (task) {
121 const box = task[2] === ' ' ? '☐' : '☑'
122 return { style: 'body', text: `${task[1] ?? ''}${box} ${plain(task[3] ?? '')}` }
123 }
124 const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line)
125 if (bullet) return { style: 'body', text: `${bullet[1] ?? ''}• ${plain(bullet[2] ?? '')}` }
126 return { style: 'body', text: plain(line) }
127 })
128}
129
130// One plan's progress: for a goal plan, the goals done and the steps across
131// every goal, each goal's own steps read from its progress file.
132async function measure(fs: Fs, planDir: string): Promise<Progress | null> {
133 const file = `${planDir}/progress.md`
134 if (!(await fs.exists(file))) return null
135 const table = tableOf(await fs.read(file))
136
137 if (table.isGoals) {
138 const stepsByGoal: Table[] = []
139 let doneSteps = 0
140 let totalSteps = 0
141 for (const goal of table.rows) {
142 const goalFile = `${planDir}/${goal.name}/progress.md`
143 const steps: Table = (await fs.exists(goalFile))
144 ? tableOf(await fs.read(goalFile))
145 : { isGoals: false, headers: [], rows: [] }
146 stepsByGoal.push(steps)
147 doneSteps += steps.rows.filter(row => isDone(row.status)).length
148 totalSteps += steps.rows.length
149 }
150 return {
151 isGoals: true,
152 goalRows: table.rows,
153 stepsByGoal,
154 doneGoals: table.rows.filter(row => isDone(row.status)).length,
155 doneSteps,
156 totalSteps,
157 percent: percent(doneSteps, totalSteps),
158 }
159 }
160
161 const done = table.rows.filter(row => isDone(row.status)).length
162 return {
163 isGoals: false,
164 goalRows: table.rows,
165 stepsByGoal: [],
166 doneGoals: done,
167 doneSteps: done,
168 totalSteps: table.rows.length,
169 percent: percent(done, table.rows.length),
170 }
171}
172
173// The plans under one folder, most recently changed first.
174async function listPlans(fs: Fs, base: string, scope: 'project' | 'global'): Promise<Entry[]> {
175 if (!(await fs.exists(base))) return []
176 const entries: Entry[] = []
177 for (const folder of await fs.list(base)) {
178 if (folder.kind !== 'dir') continue
179 const dir = `${base}/${folder.name}`
180 const progress = await measure(fs, dir)
181 if (!progress) continue
182 const { mtimeMs } = await fs.stat(`${dir}/progress.md`)
183 entries.push({ dir, name: folder.name, scope, mtimeMs, progress })
184 }
185 return entries.sort((a, b) => b.mtimeMs - a.mtimeMs)
186}
187
188function statusOf(progress: Progress): 'open' | 'completed' {
189 return progress.totalSteps > 0 && progress.percent === 100 ? 'completed' : 'open'
190}
191
192// Incomplete plans first, then the project's own before the global ones, then
193// the most recently changed.
194function order(a: Entry, b: Entry): number {
195 const openFirst = (entry: Entry) => (statusOf(entry.progress) === 'open' ? 0 : 1)
196 const projectFirst = (entry: Entry) => (entry.scope === 'project' ? 0 : 1)
197 return openFirst(a) - openFirst(b) || projectFirst(a) - projectFirst(b) || b.mtimeMs - a.mtimeMs
198}
199
200// Every plan, in the selector's order, and the one to show: the person's pick,
201// else the `plan` option, else the plan changed most recently.
202async function gather(
203 fs: Fs,
204 roots: Roots,
205 plan: string,
206 pick: PlanPick,
207): Promise<{ all: Entry[]; chosen: Entry | undefined }> {
208 const projectPlans = await listPlans(fs, `${roots.project}/.plans`, 'project')
209 const globalPlans = await listPlans(fs, roots.global, 'global')
210 const all = [...projectPlans, ...globalPlans].sort(order)
211 const chosen =
212 (pick && all.find(entry => entry.dir === pick.dir)) ||
213 (plan && all.find(entry => entry.name === plan)) ||
214 all.reduce<Entry | undefined>(
215 (newest, entry) => (!newest || entry.mtimeMs > newest.mtimeMs ? entry : newest),
216 undefined,
217 )
218 return { all, chosen }
219}
220
221// Where a step stands, from the status the planning skill records for it: done,
222// in progress, or not started (the skill's `incomplete`).
223function stageOf(status: string): 'done' | 'in progress' | 'not started' {
224 if (isDone(status)) return 'done'
225 if (/in[- ]progress/i.test(status)) return 'in progress'
226 return 'not started'
227}
228
229// The colour a summary line is drawn in: the plan name in cyan, a finished
230// status in green and an open one in yellow, the current goal or step in
231// magenta, and the counts plain.
232function summaryColor(line: string): string | undefined {
233 if (line.startsWith('Plan:')) return 'cyan'
234 if (line.startsWith('Status:')) return line.includes('completed') ? 'green' : 'yellow'
235 if (line.startsWith('Current')) return 'magenta'
236 return undefined
237}
238
239// The chosen plan's summary as lines of text: the pane shows them, and the tool
240// returns them for the chat.
241function summaryLines(chosen: Entry | undefined, roots: Roots): string[] {
242 if (!chosen) {
243 return [`No plans found in ${roots.project}/.plans or ${roots.global}.`]
244 }
245 const p = chosen.progress
246 const lines = [
247 `Plan: ${chosen.name} (${chosen.scope})`,
248 `Status: ${statusOf(p)}, ${p.percent}% done`,
249 ]
250
251 if (p.isGoals) {
252 const goalPct = percent(p.doneGoals, p.goalRows.length)
253 lines.push(
254 `Goals: ${p.doneGoals} of ${p.goalRows.length} done (${goalPct}%)`,
255 `Steps: ${p.doneSteps} of ${p.totalSteps} done (${p.percent}%)`,
256 )
257
258 // The current goal is the first unfinished one; once every goal is done it
259 // is the last, with its own step counts.
260 const focus = p.goalRows.findIndex(row => !isDone(row.status))
261 const index = focus === -1 ? p.goalRows.length - 1 : focus
262 const goalName = p.goalRows[index]?.name ?? '-'
263 const goalSteps = p.stepsByGoal[index]?.rows ?? []
264 const goalDone = goalSteps.filter(row => isDone(row.status)).length
265 const goalStepPct = percent(goalDone, goalSteps.length)
266 lines.push(
267 '',
268 `Current goal: ${goalName}, ${goalStepPct}% done (${goalDone} of ${goalSteps.length} steps)`,
269 )
270 } else {
271 // A plan kept as one step table: there is no goal level, so the current
272 // step is the first unfinished one.
273 const next = p.goalRows.find(row => !isDone(row.status))
274 lines.push('', `Current step: ${next?.name ?? 'all done'}`)
275 }
276 return lines
277}
278
279// The text as the pane shows it, for matching: the Markdown and bullet marks the
280// pane drops are removed, and whitespace is collapsed.
281function comparable(text: string): string {
282 return text.replace(/[`*•☐☑]/g, '').replace(/\s+/g, ' ').trim()
283}
284
285// The sections of a step that are the planning skill's own bookkeeping, not
286// something a person reads: who owns the step, what file it changes, the
287// atomicity checklist and the handoff to the next step.
288const INTERNAL_SECTION = /^(ownership|change target|atomicity check|handoff|owned work units)$/i
289
290// A step's Markdown with the bookkeeping sections and the `§` section numbers
291// taken out. What is left is the objective, the instructions, the acceptance
292// criteria, and anything else a person needs to read.
293function humanMarkdown(markdown: string): string {
294 const kept: string[] = []
295 let hidden = false
296 for (const raw of markdown.split('\n')) {
297 const heading = /^#{1,6}\s+(.*)$/.exec(raw.trim())
298 if (heading) hidden = INTERNAL_SECTION.test((heading[1] ?? '').trim())
299 if (hidden || raw.trimStart().startsWith('§')) continue
300 kept.push(raw)
301 }
302 return kept.join('\n')
303}
304
305// The heading a selected piece of text lies under, read from the step's own
306// Markdown: the last heading above the first line that holds it. Null when the
307// text is not in the file.
308function sectionOf(markdown: string, text: string): string | null {
309 const needle = comparable(text).slice(0, 60)
310 if (needle === '') return null
311 let current: string | null = null
312 for (const raw of markdown.split('\n')) {
313 const heading = /^#{1,6}\s+(.*)$/.exec(raw.trim())
314 if (heading) current = (heading[1] ?? '').trim()
315 if (comparable(raw).includes(needle)) return current ?? '(before the first heading)'
316 }
317 return null
318}
319
320// The drill as the chosen plan can show it: a goal or step that is not in the
321// plan falls back to the plan's top level, so the pane is never left with no way
322// back. Reads only; the stored drill is left as it is.
323function resolveDrill(drillAt: Drill, chosen: Entry | undefined): Drill {
324 const p = chosen?.progress
325 if (!p || !p.isGoals || drillAt.goal === null) return NO_DRILL
326 const index = p.goalRows.findIndex(row => row.name === drillAt.goal)
327 if (index === -1) return NO_DRILL
328 if (drillAt.step === null) return drillAt
329 const steps = p.stepsByGoal[index]
330 const found = steps?.rows.some(row => stepName(steps, row) === drillAt.step) ?? false
331 return found ? drillAt : { goal: drillAt.goal, step: null }
332}
333
334// The redraw timer's cancel handle, kept so a re-registered session replaces the
335// timer rather than adding a second one.
336let tick: { cancel: () => void } | undefined
337
338// The key a line of a drawn Markdown block is addressed by, so a scroll can land on it.
339function blockKey(block: string, index: number): string {
340 return `${block}-${index}`
341}
342
343export const register: Register = (on, options) => {
344 if (options.enabled === false) return
345
346 const plan = String(options.plan ?? '')
347 const plansDir = String(options.plansDir ?? '')
348
349 on('session.start', async ($, e, next) => {
350 await $.command.register({
351 name: 'plan-board',
352 description: 'Show the progress of a plan in a pane; `/plan-board close` hides it',
353 })
354 await $.tool.register({
355 name: TOOL,
356 description:
357 'Show the progress board of a plan (its goals and steps done, and the current goal) to the person. Opens the pane where there is one, and returns the summary as text for the chat.',
358 inputSchema: { type: 'object', properties: {} },
359 })
360 await $.tool.register({
361 name: READ,
362 description:
363 'Read which plan, goal and step the person has open in the plan board, and the file of that step. Use it when the person refers to "this step" or "this goal". Read-only.',
364 inputSchema: { type: 'object', properties: {} },
365 })
366 await $.tool.register({
367 name: OPEN,
368 description:
369 'Open a plan in the plan board at a goal, and optionally a step, so the person sees it: the plan by name (its folder name), the goal by its name, the step by its name. Only the plan is required.',
370 inputSchema: {
371 type: 'object',
372 properties: {
373 plan: { type: 'string', description: 'The plan folder name, e.g. windows-interactive-shell.' },
374 goal: { type: 'string', description: 'The goal folder name, e.g. 01-backend-abstraction.' },
375 step: { type: 'string', description: 'The step file name without .md, under the goal.' },
376 },
377 required: ['plan'],
378 },
379 })
380 await $.tool.register({
381 name: SCROLL,
382 description:
383 'Scroll the plan board to a paragraph of the plan, goal, step or testing notes it shows, by words in that paragraph. Lists nothing; says when no line matches.',
384 inputSchema: {
385 type: 'object',
386 properties: { text: { type: 'string', description: 'Words in the paragraph to scroll to.' } },
387 required: ['text'],
388 },
389 })
390 // The pane is redrawn every few seconds, so progress made by the agent
391 // shows up without the person asking again.
392 tick?.cancel()
393 tick = $.clock.every(5000, () => $.ui.invalidate('ui.render'))
394
395 return next(e)
396 })
397
398 on('command.run', { command: 'plan-board' }, async ($, e) => {
399 if (e.args.trim().toLowerCase() === 'close') {
400 await $.ui.close({ id: PANE })
401 return { text: 'Plan board closed.' }
402 }
403 await $.ui.open({ id: PANE, title: 'Plan progress' })
404
405 return { text: 'Plan board opened.' }
406 })
407
408 // The agent's way to show the board. It opens the pane where the surface has
409 // one, and returns the summary as text, so a surface without a pane (the
410 // desktop app) shows it in the chat.
411 on('tool.call', { tool: `mcp__plan-board__${TOOL}` }, async $ => {
412 const home = (await $.env.get('HOME')) ?? ''
413 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
414 const roots: Roots = {
415 project: await $.session.root(),
416 global: plansDir || `${xdg || `${home}/.config`}/tsch-ai-skills/plans`,
417 }
418 const fs: Fs = {
419 exists: path => $.fs.exists(path),
420 list: path => $.fs.list(path),
421 read: path => $.fs.read(path),
422 stat: path => $.fs.stat(path),
423 }
424 const { value } = await $.state.get(selection)
425 const { chosen } = await gather(fs, roots, plan, value ?? null)
426 await $.ui.open({ id: PANE, title: 'Plan progress' })
427
428 return {
429 result: ['Plan board shown to the person:', ...summaryLines(chosen, roots)].join('\n'),
430 }
431 })
432
433 // The agent's way to open a plan in the board at a goal and step: the person's pick,
434 // browse and drill are set as if they had chosen it, so the board draws it.
435 on('tool.call', { tool: `mcp__plan-board__${OPEN}` }, async ($, e) => {
436 const home = (await $.env.get('HOME')) ?? ''
437 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
438 const roots: Roots = {
439 project: await $.session.root(),
440 global: plansDir || `${xdg || `${home}/.config`}/tsch-ai-skills/plans`,
441 }
442 const fs: Fs = {
443 exists: path => $.fs.exists(path),
444 list: path => $.fs.list(path),
445 read: path => $.fs.read(path),
446 stat: path => $.fs.stat(path),
447 }
448 const wanted = String(e.plan ?? '').trim()
449 const { all } = await gather(fs, roots, plan, null)
450 const found = all.find(entry => entry.name === wanted)
451 if (!found) {
452 return { result: `No plan named "${wanted}". Plans: ${all.map(entry => entry.name).join(', ')}` }
453 }
454 const goal = e.goal ? String(e.goal).trim() : null
455 const step = e.step ? String(e.step).trim() : null
456 if (goal !== null && !(await fs.exists(`${found.dir}/${goal}`))) {
457 return { result: `Plan ${found.name} has no goal "${goal}".` }
458 }
459 if (goal !== null && step !== null && !(await fs.exists(`${found.dir}/${goal}/steps/${step}.md`))) {
460 return { result: `Goal ${goal} has no step "${step}".` }
461 }
462 await update($, selection, () => ({ dir: found.dir, scope: found.scope }))
463 await update($, browsing, () => false)
464 await update($, drill, () => ({ goal, step }))
465 await $.ui.open({ id: PANE, title: 'Plan progress', focus: true })
466 return { result: `Opened ${found.name}${goal ? ` at goal ${goal}` : ''}${step ? `, step ${step}` : ''} in the plan board.` }
467 })
468
469 // The agent's way to move the board to a paragraph: the first drawn line holding the
470 // words, searched in the text the board shows now (a step and its testing notes, a
471 // goal, or the plan's own text).
472 on('tool.call', { tool: `mcp__plan-board__${SCROLL}` }, async ($, e) => {
473 const home = (await $.env.get('HOME')) ?? ''
474 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
475 const roots: Roots = {
476 project: await $.session.root(),
477 global: plansDir || `${xdg || `${home}/.config`}/tsch-ai-skills/plans`,
478 }
479 const fs: Fs = {
480 exists: path => $.fs.exists(path),
481 list: path => $.fs.list(path),
482 read: path => $.fs.read(path),
483 stat: path => $.fs.stat(path),
484 }
485 const { value: pick } = await $.state.get(selection)
486 const { value: picked } = await $.state.get(drill)
487 const at: Drill = picked ?? NO_DRILL
488 const { chosen } = await gather(fs, roots, plan, pick ?? null)
489 if (!chosen) return { result: 'No plan is open in the board.' }
490
491 const stepDir = at.goal !== null ? `${chosen.dir}/${at.goal}/steps` : ''
492 const shown: { block: string; file: string }[] =
493 at.step !== null
494 ? [
495 { block: 'step', file: `${stepDir}/${at.step}.md` },
496 { block: 'testing', file: `${stepDir}/${at.step}-testing.md` },
497 ]
498 : at.goal !== null
499 ? [{ block: 'goal', file: `${chosen.dir}/${at.goal}/goal.md` }]
500 : [{ block: 'plan', file: `${chosen.dir}/plan-description.md` }]
501
502 const wanted = String(e.text ?? '').trim().toLowerCase()
503 for (const { block, file } of shown) {
504 if (!(await fs.exists(file))) continue
505 const drawn = markdownLines(humanMarkdown(await fs.read(file)))
506 const index = drawn.findIndex(line => line.style !== 'blank' && line.text.toLowerCase().includes(wanted))
507 if (index >= 0) {
508 await $.ui.open({ id: PANE, title: 'Plan progress', focus: true })
509 await $.ui.scroll({ to: { key: blockKey(block, index) }, in: PANE, block: 'start' })
510 return { result: `Scrolled the ${block} to "${drawn[index]?.text ?? ''}".` }
511 }
512 }
513 return { result: `Nothing matching "${wanted}" in what the plan board shows.` }
514 })
515
516 // The agent's read of what the person has open: the plan, whether the browser
517 // is showing, and the goal and step the drill-down is at. Read-only.
518 on('tool.call', { tool: `mcp__plan-board__${READ}` }, async $ => {
519 const home = (await $.env.get('HOME')) ?? ''
520 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
521 const roots: Roots = {
522 project: await $.session.root(),
523 global: plansDir || `${xdg || `${home}/.config`}/tsch-ai-skills/plans`,
524 }
525 const fs: Fs = {
526 exists: path => $.fs.exists(path),
527 list: path => $.fs.list(path),
528 read: path => $.fs.read(path),
529 stat: path => $.fs.stat(path),
530 }
531 const { value: pick } = await $.state.get(selection)
532 const { value: isBrowsing } = await $.state.get(browsing)
533 const { value: picked } = await $.state.get(drill)
534 const at: Drill = picked ?? NO_DRILL
535 const { chosen } = await gather(fs, roots, plan, pick ?? null)
536 // The text the person selected: the engine's last selection. Its section is
537 // found in the step's own file.
538 const live = await $.ui.selection().catch(() => undefined)
539 const text = live?.text ?? null
540 const stepFile =
541 chosen && at.goal !== null && at.step !== null
542 ? `${chosen.dir}/${at.goal}/steps/${at.step}.md`
543 : null
544 const inSection =
545 stepFile && text && (await fs.exists(stepFile))
546 ? sectionOf(await fs.read(stepFile), text)
547 : null
548
549 const lines = [
550 `Plan: ${chosen ? `${chosen.name} (${chosen.scope}, ${chosen.dir})` : 'none'}`,
551 `Plan chosen: ${pick ? 'picked by the person' : 'by default (the plan option, or the most recently changed plan)'}`,
552 `Browsing plans: ${isBrowsing ? 'yes' : 'no'}`,
553 `Goal: ${at.goal ?? 'none'}`,
554 `Step: ${at.step ?? 'none'}`,
555 `Text highlighted: ${text ?? 'none'}`,
556 `Selected in section: ${text ? (inSection ?? 'not found in this step file') : 'none'}`,
557 ]
558 if (chosen && at.goal !== null && at.step !== null) {
559 lines.push(`Step file: ${chosen.dir}/${at.goal}/steps/${at.step}.md`)
560 }
561 return { result: lines.join('\n') }
562 })
563
564 // While the person has a step open in the board, the agent is told to ask
565 // them to select the text they mean when a reference like "this step" or
566 // "here" is unclear. The board's open or closed state is not kept, so an open
567 // step is the signal.
568 on('prompt.compose', async ($, e, next) => {
569 const composed = await next(e)
570 const { value: picked } = await $.state.get(drill)
571 if (!picked || picked.step === null) return composed
572
573 return {
574 sections: [
575 ...composed.sections,
576 {
577 id: 'plan-board-selection',
578 scope: 'session',
579 text:
580 'The person has a plan step open in the plan board. When they say "this step", "this section", "here" or "this paragraph" and it is not clear what they mean, call read_plan_board to see the plan, the step, the section and any text they selected. If that is still unclear, ask them to select the text they mean in the plan board.',
581 },
582 ],
583 }
584 })
585
586 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
587 const { Box, Button, Text } = $.ui.resolve(e)
588 // Markdown drawn as Text rows, each styled by its line.
589 const rendered = (markdown: string, block: string) =>
590 markdownLines(markdown).map((line, index) => {
591 const key = blockKey(block, index)
592 if (line.style === 'blank') return <Box key={key}><Text> </Text></Box>
593 if (line.style === 'heading')
594 return (
595 <Box key={key}>
596 <Text bold underline color="cyan">
597 {line.text}
598 </Text>
599 </Box>
600 )
601 if (line.style === 'quote') return <Box key={key}><Text dimColor>{line.text}</Text></Box>
602 return <Box key={key}><Text>{line.text}</Text></Box>
603 })
604
605 const home = (await $.env.get('HOME')) ?? ''
606 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
607 const roots: Roots = {
608 project: await $.session.root(),
609 global: plansDir || `${xdg || `${home}/.config`}/tsch-ai-skills/plans`,
610 }
611 const fs: Fs = {
612 exists: path => $.fs.exists(path),
613 list: path => $.fs.list(path),
614 read: path => $.fs.read(path),
615 stat: path => $.fs.stat(path),
616 }
617 const { value: pick } = await $.state.get(selection)
618 const { value: isBrowsing } = await $.state.get(browsing)
619 const { value: picked } = await $.state.get(drill)
620 const { all, chosen } = await gather(fs, roots, plan, pick ?? null)
621 // A drill naming a goal or step the plan no longer has falls back to the plan.
622 const at: Drill = resolveDrill(picked ?? NO_DRILL, chosen)
623
624 // The plan list, drawn in full while browsing: the engine scrolls it.
625 const listed = isBrowsing
626 ? all.map(entry => (
627 <Button
628 key={entry.dir}
629 onPress={() => {
630 update($, selection, () => ({ dir: entry.dir, scope: entry.scope }))
631 update($, browsing, () => false)
632 update($, drill, () => NO_DRILL) }}
633 >
634 {`${entry.dir === chosen?.dir ? '> ' : ' '}${entry.scope === 'project' ? 'project' : 'global'}: ${entry.name} ${entry.progress.percent}% ${statusOf(entry.progress)}`}
635 </Button>
636 ))
637 : []
638
639 // The drill-down under the summary: the goal the person opened, its steps,
640 // and the step's details. A goal or step that no longer exists is ignored.
641 const p = chosen?.progress
642 const goalIndex =
643 p && p.isGoals && at.goal !== null ? p.goalRows.findIndex(row => row.name === at.goal) : -1
644 const goalSteps = p && goalIndex >= 0 ? p.stepsByGoal[goalIndex] : undefined
645 // The goal's steps in order, so a step can step to its neighbours.
646 const stepNames = goalSteps ? goalSteps.rows.map(row => stepName(goalSteps, row)) : []
647 const stepIndex = at.step !== null ? stepNames.indexOf(at.step) : -1
648 const previousStep = stepIndex > 0 ? stepNames[stepIndex - 1] : undefined
649 const nextStep =
650 stepIndex >= 0 && stepIndex < stepNames.length - 1 ? stepNames[stepIndex + 1] : undefined
651 // The row of the step being viewed, for its own state under the title.
652 const viewedRow =
653 goalSteps && at.step !== null
654 ? goalSteps.rows.find(row => stepName(goalSteps, row) === at.step)
655 : undefined
656 const viewedStage = viewedRow ? stageOf(viewedRow.status) : null
657 // The goal's own text, from its goal.md, when a goal is open and no step is.
658 const goalText =
659 chosen && goalSteps && at.goal !== null && at.step === null &&
660 (await fs.exists(`${chosen.dir}/${at.goal}/goal.md`))
661 ? humanMarkdown(await fs.read(`${chosen.dir}/${at.goal}/goal.md`))
662 : null
663 // The plan's own text, from its plan-description.md, under the goal list.
664 const planText =
665 chosen && at.goal === null && (await fs.exists(`${chosen.dir}/plan-description.md`))
666 ? humanMarkdown(await fs.read(`${chosen.dir}/plan-description.md`))
667 : null
668 const stepDir = chosen && at.goal !== null ? `${chosen.dir}/${at.goal}/steps` : ''
669 const stepText =
670 goalSteps && at.step !== null && (await fs.exists(`${stepDir}/${at.step}.md`))
671 ? humanMarkdown(await fs.read(`${stepDir}/${at.step}.md`))
672 : null
673 const testingText =
674 goalSteps && at.step !== null && (await fs.exists(`${stepDir}/${at.step}-testing.md`))
675 ? humanMarkdown(await fs.read(`${stepDir}/${at.step}-testing.md`))
676 : null
677
678 const content = (
679 <Box flexDirection="column">
680 <Button variant="primary" onPress={() => update($, browsing, open => !(open ?? false))}>
681 {isBrowsing ? 'hide plans' : 'browse plans'}
682 </Button>
683 {!isBrowsing && at.goal !== null && at.step !== null && (
684 <Button variant="primary" onPress={() => update($, drill, () => ({ goal: at.goal, step: null }))}>
685 back to goal
686 </Button>
687 )}
688 <Text> </Text>
689 <Box flexDirection="row" justifyContent="space-between">
690 <Text bold inverse>
691 Plan progress
692 </Text>
693 <Button role="dismiss" onPress={() => $.ui.close({ id: PANE })}>
694 Close
695 </Button>
696 </Box>
697 <Text> </Text>
698 {!isBrowsing && viewedStage !== null && (
699 <Text color={viewedStage === 'done' ? 'green' : viewedStage === 'in progress' ? 'yellow' : undefined}>
700 {`This step: ${viewedStage}, testing notes ${testingText === null ? 'none' : 'written'}`}
701 </Text>
702 )}
703 {!isBrowsing && viewedStage !== null && <Text> </Text>}
704 {isBrowsing && <Text dimColor>{`Pick a plan, incomplete first (${all.length}):`}</Text>}
705 {listed}
706 {!isBrowsing &&
707 summaryLines(chosen, roots).map((line, index) => (
708 <Text key={index} color={summaryColor(line)}>
709 {line}
710 </Text>
711 ))}
712
713 {/* A plan without goals lists its steps and their state; a step has no
714 drill-down, because its file's location is not part of the skill's layout. */}
715 {!isBrowsing && p && !p.isGoals && p.goalRows.length > 0 && (
716 <Box flexDirection="column">
717 <Text> </Text>
718 <Text bold underline color="cyan">
719 Steps:
720 </Text>
721 {p.goalRows.map((row, index) => (
722 <Text key={index} color={isDone(row.status) ? 'green' : undefined}>
723 {`${index + 1}. ${row.name} ${stageOf(row.status)}`}
724 </Text>
725 ))}
726 </Box>
727 )}
728
729 {!isBrowsing && p && p.isGoals && at.goal === null && (
730 <Box flexDirection="column">
731 <Text> </Text>
732 <Text bold underline color="cyan">
733 Goals (open one to see its steps):
734 </Text>
735 {p.goalRows.map((goal, index) => {
736 const steps = p.stepsByGoal[index]
737 const done = steps?.rows.filter(row => isDone(row.status)).length ?? 0
738 const total = steps?.rows.length ?? 0
739 return (
740 <Button
741 key={goal.name}
742 onPress={() => {
743 update($, drill, () => ({ goal: goal.name, step: null }))
744 }}
745 >
746 {`${index + 1}. ${goal.name} ${done} of ${total} steps, ${percent(done, total)}%`}
747 </Button>
748 )
749 })}
750 <Text> </Text>
751 {planText !== null && rendered(planText, 'plan')}
752 </Box>
753 )}
754
755 {!isBrowsing && goalSteps && at.goal !== null && at.step === null && (
756 <Box flexDirection="column">
757 <Text> </Text>
758 <Button variant="primary" onPress={() => update($, drill, () => NO_DRILL)}>back to plan goals</Button>
759 {goalSteps.rows.map((row, index) => {
760 const name = stepName(goalSteps, row)
761 return (
762 <Button
763 key={name}
764 onPress={() => {
765 update($, drill, () => ({ goal: at.goal, step: name })) }}
766 >
767 {`${index + 1}. ${name} ${stageOf(row.status)}`}
768 </Button>
769 )
770 })}
771 <Text> </Text>
772 {goalText === null ? <Text bold>{`Goal: ${at.goal}`}</Text> : rendered(goalText, 'goal')}
773 </Box>
774 )}
775
776 {!isBrowsing && goalSteps && at.step !== null && (
777 <Box flexDirection="column">
778 <Text> </Text>
779 <Box flexDirection="row" justifyContent="space-between">
780 {previousStep !== undefined ? (
781 <Button variant="primary" onPress={() => update($, drill, () => ({ goal: at.goal, step: previousStep }))}>
782 {'< Previous step'}
783 </Button>
784 ) : (
785 <Text> </Text>
786 )}
787 <Button variant="primary" onPress={() => update($, drill, () => ({ goal: at.goal, step: null }))}>
788 back to steps
789 </Button>
790 {nextStep !== undefined ? (
791 <Button variant="primary" onPress={() => update($, drill, () => ({ goal: at.goal, step: nextStep }))}>
792 {'Next step >'}
793 </Button>
794 ) : (
795 <Text> </Text>
796 )}
797 </Box>
798 <Text> </Text>
799 {stepText === null ? (
800 <Text dimColor>This step has no file.</Text>
801 ) : (
802 rendered(stepText, 'step')
803 )}
804 <Text bold underline color="cyan">
805 Testing
806 </Text>
807 {testingText === null ? (
808 <Text dimColor>No testing file: the acceptance criteria above are the test.</Text>
809 ) : (
810 rendered(testingText, 'testing')
811 )}
812 </Box>
813 )}
814 </Box>
815 )
816
817 // No box of its own: the pane's height is the window, and the engine scrolls
818 // the whole tree inside it, so the plan list, goals, steps and details all
819 // scroll when they reach past the pane's bottom.
820 return content
821 })
822}
823types/index.d.ts 17 lines1// The plan the board shows, as the person picked it from the selector. Null
2// means no pick yet: the board falls back to the `plan` option, then to the
3// plan changed most recently. `browsing` is whether the plan list is open under
4// the `[browse plans]` button. `drill` is how far the person has drilled into
5// the chosen plan: a goal, then one of its steps.
6export type PlanPick = { dir: string; scope: 'project' | 'global' } | null
7export type Drill = { goal: string | null; step: string | null }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'plan-board': {
12 selection: PlanPick
13 browsing: boolean
14 drill: Drill
15 } }
16}
17