@open-product-primer/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,6 +47,61 @@ const AGENT_DIRS = {
47
47
  claude: '.claude',
48
48
  cursor: '.cursor',
49
49
  };
50
+ function checkClaudeHooks(projectRoot, checks) {
51
+ const hooksDir = path.join(projectRoot, '.claude', 'hooks');
52
+ const promptSubmitPath = path.join(hooksDir, 'on-prompt-submit.sh');
53
+ const promptSubmitExists = fs.existsSync(promptSubmitPath);
54
+ checks.push({
55
+ name: 'agent: Claude hook (on-prompt-submit.sh)',
56
+ pass: promptSubmitExists,
57
+ note: promptSubmitExists ? undefined : "Run 'oprim update' to install",
58
+ required: false,
59
+ });
60
+ const stopHookPath = path.join(hooksDir, 'on-stop.sh');
61
+ const stopHookExists = fs.existsSync(stopHookPath);
62
+ checks.push({
63
+ name: 'agent: Claude hook (on-stop.sh)',
64
+ pass: stopHookExists,
65
+ note: stopHookExists ? undefined : "Run 'oprim update' to install",
66
+ required: false,
67
+ });
68
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
69
+ let promptSubmitRegistered = false;
70
+ let stopRegistered = false;
71
+ if (fs.existsSync(settingsPath)) {
72
+ try {
73
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
74
+ const hooksMap = settings.hooks;
75
+ const userPromptSubmit = hooksMap?.UserPromptSubmit;
76
+ promptSubmitRegistered =
77
+ userPromptSubmit?.some((entry) => {
78
+ const entryHooks = entry.hooks;
79
+ return entryHooks?.some((h) => h.command === 'bash ".claude/hooks/on-prompt-submit.sh"');
80
+ }) ?? false;
81
+ const stopHooks = hooksMap?.Stop;
82
+ stopRegistered =
83
+ stopHooks?.some((entry) => {
84
+ const entryHooks = entry.hooks;
85
+ return entryHooks?.some((h) => h.command === 'bash ".claude/hooks/on-stop.sh"');
86
+ }) ?? false;
87
+ }
88
+ catch {
89
+ // Unreadable settings.json — treat as missing
90
+ }
91
+ }
92
+ checks.push({
93
+ name: 'agent: Claude settings.json (UserPromptSubmit hook)',
94
+ pass: promptSubmitRegistered,
95
+ note: promptSubmitRegistered ? undefined : "Run 'oprim update' to register",
96
+ required: false,
97
+ });
98
+ checks.push({
99
+ name: 'agent: Claude settings.json (Stop hook)',
100
+ pass: stopRegistered,
101
+ note: stopRegistered ? undefined : "Run 'oprim update' to register",
102
+ required: false,
103
+ });
104
+ }
50
105
  function doctorCommand() {
51
106
  return new commander_1.Command('doctor')
52
107
  .description('Check oprim install health and integration readiness')
@@ -165,6 +220,9 @@ function doctorCommand() {
165
220
  required: false,
166
221
  });
167
222
  }
223
+ if (configAgents.includes('claude')) {
224
+ checkClaudeHooks(projectRoot, checks);
225
+ }
168
226
  }
169
227
  else {
170
228
  // Legacy: check for installed commands by directory presence
@@ -183,6 +241,10 @@ function doctorCommand() {
183
241
  note: cursorInstalled ? undefined : "Run 'oprim update' to install",
184
242
  required: false,
185
243
  });
244
+ // Legacy: also check hooks if .claude/ is present
245
+ if (claudeInstalled) {
246
+ checkClaudeHooks(projectRoot, checks);
247
+ }
186
248
  }
187
249
  console.log(chalk_1.default.bold('oprim') + ' — health check\n');
188
250
  for (const check of checks) {
@@ -112,9 +112,13 @@ function initCommand() {
112
112
  ' after configuring an AI tool to install /oprim:* skills.');
113
113
  }
