SLOPSHOPPER

projectman

Git-native project management for Claude Code: the pm skills, the pm agent and the projectman MCP server

panebandguardcommandtimer
v0.12.0MITupdated 2026-10-21Biztactix-Ryan/ProjectMan
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · projectman
│ ┃ ProjectMan cache cost ✕ › fix the failing auth test and add an audit log call │ ┃ No orchestrator run recorded yet. Requests │ ┃ are recorded once a pm call carries a run_id ⏺ Read(src/auth.ts) │ ┃ (/pm-orchestrate passes one). ⎿ 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 │ │ › /pm-board │ ⎿ projectman: ProjectMan: the projectman MCP server is not connect │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ProjectMan cache cost
No orchestrator run recorded yet. Requests are recorded once a pm call carries a run_id (/pm-orchestrate passes one).
Pane · pm-board
Not read yet. [ Refresh ] Press Refresh to read the board from the projectman MCP server.
README

ProjectMan

Git-native project management for Claude Code. Manage User Stories, Tasks, Estimation, and Burndown — all stored as markdown files in your repo.

Why?

Managing a growing number of projects with Claude Code leads to documentation drift, scattered TODO lists, and no structured way to plan work. ProjectMan solves this with:

  • Git-native storage — stories, tasks, and epics are markdown files with YAML frontmatter
  • MCP integration — Claude Code queries your project via MCP tools
  • Web dashboard — visual kanban board, burndown charts, and project overview in your browser
  • Minimal tokens — status summaries keep context window usage low
  • Task board — see what's ready to work on and grab tasks in one step

Quick Start

The Claude Code plugin is the supported install. It brings the pm skills, the pm agent, the projectman MCP server and a SessionStart status hook. It needs uv on PATH and a Claude Code release with plugin support. No minimum version is recorded yet; the plugin was verified on Claude Code 2.1.292.

# 1. In Claude Code, add the marketplace and install the plugin
/plugin marketplace add Biztactix-Ryan/ProjectMan
/plugin install projectman@projectman
/reload-plugins

# 2. Then:
#    /projectman:pm init           — describe your project (wizard)
#    /projectman:pm create story   — add user stories
#    /projectman:pm-do US-MP-1-1   — grab and execute a task

To create a store from a shell (projectman init --name "My Project" --prefix MP), or to run the web dashboard outside Claude Code, also install the CLI with pipx (see Install Options). On a clone whose PM store is already on the projectman branch, plain projectman init attaches that store instead of scaffolding a new one.

Already installed with projectman setup-claude? Install the plugin as above, then run projectman migrate-to-plugin --dry-run to preview and projectman migrate-to-plugin to remove the old agent, skill copies and MCP entries, so the plugin takes over. Restart Claude Code afterwards. setup-claude and refresh-skills are deprecated: they now only print these steps.

Full steps and verification: Install as a Claude Code plugin. Running the CLI from a checkout? Reinstall from your local path after every pull — Upgrading covers how to spot a stale install and the mcp<2 pin a stale environment breaks on.

Features

  • Stories & Tasks — structured work items with frontmatter metadata
  • Epics — strategic initiatives that group related stories
  • Web Dashboard — visual kanban board, epic/story/task detail views, drag-drop status updates, search, and burndown charts
  • Task Board & Grab — pm_board shows ready tasks, pm_grab claims them with readiness validation
  • Context Docs — VISION.md, ARCHITECTURE.md, DECISIONS.md for system-level context
  • Fibonacci Estimation — calibrated point system (1, 2, 3, 5, 8, 13)
  • Sprint Planning — guided workflow via /pm-plan
  • Drift Detection — pm_audit catches inconsistencies
  • Semantic Search — find items by meaning (optional, requires fastembed)
  • Tags — free-form labels on stories, epics, and tasks; filterable in search, board, and active views
  • Task Dependencies — depends_on links between sibling tasks with cycle detection and topological ordering
  • Activity Log — append-only JSONL audit trail of every create, update, delete, and archive
  • Run Log — per-item history of work attempts, outcomes, and notes, recorded via pm_update and read with pm_run_log
  • Sprint Tracking — create sprints with goals, dates, and planned stories; live progress rollup via pm_get_sprint
  • Auto-Commit & Push — pm_commit and pm_push commit and push the PM store on its own branch
  • Git Status Dashboard — pm_git_status shows the store's branch, alignment, dirty state, and ahead/behind
  • Burndown Tracking — points completed vs remaining

Architecture

User → Claude Code Skills (/pm, /pm-status, /pm-plan, /pm-do, /pm-orchestrate, /pm-autoscope, /pm-cleanup)
         → PM Agent (projectman:pm, from the plugin)
           → MCP Server (projectman serve, stdio)
             → Store (.project/ markdown files)
             → Embeddings (SQLite + fastembed)
             → Activity Log (JSONL append-only audit trail)
             → Web Dashboard (FastAPI + HTMX, launched via pm_web_start)

Install Options

# Install pipx first if you don't have it
sudo apt install pipx    # Debian/Ubuntu
brew install pipx        # macOS
pipx ensurepath          # Add to PATH (restart your shell after this)

# Recommended: install everything (MCP + web dashboard + semantic search)
pipx install "projectman[all] @ git+https://github.com/Biztactix-Ryan/ProjectMan.git"

# Or just MCP + web dashboard (no semantic search)
pipx install "projectman[mcp,web] @ git+https://github.com/Biztactix-Ryan/ProjectMan.git"

# Or with pip inside a venv
pip install "projectman[all] @ git+https://github.com/Biztactix-Ryan/ProjectMan.git"

Extras: [mcp] for Claude Code integration, [web] for the web dashboard, [embeddings] for semantic search, [all] for everything.

Upgrading

# Upgrade the package and refresh installed Claude skills in one step
projectman upgrade

# Just check the installed version and source
projectman upgrade --check

projectman upgrade upgrades the pipx CLI. The plugin updates on its own path: /plugin marketplace update projectman, /plugin update projectman@projectman, then /reload-plugins (see Updating). If old setup-claude skill copies are still installed, projectman upgrade re-renders them for one more release and tells you to run projectman migrate-to-plugin.

Documentation

License

MIT

