SLOPSHOPPER

strikethroo-viewer

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…

newbandcommandtoastprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · strikethroo-viewer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /strikethroo-open ⎿ strikethroo-viewer: No Strikethroo workspace found from this directory. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

strikethroo-viewer

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.

What this shows

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:

  1. It asks http://127.0.0.1:<port>/api/plans whether a viewer answers.
  2. If not, it splits 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.
  3. It waits up to 30 seconds for the viewer to answer, which leaves time for 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.

HookWhat it does
session.startFinds the workspace and registers /strikethroo-open
command.run on strikethroo-openFinds or starts the viewer, and returns the link
ui.render on AbovePromptDraws the button, and the link once there is one

Capabilities

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.

Demo

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:

  1. /strikethroo-open downloaded Strikethroo through npx, started the viewer and printed the link 3.3 seconds later.
  2. ss -ltn showed it listening on 127.0.0.1:4317 only, and the plan page answered 200.
  3. After the session ended, port 4317 was free again.

How it was built

  • Model: Claude Opus 5.5 in Claude Code.
  • Prompt(s): a build brief from the repository owner covering the five plugins in this marketplace. It isn't included here.
  • Transcript: not shared.
  • Iterations:
  • In the first session the band button never appeared. 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.
  • The test kit skips a process.spawn stub that throws, so the failure test makes the stub write to stderr and exit 1, as npx does.

Run it

Requirements:

  • Claude Code 2.1.287 or later. Tested with 2.1.296.
  • A Strikethroo workspace of schema version 4. Verified against the Strikethroo v4.1.1 fixtures.
  • For the default command, npx and access to the npm registry.

Steps:

  1. Install it:
   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.

  1. Run /strikethroo-open, or press the band button. In the terminal, click the link.

Configuration

OptionDefaultWhat it does
serve_commandnpx --yes strikethroo@latest serve --no-openThe command that starts the viewer, split on spaces, no quoting. --port is added; --host is refused.
port4317The loopback port, from 1024 to 65535

Notes / limitations

  • The server the mod starts lives as long as the mod: Claude Code stops it when the session ends or the mod reloads. A viewer you started yourself is reused and left alone.
  • The default runs the newest Strikethroo. If a future release stops reading schema 4 workspaces, pin a version in serve_command, such as npx --yes strikethroo@4 serve --no-open.
  • If something other than the viewer answers /api/plans with a 2xx on that port, the mod takes it for the viewer.
  • The workspace is found at session start. If you create one mid-session, the band button appears after a reload or a new session; /strikethroo-open works at once.
  • In the Desktop app a 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.
  • Not checked: the Desktop app, the VS Code extension, Windows.

Dependencies

NameVersionLicense (SPDX)Source
None

strikethroo is what the default serve_command runs. The mod doesn't ship it; npx fetches the latest release from npm.

Third-party notices

Claude and Claude Code are trademarks of Anthropic; use here is descriptive and implies no endorsement.


A prototype, MIT licensed. See the root README.

Source 5 files
hooks/register.ts 116 lines
1/**
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}
116
hooks/lib/strikethroo/index.ts 11 lines
1/**
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'
11
hooks/viewer.ts 45 lines
1/**
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)
45
hooks/lib/strikethroo/parse.ts 138 lines
1/**
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]
138
hooks/lib/strikethroo/workspace.ts 191 lines
1/**
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