SLOPSHOPPER

Project Management

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

newcommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pm
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ pm │ ⏺ Read(src/auth.ts) │ pm: needs the base plugin — claude plugin │ ⎿ Read 6 lines │ install base@domaine │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pm-doctor ⎿ pm: pm doctor — plugin root: /plugins/pm ⎿ pm: FAIL static scripts/doctor.cjs exited 0: no rows on stdout ⎿ pm: FAIL base-live pm needs the base plugin — claude plugin install base@domaine ⎿ pm: FAIL slim-live slim is not loaded — claude plugin install slim@domaine; base refuses its readers until it is ⎿ pm: doctor: 0 passed, 3 failed, 0 skipped ⎿ pm: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pm

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.

Status

  • Claude Code only: pm is a plugin of skills, references and a hooks module (mods). It ships no adapter for another host.
  • Requires base ("dependencies": ["base"] in its manifest), and slim through base. The engine does not install a dependency on its own: install all three.
  • Never runs together with fnd — install fnd OR base plus the team plugins.

Install

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

Skills

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.

SkillDoesUses / hands off to
/pm:project-estimatorDomaine-style estimators, PCRs, LOE breakdowns, line items, key assumptions, out-of-scope lists and header content, in single-item, spreadsheet or full-estimator modebase:jira-reader, base:doc-reader; a Google Drive tool when the session has one → /pm:estimator-review
/pm:estimator-reviewread-only pre-submission review of an estimate against the SE checklist: findings per category, a prioritized fix list, the verdict; never a pricing verdictthe 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-briefa merchant's requirements as a solutions brief: approach, complexity, LOE, timeline, risks, next stepsbase:jira-reader, base:doc-reader, base's Shopify Dev MCP; after approval base's Atlassian or notion MCP and base:jira-writer
/pm:solutions-engineeringrequirement scoping, implementation plans, LOE guidelines, common Shopify limitations, the merchant handoff and escalation pathsbase:jira-reader, base:doc-reader, base's Shopify Dev MCP → /pm:project-estimator, /pm:estimator-review, /pm:vendor-evaluation
/pm:vendor-evaluationcompares apps, platforms, agencies or vendors for a merchant use case, Domaine partners first when they fitbase'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.

References

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

ReferenceRead byHolds
references/loe-worksheet.md/pm:project-estimator, /pm:solutions-engineeringLOE baselines by component type, complexity factors, buffer guidelines, an example estimate
references/implementation-plan-template.md/pm:project-estimator, /pm:solutions-engineeringthe implementation plan for a Shopify Plus engagement
skills/project-estimator/references/estimator-style-notes.md/pm:project-estimatorestimator syntax and the archetype cues (migration, B2B, custom app / PCR)
skills/project-estimator/references/artifact-templates.md/pm:project-estimatorthe output shapes: single item, spreadsheet row, full package, section blurbs
skills/estimator-review/references/pre-submission-checklist.md/pm:estimator-reviewthe five-category checklist, synced from Notion
skills/estimator-review/references/evaluation-guide.md/pm:estimator-reviewhow to check each checklist item against the workbook
skills/estimator-review/references/estimator-structure.md/pm:estimator-reviewthe workbook's tabs, columns, variant tags and off-limits tabs
skills/estimator-review/references/complexity-framework.md/pm:estimator-reviewLow / Medium / High by risk, and the three-question screen
skills/estimator-review/references/baseline-comparison.md/pm:estimator-reviewwhen and how to compare against a Drive baseline estimator
skills/solutions-engineering/references/handoff-template.md/pm:solutions-engineeringthe merchant handoff document
<base root>/references/task-workspace.md (base's)/pm:project-estimator, /pm:merchant-brief, /pm:solutions-engineeringthe task workspace a ticket's work is saved to
<base root>/references/jira-adf-write.md (base's)/pm:merchant-briefhow an approved ticket description is converted to ADF before it reaches Jira

Conventions

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.

Doctor

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

RowChecks
nodeNode 18 or newer
manifestthe manifest's version, the name pm, base in its dependencies
scriptsevery scripts/*.cjs parses; every scripts/*.sh but a sourced _*.sh keeps its exec bit and answers --help
basebase installed (user scope or this project) and enabled — else claude plugin install base@domaine
atlassian, notion-mcpbase'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-logthis 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-livewhat 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.

Event log on disk

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

  • Line: {"ts":"…","plugin":"pm","version":"<pm's version>","session":"<id>","kind":"doctor","agent":"main","text":"9 passed, 0 failed, 0 skipped"}.
  • pm's lines: 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).
  • Writing: the whole file is rewritten after every line, at most 2000 lines or 256 KB, oldest dropped first; a reload of the module goes on from the file of the same session. A write that fails never reaches the hook; the first failure in a session toasts pm: event log not written: <reason>.
  • Off: PM_EVENT_LOG=0 stops the file and the pm.events lines alike.
  • Clean-up: pm never deletes; base sweeps old session folders.

Environment switches

Every switch pm reads has a row here; set it in ~/.claude/settings.json → env.

VariableDefaultEffect
PM_EVENT_LOGon0 keeps pm.events empty and writes no pm.jsonl
DOMAINE_LOG_DIR~/.claude/domaine/logWhere 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~/.clauderead, 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

Tests

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

Licence

MIT, as the repository (LICENSE).

Source 6 files
hooks/mods/register.ts 12 lines
1// 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}
12
hooks/mods/doctor.ts 180 lines
1// /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}
180
hooks/mods/session.ts 103 lines
1// 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}
103
hooks/mods/events.ts 157 lines
1// 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}
157
hooks/mods/conventions/text.ts 7 lines
1// 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)$/
7
types/index.d.ts 26 lines
1// 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