Source 12 files
hooks/register.ts 551 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3import type {
4  ProjectmanBand,
5  ProjectmanBandState,
6  ProjectmanBoardView,
7  ProjectmanCostRun,
8  ProjectmanCostState,
9  ProjectmanGuardDenial,
10} from '../types'
11import { BOARD_OPENED, BOARD_PANE, BOARD_PANE_TITLE, boardPane } from './board-pane.tsx'
12import { findBannedGit, GIT_RULE_REASON } from './git-guard.ts'
13import { absolutize, mentionsStore, STORE_RULE_REASON, storeHit, storeRoots, targetOf } from './store-guard.ts'
14import type { ResolveFn } from './store-guard.ts'
15import {
16  boardColumns,
17  formatCommand,
18  MCP_SERVER_KEY,
19  parseYaml,
20  PM_BOARD,
21  PM_COMMANDS,
22  resultText,
23  summarizeCommand,
24} from './pm-views.ts'
25import type { PmCommand, Yaml } from './pm-views.ts'
26import { viewMode } from './surface.ts'
27import { BAND_ACTIVE, BAND_NOTE, BAND_SPRINTS, bandFrom, bandSprintCall, activeSprintId, sprintBand } from './sprint-band.tsx'
28import type { BandCall } from './sprint-band.tsx'
29import { movesBand, PM_TOOL } from './band-refresh.ts'
30import { bandSessionValue } from './band-session.ts'
31import {
32  attribute,
33  COST_RUNS_KEY,
34  keptRuns,
35  latestRun,
36  MAIN_LOOP,
37  markTurn,
38  pmRunCall,
39  recordRequest,
40  rememberRun,
41} from './cost-record.ts'
42import {
43  COST_COMMAND,
44  COST_COMMAND_DESCRIPTION,
45  COST_OPENED,
46  COST_PANE,
47  COST_PANE_TITLE,
48  costPane,
49  costSummary,
50  costView,
51} from './cost-pane.tsx'
52import type { CostView } from './cost-pane.tsx'
53
54/** What the board pane draws (US-PM-60-7): null until the board is first read. */
55const board = atom({ plugin: 'projectman', key: 'board' } as const, null as ProjectmanBoardView | null)
56
57/**
58 * What the sprint band draws (US-PM-62-6): null until the band is first
59 * drawn, which is also how the turn.complete hook knows a surface shows it.
60 */
61const band = atom({ plugin: 'projectman', key: 'band' } as const, null as ProjectmanBandState | null)
62
63/**
64 * The active orchestrator run's token usage (US-PM-61-6): null until a pm
65 * call carries a run id. Its `run` is also kept in `$.store` (persistCost).
66 */
67const cost = atom({ plugin: 'projectman', key: 'cost' } as const, null as ProjectmanCostState | null)
68
69/**
70 * The run summary's writes to `$.store`, coalesced: each write takes the
71 * summary as it stands then, and a request recorded while one runs makes
72 * one more after it, so the store never settles on an older summary. The
73 * module's own, like bandQueue.
74 */
75const costQueue = {
76  isStale: false,
77  isDraining: false,
78  /** Runs no longer active whose last summary is not yet written, by run id. */
79  retired: {} as Record<string, ProjectmanCostRun>,
80}
81
82/**
83 * Writes the retired runs' and the active run's summaries into the kept runs
84 * until none is outstanding: the one writer, so no write loses another's
85 * run. Never throws.
86 */
87async function drainCost($: EngineInterface): Promise<void> {
88  try {
89    while (costQueue.isStale) {
90      costQueue.isStale = false
91      const retired = Object.values(costQueue.retired)
92      costQueue.retired = {}
93      const active = (await read($, cost))?.run
94      const runs = active ? [...retired, active] : retired
95      if (runs.length === 0) continue
96      let kept: unknown = await $.store.get(COST_RUNS_KEY)
97      for (const run of runs) kept = rememberRun(kept, run)
98      await $.store.set(COST_RUNS_KEY, kept)
99    }
100  } catch {
101    // Persisting is a convenience; the live value stands without it.
102  } finally {
103    costQueue.isDraining = false
104  }
105}
106
107/** A run's kept summary from `$.store`; undefined when there is none or the store cannot be read. */
108async function keptRun($: EngineInterface, runId: string): Promise<ProjectmanCostRun | undefined> {
109  try {
110    return keptRuns(await $.store.get(COST_RUNS_KEY))[runId]
111  } catch {
112    return undefined
113  }
114}
115
116/**
117 * What the cost pane draws (US-PM-61-7): the live run, else the most recent
118 * summary kept in `$.store` (none when the store cannot be read).
119 */
120async function readCostView($: EngineInterface): Promise<CostView | null> {
121  const state = await read($, cost)
122  if (state?.run) return costView(state, null)
123  let kept = null
124  try {
125    kept = latestRun(await $.store.get(COST_RUNS_KEY))
126  } catch {
127    kept = null
128  }
129  return costView(state, kept)
130}
131
132/** Asks for the run summary to be persisted, on the timer so no request waits on the store. */
133function persistCost($: EngineInterface): void {
134  costQueue.isStale = true
135  if (costQueue.isDraining) return
136  costQueue.isDraining = true
137  try {
138    $.clock.after(0, () => void drainCost($))
139  } catch {
140    costQueue.isDraining = false
141  }
142}
143
144/** A command's MCP answers, parsed and in order, or why they could not be had. */
145type Fetched = { results: Yaml[] } | { error: string }
146
147/** Calls a command's projectman MCP tools straight from the mod: no model request. */
148async function fetchCommand($: EngineInterface, command: PmCommand): Promise<Fetched> {
149  const connected = await $.mcp.connect(MCP_SERVER_KEY)
150  if (!connected.isConnected) {
151    return { error: `ProjectMan: the projectman MCP server is not connected (${connected.message})` }
152  }
153  const results: Yaml[] = []
154  for (const call of command.calls) {
155    const answer = await $.mcp.call(connected.server, call.tool, call.args)
156    const text = resultText(answer)
157    if (answer.isError) return { error: `ProjectMan: ${call.tool} failed: ${text.trim() || 'no detail'}` }
158    results.push(parseYaml(text))
159  }
160  return { results }
161}
162
163/**
164 * The band read from the MCP server: the active sprint, the first task in
165 * progress and the note, together, then the sprint's live progress when one
166 * is active. Null (nothing drawn) when the server is not connected or any
167 * call errors, as every call does where the directory has no store.
168 */
169async function readBand($: EngineInterface): Promise<ProjectmanBand | null> {
170  const connected = await $.mcp.connect(MCP_SERVER_KEY)
171  if (!connected.isConnected) return null
172  const server = connected.server
173  const call = async ({ tool, args }: BandCall): Promise<Yaml> => {
174    const answer = await $.mcp.call(server, tool, args)
175    if (answer.isError) throw new Error(`${tool} failed`)
176    return parseYaml(resultText(answer))
177  }
178  const [sprints, active, note] = await Promise.all([call(BAND_SPRINTS), call(BAND_ACTIVE), call(BAND_NOTE)])
179  const id = activeSprintId(sprints)
180  const sprint = id === null ? null : await call(bandSprintCall(id))
181  return bandFrom(sprint, active, note)
182}
183
184/**
185 * Re-reads the band and writes `projectman.band`, which redraws it. A read
186 * while one is in flight does nothing; a failed read hides the band.
187 */
188async function refreshBand($: EngineInterface): Promise<void> {
189  if ((await read($, band))?.isReading) return
190  await update($, band, (v) => ({ view: v?.view ?? null, isReading: true, reads: v?.reads ?? 0 }))
191  let view: ProjectmanBand | null = null
192  try {
193    view = await readBand($)
194  } catch {
195    view = null
196  }
197  const done = view
198  await update($, band, (v) => ({ view: done, isReading: false, reads: (v?.reads ?? 0) + 1 }))
199}
200
201/**
202 * The band's re-reads, coalesced (US-PM-62-7): a burst of requests (a turn of
203 * pm writes, several draws before the first read lands) makes one read, and a
204 * request that arrives while a read runs makes one more after it, so the band
205 * never settles on state older than the last write. The module's own: a
206 * reload starts it over, as no read of the old module's is still its own.
207 */
208const bandQueue = {
209  /** A request no read has started on since. */
210  isStale: false,
211  /** The first draw's request: read though no band is drawn yet. */
212  isForced: false,
213  /** A read is scheduled or running. */
214  isDraining: false,
215}
216
217/** Runs the asked-for reads, one at a time, until none is outstanding; never throws. */
218async function drainBand($: EngineInterface): Promise<void> {
219  try {
220    while (bandQueue.isStale) {
221      bandQueue.isStale = false
222      const isForced = bandQueue.isForced
223      bandQueue.isForced = false
224      // No surface has drawn the band: nothing to keep fresh, no MCP call.
225      if (!isForced && (await read($, band)) === null) continue
226      await refreshBand($)
227    }
228  } catch {
229    // The band is a convenience; a failed read leaves it as it was.
230  } finally {
231    bandQueue.isDraining = false
232  }
233}
234
235/**
236 * Asks for a band re-read, run on the timer so the caller never waits on the
237 * MCP server; `force` reads though no band is drawn yet (the first draw).
238 */
239function rereadBand($: EngineInterface, force = false): void {
240  bandQueue.isStale = true
241  if (force) bandQueue.isForced = true
242  if (bandQueue.isDraining) return
243  bandQueue.isDraining = true
244  try {
245    $.clock.after(0, () => void drainBand($))
246  } catch {
247    bandQueue.isDraining = false
248  }
249}
250
251/** The board as read: columns from `pm_board`, one more load counted. */
252function loaded(previous: ProjectmanBoardView | null, data: Yaml): ProjectmanBoardView {
253  return { columns: boardColumns(data), loads: (previous?.loads ?? 0) + 1, isRefreshing: false, error: null }
254}
255
256/**
257 * The refresh button: re-reads `pm_board` and writes `projectman.board`,
258 * which redraws the pane. A press while a read is in flight does nothing; a
259 * failed read keeps the previous columns and shows why.
260 */
261async function refreshBoard($: EngineInterface): Promise<void> {
262  if ((await read($, board))?.isRefreshing) return
263  await update($, board, (v) => ({ columns: v?.columns ?? [], loads: v?.loads ?? 0, isRefreshing: true, error: null }))
264  let outcome: Fetched
265  try {
266    outcome = await fetchCommand($, PM_BOARD)
267  } catch {
268    outcome = { error: "ProjectMan: could not read the projectman MCP server's answer" }
269  }
270  const done = outcome
271  await update($, board, (v) =>
272    'results' in done
273      ? loaded(v, done.results[0] ?? null)
274      : { columns: v?.columns ?? [], loads: v?.loads ?? 0, isRefreshing: false, error: done.error },
275  )
276}
277
278/**
279 * The drawing path, where a surface draws. /pm-board writes the board to
280 * `projectman.board` and opens it as a pane (US-PM-60-7); a pane the surface
281 * could not seat leaves the full view as text. The status and next-task
282 * views answer with their full view as text.
283 */
284async function drawView($: EngineInterface, command: PmCommand, results: readonly Yaml[]): Promise<{ text: string }> {
285  if (command !== PM_BOARD) return { text: formatCommand(command, results) }
286  await update($, board, (v) => loaded(v, results[0] ?? null))
287  const opened = await $.ui.open({ id: BOARD_PANE, title: BOARD_PANE_TITLE }).catch(() => undefined)
288  if (opened?.isPlaced !== true) return { text: formatCommand(command, results) }
289  return { text: BOARD_OPENED }
290}
291
292// ProjectMan orchestration guards (US-PM-59). Enforce the worker rules on tool
293// calls in-process instead of in skill prose.
294//
295// Scope: a call made inside a subagent's loop (the event carries `agentId`) is
296// guarded by default; the main session is guarded only when the
297// `guard_main_session` userConfig option is on, so the interactive user is
298// not blocked.
299
300const lastDenial = { plugin: 'projectman', key: 'lastDenial' } as const
301
302export const register: Register = (on, options) => {
303  const guardMain = options.guard_main_session === true
304  const inScope = (e: ToolCallInput) => e.agentId !== undefined || guardMain
305
306  // US-PM-59-7: Bash running git checkout / restore / stash.
307  const bannedGit = (e: ToolCallInput): string | undefined =>
308    e.tool === 'Bash' && inScope(e) ? findBannedGit(e.command) : undefined
309
310  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
311    const hit = bannedGit(e)
312    if (hit === undefined) return next(e)
313    const denial: ProjectmanGuardDenial = { rule: `git ${hit}`, tool: e.tool, reason: GIT_RULE_REASON }
314    try {
315      await $.state.set(lastDenial, denial)
316    } catch {
317      // Recording is a convenience; the refusal stands without it.
318    }
319    return { deny: GIT_RULE_REASON }
320  }).catch(($, e, next) => {
321    if (next.called) return next(e)
322    // Re-entry or a failed hook: judge from `e` alone, refuse if unsure.
323    try {
324      return bannedGit(e) === undefined ? next(e) : { deny: GIT_RULE_REASON }
325    } catch {
326      return { deny: `${GIT_RULE_REASON} (the ProjectMan guard failed to read this command)` }
327    }
328  })
329
330  // US-PM-59-8: Write / Edit / NotebookEdit (MultiEdit where a build has it)
331  // aimed at the real .project store. MCP tools (mcp__projectman__*) are a
332  // different tool name and never match.
333  on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit', /^MultiEdit$/] }, async ($, e, next) => {
334    if (!inScope(e)) return next(e)
335    const target = targetOf(e as unknown as { tool: string } & Record<string, unknown>)
336    if (target === undefined) return next(e)
337    // Where a path lands, every link followed; undefined when it does not stat.
338    const resolve: ResolveFn = (path) =>
339      $.fs
340        .stat(path, { resolve: true })
341        .then((s) => s.realPath)
342        .catch(() => undefined)
343    const root = await $.session.root()
344    const cwd = await $.session.cwd()
345    const roots = await storeRoots([root, cwd], resolve)
346    const hit = await storeHit(absolutize(target, cwd), roots, resolve)
347    if (hit === undefined) return next(e)
348    const denial: ProjectmanGuardDenial = { rule: 'store write', tool: e.tool, reason: STORE_RULE_REASON }
349    try {
350      await $.state.set(lastDenial, denial)
351    } catch {
352      // Recording is a convenience; the refusal stands without it.
353    }
354    return { deny: STORE_RULE_REASON }
355  }).catch(($, e, next) => {
356    if (next.called) return next(e)
357    // Re-entry or a failed hook: judge the spelling alone, refuse if it names the store.
358    try {
359      if (!inScope(e)) return next(e)
360      const target = targetOf(e as unknown as { tool: string } & Record<string, unknown>)
361      return target !== undefined && mentionsStore(target) ? { deny: STORE_RULE_REASON } : next(e)
362    } catch {
363      return { deny: `${STORE_RULE_REASON} (the ProjectMan guard failed to read this call)` }
364    }
365  })
366
367  // US-PM-60-6: /pm-board, /pm-status-view and /pm-next-task answer from the
368  // projectman MCP server through `$.mcp.call`, with no model request, so a
369  // check costs no tokens. `immediate` lets them run while a turn streams.
370  on('session.start', async ($, e, next) => {
371    for (const command of PM_COMMANDS) {
372      await $.command.register({ name: command.name, description: command.description, immediate: true })
373    }
374    // US-PM-61-7: /pm-cost opens the cache-cost pane from the mod's own state.
375    await $.command.register({ name: COST_COMMAND, description: COST_COMMAND_DESCRIPTION, immediate: true })
376    return next(e)
377  })
378
379  // US-PM-62-8: tell the SessionStart command hook (`projectman
380  // session-context`, beneath this one in the classic chain) whether the
381  // sprint band draws in this session: PROJECTMAN_BAND_SESSION is set to the
382  // session's id where a listed surface raises AbovePrompt (terminal or
383  // desktop, BAND_SURFACES), unset everywhere else (none, vscode, mobile), before
384  // `next(e)` starts the command. See band-session.ts for why it is safe.
385  on('classic.SessionStart', async ($, e, next) => {
386    let surfaces: readonly string[] | null = null
387    try {
388      surfaces = await $.session.surfaces()
389    } catch {
390      surfaces = null
391    }
392    try {
393      await $.env.set('PROJECTMAN_BAND_SESSION', bandSessionValue(e.session_id, surfaces))
394    } catch {
395      // No signal set: the command prints the full block, as without the band.
396    }
397    return next(e)
398  }).catch(async ($, e, next) => {
399    if (next.called) return next(e)
400    try {
401      await $.env.set('PROJECTMAN_BAND_SESSION', undefined)
402    } catch {
403      // Unset or not, a stale value names another session's id.
404    }
405    return next(e)
406  })
407
408  // The names spelled out so `claude plugin validate` reads the matcher; a
409  // mod test holds them equal to PM_COMMANDS.
410  on('command.run', { command: ['pm-board', 'pm-status-view', 'pm-next-task'] }, async ($, e, next) => {
411    const command = PM_COMMANDS.find((c) => c.name === e.command)
412    if (command === undefined) return next(e)
413    const fetched = await fetchCommand($, command)
414    if ('error' in fetched) return { text: fetched.error }
415    // US-PM-60-8: a pane where a surface draws; a compact summary where none does.
416    if ((await viewMode($)) === 'pane') return drawView($, command, fetched.results)
417    return { text: summarizeCommand(command, fetched.results) }
418  }).catch(($, e) => ({ text: `ProjectMan: /${e.command} failed to read the MCP server's answer` }))
419
420  // US-PM-60-7: the board pane, drawn from `projectman.board` on any surface.
421  // The read subscribes the pane, so the refresh button's write redraws it.
422  // The id spelled out (BOARD_PANE) so `claude plugin validate` reads the matcher.
423  on('ui.render', { component: 'Pane', requestId: 'pm-board' }, async ($, e) => {
424    const view = await read($, board)
425    return boardPane($.ui.resolve(e), view, e.props.bodyColumns, () => void refreshBoard($))
426  })
427
428  // US-PM-61-7: /pm-cost opens the cache-cost pane where a surface draws and
429  // can seat it, and answers with the same content as text otherwise. It
430  // reads only the mod's own state and store: no MCP call, no model request.
431  on('command.run', { command: 'pm-cost' }, async ($, e, next) => {
432    if (e.command !== COST_COMMAND) return next(e)
433    const view = await readCostView($)
434    if ((await viewMode($)) !== 'pane') return { text: costSummary(view) }
435    const opened = await $.ui.open({ id: COST_PANE, title: COST_PANE_TITLE }).catch(() => undefined)
436    return { text: opened?.isPlaced === true ? COST_OPENED : costSummary(view) }
437  }).catch(($, e) => ({ text: `ProjectMan: /${e.command} failed to read the cost recording` }))
438
439  // US-PM-61-7: the cache-cost pane, drawn from `projectman.cost`; the read
440  // subscribes it, so each recorded request redraws it. The id spelled out
441  // (COST_PANE) so `claude plugin validate` reads the matcher.
442  on('ui.render', { component: 'Pane', requestId: 'pm-cost' }, async ($, e) => {
443    return costPane($.ui.resolve(e), await readCostView($))
444  })
445
446  // US-PM-62-6: the sprint band above the prompt, drawn from `projectman.band`.
447  // Its first draw schedules the first read (the timer runs it once this
448  // draw resolves, so the draw never waits on the MCP server); until then,
449  // and whenever there is nothing to show, it passes and the engine's own
450  // band shows. It yields to a survey.
451  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
452    const state = await read($, band)
453    if (state === null) rereadBand($, true)
454    if (e.props.hasSurvey || !state?.view) return next(e)
455    return sprintBand($.ui.resolve(e), state.view, e.props.bodyColumns)
456  })
457
458  // After each turn, re-read the band where a surface has drawn it: a turn
459  // is what moves a task or writes the note. No band drawn, no MCP call.
460  on('turn.complete', async ($, e, next) => {
461    const result = await next(e)
462    try {
463      rereadBand($)
464    } catch {
465      // The band is a convenience; the turn's answer stands without it.
466    }
467    return result
468  })
469
470  // US-PM-62-7: re-read the band after a pm tool call that writes (status,
471  // a new task, the note) answers without error, so it moves with the work
472  // rather than at the turn's end. The result is returned as `next` gave it,
473  // never waiting on the read, which runs on the timer once the call has
474  // answered; a refused or errored call reads nothing. Either server prefix.
475  on('tool.call', { tool: PM_TOOL }, async ($, e, next) => {
476    const result = await next(e)
477    try {
478      if (result.deny === undefined && result.isError !== true && movesBand(e as unknown as { tool: string } & Record<string, unknown>)) {
479        rereadBand($)
480      }
481    } catch {
482      // The band is a convenience; the tool's result stands without it.
483    }
484    return result
485  }).catch(($, e, next) => next(e))
486
487  // US-PM-61-6: a pm call carrying a run id makes that run the active one
488  // and, when it names a task, sets the task its loop (the main session or
489  // the subagent by `agentId`) works on. A run new to this session goes on
490  // from its summary in `$.store`, so a restart keeps adding to it. A
491  // refused call attributes nothing.
492  on('tool.call', { tool: PM_TOOL }, async ($, e, next) => {
493    const result = await next(e)
494    try {
495      const call = pmRunCall(e as unknown as { tool: string } & Record<string, unknown>)
496      if (call !== undefined && result.deny === undefined) {
497        const loop = e.agentId ?? MAIN_LOOP
498        const at = await $.clock.now()
499        const current = await read($, cost)
500        const seed = current?.run?.runId === call.runId ? undefined : await keptRun($, call.runId)
501        await update($, cost, (v) => {
502          // A run this call ends: its last summary is written with the next drain.
503          if (v?.run && v.run.runId !== call.runId) costQueue.retired[v.run.runId] = v.run
504          return attribute(v, call, loop, at, seed)
505        })
506        persistCost($)
507      }
508    } catch {
509      // Recording is a convenience; the tool's result stands without it.
510    }
511    return result
512  }).catch(($, e, next) => next(e))
513
514  // US-PM-61-6: each model request's input, cache-read and cache-write
515  // tokens, once its response is whole, charged to the task of the loop it
516  // was made in. Nothing is recorded while no run is active. The response
517  // streams through untouched.
518  on('turn.step', async function* ($, e, next) {
519    const response = yield* next(e)
520    try {
521      const usage = response.usage
522      if (usage !== null && (await read($, cost))?.run) {
523        const at = await $.clock.now()
524        const loop = e.agentId ?? MAIN_LOOP
525        await update($, cost, (v) => recordRequest(v, loop, usage, at, { turnId: e.turnId }))
526        persistCost($)
527      }
528    } catch {
529      // Recording is a convenience; the response stands without it.
530    }
531    return response
532  })
533
534  // US-PM-61-7: a main-loop turn the heartbeat prompt starts (`Heartbeat
535  // orch-...`, the /pm-orchestrate keep-alive) is marked, so its requests go
536  // to the run's heartbeat bucket; the next main-loop turn clears the mark.
537  // Written only when the mark changes, and only while a run is active.
538  on('turn.start', async ($, e, next) => {
539    try {
540      const loop = e.agentId ?? MAIN_LOOP
541      const current = await read($, cost)
542      if (markTurn(current, loop, e.turnId, e.text) !== current) {
543        await update($, cost, (v) => markTurn(v, loop, e.turnId, e.text))
544      }
545    } catch {
546      // Recording is a convenience; the turn starts without it.
547    }
548    return next(e)
549  })
550}
551
hooks/board-pane.tsx 84 lines
1import type { BoxProps, ButtonProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2import type { ProjectmanBoardColumn, ProjectmanBoardTask, ProjectmanBoardView } from '../types'
3
4// The board pane's tree (US-PM-60-7): a header with the refresh button, then
5// the available / in progress / blocked columns side by side, stacked where
6// the pane is too narrow for three. Drawn from `projectman.board` alone;
7// register.ts reads that value and hands the refresh in, so this file holds
8// no `$` and draws the same on every surface (only Box, Text and Button,
9// which every surface's table carries).
10
11/** The id the board pane is opened under and its `ui.render` matcher names. */
12export const BOARD_PANE = 'pm-board'
13export const BOARD_PANE_TITLE = 'ProjectMan board'
14/** /pm-board's answer once the pane is drawn. */
15export const BOARD_OPENED = 'ProjectMan board opened in a pane; press Refresh there to read it again.'
16
17/** The keys a test (or another plugin) addresses the pane's elements by. */
18export const REFRESH_KEY = 'refresh'
19export const COLUMNS_KEY = 'columns'
20export const columnKey = (key: ProjectmanBoardColumn['key']): string => `col-${key}`
21
22/** Below this many body columns the three columns stack. */
23const STACK_BELOW = 72
24const GAP = 2
25
26/** The elements the pane draws with: the part of any surface's table it uses. */
27export type PaneElements = {
28  Box: ElementConstructor<BoxProps>
29  Text: ElementConstructor<TextProps>
30  Button: ElementConstructor<ButtonProps>
31}
32
33function taskText(task: ProjectmanBoardTask): string {
34  let text = `${task.id}  ${task.title}`
35  if (task.points !== null) text += ` [${task.points}]`
36  if (task.assignee) text += ` @${task.assignee}`
37  if (task.isStale) text += ' (stale)'
38  return text
39}
40
41function statusText(view: ProjectmanBoardView | null): string {
42  if (view === null) return 'Not read yet.'
43  if (view.isRefreshing) return 'Refreshing…'
44  return `Read ${view.loads} time${view.loads === 1 ? '' : 's'} this session.`
45}
46
47/** The pane's tree for `view`, `bodyColumns` wide; `onRefresh` re-reads the board. */
48export function boardPane(
49  { Box, Text, Button }: PaneElements,
50  view: ProjectmanBoardView | null,
51  bodyColumns: number,
52  onRefresh: () => void,
53): RenderElement {
54  const stacked = bodyColumns < STACK_BELOW
55  const columnWidth = stacked ? undefined : Math.max(10, Math.floor((bodyColumns - GAP * 2) / 3))
56  const columns = view?.columns ?? []
57  return (
58    <Box key="pm-board" flexDirection="column">
59      <Box flexDirection="row" justifyContent="space-between" gap={GAP}>
60        <Text dimColor>{statusText(view)}</Text>
61        <Button key={REFRESH_KEY} label={view?.isRefreshing ? 'Refreshing…' : 'Refresh'} hotkey="r" onPress={onRefresh} />
62      </Box>
63      {view?.error ? <Text color="error">{view.error}</Text> : null}
64      {view === null ? <Text dimColor>Press Refresh to read the board from the projectman MCP server.</Text> : null}
65      <Box key={COLUMNS_KEY} flexDirection={stacked ? 'column' : 'row'} gap={stacked ? 1 : GAP}>
66        {columns.map((column) => (
67          <Box key={columnKey(column.key)} flexDirection="column" width={columnWidth} flexShrink={1}>
68            <Text bold>{`${column.label} (${column.total})`}</Text>
69            {column.tasks.length === 0 ? <Text dimColor>none</Text> : null}
70            {column.tasks.map((task) => (
71              <Text color={task.isStale ? 'warning' : undefined} wrap="truncate-end">
72                {taskText(task)}
73              </Text>
74            ))}
75            {column.total > column.tasks.length ? (
76              <Text dimColor>{`… and ${column.total - column.tasks.length} more`}</Text>
77            ) : null}
78          </Box>
79        ))}
80      </Box>
81    </Box>
82  )
83}
84
hooks/git-guard.ts 226 lines
1// Detects shell commands that run `git checkout`, `git restore` or
2// `git stash` (US-PM-59-7). Pure functions, no `$`: the tool.call hook in
3// register.ts decides scope and answers the refusal.
4//
5// Best-effort shell reading, not a full parser:
6// - the command is split into simple commands on `&&`, `||`, `;`, `|`, `&`,
7//   newlines, `(`, `)`, `{`/`}` words, `$(...)` and backticks;
8// - quoted text stays one literal word, so `echo "git stash"` and
9//   `git commit -m 'no git checkout'` pass; command substitutions inside
10//   double quotes (`"$(git stash)"`) are still read as commands;
11// - `bash -c '...'` / `sh -c` / `eval ...` scripts are read recursively;
12// - leading `VAR=value` assignments and `env`, `command`, `exec`, `sudo`,
13//   `nohup`, `time`, `nice` prefixes are skipped;
14// - git's global options before the subcommand (`-C path`, `-c k=v`,
15//   `--no-pager`, `--git-dir=...`) are skipped.
16// `git stash` is refused in every form, `stash list`/`show` included: the
17// worker rule bans stash outright and a worker has no need to read stashes.
18
19export const BANNED_GIT_SUBCOMMANDS = ['checkout', 'restore', 'stash'] as const
20
21export const GIT_RULE_REASON =
22  'ProjectMan worker rule: never run git checkout, git restore or git stash — ' +
23  "earlier tasks' uncommitted work lives in the working tree " +
24  'and these commands discard it. Undo your own edits with the Edit/Write tools instead.'
25
26const SEPARATOR = Symbol('separator')
27type Token = string | typeof SEPARATOR
28
29const GIT_OPTS_WITH_ARG = new Set([
30  '-C',
31  '-c',
32  '--git-dir',
33  '--work-tree',
34  '--namespace',
35  '--super-prefix',
36  '--config-env',
37])
38const PREFIX_COMMANDS = new Set(['env', 'command', 'exec', 'sudo', 'nohup', 'time', 'nice', 'builtin'])
39const SHELL_KEYWORDS = new Set(['!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', '{', '}'])
40const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
41const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
42const REDIRECT = /^\d*(?:[<>]|&>)/
43
44/** Reads `src` from `start` (just past the opener) to the matching `)`. */
45function readParenGroup(src: string, start: number): [string, number] {
46  let depth = 1
47  let i = start
48  let quote: string | null = null
49  for (; i < src.length; i++) {
50    const c = src[i]
51    if (quote) {
52      if (c === '\\' && quote === '"') i++
53      else if (c === quote) quote = null
54      continue
55    }
56    if (c === '\\') i++
57    else if (c === "'" || c === '"') quote = c
58    else if (c === '(') depth++
59    else if (c === ')' && --depth === 0) break
60  }
61  return [src.slice(start, i), i]
62}
63
64/** Splits a command line into words and separators; collects nested scripts. */
65function tokenize(src: string, nested: string[]): Token[] {
66  const tokens: Token[] = []
67  let word = ''
68  let inWord = false
69  const endWord = () => {
70    if (inWord) tokens.push(word)
71    word = ''
72    inWord = false
73  }
74  const sep = () => {
75    endWord()
76    tokens.push(SEPARATOR)
77  }
78
79  for (let i = 0; i < src.length; i++) {
80    const c = src[i]
81    if (c === '\\') {
82      if (src[i + 1] === '\n') {
83        i++
84        continue
85      }
86      word += src[i + 1] ?? ''
87      inWord = true
88      i++
89    } else if (c === "'") {
90      const end = src.indexOf("'", i + 1)
91      const stop = end === -1 ? src.length : end
92      word += src.slice(i + 1, stop)
93      inWord = true
94      i = stop
95    } else if (c === '"') {
96      inWord = true
97      for (i++; i < src.length && src[i] !== '"'; i++) {
98        const d = src[i]
99        if (d === '\\') {
100          word += src[i + 1] ?? ''
101          i++
102        } else if (d === '$' && src[i + 1] === '(') {
103          const [inner, end] = readParenGroup(src, i + 2)
104          nested.push(inner)
105          word += '$(...)'
106          i = end
107        } else if (d === '`') {
108          const end = src.indexOf('`', i + 1)
109          const stop = end === -1 ? src.length : end
110          nested.push(src.slice(i + 1, stop))
111          word += '`...`'
112          i = stop
113        } else word += d
114      }
115    } else if (c === '#' && !inWord) {
116      while (i < src.length && src[i] !== '\n') i++
117      sep()
118    } else if (c === ' ' || c === '\t') {
119      endWord()
120    } else if (c === '\n' || c === ';' || c === '|' || c === '(' || c === ')' || c === '`') {
121      sep()
122    } else if (c === '&') {
123      // `2>&1` / `>&2` stay part of the redirection word.
124      if (inWord && /[<>]$/.test(word)) word += c
125      else sep()
126    } else if (c === '$' && src[i + 1] === '(') {
127      sep()
128      i++ // the `(` ; the closing `)` is a separator too
129    } else {
130      word += c
131      inWord = true
132    }
133  }
134  endWord()
135  return tokens
136}
137
138function splitCommands(tokens: Token[]): string[][] {
139  const commands: string[][] = []
140  let current: string[] = []
141  for (const t of tokens) {
142    if (t === SEPARATOR) {
143      if (current.length) commands.push(current)
144      current = []
145    } else current.push(t)
146  }
147  if (current.length) commands.push(current)
148  return commands
149}
150
151function basename(word: string): string {
152  return word.slice(word.lastIndexOf('/') + 1)
153}
154
155/** The banned git subcommand a simple command runs, if any. */
156function bannedInSimple(words: string[], nested: string[]): string | undefined {
157  const at = (j: number): string => words[j] ?? ''
158  let i = 0
159  const skipRedirects = () => {
160    while (i < words.length && REDIRECT.test(at(i))) {
161      // `> file` (operator alone) also consumes its target.
162      i += /^\d*(?:[<>]{1,2}|&>)$/.test(at(i)) ? 2 : 1
163    }
164  }
165  for (;;) {
166    skipRedirects()
167    if (i >= words.length) return undefined
168    const w = at(i)
169    if (ASSIGNMENT.test(w) || SHELL_KEYWORDS.has(w)) {
170      i++
171      continue
172    }
173    if (PREFIX_COMMANDS.has(basename(w))) {
174      i++
175      while (i < words.length && (at(i).startsWith('-') || ASSIGNMENT.test(at(i)) || /^\d+$/.test(at(i)))) i++
176      continue
177    }
178    break
179  }
180
181  const cmd = basename(at(i))
182  if (SHELLS.has(cmd)) {
183    for (let j = i + 1; j < words.length; j++) {
184      if (/^-[A-Za-z]*c[A-Za-z]*$/.test(at(j)) && j + 1 < words.length) {
185        nested.push(at(j + 1))
186        break
187      }
188    }
189    return undefined
190  }
191  if (cmd === 'eval') {
192    nested.push(words.slice(i + 1).join(' '))
193    return undefined
194  }
195  if (cmd !== 'git') return undefined
196
197  for (i++; i < words.length; i++) {
198    const w = at(i)
199    if (GIT_OPTS_WITH_ARG.has(w)) {
200      i++
201      continue
202    }
203    if (w.startsWith('-') || REDIRECT.test(w)) continue
204    return (BANNED_GIT_SUBCOMMANDS as readonly string[]).includes(w) ? w : undefined
205  }
206  return undefined
207}
208
209/**
210 * The banned git subcommand (`checkout`, `restore` or `stash`) the shell
211 * command runs anywhere in it, or undefined when it runs none.
212 */
213export function findBannedGit(command: string, depth = 0): string | undefined {
214  if (depth > 4) return undefined
215  const nested: string[] = []
216  for (const words of splitCommands(tokenize(command, nested))) {
217    const hit = bannedInSimple(words, nested)
218    if (hit) return hit
219  }
220  for (const inner of nested) {
221    const hit = findBannedGit(inner, depth + 1)
222    if (hit) return hit
223  }
224  return undefined
225}
226
hooks/store-guard.ts 185 lines
1// Locates a Write/Edit/NotebookEdit target and tells whether it lies inside
2// the real ProjectMan store (US-PM-59-8). No `$` here: the stat function is
3// passed in, so the path logic is plain and testable; register.ts decides
4// scope and answers the refusal.
5//
6// "The real store" is `<project root>/.project` (and `<cwd>/.project` when
7// the session's cwd differs), resolved to its real path: symlinks followed,
8// `.` and `..` folded. In this repo `.project` is a git worktree checkout;
9// resolving it makes the comparison independent of how a tool spells it.
10//
11// A target is placed by its real path: the file itself when it exists, else
12// its nearest existing ancestor resolved plus the missing tail folded
13// lexically (the missing directories will be created as plain directories,
14// so `..` in the tail lands where the fold says). Only a target placed under
15// a resolved store is refused; a tmp_path fixture's own `.project`, or a
16// sibling like `.project-backup/` or `my.project/`, resolves elsewhere.
17
18export const STORE_RULE_REASON =
19  'ProjectMan worker rule: never write to the real .project store directly — ' +
20  'use the pm MCP tools; tests must use a tmp_path store.'
21
22export const STORE_DIR = '.project'
23
24/** Answers where a path lands (`realPath`), or undefined when it cannot stat it. */
25export type ResolveFn = (path: string) => Promise<string | undefined>
26
27/** The tools whose target file the guard checks, and the field naming it. */
28export const GUARDED_TOOLS = {
29  Write: 'file_path',
30  Edit: 'file_path',
31  MultiEdit: 'file_path',
32  NotebookEdit: 'notebook_path',
33} as const
34
35/** The target path a guarded tool call names, if it names one. */
36export function targetOf(e: { tool: string } & Record<string, unknown>): string | undefined {
37  const field = (GUARDED_TOOLS as Record<string, string>)[e.tool]
38  const value = field === undefined ? undefined : e[field]
39  return typeof value === 'string' ? value : undefined
40}
41
42const SEP = /[\\/]/
43
44function sepOf(path: string): string {
45  return path.includes('\\') && !path.includes('/') ? '\\' : '/'
46}
47
48function trimSep(path: string): string {
49  return path.length > 1 ? path.replace(/[\\/]+$/, '') : path
50}
51
52/** True when `real` is `root` itself or lies beneath it. */
53export function isUnder(real: string, root: string): boolean {
54  const r = trimSep(root)
55  const p = trimSep(real)
56  if (p === r) return true
57  const base = /[\\/]$/.test(r) ? r : r + sepOf(r)
58  return p.startsWith(base)
59}
60
61/** `path` made absolute against `cwd` when it is relative (as the tools read it). */
62export function absolutize(path: string, cwd: string): string {
63  if (path === '' || /^[\\/]/.test(path) || /^[A-Za-z]:/.test(path)) return path
64  return `${trimSep(cwd)}${sepOf(cwd)}${path}`
65}
66
67/** True when some segment of the spelling is exactly `.project`. */
68export function mentionsStore(path: string): boolean {
69  return path.split(SEP).includes(STORE_DIR)
70}
71
72/**
73 * Where `path` lands: its own real path when it exists, else the nearest
74 * existing ancestor's real path joined with the missing tail (`.` folded).
75 * `..` is applied to the real path of what precedes it, as the kernel does.
76 * Undefined when no ancestor resolves.
77 */
78export async function placePath(path: string, resolve: ResolveFn, depth = 0): Promise<string | undefined> {
79  if (path === '' || depth > 32) return undefined
80  // `..` after a symlink climbs from where the link lands (the kernel's
81  // order), not from its spelling, and $.fs.stat folds `..` by spelling
82  // first: so place what comes before the first `..`, then go on from that
83  // real path's parent.
84  const segs = path.split(SEP)
85  const up = segs.indexOf('..')
86  if (up > 0) {
87    const head = await placePath(segs.slice(0, up).join('/') || '/', resolve, depth + 1)
88    if (head === undefined) return undefined
89    const parent = head.replace(/[\\/][^\\/]*$/, '') || '/'
90    const rest = segs.slice(up + 1).join('/')
91    return placePath(rest ? `${trimSep(parent)}/${rest}` : parent, resolve, depth + 1)
92  }
93  const own = await resolve(path)
94  if (own !== undefined) return own
95
96  const absolute = /^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path)
97  const parts = path.split(SEP)
98  const tail: string[] = []
99  while (parts.length > 0) {
100    tail.unshift(parts.pop() as string)
101    let folder = parts.join('/')
102    if (folder === '') folder = absolute ? '/' : '.'
103    if (/^[A-Za-z]:$/.test(folder)) folder += '/'
104    const base = await resolve(folder)
105    if (base === undefined) {
106      if (folder === '.' || folder === '/' || /^[A-Za-z]:\/$/.test(folder)) return undefined
107      continue
108    }
109    const sep = sepOf(base)
110    const out = trimSep(base).split(SEP)
111    const rootPrefix = out[0] === '' ? [''] : /^[A-Za-z]:$/.test(out[0] ?? '') ? [out[0] as string] : []
112    let folded = false
113    for (const seg of tail) {
114      if (seg === '' || seg === '.') continue
115      if (seg === '..') {
116        folded = true
117        if (out.length > rootPrefix.length) out.pop()
118      } else out.push(seg)
119    }
120    const raw = out.join(sep)
121    const joined = raw === '' || /^[A-Za-z]:$/.test(raw) ? raw + sep : raw
122    // A `..` may have climbed back onto existing ground (a symlink, the
123    // store): place the folded spelling again.
124    return folded ? placePath(joined, resolve, depth + 1) : joined
125  }
126  return undefined
127}
128
129/**
130 * `path` with `.` and `..` folded by spelling alone, the way a tool that
131 * normalises its path before opening it reads it; `..` above a relative
132 * start is kept.
133 */
134export function foldLexically(path: string): string {
135  const absolute = /^[\\/]/.test(path)
136  const out: string[] = []
137  for (const seg of path.split(SEP)) {
138    if (seg === '' || seg === '.') continue
139    if (seg === '..' && out.length > 0 && out[out.length - 1] !== '..') out.pop()
140    else if (seg !== '..' || !absolute) out.push(seg)
141  }
142  const body = out.join('/')
143  return absolute ? '/' + body : body === '' ? '.' : body
144}
145
146/**
147 * The resolved store roots for the given project directories: each
148 * `<dir>/.project` that exists, by real path, without duplicates.
149 */
150export async function storeRoots(dirs: readonly string[], resolve: ResolveFn): Promise<string[]> {
151  const roots: string[] = []
152  for (const dir of new Set(dirs)) {
153    const real = await resolve(`${trimSep(dir)}/${STORE_DIR}`)
154    if (real !== undefined && !roots.includes(real)) roots.push(real)
155  }
156  return roots
157}
158
159/** The store root `path` lands under, or undefined when it lands outside all. */
160export async function storeHit(
161  path: string,
162  roots: readonly string[],
163  resolve: ResolveFn,
164): Promise<string | undefined> {
165  if (roots.length === 0) return undefined
166  // Read both ways a tool may open it: as given (links first, then `..`, as
167  // the kernel does) and folded by spelling first (as a normalising tool does).
168  const spellings = [path]
169  if (!/^[A-Za-z]:/.test(path)) {
170    const folded = foldLexically(path)
171    if (folded !== path) spellings.push(folded)
172  }
173  for (const spelling of spellings) {
174    const real = await placePath(spelling, resolve)
175    // Unplaceable: refuse only a spelling that names the store.
176    if (real === undefined) {
177      if (mentionsStore(spelling)) return STORE_DIR
178      continue
179    }
180    const hit = roots.find((root) => isUnder(real, root))
181    if (hit !== undefined) return hit
182  }
183  return undefined
184}
185
hooks/pm-views.ts 470 lines
1// Board, status and next-task views (US-PM-60-6). Pure functions, no `$`:
2// register.ts registers the slash commands, calls the projectman MCP server
3// with `$.mcp.call` and hands the result here to be read and formatted. No
4// model request is made anywhere on this path.
5//
6// The server answers in YAML (PyYAML `default_flow_style=False`, width
7// 10000), so `parseYaml` reads that subset: block mappings and sequences,
8// plain / single- / double-quoted / literal scalars, and the empty flow
9// collections `[]` and `{}`. It is not a general YAML parser.
10
11import type { ProjectmanBoardColumn, ProjectmanBoardTask } from '../types'
12
13/** One slash command this module serves: its name, line and MCP calls. */
14export type PmCommand = {
15  name: string
16  description: string
17  /** The projectman MCP tools it calls, in order, with their arguments. */
18  calls: readonly { tool: string; args: Record<string, unknown> }[]
19}
20
21// Names chosen not to clash with the plugin's skills (skills/pm, pm-status,
22// pm-next, ...): /pm-status and /pm-next are skills already.
23export const PM_BOARD: PmCommand = {
24  name: 'pm-board',
25  description: 'ProjectMan task board, read straight from the MCP server (no Claude turn)',
26  calls: [{ tool: 'pm_board', args: { brief: true, limit: 10 } }],
27}
28export const PM_STATUS_VIEW: PmCommand = {
29  name: 'pm-status-view',
30  description: 'ProjectMan project status, read straight from the MCP server (no Claude turn)',
31  calls: [{ tool: 'pm_status', args: {} }],
32}
33export const PM_NEXT_TASK: PmCommand = {
34  name: 'pm-next-task',
35  description: 'ProjectMan next ready task and the saved next-session note (no Claude turn)',
36  calls: [
37    { tool: 'pm_board', args: { brief: true, limit: 3 } },
38    { tool: 'pm_next', args: {} },
39  ],
40}
41export const PM_COMMANDS: readonly PmCommand[] = [PM_BOARD, PM_STATUS_VIEW, PM_NEXT_TASK]
42
43/** The manifest key of the plugin's MCP server (`$.mcp.connect` takes it). */
44export const MCP_SERVER_KEY = 'projectman'
45
46// ---------------------------------------------------------------- results
47
48type ContentBlock = { type: string; text?: string }
49export type ToolResultLike = { content: readonly ContentBlock[]; isError: boolean; structuredContent?: unknown }
50
51/**
52 * The tool's text: its `text` blocks joined. FastMCP may also send the
53 * string as `{"result": "..."}` (structured output); either is unwrapped.
54 */
55export function resultText(result: ToolResultLike): string {
56  const structured = result.structuredContent as { result?: unknown } | undefined
57  if (structured && typeof structured.result === 'string') return structured.result
58  const text = result.content
59    .filter((b) => b.type === 'text' && typeof b.text === 'string')
60    .map((b) => b.text as string)
61    .join('\n')
62  const trimmed = text.trim()
63  if (trimmed.startsWith('{')) {
64    try {
65      const parsed = JSON.parse(trimmed) as { result?: unknown }
66      if (parsed && typeof parsed.result === 'string') return parsed.result
67    } catch {
68      // Not JSON: the text is the answer.
69    }
70  }
71  return text
72}
73
74// ---------------------------------------------------------------- YAML subset
75
76export type Yaml = null | boolean | number | string | Yaml[] | { [key: string]: Yaml }
77
78type Line = { indent: number; text: string }
79const BLANK: Line = { indent: -1, text: '' }
80
81function toLines(src: string): Line[] {
82  const out: Line[] = []
83  for (const raw of src.replace(/\r\n?/g, '\n').split('\n')) {
84    if (raw.trim() === '' || /^\s*#/.test(raw) || raw === '---' || raw === '...') {
85      out.push({ indent: -1, text: '' }) // blank: kept for quoted/literal scalars
86      continue
87    }
88    const indent = raw.length - raw.trimStart().length
89    out.push({ indent, text: raw.trimEnd().slice(indent) })
90  }
91  return out
92}
93
94function isSeqItem(text: string): boolean {
95  return text === '-' || text.startsWith('- ')
96}
97
98/** Index of the `: ` (or a final `:`) that ends a mapping key, outside quotes; -1 when none. */
99function keyEnd(text: string): number {
100  let i = 0
101  if (text[0] === "'" || text[0] === '"') {
102    const q = text[0]
103    i = 1
104    while (i < text.length) {
105      if (q === "'" && text[i] === "'" && text[i + 1] === "'") i += 2
106      else if (q === '"' && text[i] === '\\') i += 2
107      else if (text[i] === q) break
108      else i++
109    }
110    i++
111  }
112  for (; i < text.length; i++) {
113    if (text[i] === ':' && (i + 1 === text.length || text[i + 1] === ' ')) return i
114  }
115  return -1
116}
117
118function plainScalar(s: string): Yaml {
119  if (s === '' || s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null
120  if (s === 'true' || s === 'True' || s === 'TRUE') return true
121  if (s === 'false' || s === 'False' || s === 'FALSE') return false
122  if (s === '[]') return []
123  if (s === '{}') return {}
124  if (/^[-+]?\d+$/.test(s)) return Number(s)
125  if (/^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$/.test(s)) return Number(s)
126  return s
127}
128
129const ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', '0': '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ', _: ' ', e: '\u001b', a: '\u0007', b: '\b', f: '\f', v: '\v', N: '\u0085', L: '
', P: '
' }
130
131/** Folds a quoted scalar's raw source (between the quotes) the YAML way. */
132function foldQuoted(raw: string, double: boolean): string {
133  // Line folding: a lone break is a space, n+1 breaks are n newlines; a
134  // double-quoted line ending in `\` joins with no space.
135  const parts = raw.split('\n')
136  let out = ''
137  let blanks = 0
138  for (let i = 0; i < parts.length; i++) {
139    const rawPart = parts[i] ?? ''
140    let part = i === 0 ? rawPart : rawPart.trimStart()
141    if (i < parts.length - 1) part = part.trimEnd()
142    if (i > 0 && part === '' && i < parts.length - 1) {
143      blanks++
144      continue
145    }
146    if (i > 0) {
147      const joinNoSpace = double && /(^|[^\\])(\\\\)*\\$/.test(out)
148      if (joinNoSpace) out = out.slice(0, -1)
149      else out += blanks > 0 ? '\n'.repeat(blanks) : ' '
150    }
151    blanks = 0
152    out += part
153  }
154  if (!double) return out.replace(/''/g, "'")
155  return out.replace(/\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/g, (_, c: string) => {
156    if (c.length > 1) return String.fromCodePoint(parseInt(c.slice(1), 16))
157    return ESCAPES[c] ?? c
158  })
159}
160
161/** Index just past the closing quote of a scalar opened at `src[0]`, or -1. */
162function closingQuote(src: string): number {
163  const q = src[0]
164  for (let i = 1; i < src.length; i++) {
165    if (q === "'" && src[i] === "'") {
166      if (src[i + 1] === "'") i++
167      else return i + 1
168    } else if (q === '"' && src[i] === '\\') i++
169    else if (q === '"' && src[i] === '"') return i + 1
170  }
171  return -1
172}
173
174class Parser {
175  private i = 0
176  constructor(private readonly lines: Line[]) {}
177
178  private skipBlank(): void {
179    while (this.i < this.lines.length && (this.lines[this.i]?.indent ?? 0) < 0) this.i++
180  }
181
182  private peek(): Line | undefined {
183    this.skipBlank()
184    return this.lines[this.i]
185  }
186
187  parseDocument(): Yaml {
188    const first = this.peek()
189    if (!first) return null
190    return this.parseNode(first.indent)
191  }
192
193  private parseNode(indent: number): Yaml {
194    const line = this.peek()
195    if (!line || line.indent < indent) return null
196    if (isSeqItem(line.text)) return this.parseSeq(line.indent)
197    if (keyEnd(line.text) >= 0) return this.parseMap(line.indent)
198    // A lone scalar (a document that is one value).
199    return this.scalarAt(line.text, line.indent)
200  }
201
202  private parseSeq(indent: number): Yaml[] {
203    const out: Yaml[] = []
204    for (let line = this.peek(); line && line.indent === indent && isSeqItem(line.text); line = this.peek()) {
205      const rest = line.text === '-' ? '' : line.text.slice(2).trimStart()
206      const inner = indent + (line.text.length - rest.length)
207      if (rest === '') {
208        this.i++
209        const next = this.peek()
210        out.push(next && next.indent > indent ? this.parseNode(next.indent) : null)
211      } else if (isSeqItem(rest) || keyEnd(rest) >= 0) {
212        // `- key: value` opens a mapping (or `- - x` a sequence) at the item's column.
213        this.lines[this.i] = { indent: inner, text: rest }
214        out.push(this.parseNode(inner))
215      } else {
216        out.push(this.scalarAt(rest, indent))
217      }
218    }
219    return out
220  }
221
222  private parseMap(indent: number): { [key: string]: Yaml } {
223    const out: { [key: string]: Yaml } = {}
224    for (let line = this.peek(); line && line.indent === indent && !isSeqItem(line.text); line = this.peek()) {
225      const end = keyEnd(line.text)
226      if (end < 0) break
227      const rawKey = line.text.slice(0, end).trim()
228      const key = rawKey[0] === "'" || rawKey[0] === '"' ? foldQuoted(rawKey.slice(1, -1), rawKey[0] === '"') : rawKey
229      const rest = line.text.slice(end + 1).trim()
230      if (rest === '') {
231        this.i++
232        const next = this.peek()
233        if (next && (next.indent > indent || (next.indent === indent && isSeqItem(next.text)))) {
234          out[key] = this.parseNode(next.indent)
235        } else out[key] = null
236      } else {
237        out[key] = this.scalarAt(rest, indent)
238      }
239    }
240    return out
241  }
242
243  /** Reads the scalar starting at `src` on the current line; consumes its lines. */
244  private scalarAt(src: string, parentIndent: number): Yaml {
245    if (src[0] === "'" || src[0] === '"') {
246      let raw = src
247      let close = closingQuote(raw)
248      this.i++
249      while (close < 0 && this.i < this.lines.length) {
250        const l = this.lines[this.i++] ?? BLANK
251        raw += '\n' + (l.indent < 0 ? '' : l.text)
252        close = closingQuote(raw)
253      }
254      return foldQuoted(raw.slice(1, close < 0 ? raw.length : close - 1), src[0] === '"')
255    }
256    if (/^[|>][-+]?\d*$/.test(src)) {
257      const chomp = src.includes('-') ? 'strip' : src.includes('+') ? 'keep' : 'clip'
258      this.i++
259      const body: string[] = []
260      let blockIndent = -1
261      while (this.i < this.lines.length) {
262        const l = this.lines[this.i] ?? BLANK
263        if (l.indent >= 0 && l.indent <= parentIndent) break
264        if (l.indent >= 0 && blockIndent < 0) blockIndent = l.indent
265        body.push(l.indent < 0 ? '' : ' '.repeat(l.indent - blockIndent) + l.text)
266        this.i++
267      }
268      let text = src[0] === '>' ? body.join('\n').replace(/([^\n])\n(?=[^\n ])/g, '$1 ') : body.join('\n')
269      if (chomp !== 'keep') text = text.replace(/\n+$/, '')
270      return chomp === 'clip' && text !== '' ? text + '\n' : text
271    }
272    this.i++
273    return plainScalar(src)
274  }
275}
276
277/** Parses the YAML subset the projectman server writes. */
278export function parseYaml(src: string): Yaml {
279  return new Parser(toLines(src)).parseDocument()
280}
281
282// ---------------------------------------------------------------- formatting
283
284export type Row = { [key: string]: Yaml }
285
286export function asMap(v: Yaml | undefined): Row {
287  return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {}
288}
289export function asRows(v: Yaml | undefined): Row[] {
290  return Array.isArray(v) ? v.map(asMap) : []
291}
292export function num(v: Yaml | undefined): number | undefined {
293  return typeof v === 'number' ? v : undefined
294}
295export function str(v: Yaml | undefined): string {
296  return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)
297}
298
299function pts(v: Yaml | undefined): string {
300  const n = num(v)
301  return n === undefined ? '' : ` [${n} pt${n === 1 ? '' : 's'}]`
302}
303
304function taskLine(row: Row): string {
305  let line = `  ${str(row.id)}  ${str(row.title)}${pts(row.points)}`
306  if (row.assignee) line += ` @${str(row.assignee)}`
307  if (row.stale === true) line += ' (stale claim)'
308  return line
309}
310
311const BOARD_GROUPS: readonly [key: string, label: string][] = [
312  ['in_progress', 'In progress'],
313  ['in_review', 'In review'],
314  ['blocked', 'Blocked'],
315  ['available', 'Available'],
316  ['not_ready', 'Not ready'],
317]
318
319/** The board (`pm_board`'s answer, parsed) as transcript text. */
320export function formatBoard(data: Yaml): string {
321  const top = asMap(data)
322  const board = asMap(top.board)
323  const summary = asMap(top.summary)
324  const counts = BOARD_GROUPS.map(([key, label]) => `${num(summary[key]) ?? asRows(board[key]).length} ${label.toLowerCase()}`)
325  const out = [`ProjectMan board: ${counts.join(' · ')}`]
326  for (const [key, label] of BOARD_GROUPS) {
327    const rows = asRows(board[key])
328    const total = num(summary[key]) ?? rows.length
329    if (total === 0) continue
330    out.push('', `${label} (${total})`)
331    for (const row of rows) out.push(taskLine(row))
332    if (total > rows.length) out.push(`  … and ${total - rows.length} more`)
333  }
334  const stale = Array.isArray(top.stale_tasks) ? top.stale_tasks.map(str) : []
335  if (stale.length > 0) out.push('', `Stale claims: ${stale.join(', ')}`)
336  return out.join('\n')
337}
338
339/** The board pane's columns (US-PM-60-7), in the order drawn. */
340const PANE_COLUMNS: readonly [key: ProjectmanBoardColumn['key'], label: string][] = [
341  ['available', 'Available'],
342  ['in_progress', 'In progress'],
343  ['blocked', 'Blocked'],
344]
345
346/** `pm_board`'s answer, parsed, as the pane's available / in progress / blocked columns. */
347export function boardColumns(data: Yaml): ProjectmanBoardColumn[] {
348  const top = asMap(data)
349  const board = asMap(top.board)
350  const summary = asMap(top.summary)
351  const stale = new Set(Array.isArray(top.stale_tasks) ? top.stale_tasks.map(str) : [])
352  return PANE_COLUMNS.map(([key, label]) => {
353    const rows = asRows(board[key])
354    const tasks: ProjectmanBoardTask[] = rows.map((row) => ({
355      id: str(row.id),
356      title: str(row.title),
357      points: num(row.points) ?? null,
358      assignee: row.assignee ? str(row.assignee) : null,
359      isStale: row.stale === true || stale.has(str(row.id)),
360    }))
361    return { key, label, total: Math.max(num(summary[key]) ?? 0, tasks.length), tasks }
362  })
363}
364
365/** The project status (`pm_status`'s answer, parsed) as transcript text. */
366export function formatStatus(data: Yaml): string {
367  const s = asMap(data)
368  const name = str(s.project) || 'Project'
369  const out = [`${name}: ${str(s.completion) || '?'} complete (${num(s.completed_points) ?? 0}/${num(s.total_points) ?? 0} pts)`]
370  out.push(`${num(s.epics) ?? 0} epics · ${num(s.stories) ?? 0} stories · ${num(s.tasks) ?? 0} tasks`)
371  const by = Object.entries(asMap(s.by_status))
372  if (by.length > 0) out.push(`By status: ${by.map(([k, v]) => `${k} ${str(v)}`).join(' · ')}`)
373  return out.join('\n')
374}
375
376/** The next ready task (from `pm_board`) and the saved note (`pm_next`) as text. */
377export function formatNext(boardData: Yaml, noteData: Yaml): string {
378  const top = asMap(boardData)
379  const available = asRows(asMap(top.board).available)
380  const out: string[] = []
381  if (available.length === 0) {
382    out.push('Next task: none ready. Run /pm-board to see what is in flight or not ready.')
383  } else {
384    const [first = {}, ...rest] = available
385    out.push(`Next task: ${str(first.id)}  ${str(first.title)}${pts(first.points)}`)
386    if (rest.length > 0) {
387      out.push('Then:')
388      for (const row of rest) out.push(taskLine(row))
389    }
390  }
391  const note = asMap(noteData).note
392  out.push('')
393  if (typeof note === 'string' && note.trim() !== '') out.push('Note for the next session:', note.trimEnd())
394  else out.push('No next-session note saved.')
395  return out.join('\n')
396}
397
398/** Formats a command's MCP answers (one parsed result per call, in order). */
399export function formatCommand(command: PmCommand, results: readonly Yaml[]): string {
400  if (command === PM_BOARD) return formatBoard(results[0] ?? null)
401  if (command === PM_STATUS_VIEW) return formatStatus(results[0] ?? null)
402  return formatNext(results[0] ?? null, results[1] ?? null)
403}
404
405// ---------------------------------------------------------------- compact text
406//
407// US-PM-60-8: where no surface draws (VS Code chat, `claude -p`) a command
408// answers with these instead of a pane: ids rather than titles, one line per
409// group, a few lines in all.
410
411/** How many ids a compact group lists before `+n more`. */
412const COMPACT_IDS = 3
413/** Where a compact line is cut (the note's first line, a task title). */
414const COMPACT_WIDTH = 100
415
416function clip(text: string): string {
417  return text.length > COMPACT_WIDTH ? `${text.slice(0, COMPACT_WIDTH - 1)}…` : text
418}
419
420/** The board in a few lines: the counts, then the ids in each live group. */
421export function summarizeBoard(data: Yaml): string {
422  const top = asMap(data)
423  const board = asMap(top.board)
424  const summary = asMap(top.summary)
425  const counts = BOARD_GROUPS.map(([key, label]) => `${num(summary[key]) ?? asRows(board[key]).length} ${label.toLowerCase()}`)
426  const out = [`ProjectMan board: ${counts.join(' · ')}`]
427  for (const [key, label] of BOARD_GROUPS) {
428    if (key === 'not_ready') continue
429    const ids = asRows(board[key]).map((row) => str(row.id))
430    const total = num(summary[key]) ?? ids.length
431    if (total === 0 || ids.length === 0) continue
432    const shown = ids.slice(0, COMPACT_IDS)
433    const more = total - shown.length
434    out.push(`${label}: ${shown.join(', ')}${more > 0 ? ` (+${more} more)` : ''}`)
435  }
436  const stale = Array.isArray(top.stale_tasks) ? top.stale_tasks.map(str) : []
437  if (stale.length > 0) out.push(`Stale claims: ${stale.join(', ')}`)
438  return out.join('\n')
439}
440
441/** The project status on one line. */
442export function summarizeStatus(data: Yaml): string {
443  const s = asMap(data)
444  const name = str(s.project) || 'Project'
445  return (
446    `${name}: ${str(s.completion) || '?'} complete (${num(s.completed_points) ?? 0}/${num(s.total_points) ?? 0} pts)` +
447    ` · ${num(s.epics) ?? 0} epics · ${num(s.stories) ?? 0} stories · ${num(s.tasks) ?? 0} tasks`
448  )
449}
450
451/** The next ready task and the note's first line, two lines. */
452export function summarizeNext(boardData: Yaml, noteData: Yaml): string {
453  const [first] = asRows(asMap(asMap(boardData).board).available)
454  const out = [first ? clip(`Next task: ${str(first.id)}  ${str(first.title)}${pts(first.points)}`) : 'Next task: none ready.']
455  const note = asMap(noteData).note
456  if (typeof note === 'string' && note.trim() !== '') {
457    const lines = note.trim().split('\n')
458    const head = clip(`Note: ${(lines[0] ?? '').trim()}`)
459    out.push(lines.length > 1 && !head.endsWith('…') ? `${head} …` : head)
460  } else out.push('No next-session note saved.')
461  return out.join('\n')
462}
463
464/** A command's compact text summary, for a surface that draws nothing. */
465export function summarizeCommand(command: PmCommand, results: readonly Yaml[]): string {
466  if (command === PM_BOARD) return summarizeBoard(results[0] ?? null)
467  if (command === PM_STATUS_VIEW) return summarizeStatus(results[0] ?? null)
468  return summarizeNext(results[0] ?? null, results[1] ?? null)
469}
470
hooks/surface.ts 31 lines
1import type { RenderSurface } from 'claude-code'
2
3// Does this surface draw panes? (US-PM-60-8). The board, status and next
4// views open a pane where a surface draws one and answer with a compact
5// text summary where none does; US-PM-60-7's pane branches on this too.
6//
7// `$.session.surfaces()` lists every surface the session draws on: the
8// terminal under the REPL, Claude Code Desktop, the mobile app. It is empty
9// where nothing draws: a plain `claude -p` run, and Claude Code for VS Code,
10// which attaches no surface yet (hooks and commands run, nothing drawn shows).
11
12/** How a view answers: drawn as a pane, or as transcript text. */
13export type ViewMode = 'pane' | 'text'
14
15/** The part of `$` the check reads, so a test can hand it a stand-in. */
16export type SurfaceSource = { session: { surfaces: () => Promise<readonly RenderSurface[]> } }
17
18/** `pane` when any surface draws, `text` when the list is empty. */
19export function viewModeFor(surfaces: readonly string[]): ViewMode {
20  return surfaces.length > 0 ? 'pane' : 'text'
21}
22
23/** Asks the session where it draws; `text` when it cannot say. */
24export async function viewMode($: SurfaceSource): Promise<ViewMode> {
25  try {
26    return viewModeFor(await $.session.surfaces())
27  } catch {
28    return 'text'
29  }
30}
31
hooks/sprint-band.tsx 146 lines
1import type { ElementConstructor, RenderElement, RenderSurface, TextProps, BoxProps } from 'claude-code'
2import type { ProjectmanBand } from '../types'
3import { asMap, asRows, num, str } from './pm-views.ts'
4import type { Yaml } from './pm-views.ts'
5
6// The sprint band above the prompt (US-PM-62-6): the active sprint's live
7// points, the task in flight and the head of the NEXT.md note, e.g.
8//
9//   Sprint 20 — 11/19 pts · ▶ US-PM-62-6 Draw the sprint band · next: Close EPIC-PM-7
10//
11// so the person sees where things stand without the SessionStart hook
12// putting status text in the model's context. Pure: register.ts makes the
13// MCP calls below with `$.mcp.call`, hands the answers to `bandFrom`, keeps
14// the result in `projectman.band` and draws it with `sprintBand`.
15//
16// What shows: each part only when it has something to say. No active sprint
17// drops the sprint part (the task and note still show); with no part at all,
18// or no store in the directory (the MCP tools answer with an error), or an
19// unconnected server, the band draws nothing and the engine's own band shows.
20
21/** One MCP call the band makes. */
22export type BandCall = { tool: string; args: Record<string, unknown> }
23
24/** The calls made on every read, together: the active sprint's id, the first task in flight, the note. */
25export const BAND_SPRINTS: BandCall = { tool: 'pm_list_sprints', args: { status: 'active', fields: 'name' } }
26export const BAND_ACTIVE: BandCall = { tool: 'pm_active', args: { limit: 1 } }
27export const BAND_NOTE: BandCall = { tool: 'pm_next', args: {} }
28/** Made only when a sprint is active: its live per-story progress. */
29export const bandSprintCall = (id: string): BandCall => ({ tool: 'pm_get_sprint', args: { sprint_id: id } })
30
31/** The key of the band's Box, which a test (or another plugin) addresses it by. */
32export const BAND_KEY = 'pm-band'
33
34/**
35 * The surfaces the band draws on: those that raise the `AbovePrompt` render
36 * hook, which the engine's types document as "Raised on the terminal and
37 * desktop surfaces only". vscode and mobile list as surfaces but raise no
38 * AbovePrompt, so the band never draws there (US-PM-62-8 reads this).
39 */
40export const BAND_SURFACES: readonly RenderSurface[] = ['terminal', 'desktop']
41
42const TITLE_WIDTH = 60
43const NOTE_WIDTH = 80
44const SEP = ' · '
45
46function clip(text: string, width: number): string {
47  return text.length > width ? `${text.slice(0, width - 1)}…` : text
48}
49
50/** The active sprint's id from `pm_list_sprints(status=active)`, or null when none is active. */
51export function activeSprintId(sprints: Yaml): string | null {
52  const [first] = asRows(asMap(sprints).sprints)
53  const id = first ? str(first.id) : ''
54  return id === '' ? null : id
55}
56
57/** "Sprint 20 — The Project Asks" reads as "Sprint 20": the name up to its first dash. */
58function shortName(name: string, id: string): string {
59  const head = name.split(/\s+[—–-]\s+/)[0]?.trim() ?? ''
60  return head || name.trim() || id
61}
62
63/** `pm_get_sprint`'s answer as live points: done stories' points over the planned total. */
64export function sprintProgress(sprint: Yaml): ProjectmanBand['sprint'] {
65  const s = asMap(sprint)
66  const id = str(s.id)
67  if (id === '' && s.name === undefined) return null
68  const stories = asRows(s.story_progress)
69  const done = stories.filter((r) => r.status === 'done').reduce((n, r) => n + (num(r.points) ?? 0), 0)
70  const summed = stories.reduce((n, r) => n + (num(r.points) ?? 0), 0)
71  const total = stories.length > 0 ? summed : (num(s.planned_points) ?? 0)
72  return { name: shortName(str(s.name), id), donePoints: done, totalPoints: total }
73}
74
75/** The first in-progress task from `pm_active`, and how many more there are. */
76export function taskInFlight(active: Yaml): ProjectmanBand['task'] {
77  const a = asMap(active)
78  const [first] = asRows(a.active_tasks)
79  if (!first || str(first.id) === '') return null
80  const total = num(a.active_tasks_total) ?? 1
81  return { id: str(first.id), title: str(first.title), more: Math.max(0, total - 1) }
82}
83
84/**
85 * The note's head from `pm_next`: its first line of text. A heading line
86 * (`### 2026-10-21`, as an appended note opens) is passed over for the text
87 * under it; a note of headings alone shows its first, the marks dropped.
88 */
89export function noteHead(next: Yaml): string | null {
90  const note = asMap(next).note
91  if (typeof note !== 'string') return null
92  const lines = note.split('\n').map((l) => l.trim()).filter((l) => l !== '')
93  const text = lines.find((l) => !l.startsWith('#')) ?? lines[0]?.replace(/^#+\s*/, '')
94  return text ? text : null
95}
96
97/** The band from the MCP answers; null when no part has anything to show. */
98export function bandFrom(sprint: Yaml, active: Yaml, next: Yaml): ProjectmanBand | null {
99  const band: ProjectmanBand = {
100    sprint: sprint === null ? null : sprintProgress(sprint),
101    task: taskInFlight(active),
102    note: noteHead(next),
103  }
104  return band.sprint === null && band.task === null && band.note === null ? null : band
105}
106
107/** Each part's text, in the order drawn. */
108export function bandParts(band: ProjectmanBand): string[] {
109  const parts: string[] = []
110  if (band.sprint) parts.push(`${band.sprint.name} — ${band.sprint.donePoints}/${band.sprint.totalPoints} pts`)
111  if (band.task) {
112    const more = band.task.more > 0 ? ` (+${band.task.more} more)` : ''
113    parts.push(`▶ ${band.task.id} ${clip(band.task.title, TITLE_WIDTH)}${more}`)
114  }
115  if (band.note) parts.push(`next: ${clip(band.note, NOTE_WIDTH)}`)
116  return parts
117}
118
119/**
120 * The band's lines for `bodyColumns`: one when it fits, else two, the note
121 * (or, with no note, the task) on the second. Each line is cut at its end
122 * where it is still too wide.
123 */
124export function bandLines(band: ProjectmanBand, bodyColumns: number): string[] {
125  const parts = bandParts(band)
126  const one = parts.join(SEP)
127  if (parts.length < 2 || one.length <= bodyColumns) return [one]
128  return [parts.slice(0, -1).join(SEP), parts[parts.length - 1] ?? '']
129}
130
131/** The elements the band draws with. */
132export type BandElements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
133
134/** The band's tree for `band`, `bodyColumns` wide. */
135export function sprintBand({ Box, Text }: BandElements, band: ProjectmanBand, bodyColumns: number): RenderElement {
136  return (
137    <Box key={BAND_KEY} flexDirection="column">
138      {bandLines(band, bodyColumns).map((line) => (
139        <Text dimColor wrap="truncate-end">
140          {line}
141        </Text>
142      ))}
143    </Box>
144  )
145}
146
hooks/band-refresh.ts 64 lines
1// Which projectman MCP calls move the sprint band (US-PM-62-7). After one of
2// them answers without error, register.ts re-reads the band so the person
3// sees the new status without waiting for the turn to end.
4//
5// The tool names the model calls carry the server's prefix: a plugin install
6// names the server `plugin_projectman_projectman`, so its tools are
7// `mcp__plugin_projectman_projectman__pm_*`; a bare `projectman` server (an
8// old install, or a dev checkout) gives `mcp__projectman__pm_*`. Both match.
9
10/** Any projectman tool, under either server name; the name after the prefix is captured. */
11export const PM_TOOL = /^mcp__(?:plugin_projectman_)?projectman__(pm_\w+)$/
12
13/**
14 * Every projectman tool that writes: those its server does not declare
15 * `readOnlyHint=True`. tests/test_band_refresh.py holds this list equal to
16 * the server's registry, so a new writing tool cannot be left out.
17 */
18// BEGIN PM_WRITE_TOOLS
19export const PM_WRITE_TOOLS: readonly string[] = [
20  'pm_accept',
21  'pm_answer_decision',
22  'pm_archive',
23  'pm_archive_many',
24  'pm_commit',
25  'pm_create_decision',
26  'pm_create_epic',
27  'pm_create_sprint',
28  'pm_create_story',
29  'pm_create_task',
30  'pm_create_tasks',
31  'pm_done_next',
32  'pm_grab',
33  'pm_next',
34  'pm_park',
35  'pm_push',
36  'pm_reindex',
37  'pm_release',
38  'pm_retry',
39  'pm_review',
40  'pm_update',
41  'pm_update_doc',
42  'pm_update_many',
43  'pm_update_sprint',
44]
45// END PM_WRITE_TOOLS
46
47const WRITES = new Set(PM_WRITE_TOOLS)
48
49/** The pm tool a call names (`pm_update`), under either prefix; undefined for any other tool. */
50export function pmToolName(tool: string): string | undefined {
51  return PM_TOOL.exec(tool)?.[1]
52}
53
54/**
55 * Whether a call, as sent, can change what the band shows: a writing pm
56 * tool. `pm_next` writes only with `text` or `clear`; with neither it reads.
57 */
58export function movesBand(call: { tool: string } & Record<string, unknown>): boolean {
59  const name = pmToolName(call.tool)
60  if (name === undefined || !WRITES.has(name)) return false
61  if (name === 'pm_next') return (call.text !== undefined && call.text !== null) || call.clear === true
62  return true
63}
64
hooks/band-session.ts 46 lines
1// The sprint band's signal to the SessionStart status block (US-PM-62-8).
2//
3// `projectman session-context`, the plugin's SessionStart command hook, puts
4// a status block in the model's context. Where the sprint band draws, the
5// person already sees sprint progress and the task in flight above the
6// prompt, so the block can shrink to what steers the model (open decisions,
7// the NEXT.md note, the last handoff).
8//
9// The signal: the mod's `classic.SessionStart` hook sets the environment
10// variable PROJECTMAN_BAND_SESSION to the session's own id, or unsets it,
11// before calling `next(e)`. Hooks modules sit above the non-managed settings
12// and plugin command hooks in the classic chain, and `$.env.set` reaches
13// every process the session starts after, so the command hook beneath reads
14// the answer for this very SessionStart: same session, same moment.
15//
16// Where the band draws: only on a surface that raises the `AbovePrompt`
17// render hook, terminal and desktop (BAND_SURFACES, from the band's own
18// module). vscode and mobile list in `$.session.surfaces()` but raise no
19// AbovePrompt, so a session that draws only there gets no band.
20//
21// Why it is safe: the CLI reduces the block only when the value equals its
22// payload's `session_id` exactly. Where the band cannot draw the mod unsets
23// it: nothing listed (`claude -p`, the SDK, Claude Code for VS Code, which
24// attaches no surface today), or only surfaces without AbovePrompt (vscode,
25// mobile), or a surface list that fails. A stale value (the session before a
26// /clear, a parent session a nested `claude -p` inherits it from) names
27// another id; a disabled mod sets nothing. Each of those prints today's full
28// block. A surface draws the band only once the MCP read lands, which no
29// SessionStart can wait for, so the block is reduced, never dropped: the
30// note and a pointer to pm_context stay.
31
32import { BAND_SURFACES } from './sprint-band.tsx'
33
34/** True when one of the listed surfaces raises AbovePrompt, so the band can draw there. */
35export function bandDraws(surfaces: readonly string[] | null): boolean {
36  if (surfaces === null) return false
37  return surfaces.some((s) => (BAND_SURFACES as readonly string[]).includes(s))
38}
39
40/** What the mod sets PROJECTMAN_BAND_SESSION to: the id where the band draws, else unset. */
41export function bandSessionValue(sessionId: unknown, surfaces: readonly string[] | null): string | undefined {
42  if (typeof sessionId !== 'string' || sessionId === '') return undefined
43  if (!bandDraws(surfaces)) return undefined
44  return sessionId
45}
46
hooks/cost-record.ts 321 lines
1// Per-request token usage of an orchestrator run, per task (US-PM-61-6).
2//
3// register.ts hooks `turn.step` to read each model request's usage as the API
4// reported it, and the projectman `tool.call`s to learn which run is active
5// and which task each loop is working on. These are the pure parts: what a pm
6// call says about the run, how a request is added to the run's summary, and
7// how summaries are kept in `$.store`. The live value is `projectman.cost`.
8//
9// Attribution:
10// - A run is active from the first pm call that carries a `run_id`; a call
11//   naming another run id starts that run (a fresh summary, or the one kept
12//   in `$.store` for it, so a restarted session goes on adding to it).
13// - Each loop (the main session, or a subagent by its `agentId`) works on the
14//   task its last run-carrying pm call named (`pm_grab`, `pm_accept`, ...).
15// - A subagent that has named no task yet (a worker's requests before its
16//   own `pm_grab`) is charged to the task the main loop last named: the
17//   orchestrator dispatches a worker right after claiming its task.
18// - A request no task can be found for is counted in `unattributed`.
19// - Requests made while no run is active are not recorded.
20//
21// Gaps and heartbeats (US-PM-61-7):
22// - Each request carries the gap since the previous request in its loop (each
23//   loop has its own cached prefix) and whether that gap outlived the cache
24//   TTL, so the prefix had expired.
25// - A main-loop turn whose prompt opens `Heartbeat orch-` is the
26//   /pm-orchestrate keep-alive firing (the marker orch_cost reads too); its
27//   requests go to the run's `heartbeat` bucket, never to a task.
28// - A request whose wait since its loop's last non-heartbeat request outlived
29//   the TTL, while a heartbeat in between kept its own gap under it, is a
30//   bridged wait: its cache-read tokens are the writes the heartbeat avoided.
31
32import type {
33  ProjectmanCostRequest,
34  ProjectmanCostRun,
35  ProjectmanCostState,
36  ProjectmanHeartbeatCounts,
37  ProjectmanTokenCounts,
38} from '../types'
39import { pmToolName } from './band-refresh.ts'
40
41/**
42 * The prompt-cache TTL the orchestrator runs on: one hour (promptCacheTtl=1h,
43 * US-PM-54). A request after a longer gap in its loop found its prefix gone.
44 */
45export const CACHE_TTL_MS = 60 * 60 * 1000
46
47/** How the heartbeat prompt opens: `Heartbeat <run-id>: ...`, every run id `orch-`. */
48export const HEARTBEAT_MARKER = 'Heartbeat orch-'
49
50/** The `pastTtl` bucket of heartbeat requests and of requests no task was found for. */
51export const HEARTBEAT_BUCKET = 'heartbeat'
52export const UNATTRIBUTED_BUCKET = 'unattributed'
53
54/** The `$.store` key holding the kept run summaries, by run id. */
55export const COST_RUNS_KEY = 'costRuns'
56
57/** How many run summaries `$.store` keeps; the least recently updated go first. */
58export const KEPT_RUNS = 20
59
60/** How many of the active run's requests `projectman.cost` lists, newest last. */
61export const KEPT_REQUESTS = 200
62
63/** The loop key of the main session; a subagent's is its `agentId`. */
64export const MAIN_LOOP = 'main'
65
66/** The pm tools whose `task_id` (or `id`) names the task the calling loop works on. */
67const ATTRIBUTING = new Set([
68  'pm_grab',
69  'pm_accept',
70  'pm_done_next',
71  'pm_retry',
72  'pm_park',
73  'pm_review',
74  'pm_release',
75  'pm_update',
76])
77
78/** A task id (`US-PM-61-6`); stories, epics and sprints never name a task. */
79const TASK_ID = /^US-[A-Za-z0-9]+-\d+-\d+$/
80
81/** The four token counts of one request, as the API reported them. */
82export type RequestUsage = {
83  input_tokens: number
84  output_tokens: number
85  cache_read_input_tokens: number
86  cache_creation_input_tokens: number
87}
88
89/** What a pm call says about the run: its id, and the task it names (null for none). */
90export type PmRunCall = { runId: string; taskId: string | null }
91
92/**
93 * The run id a projectman tool call carries, and the task it attributes the
94 * calling loop to; undefined for any other tool or a call with no run id.
95 */
96export function pmRunCall(call: { tool: string } & Record<string, unknown>): PmRunCall | undefined {
97  const name = pmToolName(call.tool)
98  if (name === undefined) return undefined
99  const runId = call.run_id
100  if (typeof runId !== 'string' || runId.trim() === '') return undefined
101  if (!ATTRIBUTING.has(name)) return { runId, taskId: null }
102  const id = typeof call.task_id === 'string' ? call.task_id : call.id
103  return { runId, taskId: typeof id === 'string' && TASK_ID.test(id) ? id : null }
104}
105
106/** Counts with nothing in them. */
107export function emptyCounts(): ProjectmanTokenCounts {
108  return { requests: 0, input: 0, cacheRead: 0, cacheWrite: 0, output: 0 }
109}
110
111/** A count the response left out, or one that is not a number, reads as zero. */
112const n = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0)
113
114/** `counts` with one more request of `usage` added. */
115export function addUsage(counts: ProjectmanTokenCounts, usage: RequestUsage): ProjectmanTokenCounts {
116  return {
117    requests: counts.requests + 1,
118    input: counts.input + n(usage.input_tokens),
119    cacheRead: counts.cacheRead + n(usage.cache_read_input_tokens),
120    cacheWrite: counts.cacheWrite + n(usage.cache_creation_input_tokens),
121    output: counts.output + n(usage.output_tokens),
122  }
123}
124
125/** A fresh summary for a run starting now. */
126export function newRun(runId: string, at: number): ProjectmanCostRun {
127  return { runId, startedAt: at, updatedAt: at, tasks: {}, unattributed: emptyCounts(), totals: emptyCounts() }
128}
129
130/** The kept summaries as `$.store` holds them; anything else reads as none. */
131export function keptRuns(stored: unknown): Record<string, ProjectmanCostRun> {
132  if (stored === null || typeof stored !== 'object' || Array.isArray(stored)) return {}
133  const runs: Record<string, ProjectmanCostRun> = {}
134  for (const [id, run] of Object.entries(stored as Record<string, unknown>)) {
135    if (run !== null && typeof run === 'object' && (run as ProjectmanCostRun).runId === id) {
136      runs[id] = run as ProjectmanCostRun
137    }
138  }
139  return runs
140}
141
142/** The most recently updated kept summary; null when none is kept. */
143export function latestRun(stored: unknown): ProjectmanCostRun | null {
144  let latest: ProjectmanCostRun | null = null
145  for (const run of Object.values(keptRuns(stored))) {
146    if (latest === null || run.updatedAt > latest.updatedAt) latest = run
147  }
148  return latest
149}
150
151/** The kept summaries with `run` written in, at most `keep`, the least recently updated dropped. */
152export function rememberRun(
153  stored: unknown,
154  run: ProjectmanCostRun,
155  keep: number = KEPT_RUNS,
156): Record<string, ProjectmanCostRun> {
157  const runs = Object.values({ ...keptRuns(stored), [run.runId]: run })
158  runs.sort((a, b) => b.updatedAt - a.updatedAt)
159  const kept: Record<string, ProjectmanCostRun> = {}
160  for (const r of runs.slice(0, Math.max(1, keep))) kept[r.runId] = r
161  return kept
162}
163
164/**
165 * The state after a run-carrying pm call made in `loop`: the run started
166 * when it is a new one (from `seed`, its kept summary, when there is one),
167 * and the loop's task set when the call names one.
168 */
169export function attribute(
170  state: ProjectmanCostState | null,
171  call: PmRunCall,
172  loop: string,
173  at: number,
174  seed?: ProjectmanCostRun,
175): ProjectmanCostState {
176  const isSameRun = state?.run?.runId === call.runId
177  const run = isSameRun && state?.run ? state.run : (seed ?? newRun(call.runId, at))
178  const loops = isSameRun && state ? { ...state.loops } : {}
179  const requests = isSameRun && state ? state.requests : []
180  if (call.taskId !== null) loops[loop] = call.taskId
181  return { ...timeline(state), run, requests, loops }
182}
183
184/** The gap-timing and heartbeat fields of `state`, those it has. */
185function timeline(state: ProjectmanCostState | null): Partial<ProjectmanCostState> {
186  const kept: Partial<ProjectmanCostState> = {}
187  if (state?.lastAt !== undefined) kept.lastAt = state.lastAt
188  if (state?.lastWorkAt !== undefined) kept.lastWorkAt = state.lastWorkAt
189  if (state?.heartbeatTurn !== undefined) kept.heartbeatTurn = state.heartbeatTurn
190  return kept
191}
192
193/** The task a request made in `loop` is charged to; null when none can be found. */
194export function taskOf(state: ProjectmanCostState, loop: string): string | null {
195  return state.loops[loop] ?? state.loops[MAIN_LOOP] ?? null
196}
197
198/** True for the /pm-orchestrate heartbeat's prompt. */
199export function isHeartbeatPrompt(text: unknown): boolean {
200  return typeof text === 'string' && text.trimStart().startsWith(HEARTBEAT_MARKER)
201}
202
203/**
204 * The state as a turn starts in `loop`: a main-loop turn opened by the
205 * heartbeat prompt becomes the heartbeat turn, any other main-loop turn ends
206 * it. The same object when nothing changes (no run, a subagent's turn, or
207 * the mark already as it should be), so a caller can skip the write.
208 */
209export function markTurn(
210  state: ProjectmanCostState | null,
211  loop: string,
212  turnId: string,
213  text: unknown,
214): ProjectmanCostState | null {
215  if (state === null || state.run === null || loop !== MAIN_LOOP) return state
216  const heartbeatTurn = isHeartbeatPrompt(text) ? turnId : null
217  if ((state.heartbeatTurn ?? null) === heartbeatTurn) return state
218  return { ...state, heartbeatTurn }
219}
220
221/** Heartbeat counts with nothing in them. */
222export function emptyHeartbeat(): ProjectmanHeartbeatCounts {
223  return { ...emptyCounts(), bridged: 0, avoidedWrites: 0 }
224}
225
226/** What `recordRequest` needs to know beyond the usage. */
227export type RecordOptions = {
228  /** The turn the request was made in; a match with `heartbeatTurn` makes it a heartbeat. */
229  turnId?: string
230  /** The cache TTL gaps are held to; CACHE_TTL_MS by default. */
231  ttlMs?: number
232}
233
234/**
235 * The state with one request made in `loop` recorded: listed (the newest
236 * KEPT_REQUESTS kept) and added to its task's counts (or the heartbeat
237 * bucket) and the run's totals, with the gap since its loop's previous
238 * request. Unchanged when no run is active.
239 */
240export function recordRequest(
241  state: ProjectmanCostState | null,
242  loop: string,
243  usage: RequestUsage & { model?: string },
244  at: number,
245  options: RecordOptions = {},
246): ProjectmanCostState | null {
247  if (state === null || state.run === null) return state
248  const ttlMs = options.ttlMs ?? CACHE_TTL_MS
249  const isHeartbeat =
250    loop === MAIN_LOOP && options.turnId !== undefined && options.turnId === (state.heartbeatTurn ?? null)
251  const taskId = isHeartbeat ? null : taskOf(state, loop)
252  const run = state.run
253
254  const previousAt = state.lastAt?.[loop]
255  const previousWorkAt = state.lastWorkAt?.[loop]
256  const gapMs = previousAt === undefined ? null : Math.max(0, at - previousAt)
257  const isPastTtl = gapMs !== null && gapMs > ttlMs
258  const cacheRead = n(usage.cache_read_input_tokens)
259  // The wait since the loop's last real request outlived the TTL, a
260  // heartbeat came between, and the cache still served the prefix.
261  const isBridged =
262    !isHeartbeat &&
263    !isPastTtl &&
264    previousAt !== undefined &&
265    previousWorkAt !== undefined &&
266    previousAt > previousWorkAt &&
267    at - previousWorkAt > ttlMs &&
268    cacheRead > 0
269
270  const tasks = { ...run.tasks }
271  if (!isHeartbeat && taskId !== null) tasks[taskId] = addUsage(tasks[taskId] ?? emptyCounts(), usage)
272
273  const next: ProjectmanCostRun = {
274    ...run,
275    updatedAt: at,
276    tasks,
277    unattributed: !isHeartbeat && taskId === null ? addUsage(run.unattributed, usage) : run.unattributed,
278    totals: addUsage(run.totals, usage),
279  }
280  if (isHeartbeat || isBridged) {
281    const was = run.heartbeat ?? emptyHeartbeat()
282    next.heartbeat = {
283      ...(isHeartbeat ? addUsage(was, usage) : pickCounts(was)),
284      bridged: was.bridged + (isBridged ? 1 : 0),
285      avoidedWrites: was.avoidedWrites + (isBridged ? cacheRead : 0),
286    }
287  }
288  if (isPastTtl) {
289    const bucket = isHeartbeat ? HEARTBEAT_BUCKET : (taskId ?? UNATTRIBUTED_BUCKET)
290    next.pastTtl = { ...run.pastTtl, [bucket]: (run.pastTtl?.[bucket] ?? 0) + 1 }
291  }
292
293  const request: ProjectmanCostRequest = {
294    at,
295    taskId,
296    agentId: loop === MAIN_LOOP ? null : loop,
297    model: typeof usage.model === 'string' ? usage.model : '',
298    input: n(usage.input_tokens),
299    cacheRead,
300    cacheWrite: n(usage.cache_creation_input_tokens),
301    output: n(usage.output_tokens),
302    gapMs,
303    isPastTtl,
304    isHeartbeat,
305  }
306  return {
307    ...timeline(state),
308    loops: state.loops,
309    requests: [...state.requests, request].slice(-KEPT_REQUESTS),
310    run: next,
311    lastAt: { ...state.lastAt, [loop]: at },
312    lastWorkAt: isHeartbeat ? (state.lastWorkAt ?? {}) : { ...state.lastWorkAt, [loop]: at },
313  }
314}
315
316/** The five token counts of `counts`, nothing else. */
317function pickCounts(counts: ProjectmanTokenCounts): ProjectmanTokenCounts {
318  const { requests, input, cacheRead, cacheWrite, output } = counts
319  return { requests, input, cacheRead, cacheWrite, output }
320}
321
hooks/cost-pane.tsx 207 lines
1import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2import type { ProjectmanCostRequest, ProjectmanCostRun, ProjectmanCostState, ProjectmanTokenCounts } from '../types'
3import { CACHE_TTL_MS, HEARTBEAT_BUCKET, UNATTRIBUTED_BUCKET } from './cost-record.ts'
4
5// The cache-cost pane's tree (US-PM-61-7): the active (or most recent) run's
6// per-task input / cache-read / cache-write totals, the heartbeat requests in
7// their own bucket with the cache writes they avoided, and the recent
8// requests with a marker on each that followed a gap over the cache TTL.
9// Drawn from `projectman.cost` (US-PM-61-6) alone, or the last summary kept
10// in `$.store` when no run is live; register.ts reads those and hands them in,
11// so this file holds no `$` and draws the same on every surface (only Box and
12// Text, which every surface's table carries). /pm-cost opens it; where no
13// surface draws, `costSummary` is the same content as text.
14
15/** The id the cost pane is opened under and its `ui.render` matcher names. */
16export const COST_PANE = 'pm-cost'
17export const COST_PANE_TITLE = 'ProjectMan cache cost'
18/** The slash command that opens it. */
19export const COST_COMMAND = 'pm-cost'
20export const COST_COMMAND_DESCRIPTION = 'Cache reads vs. writes per task for the current orchestrator run (no Claude turn)'
21/** /pm-cost's answer once the pane is drawn. */
22export const COST_OPENED = 'ProjectMan cache cost opened in a pane; it updates with each request of the run.'
23
24/** The keys a test (or another plugin) addresses the pane's sections by. */
25export const COST_TASKS_KEY = 'cost-tasks'
26export const COST_HEARTBEAT_KEY = 'cost-heartbeat'
27export const COST_REQUESTS_KEY = 'cost-requests'
28
29/** How many of the most recent requests the pane lists. */
30export const RECENT_REQUESTS = 12
31
32/** The marker on a request that followed a gap over the TTL. */
33export const PAST_TTL_MARK = '▲ past TTL'
34
35/** What the pane draws: a run, live or kept, and the live run's recent requests. */
36export type CostView = {
37  run: ProjectmanCostRun
38  /** Newest last; empty for a kept run, whose requests are not kept. */
39  requests: readonly ProjectmanCostRequest[]
40  /** True for the run being recorded now, false for the last one kept. */
41  isLive: boolean
42  ttlMs: number
43}
44
45/** The view for the live state, else the kept run; null when neither has a run. */
46export function costView(
47  state: ProjectmanCostState | null,
48  kept: ProjectmanCostRun | null,
49  ttlMs: number = CACHE_TTL_MS,
50): CostView | null {
51  if (state?.run) return { run: state.run, requests: state.requests, isLive: true, ttlMs }
52  if (kept) return { run: kept, requests: [], isLive: false, ttlMs }
53  return null
54}
55
56/** The elements the pane draws with: the part of any surface's table it uses. */
57export type CostPaneElements = {
58  Box: ElementConstructor<BoxProps>
59  Text: ElementConstructor<TextProps>
60}
61
62/** A token count, short: 600, 20.5k, 1.2M. */
63export function tokens(n: number): string {
64  const short = (v: number, unit: string) => `${v.toFixed(1).replace(/\.0$/, '')}${unit}`
65  if (n < 1000) return String(n)
66  if (n < 1_000_000) return short(n / 1000, 'k')
67  return short(n / 1_000_000, 'M')
68}
69
70/** A gap, short: 40s, 12m, 1h02m. */
71export function duration(ms: number): string {
72  const seconds = Math.round(ms / 1000)
73  if (seconds < 60) return `${seconds}s`
74  const minutes = Math.round(ms / 60_000)
75  if (minutes < 60) return `${minutes}m`
76  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
77}
78
79/** `3 req · input 5 · cache read 40.5k · cache write 600`. */
80export function countsText(counts: ProjectmanTokenCounts): string {
81  return (
82    `${counts.requests} req · input ${tokens(counts.input)} · ` +
83    `cache read ${tokens(counts.cacheRead)} · cache write ${tokens(counts.cacheWrite)}`
84  )
85}
86
87/** ` · 2 past TTL` for a bucket with requests after a gap over the TTL, else ''. */
88function pastText(run: ProjectmanCostRun, bucket: string): string {
89  const past = run.pastTtl?.[bucket] ?? 0
90  return past > 0 ? ` · ${past} past TTL` : ''
91}
92
93/** The header lines: which run, whether live, how many requests, the TTL. */
94export function headerLines(view: CostView): string[] {
95  const { run } = view
96  const past = Object.values(run.pastTtl ?? {}).reduce((a, b) => a + b, 0)
97  return [
98    `Run ${run.runId} · ${view.isLive ? 'live' : 'last kept, not live'}`,
99    `${run.totals.requests} requests · cache TTL ${duration(view.ttlMs)} · ${past} past TTL`,
100  ]
101}
102
103/** One line per task, in the order first charged, then unattributed and the total. */
104export function taskLines(run: ProjectmanCostRun): { text: string; isWarning: boolean }[] {
105  const lines = Object.entries(run.tasks).map(([id, counts]) => ({
106    text: `${id}  ${countsText(counts)}${pastText(run, id)}`,
107    isWarning: (run.pastTtl?.[id] ?? 0) > 0,
108  }))
109  if (run.unattributed.requests > 0) {
110    lines.push({
111      text: `unattributed  ${countsText(run.unattributed)}${pastText(run, UNATTRIBUTED_BUCKET)}`,
112      isWarning: (run.pastTtl?.[UNATTRIBUTED_BUCKET] ?? 0) > 0,
113    })
114  }
115  lines.push({ text: `Total  ${countsText(run.totals)}`, isWarning: false })
116  return lines
117}
118
119/** The heartbeat bucket: its counts and the cache writes it avoided. */
120export function heartbeatLines(run: ProjectmanCostRun): string[] {
121  const beat = run.heartbeat
122  if (beat === undefined) return ['No heartbeat requests yet.']
123  const lines = [`${countsText(beat)}${pastText(run, HEARTBEAT_BUCKET)}`]
124  lines.push(
125    `Bridged ${beat.bridged} wait${beat.bridged === 1 ? '' : 's'} over the TTL · ` +
126      `avoided ${tokens(beat.avoidedWrites)} cache-write tokens`,
127  )
128  return lines
129}
130
131/** One request: `+1h02m  US-PM-61-7 (worker)  input 3 · read 20k · write 500  ▲ past TTL`. */
132export function requestLine(request: ProjectmanCostRequest): string {
133  const gap = request.gapMs === null ? 'first' : `+${duration(request.gapMs)}`
134  const who = request.isHeartbeat ? 'heartbeat' : (request.taskId ?? 'unattributed')
135  const loop = request.agentId === null ? '' : ' (worker)'
136  const counts = `input ${tokens(request.input)} · read ${tokens(request.cacheRead)} · write ${tokens(request.cacheWrite)}`
137  return `${gap}  ${who}${loop}  ${counts}${request.isPastTtl ? `  ${PAST_TTL_MARK}` : ''}`
138}
139
140/** The most recent requests, newest first. */
141function recent(view: CostView): ProjectmanCostRequest[] {
142  return view.requests.slice(-RECENT_REQUESTS).reverse()
143}
144
145const NO_RUN =
146  'No orchestrator run recorded yet. Requests are recorded once a pm call carries a run_id (/pm-orchestrate passes one).'
147
148/** The pane's tree for `view`. */
149export function costPane({ Box, Text }: CostPaneElements, view: CostView | null): RenderElement {
150  if (view === null) {
151    return (
152      <Box key="pm-cost" flexDirection="column">
153        <Text dimColor>{NO_RUN}</Text>
154      </Box>
155    )
156  }
157  const [title, status] = headerLines(view)
158  return (
159    <Box key="pm-cost" flexDirection="column" gap={1}>
160      <Box flexDirection="column">
161        <Text bold>{title}</Text>
162        <Text dimColor>{status}</Text>
163      </Box>
164      <Box key={COST_TASKS_KEY} flexDirection="column">
165        <Text bold>Per task</Text>
166        {taskLines(view.run).map((line) => (
167          <Text color={line.isWarning ? 'warning' : undefined} wrap="truncate-end">
168            {line.text}
169          </Text>
170        ))}
171      </Box>
172      <Box key={COST_HEARTBEAT_KEY} flexDirection="column">
173        <Text bold>Heartbeats</Text>
174        {heartbeatLines(view.run).map((line) => (
175          <Text wrap="truncate-end">{line}</Text>
176        ))}
177      </Box>
178      <Box key={COST_REQUESTS_KEY} flexDirection="column">
179        <Text bold>Recent requests</Text>
180        {view.isLive ? null : <Text dimColor>Requests are listed for the live run only.</Text>}
181        {view.isLive && view.requests.length === 0 ? <Text dimColor>none yet</Text> : null}
182        {recent(view).map((request) => (
183          <Text color={request.isPastTtl ? 'warning' : undefined} wrap="truncate-end">
184            {requestLine(request)}
185          </Text>
186        ))}
187      </Box>
188    </Box>
189  )
190}
191
192/** The pane's content as text, for /pm-cost where no surface draws or the pane cannot be seated. */
193export function costSummary(view: CostView | null): string {
194  if (view === null) return `ProjectMan cache cost: ${NO_RUN}`
195  const [title, status] = headerLines(view)
196  const lines = [`ProjectMan cache cost: ${title}`, status, '', 'Per task:']
197  for (const line of taskLines(view.run)) lines.push(`  ${line.text}`)
198  lines.push('', 'Heartbeats:')
199  for (const line of heartbeatLines(view.run)) lines.push(`  ${line}`)
200  const marked = view.requests.filter((r) => r.isPastTtl)
201  if (marked.length > 0) {
202    lines.push('', 'Requests past TTL (most recent first):')
203    for (const request of marked.slice(-RECENT_REQUESTS).reverse()) lines.push(`  ${requestLine(request)}`)
204  }
205  return lines.join('\n')
206}
207
types/index.d.ts 162 lines
1// ProjectMan mod type contract (named in .claude-plugin/plugin.json "types").
2// Declares the session values the hooks module keeps in $.state; the guards
3// added by US-PM-59-7/8 record their last refusal here.
4
5export type ProjectmanGuardDenial = {
6  rule: string
7  tool: string
8  reason: string
9}
10
11// US-PM-60-7: the board pane draws from `projectman.board`, written when
12// /pm-board reads `pm_board` and again by the pane's refresh button.
13
14/** One task on the board pane. */
15export type ProjectmanBoardTask = {
16  id: string
17  title: string
18  points: number | null
19  assignee: string | null
20  isStale: boolean
21}
22
23/** One column of the board pane: the tasks listed and the server's total. */
24export type ProjectmanBoardColumn = {
25  key: 'available' | 'in_progress' | 'blocked'
26  label: string
27  total: number
28  tasks: ProjectmanBoardTask[]
29}
30
31/** What the board pane draws. */
32export type ProjectmanBoardView = {
33  columns: ProjectmanBoardColumn[]
34  /** How many times the board was read from the MCP server this session. */
35  loads: number
36  /** True while the refresh button's `pm_board` call is in flight. */
37  isRefreshing: boolean
38  /** Why the last read failed; the columns then are the previous read's. */
39  error: string | null
40}
41
42// US-PM-62-6: the sprint band above the prompt draws from `projectman.band`,
43// read from the MCP server when the band is first drawn and after each turn.
44
45/** What the sprint band shows; a part is null when there is nothing for it. */
46export type ProjectmanBand = {
47  /** The active sprint: its short name and live points (done stories / planned). */
48  sprint: { name: string; donePoints: number; totalPoints: number } | null
49  /** The first in-progress task, and how many more are in progress. */
50  task: { id: string; title: string; more: number } | null
51  /** The first line of the NEXT.md note. */
52  note: string | null
53}
54
55/** The band's value: the last read (null when there is nothing to show). */
56export type ProjectmanBandState = {
57  view: ProjectmanBand | null
58  /** True while a read is in flight; a second read is not started meanwhile. */
59  isReading: boolean
60  /** How many reads finished this session. */
61  reads: number
62}
63
64// US-PM-61-6: each model request's token usage while an orchestrator run is
65// active, charged to the task its loop works on. Live in `projectman.cost`;
66// the run's summary is also kept in `$.store` under `costRuns`, by run id.
67
68/** Token counts summed over some requests. */
69export type ProjectmanTokenCounts = {
70  requests: number
71  /** Uncached input tokens. */
72  input: number
73  /** Input tokens the prompt cache served. */
74  cacheRead: number
75  /** Input tokens written to the prompt cache. */
76  cacheWrite: number
77  output: number
78}
79
80/** One model request of the active run, as the API reported its usage. */
81export type ProjectmanCostRequest = {
82  /** When the response was whole, in `$.clock.now()`'s milliseconds. */
83  at: number
84  /** The task it was charged to; null when none could be found. */
85  taskId: string | null
86  /** The subagent's loop it was made in; null for the main session. */
87  agentId: string | null
88  model: string
89  input: number
90  cacheRead: number
91  cacheWrite: number
92  output: number
93  // US-PM-61-7: the request's place in its loop's cache timeline.
94  /** Milliseconds since the previous request in the same loop; null for the loop's first. */
95  gapMs: number | null
96  /** The gap outlived the cache TTL: the loop's cached prefix had expired. */
97  isPastTtl: boolean
98  /** Made in a heartbeat turn: counted in the run's `heartbeat`, not a task's. */
99  isHeartbeat: boolean
100}
101
102/** The heartbeat requests' counts and what they saved (US-PM-61-7). */
103export type ProjectmanHeartbeatCounts = ProjectmanTokenCounts & {
104  /**
105   * Waits a heartbeat bridged: a request whose wait since its loop's last
106   * non-heartbeat request outlived the TTL, yet a heartbeat in between kept
107   * the gap under it, so the cache still served the prefix.
108   */
109  bridged: number
110  /** Those requests' cache-read tokens: the prefix they would have written afresh. */
111  avoidedWrites: number
112}
113
114/** A run's summary: what is persisted in `$.store`. */
115export type ProjectmanCostRun = {
116  runId: string
117  startedAt: number
118  updatedAt: number
119  /** Counts per task id. */
120  tasks: Record<string, ProjectmanTokenCounts>
121  /** Requests no task could be found for. */
122  unattributed: ProjectmanTokenCounts
123  totals: ProjectmanTokenCounts
124  /** Heartbeat requests, kept out of `tasks` (US-PM-61-7); absent until the first one. */
125  heartbeat?: ProjectmanHeartbeatCounts
126  /**
127   * Requests that followed a gap over the cache TTL, by the bucket they were
128   * counted in: a task id, `heartbeat` or `unattributed` (US-PM-61-7);
129   * absent until the first one.
130   */
131  pastTtl?: Record<string, number>
132}
133
134/** The live recording: the active run, its recent requests and each loop's task. */
135export type ProjectmanCostState = {
136  /** The active run's summary; null before any run-carrying pm call. */
137  run: ProjectmanCostRun | null
138  /** The active run's most recent requests, newest last. */
139  requests: ProjectmanCostRequest[]
140  /** Loop key (`main`, or a subagent's id) to the task it last named. */
141  loops: Record<string, string>
142  // US-PM-61-7: gap timing and heartbeat detection. Absent until first set;
143  // carried across a change of run, as a loop's cache outlives the run id.
144  /** Loop key to when its last request's response was whole. */
145  lastAt?: Record<string, number>
146  /** Loop key to when its last non-heartbeat request's response was whole. */
147  lastWorkAt?: Record<string, number>
148  /** The main-loop turn the heartbeat prompt started; null when the current turn is not one. */
149  heartbeatTurn?: string | null
150}
151
152declare module 'claude-code' {
153  interface PluginState {
154    projectman: {
155      lastDenial: ProjectmanGuardDenial | null
156      board: ProjectmanBoardView | null
157      band: ProjectmanBandState | null
158      cost: ProjectmanCostState | null
159    }
160  }
161}
162