/roadmap blendet über dem Prompt den Fortschritt der Projekt-Roadmap ein, aus der diese Session einen Punkt abarbeitet: erledigt, eigener Punkt, offene Punkte.

A Claude Code mod. It shows a progress bar above the prompt for the project roadmap that this session works one item of: how many items are done, the session's own item with its status and next step, and the items still open, in order.
It shows by itself. When the session title starts with an item number, such as T.2: Mapping, and a roadmap holds that number, the bar appears without a command. The mod looks for the number in the Markdown files under docs/roadmap/ (and ROADMAP.md), in the newest committed version across all branches, so a roadmap kept on another session's branch is found too; a changed or new file in the working copy counts as well. Where several files hold the number, the one sharing the most words with the session title wins, then the newest.
A session without a number, such as a planning session over a whole roadmap, names the file once: /roadmap docs/roadmap/plan.md shows an overview of it. Whatever /roadmap sets is kept for the session, also when it is resumed.
What counts as an item. A table row whose first cell starts with a number (S.5, T.1, R.1 Rundgang, or several: D.11 Status · D.13 Kunde), a heading that starts with one (### S.4 — Title), or a checklist line (- [x] A.1 ...); a range (R.2–R.4) is skipped. A row with ✅, [x], gemergt, erledigt, abgeschlossen or done is done; 🔵, 🔄 or "in Arbeit" is in work (shown with 🔵 among the open items); ⏸, zurückgestellt or abgelöst is set aside and not counted; ⏳, ❓ and ❌ are open. A table's "Stand" and "Nächster Schritt" columns are shown for the session's own item. The first mention of a number sets its status.
Commands.
| Command | Does |
|---|---|
/roadmap | hides the bar, or shows it again; where no bar is drawn, reports the roadmap |
/roadmap an / /roadmap aus | shows / hides it |
/roadmap T.2 | sets the item, where the title has no number |
/roadmap docs/roadmap/plan.md | reads only this file; an overview where it lacks the item |
/roadmap auto | forgets all of the above: back to the session title |
The arguments combine in one line, in any order: /roadmap an T.2 docs/roadmap/plan.md. A path may not hold spaces. A line with an unknown word changes nothing.
Local and cloud. In a local session (terminal, desktop app) the roadmap is the bar above the prompt, and /roadmap answers in one line. In a session where no bar is drawn, /roadmap publishes the roadmap as a private artifact for the side panel instead: count and bar, the session's item with its status and next step, and every item with its status. Each later /roadmap in the session updates the same artifact. The page is written to .git/claude-roadmap/ in the repository, so it never shows as a change. Where the artifact cannot be published, /roadmap answers with the roadmap as text and says why.
The mod reads the roadmap again when the session title changes and every two minutes. It needs git for the branch search; without it, it reads the working copy. A branch only counts as far as the local repository has fetched it.
Cloud sessions. There the mod draws no bar: no surface asks it to draw. The session title does not reach it either, so nothing shows by itself. /roadmap is the way in: name the item and, where needed, the file once (/roadmap T.2 or /roadmap docs/roadmap/plan.md T.2), and each later /roadmap updates the artifact in the side panel.
At the prompt of a terminal session:
/plugin install roadmap --marketplace heinelits/claude-mods
From a shell:
claude plugin install roadmap --marketplace heinelits/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with heinelits/claude-mods, then Add plugin.
For cloud sessions, see the repository README.
hooks/register.tsx 228 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RoadmapPin, RoadmapStatus } from '../types'
5import { asText, bar, idFromTitle, parseArgs, short, summarize } from './plan'
6import { publishPage } from './publish'
7import { findRoadmap } from './source'
8
9const view = atom({ plugin: 'roadmap', key: 'view' } as const, null)
10const problem = atom({ plugin: 'roadmap', key: 'problem' } as const, null)
11const sessionTitle = atom({ plugin: 'roadmap', key: 'sessionTitle' } as const, null)
12const pin = atom({ plugin: 'roadmap', key: 'pin' } as const, {})
13const isShown = atom({ plugin: 'roadmap', key: 'isShown' } as const, false)
14
15/** Read again at most this often; a new title or /roadmap reads at once. */
16const REFRESH_MS = 2 * 60_000
17const TICK_MS = 10_000
18
19/** Pins are kept per session id in the store; the oldest go past this many. */
20const PIN_PREFIX = 'pin:'
21const MAX_PINS = 200
22
23const COLOR: Record<RoadmapStatus | 'current', string> = {
24 done: 'success',
25 active: 'suggestion',
26 open: 'inactive',
27 paused: 'subtle',
28 current: 'claude',
29}
30
31const USAGE = '/roadmap [an|aus] [T.2] [pfad/zur/roadmap.md] oder /roadmap auto'
32
33let lastLoad = 0
34let isStale = true
35let sessionId: string | undefined
36
37async function savePin($: EngineInterface, next: RoadmapPin) {
38 await update($, pin, () => next)
39 if (sessionId === undefined) return
40
41 await $.store.set(`${PIN_PREFIX}${sessionId}`, next)
42 const pins = (await $.store.keys()).filter(key => key.startsWith(PIN_PREFIX))
43 for (const key of pins.slice(0, Math.max(0, pins.length - MAX_PINS))) await $.store.delete(key)
44}
45
46/**
47 * Finds the roadmap again and decides whether the band shows: hidden when the
48 * person hid it, shown when they asked for it, else shown where a roadmap holds
49 * the session title's item.
50 */
51async function reload($: EngineInterface) {
52 lastLoad = await $.clock.now()
53 isStale = false
54
55 const title = await read($, sessionTitle)
56 const pinned = await read($, pin)
57 const id = pinned.id ?? idFromTitle(title)
58
59 const found =
60 id === undefined && pinned.file === undefined
61 ? undefined
62 : await findRoadmap($, { id, sessionTitle: title ?? '', file: pinned.file })
63
64 await update($, view, () => found ?? null)
65 await update($, isShown, () => (pinned.isHidden === undefined ? found !== undefined : !pinned.isHidden))
66 await update($, problem, () => {
67 if (found) return null
68 if (pinned.file) return `${pinned.file} gibt es weder in der Arbeitskopie noch auf einem Branch.`
69 if (id) return `Punkt ${id} steht in keiner Roadmap unter docs/roadmap, auf keinem Branch.`
70 return `${title ? `der Session-Titel „${short(title, 40)}“ nennt keine Punkt-Nummer` : 'kein Session-Titel bekannt'}. ${USAGE} legt fest, was gezeigt wird.`
71 })
72}
73
74async function noteTitle($: EngineInterface, title: string | undefined) {
75 if (title === undefined || title === '') return
76 if ((await read($, sessionTitle)) === title) return
77 await update($, sessionTitle, () => title)
78 isStale = true
79}
80
81export const register: Register = on => {
82 on('session.start', async ($, e, next) => {
83 await $.command.register({
84 name: 'roadmap',
85 description: 'Fortschritt der Projekt-Roadmap, aus der diese Session einen Punkt abarbeitet',
86 argumentHint: '[an|aus] [T.2] [pfad.md] | auto',
87 })
88
89 sessionId = await $.session.id()
90 const stored = await $.store.get(`${PIN_PREFIX}${sessionId}`)
91 if (typeof stored === 'object' && stored !== null) await update($, pin, () => stored as RoadmapPin)
92 isStale = true
93
94 $.clock.every(TICK_MS, () => {
95 void (async () => {
96 if (isStale || (await $.clock.now()) - lastLoad > REFRESH_MS) await reload($)
97 })()
98 })
99
100 return next(e)
101 })
102
103 // Only reads the title: a failure here must never hold up the session or a prompt.
104 on('classic.SessionStart', async ($, e, next) => {
105 await noteTitle($, e.session_title)
106 return next(e)
107 }).catch(($, e, next) => next(e))
108
109 on('classic.UserPromptSubmit', async ($, e, next) => {
110 await noteTitle($, e.session_title)
111 return next(e)
112 }).catch(($, e, next) => next(e))
113
114 // Where a surface draws the band (a local session), the band is the answer;
115 // where none does (a cloud session), an artifact in the side panel is, and
116 // the roadmap as text where that cannot be published.
117 on('command.run', { command: 'roadmap' }, async ($, e) => {
118 const args = parseArgs(e.args ?? '')
119 if ('error' in args) return { text: `Roadmap: ${args.error}. ${USAGE}` }
120
121 const pinned = args.isReset ? {} : await read($, pin)
122 const isBare = args.show === undefined && args.id === undefined && args.file === undefined && !args.isReset
123 const isDrawn = (await $.session.surfaces()).length > 0
124
125 // A bare /roadmap hides a band that shows; with no band to hide, it only reports.
126 const isHidden =
127 args.show !== undefined ? !args.show
128 : isBare && isDrawn && (await read($, isShown)) ? true
129 : args.isReset && !args.id && !args.file ? undefined
130 : false
131
132 await savePin($, {
133 ...pinned,
134 ...(args.id !== undefined && { id: args.id }),
135 ...(args.file !== undefined && { file: args.file }),
136 ...(isHidden !== undefined && { isHidden }),
137 })
138
139 if (isHidden === true) {
140 await update($, isShown, () => false)
141 return { text: 'Roadmap-Leiste ausgeblendet. /roadmap blendet sie wieder ein.' }
142 }
143
144 await reload($)
145
146 const shown = await read($, view)
147 if (shown === null) return { text: `Roadmap: ${(await read($, problem)) ?? 'keine Roadmap gefunden.'}` }
148
149 // No band here: the roadmap goes to the side panel as an artifact, else as text.
150 if (!isDrawn) {
151 const page = await publishPage($, shown, sessionId)
152 const s = summarize(shown, 0)
153 const line = `${shown.currentId ? `${shown.currentId} · ` : ''}${s.done}/${s.counted} erledigt · ${s.percent} %`
154 if (page.error === undefined) return { text: `Roadmap im Seitenpanel${page.url ? `: ${page.url}` : ''} (${line}).` }
155 return { text: `${asText(shown)}\n\n(Seitenpanel nicht möglich: ${page.error})` }
156 }
157
158 // The band shows it all; the answer only says what it shows.
159 return {
160 text: shown.currentId
161 ? `Roadmap-Leiste: ${shown.currentId} in ${shown.file} (${shown.source}).`
162 : `Roadmap-Leiste: Übersicht von ${shown.file} (${shown.source}).`,
163 }
164 })
165
166 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
167 if (e.props.hasSurvey || !(await read($, isShown))) return next(e)
168
169 const { Box, Text } = $.ui.resolve(e)
170 const roadmap = await read($, view)
171
172 if (roadmap === null) {
173 const why = await read($, problem)
174 return (
175 <Box>
176 <Text dimColor>Roadmap: {why ?? 'wird gelesen …'}</Text>
177 </Box>
178 )
179 }
180
181 const cols = e.props.bodyColumns
182 const width = Math.max(roadmap.items.length, Math.min(60, cols - 24))
183 // Two rows of open items at most; a long roadmap ends in a count.
184 const { counted, done, percent, current, detail, named, more } = summarize(roadmap, cols * 2 - 20)
185
186 return (
187 <Box flexDirection="column">
188 <Text bold wrap="truncate-end">
189 {roadmap.title}
190 </Text>
191 <Box flexDirection="row">
192 {bar(roadmap.items, roadmap.currentId, width).map(run => (
193 <Text mono color={COLOR[run.kind]}>{run.text}</Text>
194 ))}
195 <Text>
196 {' '}
197 {done}/{counted} erledigt · {percent} %
198 </Text>
199 </Box>
200 {current !== undefined && (
201 <Text wrap="truncate-end">
202 <Text color="claude" bold>
203 ▶ Diese Session: {current.id}{' '}
204 </Text>
205 {current.title}
206 </Text>
207 )}
208 {detail !== '' && (
209 <Text dimColor wrap="truncate-end">
210 {' '}
211 {detail}
212 </Text>
213 )}
214 {named.length > 0 && (
215 <Text wrap="wrap">
216 <Text dimColor>Offen: </Text>
217 {named.join(' › ')}
218 {more > 0 && <Text dimColor>{` + ${more} weitere`}</Text>}
219 </Text>
220 )}
221 <Text dimColor wrap="truncate-start">
222 {roadmap.file} · {roadmap.source}
223 </Text>
224 </Box>
225 )
226 })
227}
228hooks/plan.ts 262 lines1import type { RoadmapItem, RoadmapStatus, RoadmapView } from '../types'
2
3/** An item number as roadmaps write them: `S.5`, `T.2`, `D.3`, `S.9b`. */
4const ID = /^([A-Z]{1,3}\.\d+[a-z]?)$/
5const TITLE_ID = /^\s*\[?([A-Z]{1,3}\.\d+[a-z]?)\]?(?=[\s:–—-]|$)/
6
7/** The item number a session title starts with: `T.2: Altsystem-Zuordnung` → `T.2`. */
8export const idFromTitle = (title: string | null | undefined): string | undefined =>
9 title ? TITLE_ID.exec(title)?.[1] : undefined
10
11export const isId = (text: string): boolean => ID.test(text)
12
13/** Markdown down to plain text: links, bold, code, strike-through. */
14const plain = (text: string): string =>
15 text
16 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
17 .replace(/\*\*|__|~~|`/g, '')
18 .replace(/<br\s*\/?>/gi, ' ')
19 .replace(/\s+/g, ' ')
20 .trim()
21
22export const statusOf = (text: string): RoadmapStatus => {
23 if (/✅|\[x\]/i.test(text)) return 'done'
24 if (/⏸|zurückgestellt|abgelöst|verworfen/i.test(text)) return 'paused'
25 if (/🔵|🔄|in arbeit|in umsetzung/i.test(text)) return 'active'
26 if (/⏳|❓|❌/.test(text)) return 'open'
27 if (/\b(gemergt|erledigt|abgeschlossen|done)\b/i.test(text)) return 'done'
28 return 'open'
29}
30
31const cells = (line: string): string[] => {
32 const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '')
33 return inner.split('|').map(cell => cell.trim())
34}
35
36/**
37 * The items a first cell names: `T.2`, `R.1 Rundgang`, or several with their
38 * own titles (`D.11 Status · D.13 Kunde`, `D.3 / D.9`). A range (`R.2–R.4`)
39 * names none, as its members are not spelled out.
40 */
41const named = (cell: string): { id: string; title: string }[] =>
42 plain(cell)
43 .split(/\s+[·/]\s+/)
44 .map(part => /^([A-Z]{1,3}\.\d+[a-z]?)(?![\w.])\s*(.*)$/.exec(part))
45 .filter((match): match is RegExpExecArray => match !== null && !/^[–-]/.test(match[2] ?? ''))
46 .map(match => ({ id: match[1] ?? '', title: (match[2] ?? '').replace(/^[:–—-]\s*/, '') }))
47
48const isTableRow = (line: string) => /^\s*\|.*\|\s*$/.test(line)
49const isSeparator = (line: string) => /^\s*\|(\s*:?-{2,}:?\s*\|)+\s*$/.test(line)
50
51/**
52 * The numbered items of a roadmap in file order: table rows whose first cell
53 * starts with a number, headings that start with one, and checklist lines
54 * that do. The first mention of a number sets its status; a later one only
55 * fills in a title or a column the first lacked.
56 */
57export const parseRoadmap = (markdown: string): { title?: string; items: RoadmapItem[] } => {
58 const lines = markdown.split(/\r?\n/)
59 const items: RoadmapItem[] = []
60 let title: string | undefined
61 let header: string[] = []
62
63 const add = (item: RoadmapItem) => {
64 const known = items.find(one => one.id === item.id)
65 if (known === undefined) {
66 items.push(item)
67 return
68 }
69 if (known.title === '') known.title = item.title
70 known.stand ??= item.stand
71 known.next ??= item.next
72 }
73
74 lines.forEach((line, i) => {
75 if (title === undefined && /^#\s+/.test(line)) title = plain(line.replace(/^#\s+/, ''))
76
77 if (isTableRow(line)) {
78 if (isSeparator(line)) return
79 if (isSeparator(lines[i + 1] ?? '')) {
80 header = cells(line).map(cell => plain(cell).toLowerCase())
81 return
82 }
83
84 const row = cells(line)
85 const column = (pattern: RegExp) => {
86 const at = header.findIndex(name => pattern.test(name))
87 const cell = at > 0 ? plain(row[at] ?? '') : ''
88 return cell === '' || cell === '—' ? undefined : cell
89 }
90
91 for (const { id, title: own } of named(row[0] ?? '')) {
92 add({
93 id,
94 // A bare number takes the next cell as its title; `R.1 Rundgang` brings its own.
95 title: own !== '' ? own : isId(plain(row[0] ?? '')) ? plain(row[1] ?? '') : '',
96 status: statusOf(line),
97 stand: column(/^(stand|status)$/),
98 next: column(/nächster schritt|next/),
99 })
100 }
101 return
102 }
103
104 header = []
105
106 const heading = /^#{2,6}\s+(.*)$/.exec(line)
107 const check = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/.exec(line)
108 const text = heading?.[1] ?? check?.[2]
109 if (text === undefined) return
110
111 const clean = plain(text)
112 const id = /^([A-Z]{1,3}\.\d+[a-z]?)(?![\w.])/.exec(clean)?.[1]
113 if (id === undefined) return
114
115 add({
116 id,
117 title: clean.slice(id.length).replace(/^\s*[:–—-]\s*/, ''),
118 status: check?.[1]?.toLowerCase() === 'x' ? 'done' : statusOf(text),
119 })
120 })
121
122 return { title, items }
123}
124
125/** How many words of the session title the item's row shares: picks between files. */
126export const overlap = (sessionTitle: string, item: RoadmapItem): number => {
127 const words = (text: string) =>
128 new Set(text.toLowerCase().split(/[^a-zäöüß0-9]+/).filter(word => word.length > 3))
129 const theirs = words(`${item.title} ${item.stand ?? ''} ${item.next ?? ''}`)
130 return [...words(sessionTitle)].filter(word => theirs.has(word)).length
131}
132
133const GLYPH: Record<RoadmapStatus, string> = { done: '█', active: '▒', open: '░', paused: '·' }
134
135/**
136 * The bar in file order, one segment per item: filled for done, shaded for
137 * in work elsewhere, light for open, dotted for set aside; the session's own
138 * item is a run of its own, so it can take its own color.
139 */
140export const bar = (
141 items: readonly RoadmapItem[],
142 currentId: string | undefined,
143 width: number,
144): { kind: RoadmapStatus | 'current'; text: string }[] => {
145 const per = Math.max(1, Math.floor(width / Math.max(1, items.length)))
146 const runs: { kind: RoadmapStatus | 'current'; text: string }[] = []
147
148 for (const item of items) {
149 const kind = item.id === currentId ? 'current' : item.status
150 const segment = (kind === 'current' ? '▓' : GLYPH[item.status]).repeat(per)
151 const last = runs[runs.length - 1]
152 if (last?.kind === kind && kind !== 'current') last.text += segment
153 else runs.push({ kind, text: segment })
154 }
155
156 return runs
157}
158
159export const short = (text: string, max: number): string =>
160 text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
161
162export type Summary = {
163 counted: number
164 done: number
165 percent: number
166 current?: RoadmapItem
167 /** The session's item: its Stand and Nächster Schritt, joined. */
168 detail: string
169 /** Open items as labels, as many as fit in `room` characters. */
170 named: string[]
171 /** Open items past `room`. */
172 more: number
173}
174
175/** What the band and the command's text both show, with `room` characters for the open items. */
176export const summarize = (view: RoadmapView, room: number): Summary => {
177 const counted = view.items.filter(item => item.status !== 'paused')
178 const done = counted.filter(item => item.status === 'done').length
179 const current = view.items.find(item => item.id === view.currentId)
180 const open = view.items.filter(
181 item => item.id !== view.currentId && (item.status === 'open' || item.status === 'active'),
182 )
183
184 const named: string[] = []
185 for (const item of open) {
186 const label = `${item.status === 'active' ? '🔵 ' : ''}${item.id} ${short(item.title, 28)}`.trim()
187 if ([...named, label].join(' › ').length > room && named.length > 0) break
188 named.push(label)
189 }
190
191 return {
192 counted: counted.length,
193 done,
194 percent: counted.length === 0 ? 0 : Math.round((done / counted.length) * 100),
195 current,
196 detail: [current?.stand && `Stand: ${current.stand}`, current?.next && `Nächster Schritt: ${current.next}`]
197 .filter(Boolean)
198 .join(' · '),
199 named,
200 more: open.length - named.length,
201 }
202}
203
204/**
205 * The roadmap as plain lines, for the command's own answer: where no surface
206 * draws the band (a cloud session), this is all the person sees.
207 */
208export const asText = (view: RoadmapView): string => {
209 const s = summarize(view, 400)
210 const glyphs = bar(view.items, view.currentId, Math.min(40, Math.max(view.items.length, 20)))
211 .map(run => run.text)
212 .join('')
213
214 return [
215 view.title,
216 `${glyphs} ${s.done}/${s.counted} erledigt · ${s.percent} %`,
217 `█ erledigt ${view.currentId ? '▓ diese Session ' : ''}▒ in Arbeit ░ offen · zurückgestellt`,
218 s.current && `▶ Diese Session: ${s.current.id} ${s.current.title}`,
219 s.detail && ` ${s.detail}`,
220 s.named.length > 0 && `Offen: ${s.named.join(' › ')}${s.more > 0 ? ` + ${s.more} weitere` : ''}`,
221 `${view.file} · ${view.source}`,
222 ]
223 .filter((line): line is string => typeof line === 'string' && line !== '')
224 .join('\n')
225}
226
227export type RoadmapArgs =
228 | { show?: boolean; id?: string; file?: string; isReset?: boolean }
229 | { error: string }
230
231const ON = ['an', 'on', 'ein', 'show']
232const OFF = ['aus', 'off', 'hide']
233
234/**
235 * `/roadmap`'s arguments one by one, in any order: an/aus, one item number,
236 * one `.md` path (without spaces), auto. Anything else is an error, and
237 * nothing is kept from a line with one.
238 */
239export const parseArgs = (args: string): RoadmapArgs => {
240 const parsed: { show?: boolean; id?: string; file?: string; isReset?: boolean } = {}
241
242 for (const token of args.trim().split(/\s+/).filter(Boolean)) {
243 const word = token.toLowerCase()
244 if (ON.includes(word) || OFF.includes(word)) {
245 if (parsed.show !== undefined) return { error: 'an oder aus nur einmal' }
246 parsed.show = ON.includes(word)
247 } else if (isId(token)) {
248 if (parsed.id !== undefined) return { error: `nur eine Punkt-Nummer, nicht ${parsed.id} und ${token}` }
249 parsed.id = token
250 } else if (word.endsWith('.md')) {
251 if (parsed.file !== undefined) return { error: 'nur eine Roadmap-Datei' }
252 parsed.file = token.replace(/\\/g, '/').replace(/^\.\//, '')
253 } else if (word === 'auto') {
254 parsed.isReset = true
255 } else {
256 return { error: `unbekannt: „${short(token, 40)}“` }
257 }
258 }
259
260 return parsed
261}
262hooks/publish.ts 58 lines1import type { EngineInterface } from 'claude-code'
2
3import type { RoadmapView } from '../types'
4import { asHtml, pageTitle } from './page'
5import { short } from './plan'
6import { git, when } from './source'
7
8/** Artifact links are kept per session id in the store; the oldest go past this many. */
9const URL_PREFIX = 'artifact:'
10const MAX_URLS = 200
11
12/**
13 * Writes the roadmap page and publishes it with the Artifact tool, where no
14 * band is drawn. The page lives in the repository's git folder, so it never
15 * shows as a change in the working copy; one file per session, so each
16 * `/roadmap` updates the same artifact.
17 */
18export async function publishPage(
19 $: EngineInterface,
20 view: RoadmapView,
21 sessionId: string | undefined,
22): Promise<{ url?: string; error?: string }> {
23 const cwd = await $.session.cwd()
24 const gitDir = (await git($, cwd, ['rev-parse', '--absolute-git-dir']))?.trim()
25 if (!gitDir) return { error: 'kein Git-Ordner, in dem die Seite liegen kann' }
26
27 const id = (sessionId ?? 'session').replace(/[^\w-]/g, '')
28 const file = `${gitDir}/claude-roadmap/roadmap-${id}.html`
29 await $.fs.write(file, asHtml(view, when(await $.clock.now())))
30
31 const key = `${URL_PREFIX}${id}`
32 const known = await $.store.get(key)
33 const ran = await $.tool
34 .call({
35 tool: 'Artifact',
36 action: 'publish',
37 file_path: file,
38 description: view.currentId
39 ? `Fortschritt von ${pageTitle(view)}, aus der diese Session ${view.currentId} abarbeitet.`
40 : `Fortschritt von ${pageTitle(view)}.`,
41 // The icon goes with the first publish only; a redeploy keeps it.
42 ...(typeof known === 'string' ? {} : { icon: 'chart' }),
43 })
44 .catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
45
46 if (ran.deny !== undefined) return { error: short(ran.deny, 200) }
47 if (ran.isError === true) return { error: short(ran.text ?? 'das Artifact-Tool meldete einen Fehler', 200) }
48
49 const url = /https:\/\/claude\.ai\/[^\s)\]"'<>]+/.exec(ran.text ?? '')?.[0] ?? (typeof known === 'string' ? known : undefined)
50 if (url !== undefined && url !== known) {
51 await $.store.set(key, url)
52 const urls = (await $.store.keys()).filter(one => one.startsWith(URL_PREFIX))
53 for (const old of urls.slice(0, Math.max(0, urls.length - MAX_URLS))) await $.store.delete(old)
54 }
55
56 return { url }
57}
58hooks/source.ts 142 lines1import type { EngineInterface } from 'claude-code'
2
3import type { RoadmapView } from '../types'
4import { overlap, parseRoadmap } from './plan'
5
6/** Where roadmaps live in a repository. */
7const ROADMAP_DIR = 'docs/roadmap'
8const ROADMAP_FILES = ['ROADMAP.md', 'docs/ROADMAP.md']
9const MAX_FILES = 40
10
11type Candidate = { file: string; source: string; time: number; text: string }
12
13export async function git($: EngineInterface, root: string, args: string[]): Promise<string | undefined> {
14 const ran = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 15_000 }).catch(() => undefined)
15 return ran?.exitCode === 0 ? ran.stdout : undefined
16}
17
18const isRoadmapPath = (path: string) =>
19 path.endsWith('.md') && (path.startsWith(`${ROADMAP_DIR}/`) || ROADMAP_FILES.includes(path))
20
21/** Markdown files under docs/roadmap (three levels) and the usual ROADMAP.md, for a folder without git. */
22async function listFiles($: EngineInterface, root: string): Promise<string[]> {
23 const found: string[] = []
24 const walk = async (dir: string, depth: number) => {
25 const entries = await $.fs.list(`${root}/${dir}`).catch(() => [])
26 for (const entry of entries) {
27 const path = `${dir}/${entry.name}`
28 if (entry.kind === 'dir' && depth < 3) await walk(path, depth + 1)
29 else if (entry.kind === 'file' && path.endsWith('.md')) found.push(path)
30 }
31 }
32 await walk(ROADMAP_DIR, 1)
33 for (const file of ROADMAP_FILES) {
34 if (await $.fs.exists(`${root}/${file}`).catch(() => false)) found.push(file)
35 }
36 return found.slice(0, MAX_FILES)
37}
38
39async function readWorking($: EngineInterface, root: string, file: string): Promise<Candidate | undefined> {
40 const text = await $.fs.read(`${root}/${file}`).catch(() => undefined)
41 if (text === undefined) return undefined
42 const stat = await $.fs.stat(`${root}/${file}`).catch(() => undefined)
43 return { file, source: 'Arbeitskopie', time: stat?.mtimeMs ?? 0, text }
44}
45
46/**
47 * Every roadmap file in its newest committed version across all branches
48 * (another session may keep the roadmap on its own branch), and in the
49 * working copy where it is changed or not committed at all.
50 */
51async function candidates($: EngineInterface, root: string, pinned?: string): Promise<Candidate[]> {
52 const paths = pinned ? [pinned] : [ROADMAP_DIR, ...ROADMAP_FILES]
53 const log = await git($, root, ['log', '--all', '-n', '400', '--format=@%H %ct', '--name-only', '--', ...paths])
54
55 if (log === undefined) {
56 const files = pinned ? [pinned] : await listFiles($, root)
57 const read = await Promise.all(files.map(file => readWorking($, root, file)))
58 return read.filter((one): one is Candidate => one !== undefined)
59 }
60
61 const newest = new Map<string, { hash: string; time: number }>()
62 let commit = { hash: '', time: 0 }
63 for (const line of log.split('\n')) {
64 const head = /^@([0-9a-f]+) (\d+)$/.exec(line)
65 if (head) commit = { hash: head[1] ?? '', time: Number(head[2]) * 1000 }
66 else if (line !== '' && (pinned ? line === pinned : isRoadmapPath(line)) && !newest.has(line)) newest.set(line, commit)
67 }
68
69 const status = (await git($, root, ['status', '--porcelain', '--untracked-files=all', '--', ...paths])) ?? ''
70 const changed = status
71 .split('\n')
72 .map(line => line.slice(3).trim())
73 .filter(path => path !== '' && (pinned ? path === pinned : isRoadmapPath(path)))
74
75 const fromGit = await Promise.all(
76 [...newest]
77 .filter(([file]) => !changed.includes(file))
78 .slice(0, MAX_FILES)
79 .map(async ([file, { hash, time }]) => {
80 const text = await git($, root, ['show', `${hash}:${file}`])
81 return text === undefined ? undefined : { file, source: hash, time, text }
82 }),
83 )
84 const fromWorking = await Promise.all(changed.map(file => readWorking($, root, file)))
85
86 return [...fromGit, ...fromWorking].filter((one): one is Candidate => one !== undefined)
87}
88
89export const when = (ms: number): string => {
90 const d = new Date(ms)
91 const two = (n: number) => String(n).padStart(2, '0')
92 return `${two(d.getDate())}.${two(d.getMonth() + 1)}. ${two(d.getHours())}:${two(d.getMinutes())}`
93}
94
95/** A commit as a person reads it: the branch it is on and its short hash. */
96async function label($: EngineInterface, root: string, candidate: Candidate): Promise<string> {
97 if (candidate.source === 'Arbeitskopie') return `Arbeitskopie · ${when(candidate.time)}`
98 const ref = (await git($, root, ['name-rev', '--name-only', '--exclude=refs/tags/*', candidate.source]))?.trim()
99 const branch = ref?.replace(/^remotes\//, '').replace(/[~^]\d*$/, '')
100 return `${branch && branch !== 'undefined' ? `${branch} @ ` : ''}${candidate.source.slice(0, 7)} · ${when(candidate.time)}`
101}
102
103/**
104 * The roadmap that holds item `id`: of the files that hold it, the one whose
105 * row and title share the most words with the session title, then the newest.
106 * Without an id, the newest version of the one file asked for.
107 */
108export async function findRoadmap($: EngineInterface,
109 wanted: { id?: string; sessionTitle: string; file?: string },
110): Promise<RoadmapView | undefined> {
111 if (wanted.id === undefined && wanted.file === undefined) return undefined
112
113 const cwd = await $.session.cwd()
114 const root = (await git($, cwd, ['rev-parse', '--show-toplevel']))?.trim() || cwd
115
116 const scored = (await candidates($, root, wanted.file))
117 .map(candidate => {
118 const parsed = parseRoadmap(candidate.text)
119 const title = parsed.title ?? candidate.file.split('/').pop() ?? candidate.file
120 const item = wanted.id === undefined ? undefined : parsed.items.find(one => one.id === wanted.id)
121 // A file asked for by name is shown even where it lacks the id: as an overview.
122 const score =
123 item !== undefined ? overlap(wanted.sessionTitle, { ...item, title: `${item.title} ${title}` })
124 : wanted.file !== undefined ? 0
125 : -1
126 return { candidate, items: parsed.items, title, score, hasItem: item !== undefined }
127 })
128 .filter(one => one.score >= 0)
129 .sort((a, b) => b.score - a.score || b.candidate.time - a.candidate.time)
130
131 const best = scored[0]
132 if (best === undefined) return undefined
133
134 return {
135 title: best.title,
136 file: best.candidate.file,
137 source: await label($, root, best.candidate),
138 items: best.items,
139 currentId: best.hasItem ? wanted.id : undefined,
140 }
141}
142hooks/page.ts 135 lines1import type { RoadmapStatus, RoadmapView } from '../types'
2import { short, summarize } from './plan'
3
4const escape = (text: string): string =>
5 text.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`)
6
7const LABEL: Record<RoadmapStatus, string> = {
8 done: 'erledigt',
9 active: 'in Arbeit',
10 open: 'offen',
11 paused: 'zurückgestellt',
12}
13
14/** The page's name: the roadmap's own heading without "Roadmap" and its trailing subtitle. */
15export const pageTitle = (view: RoadmapView): string => {
16 const core = view.title.replace(/^roadmap\s*[:–—-]?\s*/i, '').split(/\s+[—–]\s+/)[0] ?? ''
17 return `Roadmap ${short(core.trim() || view.file.split('/').pop() || 'Projekt', 50)}`
18}
19
20/**
21 * The roadmap as a page for the side panel, where no band is drawn: the
22 * count and bar first, then the session's own item, then every item in
23 * file order. Self-contained; colors follow the viewer's theme.
24 */
25export const asHtml = (view: RoadmapView, updated: string): string => {
26 const s = summarize(view, 0)
27 const segments = view.items
28 .map(item => {
29 const kind = item.id === view.currentId ? `${item.status} current` : item.status
30 return `<span class="seg ${kind}" title="${escape(`${item.id} ${item.title} · ${LABEL[item.status]}`)}"></span>`
31 })
32 .join('')
33
34 const rows = view.items
35 .map(item => {
36 const isCurrent = item.id === view.currentId
37 return `<li class="row ${item.status}${isCurrent ? ' current' : ''}">
38 <span class="id">${escape(item.id)}</span>
39 <span class="what">${escape(item.title || '—')}${item.stand ? `<small>${escape(item.stand)}</small>` : ''}</span>
40 <span class="chip ${item.status}">${isCurrent ? 'diese Session' : LABEL[item.status]}</span>
41</li>`
42 })
43 .join('\n')
44
45 const current = s.current
46 ? `<section class="mine" aria-label="Diese Session">
47 <p class="eyebrow">Diese Session</p>
48 <h2><span class="id">${escape(s.current.id)}</span> ${escape(s.current.title)}</h2>
49 <dl>
50 ${s.current.stand ? `<div><dt>Stand</dt><dd>${escape(s.current.stand)}</dd></div>` : ''}
51 ${s.current.next ? `<div><dt>Nächster Schritt</dt><dd>${escape(s.current.next)}</dd></div>` : ''}
52 </dl>
53</section>`
54 : ''
55
56 return `<title>${escape(pageTitle(view))}</title>
57<meta name="claude:viewport-fit-sides" content="cover">
58<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;600;700&family=IBM+Plex+Mono:wght@500&display=swap">
59<style>
60/* One column: count and bar, the session's item, then every item in file order. */
61:root {
62 --bg: #f6f7f9; --panel: #ffffff; --fg: #1d2330; --muted: #5c6475; --line: #dde1e8;
63 --done: #2f8a57; --active: #2f6fd1; --open: #c9ced8; --paused: #e6e8ed; --mine: #b8571c;
64 --font: "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
65 --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
66}
67@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
68 --bg: #13161c; --panel: #1b1f27; --fg: #e6e9ef; --muted: #9aa2b2; --line: #2c323d;
69 --done: #4fb47c; --active: #6a9ef0; --open: #3a414d; --paused: #262b34; --mine: #e5864a; color-scheme: dark;
70} }
71:root[data-theme="dark"] {
72 --bg: #13161c; --panel: #1b1f27; --fg: #e6e9ef; --muted: #9aa2b2; --line: #2c323d;
73 --done: #4fb47c; --active: #6a9ef0; --open: #3a414d; --paused: #262b34; --mine: #e5864a; color-scheme: dark;
74}
75body { background: var(--bg); color: var(--fg); font: 15px/1.5 var(--font); }
76main { max-width: 760px; margin: 0 auto; display: grid; gap: 20px;
77 padding-block: 20px 32px; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); }
78h1 { font-size: 1.35rem; line-height: 1.25; margin: 0; text-wrap: balance; }
79.source { margin: 4px 0 0; color: var(--muted); font: 12px/1.4 var(--mono); overflow-wrap: anywhere; }
80.count { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
81.count strong { font-size: 2rem; font-variant-numeric: tabular-nums; }
82.count span { color: var(--muted); font-variant-numeric: tabular-nums; }
83.bar { display: flex; gap: 2px; height: 14px; }
84.seg { flex: 1; min-width: 3px; border-radius: 2px; background: var(--open); }
85.seg.done { background: var(--done); } .seg.active { background: var(--active); } .seg.paused { background: var(--paused); }
86.seg.current { background: var(--mine); outline: 2px solid var(--mine); outline-offset: 1px; }
87.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 0; padding: 0; list-style: none; color: var(--muted); font-size: 12px; }
88.legend li::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; background: var(--c); }
89.mine { background: var(--panel); border: 1px solid var(--mine); border-radius: 6px; padding: 14px 16px; min-width: 0; }
90.eyebrow { margin: 0 0 4px; color: var(--mine); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
91.mine h2 { margin: 0; font-size: 1.1rem; text-wrap: balance; }
92dl { margin: 10px 0 0; display: grid; gap: 8px; }
93dt { color: var(--muted); font-size: 12px; } dd { margin: 0; overflow-wrap: anywhere; }
94h3 { margin: 0 0 8px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
95ol { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
96.row { display: grid; grid-template-columns: 4.2em minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); }
97.row.paused { color: var(--muted); }
98.row.current { background: color-mix(in srgb, var(--mine) 10%, transparent); }
99.id { font: 500 13px/1.6 var(--mono); }
100.what { min-width: 0; overflow-wrap: anywhere; }
101.what small { display: block; color: var(--muted); font-size: 12px; }
102.chip { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; border: 1px solid var(--line); color: var(--muted); }
103.chip.done { color: var(--done); border-color: var(--done); } .chip.active { color: var(--active); border-color: var(--active); }
104.row.current .chip { color: var(--mine); border-color: var(--mine); }
105footer { color: var(--muted); font-size: 12px; }
106@media (max-width: 420px) { .row { grid-template-columns: 3.6em minmax(0, 1fr); } .chip { grid-column: 2; justify-self: start; } }
107</style>
108<main>
109 <header>
110 <h1>${escape(view.title)}</h1>
111 <p class="source">${escape(view.file)} · ${escape(view.source)}</p>
112 </header>
113 <section aria-label="Fortschritt">
114 <div class="count"><strong>${s.done} / ${s.counted}</strong><span>erledigt · ${s.percent} %</span></div>
115 <div class="bar" role="img" aria-label="${s.done} von ${s.counted} Punkten erledigt">${segments}</div>
116 <ul class="legend">
117 <li style="--c: var(--done)">erledigt</li>
118 ${view.currentId ? '<li style="--c: var(--mine)">diese Session</li>' : ''}
119 <li style="--c: var(--active)">in Arbeit</li>
120 <li style="--c: var(--open)">offen</li>
121 <li style="--c: var(--paused)">zurückgestellt</li>
122 </ul>
123 </section>
124 ${current}
125 <section aria-label="Alle Punkte">
126 <h3>Alle Punkte in der Reihenfolge der Roadmap</h3>
127 <ol>
128${rows}
129 </ol>
130 </section>
131 <footer>Stand ${escape(updated)} · /roadmap aktualisiert diese Seite.</footer>
132</main>
133`
134}
135types/index.d.ts 48 lines1/**
2 * done: a check mark, [x] or a done word · active: a blue dot or "in Arbeit"
3 * (any session) · paused: a pause mark, zurückgestellt, abgelöst · open: the rest.
4 */
5export type RoadmapStatus = 'done' | 'active' | 'open' | 'paused'
6
7export type RoadmapItem = {
8 /** The item's number in the roadmap, `T.2`. */
9 id: string
10 title: string
11 status: RoadmapStatus
12 /** The row's "Stand"/"Status" cell, when its table has one. */
13 stand?: string
14 /** The row's "Nächster Schritt" cell, when its table has one. */
15 next?: string
16}
17
18export type RoadmapView = {
19 /** The roadmap's own `# ` heading, else its file name. */
20 title: string
21 /** Path relative to the repository root. */
22 file: string
23 /** Where it was read: the working copy, or a branch and commit. */
24 source: string
25 items: RoadmapItem[]
26 /** The item this session works on; absent in an overview of the whole roadmap. */
27 currentId?: string
28}
29
30/**
31 * What `/roadmap` fixed for this session, over what the title says; kept in
32 * the store under the session's id, so a resumed session keeps it.
33 */
34export type RoadmapPin = { id?: string; file?: string; isHidden?: boolean }
35
36declare module 'claude-code' {
37 interface PluginState {
38 roadmap: {
39 view: RoadmapView | null
40 /** Why there is no view, in words for the band. */
41 problem: string | null
42 sessionTitle: string | null
43 pin: RoadmapPin
44 isShown: boolean
45 }
46 }
47}
48