SLOPSHOPPER

whats-next

Live pane and band showing the work left in the current repo's cpm-next epics (docs/epics), in recommended order of execution, with an on-demand next-steps…

newpanebandguardcommandprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · whats-next
│ ┃ whats-next ✕ › fix the failing auth test and add an audit log call │ ┃ No docs/epics or docs/specifications folder │ ┃ in this directory or above it. ⏺ 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 │ │ › /next │ ⎿ whats-next: No docs/epics or docs/specifications folder in this │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · whats-next
No docs/epics or docs/specifications folder in this directory or above it.
README

Claude Code Marketplace

A Claude Code plugin marketplace whose main offering is cpm-next: a planning and building method for Claude Code, plus the mods that keep its model, effort and progress in view. It also holds smaller development tools.

cpm-next in brief

cpm-next turns an idea into working, tested code through a short chain of artefacts kept in your repository's docs/ folder: a discussion record, a product brief, a specification, and epics made of stories with acceptance criteria. Six skills cover the whole path:

SkillWhat it doesFinished when
/cpm-next:partyDiscussion with named specialist personas (PM, Architect, Developer, UX, QA and others)A discussion record is saved and the next step is named
/cpm-next:planWrites whatever is missing of brief, spec and epics, from whatever already existsThe artefacts down to the epics exist and trace upward
/cpm-next:reviewIndependent challenge of the epics before they are builtCritical and Warning findings are fixed or waiting on you
/cpm-next:doBuilds stories, verifies each acceptance criterion with evidence, and has each story auditedEvery story in scope is Complete and the tests pass
/cpm-next:libraryCurates reference documents in docs/library/ that planning and building readDocuments carry complete front-matter
/cpm-next:statusRead-only report of where things stand and the next command to runNothing is written

Each skill declares its model and effort: Opus plans and reviews, Sonnet builds, an Opus auditor agent checks every story before it closes, a Haiku scout agent answers lookups, and status runs on Haiku. The artefact formats are defined in cpm-next/shared/artifacts.md; the plugin's own log of changes is in cpm-next/README.md.

Training material (open in a browser):

CPM and DPM, the earlier planning plugins, were withdrawn on 2026-10-08. Their source is in this repository's git history.

Installation

Inside Claude Code:

/plugin marketplace add ninthspace/claude-code-marketplace

The suffix is the marketplace's name, not the repository's. marketplace.json declares ninthspace-marketplace, and every installed plugin is keyed by it, so cpm-next@claude-code-marketplace resolves to nothing.

The cpm-next set

Install these at user scope (the default), so one install serves every repository:

/plugin install cpm-next@ninthspace-marketplace           # the six skills, the auditor and scout agents
/plugin install cpm-next-models@ninthspace-marketplace    # mod: holds each skill's model and effort, per-story effort
/plugin install cpm-next-progress@ninthspace-marketplace  # mod: /progress-tracker, a claude.ai tracker for a spec's epics
/plugin install whats-next@ninthspace-marketplace         # mod: /next, a live pane of the work left in the repository
/plugin install plugin-sync@ninthspace-marketplace        # mod: reloads open sessions when a plugin here is updated
/reload-plugins

Only cpm-next is required. The four mods are optional, and each adds one thing:

PluginKindWithout it
cpm-nextSkills and agents—
cpm-next-modelsMod (function hooks)A skill's model and effort last only for the turn that invoked it; later turns of a do run fall back to the session model, and stories are not given their own effort
cpm-next-progressMod, adds /progress-trackerNo shareable progress page; read the epic docs or run /cpm-next:status
whats-nextMod, adds /nextNo live pane; run /cpm-next:status
plugin-syncModAfter an update, run /reload-plugins in each open session

Mods are plugins of function hooks that run inside Claude Code. They load like any plugin, run no model calls of their own unless stated, and are switched off and on with the plugin. A session that was open before you installed one needs /reload-plugins to load it.

