Domaine project-management plugin for Claude Code: the project estimator, the estimator pre-submission review, the merchant brief, solutions engineering…

pm is the Domaine project-management plugin for Claude Code. It holds the work of the solutions engineers and project managers before and around a build: project estimators and PCRs, the pre-submission review of an estimate, the merchant solutions brief, scoping and implementation planning with LOE baselines and the merchant handoff, and vendor evaluation.
The skills are imported from Domaine's domaine-skills-solutions repository (commit 3c96617, 2026-08-03) and adapted to Claude Code and base; update them here, not there.
pm builds on base and requires it: the Jira and doc readers, the Jira writer, the task workspace and the shared MCP servers (Atlassian, Notion, Shopify Dev) are base's (plugins/base/README.md). base requires slim, so pm runs with slim too.
Current release: pm v0.1.1.
"dependencies": ["base"] in its manifest), and slim through base. The engine does not install a dependency on its own: install all three./plugin marketplace add domaine-oleksandr-kever/claude-plugins
/plugin install slim@domaine
/plugin install band@domaine
/plugin install base@domaine
/plugin install pm@domaine
/reload-plugins
/base-doctor and /pm-doctor then check the install (§ Doctor). The same set as settings, in ~/.claude/settings.json:
{
"enabledPlugins": {
"slim@domaine": true,
"band@domaine": true,
"base@domaine": true,
"pm@domaine": true,
"fnd@domaine": false
}
}
The team plugins fe, qa, be and pm co-install: each depends on base only, and none needs another. Install the ones your work needs beside base.
Invoked by their qualified names (/pm:<skill>). They hand off to base's agents by base's qualified names (base:jira-reader, base:doc-reader, base:jira-writer). A hand-off is an offer at the end of the run, never an automatic start, and nothing is written to Jira, Confluence or Notion without your approval of the exact text.
| Skill | Does | Uses / hands off to |
|---|---|---|
/pm:project-estimator | Domaine-style estimators, PCRs, LOE breakdowns, line items, key assumptions, out-of-scope lists and header content, in single-item, spreadsheet or full-estimator mode | base:jira-reader, base:doc-reader; a Google Drive tool when the session has one → /pm:estimator-review |
/pm:estimator-review | read-only pre-submission review of an estimate against the SE checklist: findings per category, a prioritized fix list, the verdict; never a pricing verdict | the workbook from a Google Drive tool, a local .xlsx or a pasted export; Bluedot and Slack tools when present; base's notion MCP → /pm:project-estimator |
/pm:merchant-brief | a merchant's requirements as a solutions brief: approach, complexity, LOE, timeline, risks, next steps | base:jira-reader, base:doc-reader, base's Shopify Dev MCP; after approval base's Atlassian or notion MCP and base:jira-writer |
/pm:solutions-engineering | requirement scoping, implementation plans, LOE guidelines, common Shopify limitations, the merchant handoff and escalation paths | base:jira-reader, base:doc-reader, base's Shopify Dev MCP → /pm:project-estimator, /pm:estimator-review, /pm:vendor-evaluation |
/pm:vendor-evaluation | compares apps, platforms, agencies or vendors for a merchant use case, Domaine partners first when they fit | base's notion MCP (the Partnerships Database), WebSearch and WebFetch when present |
A tool a skill names that this session may not have (Google Drive, Bluedot, Slack, a Python openpyxl, web search) is never a hard dependency: the skill asks for a pasted export or skips the source and says so.
The skills cite pm's own files by their path under pm's root (<pm root>/…, the session's pm plugin root: line) and base's by their path under base's root (<base root>/…).
| Reference | Read by | Holds |
|---|---|---|
references/loe-worksheet.md | /pm:project-estimator, /pm:solutions-engineering | LOE baselines by component type, complexity factors, buffer guidelines, an example estimate |
references/implementation-plan-template.md | /pm:project-estimator, /pm:solutions-engineering | the implementation plan for a Shopify Plus engagement |
skills/project-estimator/references/estimator-style-notes.md | /pm:project-estimator | estimator syntax and the archetype cues (migration, B2B, custom app / PCR) |
skills/project-estimator/references/artifact-templates.md | /pm:project-estimator | the output shapes: single item, spreadsheet row, full package, section blurbs |
skills/estimator-review/references/pre-submission-checklist.md | /pm:estimator-review | the five-category checklist, synced from Notion |
skills/estimator-review/references/evaluation-guide.md | /pm:estimator-review | how to check each checklist item against the workbook |
skills/estimator-review/references/estimator-structure.md | /pm:estimator-review | the workbook's tabs, columns, variant tags and off-limits tabs |
skills/estimator-review/references/complexity-framework.md | /pm:estimator-review | Low / Medium / High by risk, and the three-question screen |
skills/estimator-review/references/baseline-comparison.md | /pm:estimator-review | when and how to compare against a Drive baseline estimator |
skills/solutions-engineering/references/handoff-template.md | /pm:solutions-engineering | the merchant handoff document |
<base root>/references/task-workspace.md (base's) | /pm:project-estimator, /pm:merchant-brief, /pm:solutions-engineering | the task workspace a ticket's work is saved to |
<base root>/references/jira-adf-write.md (base's) | /pm:merchant-brief | how an approved ticket description is converted to ADF before it reaches Jira |
pm adds one section to the main session's system prompt after Claude Code's own and base's, named root (ids are pm:<name>): pm plugin root: <path>, the directory pm's references start from. It never changes within a session, so the prompt cache holds.
Subagents get the same line as added context at their start (Claude Code's SubagentStart; the engine has no event for a subagent's system prompt), except base's readers and writer (base:jira-reader, base:jira-writer, base:figma-reader, base:doc-reader) and Claude Code's claude-code-guide and statusline-setup, which get nothing from pm.
base required: at a session start pm looks for base's skills in the command list. Without them it shows one toast and writes one install line: pm: needs the base plugin — claude plugin install base@domaine.
/pm-doctor checks pm's side of the install and prints one PASS / FAIL / SKIP / WARN row per check, the counts, and the last 10 pm.events lines. /base-doctor checks base's side (slim, fnd, the MCP servers).
| Row | Checks |
|---|---|
node | Node 18 or newer |
manifest | the manifest's version, the name pm, base in its dependencies |
scripts | every scripts/*.cjs parses; every scripts/*.sh but a sourced _*.sh keeps its exec bit and answers --help |
base | base installed (user scope or this project) and enabled — else claude plugin install base@domaine |
atlassian, notion-mcp | base's manifest, read at its install path, declares the server pm's skills reach Jira, Confluence and Notion through; skipped when base is not installed and enabled, failed when its manifest is unreadable |
event-log | this session's pm.jsonl: its line count and newest ts; no file yet passes (a /clear's new session has none before its first line) |
base-live, slim-live | what this session loaded: base's skills, slim's mcp__slim__view tool |
The first seven rows come from scripts/doctor.cjs, which also runs by hand: node <pm plugin root>/scripts/doctor.cjs [--project <dir>] [--log-dir <dir>]; it exits 1 when a row fails. By hand its event-log row reads the newest session folder unless --log-dir names one. A static base FAIL for a base the session loaded anyway (a --plugin-dir load) reads as a WARN in /pm-doctor. One doctor line goes to pm.events per run.
pm writes its lines to $HOME/.claude/domaine/log/<session-id>/pm.jsonl under the same contract as every Domaine plugin (plugins/base/README.md):
{"ts":"…","plugin":"pm","version":"<pm's version>","session":"<id>","kind":"doctor","agent":"main","text":"9 passed, 0 failed, 0 skipped"}.start (pm <version>, first in every session's file, once), install (base is not loaded), doctor (a /pm-doctor run's counts). The same lines fill pm.events (oldest first, at most 200).pm: event log not written: <reason>.PM_EVENT_LOG=0 stops the file and the pm.events lines alike.Every switch pm reads has a row here; set it in ~/.claude/settings.json → env.
| Variable | Default | Effect |
|---|---|---|
PM_EVENT_LOG | on | 0 keeps pm.events empty and writes no pm.jsonl |
DOMAINE_LOG_DIR | ~/.claude/domaine/log | Where every Domaine plugin (slim, band, base, fe, qa, be, pm) writes its event log on disk: <dir>/<session-id>/<plugin>.jsonl, one JSON line per event. An absolute directory; the <session-id>/ folder is still made under it. Without it and without HOME (a cloud session) no file is written. |
CLAUDE_CONFIG_DIR | ~/.claude | read, never set, by scripts/doctor.cjs: the Claude Code config directory whose plugins/installed_plugins.json and settings.json the base, atlassian and notion-mcp rows read |
claude plugin validate --strict plugins/pm and claude plugin test plugins/pm (the kit tests in plugins/pm/hooks/mods/tests/), both run by tests/mods-sim.sh with every other plugin (local only: CI has no claude).tests/pm-doctor-sim.sh — scripts/doctor.cjs's rows on planted installs.tests/team-refs-lint.sh — every qualified name and cited path resolves, no fnd name is left (the same checker for every team plugin on base).How the pieces fit: ARCHITECTURE.md.
MIT, as the repository (LICENSE).
hooks/mods/register.ts 12 lines1// pm hooks module (Claude Code only): the project-management team's root line and doctor beside base. pm writes
2// only pm.* atoms. Each feature file declares its own atoms and keeps its `$` code to itself: the validator
3// follows `$` only within one file.
4import type { Register } from 'claude-code'
5import { registerDoctor } from './doctor.ts'
6import { registerSession } from './session.ts'
7
8export const register: Register = (on) => {
9 registerSession(on)
10 registerDoctor(on)
11}
12hooks/mods/doctor.ts 180 lines1// /pm-doctor: runs scripts/doctor.cjs for what a node process sees and adds what only a session answers —
2// base loaded, slim's view tool registered — then the tail of pm.events.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On } from 'claude-code'
5import type { PmEvent } from '../../types'
6import { logDir, logLine, pushEvent } from './events.ts'
7import type { Disk } from './events.ts'
8import { BASE_MISSING } from './session.ts'
9
10export const COMMAND = {
11 name: 'pm-doctor',
12 description: "Check the pm install: node, manifest, scripts, base, base's Atlassian and Notion servers, slim, event log",
13 immediate: true,
14} as const
15export const SLIM_VIEW = 'mcp__slim__view'
16const TAIL = 10
17
18type Status = 'PASS' | 'FAIL' | 'SKIP' | 'WARN'
19export type Row = { status: Status; name: string; detail: string }
20
21const armed = atom({ plugin: 'pm', key: 'armed' } as const, null)
22const events = atom({ plugin: 'pm', key: 'events' } as const, [] as PmEvent[])
23
24type $ = EngineInterface
25
26/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
27function diskOf($: $): Disk {
28 return {
29 session: () => $.session.id(),
30 home: () => $.env.get('HOME'),
31 override: () => $.env.get('DOMAINE_LOG_DIR'),
32 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
33 read: path => $.fs.read(path),
34 write: (path, text) => $.fs.write(path, text),
35 toast: text => $.ui.toast(text),
36 }
37}
38
39/** The command on every session start (a reload or a re-enable drops it) and at the first prompt of a new session id. */
40async function arm($: $, start: boolean): Promise<void> {
41 try {
42 const sid = String(await $.session.id())
43 const fresh = (await read($, armed)) !== sid
44 if (!start && !fresh) return
45 await $.command.register(COMMAND).catch(() => undefined)
46 if (fresh) await update($, armed, () => sid)
47 } catch {}
48}
49
50const STATUSES = new Set(['PASS', 'FAIL', 'SKIP', 'WARN'])
51
52/** doctor.cjs --json rows, or null when its stdout is not that shape. */
53export function parseStatic(stdout: string): Row[] | null {
54 try {
55 const rows = (JSON.parse(stdout) as { rows?: unknown }).rows
56 if (!Array.isArray(rows)) return null
57 return rows.filter(
58 (r): r is Row => !!r && STATUSES.has(r.status) && typeof r.name === 'string' && typeof r.detail === 'string',
59 )
60 } catch {
61 return null
62 }
63}
64
65async function staticRows($: $): Promise<Row[]> {
66 const root = $.plugin.root
67 let r
68 try {
69 const argv = ['node', `${root}/scripts/doctor.cjs`, '--json', '--root', root, '--project', await $.session.root()]
70 const dir = logDir(await $.env.get('HOME'), await $.env.get('DOMAINE_LOG_DIR'), await $.session.id())
71 if (dir) argv.push('--log-dir', dir)
72 r = await $.process.run(argv, { timeoutMs: 60_000 })
73 } catch (err) {
74 const why = err instanceof Error ? err.message : String(err)
75 return [{ status: 'SKIP', name: 'static', detail: `scripts/doctor.cjs did not run (${why}): the static checks need node` }]
76 }
77 const rows = parseStatic(r.stdout)
78 if (rows?.length) return rows
79 const why = (r.stderr.trim().split('\n')[0] ?? '') || 'no rows on stdout'
80 return [{ status: 'FAIL', name: 'static', detail: `scripts/doctor.cjs exited ${r.exitCode}: ${why}` }]
81}
82
83async function liveRows($: $): Promise<Row[]> {
84 const rows: Row[] = []
85 try {
86 rows.push((await $.command.list()).some(c => c.plugin === 'base')
87 ? { status: 'PASS', name: 'base-live', detail: "base's skills are loaded" }
88 : { status: 'FAIL', name: 'base-live', detail: `pm ${BASE_MISSING}` })
89 } catch {
90 rows.push({ status: 'SKIP', name: 'base-live', detail: 'the command list did not answer' })
91 }
92 try {
93 rows.push((await $.tool.list()).some(t => t.name === SLIM_VIEW)
94 ? { status: 'PASS', name: 'slim-live', detail: `${SLIM_VIEW} registered` }
95 : { status: 'FAIL', name: 'slim-live', detail: 'slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is' })
96 } catch {
97 rows.push({ status: 'SKIP', name: 'slim-live', detail: 'the tool list did not answer' })
98 }
99 return rows
100}
101
102/** A static `base` FAIL for a base that is loaded anyway (a `--plugin-dir` load has no install record) reads as a warning. */
103export function reconcile(rows: Row[]): Row[] {
104 const live = rows.find(r => r.name === 'base-live')?.status === 'PASS'
105 return rows.map(r =>
106 live && r.name === 'base' && r.status === 'FAIL' && r.detail.startsWith('not installed')
107 ? { status: 'WARN', name: 'base', detail: 'not in installed_plugins.json, yet loaded this session (a --plugin-dir load?)' }
108 : r,
109 )
110}
111
112export function age(ms: number): string {
113 const s = Math.max(0, Math.round(ms / 1000))
114 if (s < 60) return `${s}s`
115 if (s < 3600) return `${Math.floor(s / 60)}m`
116 if (s < 48 * 3600) return `${Math.floor(s / 3600)}h`
117 return `${Math.floor(s / 86400)}d`
118}
119
120export function summary(rows: Row[]): string {
121 const n = { PASS: 0, FAIL: 0, SKIP: 0, WARN: 0 }
122 for (const r of rows) n[r.status]++
123 return `${n.PASS} passed, ${n.FAIL} failed, ${n.SKIP} skipped${n.WARN ? `, ${n.WARN} warned` : ''}`
124}
125
126export function render(root: string, rows: Row[], tail: string[]): string {
127 const width = rows.reduce((w, r) => Math.max(w, r.name.length), 0)
128 return [
129 `pm doctor — plugin root: ${root}`,
130 ...rows.map(r => `${r.status} ${r.name.padEnd(width)} ${r.detail}`),
131 `doctor: ${summary(rows)}`,
132 '',
133 ...tail,
134 ].join('\n')
135}
136
137async function eventTail($: $): Promise<string[]> {
138 if ((await $.env.get('PM_EVENT_LOG')) === '0') return ['pm events: off (PM_EVENT_LOG=0)']
139 const list = await read($, events)
140 if (!list.length) return ['pm events: none yet']
141 const now = await $.clock.now()
142 const shown = list.slice(-TAIL)
143 return [
144 `pm events (last ${shown.length} of ${list.length}, newest last):`,
145 ...shown.map(ev => ` ${age(now - ev.atMs).padStart(4)} ${ev.kind.padEnd(9)} ${ev.text}`),
146 ]
147}
148
149async function logDoctor($: $, text: string): Promise<void> {
150 try {
151 if ((await $.env.get('PM_EVENT_LOG')) === '0') return
152 const ev: PmEvent = { atMs: await $.clock.now(), kind: 'doctor', text }
153 await update($, events, l => pushEvent(l, ev))
154 await logLine(diskOf($), ev)
155 } catch {}
156}
157
158export function registerDoctor(on: On): void {
159 // A matcher apart from session.ts's start hook: one unmatched hook per event per plugin.
160 on('session.start', { cwd: /$/ }, async ($, e, next) => {
161 const r = await next(e)
162 await arm($, true)
163 return r
164 })
165
166 on('prompt.submit', async ($, e, next) => {
167 const r = await next(e)
168 await arm($, false)
169 return r
170 })
171
172 on('command.run', { command: COMMAND.name }, async $ => {
173 const [fixed, live] = await Promise.all([staticRows($), liveRows($)])
174 const rows = reconcile([...fixed, ...live])
175 const tail = await eventTail($)
176 await logDoctor($, summary(rows))
177 return { text: render($.plugin.root, rows, tail) }
178 })
179}
180hooks/mods/session.ts 103 lines1// pm's session: the start line, the base check, and the root line — a system-prompt section for the main
2// session, added context for subagents.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
5import type { PmEvent, PmEventKind } from '../../types'
6import { NO_PM_AGENT, rootLine } from './conventions/text.ts'
7import { logLine, pushEvent } from './events.ts'
8import type { Disk } from './events.ts'
9
10export const BASE_MISSING = 'needs the base plugin — claude plugin install base@domaine'
11
12const events = atom({ plugin: 'pm', key: 'events' } as const, [] as PmEvent[])
13const started = atom({ plugin: 'pm', key: 'started' } as const, null)
14
15type $ = EngineInterface
16
17/** events.ts's file writer reaches `$` through this: the validator follows `$` only within one file. */
18function diskOf($: $): Disk {
19 return {
20 session: () => $.session.id(),
21 home: () => $.env.get('HOME'),
22 override: () => $.env.get('DOMAINE_LOG_DIR'),
23 manifest: () => $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`),
24 read: path => $.fs.read(path),
25 write: (path, text) => $.fs.write(path, text),
26 toast: text => $.ui.toast(text),
27 }
28}
29
30async function logEvent($: $, kind: PmEventKind, text: string): Promise<void> {
31 try {
32 if ((await $.env.get('PM_EVENT_LOG')) === '0') return
33 const ev: PmEvent = { atMs: await $.clock.now(), kind, text }
34 await update($, events, l => pushEvent(l, ev))
35 await logLine(diskOf($), ev)
36 } catch {}
37}
38
39async function version($: $): Promise<string> {
40 try {
41 const v = (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }).version
42 return typeof v === 'string' && v ? v : 'unknown'
43 } catch {
44 return 'unknown'
45 }
46}
47
48/** base's skills carry `plugin: 'base'` in the command list from its manifest on; a list that fails says nothing. */
49async function baseLoaded($: $): Promise<boolean> {
50 try {
51 return (await $.command.list()).some(c => c.plugin === 'base')
52 } catch {
53 return true
54 }
55}
56
57/** The one section, `pm:root`: fixed per plugin root, so a render repeats it byte for byte (the prompt cache). */
58export function sections($: $): PromptComposeSection[] {
59 return [{ id: 'pm:root', text: rootLine($.plugin.root), scope: 'session' }]
60}
61
62/** null for base's readers and writer and Claude Code's helpers; the root line for every other agent. */
63export function subagentContext($: $, agentType: string): string | null {
64 return NO_PM_AGENT.test(agentType) ? null : rootLine($.plugin.root)
65}
66
67export function registerSession(on: On): void {
68 // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
69 on('session.start', { cwd: /^/ }, async ($, e, next) => {
70 try {
71 const sid = String(await $.session.id())
72 if ((await read($, started)) !== sid) {
73 await update($, started, () => sid)
74 await logEvent($, 'start', `pm ${await version($)}`)
75 if (!(await baseLoaded($))) {
76 await logEvent($, 'install', BASE_MISSING)
77 $.ui.toast(`pm: ${BASE_MISSING}`)
78 }
79 }
80 } catch {}
81 return next(e)
82 })
83
84 on('prompt.compose', async ($, e, next) => {
85 const r = await next(e)
86 let ours: PromptComposeSection[] = []
87 try {
88 ours = sections($)
89 } catch {}
90 const taken = new Set(r.sections.map(s => s.id))
91 return { sections: [...r.sections, ...ours.filter(s => !taken.has(s.id))] }
92 })
93
94 on('classic.SubagentStart', async ($, e, next) => {
95 const r = await next(e)
96 let ctx: string | null = null
97 try {
98 ctx = subagentContext($, e.agent_type ?? '')
99 } catch {}
100 return ctx ? { ...r, additionalContext: [...(r.additionalContext ?? []), ctx] } : r
101 })
102}
103hooks/mods/events.ts 157 lines1// pm's event list and its file on disk, `<log dir>/<session-id>/pm.jsonl`, under the contract base, band and
2// slim write theirs by. No `$` here: each writer file keeps its own wrapper, which pushes the line to pm.events
3// and then hands it to `logLine` with a `Disk` it built, as the validator follows `$` only within one file.
4// pm never sweeps old session directories: base owns that.
5import type { PmEvent } from '../../types'
6
7export const EVENT_CAP = 200
8export const FILE_LINES = 2000
9export const FILE_BYTES = 256 * 1024
10
11/** Appends `ev`, oldest first, at most EVENT_CAP: past the cap the oldest line goes. */
12export function pushEvent(list: readonly PmEvent[], ev: PmEvent): PmEvent[] {
13 return list.length < EVENT_CAP ? [...list, ev] : [...list.slice(1), ev]
14}
15
16const trimSlash = (p: string) => p.replace(/\/+$/, '')
17
18/** A name the engine could not have made a session id of never becomes a path segment. */
19export function isSessionName(name: string): boolean {
20 return /^[\w.-]+$/.test(name) && !/^\.+$/.test(name)
21}
22
23/** `<DOMAINE_LOG_DIR>/<session>` when the override is absolute, else `$HOME/.claude/domaine/log/<session>`; null with neither. */
24export function logDir(home: string | undefined, override: string | undefined, session: string): string | null {
25 if (!isSessionName(session)) return null
26 const o = override?.trim() ?? ''
27 if (o.startsWith('/')) return `${trimSlash(o)}/${session}`
28 const h = home?.trim() ?? ''
29 return h.startsWith('/') ? `${trimSlash(h)}/.claude/domaine/log/${session}` : null
30}
31
32/** One pm.jsonl line; `plugin` is pm's own name, never taken from another plugin's state. */
33export function fileLine(ev: PmEvent, version: string, session: string): string {
34 return JSON.stringify({ ts: new Date(ev.atMs).toISOString(), plugin: 'pm', version, session, kind: ev.kind, agent: 'main', text: ev.text })
35}
36
37export function utf8Bytes(s: string): number {
38 let n = 0
39 for (const ch of s) {
40 const cp = ch.codePointAt(0) ?? 0
41 n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
42 }
43 return n
44}
45
46/** Drops the oldest lines past FILE_LINES lines or FILE_BYTES bytes; the newest line always stays. */
47function trimFront(out: string[]): string[] {
48 let bytes = 0
49 for (const l of out) bytes += utf8Bytes(l) + 1
50 let cut = 0
51 while (out.length - cut > 1 && (out.length - cut > FILE_LINES || bytes > FILE_BYTES)) bytes -= utf8Bytes(out[cut++]!) + 1
52 return cut ? out.slice(cut) : out
53}
54
55/** Appends `line` within the cap. */
56export function capLines(lines: readonly string[], line: string): string[] {
57 return trimFront([...lines, line])
58}
59
60/** The lines of a pm.jsonl that belong to `session`, oldest first, within the cap: what a reload goes on from. */
61export function seedLines(text: string, session: string): string[] {
62 return trimFront(text.split('\n').filter(l => {
63 try {
64 return (JSON.parse(l) as { session?: unknown }).session === session
65 } catch {
66 return false
67 }
68 }))
69}
70
71const hasStart = (lines: readonly string[]) => lines.some(l => l.includes('"kind":"start"'))
72
73/**
74 * The file's lines after `ev`: the start line first in every session (a /clear's new id gets one before its
75 * first event, as no session.start announces it), and one start line per session.
76 */
77export function nextLines(lines: readonly string[], ev: PmEvent, version: string, session: string): string[] | null {
78 if (ev.kind === 'start') return hasStart(lines) ? null : capLines(lines, fileLine(ev, version, session))
79 const head = lines.length ? lines : [fileLine({ atMs: ev.atMs, kind: 'start', text: `pm ${version}` }, version, session)]
80 return capLines(head, fileLine(ev, version, session))
81}
82
83/** What the writer needs from `$`, built by each writer file's `diskOf`. */
84export type Disk = {
85 session: () => Promise<string>
86 home: () => Promise<string | undefined>
87 override: () => Promise<string | undefined>
88 manifest: () => Promise<string>
89 read: (path: string) => Promise<string>
90 write: (path: string, text: string) => Promise<void>
91 toast: (text: string) => void
92}
93
94type Sink = { path: string; lines: string[] }
95
96// Module-local, so a hot reload starts from the file: the lines already on disk for this session.
97let sink: Promise<Sink | null> | null = null
98let sinkSession = ''
99let version: Promise<string> | null = null
100let writes: Promise<void> = Promise.resolve()
101let toasted = ''
102
103async function readVersion(disk: Disk): Promise<string> {
104 try {
105 const v = (JSON.parse(await disk.manifest()) as { version?: unknown }).version
106 return typeof v === 'string' && v ? v : 'unknown'
107 } catch {
108 return 'unknown'
109 }
110}
111
112async function openSink(disk: Disk, session: string): Promise<Sink | null> {
113 const dir = logDir(await disk.home(), await disk.override(), session)
114 if (dir === null) return null
115 const path = `${dir}/pm.jsonl`
116 let lines: string[] = []
117 try {
118 lines = seedLines(await disk.read(path), session)
119 } catch {}
120 return { path, lines }
121}
122
123/**
124 * Rewrites this session's pm.jsonl with `ev` appended (`$.fs.write` has no append), after the line went to
125 * pm.events. Never throws; one toast per session when a write fails.
126 */
127export async function logLine(disk: Disk, ev: PmEvent): Promise<void> {
128 let session = ''
129 try {
130 session = await disk.session()
131 if (!sink || sinkSession !== session) {
132 sinkSession = session
133 sink = openSink(disk, session).catch(() => null)
134 }
135 version ??= readVersion(disk)
136 const v = await version
137 const s = await sink
138 if (!s) return
139 // Read, append and write inside one queue: two events in flight never build on the same snapshot.
140 const w = writes.then(async () => {
141 const lines = nextLines(s.lines, ev, v, session)
142 if (!lines) return
143 s.lines = lines
144 await disk.write(s.path, `${lines.join('\n')}\n`)
145 })
146 writes = w.catch(() => {})
147 await w
148 } catch (err) {
149 if (toasted === session) return
150 toasted = session
151 const reason = String((err as { message?: unknown } | null)?.message ?? err).replace(/\s+/g, ' ').slice(0, 120)
152 try {
153 disk.toast(`pm: event log not written: ${reason}`)
154 } catch {}
155 }
156}
157hooks/mods/conventions/text.ts 7 lines1// pm's conventions, the text the main session's system prompt and the subagents read. Pure.
2
3export const rootLine = (root: string) => `pm plugin root: ${root}`
4
5/** base's readers and writer, and Claude Code's own helpers: no pm context at all. */
6export const NO_PM_AGENT = /(^|:)(jira-reader|jira-writer|figma-reader|doc-reader)$|^(claude-code-guide|statusline-setup)$/
7types/index.d.ts 26 lines1// pm's $.state contract. Values are JSON, so an absent value is null, never undefined. base's and slim's keys
2// come from the dependency contracts the engine lays beside the module (base, and slim through base); they
3// are never redeclared here.
4
5/**
6 * At most 9 characters each (band's kind cell). `start` pm's version at session start, `install` base is not
7 * loaded, `doctor` the counts of a /pm-doctor run.
8 */
9export type PmEventKind = 'start' | 'install' | 'doctor'
10
11/** atMs = $.clock.now() when written; text is one line. */
12export type PmEvent = { atMs: number; kind: PmEventKind; text: string }
13
14declare module 'claude-code' {
15 interface PluginState {
16 pm: {
17 /** Oldest first, at most 200; any plugin reads, pm writes. Stays [] under PM_EVENT_LOG=0. */
18 events: PmEvent[]
19 /** The session id whose start line was written and whose base check ran: a module reload does neither again. */
20 started: string | null
21 /** The session id whose /pm-doctor command is registered. */
22 armed: string | null
23 }
24 }
25}
26