wogiflow 2.10.0 → 2.11.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/.workflow/state/bugfix-scope.json.template +7 -0
- package/.workflow/state/deploy-history.json.template +3 -0
- package/.workflow/state/deploy-routes.json.template +4 -0
- package/.workflow/state/strike-tracker.json.template +3 -0
- package/package.json +1 -1
- package/scripts/flow-config-defaults.js +56 -0
- package/scripts/flow-deploy-gate.js +335 -0
- package/scripts/flow-deploy-history.js +270 -0
- package/scripts/flow-hook-status.js +7 -1
- package/scripts/hooks/core/bugfix-scope-gate.js +427 -0
- package/scripts/hooks/core/deploy-gate.js +655 -0
- package/scripts/hooks/core/git-safety-gate.js +358 -0
- package/scripts/hooks/core/index.js +62 -0
- package/scripts/hooks/core/scope-mutation-gate.js +257 -0
- package/scripts/hooks/core/strike-gate.js +380 -0
- package/scripts/hooks/core/task-completed.js +41 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +33 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +137 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Deploy History & Revert-First Protocol
|
|
5
|
+
*
|
|
6
|
+
* Tracks deploy history and implements the revert-first protocol
|
|
7
|
+
* for production crashes. Part of Mechanical Enforcement Gates v3.0.
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* flow deploy-history add <commit> [env] — Record a deploy
|
|
11
|
+
* flow deploy-history show — Show deploy history
|
|
12
|
+
* flow deploy-history last-good — Show last known-good deploy
|
|
13
|
+
* flow deploy-history detect <text> — Detect production crash keywords
|
|
14
|
+
*
|
|
15
|
+
* The revert-first protocol is a WORKFLOW MODIFICATION, not a blocking hook.
|
|
16
|
+
* When a production crash is detected:
|
|
17
|
+
* 1. Present the revert option with last-good commit
|
|
18
|
+
* 2. If forward-fix chosen, reduce strike threshold
|
|
19
|
+
* 3. Track the decision in state
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { getConfig, PATHS, safeJsonParse, writeJson } = require('./flow-utils');
|
|
26
|
+
const { recordDeploy, getLastGoodDeploy, DEPLOY_HISTORY_PATH } = require('./hooks/core/deploy-gate');
|
|
27
|
+
|
|
28
|
+
// ============================================================
|
|
29
|
+
// Production Crash Detection
|
|
30
|
+
// ============================================================
|
|
31
|
+
|
|
32
|
+
/** Default keywords that suggest a production crash */
|
|
33
|
+
const DEFAULT_CRASH_KEYWORDS = [
|
|
34
|
+
'production', 'crash', 'down', 'outage',
|
|
35
|
+
'500 errors', "users can't", 'site is broken',
|
|
36
|
+
'live issue', 'prod is broken', 'prod down',
|
|
37
|
+
'users are seeing', 'in production', 'affecting users',
|
|
38
|
+
'critical bug', 'service down', 'api down',
|
|
39
|
+
'white screen in prod', 'deployment broke'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if revert-first protocol is enabled
|
|
44
|
+
* @param {Object} [config]
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isRevertFirstEnabled(config) {
|
|
48
|
+
if (!config) config = getConfig();
|
|
49
|
+
return config.enforcement?.revertFirst?.enabled === true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get revert-first configuration
|
|
54
|
+
* @param {Object} [config]
|
|
55
|
+
* @returns {Object}
|
|
56
|
+
*/
|
|
57
|
+
function getRevertFirstConfig(config) {
|
|
58
|
+
if (!config) config = getConfig();
|
|
59
|
+
const gate = config.enforcement?.revertFirst ?? {};
|
|
60
|
+
return {
|
|
61
|
+
enabled: gate.enabled === true,
|
|
62
|
+
keywords: gate.keywords ?? DEFAULT_CRASH_KEYWORDS,
|
|
63
|
+
deployHistoryRetention: gate.deployHistoryRetention ?? 50,
|
|
64
|
+
oldDeployWarningDays: gate.oldDeployWarningDays ?? 7
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Detect if a bug description suggests a production crash.
|
|
70
|
+
* @param {string} description - Bug report text
|
|
71
|
+
* @param {Object} [config]
|
|
72
|
+
* @returns {{ isProductionCrash: boolean, matchedKeywords: string[], confidence: 'high'|'medium'|'low' }}
|
|
73
|
+
*/
|
|
74
|
+
function detectProductionCrash(description, config) {
|
|
75
|
+
const revertConfig = getRevertFirstConfig(config);
|
|
76
|
+
if (!description) {
|
|
77
|
+
return { isProductionCrash: false, matchedKeywords: [], confidence: 'low' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lower = description.toLowerCase();
|
|
81
|
+
const matched = revertConfig.keywords.filter(kw => lower.includes(kw.toLowerCase()));
|
|
82
|
+
|
|
83
|
+
let confidence = 'low';
|
|
84
|
+
if (matched.length >= 3) confidence = 'high';
|
|
85
|
+
else if (matched.length >= 1) confidence = 'medium';
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
isProductionCrash: matched.length >= 1,
|
|
89
|
+
matchedKeywords: matched,
|
|
90
|
+
confidence
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Generate the revert-first recommendation message.
|
|
96
|
+
* Called by /wogi-bug when production crash is confirmed.
|
|
97
|
+
* @param {Object} [options]
|
|
98
|
+
* @param {boolean} [options.hasDeployHistory] - Whether deploy history exists
|
|
99
|
+
* @returns {string} Formatted recommendation message
|
|
100
|
+
*/
|
|
101
|
+
function generateRevertRecommendation(options) {
|
|
102
|
+
const lastDeploy = getLastGoodDeploy();
|
|
103
|
+
const revertConfig = getRevertFirstConfig();
|
|
104
|
+
|
|
105
|
+
if (!lastDeploy.found) {
|
|
106
|
+
return `━━━ REVERT-FIRST PROTOCOL ━━━
|
|
107
|
+
|
|
108
|
+
Production crash detected. No deploy history is tracked.
|
|
109
|
+
|
|
110
|
+
If you know the last good commit, provide it and I'll create a revert.
|
|
111
|
+
Otherwise, check your deployment platform for the last successful deploy hash.
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
[1] Provide a commit hash to revert to
|
|
115
|
+
[2] Forward-fix (reduced strike threshold — escalation after ${revertConfig.deployHistoryRetention} failures)
|
|
116
|
+
|
|
117
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const deploy = lastDeploy.deploy;
|
|
121
|
+
const deployAge = Math.floor((Date.now() - new Date(deploy.timestamp).getTime()) / (1000 * 60 * 60 * 24));
|
|
122
|
+
const isOld = deployAge > revertConfig.oldDeployWarningDays;
|
|
123
|
+
|
|
124
|
+
let warnings = '';
|
|
125
|
+
if (isOld) {
|
|
126
|
+
warnings += `\n⚠️ Last deploy was ${deployAge} days ago. Reverting may remove recent features.`;
|
|
127
|
+
}
|
|
128
|
+
warnings += '\n⚠️ If the issue involves database migrations or data changes, revert may not help.';
|
|
129
|
+
|
|
130
|
+
return `━━━ REVERT-FIRST PROTOCOL ━━━
|
|
131
|
+
|
|
132
|
+
Production crash detected.
|
|
133
|
+
|
|
134
|
+
Last successful deploy:
|
|
135
|
+
Commit: ${deploy.commitHash}
|
|
136
|
+
Date: ${deploy.timestamp}
|
|
137
|
+
Env: ${deploy.environment}
|
|
138
|
+
|
|
139
|
+
RECOMMENDED: Revert to ${deploy.commitHash.slice(0, 8)} to restore service immediately,
|
|
140
|
+
then forward-fix on a branch.
|
|
141
|
+
${warnings}
|
|
142
|
+
|
|
143
|
+
Options:
|
|
144
|
+
[1] Revert — \`git revert ${deploy.commitHash.slice(0, 8)}..HEAD\` (restores service now)
|
|
145
|
+
[2] Forward-fix (strike threshold reduced — escalation after 2 failures)
|
|
146
|
+
|
|
147
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Record the production crash decision (revert or forward-fix).
|
|
152
|
+
* @param {string} taskId
|
|
153
|
+
* @param {'revert'|'forward-fix'} decision
|
|
154
|
+
* @param {string} [commitHash] - Revert target commit (if reverting)
|
|
155
|
+
*/
|
|
156
|
+
function recordCrashDecision(taskId, decision, commitHash) {
|
|
157
|
+
const statePath = path.join(PATHS.state, 'crash-decisions.json');
|
|
158
|
+
const decisions = safeJsonParse(statePath, { decisions: [] });
|
|
159
|
+
|
|
160
|
+
decisions.decisions.unshift({
|
|
161
|
+
taskId,
|
|
162
|
+
decision,
|
|
163
|
+
commitHash: commitHash ?? null,
|
|
164
|
+
timestamp: new Date().toISOString()
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Keep last 20
|
|
168
|
+
if (decisions.decisions.length > 20) {
|
|
169
|
+
decisions.decisions = decisions.decisions.slice(0, 20);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
writeJson(statePath, decisions);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================
|
|
176
|
+
// CLI Commands
|
|
177
|
+
// ============================================================
|
|
178
|
+
|
|
179
|
+
function cmdAdd(commitHash, environment) {
|
|
180
|
+
if (!commitHash) {
|
|
181
|
+
console.error('Usage: flow deploy-history add <commit-hash> [environment]');
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
recordDeploy({
|
|
185
|
+
commitHash,
|
|
186
|
+
environment: environment || 'production'
|
|
187
|
+
});
|
|
188
|
+
console.log(`✓ Recorded deploy: ${commitHash.slice(0, 8)} (${environment || 'production'})`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function cmdShow() {
|
|
192
|
+
const history = safeJsonParse(DEPLOY_HISTORY_PATH, { deploys: [] });
|
|
193
|
+
console.log('━━━ Deploy History ━━━\n');
|
|
194
|
+
if (history.deploys.length === 0) {
|
|
195
|
+
console.log('No deploy history recorded.');
|
|
196
|
+
console.log('Record deploys with: flow deploy-history add <commit-hash> [environment]');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
for (const d of history.deploys.slice(0, 15)) {
|
|
200
|
+
const age = Math.floor((Date.now() - new Date(d.timestamp).getTime()) / (1000 * 60 * 60));
|
|
201
|
+
const ageStr = age < 24 ? `${age}h ago` : `${Math.floor(age / 24)}d ago`;
|
|
202
|
+
console.log(` ${d.commitHash.slice(0, 8)} | ${d.environment.padEnd(12)} | ${d.timestamp} (${ageStr})`);
|
|
203
|
+
}
|
|
204
|
+
console.log(`\nTotal: ${history.deploys.length} deploys`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cmdLastGood() {
|
|
208
|
+
const last = getLastGoodDeploy();
|
|
209
|
+
if (!last.found) {
|
|
210
|
+
console.log('No deploy history. Record with: flow deploy-history add <hash>');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
console.log(`Last known-good deploy:`);
|
|
214
|
+
console.log(` Commit: ${last.deploy.commitHash}`);
|
|
215
|
+
console.log(` Date: ${last.deploy.timestamp}`);
|
|
216
|
+
console.log(` Env: ${last.deploy.environment}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cmdDetect(text) {
|
|
220
|
+
if (!text) {
|
|
221
|
+
console.error('Usage: flow deploy-history detect "<bug description>"');
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
const result = detectProductionCrash(text);
|
|
225
|
+
console.log(JSON.stringify(result, null, 2));
|
|
226
|
+
if (result.isProductionCrash) {
|
|
227
|
+
console.log('\n' + generateRevertRecommendation());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================
|
|
232
|
+
// CLI Entrypoint
|
|
233
|
+
// ============================================================
|
|
234
|
+
|
|
235
|
+
// CLI entrypoint (only when run directly)
|
|
236
|
+
if (require.main === module) {
|
|
237
|
+
const args = process.argv.slice(2);
|
|
238
|
+
const command = args[0];
|
|
239
|
+
|
|
240
|
+
switch (command) {
|
|
241
|
+
case 'add':
|
|
242
|
+
cmdAdd(args[1], args[2]);
|
|
243
|
+
break;
|
|
244
|
+
case 'show':
|
|
245
|
+
cmdShow();
|
|
246
|
+
break;
|
|
247
|
+
case 'last-good':
|
|
248
|
+
cmdLastGood();
|
|
249
|
+
break;
|
|
250
|
+
case 'detect':
|
|
251
|
+
cmdDetect(args.slice(1).join(' '));
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
console.log('Usage: flow deploy-history <add|show|last-good|detect>');
|
|
255
|
+
if (!command) process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================================================
|
|
260
|
+
// Exports (for programmatic use)
|
|
261
|
+
// ============================================================
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
isRevertFirstEnabled,
|
|
265
|
+
getRevertFirstConfig,
|
|
266
|
+
detectProductionCrash,
|
|
267
|
+
generateRevertRecommendation,
|
|
268
|
+
recordCrashDecision,
|
|
269
|
+
recordDeploy
|
|
270
|
+
};
|
|
@@ -99,7 +99,13 @@ function buildEnforcementFromConfig(config) {
|
|
|
99
99
|
routingGate: config.enforcement?.routingGate?.enabled === true,
|
|
100
100
|
commitLogGate: config.enforcement?.commitLogGate?.enabled === true,
|
|
101
101
|
todoWriteGate: config.enforcement?.todoWriteGate?.enabled === true,
|
|
102
|
-
loopEnforcement: config.enforcement?.loopEnforcement?.enabled === true
|
|
102
|
+
loopEnforcement: config.enforcement?.loopEnforcement?.enabled === true,
|
|
103
|
+
// F5: Include v3.0 enforcement gates in hook status
|
|
104
|
+
deployGate: config.enforcement?.deployGate?.enabled === true,
|
|
105
|
+
strikeEscalation: config.enforcement?.strikeEscalation?.enabled !== false,
|
|
106
|
+
bugfixScope: config.enforcement?.bugfixScope?.enabled !== false,
|
|
107
|
+
scopeMutation: config.enforcement?.scopeMutation?.enabled !== false,
|
|
108
|
+
gitSafety: config.enforcement?.gitSafety?.enabled !== false
|
|
103
109
|
},
|
|
104
110
|
componentReuse: config.componentReuse?.enabled === true,
|
|
105
111
|
// Correct path: hooks.rules.phaseGate.enabled (matches phase-gate.js:84)
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wogi Flow - Bugfix Scope Gate (Core Module)
|
|
5
|
+
*
|
|
6
|
+
* Two-phase gate preventing systemic issues from being treated as quick fixes.
|
|
7
|
+
* Part of the Mechanical Enforcement Gates v3.0 initiative.
|
|
8
|
+
*
|
|
9
|
+
* Phase 1: Pre-classification — checks feedback-patterns.md for similar bugs
|
|
10
|
+
* at task creation. If 2+ keywords overlap → auto-classify as L2.
|
|
11
|
+
*
|
|
12
|
+
* Phase 2: Runtime monitoring — PostToolUse tracks unique file edits during
|
|
13
|
+
* L3 bugfix tasks. After threshold (default 3) unique non-test files:
|
|
14
|
+
* - WARN mode: injects message but doesn't block
|
|
15
|
+
* - BLOCK mode: blocks Edit/Write until scope inventory provided
|
|
16
|
+
*
|
|
17
|
+
* The scope inventory also satisfies the Strike Gate's hypothesis requirement.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { getConfig, PATHS, safeJsonParse, writeJson } = require('../../flow-utils');
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// Constants
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
/** File patterns excluded from the scope counter */
|
|
31
|
+
const DEFAULT_EXCLUDE_PATTERNS = [
|
|
32
|
+
/\.test\./,
|
|
33
|
+
/\.spec\./,
|
|
34
|
+
/\.d\.ts$/,
|
|
35
|
+
/__tests__\//,
|
|
36
|
+
/__mocks__\//
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Keywords for pre-classification matching */
|
|
40
|
+
const ERROR_KEYWORDS = [
|
|
41
|
+
'null', 'undefined', 'crash', 'timeout', '404', '500',
|
|
42
|
+
'TypeError', 'ReferenceError', 'cannot read', 'is not a function',
|
|
43
|
+
'is not defined', 'NaN', 'ENOENT', 'ECONNREFUSED', 'CORS',
|
|
44
|
+
'white screen', 'blank page', 'infinite loop', 'memory leak',
|
|
45
|
+
'stack overflow', 'deadlock', 'race condition'
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// ============================================================
|
|
49
|
+
// Configuration
|
|
50
|
+
// ============================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if bugfix scope gate is enabled
|
|
54
|
+
* @param {Object} [config]
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
function isBugfixScopeEnabled(config) {
|
|
58
|
+
if (!config) config = getConfig();
|
|
59
|
+
return config.enforcement?.bugfixScope?.enabled !== false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get bugfix scope configuration with defaults
|
|
64
|
+
* @param {Object} [config]
|
|
65
|
+
* @returns {Object}
|
|
66
|
+
*/
|
|
67
|
+
function getBugfixScopeConfig(config) {
|
|
68
|
+
if (!config) config = getConfig();
|
|
69
|
+
const gate = config.enforcement?.bugfixScope ?? {};
|
|
70
|
+
return {
|
|
71
|
+
enabled: gate.enabled !== false,
|
|
72
|
+
mode: gate.mode ?? 'warn',
|
|
73
|
+
fileThreshold: gate.fileThreshold ?? 3,
|
|
74
|
+
excludePatterns: gate.excludePatterns ?? ['*.test.*', '*.spec.*', '*.d.ts', '__tests__/**', '__mocks__/**'],
|
|
75
|
+
keywordMatchThreshold: gate.keywordMatchThreshold ?? 2,
|
|
76
|
+
fanOutThreshold: gate.fanOutThreshold ?? 10
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================
|
|
81
|
+
// Fan-Out Escalation (agnostic — counts importers, not file names)
|
|
82
|
+
// ============================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Count how many files import/require a given file.
|
|
86
|
+
* Fully agnostic — works for any language/framework.
|
|
87
|
+
* @param {string} filePath - The file to check importers for
|
|
88
|
+
* @returns {number} Number of files that import this file. -1 on error.
|
|
89
|
+
*/
|
|
90
|
+
function countImporters(filePath) {
|
|
91
|
+
const { execSync } = require('node:child_process');
|
|
92
|
+
const basename = path.basename(filePath).replace(/\.[^.]+$/, ''); // strip extension
|
|
93
|
+
const relPath = path.relative(PATHS.root, filePath);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
// Search for imports/requires of this file (by basename or relative path)
|
|
97
|
+
// This is agnostic — catches: import from './file', require('./file'), @import 'file'
|
|
98
|
+
// Use -- without file filters for recursive search across all tracked files
|
|
99
|
+
const safeBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
100
|
+
const result = execSync(
|
|
101
|
+
`git grep -rl -E "(from\\s+['\\"](\\./|\\.\\./).*${safeBasename}['\\"\\)]|require\\s*\\(\\s*['\\"](\\./|\\.\\./).*${safeBasename}['\\"\\)])"`,
|
|
102
|
+
{ encoding: 'utf-8', cwd: PATHS.root, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
103
|
+
).trim();
|
|
104
|
+
|
|
105
|
+
if (!result) return 0;
|
|
106
|
+
// Exclude the file itself
|
|
107
|
+
const importers = result.split('\n').filter(f => f !== relPath);
|
|
108
|
+
return importers.length;
|
|
109
|
+
} catch (_err) {
|
|
110
|
+
return 0; // grep found nothing or errored
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a file edit should trigger fan-out escalation.
|
|
116
|
+
* Called after editing a file during a bugfix task.
|
|
117
|
+
* @param {string} filePath
|
|
118
|
+
* @param {Object} [config]
|
|
119
|
+
* @returns {{ shouldEscalate: boolean, importerCount: number, threshold: number }}
|
|
120
|
+
*/
|
|
121
|
+
function checkFanOut(filePath, config) {
|
|
122
|
+
const bugfixConfig = getBugfixScopeConfig(config);
|
|
123
|
+
const importerCount = countImporters(filePath);
|
|
124
|
+
return {
|
|
125
|
+
shouldEscalate: importerCount >= bugfixConfig.fanOutThreshold,
|
|
126
|
+
importerCount,
|
|
127
|
+
threshold: bugfixConfig.fanOutThreshold
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================
|
|
132
|
+
// Pre-Classification (Phase 1)
|
|
133
|
+
// ============================================================
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Extract error keywords from a bug description.
|
|
137
|
+
* @param {string} description
|
|
138
|
+
* @returns {string[]}
|
|
139
|
+
*/
|
|
140
|
+
function extractKeywords(description) {
|
|
141
|
+
if (!description) return [];
|
|
142
|
+
const lower = description.toLowerCase();
|
|
143
|
+
return ERROR_KEYWORDS.filter(kw => lower.includes(kw.toLowerCase()));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check feedback-patterns.md for similar bugs (last 30 days).
|
|
148
|
+
* @param {string} description - Bug description
|
|
149
|
+
* @param {Object} [config]
|
|
150
|
+
* @returns {{ isRepeat: boolean, matchedKeywords: string[], matchedEntries: string[] }}
|
|
151
|
+
*/
|
|
152
|
+
function preClassifyBug(description, config) {
|
|
153
|
+
const bugfixConfig = getBugfixScopeConfig(config);
|
|
154
|
+
const keywords = extractKeywords(description);
|
|
155
|
+
|
|
156
|
+
if (keywords.length === 0) {
|
|
157
|
+
return { isRepeat: false, matchedKeywords: [], matchedEntries: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Read feedback-patterns.md
|
|
161
|
+
const feedbackPath = path.join(PATHS.state, 'feedback-patterns.md');
|
|
162
|
+
let feedbackContent = '';
|
|
163
|
+
try {
|
|
164
|
+
feedbackContent = fs.readFileSync(feedbackPath, 'utf-8');
|
|
165
|
+
} catch (_err) {
|
|
166
|
+
return { isRepeat: false, matchedKeywords: keywords, matchedEntries: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Check for keyword overlap in recent entries
|
|
170
|
+
const lines = feedbackContent.split('\n');
|
|
171
|
+
const matchedEntries = [];
|
|
172
|
+
const matchedKeywords = new Set();
|
|
173
|
+
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
const lineLower = line.toLowerCase();
|
|
176
|
+
let lineMatches = 0;
|
|
177
|
+
for (const kw of keywords) {
|
|
178
|
+
if (lineLower.includes(kw.toLowerCase())) {
|
|
179
|
+
lineMatches++;
|
|
180
|
+
matchedKeywords.add(kw);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (lineMatches >= 1) {
|
|
184
|
+
matchedEntries.push(line.trim().slice(0, 120));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isRepeat = matchedKeywords.size >= bugfixConfig.keywordMatchThreshold;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
isRepeat,
|
|
192
|
+
matchedKeywords: Array.from(matchedKeywords),
|
|
193
|
+
matchedEntries: matchedEntries.slice(0, 5)
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ============================================================
|
|
198
|
+
// Runtime Monitoring (Phase 2)
|
|
199
|
+
// ============================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get the scope tracking state for a task.
|
|
203
|
+
* @param {string} taskId
|
|
204
|
+
* @returns {Object}
|
|
205
|
+
*/
|
|
206
|
+
function getScopeState(taskId) {
|
|
207
|
+
const statePath = path.join(PATHS.state, `bugfix-scope-${taskId}.json`);
|
|
208
|
+
return safeJsonParse(statePath, {
|
|
209
|
+
taskId,
|
|
210
|
+
uniqueFiles: [],
|
|
211
|
+
thresholdReached: false,
|
|
212
|
+
scopeInventory: null,
|
|
213
|
+
warnedAt: null
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Save scope tracking state for a task.
|
|
219
|
+
* @param {string} taskId
|
|
220
|
+
* @param {Object} state
|
|
221
|
+
*/
|
|
222
|
+
function saveScopeState(taskId, state) {
|
|
223
|
+
const statePath = path.join(PATHS.state, `bugfix-scope-${taskId}.json`);
|
|
224
|
+
writeJson(statePath, state);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check if a file should be excluded from the scope counter.
|
|
229
|
+
* @param {string} filePath
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
function isExcludedFile(filePath) {
|
|
233
|
+
if (!filePath) return true;
|
|
234
|
+
const basename = path.basename(filePath);
|
|
235
|
+
const fullPath = filePath.replace(/\\/g, '/');
|
|
236
|
+
|
|
237
|
+
for (const pattern of DEFAULT_EXCLUDE_PATTERNS) {
|
|
238
|
+
if (pattern.test(basename) || pattern.test(fullPath)) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Record a file edit during an L3 bugfix task (PostToolUse).
|
|
247
|
+
* @param {string} taskId
|
|
248
|
+
* @param {string} filePath - The file that was edited
|
|
249
|
+
* @returns {{ thresholdReached: boolean, uniqueFiles: number, threshold: number }}
|
|
250
|
+
*/
|
|
251
|
+
function recordFileEdit(taskId, filePath) {
|
|
252
|
+
if (isExcludedFile(filePath)) {
|
|
253
|
+
return { thresholdReached: false, uniqueFiles: 0, threshold: 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const state = getScopeState(taskId);
|
|
257
|
+
const normalizedPath = path.relative(PATHS.root, filePath);
|
|
258
|
+
|
|
259
|
+
if (!state.uniqueFiles.includes(normalizedPath)) {
|
|
260
|
+
state.uniqueFiles.push(normalizedPath);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const config = getConfig();
|
|
264
|
+
const bugfixConfig = getBugfixScopeConfig(config);
|
|
265
|
+
const thresholdReached = state.uniqueFiles.length >= bugfixConfig.fileThreshold;
|
|
266
|
+
|
|
267
|
+
if (thresholdReached && !state.thresholdReached) {
|
|
268
|
+
state.thresholdReached = true;
|
|
269
|
+
state.thresholdReachedAt = new Date().toISOString();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
saveScopeState(taskId, state);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
thresholdReached,
|
|
276
|
+
uniqueFiles: state.uniqueFiles.length,
|
|
277
|
+
threshold: bugfixConfig.fileThreshold
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Save a scope inventory for a task (lifts the block).
|
|
283
|
+
* @param {string} taskId
|
|
284
|
+
* @param {Object} inventory
|
|
285
|
+
* @param {string[]} inventory.locations - All affected locations
|
|
286
|
+
* @param {string} inventory.rootCause - One-sentence root cause
|
|
287
|
+
* @param {string} inventory.approach - Fix approach
|
|
288
|
+
*/
|
|
289
|
+
function saveScopeInventory(taskId, inventory) {
|
|
290
|
+
const state = getScopeState(taskId);
|
|
291
|
+
state.scopeInventory = {
|
|
292
|
+
...inventory,
|
|
293
|
+
savedAt: new Date().toISOString()
|
|
294
|
+
};
|
|
295
|
+
saveScopeState(taskId, state);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Clear scope state for a task (on completion).
|
|
300
|
+
* @param {string} taskId
|
|
301
|
+
*/
|
|
302
|
+
function clearScopeState(taskId) {
|
|
303
|
+
const statePath = path.join(PATHS.state, `bugfix-scope-${taskId}.json`);
|
|
304
|
+
try {
|
|
305
|
+
if (fs.existsSync(statePath)) {
|
|
306
|
+
fs.unlinkSync(statePath);
|
|
307
|
+
}
|
|
308
|
+
} catch (_err) {
|
|
309
|
+
// Non-critical
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ============================================================
|
|
314
|
+
// Gate Checks (called by hooks)
|
|
315
|
+
// ============================================================
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Check bugfix scope gate for Edit/Write operations (PreToolUse).
|
|
319
|
+
* Only activates for L3 bugfix tasks that have reached the file threshold.
|
|
320
|
+
* @param {string} toolName - Edit or Write
|
|
321
|
+
* @param {Object} [config]
|
|
322
|
+
* @returns {{ allowed: boolean, blocked: boolean, reason?: string, message?: string }}
|
|
323
|
+
*/
|
|
324
|
+
function checkBugfixScope(toolName, config) {
|
|
325
|
+
if (!isBugfixScopeEnabled(config)) {
|
|
326
|
+
return { allowed: true, blocked: false };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Only check Edit/Write
|
|
330
|
+
if (toolName !== 'Edit' && toolName !== 'Write') {
|
|
331
|
+
return { allowed: true, blocked: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Get active task from ready.json
|
|
335
|
+
const readyPath = path.join(PATHS.state, 'ready.json');
|
|
336
|
+
const ready = safeJsonParse(readyPath, { inProgress: [] });
|
|
337
|
+
if (!ready.inProgress || ready.inProgress.length === 0) {
|
|
338
|
+
return { allowed: true, blocked: false };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const activeTask = ready.inProgress[0];
|
|
342
|
+
if (!activeTask || !activeTask.id) {
|
|
343
|
+
return { allowed: true, blocked: false };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Only activate for L3 bugfix/fix tasks
|
|
347
|
+
const level = activeTask.level || 'L2';
|
|
348
|
+
const type = activeTask.type || 'feature';
|
|
349
|
+
if (level !== 'L3' || (type !== 'bugfix' && type !== 'fix' && type !== 'bug')) {
|
|
350
|
+
return { allowed: true, blocked: false };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const taskId = activeTask.id;
|
|
354
|
+
const state = getScopeState(taskId);
|
|
355
|
+
|
|
356
|
+
if (!state.thresholdReached) {
|
|
357
|
+
return { allowed: true, blocked: false };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Threshold reached — check if scope inventory exists
|
|
361
|
+
if (state.scopeInventory) {
|
|
362
|
+
return { allowed: true, blocked: false };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const bugfixConfig = getBugfixScopeConfig(config);
|
|
366
|
+
|
|
367
|
+
if (bugfixConfig.mode === 'block') {
|
|
368
|
+
return {
|
|
369
|
+
allowed: false,
|
|
370
|
+
blocked: true,
|
|
371
|
+
reason: 'bugfix-scope-threshold',
|
|
372
|
+
message: `BUGFIX SCOPE GATE: ${state.uniqueFiles.length} unique files edited (threshold: ${bugfixConfig.fileThreshold}).\n\n` +
|
|
373
|
+
`This looks like a systemic issue, not a quick fix. Before continuing, provide a scope inventory:\n\n` +
|
|
374
|
+
`Write to .workflow/state/bugfix-scope-${taskId}.json with a "scopeInventory" field containing:\n` +
|
|
375
|
+
` 1. "locations" — ALL file locations that need the same fix\n` +
|
|
376
|
+
` 2. "rootCause" — one sentence: what's actually causing this\n` +
|
|
377
|
+
` 3. "approach" — how you'll fix all locations (not one-by-one)\n\n` +
|
|
378
|
+
`Files edited so far: ${state.uniqueFiles.join(', ')}`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Warn mode — allow but inject message
|
|
383
|
+
if (!state.warnedAt) {
|
|
384
|
+
state.warnedAt = new Date().toISOString();
|
|
385
|
+
saveScopeState(taskId, state);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
allowed: true,
|
|
390
|
+
blocked: false,
|
|
391
|
+
warning: true,
|
|
392
|
+
message: `BUGFIX SCOPE WARNING: ${state.uniqueFiles.length} unique files edited (threshold: ${bugfixConfig.fileThreshold}).\n\n` +
|
|
393
|
+
`This may be a systemic issue. Consider creating a scope inventory before continuing:\n` +
|
|
394
|
+
` - List ALL affected locations\n` +
|
|
395
|
+
` - Identify the root cause\n` +
|
|
396
|
+
` - Plan a batch fix instead of one-by-one patches\n\n` +
|
|
397
|
+
`Files: ${state.uniqueFiles.join(', ')}`
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============================================================
|
|
402
|
+
// Exports
|
|
403
|
+
// ============================================================
|
|
404
|
+
|
|
405
|
+
module.exports = {
|
|
406
|
+
// Configuration
|
|
407
|
+
isBugfixScopeEnabled,
|
|
408
|
+
getBugfixScopeConfig,
|
|
409
|
+
|
|
410
|
+
// Pre-classification (Phase 1)
|
|
411
|
+
extractKeywords,
|
|
412
|
+
preClassifyBug,
|
|
413
|
+
|
|
414
|
+
// Runtime monitoring (Phase 2)
|
|
415
|
+
getScopeState,
|
|
416
|
+
recordFileEdit,
|
|
417
|
+
saveScopeInventory,
|
|
418
|
+
clearScopeState,
|
|
419
|
+
isExcludedFile,
|
|
420
|
+
|
|
421
|
+
// Gate check (used by hooks)
|
|
422
|
+
checkBugfixScope,
|
|
423
|
+
|
|
424
|
+
// Fan-out escalation (agnostic)
|
|
425
|
+
countImporters,
|
|
426
|
+
checkFanOut
|
|
427
|
+
};
|