Plan pane for a master plan: steps with collapsible subtasks and their progress, overall progress, kept per project across sessions. The agent fills it from…

Skills for everyday engineering work, packaged as one plugin. Install once, get every skill. Works in Claude Code and Codex (do-shit needs Claude Code).
| Skill | What it does | You give it | You get back |
|---|---|---|---|
wtf | Tells you what a bug report really is. Checks if it was reported or fixed before, reads the code, and gives one verdict: user error, real bug, feature request, already fixed, already known. | A ticket link, a support conversation, or pasted text and screenshots | A verdict with evidence, written for engineers or for the customer. It changes nothing |
wtf-and-create-ticket | Triages a report and files the ticket. Runs wtf, plans the fix with create-plan (and create-spec when it is wide), and creates one ticket with the triage, the cause, the fix and the plan. Asks once per repo for the project and the ticket defaults: type, status, assignees, priority, labels, custom fields. | A ticket link, a support conversation, or pasted text and screenshots | One ticket in ClickUp, GitHub or Linear, with the plan attached. Only for a real bug, or a feature you agree to file |
quick-ask-me | Interviews you before a small task. Goal, success criteria, then at most 6 more questions. | A goal, then your answers | A brief in the chat, glossary terms in CONTEXT.md |
ask-and-create-specs | Interviews you, then writes a spec an agent can build from. At most 40 lines per file. | A goal, then your answers | A spec in docs/specs/, glossary terms in CONTEXT.md |
create-spec | Writes a spec without the interview. Sizes the work first (no spec, a few lines in chat, or a file), asks at most one round of questions, writes a spec capped at 40 lines. Other skills hand off to it. | A goal, a brief, a wtf verdict or a ticket | A spec in docs/specs/, or a few lines in chat for small work |
create-plan | Turns a spec into tasks an agent runs one by one. Files per task, which done-when items each covers, what can run in parallel, what needs its own review, the command that proves each step. A script checks it. | A spec path, or a brief | A plan in docs/plans/, or a few lines in chat for small work |
do-shit | Builds your tickets. Plans each one, runs agent teams that write and review the code, opens one PR per ticket, and merges after you approve. | Ticket refs from GitHub, ClickUp or Linear | Merged PRs, updated tickets, optional QA evidence |
improve-design | Improves one screen and proves each change. Critiques it with impeccable's rubrics, applies small design moves one at a time, and keeps only the ones a blind before-and-after comparison and the repo's checks approve. | A route, or a file with --route | A pull request with one commit per kept change and the evidence in its description. It merges nothing |
clean-slop | Removes the slop a branch added. Dead code, one-use abstractions, reinvented helpers, near-copies, narrating comments, filler in docs. Runs the repo's checks before and after, and asks before it cuts a guard, a documented fact or a test. | Nothing: the current branch. Or paths | One local commit. It never pushes |
solve-merge-conflicts | Makes a conflicted PR mergeable again. Reads what both sides did and why, resolves every conflict so both changes survive, checks each resolution in code, runs the repo's checks, and asks only where one side has to give way. | A PR number or URL. Or nothing: the current branch's PR | One merge commit pushed to the PR branch after you say yes, with a line per resolution in its message. It never rebases, overwrites a branch or merges |
fix-ci | Gets a PR's red checks green. Reads every failed job, says whether the failure is the code's, the runner's or a missing secret, runs the failing command before and after the fix, and asks about any fix that weakens a check. | A PR number or URL. Or nothing: the current branch's PR | One commit pushed to the PR branch after you say yes, then CI watched for up to three rounds. A flaky job is rerun if you say so. It never rewrites history, merges or runs a deploy step |
review-prs | Reviews pull requests. Reviewers read each PR through lenses, a script checks every finding against the code, and a second reviewer tries to disprove the bugs. Posts only if you say so. | PR numbers or URLs, or nothing for the PRs waiting on you | Findings per PR with a verdict. On approval, one comment review on GitHub |
qa-this | QAs finished work off production. Picks the methods that fit each item (the repo's tests, new tests, a browser walkthrough, read-only database queries, requests), runs them, and computes a status from what it recorded. | Tickets, PRs, a branch, or a description. Or nothing: it asks | A short evidence comment on each ticket or PR, one report file, new test files left uncommitted. It fixes nothing and files nothing |
check-infra-and-migrations | Says whether a release is safe to push. Classifies every migration statement and infrastructure change in a PR or promotion, checks the migration history, can read the real target (applied migrations, table sizes, an IaC plan), and gives a verdict and a runbook. | A PR, promotion, or <base>..<head>. Or nothing: the current branch's PR | A verdict and a runbook in the chat, one PR comment if you say so. It applies, deploys and merges nothing |
promote | Promotes one environment to the next. Knows which branch feeds which environment, checks every gate (clean merge, green source, the exact commit ran on the source, migrations and infrastructure), opens the promotion PR after one yes, merges after a second, then watches the deploy and checks what is running. | Nothing, or the environment to promote to | A brief, one PR, one merge, and a result that says whether the environment runs the promoted commit. It bypasses no rule and runs no migration, deploy or rollback |
changelog | Writes your release notes. Finds every PR in the last releases, summarises each one, and links its ticket. | Nothing required. It reads the repo and your tracker | One Markdown file. It changes nothing else |
calibrate | Lets the Jev questions earn the right to decide. The other skills log Jev's answers without acting on most of them. This checks those answers against what was right and switches on the ones that pass. | Nothing, or your Yes / No on past cases | A progress line per question. One local file changes, after you approve each question |
handoff-with-prompt | Hands the task to the next agent. Writes down where the work stands, then gives you a prompt to paste into a fresh session. | Nothing, or a note on why you are stopping | A handoff file in ~/.claude/handoffs/ and a prompt to copy. It changes nothing else |
The table follows a piece of work from report to release. wtf is where a bug report starts, and wtf-and-create-ticket is the same triage when you already know you want a ticket out of it; new work starts at an interview. quick-ask-me and ask-and-create-specs are alternatives: the first for a small task, the second when an agent will build from a written spec. create-spec writes that spec without the interview, and create-plan turns a spec into tasks; the interviews, wtf, qa-this and do-shit's planners hand off to them or read what they write. improve-design is the other builder: where do-shit builds a ticket, it takes a screen that already exists and makes it better. clean-slop comes after either, before review: it strips what the branch added and did not need. solve-merge-conflicts is for a PR that can no longer merge: it brings the base in and resolves the conflicts. fix-ci is for a PR whose checks are red: it fixes what the code caused and tells you what it did not. review-prs comes after the build: for PRs do-shit or improve-design opened, or anyone's. qa-this checks the result on a running environment before it is released, check-infra-and-migrations is the last look at what a release does to the database and the infrastructure before it is pushed, and promote pushes it: it runs that check on the range and then moves staging to production through the promotion PR. The last two sit outside the flow: calibrate tunes the Jev questions of the skills above it, and handoff-with-prompt is for whenever you stop mid-task and another agent picks it up.
| Pillar | What it is | What it does here |
|---|---|---|
| Caveman | A terse way of writing: fragments, no filler, exact paths and error text | Agents hand back short, structured reports, so a long run does not fill the context with prose. do-shit enforces it: role reports are JSON only, with length caps. ask-and-create-specs writes its specs this way, wtf its engineer-facing reports, and review-prs its reviewers' findings. Anything a teammate or customer reads, and every security finding, stays in full sentences |
| Jev | A judgment model: you ask a typed question, it answers with a probability | Each skill has a script that makes its decisions in code and asks Jev for the judgment calls. Code applies thresholds and vetoes, so you are asked less. A new question is logged first and decides only once it is calibrated. Optional: with no key the skills run on their code rules |
| Ponytail | A way of writing code: the smallest change that fully solves the task | Every skill and agent that writes or plans code carries the same rule, code-style.md: reuse what the repo, the standard library or an installed dependency already has, add nothing nobody asked for, never cut validation, error handling, security or accessibility. clean-slop applies it to code that is already written |
handoff-with-prompt uses none of them: it has no script and asks Jev nothing. clean-slop and wtf-and-create-ticket have a script and ask Jev nothing. More in How these skills work.
Contents: How these skills work · Install · Requirements · wtf · wtf-and-create-ticket · quick-ask-me · ask-and-create-specs · create-spec · create-plan · do-shit · improve-design · clean-slop · solve-merge-conflicts · fix-ci · review-prs · qa-this · check-infra-and-migrations · promote · changelog · calibrate · handoff-with-prompt · Mods (plan-progress) · Repo layout · Develop · License
The skills are built the same way, on three pillars. The exception is handoff-with-prompt, which is plain instructions: no script, no Jev.
1. Caveman: agents report in compressed form. A long run dies when the main conversation fills up with prose. do-shit, improve-design, changelog, quick-ask-me, wtf, review-prs, qa-this, check-infra-and-migrations, promote, solve-merge-conflicts and fix-ci carry the same short rule, report-style.md, modelled on caveman: fragments, no filler, exact paths and error text. In do-shit the harness enforces it: a role's reply is a JSON block and nothing else, each field has a length cap, and an over-long report is sent back once. Nothing a teammate reads is compressed (PR bodies, tracker comments, changelogs, briefs), and security findings are always written in full. ask-and-create-specs applies the same idea to its output: the spec itself is terse and capped at 40 lines per file, because the reader is an implementing agent.
2. Jev: code decides, Jev judges, you are asked last. Each skill has a script that owns its decisions. Where a decision needs judgment ("does this plan need a human to look at it?"), the script asks Jev a typed question and gets back a number. Code then applies a threshold and a list of vetoes. Jev never decides alone, and a veto always wins. You are asked only when a veto fires, Jev is unsure, or the step is one that stays yours.
| Skill | Script | Decided in code today | Jev judgments, logged until calibrated | Always yours |
|---|---|---|---|---|
wtf | wtf.mjs | Whether each file:line citation is real, whether a fix is live where the report came from, whether the evidence supports the verdict, confidence, budgets | Same issue as a prior ticket, request or breakage, did the screen say enough | Whether to reproduce, and on which environment |
quick-ask-me | gate.mjs | The six-question budget, the five stop conditions | Which questions the repo can answer, which would not change the build, whether a criterion is checkable | Objective, success criteria, final confirmation |
ask-and-create-specs | spec-jev.mjs | The 40-line cap, spec structure, missing brief fields | Ask / assume / drop per question, when to stop, one spec or slices, dead and duplicate lines | Goal, the write-or-keep-going call |
create-spec | spec-jev.mjs (shared) | The tier when a risky area or more than six files are named, the 40-line cap, spec structure | Whether the work needs a spec file, plus ask-and-create-specs's triage, shape and lint judgments | The one question round |
create-plan | plan.mjs | Header, caps, placeholders, parallel tasks sharing a file, done-when coverage, Review: yes for auth, schema, payments and deletion files | Whether a task is too big, needs its own review, or has a vague step. Even switched on, review can only be added | Going ahead with execution |
do-shit | harness.mjs | Every loop step, role scope, merge order, one architect retry, waiting on pending CI, post-QA offers from config | Passing the plan checkpoint, re-approving a fixed PR | Merge approval, QA environment and sign-in |
improve-design | improve.mjs | Which issues become moves and in what order, that a direction change is never ticked for you, whether each move stayed in scope, kept the gates green and added no detector finding, which picture is which in the blind comparison, whether a move stays, the verdict, draft or ready, the pull-request text | Whether a move is worth making, whether it changes the look as a whole, whether a direction change is needed, whether the critic's second-choice command fits better, whether the result is an improvement, whether it harmed something else. Switched on, they cut, reclassify, swap and drop; the only thing one can add is a tick on a direction change the critic proposed | Which moves run, pictures in the pull request, the merge |
solve-merge-conflicts | solve.mjs | Whether the PR conflicts at all, the hunks, whether each cited commit is real, what each file says after a resolution, whether a resolution drops something a side wrote, what changed outside the conflicts, the merge commit, every check result and whether the merge is what turned it red, the verdict, whether the head is still the checked one at the push | Whether the two sides of a hunk want different things, whether a combined text or an edit does something neither side did, whether an edit only follows the other side's change | Every resolution where one side gives way, and the push |
fix-ci | fix.mjs | What each failed job ran and reported (cut from its log), the class of every failure, which commands may run here, whether a failure is red here for the reason CI gave, whether every changed file is listed, whether the red commands are green after the fix, which shortcuts the fix takes, the commit, the verdict, whether the head is still the checked one at the push, what CI says afterwards, when the rounds end | Whether a failure would pass if run again, whether an edit weakens the check, whether an edit addresses its failure | Every shortcut, every unusual command, each push, each rerun |
review-prs | review.mjs | Which findings are real (citation check), duplicates, what was already raised, when a refutation counts, the nit budget, the verdict, the review payload | An extra lens, whether a nit is worth showing, two findings making one point, a PR doing more than it says | Which PRs to post to. It never approves or merges |
qa-this | qa.mjs | Which methods run, that every criterion has a check, every test, query and request result, whether only test files changed, the status of each item, the comment text, production refused | Whether an item needs a browser check, a data check or new tests, whether a check covers its criterion, whether a failure is the environment's | What to QA, the environment, accepting data changes, sign-in, what gets posted |
check-infra-and-migrations | check.mjs | What every SQL statement and infrastructure change is, whether the migration history still applies, what is pending and how big the tables are (from your read-only commands), whether each of the agent's citations is real, the verdict, the runbook, the comment text | Whether a change destroys data, breaks running code or disrupts a service, whether a manual step is needed, whether it is safe to push. Even switched on, an answer can only make the verdict worse | Whether to read a live environment, whether to post, whether to push |
promote | promote.mjs | Which stage, the range, every gate, whether the infra verdict is for these commits, the pull request text, whether the head is still the checked commit at the merge, the deploy state, what the environment runs | What the pipeline looks like: which branch deploys which environment, which environment feeds which, which is production | Which environment, both approvals, accepting a blocker, the stage list at setup |
changelog | judge.mjs | Which ticket is the PR's own, migration and docs-only flags, default audience from config | Ambiguous ticket IDs, default-on behaviour changes, product area | Nothing is written outside the changelog file |
What "logged until calibrated" means. A Jev question decides nothing until it has been checked against real examples. Outside do-shit's original set, every question ships uncalibrated (two more in do-shit, three in changelog, five in quick-ask-me, three in wtf, four in review-prs, five in qa-this, five in check-infra-and-migrations, three in promote, six in improve-design, three in solve-merge-conflicts, three in fix-ci, three in create-plan, and all eleven thresholds in ask-and-create-specs, one of them create-spec's): with a key set they are asked and their answers are written to a log next to what the code decided, and the skill still asks you as it did before. Turning one on is /real-skills:calibrate's job: it measures each question against a fixed bar and, with your approval, lets it decide on your machine. do-shit's original questions (role choice, dependencies, loop decisions, the merge gate, QA failures) are calibrated and decide in live mode.
Without a TypeSafe key every skill still works: the code-only column applies, and everything else is asked or judged as before.
What leaves your machine. Only when a key is set, and only to api.typesafe.ai, after credential-shaped strings are redacted (redact.jq):
| Skill | Sent | Never sent |
|---|---|---|
wtf | A one-line symptom summary written without names, the expected and actual lines, prior-ticket titles. Emails, phone numbers and long numbers are stripped as well | The report, the support thread, screenshots, customer or company names, file contents |
quick-ask-me | Your objective, success criteria, drafted questions, facts looked up in the repo | File contents |
ask-and-create-specs, create-spec | The goal, drafted questions, the running brief, spec lines, guessed file paths | Source code |
create-plan | The plan's goal, task names, file paths, step text | Source code |
do-shit | Ticket titles and bodies, plan summaries, file paths, review findings, CI check names | Source code |
improve-design | The critic's one-line problem and intent per move, command names, file paths, the three weakest heuristics with their scores, detector counts, one line per picture on what it shows | Pictures, the diff, source text, URL query strings |
solve-merge-conflicts | The PR title, conflicted file paths, how each conflict was resolved, the one-line reason for it, each side's commit subjects and PR titles | The code: hunks, resolved text, diffs, file contents, check output |
fix-ci | The PR title, job and workflow names, the failed command, one error line per failure with emails and long numbers stripped, changed file paths, the one-line reason for each edit | The code, the diff, job logs, command output |
review-prs | PR title and body, changed file paths, each finding's one-line problem | The diff, quoted lines, source |
qa-this | Item title, criteria lines, changed file paths, one line per check | Diff and source text, command output, row values, screenshots, URL query strings |
check-infra-and-migrations | The title of the change, bucketed file paths, one normalized line per statement or infrastructure hit (keywords and identifiers, every literal replaced by ?), rule names, when migrations run | Full files, diff hunks, literals, plan and query output, row counts, host names |
promote | Branch names, workflow file names, the branches each workflow runs on, environment names, hosting provider names, one line per "this branch is made of merges from that one" | URLs, hosts, workflow bodies, env values, pull request titles, anything about a promotion run |
changelog | PR titles, bodies, branch names, labels, file paths | File contents, diffs |
Turn it off per skill: unset the key, or "jev": "off" in .claude/changelog.json, or QUICK_ASK_ME_JEV=off, or ASK_SPECS_JEV=0, or "jev": "off" in .claude/wtf.json, or REVIEW_PRS_JEV=off, or QA_THIS_JEV=off, or CHECK_INFRA_JEV=off, or PROMOTE_JEV=off, or IMPROVE_DESIGN_JEV=off. The logs live in ~/.claude/state/<skill>/ (events.jsonl per run for do-shit, jev.jsonl for the others, log.jsonl for wtf). REAL_SKILLS_CALIBRATION=off makes every skill ignore what calibrate switched on.
3. Ponytail: code comes out small. Agents write too much code: wrappers with one caller, options nobody sets, a helper the repo already had. code-style.md, modelled on ponytail (MIT), is the rule against it: before writing, ask whether the code needs to exist, then reuse what the codebase, the standard library or an installed dependency already does, then write the least code that works. Validation at trust boundaries, error handling that prevents data loss, security and accessibility are never cut. The skills that write code (do-shit, fix-ci, solve-merge-conflicts, improve-design, qa-this, clean-slop) and the ones that plan it (create-spec, create-plan, ask-and-create-specs, quick-ask-me) carry a copy, and the build and plan agents (worker, data-engineer, test-engineer, integrator, designer, observability-engineer, `
hooks/register.tsx 716 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Plan, Status, Step, Task } from '../types'
5
6const PANE = 'plan-progress'
7const TOOL = 'mcp__plan-progress__plan'
8const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
9const WRAP_TOOL = 'mcp__ccd_turn__wrap_up'
10
11const EMPTY: Plan = { title: '', steps: [] }
12
13const plan = atom({ plugin: 'plan-progress', key: 'plan' } as const, EMPTY)
14const turn = atom({ plugin: 'plan-progress', key: 'turn' } as const, [])
15const open = atom({ plugin: 'plan-progress', key: 'open' } as const, {})
16const isDismissed = atom({ plugin: 'plan-progress', key: 'isDismissed' } as const, false)
17const isBriefed = atom({ plugin: 'plan-progress', key: 'isBriefed' } as const, false)
18
19const MARK: Record<Status, { glyph: string; color: string }> = {
20 pending: { glyph: '○', color: 'inactive' },
21 active: { glyph: '●', color: 'claude' },
22 done: { glyph: '✓', color: 'success' },
23 blocked: { glyph: '!', color: 'warning' },
24}
25
26// Bar fills: the master plan's in green, every step, task and turn bar in blue.
27const MASTER_FILL = 'success'
28const PART_FILL = 'suggestion'
29
30const BOX: Record<Status, string> ={ pending: ' ', active: '~', done: 'x', blocked: '!' }
31
32// ---------- model ----------
33
34function toStatus(raw: unknown): Status {
35 if (raw === 'completed' || raw === 'done') return 'done'
36 if (raw === 'in_progress' || raw === 'active') return 'active'
37 if (raw === 'blocked') return 'blocked'
38 return 'pending'
39}
40
41function clamp(raw: number): number {
42 return Math.round(Math.min(100, Math.max(0, raw)))
43}
44
45/** Progress belongs to an item in progress; any other status carries none. */
46function setStatus<T extends Task>(item: T, status: Status): T {
47 const { progress, ...rest } = item
48 return (status === 'active' && progress !== undefined ? { ...rest, status, progress } : { ...rest, status }) as T
49}
50
51/** A reported 0-100: full marks the item done, anything less puts it in progress. */
52function setProgress<T extends Task>(item: T, raw: number): T {
53 if (!Number.isFinite(raw)) return item
54 const progress = clamp(raw)
55 return progress === 100 ? setStatus(item, 'done') : { ...item, status: 'active', progress }
56}
57
58/** A step with tasks follows them: all done is done, any started is in progress. Blocked stays. */
59function settle(step: Step): Step {
60 if (step.status === 'blocked' || step.tasks.length === 0) return step
61 if (step.tasks.every(t => t.status === 'done')) return setStatus(step, 'done')
62 if (step.tasks.some(t => t.status !== 'pending')) return setStatus(step, 'active')
63 return step
64}
65
66function share(item: Task): number {
67 if (item.status === 'done') return 1
68 return item.status === 'active' ? (item.progress ?? 0) / 100 : 0
69}
70
71function stepShare(step: Step): number {
72 if (step.status === 'done' || step.tasks.length === 0) return share(step)
73 return step.tasks.reduce((sum, t) => sum + share(t), 0) / step.tasks.length
74}
75
76/** Steps weigh by their task count, so a ten-task step counts more than a one-line one. */
77function overall(p: Plan): number {
78 const weight = (s: Step) => Math.max(1, s.tasks.length)
79 const total = p.steps.reduce((sum, s) => sum + weight(s), 0)
80 return total === 0 ? 0 : p.steps.reduce((sum, s) => sum + stepShare(s) * weight(s), 0) / total
81}
82
83function pct(fraction: number): number {
84 return Math.round(fraction * 100)
85}
86
87function bar(fraction: number, width: number): [string, string] {
88 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
89 return ['━'.repeat(filled), '━'.repeat(width - filled)]
90}
91
92type RawTask = { text?: unknown; status?: unknown; progress?: unknown }
93type RawStep = RawTask & { note?: unknown; tasks?: unknown }
94
95function mintTask(raw: RawTask): Task | undefined {
96 if (typeof raw?.text !== 'string' || raw.text.trim() === '') return undefined
97 const task: Task = { id: crypto.randomUUID(), text: raw.text.trim(), status: toStatus(raw.status) }
98 return typeof raw.progress === 'number' ? setProgress(task, raw.progress) : task
99}
100
101function mintStep(raw: RawStep): Step | undefined {
102 const head = mintTask(raw)
103 if (head === undefined) return undefined
104 const tasks = (Array.isArray(raw.tasks) ? (raw.tasks as RawTask[]) : [])
105 .map(mintTask)
106 .filter((t): t is Task => t !== undefined)
107 const note = typeof raw.note === 'string' && raw.note.trim() ? raw.note.trim() : undefined
108 return { ...head, tasks, ...(note ? { note } : {}) }
109}
110
111type Update = { step?: unknown; task?: unknown; status?: unknown; progress?: unknown; note?: unknown; text?: unknown }
112
113function applyUpdate<T extends Task & { note?: string }>(item: T, u: Update): T {
114 let next = item
115 if (typeof u.text === 'string' && u.text.trim()) next = { ...next, text: u.text.trim() }
116 if (u.status !== undefined) next = setStatus(next, toStatus(u.status))
117 if (typeof u.progress === 'number') next = setProgress(next, u.progress)
118 if (typeof u.note === 'string') {
119 const { note: _drop, ...rest } = next
120 next = (u.note.trim() ? { ...rest, note: u.note.trim() } : rest) as T
121 }
122 return next
123}
124
125function applyUpdates(p: Plan, updates: Update[]): Plan {
126 const steps = [...p.steps]
127 for (const u of updates) {
128 const at = typeof u?.step === 'number' ? u.step - 1 : -1
129 const step = steps[at]
130 if (step === undefined) continue
131 if (typeof u.task === 'number') {
132 const ti = u.task - 1
133 if (step.tasks[ti] === undefined) continue
134 const tasks = step.tasks.map((one, i) => (i === ti ? applyUpdate(one, u) : one))
135 steps[at] = settle({ ...step, tasks })
136 } else {
137 steps[at] = applyUpdate(step, u)
138 }
139 }
140 return { ...p, steps }
141}
142
143function summary(p: Plan): string {
144 if (p.steps.length === 0) return 'The plan is empty.'
145 const lines = [`Plan${p.title ? `: ${p.title}` : ''} (${pct(overall(p))}% done)`]
146 p.steps.forEach((s, i) => {
147 const count = s.tasks.length ? ` (${s.tasks.filter(t => t.status === 'done').length}/${s.tasks.length})` : ''
148 lines.push(`${i + 1}. [${BOX[s.status]}] ${s.text} ${pct(stepShare(s))}%${count}${s.note ? ` — ${s.note}` : ''}`)
149 s.tasks.forEach((t, j) => {
150 const progress = t.status === 'active' && t.progress !== undefined ? ` (${t.progress}%)` : ''
151 lines.push(` ${j + 1}. [${BOX[t.status]}] ${t.text}${progress}`)
152 })
153 })
154 return lines.join('\n')
155}
156
157function age(iso: string | undefined): string {
158 const then = iso ? Date.parse(iso) : NaN
159 if (!Number.isFinite(then)) return ''
160 const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
161 if (mins < 1) return 'updated just now'
162 if (mins < 60) return `updated ${mins}m ago`
163 if (mins < 60 * 24) return `updated ${Math.round(mins / 60)}h ago`
164 return `updated ${Math.round(mins / 60 / 24)}d ago`
165}
166
167type Now = { step: Step; task?: Task; fraction?: number; done?: number; total?: number }
168
169/**
170 * The plan as drawn: the item in progress with no reported progress takes the
171 * share of the agent's turn list that is done. Stored data stays as reported.
172 * shortcut: assumes the turn list is about the task in progress, upgrade if the agent names the task it works on
173 */
174function live(p: Plan, turnList: Task[]): { view: Plan; now?: Now } {
175 const at = p.steps.findIndex(s => s.status === 'active')
176 const step = p.steps[at]
177 if (step === undefined) return { view: p }
178 const done = turnList.filter(t => t.status === 'done').length
179 const total = turnList.length
180 const fromTurn = total > 0 ? Math.min(99, Math.round((done / total) * 100)) : undefined
181 const fill = <T extends Task>(item: T): T =>
182 item.progress === undefined && fromTurn !== undefined ? { ...item, progress: fromTurn } : item
183 const ti = step.tasks.findIndex(t => t.status === 'active')
184 const task = step.tasks[ti]
185 const nextStep: Step =
186 task !== undefined
187 ? { ...step, tasks: step.tasks.map((t, i) => (i === ti ? fill(t) : t)) }
188 : step.tasks.length === 0
189 ? fill(step)
190 : step
191 const view = { ...p, steps: p.steps.map((s, i) => (i === at ? nextStep : s)) }
192 const item = task !== undefined ? nextStep.tasks[ti] : step.tasks.length === 0 ? nextStep : undefined
193 const fraction = item?.progress !== undefined ? item.progress / 100 : undefined
194 return {
195 view,
196 now: { step: nextStep, task: task !== undefined ? nextStep.tasks[ti] : undefined, fraction, ...(total ? { done, total } : {}) },
197 }
198}
199
200// ---------- engine ----------
201
202/** shortcut: one plan per project root, upgrade to named plans if a repo needs two at once */
203async function storeKey($: EngineInterface): Promise<string> {
204 return `plan:${await $.session.root()}`
205}
206
207async function paintStatus($: EngineInterface, stored: Plan) {
208 const { view, now } = live(stored, await read($, turn))
209 const where = now ? (now.task ?? now.step).text.slice(0, 40) : ''
210 const part = now?.fraction !== undefined ? ` ${pct(now.fraction)}%` : ''
211 $.ui.status(view.steps.length === 0 ? undefined : `Plan ${pct(overall(view))}%${where ? ` · ${where}${part}` : ''}`)
212}
213
214async function setPlan($: EngineInterface, change: (prev: Plan) => Plan) {
215 const prev = await read($, plan)
216 const next = { ...change(prev), updatedAt: new Date().toISOString() }
217 await update($, plan, () => next)
218 if (next.steps.length === 0) await $.store.delete(await storeKey($))
219 else await $.store.set(await storeKey($), next)
220 await paintStatus($, next)
221 if (prev.steps.length === 0 && next.steps.length > 0 && !(await read($, isDismissed))) {
222 $.ui.open({ id: PANE, title: 'Plan' }).catch(() => undefined)
223 }
224}
225
226function withStatus(items: Task[], at: number, status: Status): Task[] {
227 return items.map((item, i) => (i === at ? setStatus(item, status) : item))
228}
229
230/** The desktop app's advance / done / start steps, applied to the turn list. */
231function applyPlanSteps(items: Task[], e: Record<string, unknown>): Task[] {
232 if (Array.isArray(e.done)) {
233 for (const n of e.done) if (typeof n === 'number') items = withStatus(items, n - 1, 'done')
234 }
235 if (e.advance === true) {
236 const current = items.findIndex(t => t.status === 'active')
237 if (current >= 0) items = withStatus(items, current, 'done')
238 const nextPending = items.findIndex((t, i) => i > current && t.status === 'pending')
239 if (nextPending >= 0) items = withStatus(items, nextPending, 'active')
240 }
241 if (typeof e.start === 'number') {
242 items = items.map(t => (t.status === 'active' ? setStatus(t, 'pending') : t))
243 items = withStatus(items, e.start - 1, 'active')
244 }
245 return items
246}
247
248const TOOL_DESCRIPTION = [
249 'The master plan the person sees in their Plan pane: steps with subtasks and their progress,',
250 'kept for this project across sessions. Call with no arguments to read it back.',
251 '`steps` replaces the whole plan, each step with its `tasks`. `updates` changes one step, or one of its tasks,',
252 'by 1-based `step` and `task`: `status`, `progress` (0-100; 100 marks it done), `note` (why a step is blocked, what is open).',
253 'Take statuses from evidence (merged PRs, branches, files on main), not from checkboxes in a plan doc.',
254 'Your own update_plan list already shows under the step in progress; use this tool for the plan itself,',
255 'and update it as you finish its tasks.',
256].join(' ')
257
258const STATUS_ENUM = ['pending', 'active', 'done', 'blocked']
259const TASK_SCHEMA = {
260 type: 'object',
261 properties: {
262 text: { type: 'string' },
263 status: { type: 'string', enum: STATUS_ENUM },
264 progress: { type: 'number', minimum: 0, maximum: 100 },
265 },
266 required: ['text'],
267}
268
269function buildPrompt(request: string): string {
270 return [
271 `Lay out the master plan for this in my Plan pane: ${request}`,
272 '',
273 'Read what it points at first (specs, implementation plans, PRs, branches, tickets). Make one step per',
274 'slice, phase or milestone, each with its tasks. Take each status from evidence (merged PRs, branches,',
275 'what is on main), not from checkboxes in the docs; mark a step `blocked` with a `note` when something',
276 `must happen before it can start. Then call ${TOOL} once with a short \`title\`, an optional \`note\``,
277 '(the critical path, in one line) and the whole plan as `steps`. Only lay out the plan; do not start the work.',
278 ].join('\n')
279}
280
281const REFRESH_PROMPT = [
282 `Bring the plan in my Plan pane up to date. Read it back with ${TOOL}, check each open step and task`,
283 'against the repo (merged PRs, branches, what is on main), and send the changes as `updates`.',
284 'Only update the plan; do not start the work.',
285].join(' ')
286
287const SUBCOMMANDS = new Set(['show', 'hide', 'clear', 'refresh'])
288
289/** Sends a prompt as the person's, once the hook that asked has returned. */
290function ask($: EngineInterface, text: string) {
291 $.clock.after(0, () => void $.prompt.submit({ text, asUser: true }).catch(() => undefined))
292}
293
294export const register: Register = on => {
295 on('session.start', async ($, e, next) => {
296 await $.command.register({
297 name: 'plan-progress',
298 description: 'Show the Plan pane, or lay out a master plan from a spec, plan, PR or goal',
299 argumentHint: '[<request>|refresh|hide|clear]',
300 })
301 await $.tool.register({
302 name: 'plan',
303 description: TOOL_DESCRIPTION,
304 isDeferred: false,
305 inputSchema: {
306 type: 'object',
307 properties: {
308 title: { type: 'string', description: 'Title shown above the plan.' },
309 note: { type: 'string', description: 'One line under the title, such as the critical path.' },
310 steps: {
311 type: 'array',
312 description: 'Replaces the whole plan.',
313 items: {
314 ...TASK_SCHEMA,
315 properties: {
316 ...TASK_SCHEMA.properties,
317 note: { type: 'string' },
318 tasks: { type: 'array', items: TASK_SCHEMA },
319 },
320 },
321 },
322 updates: {
323 type: 'array',
324 description: 'Changes by 1-based position: a step, or a task of it when `task` is given.',
325 items: {
326 type: 'object',
327 properties: {
328 step: { type: 'number' },
329 task: { type: 'number' },
330 text: { type: 'string' },
331 status: { type: 'string', enum: STATUS_ENUM },
332 progress: { type: 'number', minimum: 0, maximum: 100 },
333 note: { type: 'string', description: 'Empty string removes it.' },
334 },
335 required: ['step'],
336 },
337 },
338 clear: { type: 'boolean', description: 'Empties the plan.' },
339 },
340 },
341 })
342
343 const stored = (await $.store.get(await storeKey($))) as Plan | undefined
344 if (stored && Array.isArray(stored.steps)) await update($, plan, () => stored)
345 const current = await read($, plan)
346 await paintStatus($, current)
347 if (current.steps.length > 0 && !(await read($, isDismissed))) {
348 $.ui.open({ id: PANE, title: 'Plan' }).catch(() => undefined)
349 }
350
351 return next(e)
352 })
353
354 on('tool.call', { tool: TOOL }, async ($, e) => {
355 const args = e as Record<string, unknown>
356 const isRead = ['title', 'note', 'steps', 'updates', 'clear'].every(k => args[k] === undefined)
357 if (!isRead) {
358 await setPlan($, prev => {
359 let p: Plan = args.clear === true ? EMPTY : prev
360 if (typeof args.title === 'string' && args.title.trim()) p = { ...p, title: args.title.trim() }
361 if (typeof args.note === 'string') p = { ...p, note: args.note.trim() || undefined }
362 if (Array.isArray(args.steps)) {
363 p = { ...p, steps: (args.steps as RawStep[]).map(mintStep).filter((s): s is Step => s !== undefined) }
364 }
365 if (Array.isArray(args.updates)) p = applyUpdates(p, args.updates as Update[])
366 return p
367 })
368 }
369 return { result: summary(await read($, plan)) }
370 })
371
372 // The agent's own task list becomes the turn list under the step in progress.
373 on('tool.call', { tool: PLAN_TOOL }, async ($, e, next) => {
374 const ran = await next(e)
375 if (ran.deny !== undefined || ran.isError === true) return ran
376 const args = e as Record<string, unknown>
377 await update($, turn, prev => {
378 let items = prev ?? []
379 if (Array.isArray(args.items)) {
380 items = (args.items as { content?: unknown; text?: unknown; status?: unknown }[])
381 .map(one => mintTask({ text: one?.content ?? one?.text, status: one?.status }))
382 .filter((t): t is Task => t !== undefined)
383 }
384 return applyPlanSteps(items, args)
385 })
386 await paintStatus($, await read($, plan))
387 return ran
388 })
389
390 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
391 const ran = await next(e)
392 if (ran.deny !== undefined || ran.isError === true) return ran
393 await update($, turn, () =>
394 e.todos.map(todo => mintTask({ text: todo.content, status: todo.status })).filter((t): t is Task => t !== undefined),
395 )
396 await paintStatus($, await read($, plan))
397 return ran
398 })
399
400 on('tool.call', { tool: WRAP_TOOL }, async ($, e, next) => {
401 const ran = await next(e)
402 await update($, turn, prev => (prev ?? []).map(t => (t.status === 'active' ? setStatus(t, 'done') : t)))
403 await paintStatus($, await read($, plan))
404 return ran
405 })
406
407 // A plan kept from an earlier session: tell the agent once that it is there to keep current.
408 on('prompt.submit', async ($, e, next) => {
409 if (e.command !== undefined || e.isMeta === true || (await read($, isBriefed))) return next(e)
410 const p = await read($, plan)
411 if (p.steps.length === 0) return next(e)
412 await update($, isBriefed, () => true)
413 const note = [
414 `The person's Plan pane holds this project's master plan. When your work finishes or starts one of its`,
415 `tasks, report it with ${TOOL} \`updates\`.`,
416 summary(p),
417 ].join('\n')
418 return next({ ...e, context: [...(e.context ?? []), note] })
419 })
420
421 on('ui.close', async ($, e, next) => {
422 if (e.id === PANE && e.origin.kind === 'person') await update($, isDismissed, () => true)
423 return next(e)
424 })
425
426 on('command.run', { command: 'plan-progress' }, async ($, e) => {
427 const [verb = 'show'] = e.args.trim().split(/\s+/).filter(Boolean)
428
429 if (verb === 'hide') {
430 await update($, isDismissed, () => true)
431 await $.ui.close({ id: PANE })
432 return { text: 'Plan pane hidden. /plan-progress brings it back.' }
433 }
434 if (verb === 'clear') {
435 await update($, plan, () => EMPTY)
436 await update($, open, () => ({}))
437 await $.store.delete(await storeKey($))
438 await paintStatus($, EMPTY)
439 return { text: 'Plan cleared for this project.' }
440 }
441
442 await update($, isDismissed, () => false)
443 const opened = await $.ui.open({ id: PANE, title: 'Plan' })
444 const where = opened.isPlaced ? '' : ' (no surface placed the pane right now)'
445
446 // A command.run hook holds the turn, so the prompt goes once it has returned.
447 if (verb === 'refresh') {
448 ask($, REFRESH_PROMPT)
449 return { text: `Bringing the plan up to date${where}.` }
450 }
451 if (!SUBCOMMANDS.has(verb)) {
452 ask($, buildPrompt(e.args.trim()))
453 return { text: `Laying out the plan${where}.` }
454 }
455 return { text: `${summary(await read($, plan))}${where}` }
456 })
457
458 // One row above the prompt: the plan and what is in progress. Press it to open the pane.
459 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
460 const stored = await read($, plan)
461 if (e.props.hasSurvey || stored.steps.length === 0) return next(e)
462 const { Box, Text, Button } = $.ui.resolve(e)
463 const { view, now } = live(stored, await read($, turn))
464 const whole = overall(view)
465 const where = now ? (now.task ? `${now.step.text} › ${now.task.text}` : now.step.text) : `${view.steps.filter(s => s.status === 'done').length} of ${view.steps.length} steps`
466 const [filled, rest] = bar(now?.fraction ?? whole, 8)
467 const tail = now?.fraction !== undefined ? `${pct(now.fraction)}%` : ''
468 return (
469 <Box flexDirection="row" justifyContent="space-between" gap="sm">
470 <Box flexShrink={1}>
471 <Button
472 key="plan-band"
473 plain
474 onPress={async () => {
475 await update($, isDismissed, () => false)
476 await $.ui.open({ id: PANE, title: 'Plan' })
477 }}
478 >
479 <Text wrap="truncate-end">
480 <Text weight="semibold">Plan {pct(whole)}%</Text>
481 <Text dimColor> · </Text>
482 <Text>{where}</Text>
483 </Text>
484 </Button>
485 </Box>
486 <Text mono tabular>
487 <Text color={now ? PART_FILL : MASTER_FILL}>{filled}</Text>
488 <Text color="subtle">{rest}</Text>
489 {tail ? ` ${tail.padStart(4)}` : ''}
490 </Text>
491 </Box>
492 )
493 })
494
495 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
496 const els = $.ui.resolve(e)
497 const { Box, Text, Button } = els
498 const Input = 'Input' in els ? els.Input : undefined
499 const stored = await read($, plan)
500 const turnList = await read($, turn)
501 const expanded = await read($, open)
502 const { view: p, now } = live(stored, turnList)
503
504 const columns = e.props.bodyColumns ?? 40
505
506 const meter = (fraction: number, width: number, fill: string) => {
507 const [filled, rest] = bar(fraction, width)
508 return (
509 <Text mono>
510 <Text color={fill}>{filled}</Text>
511 <Text color="subtle">{rest}</Text>
512 </Text>
513 )
514 }
515
516 // A bar on a line of its own, as wide as the room its indent and the text after it leave.
517 const progressLine = (fraction: number, max: number, indent: number, detail?: string) => {
518 const tail = ` ${pct(fraction)}%`
519 const rest = detail ? ` · ${detail}` : ''
520 const width = Math.max(6, Math.min(max, columns - indent - tail.length - rest.length))
521 return (
522 <Text size="footnote" tabular>
523 {meter(fraction, width, PART_FILL)}
524 {tail}
525 {rest ? <Text dimColor>{rest}</Text> : ''}
526 </Text>
527 )
528 }
529
530 const nowBlock = (n: Now) => (
531 <Box key="now" flexDirection="column" marginTop="sm">
532 <Text size="caption" wrap="truncate-end">
533 <Text color="claude" weight="semibold">
534 Now
535 </Text>
536 {n.task ? <Text dimColor>{` · ${n.step.text}`}</Text> : ''}
537 </Text>
538 <Text weight="medium">{(n.task ?? n.step).text}</Text>
539 {n.fraction !== undefined &&
540 progressLine(n.fraction, 32, 0, n.total ? `${n.done} of ${n.total} this turn` : undefined)}
541 </Box>
542 )
543
544 // The task the Now block names keeps its bar there, not twice.
545 const row = (item: Task, key: string, indent: number) => (
546 <Box key={key} flexDirection="column">
547 <Text wrap="truncate-end">
548 <Text color={MARK[item.status].color}>{MARK[item.status].glyph}</Text>{' '}
549 <Text dimColor={item.status === 'done'} weight={item.status === 'active' ? 'medium' : 'regular'}>
550 {item.text}
551 </Text>
552 </Text>
553 {item.status === 'active' && item.progress !== undefined && item.id !== now?.task?.id && (
554 <Box paddingLeft={2}>{progressLine(item.progress / 100, 20, indent + 2)}</Box>
555 )}
556 </Box>
557 )
558
559 const turnRows = (indent: number) =>
560 turnList.length === 0 ? null : (
561 <Box flexDirection="column" paddingLeft={indent} marginTop="xs">
562 <Text size="caption" dimColor>
563 This turn
564 </Text>
565 {turnList.map(t => row(t, `turn-${t.id}`, indent + 4))}
566 </Box>
567 )
568
569 if (p.steps.length === 0) {
570 return (
571 <Box flexDirection="column" gap="sm">
572 <Text size="heading" weight="semibold">
573 No plan yet
574 </Text>
575 <Text dimColor size="footnote">
576 Point the agent at a spec, an implementation plan, a PR or a goal. It reads what is there, checks what
577 has landed, and lays out the steps here with their tasks. The plan stays with this project across
578 sessions.
579 </Text>
580 {Input !== undefined && (
581 <Input
582 key="request"
583 placeholder="docs/plans/autopilot, a PR link, a goal"
584 submitLabel="Lay out plan"
585 onSubmit={value => {
586 if (value.trim()) ask($, buildPrompt(value.trim()))
587 }}
588 />
589 )}
590 <Text dimColor size="caption">
591 Or run /plan-progress with the same.
592 </Text>
593 {turnRows(0)}
594 </Box>
595 )
596 }
597
598 const whole = overall(p)
599 const doneSteps = p.steps.filter(s => s.status === 'done').length
600 const allTasks = p.steps.flatMap(s => s.tasks)
601 const doneTasks = allTasks.filter(t => t.status === 'done').length
602 const hasActive = p.steps.some(s => s.status === 'active')
603 const facts = [
604 `${doneSteps} of ${p.steps.length} steps`,
605 allTasks.length ? `${doneTasks} of ${allTasks.length} tasks` : '',
606 age(p.updatedAt),
607 ].filter(Boolean)
608
609 const toggle = (step: Step, isOpen: boolean) =>
610 update($, open, prev => ({ ...(prev ?? {}), [step.id]: !isOpen }))
611
612 return (
613 <Box flexDirection="column" gap="md">
614 <Box flexDirection="column" gap="xs">
615 <Box flexDirection="row" justifyContent="space-between" gap="sm">
616 <Box flexShrink={1}>
617 <Text size="heading" weight="semibold" wrap="truncate-end">
618 {p.title || 'Plan'}
619 </Text>
620 </Box>
621 <Text size="heading" weight="semibold" tabular>
622 {pct(whole)}%
623 </Text>
624 </Box>
625 {meter(whole, Math.max(8, Math.min(56, columns - 2)), MASTER_FILL)}
626 <Text size="footnote" dimColor tabular>
627 {facts.join(' · ')}
628 </Text>
629 {p.note && (
630 <Text size="footnote" dimColor>
631 {p.note}
632 </Text>
633 )}
634 {now && nowBlock(now)}
635 </Box>
636
637 <Box flexDirection="column" gap="xs">
638 {p.steps.map(step => {
639 const hasBody = step.tasks.length > 0 || step.note !== undefined || (step.status === 'active' && turnList.length > 0)
640 const isOpen = hasBody && (expanded[step.id] ?? (step.status === 'active' || step.status === 'blocked'))
641 const fraction = stepShare(step)
642 const count = step.tasks.length
643 ? `${step.tasks.filter(t => t.status === 'done').length}/${step.tasks.length}`
644 : ''
645 // The step in progress puts its numbers on the bar below; any other shows its count.
646 const isActive = step.status === 'active'
647 const tally = isActive ? '' : count
648 const label = (
649 <Text wrap="truncate-end">
650 <Text dimColor>{hasBody ? (isOpen ? '▾' : '▸') : ' '}</Text>{' '}
651 <Text color={MARK[step.status].color}>{MARK[step.status].glyph}</Text>{' '}
652 <Text dimColor={step.status === 'done'} weight={step.status === 'active' ? 'semibold' : 'regular'}>
653 {step.text}
654 </Text>
655 </Text>
656 )
657 return (
658 <Box key={`step-${step.id}`} flexDirection="column">
659 <Box flexDirection="row" justifyContent="space-between" gap="sm">
660 <Box flexShrink={1}>
661 {hasBody ? (
662 <Button key={`toggle-${step.id}`} plain onPress={() => toggle(step, isOpen)}>
663 {label}
664 </Button>
665 ) : (
666 label
667 )}
668 </Box>
669 {tally !== '' && (
670 <Text size="footnote" tabular dimColor>
671 {tally}
672 </Text>
673 )}
674 </Box>
675 {isActive && (
676 <Box paddingLeft={4}>
677 {progressLine(
678 fraction,
679 40,
680 4,
681 step.tasks.length
682 ? `${step.tasks.filter(t => t.status === 'done').length} of ${step.tasks.length} tasks`
683 : undefined,
684 )}
685 </Box>
686 )}
687 {isOpen && (
688 <Box flexDirection="column" paddingLeft={4} marginTop={isActive ? 'xs' : undefined}>
689 {step.note !== undefined && (
690 <Text
691 size="footnote"
692 color={step.status === 'blocked' ? 'warning' : undefined}
693 dimColor={step.status !== 'blocked'}
694 >
695 {step.note}
696 </Text>
697 )}
698 {step.tasks.map(t => row(t, `task-${t.id}`, 4))}
699 {step.status === 'active' && turnRows(0)}
700 </Box>
701 )}
702 </Box>
703 )
704 })}
705 </Box>
706
707 {!hasActive && turnRows(0)}
708
709 <Box flexDirection="row">
710 <Button key="refresh" plain dimColor label="Check against the repo" onPress={() => ask($, REFRESH_PROMPT)} />
711 </Box>
712 </Box>
713 )
714 })
715}
716types/index.d.ts 23 lines1export type Status = 'pending' | 'active' | 'done' | 'blocked'
2
3/** `progress` is 0-100, reported by the agent; only an item in progress carries one. */
4export type Task = { id: string; text: string; status: Status; progress?: number }
5
6export type Step = Task & { note?: string; tasks: Task[] }
7
8export type Plan = { title: string; note?: string; steps: Step[]; updatedAt?: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'plan-progress': {
13 plan: Plan
14 /** The agent's own task list for the turn, shown under the step in progress. */
15 turn: Task[]
16 /** Expanded or collapsed per step id; a step absent here is open while in progress. */
17 open: Record<string, boolean>
18 isDismissed: boolean
19 isBriefed: boolean
20 }
21 }
22}
23