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.
- package/.claude/commands/wogi-audit.md +212 -17
- package/.claude/commands/wogi-research.md +37 -0
- package/.claude/commands/wogi-review.md +200 -22
- package/.claude/commands/wogi-start.md +45 -0
- package/.claude/docs/claude-code-compatibility.md +46 -1
- package/.claude/docs/intent-grounded-review.md +209 -0
- package/.claude/settings.json +34 -1
- package/.workflow/agents/logic-adversary.md +8 -0
- package/.workflow/templates/claude-md.hbs +18 -0
- package/lib/installer.js +22 -0
- package/lib/utils.js +29 -3
- package/lib/workspace-changelog.js +2 -1
- package/lib/workspace-channel-server.js +4 -6
- package/lib/workspace-contracts.js +5 -4
- package/lib/workspace-events.js +8 -7
- package/lib/workspace-gates.js +4 -3
- package/lib/workspace-integration-tests.js +2 -1
- package/lib/workspace-intelligence.js +3 -2
- package/lib/workspace-locks.js +2 -1
- package/lib/workspace-messages.js +7 -6
- package/lib/workspace-routing.js +14 -26
- package/lib/workspace-session.js +7 -6
- package/lib/workspace-sync.js +9 -8
- package/package.json +4 -2
- package/scripts/base-workflow-step.js +1 -1
- package/scripts/flow +19 -0
- package/scripts/flow-adaptive-learning.js +1 -1
- package/scripts/flow-aggregate.js +2 -1
- package/scripts/flow-architect-pass.js +3 -3
- package/scripts/flow-archive-runs.js +372 -0
- package/scripts/flow-ask.js +1 -1
- package/scripts/flow-ast-grep.js +216 -0
- package/scripts/flow-audit-gates.js +1 -1
- package/scripts/flow-auto-learn.js +8 -11
- package/scripts/flow-bug.js +2 -2
- package/scripts/flow-capture-gate.js +644 -0
- package/scripts/flow-capture.js +4 -3
- package/scripts/flow-cli-flags.js +95 -0
- package/scripts/flow-community-sync.js +2 -1
- package/scripts/flow-community.js +6 -6
- package/scripts/flow-conclusion-classifier.js +310 -0
- package/scripts/flow-config-defaults.js +3 -3
- package/scripts/flow-constants.js +8 -11
- package/scripts/flow-context-scoring.js +1 -0
- package/scripts/flow-correction-detector.js +344 -3
- package/scripts/flow-damage-control.js +1 -1
- package/scripts/flow-decisions-merge.js +1 -0
- package/scripts/flow-done-gates.js +20 -0
- package/scripts/flow-done-report.js +2 -2
- package/scripts/flow-done.js +4 -4
- package/scripts/flow-epics.js +5 -11
- package/scripts/flow-health.js +145 -1
- package/scripts/flow-id.js +92 -0
- package/scripts/flow-io.js +15 -5
- package/scripts/flow-knowledge-router.js +2 -1
- package/scripts/flow-links.js +1 -1
- package/scripts/flow-log-manager.js +2 -1
- package/scripts/flow-logic-adversary.js +4 -4
- package/scripts/flow-long-input-cli.js +6 -0
- package/scripts/flow-long-input-stories.js +1 -1
- package/scripts/flow-loop-retry-learning.js +1 -1
- package/scripts/flow-mcp-capabilities.js +2 -3
- package/scripts/flow-mcp-docs.js +2 -1
- package/scripts/flow-memory-blocks.js +2 -1
- package/scripts/flow-memory-sync.js +1 -1
- package/scripts/flow-memory.js +767 -0
- package/scripts/flow-migrate-igr.js +1 -1
- package/scripts/flow-migrate.js +2 -1
- package/scripts/flow-model-adapter.js +1 -1
- package/scripts/flow-model-config.js +5 -1
- package/scripts/flow-model-profile.js +2 -1
- package/scripts/flow-orchestrate.js +3 -3
- package/scripts/flow-output.js +29 -0
- package/scripts/flow-parallel.js +10 -9
- package/scripts/flow-pattern-enforcer.js +2 -1
- package/scripts/flow-permissions-audit.js +124 -0
- package/scripts/flow-plugin-registry.js +2 -2
- package/scripts/flow-progress.js +5 -1
- package/scripts/flow-project-analyzer.js +1 -1
- package/scripts/flow-promote.js +510 -0
- package/scripts/flow-registries.js +86 -0
- package/scripts/flow-request-log.js +133 -0
- package/scripts/flow-research-protocol.js +0 -1
- package/scripts/flow-revision-tracker.js +2 -1
- package/scripts/flow-roadmap.js +2 -1
- package/scripts/flow-rules-sync.js +3 -7
- package/scripts/flow-session-end.js +3 -1
- package/scripts/flow-session-learning.js +6 -13
- package/scripts/flow-session-state.js +2 -2
- package/scripts/flow-setup-hooks.js +2 -1
- package/scripts/flow-skill-create.js +1 -1
- package/scripts/flow-skill-freshness.js +6 -7
- package/scripts/flow-skill-learn.js +1 -1
- package/scripts/flow-step-coverage.js +1 -1
- package/scripts/flow-step-security.js +1 -1
- package/scripts/flow-story.js +58 -10
- package/scripts/flow-sys.js +204 -0
- package/scripts/flow-task-hierarchy.js +88 -0
- package/scripts/flow-tech-debt.js +2 -1
- package/scripts/flow-test-api.js +1 -1
- package/scripts/flow-utils.js +60 -890
- package/scripts/hooks/core/bugfix-scope-gate.js +5 -4
- package/scripts/hooks/core/deploy-gate.js +1 -1
- package/scripts/hooks/core/pre-tool-helpers.js +72 -0
- package/scripts/hooks/core/pre-tool-orchestrator.js +442 -0
- package/scripts/hooks/core/routing-gate.js +8 -0
- package/scripts/hooks/core/session-end.js +28 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +48 -492
- package/scripts/hooks/entry/shared/hook-runner.js +1 -1
- package/scripts/registries/schema-registry.js +1 -1
- 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
|
+
};
|