SLOPSHOPPER

bespunky-workflow

How work moves from idea to production, whatever is being built. branch-and-release: a declared branch model (trunk to gitflow), per-feature worktrees…

newpanebandrowsguardcommand
v0.12.0no licenseupdated 2026-10-08BeSpunky/claude-toolkit/plugins/workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bespunky-workflow
│ ┃ standing ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ bespunky · standing ────────────────────── │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing to show: the project-standing engine ⎿ Read 6 lines │ ┃ did not answer. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Refresh ] [ Close ] /standing reopens it ⏺ 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 │ │ › /standing │ ✦ bespunky · standing No project standing to show: the project-stan │ answer. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · standing
✦ bespunky · standing ────────────────────────────────── Nothing to show: the project-standing engine did not answer. [ Refresh ] [ Close ] /standing reopens it
Command output
✦ bespunky · standing No project standing to show: the project-standing engine did not answer.
README

claude-toolkit

BeSpunky's one place for Claude Code skills, subagents, and commands. Develop them here once, install them into any project, and upgrade everywhere with a single update.

This is a Claude Code plugin marketplace (a git repo). It currently ships these plugins:

PluginProvidesPurpose
bespunkyskills index, tips · hookThe toolkit's front door. /bespunky:index is a self-maintaining catalog: it lists every installed toolkit skill — grouped by plugin, each with its /plugin:skill invocation and a one-line "use when" — by reading the live available-skills set in the session, so it can never go stale. Every plugin shares the bespunky- prefix, so typing /bespunky in the slash menu filters the autocomplete to the whole toolkit at once. Toolkit tips: a SessionStart hook slips a small rotating handful of toolkit tips into Claude Code's own "working…" spinner, mixed in with its built-in ones. They sit outside the conversation (the model never sees them), there's nothing to dismiss, and they only cover plugins installed for the project. Each plugin owns its tips in a tips.txt at its root. A plugin can't set spinner tips itself, so the hook keeps them in the user's ~/.claude/settings.json (spinnerTipsOverride.tips), touching only the entries it wrote. /bespunky:tips off takes them out and keeps them out, on brings them back, and list shows them all. There is no uninstall event, so run off before uninstalling if you want them gone.
browser-automationskills playwright, shared-browserTwo ways to drive a real browser. playwright — headless Chromium (pre-installed) for solo automated work: verify a change end-to-end, reproduce a bug, capture before/after screenshots, scrape the rendered DOM, watch console + network, codegen a test. shared-browser — one live browser you and the human drive together: they watch and click it in a normal host tab (over noVNC, on a per-container allocated port so parallel devcontainers never collide) while Claude attaches over loopback CDP to the same instance — for co-debugging, in-place CSS/DOM verification with measured proof (getComputedStyle, getBoundingClientRect), a real login (OAuth/captcha) completed by the human while Claude observes, and pairing on a flow. A decision tree keeps them distinct: no human watching → playwright. A status-line mod shows the noVNC URL under the prompt while the shared browser is up (⚠ when the host-side forward isn't confirmed), read from shared-browser status --json.
housecommands upgrade, add-layer, skills new, firebase-app-hosting · hook, the house band modThe house standard: create a project, or bring any repo onto it, as a stack of layers. /bespunky-house:new creates a project; by default the agent preset: the house DX (composed devcontainer with the Claude CLI & VS Code extension, Claude settings, window identity, HOUSE.md + a tailored CLAUDE.md) on the Nx floor — no package.json, no framework. Everything else is something to wear: --preset=node for a Node workspace, --preset=angular for the house web app (a clean --minimal Angular app, the dev loop, a design system from moment zero), --firebase, or any layer via --add-layer=<csv> — in the workspace shape you pick (`--layout=apps-libspackages, --linking=pathsworkspaces). /bespunky-house:upgrade brings a project up to the current house standard (newer toolkit, the migrations in between, regenerated house artifacts — it adds no layer); /bespunky-house:add-layer <layers> is an upgrade that also brings layers into being. A SessionStart hook notices when an upgrade is due, and the house band (a hooks module, so a mod) shows the same notice as one row above the prompt — the reason, a button that asks Claude to run /bespunky-house:upgrade (or to update the toolkit, or fix a failed post-create), and Dismiss. It runs the hook's own check (--json), never the upgrade; with mods disabled the hook alone remains. firebase-app-hosting is the operational truth for deploying a house app to Firebase App Hosting: the backend's Root Directory (required for Nx), GitHub rollouts vs firebase deploy from source and how to tell which is live, where apphosting*.yaml` is read from (a walk up from the Root Directory — a nearer file shadows the root one), staging's Environment name, and moving the GitHub link between accounts or orgs (Developer Connect).
product-uxskills keep-users-oriented, astonishing-to-use, redesign-means-rethink, distill-the-brief, envision-the-experience, stage-the-vision, mock-to-choose, realize-the-vision, model-intent-not-dataExperience design. keep-users-oriented — whenever you make someone wait or move them through a process, answer the three questions — expected result? where am I? next step? — and pick the right feedback (deterministic → steps/progress; nondeterministic → estimate + notify). A universal service-design principle, expressed primarily through software UI (loading/progress, async, multi-step flows, long-running jobs, notifications, optimistic UI). astonishing-to-use — the UX co-equal force the trio was missing: a design must be astonishing to use, not only to look at (effortless, understandable, no hoops, respectful of attention, a joy in the hand, built for how people really hold devices — thumbs, one-handed, distracted, bad signal). The use and the look are two forces that ping-pong until both are great — never a one-way check — with the mission setting who leads (utility → UX leads; brand/art → concept leads, then pressure-tested). Hard bar: never below great UX; keep-users-oriented is one facet. A router (friction & flow; clarity & cognitive load; embodied & contextual use; reconciling art & use; joy of use). redesign-means-rethink — the entry gate: when asked to redesign a UI, treat it as a complete creative reconception from scratch, never a reskin of the existing code; the existing implementation has zero design authority — read it only after the new design exists, to plan teardown/migration — and a redesign runs the trio below from scratch. (A targeted tweak is not a redesign.) The experiential trio — feeling → Staging → build, one altitude ladder (sensory feeling → web-native art → engineering): envision-the-experience (the feeling) — imagine the world an interface lives in before any layout, grounded in the real situation; interrogate every element (a "menu" might become a sunflower whose petals you pick), name no implementation, restraint over spectacle, produce a Vision. stage-the-vision (the web-native art — the visual architect) — the answer to "grounding stops the bad, but what makes it ART?" It invents the bold, web-native moments that turn the feeling into something you'd screenshot, staying at the art level: it speaks the web's language (parallax, cinematic scroll, a character that turns to camera, type-as-image) but says what artful thing happens and how it feels, never how to build it. Because a model isn't a native artist, it reaches art by inventing several bold concepts and choosing the striking-yet-true, stealing from specific great work and adapting its moves, composing with craft (focal point, scale, negative space, the cinematic moment), decomposing each moment to physical truth (light, shadow, material, depth, texture as they really behave — refusing the lone primitive that only symbolizes a phenomenon: warm light is never just a gradient), and sourcing genuine art — grounded so it's not generic, restrained so it's not garish (bold ≠ loud), judged by an outside eye for beauty (never self-certified). Produces the Staging (a bold concept + concrete described moments + a visual system). mock-to-choose (the verdict — the decision instrument between the art and the build) — a person cannot approve a look by reading a description of it, so this puts the concepts in front of their eyes: it builds the cheapest throwaway thing that makes each concept judgeable, mocks every option on the table (one mock asks "is this OK?" and gets a weak yes; three ask "which one?" and get a real verdict), and shows them side by side in a Compare wall — with a phone/desktop toggle and a true-size Focus view for judging and commenting up close. A mock is shell and presentation only — layout, composition, palette, type, atmosphere, dressed in plausible dummy records — with zero functionality (dead controls, no state, no routing, no data, no build step, no deps, no app integration). Heavy concepts (a scroll cinematic, a 3D scene, a living background, physically-decomposed light) are suggested, never rendered — one representative frame, a still, a flat approximation — vivid enough that the atmosphere is unmistakable, because the point is a fast verdict, not a faithful build. Every variant shares the same dummy content, so the only difference the eye sees is the design. Every review runs on a shared harness — a mini-app shipped with the skill (assets/mock-harness/: a Compare wall + a true-size Focus view + a random-port serve.sh that hot-reloads on edit) copied verbatim into the mocks folder, so Claude authors only mocks.json (the question, what's faked, the variants) and one file per concept: every mock experience is identical and only the mocks change — the user learns the review once. Because bare low fidelity reads as low quality, each mock carries an intent layer — floating notes and hover popovers that narrate the empty house the way an architect walks a site: "the sofa goes here, sideways, facing the window", "this dot is the light — it'll float and breathe; here it's a static glow, so judge where it sits and how much of the frame it owns" — so the user judges the intent, never the shortcut. And the mocks are commentable in place: the user presses c and clicks the exact spot to pin a comment right where they point, written to comments.json on disk with full DOM context (tag, text, rect, styles, ancestor path) — so Claude reads them from a file (exact words, exact element, exact point, exact variant, exact viewport). Comments run draft → submitted → handled: the user sends them to Claude (a Submit review batch, or an auto-send toggle firing each on save), Claude acts on the submitted inbox and checks each off — a handled pin vanishes from the live mock (which only ever shows the current round's open pins) and shows resolved (a green ✓ + reply) in the Focus side-list, so the user watches their notes get checked off while the mock stays uncluttered — which means an asynchronous review works as well as a co-driven one in the shared browser (noVNC, over its own allocated port). The mock iterates in internal rounds (v1 → v2 → …): every comment is version-bound to the round it was made against, Claude commits a round (snapshotting the mock's HTML) right before re-mocking, and past rounds stay viewable read-only and comparable side by side on a History timeline — a built-in, self-ignoring record of what changed and why. The side-list also manages each comment in place (inline edit, per-row send, remove with Undo, row↔pin linking). The verdict is a real gate, not a poll: "none of these" and "a hybrid of A and B" are first-class outcomes (route upstream to re-conceive), and a mock yes is provisional — it picks a direction, it does not certify the finished art (realize-the-vision still owes the outside-eye pass on the real result). Comments are copied verbatim into DECISION.md. Mocks live in a standard dated, feature-scoped package (docs/features/<YYYY-MM-DD>-<slug>/mocks/ — inside the effort's feature package, the slug shared with the git branch) that is self-ignoring and completely throwable (nothing outside may depend on it; one rm erases every trace; the user may choose to keep any of it) — while the decision is recorded durably (DECISION.md), so the conclusion outlives the evidence. Feedback travels upstream (re-conceive in stage-the-vision) and the mock is re-made cheaply — never polished into a prototype — and its code never becomes the build. realize-the-vision (the build) — the craftsman that turns a Vision and a confirmed Staging into a real interface by researching the truest means before writing any code — engineers each staged moment, surveys the field (GSAP, Motion, three.js/R3F/angular-three, Web Animations, scroll-driven CSS, View Transitions, Lottie/Rive, Canvas/SVG/WebGL, Web Audio, haptics) and its caveats, build-vs-source (figurative art is generated/licensed, never hand-coded into path-soup), requires a confirmed Staging (else invokes stage-the-vision first), never self-certifies aesthetics, fans out across subagents against the shared contract with a coherence pass, and verifies against the feeling and the Staging in the running app. Both stage-the-vision and realize-the-vision are routers over reference libraries.
design-systemskills design-system-first, design-tokens-and-themingStyling as a system. design-system-first — the discipline: before you build any feature UI, go to the design system; never hardcode a style value (every colour, space, radius, type step, elevation, border, duration and easing is a token — a CSS custom property at runtime, consumed through the DS's zero-output author-time API — the house uses SASS; components read semantic tokens only, never a raw primitive); feature components compose DS components and tokens, they never invent appearance; the second occurrence of a UI pattern is a promotion, not a copy-paste — lift it into the DS as a reusable component (nx g @bespunky/nx-tools:ds-component <name>, one secondary entry point each), migrate both sites, delete the copies; and when the DS lacks the concept, model it (add the token, the semantic alias, the scale step, the component) — never a local override, !important, a style reach-in across a component boundary (::ng-deep, :global, :deep()), a duplicated token, or a one-off variant boolean. The DS is the single source of visual truth, so a re-theme or rebrand is a change of tokens, not a thousand component files (the styling twin of redesign-means-rethink: re-token, don't re-hardcode; the styling flavour of architecture-first: never a patch — and worse there, because CSS has no compiler to catch the drift). Ships an always-on policy the scaffold bakes into every project's imported HOUSE.rules.md. design-tokens-and-theming — the techniques, a router: two layers, one truth — CSS custom properties are the runtime layer (cascading, themeable; a mode is a re-binding of tokens, live, never a swapped stylesheet) and the SASS API is the author-time layer (zero-output functions/mixins/placeholders, summoned with @use, never a global side-effect). Clusters: token taxonomy & naming (primitive → semantic → component; a value not on a scale is a design bug, not a missing token); CSS custom properties as the runtime layer; the SASS API layer & how it's summoned (the public @forward … show barrel over _-prefixed private folders named for what they are; how it resolves in-repo vs published); theming & modes (light/dark/brand, where a mode lives, persisting it without a flash, contrast that holds in every mode); component styling & encapsulation (scoped vs shadow encapsulation, tokens in / parts out, the reach-in ban, variants as data not booleans — with Angular adapters for :host / ViewEncapsulation / ::ng-deep and ng-packagr entry points); the DS library's structure & entry points (one component = one entry point, generator-first). Encodes the visual system stage-the-vision produces — it doesn't invent the look, it makes the look live in one place.

| workflow | skills branch-and-release, feature-package, delegate-and-parallelize, local-server-isolation, session-handoff, project-standing · hooks SessionStart, PreCompact · mods branch-status, /standing, merge-gate | Ways of working — the process, order, and methodology of how work moves from idea to production, independent of what is being built. branch-and-release — the house git methodology over the project's declared branch model (.bespunky/branches.json — one integration line, ordered stages, optional release and hotfix lines; presets from trunk through development → staging → main to gitflow, chosen after an investigation of how the repo actually works, never assumed): unrelated work isolated in per-feature worktrees off the integration line, small committed increments, rebase-and-re-verify at the single divergence point, every move onto a protected line human-gated. Until a model is declared, every existing long-lived branch is protected and the skill investigates and asks once per session. feature-package — a feature is a package, not a scatter of files: one effort, one slug (the same one that names the branch and worktree), one folder — docs/features/<YYYY-MM-DD>-<slug>/ — holding everything durable the effort produces that isn't code: BRIEF.md, VISION.md, STAGING.md, DECISION.md, the throwaway self-ignoring mocks/, and the effort's handoffs/ batons. Born with the worktree and filled as the work happens (a doc written at the end is a memory, and memories are where the reasons go missing); every artifact-producing skill writes into it instead of inventing a private home. Two rules: the conclusion is durable, the evidence is disposable (decisions and roads-not-taken are committed and permanent; mocks and scratch are self-ignoring, depended on by nothing, binned by default), and the user's own words are the most valuable line in the package — quote the sentence that settled it, never paraphrase. It answers "six months on, why was it done this way, and what did we already rule out?" delegate-and-parallelize — the session is an orchestrator, not a worker: decompose the goal into units, delegate every unit that isn't atomic to a subagent, and run the independent ones at once — recursively, an agent handed a still-decomposable unit splitting it again until a unit is atomic, trivial, strictly serial, or contended. One move settles two bills: context (everything read inline is permanent, and permanent cost is what forces compaction, which degrades every turn after it — a subagent reads forty files and hands back fifteen lines) and wall-clock (independent units cost the slowest, not the sum), so the default inverts to work inline only when delegating would cost more than it saves. Two halves decide whether it works in practice: the delegated-task contract (a subagent shares none of your context, so its prompt is self-contained and its return shape is specified — a distillation, never a transcript, or the context you delegated to avoid lands in your window anyway) and supervision (a parent never exits while a child it spawned is still running; it checks on long-running children, because silence reads the same whether an agent is working or wedged, and accounts for every one before it closes — no zombies left burning tokens toward a result nobody will read). And the third pillar, resumability — the tree must outlive the session that started it, because a crash, a dropped connection, a stop (deliberate or mistaken), a permissions error or a container rebuild evaporates the orchestrator's context and with it the plan, what's still outstanding, and every result already paid for. So nothing is dispatched before the plan is on disk, and each state change is written as it happens into a ledger in the effort's package (handoffs/<ts>-fanout.md): stable unit ids, per-unit status, whether each unit is safe to re-run, the returned distillations stored inline (a result that lives only in a context window dies with it), any Workflow runId verbatim — one unrecorded string is the difference between a near-free resume and a full re-run — and what was not covered. Every agent at every depth leaves traces, so a fresh session resumes the outstanding work instead of redoing the expensive work that already succeeded. Plus write-contention isolation, what is never delegated (the decision, the user's intent, the final synthesis, the human-gated promotions), and adversarial verification of what comes back. An agent budget — the total agents the tree may create (default 12, set per request or per project), estimated before anything is dispatched — and you're asked to confirm when the estimate needs more — then conserved and carved down the tree as each child's share — caps spend against your usage limits without capping depth: fewer, fatter units with share enough to recurse, batched leaves, the share worded as an allowance never a ban, and inline fallback when it runs out. Subagents are the everyday tier within that budget; Workflows need the user's explicit opt-in, since a skill that auto-fired cannot authorize its own spending. local-server-isolation — bind a random free port, never the default/forwarded one the user's own server owns. session-handoff — carry a live effort across a context boundary into a fresh session: capture writes a distilled relay baton (into the effort's package), resume re-grounds against reality; the user's corrections are captured first-class, and durable ones promoted to persistent memory. project-standing — the cold pick-up: orient in a project you've been away from, derived from git + the feature packages (never a hand-maintained status doc — that's the first thing to rot) — which efforts are live, stalled, or concluded, which baton to read first, live efforts in full and concluded ones collapsed to a one-line conclusion so orienting costs the same at effort #300 as at #3; scopes for on-demand history search and additive archive-sweep. Two hooks make continuity reliable rather than hoped-for: a SessionStart hook that stays silent unless in-flight work has gone dormant, then relays a fact (detect-don't-execute), and a PreCompact hook that writes a mechanical checkpoint into the live effort's package before context is lost — even headless — then asks the model to distill it. And a mod (hooks/branch-status.ts) pins where this checkout sits in the declared model to the status line — feat/x → development → main, a warning on a protected line, a violations count from verify, or an honest undeclared / unreadable — read from the engine (branches.mjs status --json), never re-derived; it displays and never acts. Another mod (hooks/standing.tsx): /standing opens a pane (never unasked) that leads with the answer (Nothing in flight, or the live / dormant packages across every worktree, each with its newest baton and a Resume button) and collapses finished work to one line (N concluded · latest: …, expandable to the five most recent); Resume only queues a prompt for Claude. All of it is drawn from the same derivation engine (project-standing/scripts/standing.mjs) the SessionStart notice reads, so the two can never disagree. And the merge gate (hooks/merge-gate.tsx): when Claude is done and proposes to land its branch, it calls the plugin's propose_move tool instead of asking "shall I merge?" in prose, and a band above the prompt offers Land on <integration>, Land & promote to <next stage> (only where the engine plans that promotion), Push branch and Not yet — every name and op

Source 8 files
hooks/mods.ts 15 lines
1// The workflow plugin's one hooks module: a plugin names a single module in hooks.json,
2// so each mod lives in its own file and is registered here.
3import type { Register } from 'claude-code'
4import { register as branchStatus } from './branch-status.ts'
5import { register as checkpointToast } from './checkpoint-toast.ts'
6import { register as mergeGate } from './merge-gate.tsx'
7import { register as standing } from './standing.tsx'
8
9export const register: Register = (on, options) => {
10  branchStatus(on, options)
11  checkpointToast(on, options)
12  mergeGate(on, options)
13  standing(on, options)
14}
15
hooks/branch-status.ts 201 lines
1// bespunky-workflow — the BRANCH-MODEL STATUS LINE: where this checkout sits in the declared branch model,
2// pinned under the prompt. `feat/x → development → main` on a work branch; a warning on a protected line,
3// where committing breaks the house rules; an honest word when no model is declared or it cannot be read.
4//
5// WHY IT IS A VIEW OVER THE ENGINE. `branches.mjs` is the ONE interpreter of `.bespunky/branches.json`
6// (which copy is in force, the undeclared fallbacks, the effective protected set). This module never reads
7// the file: it runs `branches.mjs status --json` (its frozen `statusShape`) and, for the violations count,
8// `branches.mjs verify --json`, and only folds their answers — plus the current branch from git — into one
9// line. The fold is `statusLine`, pure; everything else here is plumbing.
10//
11// DETECT, DON'T EXECUTE. It displays; it never moves a branch, writes the declaration, or runs a mutating
12// command. Both engine commands are read-only by construction (lib/git.mjs).
13//
14// THE PLUGIN WORKS WITHOUT IT. The skill and the PreCompact hook consult the engine themselves; with mods off
15// nothing is missed but the line. Never put behaviour here that the floor needs.
16//
17// WHEN IT REFRESHES. Cheapest first:
18//   - the person's prompt: the branch only (one `git symbolic-ref`), and the model again if the branch moved —
19//     this is what catches a checkout made in another terminal;
20//   - session start, and after a Bash call that can have touched git (`git`/`gh`) or a worktree move: the
21//     branch, the model, and the violations count (verify is the one costly read, so it runs only here).
22
23import type { EngineInterface, Register } from 'claude-code'
24
25import { brandLine } from './_brand.tsx'
26import { enginePath, isProtected, parseStatus } from './branch-engine.ts'
27import type { Model } from './branch-engine.ts'
28
29/** HEAD: a branch name, `detached`, or no repository at all. */
30export type Head = { kind: 'branch'; name: string } | { kind: 'detached' } | { kind: 'none' }
31
32/** The whole policy: what the line says, from the facts. `undefined` clears it. */
33export function statusLine(head: Head, model: Model, violations?: number): string | undefined {
34  if (head.kind === 'none' || model.state === 'absent') {
35    return undefined
36  }
37  if (model.state === 'unreadable') {
38    return `⚠ branch model unreadable: ${model.reason}`
39  }
40
41  const branch = head.kind === 'branch' ? head.name : undefined
42  const onProtected = branch !== undefined && isProtected(branch, model)
43
44  if (model.state === 'undeclared') {
45    return onProtected ? `⚠ on protected ${branch} · branch model undeclared` : `${branch ?? 'detached HEAD'} · branch model undeclared`
46  }
47
48  const where = onProtected
49    ? `⚠ on protected ${branch}, don't commit here · ${model.summary}`
50    : `${branch ?? 'detached HEAD'} → ${model.summary}`
51  const broken = violations ? ` · ⚠ ${violations} violation${violations === 1 ? '' : 's'}` : ''
52
53  return `${where}${broken}`
54}
55
56/** `verify --json`'s `violations`, or nothing when it did not answer in shape. */
57export function parseViolations(stdout: string): number | undefined {
58  try {
59    const n = (JSON.parse(stdout) as { violations?: unknown }).violations
60
61    return typeof n === 'number' && Number.isInteger(n) && n >= 0 ? n : undefined
62  } catch {
63    return undefined
64  }
65}
66
67/** A Bash command that can have moved HEAD or the model in force. */
68const TOUCHES_GIT = /(^|[^\w-])(git|gh)(\s|$)/
69/** The worktree tools. Module-private on purpose: `claude plugin validate` reads a matcher only from a const nothing else references. */
70const WORKTREE_MOVE = /^(EnterWorktree|ExitWorktree)$/
71
72export const register: Register = on => {
73  const line = keeper()
74
75  on('session.start', async ($, e, next) => {
76    // Not awaited: the first session.start holds turn one, and verify is the costly read.
77    void refresh($, line, 'full')
78
79    return next(e)
80  })
81
82  on('prompt.submit', async ($, e, next) => {
83    void refresh($, line, 'branch')
84
85    return next(e)
86  })
87
88  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
89    const ran = await next(e)
90    if (TOUCHES_GIT.test(e.command)) {
91      void refresh($, line, 'full')
92    }
93
94    return ran
95  })
96
97  on('tool.call', { tool: WORKTREE_MOVE }, async ($, e, next) => {
98    const ran = await next(e)
99    void refresh($, line, 'full')
100
101    return ran
102  })
103}
104
105type Depth = 'branch' | 'full'
106
107/**
108 * The facts last read and the line last shown. A refresh asked while one runs is folded into one more run
109 * afterwards at the deeper of the two depths, so a burst of git calls costs at most two reads.
110 */
111type Keeper = {
112  head: Head
113  model: Model
114  violations?: number
115  shown?: string
116  running?: Promise<void>
117  queued?: Depth
118}
119
120function keeper(): Keeper {
121  return { head: { kind: 'none' }, model: { state: 'absent' } }
122}
123
124async function refresh($: EngineInterface, k: Keeper, depth: Depth): Promise<void> {
125  if (k.running) {
126    k.queued = k.queued === 'full' || depth === 'full' ? 'full' : 'branch'
127
128    return k.running
129  }
130  k.running = (async () => {
131    try {
132      let todo: Depth | undefined = depth
133      while (todo) {
134        k.queued = undefined
135        await readInto($, k, todo).catch(error => $.ui.log(`bespunky-workflow: branch status failed: ${error}`, { to: 'debug' }))
136        todo = k.queued
137      }
138    } finally {
139      k.running = undefined
140    }
141  })()
142
143  return k.running
144}
145
146/** One read at `depth`, then the line re-pinned if it changed. */
147async function readInto($: EngineInterface, k: Keeper, depth: Depth) {
148  const before = k.head
149  k.head = await readHead($)
150  const moved = JSON.stringify(k.head) !== JSON.stringify(before)
151
152  if (k.head.kind === 'none') {
153    k.model = { state: 'absent' }
154    k.violations = undefined
155  } else if (depth === 'full' || moved || k.model.state === 'absent') {
156    k.model = await readModel($)
157    if (depth === 'full') {
158      k.violations = k.model.state === 'declared' ? await readViolations($) : undefined
159    }
160  }
161
162  const line = statusLine(k.head, k.model, k.violations)
163  if (line !== k.shown) {
164    k.shown = line
165    // The toolkit's mark goes on at the boundary: the fold says what, the brand says whose.
166    $.ui.status(line === undefined ? undefined : brandLine(line))
167  }
168}
169
170async function readHead($: EngineInterface): Promise<Head> {
171  const ran = await run($, ['git', 'symbolic-ref', '--short', '-q', 'HEAD'])
172  if (ran?.exitCode === 0 && ran.stdout.trim()) {
173    return { kind: 'branch', name: ran.stdout.trim() }
174  }
175  // symbolic-ref says 1 for a detached HEAD and 128 outside a repository; tell them apart by asking for HEAD.
176  const inside = await run($, ['git', 'rev-parse', '--verify', '-q', 'HEAD'])
177
178  return inside?.exitCode === 0 ? { kind: 'detached' } : { kind: 'none' }
179}
180
181async function readViolations($: EngineInterface) {
182  const ran = await run($, ['node', enginePath($.plugin.root), 'verify', '--json'], 60_000)
183
184  return ran ? parseViolations(ran.stdout) : undefined
185}
186
187async function readModel($: EngineInterface): Promise<Model> {
188  const ran = await run($, ['node', enginePath($.plugin.root), 'status', '--json'])
189
190  return ran ? parseStatus(ran.stdout) : { state: 'absent' }
191}
192
193/** A host command, or nothing when it could not start (no git, no node) or overran. */
194async function run($: EngineInterface, argv: string[], timeoutMs = 15_000) {
195  try {
196    return await $.process.run(argv, { timeoutMs })
197  } catch {
198    return undefined
199  }
200}
201
hooks/checkpoint-toast.ts 77 lines
1// bespunky-workflow — the CHECKPOINT TOAST: when the PreCompact hook writes its
2// mechanical checkpoint, the person sees where it went ("checkpoint saved →
3// docs/features/…/handoffs/…-auto.md"), not only the model.
4//
5// WHY IT ASKS THE SCRIPT. Where a checkpoint goes, whether one is written at
6// all (a protected line, no package, an unreadable branch model) and what it is
7// named are `checkpoint-on-compact.sh`'s decisions alone. The script records
8// every checkpoint it writes in a receipt and prints it on `--last`; this mod
9// reads that receipt before and after the compaction's classic hooks run, and
10// toasts only when the hook wrote a new one. It re-derives nothing.
11//
12// DETECT, DON'T EXECUTE. It shows a fact; it writes nothing and starts no turn.
13// THE PLUGIN WORKS WITHOUT IT: the command hook is the floor (it writes the
14// checkpoint and asks the model to distill it); this is only the person's view.
15
16import type { EngineInterface, Register } from 'claude-code'
17
18import { brandLine } from './_brand.tsx'
19
20/** What `checkpoint-on-compact.sh --last` prints: the last checkpoint written. */
21export type CheckpointReceipt = {
22  /** Unique per write, so a rewrite of the same file still reads as new. */
23  id: string
24  /** The checkpoint file, relative to the project dir. */
25  file: string
26}
27
28const TOAST_MS = 8_000
29
30/** The receipt in `--last`'s output, or undefined for none or anything malformed. */
31export function parseReceipt(stdout: string): CheckpointReceipt | undefined {
32  try {
33    const value: unknown = JSON.parse(stdout.trim())
34    if (typeof value !== 'object' || value === null) {
35      return undefined
36    }
37    const { id, file } = value as Record<string, unknown>
38
39    return typeof id === 'string' && id !== '' && typeof file === 'string' && file !== '' ? { id, file } : undefined
40  } catch {
41    return undefined
42  }
43}
44
45/** The toast for a compaction, given the receipt before and after its hooks ran: the whole policy, pure. */
46export function checkpointToast(before: CheckpointReceipt | undefined, after: CheckpointReceipt | undefined) {
47  return after !== undefined && after.id !== before?.id ? `checkpoint saved → ${after.file}` : undefined
48}
49
50export const register: Register = on => {
51  on('classic.PreCompact', async ($, e, next) => {
52    const before = await lastCheckpoint($)
53    const result = await next(e)
54    const text = checkpointToast(before, await lastCheckpoint($))
55
56    if (text !== undefined) {
57      $.ui.toast(brandLine(text), { timeoutMs: TOAST_MS })
58    }
59
60    return result
61  })
62}
63
64/** Asks the script for its receipt; any failure is "none" — the toast is a nicety, never an error. */
65async function lastCheckpoint($: EngineInterface) {
66  try {
67    const ran = await $.process.run(['bash', `${$.plugin.root}/hooks/checkpoint-on-compact.sh`, '--last'], {
68      env: { CLAUDE_PROJECT_DIR: await $.session.root(), CLAUDE_PLUGIN_ROOT: $.plugin.root },
69      timeoutMs: 5_000,
70    })
71
72    return ran.exitCode === 0 ? parseReceipt(ran.stdout) : undefined
73  } catch {
74    return undefined
75  }
76}
77
hooks/merge-gate.tsx 297 lines
1// bespunky-workflow — the MERGE GATE: when Claude is done and proposes to land its work branch on the
2// integration line (or to promote that line onward), a band above the prompt offers the move as one press —
3// Land, Land & promote, Push the branch, or Not yet — instead of a "shall I merge?" the person types back.
4//
5// WHY CLAUDE TRIGGERS IT. "Done" is a judgement, not a git state: a branch ahead of integration is landable
6// in every minute of an effort, and a gate drawn from git alone would nag throughout. So the trigger is
7// Claude's own explicit signal — the `propose_move` tool it calls as the last act of a turn that proposes a
8// move — never a guess read off its prose.
9//
10// WHY IT IS A VIEW OVER THE ENGINE. What the integration line is called, which stage comes next, whether
11// promoting there is a move the model has at all: the branch-model engine decides (`status --json`, and
12// `plan promote <stage>` exiting 0), read through ./branch-engine.ts. Git says only how far a line is ahead.
13// The fold is `gateOf`, pure; everything else is plumbing.
14//
15// DETECT, DON'T EXECUTE. A press is the person's explicit signal, and it is sent AS their prompt; Claude then
16// carries the move out by bespunky-workflow:branch-and-release, which owns the judgement steps a landing
17// needs (rebase and re-verify, the package's DECISION.md, keep-or-bin mocks). The mod never runs a mutating
18// command. Prompts carry only names the engine and git vouched for; Claude's note is drawn, never sent.
19//
20// THE PLUGIN WORKS WITHOUT IT. With mods off the tool does not exist and Claude asks in prose, as the skill
21// says. Its lifetime: drawn once the turn is over (never while Claude works), gone on the person's next
22// prompt — a press or anything typed — or on Not yet.
23
24import { update } from 'claude-code'
25import type { EngineInterface, Register } from 'claude-code'
26
27import type { MergeGate, MergeGateMove } from '../types/index.d.ts'
28
29import { BrandFrame } from './_brand.tsx'
30import { enginePath, isProtected, parseStatus } from './branch-engine.ts'
31import type { Model } from './branch-engine.ts'
32
33const GATE = { plugin: 'bespunky-workflow', key: 'mergeGate' } as const
34const TOOL = 'propose_move'
35/** Module-private on purpose: `claude plugin validate` reads a matcher only from a const nothing else references. */
36const TOOL_ID = 'mcp__bespunky-workflow__propose_move'
37const SKILL = 'bespunky-workflow:branch-and-release'
38/** The person's own prompts: typed at the terminal, or through Remote Control. Module-private, as TOOL_ID. */
39const PERSON = /^(composer|bridge)$/
40
41/** A git branch name safe to put in a prompt: no option-looking lead, no `..`, no spaces or shell glyphs. */
42const BRANCH = /^(?![-/.])(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._/-]{1,200}$/
43const NOTE_MAX = 200
44
45/** What Claude proposed, as the tool's input says it. */
46export type Proposal =
47  | { gate: 'land'; branch: string; note: string }
48  /** `stage` defaults to the first stage after integration. */
49  | { gate: 'promote'; stage?: string; note: string }
50
51/** What the engine and git said about the proposal. */
52export type Facts = {
53  model: Model
54  /** Commits the source has over the target (the branch over integration; the stage's predecessor over it). Undefined when git could not tell — a line that does not exist. */
55  ahead?: number
56  /** The engine plans `promote` to the stage in question (for land: the first stage after integration). */
57  canPromote: boolean
58}
59
60/** The tool's input, checked; a string says what is wrong with it. */
61export function parseProposal(input: Record<string, unknown>): Proposal | string {
62  const note = typeof input.note === 'string' ? input.note.replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX) : ''
63
64  if (input.gate === 'land') {
65    return typeof input.branch === 'string' && BRANCH.test(input.branch)
66      ? { gate: 'land', branch: input.branch, note }
67      : '`branch` must name the work branch to land'
68  }
69  if (input.gate === 'promote') {
70    if (input.stage === undefined) return { gate: 'promote', note }
71
72    return typeof input.stage === 'string' && BRANCH.test(input.stage)
73      ? { gate: 'promote', stage: input.stage, note }
74      : '`stage` must name a stage of the branch model'
75  }
76
77  return '`gate` must be `land` or `promote`'
78}
79
80/** The stage a proposal could promote to: the named one, or the first after integration. */
81export function stageOf(proposal: Proposal, model: Model): string | undefined {
82  if (model.state !== 'declared') return undefined
83
84  return proposal.gate === 'promote' && proposal.stage !== undefined ? proposal.stage : model.chain[1]
85}
86
87/** The line a stage is promoted from: its predecessor in the chain. */
88export function sourceOf(stage: string, model: Model): string | undefined {
89  if (model.state !== 'declared') return undefined
90  const at = model.chain.indexOf(stage)
91
92  return at > 0 ? model.chain[at - 1] : undefined
93}
94
95/** The whole policy: the gate to draw, or why none is drawn (said back to Claude, who then asks in prose). */
96export function gateOf(proposal: Proposal, facts: Facts): { gate: MergeGate } | { refusal: string } {
97  const { model } = facts
98  if (model.state !== 'declared') {
99    return { refusal: 'no branch model is declared, so there is no integration line to offer' }
100  }
101  const stage = stageOf(proposal, model)
102
103  if (proposal.gate === 'promote') {
104    const from = stage === undefined ? undefined : sourceOf(stage, model)
105    if (stage === undefined || from === undefined || !facts.canPromote) {
106      return { refusal: `the branch model (${model.summary}) has no promotion${stage === undefined ? '' : ` to ${stage}`}` }
107    }
108    if (!facts.ahead) {
109      return { refusal: `${from} has nothing ${stage} lacks` }
110    }
111
112    return {
113      gate: {
114        headline: `${from} → ${stage} · ${commits(facts.ahead)}`,
115        note: proposal.note,
116        moves: [
117          {
118            id: 'promote',
119            label: `Promote to ${stage}`,
120            hotkey: 'm',
121            prompt: `Promote ${from} to ${stage} — I pressed Promote in the merge gate. Follow ${SKILL} (plan promote ${stage}).`,
122          },
123        ],
124      },
125    }
126  }
127
128  const { branch } = proposal
129  const into = model.integration
130  if (isProtected(branch, model)) {
131    return { refusal: `${branch} is a protected line; only a work branch lands` }
132  }
133  if (facts.ahead === undefined) {
134    return { refusal: `there is no branch ${branch}` }
135  }
136  if (facts.ahead === 0) {
137    return { refusal: `${branch} has nothing ${into} lacks` }
138  }
139
140  const moves: MergeGateMove[] = [
141    {
142      id: 'land',
143      label: `Land on ${into}`,
144      hotkey: 'l',
145      prompt: `Land ${branch} on ${into} — I pressed Land in the merge gate. Follow ${SKILL} (plan land ${branch}).`,
146    },
147  ]
148  if (stage !== undefined && facts.canPromote) {
149    moves.push({
150      id: 'land-promote',
151      label: `Land & promote to ${stage}`,
152      hotkey: 'm',
153      prompt: `Land ${branch} on ${into}, then promote ${into} to ${stage} — I pressed Land & promote in the merge gate. Follow ${SKILL} (plan land ${branch}, then plan promote ${stage}).`,
154    })
155  }
156  moves.push({
157    id: 'push',
158    label: 'Push branch',
159    hotkey: 'p',
160    prompt: `Push ${branch} to ${model.remote} without landing it — I pressed Push in the merge gate.`,
161  })
162
163  return { gate: { headline: `${branch} → ${into} · ${commits(facts.ahead)}`, note: proposal.note, moves } }
164}
165
166/** What the tool answers Claude when the gate is up: what the person sees, and that the turn should end. */
167export function shownText(gate: MergeGate) {
168  const offered = [...gate.moves.map(move => move.label), 'Not yet'].join(' · ')
169
170  return `The person now sees a merge gate above the prompt: ${offered}. End your turn now without asking again in prose — their press arrives as their next prompt; if they type instead, follow what they say.`
171}
172
173export function refusedText(why: string) {
174  return `Merge gate not shown: ${why}. Ask the person in prose instead.`
175}
176
177function commits(n: number) {
178  return `${n} commit${n === 1 ? '' : 's'}`
179}
180
181export const register: Register = on => {
182  // Only where a person is at the prompt: a headless run has no one to press, and the skill asks in prose.
183  on('session.start', { isInteractive: true }, async ($, e, next) => {
184    await $.tool.register({
185      name: TOOL,
186      description:
187        "Show the person a merge gate: one-press buttons above the prompt to land your work branch on the branch model's integration line (and, where the model allows, land and promote, or push the branch), instead of asking 'shall I merge?' in prose. Call it as the LAST action of a turn in which the work is done and verified and you are proposing to land it (gate: land), or proposing to promote onward after a landing (gate: promote). It changes nothing itself: the person's press arrives as their next prompt, and you then carry the move out by bespunky-workflow:branch-and-release. If it answers 'not shown', ask in prose.",
188      inputSchema: {
189        type: 'object',
190        properties: {
191          gate: { type: 'string', enum: ['land', 'promote'], description: 'land: land a work branch on integration. promote: advance integration (or a stage) to the next stage.' },
192          branch: { type: 'string', description: 'gate land: the work branch to land, e.g. feat/x.' },
193          stage: { type: 'string', description: 'gate promote: the stage to promote to; omitted = the first stage after integration.' },
194          note: { type: 'string', description: 'One short line on what is ready, shown to the person.' },
195        },
196        required: ['gate', 'note'],
197      },
198    })
199
200    return next(e)
201  })
202
203  // The call's arguments sit beside `tool` and `tool_use_id` on the event itself.
204  on('tool.call', { tool: TOOL_ID }, async ($, e) => ({ result: await propose($, e as Record<string, unknown>) }))
205
206  // Anything the person typed instead answers the gate too (a press clears it itself).
207  on('prompt.submit', { origin: { kind: PERSON } }, async ($, e, next) => {
208    await update($, GATE, () => null)
209
210    return next(e)
211  })
212
213  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
214    if (e.props.hasSurvey || e.props.isWorking) return next(e)
215    const { value: gate } = await $.state.get(GATE)
216    if (!gate) return next(e)
217
218    const ui = $.ui.resolve(e)
219    const { Box, Button, Text } = ui
220
221    return (
222      <BrandFrame ui={ui} site={e}>
223        <Text bold wrap="truncate-end">
224          {gate.headline}
225        </Text>
226        {gate.note !== '' && <Text dimColor>{gate.note}</Text>}
227        <Box flexDirection="row" flexWrap="wrap" gap={1}>
228          {gate.moves.map((move, index) => (
229            <Button
230              key={`merge-gate-${move.id}`}
231              label={move.label}
232              hotkey={move.hotkey}
233              {...(index === 0 ? { variant: 'primary' as const } : {})}
234              onPress={() => void press($, move)}
235            />
236          ))}
237          <Button key="merge-gate-dismiss" label="Not yet" hotkey="n" role="dismiss" onPress={() => void dismiss($)} />
238        </Box>
239      </BrandFrame>
240    )
241  })
242}
243
244/** Reads the facts the proposal needs, folds them, and stores the gate (or says why not). */
245async function propose($: EngineInterface, input: Record<string, unknown>): Promise<string> {
246  const proposal = parseProposal(input)
247  if (typeof proposal === 'string') return refusedText(proposal)
248
249  const model = await readModel($)
250  const stage = stageOf(proposal, model)
251  const canPromote = stage !== undefined && (await run($, ['node', enginePath($.plugin.root), 'plan', 'promote', stage]))?.exitCode === 0
252  const [source, target] =
253    proposal.gate === 'land'
254      ? [proposal.branch, model.state === 'declared' ? model.integration : undefined]
255      : [stage === undefined ? undefined : sourceOf(stage, model), stage]
256  const ahead = source !== undefined && target !== undefined ? await aheadOf($, source, target) : undefined
257
258  const outcome = gateOf(proposal, { model, ahead, canPromote })
259  if ('refusal' in outcome) return refusedText(outcome.refusal)
260  await update($, GATE, () => outcome.gate)
261
262  return shownText(outcome.gate)
263}
264
265/** Commits `source` has that `target` lacks, or undefined when either is not a local branch. */
266async function aheadOf($: EngineInterface, source: string, target: string) {
267  const ran = await run($, ['git', 'rev-list', '--count', `refs/heads/${target}..refs/heads/${source}`])
268  const n = ran?.exitCode === 0 ? Number(ran.stdout.trim()) : NaN
269
270  return Number.isInteger(n) && n >= 0 ? n : undefined
271}
272
273/** A move pressed: the gate goes, and the move is sent as the person's own prompt. */
274async function press($: EngineInterface, move: MergeGateMove) {
275  await update($, GATE, () => null)
276  await $.prompt.submit({ text: move.prompt, asUser: true })
277}
278
279async function dismiss($: EngineInterface) {
280  await update($, GATE, () => null)
281}
282
283async function readModel($: EngineInterface): Promise<Model> {
284  const ran = await run($, ['node', enginePath($.plugin.root), 'status', '--json'])
285
286  return ran ? parseStatus(ran.stdout) : { state: 'absent' }
287}
288
289/** A host command, or nothing when it could not start (no git, no node) or overran. */
290async function run($: EngineInterface, argv: string[], timeoutMs = 15_000) {
291  try {
292    return await $.process.run(argv, { timeoutMs })
293  } catch {
294    return undefined
295  }
296}
297
hooks/standing.tsx 351 lines
1// bespunky-workflow — the STANDING PANE: `/standing` opens a pane listing this project's
2// in-flight feature packages (live / dormant), each with its newest handoff baton and a Resume
3// button. Its job is "what needs me?", so it leads with that answer ("Nothing in flight" when
4// so) and draws a section only when it has rows; finished work is history, collapsed to one line
5// (count + the latest) that a toggle expands to the most recent few.
6//
7// WHY IT IS A VIEW OVER THE ENGINE. What a feature package is, whether it is in flight, how
8// recently it moved and which baton is newest are DERIVED by the project-standing engine
9// (skills/project-standing/scripts/standing.mjs), the same one the SessionStart notice reads.
10// This module runs it (`--json`), folds the result into one `$.state` value
11// (types/index.d.ts) and draws that; it never re-derives anything, so the pane and the notice
12// cannot disagree.
13//
14// DETECT, DON'T EXECUTE. The pane changes nothing. Resume only queues a prompt for Claude
15// ("resume <slug> — read its newest handoff"), which the person can watch and interrupt; the
16// orientation itself stays the skill's job. Free text from the repo (the about line, a summary, tags) is
17// drawn, never put into a prompt; the prompt carries only names the engine validated.
18//
19// NEVER OPENED UNASKED. A session start only registers the command. The pane opens when the
20// person runs /standing; the floor without mods is the skill and the SessionStart notice. Because
21// it never comes back by itself, closing it says how to: the control row carries the hint, and a
22// close by any hand toasts it.
23//
24// THE TOOLKIT'S LOOK. The frame, title, rules and mark come from ./_brand.tsx (generated from
25// tools/mod-brand/brand.tsx): this module draws only its own rows.
26
27import { update } from 'claude-code'
28import type { EngineInterface, Register } from 'claude-code'
29
30import type { Standing, StandingPackage, StandingView } from '../types/index.d.ts'
31
32import { BrandCommandRow, BrandDivider, BrandFrame, brandLine } from './_brand.tsx'
33
34const VIEW = { plugin: 'bespunky-workflow', key: 'standing' } as const
35const SHOW_CONCLUDED = { plugin: 'bespunky-workflow', key: 'showConcluded' } as const
36const PANE = 'standing'
37const COMMAND = 'standing'
38const ENGINE = 'skills/project-standing/scripts/standing.mjs'
39
40const PACKAGE_DIR = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/
41const BATON = /^handoffs\/[A-Za-z0-9._-]{1,120}$/
42/** The engine's worktree rule: a relative or absolute path of safe segments, never `.` or `..`. */
43const WORKTREE = /^\/?(?!\.\.?(?:\/|$))[A-Za-z0-9._@+-]+(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9._@+-]+)*$/
44/** Digits 1–9 press the first nine Resume buttons. */
45const HOTKEYS = '123456789'
46const CONCLUDED_HOTKEY = 'c'
47/** How many concluded packages the expanded list shows. */
48const RECENT_CONCLUDED = 5
49/** How the pane comes back once closed: the one thing a closed pane cannot show. */
50export const REOPEN_HINT = `/${COMMAND} reopens it`
51export const CLOSED_TOAST = `Standing closed — ${REOPEN_HINT}`
52
53/** The engine's JSON, checked for the shape the pane relies on; anything else is no standing. */
54export function parseStanding(stdout: string): Standing | undefined {
55  try {
56    const value = JSON.parse(stdout) as Standing
57    const isShaped =
58      value?.version === 1 &&
59      Array.isArray(value.packages) &&
60      typeof value.now === 'number' &&
61      value.packages.every(pkg => typeof pkg?.dir === 'string' && typeof pkg.slug === 'string' && typeof pkg.state === 'string')
62
63    return isShaped ? value : undefined
64  } catch {
65    return undefined
66  }
67}
68
69/** What the pane shows for one engine run. */
70export function viewOf(standing: Standing | undefined): StandingView {
71  if (standing === undefined) return { phase: 'empty', why: 'the project-standing engine did not answer' }
72  if (standing.repo === null) return { phase: 'empty', why: 'this is not a git repository' }
73  if (!standing.repo.hasFeatures) return { phase: 'empty', why: 'this project has no docs/features/' }
74
75  return { phase: 'ready', standing }
76}
77
78export type Groups = { live: StandingPackage[]; dormant: StandingPackage[]; concluded: StandingPackage[] }
79
80/** The packages by state: in flight most recently active first, concluded most recently closed first. */
81export function groups(standing: Standing): Groups {
82  const newest = (a: StandingPackage, b: StandingPackage) => b.lastActivity - a.lastActivity || b.dir.localeCompare(a.dir)
83  const closedLast = (a: StandingPackage, b: StandingPackage) =>
84    (b.closedAt ?? b.lastActivity) - (a.closedAt ?? a.lastActivity) || newest(a, b)
85  const of = (state: StandingPackage['state']) => standing.packages.filter(pkg => pkg.state === state)
86
87  return { live: of('live').sort(newest), dormant: of('dormant').sort(newest), concluded: of('concluded').sort(closedLast) }
88}
89
90/** The pane's first line: the answer to "what needs me?". */
91export function headline({ live, dormant }: Groups) {
92  const inFlight = live.length + dormant.length
93
94  return inFlight === 0 ? 'Nothing in flight' : `${inFlight} in flight`
95}
96
97/** The collapsed concluded section: "28 concluded · latest: <slug> (<age>)". */
98export function concludedLine(now: number, concluded: StandingPackage[]) {
99  const [latest] = concluded
100  if (latest === undefined) return undefined
101
102  return `${concluded.length} concluded · latest: ${latest.slug} (${age(now, latest.closedAt ?? latest.lastActivity)})`
103}
104
105/** An epoch as its UTC calendar date, `YYYY-MM-DD`. */
106export function dateOf(epoch: number) {
107  return new Date(epoch * 1000).toISOString().slice(0, 10)
108}
109
110/**
111 * The prompt Resume queues, built only from validated names; undefined when a name fails,
112 * so a package the contract did not vouch for gets no button.
113 */
114export function resumePrompt(pkg: StandingPackage): string | undefined {
115  if (!PACKAGE_DIR.test(pkg.dir)) return undefined
116  if (pkg.worktree !== undefined && !WORKTREE.test(pkg.worktree)) return undefined
117  const where = `${pkg.worktree === undefined ? '' : `${pkg.worktree}/`}docs/features/${pkg.dir}/`
118  if (pkg.baton === undefined) {
119    return `Resume ${pkg.slug}: it has no handoff baton yet, so orient from ${where} (bespunky-workflow:project-standing, then bespunky-workflow:session-handoff).`
120  }
121  if (!BATON.test(pkg.baton)) return undefined
122
123  return `Resume ${pkg.slug}: read its newest handoff, ${where}${pkg.baton}, and pick up from there (bespunky-workflow:session-handoff).`
124}
125
126/** "today", "3d ago", "5w ago", "4mo ago". */
127export function age(now: number, then: number) {
128  const days = Math.floor((now - then) / 86400)
129  if (days < 1) return 'today'
130  if (days < 14) return `${days}d ago`
131  if (days < 60) return `${Math.floor(days / 7)}w ago`
132
133  return `${Math.floor(days / 30)}mo ago`
134}
135
136export const register: Register = on => {
137  // Only where a person is at the prompt: the pane is for someone to read and press, and a
138  // headless run has the skill. (The plugin's other mods hook every session start.)
139  on('session.start', { isInteractive: true }, async ($, e, next) => {
140    await $.command.register({
141      name: COMMAND,
142      description: 'Where this project stands: feature packages by status, each with its newest handoff',
143    })
144
145    return next(e)
146  })
147
148  on('command.run', { command: COMMAND }, async $ => {
149    const view = await refresh($)
150    if (view.phase === 'empty') {
151      return { text: `No project standing to show: ${view.why}.` }
152    }
153    await $.ui.open({ id: PANE, title: 'standing' })
154    const { live, dormant, concluded } = groups(view.standing)
155
156    return { text: `Standing: ${live.length} live, ${dormant.length} dormant, ${concluded.length} concluded.` }
157  })
158
159  // The command's answer row reads as the toolkit's, not as the plugin's bare name.
160  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
161    if (e.props.isErrored) return next(e)
162
163    return <BrandCommandRow ui={$.ui.resolve(e)} command={COMMAND} text={e.props.text} plugin={$.plugin.name} />
164  })
165
166  // Closed by the person's own hand (the engine's close mark, ctrl+x x), say how it comes back. The
167  // pane's Close button says it itself (`close`); an unload is no one's choice and says nothing.
168  on('ui.close', { id: PANE }, async ($, e, next) => {
169    const closed = await next(e)
170    if (e.origin.kind === 'person') {
171      $.ui.toast(brandLine(CLOSED_TOAST))
172    }
173
174    return closed
175  })
176
177  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
178    const ui = $.ui.resolve(e)
179    const { Box, Button, Text } = ui
180    const { value: view } = await $.state.get(VIEW)
181    const controls = (
182      <Box key="standing-controls" flexDirection="row" gap={1}>
183        <Button key="standing-refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
184        <Button key="standing-close" label="Close" role="dismiss" onPress={() => void close($)} />
185        <Text dimColor wrap="truncate-end">
186          {REOPEN_HINT}
187        </Text>
188      </Box>
189    )
190
191    if (view === undefined || view.phase === 'empty') {
192      return (
193        <BrandFrame ui={ui} site={e} mod={COMMAND}>
194          <Text dimColor>{view === undefined ? 'Nothing derived yet.' : `Nothing to show: ${view.why}.`}</Text>
195          {controls}
196        </BrandFrame>
197      )
198    }
199
200    const { standing } = view
201    const { value: showConcluded = false } = await $.state.get(SHOW_CONCLUDED)
202    const grouped = groups(standing)
203    const { live, dormant, concluded } = grouped
204    const resumable = [...live, ...dormant]
205    const hotkeyOf = (pkg: StandingPackage) => HOTKEYS[resumable.indexOf(pkg)]
206
207    // What the package is about, under its row: the slug alone may mean nothing a month later.
208    const aboutLine = (pkg: StandingPackage) =>
209      typeof pkg.about === 'string' && pkg.about !== '' ? (
210        <Box key={`standing-about-${pkg.dir}`} paddingLeft={2}>
211          <Text dimColor wrap="truncate-end">
212            {pkg.about}
213          </Text>
214        </Box>
215      ) : null
216
217    const inFlight = (pkg: StandingPackage) => {
218      const prompt = resumePrompt(pkg)
219      const hotkey = hotkeyOf(pkg)
220      const where = pkg.baton ?? 'no baton yet'
221
222      return (
223        <Box key={`standing-row-${pkg.dir}`} flexDirection="row" gap={1}>
224          <Box flexGrow={1} flexShrink={1} flexDirection="column">
225            <Text wrap="truncate-end">
226              <Text bold>{pkg.slug}</Text>
227              <Text dimColor>
228                {' '}
229                {age(standing.now, pkg.lastActivity)}
230                {pkg.hasWorktree ? ' · worktree' : ''} · {where}
231              </Text>
232            </Text>
233            {aboutLine(pkg)}
234          </Box>
235          {prompt !== undefined && (
236            <Button
237              key={`standing-resume-${pkg.dir}`}
238              label="Resume"
239              {...(hotkey === undefined ? {} : { hotkey })}
240              onPress={() => void resume($, pkg, prompt)}
241            />
242          )}
243        </Box>
244      )
245    }
246
247    // Items space out, each item's own lines stay together: a thin rule BETWEEN packages, never
248    // inside one (a slug and its about line are one item) and never after the last.
249    const separated = (rows: StandingPackage[], draw: (pkg: StandingPackage) => JSX.Element) =>
250      rows.flatMap((pkg, index) =>
251        index === 0 ? [draw(pkg)] : [<BrandDivider key={`standing-divider-${pkg.dir}`} ui={ui} site={e} />, draw(pkg)],
252      )
253
254    // A section only when it has rows: an empty heading answers nothing.
255    const section = (title: string, rows: StandingPackage[]) =>
256      rows.length === 0 ? null : (
257        <Box key={`standing-${title.toLowerCase()}`} flexDirection="column">
258          <Text bold>
259            {title} ({rows.length})
260          </Text>
261          {separated(rows, inFlight)}
262        </Box>
263      )
264
265    const closed = (pkg: StandingPackage) => (
266      <Box key={`standing-row-${pkg.dir}`} flexDirection="column">
267        <Text dimColor wrap="truncate-end">
268          {pkg.slug} {dateOf(pkg.closedAt ?? pkg.lastActivity)}
269          {pkg.status === 'concluded' ? '' : ` (${pkg.status})`}
270        </Text>
271        {aboutLine(pkg)}
272      </Box>
273    )
274
275    const summary = concludedLine(standing.now, concluded)
276    const history =
277      summary === undefined ? null : (
278        <Box key="standing-concluded" flexDirection="column">
279          <Box flexDirection="row" gap={1}>
280            <Box flexGrow={1} flexShrink={1}>
281              <Text dimColor wrap="truncate-end">
282                {summary}
283              </Text>
284            </Box>
285            <Button
286              key="standing-concluded-toggle"
287              label={showConcluded ? 'Hide concluded' : 'Show concluded'}
288              hotkey={CONCLUDED_HOTKEY}
289              onPress={() => void toggleConcluded($)}
290            />
291          </Box>
292          {showConcluded && separated(concluded.slice(0, RECENT_CONCLUDED), closed)}
293        </Box>
294      )
295
296    return (
297      <BrandFrame ui={ui} site={e} mod={COMMAND}>
298        <Text bold>{headline(grouped)}</Text>
299        {section('Live', live)}
300        {section('Dormant', dormant)}
301        {history}
302        {controls}
303      </BrandFrame>
304    )
305  })
306}
307
308/** Runs the engine for the session's project and stores what the pane shows. */
309async function refresh($: EngineInterface): Promise<StandingView> {
310  const view = viewOf(await derive($))
311  await update($, VIEW, () => view)
312
313  return view
314}
315
316async function derive($: EngineInterface): Promise<Standing | undefined> {
317  try {
318    const root = await $.session.root()
319    const ran = await $.process.run(['node', `${$.plugin.root}/${ENGINE}`, '--json'], {
320      cwd: root,
321      env: { CLAUDE_PROJECT_DIR: root },
322      timeoutMs: 15_000,
323    })
324
325    return ran.exitCode === 0 ? parseStanding(ran.stdout) : undefined
326  } catch (error) {
327    $.ui.log(`bespunky-workflow: standing engine failed: ${error instanceof Error ? error.message : String(error)}`, {
328      to: 'debug',
329    })
330
331    return undefined
332  }
333}
334
335/** The Close button: closes the pane and says how it comes back. */
336async function close($: EngineInterface) {
337  await $.ui.close({ id: PANE })
338  $.ui.toast(brandLine(CLOSED_TOAST))
339}
340
341/** Expands or collapses the concluded section. */
342async function toggleConcluded($: EngineInterface) {
343  await update($, SHOW_CONCLUDED, shown => !(shown ?? false))
344}
345
346/** Queues the resume prompt for Claude; the pane itself does nothing else. */
347async function resume($: EngineInterface, pkg: StandingPackage, prompt: string) {
348  $.ui.toast(brandLine(`Asked Claude to resume ${pkg.slug}`))
349  await $.prompt.submit({ text: prompt, asUser: true })
350}
351
hooks/_brand.tsx 148 lines
1// GENERATED from tools/mod-brand/brand.tsx by `node tools/mod-brand/project.mjs --write` — DO NOT EDIT.
2// Change the brand at its source; CI fails when this copy drifts from it.
3
4// ✦ bespunky — THE TOOLKIT'S MOD BRAND: one mark, one accent, one frame, so every toolkit mod (a pane, a band
5// above the prompt, a status entry, a toast) reads as one family — and never as Claude's own UI or another
6// plugin's.
7//
8// THE SINGLE SOURCE. Plugins cannot import each other's files, so this file is PROJECTED, verbatim under a
9// generated header, into `hooks/_brand.tsx` of every plugin whose hooks.json names a module
10// (`node tools/mod-brand/project.mjs --write`); CI fails on any drift. Change the brand HERE, never in a
11// projection, and never re-type the glyph or the colour in a mod.
12//
13// THE RULES IT ENCODES.
14//   - Plain-text surfaces (status line, toast) carry the GLYPH alone: Claude Code titles both with the plugin's
15//     name (`bespunky-workflow: …`), which already says bespunky — spelling it again reads
16//     `bespunky-workflow: ✦ bespunky · …`. `brandLine(text)`.
17//   - A mod's slash command answers in the transcript under the PLUGIN's name (`bespunky-workflow: …`), which
18//     reads as any plugin's. Its output row is redrawn as the toolkit's: `✦ bespunky · <command>` then the
19//     text. `<BrandCommandRow>`, from a `ui.render` hook on `{ component: 'CommandOutput', props: { command } }`.
20//   - Anything drawn BELOW the transcript (every band; a pane seated inline) starts one blank row down, so it
21//     never reads as the tail of Claude's reply.
22//   - The accent is a raw colour, not a theme key: theme keys are Claude's palette, and the point is to look
23//     like something that is not Claude. A mid-tone violet keeps its contrast on light and dark themes; with no
24//     colour at all the glyph and the bold wordmark still carry the mark.
25//
26// Pure: no `$`, no state, no I/O — the mods pass in their surface's element table and render argument.
27
28import type { BoxProps, ElementConstructor, RenderChildren, RenderSurface, TextProps } from 'claude-code'
29
30export const BRAND = {
31  glyph: '✦',
32  name: 'bespunky',
33  /** Violet-500: distinct from Claude's clay, readable on light and dark. */
34  accent: '#8b5cf6',
35} as const
36
37/** The wordmark: `✦ bespunky`. */
38export const WORDMARK = `${BRAND.glyph} ${BRAND.name}`
39
40/** A status entry or toast, marked as the toolkit's: `✦ <text>` (the engine titles both with the plugin's name). */
41export function brandLine(text: string) {
42  return `${BRAND.glyph} ${text}`
43}
44
45/** A drawn site's title: `✦ bespunky · <mod>`, or the wordmark alone. */
46export function brandTitle(mod?: string) {
47  return mod === undefined ? WORDMARK : `${WORDMARK} · ${mod}`
48}
49
50/** Cells a band's brand gutter takes from its row: the wordmark and the gap after it. */
51export const BAND_GUTTER_CELLS = WORDMARK.length + 1
52
53/** The two elements every surface's table carries, which is all the frame draws with. */
54export type BrandUi = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
55
56/** The parts of a `ui.render` argument the frame reads: where it draws, and how wide. */
57export type BrandSite =
58  | { surface: RenderSurface; component: 'AbovePrompt'; props: { bodyColumns: number } }
59  | { surface: RenderSurface; component: 'Pane'; props: { bodyColumns: number; placement: 'dock' | 'inline' } }
60
61export type BrandFrameProps = {
62  /** `$.ui.resolve(e)`, the surface's own elements. */
63  ui: BrandUi
64  /** The render argument `e` itself. */
65  site: BrandSite
66  /** The mod's short name for a pane's title (`standing`); a band shows the wordmark alone. */
67  mod?: string
68  children?: RenderChildren
69}
70
71/**
72 * The toolkit's frame for a drawn site. A band: one blank row down from the transcript, the wordmark in a
73 * gutter, the mod's rows beside it. A pane: the branded title over a dim rule (the rule on the terminal only —
74 * the other surfaces frame their panes natively, and a rule of box-drawing glyphs is a terminal idiom), then
75 * the body; one blank row down when seated inline under the transcript.
76 */
77export function BrandFrame({ ui, site, mod, children }: BrandFrameProps) {
78  const { Box, Text } = ui
79  const columns = site.props.bodyColumns
80
81  if (site.component === 'AbovePrompt') {
82    return (
83      <Box flexDirection="row" gap={1} marginTop={1}>
84        <Text color={BRAND.accent} bold>
85          {WORDMARK}
86        </Text>
87        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
88          {children}
89        </Box>
90      </Box>
91    )
92  }
93
94  const title = brandTitle(mod)
95  const rule = site.surface === 'terminal' ? Math.max(0, columns - title.length - 1) : 0
96
97  return (
98    <Box flexDirection="column" gap={1} marginTop={site.props.placement === 'inline' ? 1 : 0}>
99      <Box key="brand-title" flexDirection="row" gap={1}>
100        <Text color={BRAND.accent} bold>
101          {title}
102        </Text>
103        {rule > 0 && <Text dimColor>{'─'.repeat(rule)}</Text>}
104      </Box>
105      {children}
106    </Box>
107  )
108}
109
110/**
111 * A thin dim rule between items of a list, sized to the site (`columns` cells). On the terminal a line of
112 * `─`; elsewhere a one-row gap does the same job without a glyph the surface's font may not tile.
113 */
114export function BrandDivider({ ui, site, columns }: { ui: BrandUi; site: BrandSite; columns?: number }) {
115  const { Box, Text } = ui
116  const width = Math.max(0, columns ?? site.props.bodyColumns)
117
118  return site.surface === 'terminal' ? (
119    <Text dimColor wrap="truncate-end">
120      {'─'.repeat(width)}
121    </Text>
122  ) : (
123    <Box height={1} />
124  )
125}
126
127/**
128 * A toolkit command's output row in the transcript: `✦ bespunky · <command>` in the accent, then the text the
129 * command answered. `text` is the row's own (`e.props.text`); `plugin` is the answering plugin's manifest name
130 * (`$.plugin.name`), whose `name: ` lead — the engine's attribution of a hook-answered row — the brand replaces.
131 */
132export function BrandCommandRow({ ui, command, text, plugin }: { ui: BrandUi; command: string; text: string; plugin: string }) {
133  const { Box, Text } = ui
134  const lead = `${plugin}: `
135  const body = text.startsWith(lead) ? text.slice(lead.length) : text
136
137  return (
138    <Box flexDirection="row" gap={1}>
139      <Text color={BRAND.accent} bold>
140        {brandTitle(command)}
141      </Text>
142      <Box flexGrow={1} flexShrink={1}>
143        <Text dimColor>{body}</Text>
144      </Box>
145    </Box>
146  )
147}
148
hooks/branch-engine.ts 100 lines
1// bespunky-workflow — THE MODS' VIEW OF THE BRANCH-MODEL ENGINE. `branches.mjs` is the ONE interpreter of
2// `.bespunky/branches.json`; every mod that needs the model (the status line, the merge gate) reads it through
3// here: each runs the engine itself, and this checks the answer against the frozen `statusShape` contract and
4// folds it into `Model`. No mod reads the declaration itself, and none re-derives what the engine decides.
5//
6// Pure on purpose: a mod may not hand `$` across an import, so the running stays in each mod and only the
7// reading lives here. Read-only by construction: `status --json` and `plan` never move a branch (lib/git.mjs).
8
9/** What `branches.mjs status --json` said, already checked against its contract — or that it said nothing. */
10export type Model =
11  | {
12      state: 'declared'
13      summary: string
14      /** The line work lands on. */
15      integration: string
16      /** Integration first, then each stage in promotion order. */
17      chain: string[]
18      remote: string
19      protected: string[]
20      protectedPatterns: string[]
21    }
22  | { state: 'undeclared'; protected: string[]; protectedPatterns: string[] }
23  | { state: 'unreadable'; reason: string; protected: string[]; protectedPatterns: string[] }
24  /** Not a git repo, no node, a crash: nothing honest to show. */
25  | { state: 'absent' }
26
27/**
28 * Whether `branch` is a protected line: a name in the set, or a match for one of its globs. The globs are
29 * the engine's published form (`toGlob`: each `{x}` → `*`), matched as the PreCompact hook matches them — a
30 * shell `case`, where `*` spans any characters, `/` included.
31 */
32export function isProtected(branch: string, model: { protected: string[]; protectedPatterns: string[] }) {
33  return model.protected.includes(branch) || model.protectedPatterns.some(glob => globToRegExp(glob).test(branch))
34}
35
36function globToRegExp(glob: string) {
37  const source = [...glob]
38    .map(ch => (ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
39    .join('')
40
41  return new RegExp(`^${source}$`)
42}
43
44/**
45 * `status --json` parsed against its contract (`statusShape`). The exit code is not trusted on its own — a
46 * crash exits non-zero too — so the SHAPE decides, exactly as the PreCompact hook decides.
47 */
48export function parseStatus(stdout: string): Model {
49  let json: unknown
50  try {
51    json = JSON.parse(stdout)
52  } catch {
53    return { state: 'absent' }
54  }
55  const j = json as Record<string, unknown> | null
56
57  if (!j || !isList(j.protected) || !isList(j.protectedPatterns)) {
58    return { state: 'absent' }
59  }
60  const sets = { protected: j.protected, protectedPatterns: j.protectedPatterns }
61
62  switch (j.state) {
63    case 'declared':
64      return declared(j.projection, sets)
65    case 'undeclared':
66      return { state: 'undeclared', ...sets }
67    case 'unreadable':
68      return { state: 'unreadable', reason: oneLine(String(j.reason || 'cannot be read')), ...sets }
69    default:
70      return { state: 'absent' }
71  }
72}
73
74function declared(projection: unknown, sets: { protected: string[]; protectedPatterns: string[] }): Model {
75  const p = projection as Record<string, unknown> | null | undefined
76  const shaped =
77    typeof p?.summary === 'string' &&
78    typeof p.integration === 'string' &&
79    typeof p.remote === 'string' &&
80    isList(p.chain) &&
81    p.chain[0] === p.integration
82
83  return shaped
84    ? { state: 'declared', summary: p.summary as string, integration: p.integration as string, chain: p.chain as string[], remote: p.remote as string, ...sets }
85    : { state: 'absent' }
86}
87
88function isList(v: unknown): v is string[] {
89  return Array.isArray(v) && v.every(x => typeof x === 'string' && x !== '')
90}
91
92/** The branch-model engine, shipped beside the mods in the same plugin (`$.plugin.root`). */
93export function enginePath(pluginRoot: string) {
94  return `${pluginRoot}/skills/branch-and-release/scripts/branches.mjs`
95}
96
97export function oneLine(text: string) {
98  return text.replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '')
99}
100
types/index.d.ts 102 lines
1// bespunky-workflow — the standing pane's state contract.
2//
3// The /standing pane (hooks/standing.tsx) is a VIEW over the project-standing engine
4// (skills/project-standing/scripts/standing.mjs --json). The engine is the single source of
5// truth for what a feature package is and which state it is in; the pane only folds one run of
6// it into ONE session value and draws that. Declared here, in the plugin's contract, because
7// `$.state` values are typed by the owner's `PluginState` entry and anyone may read them.
8
9/** A feature package's closing status, from its DECISION.md (`in-flight` when it has none). */
10export type StandingStatus = 'in-flight' | 'concluded' | 'abandoned' | 'superseded'
11
12/**
13 * Which group a package is drawn in: `live` (in flight and touched within the stale window, or
14 * checked out in a worktree), `dormant` (in flight, untouched past it), `concluded` (closed).
15 */
16export type StandingState = 'live' | 'dormant' | 'concluded'
17
18/** One feature package, as the engine derived it. Names are validated at the source. */
19export type StandingPackage = {
20  /** The package folder under docs/features/: `<YYYY-MM-DD>-<slug>`. */
21  dir: string
22  date: string
23  slug: string
24  status: StandingStatus
25  state: StandingState
26  /** Newest activity in the package (commit or uncommitted edit), seconds since the epoch. */
27  lastActivity: number
28  /** A worktree has a branch named for this slug checked out. */
29  hasWorktree: boolean
30  /**
31   * Where the newest copy was found, when not in the session's checkout: that worktree's project
32   * dir, relative to the session's when inside it, else absolute. Charset-validated at the source.
33   */
34  worktree?: string
35  /** The newest baton, relative to the package: `handoffs/<name>`. */
36  baton?: string
37  /**
38   * What the package is about, in one plain line (DECISION.md's summary, else BRIEF.md's summary or
39   * first sentence); null when neither says. Free text: for display only, never a prompt.
40   */
41  about: string | null
42  /** DECISION.md frontmatter of a closed package. Free text: for display only, never a prompt. */
43  summary?: string
44  concluded?: string
45  tags?: string[]
46  /** When a closed package closed (its `concluded:` date, else when DECISION.md last moved), seconds since the epoch. */
47  closedAt?: number
48}
49
50/** What `standing.mjs --json` prints. `repo` is null outside a git repository. */
51export type Standing = {
52  version: 1
53  staleDays: number
54  /** When it was derived, seconds since the epoch. */
55  now: number
56  repo: { lastCommit: number; commitAgeDays: number; hasFeatures: boolean; hasRecentDoc: boolean } | null
57  packages: StandingPackage[]
58}
59
60/**
61 * What the pane shows.
62 *
63 * - `empty`  nothing to show (no engine, not a repo, no docs/features/); `why` says which.
64 * - `ready`  one run of the engine.
65 */
66export type StandingView = { phase: 'empty'; why: string } | { phase: 'ready'; standing: Standing }
67
68/** One move the merge gate offers: a button that queues `prompt` as the person's own. */
69export type MergeGateMove = {
70  /** Stable per move, for the button's key: `land`, `land-promote`, `push`, `promote`. */
71  id: 'land' | 'land-promote' | 'push' | 'promote'
72  label: string
73  hotkey: string
74  /** Built only from names the engine and git vouched for; never from Claude's free text. */
75  prompt: string
76}
77
78/**
79 * The merge gate (hooks/merge-gate.tsx): what Claude proposed, folded with the engine's and git's facts into
80 * the moves the person can press. `null` when no proposal is standing.
81 */
82export type MergeGate = {
83  /** What the moves act on: `feat/x → development · 3 commits`. */
84  headline: string
85  /** Claude's one line on what is ready. Free text: drawn, never a prompt. */
86  note: string
87  /** First is the main action. */
88  moves: MergeGateMove[]
89}
90
91declare module 'claude-code' {
92  interface PluginState {
93    'bespunky-workflow': {
94      standing: StandingView
95      /** The pane lists the most recent concluded packages instead of one summary line. */
96      showConcluded: boolean
97      /** The standing proposal to land or promote, until the person answers it. */
98      mergeGate: MergeGate | null
99    }
100  }
101}
102