wogiflow 2.17.0 → 2.18.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 (111) hide show
  1. package/.claude/commands/wogi-audit.md +212 -17
  2. package/.claude/commands/wogi-research.md +37 -0
  3. package/.claude/commands/wogi-review.md +200 -22
  4. package/.claude/commands/wogi-start.md +45 -0
  5. package/.claude/docs/claude-code-compatibility.md +46 -1
  6. package/.claude/docs/intent-grounded-review.md +209 -0
  7. package/.claude/settings.json +34 -1
  8. package/.workflow/agents/logic-adversary.md +8 -0
  9. package/.workflow/templates/claude-md.hbs +18 -0
  10. package/lib/installer.js +22 -0
  11. package/lib/utils.js +29 -3
  12. package/lib/workspace-changelog.js +2 -1
  13. package/lib/workspace-channel-server.js +4 -6
  14. package/lib/workspace-contracts.js +5 -4
  15. package/lib/workspace-events.js +8 -7
  16. package/lib/workspace-gates.js +4 -3
  17. package/lib/workspace-integration-tests.js +2 -1
  18. package/lib/workspace-intelligence.js +3 -2
  19. package/lib/workspace-locks.js +2 -1
  20. package/lib/workspace-messages.js +7 -6
  21. package/lib/workspace-routing.js +14 -26
  22. package/lib/workspace-session.js +7 -6
  23. package/lib/workspace-sync.js +9 -8
  24. package/package.json +4 -2
  25. package/scripts/base-workflow-step.js +1 -1
  26. package/scripts/flow +19 -0
  27. package/scripts/flow-adaptive-learning.js +1 -1
  28. package/scripts/flow-aggregate.js +2 -1
  29. package/scripts/flow-architect-pass.js +3 -3
  30. package/scripts/flow-archive-runs.js +372 -0
  31. package/scripts/flow-ask.js +1 -1
  32. package/scripts/flow-ast-grep.js +216 -0
  33. package/scripts/flow-audit-gates.js +1 -1
  34. package/scripts/flow-auto-learn.js +8 -11
  35. package/scripts/flow-bug.js +2 -2
  36. package/scripts/flow-capture-gate.js +644 -0
  37. package/scripts/flow-capture.js +4 -3
  38. package/scripts/flow-cli-flags.js +95 -0
  39. package/scripts/flow-community-sync.js +2 -1
  40. package/scripts/flow-community.js +6 -6
  41. package/scripts/flow-conclusion-classifier.js +310 -0
  42. package/scripts/flow-config-defaults.js +3 -3
  43. package/scripts/flow-constants.js +8 -11
  44. package/scripts/flow-context-scoring.js +1 -0
  45. package/scripts/flow-correction-detector.js +344 -3
  46. package/scripts/flow-damage-control.js +1 -1
  47. package/scripts/flow-decisions-merge.js +1 -0
  48. package/scripts/flow-done-gates.js +20 -0
  49. package/scripts/flow-done-report.js +2 -2
  50. package/scripts/flow-done.js +4 -4
  51. package/scripts/flow-epics.js +5 -11
  52. package/scripts/flow-health.js +145 -1
  53. package/scripts/flow-id.js +92 -0
  54. package/scripts/flow-io.js +15 -5
  55. package/scripts/flow-knowledge-router.js +2 -1
  56. package/scripts/flow-links.js +1 -1
  57. package/scripts/flow-log-manager.js +2 -1
  58. package/scripts/flow-logic-adversary.js +4 -4
  59. package/scripts/flow-long-input-cli.js +6 -0
  60. package/scripts/flow-long-input-stories.js +1 -1
  61. package/scripts/flow-loop-retry-learning.js +1 -1
  62. package/scripts/flow-mcp-capabilities.js +2 -3
  63. package/scripts/flow-mcp-docs.js +2 -1
  64. package/scripts/flow-memory-blocks.js +2 -1
  65. package/scripts/flow-memory-sync.js +1 -1
  66. package/scripts/flow-memory.js +767 -0
  67. package/scripts/flow-migrate-igr.js +1 -1
  68. package/scripts/flow-migrate.js +2 -1
  69. package/scripts/flow-model-adapter.js +1 -1
  70. package/scripts/flow-model-config.js +5 -1
  71. package/scripts/flow-model-profile.js +2 -1
  72. package/scripts/flow-orchestrate.js +3 -3
  73. package/scripts/flow-output.js +29 -0
  74. package/scripts/flow-parallel.js +10 -9
  75. package/scripts/flow-pattern-enforcer.js +2 -1
  76. package/scripts/flow-permissions-audit.js +124 -0
  77. package/scripts/flow-plugin-registry.js +2 -2
  78. package/scripts/flow-progress.js +5 -1
  79. package/scripts/flow-project-analyzer.js +1 -1
  80. package/scripts/flow-promote.js +510 -0
  81. package/scripts/flow-registries.js +86 -0
  82. package/scripts/flow-request-log.js +133 -0
  83. package/scripts/flow-research-protocol.js +0 -1
  84. package/scripts/flow-revision-tracker.js +2 -1
  85. package/scripts/flow-roadmap.js +2 -1
  86. package/scripts/flow-rules-sync.js +3 -7
  87. package/scripts/flow-session-end.js +3 -1
  88. package/scripts/flow-session-learning.js +6 -13
  89. package/scripts/flow-session-state.js +2 -2
  90. package/scripts/flow-setup-hooks.js +2 -1
  91. package/scripts/flow-skill-create.js +1 -1
  92. package/scripts/flow-skill-freshness.js +6 -7
  93. package/scripts/flow-skill-learn.js +1 -1
  94. package/scripts/flow-step-coverage.js +1 -1
  95. package/scripts/flow-step-security.js +1 -1
  96. package/scripts/flow-story.js +58 -10
  97. package/scripts/flow-sys.js +204 -0
  98. package/scripts/flow-task-hierarchy.js +88 -0
  99. package/scripts/flow-tech-debt.js +2 -1
  100. package/scripts/flow-test-api.js +1 -1
  101. package/scripts/flow-utils.js +60 -890
  102. package/scripts/hooks/core/bugfix-scope-gate.js +5 -4
  103. package/scripts/hooks/core/deploy-gate.js +1 -1
  104. package/scripts/hooks/core/pre-tool-helpers.js +72 -0
  105. package/scripts/hooks/core/pre-tool-orchestrator.js +442 -0
  106. package/scripts/hooks/core/routing-gate.js +8 -0
  107. package/scripts/hooks/core/session-end.js +28 -0
  108. package/scripts/hooks/entry/claude-code/pre-tool-use.js +48 -492
  109. package/scripts/hooks/entry/shared/hook-runner.js +1 -1
  110. package/scripts/registries/schema-registry.js +1 -1
  111. package/scripts/registries/service-registry.js +1 -1
