Festival Methodology: goal-oriented project management for human-AI development workflows. Provides fest and camp CLI integration, automatic installation, and…

A domain plugin that teaches Claude Code to drive the fest and camp CLIs and the Festival methodology. It bundles slash commands, methodology skills, specialized agents, a session hook that keeps the CLIs installed, and a commit-discipline guard.
It is not a generic engineering-process library. Its scope is the Festival methodology and the fest/camp toolchain. For the methodology itself, see the docs linked at the end.
claude-plugin/
.claude-plugin/plugin.json plugin manifest (name, version, planningTakeover option, types)
tsconfig.json type-check config for the mod (extends the engine-generated one)
types/index.d.ts types for the mod's `$.state` atoms
skills/ 12 skills, one SKILL.md each
commands/ 11 fest-* and camp-* slash commands
agents/ fest-executor, fest-planner
hooks/
hooks.json SessionStart + PreToolUse hook wiring, plus the `modules` entry for the mod
mod/register.tsx the live festival view and planning takeover (Claude Code 2.1.290+)
mod/fest.ts pure helpers that shape `fest` JSON into the band and tree rows
mod/camp.ts camp-root lookup for @-mentions
mod/*.test.ts tests, run by `claude plugin test`
scripts/ensure-festival.sh installs and updates fest and camp
scripts/ensure-festival.test.sh unit tests for local-version parsing (run by the gate)
scripts/commit-guard.sh blocks raw `git commit` inside a camp
scripts/commit-guard.test.sh unit tests for the guard (run by the gate)
scripts/sync-check.sh checks plugin command refs against the CLIs
The marketplace manifest lives at the festival repo root, not inside this directory:
.claude-plugin/marketplace.json repo-root marketplace entry; source points at ./claude-plugin
Claude Code discovers a marketplace manifest only at <repo-root>/.claude-plugin/marketplace.json, and each entry's source resolves relative to the repo root. Because the plugin bundle is the claude-plugin/ subdirectory, the manifest sits at the repo root with source: "./claude-plugin".
Two paths.
Marketplace flow. Add the festival repo as a marketplace, then install the plugin:
/plugin marketplace add Obedience-Corp/festival
/plugin install festival@festival
This resolves the repo-root marketplace.json, whose single entry points at the in-repo bundle (source: "./claude-plugin").
The same two steps from a shell:
claude plugin marketplace add Obedience-Corp/festival
claude plugin install festival@festival
On first session the SessionStart hook (hooks/scripts/ensure-festival.sh) downloads fest and camp if they are missing, checksum-verifies the archive, and installs them. It also checks for updates once per day and notifies you when a new release is available.
A PreToolUse (Bash) hook (hooks/scripts/commit-guard.sh) enforces camp commit discipline: commits must route through camp commit (camp root), camp p commit (inside projects/*), or fest commit (during festivals) so festival traceability and camp bookkeeping are preserved.
Because a plugin hook fires in every session, the guard self-scopes. It blocks a Bash command only when all of the following hold, and otherwise exits without interfering:
git commit segment, andcamp id), andcamp and jq are both available.The command is split on ;, &&, ||, and newlines and each segment is matched start-anchored, so a raw commit hidden after a wrapper in a compound command (fest commit ...; git commit ...) is still caught, and a git commit appearing only inside a wrapper's quoted message is not a false positive. Detection is a discipline guard, not a security control: it does not defeat deliberate obfuscation (bash -c, aliases, eval).
Outside a camp, in repos without camp, or on machines without jq, it fails open. Set CAMP_ALLOW_RAW_GIT=1 to override deliberately for one command. commit-guard.test.sh encodes the detection matrix and runs in the plugin gate.
The plugin ships an in-process hooks module, hooks/mod/register.tsx, that Claude Code loads from the modules key in hooks/hooks.json. It needs Claude Code 2.1.290 or newer. Older versions either ignore the module or report that it failed to load. Either way the rest of this plugin (skills, commands, agents, the install hook, and the commit guard) keeps working.
What it draws:
festival build-todo-app-BT0001 | 003_IMPLEMENT > 01_app_core > 01_todo_model | 19/35 (54%). In a workflow phase it names the current step, a blocked task or step is marked (blocked), a finished festival says complete, and in a standalone workflow it shows step N/M with the step name. The current position is the task or step in progress, or else the first unfinished one. It refreshes at session start (awaited), then after each turn and after any Bash call that runs fest or camp, without waiting for those refreshes to finish.On a narrow terminal the pane sits inline above the prompt instead of docked beside the transcript, and the engine leaves no rows for the AbovePrompt band while that inline pane is open (see AbovePrompt maxRows in the mod types).
When the session has no interactive surface (for example claude -p) the module does no band or pane work, and /fest-watch says it needs an interactive session. When fest is missing, exits non-zero, or prints something it cannot read, the module draws nothing and leaves the normal screen alone.
Commands the module registers (the markdown commands fest-next and fest-status are separate and unchanged):
/fest-watch opens or closes the pane./fest-task prints the text of fest next./fest-progress prints the text of fest progress.Camp-root mentions: inside a camp, an @path mention that does not exist relative to the current directory is retried against the camp root. This lets you mention @docs/guide.md from inside a project directory. These mentions have no autocomplete; you type the whole path.
Planning takeover is an option, planningTakeover, set when you enable the plugin. It is off by default. When on, and only while the session's current directory is inside a camp (a .campaign directory in it or an ancestor), the module hides the Plan agent, denies EnterPlanMode, denies TodoWrite and TaskCreate, and drops the todo and task reminders, so planning and task tracking go through Festival. Outside a camp the option does nothing.
What the module reads and runs, and nothing else: fest show --json for the band and pane, fest version --short once each time the module loads (retried if it fails), fest next and fest progress only when you run /fest-task or /fest-progress, and file existence checks for @-mentions. The two commands run exactly as they would in a terminal, so they can record workflow progress or migrate legacy progress files the same way. The background view never runs fest next. Before fest 0.9.3, fest show itself can write: it migrates a festival's legacy .fest/progress.yaml or .fest/workflow_state.yaml, and fest 0.9.1 also rewrites a standalone workflow's cached summary. So with an older fest (including 0.9.3 pre-releases) the band and pane only run inside a festival directory that has neither legacy file, and elsewhere stay empty with a note to update fest. It makes no network calls and never asks you a question. Every process it starts has a timeout (5 seconds for JSON and the version check, 10 seconds for text). Every hook that can refuse something has a .catch that lets the original action through, so a fault in the module cannot block a tool call or a mention.
claude plugin validate claude-plugin lists the module's hooks and the $ calls it makes, so you can audit it without reading the source.
From the festival repo root:
just plugin check runs scripts/test_claude_plugin.sh: JSON parse of both manifests, plugin semver and metadata, component frontmatter, in-bundle hook references, the CLI sync-check, the install-hook smoke test, and the mod check. The mod check always verifies the module's manifest wiring. When the claude CLI is installed it also runs claude plugin validate, claude plugin test, and a tsc type-check against the typings the engine writes to .claude-plugin/types/ (git-ignored; the gate produces them with one claude -p load when absent). Without claude those three steps are skipped with a notice, so the gate and the release never require Claude Code.just plugin list lists the bundled commands, skills, and agents.just plugin bump <version> rewrites the version in plugin.json and marketplace.json together and rejects a non-semver argument.just test all now includes the plugin gate, so plugin breakage surfaces on every local default test run, not only in the release workflow.
SKILL.md short (when to use, core loop, key commands) and move heavy reference into sibling files loaded just in time. Split a skill when its SKILL.md crosses roughly 100 lines or carries a large reference table. The current 12 skills are short and stay single-file.The plugin does not collect conversation content, chat history, memory, or uploaded files. Nothing in the bundle sends user data to Obedience Corp.
The only network use is the SessionStart hook, which talks to GitHub for the Obedience-Corp/festival repository: releases/latest, the release checksum file, and the platform archive. Downloads are checksum-verified before install. Update checks are rate-limited to once per day. When fest and camp are already on PATH and current, the hook does not download anything.
The mod described above makes no network calls either.
The PreToolUse commit guard does not make network calls. It only inspects the Bash command line, and only when the session is inside a camp and camp and jq are available.
This README covers the plugin bundle only. For the Festival methodology itself:
festivals/README.md in a camp (the agent entry point)hooks/mod/register.tsx 269 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { FestView } from '../../types/index'
4
5import { campRoot, festivalRoot, rootedPath } from './camp'
6import { LEGACY_PROGRESS_FILES, READ_ONLY_FEST, STATUS_COLOR, bandOf, coalesce, currentRow, headerOf, rowsOfView, versionAtLeast, viewOf, windowStart } from './fest'
7
8const PANE = 'fest-watch'
9const band = atom({ plugin: 'festival', key: 'band' } as const, null)
10const view = atom({ plugin: 'festival', key: 'view' } as const, null)
11const isOpen = atom({ plugin: 'festival', key: 'isOpen' } as const, false)
12const notice = atom({ plugin: 'festival', key: 'notice' } as const, null)
13
14const COMMANDS = [
15 { name: 'fest-watch', description: 'Toggle the live Festival pane' },
16 { name: 'fest-task', description: 'Print the current task (fest next)', immediate: true },
17 { name: 'fest-progress', description: 'Print festival progress (fest progress)', immediate: true },
18] as const
19
20let timer: { cancel: () => void } | null = null
21let watchGeneration = 0
22const runRefresh = coalesce()
23let wantsTakeover = false
24let showIsReadOnly: boolean | null = null
25
26const PLAN_DENY =
27 'This camp plans with Festival, so plan mode is off here. Do not retry it. Size the work as one session, a standalone workflow (`fest create workflow`), or a festival, then run `fest next` and follow what it prints.'
28const TODO_DENY =
29 'This camp tracks tasks with Festival, not a session todo list. Run `fest next` for the current task and `fest task completed` when it is done.'
30
31async function runJson($: any, argv: string[], cwd?: string) {
32 try {
33 const r = await $.process.run(argv, cwd ? { timeoutMs: 5000, cwd } : { timeoutMs: 5000 })
34 if (r.exitCode !== 0 || r.isStdoutTruncated) return null
35 return JSON.parse(r.stdout)
36 } catch {
37 return null
38 }
39}
40
41async function festShowIsReadOnly($: any): Promise<boolean | null> {
42 try {
43 const r = await $.process.run(['fest', 'version', '--short'], { timeoutMs: 5000 })
44 return r.exitCode === 0 ? versionAtLeast(r.stdout, READ_ONLY_FEST) : null
45 } catch {
46 return null
47 }
48}
49
50const NEEDS_NEWER_FEST = `This view needs fest ${READ_ONLY_FEST} or newer here.`
51const FEST_UNAVAILABLE = 'fest did not answer `fest version --short`; is it installed?'
52
53type PollPlan = { blocker: string | null; cwd?: string }
54
55async function pollPlan($: any): Promise<PollPlan> {
56 if (showIsReadOnly === null) showIsReadOnly = await festShowIsReadOnly($)
57 if (showIsReadOnly === true) return { blocker: null }
58 if (showIsReadOnly === null) return { blocker: FEST_UNAVAILABLE }
59 try {
60 const exists = (p: string) => $.fs.exists(p)
61 const cwd = await $.session.cwd()
62 const root = await festivalRoot(exists, cwd)
63 if (root === null) return { blocker: NEEDS_NEWER_FEST }
64 for (const file of LEGACY_PROGRESS_FILES) {
65 if (await exists(`${root}/.fest/${file}`)) return { blocker: NEEDS_NEWER_FEST }
66 }
67 return { blocker: null, cwd }
68 } catch {
69 return { blocker: NEEDS_NEWER_FEST }
70 }
71}
72
73async function refresh($: any) {
74 if ((await $.session.surfaces()).length === 0) return
75 const { blocker, cwd } = await pollPlan($)
76 if (blocker !== null) {
77 await update($, view, () => null)
78 await update($, band, () => null)
79 await update($, notice, () => blocker)
80 $.ui.invalidate('ui.render')
81 return
82 }
83 let next: FestView | null = null
84 let text: string | null = null
85 try {
86 next = viewOf(await runJson($, ['fest', 'show', '--json'], cwd))
87 text = bandOf(next)
88 if (next) rowsOfView(next)
89 } catch {
90 next = null
91 text = null
92 }
93 await update($, notice, () => null)
94 await update($, view, () => next)
95 await update($, band, () => text)
96 $.ui.invalidate('ui.render')
97}
98
99function scheduleRefresh($: any): Promise<void> {
100 return runRefresh(() => refresh($))
101}
102
103async function refreshOwnOrSkip($: any): Promise<void> {
104 if (runRefresh.isBusy()) {
105 void scheduleRefresh($)
106 return
107 }
108 await scheduleRefresh($)
109}
110
111function startPolling($: any, generation: number) {
112 if (generation !== watchGeneration) return
113 timer?.cancel()
114 timer = $.clock.every(5000, () => void scheduleRefresh($))
115}
116
117async function takeoverActive($: any): Promise<boolean> {
118 if (!wantsTakeover) return false
119 try {
120 return (await campRoot(p => $.fs.exists(p), await $.session.cwd())) !== null
121 } catch {
122 return false
123 }
124}
125
126async function stopWatching($: any) {
127 watchGeneration += 1
128 timer?.cancel()
129 timer = null
130 await update($, isOpen, () => false)
131}
132
133async function plain($: any, argv: string[]) {
134 try {
135 const r = await $.process.run(argv, { timeoutMs: 10000 })
136 return (r.stdout || r.stderr).trimEnd() || `(${argv.join(' ')} printed nothing, exit ${r.exitCode})`
137 } catch (err) {
138 return `${argv.join(' ')} failed: ${String(err)}`
139 }
140}
141
142export const register: Register = (on, options) => {
143 wantsTakeover = (options as { planningTakeover?: boolean } | undefined)?.planningTakeover === true
144
145 on('session.start', async ($, e, next) => {
146 for (const spec of COMMANDS) {
147 try {
148 await $.command.register(spec)
149 } catch {}
150 }
151 try {
152 const panes = await $.ui.panes()
153 if (!panes.some((p: { id: string }) => p.id === PANE)) await stopWatching($)
154 else await update($, isOpen, () => true)
155 } catch {
156 await stopWatching($)
157 }
158 const generation = watchGeneration
159 await refreshOwnOrSkip($)
160 if (await read($, isOpen)) startPolling($, generation)
161 return next(e)
162 }).catch(($, e, next) => next(e))
163
164 on('turn.complete', async ($, e, next) => {
165 const done = await next(e)
166 $.clock.after(0, () => void scheduleRefresh($))
167 return done
168 }).catch(($, e, next) => next(e))
169
170 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
171 const ran = await next(e)
172 if (/\b(fest|camp) /.test(e.command)) $.clock.after(0, () => void scheduleRefresh($))
173 return ran
174 }).catch(($, e, next) => next(e))
175
176 on('agent.offer', async ($, e, next) =>
177 e.agent === 'Plan' && (await takeoverActive($)) ? { isOffered: false } : next(e),
178 ).catch(($, e, next) => next(e))
179
180 on('tool.call', { tool: 'EnterPlanMode' }, async ($, e, next) =>
181 (await takeoverActive($)) ? { deny: PLAN_DENY } : next(e),
182 ).catch(($, e, next) => next(e))
183 on('tool.call', { tool: ['TodoWrite', 'TaskCreate'] }, async ($, e, next) =>
184 (await takeoverActive($)) ? { deny: TODO_DENY } : next(e),
185 ).catch(($, e, next) => next(e))
186 on('prompt.attachment', { type: ['todo_reminder', 'task_reminder'] }, async ($, e, next) =>
187 (await takeoverActive($)) ? { text: null } : next(e),
188 ).catch(($, e, next) => next(e))
189
190 on('classic.CwdChanged', async ($, e, next) => {
191 const done = await next(e)
192 $.ui.invalidate('prompt.attachment')
193 $.clock.after(0, () => void scheduleRefresh($))
194 return done
195 }).catch(($, e, next) => next(e))
196
197 on('command.run', { command: 'fest-task' }, async $ => ({ text: await plain($, ['fest', 'next']) }))
198 .catch(() => ({ text: 'fest-task failed; run `fest next` in a terminal.' }))
199 on('command.run', { command: 'fest-progress' }, async $ => ({ text: await plain($, ['fest', 'progress']) }))
200 .catch(() => ({ text: 'fest-progress failed; run `fest progress` in a terminal.' }))
201
202 on('command.run', { command: 'fest-watch' }, async $ => {
203 if (await read($, isOpen)) {
204 await stopWatching($)
205 await $.ui.close({ id: PANE })
206 return { text: 'Festival pane closed.' }
207 }
208 if ((await $.session.surfaces()).length === 0) {
209 return { text: 'The Festival pane needs an interactive Claude Code session.' }
210 }
211 await $.ui.open({ id: PANE, title: 'Festival' })
212 await update($, isOpen, () => true)
213 const generation = watchGeneration
214 await refreshOwnOrSkip($)
215 if (!(await read($, isOpen)) || generation !== watchGeneration) return { text: 'Festival pane closed.' }
216 startPolling($, generation)
217 return { text: 'Festival pane opened.' }
218 }).catch(() => ({ text: 'The Festival pane could not be toggled.' }))
219
220 on('ui.close', async ($, e, next) => {
221 if (e.id === PANE) await stopWatching($)
222 return next(e)
223 }).catch(($, e, next) => next(e))
224
225 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
226 const text = await read($, band)
227 if (!text || e.props.hasSurvey) return next(e)
228 const { Box, Text } = $.ui.resolve(e)
229 const [head, ...rest] = text.split(' | ')
230 const tail = rest.length > 1 ? rest.pop() : undefined
231 return (
232 <Box>
233 <Text>
234 <Text color="claude" bold>{head}</Text>
235 {rest.map(part => <Text dimColor>{' | '}{part}</Text>)}
236 {tail ? <Text color="success">{' | '}{tail}</Text> : null}
237 </Text>
238 </Box>
239 )
240 })
241
242 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243 const { Box, Text } = $.ui.resolve(e)
244 const data = await read($, view)
245 if (!data) return <Text dimColor>{(await read($, notice)) ?? 'No festival or workflow here.'}</Text>
246 const body = e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 24) - 6
247 const room = Math.max(3, body - 1)
248 const rows = rowsOfView(data)
249 const cur = currentRow(rows)
250 const start = windowStart(rows, room)
251 return (
252 <Box flexDirection="column">
253 <Text color="claude" bold>{headerOf(data)}</Text>
254 {rows.slice(start, start + room).map((r, i) => (
255 <Text color={STATUS_COLOR[r.status] ?? 'text'} bold={start + i === cur || r.status === 'in_progress'}>
256 {' '.repeat(r.depth)}{r.text}
257 </Text>
258 ))}
259 </Box>
260 )
261 })
262
263 on('prompt.mention', async ($, e, next) => {
264 if (await $.fs.exists(e.path)) return next(e)
265 const path = await rootedPath(p => $.fs.exists(p), await $.session.cwd(), e.mention)
266 return path === null ? next(e) : next({ ...e, path })
267 }).catch(($, e, next) => next(e))
268}
269hooks/mod/camp.ts 43 lines1export async function campRoot(
2 exists: (path: string) => Promise<boolean>,
3 cwd: string,
4): Promise<string | null> {
5 let dir = cwd.replace(/\/+$/, '')
6 while (dir !== '') {
7 if (await exists(`${dir}/.campaign`)) return dir
8 dir = dir.slice(0, dir.lastIndexOf('/'))
9 }
10 return (await exists('/.campaign')) ? '/' : null
11}
12
13export async function rootedPath(
14 exists: (path: string) => Promise<boolean>,
15 cwd: string,
16 mention: string,
17): Promise<string | null> {
18 const rel = mention.replace(/#.*$/, '').replace(/^\.\//, '')
19 if (rel === '' || rel.startsWith('/') || rel.split('/').includes('..')) return null
20 const root = await campRoot(exists, cwd)
21 if (root === null) return null
22 const candidate = `${root === '/' ? '' : root}/${rel}`
23 return (await exists(candidate)) ? candidate : null
24}
25
26const GOAL_ONLY_MARKERS = ['FESTIVAL_GOAL.md', 'FESTIVAL_OVERVIEW.md']
27
28export async function festivalRoot(
29 exists: (path: string) => Promise<boolean>,
30 cwd: string,
31): Promise<string | null> {
32 let dir = cwd.replace(/\/+$/, '')
33 while (dir !== '') {
34 if (await exists(`${dir}/fest.yaml`)) return dir
35 for (const marker of GOAL_ONLY_MARKERS) {
36 if (await exists(`${dir}/${marker}`)) return null
37 }
38 if (await exists(`${dir}/.campaign`)) return null
39 dir = dir.slice(0, dir.lastIndexOf('/'))
40 }
41 return null
42}
43hooks/mod/fest.ts 184 lines1import type { FestNode, FestView, WorkflowStep } from '../../types/index'
2
3const MARKS: Record<string, string> = {
4 completed: '[x]',
5 in_progress: '[~]',
6 pending: '[ ]',
7 blocked: '[!]',
8 skipped: '[-]',
9}
10
11export const STATUS_COLOR: Record<string, string> = {
12 completed: 'success',
13 in_progress: 'warning',
14 blocked: 'error',
15 pending: 'inactive',
16 skipped: 'inactive',
17}
18
19const FINISHED = new Set(['completed', 'skipped'])
20
21const last = (path: string) => path.split('/').filter(Boolean).pop() ?? path
22const bare = (name: string) => name.replace(/\.md$/, '')
23
24const isText = (v: unknown): v is string => typeof v === 'string'
25
26function nodeOf(raw: any): FestNode | null {
27 if (!raw || typeof raw !== 'object' || !isText(raw.name) || !isText(raw.status)) return null
28 const kids = Array.isArray(raw.children) ? raw.children.map(nodeOf) : []
29 if (kids.some((k: FestNode | null) => k === null)) return null
30 return { name: raw.name, status: raw.status, node_type: isText(raw.node_type) ? raw.node_type : '', children: kids }
31}
32
33function stepOf(raw: any): WorkflowStep | null {
34 if (!raw || typeof raw !== 'object' || typeof raw.number !== 'number' || !isText(raw.name) || !isText(raw.status)) return null
35 return { number: raw.number, name: raw.name, status: raw.status }
36}
37
38export function viewOf(json: any): FestView | null {
39 if (!json || typeof json !== 'object') return null
40 if (isText(json.mode) && json.mode.startsWith('standalone')) {
41 const raw = Array.isArray(json.steps) ? json.steps : []
42 const steps = raw.map(stepOf)
43 if (steps.some((s: WorkflowStep | null) => s === null)) return null
44 const doc = isText(json.workflow_doc) ? json.workflow_doc : ''
45 return {
46 kind: 'workflow',
47 name: last(doc.replace(/\/WORKFLOW\.md$/, '')) || 'workflow',
48 runStatus: isText(json.run_status) ? json.run_status : '',
49 steps: steps as WorkflowStep[],
50 }
51 }
52 const tree = nodeOf(json.view?.tree)
53 const tasks = json.stats?.tasks
54 if (tree && tasks && typeof tasks.total === 'number' && typeof tasks.completed === 'number' && typeof json.stats.progress === 'number') {
55 return {
56 kind: 'festival',
57 name: isText(json.name) ? json.name : tree.name,
58 tree,
59 stats: { tasks: { total: tasks.total, completed: tasks.completed }, progress: json.stats.progress },
60 }
61 }
62 return null
63}
64
65export type Row = { depth: number; text: string; status: string; path: string[] }
66
67export function rowsOf(node: FestNode, depth = 0, expand = true, path: string[] = []): Row[] {
68 const mark = MARKS[node.status] ?? '[?]'
69 const rows: Row[] = [{ depth, text: `${mark} ${bare(node.name)}`, status: node.status, path }]
70 const kids = node.children ?? []
71 if (!expand) return rows
72 const first = kids.findIndex(k => !FINISHED.has(k.status))
73 const below = depth === 0 ? [] : [...path, bare(node.name)]
74 kids.forEach((k, i) => {
75 const open = k.status === 'in_progress' || k.status === 'blocked' || i === first
76 rows.push(...rowsOf(k, depth + 1, open, below))
77 })
78 return rows
79}
80
81export function stepRows(steps: WorkflowStep[]): Row[] {
82 return steps.map(s => ({ depth: 0, text: `${MARKS[s.status] ?? '[?]'} ${s.number} ${s.name}`, status: s.status, path: [] }))
83}
84
85export function currentRow(rows: Row[]): number {
86 const isLeaf = (i: number) => i + 1 >= rows.length || rows[i + 1]!.depth <= rows[i]!.depth
87 const active = rows.findIndex((r, i) => r.status === 'in_progress' && isLeaf(i))
88 if (active !== -1) return active
89 return rows.findIndex((r, i) => !FINISHED.has(r.status) && isLeaf(i))
90}
91
92export function windowStart(rows: Row[], room: number): number {
93 const cur = Math.max(0, currentRow(rows))
94 return Math.max(0, Math.min(cur - Math.floor(room / 3), rows.length - room))
95}
96
97export function rowsOfView(view: FestView): Row[] {
98 return view.kind === 'festival' ? rowsOf(view.tree) : stepRows(view.steps)
99}
100
101export function headerOf(view: FestView): string {
102 if (view.kind === 'festival') {
103 const t = view.stats.tasks
104 return `tasks ${t.completed}/${t.total} (${view.stats.progress}%)`
105 }
106 const done = view.steps.filter(s => FINISHED.has(s.status)).length
107 return `steps ${done}/${view.steps.length}`
108}
109
110export function bandOf(view: FestView | null): string | null {
111 if (!view) return null
112 const rows = rowsOfView(view)
113 const cur = currentRow(rows)
114 const row = cur === -1 ? null : rows[cur]!
115 const flag = row?.status === 'blocked' ? ' (blocked)' : ''
116 if (view.kind === 'festival') {
117 const count = headerOf(view).replace(/^tasks /, '')
118 if (!row || view.tree.status === 'completed') return `festival ${view.name} | complete | ${count}`
119 const where = [...row.path, row.text.slice(4)].join(' > ')
120 return `festival ${view.name} | ${where}${flag} | ${count}`
121 }
122 if (view.steps.length === 0) return `workflow ${view.name} | no run`
123 const count = `${headerOf(view).replace(/^steps /, '')} steps`
124 if (!row || view.runStatus === 'completed') return `workflow ${view.name} | complete | ${count}`
125 const step = view.steps[cur]!
126 return `workflow ${view.name} | step ${step.number}/${view.steps.length}: ${step.name}${flag} | ${count}`
127}
128
129export const READ_ONLY_FEST = '0.9.3'
130export const LEGACY_PROGRESS_FILES = ['progress.yaml', 'workflow_state.yaml']
131
132export function versionAtLeast(text: string, want: string): boolean {
133 const have = /v?(\d+)\.(\d+)\.(\d+)(-\S*)?/.exec(text)
134 if (!have) return false
135 const w = want.split('.').map(Number)
136 for (let i = 0; i < 3; i++) {
137 const h = Number(have[i + 1])
138 if (h !== w[i]) return h > w[i]!
139 }
140 return have[4] === undefined
141}
142
143type Job = { run: () => Promise<void>; done: Promise<void>; finish: () => void }
144
145function jobOf(run: () => Promise<void>): Job {
146 let finish = () => {}
147 const done = new Promise<void>(resolve => { finish = resolve })
148 return { run, done, finish }
149}
150
151export type Scheduler = ((run: () => Promise<void>) => Promise<void>) & { isBusy: () => boolean }
152
153export function coalesce(): Scheduler {
154 let running = false
155 let pending: Job | null = null
156 const drain = async (first: Job) => {
157 running = true
158 let job: Job | null = first
159 while (job) {
160 try {
161 await job.run()
162 } catch {}
163 job.finish()
164 job = pending
165 pending = null
166 }
167 running = false
168 }
169 const schedule = (run: () => Promise<void>) => {
170 if (!running) {
171 const job = jobOf(run)
172 void drain(job)
173 return job.done
174 }
175 if (pending) {
176 pending.run = run
177 return pending.done
178 }
179 pending = jobOf(run)
180 return pending.done
181 }
182 return Object.assign(schedule, { isBusy: () => running })
183}
184types/index.d.ts 38 lines1export type FestNode = {
2 name: string
3 status: string
4 node_type: string
5 children?: FestNode[]
6}
7
8export type WorkflowStep = {
9 number: number
10 name: string
11 status: string
12}
13
14export type FestView =
15 | {
16 kind: 'festival'
17 name: string
18 tree: FestNode
19 stats: { tasks: { total: number; completed: number }; progress: number }
20 }
21 | {
22 kind: 'workflow'
23 name: string
24 runStatus: string
25 steps: WorkflowStep[]
26 }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'festival': {
31 band: string | null
32 view: FestView | null
33 isOpen: boolean
34 notice: string | null
35 }
36 }
37}
38