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.
@@ -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
- // Fallback heuristic for older streams without index on start
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
  }
@@ -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,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: deps.systemPrompt({ cwd: opts.cwd, telemetry: steps === 1 ? emptyTelemetryBlock() : telemetry.format() }),
172
- messages: transcript,
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
- vResult = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
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
- // Failure repair loop
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
- await Promise.all(finalizedCalls.map((c) => { toolCallCount++; return runOne(c); }));
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
@@ -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
  }
@@ -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
- const data = await this.readSession(id);
117
- data.messages.push(message);
118
- await this.writeSession(id, data);
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
- const data = await this.readSession(id);
122
- data.observations.push(obs);
123
- await this.writeSession(id, data);
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
- const data = await this.readSession(id);
127
- data.record.status = status;
128
- if (finalText !== undefined)
129
- data.record.finalText = finalText;
130
- await this.writeSession(id, data);
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. Don't trust the unresolved parent
75
- // re-validate it against cwd and bail if it's not inside.
76
- const { resolved: parentResolved } = resolveWithinCwd(cwd, parent);
77
- realParent = parentResolved;
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 { resolveWithinCwd } from '../../policy/path-guard.js';
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 } = resolveWithinCwd(ctx.cwd, currentFile);
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 } = resolveWithinCwd(ctx.cwd, currentFile);
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 } = resolveWithinCwd(ctx.cwd, currentFile);
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 } = resolveWithinCwd(ctx.cwd, currentFile);
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);