wogiflow 2.9.0 → 2.10.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.
@@ -15,7 +15,8 @@ const {
15
15
  readFile,
16
16
  safeJsonParse,
17
17
  getConfig,
18
- color
18
+ color,
19
+ isPathWithinProject
19
20
  } = require('./flow-utils');
20
21
 
21
22
  const {
@@ -56,9 +57,9 @@ function loadTaskContext(taskId) {
56
57
  return null;
57
58
  }
58
59
 
59
- // Load spec if available
60
+ // Load spec if available (validate path is within project for defense-in-depth)
60
61
  let spec = null;
61
- if (task.specPath && fileExists(task.specPath)) {
62
+ if (task.specPath && isPathWithinProject(path.resolve(task.specPath)) && fileExists(task.specPath)) {
62
63
  spec = readFile(task.specPath, '');
63
64
  }
64
65
 
@@ -245,9 +246,33 @@ function runTaskStandardsCheck(taskContext, files, options = {}) {
245
246
  preventionPrompts = standardsLearner.getPreventionPrompts(taskType, files.map(f => f.path));
246
247
  }
247
248
 
249
+ // Constructor-to-test-mock drift detection
250
+ // Runs on any task that changes service/controller files
251
+ let constructorMockDrift = null;
252
+ const changedFilePaths = files.map(f => f.path);
253
+ const hasServiceFiles = changedFilePaths.some(f =>
254
+ /\.(service|controller|guard|interceptor|resolver|middleware|provider)\./i.test(f)
255
+ );
256
+
257
+ if (hasServiceFiles) {
258
+ constructorMockDrift = checkConstructorMockDrift(changedFilePaths);
259
+ if (constructorMockDrift.driftDetected) {
260
+ // Add drift violations to the main violations list
261
+ for (const drift of constructorMockDrift.drifts) {
262
+ results.violations.push({
263
+ type: 'constructor-mock-drift',
264
+ file: drift.sourceFile,
265
+ message: `Constructor changed but test mock not updated: ${drift.testFile} missing mocks for [${drift.missingInTest.join(', ')}]`,
266
+ severity: drift.severity,
267
+ suggestion: `Update the test file's mock providers to include: ${drift.missingInTest.join(', ')}`
268
+ });
269
+ }
270
+ }
271
+ }
272
+
248
273
  return {
249
274
  ...results,
250
- blocked: shouldBlock,
275
+ blocked: mode === 'block' && (shouldBlock || (constructorMockDrift?.driftDetected ?? false)),
251
276
  mode,
252
277
  taskType,
253
278
  taskId: taskContext?.id,
@@ -257,7 +282,8 @@ function runTaskStandardsCheck(taskContext, files, options = {}) {
257
282
  reuseCandidateContext,
258
283
  aiAsJudge,
259
284
  learningResults,
260
- preventionPrompts
285
+ preventionPrompts,
286
+ constructorMockDrift
261
287
  };
262
288
  }
263
289
 
@@ -504,6 +530,112 @@ Examples:
504
530
  process.exit(results.blocked ? 1 : 0);
505
531
  }
506
532
 
533
+ // ============================================================================
534
+ // Constructor-to-Test-Mock Drift Detection
535
+ // ============================================================================
536
+
537
+ /**
538
+ * Detect constructor signature changes in diff and verify corresponding test
539
+ * mock/provider setups match the new signature.
540
+ *
541
+ * When a constructor adds/removes dependency injection parameters, the test
542
+ * mock providers must be updated too. This check identifies the drift.
543
+ *
544
+ * Source: Backend mistake #4 — constructor changes forgotten in tests 6 times.
545
+ *
546
+ * @param {string[]} changedFiles - List of changed file paths
547
+ * @returns {{ driftDetected: boolean, drifts: Object[], message: string }}
548
+ */
549
+ function checkConstructorMockDrift(changedFiles) {
550
+ const drifts = [];
551
+
552
+ for (const filePath of changedFiles) {
553
+ // Only check TypeScript service/controller files (not test files themselves)
554
+ if (!/\.(ts|tsx)$/.test(filePath)) continue;
555
+ if (/\.spec\.|\.test\.|__tests__/.test(filePath)) continue;
556
+ if (!/\.(service|controller|guard|interceptor|resolver|middleware|provider)\./i.test(filePath)) continue;
557
+
558
+ // Read the file to find constructor parameters
559
+ let content;
560
+ try {
561
+ content = fs.readFileSync(filePath, 'utf-8');
562
+ } catch (_err) {
563
+ continue;
564
+ }
565
+
566
+ // Extract constructor parameters
567
+ const constructorMatch = content.match(/constructor\s*\(([\s\S]*?)\)\s*\{/);
568
+ if (!constructorMatch) continue;
569
+
570
+ const params = constructorMatch[1]
571
+ .split(',')
572
+ .map(p => p.trim())
573
+ .filter(p => p.length > 0)
574
+ .map(p => {
575
+ // Extract parameter name and type: "private readonly userService: UserService"
576
+ const parts = p.replace(/^(private|protected|public|readonly)\s+/g, '').trim();
577
+ const nameMatch = parts.match(/(\w+)\s*:/);
578
+ return nameMatch ? nameMatch[1] : parts.split(/\s/)[0];
579
+ })
580
+ .filter(Boolean);
581
+
582
+ if (params.length === 0) continue;
583
+
584
+ // Find corresponding test file(s)
585
+ const dir = path.dirname(filePath);
586
+ const baseName = path.basename(filePath, path.extname(filePath));
587
+ const possibleTestFiles = [
588
+ path.join(dir, `${baseName}.spec.ts`),
589
+ path.join(dir, `${baseName}.test.ts`),
590
+ path.join(dir, '__tests__', `${baseName}.spec.ts`),
591
+ path.join(dir, '__tests__', `${baseName}.test.ts`)
592
+ ];
593
+
594
+ for (const testPath of possibleTestFiles) {
595
+ let testContent;
596
+ try {
597
+ testContent = fs.readFileSync(testPath, 'utf-8');
598
+ } catch (_err) {
599
+ continue; // Test file doesn't exist — separate concern
600
+ }
601
+
602
+ // Check if test has providers/mocks for each constructor param
603
+ const missingMocks = [];
604
+ for (const param of params) {
605
+ // Look for the parameter name in providers array or mock setup
606
+ // Common patterns: { provide: ParamType, useValue: ... }
607
+ // ParamType as jest.Mocked<ParamType>
608
+ // mock(ParamType)
609
+ const paramPattern = new RegExp(param, 'i');
610
+ if (!paramPattern.test(testContent)) {
611
+ missingMocks.push(param);
612
+ }
613
+ }
614
+
615
+ if (missingMocks.length > 0) {
616
+ drifts.push({
617
+ sourceFile: filePath,
618
+ testFile: testPath,
619
+ constructorParams: params,
620
+ missingInTest: missingMocks,
621
+ severity: 'must-fix'
622
+ });
623
+ }
624
+ }
625
+ }
626
+
627
+ return {
628
+ driftDetected: drifts.length > 0,
629
+ drifts,
630
+ message: drifts.length > 0
631
+ ? `Constructor-to-test-mock drift detected in ${drifts.length} file(s). ` +
632
+ drifts.map(d =>
633
+ `${d.sourceFile} → ${d.testFile}: missing mocks for [${d.missingInTest.join(', ')}]`
634
+ ).join('; ')
635
+ : 'No constructor-mock drift detected'
636
+ };
637
+ }
638
+
507
639
  // ============================================================================
508
640
  // Exports
509
641
  // ============================================================================
@@ -513,6 +645,7 @@ module.exports = {
513
645
  extractFilesToChange,
514
646
  inferTaskType,
515
647
  runTaskStandardsCheck,
648
+ checkConstructorMockDrift,
516
649
  formatViolationsForRetry,
517
650
  formatReuseCandidatesForAI,
518
651
  hasPassedStandards,
@@ -40,11 +40,17 @@ const FORMATS = {
40
40
  },
41
41
  detailed: {
42
42
  name: 'Detailed',
43
- description: 'Full info including skill',
44
- format: '{{#if task}}[{{task.id}}] {{task.title}} | {{/if}}{{model}} | {{context_window.used_percentage}}% used{{#if skill}} | {{skill}}{{/if}}'
43
+ description: 'Full info including skill and worktree',
44
+ format: '{{#if workspace.git_worktree}}[WT] {{/if}}{{#if task}}[{{task.id}}] {{task.title}} | {{/if}}{{model}} | {{context_window.used_percentage}}% used{{#if skill}} | {{skill}}{{/if}}'
45
45
  }
46
46
  };
47
47
 
48
+ // Default refresh interval (seconds) — re-runs status line every N seconds
49
+ // so live values like task.id, context_window, and skill stay current.
50
+ // Available in Claude Code 2.1.97+. 0 disables auto-refresh.
51
+ const DEFAULT_REFRESH_INTERVAL = 5;
52
+ const MAX_REFRESH_INTERVAL = 3600;
53
+
48
54
  // Claude settings file location
49
55
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
50
56
 
@@ -71,6 +77,41 @@ function saveClaudeSettings(settings) {
71
77
  }
72
78
  }
73
79
 
80
+ /**
81
+ * Parse and validate a refresh interval string.
82
+ * Returns the integer value, or null if invalid.
83
+ * Accepts 0 (disable) through MAX_REFRESH_INTERVAL.
84
+ */
85
+ function parseRefreshInterval(arg) {
86
+ if (arg === undefined || arg === null || arg === '') return null;
87
+ const n = Number(arg);
88
+ if (!Number.isInteger(n) || n < 0 || n > MAX_REFRESH_INTERVAL) return null;
89
+ return n;
90
+ }
91
+
92
+ /**
93
+ * Build a statusLine config that preserves any existing fields the caller
94
+ * isn't explicitly overriding. This prevents `--format X` from wiping out
95
+ * a user's previously-configured refreshInterval (and vice versa).
96
+ *
97
+ * `refreshInterval` semantics:
98
+ * - undefined → don't touch existing value
99
+ * - 0 → delete the field (disable auto-refresh)
100
+ * - N > 0 → set to N seconds
101
+ */
102
+ function buildStatusLine(existing, { format, refreshInterval } = {}) {
103
+ const next = { ...(existing || {}), enabled: true };
104
+ if (format !== undefined) next.format = format;
105
+ if (refreshInterval !== undefined) {
106
+ if (refreshInterval === 0) {
107
+ delete next.refreshInterval;
108
+ } else {
109
+ next.refreshInterval = refreshInterval;
110
+ }
111
+ }
112
+ return next;
113
+ }
114
+
74
115
  function showCurrentConfig() {
75
116
  const settings = loadClaudeSettings();
76
117
  const statusLine = settings.statusLine || {};
@@ -82,6 +123,11 @@ function showCurrentConfig() {
82
123
  } else if (statusLine.format) {
83
124
  console.log(`${colors.dim}Status: ${colors.green}Enabled${colors.reset}`);
84
125
  console.log(`${colors.dim}Format:${colors.reset} ${statusLine.format}`);
126
+ if (statusLine.refreshInterval) {
127
+ console.log(`${colors.dim}Refresh:${colors.reset} every ${statusLine.refreshInterval}s ${colors.dim}(Claude Code 2.1.97+)${colors.reset}`);
128
+ } else {
129
+ console.log(`${colors.dim}Refresh:${colors.reset} ${colors.yellow}off${colors.reset} ${colors.dim}(updates on prompt only)${colors.reset}`);
130
+ }
85
131
  } else {
86
132
  console.log(`${colors.dim}Status: ${colors.yellow}Not configured${colors.reset}`);
87
133
  }
@@ -120,13 +166,29 @@ async function interactiveSetup() {
120
166
  process.exit(1);
121
167
  }
122
168
 
169
+ const refreshAnswer = await question(
170
+ `\nAuto-refresh interval in seconds (0 = off, blank = ${DEFAULT_REFRESH_INTERVAL}, requires Claude Code 2.1.97+): `
171
+ );
172
+ let refreshInterval;
173
+ if (refreshAnswer.trim() === '') {
174
+ refreshInterval = DEFAULT_REFRESH_INTERVAL;
175
+ } else {
176
+ refreshInterval = parseRefreshInterval(refreshAnswer.trim());
177
+ if (refreshInterval === null) {
178
+ errorMsg(`Refresh interval must be an integer between 0 and ${MAX_REFRESH_INTERVAL}`);
179
+ rl.close();
180
+ process.exit(1);
181
+ }
182
+ }
183
+
123
184
  const settings = loadClaudeSettings();
124
- settings.statusLine = {
125
- enabled: true,
126
- format: FORMATS[selectedFormat].format
127
- };
185
+ settings.statusLine = buildStatusLine(settings.statusLine, {
186
+ format: FORMATS[selectedFormat].format,
187
+ refreshInterval
188
+ });
128
189
 
129
- const confirm = await question(`\nApply "${FORMATS[selectedFormat].name}" format? (y/N): `);
190
+ const refreshLabel = refreshInterval === 0 ? 'no auto-refresh' : `refresh every ${refreshInterval}s`;
191
+ const confirm = await question(`\nApply "${FORMATS[selectedFormat].name}" format with ${refreshLabel}? (y/N): `);
130
192
 
131
193
  if (confirm.toLowerCase() === 'y') {
132
194
  if (saveClaudeSettings(settings)) {
@@ -151,21 +213,31 @@ Wogi Flow - Status Line Setup
151
213
  Configure Claude Code's status line to show task and context info.
152
214
 
153
215
  Usage:
154
- flow statusline-setup Interactive setup
155
- flow statusline-setup --format X Set format directly
156
- flow statusline-setup --show Show current config
157
- flow statusline-setup --disable Disable status line
158
- flow statusline-setup --formats List available formats
216
+ flow statusline-setup Interactive setup
217
+ flow statusline-setup --format X Set format directly
218
+ flow statusline-setup --refresh-interval N
219
+ Set auto-refresh interval (seconds, 0 to disable)
220
+ flow statusline-setup --show Show current config
221
+ flow statusline-setup --disable Disable status line
222
+ flow statusline-setup --formats List available formats
159
223
 
160
224
  Formats:
161
225
  minimal - Model + context %
162
226
  compact - Task ID + model + context %
163
227
  standard - Task ID + model + labeled context (recommended)
164
- detailed - Full info including skill name
228
+ detailed - Worktree + task + model + context % + skill
229
+
230
+ Refresh interval (Claude Code 2.1.97+):
231
+ Re-runs the status line every N seconds so live values like task ID,
232
+ context %, and active skill stay current between prompts.
233
+ Default when configured via this tool: ${DEFAULT_REFRESH_INTERVAL}s. Range: 0–${MAX_REFRESH_INTERVAL}.
234
+ 0 disables auto-refresh (status line only updates on prompt).
165
235
 
166
236
  Examples:
167
237
  flow statusline-setup --format standard
168
- flow statusline-setup --format detailed
238
+ flow statusline-setup --format detailed --refresh-interval 5
239
+ flow statusline-setup --refresh-interval 10
240
+ flow statusline-setup --refresh-interval 0
169
241
  `);
170
242
  process.exit(0);
171
243
  }
@@ -182,13 +254,29 @@ Examples:
182
254
 
183
255
  if (args.includes('--disable')) {
184
256
  const settings = loadClaudeSettings();
185
- settings.statusLine = { enabled: false };
257
+ settings.statusLine = { ...(settings.statusLine || {}), enabled: false };
186
258
  if (saveClaudeSettings(settings)) {
187
259
  success('Status line disabled.');
188
260
  }
189
261
  process.exit(0);
190
262
  }
191
263
 
264
+ // Parse --refresh-interval (may be combined with --format, or used alone)
265
+ const refreshIndex = args.indexOf('--refresh-interval');
266
+ let cliRefreshInterval;
267
+ if (refreshIndex >= 0) {
268
+ const refreshArg = args[refreshIndex + 1];
269
+ if (refreshArg === undefined || refreshArg.startsWith('--')) {
270
+ errorMsg('--refresh-interval requires a value (integer between 0 and ' + MAX_REFRESH_INTERVAL + ')');
271
+ process.exit(1);
272
+ }
273
+ cliRefreshInterval = parseRefreshInterval(refreshArg);
274
+ if (cliRefreshInterval === null) {
275
+ errorMsg(`--refresh-interval must be an integer between 0 and ${MAX_REFRESH_INTERVAL}`);
276
+ process.exit(1);
277
+ }
278
+ }
279
+
192
280
  const formatIndex = args.indexOf('--format');
193
281
  if (formatIndex >= 0) {
194
282
  const format = args[formatIndex + 1];
@@ -198,18 +286,47 @@ Examples:
198
286
  }
199
287
 
200
288
  const settings = loadClaudeSettings();
201
- settings.statusLine = {
202
- enabled: true,
203
- format: FORMATS[format].format
204
- };
289
+ settings.statusLine = buildStatusLine(settings.statusLine, {
290
+ format: FORMATS[format].format,
291
+ refreshInterval: cliRefreshInterval
292
+ });
205
293
 
206
294
  if (saveClaudeSettings(settings)) {
207
- success(`Status line configured with "${format}" format.`);
295
+ let refreshNote;
296
+ if (cliRefreshInterval !== undefined) {
297
+ refreshNote = cliRefreshInterval === 0 ? ' (auto-refresh off)' : ` (refresh every ${cliRefreshInterval}s)`;
298
+ } else if (settings.statusLine.refreshInterval) {
299
+ refreshNote = ` (refresh every ${settings.statusLine.refreshInterval}s preserved)`;
300
+ } else {
301
+ refreshNote = '';
302
+ }
303
+ success(`Status line configured with "${format}" format${refreshNote}.`);
208
304
  console.log(`${colors.dim}Restart Claude Code to see changes.${colors.reset}`);
209
305
  }
210
306
  process.exit(0);
211
307
  }
212
308
 
309
+ // Standalone --refresh-interval (no --format) — update only the interval
310
+ if (cliRefreshInterval !== undefined) {
311
+ const settings = loadClaudeSettings();
312
+ if (!settings.statusLine || !settings.statusLine.format) {
313
+ errorMsg('No status line is configured yet. Run with --format <name> first, or use interactive setup.');
314
+ process.exit(1);
315
+ }
316
+ settings.statusLine = buildStatusLine(settings.statusLine, {
317
+ refreshInterval: cliRefreshInterval
318
+ });
319
+ if (saveClaudeSettings(settings)) {
320
+ success(
321
+ cliRefreshInterval === 0
322
+ ? 'Status line auto-refresh disabled.'
323
+ : `Status line refresh interval set to ${cliRefreshInterval}s.`
324
+ );
325
+ console.log(`${colors.dim}Requires Claude Code 2.1.97+. Restart Claude Code to see changes.${colors.reset}`);
326
+ }
327
+ process.exit(0);
328
+ }
329
+
213
330
  // Default: interactive mode
214
331
  await interactiveSetup();
215
332
  }
@@ -311,6 +311,83 @@ async function handleTaskCompleted(input) {
311
311
  // Workspace notifications are handled by the Stop hook (via HTTP to manager port).
312
312
  // Removed duplicate file-based notification here to prevent double messages (finding-004).
313
313
 
314
+ // Compound from success — capture positive patterns (fire-and-forget)
315
+ if (result.completed) {
316
+ try {
317
+ const config = getConfig();
318
+ if (config.skillLearning?.enabled) {
319
+ const { writeToFeedbackPatterns } = require('../../flow-learning-orchestrator');
320
+ const taskType = completedTask.type || 'unknown';
321
+ const changedFiles = input.changedFiles || [];
322
+ const criteriaCount = input.scenarioCount || completedTask.criteria || 0;
323
+ const firstPass = input.firstAttemptPass !== false;
324
+
325
+ // Only record success patterns for non-trivial tasks that passed on first attempt
326
+ if (firstPass && changedFiles.length >= 2 && criteriaCount >= 2) {
327
+ const today = new Date().toISOString().split('T')[0];
328
+ const filesSummary = changedFiles.slice(0, 5).map(f => path.basename(f)).join(', ');
329
+ const entryText = `success-pattern: ${taskType} task (${criteriaCount} criteria, ${changedFiles.length} files) completed first-pass. Files: ${filesSummary}`;
330
+ const tableRow = `| ${today} | ${entryText} | 1 | - | #success |`;
331
+
332
+ writeToFeedbackPatterns({
333
+ content: tableRow,
334
+ entryText,
335
+ caller: 'task-completed-success',
336
+ }).catch(() => {
337
+ // Non-critical
338
+ });
339
+ }
340
+ }
341
+ } catch (_err) {
342
+ // Non-critical — success pattern capture may not be available
343
+ }
344
+ }
345
+
346
+ // Skill learning extraction (fire-and-forget)
347
+ if (result.completed) {
348
+ try {
349
+ const { isLearningEnabled, extractLearningContext, matchFilesToSkills, appendLearning, discoverSkills, ensureKnowledgeDir, formatSemanticChanges } = require('../../flow-skill-learn');
350
+ const config = getConfig();
351
+ if (isLearningEnabled(config, 'task')) {
352
+ const changedFiles = input.changedFiles || [];
353
+ if (changedFiles.length > 0) {
354
+ const skills = discoverSkills();
355
+ const { matches: skillMap } = matchFilesToSkills(changedFiles, skills);
356
+ const context = extractLearningContext(changedFiles, 'task');
357
+
358
+ // Enrich context with task info
359
+ context.summary = `Task ${completedTask.id}: ${completedTask.title || ''}`;
360
+ context.taskType = completedTask.type || 'unknown';
361
+
362
+ for (const [skillName, matchedFiles] of skillMap) {
363
+ if (matchedFiles.length > 0) {
364
+ const skill = skills.find(s => s.name === skillName);
365
+ const skillDir = skill?.path;
366
+ if (skillDir) {
367
+ ensureKnowledgeDir(skillDir);
368
+ const entry = [
369
+ `### ${context.summary}`,
370
+ `**Type**: ${context.type} | **Trigger**: task-complete`,
371
+ `**Files**: ${matchedFiles.join(', ')}`,
372
+ ];
373
+ if (context.semanticChanges.length > 0) {
374
+ entry.push(`**Changes**: ${formatSemanticChanges(context.semanticChanges).slice(0, 200)}`);
375
+ }
376
+ entry.push('');
377
+ appendLearning(skillDir, entry.join('\n'));
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ } catch (_err) {
384
+ // Non-critical — skill learning may not be available
385
+ if (process.env.DEBUG) {
386
+ console.error(`[Task Completed] Skill learning failed: ${_err.message}`);
387
+ }
388
+ }
389
+ }
390
+
314
391
  // Check pending queue — notify user if items are waiting
315
392
  try {
316
393
  const { getPendingCount } = require('../../flow-pending');
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  const { gatherSessionContext } = require('../../core/session-context');
11
- const { setCliSessionId, clearStaleCurrentTaskAsync } = require('../../../flow-session-state');
11
+ const { setCliSessionId, clearStaleCurrentTaskAsync, resetSessionTaskCounter } = require('../../../flow-session-state');
12
12
  const { checkAndResetStalePhase } = require('../../core/phase-gate');
13
13
  const { setRoutingPending } = require('../../core/routing-gate');
14
14
  const { getConfig } = require('../../../flow-utils');
@@ -97,6 +97,13 @@ runHook('SessionStart', async ({ parsedInput }) => {
97
97
  }
98
98
  }
99
99
 
100
+ // Reset session task counter so first task uses full prompt
101
+ try {
102
+ resetSessionTaskCounter();
103
+ } catch (_err) {
104
+ // Non-blocking
105
+ }
106
+
100
107
  try {
101
108
  const routingResult = setRoutingPending();
102
109
  if (process.env.DEBUG) {