@@ -0,0 +1,510 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Promotion Pipeline
5
+ *
6
+ * Closes audit gaps G6 (feedback-patterns underused) and G8 (adversary findings
7
+ * not promoted) from `.workflow/audits/state-coverage-2026-04-15.md`.
8
+ *
9
+ * Two new feed wires:
10
+ * 1. Adversary-finding promotion — recurring principle FAIL/CONCERN across
11
+ * adversary-runs/*.json becomes a feedback-pattern entry after N hits.
12
+ * 2. Pattern-phrase promotion — entries in correction-patterns.json
13
+ * (wf-e6d65edf) that exceed confirmedHits >= patternToFeedbackThreshold
14
+ * become feedback-pattern entries (one-shot, idempotent via lastPromotedAt).
15
+ *
16
+ * Both wires write through `flow-learning-orchestrator.modifyFeedbackPatterns`
17
+ * — never directly to feedback-patterns.md — so dedup + locking are inherited.
18
+ *
19
+ * Story: wf-6a352aae (epic-episodic-memory). User-approved scope:
20
+ * - adversaryPromotionThreshold: 2
21
+ * - patternToFeedbackThreshold: 3
22
+ * - autoAtSessionEnd: true (writes to pending-promotions.json — interactive
23
+ * `flow promote --apply` confirms before write to feedback-patterns.md)
24
+ *
25
+ * Usage (programmatic):
26
+ * const { promoteAll, scanForPromotions, applyPendingPromotions } = require('./flow-promote');
27
+ * const result = await promoteAll(getConfig());
28
+ * // → { adversary: { proposed: N, applied: M }, patternPhrase: { ... } }
29
+ *
30
+ * CLI:
31
+ * node scripts/flow-promote.js scan # scan and write pending-promotions.json
32
+ * node scripts/flow-promote.js apply # apply pending promotions
33
+ * node scripts/flow-promote.js status # show pending-promotions.json contents
34
+ */
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ const {
40
+ PATHS,
41
+ safeJsonParse,
42
+ safeJsonParseString,
43
+ writeJson,
44
+ ensureDir,
45
+ withLock,
46
+ getTodayDate,
47
+ } = require('./flow-utils');
48
+ const { getConfig } = require('./flow-config-loader');
49
+
50
+ // ============================================================================
51
+ // Constants & paths
52
+ // ============================================================================
53
+
54
+ const ADVERSARY_RUNS_DIR = path.join(PATHS.state, 'adversary-runs');
55
+ const CORRECTION_PATTERNS_FILE = path.join(PATHS.state, 'correction-patterns.json');
56
+ const PENDING_PROMOTIONS_FILE = path.join(PATHS.state, 'pending-promotions.json');
57
+
58
+ const PROMOTION_DEFAULTS = Object.freeze({
59
+ autoAtSessionEnd: true,
60
+ adversaryPromotionThreshold: 2,
61
+ patternToFeedbackThreshold: 3,
62
+ });
63
+
64
+ // ============================================================================
65
+ // Config
66
+ // ============================================================================
67
+
68
+ function getPromotionConfig() {
69
+ let cfg = {};
70
+ try {
71
+ cfg = getConfig() || {};
72
+ } catch (_err) {
73
+ cfg = {};
74
+ }
75
+ const p = cfg.promotion || {};
76
+ return {
77
+ autoAtSessionEnd: p.autoAtSessionEnd !== false,
78
+ adversaryPromotionThreshold: Number.isFinite(p.adversaryPromotionThreshold)
79
+ ? p.adversaryPromotionThreshold
80
+ : PROMOTION_DEFAULTS.adversaryPromotionThreshold,
81
+ patternToFeedbackThreshold: Number.isFinite(p.patternToFeedbackThreshold)
82
+ ? p.patternToFeedbackThreshold
83
+ : PROMOTION_DEFAULTS.patternToFeedbackThreshold,
84
+ };
85
+ }
86
+
87
+ // ============================================================================
88
+ // Adversary-finding promotion
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Read all adversary-run JSON files (skip _archive subdir).
93
+ * @returns {Array<{filePath, taskId, principles, round}>}
94
+ */
95
+ function loadAdversaryRuns() {
96
+ if (!fs.existsSync(ADVERSARY_RUNS_DIR)) return [];
97
+ const out = [];
98
+ let entries;
99
+ try {
100
+ entries = fs.readdirSync(ADVERSARY_RUNS_DIR);
101
+ } catch (_err) {
102
+ return [];
103
+ }
104
+ for (const name of entries) {
105
+ if (!name.endsWith('.json')) continue;
106
+ // _archive is a subdirectory and has no .json extension at the top level;
107
+ // index.json lives inside _archive so won't appear in readdir here.
108
+ const full = path.join(ADVERSARY_RUNS_DIR, name);
109
+ try {
110
+ const raw = fs.readFileSync(full, 'utf-8');
111
+ const parsed = safeJsonParseString(raw, null);
112
+ if (!parsed || typeof parsed !== 'object') continue;
113
+ out.push({
114
+ filePath: full,
115
+ fileName: name,
116
+ taskId: parsed.taskId || null,
117
+ round: parsed.round || null,
118
+ principles: Array.isArray(parsed.principles) ? parsed.principles : [],
119
+ });
120
+ } catch (_err) {
121
+ // Skip malformed file silently (DEBUG-mode log only).
122
+ if (process.env.DEBUG) {
123
+ console.error(`[promote] could not read ${name}: ${_err.message}`);
124
+ }
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Normalize an issue string to a stable key for grouping. Lowercase, collapse
132
+ * whitespace, drop trailing punctuation, take leading 80 chars.
133
+ */
134
+ function normalizeIssueKey(issue) {
135
+ return String(issue || '')
136
+ .toLowerCase()
137
+ .replace(/\s+/g, ' ')
138
+ .replace(/[.!?;,:]+\s*$/, '')
139
+ .trim()
140
+ .slice(0, 80);
141
+ }
142
+
143
+ /**
144
+ * Normalize a principle ID so different run formats group together. Examples:
145
+ * "P11.2" → "p11.2"
146
+ * "11.2" → "p11.2"
147
+ * 2 → "p2"
148
+ * "P3" → "p3"
149
+ */
150
+ function normalizePrincipleId(id) {
151
+ let s = String(id || '').trim().toLowerCase();
152
+ if (!s) return '';
153
+ if (!s.startsWith('p')) s = 'p' + s;
154
+ return s;
155
+ }
156
+
157
+ /**
158
+ * Group adversary findings by (principleId, normalizedIssue). Only counts
159
+ * FAIL or CONCERN verdicts — PASS / SKIP do not signal a recurring problem.
160
+ * @returns {Map<string, {principleId, issueKey, hits: Set<string>, examples: Array}>}
161
+ */
162
+ function groupAdversaryFindings(runs) {
163
+ const groups = new Map();
164
+ for (const run of runs) {
165
+ for (const principle of run.principles) {
166
+ const verdict = String(principle?.verdict || '').toUpperCase();
167
+ if (verdict !== 'FAIL' && verdict !== 'CONCERN') continue;
168
+ const issueKey = normalizeIssueKey(principle.issue || principle.evidence || '');
169
+ if (!issueKey) continue;
170
+ const principleId = normalizePrincipleId(principle.id);
171
+ if (!principleId) continue;
172
+ const groupKey = `${principleId}::${issueKey}`;
173
+ if (!groups.has(groupKey)) {
174
+ groups.set(groupKey, {
175
+ principleId,
176
+ issueKey,
177
+ hits: new Set(),
178
+ examples: [],
179
+ });
180
+ }
181
+ const g = groups.get(groupKey);
182
+ // Use taskId+round as the unique-occurrence key so the same finding
183
+ // re-evaluated in round 2 doesn't double-count.
184
+ const occKey = `${run.taskId || run.fileName}::r${run.round || ''}`;
185
+ g.hits.add(occKey);
186
+ if (g.examples.length < 3) {
187
+ g.examples.push({
188
+ taskId: run.taskId,
189
+ fileName: run.fileName,
190
+ issue: String(principle.issue || principle.evidence || '').slice(0, 240),
191
+ remedy: String(principle.remedy || '').slice(0, 240),
192
+ });
193
+ }
194
+ }
195
+ }
196
+ return groups;
197
+ }
198
+
199
+ /**
200
+ * Identify adversary findings that meet the promotion threshold.
201
+ * @returns {Array<{kind: 'adversary', key: string, count: number, feedbackEntry, sourceRef}>}
202
+ */
203
+ function findAdversaryPromotions(threshold) {
204
+ const runs = loadAdversaryRuns();
205
+ if (runs.length === 0) return [];
206
+ const groups = groupAdversaryFindings(runs);
207
+ const out = [];
208
+ for (const [groupKey, g] of groups) {
209
+ const count = g.hits.size;
210
+ if (count < threshold) continue;
211
+ const today = getTodayDate();
212
+ const summary = g.issueKey;
213
+ out.push({
214
+ kind: 'adversary',
215
+ key: groupKey,
216
+ count,
217
+ sourceRef: `adversary-runs (${count} occurrences across: ${[...g.hits].slice(0, 5).join(', ')})`,
218
+ feedbackEntry: {
219
+ date: today,
220
+ pattern: `${g.principleId}: ${summary}`,
221
+ source: 'adversary-finding',
222
+ count,
223
+ confidence: Math.min(100, 60 + count * 10),
224
+ status: 'Monitor',
225
+ },
226
+ examples: g.examples,
227
+ });
228
+ }
229
+ return out;
230
+ }
231
+
232
+ // ============================================================================
233
+ // Pattern-phrase promotion
234
+ // ============================================================================
235
+
236
+ /**
237
+ * Read correction-patterns.json (file may be absent — treated as empty).
238
+ */
239
+ function loadCorrectionPatterns() {
240
+ if (!fs.existsSync(CORRECTION_PATTERNS_FILE)) return [];
241
+ try {
242
+ const raw = fs.readFileSync(CORRECTION_PATTERNS_FILE, 'utf-8');
243
+ const parsed = safeJsonParseString(raw, null);
244
+ if (!Array.isArray(parsed)) return [];
245
+ return parsed;
246
+ } catch (_err) {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Identify pattern phrases that meet promotion threshold AND have not been
253
+ * promoted yet (no `lastPromotedAt`).
254
+ */
255
+ function findPatternPhrasePromotions(threshold) {
256
+ const patterns = loadCorrectionPatterns();
257
+ if (patterns.length === 0) return [];
258
+ const out = [];
259
+ for (const p of patterns) {
260
+ if (!p || typeof p !== 'object' || !p.phrase) continue;
261
+ const confirmedHits = Number(p.confirmedHits) || 0;
262
+ if (confirmedHits < threshold) continue;
263
+ if (p.lastPromotedAt) continue; // already promoted (one-shot)
264
+ const today = getTodayDate();
265
+ out.push({
266
+ kind: 'pattern-phrase',
267
+ key: `phrase::${String(p.phrase).toLowerCase()}`,
268
+ count: confirmedHits,
269
+ sourceRef: `correction-patterns.json (phrase="${String(p.phrase).slice(0, 80)}", confirmedHits=${confirmedHits})`,
270
+ feedbackEntry: {
271
+ date: today,
272
+ pattern: `correction-phrase: "${p.phrase}"`,
273
+ source: 'pattern-phrase',
274
+ count: confirmedHits,
275
+ confidence: Math.min(100, 65 + confirmedHits * 5),
276
+ status: 'Monitor',
277
+ },
278
+ });
279
+ }
280
+ return out;
281
+ }
282
+
283
+ // ============================================================================
284
+ // Pending promotions queue
285
+ // ============================================================================
286
+
287
+ function loadPendingPromotions() {
288
+ if (!fs.existsSync(PENDING_PROMOTIONS_FILE)) return null;
289
+ return safeJsonParse(PENDING_PROMOTIONS_FILE, null);
290
+ }
291
+
292
+ async function savePendingPromotions(payload) {
293
+ ensureDir(path.dirname(PENDING_PROMOTIONS_FILE));
294
+ await withLock(PENDING_PROMOTIONS_FILE, async () => {
295
+ writeJson(PENDING_PROMOTIONS_FILE, payload);
296
+ });
297
+ }
298
+
299
+ async function clearPendingPromotions() {
300
+ if (!fs.existsSync(PENDING_PROMOTIONS_FILE)) return;
301
+ await withLock(PENDING_PROMOTIONS_FILE, async () => {
302
+ try {
303
+ fs.unlinkSync(PENDING_PROMOTIONS_FILE);
304
+ } catch (_err) { /* no-op */ }
305
+ });
306
+ }
307
+
308
+ // ============================================================================
309
+ // Scan + write to pending-promotions.json
310
+ // ============================================================================
311
+
312
+ /**
313
+ * Scan both promotion sources and return all promotions meeting thresholds.
314
+ * Does NOT write to feedback-patterns.md — caller decides (auto session-end
315
+ * vs interactive apply).
316
+ */
317
+ function scanForPromotions(config) {
318
+ const cfg = config || getPromotionConfig();
319
+ const adversary = findAdversaryPromotions(cfg.adversaryPromotionThreshold);
320
+ const patternPhrase = findPatternPhrasePromotions(cfg.patternToFeedbackThreshold);
321
+ return { adversary, patternPhrase };
322
+ }
323
+
324
+ /**
325
+ * Write proposals into pending-promotions.json. Idempotent: re-running with
326
+ * the same input writes the same payload (same proposedAt is replaced).
327
+ */
328
+ async function writePendingPromotions(scan) {
329
+ const all = [...scan.adversary, ...scan.patternPhrase];
330
+ if (all.length === 0) {
331
+ await clearPendingPromotions();
332
+ return { written: false, count: 0 };
333
+ }
334
+ const payload = {
335
+ proposedAt: new Date().toISOString(),
336
+ promotions: all,
337
+ };
338
+ await savePendingPromotions(payload);
339
+ return { written: true, count: all.length };
340
+ }
341
+
342
+ // ============================================================================
343
+ // Apply pending promotions (writes to feedback-patterns.md via orchestrator)
344
+ // ============================================================================
345
+
346
+ /**
347
+ * Stamp `lastPromotedAt` on the source pattern in correction-patterns.json so
348
+ * the same phrase is not proposed again. Race-safe via withLock.
349
+ */
350
+ async function stampPatternPromoted(phrase) {
351
+ if (!fs.existsSync(CORRECTION_PATTERNS_FILE)) return;
352
+ await withLock(CORRECTION_PATTERNS_FILE, async () => {
353
+ let arr;
354
+ try {
355
+ arr = safeJsonParseString(fs.readFileSync(CORRECTION_PATTERNS_FILE, 'utf-8'), null);
356
+ } catch (_err) {
357
+ return;
358
+ }
359
+ if (!Array.isArray(arr)) return;
360
+ const key = String(phrase).toLowerCase();
361
+ const idx = arr.findIndex(p => String(p?.phrase || '').toLowerCase() === key);
362
+ if (idx < 0) return;
363
+ arr[idx] = { ...arr[idx], lastPromotedAt: new Date().toISOString() };
364
+ writeJson(CORRECTION_PATTERNS_FILE, arr);
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Apply a single promotion via the learning orchestrator.
370
+ */
371
+ async function applyPromotion(promotion) {
372
+ const orch = require('./flow-learning-orchestrator');
373
+ const entry = promotion.feedbackEntry;
374
+ const tableRow = `| ${entry.date} | ${entry.pattern} | ${entry.source} | ${entry.count} | ${entry.confidence}% | ${entry.status} |`;
375
+ const result = await orch.modifyFeedbackPatterns(
376
+ (currentContent) => {
377
+ const content = currentContent || '';
378
+ // Append to end (or before "## Promotion History" if present).
379
+ let next;
380
+ if (content.includes('## Promotion History')) {
381
+ next = content.replace('## Promotion History', tableRow + '\n\n## Promotion History');
382
+ } else if (content.length === 0) {
383
+ next = `# Feedback Patterns\n\n## Auto-Captured Patterns\n\n| Date | Pattern | Source | Count | Confidence | Status |\n|------|---------|--------|-------|------------|--------|\n${tableRow}\n`;
384
+ } else {
385
+ next = content + (content.endsWith('\n') ? '' : '\n') + tableRow + '\n';
386
+ }
387
+ return { content: next, entryText: entry.pattern };
388
+ },
389
+ { caller: 'flow-promote/applyPromotion' }
390
+ );
391
+ // After successful write, stamp source.
392
+ if (result.success && promotion.kind === 'pattern-phrase') {
393
+ // Extract phrase from feedbackEntry.pattern: 'correction-phrase: "X"'
394
+ const m = String(entry.pattern).match(/^correction-phrase:\s*"(.+)"$/);
395
+ if (m) await stampPatternPromoted(m[1]);
396
+ }
397
+ return result;
398
+ }
399
+
400
+ /**
401
+ * Apply all pending promotions from pending-promotions.json. Returns a summary.
402
+ */
403
+ async function applyPendingPromotions() {
404
+ const pending = loadPendingPromotions();
405
+ if (!pending || !Array.isArray(pending.promotions) || pending.promotions.length === 0) {
406
+ return { applied: 0, skipped: 0, failed: 0 };
407
+ }
408
+ let applied = 0;
409
+ let skipped = 0;
410
+ let failed = 0;
411
+ for (const p of pending.promotions) {
412
+ try {
413
+ const r = await applyPromotion(p);
414
+ if (r.success) applied += 1;
415
+ else if (r.reason === 'duplicate') skipped += 1;
416
+ else failed += 1;
417
+ } catch (err) {
418
+ failed += 1;
419
+ if (process.env.DEBUG) {
420
+ console.error(`[promote] apply failed for ${p.key}: ${err.message}`);
421
+ }
422
+ }
423
+ }
424
+ if (failed === 0) {
425
+ await clearPendingPromotions();
426
+ }
427
+ return { applied, skipped, failed };
428
+ }
429
+
430
+ // ============================================================================
431
+ // promoteAll — top-level used by session-end hook
432
+ // ============================================================================
433
+
434
+ /**
435
+ * Scan + write pending-promotions.json (no apply). Caller can apply
436
+ * interactively via `flow promote apply`. Designed to be fire-and-forget
437
+ * from the session-end hook.
438
+ *
439
+ * @param {Object} [config]
440
+ * @returns {Promise<{ proposed: number, written: boolean, scan: Object }>}
441
+ */
442
+ async function promoteAll(config) {
443
+ const cfg = config || getPromotionConfig();
444
+ const scan = scanForPromotions(cfg);
445
+ const writeResult = await writePendingPromotions(scan);
446
+ return {
447
+ proposed: writeResult.count,
448
+ written: writeResult.written,
449
+ scan,
450
+ };
451
+ }
452
+
453
+ // ============================================================================
454
+ // Exports
455
+ // ============================================================================
456
+
457
+ module.exports = {
458
+ promoteAll,
459
+ scanForPromotions,
460
+ writePendingPromotions,
461
+ loadPendingPromotions,
462
+ applyPendingPromotions,
463
+ applyPromotion,
464
+ clearPendingPromotions,
465
+ // Internals exposed for tests
466
+ loadAdversaryRuns,
467
+ groupAdversaryFindings,
468
+ normalizeIssueKey,
469
+ normalizePrincipleId,
470
+ findAdversaryPromotions,
471
+ loadCorrectionPatterns,
472
+ findPatternPhrasePromotions,
473
+ getPromotionConfig,
474
+ PROMOTION_DEFAULTS,
475
+ };
476
+
477
+ // ============================================================================
478
+ // CLI
479
+ // ============================================================================
480
+
481
+ if (require.main === module) {
482
+ const cmd = process.argv[2];
483
+ (async () => {
484
+ if (cmd === 'scan' || cmd === undefined) {
485
+ const r = await promoteAll();
486
+ console.log(JSON.stringify({
487
+ proposed: r.proposed,
488
+ adversary: r.scan.adversary.length,
489
+ patternPhrase: r.scan.patternPhrase.length,
490
+ }, null, 2));
491
+ if (r.proposed > 0) {
492
+ console.log(`\n${r.proposed} promotion(s) ready. Run \`flow promote apply\` to write to feedback-patterns.md.`);
493
+ } else {
494
+ console.log('No promotions ready.');
495
+ }
496
+ } else if (cmd === 'apply') {
497
+ const r = await applyPendingPromotions();
498
+ console.log(JSON.stringify(r, null, 2));
499
+ } else if (cmd === 'status') {
500
+ const pending = loadPendingPromotions();
501
+ console.log(pending ? JSON.stringify(pending, null, 2) : 'No pending promotions.');
502
+ } else {
503
+ console.log('Usage: node scripts/flow-promote.js [scan|apply|status]');
504
+ process.exit(1);
505
+ }
506
+ })().catch((err) => {
507
+ console.error(`[promote] error: ${err.message}`);
508
+ process.exit(1);
509
+ });
510
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Registry Discovery
5
+ *
6
+ * Reads the active registry manifest (.workflow/state/registry-manifest.json)
7
+ * and exposes paths to all active map + index files. Falls back to a safe
8
+ * default (components, functions, apis) when the manifest is missing.
9
+ *
10
+ * Extracted from flow-utils.js (wf-94cc3b72 epic — flow-utils decomposition).
11
+ *
12
+ * Lightweight — reads the manifest file directly without pulling in
13
+ * flow-registry-manager (which has heavier deps).
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { STATE_DIR } = require('./flow-paths');
21
+ const { safeJsonParse } = require('./flow-io');
22
+
23
+ const MANIFEST_PATH = path.join(STATE_DIR, 'registry-manifest.json');
24
+
25
+ const DEFAULT_REGISTRIES = [
26
+ { id: 'components', name: 'Component Registry', mapFile: 'app-map.md', indexFile: 'component-index.json', category: 'code', type: 'components', active: true },
27
+ { id: 'functions', name: 'Function Registry', mapFile: 'function-map.md', indexFile: 'function-index.json', category: 'code', type: 'functions', active: true },
28
+ { id: 'apis', name: 'API Registry', mapFile: 'api-map.md', indexFile: 'api-index.json', category: 'code', type: 'apis', active: true }
29
+ ];
30
+
31
+ /**
32
+ * Get all active registries from the manifest (with fallback to defaults).
33
+ * @returns {Array<{id, name, mapFile, indexFile, category, type, active}>}
34
+ */
35
+ function getActiveRegistries() {
36
+ if (fs.existsSync(MANIFEST_PATH)) {
37
+ try {
38
+ const manifest = safeJsonParse(MANIFEST_PATH, null);
39
+ if (manifest) {
40
+ const active = (manifest.registries || []).filter(r => r.active);
41
+ if (active.length > 0) return active;
42
+ }
43
+ } catch (_err) {
44
+ // Fall through to defaults
45
+ }
46
+ }
47
+ return DEFAULT_REGISTRIES;
48
+ }
49
+
50
+ /**
51
+ * Get paths for all active registry map and index files.
52
+ * @returns {{ maps: string[], indexes: string[], mapsByCategory: Object, registries: Array }}
53
+ */
54
+ function getRegistryPaths() {
55
+ const registries = getActiveRegistries();
56
+ const maps = registries.map(r => path.join(STATE_DIR, r.mapFile));
57
+ const indexes = registries.map(r => path.join(STATE_DIR, r.indexFile));
58
+
59
+ const mapsByCategory = {};
60
+ for (const r of registries) {
61
+ if (!mapsByCategory[r.category]) mapsByCategory[r.category] = [];
62
+ mapsByCategory[r.category].push({
63
+ id: r.id,
64
+ mapPath: path.join(STATE_DIR, r.mapFile),
65
+ indexPath: path.join(STATE_DIR, r.indexFile)
66
+ });
67
+ }
68
+
69
+ return { maps, indexes, mapsByCategory, registries };
70
+ }
71
+
72
+ /**
73
+ * Get map file names only (for copying to worktrees, etc.).
74
+ * @returns {string[]} e.g. ['app-map.md', 'function-map.md', 'api-map.md']
75
+ */
76
+ function getRegistryMapFiles() {
77
+ return getActiveRegistries().map(r => r.mapFile);
78
+ }
79
+
80
+ module.exports = {
81
+ getActiveRegistries,
82
+ getRegistryPaths,
83
+ getRegistryMapFiles,
84
+ DEFAULT_REGISTRIES,
85
+ MANIFEST_PATH,
86
+ };