A pane showing this repo's resolved wafflestack state — behavioral keys, run files, locks, drift, sliced to what the last `/skill` cares about — read through…

<h1 align="center">WaffleStack</h1> <img src="assets/wafflestack-social-animated.svg" width="100%" alt="WaffleStack — a stack of golden waffles with an animated syrup pour"> <h3 align="center">Reusable AI agent and skill definitions, distributed shadcn-style but <strong>update-safe</strong>.</h3> <a href="https://github.com/sponsors/dustinkeeton"><img src="https://img.shields.io/badge/Sponsor-GitHub-F08A1D?logo=githubsponsors&logoColor=F5C752&labelColor=241204" alt="Sponsor on GitHub"></a> <a href="https://buymeacoffee.com/dustinkeeton"><img src="https://img.shields.io/badge/Support-Buy%20Me%20a%20Coffee-F08A1D?logo=buymeacoffee&logoColor=F5C752&labelColor=241204" alt="Support on Buy Me a Coffee"></a>
One canonical source repo, rendered into harness-native files inside each consuming project — the same batter, baked fresh wherever you need it. Every supported harness gets both agents and skills: Claude Code, OpenAI Codex, and the cross-tool .agents/ directory.
Where it fits: wafflestack is the update-safe distribution layer for agent process and skills content — consumer-owned files under a lock manifest, drift detection via doctor, and explicit, diffable upgrades with versioned migrations. It carries content across repos and harnesses and keeps it current; it doesn't produce that content (that's a process pack like gstack) or run it (that's an always-on agent runtime).
claude target only).files/ payload a stack can also carry (CI workflows, scripts, config), rendered verbatim to any repo-relative path with the same {{key}} substitution, lock tracking, and drift detection.Let your coding agent drive the whole setup. Kick it off either way:
Agent prompt — paste to your coding agent:
Set up wafflestack in this repo: run `npx github:dustinkeeton/wafflestack setup` and
follow the playbook it prints. Ask me which stacks to enable before you render.
Inline shell (Claude Code / Codex) — type in the prompt (the ! prefix runs the command in-session and feeds its output back to the model; --yes keeps npx non-interactive):
! npx --yes github:dustinkeeton/wafflestack setup
setup prints an agent playbook plus a generated inventory of every stack, config key, and prerequisite. The agent then detects targets, asks which stacks to enable, fills .waffle/waffle.yaml, renders, runs doctor, and reports what it did.
The labels wafflestack's workflows and skills gate on are listed once — table plus a copy-paste gh label create bootstrap — in schema/SETUP.md § Required labels.
cd your-project
npx github:dustinkeeton/wafflestack init # writes a starter .waffle/waffle.yaml
# edit .waffle/waffle.yaml: pick stacks, fill in config values
npx github:dustinkeeton/wafflestack#v0.13.0 render # renders all harness files + lock manifest
| Command | What it does | |
|---|---|---|
init | Write a starter .waffle/waffle.yaml (--gitignore also appends overlay/local-lock ignores). | |
setup | Print the agent-driven install playbook + generated toolkit inventory. | |
list | Show every waffle and syrup file per stack with its status; --interactive for a TTY multi-select. | |
toggle | Pick which rendered skills an agent may invoke on its own (disable-model-invocation, per project): TTY picker, or --disable/--enable <skill>; writes .waffle/waffle.yaml and re-renders. | |
render (alias bake) | Regenerate every managed file from source + config, prune stale ones, write the lock. | |
install [ref…] | Add stacks/items to .waffle/waffle.yaml, pull dependencies, then render. Bare = render. | |
upgrade | Move an install across toolkit versions: print CHANGELOG, run migrations, re-render, doctor. | |
doctor | Diff managed files against the lock; report edits, missing files, env gaps. Exit 1 on drift. | |
report | Print redacted diagnostics (lock, config keys, doctor summary) for an upstream toolkit bug report; --json for machines. | |
state | Print the resolved state — every behavioral key with its value, layer and tokens, the delegate run files, lock status and drift; --json for the documented document. | |
eject <item> | Stop managing an item — its files stay and become project-owned; drops it from include:. install <item> un-ejects it. | |
uninstall | Remove the whole install (reports until you pass --yes; keeps hand-edited files). | |
reinstall | Re-render the same selection from clean; --clean --yes for a full reset. | |
| `avatars <sync\ | status>` | Keep Gravatar in sync with the installed agent roster. |
validate | Toolkit-developer lint: manifests, frontmatter, placeholders, and agent refs. | |
help | Print usage and a one-line description of every command and flag. |
Run npx github:dustinkeeton/wafflestack help for the full flag detail on any command.
render overwrites them. Put additions in .waffle/extensions/{agents,skills}/<name>.md and parameters in .waffle/waffle.yaml..waffle/waffle.local.yaml — gitignored, merged over the committed config, and never propagated: the lock hashes the canonical render, so every teammate's lock is byte-identical.render; a breaking tag needs upgrade. See Updating.doctor is the CI drift gate — run it on PRs to fail the build on any hand-edited managed file. Add --allow-missing when some renders are gitignored, --verify-render to also catch config changes that were never re-rendered.Deciding what to commit? Committing vs. gitignoring the rendered output walks the trade-off.
Hit a toolkit bug? Report it upstream, not in your own tracker: /waffle-report <what went wrong> (from the wafflestack stack) collects wafflestack report's redacted diagnostics — config keys only, no overlay, no absolute paths — shows you the exact payload, and files it against the toolkit's issue forms on your yes. No gh auth? It prints the body and a prefilled new-issue URL instead.
Want the state at a glance? The wafflestack stack also ships the toolkit's first Claude Code mod, waffle-view: once rendered (.claude/mods/waffle-view/), check it with claude plugin validate .claude/mods/waffle-view, load it with claude --plugin-dir "$PWD/.claude/mods/waffle-view" (or list the absolute path in CLAUDE_CODE_PLUGIN_DIRS for every session), and type /waffle-view to toggle a pane showing every behavioral key's effective value and source layer, the delegate run files, lock status, and doctor drift — all read through wafflestack state --json, refreshed as you work. wafflestack setup prints the full load block for every mod a repo renders. The pane narrows to the last /skill you typed (a Skill-tool call the model makes does not move it; a /name that is not a toolkit skill reads as the full view), and the values it shows include your gitignored waffle.local.yaml overlay. Load the rendered copy, never a stack's source dir: a load lays tsconfig.json and .claude-plugin/types/ into the dir it loads (gitignore them under .claude/mods/*/).
WaffleStack ships as git tags (vX.Y.Z), versioned from a consumer's point of view:
| Bump | Example | Means for you | How to take it |
|---|---|---|---|
| patch | 0.5.0 → 0.5.1 | content-only fixes | … render |
| minor | 0.5.0 → 0.6.0 | new stacks/items, additive config | … render |
| major | renamed/removed item, new required key, changed layout | needs a migration | … upgrade |
The … is always a pinned spec (npx github:dustinkeeton/wafflestack#vX.Y.Z) — unpinned commands refuse. Full detail — the upgrade command, migrations, pin-moving, and release resolution — lives in Upgrades and release resolution.
© 2026 Dustin Keeton. Licensed under the Apache License, Version 2.0.
<a href="https://github.com/dustinkeeton/wafflestack/blob/waffle-telemetry/.waffle/telemetry/tokens.json"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdustinkeeton%2Fwafflestack%2Fwaffle-telemetry%2F.waffle%2Ftelemetry%2Ftokens.json" alt="Claude token spend (automated runs)"></a> <img src="assets/wafflestack-flat.svg" width="24" alt=""><br> A <strong>WaffleWorks</strong> project
hooks/register.tsx 286 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { WaffleViewDoc, WaffleViewKey, WaffleViewSlice } from '../types'
5import {
6 LOCAL_BIN,
7 LOCAL_CLI,
8 PANE,
9 TITLE,
10 formatMode,
11 isOverridden,
12 isPersonsPrompt,
13 parseStateOutput,
14 promptSkill,
15 resolveArgv,
16 selectKeys,
17 selectSlice,
18 tokensOf,
19 valueLines,
20} from './state'
21
22const doc = atom({ plugin: 'waffle-view', key: 'doc' } as const, null)
23const error = atom({ plugin: 'waffle-view', key: 'error' } as const, null)
24const isRefreshing = atom({ plugin: 'waffle-view', key: 'isRefreshing' } as const, false)
25// The last thing the person invoked (#563): `issue` after `/issue`, null after a plain prompt.
26// Tracked whether or not the pane is open, so opening it after `/issue` already slices.
27const skill = atom({ plugin: 'waffle-view', key: 'skill' } as const, null)
28
29type Dollar = Pick<EngineInterface, 'state' | 'process' | 'fs' | 'ui'>
30
31// The only read path: `wafflestack state --json --offline` through `$.process`. The last good
32// document survives a failed refresh; the failure is shown beside it.
33async function refresh($: Dollar): Promise<void> {
34 if (await read($, isRefreshing)) return
35 await update($, isRefreshing, () => true)
36 try {
37 const argv = resolveArgv({
38 hasLocalCli: await $.fs.exists(LOCAL_CLI),
39 hasLocalBin: await $.fs.exists(LOCAL_BIN),
40 })
41 const parsed = parseStateOutput(await $.process.run(argv, { timeoutMs: 120_000 }))
42 if (parsed.doc) await update($, doc, () => parsed.doc)
43 await update($, error, () => parsed.error)
44 } catch (err) {
45 await update($, error, () => (err instanceof Error ? err.message : String(err)))
46 } finally {
47 await update($, isRefreshing, () => false)
48 }
49}
50
51const isOpen = async ($: Dollar) => (await $.ui.panes()).some(pane => pane.id === PANE)
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 await $.command.register({
56 name: PANE,
57 description: "Toggle the waffle view: this repo's resolved wafflestack state.",
58 })
59
60 return next(e)
61 })
62
63 // `/waffle-view` toggles: a second invocation closes the pane instead of re-opening it.
64 on('command.run', { command: PANE }, async $ => {
65 if (await isOpen($)) {
66 await $.ui.close({ id: PANE })
67
68 return { text: 'Waffle view pane closed.' }
69 }
70 await $.ui.open({ id: PANE, title: TITLE })
71 await refresh($)
72
73 return { text: 'Waffle view pane opened.' }
74 })
75
76 on('command.run', async ($, e, next) => {
77 if (e.command !== PANE && isPersonsPrompt(e.origin)) await update($, skill, () => e.command)
78 const ran = await next(e)
79 if (e.command !== PANE && (await isOpen($))) void refresh($)
80
81 return ran
82 }).catch(($, e, next) => next(e))
83
84 on('prompt.submit', async ($, e, next) => {
85 const invoked = promptSkill(e.text)
86 if (invoked !== PANE && isPersonsPrompt(e.origin)) await update($, skill, () => invoked)
87 const entered = await next(e)
88 if (await isOpen($)) void refresh($)
89
90 return entered
91 }).catch(($, e, next) => next(e))
92
93 on('turn.complete', async ($, e, next) => {
94 const done = await next(e)
95 if (e.agentId === undefined && (await isOpen($))) void refresh($)
96
97 return done
98 }).catch(($, e, next) => next(e))
99
100 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
101 const { Box, Text } = $.ui.resolve(e)
102 const state = await read($, doc)
103 const failure = await read($, error)
104 const busy = await read($, isRefreshing)
105 const context = { skill: await read($, skill) }
106 const slice = state ? selectSlice(state, context) : null
107 const wide = e.props.bodyColumns >= 100
108
109 const section = (title: string, rows: RenderNode[]) => (
110 <Box flexDirection="column" marginBottom={1}>
111 <Text bold underline>{title}</Text>
112 {rows}
113 </Box>
114 )
115 const dim = (text: string) => <Text dimColor>{text}</Text>
116
117 const keyRow = (key: WaffleViewKey) => {
118 const over = isOverridden(key)
119 const value = formatMode(key.value) + (over ? ` (lock: ${formatMode(key.canonical)})` : '')
120 const tokens = tokensOf(key)
121 const style = over ? { color: 'warning' as const } : {}
122 if (!wide) {
123 return (
124 <Text wrap="truncate-end" {...style}>
125 {`${key.key} ${value} ${key.source}${tokens ? ` ${tokens}` : ''}`}
126 </Text>
127 )
128 }
129
130 return (
131 <Box>
132 <Box width={30}>
133 <Text wrap="truncate-end">{key.key}</Text>
134 </Box>
135 <Box width={26}>
136 <Text bold {...style}>{value}</Text>
137 </Box>
138 <Box width={16}>{dim(key.source)}</Box>
139 <Text dimColor wrap="truncate-end">{tokens}</Text>
140 </Box>
141 )
142 }
143
144 const keysSection = (state: WaffleViewDoc) => {
145 const keys = selectKeys(state, context)
146 const header = wide ? (
147 <Box>
148 <Box width={30}>{dim('key')}</Box>
149 <Box width={26}>{dim('value')}</Box>
150 <Box width={16}>{dim('source')}</Box>
151 {dim('tokens')}
152 </Box>
153 ) : null
154 const rows: RenderNode[] =
155 keys.length === 0
156 ? [dim(slice ? `/${slice.skill} reads no behavioral key` : 'no behavioral keys in the selected stacks')]
157 : keys.map(keyRow)
158
159 return section(`Keys (${keys.length})`, header ? [header, ...rows] : rows)
160 }
161
162 // The other config a sliced skill reads: name, layer, and the value's lines.
163 const configSection = ({ skill: name, config }: WaffleViewSlice) => {
164 if (config.length === 0) return null
165 const rows: RenderNode[] = config.flatMap(({ key, value, source }) => [
166 <Box>
167 <Text bold>{key}</Text>
168 {dim(` ${source}`)}
169 </Box>,
170 ...valueLines(value).map(line => <Text wrap="truncate-end">{` ${line}`}</Text>),
171 ])
172
173 return section(`Config /${name} reads (${config.length})`, rows)
174 }
175
176 // Sliced: only the files the skill writes, the delegate facts when they are among them, the
177 // rest by path; nothing when it writes none. Full view: the delegate facts, as before.
178 const runFilesSection = ({ runFiles: { delegate } }: WaffleViewDoc) => {
179 if (slice && slice.files.length === 0) return null
180 const sliced = slice ? new Set(slice.files) : null
181 const delegateShown = delegate && (!sliced || sliced.has(delegate.checkpoints.path) || sliced.has(delegate.memory.path))
182 if (!delegateShown) {
183 if (!sliced) return section('Run files', [dim('delegate: not declared by a selected stack')])
184
185 return section('Run files', [...sliced].map(file => <Text>{file}</Text>))
186 }
187 const { checkpoints, memory } = delegate
188 const latest = checkpoints.latest
189 const rows: RenderNode[] = [
190 <Text>
191 {checkpoints.exists
192 ? `delegate checkpoints: ${checkpoints.runs} run${checkpoints.runs === 1 ? '' : 's'} in ${checkpoints.path}`
193 : `delegate checkpoints: none (${checkpoints.path})`}
194 </Text>,
195 ]
196 if (latest) {
197 rows.push(
198 <Text {...(latest.parseError ? { color: 'error' as const } : {})}>
199 {` latest ${latest.runId} · phase ${latest.lastPhase ?? '—'} · ${latest.mtime}${latest.parseError ? ` · unreadable: ${latest.parseError}` : ''}`}
200 </Text>,
201 )
202 }
203 rows.push(
204 <Text {...(memory.overCap ? { color: 'error' as const } : {})}>
205 {memory.exists
206 ? `delegate memory: ${memory.bytes}/${memory.maxBytes} bytes${memory.overCap ? ' — OVER CAP' : ''}`
207 : `delegate memory: none (${memory.path})`}
208 </Text>,
209 )
210 if (sliced) {
211 const known = new Set([checkpoints.path, checkpoints.latest?.file, memory.path])
212 for (const file of sliced) if (!known.has(file)) rows.push(<Text>{file}</Text>)
213 }
214
215 return section('Run files', rows)
216 }
217
218 const locksSection = ({ locks }: WaffleViewDoc) => {
219 const describe = (lock: WaffleViewDoc['locks']['committed']) =>
220 lock.present
221 ? `toolkit ${lock.toolkitVersion ?? '?'} (${lock.toolkitStatus ?? '?'}${lock.toolkitRef ? `, ${lock.toolkitRef}` : ''}) · ${lock.files} files`
222 : 'absent'
223 const sync = locks.inSync === null ? '' : locks.inSync ? ' · in sync with committed' : ' · DIVERGED from committed'
224
225 return section('Locks', [
226 <Text>{`tree: ${locks.tree}`}</Text>,
227 <Text>{`committed: ${describe(locks.committed)}`}</Text>,
228 <Text {...(locks.inSync === false ? { color: 'warning' as const } : {})}>
229 {`local: ${locks.local.present ? describe(locks.local) + sync : 'none'}`}
230 </Text>,
231 ])
232 }
233
234 const driftSection = ({ drift }: WaffleViewDoc) => {
235 const rows: RenderNode[] = drift.ok
236 ? [<Text color="success">clean — the render matches the lock</Text>]
237 : [
238 <Text color="error">{`${drift.modified.length} modified · ${drift.missing.length} missing`}</Text>,
239 ...drift.modified.slice(0, 5).map(file => dim(` M ${file}`)),
240 ...drift.missing.slice(0, 5).map(file => dim(` ? ${file}`)),
241 ]
242 if (drift.absentDocs.length > 0) rows.push(dim(`${drift.absentDocs.length} generated .waffle/ docs absent (optional)`))
243
244 return section('Drift', rows)
245 }
246
247 const projectSection = ({ project }: WaffleViewDoc) =>
248 section('Project', [
249 <Text>{`targets: ${project.targets.join(', ') || '—'}`}</Text>,
250 <Text>{`stacks: ${project.stacks.join(', ') || '—'}`}</Text>,
251 <Text wrap="truncate-end">{`include: ${project.include.length ? project.include.join(', ') : '—'}`}</Text>,
252 <Text wrap="truncate-end">{`eject: ${project.eject.length ? project.eject.join(', ') : '—'}`}</Text>,
253 dim(`local overlay: ${project.localOverlay ? 'yes' : 'no'}`),
254 ...project.errors.map(problem => <Text color="error">{problem}</Text>),
255 ])
256
257 const heading = state ? (
258 <Box marginBottom={1}>
259 <Text bold>{`wafflestack ${state.cli.version}`}</Text>
260 {dim(` (${state.cli.status}${state.cli.commit ? ` ${state.cli.commit.slice(0, 7)}` : ''})`)}
261 {slice && <Text color="suggestion">{` · /${slice.skill}`}</Text>}
262 {busy && dim(' · refreshing…')}
263 </Box>
264 ) : (
265 <Box marginBottom={1}>
266 <Text bold>wafflestack</Text>
267 {dim(busy ? ' · reading state…' : ' · no state read yet')}
268 </Box>
269 )
270
271 return (
272 <Box flexDirection="column">
273 {heading}
274 {failure && <Text color="error">{`last refresh failed: ${failure}`}</Text>}
275 {state && !slice && projectSection(state)}
276 {state && keysSection(state)}
277 {slice && configSection(slice)}
278 {state && runFilesSection(state)}
279 {state && !slice && locksSection(state)}
280 {state && !slice && driftSection(state)}
281 {slice && dim(`sliced to what /${slice.skill} reads — a plain prompt restores the full view`)}
282 </Box>
283 )
284 })
285}
286hooks/state.ts 103 lines1import type { WaffleViewContext, WaffleViewDoc, WaffleViewKey, WaffleViewSlice } from '../types'
2
3export const PANE = 'waffle-view'
4export const TITLE = 'Waffle view'
5export const LOCAL_CLI = 'installer/cli.mjs'
6export const LOCAL_BIN = 'node_modules/.bin/wafflestack'
7export const TOOLKIT_SPEC = 'github:dustinkeeton/wafflestack'
8const STATE_ARGS = ['state', '--json', '--offline']
9
10export type Probe = { hasLocalCli: boolean; hasLocalBin: boolean }
11export type ProcessOutput = { exitCode: number; stdout: string; stderr: string }
12export type Parsed = { doc: WaffleViewDoc; error: null } | { doc: null; error: string }
13
14// The toolkit's own checkout first (dogfooding), then an installed dependency, then npx over
15// the toolkit's default branch: `state` is read-only, so the unpinned spec `render` refuses
16// (#373) is safe here.
17export function resolveArgv({ hasLocalCli, hasLocalBin }: Probe): string[] {
18 if (hasLocalCli) return ['node', LOCAL_CLI, ...STATE_ARGS]
19 if (hasLocalBin) return [LOCAL_BIN, ...STATE_ARGS]
20 return ['npx', '--yes', TOOLKIT_SPEC, ...STATE_ARGS]
21}
22
23export function parseStateOutput({ exitCode, stdout, stderr }: ProcessOutput): Parsed {
24 const tail = stderr.trim().split('\n').filter(Boolean).slice(-3).join(' · ')
25 if (exitCode !== 0) {
26 return { doc: null, error: `wafflestack state exited ${exitCode}${tail ? `: ${tail}` : ''}` }
27 }
28 let parsed: unknown
29 try {
30 parsed = JSON.parse(stdout)
31 } catch {
32 return { doc: null, error: `wafflestack state printed no JSON${tail ? ` (${tail})` : ''}` }
33 }
34 const version = typeof parsed === 'object' && parsed !== null ? (parsed as { version?: unknown }).version : undefined
35 if (version !== 1) {
36 return { doc: null, error: `state document version ${String(version)} is not the version 1 this pane reads` }
37 }
38 return { doc: parsed as WaffleViewDoc, error: null }
39}
40
41// The context seam (#563): the behavioral keys the pane shows for what the user is doing — every
42// key, in the CLI's order, unless `context.skill` names a skill the document knows.
43export function selectKeys(doc: WaffleViewDoc, context: WaffleViewContext): WaffleViewKey[] {
44 const slice = selectSlice(doc, context)
45 return slice ? slice.keys : doc.keys
46}
47
48// Null restores the full view: no skill, or one `doc.skills` does not know (a built-in command, a
49// skill outside the toolkit). `config` is what the skill reads beyond its behavioral keys.
50export function selectSlice(doc: WaffleViewDoc, context: WaffleViewContext): WaffleViewSlice | null {
51 if (context.skill === null) return null
52 const skill = doc.skills[context.skill]
53 if (!skill) return null
54 const wanted = new Set(skill.keys)
55 const keys = doc.keys.filter(key => wanted.has(key.key))
56 const behavioral = new Set(keys.map(key => key.key))
57 const config = skill.keys
58 .filter(key => !behavioral.has(key) && key in doc.config)
59 .map(key => ({ key, value: doc.config[key]!.value, source: doc.config[key]!.source }))
60
61 return { skill: context.skill, keys, config, files: skill.files }
62}
63
64// What a prompt invoked: `/issue 12` → `issue`; a plain prompt → null.
65export function promptSkill(text: string): string | null {
66 const m = /^\s*\/([\w][\w.-]*)/.exec(text)
67 return m ? m[1]! : null
68}
69
70// Whether a prompt is the person's own: typed, bridged, the SDK host's, or a plugin's `asUser`
71// one — never a peer's message, a task notification or a scheduled trigger delivered meanwhile.
72// A session always stamps an origin; one that is absent (a test's own submit) counts as the person's.
73export function isPersonsPrompt(origin: { kind: string; asUser?: true } | undefined): boolean {
74 if (!origin) return true
75 if (origin.kind === 'plugin') return origin.asUser === true
76 return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk' || origin.kind === 'unclassified'
77}
78
79// A config value as pane lines: strings line by line (the first `max`, then a count), anything
80// else as one JSON line.
81export function valueLines(value: unknown, max = 8): string[] {
82 if (value === null || value === undefined) return ['—']
83 if (typeof value !== 'string') return [JSON.stringify(value)]
84 const lines = value.split('\n')
85 if (lines.length <= max) return lines
86 return [...lines.slice(0, max), `… ${lines.length - max} more line${lines.length - max === 1 ? '' : 's'}`]
87}
88
89export function formatMode(value: unknown): string {
90 if (value === null || value === undefined) return '—'
91 return typeof value === 'string' ? value : JSON.stringify(value)
92}
93
94export function tokensOf(key: WaffleViewKey): string {
95 if (!key.flag) return ''
96 const sides: string[] = []
97 if (key.flag.on) sides.push(`on ${key.flag.on}`)
98 if (key.flag.off) sides.push(`off ${key.flag.off}`)
99 return sides.join(' · ')
100}
101
102export const isOverridden = (key: WaffleViewKey): boolean => key.value !== key.canonical
103types/index.d.ts 97 lines1// waffle-view's contract: the `wafflestack state --json` document (shape version 1) as the pane
2// reads it, the context the pane slices by (#563), and the values the mod keeps in `$.state`.
3
4export type WaffleViewMode = boolean | 'prompt'
5
6export type WaffleViewLayer = 'local-overlay' | 'waffle.yaml' | 'stack-default'
7
8export type WaffleViewKey = {
9 key: string
10 stacks: string[]
11 value: WaffleViewMode | string | null
12 source: WaffleViewLayer
13 canonical: WaffleViewMode | string | null
14 default: WaffleViewMode | string | null
15 modes: WaffleViewMode[]
16 prompt: boolean
17 nonInteractive: boolean | 'fail' | null
18 lockMode: WaffleViewMode | null
19 flag: { on: string | null; off: string | null } | null
20 description: string
21}
22
23export type WaffleViewCheckpoint = {
24 file: string
25 runId: string
26 mtime: string
27 lastPhase: string | null
28 sections: string[]
29 parseError: string | null
30}
31
32export type WaffleViewDelegate = {
33 checkpoints: { path: string; exists: boolean; runs: number; latest: WaffleViewCheckpoint | null }
34 memory: { path: string; exists: boolean; bytes: number; maxBytes: number; overCap: boolean }
35}
36
37export type WaffleViewLock = {
38 path: string
39 present: boolean
40 toolkitVersion: string | null
41 toolkitStatus: string | null
42 toolkitRef: string | null
43 files: number
44}
45
46/** One declared config key, resolved (`config` in the document, #563). */
47export type WaffleViewConfigValue = { value: unknown; source: WaffleViewLayer; stacks: string[] }
48
49/** What one skill cares about (`skills[<name>]` in the document, #563). */
50export type WaffleViewSkillContext = { keys: string[]; files: string[] }
51
52export type WaffleViewDoc = {
53 version: 1
54 cli: { version: string; status: string; commit: string | null }
55 project: {
56 targets: string[]
57 stacks: string[]
58 include: string[]
59 eject: string[]
60 localOverlay: boolean
61 errors: string[]
62 }
63 keys: WaffleViewKey[]
64 runFiles: { delegate: WaffleViewDelegate | null }
65 locks: {
66 committed: WaffleViewLock
67 local: WaffleViewLock
68 tree: 'committed' | 'local'
69 inSync: boolean | null
70 divergence: unknown
71 }
72 drift: { ok: boolean; modified: string[]; missing: string[]; absentDocs: string[]; notes: string[] }
73 config: Record<string, WaffleViewConfigValue>
74 skills: Record<string, WaffleViewSkillContext>
75}
76
77/**
78 * What the user is doing right now, as far as the pane can tell: the name the last prompt invoked
79 * (`/issue` → `issue`), or null after a plain prompt. A name `doc.skills` does not know (a built-in
80 * command, a skill outside the toolkit) reads as the full view.
81 */
82export type WaffleViewContext = { skill: string | null }
83
84/** A sliced view: the skill, the behavioral keys and other config it reads, the run files it writes. */
85export type WaffleViewSlice = {
86 skill: string
87 keys: WaffleViewKey[]
88 config: { key: string; value: unknown; source: WaffleViewLayer }[]
89 files: string[]
90}
91
92declare module 'claude-code' {
93 interface PluginState {
94 'waffle-view': { doc: WaffleViewDoc | null; error: string | null; isRefreshing: boolean; skill: string | null }
95 }
96}
97