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

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.
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
/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.
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.
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.
claude -p get the text answer; Codex has no mods.noacg runs reported.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.
hooks/register.tsx 394 lines1// 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}
394hooks/band.ts 68 lines1// 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}
68hooks/pane.ts 106 lines1// 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}
106hooks/packages.ts 105 lines1// 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}
105hooks/steps.ts 82 lines1// 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}
82hooks/watch.ts 692 lines1// 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}
692types/index.d.ts 65 lines1/** 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