watchfix 0.3.0 → 0.5.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/HELP.md ADDED
@@ -0,0 +1,416 @@
1
+ # watchfix - Agent Reference
2
+
3
+ > Run `watchfix manual` to display this document.
4
+
5
+ ## Quick Context
6
+
7
+ watchfix is a CLI tool that watches log files, detects errors via configurable patterns, and dispatches AI agents (Claude, Gemini, or Codex) to analyze and fix them automatically. Prerequisites: a `watchfix.yaml` config file in your project root, one of the supported AI CLIs installed and in PATH, and at least one log source configured. All state is stored locally in `.watchfix/errors.db` (SQLite).
8
+
9
+ ## Commands
10
+
11
+ ### Setup Commands
12
+
13
+ #### `watchfix init`
14
+
15
+ Create a `watchfix.yaml` config file in the current directory.
16
+
17
+ ```
18
+ watchfix init [--agent <provider>] [--force]
19
+ ```
20
+
21
+ | Flag | Description |
22
+ |------|-------------|
23
+ | `--agent <provider>` | Set agent provider: `claude`, `gemini`, or `codex` (default: `claude`) |
24
+ | `--force` | Overwrite existing `watchfix.yaml` |
25
+
26
+ Output: writes `watchfix.yaml` and adds `.watchfix/` to `.gitignore`.
27
+
28
+ #### `watchfix config validate`
29
+
30
+ Validate the configuration file.
31
+
32
+ ```
33
+ watchfix config validate [-c <path>]
34
+ ```
35
+
36
+ Exit code 0 if valid, 1 if invalid (error message on stderr).
37
+
38
+ ### Watching Commands
39
+
40
+ #### `watchfix watch`
41
+
42
+ Start watching configured log sources for errors.
43
+
44
+ ```
45
+ watchfix watch [--daemon] [--autonomous]
46
+ ```
47
+
48
+ | Flags | Mode | Behavior |
49
+ |-------|------|----------|
50
+ | _(none)_ | Foreground, manual | Detects errors, queues for manual `fix` |
51
+ | `--autonomous` | Foreground, autonomous | Detects and auto-fixes without approval |
52
+ | `--daemon` | Background, manual | Runs detached; logs to activity log only |
53
+ | `--daemon --autonomous` | Background, autonomous | Runs detached; auto-fixes in background |
54
+
55
+ Exit code 2 if a watcher is already running.
56
+
57
+ #### `watchfix stop`
58
+
59
+ Stop a running background watcher.
60
+
61
+ ```
62
+ watchfix stop
63
+ ```
64
+
65
+ Exit code 2 if no watcher is running.
66
+
67
+ #### `watchfix status`
68
+
69
+ Show watcher state and pending errors.
70
+
71
+ ```
72
+ watchfix status
73
+ ```
74
+
75
+ Output format (plain text, one error per line):
76
+
77
+ ```
78
+ Watcher: running (pid 12345, mode: autonomous)
79
+ Uptime: 2h 15m
80
+
81
+ Errors:
82
+ #1 [pending] TypeError: Cannot read property 'x' of null (app)
83
+ #3 [suggested] ReferenceError: foo is not defined (api)
84
+ #7 [fixing] FATAL: connection refused (db)
85
+ ```
86
+
87
+ When no errors exist: `No errors recorded.`
88
+ When watcher is not running: `Watcher: not running`
89
+
90
+ ### Error Management Commands
91
+
92
+ #### `watchfix show <id>`
93
+
94
+ Show full error details and analysis.
95
+
96
+ ```
97
+ watchfix show <id> [--json]
98
+ ```
99
+
100
+ | Flag | Description |
101
+ |------|-------------|
102
+ | `--json` | Output machine-readable JSON (see JSON Output Format below) |
103
+
104
+ Without `--json`, outputs human-readable text with error metadata, stack trace, analysis, fix result, and activity log.
105
+
106
+ #### `watchfix fix [id]`
107
+
108
+ Analyze and fix an error. Without an id, requires `--all`.
109
+
110
+ ```
111
+ watchfix fix <id> [-y] [--analyze-only] [--reanalyze]
112
+ watchfix fix --all [--confirm-each] [--analyze-only] [--reanalyze]
113
+ ```
114
+
115
+ | Flag | Description |
116
+ |------|-------------|
117
+ | `-y, --yes` | Skip confirmation prompt |
118
+ | `--all` | Fix all pending/suggested errors sequentially |
119
+ | `--confirm-each` | With `--all`: prompt before each fix |
120
+ | `--analyze-only` | Stop after analysis, don't apply the fix |
121
+ | `--reanalyze` | Force re-analysis even if already in `suggested` status |
122
+
123
+ Exit code 3 if the error is not in an actionable status or not found.
124
+
125
+ #### `watchfix ignore <id>`
126
+
127
+ Mark an error as ignored. It will not be processed further.
128
+
129
+ ```
130
+ watchfix ignore <id>
131
+ ```
132
+
133
+ ### Utility Commands
134
+
135
+ #### `watchfix logs`
136
+
137
+ Show the activity log.
138
+
139
+ ```
140
+ watchfix logs [--tail] [-n <count>]
141
+ ```
142
+
143
+ | Flag | Description |
144
+ |------|-------------|
145
+ | `--tail` | Follow the log (stream new entries) |
146
+ | `-n, --lines <count>` | Number of lines to show (default: 50) |
147
+
148
+ #### `watchfix clean`
149
+
150
+ Remove old context files from `.watchfix/context/`.
151
+
152
+ ```
153
+ watchfix clean [--dry-run] [--force]
154
+ ```
155
+
156
+ | Flag | Description |
157
+ |------|-------------|
158
+ | `--dry-run` | Show what would be removed without deleting |
159
+ | `--force` | Skip confirmation prompt |
160
+
161
+ #### `watchfix version`
162
+
163
+ Show version, Node.js version, config status, and agent provider.
164
+
165
+ ```
166
+ watchfix version
167
+ ```
168
+
169
+ #### `watchfix manual`
170
+
171
+ Output this reference document to stdout.
172
+
173
+ ```
174
+ watchfix manual
175
+ ```
176
+
177
+ ### Global Options
178
+
179
+ These flags work with all commands:
180
+
181
+ | Flag | Description |
182
+ |------|-------------|
183
+ | `-c, --config <path>` | Use alternate config file (default: `./watchfix.yaml`) |
184
+ | `--verbose` | Increase output verbosity |
185
+ | `-q, --quiet` | Suppress non-essential output |
186
+ | `-h, --help` | Show help for command |
187
+ | `-v, --version` | Show version and exit |
188
+
189
+ ## Common Workflows
190
+
191
+ ### Check if watchfix is set up
192
+
193
+ ```bash
194
+ # Look for config file
195
+ ls watchfix.yaml
196
+
197
+ # Validate it
198
+ watchfix config validate
199
+ ```
200
+
201
+ ### Start watching and monitor
202
+
203
+ ```bash
204
+ # Start watcher in foreground
205
+ watchfix watch
206
+
207
+ # Or in background (Linux/macOS)
208
+ watchfix watch --daemon
209
+
210
+ # Poll for errors
211
+ watchfix status
212
+
213
+ # Stream activity log
214
+ watchfix logs --tail
215
+ ```
216
+
217
+ ### Investigate and fix one error
218
+
219
+ ```bash
220
+ # See what errors exist
221
+ watchfix status
222
+
223
+ # Get full details (machine-readable)
224
+ watchfix show 3 --json
225
+
226
+ # Fix it (with confirmation)
227
+ watchfix fix 3
228
+
229
+ # Or skip confirmation
230
+ watchfix fix 3 -y
231
+ ```
232
+
233
+ ### Fix all pending errors
234
+
235
+ ```bash
236
+ watchfix fix --all
237
+ ```
238
+
239
+ ### Fully automated operation
240
+
241
+ ```bash
242
+ # Start autonomous daemon
243
+ watchfix watch --daemon --autonomous
244
+
245
+ # Monitor progress
246
+ watchfix status
247
+ watchfix logs --tail
248
+
249
+ # Stop when done
250
+ watchfix stop
251
+ ```
252
+
253
+ ### Analyze without fixing
254
+
255
+ ```bash
256
+ # Get analysis only (no code changes)
257
+ watchfix fix 3 --analyze-only
258
+
259
+ # View the analysis
260
+ watchfix show 3
261
+ ```
262
+
263
+ ## Exit Codes
264
+
265
+ | Code | Constant | Meaning |
266
+ |------|----------|---------|
267
+ | 0 | `SUCCESS` | Command completed successfully |
268
+ | 1 | `GENERAL_ERROR` | General error (invalid config, agent failure, etc.) |
269
+ | 2 | `WATCHER_CONFLICT` | Watcher state conflict (already running / not running) |
270
+ | 3 | `NOT_ACTIONABLE` | Target not actionable (error not found, wrong status, locked) |
271
+ | 4 | `SCHEMA_MISMATCH` | Database schema version mismatch (requires migration) |
272
+ | 130 | `INTERRUPTED` | Interrupted by user (SIGINT / Ctrl+C) |
273
+
274
+ ## Error Statuses
275
+
276
+ ### Status Lifecycle
277
+
278
+ ```
279
+ pending → analyzing → suggested → fixing → fixed
280
+ → failed (after max attempts)
281
+ Any status → ignored (via watchfix ignore)
282
+ ```
283
+
284
+ ### Status Reference
285
+
286
+ | Status | Description | Actionable? |
287
+ |--------|-------------|-------------|
288
+ | `pending` | Detected, awaiting analysis | Yes — `fix` will start analysis |
289
+ | `analyzing` | Agent is currently analyzing | No — locked |
290
+ | `suggested` | Analysis complete, awaiting fix | Yes — `fix` will apply the suggestion |
291
+ | `fixing` | Agent is currently applying fix | No — locked |
292
+ | `fixed` | Fix applied and verified | No — terminal |
293
+ | `failed` | Max attempts exceeded | Manual retry only (`fix <id>`, excluded from `--all`) |
294
+ | `ignored` | User chose to ignore | No — terminal |
295
+ | `resolved` | Fixed by a previous fix to another error | No — terminal |
296
+ | `deferred` | Non-code issue (infrastructure/config) | No — shows remediation guidance |
297
+
298
+ ### Deduplication
299
+
300
+ When a new error matches an existing one (by hash):
301
+ - If existing is `pending`, `analyzing`, `suggested`, or `fixing`: new error is dropped
302
+ - If existing is `fixed`, `failed`, or `ignored`: a new entry is created (error recurred)
303
+
304
+ ## JSON Output Format
305
+
306
+ `watchfix show <id> --json` returns a JSON object with this shape:
307
+
308
+ ```json
309
+ {
310
+ "error": {
311
+ "id": 1,
312
+ "hash": "abc123...",
313
+ "source": "app",
314
+ "timestamp": "2026-01-29T12:00:00.000Z",
315
+ "errorType": "TypeError",
316
+ "message": "Cannot read property 'x' of null",
317
+ "stackTrace": "TypeError: Cannot read property...\n at foo (src/app.ts:10:5)\n ...",
318
+ "rawLog": "[2026-01-29 12:00:00] ERROR TypeError: Cannot read property...",
319
+ "status": "suggested",
320
+ "suggestion": "{\"summary\":\"...\",\"root_cause\":\"...\",\"suggested_fix\":\"...\"}",
321
+ "fixResult": null,
322
+ "fixAttempts": 0,
323
+ "lockedBy": null,
324
+ "lockedAt": null,
325
+ "createdAt": "2026-01-29T12:00:01.000Z",
326
+ "updatedAt": "2026-01-29T12:00:05.000Z"
327
+ },
328
+ "analysis": {
329
+ "summary": "Null pointer access in foo()",
330
+ "root_cause": "Variable 'x' is not initialized before access",
331
+ "suggested_fix": "Add null check before accessing property",
332
+ "files_to_modify": ["src/app.ts"],
333
+ "confidence": "high",
334
+ "category": "code"
335
+ },
336
+ "fixResult": null,
337
+ "activityLog": [
338
+ {
339
+ "id": 1,
340
+ "timestamp": "2026-01-29T12:00:01.000Z",
341
+ "action": "error_detected",
342
+ "error_id": 1,
343
+ "details": "TypeError: Cannot read property 'x' of null"
344
+ }
345
+ ]
346
+ }
347
+ ```
348
+
349
+ ### Key Fields
350
+
351
+ - `error.status` — current lifecycle status (see Error Statuses above)
352
+ - `error.suggestion` — raw JSON string of agent analysis (parse it to get the `analysis` object)
353
+ - `error.fixResult` — raw JSON string of fix result (null if not yet fixed)
354
+ - `analysis` — parsed suggestion object; `null` if not yet analyzed
355
+ - `analysis.category` — `"code"`, `"infrastructure"`, or `"configuration"`
356
+ - `analysis.confidence` — `"high"`, `"medium"`, or `"low"`
357
+ - `analysis.remediation_guidance` — present for `deferred` (non-code) errors
358
+ - `fixResult` — parsed fix result object; `null` if not yet fixed
359
+ - `fixResult.success` — boolean indicating if verification passed
360
+ - `fixResult.files_changed` — array of `{ "path": "...", "change": "..." }`
361
+ - `activityLog` — chronological list of actions taken on this error
362
+
363
+ ## Configuration Quick Reference
364
+
365
+ watchfix uses a `watchfix.yaml` file in the project root. Run `watchfix init` to generate a template.
366
+
367
+ ### Key Sections
368
+
369
+ ```yaml
370
+ project:
371
+ name: my-app # Project identifier
372
+ root: . # Project root (paths resolve relative to this)
373
+
374
+ agent:
375
+ provider: claude # Required: claude | gemini | codex
376
+ timeout: 5m # Max agent execution time (default: 5m)
377
+ retries: 2 # Retries on agent timeout/crash (default: 2)
378
+
379
+ logs:
380
+ sources: # At least one source required
381
+ - name: app # Source identifier
382
+ type: file # file | docker | command
383
+ path: ./logs/app.log
384
+ context_lines_before: 10
385
+ context_lines_after: 5
386
+
387
+ verification:
388
+ test_commands: # Run after fix, in order; stop on first failure
389
+ - npm run lint
390
+ - npm test
391
+ health_checks: # HTTP GET, expect 2xx
392
+ - http://localhost:3000/health
393
+
394
+ patterns:
395
+ match: # Additional error patterns
396
+ - "FATAL:"
397
+ - "regex:OOM.*killed"
398
+ ignore: # Patterns to skip
399
+ - "DeprecationWarning"
400
+
401
+ limits:
402
+ max_attempts_per_error: 3
403
+
404
+ cleanup:
405
+ context_max_age_days: 7
406
+ ```
407
+
408
+ ### Source Types
409
+
410
+ | Type | Required Fields | Description |
411
+ |------|----------------|-------------|
412
+ | `file` | `path` | Watch a log file (supports `format: ndjson` for structured JSON logs) |
413
+ | `docker` | `container` | Watch Docker container logs |
414
+ | `command` | `run`, `interval` | Run a command periodically and scan output |
415
+
416
+ For full configuration details and NDJSON options, run `watchfix init` to see the annotated template.
@@ -27,6 +27,19 @@ logs:
27
27
  type: file
