@open-product-primer/cli 0.10.0 → 0.12.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/cli.js +2 -0
- package/dist/commands/init.js +6 -0
- package/dist/commands/ovw.d.ts +2 -0
- package/dist/commands/ovw.js +263 -0
- package/dist/lib/install-agent.js +24 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ const update_1 = require("./commands/update");
|
|
|
10
10
|
const doctor_1 = require("./commands/doctor");
|
|
11
11
|
const migrate_1 = require("./commands/migrate");
|
|
12
12
|
const measure_1 = require("./commands/measure");
|
|
13
|
+
const ovw_1 = require("./commands/ovw");
|
|
13
14
|
const package_json_1 = __importDefault(require("../package.json"));
|
|
14
15
|
const program = new commander_1.Command();
|
|
15
16
|
program
|
|
@@ -21,4 +22,5 @@ program.addCommand((0, update_1.updateCommand)());
|
|
|
21
22
|
program.addCommand((0, doctor_1.doctorCommand)());
|
|
22
23
|
program.addCommand((0, migrate_1.migrateCommand)());
|
|
23
24
|
program.addCommand((0, measure_1.measureCommand)());
|
|
25
|
+
program.addCommand((0, ovw_1.ovwCommand)());
|
|
24
26
|
program.parse();
|
package/dist/commands/init.js
CHANGED
|
@@ -128,5 +128,11 @@ function initCommand() {
|
|
|
128
128
|
console.log('\n' + chalk_1.default.green('✓') + ` Agent skills installed: ${selectedAgents.join(', ')}`);
|
|
129
129
|
}
|
|
130
130
|
console.log('\nRun ' + chalk_1.default.cyan('oprim doctor') + ' to verify your setup.');
|
|
131
|
+
console.log('\n' +
|
|
132
|
+
chalk_1.default.bold('Next:') +
|
|
133
|
+
' capture your first product decision. A bet is a time-bound commitment — what you\'re building, why now, and what would make you stop.' +
|
|
134
|
+
'\nOpen your AI agent and run ' +
|
|
135
|
+
chalk_1.default.cyan('/oprim:bet') +
|
|
136
|
+
' to create one.');
|
|
131
137
|
});
|
|
132
138
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.ovwCommand = ovwCommand;
|
|
40
|
+
const commander_1 = require("commander");
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
44
|
+
const yaml = __importStar(require("js-yaml"));
|
|
45
|
+
function resolveBetDecisionPath(betsDir, betId) {
|
|
46
|
+
if (!fs.existsSync(betsDir))
|
|
47
|
+
return null;
|
|
48
|
+
const entries = fs.readdirSync(betsDir, { withFileTypes: true });
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.isDirectory() && entry.name.startsWith(betId)) {
|
|
51
|
+
const candidate = path.join(betsDir, entry.name, 'bet-decision.md');
|
|
52
|
+
if (fs.existsSync(candidate))
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function extractDoorType(content) {
|
|
59
|
+
if (/\[x\]\s*2-way door/i.test(content))
|
|
60
|
+
return '2-way';
|
|
61
|
+
if (/\[x\]\s*1-way door/i.test(content))
|
|
62
|
+
return '1-way';
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function extractRiskLevel(content, label) {
|
|
66
|
+
const match = content.match(new RegExp(`\\*\\*${label}\\*\\*:\\s*(Low|Medium|High)`, 'i'));
|
|
67
|
+
if (!match)
|
|
68
|
+
return null;
|
|
69
|
+
const raw = match[1];
|
|
70
|
+
if (raw.toLowerCase() === 'low')
|
|
71
|
+
return 'Low';
|
|
72
|
+
if (raw.toLowerCase() === 'medium')
|
|
73
|
+
return 'Medium';
|
|
74
|
+
if (raw.toLowerCase() === 'high')
|
|
75
|
+
return 'High';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function extractRiskProfile(content) {
|
|
79
|
+
return {
|
|
80
|
+
value: extractRiskLevel(content, 'Value risk'),
|
|
81
|
+
usability: extractRiskLevel(content, 'Usability risk'),
|
|
82
|
+
feasibility: extractRiskLevel(content, 'Feasibility risk'),
|
|
83
|
+
viability: extractRiskLevel(content, 'Business viability risk'),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function loadBetMeta(betsDir, betId) {
|
|
87
|
+
const decisionPath = resolveBetDecisionPath(betsDir, betId);
|
|
88
|
+
if (!decisionPath)
|
|
89
|
+
return { doorType: null, risks: null };
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(decisionPath, 'utf-8');
|
|
92
|
+
return {
|
|
93
|
+
doorType: extractDoorType(content),
|
|
94
|
+
risks: extractRiskProfile(content),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { doorType: null, risks: null };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function abbreviate(level) {
|
|
102
|
+
if (level === 'Low')
|
|
103
|
+
return 'L';
|
|
104
|
+
if (level === 'Medium')
|
|
105
|
+
return 'M';
|
|
106
|
+
if (level === 'High')
|
|
107
|
+
return 'H';
|
|
108
|
+
return '?';
|
|
109
|
+
}
|
|
110
|
+
function renderInlineMeta(meta) {
|
|
111
|
+
if (!meta.doorType && !meta.risks)
|
|
112
|
+
return '[risk: unknown]';
|
|
113
|
+
const door = meta.doorType ?? '?-way';
|
|
114
|
+
if (!meta.risks)
|
|
115
|
+
return `[${door} | risk: unknown]`;
|
|
116
|
+
const { value, usability, feasibility, viability } = meta.risks;
|
|
117
|
+
return `[${door} | value:${abbreviate(value)} usability:${abbreviate(usability)} feasibility:${abbreviate(feasibility)} viability:${abbreviate(viability)}]`;
|
|
118
|
+
}
|
|
119
|
+
function renderBlockers(bet) {
|
|
120
|
+
if (!bet.blocked_by || bet.blocked_by.length === 0)
|
|
121
|
+
return '';
|
|
122
|
+
return ` [blocked by: ${bet.blocked_by.join(', ')}]`;
|
|
123
|
+
}
|
|
124
|
+
function renderLane(label, bets, betsDir, showMeta) {
|
|
125
|
+
console.log(chalk_1.default.bold.cyan(`\n── ${label.toUpperCase()} ──`));
|
|
126
|
+
if (bets.length === 0) {
|
|
127
|
+
console.log(' (empty)');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const bet of bets) {
|
|
131
|
+
if (showMeta) {
|
|
132
|
+
const meta = loadBetMeta(betsDir, bet.id);
|
|
133
|
+
const inline = renderInlineMeta(meta);
|
|
134
|
+
const blockers = renderBlockers(bet);
|
|
135
|
+
console.log(` ${bet.id} ${bet.title} ${inline}${blockers}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const blockers = renderBlockers(bet);
|
|
139
|
+
console.log(` ${bet.id} ${bet.title}${blockers}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isElevated(level) {
|
|
144
|
+
return level === 'Medium' || level === 'High';
|
|
145
|
+
}
|
|
146
|
+
function buildAdvisory(board, betsDir) {
|
|
147
|
+
const nudges = [];
|
|
148
|
+
const now = board.now ?? [];
|
|
149
|
+
const next = board.next ?? [];
|
|
150
|
+
// Board-shape rules
|
|
151
|
+
if (now.length === 0) {
|
|
152
|
+
nudges.push('Now lane is empty — consider promoting a bet from next');
|
|
153
|
+
}
|
|
154
|
+
if (now.length > 3) {
|
|
155
|
+
nudges.push(`Focus risk: now lane has ${now.length} active bets — consider narrowing`);
|
|
156
|
+
}
|
|
157
|
+
for (const bet of now) {
|
|
158
|
+
const blockers = bet.blocked_by ?? [];
|
|
159
|
+
const inFlightIds = new Set([...now.map((b) => b.id), ...next.map((b) => b.id)]);
|
|
160
|
+
for (const blocker of blockers) {
|
|
161
|
+
if (!inFlightIds.has(blocker)) {
|
|
162
|
+
nudges.push(`${bet.id} may be stuck — its blocker ${blocker} is not in a flight lane`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Door-type sequencing rules
|
|
167
|
+
const inFlightIds = new Set([...now.map((b) => b.id), ...next.map((b) => b.id)]);
|
|
168
|
+
const metaCache = new Map();
|
|
169
|
+
const getMeta = (betId) => {
|
|
170
|
+
if (!metaCache.has(betId))
|
|
171
|
+
metaCache.set(betId, loadBetMeta(betsDir, betId));
|
|
172
|
+
return metaCache.get(betId);
|
|
173
|
+
};
|
|
174
|
+
const nowMetas = now.map((b) => ({ bet: b, meta: getMeta(b.id) }));
|
|
175
|
+
const nextMetas = next.map((b) => ({ bet: b, meta: getMeta(b.id) }));
|
|
176
|
+
const inFlightMetas = [...nowMetas, ...nextMetas];
|
|
177
|
+
// 2-way bets in flight that unlock 1-way bets (used to check unrisker presence)
|
|
178
|
+
const twowayUnlocks = new Set();
|
|
179
|
+
for (const { bet, meta } of inFlightMetas) {
|
|
180
|
+
if (meta.doorType === '2-way') {
|
|
181
|
+
for (const unlockedId of bet.unlocks ?? [])
|
|
182
|
+
twowayUnlocks.add(unlockedId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const allNow1Way = nowMetas.length > 0 && nowMetas.every(({ meta }) => meta.doorType === '1-way');
|
|
186
|
+
if (allNow1Way) {
|
|
187
|
+
nudges.push('Your now lane has no 2-way door bets — you are in full-commitment mode with no reversible fallback');
|
|
188
|
+
}
|
|
189
|
+
for (const { bet, meta } of nowMetas) {
|
|
190
|
+
if (meta.doorType === '1-way' && !twowayUnlocks.has(bet.id)) {
|
|
191
|
+
nudges.push(`${bet.id} is a 1-way door with no 2-way door unrisker in flight — consider sequencing a reversible spike first`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// 2-way door blocked by a 1-way door (inverted order)
|
|
195
|
+
for (const { bet, meta } of inFlightMetas) {
|
|
196
|
+
if (meta.doorType === '2-way') {
|
|
197
|
+
for (const blockerId of bet.blocked_by ?? []) {
|
|
198
|
+
const blockerMeta = getMeta(blockerId);
|
|
199
|
+
if (blockerMeta.doorType === '1-way') {
|
|
200
|
+
nudges.push(`${bet.id} (2-way) is blocked by ${blockerId} (1-way) — order may be inverted; 2-way doors should precede 1-way doors`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Risk advisory rules for now lane
|
|
206
|
+
for (const { bet, meta } of nowMetas) {
|
|
207
|
+
if (!meta.risks)
|
|
208
|
+
continue;
|
|
209
|
+
const { value, usability, feasibility, viability } = meta.risks;
|
|
210
|
+
if (isElevated(value) && meta.doorType === '1-way') {
|
|
211
|
+
nudges.push(`${bet.id}: high commitment with unvalidated value — consider a 2-way door discovery bet first`);
|
|
212
|
+
}
|
|
213
|
+
if (isElevated(feasibility)) {
|
|
214
|
+
nudges.push(`${bet.id}: feasibility risk is elevated — ensure a spike or prototype is in plan before full build`);
|
|
215
|
+
}
|
|
216
|
+
if (isElevated(usability)) {
|
|
217
|
+
nudges.push(`${bet.id}: usability risk is elevated — plan for user testing before shipping`);
|
|
218
|
+
}
|
|
219
|
+
if (isElevated(viability)) {
|
|
220
|
+
nudges.push(`${bet.id}: business viability risk is elevated — align with stakeholders before committing`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return nudges;
|
|
224
|
+
}
|
|
225
|
+
function ovwCommand() {
|
|
226
|
+
return new commander_1.Command('ovw')
|
|
227
|
+
.description('Show the sequencing board with door type, risk profile, and advisory guidance')
|
|
228
|
+
.action(() => {
|
|
229
|
+
const projectRoot = process.cwd();
|
|
230
|
+
const sequencePath = path.join(projectRoot, 'oprim', 'sequence.yaml');
|
|
231
|
+
const betsDir = path.join(projectRoot, 'oprim', 'bets');
|
|
232
|
+
if (!fs.existsSync(sequencePath)) {
|
|
233
|
+
console.error("No oprim/sequence.yaml found — run 'oprim init' first");
|
|
234
|
+
process.exit(1);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
let board;
|
|
238
|
+
try {
|
|
239
|
+
board = yaml.load(fs.readFileSync(sequencePath, 'utf-8')) ?? {};
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
console.error('Failed to parse oprim/sequence.yaml');
|
|
243
|
+
process.exit(1);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const lanes = [
|
|
247
|
+
{ key: 'now', label: 'now', showMeta: true },
|
|
248
|
+
{ key: 'next', label: 'next', showMeta: true },
|
|
249
|
+
{ key: 'later', label: 'later', showMeta: false },
|
|
250
|
+
{ key: 'backlog', label: 'backlog', showMeta: false },
|
|
251
|
+
];
|
|
252
|
+
for (const { key, label, showMeta } of lanes) {
|
|
253
|
+
renderLane(label, board[key] ?? [], betsDir, showMeta);
|
|
254
|
+
}
|
|
255
|
+
const nudges = buildAdvisory(board, betsDir);
|
|
256
|
+
if (nudges.length > 0) {
|
|
257
|
+
console.log(chalk_1.default.bold.yellow('\n── ADVISORY ──'));
|
|
258
|
+
for (const nudge of nudges) {
|
|
259
|
+
console.log(` • ${nudge}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -392,6 +392,10 @@ Create a new bet in \`oprim/bets/\` and register it on the sequencing board.
|
|
|
392
392
|
|
|
393
393
|
**Interactive prompts:** Use the **AskUserQuestion tool** for every question in this skill — do not write questions as plain text.
|
|
394
394
|
|
|
395
|
+
## What you're creating
|
|
396
|
+
|
|
397
|
+
A bet is a product decision you're committing to explore: a problem worth solving, a hypothesis worth testing, or a direction worth taking. You'll name it, explain why now, and set a kill criterion so you know when to stop.
|
|
398
|
+
|
|
395
399
|
## Steps
|
|
396
400
|
|
|
397
401
|
### 1. Get the bet title
|
|
@@ -422,6 +426,15 @@ If \`oprim/sequence.yaml\` not found: report and stop — advise \`oprim init\`.
|
|
|
422
426
|
### 4. Gather content
|
|
423
427
|
Ask: Decision (Build now / Defer / Kill, default Build now), Owner, Review date (YYYY-MM-DD), Why now, Alternatives considered, Expected outcomes (metric: baseline → target in timeframe), Kill criteria / rollback trigger, PDR links (optional).
|
|
424
428
|
|
|
429
|
+
Then ask about reversibility:
|
|
430
|
+
- "Is this a **2-way door** (reversible — easy to undo, safe to try) or a **1-way door** (hard to reverse — requires high confidence)?"
|
|
431
|
+
|
|
432
|
+
Then ask about each of the four risk dimensions (Low / Medium / High + short rationale):
|
|
433
|
+
- "**Value risk**: Will users/customers actually use or buy this? (Low / Medium / High — and why?)"
|
|
434
|
+
- "**Usability risk**: Can users figure out how to use it without help? (Low / Medium / High — and why?)"
|
|
435
|
+
- "**Feasibility risk**: Can we build this with our current skills, time, and technology? (Low / Medium / High — and why?)"
|
|
436
|
+
- "**Business viability risk**: Does this solution work for the business (revenue, legal, ops)? (Low / Medium / High — and why?)"
|
|
437
|
+
|
|
425
438
|
### 5. Write oprim/bets/BET-NNN-<slug>/bet-decision.md
|
|
426
439
|
\`\`\`
|
|
427
440
|
# Decision: BET-NNN <title>
|
|
@@ -433,6 +446,16 @@ Ask: Decision (Build now / Defer / Kill, default Build now), Owner, Review date
|
|
|
433
446
|
- Owner: <owner>
|
|
434
447
|
- Review date: <review date>
|
|
435
448
|
|
|
449
|
+
## Door type
|
|
450
|
+
- [<x if 2-way>] 2-way door (reversible — safe to try, easy to undo)
|
|
451
|
+
- [<x if 1-way>] 1-way door (hard to reverse — requires higher confidence before committing)
|
|
452
|
+
|
|
453
|
+
## Risk profile
|
|
454
|
+
- **Value risk**: <Low / Medium / High> — <rationale>
|
|
455
|
+
- **Usability risk**: <Low / Medium / High> — <rationale>
|
|
456
|
+
- **Feasibility risk**: <Low / Medium / High> — <rationale>
|
|
457
|
+
- **Business viability risk**: <Low / Medium / High> — <rationale>
|
|
458
|
+
|
|
436
459
|
## Why now
|
|
437
460
|
<why-now as bullet list>
|
|
438
461
|
|
|
@@ -800,7 +823,7 @@ function pdrInlineContent() {
|
|
|
800
823
|
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.`;
|
|
801
824
|
}
|
|
802
825
|
function betInlineContent() {
|
|
803
|
-
return `Create a new bet in \`oprim/bets/\`.
|
|
826
|
+
return `Create a new bet in \`oprim/bets/\`. First explain: "A bet is a product decision you're committing to explore — a problem worth solving, a hypothesis worth testing, or a direction worth taking. You'll name it, explain why now, and set a kill criterion." Then 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.`;
|
|
804
827
|
}
|
|
805
828
|
function criteriaInlineContent() {
|
|
806
829
|
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.`;
|