SLOPSHOPPER

mod-builder

Build Claude Code mods spec-first (interview → SPEC → approve → build → verify), and watch them come together in Mod Lab: a pane, band, status line and toasts…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-builder
│ ┃ Mod Lab ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ M O D L A B S T A N D I N G B Y ● mod-builder: 2026-10-16T14:18:50.253Z NORMAL session.start Mod La │ ┃ ⏺ Read(src/auth.ts) │ ┃ 0 mods modsDir /mods ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No mods yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Type /mod-builder:build and describe ⎿ 3 pass, 1 fail │ ┃ one. │ ┃ Or press D to watch a demo build. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ctrl+x tab (again: next tab), then N ne… │ ┃ [ New mod ] [ Demo ] ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /modlab │ ┃ ⎿ mod-builder: Mod Lab opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mod Lab
M O D L A B S T A N D I N G B Y 0 mods modsDir /mods No mods yet. Type /mod-builder:build and describe one. Or press D to watch a demo build. ctrl+x tab (again: next tab), then N new mod · D demo ·… [ New mod ] [ Demo ]
README

mod-builder

Build Claude Code mods spec-first, change them the same way, and watch each one come together live.

The plugin has three parts:

PartCommandWhat it does
build (skill)/mod-builder:build <idea>Turns an idea into a working mod in five steps around one approval: interview for gaps, a SPEC.md you approve, a scaffold from one of five tested archetypes with logic first, verify (claude plugin validate, claude plugin test, tsc, a headless smoke test), then ship with a "What it reaches" README and a marketplace entry.
edit (skill)/mod-builder:edit <mod> <change>Changes an existing mod the same way: appends a dated change to its SPEC for one approval, bumps the version by semver, changes logic first, re-verifies and re-ships. It never re-scaffolds.
Mod Lab (mod)/modlabTracks every mod in your mods folder through eight stages, live, on every surface a mod can draw on.

Both skills build on Claude Code's built-in plugin-authoring skill for the API. They add house conventions, a UI-state checklist, 14 patterns and a list of 65 engine gotchas, each tagged by how it was verified.

Mod Lab

  M O D   L A B                                          ● B U I L D I N G

  3 mods · 2 green · 1 building                          modsDir  …/mod-builder/mods

    MODS            1 2 3 4 5 6 7 8
  ▸ agent-dock      ● ● ● ● ◐ ○ ○ ○   running tests · 7 of 12          0:07
    inbox-zero      ● ● ● ● ● ● ● ●   12 tests · clean                2:14
    tab-namer       ● ● ● ● ◌ ◌ ● ●   files changed · re-run Validate 1:38
  ────────────────────────────────────────────────────────────────────────
  agent-dock                                  v0.1.0 · mods/agent-dock/
  Docks every sub-agent's output beside the transcript.

  1 Spec ━ 2 Scaffold ━ 3 Logic ━ 4 Register ━ 5 Validate ━ 6 Test ┅ 7 README ┄ 8 Share

  ctrl+x tab (again: next tab), then V validate · T test · K types · O readme · J/P move · N new · D demo · esc to leave
  [ Validate ]  [ Test ]  [ Type-check ]  [ README ]
  [ Next ]  [ Prev ]  [ New mod ]  [ Demo ]

Reading the pane

  • The header row puts MODS over the names and a stage number over each light. The number over the selected mod's failed (red), running (coral) or stale (gold) stage lights up. The track below repeats the numbers beside each stage name, so 5 is Validate.
  • The lights:
LightMeaning
●passed
◐running
✕failed
○not done yet
◌stale: it passed, but the code changed afterwards, or an edit started
  • The row's status follows a run as it streams (running tests · 7 of 12), and the time column shows the run's elapsed time.
  • The selection follows the active build. When the skill reports a stage on a mod, that mod is selected. <kbd>J</kbd>/<kbd>P</kbd> still move it.

What Mod Lab uses

SurfaceWhat Mod Lab puts there
Pane beside the transcriptStage lights per mod, judged from disk and exit codes; a detail panel with version, description, the numbered track, HOOKS, REACHES and gating hooks; an error box with [F] Fix with Claude; a "ready to share" box
Band above the promptEach running stage, with a 15-cell bar that fills on real progress: streamed test counts against the last run's total, otherwise stages passed of 8
Status line◆ 3 mods · 1 building
Toasts and a native notificationWhen validate or test finishes, and when a mod is ready to share
Hotkey buttonsV T K O J P N D, and F on a failure; <kbd>ctrl</kbd>+<kbd>x</kbd> <kbd>tab</kbd> gives the pane the keyboard (a dim line says so)
Pre-filled prompts[N] for a new mod; [F] for a fix request quoting the first failure
A model-callable toollab_report: the skills report each stage as they work, so the pane lights up live. The disk and exit codes always decide pass or fail.

Stages, and what an edit asks for

#StagePasses whenDuring an edit, re-passes when
1SpecSPEC.md has no <placeholder> leftSPEC.md is saved again
2Scaffoldplugin.json and hooks/hooks.json existstays green
3Logica logic module and a *.test.ts exista logic or test file changes
4Registerhooks/register.tsx existsstays green
5Validatethe last validate passed, with no code changed sinceit runs again
6Testthe last test run passed, with no code changed sinceit runs again
7READMEit has "What it reaches" and no template commentsit's saved again
8Sharethe marketplace lists the modthe marketplace lists the new version

An edit starts a build generation with its first report. Every stage that passed before turns ◌, except Scaffold and Register, which stay green, and turns green again as the change redoes it. The generation ends, and "ready to share" fires again, when all eight are green.

Checks re-run themselves. At the end of each of Claude's turns (not a subagent's), Mod Lab rescans every mod. Edits from any tool count, Bash included. With autoRerun on (the default), it then re-runs what went stale: a stale Validate runs again, followed by Test if Validate passes and Test has run before; a stale Test alone runs again if Validate passed. Nothing runs mid-turn or on a timer.

Try it

/modlab demo

A pretend mod, aurora, walks through every stage in 12 seconds. One test fails and Claude fixes it. Press D again to stop it. Nothing is written to disk.

Then start a real one, and change it later:

/mod-builder:build a band above the prompt showing the current git branch
/mod-builder:edit git-branch also show how many commits are unpushed
/modlab commandWhat it does
/modlabOpen the pane; again to fold it to the status line. If the pane is open as a tab behind another, it comes to the front with the keyboard instead of folding
/modlab demoPlay the demo
/modlab <mod>Open the pane on that mod (a pane behind another tab comes to the front)
/modlab new <idea>Put /mod-builder:build <idea> in the prompt box

Typing /mod-builder:build or /mod-builder:edit opens Mod Lab too. A command you type opens the pane at any width. When Mod Lab opens it on its own (autoOpen, or when Claude starts a build you asked for in plain words), the window must be at least 144 columns wide, or 110 once you've opened Mod Lab yourself. Narrower than that, watch the status line.

Reaching the pane when other panes are open: ctrl+x tab shows the first tab and gives it the keyboard, each press after moves one tab right, and after the last tab the keyboard goes back to the prompt. Esc hands it back from any tab. A letter that is one of the pane's keys acts at once. To cycle without going through the first tab, bind pane:next and pane:previous (no default key) in ~/.claude/keybindings.json; it works while a pane holds the keyboard:

{"bindings":[{"context":"Pane","bindings":{"ctrl+x n":"pane:next","ctrl+x p":"pane:previous"}}]}

Install

From GitHub:

/plugin install mod-builder --marketplace bankielewicz/mod-builder

Or from a local clone of https://github.com/bankielewicz/mod-builder, which is a folder marketplace:

claude plugin marketplace add ./mod-builder
/plugin install mod-builder@claudemod

Edits to the folder load with /reload-plugins. While developing: claude --plugin-dir plugins/mod-builder.

Each way of loading the plugin (--plugin-dir, or an installed copy) keeps its own store, and within it each mods folder keeps its own results. If you switch either one (another way of loading, or a /cd into another worktree), Validate and Test show ○ until you press V and T once.

Options