28
28
  path: ./logs/app.log
29
29
 
30
+ # Example NDJSON source (for structured JSON logs like Pino, Bunyan, Winston):
31
+ # - name: app-json
32
+ # type: file
33
+ # path: ./logs/app.ndjson
34
+ # format: ndjson
35
+ # ndjson:
36
+ # messageField: msg # Required: field containing log message
37
+ # timestampField: time # Optional: field with timestamp
38
+ # levelField: level # Optional: field with log level
39
+ # levelFilter: # Optional: only process these levels
40
+ # - error
41
+ # - fatal
42
+
30
43
  # Example docker source:
31
44
  # - name: api
32
45
  # type: docker
@@ -0,0 +1 @@
1
+ export declare const manualCommand: () => Promise<void>;
@@ -0,0 +1,14 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { UserError } from '../../utils/errors.js';
5
+ export const manualCommand = async () => {
6
+ const helpPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'HELP.md');
7
+ try {
8
+ const content = await fs.readFile(helpPath, 'utf8');
9
+ process.stdout.write(content);
10
+ }
11
+ catch {
12
+ throw new UserError('HELP.md not found. This file should be included in the watchfix package.');
13
+ }
14
+ };
package/dist/cli/index.js CHANGED
@@ -12,6 +12,7 @@ import { logsCommand } from './commands/logs.js';
12
12
  import { configValidateCommand } from './commands/config.js';
