Optional article status band, review panel and explicit preflight controls for Claude Blog.

Blog Studio is an optional companion to claude-blog. It adds a compact article status band, a live pipeline and five-gate review panel, a history of past blogs, and an explicit Run checks control. Version 0.2.0. It ships in the claude-blog repository as a second plugin of the same marketplace, and it is not part of the claude-blog plugin itself.
/plugin marketplace add AgriciDaniel/claude-blog
/plugin install blog-studio@agricidaniel-blog
Neither the core plugin nor the standalone installers enable it, and nothing loads until you install it.
Blog Studio is a Claude Code mod (function-hook plugin), so it needs a Claude Code release with mods support. It was developed and checked on the Linux CLI, 2.1.294 to 2.1.296. The panel uses host theme colors and the shared terminal/Desktop element set. Desktop fixture tests do not establish native Desktop support. Codex does not run Claude Code mods.
Once installed:
/blog-studio configure /path/to/claude-blog/scripts. It is remembered for every session and project until /blog-studio configure reset. Setting the existing CLAUDE_BLOG_SCRIPTS_DIR before starting a session overrides it. The directory must include both blog_preflight.py and blog_studio_status.py./blog-studio select outputs/article. Spaces in the directory are accepted literally; do not wrap the path in shell quotes./blog-studio, then choose Refresh or Run checks. Refresh only inspects saved evidence. Run checks invokes the existing strict five-gate preflight, including its browser and public-link checks.Python 3.11 or newer must be available as python3 on PATH with the engine's already-installed optional dependencies. The mod never installs them. Configure PATH before the session to select a virtual environment. No shell is used.
| Command | Behavior |
|---|---|
/blog-studio | Open the pipeline and gates panel, or show the text fallback |
/blog-studio status | Show cached state, last check time and the pipeline line |
/blog-studio history | List past blogs from every project, newest first, with date, time, status and gate count |
/blog-studio history scan [folder] | Backfill the history from preflight-report.json files under a folder (default: this project) |
/blog-studio open <number> | Select a blog from the history, in this or another project |
/blog-studio demo | Show a sample pipeline mid-draft, to judge the design without an agent |
/blog-studio reset | Clear the pipeline view |
/blog-studio select <directory> | Select a project-relative draft |
/blog-studio configure <scripts> | Pin trusted absolute engine helpers |
/blog-studio refresh | Inspect files and saved evidence without network or writes |
/blog-studio check | Run all strict preflight gates, then verify the saved run |
The panel includes Select draft, Refresh, Run checks, and Close. Escape closes the panel. Duplicate runs are refused. Checks time out after ten minutes and status inspection after thirty seconds. Selection and cached results clear on workspace/session changes, clear, resume, and branch. Reload keeps a selection only in the same workspace/session and invalidates cached and in-flight results. A run already started may finish writing its selected draft after a clear or workspace switch; its result cannot populate the new session's panel. The companion passes --project-root to the checker. On Linux, the checker opens the selected directory through descriptors without following symlinks and anchors its working directory there. Replacing the selected path after launch cannot redirect its report writes to a different directory.
While the agent writes a blog, the band and panel follow it through seven stages in order: Research, Outline, Draft, Visuals, Render, Review and Gates. The band is one row: a progress bar, a stage rail (✓ done, ▸ working, · waiting, ✗ failed) and a clock. It shortens itself on narrow terminals and yields to surveys and other bands.
The stages are inferred, never claimed. Blog Studio watches tool calls read-only and without any model call: web search and fetch, writes of Markdown, SVG and image files, the blog-researcher, blog-writer, blog-reviewer and blog-seo subagents, and the engine scripts generate_hero.py, blog_render.py and blog_preflight.py. A stage is marked done when a later one starts, or when the preflight evidence proves it. Evidence always wins over inference, and a passing preflight marks every stage verified. A failing render or preflight command marks that stage failed. When the agent writes a draft outside the selected folder, the panel offers it as "Use this draft".
Colors are host theme keys only, so light and dark themes both work. Every status also has its own glyph and word, so nothing depends on color alone.
Every blog Blog Studio sees is recorded in the mod's own machine-wide store, newest activity first: drafts the agent writes, drafts you select, and drafts you check (with their result). The panel lists the latest five under Recent blogs, with a date, time, status glyph and gate count; All blogs (h) shows 25. Keys 1 to 9 open an entry. /blog-studio history scan <folder> adds older blogs from their delivery reports, dated by the report's file time. History holds paths and results only, never article text.
A blog from another project is re-validated before it opens: absolute root, relative draft, and no symlinks anywhere on the path. Checks then run anchored to that project, and the panel shows its root. A stored record that fails validation is dropped.
Preflight passed means the saved run was strict, requested and completed all five gates, and its local input/output fingerprints still match. It describes that recorded check, not publication approval or continuing network availability. Run Refresh after edits made outside Claude. Starting a new turn clears cached proof, and turn completion performs a read-only refresh for the selected draft.
Other states are Blocked, Partial, Stale, Unverified, Running, and Execution error. Later gates after a fail-fast blocker remain Not checked. Legacy reports are readable, but freshness is Unverified. Single-gate passes and preview runs cannot become full preflight success. Missing helpers, malformed output, truncation, crashes, timeouts, and mismatched run identities remain visible.
The panel displays gate diagnostics, reviewer score, and relative artifact paths. It does not read the article body for display, reveal the review nonce, write a review, rotate verifier state, reset repair counters, or repair articles. The analyzer's configurable 70-point gate and the separate delivery requirement (score at least 90, zero uncleared P0 issues, valid matching nonce and nonblocking decision) stay in the core engine. Manual checks do not consume repair iterations.
preflight-report.json keeps its existing fields and adds evidence, version 1: run identity, UTC start/end times, requested/completed gates, contract version, engine identity, input fingerprints, an unchanged-input flag, and generated output fingerprints. Existing stdout modes, flags and exit statuses remain.
The input snapshot conservatively includes regular files below the selected draft, including local CSS, scripts, images, fonts and review/config files. It excludes preflight-report.json, capabilities.json, .iteration-count, the four canonical screenshots under preview, and temporary files for those generated reports. Other resources below preview remain fingerprinted inputs. The verifier state is fingerprinted outside the draft without exposing its contents. Engine scripts and the delivery contract/reviewer instructions are fingerprinted too. Python runtime changes invalidate saved evidence.
Hashing streams large files. Limits are 4,096 files and 512 MiB per snapshot, with a 1 MiB report limit. Symlinks, unsupported file types, unavailable files, and limit exhaustion cannot establish freshness. Generated capabilities and preview artifacts receive a separate post-run snapshot.
Fingerprints are freshness evidence, not signatures. An actor able to forge reports can forge metadata. Delivery still requires the canonical preflight and review process; the UI does not add an approval authority.
The module registers only session lifecycle, turn lifecycle, its own command, and Pane/AbovePrompt rendering hooks. It reads the pinned scripts environment variable, path metadata, session root/id/surfaces and tool names. It stores session-scoped plain state and starts only the configured Python helpers with fixed argument vectors. Render handlers only read cached state and draw.
There are no tool permission hooks, prompt rewriting, transcript reads, model calls, HTTP calls, cross-session messaging, automatic dependency installation, or persistent configuration changes. The checker retains its own existing rendering and network behavior. The status band composes with other mods and yields to surveys or slots too small to share.
Mods run with the user's permissions. A disposable project is not isolation. Read hooks/ before installing, as with any plugin, and try it first in a project without secrets if you want extra margin. Official permissions guidance
The manifest and hook entry define the optional package. Its development dependencies and lockfile stay separate from the core. The source pin identifies the public reference declarations; reference and native configurations keep the two type checks distinct.
npm ci --ignore-scripts --no-audit --no-fund
npm run typecheck:reference
claude plugin validate --strict --json .
claude plugin test .
The reference type check covers production code using hash-pinned official public 2.1.277 declarations, plus a small explicitly provisional state-method contract. That public file predates the current state API. The reference check does not type-check test-kit code against an obsolete kit contract. The actual 2.1.294 test kit executes those tests without a session, sign-in, or network. Official testing documentation
Fresh host-generated 2.1.294 typings are a separate required native gate:
npm run typecheck:native
This command requires the generated .claude-plugin/types/claude-code/index.d.ts with the exact target build header, and checks production plus test code without the provisional reference shim. Do not substitute old captures or change their version header. Generated types and development dependencies are ignored and excluded from maintained-source scans.
Still open (honest status): native acceptance on your own terminal theme and glyphs, pane height on very tall terminals, reload and branch/resume behavior under real agent work, and the Desktop app (fixture tests only). Do not describe fixture drawings as native paint. Official Mods reference
Rollback is /plugin uninstall blog-studio@agricidaniel-blog. Nothing else in the profile is touched, and the stored history is per plugin and holds paths and results only, never article text.
hooks/register.ts 457 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import type { HistoryEntry, HistoryState, RunState, StudioState, Status } from '../types'
3import { absoluteRoot, absoluteScripts, clean, EMPTY, jsonResponse, parseStatus, relativeDraft, summary } from './logic'
4import { classify, demoRun, emptyRun, fail, pause, reduce, skillSignal, type Signal } from './pipeline'
5import { formatWhen, KEY_PREFIX, makeEntry, MAX_SHOWN, MAX_STORED, mergeEntries, parseFound, sortEntries, splitDraft, statusGlyph, title, validEntry } from './history'
6import { bandDetail, bandRow, effective, el, pane, pipelineLine, type PaneActions } from './view'
7
8const PANE = 'blog-studio-review'
9const REF = { plugin: 'blog-studio', key: 'studio' } as const
10const RUN = { plugin: 'blog-studio', key: 'run' } as const
11const HIST = { plugin: 'blog-studio', key: 'history' } as const
12const CONFIG = 'config'
13const HELPER = 'blog_studio_status.py'
14const CHECKER = 'blog_preflight.py'
15
16async function held($: EngineInterface) {
17 const result = await $.state.get(REF)
18 return { value: result.value ?? { ...EMPTY }, version: result.version }
19}
20
21async function heldRun($: EngineInterface) {
22 const result = await $.state.get(RUN)
23 return { value: result.value ?? emptyRun(), version: result.version }
24}
25
26// Display-only bookkeeping: a lost race only drops one counter tick, so retry twice and give up quietly.
27async function updateRun($: EngineInterface, change: (run: RunState) => RunState) {
28 for (let attempt = 0; attempt < 3; attempt++) {
29 const current = await heldRun($)
30 let base = current.value
31 if (!base.workspace) base = { ...base, workspace: (await $.session.root()).replace(/\/$/, '') || '/', sessionId: await $.session.id() }
32 if ((await $.state.set(RUN, change(base), { ifVersion: current.version })).isSet) return
33 }
34}
35
36let ticker: Timer | null = null
37
38function startTicker($: EngineInterface) {
39 if (ticker) return
40 ticker = $.clock.every(1000, () => { $.ui.invalidate('ui.render') })
41}
42
43function stopTicker() {
44 ticker?.cancel()
45 ticker = null
46}
47
48const rootOf = (s: StudioState) => s.projectRoot ?? s.workspace
49
50async function heldHistory($: EngineInterface) {
51 const result = await $.state.get(HIST)
52 return { value: result.value ?? { entries: [], loaded: false } as HistoryState, version: result.version }
53}
54
55async function publishHistory($: EngineInterface, change: (entries: HistoryEntry[]) => HistoryEntry[]) {
56 for (let attempt = 0; attempt < 3; attempt++) {
57 const current = await heldHistory($)
58 const entries = change(current.value.entries).slice(0, MAX_SHOWN)
59 if ((await $.state.set(HIST, { entries, loaded: true }, { ifVersion: current.version })).isSet) return
60 }
61}
62
63// Durable history lives in $.store, one key per draft folder. $.state mirrors the newest few so drawing never does I/O.
64async function loadHistory($: EngineInterface) {
65 const keys = (await $.store.keys()).filter(k => k.startsWith(KEY_PREFIX))
66 const entries: HistoryEntry[] = []
67 for (const key of keys) { const v = await $.store.get(key); if (validEntry(v)) entries.push(v) }
68 const sorted = sortEntries(entries)
69 await publishHistory($, () => sorted)
70}
71
72async function recordEntries($: EngineInterface, incoming: HistoryEntry[]) {
73 const merged: HistoryEntry[] = []
74 for (const entry of incoming) {
75 const old = await $.store.get(KEY_PREFIX + entry.id)
76 const next = mergeEntries(validEntry(old) ? [old] : [], entry)[0]
77 if (!next) continue
78 await $.store.set(KEY_PREFIX + next.id, next)
79 merged.push(next)
80 }
81 const keys = (await $.store.keys()).filter(k => k.startsWith(KEY_PREFIX))
82 if (keys.length > MAX_STORED) {
83 const all: HistoryEntry[] = []
84 for (const key of keys) { const v = await $.store.get(key); if (validEntry(v)) all.push(v); else await $.store.delete(key) }
85 for (const stale of sortEntries(all).slice(MAX_STORED)) await $.store.delete(KEY_PREFIX + stale.id)
86 }
87 await publishHistory($, entries => merged.reduce(mergeEntries, entries))
88}
89
90async function remember($: EngineInterface, entry: HistoryEntry) {
91 try { await recordEntries($, [entry]) } catch { /* history is a convenience, never a failure */ }
92}
93
94async function savedScripts($: EngineInterface): Promise<string | null> {
95 try {
96 const value = await $.store.get(CONFIG)
97 const scripts = value && typeof value === 'object' ? (value as { scripts?: unknown }).scripts : null
98 return typeof scripts === 'string' ? absoluteScripts(scripts) : null
99 } catch { return null }
100}
101
102async function context($: EngineInterface) {
103 const current = await held($)
104 const workspace = (await $.session.root()).replace(/\/$/, '') || '/'
105 const sessionId = await $.session.id()
106 if (current.value.workspace === workspace && current.value.sessionId === sessionId) return current
107 const pinned = await $.env.get('CLAUDE_BLOG_SCRIPTS_DIR')
108 const value = { ...EMPTY, workspace, sessionId, generation: current.value.generation + 1,
109 scripts: pinned ? absoluteScripts(pinned) : await savedScripts($) }
110 const written = await $.state.set(REF, value, { ifVersion: current.version })
111 if (!written.isSet) throw new Error('Session changed. Try again.')
112 return { value, version: written.version }
113}
114
115async function noLinks($: EngineInterface, absolute: string, kind: 'file' | 'dir') {
116 let path = ''
117 const parts = absolute.split('/').filter(Boolean)
118 for (let i = 0; i < parts.length; i++) {
119 path += '/' + parts[i]
120 const info = await $.fs.stat(path).catch(() => undefined)
121 if (!info || info.isLink || info.kind !== (i === parts.length - 1 ? kind : 'dir')) {
122 throw new Error('Missing or unsafe path. Symlinks are refused.')
123 }
124 }
125}
126
127async function prepare($: EngineInterface, s: StudioState) {
128 if (!s.scripts) throw new Error('Configure the trusted claude-blog scripts directory first.')
129 if (!s.draft) throw new Error('Select a draft first.')
130 const root = rootOf(s)
131 const draft = (root === '/' ? '' : root) + '/' + relativeDraft(s.draft)
132 const scripts = absoluteScripts(s.scripts)
133 if (scripts === draft || scripts.startsWith(draft + '/')) throw new Error('Executable helpers cannot come from the selected draft.')
134 await noLinks($, draft, 'dir')
135 await noLinks($, scripts + '/' + HELPER, 'file')
136 await noLinks($, scripts + '/' + CHECKER, 'file')
137 return { draft, scripts }
138}
139
140function response(raw: { stdout: string; stderr: string; exitCode: number }): string {
141 const flags = raw as typeof raw & { isStdoutTruncated?: boolean; isStderrTruncated?: boolean }
142 if (flags.isStdoutTruncated || flags.isStderrTruncated || raw.stdout.length >= 4 * 1024 * 1024) {
143 throw new Error('Checker output was truncated. No passing result was accepted. ' + clean(raw.stderr))
144 }
145 return raw.stdout
146}
147
148async function inspect($: EngineInterface, s: StudioState, runId?: string): Promise<Status> {
149 const paths = await prepare($, s)
150 const args = ['python3', paths.scripts + '/' + HELPER, '--draft', paths.draft, '--project-root', rootOf(s)]
151 if (runId) args.push('--expected-run', runId)
152 const out = await $.process.run(args, { cwd: rootOf(s), timeoutMs: 30_000 })
153 const status = parseStatus(response(out), out.stderr)
154 if (out.exitCode !== 0 && status.status !== 'error') throw new Error('Status inspector failed.')
155 return status
156}
157
158async function perform($: EngineInterface, check: boolean): Promise<string> {
159 const snapshot = await context($)
160 const s = snapshot.value
161 if (s.busy) return 'Checks are already running.'
162 // Reserve this state version before any process starts. A later selection,
163 // reset or reload changes the version, so this operation cannot publish late.
164 const reservation = await $.state.set(REF, { ...s, busy: true, message: check ? 'Running checks...' : 'Refreshing evidence...' }, { ifVersion: snapshot.version })
165 if (!reservation.isSet) return 'State changed. Try again.'
166 let status: Status | null = null
167 let message: string | null = null
168 try {
169 let runId: string | undefined
170 if (check) {
171 const paths = await prepare($, s)
172 const tools = (await $.tool.list()).map(t => t.name)
173 const out = await $.process.run(['python3', paths.scripts + '/' + CHECKER, '--draft', paths.draft,
174 '--strict', '--json', '--tools-json', JSON.stringify(tools), '--project-root', rootOf(s)], { cwd: rootOf(s), timeoutMs: 600_000 })
175 const report = jsonResponse(response(out), out.stderr)
176 if (!report || typeof report !== 'object') throw new Error('Checker returned no valid report.')
177 const r = report as { evidence?: { run_id?: unknown }; strict?: unknown; blocked?: unknown; draft?: unknown }
178 if (r.strict !== true || r.draft !== paths.draft || typeof r.blocked !== 'boolean' ||
179 (out.exitCode !== 0 && out.exitCode !== 1) || (out.exitCode === 0) === r.blocked ||
180 typeof r.evidence?.run_id !== 'string' || !/^[0-9a-f]{32}$/.test(r.evidence.run_id)) {
181 throw new Error('Checker exit status or report was inconsistent. ' + clean(out.stderr))
182 }
183 runId = r.evidence.run_id
184 }
185 status = await inspect($, s, runId)
186 } catch (error) {
187 message = clean(error instanceof Error ? error.message : String(error))
188 status = { schema_version: 1, status: 'error', label: 'Execution error', checked_at: null,
189 passed_count: 0, gates: [], artifacts: [], diagnostics: [message] }
190 }
191 if (((await $.session.root()).replace(/\/$/, '') || '/') !== s.workspace || await $.session.id() !== s.sessionId) return 'Previous check finished after the session changed; its result was discarded.'
192 const final = { ...s, busy: false, status, message }
193 const written = await $.state.set(REF, final, { ifVersion: reservation.version })
194 if (written.isSet && s.draft) {
195 const result = status.status === 'error' ? null : status
196 await remember($, makeEntry(rootOf(s), s.draft, Date.now(), check ? 'checked' : 'selected', result))
197 const score = status.gates.find(g => g.gate === 4)?.score
198 if (check && typeof score === 'number') {
199 try { await updateRun($, run => (run.scores.at(-1) === score ? run : { ...run, scores: [...run.scores, score].slice(-8) })) } catch { /* display only */ }
200 }
201 }
202 return written.isSet ? summary(final) : 'Previous check result was discarded after a state change.'
203}
204
205async function select($: EngineInterface, raw: string): Promise<string> {
206 const current = await context($)
207 const s = current.value
208 if (s.busy) throw new Error('Wait for the current check before changing the selection.')
209 const draft = relativeDraft(raw)
210 await noLinks($, (s.workspace === '/' ? '' : s.workspace) + '/' + draft, 'dir')
211 const next = { ...s, draft, projectRoot: null, status: null, message: null, generation: s.generation + 1 }
212 if (!(await $.state.set(REF, next, { ifVersion: current.version })).isSet) throw new Error('Selection changed. Try again.')
213 await remember($, makeEntry(s.workspace, draft, Date.now(), 'selected'))
214 return summary(next)
215}
216
217// A past blog may live in another project. Its recorded root is re-validated like a fresh selection,
218// and the checks run anchored to that root, which the panel shows.
219async function open($: EngineInterface, entry: HistoryEntry): Promise<string> {
220 const current = await context($)
221 const s = current.value
222 if (s.busy) throw new Error('Wait for the current check before changing the selection.')
223 const root = absoluteRoot(entry.root)
224 const draft = relativeDraft(entry.draft)
225 await noLinks($, root, 'dir')
226 await noLinks($, (root === '/' ? '' : root) + '/' + draft, 'dir')
227 const next = { ...s, draft, projectRoot: root === s.workspace ? null : root, status: null, message: null, generation: s.generation + 1 }
228 if (!(await $.state.set(REF, next, { ifVersion: current.version })).isSet) throw new Error('Selection changed. Try again.')
229 await remember($, { ...entry, lastSeen: Date.now(), source: 'selected', status: null })
230 return summary(next) + (next.projectRoot ? '\nProject root: ' + clean(next.projectRoot, 200) : '')
231}
232
233async function pick($: EngineInterface, ref: string): Promise<string> {
234 const entries = (await heldHistory($)).value.entries
235 const number = /^[0-9]{1,3}$/.test(ref) ? Number(ref) : 0
236 const entry = number ? entries[number - 1] : entries.find(e => e.id === ref)
237 if (!entry) throw new Error(number ? 'No blog number ' + number + '. Run /blog-studio history.' : 'That blog is no longer in the history.')
238 return open($, entry)
239}
240
241function historyText(entries: HistoryEntry[]): string {
242 if (!entries.length) return 'Blog Studio: no blogs in the history yet. They are added as the agent writes or you select them. Backfill with /blog-studio history scan <folder>.'
243 return 'Blog Studio history (newest first):\n' + entries.slice(0, 20).map((e, i) =>
244 String(i + 1).padStart(2) + '. ' + formatWhen(e.lastSeen) + ' ' + statusGlyph(e.status) + ' ' + title(e) +
245 (e.status ? ' ' + e.passed + '/5 gates' : '')).join('\n') +
246 '\nOpen one with /blog-studio open <number>. Remove one with /blog-studio history forget <number>.'
247}
248
249async function forget($: EngineInterface, ref: string): Promise<string> {
250 const entries = (await heldHistory($)).value.entries
251 const number = /^[0-9]{1,3}$/.test(ref) ? Number(ref) : 0
252 const entry = number ? entries[number - 1] : undefined
253 if (!entry) throw new Error('Give the number from /blog-studio history.')
254 await $.store.delete(KEY_PREFIX + entry.id)
255 await publishHistory($, list => list.filter(e => e.id !== entry.id))
256 return 'Blog Studio: removed ' + title(entry) + ' from the history. The files were not touched.'
257}
258
259async function scan($: EngineInterface, raw: string): Promise<string> {
260 const s = (await context($)).value
261 const root = raw ? absoluteRoot(raw) : s.workspace
262 await noLinks($, root, 'dir')
263 const out = await $.process.run(['find', root, '-maxdepth', '7', '-name', 'preflight-report.json', '-not', '-path', '*/node_modules/*',
264 '-not', '-path', '*/.*', '-printf', '%T@\\t%h\\n'], { cwd: s.workspace, timeoutMs: 30_000 })
265 if (out.exitCode !== 0 && !out.stdout) throw new Error('Scan failed. It needs GNU find. ' + clean(out.stderr, 200))
266 const now = Date.now()
267 const entries = parseFound(out.stdout, 100).flatMap(row => {
268 const place = splitDraft(row.path, s.workspace)
269 return place ? [makeEntry(place.root, place.draft, now, 'scan', null, row.mtimeMs)] : []
270 })
271 if (!entries.length) return 'Blog Studio: no delivered drafts found under ' + clean(root, 120) + '.'
272 await recordEntries($, entries)
273 return 'Blog Studio: added ' + entries.length + ' draft' + (entries.length === 1 ? '' : 's') + ' from ' + clean(root, 120) + '. Run /blog-studio history.'
274}
275
276async function configure($: EngineInterface, raw: string): Promise<string> {
277 const current = await context($)
278 const s = current.value
279 if (s.busy) throw new Error('Wait for the current check before configuring helpers.')
280 if (raw.trim() === 'reset') {
281 await $.store.delete(CONFIG)
282 const pinned = await $.env.get('CLAUDE_BLOG_SCRIPTS_DIR')
283 const cleared = { ...s, scripts: pinned ? absoluteScripts(pinned) : null, status: null, message: null, generation: s.generation + 1 }
284 if (!(await $.state.set(REF, cleared, { ifVersion: current.version })).isSet) throw new Error('Configuration changed. Try again.')
285 return 'Blog Studio: remembered engine location cleared.'
286 }
287 const scripts = absoluteScripts(raw)
288 await noLinks($, scripts + '/' + HELPER, 'file')
289 await noLinks($, scripts + '/' + CHECKER, 'file')
290 const next = { ...s, scripts, status: null, message: null, generation: s.generation + 1 }
291 if (!(await $.state.set(REF, next, { ifVersion: current.version })).isSet) throw new Error('Configuration changed. Try again.')
292 await $.store.set(CONFIG, { scripts })
293 return 'Trusted blog-engine helpers configured and remembered for every session. /blog-studio configure reset forgets them.'
294}
295
296async function uiAction($: EngineInterface, action: () => Promise<unknown>) {
297 try { await action() } catch (error) {
298 $.ui.toast(clean(error instanceof Error ? error.message : String(error)))
299 }
300}
301
302function paneActions($: EngineInterface): PaneActions {
303 return {
304 refresh: async () => { await uiAction($, () => perform($, false)) },
305 check: async () => { await uiAction($, () => perform($, true)) },
306 useDetected: async () => {
307 await uiAction($, async () => {
308 const run = (await heldRun($)).value
309 if (run.detected) await select($, run.detected)
310 })
311 },
312 toggleSetup: async () => { await updateRun($, run => ({ ...run, setup: !run.setup })) },
313 toggleHistory: async () => { await updateRun($, run => ({ ...run, historyOpen: !run.historyOpen })) },
314 openEntry: async (id: string) => { await uiAction($, () => pick($, id)) },
315 selectDraft: async value => { await uiAction($, () => select($, value)) },
316 configureScripts: async value => { await uiAction($, () => configure($, value)) },
317 close: async () => { await $.ui.close({ id: PANE }) },
318 }
319}
320
321async function observe($: EngineInterface, signal: Signal | null) {
322 if (!signal) return
323 await updateRun($, run => reduce(run, signal, Date.now()))
324 startTicker($)
325}
326
327export const register: Register = on => {
328 on('session.start', async ($, e, next) => {
329 const current = await context($)
330 const s = current.value
331 // Reload preserves selection but invalidates in-flight work and cached proof.
332 await $.state.set(REF, { ...s, busy: false, status: null, message: null, generation: s.generation + 1 }, { ifVersion: current.version })
333 try { if (!(await heldHistory($)).value.loaded) await loadHistory($) } catch { /* history is a convenience */ }
334 try {
335 await $.command.register({ name: 'blog-studio', description: 'Open the blog pipeline and delivery checks',
336 argumentHint: '[status|select <directory>|open <n>|history [scan <folder>|forget <n>]|configure <scripts>|refresh|check|demo|reset]', immediate: true })
337 } catch { $.ui.toast('Blog Studio could not register /blog-studio because the name is already taken.') }
338 return next(e)
339 })
340 on('session.end', async ($, e, next) => {
341 stopTicker()
342 const current = await held($)
343 await $.state.set(REF, { ...EMPTY, generation: current.value.generation + 1 })
344 await $.state.set(RUN, emptyRun())
345 return next(e)
346 })
347 on('turn.start', async ($, e, next) => {
348 const current = await held($)
349 if (current.value.draft && !current.value.busy) {
350 await $.state.set(REF, { ...current.value, status: null, message: 'Refresh evidence after this turn.' }, { ifVersion: current.version })
351 }
352 return next(e)
353 })
354 on('turn.complete', async ($, e, next) => {
355 if (!e.agentId) {
356 stopTicker()
357 try { await updateRun($, run => (run.startedAt === null ? run : pause(run, Date.now()))) } catch { /* display only */ }
358 }
359 const current = await held($)
360 if (current.value.draft && !current.value.busy) await perform($, false)
361 return next(e)
362 })
363 // Observers: read-only, no model calls, and they always fall through to the real call.
364 on('skill.prompt', async ($, e, next) => {
365 try { await observe($, skillSignal(e.skill)) } catch { /* display only */ }
366 return next(e)
367 })
368 on('tool.call', async ($, e, next) => {
369 let signal: Signal | null = null
370 try {
371 const run = (await heldRun($)).value
372 const workspace = run.workspace || (await $.session.root()).replace(/\/$/, '')
373 signal = classify(e.tool, e as unknown as Record<string, unknown>, workspace)
374 await observe($, signal)
375 if (signal?.kind === 'stage' && signal.draft && signal.draft !== run.detected) {
376 await remember($, makeEntry(workspace || '/', signal.draft, Date.now(), 'observed'))
377 }
378 } catch { signal = null }
379 const result = await next(e)
380 if (signal?.kind === 'stage' && (signal.stage === 'render' || signal.stage === 'gates') &&
381 (result as { isError?: boolean }).isError) {
382 const stage = signal.stage
383 try { await updateRun($, run => fail(run, stage, Date.now())) } catch { /* display only */ }
384 }
385 return result
386 })
387 on('command.run', { command: 'blog-studio' }, async ($, e) => {
388 try {
389 const input = e.args.trim()
390 const space = input.indexOf(' ')
391 const action = space < 0 ? input : input.slice(0, space)
392 const value = space < 0 ? '' : input.slice(space + 1).trim()
393 if (action === 'select') return { text: await select($, value) }
394 if (action === 'configure') return { text: await configure($, value) }
395 if (action === 'history') {
396 if (value.startsWith('scan')) return { text: await scan($, value.slice(4).trim()) }
397 if (value.startsWith('forget')) return { text: await forget($, value.slice(6).trim()) }
398 return { text: historyText((await heldHistory($)).value.entries) }
399 }
400 if (action === 'open') return { text: await pick($, value) }
401 if (action === 'demo') {
402 await updateRun($, run => demoRun(run, Date.now()))
403 startTicker($)
404 return { text: 'Blog Studio: showing a sample pipeline. Run /blog-studio reset to clear it.' }
405 }
406 if (action === 'reset') {
407 stopTicker()
408 await updateRun($, run => ({ ...emptyRun(run.workspace, run.sessionId), setup: run.setup, historyOpen: run.historyOpen }))
409 return { text: 'Blog Studio: pipeline cleared.' }
410 }
411 if (action === 'check' || action === 'refresh') return { text: await perform($, action === 'check') }
412 const text = async () => {
413 const s = (await context($)).value
414 const run = (await heldRun($)).value
415 return summary(s) + (run.startedAt === null ? '' : '\n' + pipelineLine(effective(run, s.status, s.busy, Date.now())))
416 }
417 if (action === 'status') return { text: await text() }
418 if (action) return { text: 'Usage: /blog-studio [status|select <directory>|open <n>|history [scan <folder>|forget <n>]|configure <scripts>|refresh|check|demo|reset]' }
419 const surfaces = await $.session.surfaces()
420 if (!surfaces.some(surface => surface === 'terminal' || surface === 'desktop')) {
421 return { text: (await text()) + '\nThis surface has no Blog Studio panel. Use the text subcommands.' }
422 }
423 const opened: unknown = await $.ui.open({ id: PANE, title: 'Blog Studio', focus: true, closeOnEscape: true })
424 return opened && typeof opened === 'object' && 'isPlaced' in opened && opened.isPlaced === true ? {} : { text: (await text()) + '\nPanel unavailable. Use the text subcommands.' }
425 } catch (error) { return { text: 'Blog Studio: ' + clean(error instanceof Error ? error.message : String(error)) } }
426 })
427 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
428 const s = (await held($)).value
429 const run = (await heldRun($)).value
430 const tracked = run.startedAt !== null
431 if ((!s.draft && !tracked) || e.props.hasSurvey || e.props.maxRows < 2 || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
432 const ui = $.ui.resolve(e)
433 const theirs = await next(e)
434 const now = Date.now()
435 const stages = effective(run, s.status, s.busy, now)
436 const detail = e.props.maxRows >= 3 ? bandDetail(s, run, stages, e.props.bodyColumns) : null
437 return ui.Box({ flexDirection: 'column', children: [theirs,
438 ui.Box({ flexDirection: 'row', children: [
439 ui.Box({ flexDirection: 'row', flexGrow: 1, children: bandRow(s, run, stages, e.props.bodyColumns - 14, now).map(seg => el(ui, seg)) }),
440 ui.Button({ key: 'open-studio', label: 'Open (o)', hotkey: 'o', onPress: async () => {
441 await uiAction($, () => $.ui.open({ id: PANE, title: 'Blog Studio', focus: true, closeOnEscape: true }))
442 } }),
443 ] }),
444 ...(detail ? [el(ui, { t: detail, dim: true })] : []),
445 ] })
446 })
447 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
448 if (e.requestId !== PANE || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
449 const s = (await held($)).value
450 const run = (await heldRun($)).value
451 const ui = $.ui.resolve(e)
452 const now = Date.now()
453 const history = (await heldHistory($)).value.entries
454 return pane(ui, s, run, effective(run, s.status, s.busy, now), history, Math.max(24, e.props.bodyColumns - 2), now, paneActions($))
455 })
456}
457hooks/logic.ts 100 lines1import type { Status, StudioState } from '../types'
2
3export const EMPTY: StudioState = {
4 workspace: '', sessionId: '', scripts: null, draft: null,
5 generation: 0, projectRoot: null, busy: false, status: null, message: null,
6}
7export const LABELS = {
8 passed: 'Preflight passed', blocked: 'Blocked', partial: 'Partial', stale: 'Stale',
9 unverified: 'Unverified', error: 'Execution error',
10} as const
11
12export function clean(value: string, limit = 500): string {
13 return value.slice(0, 8192)
14 .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|$))/g, '')
15 .replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
16 .replace(/\b[0-9a-fA-F]{32}\b/g, '[redacted identifier]')
17 .replace(/\/(?:var\/)?home\/[^/\s]+/g, '[home]')
18 .slice(0, limit)
19}
20
21export function relativeDraft(value: string): string {
22 const path = value.trim()
23 if (!path || path.length > 1000 || path.startsWith('/') || path.includes('\\') || /[\x00-\x1f\x7f]/.test(path)) {
24 throw new Error('Select a project-relative draft directory.')
25 }
26 const parts = path.split('/')
27 if (parts.some(p => !p || p === '.' || p === '..')) throw new Error('Draft traversal and empty path components are refused.')
28 return parts.join('/')
29}
30
31export function absoluteScripts(value: string): string {
32 const path = value.trim().replace(/\/$/, '')
33 if (!path.startsWith('/') || path.length > 2000 || /[\x00-\x1f\x7f\\]/.test(path) || path.split('/').slice(1).some(p => !p || p === '.' || p === '..')) {
34 throw new Error('Configure an absolute trusted scripts directory.')
35 }
36 return path
37}
38
39export function absoluteRoot(value: string): string {
40 const path = value.trim().replace(/\/$/, '') || '/'
41 if (!path.startsWith('/') || path.length > 2000 || /[\x00-\x1f\x7f\\]/.test(path) || path.split('/').slice(1).some((p, i, a) => path !== '/' && (p === '.' || p === '..' || (!p && i < a.length)))) {
42 throw new Error('Project root must be an absolute path.')
43 }
44 return path
45}
46
47export function parseStatus(raw: string, stderr = ''): Status {
48 if (raw.length > 1024 * 1024) throw new Error('Status response exceeds size limit.')
49 const value = jsonResponse(raw, stderr)
50 if (!value || typeof value !== 'object') throw new Error('Invalid status response.')
51 const x = value as Record<string, unknown>
52 if (x.schema_version !== 1 || typeof x.status !== 'string' || !Object.hasOwn(LABELS, x.status) ||
53 !Number.isInteger(x.passed_count) || Number(x.passed_count) < 0 || Number(x.passed_count) > 5 ||
54 !Array.isArray(x.gates) || x.gates.length > 5 || !Array.isArray(x.artifacts) || x.artifacts.length > 8192 ||
55 !Array.isArray(x.diagnostics) || x.diagnostics.length > 200 ||
56 !(x.checked_at === null || typeof x.checked_at === 'string')) throw new Error('Invalid status fields.')
57 const seen = new Set<number>()
58 const gates = x.gates.map((g: unknown) => {
59 if (!g || typeof g !== 'object') throw new Error('Invalid gate result.')
60 const row = g as Record<string, unknown>
61 const n = row.gate
62 if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 5 || seen.has(n) ||
63 typeof row.name !== 'string' || typeof row.passed !== 'boolean' ||
64 !Array.isArray(row.violations) || !Array.isArray(row.warnings) ||
65 !(row.score === null || (typeof row.score === 'number' && Number.isFinite(row.score) && row.score >= 0 && row.score <= 100))) throw new Error('Invalid gate fields.')
66 seen.add(n)
67 return { gate: n, name: clean(row.name), passed: row.passed, score: row.score as number | null,
68 violations: strings(row.violations), warnings: strings(row.warnings) }
69 })
70 if (Number(x.passed_count) !== gates.filter(g => g.passed).length) throw new Error('Gate count contradicts results.')
71 if (x.status === 'passed' && (gates.length !== 5 || gates.some(g => !g.passed || g.violations.length) ||
72 !gates.some(g => g.gate === 4 && g.score !== null && g.score >= 90) || typeof x.run_id !== 'string' ||
73 !/^[0-9a-f]{32}$/.test(x.run_id) || typeof x.checked_at !== 'string')) throw new Error('Incomplete passing status.')
74 return { schema_version: 1, status: x.status as Status['status'], label: LABELS[x.status as keyof typeof LABELS],
75 passed_count: Number(x.passed_count), checked_at: x.checked_at === null ? null : clean(x.checked_at as string), gates,
76 artifacts: strings(x.artifacts, 8192), diagnostics: strings(x.diagnostics),
77 ...(typeof x.run_id === 'string' ? { run_id: x.run_id } : {}) }
78}
79
80export function jsonResponse(raw: string, stderr = ''): unknown {
81 try { return JSON.parse(raw) } catch {
82 throw new Error('Helper returned malformed or incomplete JSON. No passing result was accepted.' +
83 (stderr ? ' Diagnostic: ' + clean(stderr, 500) : ''))
84 }
85}
86
87function strings(value: unknown[], max = 200): string[] {
88 if (value.length > max || value.some(v => typeof v !== 'string')) throw new Error('Invalid diagnostic list.')
89 return (value as string[]).map(v => clean(v, 1000))
90}
91
92export function summary(s: StudioState): string {
93 if (!s.draft) return 'Blog Studio: select a draft with /blog-studio select <directory>.'
94 if (s.busy) return 'Blog Studio: ' + clean(s.draft, 90) + ' | ' + (s.message ?? 'Working...')
95 const status = s.status
96 return 'Blog Studio: ' + clean(s.draft, 90) + ' | ' + (status?.label ?? 'Unverified') +
97 ' | ' + (status?.passed_count ?? 0) + '/5 gates | Last checked: ' + (status?.checked_at ?? 'unknown') +
98 (s.message ? ' | ' + clean(s.message) : '')
99}
100hooks/pipeline.ts 153 lines1import type { RunState, Stage, StageId } from '../types'
2import { clean, relativeDraft } from './logic'
3
4export const STAGES: readonly { id: StageId; label: string }[] = [
5 { id: 'research', label: 'Research' }, { id: 'outline', label: 'Outline' }, { id: 'draft', label: 'Draft' },
6 { id: 'visuals', label: 'Visuals' }, { id: 'render', label: 'Render' }, { id: 'review', label: 'Review' },
7 { id: 'gates', label: 'Gates' },
8]
9
10export type Signal =
11 | { kind: 'start'; skill: string }
12 | { kind: 'stage'; stage: StageId; note: string; draft?: string }
13
14const START_SKILLS = new Set(['blog', 'blog-write', 'blog-rewrite', 'pro-blog', 'youtube-to-blog', 'blog-multilingual'])
15const SKILL_STAGE: Record<string, StageId> = {
16 'blog-outline': 'outline', 'blog-brief': 'outline', 'blog-notebooklm': 'research', 'blog-discourse': 'research',
17 'blog-chart': 'visuals', 'blog-image': 'visuals', banana: 'visuals',
18 'blog-seo-check': 'review', 'blog-factcheck': 'review', 'blog-analyze': 'review', 'blog-geo': 'review',
19}
20const AGENT_STAGE: Record<string, StageId> = {
21 'blog-researcher': 'research', 'blog-writer': 'draft', 'blog-translator': 'draft',
22 'blog-reviewer': 'review', 'blog-seo': 'review',
23}
24const NOT_DRAFT = /^(brand|voice|discourse|readme|claude|agents|memory|skill|changelog)$/i
25
26function tail(name: string): string {
27 return name.split(':').pop() ?? name
28}
29
30function text(value: unknown, limit = 200): string {
31 return typeof value === 'string' ? value.slice(0, limit) : ''
32}
33
34export function draftDirectory(filePath: string, workspace: string): string | null {
35 const root = workspace === '/' ? '' : workspace.replace(/\/$/, '')
36 if (!filePath.startsWith(root + '/')) return null
37 const parts = filePath.slice(root.length + 1).split('/')
38 parts.pop()
39 if (!parts.length) return null
40 try { return relativeDraft(parts.join('/')) } catch { return null }
41}
42
43export function skillSignal(skill: string): Signal | null {
44 const name = tail(skill)
45 if (START_SKILLS.has(name)) return { kind: 'start', skill: clean(name, 40) }
46 const stage = SKILL_STAGE[name]
47 return stage ? { kind: 'stage', stage, note: clean('/' + name, 60) } : null
48}
49
50/** Inference only: classifies one tool call by name and arguments, never by running anything. */
51export function classify(tool: string, input: Record<string, unknown>, workspace: string): Signal | null {
52 if (tool === 'Skill') return skillSignal(text(input.skill, 120))
53 if (tool === 'WebSearch') return { kind: 'stage', stage: 'research', note: 'web search' }
54 if (tool === 'WebFetch') {
55 let host = ''
56 try { host = new URL(text(input.url, 500)).hostname } catch { /* not a URL */ }
57 return { kind: 'stage', stage: 'research', note: host ? clean('read ' + host, 60) : 'web fetch' }
58 }
59 if (tool === 'Agent' || tool === 'Task') {
60 const stage = AGENT_STAGE[tail(text(input.subagent_type, 80))]
61 return stage ? { kind: 'stage', stage, note: clean(tail(text(input.subagent_type, 80)), 40) } : null
62 }
63 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit') {
64 const file = text(input.file_path, 1000)
65 const base = file.split('/').pop() ?? ''
66 const stem = base.replace(/\.[^.]*$/, '')
67 if (/^review\.md$/i.test(base)) return { kind: 'stage', stage: 'review', note: 'review.md' }
68 if (/\.(svg|png|jpe?g|webp)$/i.test(base)) return { kind: 'stage', stage: 'visuals', note: clean(base, 40) }
69 if (/\.mdx?$/i.test(base) && /outline|brief/i.test(base)) return { kind: 'stage', stage: 'outline', note: clean(base, 40) }
70 if (/\.mdx?$/i.test(base) && !NOT_DRAFT.test(stem)) {
71 const draft = draftDirectory(file, workspace)
72 return { kind: 'stage', stage: 'draft', note: clean('write ' + base, 60), ...(draft ? { draft } : {}) }
73 }
74 return null
75 }
76 if (tool === 'Bash') {
77 const cmd = text(input.command, 2000)
78 if (/blog_preflight/.test(cmd)) return { kind: 'stage', stage: 'gates', note: 'preflight' }
79 if (/blog_render/.test(cmd)) return { kind: 'stage', stage: 'render', note: 'render html and pdf' }
80 if (/generate_hero|blog_chart|chart_generator|banana/.test(cmd)) return { kind: 'stage', stage: 'visuals', note: 'images and charts' }
81 if (/analyze_blog|lint_prose|ai_citation|quality_gate|cognitive_load/.test(cmd)) return { kind: 'stage', stage: 'review', note: 'quality scripts' }
82 if (/discourse_research|notebooklm|last30days/.test(cmd)) return { kind: 'stage', stage: 'research', note: 'research scripts' }
83 return null
84 }
85 if (tool.startsWith('mcp__')) {
86 if (/dataforseo|notebooklm|exa|firecrawl|perplexity|search/i.test(tool)) return { kind: 'stage', stage: 'research', note: 'research tool' }
87 if (/banana|nano|imagen|gemini/i.test(tool)) return { kind: 'stage', stage: 'visuals', note: 'image tool' }
88 }
89 return null
90}
91
92function blankStage(): Stage {
93 return { state: 'pending', n: 0, startedAt: null, endedAt: null, note: null }
94}
95
96export function emptyRun(workspace = '', sessionId = ''): RunState {
97 return { workspace, sessionId, skill: null, startedAt: null, endedAt: null, working: false,
98 stages: Object.fromEntries(STAGES.map(s => [s.id, blankStage()])) as RunState['stages'],
99 detected: null, scores: [], setup: false, historyOpen: false }
100}
101
102export function reduce(run: RunState, signal: Signal, now: number): RunState {
103 if (signal.kind === 'start') {
104 return { ...emptyRun(run.workspace, run.sessionId), skill: signal.skill, startedAt: now, working: true,
105 scores: run.scores, setup: run.setup, historyOpen: run.historyOpen }
106 }
107 const index = STAGES.findIndex(s => s.id === signal.stage)
108 const stages = { ...run.stages }
109 STAGES.forEach((s, i) => {
110 const cur = stages[s.id]
111 if (i < index && cur.state === 'active') stages[s.id] = { ...cur, state: 'done', endedAt: now }
112 })
113 const cur = stages[signal.stage]
114 stages[signal.stage] = { state: 'active', n: cur.n + 1, startedAt: cur.state === 'active' ? cur.startedAt : now,
115 endedAt: null, note: signal.note }
116 return { ...run, startedAt: run.startedAt ?? now, endedAt: null, working: true, stages,
117 detected: signal.draft ?? run.detected }
118}
119
120export function fail(run: RunState, stage: StageId, now: number): RunState {
121 const cur = run.stages[stage]
122 return { ...run, stages: { ...run.stages, [stage]: { ...cur, state: 'failed', endedAt: now } } }
123}
124
125export function pause(run: RunState, now: number): RunState {
126 return { ...run, working: false, endedAt: run.startedAt ? now : null }
127}
128
129export function doneCount(run: RunState): number {
130 return STAGES.filter(s => run.stages[s.id].state === 'done').length
131}
132
133export function currentStage(run: RunState): { id: StageId; label: string } | null {
134 let found: { id: StageId; label: string } | null = null
135 for (const s of STAGES) if (run.stages[s.id].state === 'active' || run.stages[s.id].state === 'failed') found = s
136 return found
137}
138
139export function hasRun(run: RunState): boolean {
140 return run.startedAt !== null
141}
142
143/** Sample data for /blog-studio demo: a run caught mid-draft, so the design can be judged without an agent. */
144export function demoRun(base: RunState, now: number): RunState {
145 const at = (ago: number, taken: number): Pick<Stage, 'startedAt' | 'endedAt'> => ({ startedAt: now - ago, endedAt: now - ago + taken })
146 const stages = emptyRun().stages
147 stages.research = { state: 'done', n: 14, ...at(134_000, 52_000), note: 'read example.com' }
148 stages.outline = { state: 'done', n: 2, ...at(82_000, 9_000), note: 'outline.md' }
149 stages.draft = { state: 'active', n: 3, startedAt: now - 73_000, endedAt: null, note: 'write preview-post.md' }
150 return { ...base, skill: 'preview', startedAt: now - 134_000, endedAt: null, working: true, stages,
151 detected: null, scores: [62, 71, 84] }
152}
153hooks/history.ts 84 lines1import type { HistoryEntry, Status } from '../types'
2import { clean, relativeDraft } from './logic'
3
4export const MAX_STORED = 100
5export const MAX_SHOWN = 30
6export const KEY_PREFIX = 'entry:'
7
8/** FNV-1a over the absolute location, so one draft folder is one entry. */
9export function entryId(root: string, draft: string): string {
10 let h = 0x811c9dc5
11 for (const c of root + '\n' + draft) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0 }
12 return h.toString(16).padStart(8, '0')
13}
14
15export function makeEntry(root: string, draft: string, now: number, source: HistoryEntry['source'],
16 status: Status | null = null, lastSeen = now): HistoryEntry {
17 return { id: entryId(root, draft), root, draft, name: clean(draft.split('/').pop() ?? draft, 80),
18 firstSeen: lastSeen, lastSeen, source, status: status?.status ?? null, passed: status?.passed_count ?? 0,
19 score: status?.gates.find(g => g.gate === 4)?.score ?? null }
20}
21
22/** Newest activity first. Merging keeps the earliest firstSeen and never lets a bare sighting erase a known result. */
23export function mergeEntries(list: HistoryEntry[], entry: HistoryEntry): HistoryEntry[] {
24 const old = list.find(e => e.id === entry.id)
25 const merged: HistoryEntry = old ? { ...entry, firstSeen: Math.min(old.firstSeen, entry.firstSeen),
26 lastSeen: Math.max(old.lastSeen, entry.lastSeen),
27 ...(entry.status === null ? { status: old.status, passed: old.passed, score: old.score } : {}) } : entry
28 return sortEntries([...list.filter(e => e.id !== entry.id), merged])
29}
30
31export function sortEntries(list: HistoryEntry[]): HistoryEntry[] {
32 return [...list].sort((a, b) => b.lastSeen - a.lastSeen || a.name.localeCompare(b.name))
33}
34
35export function validEntry(value: unknown): value is HistoryEntry {
36 if (!value || typeof value !== 'object') return false
37 const e = value as Record<string, unknown>
38 if (typeof e.id !== 'string' || typeof e.root !== 'string' || typeof e.draft !== 'string' || typeof e.name !== 'string' ||
39 typeof e.firstSeen !== 'number' || typeof e.lastSeen !== 'number' || typeof e.passed !== 'number') return false
40 if (!e.root.startsWith('/') || e.root.length > 2000 || /[\x00-\x1f\x7f\\]/.test(e.root)) return false
41 try { relativeDraft(e.draft) } catch { return false }
42 return true
43}
44
45export function formatWhen(ms: number): string {
46 const d = new Date(ms)
47 const two = (n: number) => String(n).padStart(2, '0')
48 return two(d.getMonth() + 1) + '-' + two(d.getDate()) + ' ' + two(d.getHours()) + ':' + two(d.getMinutes())
49}
50
51export function statusGlyph(status: HistoryEntry['status']): string {
52 return status === 'passed' ? '✓' : status === 'blocked' || status === 'error' ? '✗' : status === 'stale' || status === 'partial' ? '!' : '·'
53}
54
55/** Splits an absolute draft folder into the project root it is anchored to and its relative path. */
56export function splitDraft(absolute: string, workspace: string): { root: string; draft: string } | null {
57 const ws = workspace === '/' ? '' : workspace.replace(/\/$/, '')
58 if (ws && absolute.startsWith(ws + '/')) {
59 try { return { root: workspace, draft: relativeDraft(absolute.slice(ws.length + 1)) } } catch { return null }
60 }
61 const parts = absolute.split('/').filter(Boolean)
62 if (parts.length < 2) return null
63 const draft = parts.pop() as string
64 try { return { root: '/' + parts.join('/'), draft: relativeDraft(draft) } } catch { return null }
65}
66
67/** Parses `find -printf '%T@\t%h\n'` output into folders and modification times, newest first. */
68export function parseFound(out: string, limit = 300): { path: string; mtimeMs: number }[] {
69 const rows: { path: string; mtimeMs: number }[] = []
70 for (const line of out.split('\n')) {
71 const [t, path] = line.split('\t')
72 const seconds = Number(t)
73 if (!path || !path.startsWith('/') || !Number.isFinite(seconds) || /[\x00-\x1f\x7f\\]/.test(path)) continue
74 rows.push({ path, mtimeMs: Math.round(seconds * 1000) })
75 }
76 return rows.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit)
77}
78
79/** A folder called 03-article says little, so lead with the project it sits in. */
80export function title(e: Pick<HistoryEntry, 'root' | 'name'>): string {
81 const project = clean(e.root.split('/').filter(Boolean).pop() ?? '', 60)
82 return project && project !== e.name ? project + ' / ' + e.name : e.name
83}
84hooks/view.ts 277 lines1import type { EngineInterface, ThemeKey } from 'claude-code'
2import type { HistoryEntry, RunState, StageId, StageState, Status, StudioState } from '../types'
3import { clean } from './logic'
4import { formatWhen, statusGlyph, title } from './history'
5import { STAGES } from './pipeline'
6
7type Full = Extract<ReturnType<EngineInterface['ui']['resolve']>, { Input: unknown }>
8type Ui = Pick<Full, 'Box' | 'Text' | 'Button'>
9type TextProps = Parameters<Ui['Text']>[0]
10
11export type Seg = { t: string; c?: ThemeKey; dim?: boolean; bold?: boolean }
12export type EffStage = {
13 id: StageId; label: string; state: StageState; word: string; n: number; ms: number | null
14 note: string | null; evidence: boolean
15}
16
17const rootOf = (s: StudioState) => s.projectRoot ?? s.workspace
18
19export const GLYPH: Record<StageState, string> = { done: '✓', active: '▸', failed: '✗', pending: '·' }
20const COLOR: Record<StageState, ThemeKey> = { done: 'success', active: 'claude', failed: 'error', pending: 'inactive' }
21const GATE_NAMES = ['Capability Discovery', 'Format Completeness', 'Visual Verification', 'Content Review', 'Asset + Link Integrity']
22const SPARK = '▁▂▃▄▅▆▇█'
23
24/** Collapses the same finding reported once per viewport into one line: "mobile + tablet: message". */
25export function groupViolations(list: string[]): string[] {
26 const order: string[] = []
27 const seen = new Map<string, string[]>()
28 for (const v of list) {
29 const m = /^(mobile|tablet|desktop(?:-dark)?):\s*([\s\S]*)$/.exec(v)
30 const key = m?.[2] ?? v
31 if (!seen.has(key)) { seen.set(key, []); order.push(key) }
32 if (m?.[1]) seen.get(key)?.push(m[1])
33 }
34 return order.map(key => { const where = seen.get(key) ?? []; return where.length ? where.join(' + ') + ': ' + key : key })
35}
36
37export function bar(fraction: number, width: number): string {
38 const w = Math.max(0, width)
39 const filled = Math.max(0, Math.min(w, Math.round(fraction * w)))
40 return '━'.repeat(filled) + '─'.repeat(w - filled)
41}
42
43export function spark(values: number[]): string {
44 return values.slice(-8).map(v => SPARK[Math.max(0, Math.min(7, Math.floor(v / 12.5)))]).join('')
45}
46
47export function duration(ms: number | null): string {
48 if (ms === null || ms < 0) return ''
49 const total = Math.floor(ms / 1000)
50 return Math.floor(total / 60) + ':' + String(total % 60).padStart(2, '0')
51}
52
53/** Combines what the agent was seen doing (inference) with what the preflight proved (evidence). */
54export function effective(run: RunState, status: Status | null, busy: boolean, now: number): EffStage[] {
55 const gate = (n: number) => status?.gates.find(g => g.gate === n)
56 const rows = STAGES.map(({ id, label }) => {
57 const cur = run.stages[id]
58 let state = cur.state
59 let word = state === 'done' ? 'done' : state === 'active' ? (run.working ? 'working' : 'paused')
60 : state === 'failed' ? 'failed' : 'waiting'
61 let evidence = false
62 if (status?.status === 'passed') { state = 'done'; word = 'verified'; evidence = true }
63 else {
64 const proved = (id === 'visuals' || id === 'render') ? gate(2)?.passed && gate(3)?.passed !== false
65 : id === 'review' ? gate(4)?.passed : false
66 if (proved && state !== 'done') { state = 'done'; word = 'verified'; evidence = true }
67 if (id === 'gates') {
68 if (busy) { state = 'active'; word = 'checking' }
69 else if (status?.status === 'blocked' || status?.status === 'error') { state = 'failed'; word = status.status === 'error' ? 'error' : 'blocked' }
70 else if (status?.status === 'stale') { state = 'pending'; word = 'stale' }
71 else if (status?.status === 'partial') { state = 'active'; word = 'partial' }
72 }
73 }
74 const ms = cur.startedAt === null ? null : (cur.endedAt ?? now) - cur.startedAt
75 return { id, label, state, word, n: cur.n, ms: state === 'pending' ? null : ms, note: cur.note, evidence }
76 })
77 return rows
78}
79
80export function ratio(stages: EffStage[]): { done: number; fraction: number } {
81 const done = stages.filter(s => s.state === 'done').length
82 const active = stages.some(s => s.state === 'active') ? 0.5 : 0
83 return { done, fraction: (done + active) / stages.length }
84}
85
86export function glyphRail(stages: EffStage[]): string {
87 return stages.map(s => GLYPH[s.state]).join('')
88}
89
90export function pipelineLine(stages: EffStage[]): string {
91 return 'Pipeline: ' + stages.map(s => GLYPH[s.state] + s.label).join(' ')
92}
93
94function chip(status: Status | null, busy: boolean): Seg {
95 if (busy) return { t: 'Checking', c: 'claude' }
96 const label = status?.label ?? 'Unverified'
97 const c: ThemeKey = status?.status === 'passed' ? 'success' : status?.status === 'blocked' || status?.status === 'error' ? 'error'
98 : status?.status === 'stale' || status?.status === 'partial' ? 'warning' : 'inactive'
99 return { t: label, c }
100}
101
102export function el(ui: Ui, seg: Seg, wrap: 'truncate-end' | 'wrap' = 'truncate-end') {
103 const props: TextProps = { wrap, children: [seg.t] }
104 if (seg.c) props.color = seg.c
105 if (seg.dim) props.dimColor = true
106 if (seg.bold) props.bold = true
107 return ui.Text(props)
108}
109
110function row(ui: Ui, segs: Seg[], gap = 0) {
111 return ui.Box({ flexDirection: 'row', gap, children: segs.map(s => el(ui, s)) })
112}
113
114function railSegs(stages: EffStage[], labels: boolean): Seg[] {
115 const out: Seg[] = []
116 stages.forEach((s, i) => {
117 out.push({ t: GLYPH[s.state] + (labels ? ' ' + s.label : ''), c: COLOR[s.state], bold: s.state === 'active' })
118 if (labels && i < stages.length - 1) out.push({ t: ' ' })
119 })
120 return out
121}
122
123/** One band row. Wide: bar, labelled rail, clock. Medium: bar, glyph rail. Narrow: glyph rail and stage. */
124export function bandRow(s: StudioState, run: RunState, stages: EffStage[], columns: number, now: number): Seg[] {
125 const tracked = run.startedAt !== null
126 const status = s.status
127 const clock = tracked ? duration((run.endedAt ?? now) - (run.startedAt ?? now)) : ''
128 const title: Seg[] = [{ t: 'Blog ', c: 'claude', bold: true }]
129 if (!tracked) {
130 const c = chip(status, s.busy)
131 return [...title, { t: '▎ ', c: 'inactive' }, { t: clean(s.draft ?? '', Math.max(8, columns - 40)), dim: true },
132 { t: ' ' }, c, { t: ' ' + (status?.passed_count ?? 0) + '/5', dim: true }]
133 }
134 const { done, fraction } = ratio(stages)
135 const active = stages.find(x => x.state === 'active') ?? [...stages].reverse().find(x => x.state === 'failed')
136 const where: Seg = active
137 ? { t: ' ' + active.label + (active.state === 'failed' ? ' failed' : run.working ? '' : ' (paused)'), c: COLOR[active.state], bold: true }
138 : { t: done === stages.length ? ' complete' : '', c: 'success', bold: true }
139 const count: Seg = { t: ' ' + done + '/' + stages.length, dim: true }
140 const time: Seg = { t: clock ? ' ' + clock : '', dim: true }
141 if (columns >= 100) {
142 return [...title, { t: bar(fraction, 10), c: 'claude' }, count, { t: ' ' }, ...railSegs(stages, true), time]
143 }
144 if (columns >= 62) {
145 return [...title, { t: bar(fraction, 8), c: 'claude' }, count, { t: ' ' }, ...railSegs(stages, false), where, time]
146 }
147 return [...title, ...railSegs(stages, false), where]
148}
149
150/** Dim second line: the newest thing seen, or the first thing blocking delivery. */
151export function bandDetail(s: StudioState, run: RunState, stages: EffStage[], columns: number): string | null {
152 const blocker = s.status?.gates.find(g => !g.passed)?.violations[0]
153 if (blocker && !s.busy) return clean('✗ ' + blocker, Math.max(8, columns))
154 const current = stages.find(x => x.state === 'active')
155 if (current?.note) return clean('▸ ' + current.label + ' · ' + current.note, Math.max(8, columns))
156 return null
157}
158
159export type PaneActions = {
160 refresh: () => Promise<void>; check: () => Promise<void>; useDetected: () => Promise<void>
161 toggleSetup: () => Promise<void>; toggleHistory: () => Promise<void>; openEntry: (id: string) => Promise<void>; selectDraft: (value: string) => Promise<void>
162 configureScripts: (value: string) => Promise<void>; close: () => Promise<void>
163}
164
165export function pane(ui: Full, s: StudioState, run: RunState, stages: EffStage[], history: HistoryEntry[], width: number, now: number, act: PaneActions) {
166 const W = Math.max(24, width)
167 const status = s.status
168 const { done, fraction } = ratio(stages)
169 const tracked = run.startedAt !== null
170 const active = stages.find(x => x.state === 'active') ?? [...stages].reverse().find(x => x.state === 'failed')
171 const elapsed = tracked ? duration((run.endedAt ?? now) - (run.startedAt ?? now)) : ''
172 const rule = (label: string, right = ''): ReturnType<Ui['Box']> => {
173 const fill = Math.max(2, W - label.length - right.length - 4)
174 return row(ui, [{ t: label, c: 'claude', bold: true }, { t: ' ' + '─'.repeat(fill) + ' ', c: 'inactive' }, { t: right, dim: true }])
175 }
176 const children: ReturnType<Ui['Box']>[] = []
177
178 children.push(ui.Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
179 el(ui, { t: '✎ Blog Studio', c: 'claude', bold: true }),
180 el(ui, active ? { t: (active.state === 'failed' ? '✗ ' : run.working ? '▸ ' : '❚❚ ') + active.label + (elapsed ? ' · ' + elapsed : ''),
181 c: COLOR[active.state] } : { t: tracked ? 'complete' + (elapsed ? ' · ' + elapsed : '') : 'idle', c: tracked ? 'success' : 'inactive' }),
182 ] }))
183 children.push(row(ui, [{ t: bar(fraction, Math.max(10, W - 16)), c: 'claude' },
184 { t: ' ' + done + '/' + stages.length + ' · ' + Math.round(fraction * 100) + '%', dim: true }]))
185
186 children.push(rule('PIPELINE', tracked ? 'what the agent is doing, inferred' : 'starts when the agent begins a blog'))
187 const noteRoom = Math.max(0, W - 36)
188 for (const st of stages) {
189 const segs: Seg[] = [
190 { t: GLYPH[st.state] + ' ', c: COLOR[st.state], bold: st.state === 'active' },
191 { t: st.label.padEnd(9), c: st.state === 'pending' ? 'inactive' : 'text', bold: st.state === 'active' },
192 { t: st.word.padEnd(9), c: COLOR[st.state] },
193 { t: (st.n && !st.evidence ? '×' + st.n : '').padEnd(5), dim: true },
194 { t: duration(st.ms).padEnd(6), dim: true },
195 ]
196 if (st.state === 'active' && st.note && noteRoom > 8) segs.push({ t: clean(st.note, noteRoom), dim: true })
197 children.push(row(ui, segs))
198 }
199
200 const failedCount = status?.gates.filter(g => !g.passed).length ?? 0
201 const passedCount = status?.passed_count ?? 0
202 children.push(rule('DELIVERY GATES', status ? '✓' + passedCount + ' ✗' + failedCount + ' ·' + (5 - passedCount - failedCount) : 'not checked'))
203 for (let n = 1; n <= 5; n++) {
204 const g = status?.gates.find(x => x.gate === n)
205 const state: StageState = g ? (g.passed ? 'done' : 'failed') : 'pending'
206 children.push(row(ui, [{ t: GLYPH[state] + ' ', c: COLOR[state] }, { t: n + ' ', dim: true },
207 { t: clean(g?.name ?? GATE_NAMES[n - 1] ?? '', W - 6),
208 c: g ? 'text' : 'inactive' }]))
209 if (g?.score != null) {
210 const ok = g.score >= 90
211 const barWidth = Math.max(8, Math.min(24, W - 34))
212 children.push(row(ui, [{ t: ' ' }, { t: bar(g.score / 100, barWidth), c: ok ? 'success' : 'warning' },
213 { t: ' ' + g.score + '/100', c: ok ? 'success' : 'warning', bold: true }, { t: ' needs 90', dim: true },
214 ...(run.scores.length > 1 ? [{ t: ' ' + spark(run.scores), c: 'claude' as ThemeKey }] : [])]))
215 }
216 const blockers = groupViolations(g?.violations ?? [])
217 for (const v of blockers.slice(0, 3)) children.push(el(ui, { t: ' ✗ ' + clean(v, 300), c: 'error' }, 'wrap'))
218 if (blockers.length > 3) children.push(el(ui, { t: ' +' + (blockers.length - 3) + ' more', dim: true }))
219 for (const v of groupViolations(g?.warnings ?? []).slice(0, 2)) children.push(el(ui, { t: ' ! ' + clean(v, 300), c: 'warning', dim: true }, 'wrap'))
220 }
221 if (status?.diagnostics.length) for (const d of status.diagnostics.slice(0, 3)) children.push(el(ui, { t: '! ' + clean(d, 400), c: 'warning' }, 'wrap'))
222 if (s.message && !s.busy) children.push(el(ui, { t: clean(s.message, 400), dim: true }, 'wrap'))
223
224 children.push(rule('DRAFT'))
225 children.push(row(ui, [{ t: s.draft ? clean(s.draft, W - 4) : 'none selected', c: s.draft ? 'text' : 'warning' }]))
226 if (s.projectRoot) children.push(el(ui, { t: 'project ' + clean(s.projectRoot, Math.max(8, W - 10)), dim: true }))
227 if (s.draft) {
228 const when = status?.checked_at?.match(/T(\d\d:\d\d)/)?.[1]
229 children.push(row(ui, [chip(status, s.busy), { t: ' · ' + passedCount + '/5 gates · last check ' + (when ? when + 'Z' : 'never'), dim: true }]))
230 }
231 if (status?.artifacts.length) {
232 const names = status.artifacts.slice(0, 6).map(a => clean(a, 40))
233 children.push(el(ui, { t: 'files ' + names.join(', ') + (status.artifacts.length > 6 ? ' +' + (status.artifacts.length - 6) : ''), dim: true }, 'wrap'))
234 }
235 if (run.detected && run.detected !== s.draft) {
236 children.push(ui.Box({ flexDirection: 'row', gap: 1, children: [
237 el(ui, { t: 'seen: ' + clean(run.detected, Math.max(8, W - 24)), dim: true }),
238 ui.Button({ key: 'use-detected', label: 'Use this draft (u)', hotkey: 'u', onPress: act.useDetected }),
239 ] }))
240 }
241
242 if (s.busy) {
243 children.push(el(ui, { t: '◐ ' + clean(s.message ?? 'Working...', 80) + ' (buttons are off until it finishes)', c: 'claude' }, 'wrap'))
244 } else {
245 children.push(ui.Box({ flexDirection: W < 48 ? 'column' : 'row', gap: 1, children: [
246 ...(s.draft ? [
247 ui.Button({ key: 'refresh', label: 'Refresh (r)', hotkey: 'r', onPress: act.refresh }),
248 ui.Button({ key: 'check', label: 'Run checks (c)', hotkey: 'c', variant: 'primary', onPress: act.check }),
249 ] : []),
250 ui.Button({ key: 'setup', label: (run.setup ? 'Hide settings' : 'Settings') + ' (s)', hotkey: 's', dimColor: true, onPress: act.toggleSetup }),
251 ui.Button({ key: 'close', label: 'Close', role: 'dismiss', dimColor: true, onPress: act.close }),
252 ] }))
253 children.push(rule('RECENT BLOGS', history.length ? history.length + ' saved' : ''))
254 if (!history.length) {
255 children.push(el(ui, { t: 'None yet. Blogs appear as the agent writes them. Backfill: /blog-studio history scan <folder>', dim: true }, 'wrap'))
256 } else {
257 const shown = history.slice(0, run.historyOpen ? 25 : 5)
258 shown.forEach((e, i) => {
259 const current = e.root === rootOf(s) && e.draft === s.draft
260 const label = (i + 1) + ' ' + formatWhen(e.lastSeen) + ' ' + statusGlyph(e.status) + ' ' + clean(title(e), Math.max(8, W - 26)) +
261 (e.status ? ' ' + e.passed + '/5' : '')
262 children.push(ui.Button({ key: 'hist-' + e.id, label, ...(i < 9 ? { hotkey: String(i + 1) } : {}),
263 ...(current ? { variant: 'primary' as const } : { dimColor: true }), onPress: () => act.openEntry(e.id) }))
264 })
265 if (history.length > 5) children.push(ui.Button({ key: 'hist-toggle', label: (run.historyOpen ? 'Fewer' : 'All ' + history.length + ' blogs') + ' (h)',
266 hotkey: 'h', dimColor: true, onPress: act.toggleHistory }))
267 }
268 if (!s.draft || !s.scripts || run.setup) {
269 children.push(rule('SETTINGS'))
270 children.push(ui.Input({ key: 'draft', label: 'Draft folder', placeholder: 'outputs/article', onSubmit: act.selectDraft }))
271 children.push(ui.Input({ key: 'scripts', label: 'Engine scripts', placeholder: s.scripts ? clean(s.scripts, 60) : '/path/to/claude-blog/scripts',
272 onSubmit: act.configureScripts }))
273 }
274 }
275 return ui.Box({ flexDirection: 'column', gap: 0, children })
276}
277types/index.d.ts 56 lines1export type Gate = { gate: number; name: string; passed: boolean; score: number | null; violations: string[]; warnings: string[] }
2export type Status = {
3 schema_version: 1
4 status: 'passed' | 'blocked' | 'partial' | 'stale' | 'unverified' | 'error'
5 label: string
6 checked_at: string | null
7 passed_count: number
8 gates: Gate[]
9 artifacts: string[]
10 diagnostics: string[]
11 run_id?: string
12}
13export type StudioState = {
14 workspace: string
15 sessionId: string
16 scripts: string | null
17 draft: string | null
18 generation: number
19 projectRoot: string | null
20 busy: boolean
21 status: Status | null
22 message: string | null
23}
24export type StageId = 'research' | 'outline' | 'draft' | 'visuals' | 'render' | 'review' | 'gates'
25export type StageState = 'pending' | 'active' | 'done' | 'failed'
26export type Stage = { state: StageState; n: number; startedAt: number | null; endedAt: number | null; note: string | null }
27export type RunState = {
28 workspace: string
29 sessionId: string
30 skill: string | null
31 startedAt: number | null
32 endedAt: number | null
33 working: boolean
34 stages: Record<StageId, Stage>
35 detected: string | null
36 scores: number[]
37 setup: boolean
38 historyOpen: boolean
39}
40export type HistoryEntry = {
41 id: string
42 root: string
43 draft: string
44 name: string
45 firstSeen: number
46 lastSeen: number
47 status: Status['status'] | null
48 passed: number
49 score: number | null
50 source: 'observed' | 'selected' | 'checked' | 'scan'
51}
52export type HistoryState = { entries: HistoryEntry[]; loaded: boolean }
53declare module 'claude-code' {
54 interface PluginState { 'blog-studio': { studio: StudioState; run: RunState; history: HistoryState } }
55}
56