@open-product-primer/cli 2.0.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.
Files changed (56) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/context.d.ts +2 -0
  4. package/dist/commands/context.js +188 -0
  5. package/dist/commands/doctor.js +49 -0
  6. package/dist/commands/validate.d.ts +2 -0
  7. package/dist/commands/validate.js +131 -0
  8. package/dist/lib/config-merge.js +5 -1
  9. package/dist/lib/install-agent.js +78 -938
  10. package/dist/lib/remote-context.d.ts +50 -0
  11. package/dist/lib/remote-context.js +299 -0
  12. package/dist/lib/spec-delta.d.ts +30 -0
  13. package/dist/lib/spec-delta.js +216 -0
  14. package/dist/lib/templates.js +2 -1
  15. package/dist/lib/validate-checks.d.ts +13 -0
  16. package/dist/lib/validate-checks.js +145 -0
  17. package/dist/lib/workflow-renderer.d.ts +19 -0
  18. package/dist/lib/workflow-renderer.js +103 -0
  19. package/dist/lib/workflow-schema.d.ts +37 -0
  20. package/dist/lib/workflow-schema.js +126 -0
  21. package/dist/workflows/archive.inline.md +10 -0
  22. package/dist/workflows/archive.schema.yaml +13 -0
  23. package/dist/workflows/archive.template.md +106 -0
  24. package/dist/workflows/bet.cursor-command.md +1 -0
  25. package/dist/workflows/bet.inline.md +13 -0
  26. package/dist/workflows/bet.schema.yaml +14 -0
  27. package/dist/workflows/bet.template.md +114 -0
  28. package/dist/workflows/context.schema.yaml +13 -0
  29. package/dist/workflows/context.template.md +34 -0
  30. package/dist/workflows/criteria.cursor-command.md +1 -0
  31. package/dist/workflows/criteria.inline.md +11 -0
  32. package/dist/workflows/criteria.schema.yaml +14 -0
  33. package/dist/workflows/criteria.template.md +52 -0
  34. package/dist/workflows/note.cursor-command.md +1 -0
  35. package/dist/workflows/note.inline.md +11 -0
  36. package/dist/workflows/note.schema.yaml +14 -0
  37. package/dist/workflows/note.template.md +66 -0
  38. package/dist/workflows/pdr.cursor-command.md +1 -0
  39. package/dist/workflows/pdr.inline.md +10 -0
  40. package/dist/workflows/pdr.schema.yaml +14 -0
  41. package/dist/workflows/pdr.template.md +67 -0
  42. package/dist/workflows/promote.native.template.md +29 -0
  43. package/dist/workflows/promote.none.template.md +25 -0
  44. package/dist/workflows/promote.openspec.template.md +40 -0
  45. package/dist/workflows/promote.schema.yaml +14 -0
  46. package/dist/workflows/review.cursor-command.md +1 -0
  47. package/dist/workflows/review.inline.md +11 -0
  48. package/dist/workflows/review.schema.yaml +14 -0
  49. package/dist/workflows/review.template.md +69 -0
  50. package/dist/workflows/sequence.cursor-command.md +12 -0
  51. package/dist/workflows/sequence.inline.md +10 -0
  52. package/dist/workflows/sequence.schema.yaml +13 -0
  53. package/dist/workflows/sequence.template.md +88 -0
  54. package/dist/workflows/spec-authoring.schema.yaml +13 -0
  55. package/dist/workflows/spec-authoring.template.md +68 -0
  56. package/package.json +2 -2
