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

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:
| Plugin | Provides | Purpose | ||
|---|---|---|---|---|
bespunky | skills index, tips · hook | The 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-automation | skills playwright, shared-browser | Two 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. | ||
house | commands upgrade, add-layer, skills new, firebase-app-hosting · hook, the house band mod | The 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-libs | packages, --linking=paths | workspaces). /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-ux | skills 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-data | Experience 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-system | skills design-system-first, design-tokens-and-theming | Styling 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
hooks/mods.ts 15 lines1// 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}
15hooks/branch-status.ts 201 lines1// 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}
201hooks/checkpoint-toast.ts 77 lines1// 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}
77hooks/merge-gate.tsx 297 lines1// 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}
297hooks/standing.tsx 351 lines1// 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}
351hooks/_brand.tsx 148 lines1// 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}
148hooks/branch-engine.ts 100 lines1// 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}
100types/index.d.ts 102 lines1// 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