114
114
  else {
115
+ let specFramework = 'openspec';
116
+ if (selectedAgents.includes('claude')) {
117
+ specFramework = await (0, install_agent_1.promptFrameworkSelection)(projectRoot);
118
+ }
115
119
  console.log('\n' + chalk_1.default.bold('Installing agent skills...'));
116
120
  for (const agent of selectedAgents) {
117
- (0, install_agent_1.installAgentSkills)(agent, projectRoot);
121
+ (0, install_agent_1.installAgentSkills)(agent, projectRoot, specFramework);
118
122
  }
119
123
  console.log('\n' + chalk_1.default.green('✓') + ` Agent skills installed: ${selectedAgents.join(', ')}`);
120
124
  }
@@ -50,8 +50,12 @@ function updateCommand() {
50
50
  const projectRoot = process.cwd();
51
51
  const configAgents = (0, detect_1.readAgentsFromConfig)(projectRoot);
52
52
  if (configAgents !== null && configAgents.length > 0) {
53
+ let specFramework = 'openspec';
54
+ if (configAgents.includes('claude')) {
55
+ specFramework = await (0, install_agent_1.promptFrameworkSelection)(projectRoot);
56
+ }
53
57
  for (const agent of configAgents) {
54
- (0, install_agent_1.installAgentSkills)(agent, projectRoot);
58
+ (0, install_agent_1.installAgentSkills)(agent, projectRoot, specFramework);
55
59
  }
56
60
  console.log(`\nAgent skills updated: ${configAgents.join(', ')}`);
57
61
  }
@@ -59,7 +63,8 @@ function updateCommand() {
59
63
  // Legacy: fall back to directory detection
60
64
  const legacyAgents = [];
61
65
  if (fs.existsSync(path.join(projectRoot, '.claude'))) {
62
- (0, install_agent_1.installAgentSkills)('claude', projectRoot);
66
+ const specFramework = await (0, install_agent_1.promptFrameworkSelection)(projectRoot);
67
+ (0, install_agent_1.installAgentSkills)('claude', projectRoot, specFramework);
63
68
  legacyAgents.push('claude');
64
69
  }
65
70
  if (fs.existsSync(path.join(projectRoot, '.cursor'))) {
@@ -92,9 +97,13 @@ function updateCommand() {
92
97
  console.log('\nRun ' + chalk_1.default.cyan('oprim doctor') + ' to verify your setup.');
93
98
  return;
94
99
  }
100
+ let addSpecFramework = 'openspec';
101
+ if (selected.includes('claude')) {
102
+ addSpecFramework = await (0, install_agent_1.promptFrameworkSelection)(projectRoot);
103
+ }
95
104
  console.log('\n' + chalk_1.default.bold('Installing agent skills...'));
96
105
  for (const agent of selected) {
97
- (0, install_agent_1.installAgentSkills)(agent, projectRoot);
106
+ (0, install_agent_1.installAgentSkills)(agent, projectRoot, addSpecFramework);
98
107
  }
99
108
  const merged = Array.from(new Set([...currentAgents, ...selected]));
100
109
  (0, detect_1.writeAgentsToConfig)(merged, projectRoot);
@@ -1,7 +1,8 @@
1
1
  export type Agent = 'claude' | 'cursor';
2
2
  export declare const SUPPORTED_AGENTS: readonly Agent[];
3
+ export declare function promptFrameworkSelection(projectRoot: string): Promise<string>;
3
4
  export declare function promptAgentSelection(projectRoot: string): Promise<string[]>;
4
- export declare function installAgentSkills(agent: Agent, projectRoot: string): void;
5
+ export declare function installAgentSkills(agent: Agent, projectRoot: string, framework?: string): void;
5
6
  export declare const CLAUDE_SKILLS: Record<string, string>;
6
7
  export declare const CLAUDE_COMMANDS: Record<string, string>;
7
8
  export declare const CURSOR_SKILLS: Record<string, string>;
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.CURSOR_COMMANDS = exports.CURSOR_SKILLS = exports.CLAUDE_COMMANDS = exports.CLAUDE_SKILLS = exports.SUPPORTED_AGENTS = void 0;
40
+ exports.promptFrameworkSelection = promptFrameworkSelection;
40
41
  exports.promptAgentSelection = promptAgentSelection;
41
42
  exports.installAgentSkills = installAgentSkills;
42
43
  const path = __importStar(require("path"));
@@ -45,6 +46,29 @@ const chalk_1 = __importDefault(require("chalk"));
45
46
  const scaffold_1 = require("./scaffold");
46
47
  const detect_1 = require("./detect");
47
48
  exports.SUPPORTED_AGENTS = ['claude', 'cursor'];
49
+ async function promptFrameworkSelection(projectRoot) {
50
+ const configPath = path.join(projectRoot, '.claude', 'hooks', 'config.json');
51
+ if (fs.existsSync(configPath)) {
52
+ try {
53
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
54
+ if (typeof existing.framework === 'string') {
55
+ console.log(chalk_1.default.dim(` Speccing framework: ${existing.framework} (from config)`));
56
+ return existing.framework;
57
+ }
58
+ }
59
+ catch {
60
+ // fallthrough to prompt
61
+ }
62
+ }
63
+ const { select } = await Promise.resolve().then(() => __importStar(require('@inquirer/prompts')));
64
+ return select({
65
+ message: 'Which speccing framework does this project use?',
66
+ choices: [
67
+ { name: 'OpenSpec (recommended)', value: 'openspec' },
68
+ { name: 'None', value: 'none' },
69
+ ],
70
+ });
71
+ }
48
72
  async function promptAgentSelection(projectRoot) {
49
73
  const detected = (0, detect_1.detectAvailableAgents)(projectRoot);
50
74
  if (detected.length > 0) {
@@ -60,7 +84,7 @@ async function promptAgentSelection(projectRoot) {
60
84
  ],
61
85
  });
62
86
  }
63
- function installAgentSkills(agent, projectRoot) {
87
+ function installAgentSkills(agent, projectRoot, framework = 'openspec') {
64
88
  if (agent === 'claude') {
65
89
  const claudeDir = path.join(projectRoot, '.claude');
66
90
  const dirCreated = !fs.existsSync(claudeDir);
@@ -84,6 +108,25 @@ function installAgentSkills(agent, projectRoot) {
84
108
  console.log(chalk_1.default.dim(` removed legacy command .claude/commands/oprim/${filename}`));
85
109
  }
86
110
  }
111
+ // Tombstone: remove legacy on-skill-archive.sh (replaced by on-prompt-submit + on-stop in v0.x)
112
+ const legacyHookPath = path.join(claudeDir, 'hooks', 'on-skill-archive.sh');
113
+ if (fs.existsSync(legacyHookPath)) {
114
+ fs.unlinkSync(legacyHookPath);
115
+ console.log(chalk_1.default.dim(' removed legacy hook .claude/hooks/on-skill-archive.sh'));
116
+ }
117
+ // Hooks: UserPromptSubmit + Stop for co-archival coordination
118
+ const hooksDir = path.join(claudeDir, 'hooks');
119
+ (0, scaffold_1.writeFile)(path.join(hooksDir, 'config.json'), hooksConfig(framework));
120
+ console.log(chalk_1.default.green('✓') + ' .claude/hooks/config.json');
121
+ const promptSubmitPath = path.join(hooksDir, 'on-prompt-submit.sh');
122
+ (0, scaffold_1.writeFile)(promptSubmitPath, ON_PROMPT_SUBMIT_HOOK);
123
+ fs.chmodSync(promptSubmitPath, 0o755);
124
+ console.log(chalk_1.default.green('✓') + ' .claude/hooks/on-prompt-submit.sh');
125
+ const stopHookPath = path.join(hooksDir, 'on-stop.sh');
126
+ (0, scaffold_1.writeFile)(stopHookPath, ON_STOP_HOOK);
127
+ fs.chmodSync(stopHookPath, 0o755);
128
+ console.log(chalk_1.default.green('✓') + ' .claude/hooks/on-stop.sh');
129
+ mergeClaudeSettingsHooks(claudeDir);
87
130
  if (dirCreated) {
88
131
  console.log(chalk_1.default.dim(' .claude/ created — Claude Code will discover these files automatically.'));
89
132
  }
@@ -112,11 +155,13 @@ exports.CLAUDE_SKILLS = {
112
155
  'oprim-bet': betSkill(),
113
156
  'oprim-criteria': criteriaSkill(),
114
157
  'oprim-review': reviewSkill(),
158
+ 'oprim-archive': archiveSkill(),
115
159
  };
116
160
  // ─── Claude command wrappers (thin, invoke skill) ────────────────────────────
117
161
  exports.CLAUDE_COMMANDS = {
118
162
  'promote.md': claudeWrapper('OPRIM: Promote', 'Promote a prioritized bet to an OpenSpec change', promoteContent()),
119
163
  'sequence.md': claudeWrapper('OPRIM: Sequence', 'Validate and update the primer sequencing board', sequenceContent()),
164
+ 'archive.md': claudeWrapper('OPRIM: Archive', 'Archive a completed bet — move it out of the active board', archiveCommandContent()),
120
165
  };
121
166
  // ─── Cursor skill playbooks ───────────────────────────────────────────────────
122
167
  exports.CURSOR_SKILLS = {
@@ -229,8 +274,21 @@ Create a new bet in \`oprim/bets/\` and register it on the sequencing board.
229
274
  ## Steps
230
275
 
231
276
  ### 1. Get the bet title
277
+ Display the naming convention before asking:
278
+
279
+ > **Naming tip:** Use "verb + object [for context]"
280
+ > - Good: "Improve bet naming for scannability"
281
+ > - Bad: "Naming"
282
+
232
283
  If not provided, ask: "What is the title of this bet?"
233
284
 
285
+ After receiving the title, validate: if fewer than 4 words OR fewer than 25 characters:
286
+ - Show: "Warning: this title may be too vague to scan at a glance."
287
+ - Suggest a reformulation, e.g. "Consider: 'Improve <what> for <why>'"
288
+ - Ask: "Proceed with this title anyway? (y/N)"
289
+ - If "n" or Enter: ask for a revised title and re-validate
290
+ - If "y": proceed with the original title
291
+
234
292
  ### 2. Assign the next BET ID
235
293
  Scan \`oprim/bets/\` for directories matching \`BET-(\\d+)$\`. Extract all integers. Assign max+1, zero-padded to 3 digits. Default \`001\` if none.
236
294
 
@@ -243,6 +301,7 @@ Ask: Decision (Build now / Defer / Kill, default Build now), Owner, Review date
243
301
  ### 5. Write oprim/bets/BET-NNN/bet-decision.md
244
302
  \`\`\`
245
303
  # Decision: BET-NNN <title>
304
+ <!-- Naming tip: verb + object [for context] — e.g. "Improve bet naming for scannability" not "Naming" -->
246
305
 
247
306
  ## Status
248
307
  - Decision: <decision>
@@ -338,6 +397,83 @@ If not: create with \`metrics:\` list.
338
397
  ### 7. Report what was created
339
398
  `;
340
399
  }
400
+ function archiveSkill() {
401
+ return `---
402
+ name: oprim-archive
403
+ description: Archive a completed bet — moves it to oprim/bets/archived/BET-NNN/ and removes its sequence.yaml entry
404
+ ---
405
+
406
+ Archive a completed bet by moving it to \`oprim/bets/archived/\` and removing it from \`sequence.yaml\`.
407
+
408
+ ## Steps
409
+
410
+ ### 1. Get the bet ID
411
+
412
+ If provided as an argument (e.g., \`/oprim:archive BET-005\`), use it directly.
413
+
414
+ If not provided, ask: "Which bet ID would you like to archive? (e.g., BET-005)"
415
+
416
+ Normalize the input: accept \`bet-005\`, \`005\`, \`5\`, or \`BET-005\` — always treat as \`BET-NNN\` zero-padded to 3 digits.
417
+
418
+ ### 2. Check the bet directory exists
419
+
420
+ Check whether \`oprim/bets/BET-NNN/\` exists.
421
+
422
+ If not found:
423
+ - Report: "Bet BET-NNN was not found in oprim/bets/. Nothing was changed."
424
+ - Stop.
425
+
426
+ ### 3. Check for active dependencies in sequence.yaml
427
+
428
+ Read \`oprim/sequence.yaml\`. Scan every entry across all buckets (now, next, later, backlog) for any entry whose \`blocked_by\` or \`unlocks\` list contains the target bet ID.
429
+
430
+ If dependents are found:
431
+ - Show a warning listing each dependent entry and which field references the target bet.
432
+
433
+ Example:
434
+ \`\`\`
435
+ ⚠ Warning: BET-005 is referenced by active bets:
436
+ - BET-007 (blocked_by: [BET-005])
437
+ - BET-008 (unlocks: [BET-005])
438
+ \`\`\`
439
+ - Ask: "Archive BET-NNN anyway? These references will become stale. (y/N)"
440
+ - If "n" or Enter: stop, no changes made.
441
+ - If "y": proceed.
442
+
443
+ If no dependents found: proceed without warning.
444
+
445
+ ### 4. Move the bet directory to archive
446
+
447
+ Create the archive subfolder if it doesn't exist:
448
+ \`\`\`bash
449
+ mkdir -p oprim/bets/archived
450
+ \`\`\`
451
+
452
+ Move the directory:
453
+ \`\`\`bash
454
+ mv oprim/bets/BET-NNN oprim/bets/archived/BET-NNN
455
+ \`\`\`
456
+
457
+ ### 5. Remove the bet entry from sequence.yaml
458
+
459
+ Read \`oprim/sequence.yaml\`, parse it, and remove the entry with \`id: BET-NNN\` from whichever bucket it appears in (now, next, later, or backlog). Write the updated YAML back using 2-space indentation. Do not modify any other entries.
460
+
461
+ ### 6. Report what was done
462
+
463
+ \`\`\`
464
+ ## Bet Archived
465
+
466
+ **Bet:** BET-NNN
467
+ **Archived to:** oprim/bets/archived/BET-NNN/
468
+ **Removed from sequence.yaml:** ✓
469
+
470
+ The bet is preserved in full at the archive location.
471
+ \`\`\`
472
+ `;
473
+ }
474
+ function archiveCommandContent() {
475
+ return `Use the Skill tool to invoke the \`oprim-archive\` skill.`;
476
+ }
341
477
  function reviewSkill() {
342
478
  return `---
343
479
  name: oprim-review
@@ -405,7 +541,7 @@ function pdrInlineContent() {
405
541
  return `Create a new PDR in \`oprim/decisions/\`. Scan for \`PDR-(\\d+)-\` to assign next ID (zero-padded, default 001). Gather: title, context, decision, alternatives, consequences, evidence, related bets/specs. Ask if superseding an existing PDR. Write \`oprim/decisions/PDR-NNN-<slug>.md\`. If superseding: update old PDR Status to "Superseded by PDR-NNN". Report what was created.`;
406
542
  }
407
543
  function betInlineContent() {
408
- return `Create a new bet in \`oprim/bets/\`. Scan \`BET-(\\d+)$\` dirs for next ID (zero-padded, default 001). Check \`oprim/sequence.yaml\` exists (stop if not — advise oprim init). Gather: title, decision (default Build now), owner, review date, why-now, alternatives, expected outcomes, kill criteria, PDR links. Write \`oprim/bets/BET-NNN/bet-decision.md\`. Append entry to sequence.yaml backlog: \`{id, title, blocked_by: [], unlocks: [], requires_pdrs: []}\`. Then ask: "Do you want to scaffold a discovery.md now? (y/N)" — if "y", write \`oprim/bets/BET-NNN/discovery.md\` from the discovery template (sections: Problem Framing, User Research Signals, Competitive Context, Open Questions); if "n" or Enter, skip silently. Report what was created.`;
544
+ return `Create a new bet in \`oprim/bets/\`. Before asking for the title, show: "Naming tip: verb + object [for context] — Good: 'Improve bet naming for scannability' / Bad: 'Naming'". Scan \`BET-(\\d+)$\` dirs for next ID (zero-padded, default 001). Check \`oprim/sequence.yaml\` exists (stop if not — advise oprim init). After receiving the title, validate: if fewer than 4 words OR fewer than 25 characters, warn "this title may be too vague", suggest a reformulation, and ask "Proceed anyway? (y/N)" — if "n", prompt for a revised title. Gather: decision (default Build now), owner, review date, why-now, alternatives, expected outcomes, kill criteria, PDR links. Write \`oprim/bets/BET-NNN/bet-decision.md\` with an inline naming tip comment in the header. Append entry to sequence.yaml backlog: \`{id, title, blocked_by: [], unlocks: [], requires_pdrs: []}\`. Then ask: "Do you want to scaffold a discovery.md now? (y/N)" — if "y", write \`oprim/bets/BET-NNN/discovery.md\` from the discovery template (sections: Problem Framing, User Research Signals, Competitive Context, Open Questions); if "n" or Enter, skip silently. Report what was created.`;
409
545
  }
410
546
  function criteriaInlineContent() {
411
547
  return `Add metrics to \`oprim/bets/BET-NNN/criteria.yaml\`. Verify bet dir exists. Gather: metric ID, name, baseline, target, timeframe, launch date, segment. Ask source type (amplitude or bigquery). Amplitude: event, aggregation, denominator_event. BigQuery: table, metric_column, filter, aggregation, denominator_query. If file exists: append to metrics list (never overwrite). If not: create. Ask if adding more metrics. Report what was created.`;
@@ -413,6 +549,121 @@ function criteriaInlineContent() {
413
549
  function reviewInlineContent() {
414
550
  return `Create KPI review in \`oprim/reviews/YYYY-MM-DD-BET-NNN-kpi.md\`. Read \`criteria.yaml\` for pre-fill (baseline/target). Check \`oprim/bets/BET-NNN/measurements/\` for \`run-*.yaml\` files — if found, use the most recent to pre-populate actuals and status (include "Actuals from run: YYYY-MM-DD" note). If no run result, ask for each metric's actual value. Status: actual >= target → hit, actual < target → missed, not provided → pending. Ask reviewer name and decision quality notes. Write review with metric table and Actions checklist. Report what was created.`;
415
551
  }
552
+ // ─── Hook scripts: co-archival coordination ──────────────────────────────────
553
+ function hooksConfig(framework) {
554
+ return (JSON.stringify({
555
+ framework,
556
+ archive_commands: framework === 'openspec' ? ['/opsx:archive', '/openspec-archive-change'] : [],
557
+ }, null, 2) + '\n');
558
+ }
559
+ const ON_PROMPT_SUBMIT_HOOK = `#!/usr/bin/env bash
560
+ # UserPromptSubmit hook: detects archive slash commands and sets a pending flag.
561
+
562
+ config_file=".claude/hooks/config.json"
563
+ flag_file=".claude/hooks/.archive-pending"
564
+
565
+ framework="openspec"
566
+ if [ -f "$config_file" ]; then
567
+ framework=$(python3 -c "import sys,json; print(json.load(open('$config_file')).get('framework','openspec'))" 2>/dev/null || echo "openspec")
568
+ fi
569
+
570
+ prompt=$(cat | python3 -c "import sys,json; print(json.load(sys.stdin).get('prompt',''))" 2>/dev/null || true)
571
+
572
+ [ -z "$prompt" ] && exit 0
573
+
574
+ if [ "$framework" = "openspec" ]; then
575
+ if echo "$prompt" | grep -qE '^[[:space:]]*/(opsx:archive|openspec-archive-change)([[:space:]]|$)'; then
576
+ arg=$(echo "$prompt" | sed 's|^[[:space:]]*/[^[:space:]]* *||' | sed 's|^@||' | sed 's|.*/changes/||' | sed 's|/$||' | xargs 2>/dev/null || true)
577
+ echo "$arg" > "$flag_file"
578
+ fi
579
+ fi
580
+ `;
581
+ const ON_STOP_HOOK = `#!/usr/bin/env bash
582
+ # Stop hook: if an archive command was detected, find the linked bet and prompt co-archival.
583
+
584
+ flag_file=".claude/hooks/.archive-pending"
585
+ [ -f "$flag_file" ] || exit 0
586
+
587
+ change=$(tr -d '[:space:]' < "$flag_file")
588
+ rm -f "$flag_file"
589
+
590
+ if [ -z "$change" ]; then
591
+ latest=$(ls openspec/changes/archive/ 2>/dev/null | sort -r | head -1)
592
+ [ -z "$latest" ] && exit 0
593
+ change=$(echo "$latest" | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-//')
594
+ fi
595
+
596
+ [ -z "$change" ] && exit 0
597
+
598
+ archive_dir=$(ls openspec/changes/archive/ 2>/dev/null | grep -F "$change" | sort -r | head -1)
599
+ [ -z "$archive_dir" ] && exit 0
600
+
601
+ proposal="openspec/changes/archive/$archive_dir/proposal.md"
602
+ [ -f "$proposal" ] || exit 0
603
+
604
+ bet_id=$(grep -oE 'BET-[0-9]+' "$proposal" | head -1)
605
+ [ -z "$bet_id" ] && exit 0
606
+
607
+ printf '{"decision":"block","reason":"The openspec change '\''%s'\'' was just archived. Its proposal.md references %s. Please invoke \`/oprim:archive %s\` to co-archive the linked bet."}\\n' "$change" "$bet_id" "$bet_id"
608
+ `;
609
+ // Merge UserPromptSubmit + Stop hooks into .claude/settings.json without clobbering existing entries.
610
+ // Also removes the legacy PostToolUse/Skill hook from on-skill-archive.sh if present.
611
+ function mergeClaudeSettingsHooks(claudeDir) {
612
+ const settingsPath = path.join(claudeDir, 'settings.json');
613
+ let settings = {};
614
+ if (fs.existsSync(settingsPath)) {
615
+ try {
616
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
617
+ }
618
+ catch {
619
+ // Unreadable settings — start from scratch
620
+ }
621
+ }
622
+ if (!settings.hooks)
623
+ settings.hooks = {};
624
+ const hooks = settings.hooks;
625
+ // Tombstone: remove legacy PostToolUse/Skill entry from on-skill-archive.sh
626
+ const legacyCommand = 'bash ".claude/hooks/on-skill-archive.sh"';
627
+ if (hooks.PostToolUse) {
628
+ const postToolUse = hooks.PostToolUse;
629
+ const filtered = postToolUse.filter((entry) => {
630
+ const entryHooks = entry.hooks;
631
+ return !entryHooks?.some((h) => h.command === legacyCommand);
632
+ });
633
+ if (filtered.length === 0) {
634
+ delete hooks.PostToolUse;
635
+ }
636
+ else {
637
+ hooks.PostToolUse = filtered;
638
+ }
639
+ }
640
+ // Register UserPromptSubmit hook
641
+ const promptSubmitCommand = 'bash ".claude/hooks/on-prompt-submit.sh"';
642
+ if (!hooks.UserPromptSubmit)
643
+ hooks.UserPromptSubmit = [];
644
+ const userPromptSubmit = hooks.UserPromptSubmit;
645
+ const promptSubmitPresent = userPromptSubmit.some((entry) => {
646
+ const entryHooks = entry.hooks;
647
+ return entryHooks?.some((h) => h.command === promptSubmitCommand);
648
+ });
649
+ if (!promptSubmitPresent) {
650
+ userPromptSubmit.push({ hooks: [{ type: 'command', command: promptSubmitCommand }] });
651
+ }
652
+ // Register Stop hook
653
+ const stopCommand = 'bash ".claude/hooks/on-stop.sh"';
654
+ if (!hooks.Stop)
655
+ hooks.Stop = [];
656
+ const stopHooks = hooks.Stop;
657
+ const stopPresent = stopHooks.some((entry) => {
658
+ const entryHooks = entry.hooks;
659
+ return entryHooks?.some((h) => h.command === stopCommand);
660
+ });
661
+ if (!stopPresent) {
662
+ stopHooks.push({ hooks: [{ type: 'command', command: stopCommand }] });
663
+ }
664
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
665
+ console.log(chalk_1.default.green('✓') + ' .claude/settings.json (UserPromptSubmit + Stop hooks registered)');
666
+ }
416
667
  // ─── Legacy content (promote / sequence remain inline) ───────────────────────
417
668
  function promoteContent() {
418
669
  return `
@@ -1,7 +1,7 @@
1
1
  export declare function configTemplate(projectName: string, openspecEnabled: boolean, graphifyEnabled: boolean): string;
2
2
  export declare const sequenceTemplate = "wip_limits:\n now: 2\n\nnow: []\nnext: []\nlater: []\nbacklog: []\n";
3
3
  export declare const pdrTemplate = "# PDR-XXX: <Decision title>\n\n## Status\nProposed | Accepted | Deprecated | Superseded by PDR-YYY\n\n## Context\n<What forced this decision?>\n\n## Decision\n<Clear statement of what is decided>\n\n## Alternatives considered\n- <Alternative A and why rejected>\n- <Alternative B and why rejected>\n\n## Consequences\n- Positive: <...>\n- Trade-offs: <...>\n- Follow-ups: <...>\n\n## Evidence\n- <Research link>\n- <Data link>\n\n## Related\n- Bets: <BET-IDs>\n- OpenSpec: <change paths>\n- Supersedes: <PDR-ID or none>\n";
4
- export declare const betDecisionTemplate = "# Decision: BET-XXX <Bet title>\n\n## Status\n- Decision: Build now | Defer | Kill\n- Date: YYYY-MM-DD\n- Owner: <name>\n- Review date: YYYY-MM-DD\n\n## Why now\n- <prioritization rationale>\n\n## Alternatives considered\n- <alternative + reason>\n\n## Expected outcomes\n- <metric: baseline -> target in timeframe>\n\n## Kill criteria / rollback trigger\n- <condition and action>\n\n## Links\n- PDRs: <PDR-IDs>\n- OpenSpec change: <path once promoted>\n";
4
+ export declare const betDecisionTemplate = "# Decision: BET-XXX <Bet title>\n<!-- Naming tip: verb + object [for context] \u2014 e.g. \"Improve bet naming for scannability\" not \"Naming\" -->\n\n## Status\n- Decision: Build now | Defer | Kill\n- Date: YYYY-MM-DD\n- Owner: <name>\n- Review date: YYYY-MM-DD\n\n## Why now\n- <prioritization rationale>\n\n## Alternatives considered\n- <alternative + reason>\n\n## Expected outcomes\n- <metric: baseline -> target in timeframe>\n\n## Kill criteria / rollback trigger\n- <condition and action>\n\n## Links\n- PDRs: <PDR-IDs>\n- OpenSpec change: <path once promoted>\n";
5
5
  export declare const criteriaTemplate = "metrics:\n - id: metric_id\n name: \"Metric name\"\n baseline: 0.00\n target: 0.00\n timeframe: \"30 days post-launch\"\n launch_date: \"YYYY-MM-DD\"\n source:\n type: amplitude\n definition:\n event: event_name\n aggregation: unique_users\n denominator_event: null\n segment: null\n";
6
6
  export declare const discoveryTemplate = "# Discovery: BET-XXX <Bet title>\n\n## Problem Framing\n- **Problem statement**: <What problem are we solving and for whom?>\n- **Evidence this is real**: <Data, support tickets, user quotes, etc.>\n- **Why it matters**: <Business or user impact if left unsolved>\n\n## User Research Signals\n- **Research conducted**: <Interviews, surveys, usability tests, etc.>\n- **Key findings**: <What did we learn?>\n- **Assumptions to validate**: <What are we still unsure about?>\n\n## Competitive Context\n- **How others solve this**: <Competitor or adjacent solutions>\n- **Our differentiation**: <Why our approach is better or different>\n- **Gaps / opportunities**: <What's underserved in the market?>\n\n## Open Questions\n- [ ] <Question 1 \u2014 what needs to be answered before committing?>\n- [ ] <Question 2>\n- [ ] <Question 3>\n";
7
7
  export declare const kpiReviewTemplate = "# KPI Review: BET-XXX\n\n**Review date:** YYYY-MM-DD\n**Reviewed by:** <name>\n\n| Metric | Baseline | Target | Actual | Status |\n|--------|----------|--------|--------|--------|\n| <metric name> | - | - | - | pending |\n\n## Decision quality\n<Did outcomes validate the decision? What would you do differently?>\n\n## Actions\n- [ ] Update bet-decision outcome section\n- [ ] Update affected PDRs\n- [ ] Re-sequence impacted bets\n";
@@ -63,6 +63,7 @@ Proposed | Accepted | Deprecated | Superseded by PDR-YYY
63
63
  - Supersedes: <PDR-ID or none>
64
64
  `;
65
65
  exports.betDecisionTemplate = `# Decision: BET-XXX <Bet title>
66
+ <!-- Naming tip: verb + object [for context] — e.g. "Improve bet naming for scannability" not "Naming" -->
66
67
 
67
68
  ## Status
68
69
  - Decision: Build now | Defer | Kill
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-product-primer/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Open Product Primer CLI — product decisions, sequencing, and KPI tracking for repositories",
5
5
  "keywords": [
6
6
  "product",