SLOPSHOPPER

plan-board

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…

newpaneguardcommandprompttool
v0.1.0MITupdated 2026-10-06tschallacka/ai-skills/mods/plan-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-board
│ ┃ Plan progress ✕ › fix the failing auth test and add an audit log call │ ┃ [ browse plans ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Plan progress [ Close ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No plans found in /work/app/.plans or ⎿ Added 2 lines, removed 1 line │ ┃ /Users/dev/.config/tsch-ai-skills/plans. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-board │ ⎿ plan-board: Plan board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan progress
[ browse plans ] Plan progress [ Close ] No plans found in /work/app/.plans or /Users/dev/.config/tsch-ai-skills/plans.
README

AI Skills

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

Install

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.

Skills

SkillPurposeDocumentation
PlanningDurable, 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 reportA 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
TodoA 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
BrainstormShapes 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 reviewAfter-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 deviationsRecords confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging.docs
Resource-limited testingRuns heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism.docs
ChatRFC-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 shellOperates 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 worktreesParallel 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 resolvingConflicts 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 etiquetteDescriptions 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 etiquetteShorthand 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 etiquetteNumbered 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 editorServer-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport.docs
wwwA 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 failuresWhat 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
DecisionsA 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
rjqParses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq.docs
TailpipeA 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.

Supported platforms

"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
Linuxany distribution, bash 4 or 5, GNU userland
macOS11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required
WindowsGit 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.

One-command installer

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:

DestinationAgent or standard
~/.agents/skillsUniversal Agent Skills root; recommended shared destination
~/.codex/skillsCodex CLI
~/.claude/skillsClaude Code
~/.config/opencode/skillsOpenCode
~/.openclaw/skillsOpenClaw managed skills
~/.cline/skillsCline

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.

npm installation

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.

Updating an existing install

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.

Installing or updating one skill

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.

Headless and CI usage

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.

Runtime dependencies

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:

StrengthEffect
hardThe 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.
softThe 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.

Exit codes

CodeMeaning
0Everything requested was installed. Soft warnings do not change this.
1A requested skill was blocked by a hard requirement, or any other error.
2install-skill only: approval declined, nothing was written.
3install-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.

Full-screen installer UI

Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:

KeyAction
↑/k, ↓/j, PageUp, PageDown, Home, Endmove the cursor
Enter / Spacetoggle the skill under the cursor
Tab / Shift-Tabswitch focus between the skill list and the info pane
a / nselect all / select none
d, r, m (info pane focused)show dependency hints, re-verify requirements, cycle a skill's integration mode
iconfirm and install the current selection
q / Escapequit 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.

Supported agent documentation

Development checkout

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).

Notes

  • The planning-skill benchmark harness is agent-agnostic. 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.
  • Skills are instructions, not standalone applications. They add no dependencies unless a skill explicitly documents one.
  • resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.
  • Read and review third-party skills before enabling them in an agent with access to sensitive files, credentials, or external systems.

License

Distributed under the MIT License.

Source 2 files
hooks/register.tsx 823 lines
1import 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}
823
types/index.d.ts 17 lines
1// 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