SLOPSHOPPER

noacg-guide

Trial: shows the NoaCG graphics packages in this folder above the prompt. Quiet where there is none.

panebandguardcommandtoastprompt
★ 8v0.1.0Apache-2.0updated 2026-10-26NoaCG/NoaCG-Studio/cli/plugin-guide
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · noacg-guide
│ ┃ NoaCG ✕ › fix the failing auth test and add an audit log call │ ┃ [ Start a package ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /noacg-guide │ │ NoaCG [ Start a package ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
NoaCG [ Start a package ] [ Hide ]
Pane · NoaCG
[ Start a package ]
README

noacg-guide (trial)

A Claude Code mod for people making broadcast graphics with the noacg plugin. It shows the NoaCG packages in the folder Claude Code was started in, where each stands and what to ask for next.

This is a trial for the owner to try. It is not published, not in the marketplace, the npm package or the toolkit distribution, and its name may change. The plan is docs/work-specs/guided-mod/spec.md; the try-out is docs/work-specs/guided-mod/tryout.md.

What it shows

A band above the prompt, once the folder holds a package:

NoaCG  Lower third · Scoreboard · Ticker
       1 checked · 1 edited since check · 1 not checked · saved: Scoreboard
       Check Scoreboard again  [ Fill prompt ]  Open in NoaCG  [ Hide ]
       saving again adds a second library copy
  • The packages by name, then how many are checked, edited since check and not checked, and which are saved.
  • One next step: the package that needs attention first (a failed check, then edited since check, not checked, not yet seen, ready to save), with the step in plain words. Once a package is saved, sent or written to a pack file as it is, it has no step. After a save, an edit adds a line warning that saving again adds a second library copy.
  • Fill prompt puts the step's request in the prompt box for you to edit or send; it never sends it. Where the box cannot take it, a toast shows the text. Open in NoaCG opens the saved graphic or sent production. Hide hides the band until you type /noacg-guide or a noacg command runs.

/noacg-guide opens a pane beside the conversation (above the prompt on a narrow terminal or outside the fullscreen layout) with every package: its state, each record with its time, the frames folder and pack file as links, the saved or sent link, and its next step with a Fill button. It follows each new record. Esc or /noacg-guide again closes it. In a folder with no package it offers Start a package, which fills the prompt with "Make broadcast graphics with NoaCG." It opens with that button selected, or with the Fill of the package that needs attention first, so Enter fills the request at once. When no package has a step left, nothing is selected.

Where nothing draws (the VS Code panel, claude -p), /noacg-guide answers with the same list and next steps as text. That text stays in the conversation, so the model reads it later.

What it records, and where

After a noacg run in a terminal (noacg ..., npx @noacg/cli ...) or through the noacg MCP tool, it records what the run reported for the package: checked (only when validate passed and the run exited 0) or failed, frames with their folder, saved with its link, and a pack with its link or file. A screenshot alone is never a check. Each record is tied to a SHA-256 of the package's source and asset files, so an edit afterwards shows "edited since check".

The records are kept per folder on this machine, in the mod's own store: a JSON file under your Claude Code configuration folder (~/.claude/plugins/store/), never in a package, a pack or the repository. A new session in the same folder shows the same states.

It makes no network request, process run or model call, never sends a prompt or calls a tool, and adds nothing to the model's context except the text answer above when you run the command. Its one registration is /noacg-guide, which the model is not told about. It lists folders and reads package files inside the folder Claude Code was started in. Where there is no package it draws nothing.

Try it

Pull main in a checkout of this repository first. You need Claude Code 2.1.287 or newer, signed in.

In a terminal, start Claude Code in the folder you make graphics in:

claude --plugin-dir /path/to/NoaCG-Studio/cli/plugin-guide

For the Desktop Code tab, name the folder in the env block of ~/.claude/settings.json, then start a new session in the Code tab:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/NoaCG-Studio/cli/plugin-guide" } }

On Windows, write the path with / or \\, as in C:/claude/NoaCG-Studio/cli/plugin-guide. A single \ breaks the file, and then nothing loads.

To turn it off, start without --plugin-dir, or remove that line and start a new session.

Limits

  • It draws in the terminal and the Desktop Code tab only. The VS Code panel and claude -p get the text answer; Codex has no mods.
  • No frame previews or thumbnails in the pane: frames open as a folder.
  • No artwork, brand or data input: it only reads what noacg runs reported.
  • It sees only what runs in the session. A package edited outside Claude Code shows "edited since check" at the next prompt, but a check run outside it is not recorded. Nor is a run it cannot read: one in the background, or one whose output was cut before its verdict.

Checks

claude plugin validate --strict cli/plugin-guide
claude plugin test cli/plugin-guide

The tests mount the band and the pane on the terminal and desktop surfaces. CI runs both whenever a change touches this folder or .github/workflows/ci.yml (its guided-mod job), in the pull request and in the merge queue, and a red run keeps the change from landing. It also fails when the validator lists a call that sends a prompt, calls a tool, an MCP server or a command, appends to the conversation, runs a process, fetches, calls a model or starts an agent.

Source 7 files
hooks/register.tsx 394 lines
1// noacg-guide: a trial mod that shows the NoaCG packages a session works on, where each stands
2// and what to ask for next, above the prompt, and every package in a pane on `/noacg-guide`.
3//
4// Quiet outside graphics work (docs/work-specs/guided-mod/spec.md, AC-2): with no package in the
5// working directory it draws nothing, adds nothing to the model's context and registers no tool.
6// Its one registration is the `/noacg-guide` command, which the model never sees. It never
7// submits prompts, calls tools, runs processes, fetches or calls a model: its buttons fill the
8// prompt box for the person to send, open a link through the surface, or hide the band.
9// It lists folders and reads package files under the project root: at session start, when a
10// prompt is submitted and after a tool call that may have written files. After a `noacg` run it
11// records what the run reported (hooks/watch.ts) in the mod's own store.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { NoacgPackage, Run } from '../types'
16import { fitNames, STATUS, summary } from './band'
17import { factsMarkdown, factsOf, textAnswer } from './pane'
18import { findPackages, isObject, type Folders } from './packages'
19import { firstStep, linkOf, START, stepOf } from './steps'
20import {
21  fingerprint,
22  isNoacgTool,
23  mcpReports,
24  normalize,
25  outcomeOf,
26  recordsFrom,
27  relativeTo,
28  resolvePath,
29  runsNoacg,
30  statusOf,
31  storeKey,
32  terminalReports,
33  withReport,
34  type Reader,
35  type Records,
36  type Report,
37} from './watch'
38
39const packages = atom({ plugin: 'noacg-guide', key: 'packages' } as const, [] as NoacgPackage[])
40const hidden = atom({ plugin: 'noacg-guide', key: 'hidden' } as const, false)
41const offer = atom({ plugin: 'noacg-guide', key: 'offer' } as const, false)
42const pane = atom({ plugin: 'noacg-guide', key: 'pane' } as const, false)
43
44/** How long a request shown as a toast stays, where the prompt box could not take it. */
45const TOAST_MS = 12_000
46
47const TITLE = 'NoaCG'
48/** Cells between the title and the names. */
49const GAP = 2
50/** The package pane's id. */
51const PANE = 'noacg-guide'
52
53/**
54 * The pane's main button. The pane opens with the keys (`focus`), and its ring starts here, so
55 * Enter acts at once; without `autoFocus` it starts on nothing and Enter does nothing.
56 */
57const MAIN = { variant: 'primary', autoFocus: true } as const
58
59/** Whether a pane `$.ui.panes()` lists is this mod's, drawn and in front. */
60const onScreen = (open: { id: string; isShown: boolean; isPlaced: boolean }) => open.id === PANE && open.isShown && open.isPlaced
61
62/** The prompt box as one dispatch reaches it: `$` cannot be passed on, so closures over it. */
63type PromptBox = {
64  read: () => Promise<string>
65  fill: (text: string, mode: 'replace' | 'append') => Promise<boolean>
66  toast: (text: string) => void
67}
68
69/**
70 * A Fill button's press: the request goes into the prompt box for the person to send, on a line
71 * after a draft they typed, never over it, and not twice; where no box takes it, a toast.
72 */
73const filler = (box: PromptBox) => (text: string) => async () => {
74  const draft = await box.read()
75  if (draft.includes(text)) return
76  const filled = await (draft.trim() ? box.fill(`\n${text}`, 'append') : box.fill(text, 'replace')).catch(() => false)
77  if (!filled) box.toast(text)
78}
79
80/** What one pass needs from the dispatch that asked for it; every path absolute. */
81type Scan = Folders &
82  Reader & {
83  root: () => Promise<string>
84  load: (key: string) => Promise<unknown>
85  save: (key: string, value: unknown) => Promise<unknown>
86  known: () => Promise<NoacgPackage[]>
87  keep: (found: NoacgPackage[]) => Promise<unknown>
88}
89
90/** One scan at a time; a trigger during a scan asks for one more pass, with the newest access. */
91let scanning: Promise<void> | undefined
92let latest: Scan | undefined
93/** Reports since the last pass, each with its run and the fingerprint the run left. */
94const pending: { report: Report; run: Run }[] = []
95
96function rescan(scan: Scan): void {
97  latest = scan
98  scanning ??= drain()
99}
100
101async function drain(): Promise<void> {
102  while (latest) {
103    const now = latest
104    latest = undefined
105    try {
106      await pass(now)
107    } catch {
108      // The next trigger scans again.
109    }
110  }
111  // Cleared in the same step the loop ends, so no trigger can land between the two.
112  scanning = undefined
113}
114
115async function pass(scan: Scan): Promise<void> {
116  const root = normalize(await scan.root())
117  const at = (dir: string) => resolvePath(root, dir)
118  const found = await findPackages({ list: dir => scan.list(at(dir)), read: file => scan.read(at(file)) })
119  if (found.length === 0) {
120    if ((await scan.known()).length > 0) await scan.keep([])
121    return
122  }
123
124  // Read on every pass, so a new session, or a second one in the same folder, sees the same
125  // records; a run is folded in and written straight after the read, so the two seldom cross.
126  const runs = pending.splice(0)
127  let all: Records
128  try {
129    all = recordsFrom(await scan.load(storeKey(root)))
130    let recorded = false
131    for (const { report, run } of runs) {
132      const target = found.find(p => relativeTo(at(p.dir), report.target) === '.')
133      if (!target) continue
134      all[target.dir] = withReport(all[target.dir] ?? {}, report, run)
135      recorded = true
136    }
137    if (recorded) await scan.save(storeKey(root), { packages: all })
138  } catch (failed) {
139    // Kept for the next pass rather than lost.
140    pending.unshift(...runs)
141    throw failed
142  }
143
144  const list: NoacgPackage[] = []
145  for (const p of found) {
146    const record = all[p.dir] ?? {}
147    const current = record.check?.ok ? await fingerprint(scan, at(p.dir)) : null
148    const status = statusOf(record, current)
149    list.push({ ...p, status, record, next: stepOf({ ...p, record }, current) })
150  }
151  if (JSON.stringify(await scan.known()) !== JSON.stringify(list)) await scan.keep(list)
152}
153
154/** Whether this load of the module has declared `/noacg-guide`; a reload declares it again. */
155let declared = false
156
157/**
158 * Starts a scan with this dispatch's access and passes the event on unchanged. `$` cannot be
159 * passed on as a value, so the scan gets closures over it; the tool call hook builds the same.
160 *
161 * The first dispatch of a load, the session's start, also declares `/noacg-guide`. It is
162 * declared in every session, packages or not, since AC-13 has it start a package in a folder
163 * with none; a command is the person's alone, and the model is not told of it.
164 */
165async function scanThenPass<E, R>($: EngineInterface, e: E, next: (e: E) => R): Promise<Awaited<R>> {
166  rescan({
167    root: () => $.session.root(),
168    list: dir => $.fs.list(dir),
169    read: file => $.fs.read(file),
170    bytes: async file => (await $.fs.read(file, { as: 'bytes' })).base64,
171    load: key => $.store.get(key),
172    save: (key, value) => $.store.set(key, value),
173    known: () => read($, packages),
174    keep: found => update($, packages, () => found),
175  })
176  // After the scan has started; a registration that failed is tried again at the next prompt.
177  if (!declared) {
178    declared = await $.command.register({ name: 'noacg-guide', description: 'Show NoaCG packages and what to ask next', immediate: true }).then(
179      () => true,
180      () => false,
181    )
182  }
183  return await next(e)
184}
185
186export const register: Register = on => {
187  on('session.start', scanThenPass)
188  on('prompt.submit', scanThenPass)
189
190  // Opens the package pane, or closes it when open; brings a hidden band back, and with no
191  // package here offers to start one. Where no surface draws a pane (`claude -p`, the VS Code
192  // panel), it answers with the same list as text, which the engine also hands the model.
193  on('command.run', { command: 'noacg-guide' }, async $ => {
194    // A scan under way (the session's start) settles first, so a package is not missed.
195    await scanning
196    const list = await read($, packages)
197    await update($, hidden, () => false)
198    await update($, offer, () => list.length === 0)
199
200    // The engine's record, not the module's: it outlives a reload. A pane open as a tab behind
201    // another is opened again, which brings it forward, rather than closed.
202    if ((await $.ui.panes()).some(onScreen)) {
203      await $.ui.close({ id: PANE })
204      return {}
205    }
206    const surfaces = await $.session.surfaces()
207    if (surfaces.includes('terminal') || surfaces.includes('desktop')) {
208      // Asked by the person, a pane is placed at any width; one that waits undrawn is on a
209      // surface that places none, so it goes and the text answers instead, as it does when
210      // another plugin refuses the open.
211      const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true }).catch(() => null)
212      if (opened?.isPlaced) {
213        await update($, pane, () => true)
214        return {}
215      }
216      if (opened) await $.ui.close({ id: PANE })
217    }
218    const [root, now] = await Promise.all([$.session.root(), $.clock.now()])
219    return { text: textAnswer(list, normalize(root), now) }
220  })
221
222  // However the pane closes (Esc, its close mark, the command), the band comes back.
223  on('ui.close', async ($, e, next) => {
224    const closed = await next(e)
225    if (e.id === PANE) await update($, pane, () => false).catch(() => undefined)
226    return closed
227  })
228
229  on('tool.call', async ($, e, next) => {
230    const shell = e.tool === 'Bash' || e.tool === 'PowerShell' ? e : null
231    const escapes = e.tool === 'Bash'
232    const command = shell && shell.run_in_background !== true && /noacg/i.test(shell.command) ? shell.command : null
233    const mcp = isNoacgTool(e.tool)
234    const cwd = command !== null ? await $.session.cwd().catch(() => undefined) : undefined
235
236    const ran = await next(e)
237    if (ran.deny !== undefined) return ran
238
239    const scan: Scan = {
240      root: () => $.session.root(),
241      list: dir => $.fs.list(dir),
242      read: file => $.fs.read(file),
243      bytes: async file => (await $.fs.read(file, { as: 'bytes' })).base64,
244      load: key => $.store.get(key),
245      save: (key, value) => $.store.set(key, value),
246      known: () => read($, packages),
247      keep: found => update($, packages, () => found),
248    }
249    let reports: Report[] = []
250    if (command !== null || mcp) {
251      try {
252        const outcome = outcomeOf(ran)
253        const root = normalize(await scan.root())
254        // The engine's Bash and PowerShell results carry every optional key, `backgroundTaskId`
255        // undefined on a run that finished; only a run moved to the background (a timeout,
256        // Ctrl+B) names its task.
257        const backgrounded = isObject(ran.result) && typeof ran.result.backgroundTaskId === 'string'
258        if (command !== null && !backgrounded) reports = terminalReports(command, cwd ?? root, escapes, outcome)
259        else if (mcp) reports = mcpReports(e as Record<string, unknown>, root, outcome)
260        // Only a package can be the root or a folder directly below it; nothing else is read.
261        reports = reports.filter(r => /^[^/]+$/.test(relativeTo(root, r.target) ?? '/'))
262        // The fingerprint is taken now, as the run left the files, before anything else edits them.
263        const at = await $.clock.now()
264        const via = mcp ? 'mcp' : 'terminal'
265        const prints = new Map<string, string>()
266        for (const report of reports) {
267          const sha256 = prints.get(report.target) ?? (await fingerprint(scan, report.target))
268          prints.set(report.target, sha256)
269          pending.push({ report, run: { at, via, sha256 } })
270        }
271      } catch {
272        // A run the mod cannot read is a run it does not record.
273      }
274    }
275    // Any noacg run, read or not, in the background or not, brings a hidden band back.
276    if (mcp || (shell && runsNoacg(shell.command, escapes))) {
277      try {
278        if (await read($, hidden)) await update($, hidden, () => false)
279      } catch {
280        // The band stays as it was.
281      }
282    }
283    if (reports.length > 0 || ran.isReadOnly !== true) rescan(scan)
284    return ran
285  })
286
287  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288    if (e.props.hasSurvey) return next(e)
289    const [isHidden, list, offered, isPaneOpen] = await Promise.all([read($, hidden), read($, packages), read($, offer), read($, pane)])
290    if (isHidden || (list.length === 0 && !offered)) return next(e)
291    // The pane says all the band would while it is on screen. `pane` redraws the band when it
292    // opens and closes; the engine's record decides, since a pane can go without a close this
293    // module hears (an unload), or (seen at the band's next redraw) sit behind another tab.
294    if (isPaneOpen && (await $.ui.panes().catch(() => [])).some(onScreen)) return next(e)
295
296    const { Box, Button, Link, Text } = $.ui.resolve(e)
297    const fill = filler({
298      read: async () => (await $.prompt.read()).text,
299      fill: async (text, mode) => (await $.prompt.fill({ text, mode })).isFilled,
300      toast: text => $.ui.toast(text, { timeoutMs: TOAST_MS }),
301    })
302    const hide = <Button key="hide" label="Hide" dimColor role="dismiss" onPress={() => update($, hidden, () => true)} />
303
304    const focus = firstStep(list) ?? list.find(p => linkOf(p.record))
305    const step = focus?.next
306    const link = focus && linkOf(focus.record)
307    const room = Math.max(1, e.props.bodyColumns - TITLE.length - GAP)
308    return (
309      <Box key="band" flexDirection="row" gap={GAP}>
310        <Text bold>{TITLE}</Text>
311        {list.length === 0 ? (
312          <Box flexDirection="row" gap={1}>
313            <Button key="start" label="Start a package" variant="primary" onPress={fill(START)} />
314            {hide}
315          </Box>
316        ) : (
317          <Box flexDirection="column" flexShrink={1}>
318            <Text wrap="truncate-end">{fitNames(list.map(p => p.name), room)}</Text>
319            <Text wrap="truncate-end" dimColor>
320              {summary(list, room)}
321            </Text>
322            <Box flexDirection="row" gap={1}>
323              {step && (
324                <Box flexShrink={1}>
325                  <Text wrap="truncate-end">{step.label}</Text>
326                </Box>
327              )}
328              {step && <Button key="fill" label="Fill prompt" variant="primary" onPress={fill(step.request)} />}
329              {link && <Link href={link}>Open in NoaCG</Link>}
330              {hide}
331            </Box>
332            {/* A line of its own: beside the buttons it was cut at ordinary widths. */}
333            {step?.warning && (
334              <Text wrap="truncate-end" color="warning">
335                {step.warning}
336              </Text>
337            )}
338          </Box>
339        )}
340      </Box>
341    )
342  })
343
344  // The package pane: every package with its state, when each record was made, its frames
345  // folder and pack file, its saved or sent link, and its next step with a Fill button. It
346  // reads the same state as the band, so it follows every scan.
347  on('ui.render', { component: 'Pane', requestId: 'noacg-guide' }, async ($, e) => {
348    const [list, root, now] = await Promise.all([read($, packages), $.session.root().then(normalize), $.clock.now()])
349    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
350    const fill = filler({
351      read: async () => (await $.prompt.read()).text,
352      fill: async (text, mode) => (await $.prompt.fill({ text, mode })).isFilled,
353      toast: text => $.ui.toast(text, { timeoutMs: TOAST_MS }),
354    })
355    if (list.length === 0) {
356      return (
357        <Box flexDirection="column">
358          <Button key="start" label="Start a package" {...MAIN} onPress={fill(START)} />
359        </Box>
360      )
361    }
362    const first = firstStep(list)
363    return (
364      <Box flexDirection="column" gap={1}>
365        {list.map(p => {
366          const facts = factsOf(p.record)
367          return (
368            <Box key={`package:${p.dir}`} flexDirection="column">
369              <Box flexDirection="row" gap={2}>
370                <Box flexShrink={1}>
371                  <Text bold wrap="truncate-end">
372                    {p.name}
373                  </Text>
374                </Box>
375                <Text dimColor>{STATUS[p.status]}</Text>
376              </Box>
377              {facts.length > 0 && <Markdown text={factsMarkdown(facts, root, now)} dimColor />}
378              {p.next && (
379                <Box flexDirection="row" gap={1}>
380                  <Box flexShrink={1}>
381                    <Text>{p.next.label}</Text>
382                  </Box>
383                  <Button key={`fill:${p.dir}`} label="Fill" {...(p === first ? MAIN : {})} onPress={fill(p.next.request)} />
384                </Box>
385              )}
386              {p.next?.warning && <Text color="warning">{p.next.warning}</Text>}
387            </Box>
388          )
389        })}
390      </Box>
391    )
392  })
393}
394
hooks/band.ts 68 lines
1// The band's two lines, fitted to the cells they have: the package names, then where they stand.
2import type { NoacgPackage, PackageStatus } from '../types'
3
4export const SEPARATOR = ' · '
5
6export const STATUS: Record<PackageStatus, string> = { checked: 'checked', edited: 'edited since check', unchecked: 'not checked' }
7
8/**
9 * The names joined, or as many as fit followed by `+n more`, measured in terminal cells. With no
10 * room for even one name beside the count, a lone name or a count of them; the band's `Text`
11 * cuts what is still too long.
12 */
13export function fitNames(names: readonly string[], room: number): string {
14  const all = names.join(SEPARATOR)
15  if (cells(all) <= room) return all
16  for (let shown = names.length - 1; shown >= 1; shown--) {
17    const text = `${names.slice(0, shown).join(SEPARATOR)}${SEPARATOR}+${names.length - shown} more`
18    if (cells(text) <= room) return text
19  }
20  return names.length === 1 ? (names[0] ?? '') : `${names.length} packages`
21}
22
23/**
24 * How many packages are checked, edited since check and not checked, and which are saved: for one
25 * package its state alone ("checked · saved"), for several the counts and the saved names, as many
26 * as fit, else how many.
27 */
28export function summary(packages: readonly NoacgPackage[], room: number): string {
29  const saved = packages.filter(p => p.record.saved).map(p => p.name)
30  if (packages.length === 1) {
31    const only = packages[0]
32    return only ? STATUS[only.status] + (saved.length ? `${SEPARATOR}saved` : '') : ''
33  }
34  const counts = (['checked', 'edited', 'unchecked'] as const)
35    .map(status => [packages.filter(p => p.status === status).length, STATUS[status]] as const)
36    .filter(([count]) => count > 0)
37    .map(([count, word]) => `${count} ${word}`)
38    .join(SEPARATOR)
39  if (saved.length === 0) return counts
40  for (let shown = saved.length; shown >= 1; shown--) {
41    const more = shown < saved.length ? ` +${saved.length - shown} more` : ''
42    const text = `${counts}${SEPARATOR}saved: ${saved.slice(0, shown).join(', ')}${more}`
43    if (cells(text) <= room) return text
44  }
45  return `${counts}${SEPARATOR}${saved.length} saved`
46}
47
48/** Cells a text takes in a terminal: two for East Asian wide characters and most emoji. */
49function cells(text: string): number {
50  let width = 0
51  for (const char of text) width += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
52  return width
53}
54
55function isWide(code: number): boolean {
56  return (
57    (code >= 0x1100 && code <= 0x115f) ||
58    (code >= 0x2e80 && code <= 0xa4cf) ||
59    (code >= 0xac00 && code <= 0xd7a3) ||
60    (code >= 0xf900 && code <= 0xfaff) ||
61    (code >= 0xfe30 && code <= 0xfe4f) ||
62    (code >= 0xff00 && code <= 0xff60) ||
63    (code >= 0xffe0 && code <= 0xffe6) ||
64    (code >= 0x1f300 && code <= 0x1faff) ||
65    (code >= 0x20000 && code <= 0x3fffd)
66  )
67}
68
hooks/pane.ts 106 lines
1// What the pane and the text answer of `/noacg-guide` say about each package
2// (docs/work-specs/guided-mod/spec.md, AC-14 and AC-15): its state, when each record was made,
3// where its frames and pack file are, its saved or sent link, and its next step.
4import type { NoacgPackage, PackageRecord } from '../types'
5import { SEPARATOR, STATUS } from './band'
6import { START, webLink } from './steps'
7import { normalize, relativeTo } from './watch'
8
9/** One record of a package: what happened, when, and where it left something. */
10export type Fact = { word: string; at: number; folder?: string; file?: string; url?: string }
11
12/** A package's records in a fixed order: check, frames, save, pack. Paths absolute. */
13export function factsOf(record: PackageRecord): Fact[] {
14  const { check, frames, saved, pack } = record
15  const facts: Fact[] = []
16  if (check) facts.push({ word: check.ok ? 'Checked' : 'Check failed', at: check.at })
17  if (frames) facts.push({ word: 'Frames', at: frames.at, ...(frames.dir ? { folder: frames.dir } : {}) })
18  if (saved) facts.push({ word: 'Saved', at: saved.at, ...link(saved.url) })
19  if (pack?.url) facts.push({ word: 'Sent', at: pack.at, ...link(pack.url) })
20  else if (pack?.file) facts.push({ word: 'Pack written', at: pack.at, file: pack.file })
21  return facts
22}
23
24function link(url: string): { url?: string } {
25  const href = webLink(url)
26  return href ? { url: href } : {}
27}
28
29const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
30
31/** When a record was made, in local time: `14:02` today, `10 Oct 14:02` this year, else `10 Oct 2025`. */
32export function when(at: number, now: number): string {
33  const then = new Date(at)
34  const today = new Date(now)
35  const pad = (n: number) => String(n).padStart(2, '0')
36  const time = `${pad(then.getHours())}:${pad(then.getMinutes())}`
37  if (then.toDateString() === today.toDateString()) return time
38  const day = `${then.getDate()} ${MONTHS[then.getMonth()] ?? ''}`
39  return then.getFullYear() === today.getFullYear() ? `${day} ${time}` : `${day} ${then.getFullYear()}`
40}
41
42/** A path as the person reads it: relative to the project root when inside it, a folder with `/`. */
43export function shown(root: string, path: string, isFolder: boolean): string {
44  const inside = relativeTo(root, path)
45  const text = inside ?? normalize(path)
46  return isFolder ? `${text}/` : text
47}
48
49/** One character percent-encoded. */
50const percent = (c: string) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
51
52/** A URL with the characters that would end a Markdown link (`(`, `)`, space) encoded. */
53function inMarkdown(url: string): string {
54  return url.replace(/[ ()]/g, percent)
55}
56
57/** A `file:` URL for an absolute path, which `Markdown` draws as a link the surface opens. */
58export function fileUrl(path: string): string {
59  const clean = normalize(path)
60  return `file://${encodeURI(clean.startsWith('/') ? clean : `/${clean}`).replace(/[()#?]/g, percent)}`
61}
62
63/** Markdown's own characters in a label, escaped. */
64function escaped(text: string): string {
65  return text.replace(/[\\`*_[\]()<>#!|~]/g, c => `\\${c}`)
66}
67
68/**
69 * The facts on one line, joined by ` · `: as Markdown for the pane, where folders and files open
70 * and links open NoaCG, or as plain text for the text answer.
71 */
72function factsLine(facts: readonly Fact[], root: string, now: number, markdown: boolean): string {
73  return facts
74    .map(f => {
75      const head = `${f.word} ${when(f.at, now)}`
76      if (f.url) return markdown ? `${head} [Open in NoaCG](${inMarkdown(f.url)})` : `${head} ${f.url}`
77      const path = f.folder ?? f.file
78      if (!path) return head
79      const label = shown(root, path, f.folder !== undefined)
80      if (markdown) return `${head} [${escaped(label)}](${fileUrl(path)})`
81      return `${head} ${f.folder ? 'in' : 'to'} ${label}`
82    })
83    .join(SEPARATOR)
84}
85
86/** The pane's line of records for a package. */
87export function factsMarkdown(facts: readonly Fact[], root: string, now: number): string {
88  return factsLine(facts, root, now, true)
89}
90
91/**
92 * `/noacg-guide`'s answer where nothing draws a pane: the same list and next steps as plain text.
93 * The engine records it in the conversation, so a later turn's model reads it: kept short.
94 */
95export function textAnswer(packages: readonly NoacgPackage[], root: string, now: number): string {
96  if (packages.length === 0) return `No NoaCG package here. To start one, ask: ${START}`
97  const lines = ['NoaCG packages here:']
98  for (const p of packages) {
99    lines.push('', `${p.name}: ${STATUS[p.status]}`)
100    const facts = factsOf(p.record)
101    if (facts.length > 0) lines.push(`  ${factsLine(facts, root, now, false)}`)
102    if (p.next) lines.push(`  Next: ${p.next.label}${p.next.warning ? ` (${p.next.warning})` : ''}`)
103  }
104  return lines.join('\n')
105}
106
hooks/packages.ts 105 lines
1// Finding the NoaCG packages a session works on. Kept apart from the drawing; what the runs
2// reported about them is hooks/watch.ts's.
3//
4// A package is what `noacg scaffold` writes: a folder holding `<slug>.ograf.json` whose manifest
5// carries the `v_noacg` block (docs/AGENT_CLI.md, "The graphic package"). It is looked for in the
6// working directory and in each folder directly below it, never deeper. Frames folders (marked
7// by `.noacg-frames`, cli/src/workspace.ts), `node_modules` and dot folders are skipped. The
8// hooks hand in `$.fs.list` and `$.fs.read` on paths relative to the session's project root,
9// which they anchor there, so nothing outside it is read.
10import type { FsEntry } from 'claude-code'
11
12/** A package as found: its folder relative to the working directory and its name. */
13export type Found = { dir: string; name: string }
14
15/** The two reads a scan makes, relative to the working directory. */
16export type Folders = {
17  list: (dir: string) => Promise<FsEntry[]>
18  read: (file: string) => Promise<unknown>
19}
20
21const MANIFEST = /\.ograf\.json$/i
22/** The file that marks a folder as frames the CLI wrote (cli/src/workspace.ts). */
23export const FRAMES_MARKER = '.noacg-frames'
24/** A bound on the folders looked into, by name, so a session started in a huge folder stays cheap. */
25const MAX_FOLDERS = 500
26/** Folders listed at once. */
27const BATCH = 16
28
29type Manifests = Map<string, { stamp: string; name: string | null }>
30
31/** The manifests the last scan met, by path, size and time: a rescan reads only what changed. */
32let seen: Manifests = new Map()
33
34async function listed(fs: Folders, dir: string): Promise<FsEntry[] | null> {
35  try {
36    return await fs.list(dir)
37  } catch {
38    return null
39  }
40}
41
42/** The graphic's name when `file` is a NoaCG manifest ('' when it names none), else null. */
43async function manifestName(fs: Folders, file: string, entry: FsEntry, met: Manifests): Promise<string | null> {
44  const stamp = `${entry.size}:${entry.mtimeMs}`
45  const known = seen.get(file)
46  const name = known?.stamp === stamp ? known.name : await readName(fs, file)
47  met.set(file, { stamp, name })
48  return name
49}
50
51async function readName(fs: Folders, file: string): Promise<string | null> {
52  try {
53    const manifest: unknown = JSON.parse(String(await fs.read(file)))
54    if (!isObject(manifest) || !isObject(manifest.v_noacg)) return null
55    return typeof manifest.name === 'string' ? oneLine(manifest.name) : ''
56  } catch {
57    return null
58  }
59}
60
61async function packageIn(fs: Folders, dir: string, entries: FsEntry[], met: Manifests): Promise<Found | null> {
62  if (entries.some(e => e.kind === 'file' && e.name === FRAMES_MARKER)) return null
63  for (const entry of entries) {
64    if (entry.kind !== 'file' || !MANIFEST.test(entry.name)) continue
65    const file = dir === '.' ? entry.name : `${dir}/${entry.name}`
66    const name = await manifestName(fs, file, entry, met)
67    if (name !== null) return { dir, name: name || (dir === '.' ? 'This folder' : dir) }
68  }
69  return null
70}
71
72/** Every package in the working directory and one level below, sorted by name. */
73export async function findPackages(fs: Folders): Promise<Found[]> {
74  const top = await listed(fs, '.')
75  if (top === null) return []
76  const met: Manifests = new Map()
77  const here = await packageIn(fs, '.', top, met)
78  const found: Found[] = here ? [here] : []
79  const folders = top
80    .filter(e => e.kind === 'dir' && e.name !== 'node_modules' && !e.name.startsWith('.'))
81    .map(e => e.name)
82    .sort()
83    .slice(0, MAX_FOLDERS)
84  for (let at = 0; at < folders.length; at += BATCH) {
85    const batch = await Promise.all(
86      folders.slice(at, at + BATCH).map(async dir => {
87        const entries = await listed(fs, dir)
88        return entries && packageIn(fs, dir, entries, met)
89      }),
90    )
91    for (const one of batch) if (one) found.push(one)
92  }
93  seen = met
94  return found.sort((a, b) => a.name.localeCompare(b.name) || a.dir.localeCompare(b.dir))
95}
96
97/** A name as one line of plain text: control characters and runs of white space become a space. */
98function oneLine(text: string): string {
99  return text.replace(/[\u0000-\u001f\u007f-\u009f\s]+/g, ' ').trim()
100}
101
102export function isObject(value: unknown): value is Record<string, unknown> {
103  return typeof value === 'object' && value !== null && !Array.isArray(value)
104}
105
hooks/steps.ts 82 lines
1// One next step per package, from what its records say (docs/work-specs/guided-mod/spec.md,
2// AC-10), and which package the band names first. A step is a plain-language request the
3// person sends themselves: "Fill prompt" only puts it in the prompt box.
4import type { NextStep, NoacgPackage, PackageRecord } from '../types'
5import { statusOf } from './watch'
6
7/** What "Start a package" puts in the prompt box. */
8export const START = 'Make broadcast graphics with NoaCG.'
9
10const WARNING = 'saving again adds a second library copy'
11
12/**
13 * The step for a package from its records and the fingerprint of its files now (null when the
14 * scan took none: it has no passing check). Lowest rank first: 0 its last check failed,
15 * 1 edited since check, 2 not checked, 3 no frames of these files, 4 ready to save or send,
16 * 5 saved and edited since. Null once these files are saved, sent or packed, frames or not.
17 */
18export function stepOf(p: { dir: string; name: string; record: PackageRecord }, current: string | null): NextStep | null {
19  const { check, frames, saved, pack } = p.record
20  const status = statusOf(p.record, current)
21  const n = named(p.dir, p.name)
22  // A save records a passing check on the same files, so a later check's files differ from the
23  // save's exactly when the package changed since; without a fingerprint, the check's tell.
24  const files = current ?? check?.sha256
25  const warning = saved && files !== undefined && saved.sha256 !== files ? { warning: WARNING } : {}
26  const step = (rank: number, label: string, request: string): NextStep => ({ rank, label, request, ...warning })
27
28  // The check comes before the save: a save refused for errors keeps the old `saved` record.
29  if (check?.ok === false) return step(0, `Fix ${p.name} and check it again`, `Fix what the last check found in ${n}, then check it again.`)
30  // Files back to what was saved or packed were checked then: nothing to ask.
31  if (saved?.sha256 === current || pack?.sha256 === current) return null
32  if (status === 'edited') return step(1, `Check ${p.name} again`, `Check ${n} again; it changed since its last check.`)
33  if (status === 'unchecked') return step(2, `Check ${p.name}`, `Check ${n}.`)
34  if (frames?.sha256 !== current) return step(3, `See how ${p.name} looks`, `Show me how ${n} looks.`)
35  const send = 'or send all the graphics here to a production on NoaCG Home.'
36  if (!saved) return step(4, `Save ${p.name}, or send all graphics to a production`, `Save ${n} to my NoaCG library, ${send}`)
37  return step(5, `Save ${p.name} again, or send all graphics to a production`, `Save ${n} to my NoaCG library again, as a second copy, ${send}`)
38}
39
40/**
41 * The package as a request names it: with its folder, unless the folder only repeats the name
42 * ("Scores", not "Scores in scores/").
43 */
44function named(dir: string, name: string): string {
45  if (dir === '.') return `${name} (this folder)`
46  const slug = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '')
47  return slug(dir) === slug(name) ? name : `${name} (${dir}/)`
48}
49
50/** The package that needs attention first: the lowest rank, then the band's order (by name). */
51export function firstStep(packages: readonly NoacgPackage[]): NoacgPackage | undefined {
52  let first: NoacgPackage | undefined
53  for (const p of packages) if (p.next && (!first?.next || p.next.rank < first.next.rank)) first = p
54  return first
55}
56
57/**
58 * The link "Open in NoaCG" opens: the newer of the saved graphic and the sent pack. Only a web
59 * address: the links come from command output, which another command could have printed.
60 */
61export function linkOf(record: PackageRecord): string | undefined {
62  const { saved, pack } = record
63  const url = pack?.url && (!saved || pack.at >= saved.at) ? pack.url : saved?.url
64  return url ? webLink(url) : undefined
65}
66
67/**
68 * A link a `Link` element draws, spelled as the engine wants it (`new URL(href).href`), or
69 * undefined: only `https:` and `http://localhost` (a local stand-in for NoaCG), with no
70 * `user@host` part and at most 2048 characters; anything else would refuse the whole tree it is in.
71 */
72export function webLink(url: string): string | undefined {
73  try {
74    const parsed = new URL(url)
75    const isWeb = parsed.protocol === 'https:' || (parsed.protocol === 'http:' && parsed.hostname === 'localhost')
76    const fits = parsed.href.length <= 2048 && !parsed.href.includes('@')
77    return isWeb && fits ? parsed.href : undefined
78  } catch {
79    return undefined
80  }
81}
82
hooks/watch.ts 692 lines
1// State by watching (docs/work-specs/guided-mod/spec.md, "Decisions and boundaries"). The CLI
2// writes no receipts yet, so this module reads what `noacg` runs reported, fingerprints the
3// package files itself and keeps the records. It is the one module to replace when the CLI
4// answers a `status` read; register.tsx only calls what it exports.
5//
6// Three parts:
7// - Reading runs: a terminal command (`noacg ...`, `npx @noacg/cli ...`) or a call of the `noacg`
8//   MCP tool, and what it printed, become reports: checked or failed, frames, saved, packed.
9//   The output shapes are the published CLI 0.10.1's (cli/src/commands/*.ts, cli/src/mcp.ts).
10//   A check passes only on the CLI's own "OK - " verdict from a run that exited 0; a FAIL
11//   verdict fails it; anything unknown records nothing.
12// - Fingerprints: SHA-256 over a package's source and asset bytes, without the generated half
13//   `noacg validate` rewrites (cli/src/workspace.ts, GENERATED) and without frames folders.
14// - Records: per package, the newest run of each kind with the fingerprint it left, kept per
15//   working folder in the mod's own `$.store` (a file under the Claude Code configuration
16//   directory), never in a package or the repository.
17import type { FsEntry } from 'claude-code'
18
19import type { PackageRecord, PackageStatus, Run } from '../types'
20import { FRAMES_MARKER, isObject } from './packages'
21
22// ── Paths ──────────────────────────────────────────────────────────────────────────────────────
23
24const WINDOWS_DRIVE = /^[A-Za-z]:\//
25
26/** A path with forward slashes, `.` and `..` resolved and no trailing slash. */
27export function normalize(path: string): string {
28  const clean = path.replace(/\\/g, '/')
29  const drive = WINDOWS_DRIVE.test(clean) ? clean.slice(0, 2) : ''
30  const rest = drive ? clean.slice(2) : clean
31  const absolute = rest.startsWith('/')
32  const parts: string[] = []
33  for (const part of rest.split('/')) {
34    if (part === '' || part === '.') continue
35    if (part === '..' && parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
36    else if (part !== '..' || !absolute) parts.push(part)
37  }
38  const joined = parts.join('/')
39  if (absolute) return `${drive}/${joined}`
40  return drive ? `${drive}${joined}` : joined || '.'
41}
42
43function isAbsolute(path: string): boolean {
44  return /^([A-Za-z]:)?[\\/]/.test(path)
45}
46
47/** `path` resolved against the absolute `base`; against a Windows base, Git Bash's `/c/x` is `c:/x`. */
48export function resolvePath(base: string, path: string): string {
49  const gitBash = WINDOWS_DRIVE.test(normalize(base)) ? /^\/([A-Za-z])(\/|$)/.exec(path) : null
50  if (gitBash) return normalize(`${gitBash[1]}:/${path.slice(3)}`)
51  return normalize(isAbsolute(path) ? path : `${base}/${path}`)
52}
53
54/** How two paths compare: Windows paths ignore case. */
55function pathKey(path: string): string {
56  const clean = normalize(path)
57  return WINDOWS_DRIVE.test(clean) ? clean.toLowerCase() : clean
58}
59
60/** `path` relative to `root` (`.` for the root itself), or null when it lies outside. */
61export function relativeTo(root: string, path: string): string | null {
62  const from = pathKey(root)
63  const to = pathKey(path)
64  if (to === from) return '.'
65  const prefix = from.endsWith('/') ? from : `${from}/`
66  return to.startsWith(prefix) ? normalize(path).slice(prefix.length) : null
67}
68
69function dirname(path: string): string {
70  const clean = normalize(path)
71  const cut = clean.lastIndexOf('/')
72  return cut <= 0 ? clean.slice(0, cut + 1) || '.' : clean.slice(0, cut)
73}
74
75// ── Reading runs ───────────────────────────────────────────────────────────────────────────────
76
77/** What a run reported about the package at `target` (an absolute path). */
78export type Report =
79  | { kind: 'new'; target: string }
80  | { kind: 'check'; target: string; ok: boolean }
81  | { kind: 'frames'; target: string; dir: string | null }
82  | { kind: 'saved'; target: string; url: string }
83  | { kind: 'pack'; target: string; name: string; url?: string; file?: string }
84
85/** A tool call's outcome as the session saw it. */
86export type Outcome = { text: string; isError: boolean; images: number }
87
88const VERBS = ['scaffold', 'validate', 'screenshot', 'save', 'pack'] as const
89type Verb = (typeof VERBS)[number]
90
91type Args = { _: string[]; flags: Record<string, string | boolean | string[]> }
92
93/** One `noacg` run in a command line: the verb, its arguments, and the folder it ran in (null when unknown). */
94type Invocation = { verb: Verb; args: Args; cwd: string | null }
95
96/** The CLI's own argument parser (cli/src/output.ts), so a command reads here as it reads there. */
97function parseArgs(argv: string[]): Args {
98  const out: Args = { _: [], flags: {} }
99  let onlyPositional = false
100  for (let i = 0; i < argv.length; i++) {
101    const a = argv[i] ?? ''
102    if (onlyPositional || !a.startsWith('--')) {
103      out._.push(a)
104      continue
105    }
106    if (a === '--') {
107      onlyPositional = true
108      continue
109    }
110    const eq = a.indexOf('=')
111    let key = eq >= 0 ? a.slice(2, eq) : a.slice(2)
112    let value: string | boolean
113    if (eq >= 0) value = a.slice(eq + 1)
114    else if (key.startsWith('no-')) {
115      key = key.slice(3)
116      value = false
117    } else if (i + 1 < argv.length && !(argv[i + 1] ?? '').startsWith('--')) value = argv[++i] ?? ''
118    else value = true
119    const prev = out.flags[key]
120    if (prev === undefined) out.flags[key] = value
121    else if (Array.isArray(prev)) prev.push(String(value))
122    else out.flags[key] = [String(prev), String(value)]
123  }
124  return out
125}
126
127function flag(args: Args, key: string): string | undefined {
128  const v = args.flags[key]
129  if (v === undefined || typeof v === 'boolean') return undefined
130  return Array.isArray(v) ? v[v.length - 1] : v
131}
132
133/** A command line cut into its simple commands, each a list of words, quotes and redirections undone. */
134function commandsIn(line: string, escapes: boolean): string[][] {
135  const out: string[][] = []
136  let words: string[] = []
137  let word = ''
138  let has = false
139  let quote: string | null = null
140  let dropNext = false
141  const endWord = () => {
142    if (has) {
143      if (dropNext) dropNext = false
144      else words.push(word)
145    }
146    word = ''
147    has = false
148  }
149  const endCommand = () => {
150    endWord()
151    dropNext = false
152    if (words.length) out.push(words)
153    words = []
154  }
155  for (let i = 0; i < line.length; i++) {
156    const c = line[i] ?? ''
157    const next = line[i + 1] ?? ''
158    if (quote) {
159      if (c === quote) quote = null
160      else if (escapes && quote === '"' && c === '\\' && i + 1 < line.length) word += line[++i]
161      else word += c
162      continue
163    }
164    if (c === "'" || c === '"') {
165      quote = c
166      has = true
167    } else if (escapes && c === '\\') {
168      if (next !== '\n') {
169        word += next
170        has = true
171      }
172      i++
173    } else if (c === '`' && !escapes && (next === '\n' || next === '\r')) {
174      i += next === '\r' && line[i + 2] === '\n' ? 2 : 1
175    } else if (c === '>' || c === '<' || (c === '&' && next === '>')) {
176      // A redirection: a file descriptor before it goes, and so does its target unless it is `&N`.
177      if (/^\d+$/.test(word)) {
178        word = ''
179        has = false
180      }
181      endWord()
182      while (line[i + 1] === '>' || line[i + 1] === '&' || line[i + 1] === '|') i++
183      if (/\d/.test(line[i + 1] ?? '') && line[i] === '&') {
184        while (/\d/.test(line[i + 1] ?? '')) i++
185      } else dropNext = true
186    } else if (c === '(' || c === ')') {
187      // A subshell: marked, so a `cd` inside it ends with it.
188      endCommand()
189      out.push([c])
190    } else if (c === ';' || c === '\n' || c === '&' || c === '|') {
191      endCommand()
192      if ((c === '&' || c === '|') && next === c) i++
193    } else if (c === ' ' || c === '\t' || c === '\r') {
194      endWord()
195    } else {
196      word += c
197      has = true
198    }
199  }
200  endCommand()
201  return out
202}
203
204/** Words that may stand before a command: shell keywords and plain wrappers. */
205const PREFIXES = new Set(['do', 'then', 'else', '!', '{', 'env', 'time', 'command', 'exec', 'nohup'])
206const RUNNERS: Record<string, string[]> = { npx: [], bunx: [], npm: ['exec'], pnpm: ['dlx', 'exec'], yarn: ['dlx'] }
207
208/** Where the verb stands in a simple command that runs the noacg CLI, else null. */
209function verbAt(words: string[]): number | null {
210  let i = 0
211  while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '') || PREFIXES.has(words[i] ?? '')) i++
212  const program = (words[i] ?? '').replace(/^.*[\\/]/, '').replace(/\.(cmd|exe|ps1)$/i, '').toLowerCase()
213  if (program === 'noacg') return i + 1
214  const sub = RUNNERS[program]
215  if (!sub) return null
216  i++
217  if (sub.length) {
218    if (!sub.includes(words[i] ?? '')) return null
219    i++
220  }
221  while ((words[i] ?? '').startsWith('-')) {
222    const option = words[i]
223    i += option === '-p' || option === '--package' ? 2 : 1
224    if (option === '--') break
225  }
226  const name = words[i] ?? ''
227  if (!/^@noacg\/cli(@\S*)?$/.test(name) && name !== 'noacg') return null
228  return words[i + 1] === '--' ? i + 2 : i + 1
229}
230
231/** Whether a command line runs the noacg CLI, any verb: what brings a hidden band back. */
232export function runsNoacg(line: string, escapes: boolean): boolean {
233  return /noacg/i.test(line) && commandsIn(line, escapes).some(words => verbAt(words) !== null)
234}
235
236/** A word naming a folder this mod can follow: no variable, glob or home shorthand in it. */
237function plain(word: string | undefined): word is string {
238  return word !== undefined && word !== '' && !/[$*?~`{}]/.test(word)
239}
240
241/** The noacg runs of interest in a command line, each with the folder a `cd` before it left. */
242function invocationsIn(line: string, cwd: string, escapes: boolean): Invocation[] {
243  const found: Invocation[] = []
244  let here: string | null = cwd
245  const saved: (string | null)[] = []
246  for (const words of commandsIn(line, escapes)) {
247    const program = (words[0] ?? '').toLowerCase()
248    if (program === '(' || program === 'pushd' || program === 'push-location') saved.push(here)
249    if (program === ')' || program === 'popd' || program === 'pop-location') {
250      here = saved.length > 0 ? (saved.pop() ?? null) : null
251      continue
252    }
253    if (['cd', 'pushd', 'chdir', 'set-location', 'sl', 'push-location'].includes(program)) {
254      const to = words.slice(1).filter(w => !w.startsWith('-'))[0]
255      here = here !== null && plain(to) ? resolvePath(here, to) : null
256      continue
257    }
258    const at = verbAt(words)
259    const verb = at === null ? undefined : words[at]
260    if (at === null || !VERBS.includes(verb as Verb)) continue
261    found.push({ verb: verb as Verb, args: parseArgs(words.slice(at)), cwd: here })
262  }
263  return found
264}
265
266const HEADER = /^(OK|FAIL) - \d+ error\(s\)/
267const REGENERATED = /^Regenerated the package in (.+?)(?: \(the generated half was stale[^)]*\))?\.\s*$/
268const SCREENSHOTS = /^Screenshots: (.+?)(?:, |$)/
269const SHOT_FAILED = /^Screenshots stopped part way:/
270const STATE_FRAME = /^(off|onair|stress):$/
271
272/** One validate report in the output: the package it regenerated and its verdict. */
273type Block = { dir: string; ok: boolean; shotFailed: boolean; frames: string | null; shown: boolean }
274
275/** Every validate report that names the folder it regenerated, in order. */
276function blocksIn(text: string): Block[] {
277  const blocks: Block[] = []
278  let ok: boolean | undefined
279  let shotFailed = false
280  let frames: string | null = null
281  let shown = false
282  for (const line of text.split(/\r?\n/)) {
283    const header = HEADER.exec(line)
284    if (header) {
285      ok = header[1] === 'OK'
286      shotFailed = false
287      frames = null
288      shown = false
289      continue
290    }
291    if (SHOT_FAILED.test(line)) shotFailed = true
292    if (STATE_FRAME.test(line)) shown = true
293    const shots = SCREENSHOTS.exec(line)
294    if (shots?.[1]) frames = dirname(shots[1])
295    const done = REGENERATED.exec(line)
296    if (done?.[1] && ok !== undefined) {
297      blocks.push({ dir: normalize(done[1]), ok, shotFailed, frames, shown })
298      ok = undefined
299    }
300  }
301  return blocks
302}
303
304/** The verdict of a lone validate or save whose report names no folder: OK, FAIL, or unknown. */
305function verdictIn(text: string): boolean | undefined {
306  if (/^FAIL - \d+ error/m.test(text)) return false
307  if (/^OK - \d+ error/m.test(text)) return true
308  const json = /"ok":\s*(true|false)/.exec(text)
309  return json ? json[1] === 'true' : undefined
310}
311
312const SCAFFOLDED = /^Scaffolded "[^"\n]*" \([^)\n]*\) into (.+?)\s*$/gm
313const SAVED = /Saved "(?:[^"\n]*)" to (?:your|the) NoaCG library -> (\S+)/
314const SENT = /Sent "([^"\n]*)" \(\d+ graphic\(s\)\) to your NoaCG Home -> (\S+)/
315const WROTE_PACK = /^Wrote (.+?) with \d+ graphic\(s\)/m
316const WROTE_FRAME = /^Wrote (.+?) \(/gm
317const JSON_FILE = /"file": "((?:[^"\\]|\\.)*)"/g
318
319/** A JSON string's text, from between its quotes. */
320function jsonText(quoted: string | undefined): string | undefined {
321  try {
322    return quoted === undefined ? undefined : (JSON.parse(`"${quoted}"`) as string)
323  } catch {
324    return undefined
325  }
326}
327
328/**
329 * Reports from the validate blocks. A pass needs the OK verdict and every frame shot. `validate`
330 * exits 0 exactly then, so where several ran in one command each block is its own evidence; a
331 * lone one must also have ended a call that exited 0.
332 */
333function fromBlocks(blocks: Block[], isError: boolean, reports: Report[]): void {
334  for (const b of blocks) {
335    if (!b.ok) reports.push({ kind: 'check', target: b.dir, ok: false })
336    else if (!b.shotFailed && !(isError && blocks.length === 1)) reports.push({ kind: 'check', target: b.dir, ok: true })
337    if (b.frames !== null) reports.push({ kind: 'frames', target: b.dir, dir: b.frames })
338    else if (b.shown) reports.push({ kind: 'frames', target: b.dir, dir: null })
339  }
340}
341
342/** A scaffold's report: each folder it wrote starts afresh. */
343function fromScaffold(text: string, isError: boolean, reports: Report[]): void {
344  if (isError) return
345  for (const made of text.matchAll(SCAFFOLDED)) if (made[1]) reports.push({ kind: 'new', target: normalize(made[1]) })
346}
347
348/** A save's reports: saved with its link and a passing check; or a failing check. */
349function fromSave(target: string, text: string, isError: boolean, reports: Report[]): void {
350  const saved = SAVED.exec(text)
351  if (saved?.[1] && !isError) reports.push({ kind: 'check', target, ok: true }, { kind: 'saved', target, url: saved[1] })
352  else if (verdictIn(text) === false) reports.push({ kind: 'check', target, ok: false })
353}
354
355/** A pack's reports, one per package in it: where it went. */
356function fromPack(targets: string[], name: string | undefined, text: string, base: string, reports: Report[]): void {
357  const sent = SENT.exec(text)
358  const wrote = WROTE_PACK.exec(text)
359  if (!sent && !wrote) return
360  const file = wrote?.[1] ? resolvePath(base, wrote[1]) : undefined
361  const named = sent?.[1] ?? name ?? ''
362  for (const target of targets) {
363    reports.push({ kind: 'pack', target, name: named, ...(sent?.[2] ? { url: sent[2] } : {}), ...(file ? { file } : {}) })
364  }
365}
366
367/** A run that skipped part of the gate (`--no-bench`, `--no-house-contract`) passes nothing. */
368function withoutPasses(reports: Report[], weak: boolean): Report[] {
369  return weak ? reports.filter(r => !(r.kind === 'check' && r.ok)) : reports
370}
371
372/**
373 * What a terminal command's noacg runs reported. `cwd` is the folder the command started in;
374 * `escapes` is false for PowerShell, where a backslash is a path separator.
375 */
376export function terminalReports(command: string, cwd: string, escapes: boolean, outcome: Outcome): Report[] {
377  if (!/noacg/i.test(command)) return []
378  const runs = invocationsIn(command, cwd, escapes)
379  if (runs.length === 0) return []
380  const { text, isError } = outcome
381  const reports: Report[] = []
382  const count = (verb: Verb) => runs.filter(r => r.verb === verb).length
383  // A scaffold writes only into an empty folder, so it comes before any other run on that folder
384  // in the same command: `scaffold && validate` starts it afresh, then checks it.
385  if (count('scaffold')) fromScaffold(text, isError, reports)
386  const blocks = count('validate') ? blocksIn(text) : []
387  fromBlocks(blocks, isError, reports)
388
389  for (const run of runs) {
390    const here = run.cwd
391    if (here === null) continue
392    const input = run.args._[1]
393    const target = plain(input) ? resolvePath(here, input) : null
394    if (run.verb === 'validate' && target && count('validate') === 1 && blocks.length === 0) {
395      // A report cut short (`| tail`, `--json` with the log lines dropped): its verdict alone.
396      const ok = verdictIn(text)
397      const shotFailed = new RegExp(SHOT_FAILED.source, 'm').test(text)
398      if (ok === false) reports.push({ kind: 'check', target, ok: false })
399      else if (ok === true && !shotFailed && !isError) reports.push({ kind: 'check', target, ok: true })
400      const shots = flag(run.args, 'screenshots')
401      if (shots && plain(shots) && !isError) reports.push({ kind: 'frames', target, dir: resolvePath(here, shots) })
402    }
403    if (run.verb === 'screenshot' && target) {
404      const out = flag(run.args, 'out')
405      if (!out || !plain(out)) continue
406      const file = resolvePath(here, out)
407      // The file it names, in its line or its JSON: the exit code may be another command's.
408      const named = [...text.matchAll(WROTE_FRAME), ...text.matchAll(JSON_FILE)].map(m => (m[0].startsWith('"') ? jsonText(m[1]) : m[1]))
409      if (named.some(f => f !== undefined && pathKey(f) === pathKey(file))) reports.push({ kind: 'frames', target, dir: dirname(file) })
410    }
411    if (run.verb === 'save' && target && count('save') === 1) fromSave(target, text, isError, reports)
412    if (run.verb === 'pack' && count('pack') === 1) {
413      // `--save` and `--share` are switches that take the next word; the CLI gives it back.
414      const inputs = run.args._.slice(1)
415      for (const key of ['share', 'save']) {
416        const taken = run.args.flags[key]
417        if (typeof taken === 'string') inputs.unshift(taken)
418      }
419      fromPack(inputs.filter(plain).map(p => resolvePath(here, p)), flag(run.args, 'name'), text, here, reports)
420    }
421  }
422  return withoutPasses(
423    reports,
424    runs.some(r => r.args.flags.bench === false || r.args.flags['house-contract'] === false),
425  )
426}
427
428/** Whether a tool is the `noacg` MCP tool, under whatever name its server was added. */
429export function isNoacgTool(tool: string): boolean {
430  return /^mcp__.+__noacg$/.test(tool)
431}
432
433/**
434 * What a call of the `noacg` MCP tool reported. `input` is the call's arguments; relative paths
435 * are the server's, which runs in the session's folder, `root`.
436 */
437export function mcpReports(input: Record<string, unknown>, root: string, outcome: Outcome): Report[] {
438  const { text, isError } = outcome
439  const reports: Report[] = []
440  const path = typeof input.path === 'string' && input.path ? resolvePath(root, input.path) : null
441  switch (input.command) {
442    case 'scaffold':
443      fromScaffold(text, isError, reports)
444      break
445    case 'validate': {
446      // The frames come back as images; a validate that showed some names no folder.
447      const blocks = blocksIn(text)
448      fromBlocks(blocks, isError, reports)
449      const block = blocks[0]
450      if (block && !block.shown && input.screenshots === true && outcome.images > 0) {
451        reports.push({ kind: 'frames', target: block.dir, dir: null })
452      }
453      break
454    }
455    case 'screenshot':
456      if (path && !isError) reports.push({ kind: 'frames', target: path, dir: null })
457      break
458    case 'save':
459      if (path) fromSave(path, text, isError, reports)
460      break
461    case 'pack': {
462      const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === 'string' && p !== '') : []
463      fromPack(
464        paths.map(p => resolvePath(root, p)),
465        typeof input.name === 'string' ? input.name.trim() : undefined,
466        text,
467        root,
468        reports,
469      )
470      break
471    }
472  }
473  return withoutPasses(reports, input.bench === false || input.houseContract === false)
474}
475
476/** A tool result's text and image count: MCP content blocks, a plain string, or what the model read. */
477export function outcomeOf(ran: { result?: unknown; text?: string; isError?: true }): Outcome {
478  const texts: string[] = []
479  let images = 0
480  const blocks = Array.isArray(ran.result)
481    ? ran.result
482    : isObject(ran.result) && Array.isArray(ran.result.content)
483      ? ran.result.content
484      : null
485  for (const block of blocks ?? []) {
486    if (!isObject(block)) continue
487    if (block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
488    if (block.type === 'image') images++
489  }
490  if (texts.length === 0 && typeof ran.text === 'string') texts.push(ran.text)
491  if (texts.length === 0 && isObject(ran.result)) {
492    for (const key of ['stdout', 'stderr'] as const) {
493      const value = ran.result[key]
494      if (typeof value === 'string' && value) texts.push(value)
495    }
496  }
497  if (texts.length === 0 && typeof ran.result === 'string') texts.push(ran.result)
498  return { text: texts.join('\n'), isError: ran.isError === true, images }
499}
500
501// ── Fingerprints ───────────────────────────────────────────────────────────────────────────────
502
503/** The two reads a fingerprint makes, on absolute paths. */
504export type Reader = {
505  list: (dir: string) => Promise<FsEntry[]>
506  /** A file's bytes, base64. */
507  bytes: (file: string) => Promise<string>
508}
509
510/** The generated half of a package, at its top level (cli/src/workspace.ts). */
511const GENERATED = [/\.ograf\.json$/i, /^graphic\.mjs$/, /^FIELDS\.md$/, /^README\.md$/, /^GETTING-ON-AIR\.md$/, /^controlpanel\.html$/, /^thumbnail\.png$/]
512/** `$.fs.read` refuses more; a larger file counts by its size and time. */
513const MAX_READ = 4 * 1024 * 1024
514const MAX_FILES = 2000
515const MAX_DEPTH = 8
516
517/** Each file's digest, by absolute path, kept while its size and time stay the same. */
518const digests = new Map<string, { stamp: string; hex: string }>()
519
520type FileEntry = { rel: string; abs: string; size: number; mtimeMs: number }
521
522async function filesOf(fs: Reader, dir: string, rel: string, depth: number, into: FileEntry[]): Promise<void> {
523  let entries: FsEntry[]
524  try {
525    entries = await fs.list(dir)
526  } catch {
527    into.push({ rel: `${rel}/`, abs: '', size: -1, mtimeMs: 0 })
528    return
529  }
530  if (rel !== '' && entries.some(e => e.kind === 'file' && e.name === FRAMES_MARKER)) return
531  for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
532    if (into.length >= MAX_FILES) return
533    const path = rel === '' ? entry.name : `${rel}/${entry.name}`
534    if (entry.kind === 'dir') {
535      if (entry.name === 'node_modules' || entry.name === '.git' || depth >= MAX_DEPTH) continue
536      await filesOf(fs, `${dir}/${entry.name}`, path, depth + 1, into)
537    } else if (entry.kind === 'file') {
538      if (rel === '' && GENERATED.some(re => re.test(entry.name))) continue
539      into.push({ rel: path, abs: `${dir}/${entry.name}`, size: entry.size, mtimeMs: entry.mtimeMs })
540    }
541  }
542}
543
544async function digestOf(fs: Reader, file: FileEntry): Promise<string> {
545  if (file.size < 0) return 'unlisted'
546  const stamp = `${file.size}:${file.mtimeMs}`
547  const known = digests.get(file.abs)
548  if (known?.stamp === stamp) return known.hex
549  let hex: string
550  if (file.size > MAX_READ) hex = `large:${stamp}`
551  else {
552    try {
553      hex = sha256(fromBase64(await fs.bytes(file.abs)))
554    } catch {
555      // A file held open a moment (an editor, a virus scan) is read again next time.
556      return `unreadable:${stamp}`
557    }
558  }
559  digests.set(file.abs, { stamp, hex })
560  return hex
561}
562
563/**
564 * SHA-256 over a package's source and asset bytes: each file's path and SHA-256, in path order.
565 * The generated files at its top, frames folders, `node_modules` and `.git` are left out, as
566 * the CLI leaves them out of what it reads. `dir` is the package's absolute folder.
567 */
568export async function fingerprint(fs: Reader, dir: string): Promise<string> {
569  const files: FileEntry[] = []
570  await filesOf(fs, dir, '', 0, files)
571  let manifest = ''
572  for (const file of files) manifest += `${file.rel}\u0000${await digestOf(fs, file)}\n`
573  return sha256(new TextEncoder().encode(manifest))
574}
575
576const K = new Uint32Array([
577  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
578  0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
579  0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
580  0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
581  0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
582  0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
583])
584
585/**
586 * SHA-256 (FIPS 180-4), computed in place rather than with `crypto.subtle`: the engine's digest
587 * resolves off the event loop, so a scan's end could not be awaited, and under load the band and
588 * its tests saw a scan half done.
589 */
590export function sha256(bytes: Uint8Array): string {
591  const length = bytes.length
592  const padded = new Uint8Array((((length + 8) >> 6) + 1) << 6)
593  padded.set(bytes)
594  padded[length] = 0x80
595  const view = new DataView(padded.buffer)
596  view.setUint32(padded.length - 8, Math.floor(length / 0x20000000))
597  view.setUint32(padded.length - 4, (length << 3) >>> 0)
598  const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
599  const w = new Uint32Array(64)
600  const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n))
601  for (let at = 0; at < padded.length; at += 64) {
602    for (let i = 0; i < 16; i++) w[i] = view.getUint32(at + i * 4)
603    for (let i = 16; i < 64; i++) {
604      const a = w[i - 15] ?? 0
605      const b = w[i - 2] ?? 0
606      const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)
607      const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10)
608      w[i] = (w[i - 16] ?? 0) + s0 + (w[i - 7] ?? 0) + s1
609    }
610    let a = h[0]!
611    let b = h[1]!
612    let c = h[2]!
613    let d = h[3]!
614    let e = h[4]!
615    let f = h[5]!
616    let g = h[6]!
617    let hh = h[7]!
618    for (let i = 0; i < 64; i++) {
619      const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i]! + w[i]!) | 0
620      const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
621      hh = g
622      g = f
623      f = e
624      e = (d + t1) | 0
625      d = c
626      c = b
627      b = a
628      a = (t1 + t2) | 0
629    }
630    h[0]! += a
631    h[1]! += b
632    h[2]! += c
633    h[3]! += d
634    h[4]! += e
635    h[5]! += f
636    h[6]! += g
637    h[7]! += hh
638  }
639  let hex = ''
640  for (const word of h) hex += word.toString(16).padStart(8, '0')
641  return hex
642}
643
644function fromBase64(base64: string): Uint8Array {
645  const binary = atob(base64)
646  const bytes = new Uint8Array(binary.length)
647  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
648  return bytes
649}
650
651// ── Records ────────────────────────────────────────────────────────────────────────────────────
652
653/** Every package's record in one working folder, by its folder relative to it. */
654export type Records = Record<string, PackageRecord>
655
656/** The store key for a working folder: one entry per folder on this machine. */
657export function storeKey(root: string): string {
658  return `records:${pathKey(root)}`
659}
660
661/** Records as the store held them; anything else is no records. */
662export function recordsFrom(stored: unknown): Records {
663  if (!isObject(stored) || !isObject(stored.packages)) return {}
664  const records: Records = {}
665  for (const [dir, record] of Object.entries(stored.packages)) if (isObject(record)) records[dir] = record as PackageRecord
666  return records
667}
668
669/** A record with one report folded in, at the run's time and fingerprint. */
670export function withReport(record: PackageRecord, report: Report, run: Run): PackageRecord {
671  switch (report.kind) {
672    case 'new':
673      return {}
674    case 'check':
675      return { ...record, check: { ...run, ok: report.ok } }
676    case 'frames':
677      return { ...record, frames: { ...run, dir: report.dir } }
678    case 'saved':
679      return { ...record, saved: { ...run, url: report.url } }
680    case 'pack': {
681      const { kind, target, ...pack } = report
682      return { ...record, pack: { ...run, ...pack } }
683    }
684  }
685}
686
687/** Checked while its files are those the passing check saw; edited since check after that. */
688export function statusOf(record: PackageRecord, current: string | null): PackageStatus {
689  if (!record.check?.ok) return 'unchecked'
690  return record.check.sha256 === current ? 'checked' : 'edited'
691}
692
types/index.d.ts 65 lines
1/** Where a package stands: its last passing check against its files as they are now. */
2export type PackageStatus = 'checked' | 'edited' | 'unchecked'
3
4/** One run the mod saw, tied to the package's files as the run left them. */
5export type Run = {
6  /** When the run finished, milliseconds since the epoch. */
7  at: number
8  /** SHA-256 over the package's source and asset bytes after the run (hooks/watch.ts). */
9  sha256: string
10  /** `terminal` for `noacg ...` or `npx @noacg/cli ...`, `mcp` for the `noacg` MCP tool. */
11  via: 'terminal' | 'mcp'
12}
13
14/** What `noacg` runs reported for one package, the newest of each kind. */
15export type PackageRecord = {
16  /** The last `validate` or `save` whose outcome was known: `ok` only when validation passed and the run exited 0. */
17  check?: Run & { ok: boolean }
18  /** The last frames written or shown: `dir` the absolute folder, null when they came back as images in the conversation. */
19  frames?: Run & { dir: string | null }
20  /** The last `save` to the NoaCG library, with the link it returned. */
21  saved?: Run & { url: string }
22  /** The last `pack` this package went into: sent to NoaCG Home (`url`) or written to a file (`file`). */
23  pack?: Run & { name: string; url?: string; file?: string }
24}
25
26/** A NoaCG package found in the working directory or one level below it. */
27export type NoacgPackage = {
28  /** Its folder relative to the working directory: `.` for the working directory itself. */
29  dir: string
30  /** The graphic's name from its manifest, or the folder's name when the manifest has none. */
31  name: string
32  /** Checked, edited since check, or not checked. */
33  status: PackageStatus
34  /** What the runs reported; empty when no run touched it. */
35  record: PackageRecord
36  /** What to ask for next (hooks/steps.ts), or null once its files as they are now are saved, sent or packed. */
37  next: NextStep | null
38}
39
40/** One next step for a package, in plain words. */
41export type NextStep = {
42  /** How soon it needs attention, lowest first: a failed check, edited since check, not checked, no frames, ready, saved and edited since. */
43  rank: number
44  /** What the band shows. */
45  label: string
46  /** What "Fill prompt" puts in the prompt box for the person to send. */
47  request: string
48  /** Set when the package was saved and its files changed since: saving again adds a second library copy. Drawn on a line of its own. */
49  warning?: string
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'noacg-guide': {
55      packages: NoacgPackage[]
56      /** The band is hidden until `/noacg-guide` or the next `noacg` run. */
57      hidden: boolean
58      /** `/noacg-guide` ran: with no package here, the band offers to start one. */
59      offer: boolean
60      /** The package pane is drawn; the band steps aside while it is. */
61      pane: boolean
62    }
63  }
64}
65