watchfix 0.2.2 → 0.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.
@@ -152,7 +152,7 @@ const formatStatusLine = (result, maxAttempts) => {
152
152
  return 'Status: resolved (issue already fixed)';
153
153
  }
154
154
  const retryInfo = result.attempts < maxAttempts ? ', will retry' : ', max attempts reached';
155
- return `Status: ${result.status} (attempt ${result.attempts} of ${maxAttempts}${retryInfo})`;
155
+ return `Status: ${result.status} (attempt ${result.attempts + 1} of ${maxAttempts}${retryInfo})`;
156
156
  };
157
157
  const formatFixOutcome = (result, verbosity, maxAttempts) => {
158
158
  if (verbosity === 'quiet') {
@@ -211,7 +211,7 @@ const checkDaemonConflict = (db, logger) => {
211
211
  if (!state) {
212
212
  return true;
213
213
  }
214
- if (!isOurProcess(state.pid, state.project_root)) {
214
+ if (!isOurProcess(state.pid)) {
215
215
  clearWatcherState(db);
216
216
  return true;
217
217
  }
@@ -56,6 +56,9 @@ const formatAnalysis = (analysis) => {
56
56
  return ['Analysis (raw):', ` ${analysis}`];
57
57
  }
58
58
  const lines = ['Analysis:'];
59
+ if (analysis.category) {
60
+ lines.push(` Category: ${analysis.category}`);
61
+ }
59
62
  if (analysis.summary) {
60
63
  lines.push(` Summary: ${analysis.summary}`);
61
64
  }
@@ -73,6 +76,10 @@ const formatAnalysis = (analysis) => {
73
76
  lines.push(` - ${file}`);
74
77
  }
75
78
  }
79
+ if (analysis.remediation_guidance) {
80
+ lines.push(' Remediation guidance:');
81
+ lines.push(...analysis.remediation_guidance.split('\n').map((line) => ` ${line}`));
82
+ }
76
83
  if (analysis.confidence) {
77
84
  lines.push(` Confidence: ${analysis.confidence}`);
78
85
  }
@@ -13,6 +13,7 @@ const STATUS_ORDER = [
13
13
  'fixed',
14
14
  'failed',
15
15
  'ignored',
16
+ 'deferred',
16
17
  ];
17
18
  const buildDatabasePath = (rootDir) => path.join(rootDir, '.watchfix', 'errors.db');
18
19
  const getWatcherState = (db) => db.get('SELECT pid, started_at, autonomous, project_root, command_line FROM watcher_state WHERE id = 1');
@@ -62,6 +63,16 @@ const formatActionableErrors = (errors) => {
62
63
  }
63
64
  return lines;
64
65
  };
66
+ const formatDeferredErrors = (errors) => {
67
+ if (errors.length === 0) {
68
+ return ['Deferred errors: none'];
69
+ }
70
+ const lines = ['Deferred errors (manual action required):'];
71
+ for (const error of errors) {
72
+ lines.push(` #${error.id} ${error.errorType} (${error.source}): ${error.message}`);
73
+ }
74
+ return lines;
75
+ };
65
76
  export const statusCommand = async (options) => {
66
77
  const config = loadConfig(options.config);
67
78
  const dbPath = buildDatabasePath(config.project.root);
@@ -71,6 +82,7 @@ export const statusCommand = async (options) => {
71
82
  'Errors:',
72
83
  ...STATUS_ORDER.map((status) => ` ${status}: 0`),
73
84
  'Actionable errors: none',
85
+ 'Deferred errors: none',
74
86
  ];
75
87
  process.stdout.write(`${lines.join('\n')}\n`);
76
88
  return;
@@ -81,7 +93,7 @@ export const statusCommand = async (options) => {
81
93
  checkSchemaVersion(db);
82
94
  const lines = [];
83
95
  const state = getWatcherState(db);
84
- if (state && isOurProcess(state.pid, state.project_root)) {
96
+ if (state && isOurProcess(state.pid)) {
85
97
  const mode = state.autonomous ? 'autonomous' : 'manual';
86
98
  const uptime = formatUptime(state.started_at);
87
99
  lines.push(`Watcher: running (pid ${state.pid}, ${mode} mode, uptime ${uptime}).`);
@@ -102,6 +114,8 @@ export const statusCommand = async (options) => {
102
114
  }
103
115
  const actionable = getErrorsByStatus(db, ['pending', 'suggested']);
104
116
  lines.push(...formatActionableErrors(actionable));
117
+ const deferred = getErrorsByStatus(db, ['deferred']);
118
+ lines.push(...formatDeferredErrors(deferred));
105
119
  process.stdout.write(`${lines.join('\n')}\n`);
106
120
  }
107
121
  finally {
@@ -58,7 +58,7 @@ export const stopCommand = async (options) => {
58
58
  process.exitCode = EXIT_CODES.WATCHER_CONFLICT;
59
59
  return;
60
60
  }
61
- if (!isOurProcess(state.pid, state.project_root)) {
61
+ if (!isOurProcess(state.pid)) {
62
62
  process.stdout.write('Stale watcher state (process no longer exists).\n');
63
63
  clearWatcherState(db);
64
64
  process.exitCode = EXIT_CODES.WATCHER_CONFLICT;
@@ -31,7 +31,7 @@ const ensureWatcherAvailable = (db, projectRoot, logger) => {
31
31
  if (!existing) {
32
32
  return true;
33
33
  }
34
- if (isOurProcess(existing.pid, projectRoot)) {
34
+ if (isOurProcess(existing.pid)) {
35
35
  const mode = existing.autonomous ? 'autonomous' : 'manual';
36
36
  const message = `Watcher already running (pid ${existing.pid}, ${mode} mode). Use 'watchfix stop'.`;
37
37
  if (logger) {
@@ -261,10 +261,13 @@ declare const configSchema: z.ZodObject<{
261
261
  }>>;
262
262
  deduplication: z.ZodDefault<z.ZodObject<{
263
263
  fixed_grace_period: z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
264
+ deferred_grace_period: z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
264
265
  }, "strip", z.ZodTypeAny, {
265
266
  fixed_grace_period: string;
267
+ deferred_grace_period: string;
266
268
  }, {
267
269
  fixed_grace_period?: string | undefined;
270
+ deferred_grace_period?: string | undefined;
268
271
  }>>;
269
272
  patterns: z.ZodDefault<z.ZodObject<{
270
273
  match: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -324,6 +327,7 @@ declare const configSchema: z.ZodObject<{
324
327
  };
325
328
  deduplication: {
326
329
  fixed_grace_period: string;
330
+ deferred_grace_period: string;
327
331
  };
328
332
  patterns: {
329
333
  ignore: string[];
@@ -377,6 +381,7 @@ declare const configSchema: z.ZodObject<{
377
381
  } | undefined;
378
382
  deduplication?: {
379
383
  fixed_grace_period?: string | undefined;
384
+ deferred_grace_period?: string | undefined;
380
385
  } | undefined;
381
386
  patterns?: {
382
387
  ignore?: string[] | undefined;
@@ -92,6 +92,7 @@ const configSchema = z.object({
92
92
  deduplication: z
93
93
  .object({
94
94
  fixed_grace_period: durationSchema.default('10m'),
95
+ deferred_grace_period: durationSchema.default('1h'),
95
96
  })
96
97
  .default({}),
97
98
  patterns: z
@@ -35,7 +35,7 @@ export type ErrorInsert = {
35
35
  createdAt?: string;
36
36
  updatedAt?: string;
37
37
  };
38
- export type ActivityAction = 'watcher_start' | 'watcher_stop' | 'error_detected' | 'error_deduplicated' | 'analysis_start' | 'analysis_complete' | 'analysis_failed' | 'analysis_timeout' | 'already_fixed_detected' | 'fix_start' | 'fix_complete' | 'fix_failed' | 'fix_timeout' | 'verification_start' | 'verification_pass' | 'verification_fail' | 'error_ignored' | 'lock_acquired' | 'lock_released' | 'lock_expired' | 'stale_recovery';
38
+ export type ActivityAction = 'watcher_start' | 'watcher_stop' | 'error_detected' | 'error_deduplicated' | 'analysis_start' | 'analysis_complete' | 'analysis_failed' | 'analysis_timeout' | 'already_fixed_detected' | 'error_deferred' | 'deferred_reanalyzed' | 'fix_start' | 'fix_complete' | 'fix_failed' | 'fix_timeout' | 'verification_start' | 'verification_pass' | 'verification_fail' | 'error_ignored' | 'lock_acquired' | 'lock_released' | 'lock_expired' | 'stale_recovery';
39
39
  export declare function insertError(db: Database, error: ErrorInsert): number;
40
40
  export declare function getError(db: Database, id: number): ErrorRecord | null;
41
41
  export declare function getErrorByHash(db: Database, hash: string): ErrorRecord | null;
@@ -1,9 +1,32 @@
1
1
  import type { Config } from '../config/schema.js';
2
2
  import type { ErrorRecord } from '../db/queries.js';
3
+ type RetryContext = {
4
+ previousAttempt: {
5
+ analysis?: {
6
+ summary: string;
7
+ suggested_fix: string;
8
+ files_to_modify: string[];
9
+ };
10
+ fix?: {
11
+ success: boolean;
12
+ summary: string;
13
+ files_changed?: Array<{
14
+ path: string;
15
+ change: string;
16
+ }>;
17
+ };
18
+ verification_failure?: {
19
+ type: string;
20
+ command?: string;
21
+ message?: string;
22
+ stderr?: string;
23
+ };
24
+ };
25
+ };
3
26
  type GeneratedContext = {
4
27
  path: string;
5
28
  content: string;
6
29
  };
7
- export declare const generateAnalyzeContext: (error: ErrorRecord, config: Config, attempt: number) => GeneratedContext;
30
+ export declare const generateAnalyzeContext: (error: ErrorRecord, config: Config, attempt: number, retryContext?: RetryContext) => GeneratedContext;
8
31
  export declare const generateFixContext: (error: ErrorRecord, analysis: string, config: Config, attempt: number) => GeneratedContext;
9
32
  export type { GeneratedContext };
@@ -97,9 +97,52 @@ const truncateStackTraceToBytes = (stackTrace, maxBytes) => {
97
97
  const head = headBytes > 0 ? sliceUtf8ByBytes(stackTrace, headBytes, 'start') : '';
98
98
  return head ? `${head}\n${STACK_TRACE_TRUNCATION_MARKER}` : STACK_TRACE_TRUNCATION_MARKER;
99
99
  };
100
+ const buildRetrySection = (retryContext, attempt) => {
101
+ const { previousAttempt } = retryContext;
102
+ const lines = [
103
+ `## IMPORTANT: This is a RETRY (Attempt ${attempt + 1})`,
104
+ '',
105
+ 'The previous fix attempt was applied but **verification failed**.',
106
+ '',
107
+ '### What was tried:',
108
+ ];
109
+ if (previousAttempt.analysis) {
110
+ lines.push(`- Analysis: ${previousAttempt.analysis.summary}`);
111
+ if (previousAttempt.analysis.files_to_modify.length > 0) {
112
+ lines.push(`- Files modified: ${previousAttempt.analysis.files_to_modify.join(', ')}`);
113
+ }
114
+ }
115
+ if (previousAttempt.fix) {
116
+ lines.push(`- Fix applied: ${previousAttempt.fix.summary}`);
117
+ }
118
+ lines.push('');
119
+ lines.push('### Why it failed:');
120
+ if (previousAttempt.verification_failure) {
121
+ const vf = previousAttempt.verification_failure;
122
+ lines.push(`**Verification command failed**: ${vf.command || 'unknown'}`);
123
+ lines.push(`**Message**: ${vf.message || 'unknown'}`);
124
+ if (vf.stderr) {
125
+ lines.push('**Test output**:');
126
+ lines.push('```');
127
+ lines.push(vf.stderr.slice(0, 2000));
128
+ lines.push('```');
129
+ }
130
+ }
131
+ else {
132
+ lines.push('Verification failed (details not available)');
133
+ }
134
+ lines.push('');
135
+ lines.push('### Instructions for retry:');
136
+ lines.push('1. The code has ALREADY been modified by the previous attempt');
137
+ lines.push('2. Do NOT report already_fixed unless the verification test is actually passing');
138
+ lines.push('3. Focus on the verification failure output - it shows what\'s still broken');
139
+ lines.push('4. The original error message may not appear in code anymore, but the test is still failing');
140
+ lines.push('');
141
+ return lines.join('\n');
142
+ };
100
143
  const buildAnalyzeContent = (options) => {
101
144
  const { projectName, projectRoot, error, attempt, date, stackTrace } = options;
102
- const analysisPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt}-analysis.yaml`);
145
+ const analysisPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt + 1}-analysis.yaml`);
103
146
  return `# WatchFix Task
104
147
 
105
148
  ## Mode
@@ -114,7 +157,7 @@ analyze
114
157
  - Source: ${error.source}
115
158
  - Type: ${error.errorType}
116
159
  - Detected: ${error.timestamp}
117
- - Fix Attempts: ${error.fixAttempts}
160
+ - Fix Attempts: ${error.fixAttempts + 1}
118
161
 
119
162
  ### Message
120
163
  ${error.message}
@@ -124,27 +167,69 @@ ${stackTrace}
124
167
 
125
168
  ### Context (surrounding log lines)
126
169
  ${options.contextBlock}
170
+ ${options.retryContext ? `
171
+ ${buildRetrySection(options.retryContext, options.attempt)}` : ''}
172
+ ## Error Classification
173
+
174
+ First, classify this error into one of three categories:
175
+
176
+ **code** - Bugs in source code fixable by modifying code files:
177
+ - Logic errors, null pointer exceptions, type mismatches
178
+ - Missing error handling, incorrect API usage
179
+ - Syntax errors, import/export issues
180
+
181
+ **infrastructure** - Environment/deployment issues NOT fixable by code changes:
182
+ - Database/Redis/queue unavailable or connection refused
183
+ - Network connectivity issues, DNS resolution failures
184
+ - Resource exhaustion (disk full, out of memory)
185
+ - Container/orchestration problems, service not running
186
+
187
+ **configuration** - Settings or config issues NOT fixable by code changes:
188
+ - Missing or incorrect environment variables
189
+ - Wrong config file values, invalid credentials
190
+ - Permission issues, file/directory access denied
191
+ - SSL/TLS certificate problems
127
192
 
128
193
  ## Instructions
129
194
 
130
- 1. **First, check if this issue still exists in the code**
195
+ 1. **Classify the error category first**
196
+ - If infrastructure or configuration: provide remediation_guidance for the user
197
+ - Only code errors will proceed to the fix phase
198
+
199
+ 2. **Check if this issue still exists in the code** (for code errors)
131
200
  - Look at the file(s) mentioned in the stack trace
132
- - Determine if the error condition is still present
133
- - If the code has been modified and the issue is gone, report it as already_fixed
201
+ - If the code has been fixed, report already_fixed: true
202
+
203
+ 3. **Trace the root cause** (not just the symptom):
204
+ - Ask WHY the error occurs, not just WHERE
205
+ - Common root causes to check:
206
+ - Type mismatches in comparisons (e.g., comparing incompatible types)
207
+ - Failed lookups due to incorrect comparison logic
208
+ - Missing type conversions on input values
209
+ - Follow the data flow from source to error location
210
+ - The fix should address the underlying cause, not just guard against the symptom
134
211
 
135
- 2. If the issue still exists:
136
- - Investigate the project structure to understand the codebase
137
- - Identify the root cause of this error
138
- - Determine what files need to be modified
139
- - Assess your confidence in the fix
212
+ 4. **Determine the minimal fix location**:
213
+ - Fix at the point where the bug originates, not where it manifests
214
+ - For type issues: convert types at the source
215
+ - For unhandled errors: add error handling at the CALL SITE only
216
+ - Do NOT modify functions that throw errors - handle errors where they are called
217
+
218
+ ## Anti-patterns to Avoid
219
+ - Adding null/undefined checks that mask the real bug (e.g., the check passes but the lookup logic is still wrong)
220
+ - Modifying error-throwing functions instead of handling at call sites
221
+ - Adding environment variables, feature flags, or extra parameters
222
+ - Refactoring or improving code beyond the specific fix
140
223
 
141
224
  Write your analysis to: \`${analysisPath}\`
142
225
 
143
226
  Use this exact YAML format:
144
227
  \`\`\`yaml
145
228
  already_fixed: true | false
229
+ category: code | infrastructure | configuration
146
230
  summary: One sentence summary of the problem (or that it was already fixed)
147
- # The following fields are only required if already_fixed is false:
231
+
232
+ # For code errors (category: code), include these fields:
148
233
  root_cause: |
149
234
  Detailed explanation of root cause
150
235
  Can be multiple lines
@@ -155,6 +240,13 @@ files_to_modify:
155
240
  - path/to/file1
156
241
  - path/to/file2
157
242
  confidence: high | medium | low
243
+
244
+ # For non-code errors (category: infrastructure or configuration), include:
245
+ remediation_guidance: |
246
+ Steps the user should take to resolve this issue.
247
+ Be specific about what to check or change.
248
+ Example: "Start the PostgreSQL container with: docker-compose up -d postgres"
249
+ confidence: high | medium | low
158
250
  \`\`\`
159
251
 
160
252
  ## Constraints
@@ -163,11 +255,12 @@ confidence: high | medium | low
163
255
  - Be specific about file paths relative to project root
164
256
  - Set already_fixed to true if the issue no longer exists in the code (e.g., fixed by a previous error fix)
165
257
  - WARNING: If a fix fails and is retried, any file modifications from previous attempts will persist
258
+ - For infrastructure/configuration errors, provide actionable remediation_guidance
166
259
  `;
167
260
  };
168
261
  const buildFixContent = (options) => {
169
262
  const { projectName, projectRoot, error, attempt, date, stackTrace, analysis } = options;
170
- const resultPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt}-result.yaml`);
263
+ const resultPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt + 1}-result.yaml`);
171
264
  return `# WatchFix Task
172
265
 
173
266
  ## Mode
@@ -182,7 +275,7 @@ fix
182
275
  - Source: ${error.source}
183
276
  - Type: ${error.errorType}
184
277
  - Detected: ${error.timestamp}
185
- - Fix Attempts: ${error.fixAttempts}
278
+ - Fix Attempts: ${error.fixAttempts + 1}
186
279
 
187
280
  ### Message
188
281
  ${error.message}
@@ -215,8 +308,13 @@ notes: |
215
308
  \`\`\`
216
309
 
217
310
  ## Constraints
218
- - Make the smallest change that resolves the issue
219
- - Do NOT change unrelated code
311
+ - Make the SMALLEST change that fixes the ROOT CAUSE
312
+ - Fix the bug where it originates, not where symptoms appear
313
+ - Do NOT add defensive checks that mask the real bug
314
+ - Do NOT modify functions that throw errors - add handling at call sites
315
+ - Do NOT add environment variables, feature flags, or new parameters
316
+ - Do NOT refactor, improve, or clean up code beyond the fix
317
+ - If touching more than 1-2 files or 10 lines, reconsider your approach
220
318
  - If the fix cannot be applied, set success to false and explain in notes
221
319
  - WARNING: If this fix fails verification, the modified files will remain changed for the next retry attempt
222
320
  `;
@@ -263,9 +361,9 @@ const ensureSizeLimit = (options) => {
263
361
  }
264
362
  return { content, truncatedLines, beforeLines, afterLines, stackTrace };
265
363
  };
266
- export const generateAnalyzeContext = (error, config, attempt) => {
364
+ export const generateAnalyzeContext = (error, config, attempt, retryContext) => {
267
365
  const date = formatDate();
268
- const contextPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt}-analyze.md`);
366
+ const contextPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt + 1}-analyze.md`);
269
367
  const maxBytes = config.cleanup.context_max_size_kb * 1024;
270
368
  const { before, after } = splitRawLog(error);
271
369
  const buildContent = (stackTraceValue, beforeLines, afterLines, truncated) => {
@@ -281,6 +379,7 @@ export const generateAnalyzeContext = (error, config, attempt) => {
281
379
  date,
282
380
  stackTrace: stackTraceValue,
283
381
  contextBlock: buildContextBlock(beforeLines, errorLines, afterLines, truncated),
382
+ retryContext,
284
383
  });
285
384
  };
286
385
  const stackTrace = truncateStackTrace(error.stackTrace ?? '');
@@ -345,7 +444,7 @@ const ensureFixSizeLimit = (options) => {
345
444
  };
346
445
  export const generateFixContext = (error, analysis, config, attempt) => {
347
446
  const date = formatDate();
348
- const contextPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt}-fix.md`);
447
+ const contextPath = path.posix.join(CONTEXT_DIR, `${date}-error-${error.id}-attempt-${attempt + 1}-fix.md`);
349
448
  const maxBytes = config.cleanup.context_max_size_kb * 1024;
350
449
  const stackTrace = truncateStackTrace(error.stackTrace ?? '');
351
450
  const render = (analysisValue, stackTraceValue) => buildFixContent({
@@ -25,6 +25,29 @@ type FixOrchestratorOptions = {
25
25
  logger?: Logger;
26
26
  terminalEnabled?: boolean;
27
27
  };
28
+ export type RetryContext = {
29
+ previousAttempt: {
30
+ analysis?: {
31
+ summary: string;
32
+ suggested_fix: string;
33
+ files_to_modify: string[];
34
+ };
35
+ fix?: {
36
+ success: boolean;
37
+ summary: string;
38
+ files_changed?: Array<{
39
+ path: string;
40
+ change: string;
41
+ }>;
42
+ };
43
+ verification_failure?: {
44
+ type: string;
45
+ command?: string;
46
+ message?: string;
47
+ stderr?: string;
48
+ };
49
+ };
50
+ };
28
51
  export declare class FixOrchestrator {
29
52
  private readonly db;
30
53
  private readonly config;
@@ -51,9 +51,24 @@ const parseSuggestion = (value) => {
51
51
  suggested_fix: parsed.suggested_fix ?? '',
52
52
  files_to_modify: parsed.files_to_modify ?? [],
53
53
  confidence: parsed.confidence,
54
+ category: parsed.category,
55
+ remediation_guidance: parsed.remediation_guidance,
54
56
  };
55
57
  }
56
- // For regular analyses, ensure all fields are present
58
+ // For non-code categories, other fields may be empty
59
+ if (parsed.category && parsed.category !== 'code') {
60
+ return {
61
+ already_fixed: false,
62
+ summary: parsed.summary,
63
+ root_cause: parsed.root_cause ?? '',
64
+ suggested_fix: parsed.suggested_fix ?? '',
65
+ files_to_modify: parsed.files_to_modify ?? [],
66
+ confidence: parsed.confidence,
67
+ category: parsed.category,
68
+ remediation_guidance: parsed.remediation_guidance,
69
+ };
70
+ }
71
+ // For regular code analyses, ensure all fields are present
57
72
  if (typeof parsed.root_cause !== 'string' ||
58
73
  typeof parsed.suggested_fix !== 'string' ||
59
74
  !Array.isArray(parsed.files_to_modify)) {
@@ -66,8 +81,41 @@ const parseSuggestion = (value) => {
66
81
  suggested_fix: parsed.suggested_fix,
67
82
  files_to_modify: parsed.files_to_modify,
68
83
  confidence: parsed.confidence,
84
+ category: parsed.category,
85
+ remediation_guidance: parsed.remediation_guidance,
69
86
  };
70
87
  };
88
+ const buildRetryContext = (error) => {
89
+ if (error.fixAttempts === 0)
90
+ return undefined;
91
+ const previousAttempt = {};
92
+ // Extract analysis from suggestion
93
+ if (error.suggestion) {
94
+ try {
95
+ const parsed = JSON.parse(error.suggestion);
96
+ if (!parsed.error && parsed.summary) {
97
+ previousAttempt.analysis = {
98
+ summary: parsed.summary,
99
+ suggested_fix: parsed.suggested_fix || '',
100
+ files_to_modify: parsed.files_to_modify || [],
101
+ };
102
+ }
103
+ }
104
+ catch { /* ignore */ }
105
+ }
106
+ // Extract fix result and verification failure
107
+ if (error.fixResult) {
108
+ try {
109
+ const parsed = JSON.parse(error.fixResult);
110
+ if (parsed.fix)
111
+ previousAttempt.fix = parsed.fix;
112
+ if (parsed.verification_failure)
113
+ previousAttempt.verification_failure = parsed.verification_failure;
114
+ }
115
+ catch { /* ignore */ }
116
+ }
117
+ return Object.keys(previousAttempt).length > 0 ? { previousAttempt } : undefined;
118
+ };
71
119
  export class FixOrchestrator {
72
120
  db;
73
121
  config;
@@ -151,8 +199,9 @@ export class FixOrchestrator {
151
199
  throw new UserError(`Failed to transition error ${errorId} into analyzing status`);
152
200
  }
153
201
  logActivity(this.db, 'analysis_start', errorId, JSON.stringify({ attempt: attempts, lockId }));
154
- this.logger.info(`Analyzing error ${errorId} (attempt ${attempts})...`);
155
- const context = generateAnalyzeContext(error, this.config, attempts);
202
+ this.logger.info(`Analyzing error ${errorId} (attempt ${attempts + 1})...`);
203
+ const retryContext = buildRetryContext(error);
204
+ const context = generateAnalyzeContext(error, this.config, attempts, retryContext);
156
205
  const contextPath = path.resolve(this.config.project.root, context.path);
157
206
  await fs.mkdir(path.dirname(contextPath), { recursive: true });
158
207
  await fs.writeFile(contextPath, context.content, 'utf8');
@@ -185,6 +234,34 @@ export class FixOrchestrator {
185
234
  message: 'Issue already fixed',
186
235
  };
187
236
  }
237
+ // Handle non-code errors by transitioning to deferred status
238
+ const category = analysisOutput.category ?? 'code';
239
+ if (category !== 'code') {
240
+ if (!transitionStatus(this.db, errorId, 'analyzing', 'deferred', lockId)) {
241
+ throw new UserError(`Failed to transition error ${errorId} into deferred status`);
242
+ }
243
+ this.db.run('UPDATE errors SET suggestion = ?, updated_at = ? WHERE id = ?', [
244
+ JSON.stringify(analysisOutput),
245
+ new Date().toISOString(),
246
+ errorId,
247
+ ]);
248
+ logActivity(this.db, 'error_deferred', errorId, JSON.stringify({
249
+ attempt: attempts,
250
+ category,
251
+ summary: analysisOutput.summary,
252
+ }));
253
+ this.logger.info(`Error ${errorId} deferred (category=${category}): ${analysisOutput.summary}`);
254
+ await releaseLock(this.db, errorId, lockId);
255
+ lockHeld = false;
256
+ return {
257
+ errorId,
258
+ status: 'deferred',
259
+ lockAcquired: true,
260
+ attempts,
261
+ analysis: analysisOutput,
262
+ message: `Error deferred (${category})`,
263
+ };
264
+ }
188
265
  if (!transitionStatus(this.db, errorId, 'analyzing', 'suggested', lockId)) {
189
266
  throw new UserError(`Failed to transition error ${errorId} into suggested status`);
190
267
  }
@@ -279,7 +356,7 @@ export class FixOrchestrator {
279
356
  throw new UserError(`Failed to transition error ${errorId} into fixing status`);
280
357
  }
281
358
  logActivity(this.db, 'fix_start', errorId, JSON.stringify({ attempt: attempts, lockId }));
282
- this.logger.info(`Applying fix for error ${errorId} (attempt ${attempts})...`);
359
+ this.logger.info(`Applying fix for error ${errorId} (attempt ${attempts + 1})...`);
283
360
  const analysisYaml = analysisToYaml(analysisOutput);
284
361
  const fixContext = generateFixContext(error, analysisYaml, this.config, attempts);
285
362
  const fixContextPath = path.resolve(this.config.project.root, fixContext.path);
@@ -362,14 +439,16 @@ export class FixOrchestrator {
362
439
  if (!transitionStatus(this.db, errorId, 'fixing', 'fixed', lockId)) {
363
440
  throw new UserError(`Failed to transition error ${errorId} into fixed status`);
364
441
  }
365
- logActivity(this.db, 'verification_pass', errorId, JSON.stringify({ attempt: attempts }));
442
+ const newAttempts = attempts + 1;
443
+ this.db.run('UPDATE errors SET fix_attempts = ?, updated_at = ? WHERE id = ?', [newAttempts, new Date().toISOString(), errorId]);
444
+ logActivity(this.db, 'verification_pass', errorId, JSON.stringify({ attempt: newAttempts }));
366
445
  await releaseLock(this.db, errorId, lockId);
367
446
  lockHeld = false;
368
447
  return {
369
448
  errorId,
370
449
  status: 'fixed',
371
450
  lockAcquired: true,
372
- attempts,
451
+ attempts: newAttempts,
373
452
  analysis: analysisOutput,
374
453
  fix: fixOutput,
375
454
  verification: verificationResult,
@@ -382,7 +461,18 @@ export class FixOrchestrator {
382
461
  if (!transitionStatus(this.db, errorId, 'fixing', nextStatus, lockId)) {
383
462
  throw new UserError(`Failed to transition error ${errorId} after verification failure`);
384
463
  }
385
- this.db.run('UPDATE errors SET fix_attempts = ?, updated_at = ? WHERE id = ?', [newAttempts, new Date().toISOString(), errorId]);
464
+ const failureRecord = {
465
+ fix: fixOutput,
466
+ verification_failure: verificationResult.failure ? {
467
+ type: verificationResult.failure.type,
468
+ command: verificationResult.failure.type === 'command'
469
+ ? verificationResult.failure.command : undefined,
470
+ message: verificationResult.failure.message,
471
+ stderr: verificationResult.failure.type === 'command'
472
+ ? verificationResult.failure.stderr?.slice(0, 4096) : undefined,
473
+ } : undefined,
474
+ };
475
+ this.db.run('UPDATE errors SET fix_result = ?, fix_attempts = ?, updated_at = ? WHERE id = ?', [JSON.stringify(failureRecord), newAttempts, new Date().toISOString(), errorId]);
386
476
  logActivity(this.db, 'verification_fail', errorId, JSON.stringify({ attempt: attempts, failure: verificationResult.failure }));
387
477
  await releaseLock(this.db, errorId, lockId);
388
478
  lockHeld = false;
@@ -1,4 +1,5 @@
1
1
  type ConfidenceLevel = 'high' | 'medium' | 'low';
2
+ type ErrorCategory = 'code' | 'infrastructure' | 'configuration';
2
3
  type AnalysisOutput = {
3
4
  already_fixed: boolean;
4
5
  summary: string;
@@ -6,6 +7,8 @@ type AnalysisOutput = {
6
7
  suggested_fix: string;
7
8
  files_to_modify: string[];
8
9
  confidence: ConfidenceLevel;
10
+ category?: ErrorCategory;
11
+ remediation_guidance?: string;
9
12
  };
10
13
  type FixOutput = {
11
14
  success: boolean;
@@ -16,7 +19,18 @@ type FixOutput = {
16
19
  }>;
17
20
  notes?: string;
18
21
  };
22
+ type StoredFixResult = {
23
+ fix?: FixOutput;
24
+ verification_failure?: {
25
+ type: 'command' | 'health_check';
26
+ command?: string;
27
+ message?: string;
28
+ stderr?: string;
29
+ };
30
+ error?: boolean;
31
+ diagnostic?: string;
32
+ };
19
33
  declare const parseAnalysisOutput: (content: string) => AnalysisOutput;
20
34
  declare const parseFixOutput: (content: string) => FixOutput;
21
- export type { AnalysisOutput, FixOutput, ConfidenceLevel };
35
+ export type { AnalysisOutput, FixOutput, ConfidenceLevel, ErrorCategory, StoredFixResult };
22
36
  export { parseAnalysisOutput, parseFixOutput };
@@ -66,12 +66,30 @@ const validateConfidence = (value) => {
66
66
  }
67
67
  throw new UserError(`Invalid confidence value: ${value ?? 'undefined'} (expected high|medium|low)`);
68
68
  };
69
+ const validateCategory = (value) => {
70
+ if (value === 'code' || value === 'infrastructure' || value === 'configuration') {
71
+ return value;
72
+ }
73
+ throw new UserError(`Invalid category value: ${value ?? 'undefined'} (expected code|infrastructure|configuration)`);
74
+ };
69
75
  const parseAnalysisOutput = (content) => {
70
76
  const raw = parseYaml(content);
71
77
  const data = assertRecord(raw);
72
78
  const already_fixed = data.already_fixed === true;
73
79
  const summary = requireStringField(data, 'summary');
74
80
  const confidence = validateConfidence(data.confidence);
81
+ // Parse category, defaulting to 'code' if not specified (backwards compat)
82
+ const category = data.category !== undefined
83
+ ? validateCategory(data.category)
84
+ : 'code';
85
+ // Parse remediation_guidance if present
86
+ let remediation_guidance;
87
+ if (data.remediation_guidance !== undefined) {
88
+ if (typeof data.remediation_guidance !== 'string') {
89
+ throw new UserError('Invalid field: remediation_guidance must be a string');
90
+ }
91
+ remediation_guidance = data.remediation_guidance;
92
+ }
75
93
  if (already_fixed) {
76
94
  return {
77
95
  already_fixed: true,
@@ -80,8 +98,31 @@ const parseAnalysisOutput = (content) => {
80
98
  suggested_fix: '',
81
99
  files_to_modify: [],
82
100
  confidence,
101
+ category,
102
+ remediation_guidance,
103
+ };
104
+ }
105
+ // For non-code categories, require remediation_guidance but allow empty files_to_modify
106
+ if (category !== 'code') {
107
+ if (!remediation_guidance || remediation_guidance.trim() === '') {
108
+ throw new UserError(`Missing required field: remediation_guidance (required for ${category} errors)`);
109
+ }
110
+ // files_to_modify can be empty for non-code errors
111
+ const files_to_modify = data.files_to_modify !== undefined
112
+ ? requireStringArrayField(data, 'files_to_modify')
113
+ : [];
114
+ return {
115
+ already_fixed: false,
116
+ summary,
117
+ root_cause: typeof data.root_cause === 'string' ? data.root_cause : '',
118
+ suggested_fix: typeof data.suggested_fix === 'string' ? data.suggested_fix : '',
119
+ files_to_modify,
120
+ confidence,
121
+ category,
122
+ remediation_guidance,
83
123
  };
84
124
  }
125
+ // For code category, require root_cause, suggested_fix, files_to_modify
85
126
  const root_cause = requireStringField(data, 'root_cause');
86
127
  const suggested_fix = requireStringField(data, 'suggested_fix');
87
128
  const files_to_modify = requireStringArrayField(data, 'files_to_modify');
@@ -92,6 +133,8 @@ const parseAnalysisOutput = (content) => {
92
133
  suggested_fix,
93
134
  files_to_modify,
94
135
  confidence,
136
+ category,
137
+ remediation_guidance,
95
138
  };
96
139
  };
97
140
  const parseFixOutput = (content) => {
@@ -6,7 +6,7 @@ export declare class InternalError extends Error {
6
6
  cause?: unknown;
7
7
  });
8
8
  }
9
- export type ErrorStatus = 'pending' | 'analyzing' | 'suggested' | 'fixing' | 'fixed' | 'failed' | 'ignored' | 'resolved';
9
+ export type ErrorStatus = 'pending' | 'analyzing' | 'suggested' | 'fixing' | 'fixed' | 'failed' | 'ignored' | 'resolved' | 'deferred';
10
10
  export declare const EXIT_CODES: {
11
11
  readonly SUCCESS: 0;
12
12
  readonly GENERAL_ERROR: 1;
@@ -13,4 +13,4 @@ export interface SpawnResult {
13
13
  }
14
14
  export declare function checkCliExists(command: string): CliCheckResult;
15
15
  export declare function spawnWithTimeout(command: string, args: string[], options: SpawnOptions | undefined, timeoutMs: number): Promise<SpawnResult>;
16
- export declare function isOurProcess(pid: number, expectedRoot: string): boolean;
16
+ export declare function isOurProcess(pid: number): boolean;
@@ -126,7 +126,7 @@ function readCommandLine(pid) {
126
126
  });
127
127
  return `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
128
128
  }
129
- export function isOurProcess(pid, expectedRoot) {
129
+ export function isOurProcess(pid) {
130
130
  try {
131
131
  process.kill(pid, 0);
132
132
  }
@@ -138,7 +138,8 @@ export function isOurProcess(pid, expectedRoot) {
138
138
  if (!cmdline) {
139
139
  return false;
140
140
  }
141
- return cmdline.includes('watchfix') && cmdline.includes(expectedRoot);
141
+ // Check for 'watchfix' in command line, or 'index.js' for dev mode
142
+ return cmdline.includes('watchfix') || cmdline.includes('index.js');
142
143
  }
143
144
  catch {
144
145
  return false;
@@ -203,6 +203,25 @@ export class WatcherOrchestrator {
203
203
  return;
204
204
  }
205
205
  }
206
+ // Handle deferred errors with grace period
207
+ // Within grace period: deduplicate
208
+ // After grace period: create new error for re-analysis
209
+ if (existing && existing.status === 'deferred') {
210
+ const gracePeriodMs = parseDuration(this.config.deduplication.deferred_grace_period);
211
+ const deferredAt = new Date(existing.updatedAt).getTime();
212
+ if (Date.now() - deferredAt < gracePeriodMs) {
213
+ logActivity(this.db, 'error_deduplicated', existing.id, `status=${existing.status} grace_period=true`);
214
+ this.logger.info(`Deduplicated error ${error.hash} (within grace period after deferral)`);
215
+ this.emitter.emit('error_deduplicated', {
216
+ errorId: existing.id,
217
+ error,
218
+ status: existing.status,
219
+ });
220
+ return;
221
+ }
222
+ // Grace period expired - fall through to create new error for re-analysis
223
+ this.logger.info(`Deferred error ${error.hash} reappeared after grace period - creating new error for re-analysis`);
224
+ }
206
225
  const newId = insertError(this.db, {
207
226
  hash: error.hash,
208
227
  source: error.source,
@@ -216,11 +235,18 @@ export class WatcherOrchestrator {
216
235
  suggestion: null,
217
236
  fixResult: null,
218
237
  });
219
- const details = existing ? `recurrence_of=${existing.id}` : undefined;
220
- logActivity(this.db, 'error_detected', newId, details);
221
- this.logger.info(existing
222
- ? `Recurring error detected (${newId}) from ${error.source}`
223
- : `New error detected (${newId}) from ${error.source}`);
238
+ // Log appropriate activity based on previous status
239
+ if (existing && existing.status === 'deferred') {
240
+ logActivity(this.db, 'deferred_reanalyzed', newId, `previous_id=${existing.id}`);
241
+ this.logger.info(`Deferred error re-analyzed (${newId}) from ${error.source}`);
242
+ }
243
+ else {
244
+ const details = existing ? `recurrence_of=${existing.id}` : undefined;
245
+ logActivity(this.db, 'error_detected', newId, details);
246
+ this.logger.info(existing
247
+ ? `Recurring error detected (${newId}) from ${error.source}`
248
+ : `New error detected (${newId}) from ${error.source}`);
249
+ }
224
250
  this.emitter.emit('error_detected', {
225
251
  errorId: newId,
226
252
  error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchfix",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool that watches logs, detects errors, and dispatches AI agents to fix them",
5
5
  "keywords": [
6
6
  "cli",