dont-hallucinate 0.1.3

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/src/parser.js ADDED
@@ -0,0 +1,123 @@
1
+ // parser.js — error classification (port of parser.py)
2
+
3
+ const FLAG_RE = /(?<![\w-])(--[a-zA-Z0-9][\w-]*|-[a-zA-Z0-9])(?![\w-])/g;
4
+
5
+ export class ParsedFailure {
6
+ constructor({ errorType, hallucinatedFlag = null, missingCommand = null, rawError = '' }) {
7
+ this.errorType = errorType;
8
+ this.hallucinatedFlag = hallucinatedFlag;
9
+ this.missingCommand = missingCommand;
10
+ this.rawError = rawError;
11
+ }
12
+ }
13
+
14
+ export function commandName(command) {
15
+ const parts = command.trim().split(/\s+/);
16
+ return parts[0] || '';
17
+ }
18
+
19
+ export function extractCmdName(commandText) {
20
+ const known = ['git', 'npm', 'npx', 'pnpm', 'yarn', 'pip', 'pip3', 'rm', 'docker', 'kubectl', 'python', 'python3'];
21
+ for (const tool of known) {
22
+ if (new RegExp(`\\b${tool}\\b`).test(commandText)) return tool;
23
+ }
24
+ const skip = new Set(['bash', 'sh', 'zsh', 'fish', 'hallucinate', 'exec', '-c', '-lc', '--']);
25
+ for (const word of commandText.split(/\s+/)) {
26
+ if (!skip.has(word) && !word.startsWith('-')) return word;
27
+ }
28
+ return '';
29
+ }
30
+
31
+ export function extractHallucinatedFlag(stderrText) {
32
+ const patterns = [
33
+ /(?:unrecognized|unknown|unsupported|illegal)\s+(?:option|flag)\s+['"]?(--?[A-Za-z0-9][\w-]*)/i,
34
+ /no such option:\s*(--?[A-Za-z0-9][\w-]*)/i,
35
+ /invalid option\s*--\s*['"]?([A-Za-z0-9])/i,
36
+ ];
37
+ for (const pattern of patterns) {
38
+ const match = stderrText.match(pattern);
39
+ if (match) {
40
+ const token = match[1];
41
+ if (token && token.length === 1) return `-${token}`;
42
+ return token;
43
+ }
44
+ }
45
+ const allFlags = [...stderrText.matchAll(FLAG_RE)].map(m => m[1]);
46
+ return allFlags[0] ?? null;
47
+ }
48
+
49
+ export function extractMissingCommand(stderrText, commandText) {
50
+ const match = stderrText.match(/^\s*([^\s:]+):\s+command not found\b/im);
51
+ if (match) return match[1];
52
+ const first = commandName(commandText);
53
+ return first || null;
54
+ }
55
+
56
+ export function classifyFailure(command, stderrText, exitCode) {
57
+ const stderr = stderrText || '';
58
+ const stderrLow = stderr.toLowerCase();
59
+
60
+ if (exitCode === 0) return new ParsedFailure({ errorType: 'ok', rawError: stderr });
61
+
62
+ if (stderrLow.includes('permission denied'))
63
+ return new ParsedFailure({ errorType: 'permission_denied', rawError: stderr });
64
+
65
+ if (stderrLow.includes('no space left on device'))
66
+ return new ParsedFailure({ errorType: 'no_space', rawError: stderr });
67
+
68
+ if (stderrLow.includes('eaddrinuse') || stderrLow.includes('address already in use'))
69
+ return new ParsedFailure({ errorType: 'port_in_use', rawError: stderr });
70
+
71
+ const networkPatterns = [
72
+ 'could not resolve host', 'connection refused', 'econnrefused',
73
+ 'network is unreachable', 'no route to host', 'ssl error',
74
+ 'certificate verify failed', 'name or service not known',
75
+ ];
76
+ if (networkPatterns.some(p => stderrLow.includes(p)))
77
+ return new ParsedFailure({ errorType: 'network_error', rawError: stderr });
78
+
79
+ const dirtyPatterns = [
80
+ 'please commit your changes', 'you have unstaged changes',
81
+ 'your local changes to the following', 'cannot pull with rebase',
82
+ 'would be overwritten by merge',
83
+ ];
84
+ if (dirtyPatterns.some(p => stderrLow.includes(p)))
85
+ return new ParsedFailure({ errorType: 'git_dirty', rawError: stderr });
86
+
87
+ if (stderrLow.includes('conflict') && ['automatic merge failed', 'merge conflict', 'fix conflicts'].some(p => stderrLow.includes(p)))
88
+ return new ParsedFailure({ errorType: 'git_conflict', rawError: stderr });
89
+
90
+ // Hallucinated flag — must come before git_branch_missing
91
+ const hallucinatedFlag = extractHallucinatedFlag(stderr);
92
+ const flagTokens = ['unrecognized option', 'unknown option', 'unknown flag', 'invalid option', 'no such option', 'illegal option', 'unsupported option', 'unsupported flag'];
93
+ if (hallucinatedFlag && flagTokens.some(t => stderrLow.includes(t)))
94
+ return new ParsedFailure({ errorType: 'invalid_flag', hallucinatedFlag, rawError: stderr });
95
+
96
+ const branchPatterns = ['not something we can merge', 'did not match any file', 'unknown revision or path', 'no such branch', 'pathspec', 'not a valid object name'];
97
+ if (branchPatterns.some(p => stderrLow.includes(p)))
98
+ return new ParsedFailure({ errorType: 'git_branch_missing', rawError: stderr });
99
+
100
+ if (stderrLow.includes('missing script:') || stderrLow.includes('npm error missing script'))
101
+ return new ParsedFailure({ errorType: 'npm_missing_script', rawError: stderr });
102
+
103
+ const notFoundPatterns = [
104
+ 'command not found',
105
+ 'not recognized as an internal or external command',
106
+ '\u4e0d\u662f\u5185\u90e8\u6216\u5916\u90e8\u547d\u4ee4',
107
+ '\u4e0d\u662f\u5916\u90e8\u6216\u5185\u90e8\u547d\u4ee4',
108
+ '\ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4',
109
+ "n'est pas reconnu",
110
+ 'no se reconoce',
111
+ ];
112
+ if (exitCode === 127 || notFoundPatterns.some(p => stderrLow.includes(p)))
113
+ return new ParsedFailure({ errorType: 'missing_package', missingCommand: extractMissingCommand(stderr, command), rawError: stderr });
114
+
115
+ if (stderrLow.includes('no such file or directory'))
116
+ return new ParsedFailure({ errorType: 'file_not_found', rawError: stderr });
117
+
118
+ const syntaxPatterns = ['syntax error near unexpected', 'unexpected token', 'syntax error:', 'parse error'];
119
+ if (syntaxPatterns.some(p => stderrLow.includes(p)))
120
+ return new ParsedFailure({ errorType: 'syntax_error', rawError: stderr });
121
+
122
+ return new ParsedFailure({ errorType: 'generic_error', rawError: stderr });
123
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,47 @@
1
+ // runtime.js — workspace paths and JSONL utilities (port of runtime.py)
2
+
3
+ import { mkdirSync, appendFileSync } from 'fs';
4
+ import { resolve, join, sep, dirname } from 'path';
5
+
6
+ export const RUNTIME_DIRNAME = '.hallucinate';
7
+ export const STREAM_FILENAME = 'stream.jsonl';
8
+
9
+ export function workspaceRoot(path = null) {
10
+ return resolve(path ?? process.cwd());
11
+ }
12
+
13
+ export function runtimeDir(path = null) {
14
+ return join(workspaceRoot(path), RUNTIME_DIRNAME);
15
+ }
16
+
17
+ export function defaultStreamFile(path = null) {
18
+ return join(runtimeDir(path), STREAM_FILENAME);
19
+ }
20
+
21
+ export function ensureRuntimeDir(path = null) {
22
+ const dir = runtimeDir(path);
23
+ mkdirSync(dir, { recursive: true });
24
+ return dir;
25
+ }
26
+
27
+ export function isWithinWorkspace(candidate, root) {
28
+ if (root == null) return true;
29
+ if (!candidate) return false;
30
+ try {
31
+ const candidatePath = resolve(candidate);
32
+ const workspace = workspaceRoot(root);
33
+ return candidatePath === workspace || candidatePath.startsWith(workspace + sep);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function appendJsonl(filePath, payload) {
40
+ const dir = dirname(filePath);
41
+ if (dir) mkdirSync(dir, { recursive: true });
42
+ appendFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8');
43
+ }
44
+
45
+ export function utcTimestamp() {
46
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
47
+ }
package/src/ui.js ADDED
@@ -0,0 +1,240 @@
1
+ // ui.js — terminal UI (port of ui.py, using chalk instead of Rich)
2
+
3
+ import chalk from 'chalk';
4
+ import { isWithinWorkspace } from './runtime.js';
5
+ import { detectAgent, loadSnarkMap, pickRoast } from './burn.js';
6
+ import { tailJsonl, toFailureReport } from './watcher.js';
7
+
8
+ const ACCENT = chalk.hex('#FF8C00'); // orange
9
+ const ACCENT_DIM = chalk.hex('#FF6600');
10
+ const MUTED = chalk.gray;
11
+ const BOLD_WHITE = chalk.bold.white;
12
+
13
+ const LABELS = {
14
+ invalid_flag: 'hallucinated flag',
15
+ missing_package: 'phantom package',
16
+ permission_denied: 'permission denied',
17
+ git_branch_missing: 'ghost branch',
18
+ git_dirty: 'dirty working tree',
19
+ git_conflict: 'merge conflict',
20
+ npm_missing_script: 'missing npm script',
21
+ file_not_found: 'file not found',
22
+ port_in_use: 'port in use',
23
+ syntax_error: 'syntax error',
24
+ network_error: 'network failure',
25
+ no_space: 'disk full',
26
+ generic_error: 'shell failure',
27
+ };
28
+
29
+ const DONT_ART = [
30
+ '██████╗ ██████╗ ███╗ ██╗████████╗',
31
+ '██╔══██╗██╔═══██╗████╗ ██║╚══██╔══╝',
32
+ '██║ ██║██║ ██║██╔██╗██║ ██║ ',
33
+ '██║ ██║██║ ██║██║╚████║ ██║ ',
34
+ '██████╔╝╚██████╔╝██║ ╚███║ ██║ ',
35
+ '╚═════╝ ╚═════╝ ╚═╝ ╚══╝ ╚═╝ ',
36
+ ];
37
+ const HALL_ART = [
38
+ '██╗ ██╗ █████╗ ██╗ ██╗ ██╗ ██╗ ██████╗██╗███╗ ██╗ █████╗ ████████╗███████╗',
39
+ '██║ ██║██╔══██╗██║ ██║ ██║ ██║██╔════╝██║████╗ ██║██╔══██╗╚══██╔══╝██╔════╝',
40
+ '███████║███████║██║ ██║ ██║ ██║██║ ██║██╔██╗██║███████║ ██║ █████╗ ',
41
+ '██╔══██║██╔══██║██║ ██║ ██║ ██║██║ ██║██║╚████║██╔══██║ ██║ ██╔══╝ ',
42
+ '██║ ██║██║ ██║███████╗███████╗╚██████╔╝╚██████╗██║██║ ╚███║██║ ██║ ██║ ███████╗',
43
+ '╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═════╝╚═╝╚═╝ ╚══╝╚═╝ ╚═╝ ╚═╝ ╚══════╝',
44
+ ];
45
+
46
+ function banner() {
47
+ return [
48
+ ...DONT_ART.map(l => ACCENT.bold(l)),
49
+ ...HALL_ART.map(l => chalk.bold.white(l)),
50
+ ].join('\n');
51
+ }
52
+
53
+ function box(title, content, { borderColor = MUTED, width = null } = {}) {
54
+ const lines = content.split('\n');
55
+ const innerWidth = width ?? Math.max(...lines.map(l => stripAnsi(l).length), title ? stripAnsi(title).length + 2 : 0, 40);
56
+ const top = borderColor('┌─ ' + (title ? title + ' ' : '') + '─'.repeat(Math.max(0, innerWidth - (title ? stripAnsi(title).length + 3 : 1))) + '┐');
57
+ const mid = lines.map(l => borderColor('│') + ' ' + l + ' '.repeat(Math.max(0, innerWidth - stripAnsi(l).length - 1)) + borderColor('│'));
58
+ const bot = borderColor('└' + '─'.repeat(innerWidth + 1) + '┘');
59
+ return [top, ...mid, bot].join('\n');
60
+ }
61
+
62
+ function stripAnsi(str) {
63
+ // eslint-disable-next-line no-control-regex
64
+ return str.replace(/\x1B\[[0-9;]*m/g, '');
65
+ }
66
+
67
+ function rule(char = '─', color = MUTED) {
68
+ const width = Math.min(process.stdout.columns || 80, 90);
69
+ return color(char.repeat(width));
70
+ }
71
+
72
+ function padRight(str, len) {
73
+ const visible = stripAnsi(str).length;
74
+ return str + ' '.repeat(Math.max(0, len - visible));
75
+ }
76
+
77
+ function stripWrapper(command) {
78
+ let m = command.match(/bash\s+-[lc]+\s+'(.*)'$/s);
79
+ if (m) return m[1];
80
+ m = command.match(/bash\s+-[lc]+\s+"(.*)"$/s);
81
+ if (m) return m[1];
82
+ return command;
83
+ }
84
+
85
+ function renderIdleScreen(agent) {
86
+ return [
87
+ '',
88
+ banner(),
89
+ '',
90
+ rule(),
91
+ '',
92
+ MUTED(' watching for shell failures'),
93
+ MUTED(` agent: ${agent}`),
94
+ '',
95
+ ].join('\n');
96
+ }
97
+
98
+ function renderMainScreen(state) {
99
+ const { latestReport, latestRoast, totalCaught, logs } = state;
100
+ const { event, parsed, verification } = latestReport;
101
+ const cmd = stripWrapper(event.command);
102
+ const label = LABELS[parsed.errorType] || parsed.errorType;
103
+
104
+ const titleBar = [
105
+ ACCENT.bold(` dont-hallucinate`),
106
+ MUTED(` ${totalCaught} caught`),
107
+ ].join('');
108
+
109
+ const header = [
110
+ ACCENT.bold(` ${label.toUpperCase()}`),
111
+ MUTED(` exit ${event.exitCode}`),
112
+ MUTED(` ${event.actor || 'unknown'}`),
113
+ ].join('');
114
+
115
+ const cmdBox = box(MUTED('command'), cmd, { borderColor: MUTED });
116
+
117
+ const fixLines = [];
118
+ if (parsed.hallucinatedFlag) {
119
+ fixLines.push(MUTED(' bad flag ') + BOLD_WHITE(parsed.hallucinatedFlag));
120
+ }
121
+ if (verification.correctedCommand) {
122
+ fixLines.push(MUTED(' try ') + BOLD_WHITE(verification.correctedCommand));
123
+ } else if (verification.suggestedFlag) {
124
+ fixLines.push(MUTED(' try flag ') + BOLD_WHITE(verification.suggestedFlag));
125
+ }
126
+
127
+ const roastBox = box('', ACCENT.italic(`"${latestRoast}"`), { borderColor: ACCENT_DIM });
128
+
129
+ // History table
130
+ const tblHeader = [
131
+ MUTED(padRight('time', 10)),
132
+ MUTED(padRight('type', 22)),
133
+ MUTED(padRight('command', 40)),
134
+ MUTED('exit'),
135
+ ].join(' ');
136
+
137
+ const tblRows = logs.map(r => {
138
+ const ts = r.event.ts.length > 18 ? r.event.ts.slice(11, 19) : r.event.ts;
139
+ const c = stripWrapper(r.event.command).slice(0, 38);
140
+ const errLabel = LABELS[r.parsed.errorType] || r.parsed.errorType;
141
+ const isLatest = r === latestReport;
142
+ const style = isLatest ? ACCENT.bold : MUTED;
143
+ return [
144
+ MUTED(padRight(ts, 10)),
145
+ style(padRight(errLabel, 22)),
146
+ style(padRight(c, 40)),
147
+ style(String(r.event.exitCode)),
148
+ ].join(' ');
149
+ });
150
+
151
+ return [
152
+ '',
153
+ rule('═', ACCENT),
154
+ titleBar,
155
+ rule('═', ACCENT),
156
+ '',
157
+ header,
158
+ '',
159
+ cmdBox,
160
+ ...(fixLines.length ? ['', ...fixLines] : []),
161
+ '',
162
+ roastBox,
163
+ '',
164
+ rule(),
165
+ '',
166
+ tblHeader,
167
+ rule('─'),
168
+ ...tblRows,
169
+ '',
170
+ ].join('\n');
171
+ }
172
+
173
+ async function bootSplash(agent) {
174
+ const steps = [
175
+ ['initialising runtime', 300],
176
+ ['loading snark database', 400],
177
+ ['detecting agent', 300],
178
+ [`agent identified: ${agent}`, 500],
179
+ ['arming interceptors', 400],
180
+ ['watching', 300],
181
+ ];
182
+
183
+ process.stdout.write('\n');
184
+ process.stdout.write(banner() + '\n\n');
185
+
186
+ for (const [label, delay] of steps) {
187
+ process.stdout.write(` ${ACCENT_DIM('>')} ${ACCENT(label)}\n`);
188
+ await sleep(delay);
189
+ }
190
+
191
+ process.stdout.write('\n');
192
+ process.stdout.write(MUTED(' ready — intercepting shell failures') + '\n');
193
+ await sleep(500);
194
+ process.stdout.write('\n');
195
+ }
196
+
197
+ export async function runUi({ streamFile, pollInterval = 200, fromStart = false, workspace = null } = {}) {
198
+ const agent = detectAgent();
199
+ const snark = loadSnarkMap();
200
+
201
+ await bootSplash(agent);
202
+
203
+ const state = {
204
+ logs: [],
205
+ latestReport: null,
206
+ latestRoast: '',
207
+ totalCaught: 0,
208
+ agent,
209
+ };
210
+
211
+ function render() {
212
+ // Clear terminal and redraw
213
+ process.stdout.write('\x1B[2J\x1B[H');
214
+ if (state.latestReport === null) {
215
+ process.stdout.write(renderIdleScreen(agent));
216
+ } else {
217
+ process.stdout.write(renderMainScreen(state));
218
+ }
219
+ }
220
+
221
+ render();
222
+
223
+ for await (const event of tailJsonl(streamFile, { pollInterval, fromStart })) {
224
+ if (event.exitCode === 0) continue;
225
+ if (workspace && !isWithinWorkspace(event.cwd, workspace)) continue;
226
+
227
+ const report = toFailureReport(event);
228
+ state.latestReport = report;
229
+ state.totalCaught += 1;
230
+ state.latestRoast = pickRoast(snark, report.parsed.errorType, { agent });
231
+ state.logs.push(report);
232
+ if (state.logs.length > 8) state.logs.shift();
233
+
234
+ render();
235
+ }
236
+ }
237
+
238
+ function sleep(ms) {
239
+ return new Promise(r => setTimeout(r, ms));
240
+ }
@@ -0,0 +1,156 @@
1
+ // verifier.js — flag verification and fuzzy suggestion (port of verifier.py)
2
+
3
+ import { spawnSync } from 'child_process';
4
+
5
+ const FLAG_RE = /(?<![\w-])(--[a-zA-Z0-9][a-zA-Z0-9-]*|-[a-zA-Z0-9])(?![\w-])/g;
6
+
7
+ export class VerificationResult {
8
+ constructor({ availableFlags, suggestedFlag, correctedCommand, source }) {
9
+ this.availableFlags = availableFlags;
10
+ this.suggestedFlag = suggestedFlag;
11
+ this.correctedCommand = correctedCommand;
12
+ this.source = source;
13
+ }
14
+ }
15
+
16
+ export function parseFlagsFromText(text) {
17
+ const flags = new Set([...text.matchAll(FLAG_RE)].map(m => m[1]));
18
+ return [...flags].sort();
19
+ }
20
+
21
+ function runHelpCommand(commandName) {
22
+ const candidates = [
23
+ { args: [commandName, '--help'], source: 'help' },
24
+ { args: [commandName, '-h'], source: 'help' },
25
+ ];
26
+ for (const { args, source } of candidates) {
27
+ try {
28
+ const result = spawnSync(args[0], args.slice(1), {
29
+ encoding: 'utf8',
30
+ timeout: 2000,
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ });
33
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
34
+ if (output.trim()) return { output, source };
35
+ } catch {
36
+ // continue
37
+ }
38
+ }
39
+ return { output: '', source: 'none' };
40
+ }
41
+
42
+ // Approximate Python's fuzz.ratio (LCS-based similarity ratio, 0–100)
43
+ function fuzzyRatio(a, b) {
44
+ if (!a && !b) return 100;
45
+ if (!a || !b) return 0;
46
+ const la = a.length, lb = b.length;
47
+ const dp = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
48
+ for (let i = 1; i <= la; i++) {
49
+ for (let j = 1; j <= lb; j++) {
50
+ dp[i][j] = a[i - 1] === b[j - 1]
51
+ ? dp[i - 1][j - 1] + 1
52
+ : Math.max(dp[i - 1][j], dp[i][j - 1]);
53
+ }
54
+ }
55
+ return Math.round(200 * dp[la][lb] / (la + lb));
56
+ }
57
+
58
+ export function suggestFlag(hallucinatedFlag, availableFlags) {
59
+ if (!hallucinatedFlag || !availableFlags.length) return null;
60
+
61
+ const isLong = hallucinatedFlag.startsWith('--');
62
+ const candidates = isLong
63
+ ? availableFlags.filter(f => f.startsWith('--'))
64
+ : availableFlags.filter(f => f.startsWith('-') && !f.startsWith('--'));
65
+
66
+ if (!candidates.length) return null;
67
+
68
+ const normalizedBad = hallucinatedFlag.replace(/^-+/, '');
69
+
70
+ function scoreCandidate(candidate) {
71
+ const nc = candidate.replace(/^-+/, '');
72
+ let score = fuzzyRatio(normalizedBad, nc);
73
+ if (isLong) {
74
+ if (!nc || nc[0] !== normalizedBad[0]) return -1;
75
+ if (normalizedBad.length > 2 && nc.length > 2 && nc.slice(0, 2) === normalizedBad.slice(0, 2)) {
76
+ score += 10;
77
+ }
78
+ }
79
+ return score;
80
+ }
81
+
82
+ let best = null, bestScore = -1;
83
+ for (const candidate of candidates) {
84
+ const s = scoreCandidate(candidate);
85
+ if (s > bestScore) { bestScore = s; best = candidate; }
86
+ }
87
+
88
+ const threshold = isLong ? 85 : 70;
89
+ return bestScore >= threshold ? best : null;
90
+ }
91
+
92
+ export function correctedCommand(command, badFlag, goodFlag) {
93
+ if (!badFlag || !goodFlag) return null;
94
+ const parts = shellSplit(command);
95
+ if (parts === null) {
96
+ return command.includes(badFlag) ? command.replace(badFlag, goodFlag) : null;
97
+ }
98
+ const idx = parts.indexOf(badFlag);
99
+ if (idx === -1) return null;
100
+ parts[idx] = goodFlag;
101
+ return parts.map(shellQuote).join(' ');
102
+ }
103
+
104
+ export function verifyCommand(command, badFlag) {
105
+ const cmdName = command.trim().split(/\s+/)[0] || '';
106
+ if (!cmdName || cmdName === '.' || cmdName === 'source') {
107
+ return new VerificationResult({ availableFlags: [], suggestedFlag: null, correctedCommand: null, source: 'none' });
108
+ }
109
+
110
+ const { output, source } = runHelpCommand(cmdName);
111
+ const flags = parseFlagsFromText(output);
112
+ if (!badFlag) {
113
+ return new VerificationResult({ availableFlags: flags, suggestedFlag: null, correctedCommand: null, source });
114
+ }
115
+
116
+ const suggestion = suggestFlag(badFlag, flags);
117
+ const fixed = suggestion ? correctedCommand(command, badFlag, suggestion) : null;
118
+ return new VerificationResult({ availableFlags: flags, suggestedFlag: suggestion, correctedCommand: fixed, source });
119
+ }
120
+
121
+ // ── Shell tokenizer/quoter helpers ──────────────────────────────────────────
122
+
123
+ function shellSplit(cmd) {
124
+ const tokens = [];
125
+ let current = '';
126
+ let inSingle = false, inDouble = false;
127
+ for (let i = 0; i < cmd.length; i++) {
128
+ const c = cmd[i];
129
+ if (inSingle) {
130
+ if (c === "'") inSingle = false;
131
+ else current += c;
132
+ } else if (inDouble) {
133
+ if (c === '"') inDouble = false;
134
+ else if (c === '\\' && i + 1 < cmd.length) current += cmd[++i];
135
+ else current += c;
136
+ } else if (c === "'") {
137
+ inSingle = true;
138
+ } else if (c === '"') {
139
+ inDouble = true;
140
+ } else if (c === '\\' && i + 1 < cmd.length) {
141
+ current += cmd[++i];
142
+ } else if (/\s/.test(c)) {
143
+ if (current) { tokens.push(current); current = ''; }
144
+ } else {
145
+ current += c;
146
+ }
147
+ }
148
+ if (inSingle || inDouble) return null; // unbalanced quotes
149
+ if (current) tokens.push(current);
150
+ return tokens;
151
+ }
152
+
153
+ function shellQuote(s) {
154
+ if (/^[a-zA-Z0-9._\-/=:@%+,]+$/.test(s)) return s;
155
+ return "'" + s.replace(/'/g, "'\"'\"'") + "'";
156
+ }
package/src/watcher.js ADDED
@@ -0,0 +1,123 @@
1
+ // watcher.js — JSONL stream tail + failure report builder (port of watcher.py)
2
+
3
+ import { openSync, readSync, statSync, closeSync, mkdirSync, writeFileSync, existsSync } from 'fs';
4
+ import { dirname } from 'path';
5
+ import { classifyFailure } from './parser.js';
6
+ import { verifyCommand, VerificationResult } from './verifier.js';
7
+
8
+ export class StreamEvent {
9
+ constructor({ ts, exitCode, command, stderr, shell = '', actor = '', cwd = '', workspaceRoot = '', session = null, pid = null }) {
10
+ this.ts = ts;
11
+ this.exitCode = exitCode;
12
+ this.command = command;
13
+ this.stderr = stderr;
14
+ this.shell = shell;
15
+ this.actor = actor;
16
+ this.cwd = cwd;
17
+ this.workspaceRoot = workspaceRoot;
18
+ this.session = session;
19
+ this.pid = pid;
20
+ }
21
+ }
22
+
23
+ export class FailureReport {
24
+ constructor({ event, parsed, verification }) {
25
+ this.event = event;
26
+ this.parsed = parsed;
27
+ this.verification = verification;
28
+ }
29
+ }
30
+
31
+ export function parseLine(line) {
32
+ line = line.replace(/^\uFEFF/, ''); // strip UTF-8 BOM
33
+ try {
34
+ const payload = JSON.parse(line);
35
+ return new StreamEvent({
36
+ ts: String(payload.ts ?? ''),
37
+ exitCode: Number(payload.exit_code ?? 0),
38
+ command: String(payload.command ?? ''),
39
+ stderr: String(payload.stderr ?? ''),
40
+ shell: String(payload.shell ?? ''),
41
+ actor: String(payload.actor ?? ''),
42
+ cwd: String(payload.cwd ?? ''),
43
+ workspaceRoot: String(payload.workspace_root ?? ''),
44
+ session: payload.session ?? null,
45
+ pid: payload.pid ?? null,
46
+ });
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function toFailureReport(event) {
53
+ const parsed = classifyFailure(event.command, event.stderr, event.exitCode);
54
+ let verification;
55
+ if (parsed.errorType === 'invalid_flag') {
56
+ verification = verifyCommand(event.command, parsed.hallucinatedFlag);
57
+ } else {
58
+ verification = new VerificationResult({
59
+ availableFlags: [],
60
+ suggestedFlag: null,
61
+ correctedCommand: null,
62
+ source: 'none',
63
+ });
64
+ }
65
+ return new FailureReport({ event, parsed, verification });
66
+ }
67
+
68
+ // Async generator that tails a JSONL file and yields StreamEvent objects.
69
+ // Resolves as soon as the file is created and emits new lines as they appear.
70
+ export async function* tailJsonl(filePath, { pollInterval = 200, fromStart = false } = {}) {
71
+ const dir = dirname(filePath);
72
+ mkdirSync(dir, { recursive: true });
73
+ if (!existsSync(filePath)) writeFileSync(filePath, '', 'utf8');
74
+
75
+ const CHUNK = 65536;
76
+ const buf = Buffer.alloc(CHUNK);
77
+ let fd = openSync(filePath, 'r');
78
+ let pos = fromStart ? 0 : statSync(filePath).size;
79
+ let leftover = '';
80
+
81
+ try {
82
+ while (true) {
83
+ let bytesRead = readSync(fd, buf, 0, CHUNK, pos);
84
+ if (bytesRead > 0) {
85
+ pos += bytesRead;
86
+ leftover += buf.subarray(0, bytesRead).toString('utf8');
87
+ const lines = leftover.split('\n');
88
+ leftover = lines.pop(); // last potentially incomplete line
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed) continue;
92
+ const event = parseLine(trimmed);
93
+ if (event) yield event;
94
+ }
95
+ } else {
96
+ // Check for truncation (log rotation)
97
+ try {
98
+ const size = statSync(filePath).size;
99
+ if (size < pos) {
100
+ closeSync(fd);
101
+ fd = openSync(filePath, 'r');
102
+ pos = 0;
103
+ leftover = '';
104
+ }
105
+ } catch {
106
+ // file disappeared, reopen
107
+ try { closeSync(fd); } catch { /* ignore */ }
108
+ if (!existsSync(filePath)) writeFileSync(filePath, '', 'utf8');
109
+ fd = openSync(filePath, 'r');
110
+ pos = 0;
111
+ leftover = '';
112
+ }
113
+ await sleep(pollInterval);
114
+ }
115
+ }
116
+ } finally {
117
+ try { closeSync(fd); } catch { /* ignore */ }
118
+ }
119
+ }
120
+
121
+ function sleep(ms) {
122
+ return new Promise(resolve => setTimeout(resolve, ms));
123
+ }