Per-project setup for /progress-tracker. In a repository whose spec has epics in docs/epics/, run /progress-tracker 01 (the spec's number), then ask Claude to publish the page it writes. The mod saves the tracker's link and keeps the table in step with the epic docs from then on. Details under CPM Next Progress.

Keeping up to date. The marketplace auto-updates when Claude Code starts. plugin-sync then reloads sessions that were already open. To update straight away, from any directory:

claude plugin marketplace update ninthspace-marketplace
claude plugin update cpm-next@ninthspace-marketplace      # and each other plugin that changed

Other plugins

/plugin install noteplan@ninthspace-marketplace
/plugin install php-lsp@ninthspace-marketplace
/plugin install js-simplifier@ninthspace-marketplace
/plugin install filament-mockup@ninthspace-marketplace
/plugin install generated-files@ninthspace-marketplace
/plugin install weather@ninthspace-marketplace

Available Plugins

CPM Next (v0.7.2)

Plan and build with six skills: party, plan, review, do, library, status

The skills read and write Markdown artefacts under docs/: discussions/, briefs/, architecture/, specifications/, epics/, reviews/, retros/, quick/ and library/. The artefacts are the only state; there are no progress files to clean up. A typical path:

  1. /cpm-next:party to talk an idea through (optional).
  2. /cpm-next:plan to write the brief, spec and epics. It reads what exists, asks its questions in one batch, and fills only the gaps. In a brownfield project it grounds every requirement in the current code.
  3. /cpm-next:review before a large or risky epic (optional).
  4. /cpm-next:do to build: one story (/cpm-next:do 3), one epic, or all, which runs unattended and marks a blocked story instead of stopping. Each acceptance criterion gets an Evidence line, and each story is audited before it closes.
  5. /cpm-next:status whenever you return to the project.

Story effort. plan gives a story Effort: high where a mistake is costly or hard to see, and Effort: low for mechanical edits; with cpm-next-models installed, do builds each story at that effort and raises it for a fix after a failed audit. A low story marked Model: haiku is built by a Haiku subagent.

Agents: auditor (Opus, read-only) checks a finished story's evidence, diff, scope and tests; scout (Haiku, read-only) answers lookups such as where something is used or what a read-only query returns.

Quick Start:

/plugin install cpm-next@ninthspace-marketplace
/reload-plugins
/cpm-next:plan a CSV export for the bookings report

CPM Next Models (v0.3.0)

Holds each cpm-next skill's model and effort for its whole run

A Claude Code mod. A skill's model and effort frontmatter normally lasts only for the turn that invoked it. This mod keeps them for every later turn of the run, including your replies to the skill's questions, until another skill runs, you switch model or effort yourself, or /clear. It runs do all at high effort, and gives /cpm-next:do a set_story_effort tool so each story is built at its own Effort, never below high in a do all run. The footer shows what is held, for example cpm-next:do · story 3 · sonnet · low.

Commands: /cpm-models shows what is held; /cpm-models off releases it.

Quick Start:

/plugin install cpm-next-models@ninthspace-marketplace
/reload-plugins

Develop: claude plugin validate cpm-next-models and claude plugin test cpm-next-models.

CPM Next Progress (v0.3.1)

A claude.ai progress tracker for a cpm-next spec's epics, with a check that it matches the epic docs

A Claude Code mod (a plugin of function hooks). Name a spec and the mod writes a build order for its stories to a small JSON file, which you can edit; Claude then publishes a private claude.ai artifact showing that order as a table, grouped by phase. The mod compares the table with the epic docs in docs/epics/ whenever they change, and tells Claude which rows to update. Status, titles and outstanding blockers come from the epic docs; the order, phase labels, notes and open decisions come from the build-order file.

The mod only reads. It lists the artifact's rows through the ArtifactData tool, which auto mode does not ask about, and never writes to it. Claude's own ArtifactData calls do the writing, so no permission rule is needed. An earlier version wrote to the artifact itself, and auto mode refused those calls intermittently with "The server-side auto mode classifier gave no verdict for ArtifactData".

Build-order file: docs/specifications/NN-build-order.json:

{
  "title": "01-Series Build Order",
  "spec": "docs/specifications/01-spec-requirements-matrix-todos.md",
  "decisions": ["Questions still open, shown in a box above the table"],
  "phases": [
    { "label": "No visible change", "items": [
      { "epic": "01-05", "story": 1, "note": "Default value keeps today's text" },
      { "epic": "01-05", "task": "3.1" }
    ] },
    { "label": "Waiting on input", "note": "Shown beside the phase label", "items": [
      { "epic": "01-05", "story": 2, "waitingOn": "new name from the client" }
    ] }
  ]
}

An item is a story (story) or one task (task) of an epic, named by the epic's number prefix. waitingOn shows the item as Waiting on input until it is removed from the file or the item starts. artifact holds the tracker's link; the mod adds it when Claude publishes the page.

The first build order is written from the spec's epics (those whose number starts with the spec's): one phase per epic, epics ordered by their Blocked by epics, each epic's stories ordered by their Blocked by stories and then by number. Superseded and withdrawn epics and stories are left out. Edit the file afterwards to reorder, regroup, split a story into tasks or add waitingOn.

Statuses: Complete, In progress (the story says so, or one of its tasks has started), Waiting on input, Pending. A row's date moves only when its status changes. Blockers shown are the story's and epic's Blocked by items not yet Complete, earlier unfinished tasks of the same story, and any waitingOn.

How it works:

  1. After any tool call that writes (Edit, Write, Bash and so on), the mod checks whether an epic doc or build-order file changed since the last check. If none did, it does nothing more.
  2. If one did, it reads the tracker and works out the writes that would bring it in line. If there are none, it says nothing.
  3. If there are some, it saves them to .claude/cpm-next-progress/NN-build-order.pending.json, shows you a one-line notice, and gives Claude a note with the artifact link and the writes. Claude applies them with one ArtifactData batch. More than eight writes stay in the file, and Claude hands them to a Haiku subagent, which reads the file and applies each batch, so they never enter the main conversation.

The folder .claude/cpm-next-progress/ holds generated files (the page and the pending writes); /progress-tracker 01 adds it to the project's .gitignore.

Commands:

  • /progress-tracker — checks every tracker in the repository now and prints what differs, or "up to date".
  • /progress-tracker 01 (or a spec file, or a build-order file) — writes docs/specifications/01-build-order.json from the spec's epics if it does not exist yet, writes the page to .claude/cpm-next-progress/, and adds that folder to .gitignore. Then ask Claude to publish the page. When it does, the mod saves the link to the build-order file and gives Claude the rows to write. init before the name still works.

