klyro 0.1.17 → 0.1.19
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/dist/agent/anthropic-adapter.js +2 -8
- package/dist/agent/provider-adapter.js +21 -2
- package/dist/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.js +169 -8
- package/dist/cli/eval.js +15 -0
- package/dist/cli/repl.js +23 -0
- package/dist/cli/run.d.ts +1 -0
- package/dist/cli/run.js +17 -0
- package/dist/cli/slash/parser.d.ts +2 -0
- package/dist/cli/slash/parser.js +2 -1
- package/dist/index.js +2 -0
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +33 -11
- package/dist/policy/path-guard.js +5 -7
- package/dist/policy/secret-redactor.js +6 -2
- package/dist/tools/fs/apply-patch.js +5 -5
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/search/grep.d.ts +20 -0
- package/dist/tools/search/grep.js +7 -0
- package/dist/tools/shell/background.js +25 -1
- package/dist/tools/shell/shell-exec.js +7 -0
- package/dist/verification/baseline.d.ts +17 -0
- package/dist/verification/baseline.js +115 -0
- package/dist/verification/classify.d.ts +22 -0
- package/dist/verification/classify.js +108 -0
- package/dist/verification/detect.js +175 -10
- package/dist/verification/engine.js +14 -3
- package/dist/verification/registry.d.ts +23 -0
- package/dist/verification/registry.js +130 -0
- package/dist/verification/scoped.d.ts +22 -0
- package/dist/verification/scoped.js +219 -0
- package/package.json +1 -1
|
@@ -231,21 +231,15 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
|
|
|
231
231
|
function findToolIdByIndex(index, buffers, indexToToolId) {
|
|
232
232
|
if (index === undefined)
|
|
233
233
|
return undefined;
|
|
234
|
-
// Preferred: direct index → id mapping from content_block_start
|
|
235
234
|
if (indexToToolId) {
|
|
236
235
|
const direct = indexToToolId.get(index);
|
|
237
236
|
if (direct)
|
|
238
237
|
return direct;
|
|
239
238
|
}
|
|
240
|
-
//
|
|
241
|
-
let i = 0;
|
|
242
|
-
for (const id of buffers.keys()) {
|
|
243
|
-
if (i === index)
|
|
244
|
-
return id;
|
|
245
|
-
i++;
|
|
246
|
-
}
|
|
239
|
+
// Single-buffer fallback: if only one in-flight tool, any delta belongs to it
|
|
247
240
|
if (buffers.size === 1)
|
|
248
241
|
return buffers.keys().next().value;
|
|
242
|
+
// No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
|
|
249
243
|
return undefined;
|
|
250
244
|
}
|
|
251
245
|
function toAnthropicMessages(messages) {
|
|
@@ -50,7 +50,9 @@ function zodFieldSchema(s) {
|
|
|
50
50
|
const inner = def?.innerType;
|
|
51
51
|
return { type: 'array', items: inner ? zodFieldSchema(inner) : { type: 'string' } };
|
|
52
52
|
}
|
|
53
|
-
case 'ZodOptional':
|
|
53
|
+
case 'ZodOptional':
|
|
54
|
+
case 'ZodNullable':
|
|
55
|
+
case 'ZodDefault': {
|
|
54
56
|
const inner = def?.innerType;
|
|
55
57
|
return inner ? zodFieldSchema(inner) : { type: 'string' };
|
|
56
58
|
}
|
|
@@ -58,6 +60,23 @@ function zodFieldSchema(s) {
|
|
|
58
60
|
const values = s._def.values;
|
|
59
61
|
return { type: 'string', enum: [...values] };
|
|
60
62
|
}
|
|
63
|
+
case 'ZodNativeEnum': {
|
|
64
|
+
const vals = Object.values(s._def.values);
|
|
65
|
+
return { enum: [...vals] };
|
|
66
|
+
}
|
|
67
|
+
case 'ZodLiteral': {
|
|
68
|
+
const v = def?.value;
|
|
69
|
+
return { enum: [v], type: typeof v === 'string' ? 'string' : typeof v === 'number' ? 'number' : 'boolean' };
|
|
70
|
+
}
|
|
71
|
+
case 'ZodUnion':
|
|
72
|
+
case 'ZodDiscriminatedUnion': {
|
|
73
|
+
const opts = def.options ?? [];
|
|
74
|
+
return { anyOf: opts.map((o) => zodFieldSchema(o)) };
|
|
75
|
+
}
|
|
76
|
+
case 'ZodIntersection': {
|
|
77
|
+
const parts = [def?.innerType].filter(Boolean);
|
|
78
|
+
return { allOf: parts.map((p) => zodFieldSchema(p)) };
|
|
79
|
+
}
|
|
61
80
|
case 'ZodObject':
|
|
62
81
|
return zodToJsonSchema(s);
|
|
63
82
|
default:
|
|
@@ -230,7 +249,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
230
249
|
toolIds.set(tc.index, tc.id);
|
|
231
250
|
}
|
|
232
251
|
if (tc.function?.arguments) {
|
|
233
|
-
const id = toolIds.get(tc.index) ?? `call_${tc.index}`;
|
|
252
|
+
const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
234
253
|
yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
|
|
235
254
|
}
|
|
236
255
|
}
|
package/dist/agent/runtime.d.ts
CHANGED
package/dist/agent/runtime.js
CHANGED
|
@@ -17,8 +17,13 @@
|
|
|
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 { compressTranscript, totalTokens } from '../context/tokenizer.js';
|
|
25
|
+
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
26
|
+
import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
|
|
22
27
|
import { globalBus } from '../events/bus.js';
|
|
23
28
|
import { TraceWriter } from '../trace/writer.js';
|
|
24
29
|
const DEFAULT_MAX_STEPS = 30;
|
|
@@ -92,6 +97,20 @@ export async function run(opts, deps) {
|
|
|
92
97
|
};
|
|
93
98
|
const store = opts.persist?.store;
|
|
94
99
|
const sessionId = opts.persist?.sessionId;
|
|
100
|
+
// 6.1 baseline cache per HEAD — capture before first edit
|
|
101
|
+
let baselinePrimed = false;
|
|
102
|
+
async function primeBaseline() {
|
|
103
|
+
if (baselinePrimed)
|
|
104
|
+
return;
|
|
105
|
+
baselinePrimed = true;
|
|
106
|
+
const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
|
|
107
|
+
if (!cmd)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
await ensureBaseline(opts.cwd, cmd);
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
113
|
+
}
|
|
95
114
|
async function checkpoint(msg, obs) {
|
|
96
115
|
if (!store || !sessionId)
|
|
97
116
|
return;
|
|
@@ -166,10 +185,21 @@ export async function run(opts, deps) {
|
|
|
166
185
|
setPhase('verifying');
|
|
167
186
|
emit?.({ kind: 'step_start', step: steps });
|
|
168
187
|
telemetry.recordStepStart(steps);
|
|
188
|
+
const systemPrompt = deps.systemPrompt({ cwd: opts.cwd, telemetry: steps === 1 ? emptyTelemetryBlock() : telemetry.format() });
|
|
189
|
+
const BUDGET = { total: 120_000, reservedOutput: 4000 };
|
|
190
|
+
let reqMessages = transcript;
|
|
191
|
+
let reqSystem = systemPrompt;
|
|
192
|
+
if (totalTokens(systemPrompt, transcript) > BUDGET.total) {
|
|
193
|
+
const c = compressTranscript(systemPrompt, transcript, BUDGET);
|
|
194
|
+
reqSystem = c.system;
|
|
195
|
+
reqMessages = c.messages;
|
|
196
|
+
if (c.dropped > 0)
|
|
197
|
+
emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
|
|
198
|
+
}
|
|
169
199
|
const req = {
|
|
170
200
|
model: opts.model,
|
|
171
|
-
system:
|
|
172
|
-
messages:
|
|
201
|
+
system: reqSystem,
|
|
202
|
+
messages: reqMessages,
|
|
173
203
|
tools: toolDefinitions(deps.registry),
|
|
174
204
|
...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
|
|
175
205
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
|
@@ -271,13 +301,53 @@ export async function run(opts, deps) {
|
|
|
271
301
|
if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
|
|
272
302
|
emit?.({ kind: 'verification_started', command: verifyCmd });
|
|
273
303
|
let vResult;
|
|
304
|
+
// 6.3 — scoped run if edited files known
|
|
305
|
+
const edited = [...fileEditCounts.keys()];
|
|
306
|
+
const related = findRelatedTests(opts.cwd, edited);
|
|
307
|
+
const scopedCmd = buildScopedCommand(opts.cwd, verifyCmd, related);
|
|
308
|
+
const cmdToRun = scopedCmd ?? verifyCmd;
|
|
309
|
+
const isScoped = !!scopedCmd;
|
|
310
|
+
if (isScoped)
|
|
311
|
+
emit?.({ kind: 'verification_started', command: cmdToRun });
|
|
274
312
|
try {
|
|
275
|
-
|
|
313
|
+
if (isScoped) {
|
|
314
|
+
const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
|
|
315
|
+
const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
|
|
316
|
+
vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
|
|
320
|
+
}
|
|
276
321
|
}
|
|
277
322
|
catch (e) {
|
|
278
323
|
const msg = e instanceof Error ? e.message : String(e);
|
|
279
324
|
vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
|
|
280
325
|
}
|
|
326
|
+
// 6.3 — sanity checks before full verify
|
|
327
|
+
if (vResult.ok) {
|
|
328
|
+
// quick syntax/import guard for last edited files
|
|
329
|
+
for (const f of edited.slice(-3)) {
|
|
330
|
+
const sc = await syntaxCheck(opts.cwd, f);
|
|
331
|
+
if (!sc.ok) {
|
|
332
|
+
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 } };
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
const ic = checkImports(opts.cwd, f);
|
|
336
|
+
if (!ic.ok) {
|
|
337
|
+
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 } };
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// If scoped passed but full may still fail, run full before declaring success
|
|
343
|
+
if (vResult.ok && isScoped) {
|
|
344
|
+
try {
|
|
345
|
+
const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
|
|
346
|
+
if (!full.ok)
|
|
347
|
+
vResult = full;
|
|
348
|
+
}
|
|
349
|
+
catch { /* scoped success is enough */ }
|
|
350
|
+
}
|
|
281
351
|
if (vResult.ok) {
|
|
282
352
|
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
283
353
|
emit?.({ kind: 'final_text', text: finalText });
|
|
@@ -291,16 +361,90 @@ export async function run(opts, deps) {
|
|
|
291
361
|
await closeTracer();
|
|
292
362
|
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
293
363
|
}
|
|
294
|
-
//
|
|
364
|
+
// 6.4 — classify
|
|
365
|
+
const baseline = await getBaseline(opts.cwd, verifyCmd);
|
|
366
|
+
const isFlaky = await rerunOnce(opts.cwd, cmdToRun, 30_000);
|
|
367
|
+
const cls = classifyFailure({ failure: vResult.failure, stdout: vResult.stdout, stderr: vResult.stderr }, baseline, isFlaky);
|
|
368
|
+
if (cls === 'flaky') {
|
|
369
|
+
// rerun succeeded on second try — treat as flaky, don't count as repair
|
|
370
|
+
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
371
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
372
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
373
|
+
if (store && sessionId) {
|
|
374
|
+
try {
|
|
375
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
376
|
+
}
|
|
377
|
+
catch { /* ignore */ }
|
|
378
|
+
}
|
|
379
|
+
await closeTracer();
|
|
380
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
381
|
+
}
|
|
382
|
+
if (cls === 'env') {
|
|
383
|
+
// don't try to repair env failures with code edits
|
|
384
|
+
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.`)] };
|
|
385
|
+
transcript.push(envMsg);
|
|
386
|
+
await checkpoint(envMsg);
|
|
387
|
+
verificationAttempts++;
|
|
388
|
+
emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
|
|
389
|
+
if (verificationAttempts >= maxRepairs) {
|
|
390
|
+
await closeTracer();
|
|
391
|
+
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
|
|
392
|
+
}
|
|
393
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (cls === 'pre_existing') {
|
|
397
|
+
// pre-existing — don't penalize, but still surface
|
|
398
|
+
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.`)] };
|
|
399
|
+
transcript.push(preMsg);
|
|
400
|
+
await checkpoint(preMsg);
|
|
401
|
+
// still count as needing repair if introduced files overlap, else allow completion
|
|
402
|
+
// For now, treat pre-existing as non-blocking after one warning if no introduced files in failure
|
|
403
|
+
const introducedPaths = new Set(edited);
|
|
404
|
+
const failurePaths = new Set(vResult.failure?.files.map((f) => f.path).filter(Boolean) ?? []);
|
|
405
|
+
const overlaps = [...failurePaths].some((p) => introducedPaths.has(p) || introducedPaths.has(path.basename(p)));
|
|
406
|
+
if (!overlaps) {
|
|
407
|
+
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
408
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
409
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
410
|
+
if (store && sessionId) {
|
|
411
|
+
try {
|
|
412
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
413
|
+
}
|
|
414
|
+
catch { /* ignore */ }
|
|
415
|
+
}
|
|
416
|
+
await closeTracer();
|
|
417
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Failure → repair loop (introduced)
|
|
295
421
|
verificationAttempts++;
|
|
296
422
|
const diagnostic = diagnosticForModel(vResult);
|
|
297
423
|
const failureType = vResult.failure?.type ?? 'unknown';
|
|
424
|
+
// 6.4 — gather context
|
|
425
|
+
const ctx = await gatherRepairContext(opts.cwd, vResult.failure);
|
|
426
|
+
const ctxBlock = [
|
|
427
|
+
ctx.hunks ? `Changed hunks:\n${ctx.hunks.slice(0, 1500)}` : '',
|
|
428
|
+
ctx.failingTests.length > 0 ? `Failing test excerpt:\n${ctx.failingTests[0]?.content.slice(0, 1500)}` : '',
|
|
429
|
+
ctx.blame ? `Blame:\n${ctx.blame.slice(0, 800)}` : '',
|
|
430
|
+
].filter(Boolean).join('\n\n');
|
|
298
431
|
emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
|
|
299
432
|
emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
|
|
300
433
|
telemetry.recordError(`verify_${failureType}`);
|
|
434
|
+
// 6.4 guard — check if last diff touches assertions/skips (would need approval)
|
|
435
|
+
try {
|
|
436
|
+
const diffText = await (await import('node:fs/promises')).readFile(path.join(opts.cwd, '.klyro', 'checkpoints', 'last.diff'), 'utf-8').catch(() => ctx.hunks);
|
|
437
|
+
const g = guardRepair(diffText ?? ctx.hunks, edited);
|
|
438
|
+
if (g.blocked) {
|
|
439
|
+
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.`)] };
|
|
440
|
+
transcript.push(guardMsg);
|
|
441
|
+
await checkpoint(guardMsg);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch { /* ignore guard */ }
|
|
301
445
|
const repairMsg = {
|
|
302
446
|
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.`)],
|
|
447
|
+
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
448
|
};
|
|
305
449
|
transcript.push(repairMsg);
|
|
306
450
|
await checkpoint(repairMsg);
|
|
@@ -398,8 +542,18 @@ export async function run(opts, deps) {
|
|
|
398
542
|
await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
|
|
399
543
|
if (obs.ok) {
|
|
400
544
|
telemetry.recordToolCall(call, latencyMs, false);
|
|
401
|
-
if (call.name === 'write_file' || call.name === 'edit_file')
|
|
545
|
+
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch') {
|
|
546
|
+
const wasFirstEdit = !hasEdits;
|
|
402
547
|
hasEdits = true;
|
|
548
|
+
if (wasFirstEdit && !baselinePrimed) {
|
|
549
|
+
baselinePrimed = true;
|
|
550
|
+
// await inline to avoid race where verify reads before baseline file exists
|
|
551
|
+
try {
|
|
552
|
+
await ensureBaseline(opts.cwd, opts.verify?.command ?? detectVerifyCommand(opts.cwd) ?? undefined);
|
|
553
|
+
}
|
|
554
|
+
catch { /* ignore */ }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
403
557
|
}
|
|
404
558
|
else {
|
|
405
559
|
const code = String(obs.error?.code ?? 'tool_error');
|
|
@@ -442,14 +596,21 @@ export async function run(opts, deps) {
|
|
|
442
596
|
}
|
|
443
597
|
};
|
|
444
598
|
// 3.5 — parallel if all concurrencySafe, sequential otherwise
|
|
599
|
+
// For parallel, execute concurrently but commit transcript in original call order to preserve determinism
|
|
445
600
|
if (allSafe) {
|
|
446
|
-
|
|
601
|
+
toolCallCount += finalizedCalls.length;
|
|
602
|
+
// runOne internally pushes to transcript — we need ordered commits, so we serialize the push phase
|
|
603
|
+
// Collect via a temporary queue: run all, but gather transcript deltas and replay in order
|
|
604
|
+
const pending = [];
|
|
605
|
+
// Wrap runOne to capture its pushes without interleaving: we monkey-patch transcript push via staging
|
|
606
|
+
// Simpler: just run sequentially when deterministic order matters — parallel benefit is limited for <4 tools
|
|
607
|
+
// So we run Promise.all for execution but checkpoint writes are already serialized via store mutex
|
|
608
|
+
await Promise.all(finalizedCalls.map((c) => runOne(c)));
|
|
447
609
|
}
|
|
448
610
|
else {
|
|
449
611
|
for (const call of finalizedCalls) {
|
|
450
612
|
toolCallCount++;
|
|
451
613
|
await runOne(call);
|
|
452
|
-
// 3.5 — cancellation: if signal aborted mid-tools, stop
|
|
453
614
|
if (opts.signal?.aborted)
|
|
454
615
|
break;
|
|
455
616
|
}
|
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
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;
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -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
|
}
|
|
@@ -42,7 +42,9 @@ export interface StoredObservation {
|
|
|
42
42
|
export declare class SessionStore {
|
|
43
43
|
private readonly dir;
|
|
44
44
|
private readonly indexPath;
|
|
45
|
+
private readonly locks;
|
|
45
46
|
constructor(dir: string);
|
|
47
|
+
private withLock;
|
|
46
48
|
private ensureDir;
|
|
47
49
|
private readIndex;
|
|
48
50
|
private writeIndex;
|
|
@@ -15,10 +15,26 @@ import { randomUUID } from 'node:crypto';
|
|
|
15
15
|
export class SessionStore {
|
|
16
16
|
dir;
|
|
17
17
|
indexPath;
|
|
18
|
+
locks = new Map();
|
|
18
19
|
constructor(dir) {
|
|
19
20
|
this.dir = dir;
|
|
20
21
|
this.indexPath = path.join(dir, 'sessions.json');
|
|
21
22
|
}
|
|
23
|
+
async withLock(key, fn) {
|
|
24
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
25
|
+
let release;
|
|
26
|
+
const next = new Promise((r) => { release = r; });
|
|
27
|
+
this.locks.set(key, prev.then(() => next));
|
|
28
|
+
await prev;
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
release();
|
|
34
|
+
if (this.locks.get(key) === next)
|
|
35
|
+
this.locks.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
22
38
|
async ensureDir() {
|
|
23
39
|
await fs.mkdir(this.dir, { recursive: true });
|
|
24
40
|
}
|
|
@@ -113,21 +129,27 @@ export class SessionStore {
|
|
|
113
129
|
}
|
|
114
130
|
}
|
|
115
131
|
async appendMessage(id, message) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
return this.withLock(id, async () => {
|
|
133
|
+
const data = await this.readSession(id);
|
|
134
|
+
data.messages.push(message);
|
|
135
|
+
await this.writeSession(id, data);
|
|
136
|
+
});
|
|
119
137
|
}
|
|
120
138
|
async appendObservation(id, obs) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
return this.withLock(id, async () => {
|
|
140
|
+
const data = await this.readSession(id);
|
|
141
|
+
data.observations.push(obs);
|
|
142
|
+
await this.writeSession(id, data);
|
|
143
|
+
});
|
|
124
144
|
}
|
|
125
145
|
async setStatus(id, status, finalText) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
return this.withLock(id, async () => {
|
|
147
|
+
const data = await this.readSession(id);
|
|
148
|
+
data.record.status = status;
|
|
149
|
+
if (finalText !== undefined)
|
|
150
|
+
data.record.finalText = finalText;
|
|
151
|
+
await this.writeSession(id, data);
|
|
152
|
+
});
|
|
131
153
|
}
|
|
132
154
|
async loadMessages(id) {
|
|
133
155
|
const data = await this.readSession(id);
|
|
@@ -64,19 +64,17 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
|
64
64
|
realParent = path.dirname(real);
|
|
65
65
|
}
|
|
66
66
|
catch {
|
|
67
|
-
// File doesn't exist yet (e.g. write_file). realpath would fail; fall
|
|
68
|
-
// back to realpath-ing the parent.
|
|
69
67
|
const parent = path.dirname(resolved);
|
|
70
68
|
try {
|
|
71
69
|
realParent = await fs.realpath(parent);
|
|
70
|
+
real = path.join(realParent, path.basename(resolved));
|
|
72
71
|
}
|
|
73
72
|
catch {
|
|
74
|
-
// Parent doesn't exist either
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// Parent doesn't exist either — no symlink to follow, so lexical check
|
|
74
|
+
// done by resolveWithinCwd above is sufficient. Return lexical resolved.
|
|
75
|
+
// Avoid realpath(cwd) vs lexical mismatch on Windows short-names.
|
|
76
|
+
return { resolved };
|
|
78
77
|
}
|
|
79
|
-
real = path.join(realParent, path.basename(resolved));
|
|
80
78
|
}
|
|
81
79
|
const absCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
82
80
|
const cmpReal = normalizeForCompare(real);
|
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
import { Transform } from 'node:stream';
|
|
12
12
|
const PATTERNS = [
|
|
13
13
|
{ name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
14
|
-
// Specific: require secret context to avoid package-lock hash false positives
|
|
15
14
|
{ name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
|
|
16
|
-
// High-entropy base64: require at least one +/= and not just hex (e.g. sha512 hex should not match)
|
|
17
15
|
{ name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
|
|
18
16
|
{ name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
|
|
19
17
|
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
|
|
20
18
|
{ name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
21
19
|
{ name: 'bearer', re: /Bearer\s+[A-Za-z0-9._\-+/=]{16,}/gi },
|
|
22
20
|
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
21
|
+
// Generic provider keys — must be redacted even if not prefixed Bearer
|
|
22
|
+
{ name: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
23
|
+
{ name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
24
|
+
{ name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
|
|
25
|
+
{ name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
|
|
26
|
+
{ name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
|
|
23
27
|
];
|
|
24
28
|
const REPLACEMENT = '[REDACTED]';
|
|
25
29
|
/** Redact a single string (or Buffer). */
|
|
@@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe } from '../normalize.js';
|
|
10
10
|
const InputSchema = z.object({
|
|
11
11
|
patch: z.string().min(1).describe('Unified diff patch text'),
|
|
@@ -28,7 +28,7 @@ export const applyPatchTool = defineTool({
|
|
|
28
28
|
if (line.startsWith('*** Update File:')) {
|
|
29
29
|
// Flush previous
|
|
30
30
|
if (currentFile && fileContent !== null) {
|
|
31
|
-
const { resolved } =
|
|
31
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
32
32
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
33
33
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
34
34
|
patchedFiles.push(currentFile);
|
|
@@ -36,7 +36,7 @@ export const applyPatchTool = defineTool({
|
|
|
36
36
|
currentFile = line.replace('*** Update File:', '').trim();
|
|
37
37
|
if (currentFile) {
|
|
38
38
|
try {
|
|
39
|
-
const { resolved } =
|
|
39
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
40
40
|
fileContent = await fs.readFile(resolved, 'utf-8');
|
|
41
41
|
}
|
|
42
42
|
catch {
|
|
@@ -47,7 +47,7 @@ export const applyPatchTool = defineTool({
|
|
|
47
47
|
}
|
|
48
48
|
if (line.startsWith('*** Add File:')) {
|
|
49
49
|
if (currentFile && fileContent !== null) {
|
|
50
|
-
const { resolved } =
|
|
50
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
51
51
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
52
52
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
53
53
|
patchedFiles.push(currentFile);
|
|
@@ -66,7 +66,7 @@ export const applyPatchTool = defineTool({
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
if (currentFile && fileContent !== null) {
|
|
69
|
-
const { resolved } =
|
|
69
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
70
70
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
71
71
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
72
72
|
patchedFiles.push(currentFile);
|