SLOPSHOPPER

plan-progress

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…

newpanebandguardcommandstatus
★ 2v0.2.1MITupdated 2026-10-22jpcasa/real-skills/mods/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet │ ┃ Point the agent at a spec, an implementation ⏺ Read(src/auth.ts) │ ┃ plan, a PR or a goal. It reads what is ⎿ Read 6 lines │ ┃ there, checks what has landed, and lays out ⏺ Update(src/auth.ts) │ ┃ the steps here with their tasks. The plan ⎿ Added 2 lines, removed 1 line │ ┃ stays with this project across sessions. ⏺ Bash(bun test) │ ┃ docs/plans/autopilot, a PR link, a goal ⏎ La ⎿ 3 pass, 1 fail │ ┃ Or run /plan-progress with the same. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /plan-progress │ ⎿ plan-progress: The plan is empty. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
No plan yet Point the agent at a spec, an implementation plan, a PR or a goal. It reads what is there, checks what has landed, and lays out the steps here with their tasks. The plan stays with this project across sessions. docs/plans/autopilot, a PR link, a goal ⏎ Lay out plan Or run /plan-progress with the same.
README

real-skills

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

Which skill do I want?

SkillWhat it doesYou give itYou get back
wtfTells 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 screenshotsA verdict with evidence, written for engineers or for the customer. It changes nothing
wtf-and-create-ticketTriages 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 screenshotsOne ticket in ClickUp, GitHub or Linear, with the plan attached. Only for a real bug, or a feature you agree to file
quick-ask-meInterviews you before a small task. Goal, success criteria, then at most 6 more questions.A goal, then your answersA brief in the chat, glossary terms in CONTEXT.md
ask-and-create-specsInterviews you, then writes a spec an agent can build from. At most 40 lines per file.A goal, then your answersA spec in docs/specs/, glossary terms in CONTEXT.md
create-specWrites 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 ticketA spec in docs/specs/, or a few lines in chat for small work
create-planTurns 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 briefA plan in docs/plans/, or a few lines in chat for small work
do-shitBuilds 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 LinearMerged PRs, updated tickets, optional QA evidence
improve-designImproves 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 --routeA pull request with one commit per kept change and the evidence in its description. It merges nothing
clean-slopRemoves 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 pathsOne local commit. It never pushes
solve-merge-conflictsMakes 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 PROne 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-ciGets 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 PROne 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-prsReviews 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 youFindings per PR with a verdict. On approval, one comment review on GitHub
qa-thisQAs 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 asksA 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-migrationsSays 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 PRA verdict and a runbook in the chat, one PR comment if you say so. It applies, deploys and merges nothing
promotePromotes 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 toA 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
changelogWrites 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 trackerOne Markdown file. It changes nothing else
calibrateLets 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 casesA progress line per question. One local file changes, after you approve each question
handoff-with-promptHands 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 stoppingA 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.

Three pillars under most of them

PillarWhat it isWhat it does here
CavemanA terse way of writing: fragments, no filler, exact paths and error textAgents 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
JevA judgment model: you ask a typed question, it answers with a probabilityEach 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
PonytailA way of writing code: the smallest change that fully solves the taskEvery 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

How these skills work

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.

SkillScriptDecided in code todayJev judgments, logged until calibratedAlways yours
wtfwtf.mjsWhether each file:line citation is real, whether a fix is live where the report came from, whether the evidence supports the verdict, confidence, budgetsSame issue as a prior ticket, request or breakage, did the screen say enoughWhether to reproduce, and on which environment
quick-ask-megate.mjsThe six-question budget, the five stop conditionsWhich questions the repo can answer, which would not change the build, whether a criterion is checkableObjective, success criteria, final confirmation
ask-and-create-specsspec-jev.mjsThe 40-line cap, spec structure, missing brief fieldsAsk / assume / drop per question, when to stop, one spec or slices, dead and duplicate linesGoal, the write-or-keep-going call
create-specspec-jev.mjs (shared)The tier when a risky area or more than six files are named, the 40-line cap, spec structureWhether the work needs a spec file, plus ask-and-create-specs's triage, shape and lint judgmentsThe one question round
create-planplan.mjsHeader, caps, placeholders, parallel tasks sharing a file, done-when coverage, Review: yes for auth, schema, payments and deletion filesWhether a task is too big, needs its own review, or has a vague step. Even switched on, review can only be addedGoing ahead with execution
do-shitharness.mjsEvery loop step, role scope, merge order, one architect retry, waiting on pending CI, post-QA offers from configPassing the plan checkpoint, re-approving a fixed PRMerge approval, QA environment and sign-in
improve-designimprove.mjsWhich 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 textWhether 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 proposedWhich moves run, pictures in the pull request, the merge
solve-merge-conflictssolve.mjsWhether 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 pushWhether 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 changeEvery resolution where one side gives way, and the push
fix-cifix.mjsWhat 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 endWhether a failure would pass if run again, whether an edit weakens the check, whether an edit addresses its failureEvery shortcut, every unusual command, each push, each rerun
review-prsreview.mjsWhich findings are real (citation check), duplicates, what was already raised, when a refutation counts, the nit budget, the verdict, the review payloadAn extra lens, whether a nit is worth showing, two findings making one point, a PR doing more than it saysWhich PRs to post to. It never approves or merges
qa-thisqa.mjsWhich 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 refusedWhether an item needs a browser check, a data check or new tests, whether a check covers its criterion, whether a failure is the environment'sWhat to QA, the environment, accepting data changes, sign-in, what gets posted
check-infra-and-migrationscheck.mjsWhat 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 textWhether 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 worseWhether to read a live environment, whether to post, whether to push
promotepromote.mjsWhich 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 runsWhat the pipeline looks like: which branch deploys which environment, which environment feeds which, which is productionWhich environment, both approvals, accepting a blocker, the stage list at setup
changelogjudge.mjsWhich ticket is the PR's own, migration and docs-only flags, default audience from configAmbiguous ticket IDs, default-on behaviour changes, product areaNothing 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):

SkillSentNever sent
wtfA one-line symptom summary written without names, the expected and actual lines, prior-ticket titles. Emails, phone numbers and long numbers are stripped as wellThe report, the support thread, screenshots, customer or company names, file contents
quick-ask-meYour objective, success criteria, drafted questions, facts looked up in the repoFile contents
ask-and-create-specs, create-specThe goal, drafted questions, the running brief, spec lines, guessed file pathsSource code
create-planThe plan's goal, task names, file paths, step textSource code
do-shitTicket titles and bodies, plan summaries, file paths, review findings, CI check namesSource code
improve-designThe 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 showsPictures, the diff, source text, URL query strings
solve-merge-conflictsThe PR title, conflicted file paths, how each conflict was resolved, the one-line reason for it, each side's commit subjects and PR titlesThe code: hunks, resolved text, diffs, file contents, check output
fix-ciThe 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 editThe code, the diff, job logs, command output
review-prsPR title and body, changed file paths, each finding's one-line problemThe diff, quoted lines, source
qa-thisItem title, criteria lines, changed file paths, one line per checkDiff and source text, command output, row values, screenshots, URL query strings
check-infra-and-migrationsThe 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 runFull files, diff hunks, literals, plan and query output, row counts, host names
promoteBranch 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
changelogPR titles, bodies, branch names, labels, file pathsFile 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, `

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