Data layout (in the artifact's database, readable by anyone it is shared with, writable by editors): collection items, one document per row (01-05-s1, 01-05-t3.1); document meta/tracker with the title, spec, last update and decisions.

Quick Start:

/plugin install cpm-next-progress@ninthspace-marketplace
/reload-plugins

/progress-tracker 01

Then ask Claude to publish the page. The spec needs its epics in docs/epics/ first.

Reads: the cpm-next epic format (cpm-next/shared/artifacts.md), as What's Next does.

Develop: claude plugin validate cpm-next-progress and claude plugin test cpm-next-progress. To run the working tree, start Claude Code with --plugin-dir cpm-next-progress.

What's Next (v0.2.2)

A live pane and band showing the cpm-next work left in the current repository

A Claude Code mod (a plugin of function hooks). It reads the docs/epics/ and docs/specifications/ folders of the repository the session runs in — or the nearest folder above it that has either — and shows every story not yet Complete, in the order to build them, and every spec no epic has been planned from yet. It reads the files directly, with no model calls, so it stays current as /cpm-next:do or you edit the epics.

What it shows:

  • Pane — the story in progress and its next task, every remaining story in order (doing, ready, or after Story 1 / after Epic …), and each open epic's story count. Opens by itself in a repository with work left when the terminal is at least 144 columns wide; /next opens it at any width.
  • Specs without epics — each spec in docs/specifications/ that no epic was planned from, in number order. A spec counts as planned when an epic is numbered after it (03-spec-… → 03-01-epic-…) or an epic names its file in Source spec; a spec whose own Status is Complete, Superseded or Withdrawn is left out, as is a withdrawal notice (a Withdrawn or Superseded by field, or WITHDRAWN / SUPERSEDED in its title).
  • Band — one line above the prompt with the next story, its next task, and how many stories are left; with no stories left, the first spec to plan.
  • Next steps — an Ask Claude button (hotkey a) that asks Sonnet for a short note on what to do next, from the ordered list and the first two stories in full. The note is kept per repository across sessions and dimmed once the epics change after it was written.

Order of execution: stories already In Progress first; then the other stories of epics under way (the epic's own Status is In Progress, or one of its stories is); then everything else. Within each group, repeatedly, the ready story with the lowest epic number and story number, treating each as done before choosing the next. So working on a higher-numbered epic out of order moves it to the top once its Status says In Progress. A story is ready when everything its own Blocked by and its epic's Blocked by name is Complete; epics in docs/archive/epics/ count when resolving those dependencies. Stories whose dependencies can never be met (an unknown epic, a cycle) are listed last.

Quick Start:

/plugin install whats-next@ninthspace-marketplace
/reload-plugins

# Open the pane and print the ordered list into the conversation
/next

Reads: the cpm-next epic format (cpm-next/shared/artifacts.md) — Status, Blocked by, Story and Task fields, read case-insensitively, with Done read as Complete. Superseded and Withdrawn epics are skipped.

Develop: claude plugin validate whats-next and claude plugin test whats-next. To run the working tree instead of the installed release, start Claude Code with --plugin-dir whats-next (and uninstall the release, or both draw).

Plugin Sync (v0.1.0)

Open sessions pick up plugin updates without a manual /reload-plugins

A Claude Code mod (a plugin of function hooks). Every five minutes, and after each answer, it compares ~/.claude/plugins/installed_plugins.json with the versions of this marketplace's plugins that the session loaded. When one differs, it shows a one-line notice naming the plugins and versions, and runs /reload-plugins, which waits until the session is idle. A project-scope install is compared for sessions in that project, and the user-scope install for all others.

It does not install updates. The marketplace's auto-update does that when any new session starts, or run it yourself once, from any directory:

claude plugin marketplace update ninthspace-marketplace
claude plugin update cpm-next@ninthspace-marketplace   # each plugin that changed

Quick Start:

/plugin install plugin-sync@ninthspace-marketplace
/reload-plugins

Install it at user scope so every session loads it. A session already open needs one manual /reload-plugins to load the mod; after that it reloads itself.

Develop: claude plugin validate plugin-sync and claude plugin test plugin-sync.

NotePlan Search (v1.0.0)

Search and query NotePlan notes from Claude Code

A skill for searching NotePlan content across:

  • Notes folder - Standalone notes
  • Calendar folder - Daily/weekly/monthly notes
  • Spaces - Team/shared notes (SQLite database)
  • iCloud - If syncing via iCloud Drive

Results are sorted by most recently modified first.

Quick Start:

# Search for a term
/noteplan coffee

# List all Spaces notes
/noteplan --list --spaces

# Fetch full note by ID
/noteplan --get UUID

# Search with date filters
/noteplan meeting --after 2025-01-01

# Natural language queries
/noteplan find me everything about project planning

Key Features:

  • Full-text search across all NotePlan sources
  • Date filtering (--after, --before)
  • JSON output for AI tools
  • Direct noteplan:// URLs to open notes in the app
  • Excludes @Templates, @Trash, @Archive by default (use --all to include)

Requirements:

  • macOS with NotePlan 3 installed
  • Python 3

View full documentation


PHP LSP (v1.0.0)

PHP semantic code intelligence for Claude Code

Adds 24 LSP tools to Claude Code for PHP files via intelephense and the lsp-mcp-server bridge.

Capabilities:

  • Go-to-definition, find references, find implementations
  • Hover info (type signatures, documentation)
  • Code completion and signature help
  • Diagnostics (errors, warnings) per file and project-wide
  • Safe rename across entire codebase
  • Code actions (quick fixes, refactoring)
  • Call hierarchy and type hierarchy
  • File analysis (imports, exports, related files)
  • Document formatting

Quick Start:

# One-time setup (installs intelephense + lsp-mcp-server, configures project)
/php-lsp:setup

# Restart Claude Code — LSP auto-starts on first use

# Check everything is working
/php-lsp:status

Requirements:

  • Node.js >= 18
  • Git

View full documentation


JS/TS Simplifier (v1.0.0)

Simplify and improve JavaScript and TypeScript code across an entire codebase

A skill that scans all JS/TS files (or a configurable subset) and applies clarity, consistency, and maintainability improvements while preserving exact functionality. Unlike targeted simplification of recently changed files, this skill works across the whole codebase.

Three parallel analysis agents:

  • Modern Syntax — ES2015+ and ES2020+ upgrades (optional chaining, nullish coalescing, async/await, const/let)
  • Code Quality — Dead code removal, conditional simplification, naming improvements, error handling
  • Structure & Reuse — DRY violations, module organisation, function complexity, async patterns

Quick Start:

# Simplify all JS/TS files in the project
/js-simplify

# Narrow to a specific directory
/js-simplify src/

# Only git-modified files
/js-simplify only changed

# Focus on a specific pattern
/js-simplify focus on async patterns

Key Features:

  • Parallel three-agent analysis for comprehensive coverage
  • Respects project conventions (CLAUDE.md, ESLint, Prettier, tsconfig)
  • Configurable scope — all files, specific directories, globs, or git-changed only
  • Safety-first — never changes what the code does, only how it does it
  • Flags ambiguous cases for manual review rather than auto-applying

Supported File Types:

  • .js, .mjs, .cjs, .jsx, .ts, .tsx

View full documentation

Filament Mockup (v1.1.0)

Build high-fidelity Filament v5 admin mockups for stakeholder sign-off

A skill that turns a product brief or spec into a single self-contained HTML file that looks pixel-accurate to a real Filament v5 admin panel — clickable enough to walk a stakeholder through every screen and flow, and throwaway by design (the real Filament build regenerates all of it natively). Mockups use the real captured Filament theme CSS and Filament's exact fi-* markup, so what stakeholders sign off on is what gets built. Not for production Filament code or customer-facing/front-end mockups.

Workflow:

  • Capture — lift the compiled theme CSS and design tokens from a real Filament v5 panel
  • Inventory — build an FR → screen matrix so every element traces back to a numbered functional requirement
  • Build — reuse Filament's exact fi-* grammar; mark genuinely custom components with the mk- namespace
  • Verify — measure with Playwright rather than eyeballing, then sign off with a coverage audit
  • Hand off — write the durable routing table (docs/mockups/surface-routing.md) so the downstream builder mockup-to-filament knows which surfaces it owns (works stand-alone — no brief-to-mockups prerequisite)

Quick Start:

# Turn a brief/spec into clickable admin screens
create a Filament mockup from docs/specifications/05-spec-admin-panel.md

# Or describe it directly
mock up the admin panel for this PRD

Key Features:

  • Single self-contained HTML file — opens by file://, zero environment to stand up
  • Maximum fidelity to Filament's real design language (captured theme, Albert Sans, standard layouts)
  • Every element traces to a functional requirement — invented UI is flagged, not silently added
  • Visible mk- vs fi-* boundary distinguishes mockup scaffolding from real Filament
  • Bundled scaffold, capture/verify scripts, and an fi-* grammar cheat-sheet
  • Part of the mockup→build family — a producer whose output the builders consume (mockup-to-filament for Filament, mockup-to-blade for bespoke); emits a routing handoff naming the lane per surface

Requires: Node + Playwright for the capture/verify scripts (npm i -D playwright && npx playwright install chromium).

View full documentation

Generated Files (v0.1.1)

A pane listing the files Claude generated this session, each with an Open button

A Claude Code mod (a plugin of function hooks). Skills such as code-to-uml, filament-mockup and the md2docx wrapper write HTML, Office and image files, often into the session scratchpad; this pane collects them so they can be opened without finding the path.

What it records: files with the extensions .html .htm .svg .png .jpg .jpeg .gif .webp .docx .xlsx .pptx .pdf that are

  • written or edited with the Write or Edit tools, or
  • named in a Bash command and changed while it ran (a leading cd <dir> && sets the folder relative paths resolve against). For md2docx and pandoc runs, the .docx beside each .md named is checked too, since md2docx writes there by default.

What it shows: a "Files" pane, newest first, up to 30 files: each file's name and fo

Source 3 files
hooks/register.tsx 302 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { NextNote, NextPlan, NextStory } from '../types'
5import { buildPlan, describePlan, hasWork, storyText } from './plan'
6import type { EpicFile, SpecFile } from './plan'
7
8const PANE = 'whats-next'
9const POLL_MS = 3000
10const NOTE_MODEL = 'sonnet'
11
12const plan = atom({ plugin: 'whats-next', key: 'plan' } as const, null)
13const note = atom({ plugin: 'whats-next', key: 'note' } as const, null)
14const isNoteBusy = atom({ plugin: 'whats-next', key: 'isNoteBusy' } as const, false)
15const noteError = atom({ plugin: 'whats-next', key: 'noteError' } as const, null)
16
17const EPIC_FILE = /-epic-.*\.md$/
18const SPEC_FILE = /^\d+-spec-.*\.md$/
19const WRITING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
20
21type Listing = { name: string; mtimeMs: number; size: number }
22
23const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
24
25async function findRoot($: EngineInterface): Promise<string | null> {
26  let dir = await $.session.cwd()
27  for (;;) {
28    if ((await $.fs.exists(`${dir}/docs/epics`)) || (await $.fs.exists(`${dir}/docs/specifications`))) return dir
29    const cut = dir.lastIndexOf('/')
30    if (cut <= 0) return null
31    dir = dir.slice(0, cut)
32  }
33}
34
35async function listDocs($: EngineInterface, dir: string, pattern: RegExp): Promise<Listing[]> {
36  if (!(await $.fs.exists(dir))) return []
37  const entries = await $.fs.list(dir)
38
39  return entries.filter(e => e.kind === 'file' && pattern.test(e.name)).sort((a, b) => a.name.localeCompare(b.name))
40}
41
42// Active and archived epics, and active specs; the signature covers all three folders.
43async function listAll($: EngineInterface, root: string) {
44  const active = await listDocs($, `${root}/docs/epics`, EPIC_FILE)
45  const archived = await listDocs($, `${root}/docs/archive/epics`, EPIC_FILE)
46  const specs = await listDocs($, `${root}/docs/specifications`, SPEC_FILE)
47
48  return { active, archived, specs, signature: signatureOf(root, active, archived, specs) }
49}
50
51async function readDocs($: EngineInterface, root: string): Promise<{ files: EpicFile[]; specs: SpecFile[]; signature: string }> {
52  const listed = await listAll($, root)
53  const files: EpicFile[] = []
54  for (const e of listed.active) files.push({ name: e.name, text: await $.fs.read(`${root}/docs/epics/${e.name}`), isArchived: false })
55  for (const e of listed.archived) files.push({ name: e.name, text: await $.fs.read(`${root}/docs/archive/epics/${e.name}`), isArchived: true })
56  const specs: SpecFile[] = []
57  for (const s of listed.specs) specs.push({ name: s.name, text: await $.fs.read(`${root}/docs/specifications/${s.name}`) })
58
59  return { files, specs, signature: listed.signature }
60}
61
62function signatureOf(root: string, ...lists: Listing[][]): string {
63  return [root, ...lists.map(list => list.map(e => `${e.name}:${e.mtimeMs}:${e.size}`).join(','))].join('|')
64}
65
66let isRefreshing = false
67let openedFor: string | null = null
68
69// Re-reads the docs only when an epic or spec file was added, removed or changed.
70async function refresh($: EngineInterface): Promise<NextPlan | null> {
71  if (isRefreshing) return read($, plan)
72  isRefreshing = true
73  try {
74    const root = await findRoot($)
75    const current = await read($, plan)
76    if (root === null) {
77      if (current !== null) await update($, plan, () => null)
78      return null
79    }
80    if (current !== null && current.signature === (await listAll($, root)).signature) return current
81
82    const { files, specs, signature } = await readDocs($, root)
83    const next = buildPlan(root, files, signature, specs)
84    await update($, plan, () => next)
85    if (current?.root !== root) {
86      const saved = (await $.store.get(`note:${root}`)) as NextNote | undefined
87      await update($, note, () => saved ?? null)
88      await update($, noteError, () => null)
89    }
90    await maybeOpen($, next)
91
92    return next
93  } finally {
94    isRefreshing = false
95  }
96}
97
98// Opens the pane unasked once per repo, when that repo has work left.
99async function maybeOpen($: EngineInterface, next: NextPlan) {
100  if (!hasWork(next) || openedFor === next.root) return
101  openedFor = next.root
102  await $.ui.open({ id: PANE, title: "What's next" })
103}
104
105async function askForNote($: EngineInterface) {
106  if (await read($, isNoteBusy)) return
107  const current = await refresh($)
108  if (current === null || !hasWork(current)) return
109  await update($, isNoteBusy, () => true)
110  await update($, noteError, () => null)
111  try {
112    const { files } = await readDocs($, current.root)
113    const focus = current.order.slice(0, 2).map(s => {
114      const file = files.find(f => f.name.replace(/\.md$/, '') === s.epicId)
115      return file === undefined ? '' : `### ${s.epicId} (${s.epicTitle})\n${storyText(file, s.number)}`
116    })
117    const result = await $.model.complete({
118      model: NOTE_MODEL,
119      effort: 'medium',
120      maxTokens: 900,
121      system:
122        'You read cpm-next planning docs (epics with stories, tasks and acceptance criteria) and tell a developer what to do next. ' +
123        'Answer in at most 8 short markdown bullets. Name the story and task, the concrete next steps, and any risk or ' +
124        'dependency to watch. Write literally: no preamble, no metaphor, no closing line.',
125      prompt: focus.length > 0
126        ? `${describePlan(current, 20)}\n\nThe first stories in full:\n\n${focus.join('\n\n')}`
127        : `${describePlan(current, 20)}\n\nNo stories are left; say which spec to plan into epics first and why.`,
128    })
129    if (result.isAnswered) {
130      const saved: NextNote = { root: current.root, signature: current.signature, text: result.text.trim(), at: await $.clock.now() }
131      await update($, note, () => saved)
132      await $.store.set(`note:${current.root}`, saved)
133    } else {
134      await update($, noteError, () => `The note failed: ${result.reason}`)
135    }
136  } finally {
137    await update($, isNoteBusy, () => false)
138  }
139}
140
141let isStarted = false
142
143// Registers /next, reads the docs and starts the poll, once per load.
144async function start($: EngineInterface) {
145  if (isStarted) return
146  isStarted = true
147  await $.command.register({ name: 'next', description: "Show what's left to do in this repo's cpm-next epics, in order, and specs with no epics yet" })
148  await refresh($)
149  $.clock.every(POLL_MS, () => void refresh($))
150}
151
152export const register: Register = on => {
153  on('session.start', async ($, e, next) => {
154    await start($)
155
156    return next(e)
157  })
158
159  // A plugin loaded by /reload-plugins (an install or update mid-session) sees no session.start; start on the first prompt.
160  on('prompt.submit', async ($, e, next) => {
161    await start($)
162
163    return next(e)
164  })
165
166  on('command.run', { command: 'next' }, async $ => {
167    const current = await refresh($)
168    if (current === null) return { text: 'No docs/epics or docs/specifications folder in this directory or above it.' }
169    await $.ui.open({ id: PANE, title: "What's next" })
170
171    return { text: describePlan(current) }
172  })
173
174  // Edits made by tools show up at once rather than at the next poll.
175  on('tool.call', async ($, e, next) => {
176    const result = await next(e)
177    if (WRITING_TOOLS.has(String(e.tool))) await refresh($).catch(() => undefined)
178
179    return result
180  })
181
182  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183    const current = await read($, plan)
184    const first = current?.order[0]
185    const spec = current?.specs[0]
186    if (e.props.hasSurvey || current === null || (first === undefined && spec === undefined)) return next(e)
187    const { Box, Text } = $.ui.resolve(e)
188
189    if (first === undefined && spec !== undefined) {
190      return (
191        <Box>
192          <Text wrap="truncate-end">
193            <Text color="cyan">Plan next </Text>
194            <Text bold>spec {spec.id.split('-spec-')[0]}</Text>
195            <Text> {spec.title}</Text>
196            <Text dimColor>  ({current.specs.length} {current.specs.length === 1 ? 'spec' : 'specs'} without epics · /next)</Text>
197          </Text>
198        </Box>
199      )
200    }
201    if (first === undefined) return next(e)
202    const task = first.nextTask === null ? '' : ` → ${first.nextTask.id} ${first.nextTask.title}`
203
204    return (
205      <Box>
206        <Text wrap="truncate-end">
207          <Text color="cyan">Next </Text>
208          <Text bold>{first.epicId.split('-epic-')[0]} S{first.number}</Text>
209          <Text> {first.title}{task}</Text>
210          <Text dimColor>  ({current.order.length} stories left · /next)</Text>
211        </Text>
212      </Box>
213    )
214  })
215
216  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
218    const current = await read($, plan)
219    const saved = await read($, note)
220    const isBusy = await read($, isNoteBusy)
221    const error = await read($, noteError)
222
223    if (current === null) return <Text dimColor>No docs/epics or docs/specifications folder in this directory or above it.</Text>
224    if (!hasWork(current)) return <Text dimColor>{current.repo}: no stories left and every spec has epics.</Text>
225
226    const label = (s: NextStory) => `${s.epicId.split('-epic-')[0]} S${s.number}`
227    // "Epic 34-01-epic-coupons-overview" reads as "34-01" and "Story 2" as "S2", matching the row labels;
228    // an epic named by both the epic and the story appears once.
229    const after = (tokens: string[]) => {
230      const short = tokens.map(t => t.replace(/^story\s+(\d+)$/i, 'S$1').replace(/^epic\s+/i, '').replace(/-epic-.*$/, ''))
231
232      return `after ${[...new Set(short)].join(', ')}`
233    }
234    const stateOf = (s: NextStory) => (s.status === 'In Progress' ? 'doing' : s.isReady ? 'ready' : after(s.waitsOn))
235    const colourOf = (s: NextStory) => (s.status === 'In Progress' ? 'yellow' : s.isReady ? 'green' : 'red')
236    const first = current.order[0]
237    const isStale = saved !== null && saved.signature !== current.signature
238    const specCount = `${current.specs.length} ${current.specs.length === 1 ? 'spec' : 'specs'} without epics`
239    const summary = [
240      current.order.length > 0 ? `${plural(current.order.length, 'story', 'stories')} · ${plural(current.epics.length, 'epic', 'epics')} left` : '',
241      current.specs.length > 0 ? specCount : '',
242    ].filter(Boolean).join(' · ')
243
244    return (
245      <Box flexDirection="column">
246        <Text bold>{current.repo}<Text dimColor>  {summary}</Text></Text>
247        <Text> </Text>
248        {first !== undefined && (
249          <Box flexDirection="column">
250            <Text color="cyan" bold>Now</Text>
251            <Text wrap="truncate-end">{label(first)} {first.title}</Text>
252            {first.nextTask !== null && (
253              <Text wrap="truncate-end" dimColor>  task {first.nextTask.id} {first.nextTask.title} ({first.tasksDone}/{first.tasksTotal} done)</Text>
254            )}
255            <Text> </Text>
256            <Text color="cyan" bold>Order</Text>
257            {current.order.slice(0, 30).map((s, i) => (
258              <Text wrap="truncate-end">
259                <Text dimColor={!s.isReady && s.status !== 'In Progress'}>{String(i + 1).padStart(2)} {label(s)} {s.title}  </Text>
260                <Text color={colourOf(s)}>{stateOf(s)}</Text>
261              </Text>
262            ))}
263            {current.order.length > 30 && <Text dimColor>   … {current.order.length - 30} more</Text>}
264            <Text> </Text>
265            <Text color="cyan" bold>Epics</Text>
266            {current.epics.map(epic => (
267              <Text wrap="truncate-end">
268                {epic.id.split('-epic-')[0]} {epic.title}  <Text dimColor>{epic.storiesDone}/{epic.storiesTotal} stories</Text>
269                {epic.waitsOn.length > 0 && <Text color="red">  {after(epic.waitsOn)}</Text>}
270              </Text>
271            ))}
272            <Text> </Text>
273          </Box>
274        )}
275        {current.specs.length > 0 && (
276          <Box flexDirection="column">
277            <Text color="cyan" bold>Specs without epics</Text>
278            {current.specs.map(spec => (
279              <Text wrap="truncate-end">
280                {spec.id.split('-spec-')[0]} {spec.title}  <Text color="magenta">plan</Text>
281              </Text>
282            ))}
283            <Text> </Text>
284          </Box>
285        )}
286        <Box>
287          <Text color="cyan" bold>Next steps </Text>
288          <Button
289            key="ask"
290            hotkey="a"
291            label={isBusy ? 'asking…' : saved === null ? 'Ask Claude' : 'Refresh'}
292            onPress={() => void askForNote($)}
293          />
294        </Box>
295        {error !== null && <Text color="red">{error}</Text>}
296        {saved !== null && isStale && <Text dimColor>The epics or specs changed since this note was written.</Text>}
297        {saved !== null && <Markdown text={saved.text} dimColor={isStale} />}
298      </Box>
299    )
300  })
301}
302
hooks/plan.ts 302 lines
1import type { NextEpic, NextPlan, NextSpec, NextStory, NextTask, WorkStatus } from '../types'
2
3// Reads cpm-next epic docs (shared/artifacts.md in the plugin) tolerantly:
4// field case, trailing hard-break spaces and "Done" for "Complete" all pass.
5
6export type EpicFile = { name: string; text: string; isArchived: boolean }
7
8type ParsedStory = {
9  number: number
10  title: string
11  status: WorkStatus
12  blockedBy: string[]
13  tasks: NextTask[]
14  text: string
15}
16
17type ParsedEpic = {
18  id: string
19  title: string
20  status: WorkStatus
21  blockedBy: string[]
22  stories: ParsedStory[]
23  sortKey: number[]
24  isArchived: boolean
25}
26
27const FIELD = /^\*\*([A-Za-z ]+)\*\*:\s*(.*?)\s*$/
28
29export function normaliseStatus(raw: string | undefined): WorkStatus {
30  const value = (raw ?? '').trim().toLowerCase()
31  if (value.startsWith('complete') || value.startsWith('done')) return 'Complete'
32  if (value.startsWith('in progress')) return 'In Progress'
33  if (value.startsWith('superseded')) return 'Superseded'
34  if (value.startsWith('withdrawn')) return 'Withdrawn'
35  return 'Pending'
36}
37
38export function parseBlockers(raw: string | undefined): string[] {
39  const value = (raw ?? '').trim()
40  if (value === '' || /^[—–-]+$/.test(value) || /^none$/i.test(value)) return []
41
42  return value.split(/[,;]/).map(item => item.trim()).filter(item => item !== '')
43}
44
45function sortKeyOf(id: string): number[] {
46  const prefix = id.split('-epic-')[0] ?? id
47
48  return prefix.split('-').map(Number).filter(n => !Number.isNaN(n))
49}
50
51function compareKeys(a: number[], b: number[]): number {
52  for (let i = 0; i < Math.max(a.length, b.length); i++) {
53    const diff = (a[i] ?? -1) - (b[i] ?? -1)
54    if (diff !== 0) return diff
55  }
56
57  return 0
58}
59
60export function parseEpic(file: EpicFile): ParsedEpic {
61  const id = file.name.replace(/\.md$/, '')
62  const epic: ParsedEpic = {
63    id,
64    title: id,
65    status: 'Pending',
66    blockedBy: [],
67    stories: [],
68    sortKey: sortKeyOf(id),
69    isArchived: file.isArchived,
70  }
71  let story: (ParsedStory & { hasNumber: boolean }) | null = null
72  let task: (NextTask & { hasId: boolean }) | null = null
73  const stories: (ParsedStory & { hasNumber: boolean })[] = []
74
75  for (const line of file.text.split(/\r?\n/)) {
76    if (line.startsWith('# ') && epic.title === id) {
77      epic.title = line.slice(2).trim()
78      continue
79    }
80    if (line.startsWith('## ')) {
81      task = null
82      story = { number: 0, title: line.slice(3).trim(), status: 'Pending', blockedBy: [], tasks: [], text: '', hasNumber: false }
83      stories.push(story)
84    }
85    if (story !== null) story.text += `${line}\n`
86    if (line.startsWith('### ') && story !== null) {
87      task = { id: '', title: line.slice(4).trim(), status: 'Pending', hasId: false }
88      story.tasks.push(task)
89      continue
90    }
91
92    const field = FIELD.exec(line)
93    if (field === null) continue
94    const name = (field[1] ?? '').trim().toLowerCase()
95    const value = field[2] ?? ''
96
97    if (task !== null) {
98      if (name === 'task') { task.id = value; task.hasId = true }
99      if (name === 'status') task.status = normaliseStatus(value)
100    } else if (story !== null) {
101      if (name === 'story') { story.number = Number.parseInt(value, 10); story.hasNumber = true }
102      if (name === 'status') story.status = normaliseStatus(value)
103      if (name === 'blocked by') story.blockedBy = parseBlockers(value)
104    } else {
105      if (name === 'status') epic.status = normaliseStatus(value)
106      if (name === 'blocked by') epic.blockedBy = parseBlockers(value)
107    }
108  }
109
110  // A "## " section without a **Story** field is prose (notes, context), not a story.
111  epic.stories = stories
112    .filter(s => s.hasNumber && !Number.isNaN(s.number))
113    .map(({ hasNumber: _, ...s }) => ({
114      ...s,
115      tasks: s.tasks.filter(t => (t as NextTask & { hasId: boolean }).hasId)
116        .map(({ id, title, status }) => ({ id, title, status })),
117    }))
118
119  return epic
120}
121
122function effectiveStatus(story: ParsedStory): WorkStatus {
123  if (story.status === 'Complete') return 'Complete'
124  const isStarted = story.tasks.some(t => t.status !== 'Pending')
125
126  return story.status === 'In Progress' || isStarted ? 'In Progress' : 'Pending'
127}
128
129const storyKey = (epicId: string, n: number) => `${epicId}#${n}`
130
131export type SpecFile = { name: string; text: string }
132
133/**
134 * Specs no epic has been planned from: no cpm-next epic numbered after the spec
135 * (`03-spec-x` → `03-01-epic-y`), and no epic naming the spec's file in its
136 * `**Source spec**`. A spec whose own Status is Complete, Superseded or Withdrawn is left out.
137 */
138export function unplannedSpecs(specs: SpecFile[], epics: EpicFile[]): NextSpec[] {
139  const sources = epics.map(e => /^\*\*Source spec\*\*:\s*(.*)$/im.exec(e.text)?.[1] ?? '')
140
141  return specs
142    .filter(spec => {
143      const number = Number.parseInt(spec.name, 10)
144      const status = normaliseStatus(/^\*\*Status\*\*:\s*(.*)$/im.exec(spec.text)?.[1])
145      if (status === 'Complete' || status === 'Superseded' || status === 'Withdrawn') return false
146      // A withdrawal notice: `**Withdrawn**:` or `**Superseded by**:` fields, or the word in the title.
147      if (/^\*\*(Withdrawn|Superseded by)\*\*:/im.test(spec.text)) return false
148      if (/^#\s+.*\b(WITHDRAWN|SUPERSEDED)\b/m.test(spec.text)) return false
149      const isNumberedAfter = epics.some(e => {
150        const parent = /^(\d+)-\d+-epic-/.exec(e.name)?.[1]
151        return parent !== undefined && Number(parent) === number
152      })
153
154      return !isNumberedAfter && !sources.some(source => source.includes(spec.name))
155    })
156    .sort((a, b) => Number.parseInt(a.name, 10) - Number.parseInt(b.name, 10))
157    .map(spec => ({
158      id: spec.name.replace(/\.md$/, ''),
159      // "# Spec: X" and "# Spec 16: X" both read as "X"; the number is already in the row label.
160      title: /^#\s+(?:Spec(?:ification)?(?:\s+\d+)?:\s*)?(.+)$/im.exec(spec.text)?.[1]?.trim() ?? spec.name,
161      path: `docs/specifications/${spec.name}`,
162    }))
163}
164
165export function buildPlan(root: string, files: EpicFile[], signature: string, specs: SpecFile[] = []): NextPlan {
166  const epics = files.map(parseEpic).sort((a, b) => compareKeys(a.sortKey, b.sortKey))
167  const isTerminal = (e: ParsedEpic) => e.status === 'Superseded' || e.status === 'Withdrawn'
168
169  const findEpic = (token: string): ParsedEpic | undefined => {
170    const ref = token.replace(/^epic\s+/i, '').trim()
171
172    return epics.find(e => e.id === ref || e.id.startsWith(`${ref}-`))
173  }
174
175  const done = new Set<string>()
176  for (const epic of epics) {
177    for (const story of epic.stories) {
178      if (epic.status === 'Complete' || story.status === 'Complete') done.add(storyKey(epic.id, story.number))
179    }
180  }
181
182  const isEpicDone = (epic: ParsedEpic, finished: Set<string>) =>
183    !isTerminal(epic) &&
184    (epic.status === 'Complete' ||
185      (epic.stories.length > 0 && epic.stories.every(s => finished.has(storyKey(epic.id, s.number)))))
186
187  const outstanding = (epic: ParsedEpic, tokens: string[], finished: Set<string>): string[] =>
188    tokens.filter(token => {
189      const story = /^story\s+(\d+)$/i.exec(token)
190      if (story !== null) return !finished.has(storyKey(epic.id, Number(story[1])))
191      const target = findEpic(token)
192
193      return target === undefined || !isEpicDone(target, finished)
194    })
195
196  type Candidate = { epic: ParsedEpic; story: ParsedStory; status: WorkStatus }
197  const open = epics.filter(e => !e.isArchived && !isTerminal(e) && e.status !== 'Complete')
198  let remaining: Candidate[] = open.flatMap(epic =>
199    epic.stories
200      .filter(story => !done.has(storyKey(epic.id, story.number)))
201      .map(story => ({ epic, story, status: effectiveStatus(story) })),
202  )
203
204  // A story often repeats its epic's dependency; list each one once.
205  const waitsOnNow = (c: Candidate, finished: Set<string>) => [
206    ...new Set([...outstanding(c.epic, c.epic.blockedBy, finished), ...outstanding(c.epic, c.story.blockedBy, finished)]),
207  ]
208
209  const toStory = (c: Candidate): NextStory => {
210    const waitsOn = waitsOnNow(c, done)
211    const nextTask = c.story.tasks.find(t => t.status !== 'Complete') ?? null
212
213    return {
214      epicId: c.epic.id,
215      epicTitle: c.epic.title,
216      number: c.story.number,
217      title: c.story.title,
218      status: c.status,
219      isReady: waitsOn.length === 0,
220      waitsOn,
221      tasksDone: c.story.tasks.filter(t => t.status === 'Complete').length,
222      tasksTotal: c.story.tasks.length,
223      nextTask,
224    }
225  }
226
227  // An epic is under way when its own Status says In Progress or one of its stories is in progress.
228  // Completed stories alone don't count: an epic paused after them is not the current one.
229  const isEpicStarted = (epic: ParsedEpic) =>
230    epic.status === 'In Progress' ||
231    epic.stories.some(s => !done.has(storyKey(epic.id, s.number)) && effectiveStatus(s) === 'In Progress')
232  const tier = (c: Candidate) => (c.status === 'In Progress' ? 0 : isEpicStarted(c.epic) ? 1 : 2)
233
234  // Walk the work forward: take the best ready story, treat it as done, repeat.
235  // Stories in progress first, then the rest of started epics, then epic number, then story number.
236  const rank = (a: Candidate, b: Candidate) =>
237    tier(a) - tier(b) ||
238    compareKeys(a.epic.sortKey, b.epic.sortKey) ||
239    a.story.number - b.story.number
240
241  const simulated = new Set(done)
242  const order: NextStory[] = []
243  while (remaining.length > 0) {
244    const ready = remaining.filter(c => waitsOnNow(c, simulated).length === 0).sort(rank)
245    const pick = ready[0]
246    if (pick === undefined) break
247    order.push(toStory(pick))
248    simulated.add(storyKey(pick.epic.id, pick.story.number))
249    remaining = remaining.filter(c => c !== pick)
250  }
251  // Whatever is left can never become ready from the docs alone (a cycle or an unknown dependency).
252  order.push(...remaining.sort(rank).map(toStory))
253
254  const nextEpics: NextEpic[] = open.map(epic => ({
255    id: epic.id,
256    title: epic.title,
257    status: epic.stories.some(s => effectiveStatus(s) !== 'Pending') ? 'In Progress' : epic.status,
258    storiesDone: epic.stories.filter(s => done.has(storyKey(epic.id, s.number))).length,
259    storiesTotal: epic.stories.length,
260    waitsOn: outstanding(epic, epic.blockedBy, done),
261  }))
262
263  return {
264    root,
265    repo: root.split('/').filter(Boolean).at(-1) ?? root,
266    epics: nextEpics,
267    order,
268    specs: unplannedSpecs(specs, files),
269    signature,
270  }
271}
272
273/** The plan as plain text: what /next prints and what the AI note is asked about. */
274export function describePlan(plan: NextPlan, limit = 12): string {
275  const lines: string[] = []
276  if (plan.order.length === 0) {
277    lines.push(`${plan.repo}: no cpm-next stories left in docs/epics.`)
278  } else {
279    lines.push(`${plan.repo}: ${plan.order.length} stories left across ${plan.epics.length} epics, in recommended order:`)
280    plan.order.slice(0, limit).forEach((s, i) => {
281      const state = s.status === 'In Progress' ? 'in progress' : s.isReady ? 'ready' : `waits on ${s.waitsOn.join(', ')}`
282      const next = s.nextTask === null ? '' : ` — next task ${s.nextTask.id} ${s.nextTask.title}`
283      lines.push(`${i + 1}. ${s.epicId} Story ${s.number}: ${s.title} (${state}; ${s.tasksDone}/${s.tasksTotal} tasks)${next}`)
284    })
285    if (plan.order.length > limit) lines.push(`… and ${plan.order.length - limit} more.`)
286  }
287  if (plan.specs.length > 0) {
288    lines.push('', `Specs with no epics yet (plan them with /cpm-next:plan <path>):`)
289    for (const spec of plan.specs) lines.push(`- ${spec.path}: ${spec.title}`)
290  }
291
292  return lines.join('\n')
293}
294
295/** True when there is anything to show: stories left or specs not yet planned. */
296export const hasWork = (plan: NextPlan) => plan.order.length > 0 || plan.specs.length > 0
297
298/** The markdown of one story, for the AI note's context. */
299export function storyText(file: EpicFile, number: number): string {
300  return parseEpic(file).stories.find(s => s.number === number)?.text ?? ''
301}
302
types/index.d.ts 56 lines
1export type WorkStatus = 'Pending' | 'In Progress' | 'Complete' | 'Superseded' | 'Withdrawn'
2
3export type NextTask = { id: string; title: string; status: WorkStatus }
4
5export type NextStory = {
6  epicId: string
7  epicTitle: string
8  number: number
9  title: string
10  status: WorkStatus
11  /** True when nothing it depends on is outstanding right now. */
12  isReady: boolean
13  /** Dependencies not yet Complete, as written in the epic doc. */
14  waitsOn: string[]
15  tasksDone: number
16  tasksTotal: number
17  nextTask: NextTask | null
18}
19
20export type NextEpic = {
21  id: string
22  title: string
23  status: WorkStatus
24  storiesDone: number
25  storiesTotal: number
26  waitsOn: string[]
27}
28
29/** A spec in docs/specifications that no epic has been planned from yet. */
30export type NextSpec = { id: string; title: string; path: string }
31
32export type NextPlan = {
33  root: string
34  repo: string
35  /** Epics with work left, in number order. */
36  epics: NextEpic[]
37  /** Every story left, in recommended order of execution. */
38  order: NextStory[]
39  /** Specs with no epics yet, in number order. */
40  specs: NextSpec[]
41  signature: string
42}
43
44export type NextNote = { root: string; signature: string; text: string; at: number }
45
46declare module 'claude-code' {
47  interface PluginState {
48    'whats-next': {
49      plan: NextPlan | null
50      note: NextNote | null
51      isNoteBusy: boolean
52      noteError: string | null
53    }
54  }
55}
56