Turns a plan you approve in plan mode into a live checklist above the prompt that checks off each step as Claude completes it

A Claude Code plugin that turns the plan you approve in plan mode into a live checklist. When you accept a plan, Stepline splits it into steps and shows your progress in the band above the prompt, with the full checklist one press away. Claude checks each step off as it finishes, so you can follow how far a long plan has got without scrolling back through the conversation. When the work drifts from the plan, Claude keeps the checklist in step: it adds a step for something you ask for, retitles one you change, and notes a fix or a tangent it's on.
The band, right above the prompt at any terminal width:
╭──────────────────────────────────────────────────────────────────────────────╮
│ PLAN Ship the health endpoint ▰▰▰▰▰▱▱▱▱▱▱▱▱ 2/5 a: all steps │
│ Now ▶ 3. Write endpoint tests │
│ Next 4. Update the API docs │
╰──────────────────────────────────────────────────────────────────────────────╯
While Claude is on something that isn't a step, the Next row gives way to an Aside:
╭──────────────────────────────────────────────────────────────────────────────╮
│ PLAN Ship the health endpoint ▰▰▰▰▰▱▱▱▱▱▱▱▱ 2/5 a: all steps │
│ Now ▶ 3. Write endpoint tests │
│ Aside ↳ Fixing the import cycle in auth.ts │
╰──────────────────────────────────────────────────────────────────────────────╯
The full checklist, in a pane you open from the band or with /stepline:
Ship the health endpoint
▰▰▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 2/5
✔ 1. Add the HealthSerializer
✔ 2. Add the GET /health endpoint
▶ 3. Write endpoint tests
○ 4. Update the API docs
○ 5. Run the linters
Esc or /stepline closes this
Claude Code v2.1.290 or later, in the terminal or the Desktop app's Code tab. Stepline is a mod, and claude.ai chat and Cowork don't run mods.
In Claude Code, add this repository as a marketplace, then install the plugin from it:
/plugin marketplace add saadk408/stepline
/plugin install stepline@saadk408
Claude Code offers an update each time a release raises the plugin's version.
Stepline used to be called Plan Progress. If you installed plan-progress@saadk408, remove it, refresh the marketplace, then install Stepline:
/plugin uninstall plan-progress@saadk408
/plugin marketplace update saadk408
/plugin install stepline@saadk408
A plan tracked under the old name doesn't carry over. A clone loaded through CLAUDE_CODE_PLUGIN_DIRS keeps working from its folder.
To work on the plugin, clone the repository, then point Claude Code at the folder:
git clone https://github.com/saadk408/stepline.git
To load it in every session, add the folder to the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/stepline"
}
}
To try it in one session instead, start Claude Code with claude --plugin-dir /path/to/stepline. Either way, an interactive session reloads the plugin when you save a change to it.
haiku) to split the plan into an ordered checklist. It aims for 3 to 12 steps and keeps at most 20. If that call fails, it uses the plan's own numbered list or headings instead, and the pane says so.PLAN label with the plan's title, a progress bar and count, then the step being worked on (Now) and the one after it (Next).○ pending, ▶ in progress, ✔ done, ⊘ skipped. Open it with All steps in the band, or with /stepline, and close it with Esc or /stepline again. The band steps aside while the pane is open and comes back when you close it.update_step, to check each step off as it finishes, or mark it skipped. The first unfinished step counts as the one in progress, so Claude marks a step in progress only when it starts one out of order. If Claude also keeps a TodoWrite or Task list, items whose titles match a step check that step off too.In the transcript, each check-off is one dim line, such as ✔ plan 2/5 · Add the GET /health endpoint, in place of the full tool call and its result. Claude still reads the full result, and an error from the tool shows in full.
amend_plan, for when the work drifts from the plan. When you ask for something no step covers, Claude adds a step; it goes at the end with the next number, and the pane marks it with +. When you change what a step means, Claude retitles it. Before a fix or a tangent, Claude notes an aside: a few words that take the Next row's place under Now until it checks a step off or you send your next prompt. An in-progress item in Claude's todo list that matches no step shows as an aside too. In the transcript, each of these is one dim line, such as + plan 2/6 · Add rate limiting or ↳ Fixing the import cycle. Only the main conversation changes the checklist: a subagent's call to either tool does nothing, and a subagent's todo and task lists aren't mirrored, since the step that covers a subagent's task is Claude's to check off when the subagent reports back.If Claude goes back into plan mode and you approve a plan with the same title and at least one step in common, or one that keeps at least half the steps, the steps already done stay checked off, the pane says the plan was revised, and Claude is told so. A plan that looks like different work replaces the old one, and a finished plan is never revised.
✔ line in the band say so, and the band goes quiet at your next prompt.To press All steps from the keyboard, give the band focus with ctrl+x tab and press a. To fold the band away, press [-] beside it or ctrl+x ctrl+a. The band gives way to Claude Code's surveys, and whatever other plugins draw there stays in the band, under the plan. On a wide terminal the card stops growing at 120 columns.
Enter plan mode with /plan or shift+tab, then ask for a change that takes a few steps:
Add a GET /health endpoint that reports the app version and whether the database answers. Include tests and a line in the API docs.
Read the plan Claude proposes and approve it. The band shows the plan's title, a count such as 0/5, and the first step under Now. As Claude finishes each step, the bar fills, a toast such as ✔ 2. Add the GET /health endpoint (2/5) appears, and the transcript gets one dim line, ✔ plan 2/5 · Add the GET /health endpoint. Press ctrl+x tab, then a, to see every step at once.
Quit partway through, then come back later. A new session in the same project shows one line in the band, Unfinished: "Ship the health endpoint" 2/5 · /stepline to resume. Run:
/stepline
The pane opens with the checklist, and Claude gets the steps back, marked as they were. Close the pane with Esc and ask Claude to go on:
Carry on with the plan from step 3.
If you also want Claude's own todo list, ask for one after you approve the plan:
Keep a todo list for this plan as you work, one item per step, using the step titles.
When Claude marks a todo item done, the step with the same title checks off, even if Claude doesn't call update_step for it. Titles match when they're the same apart from case, punctuation, and a leading number such as 3.. A Task list (TaskCreate and TaskUpdate) works the same way.
Partway through, ask for something the plan didn't cover:
Also add a rate limit to the endpoint, 60 requests a minute.
Claude adds a step, the band's count goes from 2/5 to 2/6, a toast says + 6. Add a rate limit to the endpoint (2/6), and the pane shows the new step last, marked +. To change a step instead, say so:
For step 4, document the endpoint in the OpenAPI file, not the README.
Claude retitles step 4, and the band and pane show the new wording. When Claude stops to fix something first, the band shows an Aside row in place of Next, such as ↳ Fixing the import cycle in auth.ts, until it checks the next step off.
When you drop a plan partway, tell Claude, then clear it:
Let's stop here and leave the API docs for another day.
/stepline clear
Stepline replies Stopped tracking "Ship the health endpoint"., the band empties, the pane closes, and the saved plan for this project is removed. If Claude tries to check a step off afterwards, the tool tells it no plan is being tracked. You don't need this before a new plan: approving one replaces the old one.
| Command | What it does | | :- | :- | | /stepline | Opens the pane with the full checklist, or closes it when it's open. In a new session, it also hands the checklist back to Claude so it can carry on. | | /stepline clear | Stops tracking the plan, clears the band, and closes the pane. |
The plan is saved per project. A new session in the same project shows it as one line in the band, Unfinished: "…" 2/5 · /stepline to resume. /stepline hands the checklist back to Claude. The plan also comes back after /clear, /resume, and /branch. Approving a new plan replaces the old one. Two sessions in one project keep each other's check-offs and added steps, and a plan approved later in another session is never overwritten. When one session approves a revision of the plan, the others show it as an unfinished plan and /stepline hands the new checklist to their Claude; a check-off made there before that is refused with a line that says so.
Everything Stepline hooks, runs, sends, and stores. The names in parentheses are the ones claude plugin validate . prints on its hooks: and calls: lines. PRIVACY.md covers the same ground as a privacy policy.
$.model.complete). The plan's text goes to haiku with an instruction to split it into steps. The instruction is in hooks/plan.ts. The call runs through your own Claude Code session and credentials, and counts toward your usage. Stepline makes no other model calls.$.fs.read). When the approval doesn't carry the plan's text, Stepline reads the plan file Claude Code saved for it. It reads no other file.tool.call on ExitPlanMode, TodoWrite, TaskCreate, and TaskUpdate). Each call runs first, as Claude made it. Then Stepline reads only what it needs:ExitPlanMode, the approved plan's text or the path of its fileTodoWrite, each item's text and status; an in-progress item that matches no step shows in the band as the asideTaskCreate, the subject and the new task's idTaskUpdate, the task's id, subject, and statusIt skips calls that were denied or failed, and every call made inside a subagent. It changes none of these calls' inputs or results. The only addition is the checklist text quoted below, which goes to Claude alongside the approval.
turn.start). Stepline uses this to quiet the band at the first turn after a plan finishes, and to clear the aside. The event carries your prompt. Stepline ignores its text, and this hook can't change or block the prompt.$.tool.register, tool.call). Stepline registers mcp__stepline__update_step and mcp__stepline__amend_plan and answers calls to those two tools itself; it answers no other tool's call. They change only the checklist and the aside, and only from the main conversation: a subagent's call to either answers that it did nothing. Stepline doesn't answer any tool's permission check, its own included. Claude Code decides those calls by its own rules, and it runs a plugin's own registered tools without asking, so a check-off needs no prompt and no permission rule.tool.describe). Stepline marks update_step and amend_plan as not deferred, so their names, descriptions, and input schemas are in Claude's tool list in every session, even with no plan. That's a small, constant context cost, and it lets Claude call them without searching for them first.tool.call on ExitPlanMode, command.run). On approval, and when /stepline hands the plan back in a new session or after /clear, Claude receives the text quoted below. Claude also reads the one line each /stepline command prints and the one line each update_step or amend_plan call returns, all quoted below. Stepline adds nothing else to Claude's context.ui.render on AbovePrompt, Pane, ToolUse, and ToolResult; ui.close; $.ui.resolve, $.ui.open, $.ui.close, $.ui.panes, $.ui.toast). Its card in the band above the prompt, keeping whatever Claude Code and other plugins draw there. Its own pane, which opens only when you ask for it; Stepline is told when the pane closes, so the band can come back. Toasts. In the transcript, it redraws only the rows for its own tools' calls and results (ToolUse and ToolResult for mcp__stepline__update_step and mcp__stepline__amend_plan), and an error from either tool still shows in full. Claude Code draws every other row as usual.$.command.register, command.run). /stepline and /stepline clear, as the Commands section above describes.$.store.get, $.store.set, $.store.delete, $.session.root, $.session.id). Stepline saves the plan's title, step titles and statuses, approval time, how it was split, the project path, the id of the session whose Claude has the checklist, the ids of Task items whose titles match a step, which steps were added after the approval, when a step was last added or retitled, and the approval time of the plan it revised, if any, but not the plan's full text. These are saved as JSON in Stepline's own file under ~/.claude/plugins/store/, one entry per project. Stepline reads the entry back when a session starts and after /clear, /resume, and /branch (session.start, classic.SessionStart). /stepline clear removes the project's entry, and approving a new plan replaces it. Claude Code removes the store if it goes unused for cleanupPeriodDays. To remove everything at once, delete Stepline's file in that folder; its name starts with stepline.$.state.get, $.state.set). While a session runs, Claude Code holds the plan, the aside's few words, and two flags for Stepline: whether the pane is open, and whether a turn has started since the plan finished. Other plugins in the session can read these values, and only Stepline writes them. They last for the session; only the store entry above is kept.On approval, and when /stepline hands the plan back, Claude receives this, with the plan's title and one line per step filled in:
Stepline is tracking the approved plan "<plan title>" as a checklist the person watches above the prompt:
1. [ ] <step 1 title>
2. [ ] <step 2 title>
<one line like these for each step, up to 20>
Work through the steps in order. Call mcp__stepline__update_step with {"step": N, "status": "completed"} as soon as step N is done ("skipped" if the person drops it). The person's view treats the first unfinished step as the one in progress, so call it with "in_progress" only when you start a step out of order. If you also keep a TodoWrite or Task list, use these step titles verbatim.
When the work changes, tell Stepline with mcp__stepline__amend_plan: "add" for work the person asks for that no step covers, "retitle" when the person changes what a step means, and "aside" with a few words before unplanned work such as a fix or a tangent. Don't add steps for your own sub-tasks.
When the plan revises one approved earlier, the line This revises the plan approved earlier; steps already done are marked. follows the first.
Each step's mark is [ ] pending, [~] in progress, [x] done, or [-] skipped. On approval every step is [ ]. When /stepline hands a plan back, the marks show its progress so far.
The description of update_step in Claude's tool list reads:
Updates the checklist of the approved plan that the person watches above the prompt and in the Stepline pane. Call it with status "completed" as soon as a step is done, or "skipped" for a step the person drops. The first unfinished step counts as in progress, so use "in_progress" only when you start a step out of order. Steps are numbered as in the checklist Stepline gave you when the plan was approved.
Its two inputs are step, a whole number from 1 ("The step number from the checklist."), and status, one of pending, in_progress, completed, or skipped ("The step’s new status."). Each call returns one line, such as Step 3 is completed: Write endpoint tests. 3/5 done. Next open step: 4. Update the API docs, or an error that says what was wrong.
The description of amend_plan reads:
Changes the checklist of the approved plan that the person watches above the prompt and in the Stepline pane. "add" appends a step (title required) for work the person asks for that no step covers. "retitle" renames step N (step and title) when the person changes what a step means. "aside" shows a few words under the current step while you do unplanned work such as a fix or a tangent (title; an empty title clears it, and so does your next update_step call). Don’t add steps for your own sub-tasks.
Its inputs are action, one of add, retitle, or aside ("What changes: a step added, a step retitled, or an aside noted."), step, a whole number from 1 ("The number of the step to retitle."), and title ("The new step’s title, the step’s new title, or the aside’s few words."). Only action is required. An added or retitled step returns the same kind of line as update_step, such as Step 6 is pending: Add a rate limit to the endpoint. 2/6 done. Next open step: 3. Write endpoint tests. An aside returns Aside noted: <the words>. or Aside cleared.. From a subagent, either tool returns Only the main conversation changes the checklist: a subagent’s call does nothing.. Either tool returns an error that says what was wrong, and when another session has approved a revision of the plan, The plan was revised in another session (now "<title>", 2/6 done). Run /stepline to pick up the new checklist.
Each /stepline command prints one line in the transcript, and Claude reads it too:
Ship the health endpoint: 2/5 steps done., followed by Claude has the checklist again and can carry on with it. when the command hands the plan backClosed the checklist. when the command closes the paneStopped tracking "Ship the health endpoint". after /stepline clearNo approved plan yet. Approve one in plan mode and its steps show up here. or No plan was being tracked. when there's no plan/stepline. While Claude is working, Esc at the prompt interrupts Claude instead, and /stepline waits until Claude finishes, so use the ✕ on the pane's frame.[-] or ctrl+x ctrl+a. /stepline always opens the full checklist./plugin and check that it lists stepline among the active mods. Start Claude Code with claude --debug to see why a hook was skipped.claude plugin validate . # manifest and hooks module
claude plugin test . # tests in tests/
claude --plugin-dir . # load this checkout for one session
Claude Code writes the API's type declarations to .claude-plugin/types/ each time it loads the plugin from a folder you own, after which tsc -p . type-checks the module and the tests. Every release raises version in .claude-plugin/plugin.json and adds a CHANGELOG.md entry.
MIT. See LICENSE.
hooks/register.tsx 835 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AmendAction, Aside, Plan, PlanStep, StepStatus } from '../types'
5
6import {
7 carryOver,
8 carryTaskIds,
9 cleanTitle,
10 FALLBACK_TITLE,
11 isClosed,
12 isDone,
13 mergeSteps,
14 parsePlan,
15 parseSplit,
16 planLine,
17 SPLIT_SYSTEM,
18 splitPrompt,
19 stepFor,
20 todoAside,
21 updateResult,
22} from './plan'
23import type { Split } from './plan'
24
25const PANE = 'stepline'
26const PANE_TITLE = 'Plan'
27const TOOL = 'update_step'
28const TOOL_NAME = 'mcp__stepline__update_step'
29const AMEND = 'amend_plan'
30const AMEND_NAME = 'mcp__stepline__amend_plan'
31const MAX_STEPS = 20
32
33const planAtom = atom({ plugin: 'stepline', key: 'plan' } as const, null as Plan | null)
34const paneOpenAtom = atom({ plugin: 'stepline', key: 'isPaneOpen' } as const, false)
35const doneSeenAtom = atom({ plugin: 'stepline', key: 'isDoneSeen' } as const, false)
36const asideAtom = atom({ plugin: 'stepline', key: 'aside' } as const, null as Aside | null)
37/** Cells for the band's label column: PLAN, Now, Next. */
38const LABEL_WIDTH = 6
39/** The band's card stops growing here, so on a wide terminal the bar stays near the title. */
40const CARD_MAX_WIDTH = 120
41
42const STATUSES: readonly StepStatus[] = ['pending', 'in_progress', 'completed', 'skipped']
43const ICON: Record<StepStatus, string> = {
44 pending: '○',
45 in_progress: '▶',
46 completed: '✔',
47 skipped: '⊘',
48}
49const ICON_COLOR: Record<StepStatus, string> = {
50 pending: 'subtle',
51 in_progress: 'claude',
52 completed: 'success',
53 skipped: 'inactive',
54}
55const MARK: Record<StepStatus, string> = {
56 pending: ' ',
57 in_progress: '~',
58 completed: 'x',
59 skipped: '-',
60}
61
62const ACTIONS: readonly AmendAction[] = ['add', 'retitle', 'aside']
63const AMEND_ICON: Record<AmendAction, string> = {
64 add: '+',
65 retitle: '✎',
66 aside: '↳',
67}
68
69type Change = { n: number; status: StepStatus }
70
71const isStatus = (value: unknown): value is StepStatus =>
72 typeof value === 'string' && (STATUSES as readonly string[]).includes(value)
73const isAction = (value: unknown): value is AmendAction =>
74 typeof value === 'string' && (ACTIONS as readonly string[]).includes(value)
75const doneCount = (plan: Plan) => plan.steps.filter(step => isClosed(step.status)).length
76const storeKey = (root: string) => `plan:${root}`
77/** The step being worked on: the one in progress, else the first still open. */
78const currentStep = (plan: Plan) =>
79 plan.steps.find(step => step.status === 'in_progress') ??
80 plan.steps.find(step => !isClosed(step.status))
81
82function asPlan(value: unknown): Plan | null {
83 if (typeof value !== 'object' || value === null) return null
84 const plan = value as Partial<Plan>
85 return Array.isArray(plan.steps) && typeof plan.title === 'string' ? (plan as Plan) : null
86}
87
88async function splitPlan(
89 $: EngineInterface,
90 planText: string,
91 signal: AbortSignal
92): Promise<Split & { splitBy: Plan['splitBy'] }> {
93 try {
94 const reply = await $.model.complete(
95 {
96 model: 'haiku',
97 system: SPLIT_SYSTEM,
98 prompt: splitPrompt(planText),
99 maxTokens: 1024,
100 effort: 'low',
101 timeoutMs: 30_000,
102 },
103 { signal }
104 )
105 const split = reply.isAnswered ? parseSplit(reply.text) : undefined
106 if (split) return { ...split, splitBy: 'model' }
107 } catch {
108 // A refused request falls back to the parser like a failed one.
109 }
110 return { ...parsePlan(planText), splitBy: 'parser' }
111}
112
113// What the model and the person read
114
115function checklist(plan: Plan): string {
116 return plan.steps.map(step => `${step.n}. [${MARK[step.status]}] ${step.title}`).join('\n')
117}
118
119function announce(plan: Plan): string {
120 return [
121 `Stepline is tracking the approved plan "${plan.title}" as a checklist the person watches above the prompt:`,
122 ...(plan.revisedFrom === undefined
123 ? []
124 : ['This revises the plan approved earlier; steps already done are marked.']),
125 checklist(plan),
126 '',
127 `Work through the steps in order. Call ${TOOL_NAME} with {"step": N, "status": "completed"} as soon as step N is done ("skipped" if the person drops it). The person's view treats the first unfinished step as the one in progress, so call it with "in_progress" only when you start a step out of order. If you also keep a TodoWrite or Task list, use these step titles verbatim.`,
128 `When the work changes, tell Stepline with ${AMEND_NAME}: "add" for work the person asks for that no step covers, "retitle" when the person changes what a step means, and "aside" with a few words before unplanned work such as a fix or a tangent. Don't add steps for your own sub-tasks.`,
129 ].join('\n')
130}
131
132function progressLine(plan: Plan): string {
133 const next = plan.steps.find(step => !isClosed(step.status))
134 const count = `${doneCount(plan)}/${plan.steps.length} done`
135 return next === undefined
136 ? `All ${plan.steps.length} steps are done (${count}).`
137 : `${count}. Next open step: ${next.n}. ${next.title}`
138}
139
140/** The plan saved for the project, as this or another session left it. */
141async function storedPlan($: EngineInterface, root: string): Promise<Plan | null> {
142 return asPlan(await $.store.get(storeKey(root)))
143}
144
145/**
146 * Copies the project's saved plan into the session: at its start, where a hot
147 * reload keeps the session's own value, and after a reset of `$.state`.
148 */
149async function restorePlan(
150 $: EngineInterface,
151 reset?: 'clear' | 'resume' | 'fork'
152): Promise<Plan | null> {
153 const held = await read($, planAtom)
154 if (held !== null && reset === undefined) return held
155 const stored = await storedPlan($, await $.session.root())
156 if (stored === null) return held
157 // After /clear the model no longer holds the checklist, so the next
158 // /stepline hands it over again.
159 return update($, planAtom, () => (reset === 'clear' ? { ...stored, knownBy: '' } : stored))
160}
161
162/**
163 * Opens the full checklist, which hides the band while it's up. Only the
164 * person opens it, from the band or /stepline, so it's placed at any width.
165 * It takes the keyboard where it can, and Escape closes it: ctrl+x x only
166 * reaches a pane that already holds the keys.
167 */
168async function openPane($: EngineInterface) {
169 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true, closeOnEscape: true })
170 if (opened.isPlaced) await update($, paneOpenAtom, () => true)
171}
172
173/** Reads whether the pane is up from the engine, after `$.state` was reset. */
174async function syncPane($: EngineInterface) {
175 const panes = await $.ui.panes()
176 const isOpen = panes.some(pane => pane.id === PANE && pane.isPlaced)
177 await update($, paneOpenAtom, () => isOpen)
178}
179
180/**
181 * Writes the session's plan and saves it for the project. Every session on
182 * the machine shares the store, so a newer approval is never written over.
183 */
184async function save(
185 $: EngineInterface,
186 change: (plan: Plan | null) => Plan | null
187): Promise<Plan | null> {
188 const plan = await update($, planAtom, change)
189 if (plan !== null) {
190 const stored = await storedPlan($, plan.root)
191 if (stored === null || stored.approvedAt <= plan.approvedAt) {
192 await $.store.set(storeKey(plan.root), plan)
193 }
194 }
195 return plan
196}
197
198/**
199 * The session's plan with what other sessions saved: their progress on the
200 * same approval and the steps they added, or the plan they revised it into.
201 */
202async function withSaved($: EngineInterface, plan: Plan): Promise<Plan> {
203 const stored = await storedPlan($, plan.root)
204 if (stored === null) return plan
205 if (stored.revisedFrom === plan.approvedAt) return stored
206 if (stored.approvedAt !== plan.approvedAt) return plan
207 const amendedAt = Math.max(plan.amendedAt ?? 0, stored.amendedAt ?? 0)
208 return {
209 ...plan,
210 steps: mergeSteps(plan, stored),
211 taskIds: { ...stored.taskIds, ...plan.taskIds },
212 ...(amendedAt > 0 ? { amendedAt } : {}),
213 }
214}
215
216/**
217 * The session's plan as the store completes it, taken into the session. A
218 * plan another session revised stands in for the old one: until /stepline
219 * hands it over, the band offers it and the tools refuse to change it.
220 */
221async function currentPlan($: EngineInterface): Promise<{ plan: Plan; isRevised: boolean } | null> {
222 const held = await read($, planAtom)
223 if (held === null) return null
224 const plan = await withSaved($, held)
225 // Only a change is written, so a call that finds nothing new redraws nothing.
226 if (JSON.stringify(plan) !== JSON.stringify(held)) await update($, planAtom, () => plan)
227 const isRevised = plan.revisedFrom !== undefined && plan.knownBy !== (await $.session.id())
228 return { plan, isRevised }
229}
230
231const revisedElsewhere = (plan: Plan) =>
232 `The plan was revised in another session (now "${plan.title}", ${doneCount(plan)}/${plan.steps.length} done). Run /stepline to pick up the new checklist.`
233/** The error for a step number the plan doesn't have. */
234const noStep = (plan: Plan, step: unknown) =>
235 ({
236 result: `There is no step ${String(step)}: the plan has steps 1 to ${plan.steps.length}.`,
237 isError: true,
238 }) as const
239const NOT_CHANGED = { result: 'Stepline could not change the checklist.', isError: true } as const
240/**
241 * The main agent owns the plan: a subagent's task is one of its steps, and
242 * what the subagent finds reaches the plan through the main agent's report.
243 */
244const NOT_MAIN = {
245 result: 'Only the main conversation changes the checklist: a subagent’s call does nothing.',
246 isError: true,
247} as const
248
249/**
250 * A change to the plan's steps under its approval, for save(): made on the
251 * latest copy rather than the one the caller read, so two calls in one turn (a
252 * check-off beside an added step) both land. Another approval is left as it is.
253 */
254function revision(
255 base: Plan,
256 change: (steps: PlanStep[]) => PlanStep[],
257 extra?: Partial<Plan>
258): (latest: Plan | null) => Plan | null {
259 return latest =>
260 latest === null || latest.approvedAt !== base.approvedAt
261 ? latest
262 : { ...latest, ...extra, steps: change(latest.steps) }
263}
264
265/** What save() wrote under this approval, or null when another plan was there. */
266function written(base: Plan, plan: Plan | null): Plan | null {
267 return plan !== null && plan.approvedAt === base.approvedAt ? plan : null
268}
269
270/** Applies status changes, then toasts what was newly checked off. */
271async function mark($: EngineInterface, changes: readonly Change[]): Promise<Plan | null> {
272 const found = await currentPlan($)
273 if (found === null) return null
274 const base = found.plan
275 const wanted = changes.filter(change =>
276 base.steps.some(step => step.n === change.n && step.status !== change.status)
277 )
278 if (found.isRevised || wanted.length === 0) return base
279
280 const change = revision(base, steps =>
281 steps.map(step => {
282 const found = wanted.find(one => one.n === step.n)
283 return found ? { ...step, status: found.status } : step
284 })
285 )
286 const plan = written(base, await save($, change))
287 if (plan === null) return plan
288
289 const checked = wanted.filter(change => change.status === 'completed')
290 const total = plan.steps.length
291 if (isDone(plan) && !isDone(base)) {
292 // The band shows the finish until the next prompt.
293 await update($, doneSeenAtom, () => false)
294 $.ui.toast(`Plan complete ✔ all ${total} steps of "${plan.title}"`, { timeoutMs: 8000 })
295 } else if (checked.length === 1) {
296 const step = plan.steps.find(one => one.n === checked[0]?.n)
297 $.ui.toast(`✔ ${step?.n}. ${step?.title} (${doneCount(plan)}/${total})`)
298 } else if (checked.length > 1) {
299 $.ui.toast(`✔ ${checked.length} steps checked off (${doneCount(plan)}/${total})`)
300 }
301 return plan
302}
303
304export const register: Register = on => {
305 on('session.start', async ($, e, next) => {
306 await $.command.register({
307 name: 'stepline',
308 description: 'Show or hide the approved plan’s full checklist (clear stops tracking it)',
309 argumentHint: '[clear]',
310 })
311 await $.tool.register({
312 name: TOOL,
313 description:
314 'Updates the checklist of the approved plan that the person watches above the prompt and in the Stepline pane. Call it with status "completed" as soon as a step is done, or "skipped" for a step the person drops. The first unfinished step counts as in progress, so use "in_progress" only when you start a step out of order. Steps are numbered as in the checklist Stepline gave you when the plan was approved.',
315 inputSchema: {
316 type: 'object',
317 properties: {
318 step: { type: 'integer', minimum: 1, description: 'The step number from the checklist.' },
319 status: { type: 'string', enum: STATUSES, description: 'The step’s new status.' },
320 },
321 required: ['step', 'status'],
322 additionalProperties: false,
323 },
324 })
325 await $.tool.register({
326 name: AMEND,
327 description:
328 'Changes the checklist of the approved plan that the person watches above the prompt and in the Stepline pane. "add" appends a step (title required) for work the person asks for that no step covers. "retitle" renames step N (step and title) when the person changes what a step means. "aside" shows a few words under the current step while you do unplanned work such as a fix or a tangent (title; an empty title clears it, and so does your next update_step call). Don’t add steps for your own sub-tasks.',
329 inputSchema: {
330 type: 'object',
331 properties: {
332 action: {
333 type: 'string',
334 enum: ACTIONS,
335 description: 'What changes: a step added, a step retitled, or an aside noted.',
336 },
337 step: { type: 'integer', minimum: 1, description: 'The number of the step to retitle.' },
338 title: {
339 type: 'string',
340 description: 'The new step’s title, the step’s new title, or the aside’s few words.',
341 },
342 },
343 required: ['action'],
344 additionalProperties: false,
345 },
346 })
347
348 // A restored plan shows in the band; the pane waits until it's asked for.
349 await restorePlan($)
350 // A reload can find the pane still up.
351 await syncPane($)
352
353 return next(e)
354 })
355
356 // /clear, /resume and /branch reset `$.state` and fire no session.start.
357 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
358 await restorePlan($, e.source)
359 await syncPane($)
360
361 return next(e)
362 }).catch(($, e, next) => next(e))
363
364 // The approval: split the plan, track it, and hand the model its steps.
365 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
366 const ran = await next(e)
367 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
368 const output = ran.result
369 if (output.awaitingLeaderApproval === true) return ran
370 const planText =
371 output.plan ?? (output.filePath === undefined ? null : await $.fs.read(output.filePath))
372 if (typeof planText !== 'string' || planText.trim() === '') return ran
373
374 const split = await splitPlan($, planText, next.signal)
375 // A plan that revises the one being tracked keeps what was done of it.
376 const held = (await currentPlan($))?.plan ?? null
377 const carried = carryOver(held, split)
378 const steps = split.steps.slice(0, MAX_STEPS).map((title, i): PlanStep => ({
379 n: i + 1,
380 title,
381 status: carried.from[i]?.status ?? 'pending',
382 }))
383 const taskIds = carryTaskIds(held?.taskIds ?? {}, carried.from, steps.length)
384 const root = await $.session.root()
385 const knownBy = await $.session.id()
386 const plan = await save($, () => ({
387 title: split.title || FALLBACK_TITLE,
388 steps,
389 approvedAt: Date.now(),
390 root,
391 knownBy,
392 splitBy: split.splitBy,
393 taskIds,
394 ...(carried.isRevision && held !== null ? { revisedFrom: held.approvedAt } : {}),
395 }))
396 if (plan === null) return ran
397 // The band above the prompt shows the new plan at once, at any width.
398 await update($, doneSeenAtom, () => false)
399 await update($, asideAtom, () => null)
400
401 return { ...ran, context: [...(ran.context ?? []), announce(plan)] }
402 }).catch(($, e, next) => next(e))
403
404 // The tool the model checks steps off with.
405 on('tool.call', { tool: TOOL_NAME }, async ($, e) => {
406 if (e.agentId !== undefined) return NOT_MAIN
407 // The schema is the model's to follow, so the values are checked anyway.
408 const { step, status }: { step: unknown; status: unknown } = e
409 const found = await currentPlan($)
410 if (found === null) {
411 return { result: 'No approved plan is being tracked, so there is no step to update.', isError: true }
412 }
413 if (found.isRevised) return { result: revisedElsewhere(found.plan), isError: true }
414 const plan = found.plan
415 if (typeof step !== 'number' || !plan.steps.some(one => one.n === step)) return noStep(plan, step)
416 if (!isStatus(status)) {
417 return { result: `status must be one of ${STATUSES.join(', ')}.`, isError: true }
418 }
419 const after = (await mark($, [{ n: step, status }])) ?? plan
420 // A step reported ends whatever the model was doing off the plan.
421 await update($, asideAtom, () => null)
422 const title = after.steps.find(one => one.n === step)?.title ?? ''
423 return { result: updateResult(step, status, title, progressLine(after)) }
424 }).catch(() => ({ result: 'Stepline could not update the checklist.', isError: true }))
425
426 // The mod's own tools' schemas stay in the tool list so the model needs no
427 // ToolSearch first. Their permission checks are the person's rules' to answer.
428 on('tool.describe', { tool: TOOL_NAME }, async ($, e, next) => ({
429 ...(await next(e)),
430 isDeferred: false,
431 }))
432
433 // The tool the model changes the checklist with: a step added, one
434 // retitled, or a few words on work off the plan.
435 on('tool.call', { tool: AMEND_NAME }, async ($, e) => {
436 if (e.agentId !== undefined) return NOT_MAIN
437 const { action, step, title }: { action: unknown; step?: unknown; title?: unknown } = e
438 if (!isAction(action)) return { result: `action must be one of ${ACTIONS.join(', ')}.`, isError: true }
439 const found = await currentPlan($)
440 if (found === null) {
441 return { result: 'No approved plan is being tracked, so there is no checklist to change.', isError: true }
442 }
443 if (found.isRevised) return { result: revisedElsewhere(found.plan), isError: true }
444 const plan = found.plan
445 const text = typeof title === 'string' ? cleanTitle(title) : ''
446
447 if (action === 'aside') {
448 await update($, asideAtom, () => (text === '' ? null : { text, source: 'tool' }))
449 return { result: text === '' ? 'Aside cleared.' : `Aside noted: ${text}.` }
450 }
451 if (text === '') {
452 return { result: 'title is required: the step’s wording, under 120 characters.', isError: true }
453 }
454 if (action === 'add') {
455 if (step !== undefined) {
456 return { result: '"add" takes no step: the new step gets the next number.', isError: true }
457 }
458 if (plan.steps.length >= MAX_STEPS) {
459 return { result: `The checklist holds at most ${MAX_STEPS} steps.`, isError: true }
460 }
461 // The number is the latest list's, which a call beside this one may have grown.
462 let n = 0
463 const change = revision(
464 plan,
465 steps => {
466 n = Math.max(0, ...steps.map(one => one.n)) + 1
467 return [...steps, { n, title: text, status: 'pending', isAdded: true }]
468 },
469 { amendedAt: Date.now() }
470 )
471 const after = written(plan, await save($, change))
472 if (after === null || n === 0) return NOT_CHANGED
473 $.ui.toast(`+ ${n}. ${text} (${doneCount(after)}/${after.steps.length})`)
474 return { result: updateResult(n, 'pending', text, progressLine(after)) }
475 }
476 if (typeof step !== 'number' || !plan.steps.some(one => one.n === step)) return noStep(plan, step)
477 const change = revision(
478 plan,
479 steps => steps.map(one => (one.n === step ? { ...one, title: text } : one)),
480 { amendedAt: Date.now() }
481 )
482 const after = written(plan, await save($, change))
483 if (after === null) return NOT_CHANGED
484 const status = after.steps.find(one => one.n === step)?.status ?? 'pending'
485 return { result: updateResult(step, status, text, progressLine(after)) }
486 }).catch(() => NOT_CHANGED)
487
488 on('tool.describe', { tool: AMEND_NAME }, async ($, e, next) => ({
489 ...(await next(e)),
490 isDeferred: false,
491 }))
492
493 // Mirrors of the main conversation's own task lists, matched to steps by
494 // title. A subagent's lists are its own, as the plan is the main agent's.
495 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
496 const ran = await next(e)
497 const plan = await read($, planAtom)
498 if (plan === null || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
499 const matched = e.todos.map(todo => ({ todo, step: stepFor(plan, todo.content) }))
500 await mark($, matched.flatMap(({ todo, step }) => (step ? [{ n: step.n, status: todo.status }] : [])))
501 // An item under way that is no step is what the model is doing off the plan.
502 const off = matched.find(({ todo, step }) => step === undefined && todo.status === 'in_progress')
503 await update($, asideAtom, aside => todoAside(aside, off && cleanTitle(off.todo.content)))
504 return ran
505 }).catch(($, e, next) => next(e))
506
507 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
508 const ran = await next(e)
509 const plan = (await currentPlan($))?.plan ?? null
510 if (plan === null || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
511 const step = stepFor(plan, e.subject)
512 const id = ran.result.task.id
513 if (step !== undefined) {
514 await save($, current => current && { ...current, taskIds: { ...current.taskIds, [id]: step.n } })
515 }
516 return ran
517 }).catch(($, e, next) => next(e))
518
519 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
520 const ran = await next(e)
521 const plan = await read($, planAtom)
522 if (plan === null || e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
523 const n =
524 plan.taskIds[e.taskId] ?? (e.subject === undefined ? undefined : stepFor(plan, e.subject)?.n)
525 if (n !== undefined && isStatus(e.status)) await mark($, [{ n, status: e.status }])
526 return ran
527 }).catch(($, e, next) => next(e))
528
529 on('command.run', { command: 'stepline' }, async ($, e) => {
530 if (e.args.trim() === 'clear') {
531 const plan = await read($, planAtom)
532 if (plan === null) return { text: 'No plan was being tracked.' }
533 await update($, planAtom, () => null)
534 await update($, asideAtom, () => null)
535 // Leave a newer plan that another session approved in this project.
536 const stored = await storedPlan($, plan.root)
537 if (stored !== null && stored.approvedAt <= plan.approvedAt) {
538 await $.store.delete(storeKey(plan.root))
539 }
540 await $.ui.close({ id: PANE })
541 return { text: `Stopped tracking "${plan.title}".` }
542 }
543
544 // The keyboard's way to close the pane from the prompt.
545 if (await read($, paneOpenAtom)) {
546 await $.ui.close({ id: PANE })
547 return { text: 'Closed the checklist.' }
548 }
549
550 await openPane($)
551 const plan = (await currentPlan($))?.plan ?? null
552 if (plan === null) {
553 return { text: 'No approved plan yet. Approve one in plan mode and its steps show up here.' }
554 }
555 const summary = `${plan.title}: ${doneCount(plan)}/${plan.steps.length} steps done.`
556 const sessionId = await $.session.id()
557 if (plan.knownBy === sessionId || isDone(plan)) return { text: summary }
558
559 // A plan picked up from another session: hand the model its steps too.
560 const known = await save($, current => current && { ...current, knownBy: sessionId })
561 return {
562 text: `${summary} Claude has the checklist again and can carry on with it.`,
563 context: [announce(known ?? plan)],
564 }
565 })
566
567 // The pane closing, by the person or by the plugin, brings the band back.
568 on('ui.close', { id: PANE }, async ($, e, next) => {
569 const closed = await next(e)
570 await update($, paneOpenAtom, () => false)
571 return closed
572 }).catch(($, e, next) => next(e))
573
574 // The first turn after the plan's finish quiets the band, and any turn ends
575 // what the model was doing off the plan. turn.start only observes: the mod
576 // never sees a prompt it could change.
577 on('turn.start', async ($, e, next) => {
578 const plan = await read($, planAtom)
579 if (plan !== null && isDone(plan)) await update($, doneSeenAtom, () => true)
580 await update($, asideAtom, () => null)
581 return next(e)
582 })
583
584 // The band above the prompt: progress at a glance, the full list a press away.
585 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
586 const plan = await read($, planAtom)
587 if (plan === null || e.props.hasSurvey || (await read($, paneOpenAtom))) return next(e)
588 // A plan this session's model hasn't been given waits for /stepline.
589 const isKnown = plan.knownBy === (await $.session.id())
590 const step = currentStep(plan)
591 if (step === undefined && (!isKnown || (await read($, doneSeenAtom)))) return next(e)
592
593 const { Box, Button, Text } = $.ui.resolve(e)
594 // What the mods after this one draw stays in the band, under the plan.
595 const theirs = await next(e)
596 const width = Math.min(e.props.bodyColumns, CARD_MAX_WIDTH)
597 const total = plan.steps.length
598 const count = `${doneCount(plan)}/${total}`
599
600 // Every row starts with a label in a narrow column, so the eye can scan down it.
601 const planLabel = (
602 <Box width={LABEL_WIDTH} flexShrink={0}>
603 <Text bold color="planMode">
604 PLAN
605 </Text>
606 </Box>
607 )
608 const label = (text: string) => (
609 <Box width={LABEL_WIDTH} flexShrink={0}>
610 <Text dimColor>{text}</Text>
611 </Box>
612 )
613
614 let rows
615 if (step === undefined) {
616 rows = (
617 <Box flexDirection="row">
618 {planLabel}
619 <Box flexShrink={1}>
620 <Text color="success" wrap="truncate-end">{`✔ ${plan.title}: all ${total} steps done`}</Text>
621 </Box>
622 </Box>
623 )
624 } else if (!isKnown) {
625 rows = (
626 <Box flexDirection="row">
627 {planLabel}
628 <Box flexShrink={1}>
629 <Text dimColor wrap="truncate-end">
630 {`Unfinished: "${plan.title}" ${count} · /stepline to resume`}
631 </Text>
632 </Box>
633 </Box>
634 )
635 } else {
636 const after = plan.steps.find(one => one.n > step.n && !isClosed(one.status))
637 const barWidth = Math.max(8, Math.min(20, Math.floor(width / 6)))
638 const filled = Math.round((doneCount(plan) / Math.max(1, total)) * barWidth)
639 // What the model is doing off the plan takes the Next row's place.
640 let under: { label: string; mark: string; text: string } | undefined
641 const aside = await read($, asideAtom)
642 if (aside !== null) under = { label: 'Aside', mark: '↳ ', text: aside.text }
643 else if (after !== undefined) under = { label: 'Next', mark: ' ', text: `${after.n}. ${after.title}` }
644 rows = (
645 <Box flexDirection="column">
646 <Box flexDirection="row" justifyContent="space-between" columnGap={3}>
647 <Box flexDirection="row" flexShrink={1}>
648 {planLabel}
649 <Box flexShrink={1}>
650 <Text bold wrap="truncate-end">
651 {plan.title}
652 </Text>
653 </Box>
654 </Box>
655 <Box flexDirection="row" flexShrink={0} columnGap={3}>
656 <Box flexDirection="row">
657 <Text color="planMode">{'▰'.repeat(filled)}</Text>
658 <Text dimColor>{'▱'.repeat(barWidth - filled)}</Text>
659 <Text>{` ${count}`}</Text>
660 </Box>
661 <Button key="all-steps" label="all steps" hotkey="a" plain onPress={() => openPane($)} />
662 </Box>
663 </Box>
664 <Box flexDirection="row">
665 {label('Now')}
666 <Text color={ICON_COLOR[step.status]}>{`${ICON[step.status]} `}</Text>
667 <Box flexShrink={1}>
668 <Text bold wrap="truncate-end">{`${step.n}. ${step.title}`}</Text>
669 </Box>
670 </Box>
671 {under !== undefined && (
672 <Box flexDirection="row">
673 {label(under.label)}
674 <Text dimColor>{under.mark}</Text>
675 <Box flexShrink={1}>
676 <Text dimColor wrap="truncate-end">
677 {under.text}
678 </Text>
679 </Box>
680 </Box>
681 )}
682 </Box>
683 )
684 }
685
686 // A thin frame in the plan-mode color sets the plan apart from the transcript.
687 return (
688 <Box flexDirection="column">
689 <Box
690 flexDirection="column"
691 width={width}
692 borderStyle="round"
693 borderColor="planMode"
694 borderDimColor
695 paddingX={1}
696 >
697 {rows}
698 </Box>
699 {theirs}
700 </Box>
701 )
702 })
703
704 // In the transcript, each call of the mod's own tool is one dim line, like
705 // `✔ plan 15/18 · Install the plugin`, in place of the call and its result.
706 on('ui.render', { component: 'ToolUse', props: { tool: TOOL_NAME } }, async ($, e, next) => {
707 const { step, status } = (e.props.input ?? {}) as { step?: unknown; status?: unknown }
708 // Claude Code draws an error or an interruption in full.
709 if (e.props.isErrored || e.props.isInterrupted || typeof step !== 'number' || !isStatus(status)) {
710 return next(e)
711 }
712 const { Box, Text } = $.ui.resolve(e)
713 const line = planLine(e.props.output, `plan · step ${step}`)
714 return (
715 <Box flexDirection="row">
716 <Text color={ICON_COLOR[status]}>{`${ICON[status]} `}</Text>
717 <Box flexShrink={1}>
718 <Text dimColor wrap="truncate-end">
719 {line}
720 </Text>
721 </Box>
722 </Box>
723 )
724 })
725
726 on('ui.render', { component: 'ToolResult', props: { tool: TOOL_NAME } }, async ($, e, next) => {
727 if (e.props.isErrored) return next(e)
728 const { Box } = $.ui.resolve(e)
729 // The row above says it all; the model still reads the full result.
730 return <Box />
731 })
732
733 // Each amend_plan call too: `+ plan 2/6 · Add rate limiting`, `✎ plan 2/6 ·
734 // Use Redis for the cache`, or `↳ Fixing the import cycle` for an aside.
735 on('ui.render', { component: 'ToolUse', props: { tool: AMEND_NAME } }, async ($, e, next) => {
736 const { action, title } = (e.props.input ?? {}) as { action?: unknown; title?: unknown }
737 if (e.props.isErrored || e.props.isInterrupted || !isAction(action)) return next(e)
738 const { Box, Text } = $.ui.resolve(e)
739 const text = typeof title === 'string' ? cleanTitle(title) : ''
740 let line
741 if (action === 'aside') line = text === '' ? 'back to the steps' : text
742 else line = planLine(e.props.output, `plan · ${text}`)
743 return (
744 <Box flexDirection="row">
745 <Text color={action === 'aside' ? 'subtle' : 'planMode'}>{`${AMEND_ICON[action]} `}</Text>
746 <Box flexShrink={1}>
747 <Text dimColor wrap="truncate-end">
748 {line}
749 </Text>
750 </Box>
751 </Box>
752 )
753 })
754
755 on('ui.render', { component: 'ToolResult', props: { tool: AMEND_NAME } }, async ($, e, next) => {
756 if (e.props.isErrored) return next(e)
757 const { Box } = $.ui.resolve(e)
758 return <Box />
759 })
760
761 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
762 const { Box, Text } = $.ui.resolve(e)
763 const plan = await read($, planAtom)
764 if (plan === null) {
765 return (
766 <Box flexDirection="column">
767 <Text dimColor>No plan yet. Approve a plan in plan mode and its steps show up here.</Text>
768 <Box marginTop={1}>
769 <Text dimColor>Esc or /stepline closes this</Text>
770 </Box>
771 </Box>
772 )
773 }
774
775 const total = plan.steps.length
776 const done = doneCount(plan)
777 const isFinished = done === total
778 const current = currentStep(plan)
779 const aside = await read($, asideAtom)
780 const count = ` ${done}/${total}`
781 const barWidth = Math.max(4, Math.min(30, e.props.bodyColumns - count.length))
782 const filled = Math.round((done / Math.max(1, total)) * barWidth)
783
784 // Long plans show a window that starts just above the first open step.
785 const room = Math.max(3, (e.viewport?.rows ?? 40) - 9)
786 const firstOpen = plan.steps.findIndex(step => !isClosed(step.status))
787 const start = Math.max(0, Math.min((firstOpen < 0 ? total : firstOpen) - 1, total - room))
788 const shown = plan.steps.slice(start, start + room)
789 const below = total - start - shown.length
790
791 return (
792 <Box flexDirection="column">
793 <Text bold color={isFinished ? 'success' : 'text'} wrap="truncate-end">
794 {isFinished ? `✔ ${plan.title}` : plan.title}
795 </Text>
796 <Box flexDirection="row" marginBottom={1}>
797 <Text color="planMode">{'▰'.repeat(filled)}</Text>
798 <Text dimColor>{'▱'.repeat(barWidth - filled)}</Text>
799 <Text bold={isFinished}>{count}</Text>
800 </Box>
801 {start > 0 && <Text dimColor>{` ↑ ${start} more done`}</Text>}
802 {shown.map(step => (
803 <Box flexDirection="column">
804 <Box flexDirection="row">
805 <Text color={ICON_COLOR[step.status]}>{`${ICON[step.status]} `}</Text>
806 <Text
807 wrap="truncate-end"
808 bold={step.status === 'in_progress'}
809 dimColor={isClosed(step.status)}
810 strikethrough={step.status === 'skipped'}
811 >
812 {`${step.n}. ${step.title}${step.isAdded === true ? ' +' : ''}`}
813 </Text>
814 </Box>
815 {/* What the model is doing off the plan sits under the step it left. */}
816 {aside !== null && step.n === current?.n && (
817 <Text dimColor wrap="truncate-end">
818 {` ↳ ${aside.text}`}
819 </Text>
820 )}
821 </Box>
822 ))}
823 {below > 0 && <Text dimColor>{` ↓ ${below} more`}</Text>}
824 {plan.revisedFrom !== undefined && <Text dimColor>(revised from an earlier approval)</Text>}
825 {plan.splitBy === 'parser' && (
826 <Text dimColor>(split from the plan's own list: the model call failed)</Text>
827 )}
828 <Box marginTop={1}>
829 <Text dimColor>Esc or /stepline closes this</Text>
830 </Box>
831 </Box>
832 )
833 })
834}
835hooks/plan.ts 182 lines1// Turning an approved plan's text into steps, and matching titles to them:
2// plain functions with no `$`, so tests can call them directly.
3
4import type { Aside, Plan, PlanStep, StepStatus } from '../types'
5
6export type Split = { title: string; steps: string[] }
7
8export const cleanTitle = (text: string) =>
9 text
10 .replace(/\*\*|__|`/g, '')
11 .replace(/\s+/g, ' ')
12 .replace(/:$/, '')
13 .trim()
14 .slice(0, 120)
15
16/** A step title as matched against TodoWrite and Task subjects. */
17export const normalize = (text: string) =>
18 text
19 .toLowerCase()
20 .replace(/^\s*(step\s*)?\d+\s*[.):-]\s*/, '')
21 .replace(/[^a-z0-9]+/g, ' ')
22 .trim()
23
24export function stepFor(plan: Plan, title: string): PlanStep | undefined {
25 const key = normalize(title)
26 return key === '' ? undefined : plan.steps.find(step => normalize(step.title) === key)
27}
28
29export const SPLIT_SYSTEM =
30 'You turn an approved software implementation plan into a checklist. You reply with one JSON object and nothing else.'
31
32export function splitPrompt(planText: string): string {
33 return [
34 'Split the approved plan below into an ordered checklist of 3 to 12 steps that an engineer will carry out and check off one at a time.',
35 '- Each step is one concrete action with a visible outcome ("Add the field to the serializer", "Write tests for the endpoint"), in the order the work happens.',
36 '- Step titles are imperative, under 80 characters, with no numbering and no markdown.',
37 '- Leave out background, rationale, risks and open questions. Keep verification (tests, lint, manual checks) as its own step when the plan asks for it.',
38 '- "title" names the whole plan in under 60 characters.',
39 'Reply with exactly: {"title": "...", "steps": ["...", "..."]}',
40 '',
41 '<plan>',
42 planText,
43 '</plan>',
44 ].join('\n')
45}
46
47/** Reads the model's reply: the first `{` to the last `}`, so prose around it is fine. */
48export function parseSplit(text: string): Split | undefined {
49 const start = text.indexOf('{')
50 const end = text.lastIndexOf('}')
51 if (start < 0 || end <= start) return undefined
52 let data: unknown
53 try {
54 data = JSON.parse(text.slice(start, end + 1))
55 } catch {
56 return undefined
57 }
58 if (typeof data !== 'object' || data === null) return undefined
59 const { title, steps } = data as { title?: unknown; steps?: unknown }
60 if (!Array.isArray(steps)) return undefined
61 const titles = steps
62 .filter((step): step is string => typeof step === 'string')
63 .map(cleanTitle)
64 .filter(step => step !== '')
65 if (titles.length === 0) return undefined
66 return { title: typeof title === 'string' ? cleanTitle(title) : '', steps: titles }
67}
68
69const NOT_A_STEP =
70 /^(context|summary|overview|background|goals?|notes?|risks?|open questions|out of scope|non-goals|critical files|files( to (change|modify|touch))?)\b/i
71
72/** The fallback when the model call fails: the plan's own list, else its headings. */
73export function parsePlan(planText: string): Split {
74 const lines = planText.split('\n')
75 const heading = lines.find(line => /^#\s+\S/.test(line))
76 const title = heading === undefined ? '' : cleanTitle(heading.replace(/^#\s+/, ''))
77
78 const items = lines.flatMap(line => {
79 const match = /^(\s*)(?:\d+[.)]|[-*]\s+\[[ xX]\])\s+(.+)$/.exec(line)
80 return match ? [{ indent: (match[1] ?? '').length, text: match[2] ?? '' }] : []
81 })
82 if (items.length >= 2) {
83 const indent = Math.min(...items.map(item => item.indent))
84 return {
85 title,
86 steps: items.filter(item => item.indent === indent).map(item => cleanTitle(item.text)),
87 }
88 }
89
90 const headings = lines.flatMap(line => {
91 const text = /^#{2,4}\s+(.+)$/.exec(line)?.[1]
92 return text === undefined || NOT_A_STEP.test(text) ? [] : [cleanTitle(text)]
93 })
94 if (headings.length >= 2) return { title, steps: headings }
95
96 return { title, steps: ['Carry out the approved plan'] }
97}
98
99/**
100 * The result of update_step, which the model reads and each transcript row
101 * reads back: the step's title and the count as of that call, so a row for an
102 * earlier plan never takes a title from the current one.
103 */
104export function updateResult(n: number, status: StepStatus, title: string, progress: string): string {
105 return `Step ${n} is ${status.replace('_', ' ')}: ${title}. ${progress}`
106}
107
108/** The title and `done/total` count an `updateResult` text carries. */
109export function readUpdateResult(text: string): { title: string; count: string } | undefined {
110 const match = /^Step \d+ is [a-z ]+: (.*)\. (?:All \d+ steps are done \()?(\d+\/\d+) done/.exec(text)
111 return match ? { title: match[1] ?? '', count: match[2] ?? '' } : undefined
112}
113
114/** A transcript row's text for a tool's result: its count and title, else the fallback. */
115export function planLine(output: unknown, fallback: string): string {
116 const parsed = typeof output === 'string' ? readUpdateResult(output) : undefined
117 return parsed === undefined ? fallback : `plan ${parsed.count} · ${parsed.title}`
118}
119
120// Steps and their statuses
121
122export const isClosed = (status: StepStatus) => status === 'completed' || status === 'skipped'
123export const isDone = (plan: Plan) => plan.steps.every(step => isClosed(step.status))
124
125/** The title of a plan whose split names none; it says nothing about the plan. */
126export const FALLBACK_TITLE = 'Approved plan'
127
128/**
129 * Whether a newly approved split revises the plan being tracked, and for each
130 * new step, the step of that plan it carries on from. A finished plan is never
131 * revised: the next piece of work's "Write tests" would come back checked off.
132 */
133export function carryOver(current: Plan | null, split: Split): { isRevision: boolean; from: (PlanStep | undefined)[] } {
134 const fresh = { isRevision: false, from: split.steps.map(() => undefined) }
135 if (current === null || isDone(current)) return fresh
136 const from = split.steps.map(title => stepFor(current, title))
137 const matched = from.filter(step => step !== undefined).length
138 if (matched === 0) return fresh
139 const title = normalize(split.title)
140 const isSameTitle = title !== '' && title !== normalize(FALLBACK_TITLE) && title === normalize(current.title)
141 return isSameTitle || matched * 2 >= split.steps.length ? { isRevision: true, from } : fresh
142}
143
144/** The Task ids of a revised plan's steps, keyed to the steps that carry them on. */
145export function carryTaskIds(
146 taskIds: Record<string, number>,
147 from: readonly (PlanStep | undefined)[],
148 count: number
149): Record<string, number> {
150 return Object.fromEntries(
151 Object.entries(taskIds).flatMap(([id, n]) => {
152 const i = from.findIndex(step => step?.n === n)
153 return i < 0 || i >= count ? [] : [[id, i + 1]]
154 })
155 )
156}
157
158/**
159 * The aside after a TodoWrite: the item under way that is no step, else none
160 * when the aside came from an earlier list, else the one the model set itself.
161 */
162export function todoAside(aside: Aside | null, off: string | undefined): Aside | null {
163 if (off !== undefined && off !== '') return { text: off, source: 'todo' }
164 return aside?.source === 'todo' ? null : aside
165}
166
167/**
168 * The session's steps with those another session saved for the same approval:
169 * the union by number, each title from the side amended later, and each status
170 * as the store has it, so the other session's check-off counts.
171 */
172export function mergeSteps(mine: Plan, stored: Plan): PlanStep[] {
173 const isMineNewer = (mine.amendedAt ?? 0) > (stored.amendedAt ?? 0)
174 const numbers = [...new Set([...mine.steps, ...stored.steps].map(step => step.n))].sort((a, b) => a - b)
175 return numbers.flatMap(n => {
176 const own = mine.steps.find(step => step.n === n)
177 const theirs = stored.steps.find(step => step.n === n)
178 if (own === undefined || theirs === undefined) return own ?? theirs ?? []
179 return [{ ...(isMineNewer ? own : theirs), status: theirs.status }]
180 })
181}
182types/index.d.ts 57 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed' | 'skipped'
2
3export type PlanStep = {
4 n: number
5 title: string
6 status: StepStatus
7 /** Set on a step added after the approval, which the pane marks. */
8 isAdded?: true
9}
10
11export type Plan = {
12 title: string
13 steps: PlanStep[]
14 approvedAt: number
15 /** The project root the plan was approved in: its key in `$.store`. */
16 root: string
17 /** The session whose model has been told the steps and the tool. */
18 knownBy: string
19 /** How the plan was split: by a model call, or by the fallback parser. */
20 splitBy: 'model' | 'parser'
21 /** TaskCreate ids whose subject matched a step, to mirror TaskUpdate. */
22 taskIds: Record<string, number>
23 /** When a step was last added or retitled; absent until one is. */
24 amendedAt?: number
25 /** The `approvedAt` of the plan this one revised. */
26 revisedFrom?: number
27}
28
29/** A few words on unplanned work under way, shown under the current step. */
30export type Aside = {
31 text: string
32 /** Who set it: the model through amend_plan, or an in-progress todo. */
33 source: 'tool' | 'todo'
34}
35
36export type AmendAction = 'add' | 'retitle' | 'aside'
37
38declare module 'claude-code' {
39 interface PluginState {
40 stepline: {
41 plan: Plan | null
42 /** Whether the checklist pane is open, which hides the band. */
43 isPaneOpen: boolean
44 /** Whether a prompt has followed the plan's finish, which hides the band. */
45 isDoneSeen: boolean
46 /** What the model is doing off the plan, until its next step or prompt. */
47 aside: Aside | null
48 }
49 }
50
51 /** The inputs of the tools the mod registers, so `e.tool` narrows to them. */
52 interface McpToolInputs {
53 'mcp__stepline__update_step': { step: number; status: StepStatus }
54 'mcp__stepline__amend_plan': { action: AmendAction; step?: number; title?: string }
55 }
56}
57