klyro 0.1.16 → 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.
- package/dist/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.js +139 -4
- package/dist/cli/eval.js +15 -0
- package/dist/cli/repl.js +36 -39
- 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/tui/app.d.ts +6 -4
- package/dist/tui/app.js +49 -49
- package/dist/verification/baseline.d.ts +17 -0
- package/dist/verification/baseline.js +108 -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/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 +214 -0
- package/package.json +1 -1
package/dist/agent/runtime.d.ts
CHANGED
package/dist/agent/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
@@ -84,6 +84,14 @@ export async function startRepl(opts = {}) {
|
|
|
84
84
|
else
|
|
85
85
|
pendingQueue.push({ kind: 'append', item });
|
|
86
86
|
}
|
|
87
|
+
function queuedDelta(text) {
|
|
88
|
+
if (!text)
|
|
89
|
+
return;
|
|
90
|
+
if (isMounted && directHooks)
|
|
91
|
+
directHooks.appendDelta(text);
|
|
92
|
+
else
|
|
93
|
+
pendingQueue.push({ kind: 'delta', text });
|
|
94
|
+
}
|
|
87
95
|
function queuedStatus(s) {
|
|
88
96
|
lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
|
|
89
97
|
if (isMounted && directHooks)
|
|
@@ -125,6 +133,8 @@ export async function startRepl(opts = {}) {
|
|
|
125
133
|
hooks.updateStatus(ev.patch);
|
|
126
134
|
else if (ev.kind === 'plan')
|
|
127
135
|
hooks.updatePlan(ev.plan);
|
|
136
|
+
else if (ev.kind === 'delta')
|
|
137
|
+
hooks.appendDelta(ev.text);
|
|
128
138
|
else
|
|
129
139
|
hooks.append(ev.item);
|
|
130
140
|
}
|
|
@@ -166,8 +176,6 @@ export async function startRepl(opts = {}) {
|
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
178
|
queuedStatus({ status: 'running', step: 0, model });
|
|
169
|
-
let textBuf = '';
|
|
170
|
-
let pendingTextId = null;
|
|
171
179
|
let activeCallId = null;
|
|
172
180
|
let activeCallName = null;
|
|
173
181
|
let activeCallArgs = '';
|
|
@@ -183,36 +191,11 @@ export async function startRepl(opts = {}) {
|
|
|
183
191
|
persist: sessionId ? { store: tuiStore, sessionId } : undefined,
|
|
184
192
|
onEvent: (ev) => {
|
|
185
193
|
if (ev.kind === 'step_start') {
|
|
186
|
-
// Flush coalesced text before new step
|
|
187
|
-
pendingTextId = null;
|
|
188
194
|
queuedStatus({ step: ev.step });
|
|
189
195
|
}
|
|
190
196
|
else if (ev.kind === 'text_delta') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const g = globalThis;
|
|
194
|
-
if (isMounted && g.__klyroAppendDelta) {
|
|
195
|
-
g.__klyroAppendDelta(ev.text);
|
|
196
|
-
// Also keep coalescing for fallback inline mode
|
|
197
|
-
if (!pendingTextId)
|
|
198
|
-
pendingTextId = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// Fallback inline mode: coalesce via queuedAppend
|
|
202
|
-
if (pendingTextId) {
|
|
203
|
-
const last = pendingQueue[pendingQueue.length - 1];
|
|
204
|
-
if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
|
|
205
|
-
last.item.text += ev.text;
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (pendingTextId && isMounted) {
|
|
210
|
-
queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
214
|
-
pendingTextId = id;
|
|
215
|
-
queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
|
|
197
|
+
// single appendDelta path — App merges into one assistant item (Q→A order, no duplication)
|
|
198
|
+
queuedDelta(ev.text);
|
|
216
199
|
}
|
|
217
200
|
else if (ev.kind === 'verification_started') {
|
|
218
201
|
queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: `[verify] running \`${ev.command}\``, role: 'assistant' });
|
|
@@ -272,16 +255,7 @@ export async function startRepl(opts = {}) {
|
|
|
272
255
|
});
|
|
273
256
|
}
|
|
274
257
|
else if (ev.kind === 'final_text') {
|
|
275
|
-
//
|
|
276
|
-
const g2 = globalThis;
|
|
277
|
-
g2.__klyroCommitLive?.();
|
|
278
|
-
pendingTextId = null;
|
|
279
|
-
// For full-screen, also ensure liveText is committed if any remaining
|
|
280
|
-
if (ev.text) {
|
|
281
|
-
const g3 = globalThis;
|
|
282
|
-
// If liveText was batched, ensure it's flushed and committed
|
|
283
|
-
g2.__klyroCommitLive?.();
|
|
284
|
-
}
|
|
258
|
+
// streamingId is closed by status change; no extra handling needed
|
|
285
259
|
}
|
|
286
260
|
else if (ev.kind === 'usage') {
|
|
287
261
|
queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
|
|
@@ -506,6 +480,29 @@ export async function startRepl(opts = {}) {
|
|
|
506
480
|
}
|
|
507
481
|
return;
|
|
508
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
|
+
}
|
|
509
506
|
case 'compact':
|
|
510
507
|
queuedAppend({
|
|
511
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
|
}
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Klyro
|
|
3
|
-
*
|
|
2
|
+
* Klyro TUI — opencode-style linear transcript
|
|
3
|
+
* Header (top) → Conversation (scrollable, Q→A→Q→A) → Input (bottom) → StatusBar (bottom)
|
|
4
|
+
* Single streamingId merges text_delta into one assistant item — no duplication, no liveText ghost.
|
|
4
5
|
*/
|
|
5
6
|
import React from 'react';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import type { StatusSnapshot } from './status.js';
|
|
8
|
+
import type { TranscriptItem } from './transcript.js';
|
|
8
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
10
|
import type { PlanStep } from '../agent/runtime.js';
|
|
10
11
|
export interface AppProps {
|
|
@@ -18,6 +19,7 @@ export interface AppProps {
|
|
|
18
19
|
approvalBridge?: TuiApprovalBridge;
|
|
19
20
|
onMounted?: (hooks: {
|
|
20
21
|
append: (i: TranscriptItem) => void;
|
|
22
|
+
appendDelta: (text: string) => void;
|
|
21
23
|
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
|
22
24
|
updatePlan: (p: PlanStep[]) => void;
|
|
23
25
|
}) => void;
|
package/dist/tui/app.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* Klyro
|
|
4
|
-
*
|
|
3
|
+
* Klyro TUI — opencode-style linear transcript
|
|
4
|
+
* Header (top) → Conversation (scrollable, Q→A→Q→A) → Input (bottom) → StatusBar (bottom)
|
|
5
|
+
* Single streamingId merges text_delta into one assistant item — no duplication, no liveText ghost.
|
|
5
6
|
*/
|
|
6
7
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
8
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
|
-
import { Transcript } from './transcript.js';
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
10
10
|
import { PlanView } from './plan.js';
|
|
11
11
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
@@ -31,41 +31,17 @@ export function App(props) {
|
|
|
31
31
|
});
|
|
32
32
|
const [elapsed, setElapsed] = useState(0);
|
|
33
33
|
const [queued, setQueued] = useState(null);
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const batchTimer = useRef(null);
|
|
37
|
-
const flushBatch = useCallback(() => {
|
|
38
|
-
if (batchRef.current) {
|
|
39
|
-
const chunk = batchRef.current;
|
|
40
|
-
batchRef.current = '';
|
|
41
|
-
setLiveText((prev) => prev + chunk);
|
|
42
|
-
}
|
|
43
|
-
if (batchTimer.current) {
|
|
44
|
-
clearTimeout(batchTimer.current);
|
|
45
|
-
batchTimer.current = null;
|
|
46
|
-
}
|
|
47
|
-
}, []);
|
|
48
|
-
const appendDeltaBatched = useCallback((text) => {
|
|
49
|
-
batchRef.current += text;
|
|
50
|
-
if (!batchTimer.current)
|
|
51
|
-
batchTimer.current = setTimeout(flushBatch, 33);
|
|
52
|
-
}, [flushBatch]);
|
|
53
|
-
const commitLive = useCallback(() => {
|
|
54
|
-
flushBatch();
|
|
55
|
-
if (liveText) {
|
|
56
|
-
const item = { id: nextId('text'), kind: 'text', text: liveText, role: 'assistant' };
|
|
57
|
-
setTranscript((prev) => [...prev, item]);
|
|
58
|
-
setLiveText('');
|
|
59
|
-
}
|
|
60
|
-
}, [liveText, flushBatch]);
|
|
34
|
+
// streaming: one assistant text item that text_delta merges into
|
|
35
|
+
const streamingIdRef = useRef(null);
|
|
61
36
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
62
|
-
//
|
|
37
|
+
// queued: send when idle (2.4)
|
|
63
38
|
useEffect(() => {
|
|
64
39
|
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
65
40
|
const toSend = queued;
|
|
66
41
|
setQueued(null);
|
|
67
|
-
const item = { id: nextId('
|
|
42
|
+
const item = { id: nextId('user'), kind: 'text', text: toSend, role: 'user' };
|
|
68
43
|
setTranscript((prev) => [...prev, item]);
|
|
44
|
+
streamingIdRef.current = null;
|
|
69
45
|
const cmd = parseSlash(toSend.trim());
|
|
70
46
|
if (cmd.kind === 'prompt')
|
|
71
47
|
void props.onPrompt(cmd.text);
|
|
@@ -81,34 +57,56 @@ export function App(props) {
|
|
|
81
57
|
return () => clearInterval(t);
|
|
82
58
|
}, [status.status, elapsed]);
|
|
83
59
|
const append = useCallback((item) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
60
|
+
// any non-streaming append closes the current streaming block
|
|
61
|
+
if (item.kind !== 'text' || item.role !== 'assistant')
|
|
62
|
+
streamingIdRef.current = null;
|
|
63
|
+
setTranscript((prev) => [...prev, item]);
|
|
64
|
+
}, []);
|
|
65
|
+
const appendDelta = useCallback((text) => {
|
|
66
|
+
if (!text)
|
|
67
|
+
return;
|
|
68
|
+
const sid = streamingIdRef.current;
|
|
69
|
+
if (sid) {
|
|
70
|
+
setTranscript((prev) => {
|
|
71
|
+
const idx = prev.findIndex((x) => x.id === sid);
|
|
72
|
+
if (idx === -1)
|
|
73
|
+
return [...prev, { id: sid, kind: 'text', text, role: 'assistant' }];
|
|
74
|
+
const cur = prev[idx];
|
|
75
|
+
const next = { ...cur, text: cur.text + text };
|
|
76
|
+
const copy = [...prev];
|
|
77
|
+
copy[idx] = next;
|
|
78
|
+
return copy;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const id = nextId('stream');
|
|
83
|
+
streamingIdRef.current = id;
|
|
84
|
+
setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
|
|
85
|
+
}
|
|
91
86
|
}, []);
|
|
87
|
+
// close streaming block when status leaves running (so next text_delta starts new item)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (status.status !== 'running')
|
|
90
|
+
streamingIdRef.current = null;
|
|
91
|
+
}, [status.status]);
|
|
92
92
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
93
93
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
94
94
|
const onMountedRef = useRef(props.onMounted);
|
|
95
95
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
96
96
|
useEffect(() => {
|
|
97
|
-
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
97
|
+
onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan });
|
|
98
|
+
// global hooks for repl bridge (instance-local queue drains here)
|
|
98
99
|
globalThis.__klyroAppAppend = append;
|
|
100
|
+
globalThis.__klyroAppendDelta = appendDelta;
|
|
99
101
|
globalThis.__klyroAppStatus = updateStatus;
|
|
100
102
|
globalThis.__klyroAppPlan = updatePlan;
|
|
101
|
-
globalThis.__klyroAppendDelta = appendDeltaBatched;
|
|
102
|
-
globalThis.__klyroCommitLive = commitLive;
|
|
103
103
|
return () => {
|
|
104
104
|
delete globalThis.__klyroAppAppend;
|
|
105
|
+
delete globalThis.__klyroAppendDelta;
|
|
105
106
|
delete globalThis.__klyroAppStatus;
|
|
106
107
|
delete globalThis.__klyroAppPlan;
|
|
107
|
-
delete globalThis.__klyroAppendDelta;
|
|
108
|
-
delete globalThis.__klyroCommitLive;
|
|
109
108
|
};
|
|
110
|
-
}, [append, updateStatus, updatePlan
|
|
111
|
-
// Single useInput owner — handles queued when running (2.4)
|
|
109
|
+
}, [append, appendDelta, updateStatus, updatePlan]);
|
|
112
110
|
useInput((inputStr, key) => {
|
|
113
111
|
if (awaitingApproval)
|
|
114
112
|
return;
|
|
@@ -123,7 +121,8 @@ export function App(props) {
|
|
|
123
121
|
return;
|
|
124
122
|
setQueued(v);
|
|
125
123
|
setInput('');
|
|
126
|
-
|
|
124
|
+
// queued indicator as muted text, not a full user bubble (opencode style)
|
|
125
|
+
setTranscript((prev) => [...prev, { id: nextId('queued'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
|
|
127
126
|
return;
|
|
128
127
|
}
|
|
129
128
|
if (key.backspace || key.delete) {
|
|
@@ -141,8 +140,9 @@ export function App(props) {
|
|
|
141
140
|
if (!v)
|
|
142
141
|
return;
|
|
143
142
|
setInput('');
|
|
144
|
-
const item = { id: nextId('
|
|
143
|
+
const item = { id: nextId('user'), kind: 'text', text: v, role: 'user' };
|
|
145
144
|
setTranscript((prev) => [...prev, item]);
|
|
145
|
+
streamingIdRef.current = null;
|
|
146
146
|
const cmd = parseSlash(v);
|
|
147
147
|
if (cmd.kind === 'prompt')
|
|
148
148
|
void props.onPrompt(cmd.text);
|
|
@@ -160,5 +160,5 @@ export function App(props) {
|
|
|
160
160
|
const width = stdout?.columns ?? 100;
|
|
161
161
|
const height = stdout?.rows ?? 30;
|
|
162
162
|
const isSmall = width < 80;
|
|
163
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.
|
|
163
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.16" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"hi\" or /help" })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 300) }) : null] })) : item.kind === 'diff' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: item.summary ?? 'Diff' }), item.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.info, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.success : l.kind === 'remove' ? tokens.ansi.error : tokens.ansi.muted, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] })) : item.kind === 'error' ? (_jsxs(Text, { color: tokens.ansi.error, children: ["[error] ", item.message] })) : item.kind === 'policy' ? (_jsxs(Text, { color: tokens.ansi.muted, children: ["[policy] ", item.action, " ", item.name, item.reason ? ` — ${item.reason}` : ''] })) : item.kind === 'file_changed' ? (_jsxs(Text, { color: tokens.ansi.muted, children: ["[", item.op, "] ", item.path] })) : null }, item.id)))), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
|
|
164
164
|
}
|