@@ -0,0 +1,50 @@
1
+ export interface GitSource {
2
+ name: string;
3
+ git: string;
4
+ description?: string;
5
+ }
6
+ export interface PathSource {
7
+ name: string;
8
+ path: string;
9
+ description?: string;
10
+ }
11
+ export type RemoteContextSource = GitSource | PathSource;
12
+ export interface RemoteContextConfig {
13
+ enabled: boolean;
14
+ sources: RemoteContextSource[];
15
+ }
16
+ export interface RemoteContextIdentity {
17
+ name: string;
18
+ version: string;
19
+ description?: string;
20
+ }
21
+ export declare function isGitSource(source: RemoteContextSource): source is GitSource;
22
+ export declare function isPathSource(source: RemoteContextSource): source is PathSource;
23
+ export declare function readRemoteContextConfig(projectRoot: string): RemoteContextConfig;
24
+ export declare function writeRemoteContextConfig(projectRoot: string, config: RemoteContextConfig): void;
25
+ export declare function findSourceByName(config: RemoteContextConfig, name: string): RemoteContextSource | undefined;
26
+ export declare function identityFilePath(rootDir: string): string;
27
+ export declare function readIdentity(rootDir: string): RemoteContextIdentity | null;
28
+ export declare function writeIdentity(rootDir: string, identity: RemoteContextIdentity): void;
29
+ export interface ResolvedIdentity {
30
+ identity: RemoteContextIdentity | null;
31
+ stale: boolean;
32
+ error?: string;
33
+ nameMismatch?: {
34
+ declared: string;
35
+ resolved: string;
36
+ };
37
+ }
38
+ export interface ResolvedContent {
39
+ workspaceRoot: string;
40
+ stale: boolean;
41
+ error?: string;
42
+ nameMismatch?: {
43
+ declared: string;
44
+ resolved: string;
45
+ };
46
+ }
47
+ export declare function resolveIdentityOnly(source: RemoteContextSource): ResolvedIdentity;
48
+ export declare function hasEverFullyResolved(source: RemoteContextSource): boolean;
49
+ export declare function resolveFull(source: RemoteContextSource): ResolvedContent;
50
+ export declare function assembleOprimWorkspaceContent(workspaceRoot: string): string;
@@ -0,0 +1,299 @@
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.isGitSource = isGitSource;
37
+ exports.isPathSource = isPathSource;
38
+ exports.readRemoteContextConfig = readRemoteContextConfig;
39
+ exports.writeRemoteContextConfig = writeRemoteContextConfig;
40
+ exports.findSourceByName = findSourceByName;
41
+ exports.identityFilePath = identityFilePath;
42
+ exports.readIdentity = readIdentity;
43
+ exports.writeIdentity = writeIdentity;
44
+ exports.resolveIdentityOnly = resolveIdentityOnly;
45
+ exports.hasEverFullyResolved = hasEverFullyResolved;
46
+ exports.resolveFull = resolveFull;
47
+ exports.assembleOprimWorkspaceContent = assembleOprimWorkspaceContent;
48
+ const path = __importStar(require("path"));
49
+ const fs = __importStar(require("fs"));
50
+ const os = __importStar(require("os"));
51
+ const crypto = __importStar(require("crypto"));
52
+ const child_process_1 = require("child_process");
53
+ const yaml = __importStar(require("js-yaml"));
54
+ function isGitSource(source) {
55
+ return 'git' in source && typeof source.git === 'string';
56
+ }
57
+ function isPathSource(source) {
58
+ return 'path' in source && typeof source.path === 'string';
59
+ }
60
+ // ─── oprim/config.yaml — remote_context block ──────────────────────────────
61
+ const REMOTE_CONTEXT_KEY = 'remote_context';
62
+ function configPath(projectRoot) {
63
+ return path.join(projectRoot, 'oprim', 'config.yaml');
64
+ }
65
+ function readRemoteContextConfig(projectRoot) {
66
+ const p = configPath(projectRoot);
67
+ if (!fs.existsSync(p))
68
+ return { enabled: false, sources: [] };
69
+ const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
70
+ const raw = parsed?.[REMOTE_CONTEXT_KEY];
71
+ if (!raw)
72
+ return { enabled: false, sources: [] };
73
+ return {
74
+ enabled: raw.enabled ?? false,
75
+ sources: Array.isArray(raw.sources) ? raw.sources : [],
76
+ };
77
+ }
78
+ // Surgical replace of just the `remote_context:` top-level block, leaving every other
79
+ // line in oprim/config.yaml byte-for-byte untouched — mirrors the non-destructive approach
80
+ // in config-merge.ts (never parse+re-dump the whole file).
81
+ function replaceTopLevelBlock(content, key, blockContent) {
82
+ const lines = content.split('\n');
83
+ const startIdx = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
84
+ const blockLines = blockContent.replace(/\n$/, '').split('\n');
85
+ if (startIdx === -1) {
86
+ const separator = content.endsWith('\n') || content === '' ? '' : '\n';
87
+ return content + separator + blockLines.join('\n') + '\n';
88
+ }
89
+ let endIdx = lines.length;
90
+ for (let i = startIdx + 1; i < lines.length; i++) {
91
+ if (/^[A-Za-z_]/.test(lines[i])) {
92
+ endIdx = i;
93
+ break;
94
+ }
95
+ }
96
+ return [...lines.slice(0, startIdx), ...blockLines, ...lines.slice(endIdx)].join('\n');
97
+ }
98
+ function writeRemoteContextConfig(projectRoot, config) {
99
+ const p = configPath(projectRoot);
100
+ const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
101
+ const dumped = yaml.dump({ [REMOTE_CONTEXT_KEY]: config }, { lineWidth: -1 });
102
+ const updated = replaceTopLevelBlock(existing, REMOTE_CONTEXT_KEY, dumped);
103
+ fs.mkdirSync(path.dirname(p), { recursive: true });
104
+ fs.writeFileSync(p, updated, 'utf-8');
105
+ }
106
+ function findSourceByName(config, name) {
107
+ return config.sources.find((s) => s.name === name);
108
+ }
109
+ // ─── Remote context identity (.oprim-context/context.yaml) ─────────────────
110
+ function identityFilePath(rootDir) {
111
+ return path.join(rootDir, '.oprim-context', 'context.yaml');
112
+ }
113
+ function readIdentity(rootDir) {
114
+ const p = identityFilePath(rootDir);
115
+ if (!fs.existsSync(p))
116
+ return null;
117
+ try {
118
+ const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
119
+ if (!parsed?.name || !parsed?.version)
120
+ return null;
121
+ return { name: parsed.name, version: parsed.version, description: parsed.description };
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function writeIdentity(rootDir, identity) {
128
+ const p = identityFilePath(rootDir);
129
+ fs.mkdirSync(path.dirname(p), { recursive: true });
130
+ fs.writeFileSync(p, yaml.dump(identity, { lineWidth: -1 }), 'utf-8');
131
+ }
132
+ // ─── Cache directory layout (git sources only) ─────────────────────────────
133
+ // 5 minutes — see design.md open question. Overridable for tests that need to exercise
134
+ // post-throttle-window behavior without actually waiting.
135
+ function throttleMs() {
136
+ const override = process.env['OPRIM_REMOTE_CONTEXT_THROTTLE_MS'];
137
+ return override ? Number(override) : 5 * 60 * 1000;
138
+ }
139
+ // Overridable so tests never write into the real user's home directory.
140
+ function cacheRoot() {
141
+ return process.env['OPRIM_REMOTE_CONTEXT_CACHE_DIR'] ?? path.join(os.homedir(), '.oprim', 'remote-context-cache');
142
+ }
143
+ function sourceCacheKey(source) {
144
+ const hash = crypto.createHash('sha1').update(source.git).digest('hex').slice(0, 12);
145
+ return `${source.name}-${hash}`;
146
+ }
147
+ function fullCacheDir(source) {
148
+ return path.join(cacheRoot(), sourceCacheKey(source), 'full');
149
+ }
150
+ function identityCacheDir(source) {
151
+ return path.join(cacheRoot(), sourceCacheKey(source), 'identity');
152
+ }
153
+ function markerPath(dir) {
154
+ return path.join(dir, '.oprim-last-fetch');
155
+ }
156
+ function isWithinThrottle(dir) {
157
+ const marker = markerPath(dir);
158
+ if (!fs.existsSync(marker))
159
+ return false;
160
+ const last = Number(fs.readFileSync(marker, 'utf-8').trim() || '0');
161
+ return Date.now() - last < throttleMs();
162
+ }
163
+ function touchMarker(dir) {
164
+ fs.writeFileSync(markerPath(dir), String(Date.now()), 'utf-8');
165
+ }
166
+ function git(args, cwd) {
167
+ (0, child_process_1.execFileSync)('git', args, { cwd, stdio: 'ignore' });
168
+ }
169
+ function withNameMismatch(source, identity) {
170
+ if (!identity)
171
+ return undefined;
172
+ if (identity.name === source.name)
173
+ return undefined;
174
+ return { declared: source.name, resolved: identity.name };
175
+ }
176
+ // ─── Git source resolution ─────────────────────────────────────────────────
177
+ function ensureFullGitClone(source) {
178
+ const dir = fullCacheDir(source);
179
+ const exists = fs.existsSync(path.join(dir, '.git'));
180
+ if (exists && isWithinThrottle(dir)) {
181
+ return { dir, stale: false };
182
+ }
183
+ try {
184
+ if (!exists) {
185
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
186
+ git(['clone', '--depth', '1', '--quiet', source.git, dir]);
187
+ }
188
+ else {
189
+ git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
190
+ git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
191
+ }
192
+ touchMarker(dir);
193
+ return { dir, stale: false };
194
+ }
195
+ catch (err) {
196
+ if (exists) {
197
+ // Last-known-good fallback — resolution degrades to stale rather than failing outright.
198
+ return { dir, stale: true };
199
+ }
200
+ return { dir, stale: false, error: err instanceof Error ? err.message : String(err) };
201
+ }
202
+ }
203
+ function ensureIdentityOnlyGitClone(source) {
204
+ const dir = identityCacheDir(source);
205
+ const exists = fs.existsSync(path.join(dir, '.git'));
206
+ if (exists && isWithinThrottle(dir)) {
207
+ return { dir };
208
+ }
209
+ try {
210
+ if (!exists) {
211
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
212
+ // Partial clone (blob:none) + cone sparse-checkout scoped to .oprim-context/ — this is
213
+ // the "targeted single-file fetch" from design.md decision 5, implemented as an
214
+ // equivalent narrow fetch: git only downloads the one blob it needs to check out,
215
+ // not the whole workspace. `git archive --remote` was considered and rejected — GitHub
216
+ // and most managed git hosts disable the upload-archive service, so it isn't portable.
217
+ git(['clone', '--filter=blob:none', '--no-checkout', '--depth', '1', '--quiet', source.git, dir]);
218
+ git(['sparse-checkout', 'init', '--cone'], dir);
219
+ git(['sparse-checkout', 'set', '.oprim-context'], dir);
220
+ git(['checkout', '--quiet'], dir);
221
+ }
222
+ else {
223
+ git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
224
+ git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
225
+ }
226
+ touchMarker(dir);
227
+ return { dir };
228
+ }
229
+ catch (err) {
230
+ return { dir, error: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ }
233
+ function resolveIdentityOnly(source) {
234
+ if (isPathSource(source)) {
235
+ if (!fs.existsSync(source.path)) {
236
+ return { identity: null, stale: false, error: `path not found: ${source.path}` };
237
+ }
238
+ const identity = readIdentity(source.path);
239
+ return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
240
+ }
241
+ const { dir, error } = ensureIdentityOnlyGitClone(source);
242
+ if (error)
243
+ return { identity: null, stale: false, error };
244
+ const identity = readIdentity(dir);
245
+ return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
246
+ }
247
+ // Local-path sources are always read live (no persistent cache), so "has this ever been
248
+ // resolved" is only a meaningful question for git sources, where full resolution populates
249
+ // a durable cache directory that outlives any single command invocation.
250
+ function hasEverFullyResolved(source) {
251
+ if (isPathSource(source))
252
+ return true;
253
+ return fs.existsSync(path.join(fullCacheDir(source), '.git'));
254
+ }
255
+ function resolveFull(source) {
256
+ if (isPathSource(source)) {
257
+ if (!fs.existsSync(source.path)) {
258
+ return { workspaceRoot: source.path, stale: false, error: `path not found: ${source.path}` };
259
+ }
260
+ const identity = readIdentity(source.path);
261
+ return {
262
+ workspaceRoot: source.path,
263
+ stale: false,
264
+ nameMismatch: withNameMismatch(source, identity),
265
+ };
266
+ }
267
+ const { dir, stale, error } = ensureFullGitClone(source);
268
+ if (error)
269
+ return { workspaceRoot: dir, stale: false, error };
270
+ const identity = readIdentity(dir);
271
+ return { workspaceRoot: dir, stale, nameMismatch: withNameMismatch(source, identity) };
272
+ }
273
+ // ─── Assembling oprim/ workspace content for printing ──────────────────────
274
+ function walkTextFiles(dir, base, out) {
275
+ if (!fs.existsSync(dir))
276
+ return;
277
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
278
+ const full = path.join(dir, entry.name);
279
+ const rel = path.join(base, entry.name);
280
+ if (entry.isDirectory()) {
281
+ walkTextFiles(full, rel, out);
282
+ }
283
+ else if (entry.isFile() && /\.(md|yaml|yml)$/.test(entry.name)) {
284
+ out.push(rel);
285
+ }
286
+ }
287
+ }
288
+ function assembleOprimWorkspaceContent(workspaceRoot) {
289
+ const oprimDir = path.join(workspaceRoot, 'oprim');
290
+ const files = [];
291
+ walkTextFiles(oprimDir, 'oprim', files);
292
+ files.sort();
293
+ return files
294
+ .map((rel) => {
295
+ const content = fs.readFileSync(path.join(workspaceRoot, rel), 'utf-8');
296
+ return `─── ${rel} ───\n${content}`;
297
+ })
298
+ .join('\n\n');
299
+ }
@@ -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
+ }
@@ -31,8 +31,9 @@ sequencing:
31
31
  now: 2
32
32
  context: ""
33
33
  rules: {}
34
- store:
34
+ remote_context:
35
35
  enabled: false
36
+ sources: []
36
37
  `;
37
38
  }
38
39
  // OKF (Open Knowledge Format) — https://github.com/GoogleCloudPlatform/okf
@@ -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;