wogiflow 1.0.38 → 1.0.40

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.
@@ -49,17 +49,15 @@ When compacting, the system automatically:
49
49
  2. Stores summaries at multiple levels (root → sections → details)
50
50
  3. Applies relevance decay to older items
51
51
  4. Enables on-demand expansion when details are needed later
52
- 5. **Cleans up completed plan files** from `.claude/plans/`
52
+ 5. **Cleans up completed plan files** from `~/.claude/plans/`
53
53
 
54
54
  ### Plan File Cleanup
55
55
 
56
- Compaction automatically cleans up plan files that appear to be complete:
57
- - Plans with "Complete" in the title
58
- - Plans containing "can be deleted"
59
- - Plans with "all completed"
60
- - Very short/empty plan files
56
+ Compaction automatically cleans up plan files from your home directory (`~/.claude/plans/`) that are explicitly marked as complete:
57
+ - Plans with `# Plan: Complete` in the title
58
+ - Plans containing the explicit marker `This plan can be deleted`
61
59
 
62
- This prevents stale plan files from accumulating and being shown after context restoration.
60
+ Only files with these explicit completion markers are deleted. This prevents stale plan files from accumulating and being shown after context restoration.
63
61
 
64
62
  ## Format for Context Summary
65
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wogiflow",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "description": "AI-powered development workflow management system with multi-model support",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -93,9 +93,13 @@ function checkPressure() {
93
93
  }
94
94
 
95
95
  /**
96
- * Clean up completed plan files from .claude/plans/
97
- * Plan files that are marked complete or empty are archived or deleted
98
- * @returns {Object} Cleanup result
96
+ * Clean up completed plan files from ~/.claude/plans/
97
+ * Plan files that are explicitly marked complete are deleted.
98
+ *
99
+ * Safety: Only deletes files that explicitly contain completion markers.
100
+ * Path traversal protection: Validates resolved paths stay within plansDir.
101
+ *
102
+ * @returns {Object} Cleanup result with cleaned count and file list
99
103
  */
