@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.
package/dist/commands/doctor.js
CHANGED
|
@@ -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) {
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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.
|
|
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).
|
|
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 `
|
package/dist/lib/templates.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/templates.js
CHANGED
|
@@ -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
|