SLOPSHOPPER

studio-state

Gives Claude a studio_state tool for a studio-layout video repository: one compact answer on where a video project stands, instead of a dozen file reads…

guardtool
v0.1.0MITupdated 2026-10-160langa/claude-code-mods/studio-state
A shopper browsing a rack in a slop shop
README

studio-state

For a video-production repository with a studio/ pipeline and one projects/<slug>/ folder per video, the layout the mod looks for. To find out where a video project stands, an agent lists projects/<slug>/, reads studio.json, final.json, metadata.json, checks the registry entry, and compares file times in its head. This mod gives Claude one tool, studio_state, that does all of it and answers in about ten lines.

example-video — How a thing works  [series: example-series]
Cut: final.mp4 150.8 s, 256 MB, -14.1 LUFS, true peak -3.8 dBTP, 7 scenes.
Script: 7 scenes (7 narrated), about 149.1 s, 5 chapters, claims 5 docs-cited / 0 demonstration / 2 editorial, 0 validate warnings.
Stages run: new, validate, tts, align, stills, storyboard, render, assemble, thumbnail, package, publish (last: publish, 2026-10-16 19:29Z).
Needs attention: nothing (by file times).
Sources: 3 snapshots.
Registry: unlisted, YouTube AAAAAAAAAAA (2026-10-16).
Shorts: none planned.

With no slug it lists every project in one line each.

What "Needs attention" means

It compares file times, so it is a hint, not a proof (a fresh git clone gives every file the same time). It says so when:

  • script.json was edited after the last validate, or is newer than final.mp4 (run is due);
  • metadata.json is newer than description.md (package is due);
  • the cut changed and the frames were not reviewed since (frames is due);
  • renders/<slug>/final.mp4 is older than final.mp4 (publish is due);
  • validate left warnings.

What it does not do

It only reads. It never runs a stage, never touches credentials, never uploads. The slug is checked against the studio's own slug rule (lowercase letters, digits, single dashes), so it cannot be pointed at another path.

Only in the studio repo

The mod is installed like the others, for every session, but when a session starts it checks for studio/schema.py and projects/ in the folder. Anywhere else it registers nothing, so the tool is not in the prompt and costs no tokens. In the studio repo the tool is in the prompt's tool list from the start (roughly 200 tokens), which is what makes it the first thing reached for.

Install