13
13
  import { cleanCommand } from './commands/clean.js';
14
14
  import { versionCommand } from './commands/version.js';
15
+ import { manualCommand } from './commands/manual.js';
15
16
  import { EXIT_CODES, InternalError, UserError } from '../utils/errors.js';
16
17
  const require = createRequire(import.meta.url);
17
18
  const pkg = require('../../package.json');
@@ -105,6 +106,12 @@ const registerCommands = (program) => {
105
106
  .action(async (options) => {
106
107
  await versionCommand(options);
107
108
  }));
109
+ program
110
+ .command('manual')
111
+ .description('Show detailed reference documentation')
112
+ .action(async () => {
113
+ await manualCommand();
114
+ });
108
115
  };
109
116
  const program = new Command();
110
117
  program
@@ -114,6 +121,7 @@ addGlobalOptions(program);
114
121
  program
115
122
  .helpOption('-h, --help', 'Show help for command')
116
123
  .version(pkg.version ?? '0.0.0', '-v, --version', 'Show version and exit');
124
+ program.addHelpText('after', '\nRun "watchfix manual" for detailed reference documentation.');
117
125
  registerCommands(program);
118
126
  const handleError = (error) => {
119
127
  if (error instanceof UserError) {
@@ -1,29 +1,76 @@
1
1
  import { z } from 'zod';
2
2
  declare const durationSchema: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
3
+ declare const ndjsonConfigSchema: z.ZodObject<{
4
+ messageField: z.ZodString;
5
+ timestampField: z.ZodOptional<z.ZodString>;
6
+ levelField: z.ZodOptional<z.ZodString>;
7
+ levelFilter: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ messageField: string;
10
+ timestampField?: string | undefined;
11
+ levelField?: string | undefined;
12
+ levelFilter?: string[] | undefined;
13
+ }, {
14
+ messageField: string;
15
+ timestampField?: string | undefined;
16
+ levelField?: string | undefined;
17
+ levelFilter?: string[] | undefined;
18
+ }>;
3
19
  declare const fileSourceSchema: z.ZodObject<{
4
20
  name: z.ZodString;
5
21
  type: z.ZodLiteral<"file">;
6
22
  path: z.ZodString;
23
+ format: z.ZodOptional<z.ZodEnum<["text", "ndjson"]>>;
24
+ ndjson: z.ZodOptional<z.ZodObject<{
25
+ messageField: z.ZodString;
26
+ timestampField: z.ZodOptional<z.ZodString>;
27
+ levelField: z.ZodOptional<z.ZodString>;
28
+ levelFilter: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
29
+ }, "strip", z.ZodTypeAny, {
30
+ messageField: string;
31
+ timestampField?: string | undefined;
32
+ levelField?: string | undefined;
33
+ levelFilter?: string[] | undefined;
34
+ }, {
35
+ messageField: string;
36
+ timestampField?: string | undefined;
37
+ levelField?: string | undefined;
38
+ levelFilter?: string[] | undefined;
39
+ }>>;
7
40
  }, "strip", z.ZodTypeAny, {
8
41
  path: string;
9
- name: string;
10
42
  type: "file";
43
+ name: string;
44
+ ndjson?: {
45
+ messageField: string;
46
+ timestampField?: string | undefined;
47
+ levelField?: string | undefined;
48
+ levelFilter?: string[] | undefined;
49
+ } | undefined;
50
+ format?: "text" | "ndjson" | undefined;
11
51
  }, {
12
52
  path: string;
13
- name: string;
14
53
  type: "file";
54
+ name: string;
55
+ ndjson?: {
56
+ messageField: string;
57
+ timestampField?: string | undefined;
58
+ levelField?: string | undefined;
59
+ levelFilter?: string[] | undefined;
60
+ } | undefined;
61
+ format?: "text" | "ndjson" | undefined;
15
62
  }>;
16
63
  declare const dockerSourceSchema: z.ZodObject<{
17
64
  name: z.ZodString;
18
65
  type: z.ZodLiteral<"docker">;
19
66
  container: z.ZodString;
20
67
  }, "strip", z.ZodTypeAny, {
21
- name: string;
22
68
  type: "docker";
69
+ name: string;
23
70
  container: string;
24
71
  }, {
25
- name: string;
26
72
  type: "docker";
73
+ name: string;
27
74
  container: string;
28
75
  }>;
29
76
  declare const commandSourceSchema: z.ZodObject<{
@@ -32,13 +79,13 @@ declare const commandSourceSchema: z.ZodObject<{
32
79
  run: z.ZodString;
33
80
  interval: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
34
81
  }, "strip", z.ZodTypeAny, {
35
- name: string;
36
82
  type: "command";
83
+ name: string;
37
84
  run: string;
38
85
  interval: string;
39
86
  }, {
40
- name: string;
41
87
  type: "command";
88
+ name: string;
42
89
  run: string;
43
90
  interval: string;
44
91
  }>;
@@ -46,25 +93,56 @@ declare const logSourceSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
46
93
  name: z.ZodString;
47
94
  type: z.ZodLiteral<"file">;
48
95
  path: z.ZodString;
96
+ format: z.ZodOptional<z.ZodEnum<["text", "ndjson"]>>;
97
+ ndjson: z.ZodOptional<z.ZodObject<{
98
+ messageField: z.ZodString;
99
+ timestampField: z.ZodOptional<z.ZodString>;
100
+ levelField: z.ZodOptional<z.ZodString>;
101
+ levelFilter: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
102
+ }, "strip", z.ZodTypeAny, {
103
+ messageField: string;
104
+ timestampField?: string | undefined;
105
+ levelField?: string | undefined;
106
+ levelFilter?: string[] | undefined;
107
+ }, {
108
+ messageField: string;
109
+ timestampField?: string | undefined;
110
+ levelField?: string | undefined;
111
+ levelFilter?: string[] | undefined;
112
+ }>>;
49
113
  }, "strip", z.ZodTypeAny, {
50
114
  path: string;
51
- name: string;
52
115
  type: "file";
116
+ name: string;
117
+ ndjson?: {
118
+ messageField: string;
119
+ timestampField?: string | undefined;
120
+ levelField?: string | undefined;
121
+ levelFilter?: string[] | undefined;
122
+ } | undefined;
123
+ format?: "text" | "ndjson" | undefined;
53
124
  }, {
54
125
  path: string;
55
- name: string;
56
126
  type: "file";
127
+ name: string;
128
+ ndjson?: {
129
+ messageField: string;
130
+ timestampField?: string | undefined;
131
+ levelField?: string | undefined;
132
+ levelFilter?: string[] | undefined;
133
+ } | undefined;
134
+ format?: "text" | "ndjson" | undefined;
57
135
  }>, z.ZodObject<{
58
136
  name: z.ZodString;
59
137
  type: z.ZodLiteral<"docker">;
60
138
  container: z.ZodString;
61
139
  }, "strip", z.ZodTypeAny, {
62
- name: string;
63
140
  type: "docker";
141
+ name: string;
64
142
  container: string;
65
143
  }, {
66
- name: string;
67
144
  type: "docker";
145
+ name: string;
68
146
  container: string;
69
147
  }>, z.ZodObject<{
70
148
  name: z.ZodString;
@@ -72,13 +150,13 @@ declare const logSourceSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
72
150
  run: z.ZodString;
73
151
  interval: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
74
152
  }, "strip", z.ZodTypeAny, {
75
- name: string;
76
153
  type: "command";
154
+ name: string;
77
155
  run: string;
78
156
  interval: string;
79
157
  }, {
80
- name: string;
81
158
  type: "command";
159
+ name: string;
82
160
  run: string;
83
161
  interval: string;
84
162
  }>]>;
@@ -117,29 +195,60 @@ declare const configSchema: z.ZodObject<{
117
195
  stderr_is_progress?: boolean | undefined;
118
196
  }>;
119
197
  logs: z.ZodObject<{
120
- sources: z.ZodEffects<z.ZodArray<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
198
+ sources: z.ZodEffects<z.ZodEffects<z.ZodArray<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
121
199
  name: z.ZodString;
122
200
  type: z.ZodLiteral<"file">;
123
201
  path: z.ZodString;
202
+ format: z.ZodOptional<z.ZodEnum<["text", "ndjson"]>>;
203
+ ndjson: z.ZodOptional<z.ZodObject<{
204
+ messageField: z.ZodString;
205
+ timestampField: z.ZodOptional<z.ZodString>;
206
+ levelField: z.ZodOptional<z.ZodString>;
207
+ levelFilter: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
208
+ }, "strip", z.ZodTypeAny, {
209
+ messageField: string;
210
+ timestampField?: string | undefined;
211
+ levelField?: string | undefined;
212
+ levelFilter?: string[] | undefined;
213
+ }, {
214
+ messageField: string;
215
+ timestampField?: string | undefined;
216
+ levelField?: string | undefined;
217
+ levelFilter?: string[] | undefined;
218
+ }>>;
124
219
  }, "strip", z.ZodTypeAny, {
125
220
  path: string;
126
- name: string;
127
221
  type: "file";
222
+ name: string;
223
+ ndjson?: {
224
+ messageField: string;
225
+ timestampField?: string | undefined;
226
+ levelField?: string | undefined;
227
+ levelFilter?: string[] | undefined;
228
+ } | undefined;
229
+ format?: "text" | "ndjson" | undefined;
128
230
  }, {
129
231
  path: string;
130
- name: string;
131
232
  type: "file";
233
+ name: string;
234
+ ndjson?: {
235
+ messageField: string;
236
+ timestampField?: string | undefined;
237
+ levelField?: string | undefined;
238
+ levelFilter?: string[] | undefined;
239
+ } | undefined;
240
+ format?: "text" | "ndjson" | undefined;
132
241
  }>, z.ZodObject<{
133
242
  name: z.ZodString;
134
243
  type: z.ZodLiteral<"docker">;
135
244
  container: z.ZodString;
136
245
  }, "strip", z.ZodTypeAny, {
137
- name: string;
138
246
  type: "docker";
247
+ name: string;
139
248
  container: string;
140
249
  }, {
141
- name: string;
142
250
  type: "docker";
251
+ name: string;
143
252
  container: string;
144
253
  }>, z.ZodObject<{
145
254
  name: z.ZodString;
@@ -147,39 +256,93 @@ declare const configSchema: z.ZodObject<{
147
256
  run: z.ZodString;
148
257
  interval: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
149
258
  }, "strip", z.ZodTypeAny, {
150
- name: string;
151
259
  type: "command";
260
+ name: string;
152
261
  run: string;
153
262
  interval: string;
154
263
  }, {
155
- name: string;
156
264
  type: "command";
265
+ name: string;
157
266
  run: string;
158
267
  interval: string;
159
268
  }>]>, "many">, ({
160
269
  path: string;
161
- name: string;
162
270
  type: "file";
163
- } | {
164
271
  name: string;
272
+ ndjson?: {
273
+ messageField: string;
274
+ timestampField?: string | undefined;
275
+ levelField?: string | undefined;
276
+ levelFilter?: string[] | undefined;
277
+ } | undefined;
278
+ format?: "text" | "ndjson" | undefined;
279
+ } | {
165
280
  type: "docker";
281
+ name: string;
166
282
  container: string;
167
283
  } | {
168
- name: string;
169
284
  type: "command";
285
+ name: string;
170
286
  run: string;
171
287
  interval: string;
172
288
  })[], ({
173
289
  path: string;
174
- name: string;
175
290
  type: "file";
291
+ name: string;
292
+ ndjson?: {
293
+ messageField: string;
294
+ timestampField?: string | undefined;
295
+ levelField?: string | undefined;
296
+ levelFilter?: string[] | undefined;
297
+ } | undefined;
298
+ format?: "text" | "ndjson" | undefined;
299
+ } | {
300
+ type: "docker";
301
+ name: string;
302
+ container: string;
176
303
  } | {
304
+ type: "command";
305
+ name: string;
306
+ run: string;
307
+ interval: string;
308
+ })[]>, ({
309
+ path: string;
310
+ type: "file";
177
311
  name: string;
312
+ ndjson?: {
313
+ messageField: string;
314
+ timestampField?: string | undefined;
315
+ levelField?: string | undefined;
316
+ levelFilter?: string[] | undefined;
317
+ } | undefined;
318
+ format?: "text" | "ndjson" | undefined;
319
+ } | {
178
320
  type: "docker";
321
+ name: string;
179
322
  container: string;
180
323
  } | {
324
+ type: "command";
181
325
  name: string;
326
+ run: string;
327
+ interval: string;
328
+ })[], ({
329
+ path: string;
330
+ type: "file";
331
+ name: string;
332
+ ndjson?: {
333
+ messageField: string;
334
+ timestampField?: string | undefined;
335
+ levelField?: string | undefined;
336
+ levelFilter?: string[] | undefined;
337
+ } | undefined;
338
+ format?: "text" | "ndjson" | undefined;
339
+ } | {
340
+ type: "docker";
341
+ name: string;
342
+ container: string;
343
+ } | {
182
344
  type: "command";
345
+ name: string;
183
346
  run: string;
184
347
  interval: string;
185
348
  })[]>;
@@ -189,15 +352,22 @@ declare const configSchema: z.ZodObject<{
189
352
  }, "strip", z.ZodTypeAny, {
190
353
  sources: ({
191
354
  path: string;
192
- name: string;
193
355
  type: "file";
194
- } | {
195
356
  name: string;
357
+ ndjson?: {
358
+ messageField: string;
359
+ timestampField?: string | undefined;
360
+ levelField?: string | undefined;
361
+ levelFilter?: string[] | undefined;
362
+ } | undefined;
363
+ format?: "text" | "ndjson" | undefined;
364
+ } | {
196
365
  type: "docker";
366
+ name: string;
197
367
  container: string;
198
368
  } | {
199
- name: string;
200
369
  type: "command";
370
+ name: string;
201
371
  run: string;
202
372
  interval: string;
203
373
  })[];
@@ -207,15 +377,22 @@ declare const configSchema: z.ZodObject<{
207
377
  }, {
208
378
  sources: ({
209
379
  path: string;
210
- name: string;
211
380
  type: "file";
212
- } | {
213
381
  name: string;
382
+ ndjson?: {
383
+ messageField: string;
384
+ timestampField?: string | undefined;
385
+ levelField?: string | undefined;
386
+ levelFilter?: string[] | undefined;
387
+ } | undefined;
388
+ format?: "text" | "ndjson" | undefined;
389
+ } | {
214
390
  type: "docker";
391
+ name: string;
215
392
  container: string;
216
393
  } | {
217
- name: string;
218
394
  type: "command";
395
+ name: string;
219
396
  run: string;
220
397
  interval: string;
221
398
  })[];
@@ -295,15 +472,22 @@ declare const configSchema: z.ZodObject<{
295
472
  logs: {
296
473
  sources: ({
297
474
  path: string;
298
- name: string;
299
475
  type: "file";
300
- } | {
301
476
  name: string;
477
+ ndjson?: {
478
+ messageField: string;
479
+ timestampField?: string | undefined;
480
+ levelField?: string | undefined;
481
+ levelFilter?: string[] | undefined;
482
+ } | undefined;
483
+ format?: "text" | "ndjson" | undefined;
484
+ } | {
302
485
  type: "docker";
486
+ name: string;
303
487
  container: string;
304
488
  } | {
305
- name: string;
306
489
  type: "command";
490
+ name: string;
307
491
  run: string;
308
492
  interval: string;
309
493
  })[];
@@ -349,15 +533,22 @@ declare const configSchema: z.ZodObject<{
349
533
  logs: {
350
534
  sources: ({
351
535
  path: string;
352
- name: string;
353
536
  type: "file";
354
- } | {
355
537
  name: string;
538
+ ndjson?: {
539
+ messageField: string;
540
+ timestampField?: string | undefined;
541
+ levelField?: string | undefined;
542
+ levelFilter?: string[] | undefined;
543
+ } | undefined;
544
+ format?: "text" | "ndjson" | undefined;
545
+ } | {
356
546
  type: "docker";
547
+ name: string;
357
548
  container: string;
358
549
  } | {
359
- name: string;
360
550
  type: "command";
551
+ name: string;
361
552
  run: string;
362
553
  interval: string;
363
554
  })[];
@@ -389,5 +580,5 @@ declare const configSchema: z.ZodObject<{
389
580
  } | undefined;
390
581
  }>;
391
582
  type Config = z.infer<typeof configSchema>;
392
- export { commandSourceSchema, configSchema, dockerSourceSchema, durationSchema, fileSourceSchema, logSourceSchema, patternSchema, };
583
+ export { commandSourceSchema, configSchema, dockerSourceSchema, durationSchema, fileSourceSchema, logSourceSchema, ndjsonConfigSchema, patternSchema, };
393
584
  export type { Config };
@@ -17,10 +17,18 @@ const durationSchema = z
17
17
  };
18
18
  return amount * msByUnit[unit] <= MAX_DURATION_MS;
19
19
  }, 'Duration cannot exceed 24 hours');
20
+ const ndjsonConfigSchema = z.object({
21
+ messageField: z.string().min(1),
22
+ timestampField: z.string().min(1).optional(),
23
+ levelField: z.string().min(1).optional(),
24
+ levelFilter: z.array(z.string().min(1)).optional(),
25
+ });
20
26
  const fileSourceSchema = z.object({
21
27
  name: z.string().min(1),
22
28
  type: z.literal('file'),
23
29
  path: z.string().min(1),
30
+ format: z.enum(['text', 'ndjson']).optional(),
31
+ ndjson: ndjsonConfigSchema.optional(),
24
32
  });
25
33
  const dockerSourceSchema = z.object({
26
34
  name: z.string().min(1),
@@ -59,7 +67,15 @@ const configSchema = z.object({
59
67
  .refine((sources) => {
60
68
  const names = sources.map((source) => source.name);
61
69
  return names.length === new Set(names).size;
62
- }, { message: 'Log source names must be unique' }),
70
+ }, { message: 'Log source names must be unique' })
71
+ .refine((sources) => {
72
+ return sources.every((source) => {
73
+ if (source.type === 'file' && source.format === 'ndjson') {
74
+ return source.ndjson !== undefined;
75
+ }
76
+ return true;
77
+ });
78
+ }, { message: 'ndjson config is required when format is "ndjson"' }),
63
79
  context_lines_before: z.number().int().min(0).default(10),
64
80
  context_lines_after: z.number().int().min(0).default(5),
65
81
  max_line_buffer: z.number().int().min(100).default(10000),
@@ -102,4 +118,4 @@ const configSchema = z.object({
102
118
  })
103
119
  .default({}),
104
120
  });
105
- export { commandSourceSchema, configSchema, dockerSourceSchema, durationSchema, fileSourceSchema, logSourceSchema, patternSchema, };
121
+ export { commandSourceSchema, configSchema, dockerSourceSchema, durationSchema, fileSourceSchema, logSourceSchema, ndjsonConfigSchema, patternSchema, };
@@ -9,6 +9,8 @@ export declare class FileSource implements LogSource {
9
9
  private readonly filePath;
10
10
  private readonly logger;
11
11
  private readonly emitter;
12
+ private readonly format;
13
+ private readonly ndjsonConfig;
12
14
  private watcher;
13
15
  private position;
14
16
  private partialLine;
@@ -26,5 +28,6 @@ export declare class FileSource implements LogSource {
26
28
  private readLoop;
27
29
  private readNewLines;
28
30
  private processChunk;
31
+ private handleLine;
29
32
  }
30
33
  export {};
@@ -3,11 +3,14 @@ import path from 'node:path';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import chokidar from 'chokidar';
5
5
  import { Logger } from '../../utils/logger.js';
6
+ import { parseNdjsonLine } from './ndjson.js';
6
7
  export class FileSource {
7
8
  config;
8
9
  filePath;
9
10
  logger;
10
11
  emitter;
12
+ format;
13
+ ndjsonConfig;
11
14
  watcher = null;
12
15
  position = 0;
13
16
  partialLine = '';
@@ -23,6 +26,8 @@ export class FileSource {
23
26
  this.logger =
24
27
  options?.logger ?? new Logger({ terminalEnabled: false, verbosity: 'normal' });
25
28
  this.emitter = new EventEmitter();
29
+ this.format = config.format ?? 'text';
30
+ this.ndjsonConfig = config.ndjson ?? null;
26
31
  }
27
32
  async start() {
28
33
  if (this.watcher) {
@@ -164,12 +169,40 @@ export class FileSource {
164
169
  const lines = combined.split('\n');
165
170
  this.partialLine = lines.pop() ?? '';
166
171
  for (const rawLine of lines) {
167
- const line = rawLine.replace(/\r$/, '');
172
+ this.handleLine(rawLine.replace(/\r$/, ''));
173
+ }
174
+ }
175
+ handleLine(line) {
176
+ if (this.format !== 'ndjson' || !this.ndjsonConfig) {
168
177
  this.emitter.emit('line', {
169
178
  source: this.config.name,
170
179
  line,
171
180
  timestamp: new Date(),
172
181
  });
182
+ return;
183
+ }
184
+ const result = parseNdjsonLine(line, this.ndjsonConfig);
185
+ if (result.success) {
186
+ this.emitter.emit('line', {
187
+ source: this.config.name,
188
+ line: result.data.message,
189
+ timestamp: result.data.timestamp,
190
+ });
191
+ return;
192
+ }
193
+ // Handle different failure reasons
194
+ if (result.reason === 'filtered') {
195
+ // Line was filtered by level - silently skip
196
+ return;
197
+ }
198
+ if (result.reason === 'missing_message') {
199
+ this.logger.debug(`NDJSON line missing message field "${this.ndjsonConfig.messageField}": ${line.slice(0, 100)}`);
173
200
  }
201
+ // For parse errors or missing message, fall back to emitting raw line
202
+ this.emitter.emit('line', {
203
+ source: this.config.name,
204
+ line,
205
+ timestamp: new Date(),
206
+ });
174
207
  }
175
208
  }
@@ -0,0 +1,16 @@
1
+ import type { NdjsonConfig } from './types.js';
2
+ export type ParsedNdjsonLine = {
3
+ message: string;
4
+ timestamp: Date;
5
+ };
6
+ export type NdjsonParseResult = {
7
+ success: true;
8
+ data: ParsedNdjsonLine;
9
+ } | {
10
+ success: false;
11
+ reason: 'parse_error' | 'missing_message' | 'filtered';
12
+ };
13
+ export declare function getNestedValue(obj: unknown, path: string): unknown;
14
+ export declare function parseTimestamp(value: unknown): Date | null;
15
+ export declare function shouldProcessLevel(value: unknown, levelFilter: string[] | undefined): boolean;
16
+ export declare function parseNdjsonLine(line: string, config: NdjsonConfig): NdjsonParseResult;
@@ -0,0 +1,108 @@
1
+ export function getNestedValue(obj, path) {
2
+ const parts = path.split('.');
3
+ let current = obj;
4
+ for (const part of parts) {
5
+ if (current === null || current === undefined) {
6
+ return undefined;
7
+ }
8
+ if (typeof current !== 'object') {
9
+ return undefined;
10
+ }
11
+ current = current[part];
12
+ }
13
+ return current;
14
+ }
15
+ export function parseTimestamp(value) {
16
+ if (value === null || value === undefined) {
17
+ return null;
18
+ }
19
+ if (typeof value === 'string') {
20
+ const date = new Date(value);
21
+ if (!Number.isNaN(date.getTime())) {
22
+ return date;
23
+ }
24
+ return null;
25
+ }
26
+ if (typeof value === 'number') {
27
+ // Handle Unix timestamps in seconds or milliseconds
28
+ // If the number is less than 10^12, treat as seconds; otherwise as milliseconds
29
+ const MS_THRESHOLD = 1e12;
30
+ const timestamp = value < MS_THRESHOLD ? value * 1000 : value;
31
+ const date = new Date(timestamp);
32
+ if (!Number.isNaN(date.getTime())) {
33
+ return date;
34
+ }
35
+ return null;
36
+ }
37
+ return null;
38
+ }
39
+ export function shouldProcessLevel(value, levelFilter) {
40
+ if (!levelFilter || levelFilter.length === 0) {
41
+ return true;
42
+ }
43
+ if (value === null || value === undefined) {
44
+ // If no level is present but filter is configured, skip the line
45
+ return false;
46
+ }
47
+ // Handle string levels (most common)
48
+ if (typeof value === 'string') {
49
+ const lowerValue = value.toLowerCase();
50
+ return levelFilter.some((filter) => filter.toLowerCase() === lowerValue);
51
+ }
52
+ // Handle numeric levels (Bunyan style: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal)
53
+ if (typeof value === 'number') {
54
+ const bunyanLevels = {
55
+ 10: 'trace',
56
+ 20: 'debug',
57
+ 30: 'info',
58
+ 40: 'warn',
59
+ 50: 'error',
60
+ 60: 'fatal',
61
+ };
62
+ const levelName = bunyanLevels[value];
63
+ if (levelName) {
64
+ return levelFilter.some((filter) => filter.toLowerCase() === levelName.toLowerCase());
65
+ }
66
+ // Unknown numeric level - don't match
67
+ return false;
68
+ }
69
+ return false;
70
+ }
71
+ export function parseNdjsonLine(line, config) {
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(line);
75
+ }
76
+ catch {
77
+ return { success: false, reason: 'parse_error' };
78
+ }
79
+ if (parsed === null || typeof parsed !== 'object') {
80
+ return { success: false, reason: 'parse_error' };
81
+ }
82
+ // Check level filter first (before extracting message)
83
+ if (config.levelField) {
84
+ const levelValue = getNestedValue(parsed, config.levelField);
85
+ if (!shouldProcessLevel(levelValue, config.levelFilter)) {
86
+ return { success: false, reason: 'filtered' };
87
+ }
88
+ }
89
+ // Extract message
90
+ const messageValue = getNestedValue(parsed, config.messageField);
91
+ if (messageValue === null || messageValue === undefined) {
92
+ return { success: false, reason: 'missing_message' };
93
+ }
94
+ const message = String(messageValue);
95
+ // Extract timestamp
96
+ let timestamp = new Date();
97
+ if (config.timestampField) {
98
+ const timestampValue = getNestedValue(parsed, config.timestampField);
99
+ const parsedTimestamp = parseTimestamp(timestampValue);
100
+ if (parsedTimestamp) {
101
+ timestamp = parsedTimestamp;
102
+ }
103
+ }
104
+ return {
105
+ success: true,
106
+ data: { message, timestamp },
107
+ };
108
+ }
@@ -3,10 +3,18 @@ export type LogEvent = {
3
3
  line: string;
4
4
  timestamp: Date;
5
5
  };
6
+ export type NdjsonConfig = {
7
+ messageField: string;
8
+ timestampField?: string;
9
+ levelField?: string;
10
+ levelFilter?: string[];
11
+ };
6
12
  export type FileSourceConfig = {
7
13
  name: string;
8
14
  type: 'file';
9
15
  path: string;
16
+ format?: 'text' | 'ndjson';
17
+ ndjson?: NdjsonConfig;
10
18
  };
11
19
  export type DockerSourceConfig = {
12
20
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "watchfix",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI tool that watches logs, detects errors, and dispatches AI agents to fix them",
5
5
  "keywords": [
6
6
  "cli",
@@ -30,6 +30,7 @@
30
30
  "dist",
31
31
  "LICENSE",
32
32
  "README.md",
33
+ "HELP.md",
33
34
  "package.json"
34
35
  ],
35
36
  "dependencies": {