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.
- package/.claude/commands/wogi-start.md +3 -3
- package/.claude/commands/wogi-story.md +27 -0
- package/.claude/docs/claude-code-compatibility.md +32 -1
- package/lib/workspace-dispatch-tracking.js +175 -0
- package/lib/workspace-messages.js +2 -0
- package/lib/workspace-routing.js +17 -0
- package/lib/workspace-worker-ready.js +190 -0
- package/package.json +2 -2
- package/scripts/flow-config-defaults.js +9 -0
- package/scripts/flow-story-gates.js +504 -0
- package/scripts/flow-story.js +205 -7
- package/scripts/hooks/adapters/claude-code.js +18 -37
- package/scripts/hooks/core/overdue-dispatches.js +291 -0
- package/scripts/hooks/core/session-context.js +17 -0
- package/scripts/hooks/core/session-start-worker.js +114 -0
- package/scripts/hooks/entry/claude-code/session-start.js +22 -0
- package/scripts/hooks/entry/claude-code/stop.js +92 -47
- package/scripts/hooks/entry/claude-code/user-prompt-submit.js +18 -0
|
@@ -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
|
+
};
|