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.
@@ -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(/\*/g, '[^/]*')
57
- .replace(/\./g, '\\.');
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.push(fullPath);
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
- return found;
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
- const patternRegex = new RegExp(`\\|\\s*[\\d-]+\\s*\\|\\s*${patternKey.replace(/[-]/g, '[-]?')}\\s*\\|\\s*(\\d+)\\s*\\|`, 'i');
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*${patternKey}\\s*\\|\\s*\\d+\\s*\\|)\\s*-\\s*\\|\\s*Monitor\\s*\\|`, 'i'),
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 };