OptionDefaultWhat it does
modsDirempty: the mods/ of the marketplace nearest the session's project root (where it started, or where /cd took it), else the one next to this pluginThe folder whose child folders Mod Lab tracks. Set, it always wins. Empty, a build that reports its folder (the build and edit skills do) is watched for the rest of the session, until a /cd moves the project root or the folder's marketplace is gone; only a marketplace's mods/ in the project's own git repository (its top folder or one of its worktrees, read from .git on disk) is taken, since Validate and Test run there; a nested clone or a copy, such as community mods kept in an ignored folder, is not. Before any report, it is the mods/ of the marketplace nearest the project root (then nearest the working directory), so a session in a git worktree watches that worktree's mods/; a /cd takes effect at the next scan, and Claude's own cd in Bash doesn't move it. In another repository with a .claude-plugin/marketplace.json, that is its mods/, and the pane says No mods yet. if it keeps its plugins elsewhere. Set it to pin one folder.
autoOpenfalseOpen Mod Lab when a skill reports a running stage, if the window is at least 144 columns wide (110 once you've opened Mod Lab yourself). Typing /mod-builder:build or /mod-builder:edit opens it whatever this is set to.
autoReruntrueAt the end of each of Claude's turns, re-run stale checks: a stale Validate, then Test if Validate passes and Test has run before; a stale Test alone, if Validate passed
testTimeoutMinutes3How long a Validate, Test or type-check may run before Mod Lab stops it and shows it as timed out (at most 10; fractions are fine). When Mod Lab loads, it clears checks a reload left shown as running. One still shown as running 30 s past this stops counting: V, T and K light again and the auto re-run goes on
loggingnormaloff: nothing. normal: errors, session start and check results. verbose: also reports, scans, processes and commands. debug: also expected misses (a folder or file not there yet), each mod's lights and process output. One file per session in ~/.claude/mod-builder-data/logs/, each line also in the debug log (claude --debug).

/mod-builder config lists these with their current values and this session's log file; /mod-builder config <option> <value> changes one (for example /mod-builder config logging verbose). They're also in /config.

What it reaches

Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is taken from claude plugin validate.

ReachDetail
Hookssession.start, command.run{mod-builder} (gating, with .catch), command.run{modlab} (answers its own command), command.run{mod-builder:build} and command.run{mod-builder:edit} (gating, with .catch: open the pane, then run the skill), tool.call{mcp__mod-builder__lab_report}, turn.complete, session.end, ui.close, ui.render{Pane modlab}, ui.render{AbovePrompt}
Processesclaude plugin validate --json <mod> and npx -y -p typescript@5.6 tsc -p <mod> ($.process.run); claude plugin test <mod> streamed with $.process.spawn so the band fills on real progress (it falls back to $.process.run if the stream can't start). Only when you press V, T or K, when a skill reports a Validate or Test stage, or (with autoRerun) at the end of a turn for a mod whose checks went stale. Never on a timer. A check still running after testTimeoutMinutes is stopped (its child is killed) and shown as timed out.
FilesReads under modsDir, the marketplace file beside it and its own plugin.json. When a build reports a folder, checks its marketplace file and reads .git in the folder above it and at or above the project root, to tell whether it is the project's own repository or a worktree of it. With modsDir empty and no build's folder kept, checks whether .claude-plugin/marketplace.json exists in the project root (the working directory when the root can't be read) and then each folder above it, stopping at the first found, at each scan. Writes only its log, ~/.claude/mod-builder-data/logs/<date>-<session>.log, unless logging is off.
Prompt box$.prompt.fill puts /mod-builder:build (N, or /modlab new <idea>) or a fix request (F) in the prompt box; it never submits
Notifications$.ui.notify: a native notification when a mod is ready to share
SettingsIts own /config rows ($.config.list / $.config.set), when you run /mod-builder config <option> <value>
EnvironmentReads HOME, USERPROFILE (to place the log file)
SessionReads the project root (to find the mods folder when modsDir is empty, and to keep a build's folder until a /cd), the working directory (to name a mod's folder the way the session reads it, and to find the mods folder when the root can't be read) and the session id (to name the log file). Keeps the folder a build reported in its own session state.
StoreIts own $.store, one set of keys per mods folder: last results per mod, which mods were announced, and edit generations
NetworkNone from the mod itself. The first Type-check makes npx download TypeScript.
Model callsNone
Tools it addslab_report, which records progress (with path, the folder being built in) and runs nothing beyond the Validate and Test above
Gating hooks5, all with .catch: /mod-builder (command), /mod-builder:build and /mod-builder:edit (they open the pane and always run the skill), lab_report, ui.close

Development

claude plugin validate plugins/mod-builder
claude plugin test plugins/mod-builder               # 166 tests: logic and UI
bash plugins/mod-builder/skills/build/scripts/check-archetypes.sh   # all five archetypes
  • hooks/mod-builder-logic.ts holds every rule. It has no $ calls and is unit-tested.
  • hooks/register.tsx is the only file that calls $.
  • skills/build/ and skills/edit/ are the two skills; edit reuses build's references and templates.
  • SPEC.md is Mod Lab's own spec, written from the skill's template. Its Design section records how the pane follows the Claude Design mockups, and its Edits section covers build generations.
  • design/ holds the Claude Design briefs: the repo README, its visuals (hero and feature strip), and the pane mockups (superseded, kept for history).

CI tests it on Claude Code 2.1.296. Function hooks are early access, so the API may change between releases.

Source 3 files
hooks/register.tsx 1784 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ModView, Run, RunKind } from '../types'
5import * as Lab from './mod-builder-logic'
6
7/**
8 * Mod Lab: the showcase half of mod-builder. Every `$` call lives here; every
9 * rule it applies lives in mod-builder-logic.ts. The drawing follows the
10 * Claude Design mockups in design/ (frames 1a–5b).
11 *
12 * `/modlab` opens a pane of the mods under the mods folder, each with eight
13 * stage lights judged from disk and exit codes. Buttons run validate, test and
14 * tsc; the band, the status line, toasts and a notification report runs; the
15 * model's `lab_report` tool lights the stage it is working on; `/modlab demo`
16 * walks a pretend build through every state.
17 */
18
19const PANE = 'modlab'
20/** The pane as every open asks for it; bringing it to the front adds only `focus`. */
21const PANE_ARGS = { id: PANE, title: 'Mod Lab', columns: 80 }
22const TOOL = 'lab_report'
23const RESULTS_KEY = 'modlab:results'
24const DONE_KEY = 'modlab:done'
25const GENERATIONS_KEY = 'modlab:generations'
26const FRAME_MS = 250
27const NAME_COLUMN = 18
28const LABEL_COLUMN = 11
29const NARROW_TOAST = '◆ Mod Lab needs a wider window. Widen it, or watch the status line.'
30const SHOW_TEXT = 'Bringing Mod Lab to the front; it takes the keyboard if your prompt is empty (Esc gives it back).'
31const STILL_BEHIND_TOAST = 'Mod Lab is still behind another tab: ctrl+x tab, then again until it shows.'
32/** How long after asking for the front the pane is read again (#31). */
33const FRONT_CHECK_MS = 500
34
35/** The design's colour roles; `text` is the surface's own foreground. */
36const COLOR: Record<Lab.Tone, string | undefined> = {
37  accent: '#D97757',
38  success: '#7FB685',
39  warn: '#E0B15A',
40  error: '#E06C6C',
41  dim: '#8A8580',
42  text: undefined,
43}
44const BACKGROUND = '#1A1918'
45const HOVER = '#2C2A27'
46
47// One value on purpose: a scan's mods and the folder it judged them in are written together, so no
48// render, button, lab_report or re-run ever pairs one folder's rows with another's path. Don't split
49// it into two atoms (PR #57 review round 2; the "publishes its folder and its mods together" test).
50const judged = atom({ plugin: 'mod-builder', key: 'judged' } as const, { modsDir: null, views: [] })
51const selected = atom({ plugin: 'mod-builder', key: 'selected' } as const, null)
52const runs = atom({ plugin: 'mod-builder', key: 'runs' } as const, [])
53const reported = atom({ plugin: 'mod-builder', key: 'reported' } as const, {})
54const building = atom({ plugin: 'mod-builder', key: 'building' } as const, {})
55const frame = atom({ plugin: 'mod-builder', key: 'frame' } as const, { n: 0, now: 0 })
56const demoStartedAt = atom({ plugin: 'mod-builder', key: 'demoStartedAt' } as const, null)
57const readme = atom({ plugin: 'mod-builder', key: 'readme' } as const, null)
58const fresh = atom({ plugin: 'mod-builder', key: 'fresh' } as const, [])
59const hasWarnedNarrow = atom({ plugin: 'mod-builder', key: 'hasWarnedNarrow' } as const, false)
60// A copy of `pin` below, for a reload: written with `$.state.set` only, and read once at session start.
61const pinned = atom({ plugin: 'mod-builder', key: 'pinned' } as const, null)
62
63type Engine = EngineInterface
64type StoredResults = Record<string, Lab.Results>
65
66// Module state: options, timer handles, in-flight guards and write chains only.
67// A reload starts it afresh; what must survive lives in atoms and $.store.
68let configuredDir = ''
69let autoOpen = false
70let autoRerun = true
71/** A check's time limit in ms, from `testTimeoutMinutes`. */
72let runLimit = Lab.runLimitMsOf(undefined)
73let clock: Timer | null = null
74let lastTickMs = 0
75let scanning: Promise<void> | null = null
76let isScanQueued = false
77let isQueuedAnnounced = false
78// The folder a build reported with `path`, and the root it was reported under (#92). Module state, so the
79// lab_report hook sets it before its scan without a state read: a dispatch's first `get` fixes what all its
80// reads see (gotchas.md), and the #10 scan has to come first.
81let pin: Lab.Pin | null = null
82// The project root's repository (its common git folder), found once per root (PR #95 review follow-up).
83let rootRepo: { root: string; common: string | null } | null = null
84// Writes to the results and generations in $.store, in order (Change 0.2.8).
85let storeChain: Promise<void> = Promise.resolve()
86// What the last finished scan read; a scan swaps in its own once it has read every folder (Change 0.2.8).
87let factsByMod = new Map<string, Lab.FileFacts>()
88/** The folder and mods of the last scan that wrote its rows (`judged`): set with no await after that write. */
89let published: { modsDir: string; names: ReadonlySet<string> } | null = null
90// Logging (Change 0.2.2): the level, this session's lines, its file once known, and writes in order.
91let logLevel: Lab.LogLevel = 'normal'
92let logBuffer: Lab.LogBuffer = Lab.EMPTY_LOG
93let logFile = ''
94let logChain: Promise<void> = Promise.resolve()
95let flushTimer: Timer | null = null
96let lastCaught: Record<string, string> = {}
97let writtenSig = ''
98let hasReportedWriteFailure = false
99
100// Every function that takes `$` is declared here, at the top of the file: the
101// engine follows `$` only into functions it can see at this level.
102
103// ── Logging ───────────────────────────────────────────────────────────────
104
105/**
106 * Records one line when the `logging` level allows it: into this session's
107 * buffer, the debug log, and the log file (at once for `normal` lines, else
108 * LOG_FLUSH_MS after the last). Logging never throws into Mod Lab.
109 */
110function logEvent($: Engine, level: Lab.LineLevel, site: string, message: string, context?: unknown): void {
111  if (!Lab.shouldLog(logLevel, level)) return
112  try {
113    const line = Lab.logLineOf(Date.now(), level, site, message, context)
114    logBuffer = Lab.pushLine(logBuffer, line)
115    $.ui.log(line, { to: 'debug' })
116    if (level === 'normal') {
117      flushTimer?.cancel()
118      flushTimer = null
119      void flushLog($)
120    } else scheduleFlush($)
121  } catch {
122    // A line that can't be recorded is dropped; Mod Lab carries on.
123  }
124}
125
126/** A caught error, logged where it was caught; `normal` unless the miss is expected. Repeats at one site are logged once. */
127function logCaught($: Engine, site: string, error: unknown, level: Lab.LineLevel = 'normal', context?: unknown): void {
128  const message = Lab.errorText(error)
129  if (level === 'normal') {
130    const seen = Lab.isNewCaught(lastCaught, site, message)
131    lastCaught = seen.last
132    if (!seen.isNew) return
133  }
134  logEvent($, level, site, message, context)
135}
136
137function scheduleFlush($: Engine): void {
138  if (flushTimer) return
139  try {
140    flushTimer = $.clock.after(Lab.LOG_FLUSH_MS, () => {
141      flushTimer = null
142      void flushLog($)
143    })
144  } catch {
145    flushTimer = null
146  }
147}
148
149/** Writes the buffer to this session's log file; writes run one at a time. A failed write is noted once in the debug log. */
150function flushLog($: Engine): Promise<void> {
151  logChain = logChain
152    .then(() => writeLog($))
153    .catch((error: unknown) => {
154      if (hasReportedWriteFailure) return
155      hasReportedWriteFailure = true
156      try {
157        $.ui.log(`mod-builder: could not write its log ${logFile}: ${Lab.errorText(error)}`, { to: 'debug' })
158      } catch {
159        // Nothing more can be done.
160      }
161    })
162  return logChain
163}
164
165async function writeLog($: Engine): Promise<void> {
166  if (logLevel === 'off' || (logBuffer.lines.length === 0 && logBuffer.dropped === 0)) return
167  if (!logFile) {
168    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
169    if (!home) return
170    // A session whose id can't be read gets its own name, never a file other sessions share.
171    const sessionId = await $.session.id().catch(() => `x${Date.now().toString(36)}`)
172    const file = Lab.logFileOf(home, Date.now(), sessionId)
173    // After a reload this module starts empty: continue the session's file rather than replace it.
174    // A file that exists but can't be read is left alone; the next flush tries again.
175    if (await $.fs.exists(file)) {
176      const earlier = String(await $.fs.read(file))
177      logBuffer = Lab.continueLog(earlier, logBuffer)
178    }
179    logFile = file
180  }
181  const text = Lab.logText(logBuffer)
182  const sig = `${logBuffer.dropped}:${logBuffer.lines.length}:${logBuffer.lines.at(-1) ?? ''}`
183  if (sig === writtenSig) return
184  await $.fs.write(logFile, text)
185  writtenSig = sig
186}
187
188/** The session's working directory, read at each use so a `/cd` is followed; null when it can't be read. */
189const cwdOf = ($: Engine): Promise<string | null> =>
190  $.session.cwd().catch((error: unknown) => (logCaught($, 'session.cwd', error), null))
191
192/** A folder's entries; none when it can't be listed, and then its path goes in `unlisted`, if given. */
193const list = ($: Engine, path: string, unlisted?: string[]): Promise<Lab.Entry[]> =>
194  $.fs
195    .list(path)
196    .then(entries => entries.map(({ name, kind, mtimeMs }) => ({ name, kind, mtimeMs })))
197    .catch((error: unknown) => {
198      logCaught($, 'fs.list', error, 'debug', { path })
199      unlisted?.push(path)
200      return []
201    })
202
203const readText = ($: Engine, path: string): Promise<string | null> =>
204  $.fs.read(path).catch((error: unknown) => {
205    logCaught($, 'fs.read', error, 'debug', { path })
206    return null
207  })
208
209const exists = ($: Engine, path: string): Promise<boolean> =>
210  $.fs.exists(path).catch((error: unknown) => {
211    logCaught($, 'fs.exists', error, 'debug', { path })
212    return false
213  })
214
215/** The mods folder of the plugin's own marketplace: `modsDir`'s default when the walk finds no marketplace above the root or the working directory. */
216const pluginModsDirOf = ($: Engine): string => `${$.plugin.root}/../../mods`
217
218/** The session's project root, which `/cd` and a worktree move change and a Bash `cd` doesn't (#92); null when it can't be read. */
219const rootOf = ($: Engine): Promise<string | null> =>
220  $.session.root().catch((error: unknown) => (logCaught($, 'session.root', error, 'debug'), null))
221
222/** Sets or clears the pin, with its copy in state for a reload: a write, never a read (see `pin`). An unchanged pin isn't written again. */
223async function setPin($: Engine, next: Lab.Pin | null): Promise<void> {
224  if (pin?.modsDir === next?.modsDir && pin?.root === next?.root) return
225  pin = next
226  await $.state.set({ plugin: 'mod-builder', key: 'pinned' }, next).catch((error: unknown) => logCaught($, 'state.set', error, 'normal', { key: 'pinned' }))
227}
228
229/** A folder's `.git`: a file (a worktree's pointer) is read; a directory is known by its `HEAD`. */
230async function dotGitOf($: Engine, folder: string): Promise<Lab.DotGit> {
231  const path = `${Lab.normalizePath(folder).replace(/\/$/, '')}/.git`
232  const text = await readText($, path)
233  if (text !== null) return { kind: 'file', text }
234  return (await exists($, `${path}/HEAD`)) ? { kind: 'dir' } : null
235}
236
237/**
238 * Whether a folder's `.git` is git's own: a worktree pointer must be named back by git's record of it
239 * (`.git/worktrees/<name>/gitdir`), so a hand-written pointer doesn't count (PR #95 review follow-up).
240 */
241async function isRecordedByGit($: Engine, folder: string, dotGit: Lab.DotGit): Promise<boolean> {
242  const link = Lab.worktreeLinkOf(folder, dotGit)
243  if (!link) return true
244  const text = await readText($, link.record)
245  return text !== null && Lab.isBackLinkTo(folder, link.record, text)
246}
247
248/** The common git folder of the repository the project root is in: the first `.git` at or above it, if git records it. */
249async function rootRepoOf($: Engine, root: string): Promise<string | null> {
250  if (rootRepo?.root === root) return rootRepo.common
251  let common: string | null = null
252  for (const folder of Lab.foldersUpFrom(root)) {
253    const dotGit = await dotGitOf($, folder)
254    if (!dotGit) continue
255    common = (await isRecordedByGit($, folder, dotGit)) ? Lab.gitCommonDirOf(folder, dotGit) : null
256    break
257  }
258  rootRepo = { root, common }
259  return common
260}
261
262/**
263 * The pin a report's folder makes, or why it makes none (PR #95 review): a set `modsDir`, `Lab.pinCheckOf`'s
264 * reasons, no marketplace file, or a folder above `mods/` that isn't the project's own repository or one of
265 * its worktrees (a nested clone or copy, such as community mods kept in an ignored folder).
266 */
267async function pinFor($: Engine, dir: string): Promise<{ pin: Lab.Pin } | { reason: string }> {
268  if (configuredDir) return { reason: 'the modsDir option is set' }
269  const check = Lab.pinCheckOf(dir, await rootOf($))
270  if ('reason' in check) return check
271  const folder = Lab.normalizePath(dir)
272  if (!(await exists($, check.marketplace))) return { reason: `no marketplace holds ${folder}` }
273  const home = Lab.parentOf(folder)
274  const dotGit = await dotGitOf($, home)
275  if (!(await isRecordedByGit($, home, dotGit))) return { reason: `${home} has a worktree pointer git has no record of` }
276  const isOurs = Lab.isSameRepo(Lab.gitCommonDirOf(home, dotGit), await rootRepoOf($, check.root))
277  if (!isOurs) return { reason: `${home} isn't the project's git repository or one of its worktrees` }
278  return { pin: { modsDir: folder, root: check.root } }
279}
280
281/**
282 * The marketplace file nearest `start`, the folder the walk starts in: candidates are probed
283 * nearest first and the first one found ends the walk (each probe is slow on /mnt/c).
284 */
285async function nearestMarketplace($: Engine, start: string | null): Promise<string[]> {
286  for (const path of start === null ? [] : Lab.marketplaceCandidatesOf(start)) {
287    if (await exists($, path)) return [path]
288  }
289  return []
290}
291
292/**
293 * The walk with no pin: the marketplace nearest the project root, read now so a `/cd` is followed and a
294 * Bash `cd` isn't (#92), else the one nearest the working directory (no root, or none above it: PR #95 review).
295 */
296async function walkOf($: Engine, root: string | null): Promise<{ cwd: string | null; existing: string[] }> {
297  const fromRoot = await nearestMarketplace($, root)
298  if (fromRoot.length) return { cwd: null, existing: fromRoot }
299  const cwd = await cwdOf($)
300  return { cwd, existing: await nearestMarketplace($, cwd) }
301}
302
303/**
304 * What `modsDir` means when it isn't set and no build's folder is pinned: the `mods/` of the marketplace
305 * `walkOf` finds (a worktree's own in a worktree), else the plugin's own.
306 */
307async function defaultModsDir($: Engine): Promise<string> {
308  const root = await rootOf($)
309  const { cwd, existing } = await walkOf($, root)
310  return Lab.watchedDirOf(null, root, '', cwd, existing, pluginModsDirOf($)).dir
311}
312
313/**
314 * The mods folder to watch now (#92): a build's pinned folder while the project root is the one it
315 * was pinned under, else a set `modsDir` (nothing is walked), else the walk `defaultModsDir` takes.
316 * A drawing reads it without dropping the pin: `$.state.set` is refused while a render draws, so the
317 * next read outside a render drops it.
318 */
319async function currentModsDir($: Engine, isDrawing = false): Promise<string> {
320  // A set modsDir decides alone: no root to read, and a pin stays for when it's cleared.
321  if (configuredDir) return Lab.normalizePath(configuredDir)
322  const held = pin
323  const root = await rootOf($)
324  // A kept pin whose marketplace is gone (a worktree removed after its PR merged) is dropped too (PR #95 review).
325  const kept = Lab.keptPinOf(held, root)
326  const live = kept && (await exists($, Lab.marketplaceFileOf(kept.modsDir))) ? kept : null
327  const { cwd, existing } = live ? { cwd: null, existing: [] } : await walkOf($, root)
328  const watched = Lab.watchedDirOf(live, root, '', cwd, existing, pluginModsDirOf($))
329  // A changed root or a gone marketplace dropped the pin, unless a report has pinned another folder meanwhile.
330  if (held && !watched.pin && pin === held && !isDrawing) await setPin($, null)
331  return watched.dir
332}
333
334/**
335 * The folder the pane shows: the one the last scan judged (kept in state, so it survives a
336 * reload), else the current one before any scan. What the pane's buttons act on.
337 */
338async function shownModsDir($: Engine, isDrawing = false): Promise<string> {
339  return (await read($, judged)).modsDir ?? currentModsDir($, isDrawing)
340}
341
342/** One mod folder's file facts, as a scan judges them; null when it isn't a mod. */
343async function modFactsOf($: Engine, modsDir: string, name: string, marketplace: string): Promise<Lab.FileFacts | null> {
344  const at = `${modsDir}/${name}`
345  const top = await list($, at)
346  const has = (entry: string): boolean => top.some(one => one.name === entry)
347  // A folder that can't be listed reads as empty, so the facts say which ones (a delete would look the same).
348  const unlisted: string[] = []
349  const listIf = (folder: string): Promise<Lab.Entry[]> => (has(folder) ? list($, `${at}/${folder}`, unlisted) : Promise.resolve([]))
350  const [manifestDir, hooks, tests, types, agents, commands, skills] = await Promise.all([
351    listIf('.claude-plugin'),
352    listIf('hooks'),
353    listIf('tests'),
354    listIf('types'),
355    listIf('agents'),
356    listIf('commands'),
357    listIf('skills'),
358  ])
359  // Their subfolders are walked too; the manifest folder's are not.
360  const codeFolders = { hooks, tests, types, agents, commands, skills }
361  const nested = await Promise.all(Object.entries(codeFolders).map(([folder, entries]) => nestedFiles($, at, folder, entries, unlisted)))
362  return Lab.factsOf({
363    name,
364    top,
365    manifestDir,
366    ...codeFolders,
367    nested: nested.flat(),
368    unlisted: unlisted.map(path => path.slice(at.length + 1)),
369    spec: has('SPEC.md') ? await readText($, `${at}/SPEC.md`) : null,
370    readme: has('README.md') ? await readText($, `${at}/README.md`) : null,
371    manifest: manifestDir.some(entry => entry.name === 'plugin.json') ? await readText($, `${at}/.claude-plugin/plugin.json`) : null,
372    marketplace,
373  })
374}
375
376/** The marketplace file beside a mods folder, or '' when there is none. */
377const marketplaceOf = async ($: Engine, modsDir: string): Promise<string> => (await readText($, `${modsDir}/../.claude-plugin/marketplace.json`)) ?? ''
378
379/**
380 * The files in `folder`'s subfolders (from its `entries`), up to `depth` levels down, named by
381 * their path from the mod folder `at`. Sibling folders are listed at once, since /mnt/c is slow;
382 * dot-folders and node_modules are skipped. A subfolder that can't be listed goes in `unlisted`.
383 */
384async function nestedFiles($: Engine, at: string, folder: string, entries: readonly Lab.Entry[], unlisted: string[], depth = 3): Promise<Lab.Entry[]> {
385  if (depth === 0) return []
386  const subfolders = entries.filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.') && entry.name !== 'node_modules')
387  const found = await Promise.all(
388    subfolders.map(async sub => {
389      const path = `${folder}/${sub.name}`
390      const inner = await list($, `${at}/${path}`, unlisted)
391      const files = inner.filter(entry => entry.kind === 'file').map(entry => ({ ...entry, name: `${path}/${entry.name}` }))
392      return [...files, ...(await nestedFiles($, at, path, inner, unlisted, depth - 1))]
393    }),
394  )
395  return found.flat()
396}
397
398// ── Store ─────────────────────────────────────────────────────────────────
399
400/**
401 * The store key of one mods folder's records. One install serves every folder the working
402 * directory leads to, and $.store is shared between sessions, so a mod's name alone is not enough.
403 */
404const keyOf = (base: string, modsDir: string): string => `${base}:${modsDir}`
405
406/** The one folder Mod Lab watched before 0.2.9 (a set `modsDir`, else the plugin's own): its records were unkeyed. */
407const legacyDirOf = ($: Engine): string => Lab.normalizePath(configuredDir || pluginModsDirOf($))
408
409/** One folder's records under `base`; the folder the unkeyed set described reads that until it has its own. */
410async function storedOf($: Engine, base: string, modsDir: string): Promise<unknown> {
411  const own = await $.store.get(keyOf(base, modsDir))
412  if (own !== undefined || modsDir !== legacyDirOf($)) return own
413  return $.store.get(base)
414}
415
416const resultsOf = async ($: Engine, modsDir: string): Promise<StoredResults> =>
417  ((await storedOf($, RESULTS_KEY, modsDir)) ?? {}) as StoredResults
418
419/**
420 * The version and the logic and test files an edit starts from. `logicSig` is absent in records from before 0.2.21,
421 * and null when a folder under hooks/ or tests/ couldn't be listed then; either way the edit keeps the mtime rule.
422 */
423type Baseline = Omit<Lab.Edit, 'since'>
424/**
425 * When each mod's current edit started (a build generation), for mods being edited, and its baseline.
426 * `repeatedAt` is when a Spec running last repeated during the edit, and `repeatedFrom` the mod on disk
427 * then: the baseline of the next edit, should a scan that read the edit before the repeat end it.
428 */
429type Generation = Lab.Edit & { repeatedAt?: number; repeatedFrom?: Baseline }
430/** As stored: an older version wrote the bare start time. */
431type StoredGenerations = Record<string, number | Generation>
432const generationsFrom = (stored: StoredGenerations): Record<string, Generation> =>
433  Object.fromEntries(
434    Object.entries(stored).map(([mod, gen]) => [mod, typeof gen === 'number' ? { since: gen, fromVersion: null } : gen]),
435  )
436const generationsOf = async ($: Engine, modsDir: string): Promise<Record<string, Generation>> =>
437  generationsFrom(((await storedOf($, GENERATIONS_KEY, modsDir)) ?? {}) as StoredGenerations)
438/** Whether `latest` is still the generation `read` was: the same start, and no Spec running reported on it since. */
439const isSameGeneration = (latest: Generation | undefined, read: Generation | undefined): boolean =>
440  latest?.since === read?.since && latest?.repeatedAt === read?.repeatedAt
441
442/**
443 * Sets one folder's records under `base` to `change` of their current value. Updates run one at
444 * a time, each reading the key in its turn, so two callers never overwrite each other's change.
445 * This orders this session's writes only: $.store is one file per plugin, and a second session's
446 * write can still race. A failed update rejects for its caller; the next one runs regardless.
447 */
448function updateStore<T>($: Engine, base: string, modsDir: string, change: (current: T) => T): Promise<void> {
449  storeChain = storeChain
450    .catch(() => undefined)
451    .then(async () => {
452      await $.store.set(keyOf(base, modsDir), change(((await storedOf($, base, modsDir)) ?? {}) as T))
453    })
454  return storeChain
455}
456
457/** Updates one folder's build generations, as `updateStore` does, from their current value. */
458function updateGenerations($: Engine, modsDir: string, change: (current: Record<string, Generation>) => Record<string, Generation>): Promise<void> {
459  return updateStore($, GENERATIONS_KEY, modsDir, (stored: StoredGenerations) => change(generationsFrom(stored)))
460}
461
462// ── Scanning ──────────────────────────────────────────────────────────────
463
464/**
465 * Reads every mod folder, judges it and redraws. Single-flight: a call during
466 * a scan queues one more pass, and every caller waits until the passes end,
467 * so what it reads afterwards reflects the disk as of its call.
468 */
469function scan($: Engine, isAnnounced: boolean): Promise<void> {
470  if (scanning) {
471    isScanQueued = true
472    isQueuedAnnounced ||= isAnnounced
473    return scanning
474  }
475  scanning = scanPasses($, isAnnounced).finally(() => {
476    scanning = null
477  })
478  return scanning
479}
480
481async function scanPasses($: Engine, isAnnounced: boolean): Promise<void> {
482  let announce = isAnnounced
483  do {
484    isScanQueued = false
485    await scanOnce($, announce)
486    announce = isQueuedAnnounced
487    isQueuedAnnounced = false
488  } while (isScanQueued)
489}
490
491async function scanOnce($: Engine, isAnnounced: boolean): Promise<void> {
492  const scanStartedAt = Date.now()
493  const dir = await currentModsDir($)
494  const folders = (await list($, dir)).filter(entry => entry.kind === 'dir' && !entry.name.startsWith('.'))
495  const marketplace = await marketplaceOf($, dir)
496  const results = await resultsOf($, dir)
497  const generations = await generationsOf($, dir)
498  const cwd = await cwdOf($)
499  const inFlight = await liveRunsOf($)
500  const reports = Lab.liveReports(await read($, reported), await $.clock.now())
501  const found = new Map<string, Lab.FileFacts>()
502  for (const folder of folders) {
503    const facts = await modFactsOf($, dir, folder.name, marketplace)
504    if (facts) found.set(facts.name, facts)
505  }
506  factsByMod = found
507  // "Ready to share" marks are kept per folder: a same-named mod elsewhere didn't just turn green.
508  const freshIdOf = (name: string): string => `${dir}/${name}`
509  // A build that reported a mod before writing its folder gets a row of its own until the folder is found (#19).
510  const builds = Lab.liveReports((await read($, building))[dir] ?? {}, await $.clock.now())
511  const placeholders = Lab.placeholderNames(builds, [...found.keys()], dir)
512  const judge = (freshIds: readonly string[]): ModView[] => [
513    ...[...factsByMod.values()].map(facts =>
514      Lab.viewOf(
515        facts,
516        results[facts.name] ?? {},
517        inFlight.filter(one => one.mod === facts.name),
518        Lab.reportIn(reports[facts.name], dir),
519        freshIds.includes(freshIdOf(facts.name)),
520        generations[facts.name] ?? null,
521        Lab.displayPathOf(`${dir}/${facts.name}`, cwd),
522      ),
523    ),
524    ...placeholders.map(name =>
525      Lab.viewOf(
526        Lab.placeholderFacts(name),
527        {},
528        inFlight.filter(one => one.mod === name),
529        builds[name] ?? null,
530        false,
531        null,
532        Lab.displayPathOf(`${dir}/${name}`, cwd),
533      ),
534    ),
535  ]
536  const newlyGreen = await announceGreen($, judge([]), isAnnounced, dir)
537  const marks = await read($, fresh)
538  const kept = Lab.freshIdsOf(marks, legacyDirOf($))
539  const freshIds = [...new Set([...kept, ...newlyGreen.map(freshIdOf)])]
540  // Written when a mod just turned green, or when bare marks from before 0.2.9 moved to their folder.
541  if (newlyGreen.length || kept.some((id, at) => id !== marks[at])) await update($, fresh, () => freshIds)
542  const next = judge(freshIds)
543  // An edit's generation ends when every stage is green again.
544  const finished = next.filter(view => Lab.isGreen(view.lights) && generations[view.name] !== undefined)
545  if (finished.length) {
546    // Drop only the generations this scan judged: one begun since stays. A Spec running reported on one
547    // since was read after this scan's reports, so the edit was finished when it came: it begins the next.
548    await updateGenerations($, dir, remaining => {
549      for (const view of finished) {
550        const [latest, read] = [remaining[view.name], generations[view.name]]
551        if (isSameGeneration(latest, read)) delete remaining[view.name]
552        else if (latest?.since === read?.since && latest?.repeatedAt !== undefined) {
553          // The repeat read the mod as it began; a record from 0.2.20 or earlier has only this scan's facts.
554          const facts = found.get(view.name)
555          remaining[view.name] = { since: latest.repeatedAt, ...(latest.repeatedFrom ?? { fromVersion: facts?.version ?? null, logicSig: facts?.logicSig }) }
556        }
557      }
558      return remaining
559    })
560  }
561  // A README open over another folder's list doesn't belong to this one; closed as the list changes.
562  const previous = (await read($, judged)).modsDir
563  if (previous !== null && previous !== dir) await update($, readme, () => null)
564  await update($, judged, () => ({ modsDir: dir, views: next }))
565  published = { modsDir: dir, names: new Set(next.map(view => view.name)) }
566  logEvent($, 'verbose', 'scan', `${folders.length} folders, ${next.length} mods in ${Date.now() - scanStartedAt}ms`, { modsDir: dir })
567  logEvent($, 'debug', 'scan.lights', 'lights per mod', Object.fromEntries(next.map(view => [view.name, view.lights.join(' ')])))
568  await update($, selected, current =>
569    current && (next.some(view => view.name === current) || current === Lab.DEMO_NAME) ? current : (next[0]?.name ?? null),
570  )
571  await refreshStatus($)
572}
573
574/**
575 * The first time a mod turns all green at its current code: a toast and a
576 * notification (when announced). Returns the mods that just turned green.
577 */
578async function announceGreen($: Engine, judged: readonly ModView[], isAnnounced: boolean, modsDir: string): Promise<string[]> {
579  const doneKey = keyOf(DONE_KEY, modsDir)
580  const done = ((await storedOf($, DONE_KEY, modsDir)) ?? {}) as Record<string, number | string>
581  const newly: string[] = []
582  let isChanged = false
583  for (const view of judged) {
584    const stamp = factsByMod.get(view.name)?.codeSig ?? ''
585    if (!Lab.isGreen(view.lights) || done[view.name] === stamp) continue
586    done[view.name] = stamp
587    isChanged = true
588    if (!isAnnounced) continue
589    newly.push(view.name)
590    $.ui.toast(`✓ ${view.name} is ready to share`)
591    void $.ui.notify(`${view.name} is ready to share`, { title: 'Mod Lab' }).catch((error: unknown) => logCaught($, 'announceGreen.notify', error))
592  }
593  if (isChanged) {
594    await $.store.set(doneKey, done)
595    logEvent($, 'debug', 'store.set', doneKey)
596  }
597  return newly
598}
599
600/** Every mod drawn now: the demo first while it runs. */
601async function shownViews($: Engine): Promise<ModView[]> {
602  const started = await read($, demoStartedAt)
603  const all = (await read($, judged)).views
604  if (started === null) return all
605  const { now } = await read($, frame)
606  return [Lab.demoViewAt(Math.max(0, now - started)), ...all]
607}
608
609async function refreshStatus($: Engine): Promise<void> {
610  $.ui.status(Lab.badgeOf(Lab.countsOf(await shownViews($))))
611}
612
613// ── The clock: runs only while something is live ─────────────────────────
614
615/**
616 * session.start fires once per fresh load of this module (claude-code.d.ts), and the runs atom
617 * outlives the module that wrote it. So at that moment no row has a live run: clear them all.
618 */
619async function clearLeftRuns($: Engine): Promise<void> {
620  if ((await read($, runs)).length) await update($, runs, () => [])
621}
622
623function startClock($: Engine): void {
624  if (clock) return
625  clock = $.clock.every(FRAME_MS, () => void tick($).catch((error: unknown) => logCaught($, 'tick', error)))
626}
627
628async function tick($: Engine): Promise<void> {
629  const now = await $.clock.now()
630  const previous = lastTickMs
631  lastTickMs = now
632  await update($, frame, current => ({ n: current.n + 1, now }))
633
634  const started = await read($, demoStartedAt)
635  if (started !== null) {
636    for (const text of Lab.demoToastsBetween(previous - started, now - started)) $.ui.toast(text)
637    const walk = Lab.demoWalkMs()
638    if (previous - started < walk && now - started >= walk) {
639      $.ui.toast(`✓ ${Lab.DEMO_NAME} (demo) is ready to share. That was a whole mod build`)
640      void $.ui.notify(`${Lab.DEMO_NAME} (demo) is ready to share`, { title: 'Mod Lab' }).catch((error: unknown) => logCaught($, 'tick.notify', error))
641    }
642    if (now - started >= Lab.demoLengthMs()) await stopDemo($)
643  }
644  await refreshStatus($)
645
646  // An orphan that aged out is dropped and the mods rescanned, so its stage stops reading running.
647  const all = await read($, runs)
648  const live = Lab.liveRuns(all, now, runLimit)
649  if (live.length < all.length) {
650    await update($, runs, rows => Lab.liveRuns(rows, now, runLimit))
651    void scan($, true).catch((error: unknown) => logCaught($, 'tick.scan', error))
652  }
653
654  // A placeholder whose running report expired has no folder for a later scan to correct, so rescan now (#19).
655  const allBuilds = await read($, building)
656  const shown = await read($, judged)
657  const builds = Lab.liveReports(allBuilds[shown.modsDir ?? ''] ?? {}, now)
658  if (shown.views.some(view => view.isPlaceholder && builds[view.name]?.status !== 'running')) {
659    void scan($, true).catch((error: unknown) => logCaught($, 'tick.scan', error))
660  }
661
662  // runs is read again here: a run may have started during the awaits above.
663  const isLive =
664    (await liveRunsOf($)).length > 0 ||
665    (await read($, demoStartedAt)) !== null ||
666    (await hasRunningReport($, now))
667  if (!isLive) {
668    clock?.cancel()
669    clock = null
670    // A run or report that started after those reads found the clock still set and didn't start one: start it again.
671    if ((await liveRunsOf($)).length || (await hasRunningReport($, now))) startClock($)
672  }
673}
674
675// ── Runs ──────────────────────────────────────────────────────────────────
676
677/** Records streamed test progress on the run in flight, which redraws the row and band. */
678async function noteProgress($: Engine, entry: Run, counts: { passed: number; failed: number }): Promise<void> {
679  await update($, runs, all => Lab.withProgress(all, entry, counts))
680}
681
682/** A check ended at its time limit; the message is its summary. */
683class RunTimeout extends Error {}
684
685/** The error a check ends with at its time limit: "timed out after 3 minutes". */
686const timeoutOf = (): RunTimeout => new RunTimeout(Lab.timeoutText(runLimit))
687
688/** The runs in flight: a row whose run a reload ended counts nowhere once it is past the time limit and the grace. */
689/** Whether any build still has a live running report, read now rather than from an earlier copy. */
690async function hasRunningReport($: Engine, now: number): Promise<boolean> {
691  const reports = [Lab.liveReports(await read($, reported), now), ...Object.values(await read($, building)).map(builds => Lab.liveReports(builds, now))]
692  return reports.some(all => Object.values(all).some(report => report.status === 'running'))
693}
694
695async function liveRunsOf($: Engine): Promise<Run[]> {
696  return Lab.liveRuns(await read($, runs), await $.clock.now(), runLimit)
697}
698
699/** Runs a check to the end. `$.process.run` kills one still going at the time limit and rejects: that is a RunTimeout. */
700async function runToEnd($: Engine, argv: readonly string[], dir: string): Promise<{ exitCode: number; output: string }> {
701  const startedAt = await $.clock.now()
702  const out = await $.process.run(argv, { cwd: dir, timeoutMs: runLimit }).catch(async (error: unknown) => {
703    // It also rejects when the command cannot start, which happens at once.
704    throw Lab.isOverLimit((await $.clock.now()) - startedAt, runLimit) ? timeoutOf() : error
705  })
706  const output = `${out.stdout}\n${out.stderr}`
707  logProcess($, argv, out.exitCode, (await $.clock.now()) - startedAt, output)
708  return { exitCode: out.exitCode, output }
709}
710
711/** A finished process: argv, exit code and duration at `verbose`; its output's tail at `debug`. */
712function logProcess($: Engine, argv: readonly string[], exitCode: number, durationMs: number, output: string): void {
713  logEvent($, 'verbose', 'process', `exit ${exitCode} after ${durationMs}ms`, { argv })
714  logEvent($, 'debug', 'process.output', argv.slice(0, 3).join(' '), { tail: Lab.outputTail(output) })
715}
716
717/**
718 * Runs validate or tsc to the end; streams test so the band fills on real
719 * progress. A stream that cannot start (it rejects its first pull) falls back
720 * to running the tests to the end. A stream still going at the time limit is
721 * stopped, which kills the child, and the run ends as a RunTimeout.
722 */
723async function execute($: Engine, entry: Run, dir: string): Promise<{ exitCode: number; output: string }> {
724  const argv = Lab.argvOf(entry.kind, dir)
725  if (entry.kind !== 'Test') return runToEnd($, argv, dir)
726  let kept = Lab.EMPTY_STREAM
727  let seen = 0
728  let hasStarted = false
729  let isTimedOut = false
730  let timer: Timer | null = null
731  const startedAt = Date.now()
732  try {
733    const stream = $.process.spawn({ argv, cwd: dir })
734    // One shot at the limit: a poll overshoots any limit that isn't a multiple of its period.
735    timer = $.clock.after(runLimit, () => {
736      isTimedOut = true
737      // A stream closed before its end rejects its result (claude-code.d.ts, HookStream); nothing reads it then.
738      stream.result.catch(() => undefined)
739      void stream.return?.(undefined as never)?.catch?.(() => undefined)
740    })
741    for await (const piece of stream) {
742      hasStarted = true
743      kept = Lab.streamPiece(kept, piece.text, piece.stream)
744      if (kept.passed + kept.failed !== seen) {
745        seen = kept.passed + kept.failed
746        await noteProgress($, entry, { passed: kept.passed, failed: kept.failed })
747      }
748    }
749    if (isTimedOut) throw timeoutOf()
750    const ended = await stream.result
751    const output = Lab.streamOutput(kept)
752    logProcess($, argv, ended.code ?? 1, Date.now() - startedAt, output)
753    return { exitCode: ended.code ?? 1, output }
754  } catch (error) {
755    if (isTimedOut) throw timeoutOf()
756    if (hasStarted) throw error
757    // Expected under `claude plugin test`, where process.spawn has no stub.
758    logCaught($, 'execute.spawn', error, 'verbose', { argv, fallback: 'process.run' })
759    return runToEnd($, argv, dir)
760  } finally {
761    timer?.cancel()
762  }
763}
764
765/** Whether a newer run took this run's row (it outlived the limit and the grace): then it must not start or save anything. */
766async function isSuperseded($: Engine, entry: Run): Promise<boolean> {
767  if (Lab.hasRun(await read($, runs), entry)) return false
768  logEvent($, 'normal', 'run', `${entry.mod} ${entry.kind} superseded`, { startedAt: entry.startedAt })
769  return true
770}
771
772/** Runs a check and judges it. One that throws fails: as timed out at its time limit, else as "could not run". */
773async function resultOfRun($: Engine, entry: Run, dir: string, stamp: number): Promise<Lab.RunResult> {
774  const { mod, kind, startedAt } = entry
775  try {
776    const { exitCode, output } = await execute($, entry, dir)
777    const endedAt = await $.clock.now()
778    return Lab.resultOf(kind, exitCode, output, stamp, endedAt - startedAt, endedAt)
779  } catch (error) {
780    const endedAt = await $.clock.now()
781    const summary = error instanceof RunTimeout ? error.message : 'could not run'
782    const result: Lab.RunResult = { kind, ok: false, stampMs: stamp, durationMs: endedAt - startedAt, atMs: endedAt, summary, errors: [String(error).slice(0, 200)] }
783    logCaught($, 'run', error, 'normal', { mod, kind })
784    return result
785  }
786}
787
788/**
789 * Runs one check of `mod` in `modsDir`, the folder its caller judged it in. Its evidence and its
790 * result stay with that folder, even if a /cd moves the scans it triggers elsewhere.
791 */
792async function run($: Engine, mod: string, kind: RunKind, modsDir: string): Promise<void> {
793  if ((await liveRunsOf($)).some(one => one.mod === mod && one.kind === kind)) return
794  const dir = `${modsDir}/${mod}`
795  const typesPath = `${dir}/.claude-plugin/types/tsconfig.json`
796  const hasTypes = kind !== 'Typecheck' || (await exists($, typesPath))
797  if (!hasTypes) {
798    $.ui.toast(`${mod}: type-check needs the engine's types. Load the mod once in a session (/reload-plugins), then press K`)
799    return
800  }
801  const startedAt = await $.clock.now()
802  const last = (await resultsOf($, modsDir))[mod]?.Test?.tests
803  const entry: Run = { mod, kind, startedAt, passed: 0, failed: 0, total: last ? last.passed + last.failed : null }
804  await update($, runs, all => [...all.filter(one => !(one.mod === mod && one.kind === kind)), entry])
805  // From here the row is this run's alone to remove: whatever throws, it goes.
806  let toast = ''
807  try {
808    startClock($)
809    await scan($, false)
810    if (await isSuperseded($, entry)) return
811
812    const facts = await modFactsOf($, modsDir, mod, await marketplaceOf($, modsDir))
813    const stamp = facts?.codeMtimeMs ?? startedAt
814    const codeSig = facts?.codeSig
815    let result = await resultOfRun($, entry, dir, stamp)
816    if (await isSuperseded($, entry)) return
817    if (codeSig !== undefined) result = { ...result, codeSig }
818    logEvent($, 'normal', 'run', `${mod} ${kind} ${result.ok ? 'passed' : 'failed'}`, { durationMs: result.durationMs, summary: result.summary })
819    // A refused write loses this result, but the run still ends and says how it went.
820    const resultsKey = keyOf(RESULTS_KEY, modsDir)
821    await updateStore($, RESULTS_KEY, modsDir, (stored: StoredResults) => ({ ...stored, [mod]: { ...stored[mod], [kind]: result } })).then(
822      () => logEvent($, 'debug', 'store.set', resultsKey, { mod, kind }),
823      (error: unknown) => logCaught($, 'store.set', error, 'normal', { key: resultsKey, mod, kind }),
824    )
825    // A newer run may have taken the row while the write waited its turn. Its later write wins the
826    // store, since the writes are chained, so this result is not the one to announce.
827    if (await isSuperseded($, entry)) return
828    toast = Lab.toastOfRun(mod, result)
829  } catch (error) {
830    // No result came of it (a scan or reading the mod threw): say so, and rescan below so the stage stops reading running.
831    logCaught($, 'run', error, 'normal', { mod, kind })
832    toast = Lab.toastOfRunError(mod, kind, error)
833  } finally {
834    await update($, runs, all => all.filter(one => !(one.mod === mod && one.kind === kind && one.startedAt === startedAt)))
835  }
836  $.ui.toast(toast)
837  await scan($, true)
838}
839
840/**
841 * After a turn: rescan (edits may have come from any tool, Bash included), then
842 * re-run what went stale. Validate first; Test only if Validate passes.
843 */
844async function rerunStale($: Engine): Promise<void> {
845  await scan($, true)
846  if (!autoRerun) return
847  // The folder these views were judged in, read with them: each run's scans may move elsewhere.
848  const verdict = await read($, judged)
849  const modsDir = verdict.modsDir ?? (await currentModsDir($))
850  const inFlight = await liveRunsOf($)
851  for (const view of verdict.views) {
852    const plan = Lab.rerunPlanOf(view.lights, inFlight.some(one => one.mod === view.name))
853    if (plan.includes('Validate')) await run($, view.name, 'Validate', modsDir)
854    const validated = (await resultsOf($, modsDir))[view.name]?.Validate
855    if (plan.includes('Test') && validated?.ok) await run($, view.name, 'Test', modsDir)
856  }
857}
858
859function runInBackground($: Engine, mod: string | null, kind: RunKind, modsDir: string): void {
860  if (!mod || mod === Lab.DEMO_NAME) return
861  void run($, mod, kind, modsDir).catch((error: unknown) => logCaught($, 'runInBackground', error, 'normal', { mod, kind }))
862}
863
864// ── The pane ──────────────────────────────────────────────────────────────
865
866const isPaneOpen = async ($: Engine): Promise<boolean> => (await $.ui.panes()).some(pane => pane.id === PANE)
867/** Open and drawn: an unasked open on a narrow terminal is open but waits undrawn, and the person's open seats it. */
868const isPaneShown = async ($: Engine): Promise<boolean> => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
869
870async function openPane($: Engine): Promise<void> {
871  const opened = await $.ui.open(PANE_ARGS)
872  if (!opened.isPlaced) $.ui.toast(NARROW_TOAST)
873}
874
875/** What a bare /modlab does with the pane; `paneAction` counts a pane waiting undrawn as not listed. */
876async function paneActionOf($: Engine): Promise<ReturnType<typeof Lab.paneAction>> {
877  return Lab.paneAction(await $.ui.panes(), PANE)
878}
879
880/**
881 * Asks for a pane that is a tab behind another to the front, with the keyboard, for a person who asked to see it (#31).
882 * The request goes out before the first await. A pane that is still a tab behind another afterwards is named in a toast.
883 */
884async function bringToFront($: Engine): Promise<void> {
885  await $.ui.open({ ...PANE_ARGS, focus: true })
886  $.clock.after(FRONT_CHECK_MS, () => void warnIfBehind($).catch((error: unknown) => logCaught($, 'modlab.front', error)))
887}
888
889async function warnIfBehind($: Engine): Promise<void> {
890  if ((await paneActionOf($)) === 'show') $.ui.toast(STILL_BEHIND_TOAST)
891}
892
893/** An open for a command that names the pane: a tab behind another comes to the front, any other pane is opened as before. */
894async function openOrFront($: Engine): Promise<void> {
895  if ((await paneActionOf($)) === 'show') void bringToFront($).catch((error: unknown) => logCaught($, 'modlab.front', error))
896  else await openPane($)
897}
898
899/**
900 * Opens the pane from the hook of the person's command, before it returns: an open after that is "unasked", and the
901 * engine leaves it undrawn below 144 columns (#90).
902 */
903async function showPane($: Engine): Promise<void> {
904  await clearNarrowWarning($)
905  await openPane($)
906}
907
908/**
909 * Opens the pane for a typed build or edit skill, unless it is shown; a failure is logged and never stops the skill.
910 * It starts no scan: the skill's first report scans, and one that joined a scan started here would wait out an extra
911 * pass against its 10 s budget (PR #93 review, Codex).
912 */
913async function openForSkill($: Engine, command: string): Promise<void> {
914  try {
915    if (!(await isPaneShown($))) await showPane($)
916  } catch (error) {
917    logCaught($, 'command.skill', error, 'normal', { command })
918  }
919}
920
921/** Selects `name` in the pane, out of any README. */
922async function selectMod($: Engine, name: string): Promise<void> {
923  await update($, selected, () => name)
924  await update($, readme, () => null)
925}
926
927async function startDemo($: Engine): Promise<void> {
928  const now = await $.clock.now()
929  lastTickMs = now
930  await update($, frame, current => ({ ...current, now }))
931  await update($, demoStartedAt, () => now)
932  await update($, selected, () => Lab.DEMO_NAME)
933  await update($, readme, () => null)
934  startClock($)
935}
936
937async function stopDemo($: Engine): Promise<void> {
938  await update($, demoStartedAt, () => null)
939  await update($, selected, current => (current === Lab.DEMO_NAME ? null : current))
940  await scan($, false)
941}
942
943/** D starts the demo; D again while it runs stops it. */
944async function toggleDemo($: Engine): Promise<void> {
945  if ((await read($, demoStartedAt)) === null) await startDemo($)
946  else await stopDemo($)
947}
948
949async function showReadme($: Engine, mod: string | null, modsDir: string): Promise<void> {
950  if (!mod || mod === Lab.DEMO_NAME) return
951  const text = await readText($, `${modsDir}/${mod}/README.md`)
952  if (text === null) {
953    $.ui.toast(`${mod} has no README yet`)
954    return
955  }
956  await update($, readme, () => ({ name: mod, text, isStale: factsByMod.get(mod)?.isReadmeStale ?? false }))
957}
958
959async function moveSelection($: Engine, by: number): Promise<void> {
960  const names = (await shownViews($)).map(view => view.name)
961  if (!names.length) return
962  const current = names.indexOf((await read($, selected)) ?? '')
963  const nextAt = (Math.max(0, current) + by + names.length) % names.length
964  await update($, selected, () => names[nextAt] ?? null)
965  if (await read($, readme)) await showReadme($, names[nextAt] ?? null, await shownModsDir($))
966}
967
968async function fillFix($: Engine, view: ModView, modsDir: string): Promise<void> {
969  const failed = (await resultsOf($, modsDir))[view.name]?.Test?.tests?.failed ?? 1
970  const path = Lab.displayPathOf(`${modsDir}/${view.name}`, await cwdOf($))
971  await $.prompt.fill({ text: Lab.fixPromptOf(view.name, view.failedStage ?? 'Test', view.errors, failed, path) })
972}
973
974async function warnNarrowOnce($: Engine): Promise<void> {
975  if (await read($, hasWarnedNarrow)) return
976  await update($, hasWarnedNarrow, () => true)
977  $.ui.toast(NARROW_TOAST)
978}
979
980async function clearNarrowWarning($: Engine): Promise<void> {
981  if (await read($, hasWarnedNarrow)) await update($, hasWarnedNarrow, () => false)
982}
983
984// ── Hooks ─────────────────────────────────────────────────────────────────
985
986// ── /mod-builder config ───────────────────────────────────────────────────
987
988/** `/mod-builder config [list]` lists the options; `/mod-builder config <option> <value>` changes one. */
989async function configure($: Engine, words: string[]): Promise<string> {
990  const command = Lab.parseConfigCommand(words)
991  if (command.kind === 'error') return command.text
992  if (command.kind === 'set') {
993    const why = await setOption($, command.name, command.value)
994    if (why) return `Could not change ${command.name}: ${why}`
995  }
996  // What `default` means is only needed while modsDir is empty, or when it is being set.
997  const needsDefault = !configuredDir || (command.kind === 'set' && command.name === 'modsDir')
998  const defaultDir = needsDefault ? await defaultModsDir($) : ''
999  // A build's kept folder is what an empty modsDir watches now (#92).
1000  const kept = !configuredDir && pin ? Lab.keptPinOf(pin, await rootOf($)) : null
1001  const values: Lab.OptionValues = {
1002    logging: logLevel,
1003    autoRerun,
1004    autoOpen,
1005    modsDir: configuredDir,
1006    modsDirShown: Lab.shortPath(configuredDir ? Lab.normalizePath(configuredDir) : defaultDir),
1007    testTimeoutMinutes: Lab.limitMinutesOf(runLimit),
1008    ...(kept ? { pinnedShown: Lab.shortPath(kept.modsDir) } : {}),
1009  }
1010  // The set reloads the plugin rather than changing this module's values, so show the new one.
1011  if (command.kind === 'set') Object.assign(values, Lab.valuesAfterSet(command.name, command.value, defaultDir))
1012  const shown = (value: string | boolean | number): string =>
1013    typeof value === 'boolean' ? (value ? 'on' : 'off') : typeof value === 'number' ? String(value) : value || 'default'
1014  const head = command.kind === 'set' ? `${command.name} is now ${shown(command.value)}.\n` : ''
1015  return head + Lab.configListText(values, logFile)
1016}
1017
1018/**
1019 * Writes one option through this plugin's own `/config` row: '' once written, else why not.
1020 * Changing a plugin's options reloads it (types), which empties this module's log buffer, so
1021 * the line about the change is written to the file before the set.
1022 */
1023async function setOption($: Engine, name: Lab.OptionName, value: string | boolean | number): Promise<string> {
1024  // A plugin option's row is keyed `<plugin>.<field>`; its owner names the install, e.g.
1025  // `mod-builder@claudemod` or `mod-builder@inline` under --plugin-dir (seen live, 2026-10-13).
1026  const key = `${$.plugin.name}.${name}`
1027  const row = (await $.config.list()).find(r => r.key === key && Lab.isOwnRow((r.provider as { plugin?: string } | undefined)?.plugin, $.plugin.name))
1028  if (!row) return `no /config row ${key} in this session (headless sessions list no plugin options). Change it in /config, or /plugin → mod-builder → options.`
1029  logEvent($, 'normal', 'config', `${name} changing`, { value })
1030  await flushLog($)
1031  const result = await $.config.set({ key: row.key, value })
1032  // A refusal doesn't reload the plugin, so this module can still record it.
1033  if (result.deny) logEvent($, 'normal', 'config', `${name} change refused`, { value, reason: result.deny })
1034  return result.deny ?? ''
1035}
1036
1037/** The session's first line: plugin version, mods folder and level. */
1038async function logStart($: Engine): Promise<void> {
1039  const manifest = Lab.manifestOf(await readText($, `${$.plugin.root}/.claude-plugin/plugin.json`))
1040  logEvent($, 'normal', 'session.start', 'Mod Lab started', { version: manifest.version, modsDir: await currentModsDir($), logging: logLevel })
1041}
1042
1043export const register: Register = (on, options) => {
1044  const config = (options ?? {}) as Record<string, unknown>
1045  configuredDir = String(config.modsDir ?? '').trim()
1046  autoOpen = config.autoOpen === true
1047  autoRerun = config.autoRerun !== false
1048  runLimit = Lab.runLimitMsOf(config.testTimeoutMinutes)
1049  logLevel = Lab.logLevelOf(config.logging)
1050
1051  on('session.start', async ($, e, next) => {
1052    await $.command
1053      .register({
1054        name: 'modlab',
1055        description: 'Mod Lab: every mod you are building, stage by stage',
1056        argumentHint: '[demo | new | <mod>]',
1057      })
1058      .catch((error: unknown) => logCaught($, 'session.start.command', error, 'normal', { command: 'modlab' }))
1059    await $.command
1060      .register({ name: 'mod-builder', description: "mod-builder's options, including logging", argumentHint: 'config [list | <option> <value>]' })
1061      .catch((error: unknown) => logCaught($, 'session.start.command', error, 'normal', { command: 'mod-builder' }))
1062    await $.tool
1063      .register({
1064        name: TOOL,
1065        description:
1066          'Report mod-builder progress to Mod Lab. Call with status "running" when you start a stage of building a mod, and "passed" or "failed" when it ends. Stages: Spec, Scaffold, Logic, Register, Validate, Test, README, Share. Pass path, the mod folder\'s absolute path, so Mod Lab watches the folder you are working in. Mod Lab re-runs Validate and Test itself; the disk decides.',
1067        inputSchema: Lab.REPORT_SCHEMA,
1068      })
1069      .catch((error: unknown) => logCaught($, 'session.start.tool', error))
1070    // A pin from before a reload (#92), before the first scan reads the folder.
1071    pin = Lab.pinOf(await read($, pinned).catch((error: unknown) => (logCaught($, 'session.start.pin', error), null)))
1072    await clearLeftRuns($).catch((error: unknown) => logCaught($, 'session.start.runs', error))
1073    void logStart($).catch((error: unknown) => logCaught($, 'session.start.log', error))
1074    void scan($, false).catch((error: unknown) => logCaught($, 'session.start.scan', error))
1075    // A running report kept from before a reload has no clock in this module: start one, so it can expire (#19).
1076    if (await hasRunningReport($, await $.clock.now()).catch(() => false)) startClock($)
1077    return next(e)
1078  })
1079
1080  on('command.run', { command: 'mod-builder' }, async ($, e) => {
1081    logEvent($, 'verbose', 'command', `/mod-builder ${e.args}`.trim())
1082    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
1083    if (sub.toLowerCase() !== 'config') return { text: `${Lab.CONFIG_USAGE}` }
1084    return { text: await configure($, rest) }
1085  }).catch(($, e, next) => {
1086    logEvent($, 'normal', 'command.mod-builder', 'hook failed', { error: next.error })
1087    return { text: 'mod-builder: the config command failed; see the log or claude --debug.' }
1088  })
1089
1090  on('command.run', { command: 'modlab' }, async ($, e) => {
1091    try {
1092      logEvent($, 'verbose', 'command', `/modlab ${e.args}`.trim())
1093      const [sub = '', ...rest] = e.args.trim().split(/\s+/)
1094      const word = sub.toLowerCase()
1095      // Each branch opens the pane before this hook returns: an open after it is "unasked", left undrawn below 144 columns (#90).
1096      if (word === 'demo') {
1097        await startDemo($)
1098        await openOrFront($)
1099        return { text: `Mod Lab: watch ${Lab.DEMO_NAME} build itself in the pane. Nothing is written to disk.` }
1100      }
1101      if (word === 'new') {
1102        void $.prompt.fill({ text: `/mod-builder:build ${rest.join(' ')}`.trimEnd() + ' ' }).catch((error: unknown) => logCaught($, 'modlab.new', error))
1103        return { text: 'Describe your mod after /mod-builder:build in the prompt box.' }
1104      }
1105      if (word) {
1106        const name = sub
1107        // A mod already listed is selected before the pane opens, so its first frame shows it (PR #93 review).
1108        if ((await read($, judged)).views.some(view => view.name === name)) await selectMod($, name)
1109        // The scan starts before the open, so a refused open still selects the mod.
1110        void (async () => {
1111          await scan($, false)
1112          if ((await read($, judged)).views.some(view => view.name === name)) {
1113            await selectMod($, name)
1114          } else {
1115            $.ui.toast(`No mod named ${name} in ${Lab.shortPath(await shownModsDir($))}`)
1116          }
1117        })().catch((error: unknown) => logCaught($, 'modlab.select', error, 'normal', { mod: name }))
1118        await openOrFront($)
1119        return { text: `Mod Lab: ${name}.` }
1120      }
1121      const action = await paneActionOf($)
1122      if (action === 'close') {
1123        void $.ui.close({ id: PANE }).catch((error: unknown) => logCaught($, 'modlab.toggle', error))
1124        return { text: 'Mod Lab folded to the status line. /modlab to reopen.' }
1125      }
1126      if (action === 'show') {
1127        void bringToFront($).catch((error: unknown) => logCaught($, 'modlab.front', error))
1128        return { text: SHOW_TEXT }
1129      }
1130      await showPane($)
1131      void scan($, false).catch((error: unknown) => logCaught($, 'modlab.scan', error))
1132      return { text: 'Mod Lab opened.' }
1133    } catch (error) {
1134      logCaught($, 'modlab', error, 'normal', { args: e.args })
1135      return { text: 'Mod Lab: something went wrong; see the log (/mod-builder config) or claude --debug.' }
1136    }
1137  })
1138
1139  // A typed /mod-builder:build or /mod-builder:edit opens Mod Lab too, from this hook, so it is drawn at any width (#90).
1140  // Both fail open: the skill runs whatever happens to the pane.
1141  on('command.run', { command: 'mod-builder:build' }, async ($, e, next) => {
1142    await openForSkill($, e.command)
1143    return next(e)
1144  }).catch(($, e, next) => {
1145    logEvent($, 'normal', 'command.skill', 'hook failed', { command: e.command, error: next.error })
1146    return next(e)
1147  })
1148
1149  on('command.run', { command: 'mod-builder:edit' }, async ($, e, next) => {
1150    await openForSkill($, e.command)
1151    return next(e)
1152  }).catch(($, e, next) => {
1153    logEvent($, 'normal', 'command.skill', 'hook failed', { command: e.command, error: next.error })
1154    return next(e)
1155  })
1156
1157  on('tool.call', { tool: 'mcp__mod-builder__lab_report' }, async ($, e) => {
1158    const now = await $.clock.now()
1159    const checked = Lab.reportOf(e as unknown as Record<string, unknown>, now)
1160    if ('error' in checked) return { result: `Mod Lab: ${checked.error}` }
1161    const { mod, report } = checked
1162    logEvent($, 'verbose', 'lab_report', `${mod} ${report.stage} ${report.status}`, report.note ? { note: report.note } : undefined)
1163    // A report with `path` pins its folder for the session (#92), before anything reads the folder. It is
1164    // handled in its own folder: another report may move the pin while this one awaits the write. A set
1165    // `modsDir` wins over `path`, and only a marketplace's mods folder under the project root is pinned,
1166    // since Validate and Test run there (PR #95 review). Any other path is ignored and the reply says why.
1167    const pinning = checked.dir === undefined ? null : await pinFor($, checked.dir)
1168    const pinDir = pinning && 'pin' in pinning ? pinning.pin.modsDir : undefined
1169    const why = checked.pathIgnored ?? (pinning && 'reason' in pinning ? pinning.reason : undefined)
1170    const ignored = why === undefined ? '' : ` path ignored: ${why}.`
1171    if (pinning && 'pin' in pinning) await setPin($, pinning.pin)
1172    const modsDir = pinDir ?? (await currentModsDir($))
1173    // A Spec report on a mod the last written rows of this folder don't list waits for a scan before it reads
1174    // any state: the first `get` fixes the moment every read of this dispatch sees (#10, #79). A listed mod's rows are enough.
1175    if (report.stage === 'Spec' && !(published?.modsDir === modsDir && published.names.has(mod))) await scan($, false)
1176    // The model reports on the folder the session is in now; judge it first if a /cd moved it.
1177    if ((await read($, judged)).modsDir !== modsDir) await scan($, false)
1178    // A Spec report on a mod built before starts an edit: older evidence goes stale.
1179    const known = (await read($, judged)).views.find(view => view.name === mod)
1180    // A mod with no folder yet is being built, not edited.
1181    if (Lab.startsGeneration(report, known && !known.isPlaceholder ? known.lights : null)) {
1182      // The mod on disk as the edit begins, not as the last scan saw it: a bump or a saved file may land in between.
1183      // The skill reports the edit before it changes any file. A folder or manifest that can't be read falls back to the last scan.
1184      const onDisk = await modFactsOf($, modsDir, mod, await marketplaceOf($, modsDir))
1185      const last = factsByMod.get(mod)
1186      const baseline: Baseline = { fromVersion: onDisk?.version ?? last?.version ?? null, logicSig: (onDisk ?? last)?.logicSig }
1187      // A generation ends only when the mod is all green, so a repeat during the edit keeps its start and baseline.
1188      // The stamp tells a scan that judged the edit finished before this report that the next edit began here, from `repeatedFrom`.
1189      // It always moves on, even within one millisecond of the last, or that scan would read it as unchanged.
1190      let isStarted = false
1191      await updateGenerations($, modsDir, all => {
1192        const current = all[mod]
1193        isStarted = current === undefined
1194        return { ...all, [mod]: current ? { ...current, repeatedAt: Math.max(now, (current.repeatedAt ?? 0) + 1), repeatedFrom: baseline } : { since: now, ...baseline } }
1195      })
1196      if (isStarted) logEvent($, 'debug', 'store.set', keyOf(GENERATIONS_KEY, modsDir), { mod, edit: 'started' })
1197    }
1198    await update($, reported, all => ({ ...all, [mod]: { ...report, dir: modsDir } }))
1199    await update($, building, all => Lab.withBuilding(all, modsDir, mod, { ...report, dir: modsDir }, now))
1200    if (report.status === 'running') {
hooks/mod-builder-logic.ts 1772 lines
1/**
2 * Mod Lab's deterministic core: every rule that needs no `$`.
3 *
4 * register.tsx gathers facts from disk and process exit codes, hands them here,
5 * and draws what comes back. Nothing in this file reads the clock, the disk or
6 * the engine, so every rule is unit-tested in tests/mod-builder-logic.test.ts.
7 */
8
9import type { Gate, Light, ModView, Pin, Reach, Reported, Run, RunKind, Stage, Tone } from '../types'
10
11export type { Gate, Light, ModView, Pin, Reach, Reported, Run, RunKind, Stage, Tone }
12
13export const STAGES = [
14  'Spec',
15  'Scaffold',
16  'Logic',
17  'Register',
18  'Validate',
19  'Test',
20  'README',
21  'Share',
22] as const satisfies readonly Stage[]
23
24/** What register.tsx reads off disk for one mod folder. */
25export type FileFacts = {
26  name: string
27  hasSpec: boolean
28  /** SPEC.md still holds an unfilled `<placeholder>` outside code. */
29  specHasPlaceholder: boolean
30  hasManifest: boolean
31  hasHooksJson: boolean
32  /** hooks/ holds a module other than register.ts(x). */
33  hasLogic: boolean
34  /** tests/ holds at least one *.test.ts(x). */
35  hasTests: boolean
36  hasRegister: boolean
37  hasReadme: boolean
38  /** README.md has a "What it reaches" heading. */
39  readmeHasReaches: boolean
40  /** The marketplace beside the mods folder lists this mod. */
41  isListed: boolean
42  /**
43   * Newest mtime of what validate and test read: the manifest, hooks/, tests/, types/, agents/,
44   * commands/ and skills/ with their subfolders, and the root .mcp.json.
45   */
46  codeMtimeMs: number
47  /**
48   * A signature of every code file's path and mtime (the files `codeMtimeMs` covers, subfolders
49   * up to 3 deep): changes on an edit, an add, a delete, a rename or a move between folders.
50   */
51  codeSig: string
52  /** Oldest and newest mtime of anything in the folder, for the build span. */
53  firstMtimeMs: number
54  lastMtimeMs: number
55  /** From SPEC.md's slot table, when present. */
56  archetype: string | null
57  builtOn: string | null
58  /** From plugin.json, when present. */
59  version: string | null
60  description: string | null
61  /** README.md is older than the newest code. */
62  isReadmeStale: boolean
63  /** When SPEC.md, the newest logic or test file (subfolders included), and README.md were last saved (0 when absent). */
64  specMtimeMs: number
65  logicMtimeMs: number
66  readmeMtimeMs: number
67  /**
68   * The logic modules and test files (subfolders included) by path and mtime: a delete or a rename changes it,
69   * which no mtime shows. Null when a folder under hooks/ or tests/ couldn't be listed: what it holds is unknown.
70   */
71  logicSig: string | null
72  /** The version the marketplace lists this mod at, when it does. */
73  listedVersion: string | null
74}
75
76export type ValidateReport = {
77  ok: boolean
78  hooks: string[]
79  calls: string[]
80  gates: Gate[]
81  errors: string[]
82  warnings: string[]
83}
84
85export type TestReport = {
86  ok: boolean
87  passed: number
88  failed: number
89  durationMs: number | null
90  failures: string[]
91}
92
93/** One finished run, kept in `$.store` per mod. */
94export type RunResult = {
95  kind: RunKind
96  ok: boolean
97  /** The mod's codeMtimeMs when the run started; newer code makes it stale. */
98  stampMs: number
99  /** The mod's codeSig when the run started; any difference makes it stale (older results lack it). */
100  codeSig?: string
101  /** One line for the row and the toast. */
102  summary: string
103  /** The first few error lines, for the detail panel and Fix with Claude. */
104  errors: string[]
105  /** How long the run took. */
106  durationMs?: number
107  /** When the run finished; judged against an edit's generation. */
108  atMs?: number
109  validate?: ValidateReport
110  /** Test counts, for "12 tests · clean" and the done line. */
111  tests?: { passed: number; failed: number }
112}
113
114export type Results = Partial<Record<RunKind, RunResult>>
115
116// ── Stages ────────────────────────────────────────────────────────────────
117
118/**
119 * An edit under way (a build generation): when it began, and the version and the logic and test files
120 * (`logicSig`) it began from. A `logicSig` that is absent or null leaves Logic to the mtime rule.
121 */
122export type Edit = { since: number; fromVersion: string | null; logicSig?: string | null }
123
124/**
125 * The eight lights of one mod, from the disk, the stored runs and what runs
126 * now. The disk and exit codes decide passed or failed; a report from the
127 * model only ever adds `running` (SPEC non-negotiable 2).
128 */
129export function lightsOf(
130  facts: FileFacts,
131  results: Results,
132  running: readonly RunKind[],
133  reported: Reported | null,
134  edit: Edit | null = null,
135): Light[] {
136  // During an edit, evidence older than the edit's start is stale:
137  // it passed before, and has to be redone for this change.
138  const sinceMs = edit?.since ?? null
139  const fromVersion = edit?.fromVersion ?? null
140  const fromLogicSig = edit?.logicSig ?? null
141  const isOld = (atMs: number): boolean => sinceMs !== null && atMs < sinceMs
142  const runLight = (kind: 'Validate' | 'Test'): Light => {
143    if (running.includes(kind)) return 'running'
144    const result = results[kind]
145    if (!result) return 'pending'
146    const isChanged = result.codeSig !== undefined ? result.codeSig !== facts.codeSig : result.stampMs < facts.codeMtimeMs
147    if (isChanged || isOld(result.atMs ?? 0)) return 'stale'
148    return result.ok ? 'passed' : 'failed'
149  }
150  const fileLight = (isDone: boolean, savedMs?: number): Light =>
151    !isDone ? 'pending' : savedMs !== undefined && isOld(savedMs) ? 'stale' : 'passed'
152  // A delete or a rename saves no file, so a logic or test file changed since the edit began counts too:
153  // Logic is then judged with no mtime, which is never old.
154  // A signature that couldn't be read, then or now, shows nothing either way.
155  const isLogicChanged = fromLogicSig !== null && facts.logicSig !== null && fromLogicSig !== facts.logicSig
156  // During an edit, Share needs a new version (not the one the edit started from) that the marketplace lists.
157  const isReShared =
158    sinceMs === null ||
159    (facts.listedVersion !== null && facts.listedVersion === facts.version && (fromVersion === null || facts.version !== fromVersion))
160
161  const lights: Light[] = [
162    fileLight(facts.hasSpec && !facts.specHasPlaceholder, facts.specMtimeMs),
163    fileLight(facts.hasManifest && facts.hasHooksJson),
164    fileLight(facts.hasLogic && facts.hasTests, isLogicChanged ? undefined : facts.logicMtimeMs),
165    fileLight(facts.hasRegister),
166    runLight('Validate'),
167    runLight('Test'),
168    fileLight(facts.hasReadme && facts.readmeHasReaches, facts.readmeMtimeMs),
169    !facts.isListed ? 'pending' : isReShared ? 'passed' : 'stale',
170  ]
171
172  // What the skill says it is doing now always shows, even on a stage that
173  // passed before. A report never passes a stage: the disk does.
174  if (reported?.status === 'running') {
175    const at = STAGES.indexOf(reported.stage)
176    if (lights[at] !== 'failed') lights[at] = 'running'
177  }
178  return lights
179}
180
181/**
182 * Whether a `Spec running` report starts a new build generation: the mod has
183 * been built before (its Spec stage already passed), so this is an edit.
184 */
185export function startsGeneration(report: Reported, lights: readonly Light[] | null): boolean {
186  return report.stage === 'Spec' && report.status === 'running' && lights !== null && lights[0] !== 'pending'
187}
188
189/** The first failed stage, else the first running, else the first not passed. */
190export function focusStageOf(lights: readonly Light[]): { stage: Stage; light: Light } | null {
191  for (const want of ['failed', 'running'] as const) {
192    const at = lights.indexOf(want)
193    if (at >= 0) return { stage: STAGES[at]!, light: want }
194  }
195  const at = lights.findIndex(light => light !== 'passed')
196  return at >= 0 ? { stage: STAGES[at]!, light: lights[at]! } : null
197}
198
199/** What a run in flight is doing, in a few words: `validating`, `running tests · 7 of 12`. */
200export function runningTextOf(run: Pick<Run, 'kind' | 'passed' | 'failed' | 'total'>): string {
201  if (run.kind === 'Validate') return 'validating'
202  if (run.kind === 'Typecheck') return 'type-checking'
203  const done = run.passed + run.failed
204  if (run.total) return `running tests · ${done} of ${run.total}`
205  return done ? `running tests · ${done} so far` : 'running tests'
206}
207
208/** `12 tests · clean`: the quiet status of a green mod. */
209export function cleanTextOf(results: Results): string {
210  const tests = results.Test?.tests
211  const count = tests ? plural(tests.passed, 'test') : 'tests'
212  return `${count} · ${results.Validate?.ok ? 'clean' : 'passing'}`
213}
214
215/** The row's one-line status and its tone, in the design's words. */
216export function statusOf(
217  lights: readonly Light[],
218  results: Results,
219  reported: Reported | null,
220  runs: readonly Run[],
221  isFresh: boolean,
222): { text: string; tone: Tone } {
223  const run = runs[0]
224  if (run) return { text: runningTextOf(run), tone: 'text' }
225  const focus = focusStageOf(lights)
226  if (!focus) return isFresh ? { text: 'ready to share', tone: 'success' } : { text: cleanTextOf(results), tone: 'dim' }
227  const { stage, light } = focus
228  if (light === 'failed') {
229    if (stage === 'Test') return { text: results.Test?.summary ?? 'tests failing', tone: 'error' }
230    return { text: `validate · ${results.Validate?.summary ?? 'failed'}`, tone: 'error' }
231  }
232  if (light === 'running') {
233    const note = reported?.stage === stage && reported.note ? reported.note : `${stage.toLowerCase()} in progress`
234    return { text: note, tone: 'text' }
235  }
236  if (light === 'stale') {
237    if (stage === 'Validate' || stage === 'Test') return { text: `files changed · re-run ${stage}`, tone: 'warn' }
238    if (stage === 'Share') return { text: 'edit · bump the version and re-list it', tone: 'warn' }
239    return { text: `edit · update ${stage} for this change`, tone: 'warn' }
240  }
241  return { text: `next: ${stage}`, tone: 'dim' }
242}
243
244/**
245 * What to re-run for a mod at the end of a turn: Validate when Validate or
246 * Test has gone stale (the code changed after they passed), then Test. Nothing
247 * while a run for that mod is already in flight.
248 */
249export function rerunPlanOf(lights: readonly Light[], isRunning: boolean): RunKind[] {
250  if (isRunning) return []
251  const validate = lights[STAGES.indexOf('Validate')]
252  const test = lights[STAGES.indexOf('Test')]
253  // A Validate that failed on the current code blocks Test; re-running it changes nothing.
254  if (validate === 'stale') return test === 'pending' ? ['Validate'] : ['Validate', 'Test']
255  if ((validate === 'passed' || validate === 'pending') && test === 'stale') return ['Test']
256  return []
257}
258
259/** Every light green. */
260export function isGreen(lights: readonly Light[]): boolean {
261  return lights.length === STAGES.length && lights.every(light => light === 'passed')
262}
263
264// ── Reading files ─────────────────────────────────────────────────────────
265
266/**
267 * The start of a line that ends a paragraph: a blank line, or one that opens an
268 * ATX heading, a list item or a block quote. `$` is the end of the input (no
269 * `m` flag). A heading or quote takes at most three spaces of indent, as in
270 * CommonMark; a list item takes any, since nested items sit deeper. Any `N.`
271 * counts as an item, though CommonMark lets only `1.` interrupt a paragraph.
272 */
273const BLOCK_BREAK = String.raw`(?:[ \t]*(?:\r?\n|$|[-*+][ \t]|\d{1,9}[.)][ \t])| {0,3}(?:#{1,6}(?:[ \t]|$)|>))`
274
275/**
276 * An escaped `\` or backtick, which opens no span, or an inline code span: it
277 * may wrap over lines, but not into another block (CommonMark).
278 */
279const ESCAPE_OR_SPAN = new RegExp(String.raw`\\[\\\x60]|\x60(?:[^\x60\n]|\n(?!${BLOCK_BREAK}))*\x60`, 'g')
280
281/**
282 * Whether a SPEC still holds a `<placeholder>`: an angle-bracketed phrase
283 * outside fenced code, inline code and HTML comments.
284 */
285export function hasPlaceholder(spec: string): boolean {
286  const prose = spec
287    .replace(/<!--[\s\S]*?-->/g, '')
288    .replace(/```[\s\S]*?```/g, '')
289    .replace(ESCAPE_OR_SPAN, m => (m.startsWith('\\') ? m : ''))
290  return /<(?![/!])[^<>\n]{1,120}>/.test(prose)
291}
292
293/**
294 * The SPEC's slot table as `slot → value`: rows of `| slot | kind | value | … |`,
295 * the slot cut at its first space or parenthesis, the value with code ticks dropped.
296 */
297export function slotsOf(spec: string): Record<string, string> {
298  const slots: Record<string, string> = {}
299  for (const line of spec.split('\n')) {
300    const cells = line.split('|').map(cell => cell.trim())
301    if (cells.length < 5 || !/^(REQUIRED|HOUSE|ARCH)/.test(cells[2] ?? '')) continue
302    const slot = (cells[1] ?? '').split(/[\s(]/)[0]!.toLowerCase()
303    const value = (cells[3] ?? '').replace(/`/g, '').trim()
304    if (slot && value) slots[slot] = value
305  }
306  return slots
307}
308
309/** Whether a README has its "What it reaches" heading. */
310export function hasReachesHeading(readme: string): boolean {
311  return /^#{1,6}\s+what it reaches\b/im.test(readme)
312}
313
314/** Whether a README is finished: the heading is there and no template `<!-- … -->` comment is left. */
315export function isReadmeDone(readme: string): boolean {
316  return hasReachesHeading(readme) && !/<!--/.test(readme)
317}
318
319type MarketplaceEntry = { name?: unknown; source?: unknown; version?: unknown }
320
321/** The marketplace entry for a mod: by name, or by a `…/<name>` source. Null when unlisted or unreadable. */
322export function findEntry(marketplace: string, name: string): MarketplaceEntry | null {
323  try {
324    const parsed = JSON.parse(marketplace) as { plugins?: MarketplaceEntry[] }
325    return (
326      (parsed.plugins ?? []).find(
327        plugin =>
328          plugin.name === name ||
329          (typeof plugin.source === 'string' && plugin.source.replace(/\/+$/, '').endsWith(`/${name}`)),
330      ) ?? null
331    )
332  } catch {
333    return null
334  }
335}
336
337/** The version a marketplace.json lists the mod at, or null when unlisted or unversioned. */
338export function listedVersionOf(marketplace: string, name: string): string | null {
339  const version = findEntry(marketplace, name)?.version
340  return typeof version === 'string' ? version : null
341}
342
343/** Whether a marketplace.json lists the mod by name or by a `…/<name>` source. */
344export function isListedIn(marketplace: string, name: string): boolean {
345  return findEntry(marketplace, name) !== null
346}
347
348/** Whether a hooks/ file name is a module other than the register entry. */
349export function isLogicModule(file: string): boolean {
350  return /\.(m|c)?(t|j)sx?$/.test(file) && !/^register\./.test(file) && !/\.d\.ts$/.test(file)
351}
352
353/** Whether a tests/ file name is one `claude plugin test` collects. */
354export function isTestFile(file: string): boolean {
355  return /\.test\.tsx?$/.test(file)
356}
357
358/** Whether a file, named by its path from the mod folder, is logic under hooks/ or a test under tests/, at any depth. Freshness only: existence stays top-level. */
359export function isLogicOrTestPath(path: string): boolean {
360  const base = path.slice(path.lastIndexOf('/') + 1)
361  if (path.startsWith('hooks/')) return isLogicModule(base)
362  return path.startsWith('tests/') && isTestFile(base)
363}
364
365// ── Parsing what the commands print ───────────────────────────────────────
366
367/** Splits on commas outside braces and parentheses. */
368export function splitTop(list: string): string[] {
369  const parts: string[] = []
370  let depth = 0
371  let part = ''
372  for (const char of list) {
373    if (char === '{' || char === '(') depth += 1
374    if (char === '}' || char === ')') depth = Math.max(0, depth - 1)
375    if (char === ',' && depth === 0) {
376      if (part.trim()) parts.push(part.trim())
377      part = ''
378    } else {
379      part += char
380    }
381  }
382  if (part.trim()) parts.push(part.trim())
383  return parts
384}
385
386type ValidateSection = {
387  errors?: { path?: string; message?: string }[]
388  warnings?: { path?: string; message?: string }[]
389  notes?: string[]
390  gatingHooks?: { hook?: string; hasCatch?: boolean }[]
391}
392
393type ValidateJson = {
394  success?: boolean
395  manifest?: ValidateSection
396  contents?: ValidateSection[]
397}
398
399/** `claude plugin validate --json` output (and its exit code) → a report. */
400export function parseValidate(stdout: string, exitCode: number): ValidateReport {
401  let json: ValidateJson
402  try {
403    json = JSON.parse(stdout.slice(Math.max(0, stdout.indexOf('{')))) as ValidateJson
404  } catch {
405    return { ok: false, hooks: [], calls: [], gates: [], errors: nonBlankLines(stdout).slice(0, 3), warnings: [] }
406  }
407  const sections = [json.manifest, ...(json.contents ?? [])].filter(
408    (section): section is ValidateSection => section !== undefined,
409  )
410  const hooks: string[] = []
411  const calls: string[] = []
412  const gates: Gate[] = []
413  const errors: string[] = []
414  const warnings: string[] = []
415  for (const section of sections) {
416    for (const note of section.notes ?? []) {
417      const hooked = note.match(/\bhooks: (.*)$/)
418      if (hooked) hooks.push(...splitTop(hooked[1]!))
419      const called = note.match(/\bcalls: (.*)$/)
420      if (called && called[1] !== 'nothing on $') {
421        calls.push(...splitTop(called[1]!).map(call => call.replace(/\s*\(via .*\)$/, '')))
422      }
423    }
424    for (const gate of section.gatingHooks ?? []) {
425      if (gate.hook) gates.push({ hook: gate.hook, hasCatch: gate.hasCatch === true })
426    }
427    for (const error of section.errors ?? []) errors.push(`${error.path ?? ''}: ${error.message ?? ''}`)
428    for (const warning of section.warnings ?? []) {
429      warnings.push(`${warning.path ?? ''}: ${warning.message ?? ''}`)
430    }
431  }
432  return {
433    ok: exitCode === 0 && json.success !== false,
434    hooks: [...new Set(hooks)],
435    calls: [...new Set(calls)],
436    gates,
437    errors,
438    warnings,
439  }
440}
441
442/** `claude plugin test` output (bun's reporter, with stderr) and its exit code → a report. */
443export function parseTest(output: string, exitCode: number): TestReport {
444  const count = (word: string): number =>
445    Number(output.match(new RegExp(`^\\s*(\\d+) ${word}\\s*$`, 'm'))?.[1] ?? 0)
446  const time = output.match(/^Ran \d+ tests? across .*\[(\d+(?:\.\d+)?)(ms|s)\]\s*$/m)
447  const durationMs = time ? Math.round(Number(time[1]) * (time[2] === 's' ? 1000 : 1)) : null
448  const lines = output.split('\n')
449  const failures: string[] = []
450  lines.forEach((line, at) => {
451    if (!line.startsWith('(fail) ')) return
452    failures.push(line.slice(7).replace(/\s*\[[\d.]+m?s\]$/, ''))
453    const detail = lines
454      .slice(at + 1, at + 3)
455      .map(next => next.trim())
456      .find(Boolean)
457    if (detail && !detail.startsWith('(')) failures.push(`  ${detail}`)
458  })
459  // Exits before the runner starts (no tests, bad folder, corrupt manifest) print one stderr line and no `(fail)`.
460  if (exitCode !== 0 && !failures.length) failures.push(...nonBlankLines(output).slice(-3))
461  const failed = count('fail')
462  return {
463    ok: exitCode === 0 && failed === 0,
464    passed: count('pass'),
465    failed,
466    durationMs,
467    failures: failures.slice(0, 6),
468  }
469}
470
471/** `tsc -p` output → its error lines, each cut to `file(line,col): message`; none means clean. */
472export function parseTsc(stdout: string): string[] {
473  return stdout
474    .split('\n')
475    .filter(line => /error TS\d+/.test(line))
476    .map(line => line.replace(/^.*?([^/\\]+\.tsx?\(\d+,\d+\))/, '$1').trim())
477}
478
479
480/** A finished run's one-line summary. */
481export function summaryOfRun(
482  kind: RunKind,
483  ok: boolean,
484  detail: { validate?: ValidateReport; test?: TestReport; tscErrors?: readonly string[] },
485): string {
486  if (kind === 'Validate' && detail.validate) {
487    const v = detail.validate
488    if (!ok) return plural(Math.max(1, v.errors.length), 'problem')
489    const gates = v.gates.length ? ` · ${plural(v.gates.length, 'gate')}` : ''
490    return `clean · ${plural(v.hooks.length, 'hook')}${gates}`
491  }
492  if (kind === 'Test' && detail.test) {
493    const t = detail.test
494    if (!ok) return t.failed === 0 ? 'tests did not run' : `${plural(t.failed, 'test')} failing`
495    const time = t.durationMs === null ? '' : ` in ${(t.durationMs / 1000).toFixed(1)}s`
496    return `${plural(t.passed, 'test')} passed${time}`
497  }
498  if (kind === 'Typecheck') {
499    return ok ? 'types clean' : plural(Math.max(1, detail.tscErrors?.length ?? 0), 'type error')
500  }
501  return ok ? 'passed' : 'failed'
502}
503
504/** What a mod reaches, grouped from validate's `calls:` list. */
505export function reachesOf(calls: readonly string[]): Reach[] {
506  const of = (noun: string): string => {
507    const methods = calls.filter(call => call.startsWith(`$.${noun}.`)).map(call => call.slice(noun.length + 3))
508    return methods.length ? methods.join(', ') : 'none'
509  }
510  return [
511    { label: 'processes', value: of('process') },
512    { label: 'files', value: of('fs') },
513    { label: 'network', value: of('http') },
514    { label: 'model', value: of('model') },
515    { label: 'mcp', value: of('mcp') },
516  ]
517}
518
519// ── Text ──────────────────────────────────────────────────────────────────
520
521const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
522const nonBlankLines = (text: string): string[] => text.split('\n').map(line => line.trim()).filter(Boolean)
523
524/** `0:42`, `12:05`, `3h`, `2d`; an empty string under a second. */
525export function durationOf(ms: number): string {
526  if (!(ms >= 1000)) return ''
527  const seconds = Math.floor(ms / 1000)
528  if (seconds < 3600) return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
529  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
530  return `${Math.floor(seconds / 86400)}d`
531}
532
533/** Like durationOf, but `0:00` rather than empty: for clocks that must always show. */
534export function clockOf(ms: number): string {
535  return durationOf(ms) || '0:00'
536}
537
538export type Counts = { mods: number; green: number; building: number; failing: number; stale: number }
539
540export function countsOf(views: readonly ModView[]): Counts {
541  return {
542    mods: views.length,
543    green: views.filter(view => isGreen(view.lights)).length,
544    building: views.filter(view => view.lights.includes('running')).length,
545    failing: views.filter(view => view.lights.includes('failed')).length,
546    stale: views.filter(view => view.lights.includes('stale')).length,
547  }
548}
549
550/** The summary's parts, joined in the pane by a dim ` · `; counts of zero are left out. */
551export function summaryPartsOf(counts: Counts): string[] {
552  if (counts.mods === 0) return ['0 mods']
553  return [
554    plural(counts.mods, 'mod'),
555    counts.green ? `${counts.green} green` : '',
556    counts.building ? `${counts.building} building` : '',
557    counts.failing ? `${counts.failing} failing` : '',
558    counts.stale ? `${counts.stale} stale` : '',
559  ].filter(Boolean)
560}
561
562/** `3 mods · 2 green · 1 building`. */
563export function summaryOf(counts: Counts): string {
564  return counts.mods === 0 ? 'no mods yet' : summaryPartsOf(counts).join(' · ')
565}
566
567/** Letter-spaced text: `mod lab` → `M O D   L A B`. */
568export function spaced(text: string): string {
569  return text
570    .toUpperCase()
571    .split(' ')
572    .map(word => word.split('').join(' '))
573    .join('   ')
574}
575
576/**
577 * The masthead's state word and its tone. While building, `dot` is the `●` that
578 * pulses on its own (the letters never move).
579 */
580export function mastheadOf(counts: Counts): { dot: boolean; word: string; tone: Tone } {
581  if (counts.building) return { dot: true, word: spaced('building'), tone: 'success' }
582  if (counts.failing) return { dot: false, word: spaced(`${counts.failing} failing`), tone: 'error' }
583  if (counts.mods && counts.green === counts.mods) return { dot: false, word: spaced('all green'), tone: 'success' }
584  return { dot: false, word: spaced('standing by'), tone: 'dim' }
585}
586
587/** The building `●` toggles dimColor every 4 ticks (1 Hz at 250 ms). */
588export function isPulseDim(frame: number): boolean {
589  return Math.floor(frame / 4) % 2 === 1
590}
591
592/** The status-line badge; undefined clears it (no mods). Plain text: the host does not colour it. */
593export function badgeOf(counts: Counts): string | undefined {
594  if (counts.mods === 0) return undefined
595  const head = `◆ ${plural(counts.mods, 'mod')}`
596  if (counts.building) return `${head} · ${counts.building} building`
597  if (counts.failing) return `${head} · ${counts.failing} failing`
598  if (counts.green === counts.mods) return `${head} · all green`
599  return `${head} · ${counts.mods - counts.green} in progress`
600}
601
602/** Under 34 columns nothing is drawn (and the too-narrow toast fires once). */
603export type Layout = 'full' | 'medium' | 'compact' | 'none'
604
605export function layoutOf(bodyColumns: number): Layout {
606  if (bodyColumns >= 60) return 'full'
607  if (bodyColumns >= 40) return 'medium'
608  if (bodyColumns >= 34) return 'compact'
609  return 'none'
610}
611
612/** One drawn cell run: a glyph or word with its colour role, bold and dimColor. */
613export type Mark = { text: string; tone: Tone; bold?: boolean; dim?: boolean }
614
615/** A light's glyph at an animation frame. Running cycles ◐ · ◐ dim · ○, all accent. */
616export function lightMarkOf(light: Light, frame = 0): Mark {
617  if (light === 'passed') return { text: '●', tone: 'success' }
618  if (light === 'failed') return { text: '✕', tone: 'error', bold: true }
619  if (light === 'stale') return { text: '◌', tone: 'warn' }
620  if (light === 'pending') return { text: '○', tone: 'dim' }
621  const at = frame % 3
622  return at === 1 ? { text: '◐', tone: 'accent', dim: true } : { text: at === 2 ? '○' : '◐', tone: 'accent', bold: true }
623}
624
625/**
626 * The column header above the lights: one stage number per light, at the
627 * lights' pitch of 2 (digit + space). The number over the selected mod's
628 * failed, else running, else stale stage lights up; the rest are dim. The detail panel's
629 * track carries the same numbers beside the stage names, as the key.
630 */
631export function columnsOf(lights: readonly Light[]): Mark[] {
632  const first = (light: Light): number => lights.indexOf(light)
633  const focus = [first('failed'), first('running'), first('stale')].find(at => at >= 0) ?? -1
634  const tone: Tone = lights[focus] === 'failed' ? 'error' : lights[focus] === 'stale' ? 'warn' : 'accent'
635  return STAGES.map((_, at) =>
636    at === focus ? { text: `${at + 1} `, tone, bold: true } : { text: `${at + 1} `, tone: 'dim' },
637  )
638}
639
640/**
641 * The pipeline track: each stage name in its light's colour, then its
642 * connector. After a passed stage whose next has started: `━` success. After
643 * a running stage: `┅ ━ ┄` by frame, accent. Otherwise `┄` dim.
644 */
645export function trackOf(lights: readonly Light[], frame = 0): { num: Mark; name: Mark; joint: Mark | null }[] {
646  return STAGES.map((stage, at) => {
647    const light = lights[at] ?? 'pending'
648    const next = lights[at + 1]
649    const name: Mark =
650      light === 'passed'
651        ? { text: stage, tone: 'success' }
652        : light === 'running'
653          ? { text: stage, tone: 'accent', bold: true }
654          : light === 'failed'
655            ? { text: stage, tone: 'error', bold: true }
656            : light === 'stale'
657              ? { text: stage, tone: 'warn' }
658              : { text: stage, tone: 'dim' }
659    const num: Mark = { text: `${at + 1} `, tone: 'dim', dim: true }
660    if (at === STAGES.length - 1) return { num, name, joint: null }
661    let joint: Mark = { text: ' ┄ ', tone: 'dim', dim: true }
662    if (light === 'running') joint = { text: ` ${['┅', '━', '┄'][frame % 3]} `, tone: 'accent' }
663    else if (light === 'passed' && next !== undefined && next !== 'pending') joint = { text: ' ━ ', tone: 'success' }
664    return { num, name, joint }
665  })
666}
667
668/** A 15-cell bar filled to `fraction` (0..1), for real progress only. */
669export function barOf(fraction: number, width = 15): { filled: string; empty: string } {
670  const cells = Math.max(0, Math.min(width, Math.round(width * fraction)))
671  return { filled: '━'.repeat(cells), empty: '░'.repeat(width - cells) }
672}
673
674/**
675 * The draft `[F] Fix with Claude` puts in the prompt box: plain words, the
676 * first failure quoted from the error box. `path` is the mod's folder as the
677 * session should read it (see `displayPathOf`).
678 */
679export function fixPromptOf(name: string, stage: Stage, errors: readonly string[], failedCount: number, path: string): string {
680  // A Test with no failing test never ran: the errors are the runner's own line, not a test.
681  const didNotRun = stage === 'Test' && failedCount === 0
682  const what = didNotRun
683    ? 'the Test run'
684    : stage === 'Test'
685      ? `the ${failedCount === 1 ? 'failing test' : `${failedCount} failing tests`}`
686      : `the ${stage} problem${failedCount === 1 ? '' : 's'}`
687  const [first, ...rest] = errors
688  const quoted = [`    ✕ ${(first ?? '').trim()}`, ...rest.slice(0, 2).map(line => `      ${line.trim()}`)].join('\n')
689  // `in .. First failure` would read as the parent folder.
690  const where = path === '.' ? 'the current folder' : path
691  const note = didNotRun ? ' (the tests did not run)' : ''
692  return `Fix ${what} in ${where}${note}. First failure, from Mod Lab:\n${quoted}\nThen re-run ${stage}.`
693}
694
695/** The done box's one line: `ready to share  ·  8/8 stages · 12 tests · validate clean · 1 gate`. */
696export function doneLineOf(results: Results): string {
697  const parts = ['8/8 stages']
698  const tests = results.Test?.tests
699  if (tests) parts.push(plural(tests.passed, 'test'))
700  if (results.Validate?.ok) parts.push('validate clean')
701  const gates = results.Validate?.validate?.gates.length ?? 0
702  if (gates) parts.push(plural(gates, 'gate'))
703  return parts.join(' · ')
704}
705
706/**
707 * The view of one mod, from everything gathered. `path` is the mod's folder
708 * as shown in the detail header (see `displayPathOf`).
709 */
710export function viewOf(
711  facts: FileFacts,
712  results: Results,
713  runs: readonly Run[],
714  reported: Reported | null,
715  isFresh: boolean,
716  edit: Edit | null,
717  path: string,
718): ModView {
719  const lights = lightsOf(facts, results, runs.map(run => run.kind), reported, edit)
720  const { text, tone } = statusOf(lights, results, reported, runs, isFresh && isGreen(lights))
721  const validate = results.Validate?.validate
722  const focus = focusStageOf(lights)
723  const failedStage = focus?.light === 'failed' ? focus.stage : null
724  const failedRun = failedStage === 'Validate' || failedStage === 'Test' ? results[failedStage] : undefined
725  const last = [results.Validate, results.Test].filter((one): one is RunResult => one !== undefined)
726  const lastMs = last.reduce((longest, one) => Math.max(longest, one.durationMs ?? 0), 0)
727  const fresh = isFresh && isGreen(lights)
728  return {
729    name: facts.name,
730    lights,
731    status: text,
732    tone,
733    time: durationOf(lastMs),
734    isDemo: false,
735    isFresh: fresh,
736    version: facts.version,
737    description: facts.description,
738    path: `${path}/`,
739    isReadmeStale: facts.isReadmeStale,
740    doneLine: fresh ? doneLineOf(results) : null,
741    archetype: facts.archetype,
742    builtOn: facts.builtOn,
743    hooks: validate?.hooks ?? [],
744    reaches: validate ? reachesOf(validate.calls) : [],
745    gates: validate?.gates ?? [],
746    errors: failedRun?.errors ?? [],
747    failedStage,
748    // factsOf never returns facts without a manifest or SPEC, so these are the placeholder's (#19).
749    isPlaceholder: !facts.hasSpec && !facts.hasManifest,
750  }
751}
752
753// ── Demo ──────────────────────────────────────────────────────────────────
754
755/** One beat of the demo: from `atMs`, `stage` shows `light`, with what the row says. */
756export type Beat = { atMs: number; stage: Stage; light: Light; note?: string }
757
758/**
759 * The demo build of `aurora`: every stage runs then passes, Test fails once
760 * and Claude fixes it. Exactly 12 s, then it holds green for 3 s.
761 */
762export function demoTimeline(): Beat[] {
763  const beats: Beat[] = []
764  let at = 0
765  const step = (stage: Stage, runMs: number, light: Light, note: string): void => {
766    beats.push({ atMs: at, stage, light: 'running', note })
767    at += runMs
768    beats.push({ atMs: at, stage, light })
769  }
770  step('Spec', 1000, 'passed', 'writing SPEC.md')
771  step('Scaffold', 800, 'passed', 'scaffolding plugin.json')
772  step('Logic', 1400, 'passed', 'aurora-logic.ts + tests')
773  step('Register', 1000, 'passed', 'register.tsx')
774  step('Validate', 1300, 'passed', 'validating')
775  step('Test', 1300, 'failed', 'running tests')
776  at += 1600
777  step('Test', 1300, 'passed', 'Claude is fixing src/band.tsx')
778  step('README', 1300, 'passed', 'What it reaches')
779  step('Share', 1000, 'passed', 'marketplace entry')
780  return beats
781}
782
783export const DEMO_NAME = 'aurora'
784
785/** How long the demo holds green after its last beat, before the real state returns. */
786export const DEMO_HOLD_MS = 3000
787
788export function demoWalkMs(beats: readonly Beat[] = demoTimeline()): number {
789  return beats[beats.length - 1]?.atMs ?? 0
790}
791
792export function demoLengthMs(beats: readonly Beat[] = demoTimeline()): number {
793  return demoWalkMs(beats) + DEMO_HOLD_MS
794}
795
796/** While the demo runs, the summary row: `aurora is a demo · nothing is written to disk`, and `0:02 / 0:12`. */
797export function demoClockOf(elapsedMs: number, beats: readonly Beat[] = demoTimeline()): { now: string; total: string } {
798  const walk = demoWalkMs(beats)
799  return { now: clockOf(Math.min(elapsedMs, walk)), total: clockOf(walk) }
800}
801
802const DEMO_ERRORS = ['band steps aside when hasSurvey is true', 'expected 0 band rows, received 2', 'at test/band.test.ts:18:5']
803
804/** The line that replaces `[F] Fix with Claude` while the demo's test is failing. */
805export const DEMO_FIXING = 'Claude is fixing src/band.tsx'
806
807/** The demo mod as a view, `elapsedMs` into the demo. */
808export function demoViewAt(elapsedMs: number, beats: readonly Beat[] = demoTimeline()): ModView {
809  const lights: Light[] = STAGES.map(() => 'pending')
810  let current: Beat | undefined
811  for (const beat of beats) {
812    if (beat.atMs > elapsedMs) break
813    lights[STAGES.indexOf(beat.stage)] = beat.light
814    current = beat
815  }
816  const testFailed = lights[STAGES.indexOf('Test')] === 'failed'
817  const isDone = isGreen(lights)
818  let status: { text: string; tone: Tone }
819  if (isDone) status = { text: 'ready to share', tone: 'success' }
820  else if (current?.light === 'running') status = { text: current.note ?? 'running', tone: 'text' }
821  else if (testFailed) status = { text: '1 test failing', tone: 'error' }
822  else status = { text: `next: ${STAGES[lights.findIndex(light => light !== 'passed')] ?? 'Share'}`, tone: 'dim' }
823  const hasValidated = lights[STAGES.indexOf('Validate')] === 'passed'
824  const walk = demoWalkMs(beats)
825  return {
826    name: DEMO_NAME,
827    lights,
828    status: status.text,
829    tone: status.tone,
830    time: clockOf(Math.min(elapsedMs, walk)),
831    isDemo: true,
832    isFresh: isDone,
833    version: 'demo',
834    description: 'A pretend mod. Walks all 8 stages in 12 seconds.',
835    path: 'not written to disk',
836    isReadmeStale: false,
837    doneLine: isDone ? `${DEMO_NAME} walked all 8 stages in ${clockOf(walk)}  ·  demo, nothing saved` : null,
838    archetype: 'band + command',
839    builtOn: null,
840    hooks: hasValidated ? ['session.start', 'command.run{command=aurora}', 'ui.render{component=AbovePrompt}'] : [],
841    reaches: hasValidated ? reachesOf(['$.clock.every', '$.ui.status']) : [],
842    gates: [],
843    errors: testFailed ? DEMO_ERRORS : [],
844    failedStage: testFailed ? 'Test' : null,
845    isPlaceholder: false,
846  }
847}
848
849// ── Gathering ─────────────────────────────────────────────────────────────
850
851/** A directory entry as `$.fs.list` gives it, cut to what Mod Lab reads. */
852export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number }
853
854/** How many parts of a UNC path (`//server/share`) are its root. */
855const UNC_ROOT_PARTS = 2
856
857/** Resolves `.` and `..` segments in a `/`-separated path. */
858export function normalizePath(path: string): string {
859  const slashed = path.replace(/\\/g, '/')
860  const root = rootOf(slashed)
861  // A UNC path's server and share are part of its root: '..' stops above them, as at `/`.
862  const floor = root === '//' ? UNC_ROOT_PARTS : 0
863  const parts: string[] = []
864  for (const part of slashed.slice(root.length).split('/')) {
865    if (part === '' || part === '.') continue
866    if (part === '..') {
867      if (parts.length > floor && parts[parts.length - 1] !== '..') parts.pop()
868      else if (!root) parts.push('..')
869    } else {
870      parts.push(part)
871    }
872  }
873  return root + parts.join('/')
874}
875
876/** A `/`-separated path's root: `//` for a UNC path, `/`, a drive (`C:/`, upper case), or '' when it is relative. */
877function rootOf(slashed: string): string {
878  if (/^\/\/[^/]/.test(slashed)) return '//'
879  if (slashed.startsWith('/')) return '/'
880  return /^[A-Za-z]:(\/|$)/.test(slashed) ? `${slashed.charAt(0).toUpperCase()}:/` : ''
881}
882
883/**
884 * A path as the session reads it: relative to `cwd` when inside it
885 * (`mods/foo`), `.` when it is `cwd`, absolute otherwise, and absolute when
886 * the working directory couldn't be read (`cwd` null).
887 */
888export function displayPathOf(path: string, cwd: string | null): string {
889  const target = normalizePath(path)
890  if (cwd === null) return target
891  const root = normalizePath(cwd)
892  if (target === root) return '.'
893  // `normalizePath` keeps a trailing `/` only for the root itself: cwd `/` gives prefix `/`, not `//`.
894  const prefix = `${root.replace(/\/$/, '')}/`
895  return target.startsWith(prefix) ? target.slice(prefix.length) : target
896}
897
898/** Where a marketplace keeps its manifest, below its folder. */
899const MARKETPLACE_FILE = '/.claude-plugin/marketplace.json'
900
901/**
902 * The marketplace file `cwd` would have if it were a marketplace, then that of every folder above
903 * it, nearest first, down to its root: `/`, a drive (`C:/`) or a UNC share (`//server/share`).
904 * A relative `cwd` names no folder to walk, so it has none.
905 */
906export function marketplaceCandidatesOf(cwd: string): string[] {
907  return foldersUpFrom(cwd).map(folder => (folder.endsWith('/') ? folder + MARKETPLACE_FILE.slice(1) : folder + MARKETPLACE_FILE))
908}
909
910/** `path` and every folder above it, nearest first, down to its root; none for a relative path. */
911export function foldersUpFrom(path: string): string[] {
912  const normal = normalizePath(path)
913  const root = rootOf(normal)
914  if (!root) return []
915  const parts = normal.slice(root.length).split('/').filter(Boolean)
916  // A UNC path's server and share are its root folder; nothing sits above them.
917  const top = root === '//' ? Math.min(UNC_ROOT_PARTS, parts.length) : 0
918  const folders: string[] = []
919  for (let depth = parts.length; depth >= top; depth--) folders.push(root + parts.slice(0, depth).join('/'))
920  return folders
921}
922
923/**
924 * The mods folder: a set `modsDir` always wins. Otherwise the `mods/` of the nearest folder at or
925 * above `cwd` whose marketplace file is among `existing`, the way /mod-builder:build finds it, and
926 * `fallback` (the plugin's own) when `cwd` is unknown or no marketplace is above it.
927 */
928export function modsDirFor(configured: string, cwd: string | null, existing: readonly string[], fallback: string): string {
929  if (configured) return normalizePath(configured)
930  const found = cwd === null ? undefined : marketplaceCandidatesOf(cwd).find(candidate => existing.includes(candidate))
931  return found === undefined ? normalizePath(fallback) : `${found.slice(0, -MARKETPLACE_FILE.length)}/mods`
932}
933
934/** Where the marketplace walk starts (#92): the session's project root when known, which a Bash `cd` doesn't move, else `cwd`. */
935export function walkStartOf(root: string | null, cwd: string | null): string | null {
936  return root ?? cwd
937}
938
939/** A path to compare by: normalized, and lower-cased on a drive or UNC share, which Windows reads without case (PR #95 review). */
940function pathKey(path: string): string {
941  const normal = normalizePath(path)
942  const root = rootOf(normal)
943  return root === '/' || root === '' ? normal : normal.toLowerCase()
944}
945
946/** A build's pinned folder, while the project root is the one it was pinned under or can't be read now; else null (#92). */
947export function keptPinOf(pin: Pin | null, root: string | null): Pin | null {
948  if (!pin) return null
949  return root === null || pathKey(pin.root) === pathKey(root) ? pin : null
950}
951
952/**
953 * The report a row in `dir` shows: one made in that folder, or one with no folder (stored before 0.2.16).
954 * `reported` keeps one report per mod name, so a same-named mod in another folder must not light this row (PR #95 review).
955 */
956export function reportIn(report: Reported | undefined, dir: string): Reported | null {
957  if (!report) return null
958  return report.dir === undefined || report.dir === dir ? report : null
959}
960
961/** The marketplace file a mods folder belongs to: `<marketplace>/mods` → `<marketplace>/.claude-plugin/marketplace.json`. */
962export function marketplaceFileOf(modsDir: string): string {
963  const parent = parentOf(modsDir)
964  return parent.endsWith('/') ? parent + MARKETPLACE_FILE.slice(1) : parent + MARKETPLACE_FILE
965}
966
967/** The folder above `path`, normalized; a root is its own parent. */
968export function parentOf(path: string): string {
969  const dir = normalizePath(path)
970  const cut = dir.lastIndexOf('/')
971  return cut < rootOf(dir).length ? rootOf(dir) : dir.slice(0, cut)
972}
973
974/** A folder's `.git` as the disk has it: a directory (a repository's top), a file (a worktree's or submodule's pointer), or none. */
975export type DotGit = { kind: 'dir' } | { kind: 'file'; text: string } | null
976
977/**
978 * The common git folder of the repository `folder` is the top of (PR #95 review follow-up): `<folder>/.git`
979 * for a repository top; for a `.git` file, its `gitdir:` (resolved against `folder`) without a trailing
980 * `/worktrees/<name>`. A submodule's `gitdir` names its own repository's folder. Null with no `.git`.
981 */
982export function gitCommonDirOf(folder: string, dotGit: DotGit): string | null {
983  if (!dotGit) return null
984  if (dotGit.kind === 'dir') return `${normalizePath(folder).replace(/\/$/, '')}/.git`
985  const gitDir = gitDirNamedIn(folder, dotGit.text)
986  if (gitDir === null) return null
987  return /^(.*)\/worktrees\/[^/]+$/.exec(gitDir)?.[1] ?? gitDir
988}
989
990/** The git folder a `.git` file's `gitdir:` line names, resolved against `folder`; null without one. */
991function gitDirNamedIn(folder: string, text: string): string | null {
992  const line = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text.replace(/\r/g, ''))
993  if (!line) return null
994  const named = line[1]!.replace(/\\/g, '/')
995  return normalizePath(rootOf(named) ? named : `${normalizePath(folder).replace(/\/$/, '')}/${named}`)
996}
997
998/**
999 * Git's own record of the worktree whose `.git` file this is: `<gitdir>/gitdir`, when the gitdir is
1000 * `…/worktrees/<name>` (PR #95 review follow-up). Null for a repository top, a submodule or no `.git`.
1001 */
1002export function worktreeLinkOf(folder: string, dotGit: DotGit): { record: string } | null {
1003  if (dotGit?.kind !== 'file') return null
1004  const gitDir = gitDirNamedIn(folder, dotGit.text)
1005  return gitDir !== null && /\/worktrees\/[^/]+$/.test(gitDir) ? { record: `${gitDir}/gitdir` } : null
1006}
1007
1008/** Whether git's record (`record`, holding `text`) names `folder`'s `.git` back: absolute, or relative to the record's folder. */
1009export function isBackLinkTo(folder: string, record: string, text: string): boolean {
1010  const named = text.replace(/\r/g, '').trim().replace(/\\/g, '/')
1011  if (!named) return false
1012  const resolved = normalizePath(rootOf(named) ? named : `${parentOf(record)}/${named}`)
1013  return pathKey(resolved) === pathKey(`${normalizePath(folder).replace(/\/$/, '')}/.git`)
1014}
1015
1016/** Whether two common git folders are one repository's (Windows roots without case); an unknown one never is. */
1017export function isSameRepo(a: string | null, b: string | null): boolean {
1018  return a !== null && b !== null && pathKey(a) === pathKey(b)
1019}
1020
1021/**
1022 * Whether a reported folder may be pinned, as far as its name and the root tell (PR #95 review): a
1023 * readable root, and a folder named `mods`. The caller checks that its marketplace file exists and that
1024 * the folder above it is the project's own repository or one of its worktrees (`isSameRepo`), since
1025 * Validate and Test then run there. Gives the marketplace file and the normalized root, or why not.
1026 */
1027export function pinCheckOf(dir: string, root: string | null): { marketplace: string; root: string } | { reason: string } {
1028  if (root === null) return { reason: "the project root can't be read" }
1029  const folder = normalizePath(dir)
1030  if (folder.slice(folder.lastIndexOf('/') + 1) !== 'mods') return { reason: `${folder} isn't a marketplace's mods folder` }
1031  return { marketplace: marketplaceFileOf(folder), root: normalizePath(root) }
1032}
1033
1034/** A pin as state kept it, or null when it isn't one (no root, or not a pin at all). */
1035export function pinOf(value: unknown): Pin | null {
1036  const held = value as { modsDir?: unknown; root?: unknown } | null
1037  return held && typeof held.modsDir === 'string' && typeof held.root === 'string' ? { modsDir: held.modsDir, root: held.root } : null
1038}
1039
1040/**
1041 * The folder Mod Lab watches (#92): a set `modsDir`, else the pin `keptPinOf` keeps, else `modsDirFor`
1042 * walking from the root, or from `cwd` when no marketplace in `existing` is above the root (PR #95 review).
1043 * `pin` is the pin to keep: null when there was none or a changed root dropped it.
1044 */
1045export function watchedDirOf(
1046  pin: Pin | null,
1047  root: string | null,
1048  configured: string,
1049  cwd: string | null,
1050  existing: readonly string[],
1051  fallback: string,
1052): { dir: string; pin: Pin | null } {
1053  const kept = keptPinOf(pin, root)
1054  if (configured) return { dir: normalizePath(configured), pin: kept }
1055  if (kept) return { dir: kept.modsDir, pin: kept }
1056  const hasMarketplace = (start: string | null): boolean => start !== null && marketplaceCandidatesOf(start).some(path => existing.includes(path))
1057  const start = [root, cwd].find(hasMarketplace) ?? walkStartOf(root, cwd)
1058  return { dir: modsDirFor('', start, existing, fallback), pin: null }
1059}
1060
1061/**
1062 * "Ready to share" marks as `<modsDir>/<name>`. Before 0.2.9 a mark was the bare name, of a mod
1063 * in the one folder Mod Lab watched (`legacyDir`), so a bare one is read as that folder's.
1064 */
1065export function freshIdsOf(stored: readonly string[], legacyDir: string): string[] {
1066  return stored.map(id => (id.includes('/') ? id : `${legacyDir}/${id}`))
1067}
1068
1069/** The last two segments of a path, for the summary line: `…/ClaudeMod/mods`. */
1070export function shortPath(path: string): string {
1071  const parts = normalizePath(path).split('/').filter(Boolean)
1072  return parts.length <= 2 ? normalizePath(path) : `…/${parts.slice(-2).join('/')}`
1073}
1074
1075/** Everything read from one mod folder, before it is judged. */
1076export type FolderRead = {
1077  name: string
1078  top: readonly Entry[]
1079  manifestDir: readonly Entry[]
1080  hooks: readonly Entry[]
1081  tests: readonly Entry[]
1082  types: readonly Entry[]
1083  /** Top-level listings of agents/, commands/ and skills/, which validate reads. Optional: absent reads as empty. */
1084  agents?: readonly Entry[]
1085  commands?: readonly Entry[]
1086  skills?: readonly Entry[]
1087  /** Files in subfolders of hooks/, tests/, types/, agents/, commands/ and skills/, named by their path from the mod folder (`hooks/lib/parser.ts`). */
1088  nested?: readonly Entry[]
1089  /** Folders whose listing failed, by their path from the mod folder (`hooks/lib`): read as empty, though they may not be. */
1090  unlisted?: readonly string[]
1091  spec: string | null
1092  readme: string | null
1093  /** plugin.json's text, when present. */
1094  manifest: string | null
1095  marketplace: string
1096}
1097
1098/** `version` and `description` from a plugin.json's text. */
1099export function manifestOf(text: string | null): { version: string | null; description: string | null } {
1100  try {
1101    const parsed = JSON.parse(text ?? '') as { version?: unknown; description?: unknown }
1102    return {
1103      version: typeof parsed.version === 'string' ? parsed.version : null,
1104      description: typeof parsed.description === 'string' ? parsed.description : null,
1105    }
1106  } catch {
1107    return { version: null, description: null }
1108  }
1109}
1110
1111/** A short, order-independent hash of a list of strings (FNV-1a over the sorted list). */
1112export function signatureOf(items: readonly string[]): string {
1113  let h = 0x811c9dc5
1114  for (const ch of [...items].sort().join('\n')) {
1115    h ^= ch.codePointAt(0)!
1116    h = Math.imul(h, 0x01000193) >>> 0
1117  }
1118  return `${items.length}:${h.toString(36)}`
1119}
1120
1121/** One folder's reads → its facts. A folder with neither manifest nor SPEC is no mod: null. */
1122export function factsOf(read: FolderRead): FileFacts | null {
1123  const has = (list: readonly Entry[], name: string): boolean => list.some(entry => entry.name === name)
1124  const hasManifest = has(read.manifestDir, 'plugin.json')
1125  const hasSpec = read.spec !== null
1126  if (!hasManifest && !hasSpec) return null
1127  const manifest = read.manifestDir.filter(entry => entry.name === 'plugin.json')
1128  // Named by folder, so a file moved between folders without a new mtime still changes the signature.
1129  const inFolder = (folder: string, entries: readonly Entry[]): Entry[] =>
1130    entries.filter(entry => entry.kind === 'file').map(entry => ({ ...entry, name: `${folder}/${entry.name}` }))
1131  const sigOf = (entries: readonly Entry[]): string => signatureOf(entries.map(entry => `${entry.name}@${entry.mtimeMs}`))
1132  // The one file at the top that validate reads; the SPEC and README are read by neither validate nor test.
1133  const mcp = read.top.filter(entry => entry.kind === 'file' && entry.name === '.mcp.json')
1134  const nested = read.nested ?? []
1135  const code = [
1136    ...inFolder('.claude-plugin', manifest),
1137    ...inFolder('hooks', read.hooks),
1138    ...inFolder('tests', read.tests),
1139    ...inFolder('types', read.types),
1140    ...inFolder('agents', read.agents ?? []),
1141    ...inFolder('commands', read.commands ?? []),
1142    ...inFolder('skills', read.skills ?? []),
1143    ...mcp,
1144    ...nested.filter(entry => entry.kind === 'file'),
1145  ]
1146  const all = [...read.top.filter(entry => entry.kind === 'file'), ...code]
1147  const times = all.map(entry => entry.mtimeMs)
1148  const slots = read.spec ? slotsOf(read.spec) : {}
1149  const codeMtimeMs = Math.max(0, ...code.map(entry => entry.mtimeMs))
1150  // The code files that are logic modules or tests, top-level or nested: one rule for logicMtimeMs and logicSig.
1151  const logic = code.filter(entry => isLogicOrTestPath(entry.name))
1152  const codeSig = sigOf(code)
1153  const readmeEntry = read.top.find(entry => entry.name === 'README.md')
1154  return {
1155    ...manifestOf(read.manifest),
1156    isReadmeStale: read.readme !== null && readmeEntry !== undefined && readmeEntry.mtimeMs < codeMtimeMs,
1157    name: read.name,
1158    hasSpec,
1159    specHasPlaceholder: read.spec !== null && hasPlaceholder(read.spec),
1160    hasManifest,
1161    hasHooksJson: has(read.hooks, 'hooks.json'),
1162    hasLogic: read.hooks.some(entry => entry.kind === 'file' && isLogicModule(entry.name)),
1163    hasTests: read.tests.some(entry => entry.kind === 'file' && isTestFile(entry.name)),
1164    hasRegister: read.hooks.some(entry => /^register\.(m|c)?(t|j)sx?$/.test(entry.name)),
1165    hasReadme: read.readme !== null,
1166    readmeHasReaches: read.readme !== null && isReadmeDone(read.readme),
1167    isListed: isListedIn(read.marketplace, read.name),
1168    listedVersion: listedVersionOf(read.marketplace, read.name),
1169    specMtimeMs: read.top.find(entry => entry.name === 'SPEC.md')?.mtimeMs ?? 0,
1170    readmeMtimeMs: readmeEntry?.mtimeMs ?? 0,
1171    logicMtimeMs: Math.max(0, ...logic.map(entry => entry.mtimeMs)),
1172    logicSig: (read.unlisted ?? []).some(path => /^(hooks|tests)(\/|$)/.test(path)) ? null : sigOf(logic),
1173    codeMtimeMs,
1174    codeSig,
1175    firstMtimeMs: times.length ? Math.min(...times) : 0,
1176    lastMtimeMs: times.length ? Math.max(...times) : 0,
1177    archetype: slots.archetype ?? null,
1178    builtOn: slots.built?.replace(/^Claude Code\s+/i, '') ?? null,
1179  }
1180}
1181
1182/**
1183 * The facts of a mod whose folder is not written yet (#19): nothing found, nothing read. Its row
1184 * exists only because a build reported it; the next scan that finds the folder replaces it.
1185 */
1186export function placeholderFacts(name: string): FileFacts {
1187  return {
1188    name,
1189    hasSpec: false,
1190    specHasPlaceholder: false,
1191    hasManifest: false,
1192    hasHooksJson: false,
1193    hasLogic: false,
1194    hasTests: false,
1195    hasRegister: false,
1196    hasReadme: false,
1197    readmeHasReaches: false,
1198    isListed: false,
1199    codeMtimeMs: 0,
1200    codeSig: signatureOf([]),
types/index.d.ts 103 lines
1// Mod Lab's state contract: every value it keeps in `$.state`, and the types
2// those values are made of. Self-contained by rule: no imports. hooks/mod-builder-logic.ts
3// and hooks/register.tsx import these types from here.
4
5export type Stage = 'Spec' | 'Scaffold' | 'Logic' | 'Register' | 'Validate' | 'Test' | 'README' | 'Share'
6
7/** A run Mod Lab starts itself, by button, by hotkey or after a `lab_report`. */
8export type RunKind = 'Validate' | 'Test' | 'Typecheck'
9
10export type Light = 'pending' | 'running' | 'passed' | 'failed' | 'stale'
11
12export type Tone = 'success' | 'accent' | 'error' | 'warn' | 'dim' | 'text'
13
14export type Gate = { hook: string; hasCatch: boolean }
15
16export type Reach = { label: string; value: string }
17
18/** What a `lab_report` call last said, before the disk confirms it. */
19export type Reported = {
20  stage: Stage
21  status: 'running' | 'passed' | 'failed'
22  note?: string
23  /** When this report arrived, for the band's timer. */
24  since: number
25  /** The mods folder the session was in when it reported: a placeholder row shows only there (#19). */
26  dir?: string
27}
28
29/** A build's mods folder, pinned for the session by a `lab_report` with `path`, and the project root it was pinned under (#92). */
30export type Pin = { modsDir: string; root: string }
31
32/** One mod as the pane, band and status line draw it. */
33export type ModView = {
34  name: string
35  lights: Light[]
36  status: string
37  tone: Tone
38  /** The time column: a run's elapsed time while it runs, else the last run's duration. */
39  time: string
40  isDemo: boolean
41  /** Went all green during this session: the row reads "ready to share" and the done box shows. */
42  isFresh: boolean
43  version: string | null
44  description: string | null
45  /** The mod's folder as the session read it at the last scan, with a trailing `/` (`mods/<name>/` under the default `modsDir`); the demo's is `not written to disk`. The detail header works it out again when it draws. */
46  path: string
47  /** README.md is older than the code it describes. */
48  isReadmeStale: boolean
49  /** The done box's line, when the mod is fresh. */
50  doneLine: string | null
51  archetype: string | null
52  builtOn: string | null
53  hooks: string[]
54  reaches: Reach[]
55  gates: Gate[]
56  errors: string[]
57  failedStage: Stage | null
58  /** Reported by a build before its folder exists: a row with no actions, until a scan finds the folder. */
59  isPlaceholder: boolean
60}
61
62/** A run in flight: which mod, which kind, since when, and how far it has got. */
63export type Run = {
64  mod: string
65  kind: RunKind
66  startedAt: number
67  /** Tests reported so far (streamed), and the total the last run had, when known. */
68  passed: number
69  failed: number
70  total: number | null
71}
72
73declare module 'claude-code' {
74  interface PluginState {
75    'mod-builder': {
76      /**
77       * The last scan's verdict, written as one value so no reader pairs one folder's rows with
78       * another's path: `views`, the mods on disk judged (the demo is not among them), and `modsDir`,
79       * the folder they were judged in, which the pane's buttons act on (null before the first scan).
80       */
81      judged: { modsDir: string | null; views: ModView[] }
82      /** The row the detail panel shows. */
83      selected: string | null
84      runs: Run[]
85      reported: Record<string, Reported>
86      /** Builds still running, per mods folder then mod name, for placeholder rows (see `withBuilding`). */
87      building: Record<string, Record<string, Reported>>
88      /** The animation clock: frame count and the time it last ticked. */
89      frame: { n: number; now: number }
90      /** When the demo started, or null when none runs. */
91      demoStartedAt: number | null
92      /** The README the pane shows in place of the list, or null. */
93      readme: { name: string; text: string; isStale: boolean } | null
94      /** Mods that went all green this session (newest code stamp), for "ready to share", as `<modsDir>/<name>`. */
95      fresh: string[]
96      /** The "too narrow" toast fired for the current narrow spell; reset once wide again. */
97      hasWarnedNarrow: boolean
98      /** The folder a build reported with `path`: watched until the project root changes (#92). */
99      pinned: Pin | null
100    }
101  }
102}
103