Minimal dev workflow: shape an idea into a markdown roadmap, build features, launch, commit, convert designs to code, plan and measure SEO.

A small Claude Code plugin for building software with Claude. It covers the whole loop: shape an idea, turn it into a roadmap, build features one at a time (spec → code → independent verify → commit), fix bugs with discipline, and convert designs into code.
kit is the successor to claude-config v1. v1 grew to 42 skills, ~15k lines of shared instructions and its own state engine (JSON state files, a backlog server, checkpoints, a sync branch). Most of the friction came from that machinery, not from the actual work. kit keeps the workflow and drops the machinery: all state is plain markdown in your repo, and wherever Claude Code already has a native feature (plan mode, subagents, AGENTS.md loading, /simplify, /security-review) kit uses it instead of rebuilding it.
14 skills · 1 agent · 2 hooks + 1 mod (2 panes) · 3 scripts · ~1550 lines of skills · ~420 tokens always-on
This repo is both the plugin and its marketplace (airmile). It's private, so installing needs a GitHub login that can read AirMile/claude-kit.
Any machine (installs a copy from GitHub):
claude plugin marketplace add AirMile/claude-kit
claude plugin install kit@airmile
Get updates later with claude plugin marketplace update airmile.
The machine you develop kit on (runs straight from your clone):
git clone https://github.com/AirMile/claude-kit ~/Projects/claude-kit
claude plugin marketplace add ~/Projects/claude-kit
claude plugin install kit@airmile
The install is a copy in ~/.claude/plugins/cache/airmile/kit/<version>/: edits in the clone take effect only after a version bump + update (see Update); /commit pushes them to GitHub.
Check with claude plugin details kit: it should list 14 skills, 1 agent and 2 hooks. Skills are available in new sessions; in a running session use /reload-plugins.
Skills are namespaced as /kit:<skill> (e.g. /kit:build). The bare name (/build) also works as long as no other skill or command uses it.
kit keeps project instructions in AGENTS.md, including nested ones per module. Claude Code (v2.1.277+) reads root and nested AGENTS.md files natively, but only when the project has no CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md (docs: memory § AGENTS.md). So kit projects have none: /setup moves anything useful from an existing CLAUDE.md into AGENTS.md and removes it. Your personal ~/.claude/CLAUDE.md doesn't count and keeps working. Bonus: AGENTS.md is also read by other coding agents, so the project stays tool-agnostic.
Plugins can't ship rules, so kit keeps a path-scoped rule in rules/frontend.md (simplest CSS first, use the project's tokens, check layout edits in the browser, edit pasted inspect refs in place). It loads only when Claude reads frontend files. Link it once per machine:
mkdir -p ~/.claude/rules
ln -sfn ~/Projects/claude-kit/rules/frontend.md ~/.claude/rules/frontend.md
On Windows, copy the file instead (symlinks need admin rights there).
version in .claude-plugin/plugin.json, then claude plugin marketplace update airmile && claude plugin update kit@airmile and start a new session. Without the bump the cached copy stays as it was.claude --plugin-dir ~/Projects/claude-kit.claude plugin marketplace remove airmile (also uninstalls the plugin).A brand-new idea
/setup a habit tracker for people who quit after a week
→ /product interview, scaffold, AGENTS.md
(started outside a project? it makes the folder first)
/build → builds the first roadmap item
/build → and the next one …
An existing repo
/setup → reads the repo, writes AGENTS.md + docs/
/product → writes docs/product.md and the roadmap from a short interview
/build <slug>
A quick change: no skill needed. Ask for it in plain words (auto mode is fine), then:
/commit
AGENTS.md and the frontend rule load in every chat, so a plain request still follows the project's conventions. Use /build once it's a real feature or a bug whose cause isn't obvious: that's where the spec, the independent verify and resuming in a new chat pay for themselves.
A bug
/build "fix: saving a habit twice creates a duplicate"
Going live
/launch
A design
/convert https://figma.com/design/<file>/<name>?node-id=12-34 app/pricing/page.tsx
kit has no database, no JSON state, no background server and no checkpoint files. Everything a skill needs to know lives in a handful of markdown files committed to your repo:
/build <slug> resumes from the spec's Status: line and its checkboxes).<!-- format: … --> header that describes its own format, so skills (and you) never need a separate schema document.Two rules keep them small:
your-project/
├── AGENTS.md instructions: commands, conventions, pitfalls (≤100 lines)
├── .worktreeinclude gitignored files (.env …) copied into new worktree sessions
├── src/payments/AGENTS.md module-specific pitfalls, loaded only when Claude works there
└── docs/
├── product.md what, for whom, why, non-goals, stack
├── roadmap.md ordered features = the backlog
├── decisions.md decisions + the alternative that was rejected
├── seo.md intake answers, search terms, page plan, measurements (SEO work)
└── specs/
└── <slug>.md one per feature: criteria, status, verify result, later fixes
| File | Written by | Read by | Loaded into context |
|---|---|---|---|
AGENTS.md (root) | /setup, lessons | every session | always, at session start |
<dir>/AGENTS.md | lessons | every session | lazily, when Claude reads a file in that dir |
docs/product.md | /product | /build, /convert, /setup | on demand |
docs/roadmap.md | /product, /build | /build | on demand |
docs/specs/<slug>.md | /build | /build, verifier | on demand |
docs/decisions.md | /build, /setup | /build | on demand |
docs/seo.md | /scan, /intake, /seo | /seo, /build, /audit, /launch | on demand |
The one instruction file Claude Code reads every session. AGENTS.md is also understood by other coding agents (Codex, Cursor, …), so your project stays tool-agnostic. Template:
# my-app
Habit tracker PWA for people who drop habits after a week.
## Commands
- Dev: `pnpm dev` → http://localhost:5173
- Test: `pnpm test` (single file: `pnpm test src/habits.test.ts`)
- Lint / typecheck: `pnpm lint && pnpm typecheck`
- Build: `pnpm build`
## Conventions
- Dates are stored as ISO strings in UTC; convert only in the view layer.
## Pitfalls
- Service worker caches /api in dev too: hard-reload after changing API responses.
## Git
- Workflow: branches
- Fresh worktree: `pnpm install` before anything else
## Docs
- Product intent: `docs/product.md` · roadmap: `docs/roadmap.md` · specs: `docs/specs/`
- Decisions and why: `docs/decisions.md`
The Commands section matters: /build and the verifier use the dev command and port from it to run browser checks.
Nested AGENTS.md files hold pitfalls that only matter in one module. Because they load only when Claude touches that directory, they cost nothing the rest of the time.
Product intent, under ~60 lines. Sections: What, For whom, Why now, Core experience, Non-goals, Stack, Open questions. /build reads the non-goals before defining a feature, so scope creep is caught early.
The backlog. One line per item, order = priority:
# Roadmap
- [x] **scaffold** · project skeleton, dev server, test runner · spec: docs/specs/scaffold.md
- [ ] **habit-crud** · create, edit, delete habits · spec: docs/specs/habit-crud.md
- [ ] **streaks** · show current and best streak per habit
- [ ] **reminders** · daily push reminder at a chosen time
## Later
- sharing streaks with a friend
The three states are encoded in the line itself:
| Line | Meaning |
|---|---|
- [ ] slug · description | open, not started |
- [ ] slug · description · spec: docs/specs/slug.md | in progress (spec exists) |
- [x] slug · description · spec: … | done |
## Later holds unordered ideas that are not in the flow yet.
Created by /build when you approve a feature plan. It is both the contract and the resume point:
# habit-crud
Status: building
Design: —
## Goal
Users can create, rename and delete habits; the list survives a reload.
## Acceptance criteria
- [x] Happy: adding "Read 10 pages" shows it at the top of the list
- [ ] Edge: an empty or whitespace-only name is rejected with an inline message
- [ ] Error: when storage is full, the user sees "Couldn't save" and the form keeps its input
## Out of scope
- Reordering habits
## Approach
- src/habits/store.ts (new): load/save to IndexedDB
- src/habits/HabitForm.tsx (new) …
## Tests
- src/habits/store.test.ts → happy, error
- browser: empty-name message visible
## Handoff
- Store keeps names trimmed; the form shows the raw input (user asked for that)
## Verify
<auto result, manual items and their outcome>
## Fixes
<later bugfixes: date · symptom · cause · fix>
Status moves defined → building → verifying → manual → done. A criterion is checked only when it is built and verified.
Newest first; one block per decision where an alternative was genuinely rejected:
## 2026-10-01 · Store habits in IndexedDB, not localStorage
Why: needs >5 MB for history · Rejected: localStorage (size cap, sync API blocks UI) · Superseded by: —
This is the part of "architecture" that can't be re-derived from the code: why it is the way it is.
Turn an idea into docs/product.md + docs/roadmap.md, or update them later.
/product <idea> new project (when docs/product.md doesn't exist)
/product add <item> update: add, reorder, remove or reword roadmap items
/product critique stress-test product.md and the roadmap without changing it
New
Roadmap rules: every item is a user-visible capability buildable in one /build run (≤ ~6 acceptance criteria, otherwise split); dependencies first, then value; greenfield projects start with a scaffold item; 5–15 items for an MVP, the rest under ## Later.
Update finds the right position for a new item, checks it against the product's non-goals (conflict → asks whether the non-goal changes or the item is dropped), never touches done or in-progress items, and shows a diff before writing.
Critique applies three lenses (max 3 findings each): the least-proven assumption, how the MVP disappoints its first real user, and the smallest roadmap that still tests the core idea. It then offers to apply the changes through Update.
Make a project kit-ready. Idempotent: running it again only fills gaps.
It detects one of five situations:
| Situation | What happens |
|---|---|
| Not in a project (home folder, or a folder of projects) | New folder: asks the name, creates the folder + git init, moves the session there; send /setup again |
| Empty folder, no docs/product.md | Product first: runs the /product interview, then scaffolds |
Empty folder, docs/product.md exists | Scaffold: runs the stack's official generator, adds a test runner, boots the dev server once, then onboards |
A v1 project (.project/, no docs/roadmap.md yet) | Migrate (below) |
| Any other existing code | Onboard |
Onboard reads the manifest, scripts, test/lint config and recent git history, then:
AGENTS.md from the template, with only verified facts (no placeholders);CLAUDE.md into AGENTS.md and removes the CLAUDE.md (otherwise AGENTS.md wouldn't load);docs/roadmap.md and docs/decisions.md with their format headers (never overwrites);AGENTS.md and docs/ are not in .gitignore, and ignores .claude/worktrees/;.worktreeinclude, autoPort, database line).Migrate (v1 → kit) moves the durable parts of v1's state and leaves .project/ untouched so you can delete it when satisfied:
| v1 source | kit destination |
|---|---|
Generated CLAUDE.md | real rules/runbooks/pitfalls → AGENTS.md; template sections dropped |
AGENTS.md symlinked to CLAUDE.md | replaced by a real file |
.project/project-seed.md | docs/product.md |
.project/backlog.json (unshipped, board order) | docs/roadmap.md |
.project/project-context.json learnings | ≤ ~15 non-derivable ones → root or nested AGENTS.md |
You get a preview and one confirmation before anything is written. /setup never commits; it offers /commit.
For features and real bugs. It keeps feature progress in the spec so any new chat can resume. Small changes don't need it; if it gets one anyway, it takes a light path.
/build resume the in-progress item, else start the next roadmap item
/build <slug> start or resume that feature
/build "make X do Y" small change or feature, decided by the size gate
/build "fix: X is broken" bugfix via the debug ladder
| Input | Path |
|---|---|
| slug with an existing spec | Resume at the step matching Status |
| slug on the roadmap, no spec | Feature |
| no argument | in-progress roadmap item → Resume; else first open item → Feature |
| text describing a bug | Fix |
| other text | size gate → Small or Feature |
Size gate. A change is Small only if none of these hold:
The gate is re-checked while working: as soon as the real scope crosses a line, /build stops, tells you, and switches to the Feature path with a spec for what's left.
Make the change → scoped tests/lint → a screenshot check if it's UI → lessons if something surprising came up → /commit.
Effort scales with how well the cause is understood, judged from observable signals, never from "confidence":
| Tier | When | What |
|---|---|---|
| 1 Direct | cause and symptom both visible; a known value; 1–2 files | change it, re-check live |
| 2 Hypothesis | symptom clear, cause unproven | reproduce → write the hypothesis and what evidence would confirm it → gather that evidence → fix the proven cause |
| 3 Investigate | spans modules, intermittent, or a lower tier failed | an Explore subagent traces the causal chain with path:line evidence; the fix is presented as a plan first |
Hard rule: every failed fix moves up one tier. The same fix is never retried without new evidence. The fix is done only when the reproduction passes (test green or you confirm it live). It's logged in the feature's spec under ## Fixes and committed as fix:.
docs/specs/<slug>.md, links it from the roadmap line (= in progress) and records real decisions in docs/decisions.md. Then the safe point: Continue here, or Fresh start, which drops the exploration and keeps the spec: the mod clears the chat after that turn and runs /build <slug> in the fresh one (without the mod: /clear, then /build <slug> yourself). Or stop early: Stop here keeps just the spec, Build, then stop builds and leaves verify for later (Status: verifying, changes uncommitted until Finish). /build <slug> or the roadmap pane picks it up at that step. The kit mod shows your context % above the prompt to help you choose.AGENTS.md. If the spec has a Design: source, the UI is built with the /convert procedure. Full suite + typecheck/lint must be green.Status: done, roadmap line checked, the verifier's improvement notes (max 3) added under ## Later in the roadmap without asking (delete the ones you don't want; /build never picks up ## Later items by itself), lessons, then one commit with code + spec + roadmap. The report suggests /simplify for diffs over ~150 lines and /security-review for auth, stored user input or payments. When the feature closed the last open item of its phase, the report says so and suggests /launch (major for a vX heading, minor for vX.Y), then /product to sort ## Later. It never pushes.Finish asks no questions, so a feature run only stops for the plan approval, the safe point right after it, and manual checks that genuinely need you. That keeps it smooth in auto mode.
Stop anywhere (close the chat, run out of context, come back tomorrow). Before every Status change /build rewrites the spec's ## Handoff (max 5 lines: deviations, failed attempts, your corrections), so what was only said in the chat survives. In a new chat:
/build habit-crud (or just /build)
It reads Status, ## Handoff, the unchecked criteria and git status, says in one line where it picks up, and continues: defined/building → build, verifying → verify, manual → manual checks.
Safe staging and a clean message. Called by you, or by /build as commit feature=<slug>.
hooks/register.tsx 151 lines1import type {
2 EngineInterface,
3 Register,
4 SessionMessage,
5 SessionUsage,
6} from "claude-code";
7
8import { feedbackInbox, feedbackTool } from "./feedback-inbox";
9import { freshStart, freshTool } from "./fresh-start";
10import { roadmapOpen, roadmapTool } from "./roadmap-open";
11import {
12 roadmapEdited,
13 roadmapStale,
14 roadmapUsage,
15 roadmapView,
16} from "./roadmap-view";
17import type { Usage } from "./roadmap-parts";
18import { branchSlug, inRoot, parseSpec, type SpecState } from "./spec";
19import { themeTool, themeView } from "./theme-view";
20
21// kit's context mod. Reads the active spec (slug from the feat/ or fix/ branch); never writes.
22// - After a compaction: appends the spec's state, so the next turn re-reads it from disk.
23// - Context and plan limits as one line in the roadmap pane's dashboard (USAGE).
24// - The roadmap and theme panes: tools the skills call with data (roadmap-view, theme-view);
25// the roadmap pane also opens itself in a project with docs/roadmap.md (roadmap-open).
26// - fresh_start: /build's safe point clears the chat and resumes (fresh-start).
27// - feedback: the Skill Feedback inbox, and its status line in a kit checkout (feedback-inbox).
28
29type Spec = SpecState & { slug: string };
30type Figures = Pick<SessionUsage, "context" | "rateLimits">;
31type Meter = Usage;
32
33const SPEC_PATH = /docs\/specs\/[^/]+\.md$/;
34
35let meters: Meter[] = [];
36
37async function activeSpec($: EngineInterface): Promise<Spec | null> {
38 const git = await $.process
39 .run(["git", "symbolic-ref", "--short", "HEAD"], {
40 cwd: await $.session.root(),
41 })
42 .catch(() => null);
43 const slug = git?.exitCode === 0 ? branchSlug(git.stdout) : null;
44 const text = slug
45 ? await $.fs
46 .read(inRoot(await $.session.root(), `docs/specs/${slug}.md`))
47 .catch(() => null)
48 : null;
49 const state = parseSpec(text);
50 return slug && state ? { slug, ...state } : null;
51}
52
53function untilReset(iso: string | undefined, now: number): string {
54 const min = iso
55 ? Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
56 : 0;
57 const [d, h, m] = [
58 Math.floor(min / 1440),
59 Math.floor(min / 60) % 24,
60 min % 60,
61 ];
62 return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`;
63}
64
65async function refresh($: EngineInterface, figures?: Figures) {
66 const usage = figures ?? (await $.session.usage());
67 const pct = usage.context.percent;
68 const now = await $.clock.now();
69 meters = [
70 {
71 label: "ctx",
72 pct: pct ?? 0,
73 value: `${Math.round((usage.context.tokens ?? 0) / 1000)}k`,
74 },
75 ];
76 for (const [kind, label] of [
77 ["five_hour", "session"],
78 ["seven_day", "week"],
79 ] as const) {
80 const rl = usage.rateLimits.find((r) => r.kind === kind);
81 if (rl)
82 meters.push({
83 label,
84 value: `${Math.round(rl.percentUsed)}%`,
85 pct: rl.percentUsed,
86 note: untilReset(rl.resetsAt, now),
87 });
88 }
89 roadmapUsage(meters);
90 $.ui.invalidate("ui.render");
91}
92
93export const register: Register = (on) => {
94 roadmapView(on);
95 roadmapOpen(on);
96 themeView(on);
97 freshStart(on);
98 feedbackInbox(on);
99
100 on("session.start", async ($, e, next) => {
101 const started = await next(e);
102 await $.tool.register(roadmapTool);
103 await $.tool.register(themeTool);
104 await $.tool.register(freshTool);
105 await $.tool.register(feedbackTool);
106 await refresh($);
107 return started;
108 });
109
110 // A /clear fires no session.start or measure until the next turn: drop the old context
111 // figure now, or the dashboard keeps showing it in the fresh chat.
112 on("session.end", async ($, e, next) => {
113 if (e.reason === "clear") {
114 meters = meters.filter((m) => m.label !== "ctx");
115 roadmapUsage(meters);
116 $.ui.invalidate("ui.render");
117 }
118 return next(e);
119 });
120
121 on("session.measure", async ($, e, next) => {
122 roadmapStale(); // the pane reloads git and specs on its next draw
123 await refresh($, e);
124 return next(e);
125 });
126
127 on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
128 const ran = await next(e);
129 if (SPEC_PATH.test(e.file_path.replace(/\\/g, "/"))) await refresh($);
130 if (roadmapEdited(e.file_path)) $.ui.invalidate("ui.render");
131 return ran;
132 });
133
134 on("session.compact", async ($, e, next) => {
135 const compacted = await next(e);
136 if (e.trigger === "precompute" || e.agentId || !compacted.messages)
137 return compacted;
138 const now = await activeSpec($);
139 if (!now) return compacted;
140 const note: SessionMessage = {
141 role: "user",
142 toolUses: [],
143 text:
144 `kit: building ${now.slug} · Status: ${now.status} · criteria ${now.done}/${now.total}. ` +
145 `Re-read docs/specs/${now.slug}.md (## Handoff first) before continuing; ` +
146 `if the /build procedure is no longer in context, run /build ${now.slug}.`,
147 };
148 return { ...compacted, messages: [...compacted.messages, note] };
149 });
150};
151hooks/feedback-inbox.ts 124 lines1import type { EngineInterface, On, ToolSpec } from "claude-code";
2
3import * as fb from "./feedback";
4import { inRoot } from "./spec";
5
6// The feedback inbox: the tool the Skill Feedback rule calls after a kit skill run (any
7// project) and /improve reads in the kit repo, plus the status line there while points wait.
8
9const TOOL = "mcp__kit__feedback";
10
11export const feedbackTool: ToolSpec = {
12 name: "feedback",
13 description:
14 "kit's Skill Feedback inbox, shared by every project on this machine. add: record one " +
15 "friction point about a kit skill (skill = build, commit, …; text = one line). list: " +
16 "open points, oldest first (optional skill). done: close points by id after /improve " +
17 "acted on or declined them.",
18 inputSchema: {
19 type: "object",
20 properties: {
21 action: { type: "string", enum: ["add", "list", "done"] },
22 skill: { type: "string" },
23 text: { type: "string" },
24 ids: { type: "array", items: { type: "string" } },
25 },
26 required: ["action"],
27 },
28};
29
30async function readAll($: EngineInterface): Promise<Record<string, fb.Point>> {
31 const keys = (await $.store.keys()).filter((k) => k.startsWith(fb.PREFIX));
32 const points: Record<string, fb.Point> = {};
33 for (const key of keys) {
34 const p = (await $.store.get(key)) as fb.Point | undefined;
35 if (p && typeof p.skill === "string" && typeof p.text === "string")
36 points[key.slice(fb.PREFIX.length)] = p;
37 }
38 return points;
39}
40
41async function isKitCheckout($: EngineInterface): Promise<boolean> {
42 const root = await $.session.root();
43 const manifest = await $.fs
44 .read(inRoot(root, ".claude-plugin/plugin.json"))
45 .catch(() => null);
46 try {
47 return JSON.parse(String(manifest)).name === "kit";
48 } catch {
49 return false;
50 }
51}
52
53// The line under the prompt: open points in a kit checkout, cleared everywhere else.
54async function inboxStatus(
55 $: EngineInterface,
56 points?: Record<string, fb.Point>,
57) {
58 const text = (await isKitCheckout($))
59 ? fb.statusText(points ?? (await readAll($).catch(() => ({}))))
60 : undefined;
61 $.ui.status(text);
62}
63
64async function answer($: EngineInterface, req: fb.Request): Promise<string> {
65 const points = await readAll($);
66 let reply: string;
67 if (req.action === "add") {
68 const dup = fb.duplicate(points, req.skill, req.text);
69 if (dup) return `Already open as #${dup} · ${req.skill}`;
70 const at = await $.clock.now();
71 let id = fb.newId(at, Math.random());
72 while (id in points) id = fb.newId(at, Math.random());
73 const point: fb.Point = {
74 skill: req.skill,
75 text: req.text,
76 project: await $.session.root(),
77 at,
78 };
79 await $.store.set(fb.PREFIX + id, point);
80 points[id] = point;
81 const open = Object.values(points).filter((p) => p.skill === req.skill);
82 reply = `Recorded #${id} · ${req.skill}: ${open.length} open`;
83 } else if (req.action === "list") {
84 return fb.lines(points, req.skill);
85 } else {
86 const known = req.ids.filter((id) => id in points);
87 const unknown = req.ids.filter((id) => !(id in points));
88 for (const id of known) {
89 await $.store.delete(fb.PREFIX + id);
90 delete points[id];
91 }
92 reply =
93 `Closed ${known.length}` +
94 (unknown.length
95 ? ` · unknown: ${unknown.map((id) => `#${id}`).join(", ")}`
96 : "");
97 }
98 // The write is done: a failing status refresh must not report it as failed.
99 await inboxStatus($, points).catch(() => {});
100 return reply;
101}
102
103export function feedbackInbox(on: On) {
104 // Open points in a kit checkout; elsewhere this clears the line older versions drew.
105 // (classic.SessionStart: register.tsx owns session.start; this one also fires after /clear.)
106 on("classic.SessionStart", async ($, e, next) => {
107 const started = await next(e);
108 await inboxStatus($).catch(() => $.ui.status(undefined));
109 return started;
110 });
111
112 on("tool.check", { tool: TOOL }, () => ({ decision: "allow" }));
113
114 on("tool.call", { tool: TOOL }, async ($, e) => {
115 const req = fb.validate(e as unknown as Record<string, unknown>);
116 if (typeof req === "string") return { result: req };
117 try {
118 return { result: await answer($, req) };
119 } catch (err) {
120 return { result: `kit: inbox unavailable (${String(err)})` };
121 }
122 });
123}
124hooks/fresh-start.ts 66 lines1import type { On, ToolSpec } from "claude-code";
2
3// /build's safe point, automated: the model calls fresh_start after the user picked
4// "Fresh start"; when that turn ends, the chat clears and /kit:build <slug> resumes from the
5// spec in the fresh one. Same chain as the roadmap pane's Pick up, but run from turn.complete.
6
7const TOOL = "mcp__kit__fresh_start";
8const SLUG = /^[a-z0-9][a-z0-9-]*$/;
9
10let armed: string | null = null;
11
12export const freshTool: ToolSpec = {
13 name: "fresh_start",
14 description:
15 "kit /build safe point: after this turn ends, clear the chat and run /kit:build <slug> " +
16 "in the fresh one. Call it only after the user chose Fresh start, then end the turn.",
17 inputSchema: {
18 type: "object",
19 properties: { slug: { type: "string" } },
20 required: ["slug"],
21 },
22};
23
24export function freshStart(on: On) {
25 on("tool.check", { tool: TOOL }, () => ({ decision: "allow" }));
26
27 on("tool.call", { tool: TOOL }, async (_$, e) => {
28 const slug = String((e as unknown as { slug?: unknown }).slug ?? "");
29 if (!SLUG.test(slug))
30 return { result: `Not armed: invalid slug "${slug}".` };
31 armed = slug;
32 return {
33 result: `Armed: when this turn ends, the chat clears and /kit:build ${slug} starts. End the turn now.`,
34 };
35 });
36
37 on("turn.complete", async ($, e, next) => {
38 const done = await next(e);
39 if (!armed || e.agentId) return done;
40 const slug = armed;
41 armed = null;
42 if (e.reason !== "answer") {
43 $.ui.toast(`kit: fresh start for ${slug} cancelled`);
44 return done;
45 }
46 // Outside the hook: $.command.run rejects inside a hook the turn waits on.
47 $.clock.after(300, async () => {
48 try {
49 await $.command.run({ command: "clear" });
50 } catch (err) {
51 // Building on in this same chat would defeat the point: leave it to the user.
52 $.ui.toast(
53 `kit: could not clear (${String(err)}). Run /clear, then /build ${slug}`,
54 );
55 return;
56 }
57 await $.command
58 .run({ command: "kit:build", args: slug })
59 .catch((err) =>
60 $.ui.toast(`kit: could not start /build ${slug}: ${String(err)}`),
61 );
62 });
63 return done;
64 });
65}
66hooks/roadmap-open.ts 121 lines1import type { EngineInterface, On, ToolSpec } from "claude-code";
2
3import type { Card } from "./roadmap-parts";
4import { PANE, roadmapOpened } from "./roadmap-view";
5import { inRoot } from "./spec";
6import { list } from "./theme-view";
7
8// Opens the roadmap pane (roadmap-view.tsx draws it): the tool /product calls, with items for
9// a read-only draft (plan mode) or none for docs/roadmap.md live. The live pane also opens on
10// its own in a project with docs/roadmap.md: at session start, and at the first prompt when the
11// start could not seat it (unasked, a terminal needs 144 columns; a prompt counts as asked).
12
13export const roadmapTool: ToolSpec = {
14 name: "roadmap_view",
15 description:
16 "Open kit's roadmap pane. No items → it shows docs/roadmap.md live and editable. With " +
17 "items → a read-only draft (plan mode): items in order, ## Later excluded; state open, " +
18 "progress (spec linked, unchecked) or done; phase = the item's heading without '## '.",
19 inputSchema: {
20 type: "object",
21 properties: {
22 product: { type: "string" },
23 items: list({
24 slug: "string",
25 description: "string",
26 state: "string",
27 phase: "string",
28 }),
29 later: { type: "array", items: { type: "string" } },
30 },
31 },
32};
33
34// docs/product.md's `# <name>`, or "Roadmap" without one.
35async function productName($: EngineInterface): Promise<string> {
36 const root = await $.session.root();
37 const text = await $.fs.read(inRoot(root, "docs/product.md")).catch(() => "");
38 return /^# (.+)$/m.exec(String(text))?.[1]?.trim() || "Roadmap";
39}
40
41// Opens the pane, a draft or the live roadmap; says whether a surface drew it.
42async function show(
43 $: EngineInterface,
44 draft: { product: string; cards: Card[]; later: string[] } | null,
45): Promise<{ isPlaced: boolean; reason?: string }> {
46 const name = draft ? draft.product : await productName($);
47 roadmapOpened(draft, name);
48 // The pane is titled with the project's name (an open pane is retitled).
49 const opened = await $.ui.open({
50 id: PANE,
51 title: draft ? `${name} · draft` : name,
52 });
53 $.ui.invalidate("ui.render");
54 return opened;
55}
56
57// True while the pane opened at session start waits undrawn (terminal too narrow).
58let waiting = false;
59
60export function roadmapOpen(on: On) {
61 // Show the live roadmap without a /product call, in a project with docs/roadmap.md only (a
62 // user plugin runs everywhere; a pane elsewhere is noise). Not after /clear or a compaction,
63 // so a pane the user closed by hand stays closed. (feedback-inbox.ts owns the matcher-less
64 // classic.SessionStart; register.tsx owns session.start.)
65 on(
66 "classic.SessionStart",
67 { source: /^(startup|resume)$/ },
68 async ($, e, next) => {
69 const started = await next(e);
70 waiting = false;
71 if (e.agent_type) return started;
72 const root = await $.session.root();
73 const has = await $.fs
74 .exists(inRoot(root, "docs/roadmap.md"))
75 .catch(() => false);
76 if (has)
77 waiting = !(await show($, null).catch(() => ({ isPlaced: true })))
78 .isPlaced;
79 return started;
80 },
81 );
82
83 // The first prompt seats a pane that waited undrawn at session start (asked, so any width).
84 on("prompt.submit", async ($, e, next) => {
85 if (waiting) {
86 waiting = false;
87 await show($, null).catch(() => {});
88 }
89 return next(e);
90 });
91
92 on("tool.check", { tool: "mcp__kit__roadmap_view" }, () => ({
93 decision: "allow",
94 }));
95
96 on("tool.call", { tool: "mcp__kit__roadmap_view" }, async ($, e) => {
97 const input = e as unknown as {
98 product?: string;
99 items?: Card[];
100 later?: string[];
101 };
102 const draft = Array.isArray(input.items)
103 ? {
104 product: input.product ?? "Roadmap",
105 cards: input.items.map((i) => ({
106 ...i,
107 phase: i.phase ?? "",
108 editable: false,
109 })),
110 later: input.later ?? [],
111 }
112 : null;
113 const opened = await show($, draft);
114 return {
115 result: opened.isPlaced
116 ? "Roadmap pane shown."
117 : `Roadmap pane not shown (${opened.reason}).`,
118 };
119 });
120}
121hooks/roadmap-view.tsx 306 lines1import type { ElementTable, EngineInterface, On } from "claude-code";
2
3import * as file from "./roadmap-file";
4import { body } from "./roadmap-dashboard";
5import { pressOnFocus, tracked } from "./roadmap-press";
6import type { Card, Git, Usage } from "./roadmap-parts";
7import * as wt from "./roadmap-worktree";
8import { inRoot, parseSpec, type SpecState } from "./spec";
9
10// The roadmap pane, opened by /product and, in a project with docs/roadmap.md, at session start
11// (roadmap-open.ts): a project dashboard. Live: reads docs/roadmap.md, the
12// open features' specs and git state, and edits open items in place (roadmap-file.ts keeps
13// every other line as is); its buttons run kit's skills in the chat. Draft: /product in plan
14// mode passes items as data, read-only. Drawing: roadmap-dashboard.tsx + roadmap-parts.tsx; done and in-progress
15// state stays /build's.
16
17export const PANE = "kit-roadmap";
18const PATH = "docs/roadmap.md";
19
20export type Draft = { product: string; cards: Card[]; later: string[] };
21let draft: Draft | null = null;
22let product = "Roadmap"; // docs/product.md's name, read when the pane opens (roadmap-open.ts)
23let live: file.Roadmap | null = null;
24let specs: Record<string, SpecState | null> = {};
25let away: Record<string, string> = {}; // open features running elsewhere (roadmap-worktree.ts)
26let git: Git | null = null;
27let isSetUp = true;
28let hasDiff = false;
29let gitError = ""; // why git state is missing, shown dim in the dashboard
30let usage: Usage[] = []; // context and plan limits, from register.tsx
31let needsLoad = true;
32let note = "";
33let added = 0;
34let adding: string | null = null; // the phase whose "+ Add" field is open
35let menuOpen: string | null = null; // the card or Later idea whose ⋯ row is open
36let confirming: string | null = null; // an armed Clear or Remove, waiting for its 2nd press
37let sent = 0; // when a button last ran a command: presses right after it are a double click
38// Phases the user folded or unfolded against the default (a finished phase starts folded).
39const toggled = new Set<string>();
40
41// Called when the pane opens (/product or session start): a draft (plan mode), or null for the live roadmap.
42export function roadmapOpened(next: Draft | null, name: string): void {
43 [draft, product, needsLoad, note] = [next, name, true, ""];
44 [specs, away] = [{}, {}]; // a draft's cards must not show the live pane's specs
45}
46
47// Called on every Write/Edit: a change to docs/roadmap.md or a spec reloads the live pane.
48export function roadmapEdited(path: string): boolean {
49 if (!/docs\/(roadmap\.md|specs\/[^/]+\.md)$/.test(path.replace(/\\/g, "/")))
50 return false;
51 return (needsLoad = true);
52}
53
54// Called after every turn (session.measure): it may have committed, built or launched.
55export function roadmapStale(): void {
56 needsLoad = true;
57}
58
59// Context and plan limits, as register.tsx measures them after each turn.
60export function roadmapUsage(figures: Usage[]): void {
61 usage = figures;
62}
63
64function cards(r: file.Roadmap): Card[] {
65 return r.items.map((i) => ({
66 slug: i.slug,
67 description: i.description,
68 state: i.done ? "done" : i.spec ? "progress" : "open",
69 phase: i.phase,
70 editable: file.isEditable(i),
71 }));
72}
73
74// Read-only git facts for the dashboard: changed files, branch, commits not on the remote's
75// default branch. Null outside a repo; `unpushed` null without a remote.
76async function readGit($: EngineInterface): Promise<Git | null> {
77 const root = await $.session.root();
78 let cwd: string | undefined = root;
79 const out = async (args: string[]) => {
80 const r = await $.process
81 .run(["git", ...args], cwd ? { cwd } : undefined)
82 .catch((err) => ({ exitCode: -1, stdout: "", stderr: String(err) }));
83 if (r.exitCode !== 0) gitError = r.stderr.trim().split("\n")[0] ?? "";
84 return r.exitCode === 0 ? r.stdout.trim() : null;
85 };
86 gitError = "";
87 let status = await out(["status", "--porcelain"]);
88 if (status === null) {
89 cwd = undefined; // the shell's cwd: still inside the repo after a `cd` within it
90 status = await out(["status", "--porcelain"]);
91 }
92 if (status === null) return null;
93 gitError = "";
94 const ahead = await out(["rev-list", "--count", "origin/HEAD..HEAD"]);
95 gitError = ""; // no origin/HEAD is normal: no Launch row then
96 return {
97 changed: status ? status.split("\n").length : 0,
98 branch: (await out(["symbolic-ref", "--short", "HEAD"])) ?? "detached",
99 unpushed: ahead === null ? null : Number(ahead),
100 };
101}
102
103// Open features sent to a worktree (a $.store mark per project) or claimed on another branch
104// (a spec commit there), plus the live spec of a claim checked out in a worktree here. Ended
105// marks are dropped. Best effort: a failing call shows nothing.
106async function readAway($: EngineInterface, root: string, open: string[]) {
107 const git = (args: string[]) =>
108 $.process.run(["git", ...args], { cwd: root }).catch(() => null);
109 const r = await git(wt.CLAIMS_GIT);
110 const t = await git(wt.WORKTREES_GIT);
111 const all = ((await $.store.get(wt.STORE_KEY).catch(() => null)) ??
112 {}) as wt.Marks;
113 const mine = all[root] ?? {};
114 const claimed = r?.exitCode === 0 ? wt.claims(r.stdout) : {};
115 const trees = t?.exitCode === 0 ? wt.worktrees(t.stdout) : {};
116 const now = await $.clock.now();
117 const { lines, specs: at, keep } = wt.away(open, claimed, mine, now, trees);
118 if (Object.keys(keep).length !== Object.keys(mine).length) {
119 const rest = { ...all, [root]: keep };
120 if (!Object.keys(keep).length) delete rest[root];
121 await $.store.set(wt.STORE_KEY, rest).catch(() => {});
122 }
123 const read: Record<string, SpecState | null> = {};
124 for (const [slug, path] of Object.entries(at))
125 read[slug] = parseSpec(await $.fs.read(path).catch(() => null));
126 return { lines, specs: read };
127}
128
129export function roadmapView(on: On) {
130 pressOnFocus(on);
131 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
132 const el = $.ui.resolve(e);
133 if (needsLoad && !draft) {
134 needsLoad = false;
135 const root = await $.session.root();
136 const text = await $.fs.read(inRoot(root, PATH)).catch(() => null);
137 live = typeof text === "string" ? file.parse(text) : null;
138 specs = {};
139 for (const i of live?.items ?? [])
140 if (i.spec && !i.done)
141 specs[i.slug] = parseSpec(
142 await $.fs.read(inRoot(root, i.spec)).catch(() => null),
143 );
144 git = await readGit($);
145 const open = (live?.items ?? []).filter((i) => !i.done && !i.spec);
146 const elsewhere = await readAway(
147 $,
148 root,
149 open.map((i) => i.slug),
150 );
151 away = elsewhere.lines;
152 Object.assign(specs, elsewhere.specs); // their /build step, from the worktree
153 isSetUp = await $.fs.exists(inRoot(root, "AGENTS.md")).catch(() => true);
154 hasDiff = (await $.command.list().catch(() => [])).some(
155 (c) => c.name === "diff",
156 );
157 }
158 const view =
159 draft ?? (live && { product, cards: cards(live), later: live.later });
160 if (!view)
161 return (
162 <el.Text dimColor>
163 No docs/roadmap.md here: run /product to make one.
164 </el.Text>
165 );
166
167 const redraw = (message?: string) => {
168 if (message !== undefined) note = message;
169 $.ui.invalidate("ui.render");
170 };
171 // Runs a slash command at once (a feature's button clears the chat first, as fresh-start.ts
172 // does), outside the press: inside it, $.command.run waits on the turn. A press within 2s
173 // of the last is a double click and dropped; not "until the run settles": /compact's run
174 // may not settle, which held every button. Failing, the line goes in the prompt box.
175 const run = async (command: string, args: string, fresh: boolean) => {
176 const line = `/${command}${args ? ` ${args}` : ""}`;
177 const now = await $.clock.now();
178 if (now - sent < 2000) return;
179 sent = now;
180 $.clock.after(50, async () => {
181 let cleared = !fresh;
182 try {
183 if (fresh) await $.command.run({ command: "clear" });
184 cleared = true;
185 await $.command.run(args ? { command, args } : { command });
186 } catch (err) {
187 // Not cleared: building on in this chat would defeat the point.
188 if (cleared) await $.prompt.fill({ text: line });
189 const todo = `${cleared ? "" : "/clear, then "}${line}`;
190 $.ui.toast(`kit: run ${todo} yourself (${String(err)})`);
191 }
192 });
193 };
194 // Raises the desktop's worktree chip (ccd_session's spawn_task) for /kit:build <slug>,
195 // marked first so the card asks "Again?" at once. Failing: the old mark comes back.
196 const toWorktree = async (slug: string) => {
197 const now = await $.clock.now();
198 if (now - sent < 2000) return;
199 sent = now;
200 const root = await $.session.root();
201 const all = ((await $.store.get(wt.STORE_KEY).catch(() => null)) ??
202 {}) as wt.Marks;
203 const mine = { ...all[root] };
204 const before = mine[slug];
205 const mark = { ...all, [root]: { ...mine, [slug]: now } };
206 await $.store.set(wt.STORE_KEY, mark).catch(() => {});
207 away = { ...away, [slug]: away[slug] ?? "sent to worktree" };
208 redraw();
209 const why = await $.mcp
210 .call("ccd_session", "spawn_task", wt.chip(slug))
211 .then(wt.failure, (err) => String(err));
212 if (why !== null) {
213 if (before === undefined) delete mine[slug];
214 const back = { ...all, [root]: mine };
215 await $.store.set(wt.STORE_KEY, back).catch(() => {});
216 $.ui.toast(`kit: worktree chip failed (${why})`);
217 }
218 needsLoad = true;
219 redraw();
220 };
221 const edit = async (done: string, change: (text: string) => string) => {
222 const path = inRoot(await $.session.root(), PATH);
223 const text = String(await $.fs.read(path).catch(() => ""));
224 try {
225 const next = change(text);
226 if (next !== text) await $.fs.write(path, next);
227 note = next === text ? "nothing to change" : done;
228 } catch (err) {
229 note = err instanceof Error ? err.message : String(err);
230 }
231 needsLoad = true;
232 menuOpen = null;
233 redraw();
234 };
235
236 return body({
237 el: tracked(el, e.surface),
238 // Input and Select: terminal and desktop only.
239 ui:
240 e.surface === "terminal" || e.surface === "desktop"
241 ? (el as ElementTable<"terminal" | "desktop">)
242 : null,
243 ...view,
244 draft: !!draft,
245 specs,
246 away,
247 canWorktree: e.surface === "desktop",
248 git,
249 isSetUp,
250 hasDiff,
251 gitError,
252 usage,
253 note,
254 adding,
255 added,
256 menuOpen,
257 confirming,
258 toggled,
259 act: {
260 build: (slug) => void run("kit:build", slug, true),
261 worktree: (slug) => void toWorktree(slug),
262 run: (command, args = "") => void run(command, args, false),
263 edit: (done, change) => void edit(done, change),
264 fold: (phase) => {
265 if (!toggled.delete(phase)) toggled.add(phase);
266 redraw();
267 },
268 toggleAdd: (phase, folded) => {
269 adding = adding === phase ? null : phase;
270 // Unfold the phase so the new card shows up where it lands.
271 if (adding && folded && !toggled.delete(phase)) toggled.add(phase);
272 redraw();
273 },
274 confirm: (key, run) => {
275 if (confirming === key) {
276 confirming = null;
277 run();
278 return;
279 }
280 confirming = key;
281 redraw();
282 $.clock.after(4000, () => {
283 if (confirming !== key) return;
284 confirming = null;
285 redraw();
286 });
287 },
288 menu: (key) => {
289 menuOpen = menuOpen === key ? null : key;
290 redraw();
291 },
292 addLater: (idea) => {
293 added += 1;
294 adding = null;
295 void edit("added to Later", (t) => file.addLater(t, idea));
296 },
297 add: (phase, title) => {
298 added += 1; // a new key draws a fresh, empty field
299 adding = null;
300 void edit(`added to ${phase}`, (t) => file.add(t, phase, title));
301 },
302 },
303 });
304 });
305}
306hooks/roadmap-parts.tsx 82 lines1import type { ElementTable } from "claude-code";
2
3import type { SpecState } from "./spec";
4
5// The roadmap pane's shared shapes: the data and actions every drawing file gets (`Parts`),
6// colors and small helpers. The drawing is pure: no `$` (it never crosses an import), so
7// every press goes through `Actions`, which roadmap-view.tsx builds. Files: dashboard (top),
8// phase (a phase and its cards), card (one card and its ⋯ row), later (## Later).
9
10export type State = "open" | "progress" | "done";
11export type Card = {
12 slug: string;
13 description: string;
14 state: State;
15 phase: string;
16 editable: boolean;
17};
18// One usage figure: "ctx 266k", "session 70% · 1h18m"; pct colors it from 60% and 85%.
19export type Usage = {
20 label: string;
21 value: string;
22 note?: string;
23 pct: number;
24};
25export type Git = { changed: number; branch: string; unpushed: number | null };
26export type Actions = {
27 build: (slug: string) => void; // runs /clear, then /kit:build <slug> in the fresh chat
28 worktree: (slug: string) => void; // raises the desktop's chip: /kit:build <slug> in a worktree
29 run: (command: string, args?: string) => void; // runs the slash command at once
30 edit: (done: string, change: (text: string) => string) => void;
31 fold: (phase: string) => void;
32 toggleAdd: (phase: string, folded: boolean) => void;
33 add: (phase: string, title: string) => void;
34 addLater: (idea: string) => void;
35 menu: (key: string) => void; // open or close one ⋯ row (a slug, or later-<index>)
36 // A press that can't be undone from the pane: the first arms it (its label asks), a second
37 // press within a few seconds runs it.
38 confirm: (key: string, run: () => void) => void;
39};
40export type Parts = {
41 el: ElementTable;
42 ui: ElementTable<"terminal" | "desktop"> | null; // Input lives only there
43 menuOpen: string | null;
44 confirming: string | null;
45 product: string;
46 cards: Card[];
47 later: string[];
48 draft: boolean;
49 specs: Record<string, SpecState | null>;
50 away: Record<string, string>; // open features running elsewhere: "runs on <branch>", …
51 canWorktree: boolean; // the desktop app, which has the worktree chip (ccd_session)
52 git: Git | null;
53 isSetUp: boolean;
54 hasDiff: boolean;
55 gitError: string; // /diff exists here (the CLI's built-in diff mod)
56 usage: Usage[];
57 note: string;
58 adding: string | null;
59 added: number;
60 toggled: Set<string>;
61 act: Actions;
62};
63
64export const ICON: Record<State, string> = {
65 done: "✓",
66 progress: "●",
67 open: "○",
68};
69export const COLOR: Record<State, string> = {
70 done: "#22c55e",
71 progress: "#f59e0b",
72 open: "#94a3b8",
73};
74export const BAND = "#1e293b"; // section headings
75export const LATER = "\u0000later"; // `adding` key for Later: never a phase name
76
77export const doneOf = (cs: Card[]) =>
78 cs.filter((c) => c.state === "done").length;
79export const phaseNames = (p: Parts) => [
80 ...new Set(p.cards.map((c) => c.phase)),
81];
82hooks/spec.ts 27 lines1// Parses one spec's state; the only kit file the mod reads (see AGENTS.md § Budgets).
2// Pure functions: the engine only follows `$` within one file, so callers do the reading.
3
4export type SpecState = { status: string; done: number; total: number };
5
6export function parseSpec(text: unknown): SpecState | null {
7 if (typeof text !== "string") return null;
8 const criteria =
9 text.split(/^## /m).find((s) => s.startsWith("Acceptance criteria")) ?? "";
10 const done = criteria.match(/^- \[x\]/gim)?.length ?? 0;
11 const open = criteria.match(/^- \[ \]/gm)?.length ?? 0;
12 const status = /^Status:\s*(\S+)/m.exec(text)?.[1] ?? "?";
13 return { status, done, total: done + open };
14}
15
16// `git symbolic-ref --short HEAD` output → the feat/ or fix/ slug, or null.
17export function branchSlug(head: string | undefined): string | null {
18 return /^(?:feat|fix)\/(.+)$/.exec(head?.trim() ?? "")?.[1] ?? null;
19}
20
21// A project path against the session's root ($.session.root()), not the shell's cwd: a `cd`
22// during the session must not hide docs/ or AGENTS.md. Absolute paths pass through.
23export function inRoot(root: string, path: string): string {
24 if (/^(\/|[a-zA-Z]:[\\/])/.test(path)) return path;
25 return `${root.replace(/[\\/]+$/, "")}/${path}`;
26}
27hooks/theme-view.tsx 354 lines1import type { EngineInterface, On, ToolSpec } from "claude-code";
2
3// The theme pane. /theme passes its proposal as data (it is shown in plan mode, before
4// anything is written). The mod computes WCAG contrast and returns it to the model. Desktop
5// draws an Svg with the real fonts (a glyph subset fetched and embedded: the Svg sandbox
6// loads nothing itself); the terminal gets swatches and text.
7
8type Colors = Record<string, string>;
9type Font = {
10 role: string;
11 family: string;
12 google?: boolean;
13 weights?: number[];
14};
15type Named = { name: string; px: number };
16type Theme = {
17 colors: { light: Colors; dark?: Colors };
18 fonts?: Font[];
19 typeScale?: { step: string; size: number; lineHeight: number }[];
20 spacing?: Named[];
21 radius?: Named[];
22 shadow?: { name: string; value: string }[];
23};
24type Pair = { fg: string; bg: string; ratio: number | null };
25
26const PANE = "kit-theme";
27const SAMPLE = "The quick brown fox jumps";
28const BODY = "Body text in the muted role, on the surface.";
29const W = 600;
30const GREY = "#64748b";
31const MONO = "ui-monospace, monospace";
32
33let shown: Theme | null = null;
34let svg = "";
35const fontCss = new Map<string, string>();
36
37// JSON schema for an array of flat objects: list({ name: "string", px: "number" }).
38export const list = (fields: Record<string, string>) => ({
39 type: "array",
40 items: {
41 type: "object",
42 properties: Object.fromEntries(
43 Object.entries(fields).map(([k, t]) => [
44 k,
45 t.endsWith("[]")
46 ? { type: "array", items: { type: t.slice(0, -2) } }
47 : { type: t },
48 ]),
49 ),
50 },
51});
52
53export const themeTool: ToolSpec = {
54 name: "theme_view",
55 description:
56 "Show a proposed theme in kit's theme pane and get WCAG contrast for every text pair. " +
57 "Colors are semantic roles → hex (#rrggbb): bg, surface, fg, muted, border, primary, " +
58 "primary-fg, …; sizes in px. Display only.",
59 inputSchema: {
60 type: "object",
61 required: ["colors"],
62 properties: {
63 colors: {
64 type: "object",
65 properties: { light: { type: "object" }, dark: { type: "object" } },
66 },
67 fonts: list({
68 role: "string",
69 family: "string",
70 google: "boolean",
71 weights: "number[]",
72 }),
73 typeScale: list({ step: "string", size: "number", lineHeight: "number" }),
74 spacing: list({ name: "string", px: "number" }),
75 radius: list({ name: "string", px: "number" }),
76 shadow: list({ name: "string", value: "string" }),
77 },
78 },
79};
80
81// Google Fonts CSS with its files inlined as data URIs (Node 18+ fetch; $.http.fetch is text-only).
82const INLINE_FONTS = `(async () => {
83 let css = await (await fetch(process.argv[1])).text();
84 for (const url of [...new Set(css.match(/https:[^)]+/g) || [])]) {
85 const b = Buffer.from(await (await fetch(url)).arrayBuffer()).toString("base64");
86 css = css.split(url).join("data:font/ttf;base64," + b);
87 }
88 process.stdout.write(css);
89})().catch((e) => { console.error(String(e)); process.exit(1); })`;
90
91function luminance(hex: string): number | null {
92 const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})([0-9a-f]{2})?$/i.exec(hex.trim());
93 if (!m?.[1]) return null;
94 const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
95 const [r = 0, g = 0, b = 0] = [0, 2, 4].map((i) => {
96 const c = parseInt(h.slice(i, i + 2), 16) / 255;
97 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
98 });
99 return 0.2126 * r + 0.7152 * g + 0.0722 * b;
100}
101
102export function contrast(a: string, b: string): number | null {
103 const [la, lb] = [luminance(a), luminance(b)];
104 if (la === null || lb === null) return null;
105 return (
106 Math.floor(((Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)) * 100) /
107 100
108 );
109}
110
111// Text pairs that need 4.5:1: fg and muted on bg and surface, and every `<x>-fg` on `<x>`.
112export function pairs(c: Colors): Pair[] {
113 const list: [string, string][] = [];
114 for (const fg of ["fg", "muted"])
115 for (const bg of ["bg", "surface"]) list.push([fg, bg]);
116 for (const role of Object.keys(c))
117 if (role.endsWith("-fg")) list.push([role, role.slice(0, -3)]);
118 return list
119 .filter(([fg, bg]) => c[fg] && c[bg])
120 .map(([fg, bg]) => ({ fg, bg, ratio: contrast(c[fg] ?? "", c[bg] ?? "") }));
121}
122
123const mark = (p: Pair) => (p.ratio === null ? "?" : p.ratio >= 4.5 ? "✓" : "✗");
124const esc = (s: string | number) =>
125 String(s).replace(/&/g, "&").replace(/</g, "<");
126
127async function embedFonts($: EngineInterface, fonts: Font[]): Promise<string> {
128 const glyphs = encodeURIComponent(`${SAMPLE}${BODY}Primary0123456789/`);
129 const css: string[] = [];
130 for (const font of fonts.filter((f) => f.google !== false).slice(0, 3)) {
131 const weights = [...new Set(font.weights ?? [400])]
132 .sort((a, b) => a - b)
133 .join(";");
134 const family = font.family.replace(/ /g, "+");
135 const url = `https://fonts.googleapis.com/css2?family=${family}:wght@${weights}&text=${glyphs}`;
136 if (!fontCss.has(url)) {
137 const ran = await $.process
138 .run(["node", "-e", INLINE_FONTS, url])
139 .catch(() => null);
140 fontCss.set(url, ran?.exitCode === 0 ? ran.stdout : "");
141 }
142 css.push(fontCss.get(url) ?? "");
143 }
144 const joined = css.join("\n");
145 return joined.length < 90_000 ? joined : ""; // the Svg source caps at 131 072 characters
146}
147
148// box-shadow "0 4px 12px 0 rgba(…)" → drop-shadow "0 4px 12px rgba(…)" (it has no spread).
149function dropShadow(value: string): string {
150 const first = value.split(/,(?![^(]*\))/)[0] ?? "";
151 const color =
152 /(rgba?|hsla?)\([^)]*\)|#[0-9a-f]{3,8}/i.exec(first)?.[0] ??
153 "rgba(0,0,0,.2)";
154 return `${first.replace(color, "").trim().split(/\s+/).slice(0, 3).join(" ")} ${color}`;
155}
156
157function drawSvg(t: Theme, fontFaces: string): string {
158 const fam = (role: RegExp) => {
159 const f = t.fonts?.find((x) => role.test(x.role)) ?? t.fonts?.[0];
160 return f
161 ? `'${esc(f.family)}', system-ui, sans-serif`
162 : "system-ui, sans-serif";
163 };
164 const [head, body] = [fam(/display|head/i), fam(/sans|body|text/i)];
165 const out: string[] = [];
166 const text = (
167 x: number,
168 y: number,
169 size: number,
170 fill: string,
171 s: string,
172 font = body,
173 extra = "",
174 ) =>
175 out.push(
176 `<text x="${x}" y="${y}" font-family="${font}" font-size="${size}" fill="${esc(fill)}" ${extra}>${esc(s)}</text>`,
177 );
178 const rect = (x: number, y: number, w: number, h: number, attrs: string) =>
179 out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" ${attrs}/>`);
180 let y = 0;
181 const title = (s: string) => (
182 text(0, (y += 28), 13, GREY, s, body, 'font-weight="700"'),
183 (y += 10)
184 );
185
186 for (const [mode, c] of [
187 ["Light", t.colors.light],
188 ["Dark", t.colors.dark],
189 ] as const) {
190 if (!c) continue;
191 title(`${mode} colors`);
192 Object.entries(c).forEach(([role, hex], i) => {
193 const [x, top] = [(i % 8) * 74, y + Math.floor(i / 8) * 74];
194 rect(x, top, 66, 40, `rx="8" fill="${esc(hex)}" stroke="#94a3b844"`);
195 text(x, top + 54, 11, GREY, role);
196 text(x, top + 66, 10, "#94a3b8", hex, MONO);
197 });
198 y += Math.ceil(Object.keys(c).length / 8) * 74 + 4;
199 text(
200 0,
201 y,
202 11,
203 GREY,
204 pairs(c)
205 .map((p) => `${p.fg}/${p.bg} ${p.ratio ?? "?"} ${mark(p)}`)
206 .join(" "),
207 );
208 const r = t.radius?.find((x) => /md|base/.test(x.name))?.px ?? 10;
209 const card = y + 14;
210 rect(
211 0,
212 card,
213 W,
214 110,
215 `rx="${r}" fill="${esc(c.surface ?? c.bg ?? "#fff")}" stroke="${esc(c.border ?? "none")}"`,
216 );
217 text(20, card + 36, 22, c.fg ?? "#000", SAMPLE, head, 'font-weight="700"');
218 text(20, card + 60, 14, c.muted ?? c.fg ?? "#555", BODY);
219 rect(
220 20,
221 card + 72,
222 104,
223 28,
224 `rx="${Math.min(r, 14)}" fill="${esc(c.primary ?? "#333")}"`,
225 );
226 text(
227 72,
228 card + 91,
229 13,
230 c["primary-fg"] ?? "#fff",
231 "Primary",
232 body,
233 'text-anchor="middle" font-weight="600"',
234 );
235 y = card + 110;
236 }
237 if (t.typeScale?.length) title("Type scale");
238 for (const s of [...(t.typeScale ?? [])].sort((a, b) => b.size - a.size)) {
239 const size = Math.min(s.size, 44);
240 y += Math.max(s.lineHeight, s.size) * (size / s.size);
241 text(0, y, 10, "#94a3b8", `${s.step} ${s.size}/${s.lineHeight}`, MONO);
242 text(72, y, size, "#334155", SAMPLE, s.size >= 24 ? head : body);
243 }
244 if (t.spacing?.length) title("Spacing");
245 for (const s of t.spacing ?? []) {
246 rect(72, y, Math.min(s.px, W - 80), 12, 'rx="2" fill="#6366f1"');
247 text(0, (y += 18) - 8, 10, "#94a3b8", `${s.name} ${s.px}`, MONO);
248 }
249 const boxes = [
250 ...(t.radius ?? []).map((r) => [
251 `${r.name} ${r.px}`,
252 `rx="${Math.min(r.px, 24)}" fill="#e2e8f0"`,
253 ]),
254 ...(t.shadow ?? []).map((s) => [
255 s.name,
256 `rx="8" fill="#fff" style="filter: drop-shadow(${esc(dropShadow(s.value))})"`,
257 ]),
258 ];
259 if (boxes.length) title("Radius · shadow");
260 boxes.forEach(([label = "", attrs = ""], i) => {
261 const [x, top] = [(i % 6) * 98 + 4, y + 8 + Math.floor(i / 6) * 84];
262 rect(x, top, 80, 52, attrs);
263 text(x, top + 70, 11, GREY, label);
264 });
265 y += Math.ceil(boxes.length / 6) * 84 + 16;
266 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${y}" viewBox="-4 0 ${W + 8} ${y}"><style>${fontFaces}</style>${out.join("")}</svg>`;
267}
268
269export function themeView(on: On) {
270 on("tool.check", { tool: "mcp__kit__theme_view" }, () => ({
271 decision: "allow",
272 }));
273
274 on("tool.call", { tool: "mcp__kit__theme_view" }, async ($, e) => {
275 const t = e as unknown as Theme;
276 if (!t.colors?.light) return { result: "error: colors.light is required" };
277 shown = t;
278 const isDesktop = (await $.session.surface()) === "desktop";
279 svg = isDesktop ? drawSvg(t, await embedFonts($, t.fonts ?? [])) : "";
280 const opened = await $.ui.open({ id: PANE, title: "Theme" });
281 $.ui.invalidate("ui.render");
282 const lines = (["light", "dark"] as const).flatMap((mode) =>
283 pairs(t.colors[mode] ?? {}).map(
284 (p) => `${mode} ${p.fg}/${p.bg}: ${p.ratio ?? "not hex"} ${mark(p)}`,
285 ),
286 );
287 const pane = opened.isPlaced ? "shown" : `not shown (${opened.reason})`;
288 return {
289 result: `Theme pane ${pane}. Contrast (min 4.5):\n${lines.join("\n") || "no text pairs"}`,
290 };
291 });
292
293 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
294 const { Box, Text } = $.ui.resolve(e);
295 if (!shown)
296 return <Text dimColor>Run /theme to preview a theme here.</Text>;
297 if (e.surface === "desktop" && svg) {
298 const { Svg } = $.ui.resolve(e);
299 return (
300 <Svg
301 source={svg}
302 alt="Theme preview: colors, contrast, type, spacing, radius, shadow"
303 />
304 );
305 }
306 const t = shown;
307 const row = (label: string, items?: string[]) =>
308 items?.length ? (
309 <Text>
310 {label}: {items.join(" · ")}
311 </Text>
312 ) : null;
313 return (
314 <Box flexDirection="column">
315 {(["light", "dark"] as const).map((mode) =>
316 Object.entries(t.colors[mode] ?? {}).map(([role, hex]) => (
317 <Text key={`${mode}-${role}`}>
318 <Text backgroundColor={hex}>{" "}</Text> {mode} {role}{" "}
319 <Text dimColor>{hex}</Text>
320 </Text>
321 )),
322 )}
323 {row(
324 "contrast",
325 (["light", "dark"] as const).flatMap((m) =>
326 pairs(t.colors[m] ?? {}).map(
327 (p) => `${m} ${p.fg}/${p.bg} ${p.ratio ?? "?"} ${mark(p)}`,
328 ),
329 ),
330 )}
331 {row(
332 "fonts",
333 t.fonts?.map((f) => `${f.role} ${f.family}`),
334 )}
335 {row(
336 "type",
337 t.typeScale?.map((s) => `${s.step} ${s.size}/${s.lineHeight}`),
338 )}
339 {row(
340 "spacing",
341 t.spacing?.map(
342 (s) =>
343 `${s.name} ${"▇".repeat(Math.max(1, Math.min(12, s.px / 4)))} ${s.px}`,
344 ),
345 )}
346 {row(
347 "radius",
348 t.radius?.map((r) => `${r.name} ${r.px}`),
349 )}
350 </Box>
351 );
352 });
353}
354hooks/feedback.ts 110 lines1// The feedback inbox's rules: Skill Feedback points raised after a kit skill run, in any
2// project, kept until /improve handles them. Pure, like spec.ts: feedback-inbox.ts makes the
3// calls ($.store, one key per point so parallel sessions never overwrite each other).
4
5export const PREFIX = "feedback:"; // $.store: `feedback:<id>` → Point
6export const MAX_TEXT = 500;
7
8export type Point = {
9 skill: string;
10 text: string;
11 project: string;
12 at: number;
13};
14export type Request =
15 | { action: "add"; skill: string; text: string }
16 | { action: "list"; skill?: string }
17 | { action: "done"; ids: string[] };
18
19const word = (v: unknown) => (typeof v === "string" ? v.trim() : "");
20
21// The tool's input → a request, or what is wrong with it (the tool's answer).
22export function validate(e: Record<string, unknown>): Request | string {
23 const skill = word(e.skill);
24 if (e.action === "add") {
25 const text = word(e.text);
26 if (!skill) return "skill is required: the kit skill the point is about";
27 if (!text) return "text is required: the point itself";
28 if (text.length > MAX_TEXT) return `text is over ${MAX_TEXT} characters`;
29 return { action: "add", skill, text };
30 }
31 if (e.action === "list") {
32 if (e.skill === undefined) return { action: "list" };
33 return skill ? { action: "list", skill } : "skill must be a skill name";
34 }
35 if (e.action === "done") {
36 const ids = Array.isArray(e.ids) ? e.ids.map(word) : [];
37 if (
38 !ids.length ||
39 ids.some((id) => !id) ||
40 ids.length !== (e.ids as []).length
41 )
42 return 'ids must be a list of point ids, such as ["#k2x9ab"]';
43 return { action: "done", ids: ids.map((id) => id.replace(/^#/, "")) };
44 }
45 return 'action must be "add", "list" or "done"';
46}
47
48export const normalise = (text: string) =>
49 text.trim().toLowerCase().replace(/\s+/g, " ");
50
51// The id of an open point with the same skill and text, or null.
52export function duplicate(
53 points: Record<string, Point>,
54 skill: string,
55 text: string,
56): string | null {
57 const want = normalise(text);
58 const hit = Object.entries(points).find(
59 ([, p]) => p.skill === skill && normalise(p.text) === want,
60 );
61 return hit?.[0] ?? null;
62}
63
64const oldestFirst = (points: Record<string, Point>) =>
65 Object.entries(points).sort(([, a], [, b]) => a.at - b.at);
66const dirName = (path: string) =>
67 path
68 .replace(/[\\/]+$/, "")
69 .split(/[\\/]/)
70 .pop() || path;
71
72// `#<id> · <skill> · <project dir> · <YYYY-MM-DD> · <text>`, one line per open point.
73export function lines(points: Record<string, Point>, skill?: string): string {
74 const shown = oldestFirst(points).filter(
75 ([, p]) => !skill || p.skill === skill,
76 );
77 if (!shown.length) return skill ? `Inbox empty for ${skill}` : "Inbox empty";
78 return shown
79 .map(
80 ([id, p]) =>
81 `#${id} · ${p.skill} · ${dirName(p.project)} · ` +
82 `${new Date(p.at).toISOString().slice(0, 10)} · ${p.text}`,
83 )
84 .join("\n");
85}
86
87// The open count and the skill with the most points (a tie: whose oldest waited longest).
88export function summary(points: Record<string, Point>) {
89 const per = new Map<string, number>();
90 for (const [, p] of oldestFirst(points))
91 per.set(p.skill, (per.get(p.skill) ?? 0) + 1);
92 let top: string | null = null;
93 for (const [skill, n] of per) if (!top || n > per.get(top)!) top = skill;
94 return { count: Object.keys(points).length, top };
95}
96
97// The status line's text in a kit checkout ($.ui.status puts "kit:" before it).
98export function statusText(points: Record<string, Point>): string | undefined {
99 const { count, top } = summary(points);
100 if (!count) return undefined;
101 return `${count} feedback point${count === 1 ? "" : "s"} · /improve ${top}`;
102}
103
104// A short id: the time in base 36 plus three random characters.
105export const newId = (now: number, rand: number) =>
106 now.toString(36) +
107 Math.floor(rand * 36 ** 3)
108 .toString(36)
109 .padStart(3, "0");
110hooks/roadmap-file.ts 157 lines1// docs/roadmap.md, read and edited line by line (format: skills/product/SKILL.md § Formats).
2// Pure: text in, text out; every line the edit doesn't touch is kept byte for byte.
3// Edits refuse done and in-progress items: their state belongs to /build.
4
5export type Item = {
6 slug: string;
7 description: string;
8 done: boolean;
9 spec?: string;
10 phase: string;
11 line: number;
12};
13export type Roadmap = { items: Item[]; phases: string[]; later: string[] };
14
15const ITEM = /^- \[( |x)\] \*\*([^*]+)\*\* · (.*?)(?: · spec: (\S+))?\s*$/i;
16const HEADING = /^## (.+?)\s*$/;
17
18export function parse(text: string): Roadmap {
19 const out: Roadmap = { items: [], phases: [], later: [] };
20 let phase = "";
21 let inComment = false;
22 let inLater = false;
23 text.split("\n").forEach((raw, line) => {
24 if (inComment || raw.startsWith("<!--")) {
25 inComment = !raw.includes("-->");
26 return;
27 }
28 const heading = HEADING.exec(raw)?.[1];
29 if (heading) {
30 inLater = /^later$/i.test(heading);
31 if (!inLater) out.phases.push((phase = heading));
32 return;
33 }
34 if (inLater) {
35 if (raw.startsWith("- ")) out.later.push(raw.slice(2));
36 return;
37 }
38 const m = ITEM.exec(raw);
39 if (m?.[2])
40 out.items.push({
41 slug: m[2],
42 description: m[3] ?? "",
43 done: m[1] === "x",
44 spec: m[4],
45 phase,
46 line,
47 });
48 });
49 return out;
50}
51
52export const isEditable = (item: Item) => !item.done && !item.spec;
53export const toLine = (slug: string, description: string) =>
54 `- [ ] **${slug}** · ${description}`;
55export const slugify = (text: string) =>
56 text
57 .toLowerCase()
58 .replace(/[^a-z0-9\s-]/g, "")
59 .trim()
60 .split(/\s+/)
61 .slice(0, 3)
62 .join("-");
63
64function editable(r: Roadmap, slug: string): Item {
65 const item = r.items.find((i) => i.slug === slug);
66 if (!item) throw new Error(`no roadmap item "${slug}"`);
67 if (!isEditable(item))
68 throw new Error(`"${slug}" is done or in progress: /build owns it`);
69 return item;
70}
71
72// The line index after which a new item of `phase` goes: its last item, else its heading.
73function endOf(lines: string[], r: Roadmap, phase: string): number {
74 const last = r.items.filter((i) => i.phase === phase).at(-1);
75 if (last) return last.line;
76 const heading = lines.findIndex((l) => HEADING.exec(l)?.[1] === phase);
77 if (heading < 0) throw new Error(`no phase "${phase}"`);
78 return lines[heading + 1] === "" ? heading + 1 : heading;
79}
80
81export function add(text: string, phase: string, title: string): string {
82 const r = parse(text);
83 const lines = text.split("\n");
84 const slug = slugify(title);
85 if (!slug) throw new Error("empty title");
86 if (r.items.some((i) => i.slug === slug))
87 throw new Error(`"${slug}" already exists`);
88 lines.splice(endOf(lines, r, phase) + 1, 0, toLine(slug, title.trim()));
89 return lines.join("\n");
90}
91
92export function movePhase(text: string, slug: string, phase: string): string {
93 const r = parse(text);
94 const item = editable(r, slug);
95 if (item.phase === phase) return text;
96 if (!r.phases.includes(phase)) throw new Error(`no phase "${phase}"`);
97 const lines = text.split("\n");
98 const [raw = ""] = lines.splice(item.line, 1);
99 lines.splice(endOf(lines, parse(lines.join("\n")), phase) + 1, 0, raw);
100 return lines.join("\n");
101}
102
103export function remove(text: string, slug: string): string {
104 const item = editable(parse(text), slug);
105 const lines = text.split("\n");
106 lines.splice(item.line, 1);
107 return lines.join("\n");
108}
109
110export function toLater(text: string, slug: string): string {
111 const item = editable(parse(text), slug);
112 return appendLater(remove(text, slug), `${item.slug}: ${item.description}`);
113}
114
115// A new idea at the end of `## Later` (made when missing).
116export function addLater(text: string, idea: string): string {
117 if (!idea.trim()) throw new Error("empty idea");
118 return appendLater(text, idea.trim());
119}
120
121function appendLater(text: string, line: string): string {
122 const lines = text.split("\n");
123 const later = lines.findIndex((l) => /^## later\s*$/i.test(l));
124 const idea = `- ${line}`;
125 if (later < 0) {
126 while (lines.at(-1) === "") lines.pop();
127 lines.push("", "## Later", "", idea, "");
128 } else {
129 let end = later + 1;
130 while (end < lines.length && !HEADING.test(lines[end] ?? "")) end++;
131 while (end > later + 1 && lines[end - 1] === "") end--;
132 lines.splice(end, 0, idea);
133 }
134 return lines.join("\n");
135}
136
137// A `## Later` idea back onto the roadmap, at the end of `phase` ("slug: description" or free text).
138export function restore(text: string, index: number, phase: string): string {
139 const lines = text.split("\n");
140 const later = lines.findIndex((l) => /^## later\s*$/i.test(l));
141 const at = lines.findIndex(
142 (l, i) => i > later && l.startsWith("- ") && index-- === 0,
143 );
144 if (later < 0 || at < 0) throw new Error("no such Later idea");
145 const idea = (lines[at] ?? "").slice(2);
146 const [, slug, description] = /^([a-z0-9-]+): (.+)$/.exec(idea) ?? [];
147 lines.splice(at, 1);
148 const rest = lines.join("\n");
149 const r = parse(rest);
150 if (!slug || !description) return add(rest, phase, idea);
151 if (r.items.some((i) => i.slug === slug))
152 throw new Error(`"${slug}" already exists`);
153 const out = rest.split("\n");
154 out.splice(endOf(out, r, phase) + 1, 0, toLine(slug, description));
155 return out.join("\n");
156}
157hooks/roadmap-dashboard.tsx 113 lines1import { type Parts, phaseNames } from "./roadmap-parts";
2import { phases } from "./roadmap-phase";
3import { later } from "./roadmap-later";
4
5// The roadmap pane's top (the pane's title is the product): a dashboard of context and plan usage and kit's
6// skills as buttons. Pure, like roadmap-parts.tsx.
7
8export function body(p: Parts) {
9 const { Box, Text } = p.el;
10 return (
11 <Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
12 {p.draft ? null : dashboard(p)}
13 {p.note ? <Text color="#818cf8">{p.note}</Text> : null}
14 {phases(p, phaseNames(p))}
15 {p.later.length || !p.draft ? later(p) : null}
16 </Box>
17 );
18}
19
20// Calm below the fresh-start advice (60%), amber up to 85%, red above.
21const tone = (pct: number) =>
22 pct < 60 ? undefined : pct < 85 ? "#f59e0b" : "#ef4444";
23
24// The dashboard: its own bordered block above the roadmap. Usage; git state with Diff, Commit
25// and Launch only when there is something for them to do; then kit's skills as buttons.
26function dashboard(p: Parts) {
27 const { Box, Text, Button } = p.el;
28 const g = p.git;
29 // Two lines: context and the 5h session, then the weekly limit.
30 const usageRows = [
31 p.usage.filter((u) => u.label !== "week"),
32 p.usage.filter((u) => u.label === "week"),
33 ].filter((row) => row.length);
34 const skill = (
35 key: string,
36 label: string,
37 command: string,
38 args = "",
39 primary = false,
40 ) => (
41 <Button
42 key={key}
43 variant={primary ? "primary" : "secondary"}
44 label={label}
45 onPress={() => p.act.run(command, args)}
46 />
47 );
48 return (
49 <Box
50 flexDirection="column"
51 gap={1}
52 borderStyle="round"
53 borderColor="#334155"
54 paddingX={2}
55 paddingY={1}
56 >
57 {/* Usage, with the two commands that act on the context: compact or clear it. */}
58 <Box justifyContent="space-between" gap={2} marginBottom={1}>
59 <Box flexDirection="column">
60 {usageRows.map((row, r) => (
61 <Text key={r}>
62 {row.map((u, i) => (
63 <Text key={u.label}>
64 {i ? " " : ""}
65 <Text dimColor>{`${u.label} `}</Text>
66 <Text bold color={tone(u.pct)}>
67 {u.value}
68 </Text>
69 {u.note ? <Text dimColor>{` · ${u.note}`}</Text> : null}
70 </Text>
71 ))}
72 </Text>
73 ))}
74 </Box>
75 <Box gap={1}>
76 {skill("compact", "Compact", "compact")}
77 {skill("clear", "Clear", "clear")}
78 </Box>
79 </Box>
80 {!g && p.gitError ? (
81 <Text dimColor wrap="wrap">{`git: ${p.gitError}`}</Text>
82 ) : null}
83 {g ? (
84 <Box justifyContent="space-between" gap={2}>
85 <Text wrap="wrap">
86 <Text dimColor>{`${g.branch} `}</Text>
87 {g.changed ? (
88 <Text>{`${g.changed} file${g.changed === 1 ? "" : "s"} changed`}</Text>
89 ) : (
90 <Text dimColor>clean</Text>
91 )}
92 {g.unpushed ? (
93 <Text>{` · ${g.unpushed} commit${g.unpushed === 1 ? "" : "s"} not live`}</Text>
94 ) : null}
95 </Text>
96 <Box gap={1}>
97 {g.changed && p.hasDiff ? skill("diff", "Diff", "diff") : null}
98 {g.changed ? skill("commit", "Commit", "kit:commit") : null}
99 {g.unpushed ? skill("launch", "Launch", "kit:launch") : null}
100 </Box>
101 </Box>
102 ) : null}
103 <Box gap={1} marginTop={1}>
104 {p.isSetUp ? null : skill("setup", "Setup", "kit:setup", "", true)}
105 {skill("ideas", "Ideas", "kit:product", "brainstorm")}
106 {skill("critique", "Critique", "kit:product", "critique")}
107 {skill("theme", "Theme", "kit:theme")}
108 {skill("audit", "Audit", "kit:audit")}
109 </Box>
110 </Box>
111 );
112}
113hooks/roadmap-press.ts 55 lines1import type { ElementTable, On, UiPressArgument } from "claude-code";
2
3// The desktop drops a pane's first click: while the pane's focus ring sits elsewhere (on
4// nothing after open, or lost after a click in the chat), a click on a Button only moves the
5// ring there (ui.focus, origin person) and raises no ui.press. autoFocus and opening with
6// focus don't help. So the pane draws its Buttons through `tracked`, which keeps each one's
7// onPress by key; a person's ring move onto one on the desktop runs it, and a ui.press the
8// same click may still raise is swallowed. The cost: on the desktop, Tab onto a Button
9// presses it.
10
11const PANE = "kit-roadmap"; // roadmap-view.tsx's pane
12const SAME_CLICK_MS = 250;
13const presses = new Map<string, (e: UiPressArgument) => void>();
14let surface = "";
15let ran: { key: string; at: number } | null = null;
16
17// The element table with a Button that remembers its onPress (key defaults to the label,
18// as the engine's does). Called on every draw: the last drawing's buttons are the live ones.
19export function tracked(el: ElementTable, drawnOn: string): ElementTable {
20 surface = drawnOn;
21 presses.clear();
22 const Button: typeof el.Button = (props) => {
23 const key = props.key ?? props.label;
24 if (key) presses.set(key, props.onPress);
25 return el.Button(props);
26 };
27 return { ...el, Button };
28}
29
30export function pressOnFocus(on: On) {
31 on("ui.focus", { requestId: PANE }, async (_$, e, next) => {
32 const done = await next(e);
33 const key = e.element;
34 const press = key ? presses.get(key) : undefined;
35 if (done.deny || !key || !press || surface !== "desktop") return done;
36 if (e.origin.kind !== "person") return done;
37 ran = { key, at: Date.now() };
38 press({
39 plugin: e.plugin ?? "",
40 element: key,
41 component: e.component,
42 requestId: e.requestId,
43 surface: "desktop",
44 });
45 return done;
46 });
47
48 on("ui.press", { requestId: PANE }, async (_$, e, next) => {
49 if (ran?.key !== e.element || Date.now() - ran.at > SAME_CLICK_MS)
50 return next(e);
51 ran = null; // the click the ring move already ran
52 return { element: e.element };
53 });
54}
55