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.
@@ -76,6 +76,7 @@ export interface RunOptions {
76
76
  command?: string;
77
77
  maxRepairAttempts?: number;
78
78
  timeoutMs?: number;
79
+ requireVerify?: boolean;
79
80
  };
80
81
  /**
81
82
  * Level 9 — persistence. When a SessionStore is provided, every message
@@ -17,8 +17,12 @@
17
17
  import { text, toolUse, toolResult as mkToolResult } from './message.js';
18
18
  import { redact } from '../policy/secret-redactor.js';
19
19
  import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
20
+ import * as path from 'node:path';
20
21
  import { verify, diagnosticForModel } from '../verification/engine.js';
21
22
  import { detectVerifyCommand } from '../verification/auto.js';
23
+ import { ensureBaseline, getBaseline } from '../verification/baseline.js';
24
+ import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
25
+ import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
22
26
  import { globalBus } from '../events/bus.js';
23
27
  import { TraceWriter } from '../trace/writer.js';
24
28
  const DEFAULT_MAX_STEPS = 30;
@@ -92,6 +96,20 @@ export async function run(opts, deps) {
92
96
  };
93
97
  const store = opts.persist?.store;
94
98
  const sessionId = opts.persist?.sessionId;
99
+ // 6.1 baseline cache per HEAD — capture before first edit
100
+ let baselinePrimed = false;
101
+ async function primeBaseline() {
102
+ if (baselinePrimed)
103
+ return;
104
+ baselinePrimed = true;
105
+ const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
106
+ if (!cmd)
107
+ return;
108
+ try {
109
+ await ensureBaseline(opts.cwd, cmd);
110
+ }
111
+ catch { /* ignore */ }
112
+ }
95
113
  async function checkpoint(msg, obs) {
96
114
  if (!store || !sessionId)
97
115
  return;
@@ -271,13 +289,53 @@ export async function run(opts, deps) {
271
289
  if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
272
290
  emit?.({ kind: 'verification_started', command: verifyCmd });
273
291
  let vResult;
292
+ // 6.3 — scoped run if edited files known
293
+ const edited = [...fileEditCounts.keys()];
294
+ const related = findRelatedTests(opts.cwd, edited);
295
+ const scopedCmd = buildScopedCommand(opts.cwd, verifyCmd, related);
296
+ const cmdToRun = scopedCmd ?? verifyCmd;
297
+ const isScoped = !!scopedCmd;
298
+ if (isScoped)
299
+ emit?.({ kind: 'verification_started', command: cmdToRun });
274
300
  try {
275
- vResult = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
301
+ if (isScoped) {
302
+ const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
303
+ const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
304
+ vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
305
+ }
306
+ else {
307
+ vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
308
+ }
276
309
  }
277
310
  catch (e) {
278
311
  const msg = e instanceof Error ? e.message : String(e);
279
312
  vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
280
313
  }
314
+ // 6.3 — sanity checks before full verify
315
+ if (vResult.ok) {
316
+ // quick syntax/import guard for last edited files
317
+ for (const f of edited.slice(-3)) {
318
+ const sc = await syntaxCheck(opts.cwd, f);
319
+ if (!sc.ok) {
320
+ vResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
321
+ break;
322
+ }
323
+ const ic = checkImports(opts.cwd, f);
324
+ if (!ic.ok) {
325
+ vResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ // If scoped passed but full may still fail, run full before declaring success
331
+ if (vResult.ok && isScoped) {
332
+ try {
333
+ const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
334
+ if (!full.ok)
335
+ vResult = full;
336
+ }
337
+ catch { /* scoped success is enough */ }
338
+ }
281
339
  if (vResult.ok) {
282
340
  emit?.({ kind: 'verification_succeeded', command: verifyCmd });
283
341
  emit?.({ kind: 'final_text', text: finalText });
@@ -291,16 +349,90 @@ export async function run(opts, deps) {
291
349
  await closeTracer();
292
350
  return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
293
351
  }
294
- // Failure repair loop
352
+ // 6.4 classify
353
+ const baseline = await getBaseline(opts.cwd, verifyCmd);
354
+ const isFlaky = await rerunOnce(opts.cwd, cmdToRun, 30_000);
355
+ const cls = classifyFailure({ failure: vResult.failure, stdout: vResult.stdout, stderr: vResult.stderr }, baseline, isFlaky);
356
+ if (cls === 'flaky') {
357
+ // rerun succeeded on second try — treat as flaky, don't count as repair
358
+ emit?.({ kind: 'verification_succeeded', command: verifyCmd });
359
+ emit?.({ kind: 'final_text', text: finalText });
360
+ emit?.({ kind: 'step_end', step: steps });
361
+ if (store && sessionId) {
362
+ try {
363
+ await store.setStatus(sessionId, 'complete', finalText);
364
+ }
365
+ catch { /* ignore */ }
366
+ }
367
+ await closeTracer();
368
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
369
+ }
370
+ if (cls === 'env') {
371
+ // don't try to repair env failures with code edits
372
+ const envMsg = { role: 'user', content: [text(`Verification failed due to environment issue (not code):\n\n${diagnosticForModel(vResult)}\n\nPlease suggest how to fix the environment (install deps, set env vars) rather than editing code. If this is a missing binary, ask the user.`)] };
373
+ transcript.push(envMsg);
374
+ await checkpoint(envMsg);
375
+ verificationAttempts++;
376
+ emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
377
+ if (verificationAttempts >= maxRepairs) {
378
+ await closeTracer();
379
+ return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
380
+ }
381
+ emit?.({ kind: 'step_end', step: steps });
382
+ continue;
383
+ }
384
+ if (cls === 'pre_existing') {
385
+ // pre-existing — don't penalize, but still surface
386
+ const preMsg = { role: 'user', content: [text(`Note: verification failure appears pre-existing (present in baseline at HEAD). Current failure:\n\n${diagnosticForModel(vResult)}\n\nIf this failure is unrelated to your changes, you may proceed, but try to avoid making it worse.`)] };
387
+ transcript.push(preMsg);
388
+ await checkpoint(preMsg);
389
+ // still count as needing repair if introduced files overlap, else allow completion
390
+ // For now, treat pre-existing as non-blocking after one warning if no introduced files in failure
391
+ const introducedPaths = new Set(edited);
392
+ const failurePaths = new Set(vResult.failure?.files.map((f) => f.path).filter(Boolean) ?? []);
393
+ const overlaps = [...failurePaths].some((p) => introducedPaths.has(p) || introducedPaths.has(path.basename(p)));
394
+ if (!overlaps) {
395
+ emit?.({ kind: 'verification_succeeded', command: verifyCmd });
396
+ emit?.({ kind: 'final_text', text: finalText });
397
+ emit?.({ kind: 'step_end', step: steps });
398
+ if (store && sessionId) {
399
+ try {
400
+ await store.setStatus(sessionId, 'complete', finalText);
401
+ }
402
+ catch { /* ignore */ }
403
+ }
404
+ await closeTracer();
405
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
406
+ }
407
+ }
408
+ // Failure → repair loop (introduced)
295
409
  verificationAttempts++;
296
410
  const diagnostic = diagnosticForModel(vResult);
297
411
  const failureType = vResult.failure?.type ?? 'unknown';
412
+ // 6.4 — gather context
413
+ const ctx = await gatherRepairContext(opts.cwd, vResult.failure);
414
+ const ctxBlock = [
415
+ ctx.hunks ? `Changed hunks:\n${ctx.hunks.slice(0, 1500)}` : '',
416
+ ctx.failingTests.length > 0 ? `Failing test excerpt:\n${ctx.failingTests[0]?.content.slice(0, 1500)}` : '',
417
+ ctx.blame ? `Blame:\n${ctx.blame.slice(0, 800)}` : '',
418
+ ].filter(Boolean).join('\n\n');
298
419
  emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
299
420
  emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
300
421
  telemetry.recordError(`verify_${failureType}`);
422
+ // 6.4 guard — check if last diff touches assertions/skips (would need approval)
423
+ try {
424
+ const diffText = await (await import('node:fs/promises')).readFile(path.join(opts.cwd, '.klyro', 'checkpoints', 'last.diff'), 'utf-8').catch(() => ctx.hunks);
425
+ const g = guardRepair(diffText ?? ctx.hunks, edited);
426
+ if (g.blocked) {
427
+ const guardMsg = { role: 'user', content: [text(`Repair guard blocked: ${g.reason}\n\nIf you must edit test assertions or add skips, first ask the user for explicit approval via ask_user.`)] };
428
+ transcript.push(guardMsg);
429
+ await checkpoint(guardMsg);
430
+ }
431
+ }
432
+ catch { /* ignore guard */ }
301
433
  const repairMsg = {
302
434
  role: 'user',
303
- content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above.`)],
435
+ content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}, class=${cls}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\n${ctxBlock ? `\nContext:\n${ctxBlock}\n` : ''}\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above. Do not edit test assertions unless the test itself is wrong — fix the source.`)],
304
436
  };
305
437
  transcript.push(repairMsg);
306
438
  await checkpoint(repairMsg);
@@ -398,8 +530,11 @@ export async function run(opts, deps) {
398
530
  await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
399
531
  if (obs.ok) {
400
532
  telemetry.recordToolCall(call, latencyMs, false);
401
- if (call.name === 'write_file' || call.name === 'edit_file')
533
+ if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch')
402
534
  hasEdits = true;
535
+ // 6.1 — prime baseline on first edit
536
+ if (hasEdits && !baselinePrimed)
537
+ void primeBaseline();
403
538
  }
404
539
  else {
405
540
  const code = String(obs.error?.code ?? 'tool_error');
package/dist/cli/eval.js CHANGED
@@ -61,6 +61,21 @@ export async function runEval(opts) {
61
61
  for (const e of entries) {
62
62
  if (opts.filter && !e.includes(opts.filter))
63
63
  continue;
64
+ // 6.5 — suite filter: smoke = type smoke, l6 = prefix l6-introduce
65
+ if (opts.suite && opts.suite !== 'smoke') {
66
+ if (!e.startsWith(opts.suite) && !e.includes(opts.suite))
67
+ continue;
68
+ }
69
+ else if (opts.suite === 'smoke') {
70
+ // smoke = only type smoke (exclude l6 introduce fixtures)
71
+ try {
72
+ const metaRaw = await fs.readFile(path.join(fixturesDir, e, 'meta.json'), 'utf-8');
73
+ const meta = JSON.parse(metaRaw);
74
+ if (meta.suite === 'l6' || meta.type === 'l6-introduce')
75
+ continue;
76
+ }
77
+ catch { /* no meta → include */ }
78
+ }
64
79
  const taskPath = path.join(fixturesDir, e, 'task.md');
65
80
  try {
66
81
  const task = await fs.readFile(taskPath, 'utf-8');
package/dist/cli/repl.js CHANGED
@@ -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
- textBuf += ev.text;
192
- // Try full-screen live region first (batched 30fps)
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
- // Commit live region for both inline and full-screen TUI
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
@@ -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
  }
package/dist/tui/app.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Klyro Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
3
- * Full viewport, conversation, input, status bar professional, dense, terminal-native
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 { type StatusSnapshot } from './status.js';
7
- import { type TranscriptItem } from './transcript.js';
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 Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
4
- * Full viewport, conversation, input, status bar professional, dense, terminal-native
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
- const [liveText, setLiveText] = useState('');
35
- const batchRef = useRef('');
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
- // 2.4 send queued when idle
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('text'), kind: 'text', text: toSend, role: 'user' };
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
- setTranscript((prev) => {
85
- const last = prev[prev.length - 1];
86
- if (last?.kind === 'text' && item.kind === 'text' && last.role === 'assistant' && item.role === 'assistant' && last.id === item.id) {
87
- return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
88
- }
89
- return [...prev, item];
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, appendDeltaBatched, commitLive]);
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
- setTranscript((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
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('text'), kind: 'text', text: v, role: 'user' };
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.15" }), _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(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (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, 200) }) : 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)))] })) : (_jsx(Transcript, { items: [item] })) }, item.id)))), liveText ? (_jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { children: [liveText, "\u258D"] }) })) : status.status === 'running' ? (_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' })] })] }));
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
  }