SLOPSHOPPER

plan-steps

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…

newpanebandguardcommandstatus
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/plan-steps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-steps
│ ┃ Plan ✕ › fix the failing auth test and add an audit log call │ ┃ Plan │ ┃ No plan yet · steps show up here once a ⏺ Read(src/auth.ts) │ ┃ task list or an approved plan arrives ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /steps │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan
Plan No plan yet · steps show up here once a task list or an approved plan arrives
README

plan-steps

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.

Install

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

What you see

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 (every step pending): the pane says not started.
  • Gone quiet (no step change, plan edit, file edit or agent on the current step for 30 minutes): the pane says 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 an answer leaves steps open

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        │
╰──────────────────────────────────────────────────────╯
  • Continue the plan sends Claude a short prompt to go on with the next open step.
  • Mark plan done stops tracking this plan: no card, no carrying, and it is removed from disk.
  • Not now hides the card. It also goes away with the next prompt.

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.

Keeping the plan across /clear and compaction

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:

  • after a compaction (/compact or automatic) of the main conversation,
  • after /clear,
  • in a new session in the same project, before anything was said.

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.

Where the plan comes from

The mod watches tool calls and reads no model output.

  • Task list: 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.
  • Approved plan: the plan text that ExitPlanMode returns once you approve it.
  • Plan file: a markdown file Claude reads or writes. 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.

Commands

  • /steps opens the pane. (/plan is a built-in Claude Code command, so this mod cannot use that name.)

Settings

  • Hold the finish (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.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 42 files
hooks/register.tsx 240 lines
1import { 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}
240
hooks/freshness.ts 30 lines
1import 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}
30
hooks/carry.ts 114 lines
1import 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))
114
hooks/carry-hooks.ts 156 lines
1import { 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}
156
hooks/finish-hooks.tsx 156 lines
1import { 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}
156
hooks/hub.ts 26 lines
1import 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}
26
hooks/match.ts 154 lines
1import 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}
154
hooks/pane-hook.tsx 126 lines
1import { 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}
126
hooks/parse.ts 135 lines
1import 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}
135
hooks/progress.ts 152 lines
1import 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}
152
hooks/plan.ts 52 lines
1import 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}
52
hooks/tasks.ts 84 lines
1import 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