wogiflow 2.22.0 → 2.22.2

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.
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow — Story Creation Quality Gates (wf-63c0f4cc)
5
+ *
6
+ * Five P0 specification-quality gates enforced at story-creation time:
7
+ *
8
+ * 1. longInputGate — large input → /wogi-extract-review
9
+ * 2. itemReconciliation — 3+ items must all map to criteria/sub-tasks
10
+ * 3. consumerImpactAnalysis — refactoring keywords → grep consumers
11
+ * 4. scopeConfidenceAudit — verify assumptions about what exists
12
+ * 5. intentBootstrapCoord — schedule IGR bootstrap if missing, no duplicate prompt
13
+ *
14
+ * All gates fail-open: any internal error logs a warning and continues.
15
+ * Gates enforce SPEC quality, not EXECUTION quality — those remain
16
+ * /wogi-start's job.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { execSync } = require('node:child_process');
22
+ const { getConfig, PATHS, safeJsonParse } = require('./flow-utils');
23
+
24
+ // Refactoring keywords — case-insensitive, word-boundary. Word boundaries
25
+ // prevent "transfer" from matching "trans" and "research" from matching "re".
26
+ const REFACTOR_KEYWORDS = [
27
+ 'refactor', 'rename', 'restructure', 'migrate', 'replace',
28
+ 'consolidate', 'split', 'extract', 'move'
29
+ ];
30
+
31
+ const REFACTOR_RE = new RegExp(`\\b(${REFACTOR_KEYWORDS.join('|')})\\b`, 'i');
32
+
33
+ // ============================================================
34
+ // Gate 1: Long Input Detection
35
+ // ============================================================
36
+
37
+ /**
38
+ * Count discrete items in free-form text:
39
+ * - numbered list items (^\d+[.)])
40
+ * - bullet list items (^[-*•])
41
+ * - semicolon-separated requests (; in single line)
42
+ * - " and also " / " plus " as item separators
43
+ *
44
+ * @param {string} text
45
+ * @returns {number}
46
+ */
47
+ function countDiscreteItems(text) {
48
+ if (!text || typeof text !== 'string') return 0;
49
+ let count = 0;
50
+
51
+ // Numbered list items (^1., ^2), etc.)
52
+ const numberedMatches = text.match(/^\s*\d+[.)]\s+\S/gm);
53
+ if (numberedMatches) count += numberedMatches.length;
54
+
55
+ // Bullet list items
56
+ const bulletMatches = text.match(/^\s*[-*•]\s+\S/gm);
57
+ if (bulletMatches) count += bulletMatches.length;
58
+
59
+ // Semicolon separators — count items = semicolons + 1 when text is a
60
+ // single-line (or nearly so) run of 2+ semicolons. Heuristic keeps us from
61
+ // treating a multi-sentence paragraph as "many items".
62
+ const lines = text.split(/\n/).filter(l => l.trim().length > 0);
63
+ if (lines.length <= 3) {
64
+ const semiCount = (text.match(/;/g) || []).length;
65
+ if (semiCount >= 2) count += semiCount + 1;
66
+ }
67
+
68
+ // " and also ", " plus " connectors
69
+ const andAlsoMatches = text.match(/\b(and also|plus)\b/gi);
70
+ if (andAlsoMatches) count += andAlsoMatches.length;
71
+
72
+ return count;
73
+ }
74
+
75
+ /**
76
+ * Gate 1: check whether input should route to /wogi-extract-review.
77
+ *
78
+ * @param {string} input
79
+ * @param {Object} [opts] - { bypassLongInput, lineThreshold, itemThreshold }
80
+ * @returns {{route: boolean, reason?: string, lineCount?: number, itemCount?: number}}
81
+ */
82
+ function checkLongInput(input, opts = {}) {
83
+ if (opts.bypassLongInput) return { route: false, reason: 'bypass' };
84
+
85
+ try {
86
+ const config = getConfig();
87
+ const lineThreshold = Number.isFinite(opts.lineThreshold)
88
+ ? opts.lineThreshold
89
+ : (config.longInputGate?.lineThreshold || 40);
90
+ const itemThreshold = Number.isFinite(opts.itemThreshold)
91
+ ? opts.itemThreshold
92
+ : 5;
93
+ const enabled = config.longInputGate?.enabled !== false;
94
+ if (!enabled) return { route: false, reason: 'disabled' };
95
+
96
+ const text = String(input || '');
97
+ const lineCount = text.split(/\n/).length;
98
+ const itemCount = countDiscreteItems(text);
99
+
100
+ if (lineCount >= lineThreshold) {
101
+ return { route: true, reason: 'line-count', lineCount, itemCount };
102
+ }
103
+ if (itemCount >= itemThreshold) {
104
+ return { route: true, reason: 'item-count', lineCount, itemCount };
105
+ }
106
+ return { route: false, lineCount, itemCount };
107
+ } catch (err) {
108
+ if (process.env.DEBUG) {
109
+ console.error(`[story-gates] longInputGate failed (fail-open): ${err.message}`);
110
+ }
111
+ return { route: false, reason: 'error' };
112
+ }
113
+ }
114
+
115
+ // ============================================================
116
+ // Gate 2: Item Reconciliation
117
+ // ============================================================
118
+
119
+ /**
120
+ * Enumerate items from multi-item input. Returns a numbered list.
121
+ * Strategy: pick whichever of {numbered, bullets, lines, semi-separated}
122
+ * produces the most items.
123
+ *
124
+ * @param {string} input
125
+ * @returns {Array<string>}
126
+ */
127
+ function enumerateItems(input) {
128
+ const text = String(input || '');
129
+ const candidates = [];
130
+
131
+ // Numbered items (keep content after the number marker)
132
+ const numbered = [];
133
+ for (const m of text.matchAll(/^\s*\d+[.)]\s+(.+)$/gm)) {
134
+ numbered.push(m[1].trim());
135
+ }
136
+ if (numbered.length > 0) candidates.push(numbered);
137
+
138
+ // Bullet items
139
+ const bullets = [];
140
+ for (const m of text.matchAll(/^\s*[-*•]\s+(.+)$/gm)) {
141
+ bullets.push(m[1].trim());
142
+ }
143
+ if (bullets.length > 0) candidates.push(bullets);
144
+
145
+ // Semicolon-separated (single-line)
146
+ if (/;/.test(text) && text.split(/\n/).filter(l => l.trim()).length <= 3) {
147
+ const parts = text.split(/\s*;\s*/).map(p => p.trim()).filter(Boolean);
148
+ if (parts.length >= 2) candidates.push(parts);
149
+ }
150
+
151
+ // " and also " / " plus "
152
+ if (/\b(and also|plus)\b/i.test(text)) {
153
+ const parts = text.split(/\b(?:and also|plus)\b/i).map(p => p.trim()).filter(Boolean);
154
+ if (parts.length >= 2) candidates.push(parts);
155
+ }
156
+
157
+ if (candidates.length === 0) return [];
158
+ return candidates.reduce((max, c) => (c.length > max.length ? c : max), []);
159
+ }
160
+
161
+ /**
162
+ * Gate 2: build reconciliation manifest. Runs BEFORE decomposition so
163
+ * enumerated items can drive sub-task generation.
164
+ *
165
+ * @param {string} input
166
+ * @param {Object} [opts]
167
+ * @returns {{active: boolean, items: Array<string>, count: number}}
168
+ */
169
+ function reconcileItems(input, opts = {}) {
170
+ try {
171
+ const config = getConfig();
172
+ const enabled = config.storyFlow?.itemReconciliation?.enabled !== false;
173
+ const minItems = opts.minItems ?? config.storyFlow?.itemReconciliation?.minItems ?? 3;
174
+ if (!enabled) return { active: false, items: [], count: 0 };
175
+
176
+ const items = enumerateItems(input);
177
+ if (items.length < minItems) {
178
+ return { active: false, items, count: items.length };
179
+ }
180
+ return { active: true, items, count: items.length };
181
+ } catch (err) {
182
+ if (process.env.DEBUG) {
183
+ console.error(`[story-gates] itemReconciliation failed (fail-open): ${err.message}`);
184
+ }
185
+ return { active: false, items: [], count: 0 };
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Verify each enumerated item appears in at least one criterion/sub-task.
191
+ * Matching: a criterion is considered to "cover" an item if it shares
192
+ * >= MIN_OVERLAP distinct keyword tokens (length >= 4) with the item.
193
+ *
194
+ * @param {Array<string>} items
195
+ * @param {Array<string>} criteria — criterion texts + sub-task objectives
196
+ * @returns {{allMapped: boolean, unmapped: Array<string>}}
197
+ */
198
+ function verifyItemCoverage(items, criteria) {
199
+ const STOPWORDS = new Set([
200
+ 'with', 'from', 'that', 'this', 'have', 'make', 'been', 'were', 'their',
201
+ 'they', 'them', 'will', 'should', 'would', 'could', 'there', 'into',
202
+ 'when', 'then', 'than', 'which', 'what', 'your', 'mine', 'ours'
203
+ ]);
204
+ const MIN_OVERLAP = 1;
205
+ const tokenize = (s) => new Set(
206
+ String(s).toLowerCase().match(/\b[a-z]{4,}\b/g)?.filter(w => !STOPWORDS.has(w)) || []
207
+ );
208
+
209
+ const criterionTokens = criteria.map(tokenize);
210
+ const unmapped = [];
211
+ for (const item of items) {
212
+ const itemTokens = tokenize(item);
213
+ if (itemTokens.size === 0) continue; // Skip items with no indexable tokens
214
+ const covered = criterionTokens.some(ct => {
215
+ let overlap = 0;
216
+ for (const t of itemTokens) {
217
+ if (ct.has(t) && ++overlap >= MIN_OVERLAP) return true;
218
+ }
219
+ return false;
220
+ });
221
+ if (!covered) unmapped.push(item);
222
+ }
223
+ return { allMapped: unmapped.length === 0, unmapped };
224
+ }
225
+
226
+ // ============================================================
227
+ // Gate 3: Consumer Impact Analysis
228
+ // ============================================================
229
+
230
+ /**
231
+ * Extract likely module / path tokens from input for consumer-grep seeds.
232
+ * Heuristic: filenames (foo.js, bar.ts), quoted strings, CamelCase words,
233
+ * kebab-case identifiers.
234
+ *
235
+ * @param {string} input
236
+ * @returns {Array<string>}
237
+ */
238
+ function extractConsumerSeeds(input) {
239
+ const text = String(input || '');
240
+ const seeds = new Set();
241
+
242
+ // Filenames with extension
243
+ for (const m of text.matchAll(/\b([a-z0-9][\w-]*\.(?:js|ts|tsx|jsx|mjs|cjs|json|md))\b/gi)) {
244
+ seeds.add(m[1]);
245
+ }
246
+ // Quoted strings (single line, non-empty, reasonable length)
247
+ for (const m of text.matchAll(/['"`]([^'"`\n]{3,60})['"`]/g)) {
248
+ const v = m[1].trim();
249
+ // Skip natural-language-ish quoted strings
250
+ if (/^[A-Za-z][\w./-]*$/.test(v)) seeds.add(v);
251
+ }
252
+ // kebab-case or snake_case or CamelCase identifiers with length >= 6
253
+ for (const m of text.matchAll(/\b([a-z][a-z0-9]*[-_][a-z0-9][\w-]{2,}|[A-Z][a-z]+[A-Z][A-Za-z]{3,})\b/g)) {
254
+ seeds.add(m[1]);
255
+ }
256
+ return [...seeds];
257
+ }
258
+
259
+ /**
260
+ * Gate 3: run Consumer Impact Analysis when refactoring keywords are present.
261
+ * Uses git grep (fast, respects .gitignore). Fail-open if git is unavailable.
262
+ *
263
+ * @param {string} input
264
+ * @param {Object} [opts]
265
+ * @param {string} [opts.cwd]
266
+ * @returns {{active: boolean, seeds?: Array, matches?: Array, breakingCount?: number, phasedMigrationRecommended?: boolean, reason?: string}}
267
+ */
268
+ function analyzeConsumerImpact(input, opts = {}) {
269
+ try {
270
+ const config = getConfig();
271
+ const enabled = config.storyFlow?.consumerImpactAnalysis?.enabled !== false;
272
+ const breakingThreshold = opts.breakingThreshold
273
+ ?? config.storyFlow?.consumerImpactAnalysis?.breakingThreshold
274
+ ?? 5;
275
+ if (!enabled) return { active: false, reason: 'disabled' };
276
+
277
+ const text = String(input || '');
278
+ if (!REFACTOR_RE.test(text)) {
279
+ return { active: false, reason: 'no-refactor-keyword' };
280
+ }
281
+
282
+ const seeds = extractConsumerSeeds(text);
283
+ if (seeds.length === 0) {
284
+ return { active: true, seeds: [], matches: [], breakingCount: 0, reason: 'no-seeds' };
285
+ }
286
+
287
+ const cwd = opts.cwd || PATHS.root;
288
+ const matches = [];
289
+ for (const seed of seeds.slice(0, 10)) { // cap to 10 seeds
290
+ try {
291
+ // git grep -l: files containing seed; -i: case-insensitive; --fixed-strings for literal
292
+ const safeSeed = seed.replace(/\x00/g, '');
293
+ if (!safeSeed) continue;
294
+ const out = execSync('git grep -l --fixed-strings -i -- ' + JSON.stringify(safeSeed), {
295
+ cwd,
296
+ encoding: 'utf-8',
297
+ timeout: 5000,
298
+ stdio: ['pipe', 'pipe', 'pipe']
299
+ });
300
+ const files = out.split('\n').map(s => s.trim()).filter(Boolean);
301
+ for (const file of files) {
302
+ matches.push({ seed, file, kind: classifyConsumerKind(file) });
303
+ }
304
+ } catch (_err) {
305
+ // No matches (exit 1) or git not available — skip
306
+ }
307
+ }
308
+
309
+ // Classify as BREAKING if the match file looks like a consumer (imports /
310
+ // requires pattern possible). Heuristic: any .js/.ts file that is not the
311
+ // seed itself. Without deeper analysis we treat all non-doc/non-test
312
+ // matches as BREAKING candidates.
313
+ const breaking = matches.filter(m => m.kind === 'code');
314
+ const breakingCount = new Set(breaking.map(m => m.file)).size;
315
+
316
+ return {
317
+ active: true,
318
+ seeds,
319
+ matches,
320
+ breakingCount,
321
+ phasedMigrationRecommended: breakingCount >= breakingThreshold
322
+ };
323
+ } catch (err) {
324
+ if (process.env.DEBUG) {
325
+ console.error(`[story-gates] consumerImpactAnalysis failed (fail-open): ${err.message}`);
326
+ }
327
+ return { active: false, reason: 'error' };
328
+ }
329
+ }
330
+
331
+ function classifyConsumerKind(file) {
332
+ if (/\.(md|txt)$/i.test(file)) return 'doc';
333
+ if (/(^|\/)tests?\//i.test(file) || /\.(test|spec)\.(js|ts|tsx|jsx)$/i.test(file)) return 'test';
334
+ if (/\.(json|ya?ml|toml)$/i.test(file)) return 'config';
335
+ return 'code';
336
+ }
337
+
338
+ // ============================================================
339
+ // Gate 4: Scope-Confidence Audit
340
+ // ============================================================
341
+
342
+ // Assumption patterns: capture the noun phrase being claimed.
343
+ const ASSUMPTION_PATTERNS = [
344
+ { label: 'new', re: /\bnew\s+([a-z][\w-]{2,}(?:\s+[a-z][\w-]{2,}){0,2})\b/gi },
345
+ { label: 'existing', re: /\bexisting\s+([a-z][\w-]{2,}(?:\s+[a-z][\w-]{2,}){0,2})\b/gi },
346
+ { label: 'the-service', re: /\bthe\s+([A-Z][A-Za-z]{2,})\s+(?:service|table|endpoint|component|module|hook)\b/g }
347
+ ];
348
+
349
+ /**
350
+ * Extract assumptions from input.
351
+ *
352
+ * @param {string} input
353
+ * @returns {Array<{label: string, phrase: string}>}
354
+ */
355
+ function extractAssumptions(input) {
356
+ const text = String(input || '');
357
+ const out = [];
358
+ for (const { label, re } of ASSUMPTION_PATTERNS) {
359
+ for (const m of text.matchAll(re)) {
360
+ const phrase = (m[1] || '').trim();
361
+ if (phrase && phrase.length >= 3) out.push({ label, phrase });
362
+ }
363
+ }
364
+ // De-dupe by phrase
365
+ const seen = new Set();
366
+ return out.filter(a => {
367
+ const k = `${a.label}::${a.phrase.toLowerCase()}`;
368
+ if (seen.has(k)) return false;
369
+ seen.add(k);
370
+ return true;
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Gate 4: audit assumptions against the codebase.
376
+ *
377
+ * @param {string} input
378
+ * @param {Object} [opts]
379
+ * @returns {{active: boolean, assumptions: Array, reason?: string}}
380
+ */
381
+ function auditScopeConfidence(input, opts = {}) {
382
+ try {
383
+ const config = getConfig();
384
+ const enabled = config.storyFlow?.scopeConfidenceAudit?.enabled !== false;
385
+ if (!enabled) return { active: false, assumptions: [], reason: 'disabled' };
386
+
387
+ const raw = extractAssumptions(input);
388
+ if (raw.length === 0) return { active: false, assumptions: [], reason: 'no-assumptions' };
389
+
390
+ const cwd = opts.cwd || PATHS.root;
391
+ const assumptions = raw.map(a => {
392
+ let status = 'UNVERIFIED';
393
+ try {
394
+ const safe = a.phrase.replace(/\x00/g, '');
395
+ const out = execSync('git grep -l --fixed-strings -i -- ' + JSON.stringify(safe), {
396
+ cwd, encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
397
+ }).trim();
398
+ if (out.length > 0) {
399
+ // Exists in codebase.
400
+ // label=existing → VERIFIED (matches assumption)
401
+ // label=new → CONTRADICTED (user said new, but it exists)
402
+ // label=the-X → VERIFIED
403
+ status = a.label === 'new' ? 'CONTRADICTED' : 'VERIFIED';
404
+ } else {
405
+ // label=existing → CONTRADICTED (said existing but not found)
406
+ // label=new → VERIFIED (truly new)
407
+ // label=the-X → UNVERIFIED
408
+ if (a.label === 'existing') status = 'CONTRADICTED';
409
+ else if (a.label === 'new') status = 'VERIFIED';
410
+ else status = 'UNVERIFIED';
411
+ }
412
+ } catch (_err) {
413
+ // git grep exit 1 = no match
414
+ if (a.label === 'new') status = 'VERIFIED';
415
+ else if (a.label === 'existing') status = 'CONTRADICTED';
416
+ else status = 'UNVERIFIED';
417
+ }
418
+ return { ...a, status };
419
+ });
420
+
421
+ return { active: true, assumptions };
422
+ } catch (err) {
423
+ if (process.env.DEBUG) {
424
+ console.error(`[story-gates] scopeConfidenceAudit failed (fail-open): ${err.message}`);
425
+ }
426
+ return { active: false, assumptions: [], reason: 'error' };
427
+ }
428
+ }
429
+
430
+ // ============================================================
431
+ // Gate 5: Intent Bootstrap Coordination
432
+ // ============================================================
433
+
434
+ /**
435
+ * Gate 5: schedule IGR bootstrap if artifacts missing + not already scheduled.
436
+ * Writes a per-session coordination flag to session-state.json so /wogi-start
437
+ * does not re-prompt.
438
+ *
439
+ * @param {Object} [opts] - { sessionStatePath }
440
+ * @returns {{active: boolean, scheduled?: boolean, reason?: string}}
441
+ */
442
+ function coordinateIntentBootstrap(opts = {}) {
443
+ try {
444
+ const config = getConfig();
445
+ const igrEnabled = config.intentGroundedReasoning?.enabled;
446
+ if (!igrEnabled) return { active: false, reason: 'igr-disabled' };
447
+
448
+ const stateDir = PATHS.state;
449
+ const artifacts = ['product.md', 'domain-model.md', 'user-journeys.md', 'glossary.md'];
450
+ const allExist = artifacts.every(f => fs.existsSync(path.join(stateDir, f)));
451
+ if (allExist) return { active: true, scheduled: false, reason: 'artifacts-exist' };
452
+
453
+ const sessionPath = opts.sessionStatePath || path.join(stateDir, 'session-state.json');
454
+ const session = safeJsonParse(sessionPath, {}) || {};
455
+
456
+ if (session.intentBootstrapScheduledAt) {
457
+ return { active: true, scheduled: false, reason: 'already-scheduled' };
458
+ }
459
+
460
+ // Write the coordination flag. The actual bootstrap run is fire-and-forget
461
+ // and delegated to /wogi-session-end (Option C [2]). The flag tells
462
+ // /wogi-start to skip its own prompt.
463
+ session.intentBootstrapScheduledAt = new Date().toISOString();
464
+ session.intentBootstrapScheduledBy = '/wogi-story';
465
+ try {
466
+ fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
467
+ fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2));
468
+ } catch (err) {
469
+ if (process.env.DEBUG) {
470
+ console.error(`[story-gates] session-state write failed: ${err.message}`);
471
+ }
472
+ return { active: true, scheduled: false, reason: 'write-error' };
473
+ }
474
+
475
+ return { active: true, scheduled: true };
476
+ } catch (err) {
477
+ if (process.env.DEBUG) {
478
+ console.error(`[story-gates] intentBootstrapCoord failed (fail-open): ${err.message}`);
479
+ }
480
+ return { active: false, reason: 'error' };
481
+ }
482
+ }
483
+
484
+ // ============================================================
485
+ // Exports
486
+ // ============================================================
487
+
488
+ module.exports = {
489
+ // Gates
490
+ checkLongInput,
491
+ reconcileItems,
492
+ analyzeConsumerImpact,
493
+ auditScopeConfidence,
494
+ coordinateIntentBootstrap,
495
+
496
+ // Helpers (exposed for tests)
497
+ countDiscreteItems,
498
+ enumerateItems,
499
+ verifyItemCoverage,
500
+ extractConsumerSeeds,
501
+ extractAssumptions,
502
+ classifyConsumerKind,
503
+ REFACTOR_KEYWORDS
504
+ };