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…

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.
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);frames is due);renders/<slug>/final.mp4 is older than final.mp4 (publish is due);validate left warnings.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.
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.
/plugin install studio-state --marketplace 0langa/claude-code-modshooks/register.ts 54 lines1import 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}
54hooks/summarize.ts 205 lines1// 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