Opens the active Strikethroo plan in strikethroo serve: checks whether it already answers on 127.0.0.1, starts it with no shell when it doesn't, and shows a…

A Claude Code mod that opens the active Strikethroo plan in strikethroo serve, Strikethroo's local web viewer. /strikethroo-open or a button above the prompt checks whether a viewer already answers on 127.0.0.1, starts one if not, and shows a link to the plan.
Above the prompt, in a project with a Strikethroo workspace:
Open in Strikethroo viewer
After /strikethroo-open or a press of that button:
⎿ strikethroo-viewer: Strikethroo viewer: http://127.0.0.1:4317/plans/83--workspace-data-layer
Reopen viewer http://127.0.0.1:4317/plans/83--workspace-data-layer
What happens on open:
http://127.0.0.1:<port>/api/plans whether a viewer answers.serve_command on spaces, adds --port <port>, and starts it with $.process.spawn in the project directory, with no shell. It refuses a serve_command that contains --host, so the viewer never listens beyond loopback.npx to download Strikethroo on first use. It then links to /plans/<id>--<slug> for the active plan: the plan with a task in progress, or else the plan whose task files changed most recently. strikethroo-progress's pin_plan doesn't apply here.If the viewer doesn't answer, the command prints the end of the server's output, such as an npm error.
| Hook | What it does |
|---|---|
session.start | Finds the workspace and registers /strikethroo-open |
command.run on strikethroo-open | Finds or starts the viewer, and returns the link |
ui.render on AbovePrompt | Draws the button, and the link once there is one |
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). It is the only plugin in this marketplace allowed $.process.spawn and $.http.fetch.
<!-- capabilities -->
{
"hooks": ["session.start", "command.run", "ui.render"],
"calls": [
"$.clock.sleep",
"$.command.register",
"$.fs.exists",
"$.fs.list",
"$.fs.read",
"$.http.fetch",
"$.process.spawn",
"$.session.cwd",
"$.ui.invalidate",
"$.ui.log",
"$.ui.resolve",
"$.ui.toast"
]
}
<!-- /capabilities -->
$.http.fetch only ever gets a URL built from the constant 127.0.0.1 and a port between 1024 and 65535 (hooks/viewer.ts). $.process.spawn runs serve_command as you, so treat that setting like any command you would type. The default downloads the latest strikethroo release from npm and runs it without asking; set serve_command to a pinned version or a local install to avoid that. The mod calls no viewer write route, such as archive or config.
Captured from a Claude Code 2.1.296 session in a temporary project holding the Strikethroo v4.1.1 fixture workspace, with plan 83 copied into plans/ and an empty npm cache:
/strikethroo-open downloaded Strikethroo through npx, started the viewer and printed the link 3.3 seconds later.ss -ltn showed it listening on 127.0.0.1:4317 only, and the plan page answered 200.session.start found the workspace but didn't ask for a redraw, and $.ui.invalidate only redraws the calling mod's own drawings. It now calls $.ui.invalidate once the workspace is found.process.spawn stub that throws, so the failure test makes the stub write to stderr and exit 1, as npx does.Requirements:
v4.1.1 fixtures.npx and access to the npm registry.Steps:
claude plugin marketplace add e0ipso/strikethroo-mods
claude plugin install strikethroo-viewer@strikethroo-mods
Or from a clone, for one session: claude --plugin-dir ./plugins/strikethroo-viewer.
/strikethroo-open, or press the band button. In the terminal, click the link.| Option | Default | What it does |
|---|---|---|
serve_command | npx --yes strikethroo@latest serve --no-open | The command that starts the viewer, split on spaces, no quoting. --port is added; --host is refused. |
port | 4317 | The loopback port, from 1024 to 65535 |
serve_command, such as npx --yes strikethroo@4 serve --no-open./api/plans with a 2xx on that port, the mod takes it for the viewer./strikethroo-open works at once.Link to http://127.0.0.1 draws as plain text, because the Desktop app only makes https: and http://localhost links clickable. The command's text still has the URL.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
strikethroo is what the default serve_command runs. The mod doesn't ship it; npx fetches the latest release from npm.
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 116 lines1/**
2 * strikethroo-viewer: /strikethroo-open and a band button that open the
3 * active plan in `strikethroo serve`.
4 *
5 * It asks http://127.0.0.1:<port>/api/plans whether a viewer already answers,
6 * and if not, spawns serve_command with --port, no shell, in the project
7 * directory, never with --host. The child lives as long as the module: Claude
8 * Code stops it when the session ends or the mod reloads. Read-only on
9 * .ai/strikethroo/; serve's own write routes are not called.
10 */
11import type { EngineInterface, On, PluginOptions } from 'claude-code'
12
13import type { WorkspaceFs, WorkspaceLookup } from './lib/strikethroo/index.ts'
14import { findWorkspace, listActivePlans, pickActivePlan } from './lib/strikethroo/index.ts'
15import type { ViewerConfig } from './viewer.ts'
16import { apiUrl, configOf, pageUrl, serveArgv, tailOf } from './viewer.ts'
17
18const COMMAND = 'strikethroo-open'
19const WAIT_STEP_MS = 250
20/** 30 seconds: long enough for npx to download Strikethroo on first use. */
21const WAIT_STEPS = 120
22
23let config: ViewerConfig = configOf({})
24let cwd: string | undefined
25let lookup: WorkspaceLookup = { kind: 'none' }
26/** The page last opened, shown as a link in the band. */
27let url: string | undefined
28let isChildRunning = false
29let childOutput = ''
30
31const fsOf = ($: EngineInterface): WorkspaceFs => ({
32 exists: path => $.fs.exists(path),
33 read: path => $.fs.read(path),
34 list: path => $.fs.list(path),
35})
36
37/** Whether a viewer answers on the configured loopback port. */
38async function isServing($: EngineInterface): Promise<boolean> {
39 try {
40 return (await $.http.fetch(apiUrl(config.port))).ok
41 } catch {
42 return false
43 }
44}
45
46/** Starts serve_command for the session's life, keeping the end of its output. */
47function startServer($: EngineInterface, project: string): void {
48 isChildRunning = true
49 childOutput = ''
50 void (async () => {
51 try {
52 for await (const chunk of $.process.spawn({ argv: serveArgv(config), cwd: project })) {
53 childOutput = (childOutput + chunk.text).slice(-4000)
54 }
55 } catch (error) {
56 childOutput += `\n${String(error)}`
57 }
58 isChildRunning = false
59 url = undefined
60 $.ui.invalidate('ui.render')
61 })()
62}
63
64/** Finds or starts the viewer and returns the line to show. */
65async function openViewer($: EngineInterface): Promise<string> {
66 lookup = await findWorkspace(fsOf($), cwd ?? (await $.session.cwd()))
67 if (lookup.kind === 'unsupported') return `${lookup.message}. This mod reads workspace schema 4 only.`
68 if (lookup.kind === 'none') return 'No Strikethroo workspace found from this directory.'
69 if (config.problem !== undefined) return config.problem
70
71 if (!(await isServing($))) {
72 if (!isChildRunning) startServer($, lookup.project)
73 for (let step = 0; step < WAIT_STEPS && isChildRunning && !(await isServing($)); step++) await $.clock.sleep(WAIT_STEP_MS)
74 if (!(await isServing($))) {
75 const tail = tailOf(childOutput)
76 return `strikethroo serve did not answer on port ${config.port}${tail === '' ? '.' : `:\n${tail}`}`
77 }
78 }
79
80 const plan = pickActivePlan(await listActivePlans(fsOf($), lookup.root))
81 url = pageUrl(config.port, plan?.name)
82 $.ui.invalidate('ui.render')
83 return `Strikethroo viewer: ${url}`
84}
85
86export function register(on: On, options: PluginOptions): void {
87 config = configOf(options)
88
89 on('session.start', async ($, e, next) => {
90 cwd = e.cwd
91 lookup = await findWorkspace(fsOf($), e.cwd)
92 $.ui.invalidate('ui.render')
93 try {
94 await $.command.register({ name: COMMAND, description: 'Open the active Strikethroo plan in strikethroo serve' })
95 } catch (error) {
96 $.ui.log(`/${COMMAND} is unavailable: ${String(error)}`)
97 }
98 return next(e)
99 })
100
101 on('command.run', { command: COMMAND }, async $ => ({ text: await openViewer($) }))
102
103 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104 if (lookup.kind !== 'found' || e.props.hasSurvey) return next(e)
105 const { Box, Button, Link } = $.ui.resolve(e)
106 const button = Button({
107 key: 'open-viewer',
108 label: url === undefined ? 'Open in Strikethroo viewer' : 'Reopen viewer',
109 plain: true,
110 onPress: async () => $.ui.toast(await openViewer($)),
111 })
112 const row = Box({ flexDirection: 'row', columnGap: 2, children: url === undefined ? [button] : [button, Link({ href: url, label: url })] })
113 return Box({ flexDirection: 'column', children: [row, await next(e)] })
114 })
115}
116hooks/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/viewer.ts 45 lines1/**
2 * The viewer's configuration and URLs, as plain functions. Every URL is built
3 * here from a fixed loopback host and a validated port, so nothing the user
4 * or a workspace supplies can point a request elsewhere.
5 */
6import type { PluginOptions } from 'claude-code'
7
8export const HOST = '127.0.0.1'
9export const DEFAULT_COMMAND = 'npx --yes strikethroo@latest serve --no-open'
10export const DEFAULT_PORT = 4317
11
12/** The plugin's `userConfig`, as plugin.json declares it, plus what was wrong with it. */
13export type ViewerConfig = {
14 argv: readonly string[]
15 port: number
16 /** Set when serve_command can't be used; the mod then starts nothing. */
17 problem: string | undefined
18}
19
20export const configOf = (options: PluginOptions): ViewerConfig => {
21 const command = typeof options.serve_command === 'string' && options.serve_command.trim() !== '' ? options.serve_command : DEFAULT_COMMAND
22 const argv = command.trim().split(/\s+/)
23 const port = options.port
24 const isPort = typeof port === 'number' && Number.isInteger(port) && port >= 1024 && port <= 65535
25 const hasHost = argv.some(arg => arg === '--host' || arg.startsWith('--host='))
26 return {
27 argv,
28 port: isPort ? port : DEFAULT_PORT,
29 problem: hasHost ? 'serve_command passes --host, which this mod refuses: the viewer stays on 127.0.0.1.' : undefined,
30 }
31}
32
33/** The command to spawn: the configured argv with the port added. */
34export const serveArgv = (config: ViewerConfig): string[] => [...config.argv, '--port', String(config.port)]
35
36export const apiUrl = (port: number): string => `http://${HOST}:${port}/api/plans`
37
38/** The viewer page for a plan directory such as `42--add-login`, or the plan list. */
39export const pageUrl = (port: number, plan: string | undefined): string =>
40 `http://${HOST}:${port}/${plan === undefined ? '' : `plans/${encodeURIComponent(plan)}`}`
41
42/** The end of a process's output, without control characters, for an error message. */
43export const tailOf = (output: string): string =>
44 output.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').trim().slice(-500)
45hooks/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