wogiflow 2.1.3 → 2.3.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 +189 -3
- package/.claude/commands/wogi-onboard.md +30 -8
- package/.claude/commands/wogi-review.md +86 -13
- package/.claude/commands/wogi-start.md +66 -21
- package/.claude/docs/claude-code-compatibility.md +28 -0
- package/.workflow/templates/claude-md.hbs +32 -2
- package/package.json +1 -1
- package/scripts/flow-api-index.js +128 -63
- package/scripts/flow-audit.js +158 -1
- package/scripts/flow-function-index.js +65 -63
- package/scripts/flow-pattern-extractor.js +1 -1
- package/scripts/flow-progress-tracker.js +289 -0
- package/scripts/flow-prompt-capture.js +263 -170
- package/scripts/flow-scanner-base.js +200 -7
- package/scripts/flow-skill-generator.js +1 -0
- package/scripts/flow-standards-learner.js +167 -3
- package/scripts/flow-task-checkpoint.js +2 -0
- package/scripts/flow-template-extractor.js +1 -1
- package/scripts/flow-version-check.js +1 -0
- package/scripts/hooks/core/commit-log-gate.js +146 -0
- package/scripts/hooks/core/post-compact.js +81 -8
- package/scripts/hooks/core/task-completed.js +19 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +60 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +27 -0
- package/scripts/registries/component-registry.js +141 -4
|
@@ -36,6 +36,7 @@ class BaseScanner {
|
|
|
36
36
|
|
|
37
37
|
this.config = {
|
|
38
38
|
directories: registryConfig.directories || options.directories || [],
|
|
39
|
+
globPatterns: registryConfig.globPatterns || options.globPatterns || [],
|
|
39
40
|
filePatterns: options.filePatterns || ['**/*.ts', '**/*.js', '**/*.tsx', '**/*.jsx'],
|
|
40
41
|
excludePatterns: options.excludePatterns || [
|
|
41
42
|
'**/*.test.*',
|
|
@@ -50,11 +51,13 @@ class BaseScanner {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
// Pre-compile exclude patterns to avoid per-file RegExp allocation
|
|
54
|
+
// Use placeholder to prevent ** and * from interfering during replacement
|
|
53
55
|
this._excludeRegexps = this.config.excludePatterns.map(pattern => {
|
|
54
56
|
const regexPattern = pattern
|
|
55
|
-
.replace(/\*\*/g, '
|
|
56
|
-
.replace(
|
|
57
|
-
.replace(
|
|
57
|
+
.replace(/\*\*/g, '\0GLOBSTAR\0') // Placeholder for **
|
|
58
|
+
.replace(/\./g, '\\.') // Escape dots
|
|
59
|
+
.replace(/\*/g, '[^/]*') // Single * → non-slash wildcard
|
|
60
|
+
.replace(/\0GLOBSTAR\0/g, '.*'); // Restore ** → any path
|
|
58
61
|
return new RegExp('^' + regexPattern + '$');
|
|
59
62
|
});
|
|
60
63
|
|
|
@@ -70,18 +73,96 @@ class BaseScanner {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/**
|
|
73
|
-
* Find existing directories from config
|
|
76
|
+
* Find existing directories from config (explicit + glob-discovered)
|
|
74
77
|
* @returns {string[]} Array of full paths to existing directories
|
|
75
78
|
*/
|
|
76
79
|
findDirectories() {
|
|
77
|
-
const found =
|
|
80
|
+
const found = new Set();
|
|
81
|
+
|
|
82
|
+
// 1. Explicit directories from config
|
|
78
83
|
for (const dir of this.config.directories) {
|
|
79
84
|
const fullPath = path.join(PROJECT_ROOT, dir);
|
|
80
85
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
81
|
-
found.
|
|
86
|
+
found.add(fullPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Glob-discovered directories (e.g. "src/**/hooks", "src/**/utils")
|
|
91
|
+
for (const pattern of this.config.globPatterns) {
|
|
92
|
+
for (const dir of this._expandGlobPattern(pattern)) {
|
|
93
|
+
found.add(dir);
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
|
-
|
|
96
|
+
|
|
97
|
+
return [...found];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Expand a glob pattern like "src/** /hooks" into matching directories.
|
|
102
|
+
* Supports ** (any depth) and * (single segment). No external dependencies.
|
|
103
|
+
* @param {string} pattern - Glob pattern relative to project root
|
|
104
|
+
* @returns {string[]} Array of full paths to matching directories
|
|
105
|
+
*/
|
|
106
|
+
_expandGlobPattern(pattern) {
|
|
107
|
+
const results = [];
|
|
108
|
+
const segments = pattern.split('/');
|
|
109
|
+
const MAX_DEPTH = 20;
|
|
110
|
+
const MAX_DIRS = 5000;
|
|
111
|
+
let dirsVisited = 0;
|
|
112
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.cache', '.yarn', '.pnpm']);
|
|
113
|
+
|
|
114
|
+
const walk = (currentPath, segIdx, depth) => {
|
|
115
|
+
if (depth > MAX_DEPTH || dirsVisited > MAX_DIRS) return;
|
|
116
|
+
dirsVisited++;
|
|
117
|
+
|
|
118
|
+
if (segIdx >= segments.length) {
|
|
119
|
+
if (fs.existsSync(currentPath) && fs.statSync(currentPath).isDirectory()) {
|
|
120
|
+
results.push(currentPath);
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const seg = segments[segIdx];
|
|
126
|
+
|
|
127
|
+
if (seg === '**') {
|
|
128
|
+
// Zero levels: skip this segment
|
|
129
|
+
walk(currentPath, segIdx + 1, depth);
|
|
130
|
+
|
|
131
|
+
// One+ levels: recurse into subdirectories
|
|
132
|
+
if (!fs.existsSync(currentPath) || !fs.statSync(currentPath).isDirectory()) return;
|
|
133
|
+
try {
|
|
134
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
137
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
138
|
+
walk(path.join(currentPath, entry.name), segIdx, depth + 1);
|
|
139
|
+
}
|
|
140
|
+
} catch (_err) {
|
|
141
|
+
// Permission error — skip
|
|
142
|
+
}
|
|
143
|
+
} else if (seg.includes('*')) {
|
|
144
|
+
if (!fs.existsSync(currentPath) || !fs.statSync(currentPath).isDirectory()) return;
|
|
145
|
+
// Escape regex metacharacters except *, then convert * to [^/]*
|
|
146
|
+
const escaped = seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
|
|
147
|
+
const segRegex = new RegExp('^' + escaped + '$');
|
|
148
|
+
try {
|
|
149
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (!entry.isDirectory()) continue;
|
|
152
|
+
if (segRegex.test(entry.name)) {
|
|
153
|
+
walk(path.join(currentPath, entry.name), segIdx + 1, depth + 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (_err) {
|
|
157
|
+
// Permission error — skip
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
walk(path.join(currentPath, seg), segIdx + 1, depth + 1);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
walk(PROJECT_ROOT, 0, 0);
|
|
165
|
+
return results;
|
|
85
166
|
}
|
|
86
167
|
|
|
87
168
|
/**
|
|
@@ -268,6 +349,118 @@ class BaseScanner {
|
|
|
268
349
|
});
|
|
269
350
|
}
|
|
270
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Two-pass AST: collect all top-level declarations and all exported names.
|
|
354
|
+
* Returns { declarations: Map<name, {node, kind}>, exported: Map<name, {isDefault}> }
|
|
355
|
+
* Subclasses call this then intersect with their own registration logic.
|
|
356
|
+
* @param {string} content - File content
|
|
357
|
+
* @returns {Object|null} { declarations, exported } or null if parse fails
|
|
358
|
+
*/
|
|
359
|
+
collectExportedDeclarations(content) {
|
|
360
|
+
if (!this.parser) return null;
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const ast = this.parser.parse(content, {
|
|
364
|
+
sourceType: 'module',
|
|
365
|
+
plugins: ['typescript', 'jsx', 'decorators-legacy']
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Pass 1: Collect all top-level function-like declarations
|
|
369
|
+
const declarations = new Map();
|
|
370
|
+
|
|
371
|
+
this.traverse(ast, {
|
|
372
|
+
FunctionDeclaration: (nodePath) => {
|
|
373
|
+
const parent = nodePath.parent.type;
|
|
374
|
+
if (!nodePath.node.id) return;
|
|
375
|
+
if (parent === 'Program' || parent === 'ExportNamedDeclaration' || parent === 'ExportDefaultDeclaration') {
|
|
376
|
+
declarations.set(nodePath.node.id.name, { node: nodePath.node, kind: 'func' });
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
VariableDeclaration: (nodePath) => {
|
|
380
|
+
const parent = nodePath.parent.type;
|
|
381
|
+
if (parent !== 'Program' && parent !== 'ExportNamedDeclaration') return;
|
|
382
|
+
for (const decl of nodePath.node.declarations) {
|
|
383
|
+
if (decl.id?.name && decl.init &&
|
|
384
|
+
(decl.init.type === 'ArrowFunctionExpression' ||
|
|
385
|
+
decl.init.type === 'FunctionExpression')) {
|
|
386
|
+
declarations.set(decl.id.name, { node: decl, kind: 'var' });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// Pass 2: Collect all exported names
|
|
393
|
+
const exported = new Map();
|
|
394
|
+
|
|
395
|
+
this.traverse(ast, {
|
|
396
|
+
ExportNamedDeclaration: (nodePath) => {
|
|
397
|
+
const decl = nodePath.node.declaration;
|
|
398
|
+
if (decl) {
|
|
399
|
+
if (decl.type === 'FunctionDeclaration' && decl.id) {
|
|
400
|
+
exported.set(decl.id.name, { isDefault: false });
|
|
401
|
+
} else if (decl.type === 'VariableDeclaration') {
|
|
402
|
+
for (const d of decl.declarations) {
|
|
403
|
+
if (d.id?.name) exported.set(d.id.name, { isDefault: false });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const spec of nodePath.node.specifiers || []) {
|
|
408
|
+
if (spec.local?.name) {
|
|
409
|
+
exported.set(spec.local.name, { isDefault: false });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
ExportDefaultDeclaration: (nodePath) => {
|
|
414
|
+
const decl = nodePath.node.declaration;
|
|
415
|
+
if (decl.type === 'FunctionDeclaration' && decl.id) {
|
|
416
|
+
exported.set(decl.id.name, { isDefault: true });
|
|
417
|
+
} else if (decl.type === 'Identifier') {
|
|
418
|
+
exported.set(decl.name, { isDefault: true });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return { declarations, exported };
|
|
424
|
+
} catch (_err) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Two-pass regex: collect all exported names from file content.
|
|
431
|
+
* Shared across scanners for consistent export detection.
|
|
432
|
+
* @param {string} content - File content
|
|
433
|
+
* @returns {Set<string>} Set of exported names
|
|
434
|
+
*/
|
|
435
|
+
collectExportedNamesRegex(content) {
|
|
436
|
+
const exported = new Set();
|
|
437
|
+
let match;
|
|
438
|
+
|
|
439
|
+
// export function / export const / export default function
|
|
440
|
+
const exportedDeclRegex = /export\s+(?:default\s+)?(?:async\s+)?(?:function|const)\s+(\w+)/g;
|
|
441
|
+
while ((match = exportedDeclRegex.exec(content)) !== null) {
|
|
442
|
+
exported.add(match[1]);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// export default Name (identifier)
|
|
446
|
+
const exportDefaultIdRegex = /export\s+default\s+([A-Za-z_$]\w*)\s*;/g;
|
|
447
|
+
while ((match = exportDefaultIdRegex.exec(content)) !== null) {
|
|
448
|
+
exported.add(match[1]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// export { Name, Name2 as Alias }
|
|
452
|
+
const exportSpecRegex = /export\s*\{([^}]+)\}/g;
|
|
453
|
+
while ((match = exportSpecRegex.exec(content)) !== null) {
|
|
454
|
+
const specifiers = match[1].split(',');
|
|
455
|
+
for (const spec of specifiers) {
|
|
456
|
+
const name = spec.trim().split(/\s+as\s+/)[0].trim();
|
|
457
|
+
if (name) exported.add(name);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return exported;
|
|
462
|
+
}
|
|
463
|
+
|
|
271
464
|
/**
|
|
272
465
|
* Parse params from string (regex fallback)
|
|
273
466
|
* @param {string} paramsStr - Parameter string
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
const fs = require('node:fs');
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const { ensureDir, getConfig, invalidateConfigCache, writeJson, PATHS, success } = require('./flow-utils');
|
|
11
|
+
const { getTodayDate } = require('./flow-output');
|
|
11
12
|
|
|
12
13
|
// Import helper functions from tech options
|
|
13
14
|
let _techOptions = null;
|
|
@@ -18,8 +18,10 @@ const {
|
|
|
18
18
|
readFile,
|
|
19
19
|
writeFile,
|
|
20
20
|
getConfig,
|
|
21
|
-
color
|
|
21
|
+
color,
|
|
22
|
+
escapeRegex
|
|
22
23
|
} = require('./flow-utils');
|
|
24
|
+
const { getTodayDate } = require('./flow-output');
|
|
23
25
|
|
|
24
26
|
// ============================================================================
|
|
25
27
|
// Constants
|
|
@@ -101,6 +103,158 @@ const VIOLATION_LEARNING_MAP = {
|
|
|
101
103
|
}
|
|
102
104
|
};
|
|
103
105
|
|
|
106
|
+
// ============================================================================
|
|
107
|
+
// Enforcement Gap Detection
|
|
108
|
+
// ============================================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a pattern has an existing rule in decisions.md
|
|
112
|
+
* @param {string} patternId - kebab-case pattern identifier
|
|
113
|
+
* @param {string} [description] - description to also search for
|
|
114
|
+
* @returns {{ exists: boolean, ruleText: string|null, section: string|null }}
|
|
115
|
+
*/
|
|
116
|
+
function checkEnforcementGap(patternId, description) {
|
|
117
|
+
if (!fileExists(DECISIONS_PATH)) {
|
|
118
|
+
return { exists: false, ruleText: null, section: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const content = readFile(DECISIONS_PATH, '');
|
|
122
|
+
|
|
123
|
+
// Extract description words for fuzzy matching (separate from patternId)
|
|
124
|
+
const STOPWORDS = new Set(['code', 'file', 'data', 'type', 'that', 'this', 'with', 'from', 'have', 'been', 'each', 'when', 'should', 'must', 'function', 'class', 'module']);
|
|
125
|
+
const descriptionWords = description
|
|
126
|
+
? description.split(/\s+/)
|
|
127
|
+
.filter(w => w.length >= 4 && !STOPWORDS.has(w.toLowerCase()))
|
|
128
|
+
.map(w => w.toLowerCase())
|
|
129
|
+
.slice(0, 5)
|
|
130
|
+
: [];
|
|
131
|
+
|
|
132
|
+
// Find which section contains the match
|
|
133
|
+
const sections = content.split(/^## /m).slice(1);
|
|
134
|
+
for (const section of sections) {
|
|
135
|
+
const sectionTitle = section.split('\n')[0].trim();
|
|
136
|
+
const sectionLower = section.toLowerCase();
|
|
137
|
+
|
|
138
|
+
// Check if pattern ID appears in this section (exact match — most reliable)
|
|
139
|
+
if (sectionLower.includes(patternId.toLowerCase())) {
|
|
140
|
+
// Extract the rule block (from ### to next ### or end of section)
|
|
141
|
+
// escapeRegex prevents ReDoS from patternId with regex metacharacters
|
|
142
|
+
const escapedId = escapeRegex(patternId).replace(/-/g, '[\\s-]');
|
|
143
|
+
const ruleMatch = section.match(new RegExp(
|
|
144
|
+
`### [^\\n]*${escapedId}[^\\n]*\\n([\\s\\S]*?)(?=###|$)`,
|
|
145
|
+
'i'
|
|
146
|
+
));
|
|
147
|
+
return {
|
|
148
|
+
exists: true,
|
|
149
|
+
ruleText: ruleMatch ? ruleMatch[0].trim().slice(0, 2000) : section.trim().slice(0, 2000),
|
|
150
|
+
section: `## ${sectionTitle}`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fuzzy fallback: check description words ONLY (patternId excluded to avoid weak matches)
|
|
155
|
+
// Proportional threshold: at least 50% of words must match, minimum 3
|
|
156
|
+
if (descriptionWords.length >= 3) {
|
|
157
|
+
const matchCount = descriptionWords.filter(word => sectionLower.includes(word)).length;
|
|
158
|
+
const threshold = Math.max(3, Math.ceil(descriptionWords.length * 0.5));
|
|
159
|
+
if (matchCount >= threshold) {
|
|
160
|
+
return {
|
|
161
|
+
exists: true,
|
|
162
|
+
ruleText: section.trim().slice(0, 2000),
|
|
163
|
+
section: `## ${sectionTitle}`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { exists: false, ruleText: null, section: null };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Record a pattern from an audit finding (wraps recordViolationPattern with audit-specific logic).
|
|
174
|
+
* Creates a synthetic learning object from audit cluster data.
|
|
175
|
+
*
|
|
176
|
+
* @param {Object} cluster - Clustered audit pattern
|
|
177
|
+
* @param {string} cluster.patternId - kebab-case canonical name
|
|
178
|
+
* @param {string} cluster.category - architecture, code-style, security, etc.
|
|
179
|
+
* @param {string} cluster.description - one-sentence description
|
|
180
|
+
* @param {number} cluster.instanceCount - number of files affected
|
|
181
|
+
* @param {Object[]} [cluster.instances] - array of { file, detail }
|
|
182
|
+
* @returns {Object} Result with status, count, shouldPromote
|
|
183
|
+
*/
|
|
184
|
+
function recordAuditPattern(cluster) {
|
|
185
|
+
const safeDesc = sanitizeForMarkdown(cluster.description, 100);
|
|
186
|
+
// Build a synthetic learning object compatible with recordViolationPattern
|
|
187
|
+
const learning = {
|
|
188
|
+
canLearn: true,
|
|
189
|
+
violationType: cluster.category,
|
|
190
|
+
category: mapAuditCategoryToLearnerCategory(cluster.category),
|
|
191
|
+
subcategory: cluster.patternId,
|
|
192
|
+
patternName: safeDesc,
|
|
193
|
+
preventionPrompt: `Avoid: ${safeDesc}`,
|
|
194
|
+
ruleTemplate: buildAuditRuleTemplate(cluster),
|
|
195
|
+
message: `${cluster.instanceCount} instances found across project (audit source)`,
|
|
196
|
+
source: 'audit'
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return recordViolationPattern(learning);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Map audit category names to learner category names
|
|
204
|
+
*/
|
|
205
|
+
function mapAuditCategoryToLearnerCategory(auditCategory) {
|
|
206
|
+
const map = {
|
|
207
|
+
'architecture': 'architecture',
|
|
208
|
+
'code-style': 'code-style',
|
|
209
|
+
'security': 'security',
|
|
210
|
+
'performance': 'code-style',
|
|
211
|
+
'consistency': 'code-style',
|
|
212
|
+
'dependencies': 'architecture',
|
|
213
|
+
'tech-debt': 'architecture',
|
|
214
|
+
'modernization': 'code-style'
|
|
215
|
+
};
|
|
216
|
+
return map[auditCategory] || 'code-style';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Sanitize a value for safe interpolation into markdown state files.
|
|
221
|
+
* Strips heading markers, pipe chars (break tables), and newlines.
|
|
222
|
+
* Prevents prompt injection via decisions.md or feedback-patterns.md.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} value - Raw string
|
|
225
|
+
* @param {number} [maxLen=200] - Maximum length
|
|
226
|
+
* @returns {string} Sanitized string
|
|
227
|
+
*/
|
|
228
|
+
function sanitizeForMarkdown(value, maxLen = 200) {
|
|
229
|
+
return String(value)
|
|
230
|
+
.replace(/^#+\s/gm, '') // strip heading markers
|
|
231
|
+
.replace(/\|/g, '-') // replace pipes (break table formatting)
|
|
232
|
+
.replace(/`{3,}/g, '') // strip code fence markers (prevent breakout)
|
|
233
|
+
.replace(/[<>]/g, '') // strip angle brackets (prevent HTML injection)
|
|
234
|
+
.replace(/[\r\n]+/g, ' ') // collapse newlines
|
|
235
|
+
.slice(0, maxLen)
|
|
236
|
+
.trim();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build a rule template from an audit cluster
|
|
241
|
+
*/
|
|
242
|
+
function buildAuditRuleTemplate(cluster) {
|
|
243
|
+
const safeDesc = sanitizeForMarkdown(cluster.description, 200);
|
|
244
|
+
const files = (cluster.instances || [])
|
|
245
|
+
.slice(0, 5)
|
|
246
|
+
.map(i => `\`${sanitizeForMarkdown(i.file, 100)}\``)
|
|
247
|
+
.join(', ');
|
|
248
|
+
|
|
249
|
+
const moreCount = (cluster.instanceCount || 0) - 5;
|
|
250
|
+
const filesList = moreCount > 0 ? `${files}, and ${moreCount} more` : files;
|
|
251
|
+
|
|
252
|
+
return `### ${safeDesc}
|
|
253
|
+
**Source**: Audit pattern promotion (${cluster.instanceCount} instances)
|
|
254
|
+
**Files**: ${filesList || 'Multiple files'}
|
|
255
|
+
**Rule**: ${safeDesc}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
104
258
|
// ============================================================================
|
|
105
259
|
// Pattern Analysis
|
|
106
260
|
// ============================================================================
|
|
@@ -223,7 +377,10 @@ function recordViolationPattern(learning) {
|
|
|
223
377
|
|
|
224
378
|
// Check if pattern already exists in table
|
|
225
379
|
const dateStr = getTodayDate();
|
|
226
|
-
|
|
380
|
+
// escapeRegex prevents ReDoS from patternKey with regex metacharacters
|
|
381
|
+
// Hyphens are required (not optional) — patternKey is kebab-case and must match exactly
|
|
382
|
+
const escapedKey = escapeRegex(patternKey);
|
|
383
|
+
const patternRegex = new RegExp(`\\|\\s*[\\d-]+\\s*\\|\\s*${escapedKey}\\s*\\|\\s*(\\d+)\\s*\\|`, 'i');
|
|
227
384
|
|
|
228
385
|
if (patternRegex.test(content)) {
|
|
229
386
|
// Update existing count
|
|
@@ -362,8 +519,10 @@ ${learning.ruleTemplate}
|
|
|
362
519
|
try {
|
|
363
520
|
let patternsContent = readFile(FEEDBACK_PATTERNS_PATH, '');
|
|
364
521
|
const patternKey = `${learning.violationType}-${learning.patternName}`.replace(/\s+/g, '-').toLowerCase();
|
|
522
|
+
// escapeRegex prevents ReDoS from patternKey with regex metacharacters
|
|
523
|
+
const escapedPromoteKey = escapeRegex(patternKey);
|
|
365
524
|
patternsContent = patternsContent.replace(
|
|
366
|
-
new RegExp(`(\\|\\s*[\\d-]+\\s*\\|\\s*${
|
|
525
|
+
new RegExp(`(\\|\\s*[\\d-]+\\s*\\|\\s*${escapedPromoteKey}\\s*\\|\\s*\\d+\\s*\\|)\\s*-\\s*\\|\\s*Monitor\\s*\\|`, 'i'),
|
|
367
526
|
`$1 decisions.md | **PROMOTED** |`
|
|
368
527
|
);
|
|
369
528
|
fs.writeFileSync(FEEDBACK_PATTERNS_PATH, patternsContent, 'utf-8');
|
|
@@ -680,8 +839,13 @@ Examples:
|
|
|
680
839
|
module.exports = {
|
|
681
840
|
analyzeViolationForLearning,
|
|
682
841
|
recordViolationPattern,
|
|
842
|
+
recordAuditPattern,
|
|
683
843
|
promoteToDecisions,
|
|
684
844
|
syncToRulesDir,
|
|
845
|
+
checkEnforcementGap,
|
|
846
|
+
mapAuditCategoryToLearnerCategory,
|
|
847
|
+
buildAuditRuleTemplate,
|
|
848
|
+
sanitizeForMarkdown,
|
|
685
849
|
getPreventionPrompts,
|
|
686
850
|
formatPreventionPrompts,
|
|
687
851
|
learnFromViolations,
|
|
@@ -72,6 +72,7 @@ function getDefaultCheckpoint() {
|
|
|
72
72
|
currentIndex: -1
|
|
73
73
|
},
|
|
74
74
|
changedFiles: [],
|
|
75
|
+
criteria: [],
|
|
75
76
|
verificationResults: [],
|
|
76
77
|
explorationSummary: null,
|
|
77
78
|
completedPhases: [],
|
|
@@ -129,6 +130,7 @@ async function saveCheckpoint(params) {
|
|
|
129
130
|
specPath: params.specPath || existing.specPath,
|
|
130
131
|
scenarios: params.scenarios || existing.scenarios,
|
|
131
132
|
changedFiles: params.changedFiles || existing.changedFiles,
|
|
133
|
+
criteria: params.criteria || existing.criteria,
|
|
132
134
|
verificationResults: params.verificationResults || existing.verificationResults,
|
|
133
135
|
explorationSummary: params.explorationSummary || existing.explorationSummary,
|
|
134
136
|
lastUpdated: new Date().toISOString(),
|
|
@@ -77,7 +77,7 @@ const IGNORE_PATTERNS = [
|
|
|
77
77
|
];
|
|
78
78
|
|
|
79
79
|
// Colors for CLI
|
|
80
|
-
const { colors: c } = require('./flow-output');
|
|
80
|
+
const { colors: c, getTodayDate } = require('./flow-output');
|
|
81
81
|
|
|
82
82
|
// ============================================================================
|
|
83
83
|
// File Classification
|
|
@@ -124,6 +124,7 @@ function checkClaudeCodeVersionOnce() {
|
|
|
124
124
|
{ version: [2, 1, 50], features: 'worktree hooks, agent isolation' },
|
|
125
125
|
{ version: [2, 1, 72], features: 'ConfigChange/InstructionsLoaded hooks, effort levels' },
|
|
126
126
|
{ version: [2, 1, 76], features: 'PostCompact hook (state recovery after compaction)' },
|
|
127
|
+
{ version: [2, 1, 77], features: 'Elicitation hooks, worktree sparse checkout, 128k output tokens, compaction circuit breaker' },
|
|
127
128
|
];
|
|
128
129
|
|
|
129
130
|
const disabledFeatures = SOFT_GATES
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Commit Log Gate (Core Module)
|
|
5
|
+
*
|
|
6
|
+
* Blocks git commit when there's an active task but request-log.md
|
|
7
|
+
* hasn't been staged. Mechanical enforcement — same pattern as routing gate.
|
|
8
|
+
*
|
|
9
|
+
* Whitelist (commit allowed without log entry):
|
|
10
|
+
* - No active task in ready.json
|
|
11
|
+
* - Merge commits
|
|
12
|
+
* - State-only commits (all files under .workflow/ or .claude/)
|
|
13
|
+
* - Gate disabled via config.enforcement.commitLogGate.enabled: false
|
|
14
|
+
*
|
|
15
|
+
* v1.0: Initial implementation — pre-commit blocking gate
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
const { getConfig, getReadyData } = require('../../flow-utils');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a Bash command contains a git commit
|
|
23
|
+
* @param {string} command - The Bash command string
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isGitCommit(command) {
|
|
27
|
+
if (!command || typeof command !== 'string') return false;
|
|
28
|
+
// Match git commit at start or after chain operators (&&, ;, ||)
|
|
29
|
+
return /(?:^|&&\s*|;\s*|\|\|\s*)git\s+commit\b/.test(command.trim());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if the command is a merge-related commit (whitelisted)
|
|
34
|
+
* @param {string} command - The Bash command string
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function isMergeCommit(command) {
|
|
38
|
+
// git merge --continue or similar
|
|
39
|
+
if (/git\s+merge/.test(command)) return true;
|
|
40
|
+
// Commit message starts with "Merge" (from -m flag)
|
|
41
|
+
const msgMatch = command.match(/-m\s+["']([^"']*)/);
|
|
42
|
+
if (msgMatch && /^Merge\b/i.test(msgMatch[1])) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get list of staged file paths (relative to repo root)
|
|
48
|
+
* @returns {string[]}
|
|
49
|
+
*/
|
|
50
|
+
function getStagedFiles() {
|
|
51
|
+
try {
|
|
52
|
+
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
timeout: 3000,
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
56
|
+
});
|
|
57
|
+
return output.trim().split('\n').filter(Boolean);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (process.env.DEBUG) {
|
|
60
|
+
console.error(`[commit-log-gate] getStagedFiles error: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a git commit should be blocked due to missing request-log entry.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} command - The Bash command being executed
|
|
70
|
+
* @param {Object} [config] - Pre-loaded config (optional)
|
|
71
|
+
* @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string }}
|
|
72
|
+
*/
|
|
73
|
+
function checkCommitLogGate(command, config) {
|
|
74
|
+
// Only check git commit commands
|
|
75
|
+
if (!isGitCommit(command)) {
|
|
76
|
+
return { allowed: true, blocked: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Load config if not provided
|
|
80
|
+
if (!config) {
|
|
81
|
+
try { config = getConfig(); } catch (err) { config = {}; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if gate is enabled (default: enabled when enforcement section exists)
|
|
85
|
+
if (config.enforcement?.commitLogGate?.enabled === false) {
|
|
86
|
+
return { allowed: true, blocked: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check for active task in ready.json
|
|
90
|
+
let readyData;
|
|
91
|
+
try {
|
|
92
|
+
readyData = getReadyData();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Can't read ready.json → fail-open (don't block work)
|
|
95
|
+
return { allowed: true, blocked: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!readyData.inProgress || readyData.inProgress.length === 0) {
|
|
99
|
+
// No active task → allow (non-task commit)
|
|
100
|
+
return { allowed: true, blocked: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Whitelist merge commits
|
|
104
|
+
if (isMergeCommit(command)) {
|
|
105
|
+
return { allowed: true, blocked: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Get staged files to check
|
|
109
|
+
const stagedFiles = getStagedFiles();
|
|
110
|
+
if (stagedFiles.length === 0) {
|
|
111
|
+
// No staged files → commit will fail on its own, don't add noise
|
|
112
|
+
return { allowed: true, blocked: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whitelist state-only commits (e.g., pre-compact, wogi-pre-compact state saves)
|
|
116
|
+
const allStateOrConfig = stagedFiles.every(f =>
|
|
117
|
+
f.startsWith('.workflow/') || f.startsWith('.claude/')
|
|
118
|
+
);
|
|
119
|
+
if (allStateOrConfig) {
|
|
120
|
+
return { allowed: true, blocked: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check if request-log.md is in staged changes
|
|
124
|
+
const hasLogEntry = stagedFiles.some(f => f.endsWith('request-log.md'));
|
|
125
|
+
if (hasLogEntry) {
|
|
126
|
+
return { allowed: true, blocked: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Block: active task + code changes but no log entry
|
|
130
|
+
const task = readyData.inProgress[0];
|
|
131
|
+
const taskId = (typeof task === 'string' ? task : task.id) || 'unknown';
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
allowed: false,
|
|
135
|
+
blocked: true,
|
|
136
|
+
reason: 'commit_without_log_entry',
|
|
137
|
+
message: [
|
|
138
|
+
`BLOCKED: Active task ${taskId} but request-log.md is not staged.`,
|
|
139
|
+
'Add a request-log entry before committing.',
|
|
140
|
+
'Append a ### R-[N] entry to .workflow/state/request-log.md following the existing format,',
|
|
141
|
+
'then stage it: git add .workflow/state/request-log.md'
|
|
142
|
+
].join(' ')
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { checkCommitLogGate, isGitCommit, isMergeCommit };
|