SLOPSHOPPER

waffle-view

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…

newpanecommandpromptprocess
★ 2v0.2.1Apache-2.0updated 2026-10-06dustinkeeton/wafflestack/.claude/mods/waffle-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · waffle-view
│ ┃ Waffle view ✕ › fix the failing auth test and add an audit log call │ ┃ wafflestack · no state read yet │ ┃ ⏺ Read(src/auth.ts) │ ┃ last refresh failed: wafflestack state ⎿ Read 6 lines │ ┃ printed no JSON ⏺ 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 │ │ › /waffle-view │ ⎿ waffle-view: Waffle view pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Waffle view
wafflestack · no state read yet last refresh failed: wafflestack state printed no JSON
README

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

Vocabulary

  • waffle — an individual installable item: an agent, a skill, or a mod (a Claude Code plugin dir, rendered verbatim for the claude target only).
  • stack — a named group of waffles.
  • syrup — the generic 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.

Quick start

Guided (recommended)

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.

Manual

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

release resolution.

Commands

CommandWhat it does
initWrite a starter .waffle/waffle.yaml (--gitignore also appends overlay/local-lock ignores).
setupPrint the agent-driven install playbook + generated toolkit inventory.
listShow every waffle and syrup file per stack with its status; --interactive for a TTY multi-select.
togglePick 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.
upgradeMove an install across toolkit versions: print CHANGELOG, run migrations, re-render, doctor.
doctorDiff managed files against the lock; report edits, missing files, env gaps. Exit 1 on drift.
reportPrint redacted diagnostics (lock, config keys, doctor summary) for an upstream toolkit bug report; --json for machines.
statePrint 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.
uninstallRemove the whole install (reports until you pass --yes; keeps hand-edited files).
reinstallRe-render the same selection from clean; --clean --yes for a full reset.
`avatars <sync\status>`Keep Gravatar in sync with the installed agent roster.
validateToolkit-developer lint: manifests, frontmatter, placeholders, and agent refs.
helpPrint 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.

Some guidance

  1. Never edit rendered files — render overwrites them. Put additions in .waffle/extensions/{agents,skills}/<name>.md and parameters in .waffle/waffle.yaml.
  2. Account-specific values (bot identities, board IDs) go in .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.
  3. Updates are re-renders — until they aren't. Patch/minor tags are a plain render; a breaking tag needs upgrade. See Updating.
  4. 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/*/).

Updating

WaffleStack ships as git tags (vX.Y.Z), versioned from a consumer's point of view:

BumpExampleMeans for youHow to take it
patch0.5.0 → 0.5.1content-only fixes… render
minor0.5.0 → 0.6.0new stacks/items, additive config… render
majorrenamed/removed item, new required key, changed layoutneeds 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.

Learn more

License

© 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

Source 3 files
hooks/register.tsx 286 lines
1import { 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}
286
hooks/state.ts 103 lines
1import 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
103
types/index.d.ts 97 lines
1// 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