SLOPSHOPPER

kit

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

newpaneguardtoaststatusprompt
v0.8.0no licenseupdated 2026-10-08AirMile/claude-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kit
│ ┃ kit-roadmap ✕ › fix the failing auth test and add an audit log call │ ┃ No docs/roadmap.md here: run /product to │ ┃ make one. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kit-roadmap
No docs/roadmap.md here: run /product to make one.
Pane · kit-theme
Run /theme to preview a theme here.
README

kit

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

Contents


Install

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.

No CLAUDE.md in your projects

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.

Recommended: frontend rule

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

Update / uninstall

  • Edits to this repo take effect only after you bump 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.
  • Try edits without installing: claude --plugin-dir ~/Projects/claude-kit.
  • Remove: claude plugin marketplace remove airmile (also uninstalls the plugin).

Quick start

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

Core idea: your repo is the state

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:

  • They sync between your machines through git, like the rest of your code.
  • A new chat can pick up any interrupted work by reading them (/build <slug> resumes from the spec's Status: line and its checkboxes).
  • You can read and edit them by hand. Each file starts with a <!-- format: … --> header that describes its own format, so skills (and you) never need a separate schema document.

Two rules keep them small:

  1. Only write down what can't be derived from the code. Architecture summaries and file lists go stale; Claude can always re-read the code. Decisions, intent, pitfalls and conventions can't be re-derived, so those are what kit records.
  2. Every file has one owner skill that knows its format (listed below).

Files kit maintains in your project

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
FileWritten byRead byLoaded into context
AGENTS.md (root)/setup, lessonsevery sessionalways, at session start
<dir>/AGENTS.mdlessonsevery sessionlazily, when Claude reads a file in that dir
docs/product.md/product/build, /convert, /setupon demand
docs/roadmap.md/product, /build/buildon demand
docs/specs/<slug>.md/build/build, verifieron demand
docs/decisions.md/build, /setup/buildon demand
docs/seo.md/scan, /intake, /seo/seo, /build, /audit, /launchon demand

AGENTS.md

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.

docs/product.md

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.

docs/roadmap.md

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:

LineMeaning
- [ ] slug · descriptionopen, not started
- [ ] slug · description · spec: docs/specs/slug.mdin progress (spec exists)
- [x] slug · description · spec: …done

## Later holds unordered ideas that are not in the flow yet.

docs/specs/\<slug\>.md

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.

docs/decisions.md

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.


Skills

/product

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

  1. Takes your idea (or asks one open question).
  2. Enters plan mode: the draft product.md and roadmap are what you approve.
  3. Runs 1–3 rounds of clickable questions (audience, MVP size, core experience, stack, then gaps). Vision and naming are asked as open questions; visual choices are shown as small mocks; competing designs get a trade-off table first.
  4. Shows both files in the plan, and the draft roadmap in the roadmap pane. Accept writes them; reject revises.

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.

/setup

Make a project kit-ready. Idempotent: running it again only fills gaps.

It detects one of five situations:

SituationWhat 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.mdProduct first: runs the /product interview, then scaffolds
Empty folder, docs/product.md existsScaffold: 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 codeOnboard

Onboard reads the manifest, scripts, test/lint config and recent git history, then:

  • writes AGENTS.md from the template, with only verified facts (no placeholders);
  • moves useful content from an existing CLAUDE.md into AGENTS.md and removes the CLAUDE.md (otherwise AGENTS.md wouldn't load);
  • creates docs/roadmap.md and docs/decisions.md with their format headers (never overwrites);
  • makes sure AGENTS.md and docs/ are not in .gitignore, and ignores .claude/worktrees/;
  • asks the git mode and prepares parallel sessions (.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 sourcekit destination
Generated CLAUDE.mdreal rules/runbooks/pitfalls → AGENTS.md; template sections dropped
AGENTS.md symlinked to CLAUDE.mdreplaced by a real file
.project/project-seed.mddocs/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.

/build

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
Routing
InputPath
slug with an existing specResume at the step matching Status
slug on the roadmap, no specFeature
no argumentin-progress roadmap item → Resume; else first open item → Feature
text describing a bugFix
other textsize gate → Small or Feature

Size gate. A change is Small only if none of these hold:

  1. It adds a capability (new page, route, endpoint, model, migration).
  2. It touches more than 3 source files.
  3. It needs a new test file or harness.
  4. It changes a shared layer used by more than 2 modules, or is a cross-cutting rename.

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.

Small path

Make the change → scoped tests/lint → a screenshot check if it's UI → lessons if something surprising came up → /commit.

Fix path (debug ladder)

Effort scales with how well the cause is understood, judged from observable signals, never from "confidence":

TierWhenWhat
1 Directcause and symptom both visible; a known value; 1–2 fileschange it, re-check live
2 Hypothesissymptom clear, cause unprovenreproduce → write the hypothesis and what evidence would confirm it → gather that evidence → fix the proven cause
3 Investigatespans modules, intermittent, or a lower tier failedan 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:.

Feature path
  1. Define (plan mode). Reads docs/product.md (non-goals!), decisions, the roadmap line and the code it touches. Asks only what it can't answer itself (usually 0–3 questions). Drafts the spec: criteria split into happy / edge / error, the approach with files, an ASCII wireframe for UI, and which test covers which criterion. More than 6 criteria → it proposes a split. It also flags roadmap items this feature makes obsolete. Accept writes 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.
  2. Build (inline). Test first where testable, then code, following AGENTS.md. If the spec has a Design: source, the UI is built with the /convert procedure. Full suite + typecheck/lint must be green.
  3. Verify. A fresh verifier subagent (which didn't build the code) checks every criterion with evidence. Failures are fixed and re-checked, max 2 rounds; still failing → it stops with the state written into the spec.
  4. Manual. Only for what can't be automated (real credentials, "does it feel smooth", physical device, audio). You get concrete steps + the expected result and answer Pass / Fail / Skip. A fail becomes a hypothesis-tier fix and the item is asked again.
  5. Finish. Spec 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.

Resuming

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.

/commit

Safe staging and a clean message. Called by you, or by /build as commit feature=<slug>.

  1. Pre-flight: stops if there's nothing to commit or a rebase/merge/cherry-pick is in progress. Larg
Source 16 files
hooks/register.tsx 151 lines
1import 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};
151
hooks/feedback-inbox.ts 124 lines
1import 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}
124
hooks/fresh-start.ts 66 lines
1import 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}
66
hooks/roadmap-open.ts 121 lines
1import 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}
121
hooks/roadmap-view.tsx 306 lines
1import 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}
306
hooks/roadmap-parts.tsx 82 lines
1import 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];
82
hooks/spec.ts 27 lines
1// 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}
27
hooks/theme-view.tsx 354 lines
1import 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, "&amp;").replace(/</g, "&lt;");
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}
354
hooks/feedback.ts 110 lines
1// 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");
110
hooks/roadmap-file.ts 157 lines
1// 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}
157
hooks/roadmap-dashboard.tsx 113 lines
1import { 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}
113
hooks/roadmap-press.ts 55 lines
1import 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