@open-product-primer/cli 2.1.0 → 2.2.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/validate.d.ts +2 -0
- package/dist/commands/validate.js +131 -0
- package/dist/lib/install-agent.js +78 -977
- package/dist/lib/spec-delta.d.ts +30 -0
- package/dist/lib/spec-delta.js +216 -0
- package/dist/lib/validate-checks.d.ts +13 -0
- package/dist/lib/validate-checks.js +145 -0
- package/dist/lib/workflow-renderer.d.ts +19 -0
- package/dist/lib/workflow-renderer.js +103 -0
- package/dist/lib/workflow-schema.d.ts +37 -0
- package/dist/lib/workflow-schema.js +126 -0
- package/dist/workflows/archive.inline.md +10 -0
- package/dist/workflows/archive.schema.yaml +13 -0
- package/dist/workflows/archive.template.md +106 -0
- package/dist/workflows/bet.cursor-command.md +1 -0
- package/dist/workflows/bet.inline.md +13 -0
- package/dist/workflows/bet.schema.yaml +14 -0
- package/dist/workflows/bet.template.md +114 -0
- package/dist/workflows/context.schema.yaml +13 -0
- package/dist/workflows/context.template.md +34 -0
- package/dist/workflows/criteria.cursor-command.md +1 -0
- package/dist/workflows/criteria.inline.md +11 -0
- package/dist/workflows/criteria.schema.yaml +14 -0
- package/dist/workflows/criteria.template.md +52 -0
- package/dist/workflows/note.cursor-command.md +1 -0
- package/dist/workflows/note.inline.md +11 -0
- package/dist/workflows/note.schema.yaml +14 -0
- package/dist/workflows/note.template.md +66 -0
- package/dist/workflows/pdr.cursor-command.md +1 -0
- package/dist/workflows/pdr.inline.md +10 -0
- package/dist/workflows/pdr.schema.yaml +14 -0
- package/dist/workflows/pdr.template.md +67 -0
- package/dist/workflows/promote.native.template.md +29 -0
- package/dist/workflows/promote.none.template.md +25 -0
- package/dist/workflows/promote.openspec.template.md +40 -0
- package/dist/workflows/promote.schema.yaml +14 -0
- package/dist/workflows/review.cursor-command.md +1 -0
- package/dist/workflows/review.inline.md +11 -0
- package/dist/workflows/review.schema.yaml +14 -0
- package/dist/workflows/review.template.md +69 -0
- package/dist/workflows/sequence.cursor-command.md +12 -0
- package/dist/workflows/sequence.inline.md +10 -0
- package/dist/workflows/sequence.schema.yaml +13 -0
- package/dist/workflows/sequence.template.md +88 -0
- package/dist/workflows/spec-authoring.schema.yaml +13 -0
- package/dist/workflows/spec-authoring.template.md +68 -0
- package/package.json +2 -2
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type DeltaSection = 'ADDED' | 'MODIFIED' | 'REMOVED';
|
|
2
|
+
export interface FoldResult {
|
|
3
|
+
content: string;
|
|
4
|
+
notes: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface CrossBetConflict {
|
|
7
|
+
betA: string;
|
|
8
|
+
betB: string;
|
|
9
|
+
capability: string;
|
|
10
|
+
header: string;
|
|
11
|
+
}
|
|
12
|
+
/** Extracts `### Requirement:` header text from a `## ADDED/MODIFIED/REMOVED Requirements` section of a delta file. */
|
|
13
|
+
export declare function parseRequirementHeaders(deltaContent: string, section: DeltaSection): string[];
|
|
14
|
+
/** Extracts `### Requirement:` header text from current truth's flat `## Requirements` section. */
|
|
15
|
+
export declare function parseCurrentTruthHeaders(currentTruthContent: string): string[];
|
|
16
|
+
/**
|
|
17
|
+
* Folds a bet's spec delta into current truth, mirroring the ADDED-append /
|
|
18
|
+
* MODIFIED-replace-or-note / REMOVED-delete-or-note algorithm `/oprim:archive` uses.
|
|
19
|
+
* Throws if `currentTruth` is null and the delta contains MODIFIED/REMOVED requirements
|
|
20
|
+
* (nothing to modify/remove against yet).
|
|
21
|
+
*/
|
|
22
|
+
export declare function foldDelta(currentTruth: string | null, delta: string): FoldResult;
|
|
23
|
+
/**
|
|
24
|
+
* Scans all active bet directories (excluding archived/) pairwise for overlapping
|
|
25
|
+
* `### Requirement:` headers within the same capability's spec delta.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findCrossBetConflicts(betsDir: string): CrossBetConflict[];
|
|
28
|
+
export declare function normalizeBetId(input: string): string;
|
|
29
|
+
/** Resolves a bet ID (accepting `bet-005`, `005`, `5`, or `BET-005`) to its directory name under `betsDir`. */
|
|
30
|
+
export declare function resolveBetDirectory(betsDir: string, betIdInput: string): string | null;
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseRequirementHeaders = parseRequirementHeaders;
|
|
37
|
+
exports.parseCurrentTruthHeaders = parseCurrentTruthHeaders;
|
|
38
|
+
exports.foldDelta = foldDelta;
|
|
39
|
+
exports.findCrossBetConflicts = findCrossBetConflicts;
|
|
40
|
+
exports.normalizeBetId = normalizeBetId;
|
|
41
|
+
exports.resolveBetDirectory = resolveBetDirectory;
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
function normalizeHeader(header) {
|
|
45
|
+
return header.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
function extractSection(markdown, sectionTitle) {
|
|
48
|
+
const lines = markdown.split('\n');
|
|
49
|
+
const startIdx = lines.findIndex((l) => l.trim() === `## ${sectionTitle}`);
|
|
50
|
+
if (startIdx === -1)
|
|
51
|
+
return null;
|
|
52
|
+
let endIdx = lines.length;
|
|
53
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
54
|
+
if (/^##\s/.test(lines[i])) {
|
|
55
|
+
endIdx = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return lines.slice(startIdx + 1, endIdx).join('\n');
|
|
60
|
+
}
|
|
61
|
+
function parseRequirementBlocks(sectionContent) {
|
|
62
|
+
const lines = sectionContent.split('\n');
|
|
63
|
+
const blocks = [];
|
|
64
|
+
let current = null;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const match = line.match(/^### Requirement:\s*(.*)$/);
|
|
67
|
+
if (match) {
|
|
68
|
+
if (current) {
|
|
69
|
+
blocks.push({ header: current.header, content: current.lines.join('\n').trim() });
|
|
70
|
+
}
|
|
71
|
+
current = { header: match[1].trim(), lines: [line] };
|
|
72
|
+
}
|
|
73
|
+
else if (current) {
|
|
74
|
+
current.lines.push(line);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (current) {
|
|
78
|
+
blocks.push({ header: current.header, content: current.lines.join('\n').trim() });
|
|
79
|
+
}
|
|
80
|
+
return blocks;
|
|
81
|
+
}
|
|
82
|
+
/** Extracts `### Requirement:` header text from a `## ADDED/MODIFIED/REMOVED Requirements` section of a delta file. */
|
|
83
|
+
function parseRequirementHeaders(deltaContent, section) {
|
|
84
|
+
const sectionContent = extractSection(deltaContent, `${section} Requirements`);
|
|
85
|
+
if (sectionContent === null)
|
|
86
|
+
return [];
|
|
87
|
+
return parseRequirementBlocks(sectionContent).map((b) => b.header);
|
|
88
|
+
}
|
|
89
|
+
/** Extracts `### Requirement:` header text from current truth's flat `## Requirements` section. */
|
|
90
|
+
function parseCurrentTruthHeaders(currentTruthContent) {
|
|
91
|
+
const sectionContent = extractSection(currentTruthContent, 'Requirements');
|
|
92
|
+
if (sectionContent === null)
|
|
93
|
+
return [];
|
|
94
|
+
return parseRequirementBlocks(sectionContent).map((b) => b.header);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Folds a bet's spec delta into current truth, mirroring the ADDED-append /
|
|
98
|
+
* MODIFIED-replace-or-note / REMOVED-delete-or-note algorithm `/oprim:archive` uses.
|
|
99
|
+
* Throws if `currentTruth` is null and the delta contains MODIFIED/REMOVED requirements
|
|
100
|
+
* (nothing to modify/remove against yet).
|
|
101
|
+
*/
|
|
102
|
+
function foldDelta(currentTruth, delta) {
|
|
103
|
+
const notes = [];
|
|
104
|
+
const addedSection = extractSection(delta, 'ADDED Requirements');
|
|
105
|
+
const modifiedSection = extractSection(delta, 'MODIFIED Requirements');
|
|
106
|
+
const removedSection = extractSection(delta, 'REMOVED Requirements');
|
|
107
|
+
const added = addedSection !== null ? parseRequirementBlocks(addedSection) : [];
|
|
108
|
+
const modified = modifiedSection !== null ? parseRequirementBlocks(modifiedSection) : [];
|
|
109
|
+
const removed = removedSection !== null ? parseRequirementBlocks(removedSection) : [];
|
|
110
|
+
if (currentTruth === null) {
|
|
111
|
+
if (modified.length > 0 || removed.length > 0) {
|
|
112
|
+
throw new Error('cannot modify/remove a requirement — no current-truth spec exists yet for this capability.');
|
|
113
|
+
}
|
|
114
|
+
const body = added.map((b) => b.content).join('\n\n');
|
|
115
|
+
return { content: `## Requirements\n\n${body}\n`, notes };
|
|
116
|
+
}
|
|
117
|
+
const reqSection = extractSection(currentTruth, 'Requirements') ?? '';
|
|
118
|
+
const blocks = parseRequirementBlocks(reqSection);
|
|
119
|
+
for (const block of modified) {
|
|
120
|
+
const idx = blocks.findIndex((b) => normalizeHeader(b.header) === normalizeHeader(block.header));
|
|
121
|
+
if (idx === -1) {
|
|
122
|
+
blocks.push(block);
|
|
123
|
+
notes.push(`MODIFIED requirement "${block.header}" had no match in current truth — treated as ADDED`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
blocks[idx] = block;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const block of removed) {
|
|
130
|
+
const idx = blocks.findIndex((b) => normalizeHeader(b.header) === normalizeHeader(block.header));
|
|
131
|
+
if (idx === -1) {
|
|
132
|
+
notes.push(`REMOVED requirement "${block.header}" had no match in current truth — nothing removed`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
blocks.splice(idx, 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const block of added) {
|
|
139
|
+
blocks.push(block);
|
|
140
|
+
}
|
|
141
|
+
const body = blocks.map((b) => b.content).join('\n\n');
|
|
142
|
+
return { content: `## Requirements\n\n${body}\n`, notes };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Scans all active bet directories (excluding archived/) pairwise for overlapping
|
|
146
|
+
* `### Requirement:` headers within the same capability's spec delta.
|
|
147
|
+
*/
|
|
148
|
+
function findCrossBetConflicts(betsDir) {
|
|
149
|
+
if (!fs.existsSync(betsDir))
|
|
150
|
+
return [];
|
|
151
|
+
const betNames = fs
|
|
152
|
+
.readdirSync(betsDir, { withFileTypes: true })
|
|
153
|
+
.filter((e) => e.isDirectory() && e.name !== 'archived')
|
|
154
|
+
.map((e) => e.name);
|
|
155
|
+
const betCapHeaders = new Map();
|
|
156
|
+
for (const betName of betNames) {
|
|
157
|
+
const specsDir = path.join(betsDir, betName, 'specs');
|
|
158
|
+
if (!fs.existsSync(specsDir))
|
|
159
|
+
continue;
|
|
160
|
+
const capMap = new Map();
|
|
161
|
+
const capEntries = fs.readdirSync(specsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
162
|
+
for (const capEntry of capEntries) {
|
|
163
|
+
const specPath = path.join(specsDir, capEntry.name, 'spec.md');
|
|
164
|
+
if (!fs.existsSync(specPath))
|
|
165
|
+
continue;
|
|
166
|
+
const content = fs.readFileSync(specPath, 'utf-8');
|
|
167
|
+
const headers = [
|
|
168
|
+
...parseRequirementHeaders(content, 'ADDED'),
|
|
169
|
+
...parseRequirementHeaders(content, 'MODIFIED'),
|
|
170
|
+
...parseRequirementHeaders(content, 'REMOVED'),
|
|
171
|
+
];
|
|
172
|
+
capMap.set(capEntry.name, headers);
|
|
173
|
+
}
|
|
174
|
+
betCapHeaders.set(betName, capMap);
|
|
175
|
+
}
|
|
176
|
+
const conflicts = [];
|
|
177
|
+
const names = [...betCapHeaders.keys()];
|
|
178
|
+
for (let i = 0; i < names.length; i++) {
|
|
179
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
180
|
+
const capMapA = betCapHeaders.get(names[i]);
|
|
181
|
+
const capMapB = betCapHeaders.get(names[j]);
|
|
182
|
+
for (const [capability, headersA] of capMapA) {
|
|
183
|
+
const headersB = capMapB.get(capability);
|
|
184
|
+
if (!headersB)
|
|
185
|
+
continue;
|
|
186
|
+
for (const headerA of headersA) {
|
|
187
|
+
const match = headersB.find((h) => normalizeHeader(h) === normalizeHeader(headerA));
|
|
188
|
+
if (match) {
|
|
189
|
+
conflicts.push({ betA: names[i], betB: names[j], capability, header: headerA });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return conflicts;
|
|
196
|
+
}
|
|
197
|
+
function normalizeBetId(input) {
|
|
198
|
+
const digits = input.replace(/[^0-9]/g, '');
|
|
199
|
+
return `BET-${digits.padStart(3, '0')}`;
|
|
200
|
+
}
|
|
201
|
+
/** Resolves a bet ID (accepting `bet-005`, `005`, `5`, or `BET-005`) to its directory name under `betsDir`. */
|
|
202
|
+
function resolveBetDirectory(betsDir, betIdInput) {
|
|
203
|
+
if (!fs.existsSync(betsDir))
|
|
204
|
+
return null;
|
|
205
|
+
const betId = normalizeBetId(betIdInput);
|
|
206
|
+
const entries = fs
|
|
207
|
+
.readdirSync(betsDir, { withFileTypes: true })
|
|
208
|
+
.filter((e) => e.isDirectory() && e.name !== 'archived');
|
|
209
|
+
const exact = entries.find((e) => e.name === betId);
|
|
210
|
+
if (exact)
|
|
211
|
+
return exact.name;
|
|
212
|
+
const slugMatches = entries.filter((e) => e.name.startsWith(`${betId}-`));
|
|
213
|
+
if (slugMatches.length === 1)
|
|
214
|
+
return slugMatches[0].name;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Check } from './integrity';
|
|
2
|
+
/**
|
|
3
|
+
* Flags a promoted bet (one whose bet-decision.md links a non-placeholder OpenSpec
|
|
4
|
+
* change) that has no criteria.yaml alongside it. Un-promoted bets are not checked.
|
|
5
|
+
*/
|
|
6
|
+
export declare function checkBetDefinitionOfDone(projectRoot: string, checks: Check[]): void;
|
|
7
|
+
/**
|
|
8
|
+
* Flags a MODIFIED/REMOVED requirement in an active bet's spec delta whose header no
|
|
9
|
+
* longer text-matches (whitespace-insensitive) current truth.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkSpecDeltaDrift(projectRoot: string, checks: Check[]): void;
|
|
12
|
+
/** Surfaces overlapping requirement headers across active bets' spec deltas. */
|
|
13
|
+
export declare function checkCrossBetConflicts(projectRoot: string, checks: Check[]): void;
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.checkBetDefinitionOfDone = checkBetDefinitionOfDone;
|
|
37
|
+
exports.checkSpecDeltaDrift = checkSpecDeltaDrift;
|
|
38
|
+
exports.checkCrossBetConflicts = checkCrossBetConflicts;
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const spec_delta_1 = require("./spec-delta");
|
|
42
|
+
function normalizeHeader(header) {
|
|
43
|
+
return header.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
function extractBetId(dirName) {
|
|
46
|
+
const match = dirName.match(/^(BET-\d+)/);
|
|
47
|
+
return match ? match[1] : dirName;
|
|
48
|
+
}
|
|
49
|
+
function activeBetDirs(betsDir) {
|
|
50
|
+
if (!fs.existsSync(betsDir))
|
|
51
|
+
return [];
|
|
52
|
+
return fs
|
|
53
|
+
.readdirSync(betsDir, { withFileTypes: true })
|
|
54
|
+
.filter((e) => e.isDirectory() && e.name !== 'archived');
|
|
55
|
+
}
|
|
56
|
+
function isPromoted(betDecisionContent) {
|
|
57
|
+
const linksMatch = betDecisionContent.match(/## Links\n([\s\S]*?)(?=\n## |$)/);
|
|
58
|
+
const linksSection = linksMatch ? linksMatch[1] : betDecisionContent;
|
|
59
|
+
const lineMatch = linksSection.match(/^- *OpenSpec change:\s*(.*)$/m);
|
|
60
|
+
if (!lineMatch)
|
|
61
|
+
return false;
|
|
62
|
+
const value = lineMatch[1].trim().replace(/^<|>$/g, '').trim();
|
|
63
|
+
if (value === '')
|
|
64
|
+
return false;
|
|
65
|
+
if (/to be filled when promoted/i.test(value))
|
|
66
|
+
return false;
|
|
67
|
+
if (/^path once promoted$/i.test(value))
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Flags a promoted bet (one whose bet-decision.md links a non-placeholder OpenSpec
|
|
73
|
+
* change) that has no criteria.yaml alongside it. Un-promoted bets are not checked.
|
|
74
|
+
*/
|
|
75
|
+
function checkBetDefinitionOfDone(projectRoot, checks) {
|
|
76
|
+
const betsDir = path.join(projectRoot, 'oprim', 'bets');
|
|
77
|
+
for (const entry of activeBetDirs(betsDir)) {
|
|
78
|
+
const betDir = path.join(betsDir, entry.name);
|
|
79
|
+
const decisionPath = path.join(betDir, 'bet-decision.md');
|
|
80
|
+
if (!fs.existsSync(decisionPath))
|
|
81
|
+
continue;
|
|
82
|
+
const content = fs.readFileSync(decisionPath, 'utf-8');
|
|
83
|
+
if (!isPromoted(content))
|
|
84
|
+
continue;
|
|
85
|
+
const hasCriteria = fs.existsSync(path.join(betDir, 'criteria.yaml'));
|
|
86
|
+
if (!hasCriteria) {
|
|
87
|
+
const betId = extractBetId(entry.name);
|
|
88
|
+
checks.push({
|
|
89
|
+
name: `bet: ${betId} promoted without criteria.yaml`,
|
|
90
|
+
pass: false,
|
|
91
|
+
note: `Add criteria.yaml to oprim/bets/${entry.name}/, or run the oprim-criteria skill`,
|
|
92
|
+
required: false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Flags a MODIFIED/REMOVED requirement in an active bet's spec delta whose header no
|
|
99
|
+
* longer text-matches (whitespace-insensitive) current truth.
|
|
100
|
+
*/
|
|
101
|
+
function checkSpecDeltaDrift(projectRoot, checks) {
|
|
102
|
+
const betsDir = path.join(projectRoot, 'oprim', 'bets');
|
|
103
|
+
for (const entry of activeBetDirs(betsDir)) {
|
|
104
|
+
const betId = extractBetId(entry.name);
|
|
105
|
+
const specsDir = path.join(betsDir, entry.name, 'specs');
|
|
106
|
+
if (!fs.existsSync(specsDir))
|
|
107
|
+
continue;
|
|
108
|
+
const capEntries = fs.readdirSync(specsDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
109
|
+
for (const capEntry of capEntries) {
|
|
110
|
+
const deltaPath = path.join(specsDir, capEntry.name, 'spec.md');
|
|
111
|
+
if (!fs.existsSync(deltaPath))
|
|
112
|
+
continue;
|
|
113
|
+
const deltaContent = fs.readFileSync(deltaPath, 'utf-8');
|
|
114
|
+
const currentTruthPath = path.join(projectRoot, 'oprim', 'specs', capEntry.name, 'spec.md');
|
|
115
|
+
const currentHeaders = fs.existsSync(currentTruthPath)
|
|
116
|
+
? (0, spec_delta_1.parseCurrentTruthHeaders)(fs.readFileSync(currentTruthPath, 'utf-8'))
|
|
117
|
+
: [];
|
|
118
|
+
for (const section of ['MODIFIED', 'REMOVED']) {
|
|
119
|
+
for (const header of (0, spec_delta_1.parseRequirementHeaders)(deltaContent, section)) {
|
|
120
|
+
const matches = currentHeaders.some((h) => normalizeHeader(h) === normalizeHeader(header));
|
|
121
|
+
if (!matches) {
|
|
122
|
+
checks.push({
|
|
123
|
+
name: `spec-delta: ${betId}'s ${section} requirement "${header}" in ${capEntry.name} no longer matches current truth`,
|
|
124
|
+
pass: false,
|
|
125
|
+
note: `No matching requirement header found in oprim/specs/${capEntry.name}/spec.md`,
|
|
126
|
+
required: true,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Surfaces overlapping requirement headers across active bets' spec deltas. */
|
|
135
|
+
function checkCrossBetConflicts(projectRoot, checks) {
|
|
136
|
+
const betsDir = path.join(projectRoot, 'oprim', 'bets');
|
|
137
|
+
for (const conflict of (0, spec_delta_1.findCrossBetConflicts)(betsDir)) {
|
|
138
|
+
checks.push({
|
|
139
|
+
name: `spec-delta: ${extractBetId(conflict.betA)} and ${extractBetId(conflict.betB)} both touch "${conflict.header}" in ${conflict.capability}`,
|
|
140
|
+
pass: false,
|
|
141
|
+
note: 'Overlapping requirement header between active bets — archiving one first applies last-write-wins to the other (no 3-way merge)',
|
|
142
|
+
required: true,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The full workflow body — a workflow's own frontmatter (`name:`/`description:`) plus its
|
|
3
|
+
* instructional steps — used verbatim as the Claude/Cursor/Poolside SKILL.md content.
|
|
4
|
+
*/
|
|
5
|
+
export declare function renderSkillBody(id: string, projectRoot?: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* A thin Claude command wrapper that delegates to the workflow's skill via the Skill tool
|
|
8
|
+
* (archive/sequence/context), or promote's dynamic per-framework content.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderClaudeCommand(id: string, projectRoot?: string, framework?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* A full Cursor command file: promote's dynamic per-framework content, or a condensed
|
|
13
|
+
* `<id>.cursor-command.md` body (Cursor has no Skill tool, so content is always inlined).
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderCursorCommand(id: string, projectRoot?: string, framework?: string): string;
|
|
16
|
+
/** A workflow's condensed section for the shared Codex/Gemini/Poolside inline instruction block. */
|
|
17
|
+
export declare function renderInlineSection(id: string): string | null;
|
|
18
|
+
/** The full `<!-- oprim:start -->`-delimited inline instruction block written into AGENTS.md/GEMINI.md. */
|
|
19
|
+
export declare function renderAgentInstructions(): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderSkillBody = renderSkillBody;
|
|
4
|
+
exports.renderClaudeCommand = renderClaudeCommand;
|
|
5
|
+
exports.renderCursorCommand = renderCursorCommand;
|
|
6
|
+
exports.renderInlineSection = renderInlineSection;
|
|
7
|
+
exports.renderAgentInstructions = renderAgentInstructions;
|
|
8
|
+
const workflow_schema_1 = require("./workflow-schema");
|
|
9
|
+
// Fixed order the pre-refactor oprimWorkflowsInline() concatenated its per-workflow sections in —
|
|
10
|
+
// preserved here so Codex/Gemini/Poolside output is unchanged.
|
|
11
|
+
const INLINE_SECTION_ORDER = ['bet', 'note', 'criteria', 'pdr', 'review', 'archive', 'sequence'];
|
|
12
|
+
function claudeWrapper(name, description, body) {
|
|
13
|
+
return `---
|
|
14
|
+
name: "${name}"
|
|
15
|
+
description: ${description}
|
|
16
|
+
category: Workflow
|
|
17
|
+
tags: [workflow, primer]
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
${description}.
|
|
21
|
+
|
|
22
|
+
${body}`;
|
|
23
|
+
}
|
|
24
|
+
function cursorWrapper(id, description, body) {
|
|
25
|
+
return `---
|
|
26
|
+
name: /${id}
|
|
27
|
+
id: ${id}
|
|
28
|
+
category: Workflow
|
|
29
|
+
description: ${description}
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
${body}`;
|
|
33
|
+
}
|
|
34
|
+
function promoteVariantBody(id, schema, framework) {
|
|
35
|
+
const variants = schema.variants;
|
|
36
|
+
if (!variants)
|
|
37
|
+
throw new Error(`workflow "${id}" has no variants configured`);
|
|
38
|
+
const variantId = variants.includes(framework) ? framework : variants[0];
|
|
39
|
+
const content = (0, workflow_schema_1.loadWorkflowVariant)(`${id}.${variantId}.template.md`);
|
|
40
|
+
if (content === null) {
|
|
41
|
+
throw new Error(`Missing variant template ${id}.${variantId}.template.md for workflow "${id}"`);
|
|
42
|
+
}
|
|
43
|
+
return content;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The full workflow body — a workflow's own frontmatter (`name:`/`description:`) plus its
|
|
47
|
+
* instructional steps — used verbatim as the Claude/Cursor/Poolside SKILL.md content.
|
|
48
|
+
*/
|
|
49
|
+
function renderSkillBody(id, projectRoot) {
|
|
50
|
+
return (0, workflow_schema_1.loadWorkflowTemplate)(id, projectRoot);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A thin Claude command wrapper that delegates to the workflow's skill via the Skill tool
|
|
54
|
+
* (archive/sequence/context), or promote's dynamic per-framework content.
|
|
55
|
+
*/
|
|
56
|
+
function renderClaudeCommand(id, projectRoot, framework = 'openspec') {
|
|
57
|
+
const schema = (0, workflow_schema_1.loadWorkflowSchema)(id, projectRoot);
|
|
58
|
+
if (!schema.claude.command) {
|
|
59
|
+
throw new Error(`workflow "${id}" has no Claude command target`);
|
|
60
|
+
}
|
|
61
|
+
const body = schema.variants
|
|
62
|
+
? promoteVariantBody(id, schema, framework)
|
|
63
|
+
: schema.skillName
|
|
64
|
+
? `Use the Skill tool to invoke the \`${schema.skillName}\` skill.`
|
|
65
|
+
: (() => {
|
|
66
|
+
throw new Error(`workflow "${id}" Claude command has neither a skillName nor variants`);
|
|
67
|
+
})();
|
|
68
|
+
return claudeWrapper(schema.title ?? schema.id, schema.description, body);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A full Cursor command file: promote's dynamic per-framework content, or a condensed
|
|
72
|
+
* `<id>.cursor-command.md` body (Cursor has no Skill tool, so content is always inlined).
|
|
73
|
+
*/
|
|
74
|
+
function renderCursorCommand(id, projectRoot, framework = 'openspec') {
|
|
75
|
+
const schema = (0, workflow_schema_1.loadWorkflowSchema)(id, projectRoot);
|
|
76
|
+
if (!schema.cursor.command) {
|
|
77
|
+
throw new Error(`workflow "${id}" has no Cursor command target`);
|
|
78
|
+
}
|
|
79
|
+
const cursorId = schema.cursor.command.replace(/\.md$/, '');
|
|
80
|
+
const body = schema.variants
|
|
81
|
+
? promoteVariantBody(id, schema, framework)
|
|
82
|
+
: (() => {
|
|
83
|
+
const content = (0, workflow_schema_1.loadWorkflowVariant)(`${id}.cursor-command.md`);
|
|
84
|
+
if (content === null)
|
|
85
|
+
throw new Error(`Missing ${id}.cursor-command.md for workflow "${id}"`);
|
|
86
|
+
return content;
|
|
87
|
+
})();
|
|
88
|
+
return cursorWrapper(cursorId, schema.cursorDescription ?? schema.description, body);
|
|
89
|
+
}
|
|
90
|
+
/** A workflow's condensed section for the shared Codex/Gemini/Poolside inline instruction block. */
|
|
91
|
+
function renderInlineSection(id) {
|
|
92
|
+
return (0, workflow_schema_1.loadWorkflowVariant)(`${id}.inline.md`);
|
|
93
|
+
}
|
|
94
|
+
/** The full `<!-- oprim:start -->`-delimited inline instruction block written into AGENTS.md/GEMINI.md. */
|
|
95
|
+
function renderAgentInstructions() {
|
|
96
|
+
const sections = INLINE_SECTION_ORDER.map((id) => {
|
|
97
|
+
const content = renderInlineSection(id);
|
|
98
|
+
if (content === null)
|
|
99
|
+
throw new Error(`Missing inline section for workflow "${id}"`);
|
|
100
|
+
return content;
|
|
101
|
+
});
|
|
102
|
+
return '\n## oprim workflows\n\n' + sections.join('\n\n') + '\n';
|
|
103
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface WorkflowAgentTarget {
|
|
2
|
+
skill: boolean;
|
|
3
|
+
command: string | null;
|
|
4
|
+
}
|
|
5
|
+
export interface WorkflowSchema {
|
|
6
|
+
id: string;
|
|
7
|
+
skillName: string | null;
|
|
8
|
+
title: string | null;
|
|
9
|
+
description: string;
|
|
10
|
+
cursorDescription: string | null;
|
|
11
|
+
claude: WorkflowAgentTarget;
|
|
12
|
+
cursor: WorkflowAgentTarget;
|
|
13
|
+
poolside: {
|
|
14
|
+
skill: boolean;
|
|
15
|
+
};
|
|
16
|
+
inline: boolean;
|
|
17
|
+
variants: string[] | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Loads a workflow's schema.yaml, resolving a project-level
|
|
21
|
+
* `oprim/workflows/<id>.schema.yaml` override over the CLI-bundled default.
|
|
22
|
+
* Throws an actionable, file-naming error if an override exists but fails to parse
|
|
23
|
+
* or is missing required fields — never silently falls back to the bundled default.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadWorkflowSchema(id: string, projectRoot?: string): WorkflowSchema;
|
|
26
|
+
/**
|
|
27
|
+
* Loads a workflow's template.md, resolving a project-level
|
|
28
|
+
* `oprim/workflows/<id>.template.md` override over the CLI-bundled default.
|
|
29
|
+
*/
|
|
30
|
+
export declare function loadWorkflowTemplate(id: string, projectRoot?: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Loads a bundled, non-overridable variant file (Cursor's condensed command body, the
|
|
33
|
+
* Codex/Gemini/Poolside inline section, promote's per-framework bodies). Only
|
|
34
|
+
* `<id>.schema.yaml` and `<id>.template.md` are project-forkable — see
|
|
35
|
+
* workflow-schema-authoring spec. Returns null if the workflow has no such variant.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loadWorkflowVariant(filename: string): string | null;
|