SLOPSHOPPER

blog-studio

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

newpanebandguardcommandtoast
★ 2,402v0.2.0MITupdated 2026-10-14AgriciDaniel/claude-blog/plugins/blog-studio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blog-studio
│ ┃ Blog Studio ✕ › fix the failing auth test and add an audit log call │ ┃ ✎ Blog Studio idle │ ┃ ────────────────────────────────────── 0/7 ⏺ Read(src/auth.ts) │ ┃ PIPELINE ─────── starts when the agent begin ⎿ Read 6 lines │ ┃ · Research waiting ⏺ Update(src/auth.ts) │ ┃ · Outline waiting ⎿ Added 2 lines, removed 1 line │ ┃ · Draft waiting ⏺ Bash(bun test) │ ┃ · Visuals waiting ⎿ 3 pass, 1 fail │ ┃ · Render waiting │ ┃ · Review waiting ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · Gates waiting │ ┃ DELIVERY GATES ───────────────────────── not ✻ Worked for 42s · done 4:20 PM │ ┃ · 1 Capability Discovery │ ┃ · 2 Format Completeness › /blog-studio │ ┃ · 3 Visual Verification │ ┃ · 4 Content Review │ ┃ · 5 Asset + Link Integrity │ ┃ DRAFT ────────────────────────────────────── │ ┃ none selected │ ┃ [ Settings (s) ] [ Close ] │ ┃ RECENT BLOGS ─────────────────────────────── │ ┃ None yet. Blogs appear as the agent writes │ ┃ them. Backfill: /blog-studio history scan │ ┃ <folder> │ ┃ SETTINGS ─────────────────────────────────── │ ┃ Draft folder: outputs/article ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blog Studio
✎ Blog Studio idle ────────────────────────────────────── 0/7 · 0% PIPELINE ─────── starts when the agent begins a blog · Research waiting · Outline waiting · Draft waiting · Visuals waiting · Render waiting · Review waiting · Gates waiting DELIVERY GATES ───────────────────────── not checked · 1 Capability Discovery · 2 Format Completeness · 3 Visual Verification · 4 Content Review · 5 Asset + Link Integrity DRAFT ───────────────────────────────────────────── none selected [ Settings (s) ] [ Close ] RECENT BLOGS ────────────────────────────────────── None yet. Blogs appear as the agent writes them. Backfill: /blog-studio history scan <folder> SETTINGS ────────────────────────────────────────── Draft folder: outputs/article Engine scripts: /path/to/claude-blog/scripts
README

Blog Studio

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.

Install

/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.

Use

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:

  1. Configure the absolute scripts directory from a trusted engine installation using /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.
  2. Select an existing project-relative article directory with /blog-studio select outputs/article. Spaces in the directory are accepted literally; do not wrap the path in shell quotes.
  3. Open /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.

CommandBehavior
/blog-studioOpen the pipeline and gates panel, or show the text fallback
/blog-studio statusShow cached state, last check time and the pipeline line
/blog-studio historyList 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 demoShow a sample pipeline mid-draft, to judge the design without an agent
/blog-studio resetClear the pipeline view
/blog-studio select <directory>Select a project-relative draft
/blog-studio configure <scripts>Pin trusted absolute engine helpers
/blog-studio refreshInspect files and saved evidence without network or writes
/blog-studio checkRun 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.

The live pipeline

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.

History of past blogs

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.

Reading the results

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.

Evidence format

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.

Capability footprint

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

Development and acceptance gates

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.

Source 6 files
hooks/register.ts 457 lines
1import 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}
457
hooks/logic.ts 100 lines
1import 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}
100
hooks/pipeline.ts 153 lines
1import 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}
153
hooks/history.ts 84 lines
1import 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}
84
hooks/view.ts 277 lines
1import 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}
277
types/index.d.ts 56 lines
1export 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