SLOPSHOPPER

Stepline

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

newpanebandrowsguardcommand
v1.2.1MITupdated 2026-10-06saadk408/stepline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stepline
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet. Approve a plan in plan mode and │ ┃ its steps show up here. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Esc or /stepline closes this ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /stepline │ ⎿ stepline: No approved plan yet. Approve one in plan mode and its │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No plan yet. Approve a plan in plan mode and its steps show up here. Esc or /stepline closes this
README

Stepline

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

Requirements

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.

Install

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.

Coming from plan-progress

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.

From a clone

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.

How it works

  1. You approve a plan. Stepline asks a small, fast model (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.
  2. The band shows your progress right away, in a card just above the prompt: a 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).
  3. The pane holds the full checklist, one row per step: ○ 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.
  4. Claude gets the checklist with the approval, and a tool, 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.

  1. The checklist follows the work. Claude has a second tool, 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.

  1. Each step checked off shows a toast. When the whole plan is done, a final toast and a ✔ 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.

Examples

Plan a change and watch it check off

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.

Pick up an unfinished plan in a new session

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.

Let Claude's todo list check steps off

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.

Change the plan as you go

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.

Stop tracking a plan

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.

Commands

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

Across sessions

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.

Data and permissions

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.

  • One model call per approved plan ($.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.
  • At most one file read per approved plan ($.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.
  • Four tool calls it watches (tool.call on ExitPlanMode, TodoWrite, TaskCreate, and TaskUpdate). Each call runs first, as Claude made it. Then Stepline reads only what it needs:
  • from ExitPlanMode, the approved plan's text or the path of its file
  • from TodoWrite, each item's text and status; an in-progress item that matches no step shows in the band as the aside
  • from TaskCreate, the subject and the new task's id
  • from TaskUpdate, the task's id, subject, and status

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

  • Told when each turn starts (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.
  • Its own tools ($.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.
  • Both tools' schemas are always in Claude's tool list (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.
  • Text added to Claude's context (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.
  • What it draws (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.
  • Its command ($.command.register, command.run). /stepline and /stepline clear, as the Commands section above describes.
  • Local storage only ($.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.
  • Session state ($.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.
  • Nothing else. Stepline makes no network requests of its own, runs no shell commands, writes no files outside its store, changes no settings, and sends no telemetry.

The text Claude receives

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:

  • the plan's title and count, such as 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 back
  • Closed the checklist. when the command closes the pane
  • Stopped tracking "Ship the health endpoint". after /stepline clear
  • No 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

Troubleshooting

  • The pane won't close. Press Esc, or run /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.
  • The band is empty. It steps aside while the pane is open and while a survey is showing, and it may be folded away: press [-] or ctrl+x ctrl+a. /stepline always opens the full checklist.
  • The band shows an aside that's over. It clears when Claude checks a step off, when you send your next prompt, or when Claude's todo list no longer has that item in progress.
  • Nothing happens at all. Run /plugin and check that it lists stepline among the active mods. Start Claude Code with claude --debug to see why a hook was skipped.

Development

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.

Support

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 835 lines
1import { 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}
835
hooks/plan.ts 182 lines
1// 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}
182
types/index.d.ts 57 lines
1export 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