SLOPSHOPPER

festival

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

newpanebandguardcommandprompt
★ 61v1.4.1Apache-2.0updated 2026-10-08Obedience-Corp/festival/claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · festival
│ ┃ Festival ✕ › fix the failing auth test and add an audit log call │ ┃ This view needs fest 0.9.3 or newer here. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fest-watch │ ⎿ festival: Festival pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Festival
This view needs fest 0.9.3 or newer here.
README

Festival Plugin for Claude Code

What this is

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.

Layout

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

Install

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.

Commit guard

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:

  • the command has a raw git commit segment, and
  • the session is inside a camp (detected via camp id), and
  • camp 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.

Live festival view (mod)

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:

  • A one-line band above the prompt with the current position, for example 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.
  • A pane that shows the festival tree with finished branches collapsed and the current branch expanded, headed by the task count and percentage, or a standalone workflow's steps. When the tree is taller than the pane, the view follows the current task so it stays on screen. It refreshes every five seconds while open.

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.

Local development gate

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-authoring conventions

  • Descriptions are trigger-style. Lead with "Use when ..." and name concrete cues (commands, directory names, user intents). Keep them to one or two sentences. Do not claim a skill "auto-activates"; the description is the only signal Claude uses to load the skill. No emdashes (house style).
  • Supporting-file pattern. Keep 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.

Privacy and network access

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.

Methodology docs

This README covers the plugin bundle only. For the Festival methodology itself:

Source 4 files
hooks/mod/register.tsx 269 lines
1import { 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}
269
hooks/mod/camp.ts 43 lines
1export 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}
43
hooks/mod/fest.ts 184 lines
1import 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}
184
types/index.d.ts 38 lines
1export 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