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

Git-native project management for Claude Code. Manage User Stories, Tasks, Estimation, and Burndown — all stored as markdown files in your repo.
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:
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.
pm_board shows ready tasks, pm_grab claims them with readiness validation/pm-planpm_audit catches inconsistenciesdepends_on links between sibling tasks with cycle detection and topological orderingpm_update and read with pm_run_logpm_get_sprintpm_commit and pm_push commit and push the PM store on its own branchpm_git_status shows the store's branch, alignment, dirty state, and ahead/behindUser → 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 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.
# 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.
MIT
hooks/register.ts 551 lines1import { 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}
551hooks/board-pane.tsx 84 lines1import 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}
84hooks/git-guard.ts 226 lines1// 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}
226hooks/store-guard.ts 185 lines1// 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}
185hooks/pm-views.ts 470 lines1// 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}
470hooks/surface.ts 31 lines1import 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}
31hooks/sprint-band.tsx 146 lines1import 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}
146hooks/band-refresh.ts 64 lines1// 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}
64hooks/band-session.ts 46 lines1// 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}
46hooks/cost-record.ts 321 lines1// 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}
321hooks/cost-pane.tsx 207 lines1import 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}
207types/index.d.ts 162 lines1// 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