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…

Build Claude Code mods spec-first, change them the same way, and watch each one come together live.
The plugin has three parts:
| Part | Command | What 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) | /modlab | Tracks 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.
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
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.| Light | Meaning |
|---|---|
● | passed |
◐ | running |
✕ | failed |
○ | not done yet |
◌ | stale: it passed, but the code changed afterwards, or an edit started |
running tests · 7 of 12), and the time column shows the run's elapsed time.What Mod Lab uses
| Surface | What Mod Lab puts there |
|---|---|
| Pane beside the transcript | Stage 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 prompt | Each 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 notification | When validate or test finishes, and when a mod is ready to share |
| Hotkey buttons | V 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 tool | lab_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
| # | Stage | Passes when | During an edit, re-passes when |
|---|---|---|---|
| 1 | Spec | SPEC.md has no <placeholder> left | SPEC.md is saved again |
| 2 | Scaffold | plugin.json and hooks/hooks.json exist | stays green |
| 3 | Logic | a logic module and a *.test.ts exist | a logic or test file changes |
| 4 | Register | hooks/register.tsx exists | stays green |
| 5 | Validate | the last validate passed, with no code changed since | it runs again |
| 6 | Test | the last test run passed, with no code changed since | it runs again |
| 7 | README | it has "What it reaches" and no template comments | it's saved again |
| 8 | Share | the marketplace lists the mod | the 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.
/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 command | What it does |
|---|---|
/modlab | Open 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 demo | Play 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"}}]}
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.
| Option | Default | What it does |
|---|---|---|
modsDir | empty: 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 plugin | The 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. |
autoOpen | false | Open 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. |
autoRerun | true | At 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 |
testTimeoutMinutes | 3 | How 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 |
logging | normal | off: 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.
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.
| Reach | Detail |
|---|---|
| Hooks | session.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} |
| Processes | claude 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. |
| Files | Reads 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 |
| Settings | Its own /config rows ($.config.list / $.config.set), when you run /mod-builder config <option> <value> |
| Environment | Reads HOME, USERPROFILE (to place the log file) |
| Session | Reads 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. |
| Store | Its own $.store, one set of keys per mods folder: last results per mod, which mods were announced, and edit generations |
| Network | None from the mod itself. The first Type-check makes npx download TypeScript. |
| Model calls | None |
| Tools it adds | lab_report, which records progress (with path, the folder being built in) and runs nothing beyond the Validate and Test above |
| Gating hooks | 5, 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 |
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.
hooks/register.tsx 1784 lines1import { 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 lines1/**
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 lines1// 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