Hooks
What product-on-purpose-pm-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add product-on-purpose/pm-skills > /plugin install pm-skills@pm-skills-marketplace
Ships with product-on-purpose-pm-skills. Installing the plugin gets these hooks.
What fires, and when
PreToolUse
- Matches
Write|Edit|MultiEdit|NotebookEdit|ExitPlanModenode "${CLAUDE_PLUGIN_ROOT}/hooks/guardrails.mjs"
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
- Matches
startup|resume|clear|compactnode "${CLAUDE_PLUGIN_ROOT}/hooks/phase-router.mjs"
In the plugin's words
How product-on-purpose-pm-skills describes its own hook set.
pm-skills hooks: opt-in house-rule guardrails (PreToolUse) + confident-only phase router (SessionStart)
Where it lives
- hooks/guardrails.mjsRunsGitHub
Read the script
// hooks/guardrails.mjs - PreToolUse(Write|Edit|NotebookEdit) house-rule guardrail. // Opt-in via .claude/pm-skills.local.md. em-dash/en-dash BLOCKS; placeholder and // fabricated-metric WARN. Fail-open on every error path. Dependency-free. // The banned chars are built with String.fromCharCode so this file does not deny // edits to itself (and so it can be written past the no-em-dashes hook). import { pathToFileURL } from 'node:url'; import { readLocalConfig, enabledChecks } from './lib/local-config.mjs'; const EM = String.fromCharCode(0x2014); // em-dash const EN = String.fromCharCode(0x2013); // en-dash const PLACEHOLDERS = [/\[Placeholder\]/i, /\[Feature Name\]/i, /\bTODO\b/, /<\.\.\.>/]; // Fabricated-metric heuristic. A number ALONE is not a metric: a date (2026-07-04), // a version (1.2.3), or a count ("3 skills") must NOT trip the check. Fire only when // a number shares its LINE with metric context: a percentage, a currency symbol, or a // metric keyword. Kept advisory - a false positive here is a nudge to cite a source, // never a block. Currency glyphs are built from code points so this source stays ASCII // (the file's existing convention, mirroring the EM / EN escapes above). const CURRENCY = '$' + String.fromCharCode(0x00a3, 0x20ac, 0x00a5); // $ GBP EUR JPY const METRIC_PERCENT = /\d\s*%/; // 40% or 40 % const METRIC_CURRENCY = new RegExp('[' + CURRENCY + ']\\s*\\d'); // symbol then a number const METRIC_WORD = /\b(?:users?|revenue|conversions?|growth|retention|churn|signups?|subscribers?|engagement|MRR|ARR|ROI|CTR|DAU|MAU|NPS|LTV|CAC)\b/i; function hasMetricContext(text) { for (const line of text.split('\n')) { if (METRIC_PERCENT.test(line) || METRIC_CURRENCY.test(line)) return true; if (/\d/.test(line) && METRIC_WORD.test(line)) return true; } return false; } /** * Pure evaluator. Returns a hook-output object to print, or null to allow silently. * config: a parsed .local.md config object ({} = not opted in). */ export function evaluateGuardrail(payloadText, config) { let text; try { const payload = JSON.parse(payloadText); const ti = payload.tool_input || {}; const parts = [ti.new_string, ti.content, ti.new_source, ti.plan]; // ti.plan = ExitPlanMode if (Array.isArray(ti.edits)) for (const e of ti.edits) parts.push(e && e.new_string); // MultiEdit text = parts.filter((s) => typeof s === 'string').join('\n'); } catch { return null; // malformed payload -> fail open } const checks = enabledChecks(config || {}); if (!checks.length) return null; // not opted in -> inert if (checks.includes('em-dash')) { const found = []; if (text.includes(EM)) found.push('em-dash (U+2014)'); if (text.includes(EN)) found.push('en-dash (U+2013)'); if (found.length) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Blocked: would write ' + found.join(' and ') + ". Replace with ' - ' or restructure with a comma / colon / sentence break.", }, }; } } const notes = []; if (checks.includes('placeholder') && PLACEHOLDERS.some((re) => re.test(text))) { notes.push('Advisory: unfilled placeholder (e.g. TODO / [Placeholder]) detected.'); } if (checks.includes('fabricated-metric') && hasMetricContext(text) && !/\[fictional\]/i.test(text)) { notes.push('Advisory: a numeric metric was written; confirm it is sourced or marked [fictional].'); } if (notes.length) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: notes.join(' ') } }; } return null; } async function main() { const chunks = []; try { for await (const chunk of process.stdin) chunks.push(chunk); const payloadText = Buffer.concat(chunks).toString('utf8'); // explicit UTF-8 (cp1252 guard) const cwd = (JSON.parse(payloadText).cwd) || process.cwd(); const decision = evaluateGuardrail(payloadText, readLocalConfig(cwd)); if (decision) process.stdout.write(JSON.stringify(decision)); } catch { // fail open: emit nothing, allow the write } process.exit(0); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } - hooks/guardrails.test.mjsGitHub
Read the script
// hooks/guardrails.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import { evaluateGuardrail } from './guardrails.mjs'; const EM = String.fromCharCode(0x2014); // em-dash, built so this file never self-blocks const payload = (input) => JSON.stringify({ tool_input: input }); test('inert by default: em-dash allowed when config empty', () => { const d = evaluateGuardrail(payload({ content: 'a' + EM + 'b' }), {}); assert.equal(d, null); // null = allow, no output }); test('em-dash denied when guardrails on', () => { const d = evaluateGuardrail(payload({ content: 'a' + EM + 'b' }), { guardrails: true }); assert.equal(d.hookSpecificOutput.permissionDecision, 'deny'); assert.match(d.hookSpecificOutput.permissionDecisionReason, /em-dash/); }); test('placeholder not blocked when only em-dash enabled', () => { const d = evaluateGuardrail(payload({ content: 'TODO later' }), { guardrails: true, guardrail_checks: ['em-dash'] }); assert.equal(d, null); }); test('placeholder warns (not denies) when enabled', () => { const d = evaluateGuardrail(payload({ content: 'TODO later' }), { guardrails: true, guardrail_checks: ['em-dash', 'placeholder'] }); assert.equal(d.hookSpecificOutput.permissionDecision, undefined); assert.match(d.hookSpecificOutput.additionalContext, /placeholder/i); }); test('MultiEdit edits[] are scanned (no bypass)', () => { const p = JSON.stringify({ tool_input: { edits: [{ new_string: 'clean' }, { new_string: 'a' + EM + 'b' }] } }); const d = evaluateGuardrail(p, { guardrails: true }); assert.equal(d.hookSpecificOutput.permissionDecision, 'deny'); }); test('ExitPlanMode plan content is scanned (no plan-mode bypass)', () => { const p = JSON.stringify({ tool_input: { plan: 'Step 1 ' + EM + ' Step 2' } }); const d = evaluateGuardrail(p, { guardrails: true }); assert.equal(d.hookSpecificOutput.permissionDecision, 'deny'); }); test('quoted guardrail_checks still enable the check', () => { const d = evaluateGuardrail(payload({ content: 'a' + EM + 'b' }), { guardrails: true, guardrail_checks: ['em-dash'] }); assert.equal(d.hookSpecificOutput.permissionDecision, 'deny'); }); test('malformed payload fails open (allow)', () => { assert.equal(evaluateGuardrail('not json', { guardrails: true }), null); }); const METRIC_CFG = { guardrails: true, guardrail_checks: ['fabricated-metric'] }; test('fabricated-metric warns (advisory, not deny) on a percentage with context', () => { const d = evaluateGuardrail(payload({ content: 'Lifted signups +40% conversion this quarter' }), METRIC_CFG); assert.equal(d.hookSpecificOutput.permissionDecision, undefined); // advisory, never blocks assert.match(d.hookSpecificOutput.additionalContext, /metric/i); }); test('fabricated-metric fires on a currency figure and on a metric keyword near a number', () => { assert.notEqual(evaluateGuardrail(payload({ content: 'closed $2,000,000 in revenue' }), METRIC_CFG), null); assert.notEqual(evaluateGuardrail(payload({ content: 'grew active users by 12 last month' }), METRIC_CFG), null); }); test('fabricated-metric does NOT trip on a bare date or a version string', () => { assert.equal(evaluateGuardrail(payload({ content: 'Shipped on 2026-07-04.' }), METRIC_CFG), null); assert.equal(evaluateGuardrail(payload({ content: 'Bumped the plugin to 1.2.3 today.' }), METRIC_CFG), null); assert.equal(evaluateGuardrail(payload({ content: 'We added 3 skills and 6 sub-agents.' }), METRIC_CFG), null); }); test('fabricated-metric respects the [fictional] escape hatch', () => { assert.equal(evaluateGuardrail(payload({ content: '[fictional] +40% conversion' }), METRIC_CFG), null); }); test('a date does not trip the metric check even when its LINE also names a metric word', () => { // metric word + number share the line, so this is a deliberate true positive: // the heuristic is line-scoped, and the date sits with "conversion" here. const withContext = evaluateGuardrail(payload({ content: 'conversion review scheduled 2026-07-04' }), METRIC_CFG); assert.notEqual(withContext, null); // but the same date on its own line stays silent const alone = evaluateGuardrail(payload({ content: 'conversion review\nscheduled 2026-07-04' }), METRIC_CFG); assert.equal(alone, null); }); test('fabricated-metric is inert unless the check is enabled', () => { const d = evaluateGuardrail(payload({ content: '+40% conversion' }), { guardrails: true, guardrail_checks: ['em-dash'] }); assert.equal(d, null); }); - hooks/phase-router.mjsRunsGitHub
Read the script
// hooks/phase-router.mjs - SessionStart phase router. ON by default, silent // unless a strong signal resolves a phase. Opt out with `phase_router: off` in // .claude/pm-skills.local.md (unset stays on). Dependency-free. // // Phase precedence (B1 / F-48 project memory): // 1. `phase:` declared in .claude/pm-skills.local.md <- a statement of fact // 2. branch name (branchPhase) <- heuristic // 3. artifact filenames (artifactPhase) <- heuristic // The file is opt-in and absent by default, so with no file the router behaves // exactly as it did before B1: infer, or stay silent. import { pathToFileURL, fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { branchPhase, artifactPhase, resolvePhase } from './lib/signals.mjs'; import { buildPhaseMap, skillsForPhase } from './lib/phase-map.mjs'; import { readLocalConfig, isPhaseRouterEnabled, memoryPhase, activeInitiative } from './lib/local-config.mjs'; const SELF_DIR = dirname(fileURLToPath(import.meta.url)); const DEFAULT_SKILLS_DIR = join(SELF_DIR, '..', 'skills'); const TITLE = { discover: 'Discover', define: 'Define', develop: 'Develop', deliver: 'Deliver', measure: 'Measure', iterate: 'Iterate' }; /** Router. Returns a SessionStart hook-output object, or null to stay silent. * `config` defaults to the project's .local.md; pass it explicitly in tests. * A `phase_router: off` opt-out short-circuits to silence before any signal work. */ export function route(cwd, skillsDir = DEFAULT_SKILLS_DIR, config = readLocalConfig(cwd)) { if (!isPhaseRouterEnabled(config)) return null; // opt-out honored -> silent let phase; let declared = false; try { // Project memory (B1) is a DECLARATION and outranks both heuristics. The branch // and artifact signals exist because the router had no way to be told; when it // has been told, guessing over the top of that would be strictly worse. const fromMemory = memoryPhase(config); if (fromMemory) { phase = fromMemory; declared = true; } else { phase = resolvePhase(branchPhase(cwd), artifactPhase(cwd)); } } catch { return null; // any signal error -> silent (fail safe) } if (!phase) return null; const skills = skillsForPhase(buildPhaseMap(skillsDir), phase).slice(0, 4); if (!skills.length) return null; // Hedged phrasing is correct for an inference and wrong for a declaration. Saying // "you appear to be" about a phase the user wrote down themselves reads as the hook // not having read it. const initiative = declared ? activeInitiative(config) : null; const lead = declared ? 'Project memory records the ' + TITLE[phase] + ' phase of the Triple Diamond' + (initiative ? ', active initiative: ' + initiative : '') + '. ' : 'You appear to be in the ' + TITLE[phase] + ' phase of the Triple Diamond. '; const context = lead + 'Relevant pm-skills: ' + skills.join(', ') + '. Invoke the one that fits the task.'; return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } }; } async function main() { const chunks = []; try { for await (const chunk of process.stdin) chunks.push(chunk); const raw = Buffer.concat(chunks).toString('utf8') || '{}'; const cwd = (JSON.parse(raw).cwd) || process.cwd(); const out = route(cwd); if (out) process.stdout.write(JSON.stringify(out)); } catch { // silent on any error } process.exit(0); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } - hooks/phase-router.test.mjsGitHub
Read the script
// hooks/phase-router.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { route } from './phase-router.mjs'; const FIX_SKILLS = join(import.meta.dirname, 'fixtures', 'skills'); test('confident branch signal injects a phase nudge naming a real skill', () => { const root = mkdtempSync(join(tmpdir(), 'route-')); mkdirSync(join(root, '.git'), { recursive: true }); writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/define/x\n'); const out = route(root, FIX_SKILLS); assert.equal(out.hookSpecificOutput.hookEventName, 'SessionStart'); assert.match(out.hookSpecificOutput.additionalContext, /Define phase/); assert.match(out.hookSpecificOutput.additionalContext, /define-x/); rmSync(root, { recursive: true, force: true }); }); test('no signal returns null (silent path)', () => { const root = mkdtempSync(join(tmpdir(), 'route-')); assert.equal(route(root, FIX_SKILLS), null); rmSync(root, { recursive: true, force: true }); }); // Helper: a project root that DOES resolve a phase (define branch), so a null // result can only come from the opt-out, not from a missing signal. function rootWithDefineSignal(localMd) { const root = mkdtempSync(join(tmpdir(), 'route-')); mkdirSync(join(root, '.git'), { recursive: true }); writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/define/x\n'); if (localMd !== null) { mkdirSync(join(root, '.claude'), { recursive: true }); writeFileSync(join(root, '.claude', 'pm-skills.local.md'), localMd, 'utf8'); } return root; } test('phase_router: off silences the router even with a resolving signal', () => { const root = rootWithDefineSignal('---\nphase_router: off\n---\n'); assert.equal(route(root, FIX_SKILLS), null); rmSync(root, { recursive: true, force: true }); }); test('an injected off config short-circuits before any signal work', () => { const root = rootWithDefineSignal(null); // signal present, no .local.md on disk assert.equal(route(root, FIX_SKILLS, { phase_router: 'off' }), null); rmSync(root, { recursive: true, force: true }); }); test('unset opt-out leaves the router on (default behavior unchanged)', () => { const root = rootWithDefineSignal(null); const out = route(root, FIX_SKILLS); // config unset -> reads {} -> on assert.equal(out.hookSpecificOutput.hookEventName, 'SessionStart'); assert.match(out.hookSpecificOutput.additionalContext, /define-x/); rmSync(root, { recursive: true, force: true }); }); test('phase_router: auto keeps the router on', () => { const root = rootWithDefineSignal('---\nphase_router: auto\n---\n'); const out = route(root, FIX_SKILLS); assert.match(out.hookSpecificOutput.additionalContext, /Define phase/); rmSync(root, { recursive: true, force: true }); }); // --- Project memory precedence (B1 / F-48) --------------------------------- // A root that has a CONFLICTING branch signal (define) plus a project-memory file. // Any Deliver result therefore proves the declaration beat the heuristic. function rootWithDefineBranchAnd(localMd) { const root = mkdtempSync(join(tmpdir(), 'route-mem-')); mkdirSync(join(root, '.git'), { recursive: true }); writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/define\n'); if (localMd !== null) { mkdirSync(join(root, '.claude'), { recursive: true }); writeFileSync(join(root, '.claude', 'pm-skills.local.md'), localMd, 'utf8'); } return root; } test('a declared phase outranks a conflicting branch signal', () => { const root = rootWithDefineBranchAnd('---\nschema: 1\nphase: deliver\n---\n'); const ctx = route(root, FIX_SKILLS).hookSpecificOutput.additionalContext; assert.match(ctx, /Deliver phase/); assert.match(ctx, /deliver-y/); assert.doesNotMatch(ctx, /Define phase/); rmSync(root, { recursive: true, force: true }); }); test('a declared phase is stated, not hedged, and names the active initiative', () => { const root = rootWithDefineBranchAnd( '---\nschema: 1\nphase: deliver\nactive_initiative: "Self-serve onboarding"\n---\n' ); const ctx = route(root, FIX_SKILLS).hookSpecificOutput.additionalContext; assert.match(ctx, /Project memory records the Deliver phase/); assert.match(ctx, /Self-serve onboarding/); // "you appear to be" is an inference claim; a written-down phase is not inferred. assert.doesNotMatch(ctx, /appear to be/); rmSync(root, { recursive: true, force: true }); }); test('an inferred phase keeps its hedged phrasing and claims no initiative', () => { const root = rootWithDefineBranchAnd(null); const ctx = route(root, FIX_SKILLS).hookSpecificOutput.additionalContext; assert.match(ctx, /You appear to be in the Define phase/); assert.doesNotMatch(ctx, /Project memory records/); rmSync(root, { recursive: true, force: true }); }); test('an unrecognized declared phase falls back to the heuristic rather than failing', () => { const root = rootWithDefineBranchAnd('---\nschema: 1\nphase: delivery\n---\n'); const ctx = route(root, FIX_SKILLS).hookSpecificOutput.additionalContext; assert.match(ctx, /You appear to be in the Define phase/); rmSync(root, { recursive: true, force: true }); }); test('an initiative without a declared phase is NOT surfaced on an inferred nudge', () => { // The initiative describes what memory says you are working on. Attaching it to a // guessed phase would dress an inference up as a record. const root = rootWithDefineBranchAnd('---\nschema: 1\nactive_initiative: "Billing rewrite"\n---\n'); const ctx = route(root, FIX_SKILLS).hookSpecificOutput.additionalContext; assert.match(ctx, /You appear to be in the Define phase/); assert.doesNotMatch(ctx, /Billing rewrite/); rmSync(root, { recursive: true, force: true }); }); test('memory is inert when the file is absent: behavior is unchanged from pre-B1', () => { // The
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
68 plug-and-play, best-practice product management skills for AI agents: 30 Triple Diamond phase + 11 foundation + 12 utility + 15 tool (Foundation Sprint + Design Sprint). Plus 6 sub-agents, workflows, 200+ output samples, guides, and CI-enforced contracts. Apache 2.0.
Repo: product-on-purpose/pm-skills

