klyro 0.1.17 → 0.1.18

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.
@@ -76,6 +76,7 @@ export interface RunOptions {
76
76
  command?: string;
77
77
  maxRepairAttempts?: number;
78
78
  timeoutMs?: number;
79
+ requireVerify?: boolean;
79
80
  };
80
81
  /**
81
82
  * Level 9 — persistence. When a SessionStore is provided, every message
@@ -17,8 +17,12 @@
17
17
  import { text, toolUse, toolResult as mkToolResult } from './message.js';
18
18
  import { redact } from '../policy/secret-redactor.js';
19
19
  import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
20
+ import * as path from 'node:path';
20
21
  import { verify, diagnosticForModel } from '../verification/engine.js';
21
22
  import { detectVerifyCommand } from '../verification/auto.js';
23
+ import { ensureBaseline, getBaseline } from '../verification/baseline.js';
24
+ import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
25
+ import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
22
26
  import { globalBus } from '../events/bus.js';
23
27
  import { TraceWriter } from '../trace/writer.js';
24
28
  const DEFAULT_MAX_STEPS = 30;
@@ -92,6 +96,20 @@ export async function run(opts, deps) {
92
96
  };
93
97
  const store = opts.persist?.store;
94
98
  const sessionId = opts.persist?.sessionId;
99
+ // 6.1 baseline cache per HEAD — capture before first edit
100
+ let baselinePrimed = false;
101
+ async function primeBaseline() {
102
+ if (baselinePrimed)
103
+ return;
104
+ baselinePrimed = true;
105
+ const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
106
+ if (!cmd)
107
+ return;
108
+ try {
109
+ await ensureBaseline(opts.cwd, cmd);
110
+ }
111
+ catch { /* ignore */ }
112
+ }
95
113
  async function checkpoint(msg, obs) {
96
114
  if (!store || !sessionId)
97
115
  return;
@@ -271,13 +289,53 @@ export async function run(opts, deps) {
271
289
  if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
272
290
  emit?.({ kind: 'verification_started', command: verifyCmd });
273
291
  let vResult;
292
+ // 6.3 — scoped run if edited files known
293
+ const edited = [...fileEditCounts.keys()];
294
+ const related = findRelatedTests(opts.cwd, edited);
295
+ const scopedCmd = buildScopedCommand(opts.cwd, verifyCmd, related);
296
+ const cmdToRun = scopedCmd ?? verifyCmd;
297
+ const isScoped = !!scopedCmd;
298
+ if (isScoped)
299
+ emit?.({ kind: 'verification_started', command: cmdToRun });
274
300
  try {
275
- vResult = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
301
+ if (isScoped) {
302
+ const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
303
+ const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
304
+ vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
305
+ }
306
+ else {
307
+ vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
308
+ }
276
309
  }
277
310
  catch (e) {
278
311
  const msg = e instanceof Error ? e.message : String(e);
279
312
  vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
280
313
  }
314
+ // 6.3 — sanity checks before full verify
315
+ if (vResult.ok) {
316
+ // quick syntax/import guard for last edited files
317
+ for (const f of edited.slice(-3)) {
318
+ const sc = await syntaxCheck(opts.cwd, f);
319
+ if (!sc.ok) {
320
+ vResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
321
+ break;
322
+ }
323
+ const ic = checkImports(opts.cwd, f);
324
+ if (!ic.ok) {
325
+ vResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ // If scoped passed but full may still fail, run full before declaring success
331
+ if (vResult.ok && isScoped) {
332
+ try {
333
+ const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
334
+ if (!full.ok)
335
+ vResult = full;
336
+ }
337
+ catch { /* scoped success is enough */ }
338
+ }
281
339
  if (vResult.ok) {
282
340
  emit?.({ kind: 'verification_succeeded', command: verifyCmd });
283
341
  emit?.({ kind: 'final_text', text: finalText });
@@ -291,16 +349,90 @@ export async function run(opts, deps) {
291
349
  await closeTracer();
292
350
  return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
293
351
  }
294
- // Failure repair loop
352
+ // 6.4 classify
353
+ const baseline = await getBaseline(opts.cwd, verifyCmd);
354
+ const isFlaky = await rerunOnce(opts.cwd, cmdToRun, 30_000);
355
+ const cls = classifyFailure({ failure: vResult.failure, stdout: vResult.stdout, stderr: vResult.stderr }, baseline, isFlaky);
356
+ if (cls === 'flaky') {
357
+ // rerun succeeded on second try — treat as flaky, don't count as repair
358
+ emit?.({ kind: 'verification_succeeded', command: verifyCmd });
359
+ emit?.({ kind: 'final_text', text: finalText });
360
+ emit?.({ kind: 'step_end', step: steps });
361
+ if (store && sessionId) {
362
+ try {
363
+ await store.setStatus(sessionId, 'complete', finalText);
364
+ }
365
+ catch { /* ignore */ }
366
+ }
367
+ await closeTracer();
368
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
369
+ }
370
+ if (cls === 'env') {
371
+ // don't try to repair env failures with code edits
372
+ const envMsg = { role: 'user', content: [text(`Verification failed due to environment issue (not code):\n\n${diagnosticForModel(vResult)}\n\nPlease suggest how to fix the environment (install deps, set env vars) rather than editing code. If this is a missing binary, ask the user.`)] };
373
+ transcript.push(envMsg);
374
+ await checkpoint(envMsg);
375
+ verificationAttempts++;
376
+ emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
377
+ if (verificationAttempts >= maxRepairs) {
378
+ await closeTracer();
379
+ return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
380
+ }
381
+ emit?.({ kind: 'step_end', step: steps });
382
+ continue;
383
+ }
384
+ if (cls === 'pre_existing') {
385
+ // pre-existing — don't penalize, but still surface
386
+ const preMsg = { role: 'user', content: [text(`Note: verification failure appears pre-existing (present in baseline at HEAD). Current failure:\n\n${diagnosticForModel(vResult)}\n\nIf this failure is unrelated to your changes, you may proceed, but try to avoid making it worse.`)] };
387
+ transcript.push(preMsg);
388
+ await checkpoint(preMsg);
389
+ // still count as needing repair if introduced files overlap, else allow completion
390
+ // For now, treat pre-existing as non-blocking after one warning if no introduced files in failure
391
+ const introducedPaths = new Set(edited);
392
+ const failurePaths = new Set(vResult.failure?.files.map((f) => f.path).filter(Boolean) ?? []);
393
+ const overlaps = [...failurePaths].some((p) => introducedPaths.has(p) || introducedPaths.has(path.basename(p)));
394
+ if (!overlaps) {
395
+ emit?.({ kind: 'verification_succeeded', command: verifyCmd });
396
+ emit?.({ kind: 'final_text', text: finalText });
397
+ emit?.({ kind: 'step_end', step: steps });
398
+ if (store && sessionId) {
399
+ try {
400
+ await store.setStatus(sessionId, 'complete', finalText);
401
+ }
402
+ catch { /* ignore */ }
403
+ }
404
+ await closeTracer();
405
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
406
+ }
407
+ }
408
+ // Failure → repair loop (introduced)
295
409
  verificationAttempts++;
296
410
  const diagnostic = diagnosticForModel(vResult);
297
411
  const failureType = vResult.failure?.type ?? 'unknown';
412
+ // 6.4 — gather context
413
+ const ctx = await gatherRepairContext(opts.cwd, vResult.failure);
414
+ const ctxBlock = [
415
+ ctx.hunks ? `Changed hunks:\n${ctx.hunks.slice(0, 1500)}` : '',
416
+ ctx.failingTests.length > 0 ? `Failing test excerpt:\n${ctx.failingTests[0]?.content.slice(0, 1500)}` : '',
417
+ ctx.blame ? `Blame:\n${ctx.blame.slice(0, 800)}` : '',
418
+ ].filter(Boolean).join('\n\n');
298
419
  emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
299
420
  emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
300
421
  telemetry.recordError(`verify_${failureType}`);
422
+ // 6.4 guard — check if last diff touches assertions/skips (would need approval)
423
+ try {
424
+ const diffText = await (await import('node:fs/promises')).readFile(path.join(opts.cwd, '.klyro', 'checkpoints', 'last.diff'), 'utf-8').catch(() => ctx.hunks);
425
+ const g = guardRepair(diffText ?? ctx.hunks, edited);
426
+ if (g.blocked) {
427
+ const guardMsg = { role: 'user', content: [text(`Repair guard blocked: ${g.reason}\n\nIf you must edit test assertions or add skips, first ask the user for explicit approval via ask_user.`)] };
428
+ transcript.push(guardMsg);
429
+ await checkpoint(guardMsg);
430
+ }
431
+ }
432
+ catch { /* ignore guard */ }
301
433
  const repairMsg = {
302
434
  role: 'user',
303
- content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above.`)],
435
+ content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}, class=${cls}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\n${ctxBlock ? `\nContext:\n${ctxBlock}\n` : ''}\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above. Do not edit test assertions unless the test itself is wrong — fix the source.`)],
304
436
  };
305
437
  transcript.push(repairMsg);
306
438
  await checkpoint(repairMsg);
@@ -398,8 +530,11 @@ export async function run(opts, deps) {
398
530
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
399
531
  if (obs.ok) {
400
532
  telemetry.recordToolCall(call, latencyMs, false);
401
- if (call.name === 'write_file' || call.name === 'edit_file')
533
+ if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch')
402
534
  hasEdits = true;
535
+ // 6.1 — prime baseline on first edit
536
+ if (hasEdits && !baselinePrimed)
537
+ void primeBaseline();
403
538
  }
404
539
  else {
405
540
  const code = String(obs.error?.code ?? 'tool_error');
package/dist/cli/eval.js CHANGED
@@ -61,6 +61,21 @@ export async function runEval(opts) {
61
61
  for (const e of entries) {
62
62
  if (opts.filter && !e.includes(opts.filter))
63
63
  continue;
64
+ // 6.5 — suite filter: smoke = type smoke, l6 = prefix l6-introduce
65
+ if (opts.suite && opts.suite !== 'smoke') {
66
+ if (!e.startsWith(opts.suite) && !e.includes(opts.suite))
67
+ continue;
68
+ }
69
+ else if (opts.suite === 'smoke') {
70
+ // smoke = only type smoke (exclude l6 introduce fixtures)
71
+ try {
72
+ const metaRaw = await fs.readFile(path.join(fixturesDir, e, 'meta.json'), 'utf-8');
73
+ const meta = JSON.parse(metaRaw);
74
+ if (meta.suite === 'l6' || meta.type === 'l6-introduce')
75
+ continue;
76
+ }
77
+ catch { /* no meta → include */ }
78
+ }
64
79
  const taskPath = path.join(fixturesDir, e, 'task.md');
65
80
  try {
66
81
  const task = await fs.readFile(taskPath, 'utf-8');
package/dist/cli/repl.js CHANGED
@@ -480,6 +480,29 @@ export async function startRepl(opts = {}) {
480
480
  }
481
481
  return;
482
482
  }
483
+ case 'verify': {
484
+ const { detectVerifiers, primaryVerifyCommand } = await import('../verification/registry.js');
485
+ const { verify } = await import('../verification/engine.js');
486
+ const verifiers = detectVerifiers(cwd);
487
+ if (verifiers.length === 0) {
488
+ queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: 'No verifiers detected (no test/typecheck/lint/build). Try `npm test` manually.', role: 'assistant' });
489
+ return;
490
+ }
491
+ const list = verifiers.map((v) => ` ${v.id}: ${v.command}`).join('\n');
492
+ queuedAppend({ id: `vrfy-list-${Date.now()}`, kind: 'text', text: `Verifiers:\n${list}`, role: 'assistant' });
493
+ const cmd = primaryVerifyCommand(cwd);
494
+ if (!cmd)
495
+ return;
496
+ queuedAppend({ id: `vrfy-run-${Date.now()}`, kind: 'text', text: `[verify] running \`${cmd}\`...`, role: 'assistant' });
497
+ try {
498
+ const res = await verify({ cwd, command: cmd });
499
+ queuedAppend({ id: `vrfy-res-${Date.now()}`, kind: 'text', text: res.ok ? `[verify] passed (${cmd})` : `[verify] failed (${cmd}): ${res.stderr.slice(0, 500)}`, role: 'assistant' });
500
+ }
501
+ catch (err) {
502
+ queuedAppend({ id: `vrfy-err-${Date.now()}`, kind: 'error', message: `verify failed: ${err instanceof Error ? err.message : String(err)}` });
503
+ }
504
+ return;
505
+ }
483
506
  case 'compact':
484
507
  queuedAppend({
485
508
  id: `stub-${Date.now()}`,
package/dist/cli/run.d.ts CHANGED
@@ -59,6 +59,7 @@ export interface RunCliOptions {
59
59
  verifyCommand?: string;
60
60
  maxRepairAttempts?: number;
61
61
  verifyTimeoutMs?: number;
62
+ requireVerify?: boolean;
62
63
  /** Level 9 — persistence */
63
64
  persist?: boolean;
64
65
  sessionId?: string;
package/dist/cli/run.js CHANGED
@@ -110,6 +110,7 @@ export async function runOnce(opts) {
110
110
  command: opts.verifyCommand,
111
111
  maxRepairAttempts: opts.maxRepairAttempts ?? 3,
112
112
  timeoutMs: opts.verifyTimeoutMs,
113
+ requireVerify: opts.requireVerify,
113
114
  };
114
115
  const result = await run({
115
116
  task: opts.task,
@@ -212,6 +213,22 @@ export async function runOnce(opts) {
212
213
  stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
213
214
  return 5;
214
215
  }
216
+ // 6.5 — --require-verify: if edits were made but verification never passed, exit 8
217
+ if (opts.requireVerify && result.verification && !result.verification.ok) {
218
+ if (output === 'json')
219
+ stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_failed' }) + '\n');
220
+ else
221
+ stderr.write('klyro: --require-verify: verification required but not passed\n');
222
+ return 8;
223
+ }
224
+ if (opts.requireVerify && !result.verification) {
225
+ // hasEdits but no verification command found
226
+ if (output === 'json')
227
+ stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_missing' }) + '\n');
228
+ else
229
+ stderr.write('klyro: --require-verify: no verification command found and edits were made\n');
230
+ return 8;
231
+ }
215
232
  if (output === 'json')
216
233
  stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
217
234
  return 0;
@@ -49,6 +49,8 @@ export type SlashCommand = {
49
49
  kind: 'memory';
50
50
  } | {
51
51
  kind: 'jobs';
52
+ } | {
53
+ kind: 'verify';
52
54
  } | {
53
55
  kind: 'prompt';
54
56
  text: string;
@@ -14,7 +14,7 @@
14
14
  * Anything not starting with "/" is a regular prompt and yields
15
15
  * { kind: 'prompt', text }.
16
16
  */
17
- const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'exit', 'clear'];
17
+ const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'verify', 'exit', 'clear'];
18
18
  export function parse(input) {
19
19
  const trimmed = input.trim();
20
20
  if (!trimmed.startsWith('/')) {
@@ -35,6 +35,7 @@ export function parse(input) {
35
35
  case 'thinking': return { kind: 'thinking' };
36
36
  case 'memory': return { kind: 'memory' };
37
37
  case 'jobs': return { kind: 'jobs' };
38
+ case 'verify': return { kind: 'verify' };
38
39
  case 'quit':
39
40
  case 'exit':
40
41
  case 'q': return { kind: 'quit' };
package/dist/index.js CHANGED
@@ -296,6 +296,7 @@ async function main() {
296
296
  .option('--verify-command <cmd>', 'Custom verification command (default: auto-detected)')
297
297
  .option('--max-repairs <n>', 'Max autonomous repair attempts (default 3)', (v) => parsePositiveInt('--max-repairs', v))
298
298
  .option('--persist', 'Enable session persistence (Level 9, default: enabled)')
299
+ .option('--require-verify', 'Fail with exit 8 if no verification passed after edits (6.5)')
299
300
  .action(async (prompt, opts) => {
300
301
  const model = opts.model ?? process.env.KLYRO_MODEL;
301
302
  if (!model) {
@@ -328,6 +329,7 @@ async function main() {
328
329
  verifyCommand: opts.verifyCommand,
329
330
  maxRepairAttempts: opts.maxRepairs,
330
331
  persist: opts.persist,
332
+ requireVerify: !!opts.requireVerify,
331
333
  });
332
334
  process.exit(code);
333
335
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 6.1 — Baseline run (pre-existing failure cache per HEAD)
3
+ * Before first edit, run primary verifier and cache result keyed by git HEAD.
4
+ * Used to distinguish introduced vs pre-existing failures (6.4 classify).
5
+ */
6
+ export interface BaselineResult {
7
+ head: string;
8
+ command: string;
9
+ ok: boolean;
10
+ exitCode: number;
11
+ stdout: string;
12
+ stderr: string;
13
+ capturedAt: number;
14
+ }
15
+ export declare function getBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
16
+ export declare function runBaseline(cwd: string, command?: string, timeoutMs?: number): Promise<BaselineResult | null>;
17
+ export declare function ensureBaseline(cwd: string, command?: string): Promise<BaselineResult | null>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 6.1 — Baseline run (pre-existing failure cache per HEAD)
3
+ * Before first edit, run primary verifier and cache result keyed by git HEAD.
4
+ * Used to distinguish introduced vs pre-existing failures (6.4 classify).
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import * as crypto from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+ import { primaryVerifyCommand } from './registry.js';
11
+ async function gitHead(cwd) {
12
+ return new Promise((resolve) => {
13
+ const child = spawn('git', ['rev-parse', 'HEAD'], { cwd, shell: false });
14
+ let out = '';
15
+ child.stdout.on('data', (b) => { out += b.toString(); });
16
+ child.on('close', () => resolve(out.trim() || 'no-head'));
17
+ child.on('error', () => resolve('no-head'));
18
+ });
19
+ }
20
+ function baselinePath(cwd, head) {
21
+ const safe = head.slice(0, 12) || 'no-head';
22
+ return path.join(cwd, '.klyro', 'baselines', `${safe}.json`);
23
+ }
24
+ export async function getBaseline(cwd, command) {
25
+ const cmd = command ?? primaryVerifyCommand(cwd);
26
+ if (!cmd)
27
+ return null;
28
+ const head = await gitHead(cwd);
29
+ const p = baselinePath(cwd, head);
30
+ try {
31
+ const raw = fs.readFileSync(p, 'utf-8');
32
+ const parsed = JSON.parse(raw);
33
+ if (parsed.head === head && parsed.command === cmd)
34
+ return parsed;
35
+ }
36
+ catch { /* miss */ }
37
+ return null;
38
+ }
39
+ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
40
+ const cmd = command ?? primaryVerifyCommand(cwd);
41
+ if (!cmd)
42
+ return null;
43
+ const head = await gitHead(cwd);
44
+ const p = baselinePath(cwd, head);
45
+ // if cached, return
46
+ const cached = await getBaseline(cwd, cmd);
47
+ if (cached)
48
+ return cached;
49
+ const result = await runCmd(cwd, cmd, timeoutMs);
50
+ const baseline = {
51
+ head,
52
+ command: cmd,
53
+ ok: result.ok,
54
+ exitCode: result.exitCode,
55
+ stdout: result.stdout.slice(0, 8000),
56
+ stderr: result.stderr.slice(0, 8000),
57
+ capturedAt: Date.now(),
58
+ };
59
+ try {
60
+ fs.mkdirSync(path.dirname(p), { recursive: true });
61
+ const tmp = `${p}.tmp-${crypto.randomBytes(4).toString('hex')}`;
62
+ fs.writeFileSync(tmp, JSON.stringify(baseline, null, 2));
63
+ fs.renameSync(tmp, p);
64
+ }
65
+ catch { /* ignore */ }
66
+ return baseline;
67
+ }
68
+ function runCmd(cwd, command, timeoutMs) {
69
+ return new Promise((resolve) => {
70
+ const child = spawn(command, { cwd, shell: true, env: process.env });
71
+ let stdout = '';
72
+ let stderr = '';
73
+ let done = false;
74
+ const timer = setTimeout(() => {
75
+ if (done)
76
+ return;
77
+ done = true;
78
+ try {
79
+ child.kill();
80
+ }
81
+ catch { /* ignore */ }
82
+ resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
83
+ }, timeoutMs);
84
+ child.stdout.on('data', (b) => { stdout += b.toString(); });
85
+ child.stderr.on('data', (b) => { stderr += b.toString(); });
86
+ child.on('close', (code) => {
87
+ if (done)
88
+ return;
89
+ done = true;
90
+ clearTimeout(timer);
91
+ const exit = typeof code === 'number' ? code : -1;
92
+ resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
93
+ });
94
+ child.on('error', (err) => {
95
+ if (done)
96
+ return;
97
+ done = true;
98
+ clearTimeout(timer);
99
+ resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
100
+ });
101
+ });
102
+ }
103
+ export async function ensureBaseline(cwd, command) {
104
+ const existing = await getBaseline(cwd, command);
105
+ if (existing)
106
+ return existing;
107
+ return runBaseline(cwd, command);
108
+ }
@@ -0,0 +1,22 @@
1
+ import type { Failure } from './detect.js';
2
+ import type { BaselineResult } from './baseline.js';
3
+ export type FailureClass = 'introduced' | 'pre_existing' | 'flaky' | 'env';
4
+ export declare function classifyFailure(current: {
5
+ failure?: Failure;
6
+ stdout: string;
7
+ stderr: string;
8
+ }, baseline: BaselineResult | null, flakyRerunOk?: boolean): FailureClass;
9
+ export declare function rerunOnce(cwd: string, command: string, timeoutMs?: number): Promise<boolean>;
10
+ export interface RepairContext {
11
+ failingTests: {
12
+ file: string;
13
+ content: string;
14
+ }[];
15
+ hunks: string;
16
+ blame: string;
17
+ }
18
+ export declare function gatherRepairContext(cwd: string, failure: Failure | undefined): Promise<RepairContext>;
19
+ export declare function guardRepair(diff: string, editedFiles: string[]): {
20
+ blocked: boolean;
21
+ reason?: string;
22
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 6.4 — Repair classifier + context gather + guard
3
+ */
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ export function classifyFailure(current, baseline, flakyRerunOk) {
8
+ const combined = (current.stderr + '\n' + current.stdout).toLowerCase();
9
+ // env: missing binary, network, permission, no such file, EACCES etc
10
+ if (/enoent|command not found|no such file|network|econn|etimedout|eacces|permission denied|env/.test(combined) && /error/i.test(combined)) {
11
+ // only if not a real test failure but env
12
+ if (!current.failure || current.failure.type === 'unknown')
13
+ return 'env';
14
+ // even test failures can be env if message hints
15
+ if (/enoent|not found/.test(combined))
16
+ return 'env';
17
+ }
18
+ if (flakyRerunOk)
19
+ return 'flaky';
20
+ if (baseline && !baseline.ok) {
21
+ const baseRaw = (baseline.stderr + '\n' + baseline.stdout);
22
+ // if current failure files overlap baseline failure raw, treat as pre-existing
23
+ if (current.failure && baseline.stdout + baseline.stderr) {
24
+ const curPaths = new Set(current.failure.files.map((f) => f.path).filter(Boolean));
25
+ const baseContains = [...curPaths].some((p) => baseRaw.includes(p));
26
+ if (baseContains)
27
+ return 'pre_existing';
28
+ // also if same exit code and type, likely pre-existing
29
+ if (baseRaw.includes(current.failure.files[0]?.message ?? '') && baseRaw.length > 0)
30
+ return 'pre_existing';
31
+ }
32
+ // if baseline failed and current also fails with similar raw length, assume pre-existing
33
+ if (current.failure && baseline.stderr.length > 0 && combined.includes(baseline.stderr.slice(0, 200).toLowerCase()))
34
+ return 'pre_existing';
35
+ }
36
+ return 'introduced';
37
+ }
38
+ export async function rerunOnce(cwd, command, timeoutMs = 45_000) {
39
+ return new Promise((resolve) => {
40
+ const child = spawn(command, { cwd, shell: true, env: process.env });
41
+ let done = false;
42
+ const t = setTimeout(() => { if (!done) {
43
+ done = true;
44
+ try {
45
+ child.kill();
46
+ }
47
+ catch { }
48
+ resolve(false);
49
+ } }, timeoutMs);
50
+ child.on('close', (code) => { if (done)
51
+ return; done = true; clearTimeout(t); resolve(code === 0); });
52
+ child.on('error', () => { if (done)
53
+ return; done = true; clearTimeout(t); resolve(false); });
54
+ });
55
+ }
56
+ export async function gatherRepairContext(cwd, failure) {
57
+ const failingTests = [];
58
+ if (failure) {
59
+ for (const f of failure.files.slice(0, 3)) {
60
+ if (!f.path)
61
+ continue;
62
+ const full = path.join(cwd, f.path);
63
+ try {
64
+ const raw = fs.readFileSync(full, 'utf-8');
65
+ // slice around failing line if available
66
+ const lines = raw.split('\n');
67
+ const start = Math.max(0, (f.line ?? 1) - 20);
68
+ const end = Math.min(lines.length, (f.line ?? 1) + 20);
69
+ failingTests.push({ file: f.path, content: lines.slice(start, end).join('\n') });
70
+ }
71
+ catch { /* ignore */ }
72
+ }
73
+ }
74
+ // hunks: git diff --stat + --unified=2 for changed files
75
+ const hunks = await execCapture(cwd, 'git diff --stat && echo "---" && git diff -U2 2>&1 | head -n 300');
76
+ const blame = failure?.files[0]?.path
77
+ ? await execCapture(cwd, `git blame "${failure.files[0].path}" 2>&1 | head -n 20`)
78
+ : '';
79
+ return { failingTests, hunks, blame };
80
+ }
81
+ function execCapture(cwd, cmd) {
82
+ return new Promise((resolve) => {
83
+ const child = spawn(cmd, { cwd, shell: true, env: process.env });
84
+ let out = '';
85
+ child.stdout.on('data', (b) => { out += b.toString(); });
86
+ child.stderr.on('data', (b) => { out += b.toString(); });
87
+ child.on('close', () => resolve(out.slice(0, 4000)));
88
+ child.on('error', () => resolve(''));
89
+ });
90
+ }
91
+ // Guard: does diff touch assertions or add skips?
92
+ const ASSERT_RE = /(?:expect\s*\(|assert\.|assert\(|should\.|chai\.)/;
93
+ const SKIP_RE = /(?:\.skip\(|\.todo\(|xdescribe\(|xit\(|xtest\(|@pytest\.mark\.skip|pytest\.skip|:\s*skip\b)/i;
94
+ export function guardRepair(diff, editedFiles) {
95
+ if (!diff)
96
+ return { blocked: false };
97
+ const addedLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++'));
98
+ const touchesAssert = addedLines.some((l) => ASSERT_RE.test(l));
99
+ const addsSkip = addedLines.some((l) => SKIP_RE.test(l));
100
+ if (touchesAssert && addedLines.length < 20) {
101
+ // touching assertions in a small diff likely means editing test expectations — require approval
102
+ return { blocked: true, reason: 'repair touches test assertions — requires explicit approval (edit expectations directly is discouraged)' };
103
+ }
104
+ if (addsSkip) {
105
+ return { blocked: true, reason: 'repair adds skip/todo — requires explicit approval (skipping tests is not a fix)' };
106
+ }
107
+ return { blocked: false };
108
+ }
@@ -11,39 +11,178 @@
11
11
  * the raw output; we just pre-structure the high-signal lines.
12
12
  */
13
13
  const TS_LINE = /^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
14
+ const TSC_LINE2 = /^(.+?):(\d+):(\d+)\s+-\s+error\s+(TS\d+):\s+(.+)$/;
14
15
  const TEST_FAIL = /^\s*✘|FAIL\s+(\S+)|✗\s+(\S+)|×\s+(.+?)\s/;
15
- const LINT_LINE = /^(.+?)\s*$/;
16
16
  const RUNTIME_LINE = /^(?:Error|TypeError|ReferenceError):\s+(.+)$/;
17
+ // 6.2 — dedicated patterns for each runner/linter/compiler
18
+ const PYTEST_FAIL = /FAILED\s+(\S+?)(?:::\S+)?\s+-\s+(.+)/;
19
+ const PYTEST_ASSERT = /AssertionError:\s*(.+)/;
20
+ const GO_FAIL = /^---\s+FAIL:\s+(\S+)\s+\((.+)\)/;
21
+ const GO_PKG_FAIL = /^FAIL\s+(\S+)\s/;
22
+ const CARGO_FAIL = /^test\s+(\S+)\s+\.\.\.\s+FAILED/;
23
+ const CARGO_PANIC = /thread\s+'(.+?)'\s+panicked at\s+'(.+?)',\s+(.+?):(\d+):(\d+)/;
24
+ const MOCHA_FAIL = /^\s*\d+\)\s+(.+)$/;
25
+ const JUNIT_FAIL = /<(?:failure|error)[\s>]/;
26
+ const ESLINT_LINE = /^(.+?):(\d+):(\d+):\s+(error|warning)\s+(.+?)\s+\((.+?)\)\s*$/;
27
+ const RUFF_LINE = /^(.+?):(\d+):(\d+):\s+([A-Z]\d+)\s+(.+)$/;
28
+ const MYPY_LINE = /^(.+?):(\d+):\s+error:\s+(.+?)\s+\[(.+?)\]\s*$/;
29
+ const GCC_LINE = /^(.+?):(\d+):(\d+):\s+(?:fatal\s+)?error:\s+(.+)$/;
30
+ const VITE_ERROR = /ERROR\s+in\s+(.+)|Module build failed.+/;
31
+ const DOTNET_CS = /^(.+?)\((\d+),(\d+)\):\s+error\s+(CS\d+):\s+(.+)$/;
32
+ const GENERIC_FILE_LINE = /^(.+?):(\d+)(?::(\d+))?:\s*(.+)$/;
17
33
  export function detect(stdout, stderr, exitCode) {
18
34
  const combined = stderr + '\n' + stdout;
19
35
  if (exitCode === 0) {
20
36
  return { type: 'unknown', files: [], raw: combined, exitCode };
21
37
  }
22
- // TypeScript tsc.
38
+ // TypeScript tsc (both formats)
23
39
  const tsMatches = [];
24
40
  for (const line of combined.split(/\r?\n/)) {
25
- const m = TS_LINE.exec(line);
41
+ let m = TS_LINE.exec(line);
26
42
  if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
27
43
  tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
44
+ continue;
45
+ }
46
+ m = TSC_LINE2.exec(line);
47
+ if (m && m[1] && m[2] && m[3] && m[4] && m[5]) {
48
+ tsMatches.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
49
+ }
50
+ const dm = DOTNET_CS.exec(line);
51
+ if (dm && dm[1] && dm[2] && dm[3] && dm[4] && dm[5]) {
52
+ tsMatches.push({ path: dm[1], line: Number(dm[2]), column: Number(dm[3]), code: dm[4], message: dm[5] });
28
53
  }
29
54
  }
30
55
  if (tsMatches.length > 0)
31
- return { type: 'type', files: tsMatches, raw: combined, exitCode };
32
- // Test runner.
33
- if (/Test Suites:|FAIL\s|Tests:.*failed|✘|✗/.test(combined)) {
56
+ return { type: 'type', files: dedupe(tsMatches), raw: combined, exitCode };
57
+ // JUnit XML
58
+ if (JUNIT_FAIL.test(combined)) {
59
+ const files = [];
60
+ for (const line of combined.split(/\r?\n/)) {
61
+ if (JUNIT_FAIL.test(line))
62
+ files.push({ path: '', message: line.trim() });
63
+ }
64
+ // also extract file:line fallback
65
+ if (files.length === 0)
66
+ return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
67
+ return { type: 'test', files: dedupe(files), raw: combined, exitCode };
68
+ }
69
+ // pytest
70
+ if (/FAILED\s+\S+|AssertionError|pytest/.test(combined)) {
71
+ const files = [];
72
+ for (const line of combined.split(/\r?\n/)) {
73
+ let m = PYTEST_FAIL.exec(line);
74
+ if (m && m[1]) {
75
+ files.push({ path: m[1], message: m[2] ?? line.trim() });
76
+ continue;
77
+ }
78
+ m = PYTEST_ASSERT.exec(line);
79
+ if (m) {
80
+ files.push({ path: '', message: m[0] });
81
+ continue;
82
+ }
83
+ }
84
+ if (files.length > 0)
85
+ return { type: 'test', files: dedupe(files), raw: combined, exitCode };
86
+ }
87
+ // go test
88
+ if (/---\s+FAIL:|FAIL\s+\S+/.test(combined)) {
89
+ const files = [];
90
+ for (const line of combined.split(/\r?\n/)) {
91
+ let m = GO_FAIL.exec(line);
92
+ if (m && m[1]) {
93
+ files.push({ path: m[1], message: line.trim() });
94
+ continue;
95
+ }
96
+ m = GO_PKG_FAIL.exec(line);
97
+ if (m && m[1]) {
98
+ files.push({ path: m[1], message: line.trim() });
99
+ }
100
+ }
101
+ if (files.length > 0)
102
+ return { type: 'test', files: dedupe(files), raw: combined, exitCode };
103
+ }
104
+ // cargo test
105
+ if (/test result: FAILED|FAILED.*cargo|panicked at/.test(combined)) {
106
+ const files = [];
107
+ for (const line of combined.split(/\r?\n/)) {
108
+ let m = CARGO_FAIL.exec(line);
109
+ if (m && m[1]) {
110
+ files.push({ path: m[1], message: line.trim() });
111
+ continue;
112
+ }
113
+ m = CARGO_PANIC.exec(line);
114
+ if (m && m[3] && m[4] && m[5]) {
115
+ files.push({ path: m[3], line: Number(m[4]), column: Number(m[5]), message: m[2] ?? line.trim() });
116
+ }
117
+ }
118
+ if (files.length > 0)
119
+ return { type: 'test', files: dedupe(files), raw: combined, exitCode };
120
+ }
121
+ // Generic test runner (vitest/jest/mocha)
122
+ if (/Test Suites:|FAIL\s|Tests:.*failed|✘|✗|×\s+/.test(combined) || MOCHA_FAIL.test(combined)) {
34
123
  const files = [];
35
124
  for (const line of combined.split(/\r?\n/)) {
36
125
  const m = TEST_FAIL.exec(line);
37
126
  if (m) {
38
127
  const target = m[1] ?? m[2] ?? m[3] ?? line.trim();
39
128
  files.push({ path: target, message: line.trim() });
129
+ continue;
40
130
  }
131
+ const mm = MOCHA_FAIL.exec(line);
132
+ if (mm && mm[1])
133
+ files.push({ path: '', message: mm[1] });
41
134
  }
42
- return { type: 'test', files, raw: combined, exitCode };
135
+ if (files.length > 0)
136
+ return { type: 'test', files: dedupe(files), raw: combined, exitCode };
137
+ // fallback: any FAIL line
138
+ if (/FAIL/.test(combined))
139
+ return { type: 'test', files: extractGeneric(combined, 5), raw: combined, exitCode };
43
140
  }
44
- // ESLint.
45
- if (/error\s+at\s+|eslint.*problem|✖/.test(combined)) {
46
- return { type: 'lint', files: extractFirstLines(combined, 5), raw: combined, exitCode };
141
+ // ESLint
142
+ {
143
+ const es = [];
144
+ for (const line of combined.split(/\r?\n/)) {
145
+ const m = ESLINT_LINE.exec(line);
146
+ if (m && m[1] && m[2] && m[3] && m[5])
147
+ es.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[6], message: m[5] });
148
+ }
149
+ if (es.length > 0)
150
+ return { type: 'lint', files: dedupe(es), raw: combined, exitCode };
151
+ }
152
+ // ruff / flake8
153
+ {
154
+ const rf = [];
155
+ for (const line of combined.split(/\r?\n/)) {
156
+ const m = RUFF_LINE.exec(line);
157
+ if (m && m[1] && m[2] && m[3] && m[4] && m[5])
158
+ rf.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), code: m[4], message: m[5] });
159
+ }
160
+ if (rf.length > 0)
161
+ return { type: 'lint', files: dedupe(rf), raw: combined, exitCode };
162
+ }
163
+ // mypy
164
+ {
165
+ const mp = [];
166
+ for (const line of combined.split(/\r?\n/)) {
167
+ const m = MYPY_LINE.exec(line);
168
+ if (m && m[1] && m[2] && m[3] && m[4])
169
+ mp.push({ path: m[1], line: Number(m[2]), code: m[4], message: m[3] });
170
+ }
171
+ if (mp.length > 0)
172
+ return { type: 'type', files: dedupe(mp), raw: combined, exitCode };
173
+ }
174
+ // gcc/clang, vite/webpack
175
+ {
176
+ const gc = [];
177
+ for (const line of combined.split(/\r?\n/)) {
178
+ const m = GCC_LINE.exec(line);
179
+ if (m && m[1] && m[2] && m[3] && m[4])
180
+ gc.push({ path: m[1], line: Number(m[2]), column: Number(m[3]), message: m[4] });
181
+ else if (VITE_ERROR.test(line))
182
+ gc.push({ path: '', message: line.trim() });
183
+ }
184
+ if (gc.length > 0)
185
+ return { type: 'build', files: dedupe(gc), raw: combined, exitCode };
47
186
  }
48
187
  // Runtime exception.
49
188
  const lines = combined.split(/\r?\n/);
@@ -53,10 +192,36 @@ export function detect(stdout, stderr, exitCode) {
53
192
  }
54
193
  // Generic build.
55
194
  if (/error/i.test(combined)) {
195
+ const gf = extractGeneric(combined, 5);
196
+ if (gf.length > 0)
197
+ return { type: 'build', files: gf, raw: combined, exitCode };
56
198
  return { type: 'build', files: extractFirstLines(combined, 5), raw: combined, exitCode };
57
199
  }
58
200
  return { type: 'unknown', files: extractFirstLines(combined, 3), raw: combined, exitCode };
59
201
  }
202
+ function dedupe(files) {
203
+ const seen = new Set();
204
+ const out = [];
205
+ for (const f of files) {
206
+ const key = `${f.path}:${f.line ?? ''}:${f.column ?? ''}:${f.message}`;
207
+ if (!seen.has(key)) {
208
+ seen.add(key);
209
+ out.push(f);
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+ function extractGeneric(s, n) {
215
+ const out = [];
216
+ for (const line of s.split(/\r?\n/)) {
217
+ const m = GENERIC_FILE_LINE.exec(line);
218
+ if (m && m[1] && m[2] && m[4])
219
+ out.push({ path: m[1], line: Number(m[2]), column: m[3] ? Number(m[3]) : undefined, message: m[4] });
220
+ if (out.length >= n)
221
+ break;
222
+ }
223
+ return out;
224
+ }
60
225
  function extractFirstLines(s, n) {
61
226
  return s
62
227
  .split(/\r?\n/)
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 6.1 — Verifier registry
3
+ * Detects available verifiers from project heuristics (package.json / Makefile / pyproject / Cargo).
4
+ * Each verifier is a named command that can be run via `verify` engine.
5
+ */
6
+ export type VerifierKind = 'tests' | 'typecheck' | 'lint' | 'build' | 'format-check' | 'custom';
7
+ export interface Verifier {
8
+ id: VerifierKind;
9
+ label: string;
10
+ command: string;
11
+ priority: number;
12
+ }
13
+ export declare function detectVerifiers(cwd: string): Verifier[];
14
+ export declare function primaryVerifyCommand(cwd: string): string | null;
15
+ export interface VerifySettings {
16
+ commands?: string[];
17
+ onEdit?: boolean;
18
+ afterChange?: boolean;
19
+ beforeDone?: boolean;
20
+ maxRepairs?: number;
21
+ requireVerify?: boolean;
22
+ }
23
+ export declare function resolveVerifySettings(raw: Record<string, unknown> | undefined): VerifySettings;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * 6.1 — Verifier registry
3
+ * Detects available verifiers from project heuristics (package.json / Makefile / pyproject / Cargo).
4
+ * Each verifier is a named command that can be run via `verify` engine.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ function exists(p) {
9
+ try {
10
+ return fs.existsSync(p);
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function readJson(p) {
17
+ try {
18
+ const raw = fs.readFileSync(p, 'utf-8');
19
+ return JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function pm(cwd) {
26
+ if (exists(path.join(cwd, 'pnpm-lock.yaml')))
27
+ return 'pnpm';
28
+ if (exists(path.join(cwd, 'yarn.lock')))
29
+ return 'yarn';
30
+ if (exists(path.join(cwd, 'bun.lockb')))
31
+ return 'bun';
32
+ return 'npm';
33
+ }
34
+ function runCmd(base, cwd) {
35
+ const m = pm(cwd);
36
+ if (base === 'test')
37
+ return `${m} test`;
38
+ if (base === 'build')
39
+ return `${m} run build`;
40
+ if (base === 'lint')
41
+ return `${m} run lint`;
42
+ return `${m} run ${base}`;
43
+ }
44
+ export function detectVerifiers(cwd) {
45
+ const out = [];
46
+ const pkg = readJson(path.join(cwd, 'package.json'));
47
+ const hasPkg = !!pkg;
48
+ // tests — highest priority
49
+ if (hasPkg && pkg?.scripts?.test) {
50
+ out.push({ id: 'tests', label: 'tests', command: runCmd('test', cwd), priority: 10 });
51
+ }
52
+ else if (exists(path.join(cwd, 'pyproject.toml')) || exists(path.join(cwd, 'requirements.txt')) || exists(path.join(cwd, 'setup.py'))) {
53
+ // pytest heuristic — check pyproject for pytest
54
+ out.push({ id: 'tests', label: 'pytest', command: 'pytest', priority: 10 });
55
+ }
56
+ else if (exists(path.join(cwd, 'go.mod'))) {
57
+ out.push({ id: 'tests', label: 'go test', command: 'go test ./...', priority: 10 });
58
+ }
59
+ else if (exists(path.join(cwd, 'Cargo.toml'))) {
60
+ out.push({ id: 'tests', label: 'cargo test', command: 'cargo test', priority: 10 });
61
+ }
62
+ else if (exists(path.join(cwd, 'Makefile')) || exists(path.join(cwd, 'makefile'))) {
63
+ try {
64
+ const mk = fs.readFileSync(path.join(cwd, exists(path.join(cwd, 'Makefile')) ? 'Makefile' : 'makefile'), 'utf-8');
65
+ if (/^test:/m.test(mk))
66
+ out.push({ id: 'tests', label: 'make test', command: 'make test', priority: 10 });
67
+ }
68
+ catch { /* ignore */ }
69
+ }
70
+ // typecheck
71
+ if (exists(path.join(cwd, 'tsconfig.json'))) {
72
+ out.push({ id: 'typecheck', label: 'tsc', command: 'npx tsc --noEmit', priority: 20 });
73
+ }
74
+ else if (exists(path.join(cwd, 'pyproject.toml'))) {
75
+ try {
76
+ const py = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
77
+ if (py.includes('mypy'))
78
+ out.push({ id: 'typecheck', label: 'mypy', command: 'mypy .', priority: 20 });
79
+ }
80
+ catch { /* ignore */ }
81
+ }
82
+ // lint — check for eslint config or lint script
83
+ if (hasPkg && pkg?.scripts?.lint) {
84
+ out.push({ id: 'lint', label: 'lint', command: runCmd('lint', cwd), priority: 30 });
85
+ }
86
+ else if (exists(path.join(cwd, '.eslintrc.json')) || exists(path.join(cwd, '.eslintrc.js')) || exists(path.join(cwd, '.eslintrc.cjs')) || exists(path.join(cwd, 'eslint.config.js')) || exists(path.join(cwd, 'eslint.config.cjs')) || exists(path.join(cwd, 'eslint.config.mjs'))) {
87
+ out.push({ id: 'lint', label: 'eslint', command: 'npx eslint .', priority: 30 });
88
+ }
89
+ else if (exists(path.join(cwd, 'ruff.toml')) || exists(path.join(cwd, '.ruff.toml')) || exists(path.join(cwd, 'pyproject.toml'))) {
90
+ try {
91
+ if (exists(path.join(cwd, 'pyproject.toml'))) {
92
+ const py2 = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
93
+ if (py2.includes('[tool.ruff]'))
94
+ out.push({ id: 'lint', label: 'ruff', command: 'ruff check .', priority: 30 });
95
+ }
96
+ }
97
+ catch { /* ignore */ }
98
+ }
99
+ // build
100
+ if (hasPkg && pkg?.scripts?.build) {
101
+ out.push({ id: 'build', label: 'build', command: runCmd('build', cwd), priority: 40 });
102
+ }
103
+ // format-check
104
+ if (exists(path.join(cwd, '.prettierrc')) || exists(path.join(cwd, '.prettierrc.json')) || exists(path.join(cwd, 'prettier.config.js')) || exists(path.join(cwd, '.prettierrc.cjs'))) {
105
+ out.push({ id: 'format-check', label: 'prettier', command: 'npx prettier --check .', priority: 50 });
106
+ }
107
+ else if (hasPkg && pkg?.scripts?.['format:check']) {
108
+ out.push({ id: 'format-check', label: 'format:check', command: runCmd('format:check', cwd), priority: 50 });
109
+ }
110
+ return out.sort((a, b) => a.priority - b.priority);
111
+ }
112
+ export function primaryVerifyCommand(cwd) {
113
+ const v = detectVerifiers(cwd);
114
+ return v[0]?.command ?? null;
115
+ }
116
+ export function resolveVerifySettings(raw) {
117
+ if (!raw || typeof raw !== 'object')
118
+ return {};
119
+ const v = raw.verify;
120
+ if (!v || typeof v !== 'object')
121
+ return {};
122
+ return {
123
+ commands: Array.isArray(v.commands) ? v.commands : undefined,
124
+ onEdit: typeof v.onEdit === 'boolean' ? v.onEdit : undefined,
125
+ afterChange: typeof v.afterChange === 'boolean' ? v.afterChange : undefined,
126
+ beforeDone: typeof v.beforeDone === 'boolean' ? v.beforeDone : undefined,
127
+ maxRepairs: typeof v.maxRepairs === 'number' ? v.maxRepairs : undefined,
128
+ requireVerify: typeof v.requireVerify === 'boolean' ? v.requireVerify : undefined,
129
+ };
130
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 6.3 — Scoped runs & sanity checks
3
+ * Edited files → related tests (name match), scoped verify command, syntax + import checks.
4
+ */
5
+ export declare function findRelatedTests(cwd: string, editedFiles: string[]): string[];
6
+ export declare function buildScopedCommand(cwd: string, baseCommand: string, relatedTests: string[]): string | null;
7
+ export declare function runScopedVerify(cwd: string, command: string, timeoutMs?: number): Promise<{
8
+ ok: boolean;
9
+ exitCode: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }>;
13
+ /** Quick syntax check: try to parse file with node --check or tsc snippet */
14
+ export declare function syntaxCheck(cwd: string, file: string): Promise<{
15
+ ok: boolean;
16
+ error?: string;
17
+ }>;
18
+ /** Import-path existence check (TS/JS): read file, extract imports, verify targets exist */
19
+ export declare function checkImports(cwd: string, file: string): {
20
+ ok: boolean;
21
+ missing: string[];
22
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 6.3 — Scoped runs & sanity checks
3
+ * Edited files → related tests (name match), scoped verify command, syntax + import checks.
4
+ */
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ export function findRelatedTests(cwd, editedFiles) {
9
+ if (editedFiles.length === 0)
10
+ return [];
11
+ const allTests = [];
12
+ // collect candidate test files via simple walk (respect .gitignore minimally)
13
+ function walk(dir, depth = 0) {
14
+ if (depth > 6)
15
+ return;
16
+ let entries = [];
17
+ try {
18
+ entries = fs.readdirSync(dir, { withFileTypes: true });
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ for (const e of entries) {
24
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'dist' || e.name === '.klyro')
25
+ continue;
26
+ const p = path.join(dir, e.name);
27
+ if (e.isDirectory())
28
+ walk(p, depth + 1);
29
+ else if (e.isFile() && /(?:\.test\.|\.spec\.|__tests__|test_)/.test(p))
30
+ allTests.push(p);
31
+ }
32
+ }
33
+ walk(cwd);
34
+ const related = new Set();
35
+ for (const edited of editedFiles) {
36
+ const base = path.basename(edited, path.extname(edited)); // foo.ts -> foo
37
+ const dir = path.dirname(edited);
38
+ for (const t of allTests) {
39
+ const tb = path.basename(t);
40
+ // name match: foo.ts -> foo.test.ts, test_foo.py, foo_spec.ts, etc.
41
+ if (tb.includes(base) || base.includes(path.basename(t, path.extname(t)).replace(/\.test|\.spec|test_/g, ''))) {
42
+ related.add(path.relative(cwd, t));
43
+ }
44
+ // same directory prefix also counts
45
+ if (t.startsWith(dir) && tb.includes(base.slice(0, 4)))
46
+ related.add(path.relative(cwd, t));
47
+ }
48
+ }
49
+ return [...related];
50
+ }
51
+ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
52
+ if (relatedTests.length === 0)
53
+ return null;
54
+ if (relatedTests.length > 10)
55
+ return null; // too many → full suite
56
+ // npm test heuristic: npm test -- <files> or vitest/jest file list
57
+ if (baseCommand.includes('npm test') || baseCommand.includes('pnpm test') || baseCommand.includes('yarn test') || baseCommand.includes('bun test')) {
58
+ return `${baseCommand} -- ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
59
+ }
60
+ if (baseCommand.includes('pytest')) {
61
+ return `pytest ${relatedTests.map((f) => `"${f}"`).join(' ')}`;
62
+ }
63
+ if (baseCommand.includes('go test')) {
64
+ // go test ./... with file filter — fallback to full
65
+ return null;
66
+ }
67
+ if (baseCommand.includes('cargo test')) {
68
+ // cargo test --test <name>
69
+ return null;
70
+ }
71
+ return null;
72
+ }
73
+ export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
74
+ return new Promise((resolve) => {
75
+ const child = spawn(command, { cwd, shell: true, env: process.env });
76
+ let stdout = '';
77
+ let stderr = '';
78
+ let done = false;
79
+ const timer = setTimeout(() => {
80
+ if (done)
81
+ return;
82
+ done = true;
83
+ try {
84
+ child.kill();
85
+ }
86
+ catch { /* ignore */ }
87
+ resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[scoped timeout]' });
88
+ }, timeoutMs);
89
+ child.stdout.on('data', (b) => { stdout += b.toString(); });
90
+ child.stderr.on('data', (b) => { stderr += b.toString(); });
91
+ child.on('close', (code) => {
92
+ if (done)
93
+ return;
94
+ done = true;
95
+ clearTimeout(timer);
96
+ const exit = typeof code === 'number' ? code : -1;
97
+ resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
98
+ });
99
+ child.on('error', (err) => {
100
+ if (done)
101
+ return;
102
+ done = true;
103
+ clearTimeout(timer);
104
+ resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
105
+ });
106
+ });
107
+ }
108
+ /** Quick syntax check: try to parse file with node --check or tsc snippet */
109
+ export async function syntaxCheck(cwd, file) {
110
+ const full = path.join(cwd, file);
111
+ const ext = path.extname(file);
112
+ if (ext === '.ts' || ext === '.js' || ext === '.mjs' || ext === '.cjs') {
113
+ // Use tsc transpileModule if available, else node --check for js
114
+ try {
115
+ const content = fs.readFileSync(full, 'utf-8');
116
+ // minimal check: try to parse via new Function (for js) or just check no obvious syntax error via tsc
117
+ // For now, use tsc --noEmit --skipLibCheck on single file quickly
118
+ if (ext === '.ts') {
119
+ // spawn tsc --noEmit --skipLibCheck <file> with 10s timeout
120
+ const ok = await new Promise((resolve) => {
121
+ const child = spawn(`npx tsc --noEmit --skipLibCheck "${full}"`, { cwd, shell: true, env: process.env });
122
+ let done = false;
123
+ const t = setTimeout(() => { if (!done) {
124
+ done = true;
125
+ try {
126
+ child.kill();
127
+ }
128
+ catch { }
129
+ resolve(false);
130
+ } }, 10_000);
131
+ child.on('close', (code) => { if (done)
132
+ return; done = true; clearTimeout(t); resolve(code === 0); });
133
+ child.on('error', () => { if (done)
134
+ return; done = true; clearTimeout(t); resolve(false); });
135
+ });
136
+ if (!ok)
137
+ return { ok: false, error: `syntax error in ${file} (tsc)` };
138
+ return { ok: true };
139
+ }
140
+ // js: node --check
141
+ const ok2 = await new Promise((resolve) => {
142
+ const child = spawn(`node --check "${full}"`, { cwd, shell: true, env: process.env });
143
+ let done = false;
144
+ const t = setTimeout(() => { if (!done) {
145
+ done = true;
146
+ try {
147
+ child.kill();
148
+ }
149
+ catch { }
150
+ resolve(false);
151
+ } }, 5000);
152
+ child.on('close', (code) => { if (done)
153
+ return; done = true; clearTimeout(t); resolve(code === 0); });
154
+ child.on('error', () => { if (done)
155
+ return; done = true; clearTimeout(t); resolve(false); });
156
+ });
157
+ if (!ok2)
158
+ return { ok: false, error: `syntax error in ${file} (node --check)` };
159
+ return { ok: true };
160
+ }
161
+ catch (e) {
162
+ return { ok: false, error: String(e) };
163
+ }
164
+ }
165
+ if (ext === '.py') {
166
+ const ok = await new Promise((resolve) => {
167
+ const child = spawn(`python -m py_compile "${full}"`, { cwd, shell: true, env: process.env });
168
+ let done = false;
169
+ const t = setTimeout(() => { if (!done) {
170
+ done = true;
171
+ try {
172
+ child.kill();
173
+ }
174
+ catch { }
175
+ resolve(false);
176
+ } }, 5000);
177
+ child.on('close', (code) => { if (done)
178
+ return; done = true; clearTimeout(t); resolve(code === 0); });
179
+ child.on('error', () => { if (done)
180
+ return; done = true; clearTimeout(t); resolve(true); }); // python may not exist → skip
181
+ });
182
+ if (!ok)
183
+ return { ok: false, error: `syntax error in ${file} (py_compile)` };
184
+ }
185
+ return { ok: true };
186
+ }
187
+ /** Import-path existence check (TS/JS): read file, extract imports, verify targets exist */
188
+ export function checkImports(cwd, file) {
189
+ const full = path.join(cwd, file);
190
+ const ext = path.extname(file);
191
+ if (!['.ts', '.js', '.tsx', '.jsx', '.mjs', '.cjs'].includes(ext))
192
+ return { ok: true, missing: [] };
193
+ let content = '';
194
+ try {
195
+ content = fs.readFileSync(full, 'utf-8');
196
+ }
197
+ catch {
198
+ return { ok: true, missing: [] };
199
+ }
200
+ const importRe = /(?:import\s+.*?from\s+['"](.+?)['"]|require\(['"](.+?)['"]\))/g;
201
+ const missing = [];
202
+ let m;
203
+ while ((m = importRe.exec(content))) {
204
+ const spec = m[1] ?? m[2];
205
+ if (!spec || !spec.startsWith('.'))
206
+ continue; // only relative imports
207
+ const target = path.resolve(path.dirname(full), spec);
208
+ const candidates = [target, `${target}.ts`, `${target}.js`, `${target}/index.ts`, `${target}/index.js`, `${target}.tsx`];
209
+ const exists = candidates.some((p) => fs.existsSync(p));
210
+ if (!exists)
211
+ missing.push(spec);
212
+ }
213
+ return { ok: missing.length === 0, missing };
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",