Shows the active Strikethroo plan's phase and task progress: one line above the prompt, a pane with every task, and /strikethroo-status as plain text. Reads…

A Claude Code mod that shows where the active Strikethroo plan stands while a blueprint runs. It reads the task files under .ai/strikethroo/plans/ and draws one line above the prompt, an optional pane with every task, and a /strikethroo-status command that prints the same thing as text.
The band above the prompt, one line:
Plan 83 · phase 3/4 · task 03 in progress · 2/5 done
The pane, phase by phase. Finished tasks are dimmed and failed ones are red:
Plan 83: Workspace Data Layer (Parser → JSON Model)
3/5 done · phase 4/4
Phase 1: Parsing primitives
✓ 01 Markdown frontmatter, section, and mermaid parsing primitives
Phase 2: Derivation layer
✓ 02 Task scanning, derived plan state, and phase inference
Phase 3: Model assembly
✓ 03 Workspace model assembly, config enumeration, and typed public API
Phase 4: Verification and documentation
▶ 04 Integration tests for the workspace data layer · in progress
✗ 05 Document the serve data layer in AGENTS.md · failed
/strikethroo-status prints those pane lines in the transcript. It runs without a Claude turn, and it works where nothing is drawn, such as claude -p "/strikethroo-status".
The status words come straight from each task's frontmatter. completed means the file says so. The mod checks nothing and certifies nothing.
The mod picks the plan to show in this order:
pin_plan, if it existsin-progressIt refreshes after any tool call whose input mentions .ai/strikethroo/, and on a timer that lists the plan and task directories every refresh_seconds. It re-reads the files only when a listing changed, and redraws only when the text it shows changed.
| Hook | What it does |
|---|---|
session.start | Finds the workspace, reads the plans, starts the timer, registers /strikethroo-status |
tool.call | Lets the call run, then refreshes if its input mentions .ai/strikethroo/ |
command.run on strikethroo-status | Refreshes and returns the plan as text; opens the pane when display includes it |
ui.render on AbovePrompt | Draws the band line above whatever later mods draw there |
ui.render on Pane | Draws the pane |
The mod runs as you, unsandboxed. This is everything its hooks module hooks and calls, as claude plugin validate reports it. CI fails if the module uses anything outside this list (scripts/check-capabilities.ts).
<!-- capabilities -->
{
"hooks": ["session.start", "tool.call", "command.run", "ui.render"],
"calls": [
"$.clock.every",
"$.command.register",
"$.fs.exists",
"$.fs.list",
"$.fs.read",
"$.session.cwd",
"$.ui.invalidate",
"$.ui.log",
"$.ui.open",
"$.ui.resolve"
]
}
<!-- /capabilities -->
The file calls only read. There is no $.fs.write, no process, no network, and no model call. The tool.call hook always returns the tool's own result, so it never blocks or changes a call.
The blocks above are text captured from a Claude Code 2.1.296 session in a 180-column tmux window. The project was set up with npx strikethroo init --harnesses claude, plus plan 83 copied from the Strikethroo fixtures with its task statuses edited by hand. The pane capture used a copy of the plugin whose display default was both. Editing a task file from another shell updated the band and the pane within one refresh. So did asking Claude to edit one.
refresh was a closure inside register. claude plugin validate refused it, because a hooks module may pass $ only to functions declared at the top of the file. refresh and the state it reads moved to module level.$.fs with an in-memory workspace (tests/lib/strikethroo/memory-fs.ts).ui.invalidate calls checks that an unchanged poll doesn't redraw. Removing the change check makes it fail.Requirements:
workspaceSchemaVersion in .ai/strikethroo/.init-metadata.json). Verified against the Strikethroo v4.1.1 fixtures. Any other version gets one log line and then nothing.Steps:
claude plugin marketplace add e0ipso/strikethroo-mods
claude plugin install strikethroo-progress@strikethroo-mods
Or try it for one session from a clone: claude --plugin-dir ./plugins/strikethroo-progress.
/strikethroo-status for the full list.Set these in /config, or under pluginConfigs["strikethroo-progress@strikethroo-mods"].options in ~/.claude/settings.json.
| Option | Default | What it does |
|---|---|---|
display | band | band, pane, both or off. With off there is no timer and no tool call hook; /strikethroo-status still works. |
refresh_seconds | 3 | How often the timer lists the task directories, from 1 to 60 |
pin_plan | empty | A plan id such as 42, or a directory name such as 42--add-login |
/strikethroo-status opens it at any width./strikethroo-status, and after a tool call that mentions .ai/strikethroo/. If you run strikethroo init from your own shell mid-session, run /strikethroo-status once.AbovePrompt ahead of this one can hide it.## Execution Blueprint shows no phase.claude -p and the terminal were checked.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
typescript and @types/node are development dependencies of the repository, not of the mod. Claude Code loads the .ts files itself.
Claude and Claude Code are trademarks of Anthropic; use here is descriptive and implies no endorsement.
A prototype, MIT licensed. See the root README.
hooks/register.ts 149 lines1/**
2 * strikethroo-progress: the active Strikethroo plan's progress, drawn in the
3 * band above the prompt and in a pane, and printed by /strikethroo-status.
4 *
5 * Read-only: it lists and reads files under .ai/strikethroo/ and writes
6 * nothing. It refreshes after a tool call that touches .ai/strikethroo/ and on
7 * a timer that compares directory listings, and redraws only on a change.
8 */
9import type { EngineInterface, On, PluginOptions } from 'claude-code'
10
11import type { Plan, WorkspaceFs, WorkspaceLookup } from './lib/strikethroo/index.ts'
12import { findWorkspace, listActivePlans, pickActivePlan, planSignature } from './lib/strikethroo/index.ts'
13import type { ProgressConfig, Row, Tone } from './progress.ts'
14import { bandLine, configOf, paneRows } from './progress.ts'
15
16const PANE = 'strikethroo-progress'
17const COMMAND = 'strikethroo-status'
18const TOUCHES_WORKSPACE = /\.ai[\\/]+strikethroo/
19
20const STYLES: Readonly<Record<Tone, { bold?: true; dimColor?: true; color?: 'error' }>> = {
21 heading: { bold: true },
22 done: { dimColor: true },
23 active: { bold: true },
24 failed: { color: 'error', bold: true },
25 plain: {},
26 problem: { color: 'error' },
27}
28
29let cwd: string | undefined
30let lookup: WorkspaceLookup = { kind: 'none' }
31let plans: Plan[] = []
32let signature: string | undefined
33let shown = ''
34let isRefreshing = false
35let hasWarned = false
36let hasOpened = false
37
38/** `$.fs` in the shape the shared reader takes. */
39const fsOf = ($: EngineInterface): WorkspaceFs => ({
40 exists: path => $.fs.exists(path),
41 read: path => $.fs.read(path),
42 list: path => $.fs.list(path),
43})
44
45/** What /strikethroo-status prints when there is no plan to show. */
46const emptyText = (): string => {
47 if (lookup.kind === 'unsupported') return `${lookup.message}. This mod reads workspace schema 4 only.`
48 if (lookup.kind === 'none') return `No Strikethroo workspace found from ${cwd ?? 'this directory'}.`
49 return `No active plans in ${lookup.root}/plans.`
50}
51
52/** What the panes and commands read; `register` sets it from the options. */
53let config: ProgressConfig = configOf({})
54const showsBand = (): boolean => config.display === 'band' || config.display === 'both'
55const showsPane = (): boolean => config.display === 'pane' || config.display === 'both'
56const active = (): Plan | undefined => pickActivePlan(plans, config.pinPlan)
57
58const statusText = (): string => {
59 const plan = active()
60 return plan === undefined ? emptyText() : paneRows(plan).map(row => row.text).join('\n')
61}
62
63/**
64 * Re-reads the plans when their files changed. `isRefind` looks for the
65 * workspace again first; the timer never does, so a session outside any
66 * workspace costs nothing between tool calls.
67 */
68async function refresh($: EngineInterface, isRefind: boolean): Promise<void> {
69 if (isRefreshing && !isRefind) return
70 isRefreshing = true
71 try {
72 if (isRefind) lookup = await findWorkspace(fsOf($), cwd ?? (await $.session.cwd()))
73 if (lookup.kind === 'unsupported' && !hasWarned) {
74 hasWarned = true
75 $.ui.log(`${lookup.message}; showing nothing. This mod reads workspace schema 4 only.`)
76 }
77 const next = lookup.kind === 'found' ? await planSignature(fsOf($), lookup.root) : undefined
78 if (next !== signature || isRefind) {
79 signature = next
80 plans = lookup.kind === 'found' ? await listActivePlans(fsOf($), lookup.root) : []
81 }
82 const text = statusText()
83 if (text === shown) return
84 shown = text
85 $.ui.invalidate('ui.render')
86 if (showsPane() && !hasOpened && active() !== undefined) {
87 hasOpened = true
88 await $.ui.open({ id: PANE, title: 'Strikethroo' })
89 }
90 } finally {
91 isRefreshing = false
92 }
93}
94
95export function register(on: On, options: PluginOptions): void {
96 config = configOf(options)
97
98 on('session.start', async ($, e, next) => {
99 cwd = e.cwd
100 if (config.display !== 'off') {
101 await refresh($, true)
102 $.clock.every(config.refreshSeconds * 1000, () => void refresh($, false).catch(() => undefined))
103 }
104 try {
105 await $.command.register({
106 name: COMMAND,
107 description: 'Show the active Strikethroo plan, phase by phase',
108 immediate: true,
109 })
110 } catch (error) {
111 $.ui.log(`/${COMMAND} is unavailable: ${String(error)}`)
112 }
113 return next(e)
114 })
115
116 if (config.display !== 'off') {
117 on('tool.call', async ($, e, next) => {
118 const result = await next(e)
119 if (TOUCHES_WORKSPACE.test(JSON.stringify(e))) void refresh($, true).catch(() => undefined)
120 return result
121 })
122 }
123
124 on('command.run', { command: COMMAND }, async $ => {
125 await refresh($, true)
126 if (showsPane() && active() !== undefined) await $.ui.open({ id: PANE, title: 'Strikethroo' })
127 return { text: statusText() }
128 })
129
130 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
131 const plan = showsBand() && !e.props.hasSurvey ? active() : undefined
132 if (plan === undefined) return next(e)
133 const { Box, Text } = $.ui.resolve(e)
134 const line = Text({ dimColor: true, wrap: 'truncate-end', children: [bandLine(plan)] })
135 return Box({ flexDirection: 'column', children: [line, await next(e)] })
136 })
137
138 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
139 if (e.requestId !== PANE) return next(e)
140 const { Box, Text } = $.ui.resolve(e)
141 const plan = active()
142 const rows: Row[] = plan === undefined ? [{ text: emptyText(), tone: 'plain' }] : paneRows(plan)
143 return Box({
144 flexDirection: 'column',
145 children: rows.map(row => Text({ ...STYLES[row.tone], wrap: 'truncate-end', children: [row.text] })),
146 })
147 })
148}
149hooks/lib/strikethroo/index.ts 11 lines1/**
2 * The Strikethroo workspace reader shared by every mod in this repository.
3 * The source of truth is `shared/lib/` at the repository root;
4 * `node scripts/sync-shared.ts` copies it into each plugin's
5 * `hooks/lib/strikethroo/`. Edit it there, never in a copy.
6 */
7export type { BlueprintPhase, TaskFrontmatter, TaskStatus } from './parse.ts'
8export { parseBlueprint, parseTaskFrontmatter, parseTitle, TASK_STATUSES } from './parse.ts'
9export type { FsEntryLike, Plan, Task, WorkspaceFs, WorkspaceLookup } from './workspace.ts'
10export { findWorkspace, listActivePlans, pickActivePlan, planSignature, SUPPORTED_SCHEMA_VERSION } from './workspace.ts'
11hooks/progress.ts 126 lines1/**
2 * What strikethroo-progress shows, as plain data: its configuration, a plan's
3 * summary, the band line, and the pane's rows. No mods API here, so the tests
4 * call these directly.
5 */
6import type { PluginOptions } from 'claude-code'
7
8import type { Plan, Task, TaskStatus } from './lib/strikethroo/index.ts'
9
10export type Display = 'band' | 'pane' | 'both' | 'off'
11
12/** The plugin's `userConfig`, as plugin.json declares it. */
13export type ProgressConfig = {
14 display: Display
15 refreshSeconds: number
16 pinPlan: string | undefined
17}
18
19const DISPLAYS: readonly Display[] = ['band', 'pane', 'both', 'off']
20
21/** Narrows `register`'s options; anything unexpected falls back to the default. */
22export const configOf = (options: PluginOptions): ProgressConfig => {
23 const seconds = options.refresh_seconds
24 const pin = options.pin_plan
25 return {
26 display: DISPLAYS.find(display => display === options.display) ?? 'band',
27 refreshSeconds: typeof seconds === 'number' && Number.isFinite(seconds) ? Math.min(60, Math.max(1, seconds)) : 3,
28 pinPlan: typeof pin === 'string' && pin.trim() !== '' ? pin.trim() : undefined,
29 }
30}
31
32export type Summary = {
33 done: number
34 total: number
35 /** The first phase with an unfinished task, or the last phase when all are done. */
36 phase: { index: number; count: number } | undefined
37 inProgress: Task[]
38 failed: Task[]
39}
40
41export const summarize = (plan: Plan): Summary => {
42 const byId = new Map(plan.tasks.map(task => [task.id, task]))
43 const phases = plan.phases ?? []
44 const current = phases.find(phase => phase.taskIds.some(id => byId.get(id)?.status !== 'completed')) ?? phases.at(-1)
45 return {
46 done: plan.tasks.filter(task => task.status === 'completed').length,
47 total: plan.tasks.length,
48 phase: current === undefined ? undefined : { index: current.index, count: phases.length },
49 inProgress: plan.tasks.filter(task => task.status === 'in-progress'),
50 failed: plan.tasks.filter(task => task.status === 'failed'),
51 }
52}
53
54const number = (task: Task): string => String(task.id).padStart(2, '0')
55
56const tasksPhrase = (tasks: readonly Task[], state: string): string =>
57 `${tasks.length === 1 ? 'task' : 'tasks'} ${tasks.map(number).join(', ')} ${state}`
58
59/** `Plan 42 · phase 2/4 · task 05 in progress · 7/12 done` */
60export const bandLine = (plan: Plan): string => {
61 const summary = summarize(plan)
62 const parts = [`Plan ${plan.id}`]
63 if (summary.phase !== undefined) parts.push(`phase ${summary.phase.index}/${summary.phase.count}`)
64 if (summary.inProgress.length > 0) parts.push(tasksPhrase(summary.inProgress, 'in progress'))
65 if (summary.failed.length > 0) parts.push(tasksPhrase(summary.failed, 'failed'))
66 parts.push(`${summary.done}/${summary.total} done`)
67 return parts.join(' · ')
68}
69
70export type Tone = 'heading' | 'done' | 'active' | 'failed' | 'plain' | 'problem'
71export type Row = { text: string; tone: Tone }
72
73const MARKS: Readonly<Record<TaskStatus, string>> = {
74 completed: '✓',
75 'in-progress': '▶',
76 failed: '✗',
77 pending: '○',
78 'needs-clarification': '?',
79}
80
81const TONES: Readonly<Record<TaskStatus, Tone>> = {
82 completed: 'done',
83 'in-progress': 'active',
84 failed: 'failed',
85 pending: 'plain',
86 'needs-clarification': 'plain',
87}
88
89const taskRow = (task: Task): Row => {
90 const state = task.status === undefined ? 'unreadable status' : task.status === 'completed' ? '' : task.status.replace('-', ' ')
91 const mark = task.status === undefined ? '!' : MARKS[task.status]
92 return {
93 text: ` ${mark} ${number(task)} ${task.title}${state === '' ? '' : ` · ${state}`}`,
94 tone: task.status === undefined ? 'problem' : TONES[task.status],
95 }
96}
97
98/** The pane's lines; `/strikethroo-status` prints the same lines as text. */
99export const paneRows = (plan: Plan): Row[] => {
100 const summary = summarize(plan)
101 const phaseText = summary.phase === undefined ? '' : ` · phase ${summary.phase.index}/${summary.phase.count}`
102 const rows: Row[] = [
103 { text: `Plan ${plan.id}: ${plan.title.replace(/^Plan:\s*/i, '')}`, tone: 'heading' },
104 { text: `${summary.done}/${summary.total} done${phaseText}`, tone: 'plain' },
105 ]
106 const placed = new Set<number>()
107 for (const phase of plan.phases ?? []) {
108 rows.push({ text: `Phase ${phase.index}${phase.name === undefined ? '' : `: ${phase.name}`}`, tone: 'heading' })
109 for (const task of plan.tasks.filter(task => phase.taskIds.includes(task.id))) {
110 placed.add(task.id)
111 rows.push(taskRow(task))
112 }
113 }
114 const unplaced = plan.tasks.filter(task => !placed.has(task.id))
115 if (unplaced.length > 0) {
116 if (plan.phases !== undefined) rows.push({ text: 'Not in the blueprint', tone: 'heading' })
117 rows.push(...unplaced.map(taskRow))
118 }
119 const problems = [...plan.problems, ...plan.tasks.flatMap(task => task.problems.map(problem => `${task.file}: ${problem}`))]
120 if (problems.length > 0) {
121 rows.push({ text: 'Could not read', tone: 'heading' })
122 rows.push(...problems.map(problem => ({ text: ` ${problem}`, tone: 'problem' as const })))
123 }
124 return rows
125}
126hooks/lib/strikethroo/parse.ts 138 lines1/**
2 * Pure parsers for the Markdown files of a Strikethroo v4 workspace. They take
3 * text, never touch the file system, and never throw.
4 */
5
6/** The statuses a v4 task may carry (`TASK_STATUSES` in Strikethroo). */
7export const TASK_STATUSES = ['pending', 'in-progress', 'completed', 'needs-clarification', 'failed'] as const
8
9export type TaskStatus = (typeof TASK_STATUSES)[number]
10
11export type TaskFrontmatter = {
12 id: number | undefined
13 status: TaskStatus | undefined
14 dependencies: number[]
15 group: string | undefined
16 /** One line for each field that could not be read; empty for a clean file. */
17 problems: string[]
18}
19
20/** One `### Phase N: Name` heading of an `## Execution Blueprint` section. */
21export type BlueprintPhase = {
22 /** 1-based position among the section's phase headings. */
23 index: number
24 name: string | undefined
25 /** Task ids named by `-` or `*` bullets, in order of first mention. */
26 taskIds: number[]
27}
28
29const FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---/
30const LIST_ITEM_RE = /^\s*-\s*(.*)$/
31const INTEGER_RE = /^\d+$/
32
33/** A scalar with its trailing `# comment` cut and its quotes removed. */
34const scalarOf = (raw: string): string => {
35 const value = raw.trim()
36 const quote = value[0]
37 if (quote === '"' || quote === "'") {
38 const end = value.indexOf(quote, 1)
39 return end === -1 ? value : value.slice(1, end)
40 }
41 const hash = value.indexOf('#')
42 return (hash === -1 ? value : value.slice(0, hash)).trim()
43}
44
45const isTaskStatus = (value: string): value is TaskStatus =>
46 (TASK_STATUSES as readonly string[]).includes(value)
47
48/** Reads `id`, `status`, `dependencies` and `group` from a task file. */
49export const parseTaskFrontmatter = (text: string): TaskFrontmatter => {
50 const result: TaskFrontmatter = { id: undefined, status: undefined, dependencies: [], group: undefined, problems: [] }
51 const block = FRONTMATTER_RE.exec(text)?.[1]
52 if (block === undefined) {
53 result.problems.push('no frontmatter')
54 return result
55 }
56
57 const lines = block.split(/\r?\n/)
58 const fields = new Map<string, { value: string; items: string[] }>()
59 for (let i = 0; i < lines.length; i++) {
60 const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(lines[i] ?? '')
61 if (match === null) continue
62 const items: string[] = []
63 while (LIST_ITEM_RE.test(lines[i + 1] ?? 'x')) items.push(scalarOf(LIST_ITEM_RE.exec(lines[++i] ?? '')?.[1] ?? ''))
64 fields.set(match[1] ?? '', { value: match[2] ?? '', items })
65 }
66
67 const id = fields.get('id')
68 const idValue = id === undefined ? '' : scalarOf(id.value)
69 if (INTEGER_RE.test(idValue)) result.id = Number(idValue)
70 else result.problems.push(id === undefined ? 'id is missing' : `id "${idValue}" is not a number`)
71
72 const status = fields.get('status')
73 const statusValue = status === undefined ? '' : scalarOf(status.value)
74 if (isTaskStatus(statusValue)) result.status = statusValue
75 else result.problems.push(status === undefined ? 'status is missing' : `status "${statusValue}" is not a v4 status`)
76
77 const group = fields.get('group')
78 if (group !== undefined && scalarOf(group.value) !== '') result.group = scalarOf(group.value)
79
80 const dependencies = fields.get('dependencies')
81 if (dependencies !== undefined) {
82 const inline = scalarOf(dependencies.value)
83 const items =
84 inline.startsWith('[') && inline.endsWith(']')
85 ? inline.slice(1, -1).split(',').map(scalarOf).filter(item => item !== '')
86 : inline === '' ? dependencies.items : [inline]
87 for (const item of items) {
88 if (INTEGER_RE.test(item)) result.dependencies.push(Number(item))
89 else result.problems.push(`dependency "${item}" is not a task id`)
90 }
91 }
92 return result
93}
94
95/** The text after a `## <heading>` line, up to the next `##` heading. */
96const sectionOf = (text: string, heading: RegExp): string | undefined => {
97 const match = heading.exec(text)
98 if (match === null) return undefined
99 const after = text.slice(match.index + match[0].length)
100 const next = after.search(/^##[ \t]/m)
101 return next === -1 ? after : after.slice(0, next)
102}
103
104const PHASE_HEADING_RE = /^###[ \t]+(?:✅[ \t]*)?Phase[ \t]+(\d+)[ \t]*:?[ \t]*(.*?)[ \t]*$/gm
105const TASK_REF_RE = /Task[ \t]+0*(\d+)/i
106
107/**
108 * The phases of a plan's `## Execution Blueprint` section, or undefined when
109 * the section or its phase headings are absent. A port of Strikethroo's
110 * `parseBlueprintPhases` (src/skill-scripts/shared/blueprint-parse.ts, v4.1.1).
111 */
112export const parseBlueprint = (planText: string): BlueprintPhase[] | undefined => {
113 const blueprint = sectionOf(planText, /^##[ \t]+Execution Blueprint[ \t]*$/m)
114 if (blueprint === undefined) return undefined
115
116 const headings = [...blueprint.matchAll(PHASE_HEADING_RE)].map(match => ({
117 start: match.index,
118 end: match.index + match[0].length,
119 name: (match[2] ?? '').trim(),
120 }))
121 if (headings.length === 0) return undefined
122
123 return headings.map((heading, i) => {
124 const taskIds: number[] = []
125 for (const line of blueprint.slice(heading.end, headings[i + 1]?.start ?? blueprint.length).split(/\r?\n/)) {
126 const trimmed = line.trim()
127 if (!trimmed.startsWith('-') && !trimmed.startsWith('*')) continue
128 const ref = TASK_REF_RE.exec(trimmed)?.[1]
129 if (ref !== undefined && !taskIds.includes(Number(ref))) taskIds.push(Number(ref))
130 }
131 return { index: i + 1, name: heading.name === '' ? undefined : heading.name, taskIds }
132 })
133}
134
135/** The first `# ` heading after the frontmatter, or undefined. */
136export const parseTitle = (text: string): string | undefined =>
137 /^#[ \t]+(.+?)[ \t]*$/m.exec(text.replace(FRONTMATTER_RE, ''))?.[1]
138hooks/lib/strikethroo/workspace.ts 191 lines1/**
2 * Reads a Strikethroo v4 workspace through a file system the caller supplies,
3 * so the same code runs under a mod's `$.fs` and under a test's in-memory map.
4 * Nothing here writes, and nothing throws: a file that can't be read becomes a
5 * problem line on its plan or task.
6 */
7import type { BlueprintPhase, TaskStatus } from './parse.ts'
8import { parseBlueprint, parseTaskFrontmatter, parseTitle } from './parse.ts'
9
10/** The workspace schema these mods read (`workspaceSchemaVersion`). */
11export const SUPPORTED_SCHEMA_VERSION = 4
12
13export type FsEntryLike = {
14 readonly name: string
15 readonly kind: 'file' | 'dir' | 'other'
16 /** A regular file's modification time; 0 for every other kind. */
17 readonly mtimeMs: number
18}
19
20/** The three reads the reader needs, shaped like `$.fs`. */
21export type WorkspaceFs = {
22 readonly exists: (path: string) => Promise<boolean>
23 readonly read: (path: string) => Promise<string>
24 readonly list: (path: string) => Promise<readonly FsEntryLike[]>
25}
26
27export type WorkspaceLookup =
28 /** `root` is the `.ai/strikethroo` directory, `project` the one holding `.ai/`. */
29 | { kind: 'found'; root: string; project: string }
30 /** A workspace these mods don't read; `message` is the line to show once. */
31 | { kind: 'unsupported'; root: string; message: string }
32 | { kind: 'none' }
33
34export type Task = {
35 /** File name, such as `05--write-tests.md`. */
36 file: string
37 path: string
38 /** The frontmatter `id`, or the file name's number when that is unreadable. */
39 id: number
40 title: string
41 status: TaskStatus | undefined
42 dependencies: number[]
43 group: string | undefined
44 mtimeMs: number
45 text: string
46 problems: string[]
47}
48
49export type Plan = {
50 /** Directory name, `<id>--<slug>`. */
51 name: string
52 id: number
53 slug: string
54 dir: string
55 title: string
56 phases: BlueprintPhase[] | undefined
57 tasks: Task[]
58 problems: string[]
59 /** The newest task file's modification time, or the plan file's. */
60 mtimeMs: number
61}
62
63const PLAN_DIR_RE = /^(\d+)--([a-z0-9-]+)$/
64const TASK_FILE_RE = /^(\d+)--.+\.md$/
65const MAX_PLANS = 200
66const MAX_TASKS = 500
67
68const join = (dir: string, name: string): string =>
69 dir.endsWith('/') || dir.endsWith('\\') ? dir + name : `${dir}/${name}`
70
71/** The parent directory, or undefined at a file system root. */
72const parentOf = (dir: string): string | undefined => {
73 const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
74 if (cut === -1) return undefined
75 const up = /^[A-Za-z]:$/.test(dir.slice(0, cut)) || cut === 0 ? dir.slice(0, cut + 1) : dir.slice(0, cut)
76 return up === dir ? undefined : up
77}
78
79const listOr = async (fs: WorkspaceFs, path: string): Promise<readonly FsEntryLike[]> =>
80 fs.list(path).catch(() => [])
81
82const schemaMessage = (metadata: unknown): string | undefined => {
83 if (typeof metadata !== 'object' || metadata === null) return 'unreadable .init-metadata.json'
84 const version = (metadata as { workspaceSchemaVersion?: unknown }).workspaceSchemaVersion ?? 1
85 return version === SUPPORTED_SCHEMA_VERSION ? undefined : `unsupported workspace version ${String(version)}`
86}
87
88/**
89 * Walks up from `cwd` to the first `.ai/strikethroo/.init-metadata.json` and
90 * checks its `workspaceSchemaVersion`, which is 1 when absent.
91 */
92export const findWorkspace = async (fs: WorkspaceFs, cwd: string): Promise<WorkspaceLookup> => {
93 for (let dir: string | undefined = cwd; dir !== undefined; dir = parentOf(dir)) {
94 const root = join(dir, '.ai/strikethroo')
95 const metadataPath = join(root, '.init-metadata.json')
96 if (!(await fs.exists(metadataPath).catch(() => false))) continue
97 const metadata: unknown = await fs.read(metadataPath).then(text => JSON.parse(text) as unknown).catch(() => undefined)
98 const message = schemaMessage(metadata)
99 return message === undefined ? { kind: 'found', root, project: dir } : { kind: 'unsupported', root, message }
100 }
101 return { kind: 'none' }
102}
103
104const readTask = async (fs: WorkspaceFs, dir: string, entry: FsEntryLike, number: number): Promise<Task> => {
105 const path = join(dir, entry.name)
106 const text = await fs.read(path).catch(() => undefined)
107 const frontmatter = parseTaskFrontmatter(text ?? '')
108 return {
109 file: entry.name,
110 path,
111 id: frontmatter.id ?? number,
112 title: parseTitle(text ?? '') ?? entry.name.replace(/\.md$/, ''),
113 status: frontmatter.status,
114 dependencies: frontmatter.dependencies,
115 group: frontmatter.group,
116 mtimeMs: entry.mtimeMs,
117 text: text ?? '',
118 problems: text === undefined ? ['could not be read'] : frontmatter.problems,
119 }
120}
121
122const readPlan = async (fs: WorkspaceFs, plansDir: string, name: string, id: number, slug: string): Promise<Plan> => {
123 const dir = join(plansDir, name)
124 const planFile = `plan-${name}.md`
125 const entries = await listOr(fs, dir)
126 const text = await fs.read(join(dir, planFile)).catch(() => undefined)
127 const taskEntries = (await listOr(fs, join(dir, 'tasks')))
128 .filter(entry => entry.kind === 'file' && TASK_FILE_RE.test(entry.name))
129 .map(entry => ({ entry, number: Number(TASK_FILE_RE.exec(entry.name)?.[1]) }))
130 .sort((a, b) => a.number - b.number)
131 .slice(0, MAX_TASKS)
132 const tasks = await Promise.all(taskEntries.map(({ entry, number }) => readTask(fs, join(dir, 'tasks'), entry, number)))
133 const planMtime = entries.find(entry => entry.name === planFile)?.mtimeMs ?? 0
134 return {
135 name,
136 id,
137 slug,
138 dir,
139 title: (text === undefined ? undefined : parseTitle(text)) ?? slug,
140 phases: text === undefined ? undefined : parseBlueprint(text),
141 tasks,
142 problems: text === undefined ? [`${planFile} could not be read`] : [],
143 mtimeMs: Math.max(planMtime, ...tasks.map(task => task.mtimeMs)),
144 }
145}
146
147const planDirsOf = async (fs: WorkspaceFs, plansDir: string) =>
148 (await listOr(fs, plansDir))
149 .flatMap(entry => {
150 const match = entry.kind === 'dir' ? PLAN_DIR_RE.exec(entry.name) : null
151 return match === null ? [] : [{ name: entry.name, id: Number(match[1]), slug: match[2] ?? '' }]
152 })
153 .sort((a, b) => a.id - b.id)
154 .slice(0, MAX_PLANS)
155
156/** The plans under `plans/` (never `archive/`), sorted by id. */
157export const listActivePlans = async (fs: WorkspaceFs, root: string): Promise<Plan[]> => {
158 const plansDir = join(root, 'plans')
159 const dirs = await planDirsOf(fs, plansDir)
160 return Promise.all(dirs.map(({ name, id, slug }) => readPlan(fs, plansDir, name, id, slug)))
161}
162
163/**
164 * A string that changes when a plan directory, plan file or task file under
165 * `plans/` appears, disappears or is modified. It costs directory listings
166 * only, so a timer can call it often and re-read the plans only on a change.
167 */
168export const planSignature = async (fs: WorkspaceFs, root: string): Promise<string> => {
169 const plansDir = join(root, 'plans')
170 const parts: string[] = []
171 for (const { name } of await planDirsOf(fs, plansDir)) {
172 const dir = join(plansDir, name)
173 for (const entry of await listOr(fs, dir)) parts.push(`${name}/${entry.name}:${entry.mtimeMs}`)
174 for (const entry of await listOr(fs, join(dir, 'tasks'))) parts.push(`${name}/tasks/${entry.name}:${entry.mtimeMs}`)
175 }
176 return parts.join('\n')
177}
178
179/**
180 * The plan to show: the pinned one when it exists, else the plan with an
181 * `in-progress` task, else the plan whose task files changed most recently.
182 * `pin` is a plan id (`42`) or a directory name (`42--slug`).
183 */
184export const pickActivePlan = (plans: readonly Plan[], pin?: string): Plan | undefined => {
185 const pinned = pin === undefined ? undefined : plans.find(plan => plan.name === pin || String(plan.id) === pin.replace(/^0+(?=\d)/, ''))
186 if (pinned !== undefined) return pinned
187 const working = plans.filter(plan => plan.tasks.some(task => task.status === 'in-progress'))
188 const pool = working.length > 0 ? working : plans
189 return pool.reduce<Plan | undefined>((best, plan) => (best === undefined || plan.mtimeMs > best.mtimeMs ? plan : best), undefined)
190}
191