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,372 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Archive Runs (stale-cleanup)
5
+ *
6
+ * Manual-only archival for adversary-runs/*.json + gate-telemetry.jsonl.
7
+ *
8
+ * Story: wf-6a352aae (epic-episodic-memory). User-approved scope:
9
+ * - archive.adversaryRunsDays: 30
10
+ * - archive.telemetryMaxLines: 5000
11
+ * - archive.autoAtSessionEnd: false (manual-only — destructive operations)
12
+ *
13
+ * Boundaries:
14
+ * - Files referenced by the active task-checkpoint are NEVER archived.
15
+ * - Original content is preserved in gzip form; nothing deleted.
16
+ * - Idempotent: re-running on the same input writes no duplicate archives.
17
+ *
18
+ * Usage (programmatic):
19
+ * const { archiveAdversaryRuns, archiveTelemetryLog, archiveAll } = require('./flow-archive-runs');
20
+ * const result = await archiveAll({ dryRun: false });
21
+ *
22
+ * CLI:
23
+ * node scripts/flow-archive-runs.js # archive (writes + moves)
24
+ * node scripts/flow-archive-runs.js --dry-run # preview only
25
+ * node scripts/flow-archive-runs.js status # show what would archive
26
+ */
27
+
28
+ const fs = require('node:fs');
29
+ const path = require('node:path');
30
+ const zlib = require('node:zlib');
31
+ const { pipeline } = require('node:stream/promises');
32
+ const { createReadStream, createWriteStream } = require('node:fs');
33
+
34
+ const {
35
+ PATHS,
36
+ safeJsonParse,
37
+ writeJson,
38
+ ensureDir,
39
+ withLock,
40
+ } = require('./flow-utils');
41
+ const { getConfig } = require('./flow-config-loader');
42
+
43
+ // ============================================================================
44
+ // Constants & paths
45
+ // ============================================================================
46
+
47
+ const ADVERSARY_RUNS_DIR = path.join(PATHS.state, 'adversary-runs');
48
+ const ADVERSARY_ARCHIVE_DIR = path.join(ADVERSARY_RUNS_DIR, '_archive');
49
+ const ADVERSARY_INDEX_FILE = path.join(ADVERSARY_ARCHIVE_DIR, 'index.json');
50
+ const TELEMETRY_LOG = path.join(PATHS.state, 'gate-telemetry.jsonl');
51
+ const TELEMETRY_ARCHIVE_DIR = path.join(PATHS.state, '_archive');
52
+ const TASK_CHECKPOINT_FILE = path.join(PATHS.state, 'task-checkpoint.json');
53
+
54
+ const ARCHIVE_DEFAULTS = Object.freeze({
55
+ autoAtSessionEnd: false,
56
+ adversaryRunsDays: 30,
57
+ telemetryMaxLines: 5000,
58
+ });
59
+
60
+ // ============================================================================
61
+ // Config
62
+ // ============================================================================
63
+
64
+ function getArchiveConfig() {
65
+ let cfg = {};
66
+ try {
67
+ cfg = getConfig() || {};
68
+ } catch (_err) {
69
+ cfg = {};
70
+ }
71
+ const a = cfg.archive || {};
72
+ return {
73
+ autoAtSessionEnd: a.autoAtSessionEnd === true,
74
+ adversaryRunsDays: Number.isFinite(a.adversaryRunsDays)
75
+ ? a.adversaryRunsDays
76
+ : ARCHIVE_DEFAULTS.adversaryRunsDays,
77
+ telemetryMaxLines: Number.isFinite(a.telemetryMaxLines)
78
+ ? a.telemetryMaxLines
79
+ : ARCHIVE_DEFAULTS.telemetryMaxLines,
80
+ };
81
+ }
82
+
83
+ // ============================================================================
84
+ // Helpers
85
+ // ============================================================================
86
+
87
+ /**
88
+ * Read active taskId from task-checkpoint.json (used as do-not-archive guard).
89
+ */
90
+ function getActiveTaskId() {
91
+ if (!fs.existsSync(TASK_CHECKPOINT_FILE)) return null;
92
+ const checkpoint = safeJsonParse(TASK_CHECKPOINT_FILE, null);
93
+ return checkpoint?.taskId || null;
94
+ }
95
+
96
+ function ageDays(filePath) {
97
+ try {
98
+ const stat = fs.statSync(filePath);
99
+ const ageMs = Date.now() - stat.mtimeMs;
100
+ return ageMs / (1000 * 60 * 60 * 24);
101
+ } catch (_err) {
102
+ return 0;
103
+ }
104
+ }
105
+
106
+ function yyyyMm(date) {
107
+ const d = new Date(date);
108
+ const y = d.getUTCFullYear();
109
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
110
+ return `${y}-${m}`;
111
+ }
112
+
113
+ function yyyyMmDd(date) {
114
+ const d = new Date(date);
115
+ const y = d.getUTCFullYear();
116
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
117
+ const day = String(d.getUTCDate()).padStart(2, '0');
118
+ return `${y}-${m}-${day}`;
119
+ }
120
+
121
+ /**
122
+ * Stream-gzip a source file to a destination .gz. Cross-platform via Node zlib.
123
+ */
124
+ async function gzipFile(srcPath, destPath) {
125
+ ensureDir(path.dirname(destPath));
126
+ await pipeline(createReadStream(srcPath), zlib.createGzip(), createWriteStream(destPath));
127
+ }
128
+
129
+ /**
130
+ * Cross-platform "move": copy then unlink. fs.rename can fail across volumes
131
+ * on Windows; copy+unlink is portable.
132
+ */
133
+ function safeUnlink(filePath) {
134
+ try {
135
+ fs.unlinkSync(filePath);
136
+ return true;
137
+ } catch (_err) {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ // ============================================================================
143
+ // Adversary-runs archival
144
+ // ============================================================================
145
+
146
+ function loadAdversaryArchiveIndex() {
147
+ if (!fs.existsSync(ADVERSARY_INDEX_FILE)) return {};
148
+ const idx = safeJsonParse(ADVERSARY_INDEX_FILE, {});
149
+ return idx && typeof idx === 'object' ? idx : {};
150
+ }
151
+
152
+ async function saveAdversaryArchiveIndex(index) {
153
+ ensureDir(ADVERSARY_ARCHIVE_DIR);
154
+ await withLock(ADVERSARY_INDEX_FILE, async () => {
155
+ writeJson(ADVERSARY_INDEX_FILE, index);
156
+ });
157
+ }
158
+
159
+ /**
160
+ * List adversary-run files eligible for archival.
161
+ */
162
+ function listEligibleAdversaryRuns(config, activeTaskId) {
163
+ if (!fs.existsSync(ADVERSARY_RUNS_DIR)) return [];
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(ADVERSARY_RUNS_DIR);
167
+ } catch (_err) {
168
+ return [];
169
+ }
170
+ const out = [];
171
+ for (const name of entries) {
172
+ if (!name.endsWith('.json')) continue;
173
+ // _archive is the archive subdirectory (no .json extension, won't reach here);
174
+ // index.json (under _archive) has a parent dir of _archive so won't appear here.
175
+ // We only filter individual run files now.
176
+ const full = path.join(ADVERSARY_RUNS_DIR, name);
177
+ try {
178
+ const stat = fs.statSync(full);
179
+ if (!stat.isFile()) continue;
180
+ } catch (_err) {
181
+ continue;
182
+ }
183
+ // Active-task guard: skip any file whose name contains the active task ID.
184
+ if (activeTaskId && name.includes(activeTaskId)) continue;
185
+ const age = ageDays(full);
186
+ if (age < config.adversaryRunsDays) continue;
187
+ out.push({ name, full, ageDays: Math.round(age * 10) / 10 });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Archive eligible adversary-runs.
194
+ * @returns {{ archived: number, skipped: number, failed: number, archives: Array }}
195
+ */
196
+ async function archiveAdversaryRuns(opts = {}) {
197
+ const config = opts.config || getArchiveConfig();
198
+ const dryRun = !!opts.dryRun;
199
+ const activeTaskId = getActiveTaskId();
200
+ const eligible = listEligibleAdversaryRuns(config, activeTaskId);
201
+ const result = { archived: 0, skipped: 0, failed: 0, archives: [], dryRun, activeTaskId };
202
+ if (eligible.length === 0) return result;
203
+ if (dryRun) {
204
+ result.archives = eligible.map((e) => ({
205
+ name: e.name,
206
+ ageDays: e.ageDays,
207
+ destinationDir: path.join(ADVERSARY_ARCHIVE_DIR, yyyyMm(new Date())),
208
+ }));
209
+ return result;
210
+ }
211
+ const index = loadAdversaryArchiveIndex();
212
+ for (const f of eligible) {
213
+ const ymDir = yyyyMm(new Date());
214
+ const destPath = path.join(ADVERSARY_ARCHIVE_DIR, ymDir, `${f.name}.gz`);
215
+ // Idempotency: if archive already exists for this filename, skip.
216
+ if (index[f.name]) {
217
+ result.skipped += 1;
218
+ continue;
219
+ }
220
+ try {
221
+ await gzipFile(f.full, destPath);
222
+ // Read the run to extract metadata for the index. safeJsonParse handles
223
+ // file errors and prototype-pollution per security-patterns.md §2.
224
+ const meta = { taskId: null, round: null };
225
+ const parsed = safeJsonParse(f.full, null);
226
+ if (parsed && typeof parsed === 'object') {
227
+ meta.taskId = parsed.taskId || null;
228
+ meta.round = parsed.round || null;
229
+ }
230
+ const removed = safeUnlink(f.full);
231
+ if (!removed) {
232
+ // Source unlink failed → leave .gz in place; don't update index so
233
+ // a retry will skip (file already gzipped) and complete the unlink later.
234
+ result.failed += 1;
235
+ continue;
236
+ }
237
+ index[f.name] = {
238
+ archivedAt: new Date().toISOString(),
239
+ archivePath: path.relative(PATHS.state, destPath),
240
+ taskId: meta.taskId,
241
+ round: meta.round,
242
+ ageDaysAtArchive: f.ageDays,
243
+ };
244
+ result.archived += 1;
245
+ result.archives.push({ name: f.name, archivePath: index[f.name].archivePath });
246
+ } catch (err) {
247
+ result.failed += 1;
248
+ if (process.env.DEBUG) {
249
+ console.error(`[archive] gzip failed for ${f.name}: ${err.message}`);
250
+ }
251
+ }
252
+ }
253
+ if (result.archived > 0) {
254
+ await saveAdversaryArchiveIndex(index);
255
+ }
256
+ return result;
257
+ }
258
+
259
+ // ============================================================================
260
+ // Telemetry-log rotation
261
+ // ============================================================================
262
+
263
+ /**
264
+ * Count lines in a file (streaming, no full-read).
265
+ */
266
+ function countLines(filePath) {
267
+ return new Promise((resolve) => {
268
+ if (!fs.existsSync(filePath)) return resolve(0);
269
+ let count = 0;
270
+ const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
271
+ let trailingNewline = false;
272
+ stream.on('data', (chunk) => {
273
+ for (let i = 0; i < chunk.length; i++) {
274
+ if (chunk[i] === '\n') count += 1;
275
+ }
276
+ trailingNewline = chunk[chunk.length - 1] === '\n';
277
+ });
278
+ stream.on('end', () => {
279
+ // If file doesn't end with \n, the last partial line still counts as a line.
280
+ // We're a JSONL log so most lines end with \n; keep the count as-is.
281
+ resolve(count + (trailingNewline ? 0 : (count > 0 || fs.existsSync(filePath) ? 1 : 0)));
282
+ });
283
+ stream.on('error', () => resolve(0));
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Rotate gate-telemetry.jsonl when over the line cap.
289
+ * @returns {{ rotated: boolean, lineCount: number, archivePath?: string, dryRun: boolean }}
290
+ */
291
+ async function archiveTelemetryLog(opts = {}) {
292
+ const config = opts.config || getArchiveConfig();
293
+ const dryRun = !!opts.dryRun;
294
+ const lineCount = await countLines(TELEMETRY_LOG);
295
+ if (lineCount <= config.telemetryMaxLines) {
296
+ return { rotated: false, lineCount, dryRun };
297
+ }
298
+ const archivePath = path.join(
299
+ TELEMETRY_ARCHIVE_DIR,
300
+ `gate-telemetry-${yyyyMmDd(new Date())}.jsonl.gz`
301
+ );
302
+ if (dryRun) {
303
+ return { rotated: false, lineCount, archivePath, dryRun: true };
304
+ }
305
+ await withLock(TELEMETRY_LOG, async () => {
306
+ await gzipFile(TELEMETRY_LOG, archivePath);
307
+ // Truncate live file (keeps existing FD-readers from breaking — file replaced).
308
+ fs.writeFileSync(TELEMETRY_LOG, '');
309
+ });
310
+ return { rotated: true, lineCount, archivePath: path.relative(PATHS.state, archivePath), dryRun };
311
+ }
312
+
313
+ // ============================================================================
314
+ // Top-level archiveAll
315
+ // ============================================================================
316
+
317
+ async function archiveAll(opts = {}) {
318
+ const config = opts.config || getArchiveConfig();
319
+ const dryRun = !!opts.dryRun;
320
+ const adversary = await archiveAdversaryRuns({ config, dryRun });
321
+ const telemetry = await archiveTelemetryLog({ config, dryRun });
322
+ return { adversary, telemetry, dryRun };
323
+ }
324
+
325
+ // ============================================================================
326
+ // Exports
327
+ // ============================================================================
328
+
329
+ module.exports = {
330
+ archiveAll,
331
+ archiveAdversaryRuns,
332
+ archiveTelemetryLog,
333
+ loadAdversaryArchiveIndex,
334
+ listEligibleAdversaryRuns,
335
+ getActiveTaskId,
336
+ getArchiveConfig,
337
+ countLines,
338
+ yyyyMm,
339
+ yyyyMmDd,
340
+ ARCHIVE_DEFAULTS,
341
+ };
342
+
343
+ // ============================================================================
344
+ // CLI
345
+ // ============================================================================
346
+
347
+ if (require.main === module) {
348
+ const args = process.argv.slice(2);
349
+ const dryRun = args.includes('--dry-run');
350
+ const cmd = args.find((a) => !a.startsWith('--')) || 'run';
351
+ (async () => {
352
+ if (cmd === 'status') {
353
+ const cfg = getArchiveConfig();
354
+ const eligible = listEligibleAdversaryRuns(cfg, getActiveTaskId());
355
+ const lineCount = await countLines(TELEMETRY_LOG);
356
+ console.log(JSON.stringify({
357
+ adversaryEligible: eligible.length,
358
+ adversaryFiles: eligible.map((e) => `${e.name} (${e.ageDays}d)`),
359
+ telemetryLineCount: lineCount,
360
+ telemetryMaxLines: cfg.telemetryMaxLines,
361
+ wouldRotateTelemetry: lineCount > cfg.telemetryMaxLines,
362
+ activeTaskId: getActiveTaskId(),
363
+ }, null, 2));
364
+ return;
365
+ }
366
+ const r = await archiveAll({ dryRun });
367
+ console.log(JSON.stringify(r, null, 2));
368
+ })().catch((err) => {
369
+ console.error(`[archive] error: ${err.message}`);
370
+ process.exit(1);
371
+ });
372
+ }
@@ -80,7 +80,7 @@ function clearPendingQuestion() {
80
80
  * @returns {boolean}
81
81
  */
82
82
  function hasPendingQuestion() {
83
- try { return fs.existsSync(getPendingQuestionPath()); } catch (_e) { return false; }
83
+ try { return fs.existsSync(getPendingQuestionPath()); } catch (_err) { return false; }
84
84
  }
85
85
 
86
86
  module.exports = {
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - AST-Grep Integration
5
+ *
6
+ * Structural pattern search via the `sg` CLI (ast-grep). Extracted from
7
+ * flow-utils.js (wf-94cc3b72 epic — flow-utils decomposition) to keep the
8
+ * barrel thin. Re-exported from flow-utils.js for backwards compatibility.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const path = require('node:path');
14
+ const { execSync, execFileSync } = require('node:child_process');
15
+ const { PROJECT_ROOT } = require('./flow-paths');
16
+ const { dirExists } = require('./flow-io');
17
+
18
+ /**
19
+ * Common AST patterns for code discovery
20
+ */
21
+ const AST_PATTERNS = {
22
+ // React patterns
23
+ reactComponent: 'function $NAME($PROPS) { return <$_>$$$</$_> }',
24
+ reactArrowComponent: 'const $NAME = ($PROPS) => { return <$_>$$$</$_> }',
25
+ useStateHook: 'const [$STATE, $SETTER] = useState($INIT)',
26
+ useEffectHook: 'useEffect($FN, [$$$DEPS])',
27
+ useCustomHook: 'const $RESULT = use$NAME($$$ARGS)',
28
+
29
+ // TypeScript patterns
30
+ interfaceDefinition: 'interface $NAME { $$$ }',
31
+ typeDefinition: 'type $NAME = $$$',
32
+ exportedFunction: 'export function $NAME($$$PARAMS) { $$$ }',
33
+ exportedConst: 'export const $NAME = $$$',
34
+
35
+ // Import patterns
36
+ namedImport: 'import { $$$IMPORTS } from "$PATH"',
37
+ defaultImport: 'import $NAME from "$PATH"',
38
+
39
+ // Class patterns
40
+ classDefinition: 'class $NAME { $$$ }',
41
+ classExtends: 'class $NAME extends $BASE { $$$ }'
42
+ };
43
+
44
+ /**
45
+ * Check if ast-grep CLI (sg) is available
46
+ */
47
+ function isAstGrepAvailable() {
48
+ try {
49
+ execSync('which sg', { stdio: 'ignore' });
50
+ return true;
51
+ } catch (_err) {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // Allowed languages for ast-grep to prevent command injection (Security Rule 8)
57
+ const ALLOWED_AST_GREP_LANGUAGES = new Set([
58
+ 'typescript', 'javascript', 'tsx', 'jsx', 'python', 'go', 'rust',
59
+ 'java', 'c', 'cpp', 'csharp', 'ruby', 'swift', 'kotlin', 'html', 'css'
60
+ ]);
61
+
62
+ /**
63
+ * Search codebase using ast-grep for structural patterns
64
+ * @param {string} pattern - AST pattern (e.g., "useState($INIT)")
65
+ * @param {object} options - { lang, cwd, maxResults }
66
+ * @returns {Array|null} Array of matches or null if ast-grep unavailable
67
+ */
68
+ function astGrepSearch(pattern, options = {}) {
69
+ const {
70
+ lang = 'typescript',
71
+ cwd = PROJECT_ROOT,
72
+ maxResults = 20,
73
+ searchDir = 'src'
74
+ } = options;
75
+
76
+ // Validate lang parameter to prevent command injection (Security Rule 8)
77
+ if (!ALLOWED_AST_GREP_LANGUAGES.has(lang)) {
78
+ if (process.env.DEBUG) {
79
+ console.error(`[ast-grep] Invalid language: ${lang}. Allowed: ${[...ALLOWED_AST_GREP_LANGUAGES].join(', ')}`);
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Check if ast-grep is available
85
+ if (!isAstGrepAvailable()) {
86
+ return null;
87
+ }
88
+
89
+ const searchPath = path.join(cwd, searchDir);
90
+ if (!dirExists(searchPath)) {
91
+ return [];
92
+ }
93
+
94
+ try {
95
+ // Use execFileSync with array args to prevent shell injection (Security Rule 8)
96
+ const result = execFileSync('sg', [
97
+ '--pattern', pattern,
98
+ '--lang', lang,
99
+ '--json', searchPath
100
+ ], {
101
+ encoding: 'utf-8',
102
+ maxBuffer: 10 * 1024 * 1024,
103
+ timeout: 30000
104
+ });
105
+
106
+ const matches = JSON.parse(result || '[]');
107
+ return matches.slice(0, maxResults).map(m => ({
108
+ file: path.relative(cwd, m.file || m.path),
109
+ line: m.range?.start?.line ?? m.startLine ?? 0,
110
+ endLine: m.range?.end?.line ?? m.endLine ?? 0,
111
+ content: m.text || m.match,
112
+ meta: m.metaVariables || {} // Captured $VARS
113
+ }));
114
+ } catch (err) {
115
+ // Parse error, timeout, or no matches
116
+ if (err.stdout) {
117
+ try {
118
+ const matches = JSON.parse(err.stdout);
119
+ return matches.slice(0, maxResults).map(m => ({
120
+ file: path.relative(cwd, m.file || m.path),
121
+ line: m.range?.start?.line ?? 0,
122
+ content: m.text || m.match,
123
+ meta: m.metaVariables || {}
124
+ }));
125
+ } catch (_err) {
126
+ // Ignore parse errors
127
+ }
128
+ }
129
+ return [];
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Search for React components in the codebase
135
+ * @param {object} options - Search options
136
+ */
137
+ function findReactComponents(options = {}) {
138
+ const { maxResults = 10 } = options;
139
+
140
+ // Try function components first
141
+ let results = astGrepSearch(AST_PATTERNS.reactComponent, { ...options, maxResults });
142
+
143
+ // If ast-grep not available, return null
144
+ if (results === null) return null;
145
+
146
+ // Also search arrow function components
147
+ const arrowResults = astGrepSearch(AST_PATTERNS.reactArrowComponent, { ...options, maxResults });
148
+ if (arrowResults) {
149
+ results = [...results, ...arrowResults];
150
+ }
151
+
152
+ // Dedupe by file
153
+ const seen = new Set();
154
+ return results.filter(r => {
155
+ if (seen.has(r.file)) return false;
156
+ seen.add(r.file);
157
+ return true;
158
+ }).slice(0, maxResults);
159
+ }
160
+
161
+ /**
162
+ * Search for custom hooks in the codebase
163
+ * @param {object} options - Search options
164
+ */
165
+ function findCustomHooks(options = {}) {
166
+ const { maxResults = 10 } = options;
167
+
168
+ // Search for function use* pattern
169
+ const results = astGrepSearch('function use$NAME($$$) { $$$ }', { ...options, maxResults });
170
+
171
+ if (results === null) return null;
172
+
173
+ return results.filter(r => {
174
+ // Filter to only actual hook files
175
+ const fileName = path.basename(r.file).toLowerCase();
176
+ return fileName.startsWith('use') || fileName.includes('hook');
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Search for TypeScript interfaces/types
182
+ * @param {string} namePattern - Optional name pattern to filter by
183
+ * @param {object} options - Search options
184
+ */
185
+ function findTypeDefinitions(namePattern = null, options = {}) {
186
+ const { maxResults = 10 } = options;
187
+
188
+ // Search interfaces
189
+ let results = astGrepSearch(AST_PATTERNS.interfaceDefinition, { ...options, maxResults });
190
+
191
+ if (results === null) return null;
192
+
193
+ // Also search type aliases
194
+ const typeResults = astGrepSearch(AST_PATTERNS.typeDefinition, { ...options, maxResults });
195
+ if (typeResults) {
196
+ results = [...results, ...typeResults];
197
+ }
198
+
199
+ // Filter by name pattern if provided
200
+ if (namePattern) {
201
+ const regex = new RegExp(namePattern, 'i');
202
+ results = results.filter(r => regex.test(r.content));
203
+ }
204
+
205
+ return results.slice(0, maxResults);
206
+ }
207
+
208
+ module.exports = {
209
+ AST_PATTERNS,
210
+ ALLOWED_AST_GREP_LANGUAGES,
211
+ isAstGrepAvailable,
212
+ astGrepSearch,
213
+ findReactComponents,
214
+ findCustomHooks,
215
+ findTypeDefinitions,
216
+ };
@@ -29,7 +29,7 @@
29
29
 
30
30
  'use strict';
31
31
 
32
- const { execFileSync } = require('node:child_process');
32
+ const { execFileSync, execSync } = require('node:child_process');
33
33
  const fs = require('node:fs');
34
34
  const path = require('node:path');
35
35
 
@@ -15,13 +15,13 @@ const fs = require('node:fs');
15
15
  const path = require('node:path');
16
16
  const {
17
17
  PATHS,
18
- PROJECT_ROOT,
19
18
  fileExists,
20
19
  getConfig,
21
20
  color,
22
21
  success,
23
22
  info,
24
- warn
23
+ warn,
24
+ getTodayDate
25
25
  } = require('./flow-utils');
26
26
 
27
27
  // ============================================================
@@ -142,15 +142,9 @@ function normalizeIssueToPattern(issue) {
142
142
  * @param {string} str - Input string
143
143
  * @returns {string} Kebab-case string
144
144
  */
145
- function slugify(str) {
146
- return str
147
- .toLowerCase()
148
- .replace(/[^a-z0-9\s-]/g, '')
149
- .replace(/\s+/g, '-')
150
- .replace(/-+/g, '-')
151
- .replace(/^-|-$/g, '')
152
- .slice(0, 50);
153
- }
145
+ // Local slugify removed in favor of canonical flow-output slugify (wf-7072d3ac).
146
+ const { slugify: _slugify } = require('./flow-output');
147
+ const slugify = (str) => _slugify(str, { maxLength: 50 });
154
148
 
155
149
  // ============================================================
156
150
  // Feedback Patterns File Management
@@ -608,6 +602,9 @@ module.exports = {
608
602
  getAutoLearnConfig,
609
603
  loadAutoPatterns,
610
604
  saveAutoPatterns,
605
+ // Promotion API exposed for wf-6a352aae callers (adversary-promotion + pattern-phrase-promotion)
606
+ handlePromotion,
607
+ promoteToDecisions,
611
608
  showStatus
612
609
  };
613
610
 
@@ -42,10 +42,10 @@ let loadSessionState;
42
42
  try {
43
43
  const sessionModule = require('./flow-session-state');
44
44
  loadSessionState = sessionModule.loadSessionState;
45
- } catch (importError) {
45
+ } catch (err) {
46
46
  // Log in debug mode - don't silently hide potential syntax errors
47
47
  if (process.env.DEBUG) {
48
- console.warn(`[DEBUG] Could not load flow-session-state: ${importError.message}`);
48
+ console.warn(`[DEBUG] Could not load flow-session-state: ${err.message}`);
49
49
  }
50
50
  loadSessionState = () => ({});
51
51
  }