/plugin install studio-state --marketplace 0langa/claude-code-mods
Source 2 files
hooks/register.ts 54 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { describeProject, listProjects, SLUG } from './summarize'
4import type { Files } from './summarize'
5
6const TOOL = 'mcp__studio-state__studio_state'
7
8const DESCRIPTION =
9  'Where a studio video project stands, in about ten lines: title and series, the cut (length, size, loudness, scenes), script figures, ' +
10  'which stages have run, files edited after what was built from them (what to rerun), source count, registry and upload status, and Shorts. ' +
11  'Read-only. Use it instead of listing projects/<slug>/ and reading studio.json, final.json, metadata.json and the registry entry. ' +
12  'With no slug it lists every project in one line each.'
13
14const filesOf = ($: EngineInterface): Files => ({
15  read: path => $.fs.read(path).catch(() => undefined),
16  mtime: path => $.fs.stat(path).then(stat => stat.mtimeMs, () => undefined),
17  dirs: path => $.fs.list(path).then(entries => entries.filter(entry => entry.kind === 'dir').map(entry => entry.name), () => []),
18})
19
20// Only the studio repo: nothing is registered, so nothing costs a token, anywhere else.
21const isStudioRepo = async ($: EngineInterface) => (await $.fs.exists('studio/schema.py')) && (await $.fs.exists('projects'))
22
23export const register: Register = on => {
24  on('session.start', async ($, e, next) => {
25    if (await isStudioRepo($).catch(() => false)) {
26      await $.tool.register({
27        name: 'studio_state',
28        description: DESCRIPTION,
29        inputSchema: {
30          type: 'object',
31          properties: { slug: { type: 'string', description: 'The project folder name under projects/. Leave out to list all projects.' } },
32        },
33        // In the prompt's tool list from the start: that is what makes it the first thing reached for.
34        isDeferred: false,
35      })
36    }
37
38    return next(e)
39  })
40
41  on('tool.call', { tool: TOOL }, async ($, e) => {
42    const slug = typeof e.slug === 'string' ? e.slug.trim() : ''
43    const files = filesOf($)
44    const text =
45      slug === ''
46        ? await listProjects(files)
47        : SLUG.test(slug)
48          ? await describeProject(files, slug)
49          : `"${slug}" is not a project folder name (lowercase letters, digits and single dashes).`
50
51    return { result: { content: [{ type: 'text', text }], isError: false } }
52  })
53}
54
hooks/summarize.ts 205 lines
1// Where a studio video project stands, in a few lines, from the files the
2// studio's stages already write (projects/<slug>/studio.json, final.json,
3// metadata.json, shorts.json, and the registry entry). Read only.
4
5export type Files = {
6  /** The file's text, or undefined when there is none. */
7  read: (path: string) => Promise<string | undefined>
8  /** The file's modification time in milliseconds, or undefined when there is none. */
9  mtime: (path: string) => Promise<number | undefined>
10  /** The names of the directories inside a directory. */
11  dirs: (path: string) => Promise<string[]>
12}
13
14export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
15
16// The order `python -m studio run` and `publish` go in.
17const STAGES = ['new', 'validate', 'tts', 'align', 'stills', 'storyboard', 'render', 'assemble', 'thumbnail', 'package', 'publish']
18
19type Stage = { at?: string; [key: string]: unknown }
20type StudioJson = { stages?: Record<string, Stage | undefined> }
21type FinalJson = {
22  duration_s?: number
23  output?: { bytes?: number }
24  loudness?: { integrated_lufs?: number; true_peak_dbtp?: number }
25  segments?: unknown[]
26}
27type Metadata = { title?: string; series?: string }
28type ShortsPlan = { shorts?: { stem?: string; shape?: string; title?: string }[] }
29
30const parse = <T>(text: string | undefined): T | undefined => {
31  if (text === undefined) {
32    return undefined
33  }
34  try {
35    return JSON.parse(text) as T
36  } catch {
37    return undefined
38  }
39}
40
41const num = (value: unknown) => (typeof value === 'number' ? value : undefined)
42const stamp = (at: string | undefined) => (at === undefined ? '?' : `${at.slice(0, 10)} ${at.slice(11, 16)}Z`)
43
44// A registry entry: the studio writes it as plain YAML, so two lines are enough.
45const registryOf = (yaml: string | undefined) => {
46  if (yaml === undefined) {
47    return undefined
48  }
49  const status = /^status:[ \t]*(\S+)/m.exec(yaml)?.[1]
50  const id = /^[ \t]+video_id:[ \t]*(\S+)/m.exec(yaml)?.[1]
51  const date = /^[ \t]+date:[ \t]*(\S+)/m.exec(yaml)?.[1]
52  const isUploaded = id !== undefined && id !== 'null' && id !== '~' && id !== '""'
53
54  return { status: status ?? '?', text: isUploaded ? `${status ?? '?'}, YouTube ${id}${date !== undefined && date !== 'null' ? ` (${date})` : ''}` : `${status ?? '?'}, not uploaded` }
55}
56
57const lastStage = (studio: StudioJson | undefined) => {
58  const done = STAGES.filter(stage => studio?.stages?.[stage] !== undefined)
59
60  return done.length === 0
61    ? undefined
62    : done.reduce((latest, stage) => ((studio?.stages?.[stage]?.at ?? '') >= (studio?.stages?.[latest]?.at ?? '') ? stage : latest), done[0] ?? 'new')
63}
64
65export const listProjects = async (files: Files) => {
66  const slugs = (await files.dirs('projects')).filter(name => SLUG.test(name)).sort()
67
68  if (slugs.length === 0) {
69    return 'No projects under projects/.'
70  }
71  const lines = await Promise.all(
72    slugs.map(async slug => {
73      const base = `projects/${slug}`
74      const [studioText, metaText, finalText, registryText] = await Promise.all([
75        files.read(`${base}/studio.json`),
76        files.read(`${base}/metadata.json`),
77        files.read(`${base}/final.json`),
78        files.read(`registry/videos/${slug}.yaml`),
79      ])
80      const studio = parse<StudioJson>(studioText)
81      const final = parse<FinalJson>(finalText)
82      const last = lastStage(studio)
83      const duration = num(final?.duration_s)
84      const registry = registryOf(registryText)
85
86      return [
87        slug,
88        parse<Metadata>(metaText)?.title ?? '(no title yet)',
89        last === undefined ? 'no stage run' : `last stage ${last}`,
90        duration === undefined ? 'no cut' : `cut ${duration.toFixed(0)} s`,
91        registry?.text ?? 'no registry entry',
92      ].join(' — ')
93    }),
94  )
95
96  return `${slugs.length} projects (studio_state with a slug gives the detail):\n${lines.join('\n')}`
97}
98
99export const describeProject = async (files: Files, slug: string) => {
100  const base = `projects/${slug}`
101  const studio = parse<StudioJson>(await files.read(`${base}/studio.json`))
102
103  if (studio === undefined) {
104    const known = (await files.dirs('projects')).filter(name => SLUG.test(name)).sort()
105
106    return `No project "${slug}" (no ${base}/studio.json). Projects: ${known.join(', ') || 'none'}.`
107  }
108  const [metaText, finalText, sourcesText, shortsText, registryText] = await Promise.all([
109    files.read(`${base}/metadata.json`),
110    files.read(`${base}/final.json`),
111    files.read(`${base}/sources.json`),
112    files.read(`${base}/shorts.json`),
113    files.read(`registry/videos/${slug}.yaml`),
114  ])
115  const meta = parse<Metadata>(metaText)
116  const final = parse<FinalJson>(finalText)
117  const stages = studio.stages ?? {}
118  const lines: string[] = []
119
120  lines.push(`${slug} — ${meta?.title ?? '(no title yet)'}${meta?.series === undefined ? '' : `  [series: ${meta.series}]`}`)
121
122  const duration = num(final?.duration_s)
123
124  lines.push(
125    duration === undefined
126      ? 'Cut: none yet (no final.json).'
127      : `Cut: final.mp4 ${duration.toFixed(1)} s, ${Math.round((num(final?.output?.bytes) ?? 0) / 1048576)} MB, ${final?.loudness?.integrated_lufs ?? '?'} LUFS, true peak ${final?.loudness?.true_peak_dbtp ?? '?'} dBTP, ${final?.segments?.length ?? '?'} scenes.`,
128  )
129
130  const validate = stages.validate
131  const claims = validate?.claim_kinds as Record<string, number> | undefined
132
133  lines.push(
134    validate === undefined
135      ? 'Script: not validated yet.'
136      : `Script: ${validate.scenes} scenes (${validate.narrated_scenes} narrated), about ${validate.estimated_seconds} s, ${validate.chapters} chapters, claims ${claims?.['docs-cited'] ?? 0} docs-cited / ${claims?.demonstration ?? 0} demonstration / ${claims?.editorial ?? 0} editorial, ${validate.warnings} validate warnings.`,
137  )
138
139  const ran = STAGES.filter(stage => stages[stage] !== undefined)
140  const notRun = STAGES.filter(stage => stages[stage] === undefined)
141  const last = lastStage(studio)
142
143  lines.push(`Stages run: ${ran.join(', ') || 'none'}${last === undefined ? '' : ` (last: ${last}, ${stamp(stages[last]?.at)})`}.${notRun.length === 0 ? '' : ` Not run: ${notRun.join(', ')}.`}`)
144
145  // Which file was edited after the thing built from it. File times, so a hint.
146  const t = async (path: string) => files.mtime(`${base}/${path}`)
147  const [script, metadata, finalMp4, description, frames, validatedAt] = await Promise.all([
148    t('script.json'),
149    t('metadata.json'),
150    t('final.mp4'),
151    t('description.md'),
152    t('review/contact-sheet.png'),
153    Promise.resolve(validate?.at === undefined ? undefined : Date.parse(validate.at)),
154  ])
155  const publishCopy = await files.mtime(`renders/${slug}/final.mp4`)
156  const attention: string[] = []
157
158  if (script !== undefined && validatedAt !== undefined && script > validatedAt + 2000) {
159    attention.push('script.json was edited after the last validate: python -m studio validate ' + slug)
160  }
161  if (script !== undefined && finalMp4 !== undefined && script > finalMp4 + 1000) {
162    attention.push('script.json is newer than final.mp4: python -m studio run ' + slug)
163  }
164  if (metadata !== undefined && description !== undefined && metadata > description + 1000) {
165    attention.push('metadata.json is newer than description.md: python -m studio package ' + slug)
166  }
167  if (finalMp4 !== undefined && (frames === undefined || frames < finalMp4)) {
168    attention.push('the frames have not been reviewed since the cut changed: python -m studio frames ' + slug)
169  }
170  if (finalMp4 !== undefined && publishCopy !== undefined && publishCopy < finalMp4 - 1000) {
171    attention.push(`renders/${slug}/final.mp4 is older than final.mp4: python -m studio publish ${slug}`)
172  }
173  if (typeof validate?.warnings === 'number' && validate.warnings > 0) {
174    attention.push(`validate left ${validate.warnings} warnings: read them and decide`)
175  }
176  lines.push(attention.length === 0 ? 'Needs attention: nothing (by file times).' : `Needs attention (by file times):\n- ${attention.join('\n- ')}`)
177
178  const sources = parse<unknown[]>(sourcesText)
179
180  const sourceCount = Array.isArray(sources) ? sources.length : 0
181
182  lines.push(`Sources: ${sourceCount} ${sourceCount === 1 ? 'snapshot' : 'snapshots'}.`)
183  lines.push(`Registry: ${registryOf(registryText)?.text ?? 'no entry yet (publish has not run)'}.`)
184
185  const shorts = parse<ShortsPlan>(shortsText)?.shorts ?? []
186
187  if (shorts.length === 0) {
188    lines.push('Shorts: none planned.')
189  } else {
190    const rows = await Promise.all(
191      shorts.map(async short => {
192        const stem = short.stem ?? '?'
193        const isCut = (await files.mtime(`${base}/shorts/${stem}.mp4`)) !== undefined
194        const entry = registryOf(await files.read(`registry/videos/${slug}-${stem}.yaml`))
195
196        return `${stem} (${short.shape ?? '?'}, "${short.title ?? ''}"): ${isCut ? 'cut' : 'planned, not cut'}; ${entry?.text ?? 'no registry entry'}`
197      }),
198    )
199
200    lines.push(`Shorts:\n- ${rows.join('\n- ')}`)
201  }
202
203  return lines.join('\n')
204}
205