Shows the plan's steps when there is one (task list, approved plan or plan file): the /steps pane and a quiet status line; keeps an open plan across /clear and…

Shows the steps of the plan Claude is working through, and which step it is on. Keeps an open plan on disk so it survives /clear, compaction and a new session, and shows a card when an answer leaves steps open. Inspired by planning-with-files.
/plugin marketplace add partalemre/MODZ
/plugin install plan-steps@modz
Or in one step: /plugin install plan-steps --marketplace partalemre/MODZ.
The MODZ panel (modz) shows its line here, for example 3 of 9 steps, 5 steps · not started or 3 of 9 steps · waiting.
Open the pane with /steps. It never opens by itself.
Plan · 3/9 steps · ≈39% task list
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ≈24m left
✓ Write the types and the state contract 3m
✓ Task list tracking (TodoWrite, TaskCreate) 5m
✓ Markdown plan parser 4m
◐ Fuzzy step-to-task matching 6m
› 3 files: match.test.ts, parse.ts +1
● Matching edge cases
○ Draw the pane: header, bar, step list
○ Status line
✓ is a finished step, dim, with how long it took.◐ is the current step, bold, with its elapsed time. Below it: checked items (2/5 items), the files edited while it was current (by the main loop or any subagent), how many agents have finished, and one ● row for each agent still running.○ is an upcoming step, with its item count if it has items.On the desktop app the pane is one drawing with a progress meter and numbered circles on a rail; the current step has a breathing ring. The terminal shows the text view above. Long plans scroll to keep the current step in view.
The header shows done/total and a percentage: finished steps count fully, and the step in progress adds the share of its checked items, or half if it has none. When partial credit is included the percentage is marked ≈. The time left is the average time of the finished steps times the steps remaining; it appears only after 3 finished steps have measured times. Times come only from changes seen in this session.
A status line entry plan 3/9 · <current step> shows while the plan is under way. When every step is done it reads plan 9/9 · done until your next prompt. A plan that is not under way stays off the status line:
not started.waiting · 45m ago in place of the time left, and the line clears until the next piece of work./clear empties the pane, but an open plan comes back (see below).
When Claude ends a turn with an answer, the turn worked on the plan (a step changed, the plan file was edited, or a file was edited or an agent ran on the current step), and steps are still open, a card shows above the prompt:
╭──────────────────────────────────────────────────────╮
│ ≡ PLAN task list │
│ 6 of 9 steps still open │
│ Next: Fuzzy step-to-task matching │
│ │
│ ●─●─●─○─○─○─○─○─○ 3/9 │
│ │
│ [Continue the plan] [Mark plan done] Not now │
╰──────────────────────────────────────────────────────╯
No card for a subagent's turn, an interrupted turn, a turn that did not touch the plan, a plan nobody has started yet, or a finished plan. On the desktop app the steps are dots in one drawing; the terminal draws them as text. The card waits while another notification (cache-tax, you-should-know, reflect, session-budget, test-badge) is showing, and while a question is open.
An open plan is saved on disk (the plugin's own store), one per project folder, with its steps and their status. It is removed once every step is done or you mark it done, and it is not brought back once it is older than 24 hours (the time of its last change).
When the conversation no longer holds the plan, it gets one short section in the system prompt with the plan's name, how many steps are done, and the open steps:
/compact or automatic) of the main conversation,/clear,After /clear or in a new session the plan also comes back to the pane, the status line and the MODZ panel. The section is written once per such event and stays the same on each request after it, so the prompt cache keeps working. It is dropped when the plan is finished, marked done, or replaced by a different plan. It is never added for a finished plan. A worktree counts as its own project, so parallel worktrees keep separate plans.
The mod watches tool calls and reads no model output.
TodoWrite, or TaskCreate and TaskUpdate, with each item's pending, in-progress or completed status. A subagent's own TodoWrite is ignored. On the desktop app's Code tab Claude keeps the list with the app's own tool (update_plan) instead; that list counts the same way, and the app's wrap-up checks it off.ExitPlanMode returns once you approve it.docs/**/plans/*.md, PLAN.md and *.plan.md always count. Any other .md counts only if it has ### Task N: or ## Step N headings or at least 3 checkboxes, and it never replaces a named plan file or an approved plan. When the file is edited it is parsed again, so ticked boxes show up.If both a plan document and a task list exist, the document's steps lead and the tasks give their status, matched by number (Task 3: ...) or by shared words. If the tasks match fewer than half of the steps, the task list is shown instead, since that is the live work.
/steps opens the pane. (/plan is a built-in Claude Code command, so this mod cannot use that name.)holdFinish, off by default). When an answer leaves steps open, the mod asks Claude to go on by itself instead of showing the card, at most 2 times per plan; after that the card shows as usual. This sends prompts without a press, so it is opt-in. Set it in the plugin's options (/plugin), or under pluginConfigs in settings.json.Makes no model calls. The only thing it adds to the context is the carried-plan section above: at most 600 characters (about 150 input tokens) on each request while it rides, after a compaction, a /clear or a new session with an open plan; nothing otherwise. With Hold the finish on, each automatic continue is one more turn. Its only timers are a 30-second tick while the pane is open and one 30-minute timer that takes a quiet plan off the status line. The only file reads are re-reads of markdown files Claude has just read or written; the only writes are the saved plan in the plugin's store.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 240 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register } from 'claude-code'
3
4import { isShown, planState, STALE_MS } from './freshness'
5import { withoutRestored } from './carry'
6import { wireCarry } from './carry-hooks'
7import { wireFinish } from './finish-hooks'
8import { HUB_PANE, hubOf } from './hub'
9import type { HubShown } from './hub'
10import { resolvePlan } from './match'
11import type { Plan } from './match'
12import { wirePane } from './pane-hook'
13import { baseName } from './parse'
14import { addAgent, addFile, currentIndex, observe, progressOf, settleAgents } from './progress'
15import { approvedDoc, fileDoc, isReplacing, mayHoldPlan, statusText } from './plan'
16import { createdId, createTask, finished, fromDeskPlan, fromTodos, updateTask } from './tasks'
17import type { HubCard, PlanDoc, PlanTask, StepTrack } from '../types'
18import { setLang } from './kit/lang'
19import { words } from './words'
20
21// The engine reads each module's use of the mod's values from that module's own source.
22const docAtom = atom({ plugin: 'plan-steps', key: 'doc' } as const, null)
23const tasksAtom = atom({ plugin: 'plan-steps', key: 'tasks' } as const, [])
24const tracksAtom = atom({ plugin: 'plan-steps', key: 'tracks' } as const, {})
25
26const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
27const FILE_TOOLS = ['Read', ...EDIT_TOOLS]
28const LIVE_AGENT = ['async_launched', 'teammate_spawned']
29const DESK_PLAN = 'mcp__ccd_turn__update_plan'
30const DESK_WRAP_UP = 'mcp__ccd_turn__wrap_up'
31const hubRef = { plugin: 'plan-steps', key: 'hub' } as const
32
33type Timer = { cancel: () => void }
34
35// Session-local facts. `isSettled`: a finished plan's status line was cleared. `touchedAt`: the last
36// time the plan or the step under way moved; `quiet` re-checks the status line once that goes stale.
37const flags = { cwd: '', isSettled: false, touchedAt: 0, quiet: null as Timer | null }
38
39type Ran = { deny?: unknown; isError?: unknown; result?: unknown }
40type Call = { tool: string; tool_use_id: string; agentId?: string } & Record<string, unknown>
41
42function isOk(ran: Ran): boolean {
43 return ran.deny === undefined && ran.isError !== true
44}
45
46function relative(path: string): string {
47 return flags.cwd !== '' && path.startsWith(`${flags.cwd}/`) ? path.slice(flags.cwd.length + 1) : path
48}
49
50async function currentPlan($: EngineInterface): Promise<Plan | null> {
51 return resolvePlan(await read($, tasksAtom), await read($, docAtom))
52}
53
54// The MODZ main panel's card (DESIGN.md §9), written only when what it shows changes.
55async function writeHub($: EngineInterface, shown: HubShown): Promise<void> {
56 const { value } = await $.state.get(hubRef)
57 if (value !== undefined && value.detail === shown.detail && value.tone === shown.tone) return
58 const card: HubCard = { pane: HUB_PANE, ...shown, at: await $.clock.now() }
59 await $.state.set(hubRef, card)
60}
61
62// Re-reads the plan, stamps status changes with the time, and refreshes the status line: only a
63// plan under way (or just finished) is on it; one not started or gone stale stays in the pane.
64async function sync($: EngineInterface): Promise<void> {
65 const plan = await currentPlan($)
66 const now = await $.clock.now()
67 const tracks = await update($, tracksAtom, before => (plan === null ? before : observe(before, plan.steps, now)))
68 const progress = plan === null ? null : progressOf(plan)
69 const state = planState(plan, { now, touchedAt: flags.touchedAt })
70 if (progress === null || progress.done < progress.total) flags.isSettled = false
71 $.ui.status(flags.isSettled || !isShown(state) ? undefined : statusText(plan, tracks))
72 await writeHub($, hubOf(progress, state))
73}
74
75// The plan moved: it is fresh again, and goes quiet if nothing moves it for STALE_MS.
76async function touch($: EngineInterface): Promise<void> {
77 flags.touchedAt = await $.clock.now()
78 flags.quiet?.cancel()
79 flags.quiet = $.clock.after(STALE_MS, () => void sync($))
80}
81
82async function setTasks($: EngineInterface, change: (tasks: PlanTask[]) => PlanTask[]): Promise<void> {
83 await update($, tasksAtom, change)
84 await touch($)
85 await sync($)
86}
87
88async function offerDoc($: EngineInterface, doc: PlanDoc | null): Promise<void> {
89 if (doc === null || !isReplacing(await read($, docAtom), doc)) return
90 await update($, docAtom, () => doc)
91 await touch($)
92 await sync($)
93}
94
95// Edits and agents count toward the step under way, if one is.
96async function onCurrent($: EngineInterface, change: (track: StepTrack) => StepTrack): Promise<void> {
97 const plan = await currentPlan($)
98 if (plan === null) return
99 let isMoved = false
100 await update($, tracksAtom, tracks => {
101 const step = plan.steps[currentIndex(plan.steps, tracks)]
102 const track = step === undefined ? undefined : tracks[step.key]
103 if (step === undefined || track === undefined) return tracks
104 isMoved = true
105
106 return { ...tracks, [step.key]: change(track) }
107 })
108 if (isMoved) await revive($)
109}
110
111// Work on the step under way keeps the plan fresh; a plan that had gone stale comes back.
112async function revive($: EngineInterface): Promise<void> {
113 const wasStale = (await $.clock.now()) - flags.touchedAt >= STALE_MS
114 await touch($)
115 if (wasStale) await sync($)
116}
117
118async function afterFile($: EngineInterface, call: Call): Promise<void> {
119 const path = call.file_path ?? call.notebook_path
120 if (typeof path !== 'string') return
121 if (EDIT_TOOLS.includes(call.tool)) await onCurrent($, track => addFile(track, relative(path)))
122 if (!mayHoldPlan(path)) return
123 try {
124 await offerDoc($, fileDoc(path, await $.fs.read(path)))
125 } catch (error) {
126 $.ui.log(`plan-steps: could not read ${baseName(path)} (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
127 }
128}
129
130// The task list is the main loop's own (TodoWrite); TaskCreate/TaskUpdate share one list across loops.
131async function afterTasks($: EngineInterface, call: Call, ran: Ran): Promise<void> {
132 const isMainLoop = call.agentId === undefined
133 if (call.tool === 'TodoWrite' && isMainLoop) await setTasks($, () => fromTodos(call.todos))
134 if (call.tool === DESK_PLAN && isMainLoop) await setTasks($, tasks => fromDeskPlan(tasks, call))
135 if (call.tool === DESK_WRAP_UP && isMainLoop && (await read($, tasksAtom)).length > 0) await setTasks($, finished)
136 if (call.tool === 'TaskCreate') {
137 const subject = String(call.subject ?? '')
138 await setTasks($, tasks => createTask(withoutRestored(tasks), { id: createdId(ran.result, withoutRestored(tasks)), subject }))
139 }
140 if (call.tool === 'TaskUpdate') await setTasks($, tasks => updateTask(tasks, { taskId: call.taskId, subject: call.subject, status: call.status }))
141}
142
143async function startAgent($: EngineInterface, call: Call): Promise<void> {
144 const description = String(call.description ?? call.subagent_type ?? words().subagent)
145 await onCurrent($, track => addAgent(track, { key: call.tool_use_id, description, isRunning: true, agentId: null }))
146}
147
148// A background agent runs on after its call returns; its loop's turn.complete ends it.
149async function endAgent($: EngineInterface, call: Call, ran: Ran): Promise<void> {
150 const record = ran.result as { agentId?: unknown; agent_id?: unknown; status?: unknown } | undefined
151 const id = record?.agentId ?? record?.agent_id
152 const isLive = isOk(ran) && LIVE_AGENT.includes(String(record?.status))
153 const patch = { isRunning: isLive, agentId: typeof id === 'string' ? id : null }
154 await update($, tracksAtom, tracks => settleAgents(tracks, agent => agent.key === call.tool_use_id, patch))
155}
156
157async function onToolCall<R extends Ran>($: EngineInterface, call: Call, run: () => Promise<R>): Promise<R> {
158 if (call.tool === 'Agent') await startAgent($, call)
159 const ran = await run()
160 if (call.tool === 'Agent') await endAgent($, call, ran)
161 if (!isOk(ran)) return ran
162 if (FILE_TOOLS.includes(call.tool)) await afterFile($, call)
163 else if (call.tool === 'ExitPlanMode') await offerDoc($, approvedDoc(ran.result))
164 else await afterTasks($, call, ran)
165
166 return ran
167}
168
169async function reset($: EngineInterface): Promise<void> {
170 await update($, tasksAtom, () => [])
171 await update($, docAtom, () => null)
172 await update($, tracksAtom, () => ({}))
173 flags.isSettled = false
174 flags.quiet?.cancel()
175 flags.quiet = null
176 $.ui.status(undefined)
177 await writeHub($, hubOf(null, 'none'))
178}
179
180function lifecycle(on: On): void {
181 on('session.start', async ($, e, next) => {
182 // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
183 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
184 flags.cwd = e.cwd.replace(/\/$/, '')
185 const plan = await currentPlan($)
186 const state = planState(plan, { now: await $.clock.now(), touchedAt: flags.touchedAt })
187 await writeHub($, hubOf(plan === null ? null : progressOf(plan), state))
188 try {
189 await $.command.register({ name: 'steps', description: words().command })
190 } catch {
191 $.ui.log('plan-steps: could not register /steps', { to: 'debug' })
192 }
193
194 return next(e)
195 })
196
197 on('session.end', async ($, e, next) => {
198 if (e.reason === 'clear') await reset($)
199
200 return next(e)
201 }).catch(($, e, next) => next(e))
202}
203
204function tracking(on: On): void {
205 on('tool.call', ($, e, next) => onToolCall($, e as unknown as Call, () => next(e))).catch(($, e, next) => next(e))
206
207 on('turn.complete', async ($, e, next) => {
208 const agentId = e.agentId
209 if (agentId !== undefined) {
210 await update($, tracksAtom, tracks => settleAgents(tracks, agent => agent.agentId === agentId, { isRunning: false }))
211 }
212
213 return next(e)
214 }).catch(($, e, next) => next(e))
215
216 // A finished plan keeps its line until the person moves on.
217 on('prompt.submit', async ($, e, next) => {
218 flags.isSettled = (await currentPlan($))?.steps.every(step => step.status === 'completed') ?? false
219 if (flags.isSettled) await sync($)
220
221 return next(e)
222 }).catch(($, e, next) => next(e))
223
224 // A plan brought back from disk (carry-hooks.ts) is the plan again: its steps, line and hub card.
225 on('state.set', { plugin: 'plan-steps', key: 'carried' }, async ($, e, next) => {
226 const done = await next(e)
227 if (e.value !== null) await sync($)
228
229 return done
230 }).catch(($, e, next) => next(e))
231}
232
233export const register: Register = (on, options) => {
234 lifecycle(on)
235 tracking(on)
236 wireCarry(on)
237 wireFinish(on, { touchedAt: () => flags.touchedAt, isStrict: options.holdFinish === true })
238 wirePane(on, { touchedAt: () => flags.touchedAt })
239}
240hooks/freshness.ts 30 lines1import type { Plan } from './match'
2import { formatDuration } from './progress'
3import { words } from './words'
4
5// How alive the plan in view is. A plan nobody has started, or one nothing has touched for half an
6// hour, is not the work under way: the status line leaves it out and the pane says so quietly.
7
8export const STALE_MS = 30 * 60_000
9
10export type PlanState = 'none' | 'unstarted' | 'live' | 'stale' | 'done'
11
12export function planState(plan: Plan | null, at: { now: number; touchedAt: number }): PlanState {
13 if (plan === null || plan.steps.length === 0) return 'none'
14 if (plan.steps.every(step => step.status === 'completed')) return 'done'
15 if (plan.steps.every(step => step.status === 'pending')) return 'unstarted'
16
17 return at.now - at.touchedAt >= STALE_MS ? 'stale' : 'live'
18}
19
20// Whether the status line carries the plan: only work under way, or a plan just finished.
21export const isShown = (state: PlanState): boolean => state === 'live' || state === 'done'
22
23// The quiet note a plan that is not under way carries: "başlamadı", "bekliyor · 45dk önce".
24export function stateNote(state: PlanState, at: { now: number; touchedAt: number }): string {
25 if (state === 'unstarted') return words().notStarted
26 if (state === 'stale') return words().waitingSince(formatDuration(at.now - at.touchedAt))
27
28 return ''
29}
30hooks/carry.ts 114 lines1import type { Plan } from './match'
2import { clip } from './progress'
3import type { PlanTask, StoredPlan, StoredStep } from '../types'
4
5// The plan kept on disk, one entry per project root, and the short system prompt section that brings an
6// open one back into a conversation that no longer holds it (after a compaction, a /clear, a new session).
7
8export const MAX_AGE_MS = 24 * 60 * 60_000
9export const SECTION_MAX = 600
10export const SECTION_ID = 'plan-steps:open-plan'
11export const STORE_KEY = 'plans'
12const STEP_TITLE_MAX = 80
13const FNV_OFFSET = 0x811c9dc5
14const FNV_PRIME = 0x01000193
15const HEX = 16
16const RESTORED = 'stored-'
17
18export type Plans = Record<string, StoredPlan>
19export type CarryCause = 'compact' | 'clear' | 'fresh'
20
21const LEAD: Record<CarryCause, string> = {
22 compact: 'The conversation was just compacted, so the plan it was following is no longer in view.',
23 clear: 'The conversation was cleared while a plan in this project was still open.',
24 fresh: 'A plan from an earlier session in this project is still open.',
25}
26const TAIL = 'Pick up from the first open step unless the person asks for something else, and keep the task list current.'
27
28// Names a plan by its step titles, so a new read of the same plan is the same plan whatever its statuses.
29export function signatureOf(steps: readonly { title: string }[]): string {
30 let hash = FNV_OFFSET
31 for (const char of steps.map(step => step.title.trim().toLowerCase()).join('\n')) {
32 hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), FNV_PRIME) >>> 0
33 }
34
35 return hash.toString(HEX)
36}
37
38export const isOpen = (steps: readonly StoredStep[]): boolean => steps.some(step => step.status !== 'completed')
39
40const stepsOf = (plan: Plan): StoredStep[] => plan.steps.map(step => ({ title: step.title, status: step.status }))
41
42const sameSteps = (a: readonly StoredStep[], b: readonly StoredStep[]): boolean =>
43 a.length === b.length && a.every((step, k) => step.title === b[k]?.title && step.status === b[k]?.status)
44
45export const plansOf = (value: unknown): Plans => (typeof value === 'object' && value !== null ? (value as Plans) : {})
46
47// The store once the person marked this project's plan done.
48export function withoutPlan(plans: Plans, root: string): Plans {
49 return Object.fromEntries(Object.entries(plans).filter(([at]) => at !== root))
50}
51
52function pruned(plans: Plans, now: number): Plans {
53 return Object.fromEntries(Object.entries(plans).filter(([, kept]) => now - kept.at <= MAX_AGE_MS))
54}
55
56// The store after the plan in view changed: kept while open, dropped once done or marked done. An entry
57// whose steps did not change keeps its time, so a plan carried from session to session still ages out.
58export function keptPlans(plans: Plans, seen: { root: string; plan: Plan | null; dismissed: string | null; now: number }): Plans {
59 const next = pruned(plans, seen.now)
60 const { plan, root } = seen
61 if (plan === null || plan.steps.length === 0) return next
62 const steps = stepsOf(plan)
63 const before = next[root]
64 if (seen.dismissed === signatureOf(steps) || !isOpen(steps)) {
65 delete next[root]
66
67 return next
68 }
69 if (before !== undefined && sameSteps(before.steps, steps)) return next
70
71 return { ...next, [root]: { title: plan.source, steps, at: seen.now } }
72}
73
74// An open plan of this project, young enough to bring back.
75export function freshPlan(plans: Plans, at: { root: string; now: number }): StoredPlan | null {
76 const kept = plans[at.root]
77 if (kept === undefined || at.now - kept.at > MAX_AGE_MS || !isOpen(kept.steps)) return null
78
79 return kept
80}
81
82function stepLine(step: StoredStep): string {
83 return `\n- ${clip(step.title, STEP_TITLE_MAX)}${step.status === 'in_progress' ? ' (in progress)' : ''}`
84}
85
86// At most SECTION_MAX characters: as many open steps as fit, then how many more there are.
87export function sectionText(plan: { title: string; steps: readonly StoredStep[] }, cause: CarryCause): string {
88 const done = plan.steps.filter(step => step.status === 'completed').length
89 const open = plan.steps.filter(step => step.status !== 'completed')
90 const head = `${LEAD[cause]}\nPlan: ${clip(plan.title, STEP_TITLE_MAX)}, ${done} of ${plan.steps.length} steps done. Open steps:`
91 const tail = `\n${TAIL}`
92 const lines: string[] = []
93 for (const [k, step] of open.entries()) {
94 const more = `\n- and ${open.length - k - 1} more`
95 const line = stepLine(step)
96 const reserve = k === open.length - 1 ? 0 : more.length
97 if (head.length + lines.join('').length + line.length + reserve + tail.length > SECTION_MAX) {
98 lines.push(`\n- and ${open.length - k} more`)
99 break
100 }
101 lines.push(line)
102 }
103
104 return head + lines.join('') + tail
105}
106
107// The kept steps as a task list, so the pane, the hub and the finish card know the plan again.
108export function restoredTasks(plan: StoredPlan): PlanTask[] {
109 return plan.steps.map((step, k) => ({ id: `${RESTORED}${k}`, subject: step.title, status: step.status }))
110}
111
112// A task list brought back from disk gives way to the first task the model creates.
113export const withoutRestored = (tasks: PlanTask[]): PlanTask[] => tasks.filter(task => !task.id.startsWith(RESTORED))
114hooks/carry-hooks.ts 156 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { freshPlan, keptPlans, plansOf, restoredTasks, SECTION_ID, sectionText, signatureOf, STORE_KEY } from './carry'
5import type { CarryCause, Plans } from './carry'
6import { isSame } from './kit/same'
7import { resolvePlan } from './match'
8import type { Plan } from './match'
9import type { PlanDoc, PlanTask, StoredStep } from '../types'
10
11// The engine reads each module's use of the mod's values from that module's own source.
12const carriedAtom = atom({ plugin: 'plan-steps', key: 'carried' } as const, null)
13const dismissedAtom = atom({ plugin: 'plan-steps', key: 'dismissed' } as const, null)
14const docAtom = atom({ plugin: 'plan-steps', key: 'doc' } as const, null)
15const tasksAtom = atom({ plugin: 'plan-steps', key: 'tasks' } as const, [])
16
17// The plan outliving the conversation (carry.ts is the pure half): kept in $.store whenever it changes,
18// brought back into a conversation that starts empty, and carried by one short system prompt section after
19// a compaction, a /clear or a new session. This file talks to the rest of the mod through $.state alone.
20
21const reason = (error: unknown): string => (error instanceof Error ? error.message : 'unknown')
22
23const isOpenPlan = (plan: Plan | null): plan is Plan => plan !== null && plan.steps.some(step => step.status !== 'completed')
24
25async function currentPlan($: EngineInterface): Promise<Plan | null> {
26 return resolvePlan(await read($, tasksAtom), await read($, docAtom))
27}
28
29// The project root, not the repository's: a worktree has its own plan, and parallel worktrees would
30// otherwise overwrite each other's.
31async function rootOf($: EngineInterface): Promise<string | null> {
32 try {
33 return await $.session.root()
34 } catch (error) {
35 $.ui.log(`plan-steps: no project root (${reason(error)})`, { to: 'debug' })
36
37 return null
38 }
39}
40
41async function readPlans($: EngineInterface): Promise<Plans> {
42 return plansOf(await $.store.get(STORE_KEY))
43}
44
45// The values a state.set hook was handed win over a read, which sees its dispatch's moment.
46type Written = { tasks?: PlanTask[]; doc?: PlanDoc | null }
47
48// Writes only when the kept plans changed. A failing store costs the carrying alone.
49async function keepPlan($: EngineInterface, written: Written): Promise<void> {
50 const root = await rootOf($)
51 if (root === null) return
52 try {
53 const before = await readPlans($)
54 const plan = resolvePlan(written.tasks ?? (await read($, tasksAtom)), written.doc === undefined ? await read($, docAtom) : written.doc)
55 const next = keptPlans(before, { root, plan, dismissed: await read($, dismissedAtom), now: await $.clock.now() })
56 if (!isSame(before, next)) await $.store.set(STORE_KEY, next)
57 } catch (error) {
58 $.ui.log(`plan-steps: could not keep the plan (${reason(error)})`, { to: 'debug' })
59 }
60}
61
62// Freezes the section once per event: the same text on every request after, so the prompt cache holds.
63async function arm($: EngineInterface, plan: { title: string; steps: readonly StoredStep[] }, cause: CarryCause): Promise<void> {
64 const signature = signatureOf(plan.steps)
65 if ((await read($, dismissedAtom)) === signature) return
66 const carried = { text: sectionText(plan, cause), signature, at: await $.clock.now() }
67 await update($, carriedAtom, () => carried)
68}
69
70// An empty conversation gets this project's open plan back, as the task list and as the section. The
71// `carried` write tells the register file to bring the pane, the status line and the hub up to date.
72async function restore($: EngineInterface, cause: CarryCause): Promise<void> {
73 const root = await rootOf($)
74 if (root === null) return
75 try {
76 const kept = freshPlan(await readPlans($), { root, now: await $.clock.now() })
77 if (kept === null || (await read($, dismissedAtom)) === signatureOf(kept.steps)) return
78 await update($, tasksAtom, () => restoredTasks(kept))
79 await arm($, kept, cause)
80 } catch (error) {
81 $.ui.log(`plan-steps: could not bring the plan back (${reason(error)})`, { to: 'debug' })
82 }
83}
84
85// A new process with nothing said yet; a resumed session, or a reload, already holds its plan.
86async function isEmptyConversation($: EngineInterface): Promise<boolean> {
87 try {
88 return (await $.session.turns()) === 0 && (await currentPlan($)) === null
89 } catch (error) {
90 $.ui.log(`plan-steps: turns not readable (${reason(error)})`, { to: 'debug' })
91
92 return false
93 }
94}
95
96function keeping(on: On): void {
97 // Every change of the task list or the plan document, whoever made it.
98 on('state.set', { plugin: 'plan-steps', key: 'tasks' }, async ($, e, next) => {
99 const done = await next(e)
100 await keepPlan($, { tasks: e.value })
101
102 return done
103 }).catch(($, e, next) => next(e))
104
105 on('state.set', { plugin: 'plan-steps', key: 'doc' }, async ($, e, next) => {
106 const done = await next(e)
107 await keepPlan($, { doc: e.value })
108
109 return done
110 }).catch(($, e, next) => next(e))
111}
112
113function bringingBack(on: On): void {
114 // The register file's own session.start hook takes every start; this one only a person's session.
115 on('session.start', { isInteractive: true }, async ($, e, next) => {
116 const done = await next(e)
117 if (await isEmptyConversation($)) await restore($, 'fresh')
118
119 return done
120 }).catch(($, e, next) => next(e))
121
122 // After the register file's reset (it runs before `next`), whichever way round the two hooks sit.
123 on('session.end', { reason: 'clear' }, async ($, e, next) => {
124 const done = await next(e)
125 await restore($, 'clear')
126
127 return done
128 }).catch(($, e, next) => next(e))
129
130 on('session.compact', async ($, e, next) => {
131 const result = await next(e)
132 if (e.agentId !== undefined || e.trigger === 'precompute' || result.messages === undefined) return result
133 const plan = await currentPlan($)
134 if (isOpenPlan(plan)) await arm($, { title: plan.source, steps: plan.steps }, 'compact')
135
136 return result
137 }).catch(($, e, next) => next(e))
138}
139
140export function wireCarry(on: On): void {
141 keeping(on)
142 bringingBack(on)
143
144 // The section rides every request until the plan is done, marked done or replaced by another one.
145 on('prompt.compose', async ($, e, next) => {
146 const composed = await next(e)
147 const carried = await read($, carriedAtom)
148 if (carried === null) return composed
149 const plan = await currentPlan($)
150 if (!isOpenPlan(plan) || signatureOf(plan.steps) !== carried.signature) return composed
151 if ((await read($, dismissedAtom)) === carried.signature) return composed
152
153 return { sections: [...composed.sections, { id: SECTION_ID, text: carried.text, scope: 'session' as const }] }
154 }).catch(($, e, next) => next(e))
155}
156hooks/finish-hooks.tsx 156 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { plansOf, signatureOf, STORE_KEY, withoutPlan } from './carry'
5import { finishCard } from './finish-card'
6import type { FinishActions } from './finish-card'
7import { finishCall, heldCount, nextStep, noticeOf } from './finish'
8import { resolveLight } from './kit/tokens'
9import { resolvePlan } from './match'
10import type { Plan } from './match'
11import { pinned } from './pin'
12import { words } from './words'
13
14// The engine reads each module's use of the mod's values from that module's own source.
15const carriedAtom = atom({ plugin: 'plan-steps', key: 'carried' } as const, null)
16const dismissedAtom = atom({ plugin: 'plan-steps', key: 'dismissed' } as const, null)
17const docAtom = atom({ plugin: 'plan-steps', key: 'doc' } as const, null)
18const heldAtom = atom({ plugin: 'plan-steps', key: 'held' } as const, null)
19const noticeAtom = atom({ plugin: 'plan-steps', key: 'notice' } as const, null)
20const tasksAtom = atom({ plugin: 'plan-steps', key: 'tasks' } as const, [])
21
22// The finish guard (finish.ts is the pure half): after a main-loop answer that worked on the plan and left
23// steps open, a card above the prompt (DESIGN.md §6, slot 2, one notification at a time), or in strict mode
24// a prompt to go on.
25
26export type FinishCtx = {
27 // When the plan last moved, by the register file's clock of it.
28 touchedAt: () => number
29 isStrict: boolean
30}
31
32// Louder notifications share the slot: while one shows, the card waits.
33const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
34const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
35const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
36const budgetNotice = { plugin: 'session-budget', key: 'notice' } as const
37const testBadgeNotice = { plugin: 'test-badge', key: 'notice' } as const
38const handoffNotice = { plugin: 'handoff', key: 'notice' } as const
39
40// When the current turn began (a plan moved since then is one this turn worked on), and the card's theme.
41const turn = { startedAt: 0, isLight: false }
42
43const reason = (error: unknown): string => (error instanceof Error ? error.message : 'unknown')
44
45async function currentPlan($: EngineInterface): Promise<Plan | null> {
46 return resolvePlan(await read($, tasksAtom), await read($, docAtom))
47}
48
49async function hasLouderNotice($: EngineInterface): Promise<boolean> {
50 const others: { value?: unknown }[] = [
51 await $.state.get(cacheTaxNotice as never),
52 await $.state.get(knowNotice as never),
53 await $.state.get(reflectNotice as never),
54 await $.state.get(budgetNotice as never),
55 await $.state.get(testBadgeNotice as never),
56 await $.state.get(handoffNotice as never),
57 ]
58
59 return others.some(other => other.value !== undefined && other.value !== null)
60}
61
62async function clearNotice($: EngineInterface): Promise<void> {
63 if ((await read($, noticeAtom)) !== null) await update($, noticeAtom, () => null)
64}
65
66// Only a person's press, or the strict mode the person turned on, submits.
67function submit($: EngineInterface, title: string): void {
68 void $.prompt.submit({ text: words().continuePrompt(title) }).catch(error => {
69 $.ui.log(`plan-steps: could not continue the plan (${reason(error)})`, { to: 'debug' })
70 })
71}
72
73// The card follows Claude Code's theme setting; `auto` leaves its colours to the system's appearance.
74async function refreshLook($: EngineInterface): Promise<void> {
75 try {
76 turn.isLight = resolveLight({ theme: (await $.config.list()).find(row => row.key === 'theme')?.value })
77 } catch (error) {
78 $.ui.log(`plan-steps: theme not readable (${reason(error)})`, { to: 'debug' })
79 }
80}
81
82async function guard($: EngineInterface, ctx: FinishCtx): Promise<void> {
83 const plan = await currentPlan($)
84 const facts = { plan, isTouched: ctx.touchedAt() >= turn.startedAt, dismissed: await read($, dismissedAtom), held: await read($, heldAtom), isStrict: ctx.isStrict }
85 const call = finishCall(facts)
86 if (call === 'none' || plan === null) return
87 if (call === 'card') {
88 await refreshLook($)
89 const notice = noticeOf(plan, await $.clock.now())
90 await update($, noticeAtom, () => notice)
91
92 return
93 }
94 const signature = signatureOf(plan.steps)
95 await update($, heldAtom, held => ({ signature, count: heldCount(held, signature) + 1 }))
96 submit($, nextStep(plan))
97}
98
99// No longer kept, carried or guarded. Done here, in the press: the mod's own state.set hooks do not hear it.
100async function markDone($: EngineInterface, signature: string): Promise<void> {
101 await update($, dismissedAtom, () => signature)
102 if ((await read($, carriedAtom)) !== null) await update($, carriedAtom, () => null)
103 try {
104 const root = await $.session.root()
105 const plans = plansOf(await $.store.get(STORE_KEY))
106 if (plans[root] !== undefined) await $.store.set(STORE_KEY, withoutPlan(plans, root))
107 } catch (error) {
108 $.ui.log(`plan-steps: could not drop the kept plan (${reason(error)})`, { to: 'debug' })
109 }
110}
111
112function actionsFor($: EngineInterface): FinishActions {
113 return {
114 onContinue: () => void (async () => {
115 const notice = await read($, noticeAtom)
116 await clearNotice($)
117 if (notice !== null) submit($, notice.next)
118 })(),
119 onMarkDone: () => void (async () => {
120 const notice = await read($, noticeAtom)
121 await clearNotice($)
122 if (notice !== null) await markDone($, notice.signature)
123 })(),
124 onNotNow: () => void clearNotice($),
125 }
126}
127
128export function wireFinish(on: On, ctx: FinishCtx): void {
129 // The turn a prompt starts; the card goes with it.
130 on('turn.start', async ($, e, next) => {
131 turn.startedAt = await $.clock.now()
132 await clearNotice($)
133
134 return next(e)
135 }).catch(($, e, next) => next(e))
136
137 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
138 const done = await next(e)
139 if (e.agentId === undefined) await guard($, ctx)
140
141 return done
142 }).catch(($, e, next) => next(e))
143
144 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145 if (e.props.hasSurvey) return next(e)
146 const theirs = await next(e)
147 const notice = await read($, noticeAtom)
148 if (notice === null || e.props.isWorking || (await hasLouderNotice($))) return theirs
149 const ui = $.ui.resolve(e)
150 const card = finishCard({ ui, surface: e.surface, isLight: turn.isLight }, { notice, columns: e.props.bodyColumns, actions: actionsFor($) })
151 const { Box } = ui
152
153 return pinned(e, <Box flexDirection="column" gap={1}>{card}{theirs}</Box>)
154 })
155}
156hooks/hub.ts 26 lines1import type { PlanState } from './freshness'
2import type { HubTone } from '../types'
3import { words } from './words'
4
5// The MODZ main panel's side of this mod (DESIGN.md §9): a one-line summary card it collects, and its
6// "open" requests, which name a pane by key.
7
8export const HUB_PANE = 'plan'
9
10export type HubShown = { detail: string; tone: HubTone }
11
12// Iris only while the plan is under way; a plan not started or gone quiet reads idle.
13export function hubOf(progress: { done: number; total: number } | null, state: PlanState): HubShown {
14 if (progress === null || progress.total === 0) return { detail: '', tone: 'idle' }
15 const say = words()
16 if (progress.done >= progress.total) return { detail: say.hubDone(progress.total), tone: 'ok' }
17 if (state === 'unstarted') return { detail: say.hubUnstarted(progress.total), tone: 'idle' }
18 if (state === 'stale') return { detail: say.hubWaiting(progress.done, progress.total), tone: 'idle' }
19
20 return { detail: say.hubLive(progress.done, progress.total), tone: 'live' }
21}
22
23export function isOpenFor(value: unknown, pane: string): boolean {
24 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
25}
26hooks/match.ts 154 lines1import type { DocStep, PlanDoc, PlanTask, StepStatus } from '../types'
2import { words } from './words'
3
4export type Step = {
5 key: string
6 title: string
7 status: StepStatus
8 // Credit toward progress: 1 done, 0 not begun, between for a step under way.
9 credit: number
10 checksDone: number
11 checksTotal: number
12}
13
14export type Plan = { steps: Step[]; source: string }
15
16const IN_PROGRESS_CREDIT = 0.5
17const MATCH_MIN = 0.6
18const MIN_WORD = 3
19const FOLD: Record<string, string> = { ç: 'c', ğ: 'g', ı: 'i', ö: 'o', ş: 's', ü: 'u', â: 'a', î: 'i', û: 'u' }
20const NUMBER_REF = /\b(?:task|step|adım|görev|phase|aşama)\s*(\d+)\b/i
21const NUMBER_LEAD = /^\s*(\d+)[.):]\s/
22const RANK: Record<StepStatus, number> = { pending: 0, in_progress: 1, completed: 2 }
23
24export function normalize(text: string): string {
25 return text
26 .toLocaleLowerCase('tr')
27 .replace(/[çğıöşüâîû]/g, char => FOLD[char] ?? char)
28 .replace(/\b(?:task|step|adim|gorev|phase|asama)\s*\d+\b/g, ' ')
29 .replace(/[^a-z0-9]+/g, ' ')
30 .trim()
31}
32
33function wordSet(text: string): Set<string> {
34 return new Set(normalize(text).split(' ').filter(word => word.length >= MIN_WORD))
35}
36
37export function numberOf(text: string): number | null {
38 const found = NUMBER_REF.exec(text) ?? NUMBER_LEAD.exec(text)
39
40 return found === null ? null : Number(found[1])
41}
42
43// Overlap of the shorter title's words with the longer's: 1 when one title holds the other.
44export function similarity(a: string, b: string): number {
45 const left = wordSet(a)
46 const right = wordSet(b)
47 const smaller = Math.min(left.size, right.size)
48 if (smaller === 0) return normalize(a) !== '' && normalize(a) === normalize(b) ? 1 : 0
49 let shared = 0
50 for (const word of left) if (right.has(word)) shared += 1
51
52 return shared / smaller
53}
54
55function score(step: DocStep, task: PlanTask): number {
56 const taskNumber = numberOf(task.subject)
57 if (step.number !== null && taskNumber === step.number) return 1
58
59 return similarity(step.title, task.subject)
60}
61
62// Greedy best-first pairing: each task speaks for at most one step.
63export function matchTasks(steps: DocStep[], tasks: PlanTask[]): Map<number, PlanTask> {
64 const pairs: { step: number; task: number; value: number }[] = []
65 steps.forEach((step, stepIndex) =>
66 tasks.forEach((task, taskIndex) => {
67 const value = score(step, task)
68 if (value >= MATCH_MIN) pairs.push({ step: stepIndex, task: taskIndex, value })
69 }),
70 )
71 pairs.sort((a, b) => b.value - a.value)
72 const matched = new Map<number, PlanTask>()
73 const usedTasks = new Set<number>()
74 for (const pair of pairs) {
75 const task = tasks[pair.task]
76 if (matched.has(pair.step) || usedTasks.has(pair.task) || task === undefined) continue
77 matched.set(pair.step, task)
78 usedTasks.add(pair.task)
79 }
80
81 return matched
82}
83
84// Keys keep a step's own number ("Adım 21"), unlike the matching words.
85function slug(text: string): string {
86 return text
87 .toLocaleLowerCase('tr')
88 .replace(/[çğıöşüâîû]/g, char => FOLD[char] ?? char)
89 .replace(/[^a-z0-9]+/g, '-')
90 .replace(/^-|-$/g, '')
91}
92
93function uniqueKey(title: string, seen: Set<string>): string {
94 const base = slug(title) || 'step'
95 let key = base
96 for (let index = 2; seen.has(key); index += 1) key = `${base}-${index}`
97 seen.add(key)
98
99 return key
100}
101
102function docStatus(step: DocStep): StepStatus {
103 if (step.isChecked || (step.checksTotal > 0 && step.checksDone === step.checksTotal)) return 'completed'
104
105 return step.checksDone > 0 ? 'in_progress' : 'pending'
106}
107
108function creditOf(status: StepStatus, step: { checksDone: number; checksTotal: number }): number {
109 if (status === 'completed') return 1
110 if (status === 'pending') return 0
111 if (step.checksTotal > 0 && step.checksDone > 0) return step.checksDone / step.checksTotal
112
113 return IN_PROGRESS_CREDIT
114}
115
116function fromTasks(tasks: PlanTask[]): Plan {
117 const seen = new Set<string>()
118 const steps = tasks.map(task => ({
119 key: uniqueKey(task.subject, seen),
120 title: task.subject,
121 status: task.status,
122 credit: creditOf(task.status, { checksDone: 0, checksTotal: 0 }),
123 checksDone: 0,
124 checksTotal: 0,
125 }))
126
127 return { steps, source: words().tasksLabel }
128}
129
130function fromDoc(doc: PlanDoc, matched: Map<number, PlanTask>): Step[] {
131 const seen = new Set<string>()
132
133 return doc.steps.map((step, index) => {
134 const fromTask = matched.get(index)?.status ?? 'pending'
135 const fromDocument = docStatus(step)
136 const status = RANK[fromTask] >= RANK[fromDocument] ? fromTask : fromDocument
137 const checks = { checksDone: step.checksDone, checksTotal: step.checksTotal }
138
139 return { key: uniqueKey(step.title, seen), title: step.title, status, credit: creditOf(status, checks), ...checks }
140 })
141}
142
143// The plan's own steps lead; the task list gives their status when it speaks for at least half of them.
144// A step takes the further of the two states, so a ticked box is never undone by a stale task.
145export function resolvePlan(tasks: PlanTask[], doc: PlanDoc | null): Plan | null {
146 if (doc === null || doc.steps.length === 0) return tasks.length === 0 ? null : fromTasks(tasks)
147 if (tasks.length === 0) return { steps: fromDoc(doc, new Map()), source: doc.label }
148 const matched = matchTasks(doc.steps, tasks)
149 const needed = Math.ceil(Math.min(doc.steps.length, tasks.length) / 2)
150 if (matched.size < needed) return fromTasks(tasks)
151
152 return { steps: fromDoc(doc, matched), source: `${doc.label} · ${words().tasksLabel}` }
153}
154hooks/pane-hook.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { HomeButton } from './home'
5import { HUB_PANE, isOpenFor } from './hub'
6import { isOffStage, shownOf } from './kit/stage'
7import { resolvePlan } from './match'
8import { motion, planPane } from './pane'
9import { pinned } from './pin'
10import { themeOf } from './ui/theme'
11import type { Theme } from './ui/theme'
12
13// The engine reads each module's use of the mod's values from that module's own source.
14const docAtom = atom({ plugin: 'plan-steps', key: 'doc' } as const, null)
15const tasksAtom = atom({ plugin: 'plan-steps', key: 'tasks' } as const, [])
16const tickAtom = atom({ plugin: 'plan-steps', key: 'tick' } as const, 0)
17const tracksAtom = atom({ plugin: 'plan-steps', key: 'tracks' } as const, {})
18
19// The Plan pane: /steps or the MODZ main panel opens it, nothing else does; with the hub on, "← MODZ"
20// comes first (DESIGN.md §9).
21
22const PANE = 'plan-steps'
23const PANE_TITLE = 'Plan'
24const TICK_MS = 30_000
25const DEFAULT_ROWS = 24
26const DEFAULT_COLUMNS = 48
27// The "← MODZ" button's row.
28const HOME_ROWS = 1
29
30export type PaneCtx = { touchedAt: () => number }
31
32type Timer = { cancel: () => void }
33
34const pane = { ticker: null as Timer | null, theme: 'auto' as Theme }
35
36// Asks the hub to come forward; the hub hears this mod's own `home` value. A failure only shows in the debug log.
37async function goHome($: EngineInterface): Promise<void> {
38 try {
39 await $.state.set({ plugin: 'plan-steps', key: 'home' }, { at: await $.clock.now() })
40 } catch (error) {
41 $.ui.log(`plan-steps: home failed (${String(error)})`, { to: 'debug' })
42 }
43}
44
45// Tells the hub this pane now holds the panel, so the hub and every other pane step aside (kit/stage.ts).
46async function reportShown($: EngineInterface): Promise<void> {
47 try {
48 if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
49 await $.state.set({ plugin: 'plan-steps', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
50 } catch (error) {
51 $.ui.log(`plan-steps: shown failed (${String(error)})`, { to: 'debug' })
52 }
53}
54
55// The drawing follows Claude Code's theme setting; `auto` leaves it to the system's appearance.
56async function refreshTheme($: EngineInterface): Promise<void> {
57 try {
58 pane.theme = themeOf((await $.config.list()).find(row => row.key === 'theme')?.value)
59 } catch (error) {
60 $.ui.log(`plan-steps: theme not readable (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
61 }
62}
63
64// The same pane whoever asks; an open pane comes forward. A 30-second tick moves its times while it is open.
65async function openPane($: EngineInterface): Promise<void> {
66 await refreshTheme($)
67 motion.reset()
68 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
69 if (opened.isPlaced) await reportShown($)
70 pane.ticker ??= $.clock.every(TICK_MS, () => void update($, tickAtom, tick => tick + 1))
71}
72
73function opening(on: On): void {
74 on('command.run', { command: 'steps' }, async $ => {
75 await openPane($)
76
77 return {}
78 })
79
80 // The MODZ main panel asks by pane key; this only opens, so a pane already open just comes forward.
81 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
82 const done = await next(e)
83 if (isOpenFor(e.value, HUB_PANE)) await openPane($)
84
85 return done
86 }).catch(($, e, next) => next(e))
87
88 // Another pane took the MODZ panel: this one steps aside rather than wait as a tab; its ticker stops on the close.
89 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
90 const done = await next(e)
91 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
92
93 return done
94 }).catch(($, e, next) => next(e))
95
96 on('ui.close', { id: PANE }, ($, e, next) => {
97 pane.ticker?.cancel()
98 pane.ticker = null
99
100 return next(e)
101 }).catch(($, e, next) => next(e))
102}
103
104export function wirePane(on: On, ctx: PaneCtx): void {
105 opening(on)
106
107 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108 await read($, tickAtom)
109 const plan = resolvePlan(await read($, tasksAtom), await read($, docAtom))
110 const view = { plan, tracks: await read($, tracksAtom), now: await $.clock.now(), theme: pane.theme, touchedAt: ctx.touchedAt() }
111 const layout = { columns: e.props.bodyColumns || DEFAULT_COLUMNS, rows: e.props.scroll?.bodyRows || e.viewport?.rows || DEFAULT_ROWS, surface: e.surface }
112
113 const ui = $.ui.resolve(e)
114 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
115 if (!isHubOn) return pinned(e, planPane(ui, view, layout))
116 const { Box } = ui
117
118 return pinned(e, (
119 <Box flexDirection="column">
120 {HomeButton(ui, () => void goHome($))}
121 {planPane(ui, view, { ...layout, rows: layout.rows - HOME_ROWS })}
122 </Box>
123 ))
124 })
125}
126hooks/parse.ts 135 lines1import type { DocStep } from '../types'
2import { words } from './words'
3
4// A named plan file is a plan on sight; any other markdown must show plan structure.
5const PLAN_PATH = /(^|\/)(docs\/(.+\/)?plans\/[^/]+\.md|PLAN\.md|[^/]+\.plan\.md)$/i
6const MARKDOWN = /\.(md|markdown)$/i
7const FENCE = /^\s*(```|~~~)/
8const STEP_HEADING = /^#{2,4}\s+(?:(?:task|step|adım|görev|phase|aşama)\s*(\d+)\b\s*[:.)\-–—]?\s*(.*)|(\d+)[.)]\s+(.+))$/i
9const CHECKBOX = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/
10const NUMBERED = /^(\d+)[.)]\s+(.+)$/
11const NESTED_INDENT = 2
12const MIN_STEPS = 2
13const MIN_LOOSE_CHECKBOXES = 3
14
15// A loose parse (two checkboxes, a numbered list) counts only in a named plan file or an approved plan.
16export type ParsedPlan = { steps: DocStep[]; isStructured: boolean }
17
18export function isPlanPath(path: string): boolean {
19 return PLAN_PATH.test(path)
20}
21
22export function isMarkdownPath(path: string): boolean {
23 return MARKDOWN.test(path)
24}
25
26export function cleanTitle(text: string): string {
27 return text
28 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
29 .replace(/[*_`]+/g, '')
30 .replace(/\s+/g, ' ')
31 .trim()
32 .replace(/[:.]$/, '')
33 .trim()
34}
35
36function visibleLines(markdown: string): string[] {
37 const lines: string[] = []
38 let isInFence = false
39 for (const line of markdown.split(/\r?\n/)) {
40 if (FENCE.test(line)) isInFence = !isInFence
41 else if (!isInFence) lines.push(line)
42 }
43
44 return lines
45}
46
47function newStep(title: string, number: number | null): DocStep {
48 return { title, number, isChecked: false, checksDone: 0, checksTotal: 0 }
49}
50
51function count(step: DocStep, mark: string): void {
52 step.checksTotal += 1
53 if (mark !== ' ') step.checksDone += 1
54}
55
56function fromHeadings(lines: string[]): DocStep[] {
57 const steps: DocStep[] = []
58 let current: DocStep | null = null
59 for (const line of lines) {
60 const heading = STEP_HEADING.exec(line.trim())
61 if (heading !== null) {
62 const number = Number(heading[1] ?? heading[3])
63 const title = cleanTitle(heading[2] ?? heading[4] ?? '')
64 current = newStep(title === '' ? words().stepTitle(number) : title, number)
65 steps.push(current)
66 } else if (/^#{1,3}\s/.test(line.trim())) {
67 current = null
68 } else if (current !== null) {
69 const box = CHECKBOX.exec(line)
70 if (box !== null) count(current, box[2] ?? ' ')
71 }
72 }
73
74 return steps
75}
76
77function fromCheckboxes(lines: string[]): DocStep[] {
78 const steps: DocStep[] = []
79 for (const line of lines) {
80 const box = CHECKBOX.exec(line)
81 if (box === null) continue
82 const indent = (box[1] ?? '').replace(/\t/g, ' ').length
83 const mark = box[2] ?? ' '
84 const parent = steps[steps.length - 1]
85 if (indent >= NESTED_INDENT && parent !== undefined) {
86 count(parent, mark)
87 } else {
88 const step = newStep(cleanTitle(box[3] ?? ''), null)
89 step.isChecked = mark !== ' '
90 steps.push(step)
91 }
92 }
93
94 return steps
95}
96
97// The longest run numbered from 1, so a later "Verification" list does not join the steps.
98function fromNumbered(lines: string[]): DocStep[] {
99 let best: DocStep[] = []
100 let run: DocStep[] = []
101 for (const line of lines) {
102 const item = NUMBERED.exec(line)
103 if (item === null) {
104 if (/^#{1,6}\s/.test(line.trim())) run = []
105 continue
106 }
107 const number = Number(item[1])
108 if (number === 1) run = []
109 else if (number !== run.length + 1) continue
110 run.push(newStep(cleanTitle(item[2] ?? ''), number))
111 if (run.length > best.length) best = run
112 }
113
114 return best
115}
116
117// Headings first (`### Task N:`, `## Step N`), then top-level checkboxes, then a numbered list.
118export function parsePlan(markdown: string): ParsedPlan | null {
119 const lines = visibleLines(markdown)
120 const headed = fromHeadings(lines)
121 if (headed.length >= MIN_STEPS) return { steps: headed, isStructured: true }
122 const boxed = fromCheckboxes(lines)
123 const boxCount = boxed.reduce((sum, step) => sum + 1 + step.checksTotal, 0)
124 if (boxed.length >= MIN_STEPS) {
125 return { steps: boxed, isStructured: boxCount >= MIN_LOOSE_CHECKBOXES }
126 }
127 const numbered = fromNumbered(lines)
128
129 return numbered.length >= MIN_STEPS ? { steps: numbered, isStructured: false } : null
130}
131
132export function baseName(path: string): string {
133 return path.split('/').pop() ?? path
134}
135hooks/progress.ts 152 lines1import type { Plan, Step } from './match'
2import type { AgentRun, StepStatus, StepTrack } from '../types'
3import { words } from './words'
4
5const SECOND = 1000
6const MINUTE = 60 * SECOND
7const HOUR = 60 * MINUTE
8const PERCENT = 100
9const ETA_MIN_DONE = 3
10const FILES_MAX = 50
11const AGENTS_MAX = 20
12
13export type Tracks = Record<string, StepTrack>
14
15export function emptyTrack(status: StepStatus): StepTrack {
16 return { status, startedAt: null, finishedAt: null, files: [], agents: [] }
17}
18
19function moved(track: StepTrack, status: StepStatus, now: number): StepTrack {
20 if (status === 'in_progress') return { ...track, status, startedAt: track.startedAt ?? now, finishedAt: null }
21 if (status === 'completed') return { ...track, status, finishedAt: now }
22
23 return { ...track, status, startedAt: null, finishedAt: null }
24}
25
26// Times come from status changes the session showed; a step first seen already done has no duration.
27export function observe(tracks: Tracks, steps: Step[], now: number): Tracks {
28 const next: Tracks = { ...tracks }
29 for (const step of steps) {
30 const track = next[step.key]
31 if (track === undefined) {
32 next[step.key] = step.status === 'in_progress' ? moved(emptyTrack('pending'), 'in_progress', now) : emptyTrack(step.status)
33 } else if (track.status !== step.status) {
34 next[step.key] = moved(track, step.status, now)
35 }
36 }
37
38 return next
39}
40
41// The step under way; of several, the one begun last.
42export function currentIndex(steps: Step[], tracks: Tracks): number {
43 let best = -1
44 let bestStart = -Infinity
45 steps.forEach((step, index) => {
46 const start = tracks[step.key]?.startedAt ?? 0
47 if (step.status === 'in_progress' && start > bestStart) {
48 best = index
49 bestStart = start
50 }
51 })
52
53 return best
54}
55
56// Where the list centres: the step under way, else the first not done, else the last.
57export function anchorIndex(steps: Step[], tracks: Tracks): number {
58 const current = currentIndex(steps, tracks)
59 if (current >= 0) return current
60 const next = steps.findIndex(step => step.status !== 'completed')
61
62 return next >= 0 ? next : Math.max(0, steps.length - 1)
63}
64
65export type Progress = { done: number; total: number; percent: number; isEstimate: boolean }
66
67export function progressOf(plan: Plan): Progress {
68 const total = plan.steps.length
69 const done = plan.steps.filter(step => step.status === 'completed').length
70 const credit = plan.steps.reduce((sum, step) => sum + step.credit, 0)
71 const percent = total === 0 ? 0 : Math.round((credit / total) * PERCENT)
72
73 return { done, total, percent, isEstimate: credit > done }
74}
75
76export function durationOf(track: StepTrack | undefined, now: number): number | null {
77 if (track === undefined || track.startedAt === null) return null
78 const end = track.status === 'completed' ? track.finishedAt : now
79
80 return end === null ? null : Math.max(0, end - track.startedAt)
81}
82
83// Average finished-step time times the steps left; only once three steps have a measured time.
84export function etaOf(plan: Plan, tracks: Tracks): number | null {
85 const measured = plan.steps
86 .filter(step => step.status === 'completed')
87 .map(step => durationOf(tracks[step.key], 0))
88 .filter((ms): ms is number => ms !== null)
89 const remaining = plan.steps.filter(step => step.status !== 'completed').length
90 if (measured.length < ETA_MIN_DONE || remaining === 0) return null
91 const average = measured.reduce((sum, ms) => sum + ms, 0) / measured.length
92
93 return average * remaining
94}
95
96export function formatDuration(ms: number): string {
97 const unit = words().units
98 if (ms < MINUTE) return `${Math.max(1, Math.round(ms / SECOND))}${unit.second}`
99 if (ms < HOUR) return `${Math.round(ms / MINUTE)}${unit.minute}`
100 const hours = Math.floor(ms / HOUR)
101 const minutes = Math.round((ms % HOUR) / MINUTE)
102
103 return minutes === 0 ? `${hours}${unit.hour}` : `${hours}${unit.hour} ${minutes}${unit.minute}`
104}
105
106export function percentText(progress: Progress): string {
107 return words().percent(progress.percent, progress.isEstimate)
108}
109
110export function addFile(track: StepTrack, path: string): StepTrack {
111 const files = [...track.files.filter(file => file !== path), path].slice(-FILES_MAX)
112
113 return { ...track, files }
114}
115
116export function addAgent(track: StepTrack, agent: AgentRun): StepTrack {
117 return { ...track, agents: [...track.agents.filter(one => one.key !== agent.key), agent].slice(-AGENTS_MAX) }
118}
119
120export function settleAgents(tracks: Tracks, isMatch: (agent: AgentRun) => boolean, patch: Partial<AgentRun>): Tracks {
121 const next: Tracks = {}
122 for (const [key, track] of Object.entries(tracks)) {
123 next[key] = { ...track, agents: track.agents.map(agent => (isMatch(agent) ? { ...agent, ...patch } : agent)) }
124 }
125
126 return next
127}
128
129export type Window = { start: number; end: number }
130
131// The rows of steps that fit, kept around the anchor; two rows go to the "more" lines when it overflows.
132export function windowOf(total: number, anchor: number, room: number): Window {
133 if (total <= room) return { start: 0, end: total }
134 const inner = Math.max(1, room - 2)
135 const lead = Math.min(anchor, Math.max(1, Math.floor(inner / 4)))
136 const end = Math.min(total, anchor - lead + inner)
137 const start = Math.max(0, end - inner)
138
139 return { start, end }
140}
141
142const WORD_CUT_SHARE = 0.6
143
144// Cuts at a word boundary when one lies near the end, so a title never ends mid-word without need.
145export function clip(text: string, max: number): string {
146 if (text.length <= max) return text
147 const cut = text.slice(0, Math.max(1, max - 1))
148 const space = cut.lastIndexOf(' ')
149
150 return `${(space >= max * WORD_CUT_SHARE ? cut.slice(0, space) : cut).trimEnd()}…`
151}
152hooks/plan.ts 52 lines1import type { Plan } from './match'
2import { baseName, isMarkdownPath, isPlanPath, parsePlan } from './parse'
3import { clip, currentIndex, progressOf } from './progress'
4import type { Tracks } from './progress'
5import type { PlanDoc } from '../types'
6import { words } from './words'
7
8const STATUS_TITLE_MAX = 36
9const STRONG = 2
10const WEAK = 1
11
12export function statusText(plan: Plan | null, tracks: Tracks): string | undefined {
13 if (plan === null || plan.steps.length === 0) return undefined
14 const progress = progressOf(plan)
15 const head = `plan ${progress.done}/${progress.total}`
16 if (progress.done === progress.total) return `${head} · ${words().allDone}`
17 const current = plan.steps[currentIndex(plan.steps, tracks)]
18
19 return current === undefined ? head : `${head} · ${clip(current.title, STATUS_TITLE_MAX)}`
20}
21
22// Worth reading at all: a named plan file, or any markdown that may hold one.
23export function mayHoldPlan(path: string): boolean {
24 return isPlanPath(path) || isMarkdownPath(path)
25}
26
27// A named plan file takes any plan shape; other markdown only a structured one, and ranks below.
28export function fileDoc(path: string, text: string): PlanDoc | null {
29 const isNamed = isPlanPath(path)
30 const parsed = parsePlan(text)
31 if (parsed === null || (!isNamed && !parsed.isStructured)) return null
32
33 return { kind: 'file', label: baseName(path), path, strength: isNamed ? STRONG : WEAK, steps: parsed.steps }
34}
35
36export function approvedDoc(result: unknown): PlanDoc | null {
37 const record = result as { plan?: unknown; filePath?: unknown } | undefined
38 if (typeof record?.plan !== 'string') return null
39 const parsed = parsePlan(record.plan)
40 if (parsed === null) return null
41 const path = typeof record.filePath === 'string' ? record.filePath : null
42
43 return { kind: 'approved', label: words().approvedLabel, path, strength: STRONG, steps: parsed.steps }
44}
45
46// The plan in view gives way to one as sure or surer, or to a new read of the same file.
47export function isReplacing(before: PlanDoc | null, doc: PlanDoc): boolean {
48 const isSamePath = before !== null && before.path !== null && before.path === doc.path
49
50 return before === null || isSamePath || doc.strength >= before.strength
51}
52hooks/tasks.ts 84 lines1import type { PlanTask, StepStatus } from '../types'
2
3const STATUSES: StepStatus[] = ['pending', 'in_progress', 'completed']
4
5type Todo = { content?: unknown; status?: unknown }
6type TaskPatch = { taskId?: unknown; subject?: unknown; status?: unknown }
7
8function statusOf(value: unknown): StepStatus | null {
9 return STATUSES.includes(value as StepStatus) ? (value as StepStatus) : null
10}
11
12// TodoWrite sends the whole list each time; ids are its positions.
13export function fromTodos(todos: unknown): PlanTask[] {
14 if (!Array.isArray(todos)) return []
15
16 return (todos as Todo[]).flatMap((todo, index) => {
17 const status = statusOf(todo.status)
18 const subject = typeof todo.content === 'string' ? todo.content.trim() : ''
19
20 return status === null || subject === '' ? [] : [{ id: `todo-${index}`, subject, status }]
21 })
22}
23
24export function createTask(tasks: PlanTask[], task: { id: string; subject: string }): PlanTask[] {
25 return [...tasks.filter(one => one.id !== task.id), { id: task.id, subject: task.subject.trim(), status: 'pending' }]
26}
27
28export function updateTask(tasks: PlanTask[], patch: TaskPatch): PlanTask[] {
29 const id = String(patch.taskId ?? '')
30 if (patch.status === 'deleted') return tasks.filter(task => task.id !== id)
31 const status = statusOf(patch.status)
32 const subject = typeof patch.subject === 'string' && patch.subject.trim() !== '' ? patch.subject.trim() : null
33
34 return tasks.map(task =>
35 task.id === id ? { ...task, status: status ?? task.status, subject: subject ?? task.subject } : task,
36 )
37}
38
39// The id the tool gave the new task, else the next number, as the tool counts them.
40export function createdId(result: unknown, tasks: PlanTask[]): string {
41 const id = (result as { task?: { id?: unknown } } | undefined)?.task?.id
42 if (typeof id === 'string' || typeof id === 'number') return String(id)
43
44 return String(tasks.length + 1)
45}
46
47// The desktop Code tab keeps its own task list (mcp__ccd_turn__update_plan) in place of TodoWrite: the whole
48// list with `items`, else `advance` (finish the step under way, start the next) or 1-based `done`/`start`.
49type DeskPlan = { items?: unknown; advance?: unknown; done?: unknown; start?: unknown }
50
51function steps(value: unknown): Set<number> {
52 return new Set(Array.isArray(value) ? value.filter(Number.isInteger).map(n => (n as number) - 1) : [])
53}
54
55function advanced(tasks: PlanTask[]): PlanTask[] {
56 const current = tasks.findIndex(task => task.status === 'in_progress')
57 const next = tasks.findIndex((task, index) => index > current && task.status === 'pending')
58
59 return tasks.map((task, index) => {
60 if (index === current) return { ...task, status: 'completed' }
61
62 return index === next ? { ...task, status: 'in_progress' } : task
63 })
64}
65
66export function fromDeskPlan(tasks: PlanTask[], plan: DeskPlan): PlanTask[] {
67 if (Array.isArray(plan.items)) return fromTodos(plan.items)
68 if (plan.advance === true) return advanced(tasks)
69 const done = steps(plan.done)
70 const start = Number.isInteger(plan.start) ? (plan.start as number) - 1 : -1
71
72 return tasks.map((task, index) => {
73 if (done.has(index)) return { ...task, status: 'completed' }
74 if (index === start) return { ...task, status: 'in_progress' }
75
76 return start !== -1 && task.status === 'in_progress' ? { ...task, status: 'pending' } : task
77 })
78}
79
80// The desktop's wrap-up checks the whole list off.
81export function finished(tasks: PlanTask[]): PlanTask[] {
82 return tasks.map(task => ({ ...task, status: 'completed' }))
83}
84