100
104
  function cleanupPlanFiles() {
101
105
  const fs = require('fs');
@@ -112,27 +116,47 @@ function cleanupPlanFiles() {
112
116
  return result;
113
117
  }
114
118
 
119
+ // Resolve to absolute path for security comparison
120
+ const realPlansDir = path.resolve(plansDir);
121
+
115
122
  try {
116
123
  const files = fs.readdirSync(plansDir);
117
124
 
118
125
  for (const file of files) {
126
+ // Only process .md files
119
127
  if (!file.endsWith('.md')) continue;
120
128
 
129
+ // Skip files with path separators (potential traversal)
130
+ if (file.includes('/') || file.includes('\\')) continue;
131
+
121
132
  const filePath = path.join(plansDir, file);
122
- let content;
123
133
 
134
+ // Path traversal protection: ensure resolved path is within plansDir
135
+ const realFilePath = path.resolve(filePath);
136
+ if (!realFilePath.startsWith(realPlansDir + path.sep) && realFilePath !== realPlansDir) {
137
+ continue; // Skip files outside target directory
138
+ }
139
+
140
+ // Skip symlinks (security measure)
141
+ try {
142
+ const stats = fs.lstatSync(filePath);
143
+ if (stats.isSymbolicLink()) continue;
144
+ } catch (err) {
145
+ continue;
146
+ }
147
+
148
+ let content;
124
149
  try {
125
150
  content = fs.readFileSync(filePath, 'utf-8');
126
151
  } catch (err) {
127
152
  continue; // Skip files we can't read
128
153
  }
129
154
 
130
- // Check if plan appears to be complete
155
+ // Check if plan is EXPLICITLY marked complete
156
+ // More restrictive patterns to avoid accidental deletion
131
157
  const isComplete =
132
- /^#\s*Plan:\s*Complete/im.test(content) ||
133
- /can be deleted/i.test(content) ||
134
- /all.*completed/i.test(content) ||
135
- content.trim().length < 100; // Very short/empty plans
158
+ /^#\s*Plan:\s*Complete/im.test(content) || // Title starts with "Plan: Complete"
159
+ /^This plan (file )?can be deleted\.?$/im.test(content); // Explicit deletion marker
136
160
 
137
161
  if (isComplete) {
138
162
  try {
@@ -141,11 +165,17 @@ function cleanupPlanFiles() {
141
165
  result.files.push(file);
142
166
  } catch (err) {
143
167
  // Couldn't delete - that's okay
168
+ if (process.env.DEBUG) {
169
+ console.error(`[cleanupPlanFiles] Failed to delete ${file}: ${err.message}`);
170
+ }
144
171
  }
145
172
  }
146
173
  }
147
174
  } catch (err) {
148
175
  // Error reading plans directory - non-critical
176
+ if (process.env.DEBUG) {
177
+ console.error(`[cleanupPlanFiles] Error: ${err.message}`);
178
+ }
149
179
  }
150
180
 
151
181
  return result;
@@ -18,7 +18,7 @@
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { execSync } = require('child_process');
21
- const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir } = require('./flow-utils');
21
+ const { getConfig, getProjectRoot, MAX_SESSION_HISTORY, withLock, writeJson, ensureDir, safeJsonParse } = require('./flow-utils');
22
22
  const { validateCommand } = require('./flow-workflow');
23
23
  const { validatePathWithinProject } = require('./flow-security');
24
24
 
@@ -138,7 +138,7 @@ function createDurableSession(taskId, taskType, steps = []) {
138
138
  * @param {Array} steps - Array of step definitions
139
139
  * @returns {Promise<Object>} Created or existing session
140
140
  */
141
- async function createDurableSessionAsync(taskId, taskType, steps = []) {
141
+ async function createDurableSessionAsync(taskId, taskType, steps = [], options = {}) {
142
142
  const sessionPath = getSessionPath();
143
143
 
144
144
  return withLock(sessionPath, () => {
@@ -152,7 +152,7 @@ async function createDurableSessionAsync(taskId, taskType, steps = []) {
152
152
  // Clean up legacy hybrid-session.json if present (migration to v2.0)
153
153
  cleanupLegacyHybridSession();
154
154
 
155
- const session = createSessionObject(taskId, taskType, steps);
155
+ const session = createSessionObject(taskId, taskType, steps, options);
156
156
  saveDurableSession(session);
157
157
  return session;
158
158
  });
@@ -160,8 +160,13 @@ async function createDurableSessionAsync(taskId, taskType, steps = []) {
160
160
 
161
161
  /**
162
162
  * Create a session object (internal helper)
163
+ * @param {string} taskId - Task identifier
164
+ * @param {string} taskType - Type: "task", "loop", "bulk"
165
+ * @param {Array} steps - Array of step definitions
166
+ * @param {Object} options - Additional options
167
+ * @param {Object} options.filesToChange - File scope from spec (create/modify/delete arrays)
163
168
  */
164
- function createSessionObject(taskId, taskType, steps = []) {
169
+ function createSessionObject(taskId, taskType, steps = [], options = {}) {
165
170
  return {
166
171
  version: SESSION_VERSION,
167
172
  sessionId: `sess-${Date.now()}`,
@@ -201,7 +206,10 @@ function createSessionObject(taskId, taskType, steps = []) {
201
206
  source: null, // How queue was created: "bulk", "natural", "manual"
202
207
  queuedAt: null,
203
208
  completedTasks: [] // Track completed task IDs
204
- }
209
+ },
210
+
211
+ // v4.0: File scope for runtime enforcement (from spec's filesToChange)
212
+ filesToChange: options.filesToChange || null
205
213
  };
206
214
  }
207
215
 
@@ -261,8 +269,8 @@ function loadDurableSession() {
261
269
  }
262
270
 
263
271
  try {
264
- const content = fs.readFileSync(sessionPath, 'utf-8');
265
- const session = JSON.parse(content);
272
+ // Use safeJsonParse to prevent prototype pollution
273
+ const session = safeJsonParse(sessionPath, null);
266
274
 
267
275
  // Validate session structure
268
276
  if (!session || typeof session !== 'object') {
@@ -306,6 +314,16 @@ function loadDurableSession() {
306
314
  }
307
315
  }
308
316
 
317
+ /**
318
+ * Get file scope from current durable session
319
+ * Used by scope-gate for runtime enforcement
320
+ * @returns {Object|null} filesToChange object or null if no scope defined
321
+ */
322
+ function getSessionFileScope() {
323
+ const session = loadDurableSession();
324
+ return session?.filesToChange || null;
325
+ }
326
+
309
327
  /**
310
328
  * Save the durable session
311
329
  * @param {Object} session - Session to save
@@ -344,11 +362,9 @@ function archiveDurableSession(status = 'completed') {
344
362
  let history = [];
345
363
 
346
364
  if (fs.existsSync(historyPath)) {
347
- try {
348
- history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
349
- } catch {
350
- history = [];
351
- }
365
+ // Use safeJsonParse to prevent prototype pollution
366
+ const parsed = safeJsonParse(historyPath, []);
367
+ history = Array.isArray(parsed) ? parsed : [];
352
368
  }
353
369
 
354
370
  history.push(session);
@@ -964,7 +980,15 @@ function checkFileCondition(config) {
964
980
  // If expected content specified, check it
965
981
  if (config.expectedContent) {
966
982
  try {
967
- const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
983
+ // Use safeJsonParse to prevent prototype pollution
984
+ const content = safeJsonParse(filePath, null);
985
+ if (!content) {
986
+ return {
987
+ canResume: false,
988
+ reason: 'file-parse-error',
989
+ error: 'Could not parse file content'
990
+ };
991
+ }
968
992
  const matches = deepEqual(content, config.expectedContent);
969
993
 
970
994
  if (matches) {
@@ -1184,7 +1208,9 @@ function getSessionStats() {
1184
1208
  }
1185
1209
 
1186
1210
  try {
1187
- const history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1211
+ // Use safeJsonParse to prevent prototype pollution
1212
+ const parsed = safeJsonParse(historyPath, []);
1213
+ const history = Array.isArray(parsed) ? parsed : [];
1188
1214
  const completed = history.filter(h => h.status === 'completed').length;
1189
1215
  const failed = history.filter(h => h.status === 'failed').length;
1190
1216
  const avgSteps = history.length > 0
@@ -1408,6 +1434,7 @@ module.exports = {
1408
1434
  loadDurableSession,
1409
1435
  saveDurableSession,
1410
1436
  archiveDurableSession,
1437
+ getSessionFileScope, // v4.0: Get file scope for runtime enforcement
1411
1438
 
1412
1439
  // Step management
1413
1440
  getNextPendingStep,
@@ -66,6 +66,14 @@ const {
66
66
  STEP_STATUS
67
67
  } = require('./flow-durable-session');
68
68
 
69
+ // Spec loader for scope enforcement (optional - graceful degradation)
70
+ let loadSpec = null;
71
+ try {
72
+ loadSpec = require('./flow-spec-generator').loadSpec;
73
+ } catch (err) {
74
+ if (process.env.DEBUG) console.error(`[DEBUG] flow-spec-generator not available: ${err.message}`);
75
+ }
76
+
69
77
  // v3.0 phased task execution (recursive enhancements)
70
78
  const {
71
79
  initializePhasedTask,
@@ -288,8 +296,29 @@ async function main() {
288
296
  const steps = Array.isArray(acceptanceCriteria) ? acceptanceCriteria : [];
289
297
  const sessionSteps = steps.length > 0 ? steps : [taskTitle || taskId];
290
298
 
299
+ // v4.0: Load spec to get filesToChange for scope enforcement
300
+ let filesToChange = null;
301
+ if (loadSpec) {
302
+ try {
303
+ const spec = loadSpec(taskId);
304
+ if (spec?.sections?.filesToChange) {
305
+ filesToChange = spec.sections.filesToChange;
306
+ if (process.env.DEBUG) {
307
+ const fileCount = (filesToChange.create?.length || 0) +
308
+ (filesToChange.modify?.length || 0) +
309
+ (filesToChange.delete?.length || 0);
310
+ console.log(color('dim', `[DEBUG] Loaded scope: ${fileCount} files from spec`));
311
+ }
312
+ }
313
+ } catch (err) {
314
+ if (process.env.DEBUG) console.error(`[DEBUG] Spec load for scope: ${err.message}`);
315
+ }
316
+ }
317
+
291
318
  // Use async version with file locking to prevent race conditions
292
- const session = await createDurableSessionAsync(taskId, 'task', sessionSteps);
319
+ const session = await createDurableSessionAsync(taskId, 'task', sessionSteps, {
320
+ filesToChange
321
+ });
293
322
 
294
323
  if (steps.length > 0) {
295
324
  console.log(color('cyan', `📋 Durable session initialized with ${steps.length} steps`));
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  const taskGate = require('./task-gate');
10
+ const scopeGate = require('./scope-gate');
10
11
  const validation = require('./validation');
11
12
  const loopCheck = require('./loop-check');
12
13
  const componentCheck = require('./component-check');
@@ -21,6 +22,10 @@ module.exports = {
21
22
  ...taskGate,
22
23
  taskGate,
23
24
 
25
+ // Scope Gating (v4.0 - validates edits are within task scope)
26
+ ...scopeGate,
27
+ scopeGate,
28
+
24
29
  // Validation
25
30
  ...validation,
26
31
  validation,
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Scope Gate (Core Module)
5
+ *
6
+ * v4.0: Runtime scope enforcement for task-work alignment.
7
+ * Validates that file edits are within the task's declared scope
8
+ * (from spec's filesToChange).
9
+ *
10
+ * This module wraps task-gate and adds scope checking:
11
+ * 1. First checks if a task is active (via task-gate)
12
+ * 2. Then checks if the file being edited is in the task's scope
13
+ * 3. Warns or blocks based on configuration
14
+ */
15
+
16
+ const path = require('path');
17
+
18
+ // Import from parent scripts directory
19
+ const { getConfig, getProjectRoot } = require('../../flow-utils');
20
+ const { checkTaskGate, getActiveTask } = require('./task-gate');
21
+ const { getSessionFileScope } = require('../../flow-durable-session');
22
+
23
+ /**
24
+ * Check if scope gating is enabled
25
+ * @returns {boolean}
26
+ */
27
+ function isScopeGatingEnabled() {
28
+ const config = getConfig();
29
+
30
+ // Check hooks config
31
+ if (config.hooks?.rules?.scopeGating?.enabled === false) {
32
+ return false;
33
+ }
34
+
35
+ // Default to enabled if not explicitly disabled
36
+ return true;
37
+ }
38
+
39
+ /**
40
+ * Get the scope gating mode
41
+ * @returns {string} 'warn' | 'block'
42
+ */
43
+ function getScopeGatingMode() {
44
+ const config = getConfig();
45
+ return config.hooks?.rules?.scopeGating?.mode || 'warn';
46
+ }
47
+
48
+ /**
49
+ * Get exempt patterns from config
50
+ * @returns {string[]}
51
+ */
52
+ function getExemptPatterns() {
53
+ const config = getConfig();
54
+ return config.hooks?.rules?.scopeGating?.exemptPatterns || [
55
+ '.workflow/state/**',
56
+ '.workflow/specs/**',
57
+ '.workflow/plans/**',
58
+ 'package.json',
59
+ 'tsconfig.json',
60
+ 'package-lock.json'
61
+ ];
62
+ }
63
+
64
+ /**
65
+ * Validate that a path is within the project root (prevents path traversal)
66
+ * @param {string} filePath - The file path to validate
67
+ * @returns {boolean} True if path is within project
68
+ */
69
+ function isPathWithinProject(filePath) {
70
+ try {
71
+ const projectRoot = getProjectRoot();
72
+ const resolvedPath = path.resolve(projectRoot, filePath);
73
+ return resolvedPath.startsWith(projectRoot + path.sep) || resolvedPath === projectRoot;
74
+ } catch (_err) {
75
+ // If we can't resolve, reject for safety
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if a file path matches a pattern
82
+ * Supports:
83
+ * - Exact paths: 'src/index.ts'
84
+ * - Directory patterns: 'src/components/**' (recursive)
85
+ * - Directory patterns: 'src/components/*' (direct children only)
86
+ *
87
+ * @param {string} filePath - The file being edited
88
+ * @param {string} pattern - The pattern to match against
89
+ * @returns {boolean}
90
+ */
91
+ function matchesPattern(filePath, pattern) {
92
+ // Validate inputs
93
+ if (!filePath || !pattern || typeof filePath !== 'string' || typeof pattern !== 'string') {
94
+ return false;
95
+ }
96
+
97
+ // Reject path traversal attempts
98
+ if (pattern.includes('..') || filePath.includes('..')) {
99
+ return false;
100
+ }
101
+
102
+ // Normalize paths (convert backslashes to forward slashes for consistency)
103
+ const normalizedFile = path.normalize(filePath).replace(/\\/g, '/');
104
+ const normalizedPattern = path.normalize(pattern).replace(/\\/g, '/');
105
+
106
+ // Validate the file path is within project after normalization
107
+ if (!isPathWithinProject(normalizedFile)) {
108
+ return false;
109
+ }
110
+
111
+ // Exact match
112
+ if (normalizedFile === normalizedPattern) {
113
+ return true;
114
+ }
115
+
116
+ // Directory pattern (ends with /**) - recursive match
117
+ if (normalizedPattern.endsWith('/**')) {
118
+ const dirPrefix = normalizedPattern.slice(0, -3);
119
+ return normalizedFile.startsWith(dirPrefix + '/') || normalizedFile === dirPrefix;
120
+ }
121
+
122
+ // Directory pattern (ends with /*) - direct children only
123
+ if (normalizedPattern.endsWith('/*')) {
124
+ const dirPrefix = normalizedPattern.slice(0, -2);
125
+ // Only match files directly in the directory, not subdirectories
126
+ if (!normalizedFile.startsWith(dirPrefix + '/')) {
127
+ return false;
128
+ }
129
+ const relativePath = normalizedFile.slice(dirPrefix.length + 1);
130
+ return !relativePath.includes('/');
131
+ }
132
+
133
+ // File is within a directory specified as scope (without glob)
134
+ // e.g., scope "src/utils" matches "src/utils/helper.ts"
135
+ if (normalizedFile.startsWith(normalizedPattern + '/')) {
136
+ return true;
137
+ }
138
+
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Check if a file is in the exempt list
144
+ * @param {string} filePath - The file being edited
145
+ * @returns {boolean}
146
+ */
147
+ function isFileExempt(filePath) {
148
+ if (!filePath) {
149
+ return false;
150
+ }
151
+
152
+ const exemptPatterns = getExemptPatterns();
153
+
154
+ for (const pattern of exemptPatterns) {
155
+ if (matchesPattern(filePath, pattern)) {
156
+ return true;
157
+ }
158
+ }
159
+
160
+ return false;
161
+ }
162
+
163
+ /**
164
+ * Check if a file is within the task's declared scope
165
+ *
166
+ * @param {string} filePath - The file being edited
167
+ * @param {Object} filesToChange - The scope object from spec
168
+ * @param {Array} filesToChange.create - Files to create
169
+ * @param {Array} filesToChange.modify - Files to modify (may be objects with .path)
170
+ * @param {Array} filesToChange.delete - Files to delete
171
+ * @returns {boolean}
172
+ */
173
+ function isFileInScope(filePath, filesToChange) {
174
+ // If no scope object provided, scope gating is disabled for this task
175
+ if (!filesToChange) {
176
+ return true;
177
+ }
178
+
179
+ // Extract all file paths from the scope
180
+ const allFiles = [];
181
+
182
+ // Add create files
183
+ if (Array.isArray(filesToChange.create)) {
184
+ for (const file of filesToChange.create) {
185
+ if (typeof file === 'string' && file.length > 0) {
186
+ allFiles.push(file);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Add modify files (may be strings or objects with .path)
192
+ if (Array.isArray(filesToChange.modify)) {
193
+ for (const file of filesToChange.modify) {
194
+ if (typeof file === 'string' && file.length > 0) {
195
+ allFiles.push(file);
196
+ } else if (file && typeof file.path === 'string' && file.path.length > 0) {
197
+ allFiles.push(file.path);
198
+ }
199
+ }
200
+ }
201
+
202
+ // Add delete files
203
+ if (Array.isArray(filesToChange.delete)) {
204
+ for (const file of filesToChange.delete) {
205
+ if (typeof file === 'string' && file.length > 0) {
206
+ allFiles.push(file);
207
+ }
208
+ }
209
+ }
210
+
211
+ // If scope is explicitly defined but empty, no changes are allowed
212
+ // This means the spec declared filesToChange but with no files
213
+ if (allFiles.length === 0) {
214
+ return false;
215
+ }
216
+
217
+ // Check if file matches any scope pattern
218
+ for (const pattern of allFiles) {
219
+ if (matchesPattern(filePath, pattern)) {
220
+ return true;
221
+ }
222
+ }
223
+
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Generate warning message for out-of-scope edits
229
+ * @param {string} filePath - The file being edited
230
+ * @param {Object} task - The active task
231
+ * @param {Object} filesToChange - The scope object
232
+ * @returns {string}
233
+ */
234
+ function generateScopeWarning(filePath, task, filesToChange) {
235
+ // Defensive: handle missing inputs gracefully
236
+ if (!filePath || !task || !filesToChange) {
237
+ return 'Scope warning: File not in task scope (insufficient context for details)';
238
+ }
239
+
240
+ const fileName = path.basename(filePath);
241
+ const fileCount = (filesToChange.create?.length || 0) +
242
+ (filesToChange.modify?.length || 0) +
243
+ (filesToChange.delete?.length || 0);
244
+
245
+ return `Scope Warning: Editing ${fileName} which is not in task scope.
246
+ Task: ${task.id}${task.title ? ' - ' + task.title : ''}
247
+ Spec defines ${fileCount} file(s) in scope.
248
+ Proceed with caution - this file may not be related to your current task.`;
249
+ }
250
+
251
+ /**
252
+ * Generate block message for out-of-scope edits
253
+ * @param {string} filePath - The file being edited
254
+ * @param {Object} task - The active task
255
+ * @param {Object} filesToChange - The scope object
256
+ * @returns {string}
257
+ */
258
+ function generateScopeBlockMessage(filePath, task, filesToChange) {
259
+ // Defensive: handle missing inputs gracefully
260
+ if (!filePath || !task || !filesToChange) {
261
+ return 'Scope Violation: Cannot edit file - not in task scope';
262
+ }
263
+
264
+ const fileName = path.basename(filePath);
265
+
266
+ // Get first few files from scope for context
267
+ const scopeFiles = [
268
+ ...(filesToChange.create || []).slice(0, 2),
269
+ ...(filesToChange.modify || []).slice(0, 2).map(file => typeof file === 'string' ? file : file?.path)
270
+ ].filter(Boolean).slice(0, 3);
271
+
272
+ return `Scope Violation: Cannot edit ${fileName}
273
+
274
+ Task: ${task.id}${task.title ? ' - ' + task.title : ''}
275
+ Expected scope includes: ${scopeFiles.join(', ')}${scopeFiles.length >= 3 ? ', ...' : ''}
276
+
277
+ To proceed:
278
+ 1. Update the spec to include this file, OR
279
+ 2. Complete current task and start a new one for this file, OR
280
+ 3. Set scopeGating.mode to "warn" in config to allow with warning`;
281
+ }
282
+
283
+ /**
284
+ * Main scope gate check
285
+ * Wraps task-gate and adds scope validation
286
+ *
287
+ * @param {Object} options
288
+ * @param {string} options.filePath - Path being edited/written
289
+ * @param {string} options.operation - 'edit' or 'write'
290
+ * @returns {Object} Result: { allowed, blocked, message, warning, task, scopeChecked, inScope }
291
+ */
292
+ function checkScopeGate(options = {}) {
293
+ const { filePath } = options;
294
+
295
+ // First, run the normal task gate
296
+ const taskResult = checkTaskGate(options);
297
+
298
+ // If task gate blocked, return that result (no task active)
299
+ if (taskResult.blocked) {
300
+ return taskResult;
301
+ }
302
+
303
+ // Check if scope gating is enabled
304
+ if (!isScopeGatingEnabled()) {
305
+ return { ...taskResult, scopeChecked: false, reason: 'scope_gating_disabled' };
306
+ }
307
+
308
+ // Check if file is exempt
309
+ if (filePath && isFileExempt(filePath)) {
310
+ return { ...taskResult, scopeChecked: true, inScope: true, reason: 'file_exempt' };
311
+ }
312
+
313
+ // Get the active task
314
+ const activeTask = taskResult.task || getActiveTask();
315
+ if (!activeTask) {
316
+ return { ...taskResult, scopeChecked: false, reason: 'no_active_task' };
317
+ }
318
+
319
+ // Get scope from durable session
320
+ const filesToChange = getSessionFileScope();
321
+
322
+ // If no scope defined (spec didn't have filesToChange), skip scope check
323
+ // This allows tasks without specs to work normally
324
+ if (!filesToChange) {
325
+ if (process.env.DEBUG) {
326
+ console.log('[scope-gate] No scope defined in session - skipping scope enforcement');
327
+ }
328
+ return {
329
+ ...taskResult,
330
+ scopeChecked: false,
331
+ reason: 'no_scope_defined'
332
+ };
333
+ }
334
+
335
+ // Check if file is in scope
336
+ const inScope = isFileInScope(filePath, filesToChange);
337
+
338
+ if (inScope) {
339
+ return {
340
+ ...taskResult,
341
+ scopeChecked: true,
342
+ inScope: true,
343
+ reason: 'in_scope'
344
+ };
345
+ }
346
+
347
+ // File is NOT in scope - warn or block based on config
348
+ const mode = getScopeGatingMode();
349
+
350
+ if (mode === 'warn') {
351
+ return {
352
+ ...taskResult,
353
+ scopeChecked: true,
354
+ inScope: false,
355
+ warning: generateScopeWarning(filePath, activeTask, filesToChange),
356
+ reason: 'out_of_scope_warning'
357
+ };
358
+ }
359
+
360
+ // Block mode
361
+ return {
362
+ allowed: false,
363
+ blocked: true,
364
+ scopeChecked: true,
365
+ inScope: false,
366
+ message: generateScopeBlockMessage(filePath, activeTask, filesToChange),
367
+ reason: 'out_of_scope_blocked'
368
+ };
369
+ }
370
+
371
+ module.exports = {
372
+ checkScopeGate,
373
+ isFileInScope,
374
+ isFileExempt,
375
+ matchesPattern,
376
+ isScopeGatingEnabled,
377
+ getScopeGatingMode,
378
+ isPathWithinProject,
379
+ generateScopeWarning,
380
+ generateScopeBlockMessage
381
+ };
@@ -4,10 +4,12 @@
4
4
  * Wogi Flow - Claude Code PreToolUse Hook
5
5
  *
6
6
  * Called before Edit/Write/TodoWrite tool execution.
7
- * Enforces task gating, component reuse checking, and TodoWrite gating.
7
+ * Enforces task gating, scope validation, component reuse checking, and TodoWrite gating.
8
+ *
9
+ * v4.0: Added scope gating to validate edits are within task's declared scope
8
10
  */
9
11
 
10
- const { checkTaskGate } = require('../../core/task-gate');
12
+ const { checkScopeGate } = require('../../core/scope-gate');
11
13
  const { checkComponentReuse } = require('../../core/component-check');
12
14
  const { checkTodoWriteGate } = require('../../core/todowrite-gate');
13
15
  const { claudeCodeAdapter } = require('../../adapters/claude-code');
@@ -61,14 +63,15 @@ async function main() {
61
63
 
62
64
  let coreResult = { allowed: true, blocked: false };
63
65
 
64
- // Task gating check (for Edit and Write)
66
+ // Task + scope gating check (for Edit and Write)
67
+ // v4.0: checkScopeGate wraps checkTaskGate and adds scope validation
65
68
  if (toolName === 'Edit' || toolName === 'Write') {
66
- coreResult = checkTaskGate({
69
+ coreResult = checkScopeGate({
67
70
  filePath,
68
71
  operation: toolName.toLowerCase()
69
72
  });
70
73
 
71
- // If blocked by task gating, return early
74
+ // If blocked by task or scope gating, return early
72
75
  if (coreResult.blocked) {
73
76
  const output = claudeCodeAdapter.transformResult('PreToolUse', coreResult);
74
77
  console.log(JSON.stringify(output));