Skip to content
Product
Hook

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.

From plugin
product-on-purpose-pm-skills
72668 skills6 agents11 commands2 hooks
Install
> /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

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

  • Matchesstartup|resume|clear|compactnode "${CLAUDE_PLUGIN_ROOT}/hooks/phase-router.mjs"
Read hooks/hooks.json

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.

Ships withproduct-on-purpose-pm-skills

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.

Get the whole plugin