klyro 1.0.16 → 1.0.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.
Files changed (92) hide show
  1. package/dist/agent/anthropic-adapter.js +26 -1
  2. package/dist/agent/provider-adapter.d.ts +7 -0
  3. package/dist/agent/provider-adapter.js +26 -1
  4. package/dist/agent/runtime.d.ts +14 -0
  5. package/dist/agent/runtime.js +59 -22
  6. package/dist/chat.js +16 -56
  7. package/dist/checkpoints/store.d.ts +15 -1
  8. package/dist/checkpoints/store.js +7 -2
  9. package/dist/cli/args.d.ts +7 -0
  10. package/dist/cli/args.js +10 -0
  11. package/dist/cli/completion.js +0 -3
  12. package/dist/cli/doctor.d.ts +13 -0
  13. package/dist/cli/doctor.js +133 -1
  14. package/dist/cli/errors.js +1 -1
  15. package/dist/cli/eval.d.ts +32 -0
  16. package/dist/cli/eval.js +83 -6
  17. package/dist/cli/markdown.js +1 -1
  18. package/dist/cli/repl.js +13 -24
  19. package/dist/cli/run.d.ts +37 -0
  20. package/dist/cli/run.js +81 -9
  21. package/dist/cli/update.js +7 -1
  22. package/dist/context/accounting.js +1 -1
  23. package/dist/context/klyro-md.d.ts +11 -0
  24. package/dist/context/klyro-md.js +59 -4
  25. package/dist/context/level7.d.ts +4 -0
  26. package/dist/context/level7.js +20 -1
  27. package/dist/context/project-map.js +14 -13
  28. package/dist/eval/baseline.d.ts +47 -0
  29. package/dist/eval/baseline.js +55 -0
  30. package/dist/eval/harness.d.ts +14 -2
  31. package/dist/eval/harness.js +42 -4
  32. package/dist/index.js +57 -3
  33. package/dist/persistence/store.js +2 -2
  34. package/dist/policy/approval.js +3 -0
  35. package/dist/policy/path-guard.d.ts +9 -4
  36. package/dist/policy/path-guard.js +47 -23
  37. package/dist/providers/endpoints.js +8 -0
  38. package/dist/providers/model-info.d.ts +1 -0
  39. package/dist/providers/model-info.js +19 -0
  40. package/dist/providers.js +6 -2
  41. package/dist/renderers/terminal.d.ts +7 -0
  42. package/dist/renderers/terminal.js +52 -0
  43. package/dist/repl.d.ts +9 -0
  44. package/dist/repl.js +24 -5
  45. package/dist/shared/index.d.ts +1 -0
  46. package/dist/shared/index.js +1 -0
  47. package/dist/shared/proxy.d.ts +37 -0
  48. package/dist/shared/proxy.js +360 -0
  49. package/dist/tools/agent/task-list.d.ts +1 -1
  50. package/dist/tools/fs/apply-patch.js +4 -4
  51. package/dist/tools/fs/edit-file.js +2 -4
  52. package/dist/tools/fs/list-dir.js +1 -1
  53. package/dist/tools/fs/multi-edit.js +2 -2
  54. package/dist/tools/fs/read-file.js +1 -1
  55. package/dist/tools/fs/write-file.js +2 -2
  56. package/dist/tools/git/git-blame.d.ts +5 -0
  57. package/dist/tools/git/git-blame.js +22 -0
  58. package/dist/tools/git/git-diff.js +3 -20
  59. package/dist/tools/git/git-log.js +5 -19
  60. package/dist/tools/git/git-show.d.ts +5 -0
  61. package/dist/tools/git/git-show.js +22 -0
  62. package/dist/tools/git/git-status.js +4 -21
  63. package/dist/tools/git/run-git.d.ts +5 -0
  64. package/dist/tools/git/run-git.js +18 -0
  65. package/dist/tools/lsp/diagnostics.js +2 -3
  66. package/dist/tools/plan/todo-write.d.ts +1 -1
  67. package/dist/tools/registry.js +4 -0
  68. package/dist/tools/search/dependencies.d.ts +4 -4
  69. package/dist/tools/search/dependencies.js +1 -1
  70. package/dist/tools/search/glob.js +9 -4
  71. package/dist/tools/search/grep.js +11 -4
  72. package/dist/tools/search/ignore.d.ts +8 -0
  73. package/dist/tools/search/ignore.js +49 -0
  74. package/dist/tools/search/recent-files.js +1 -1
  75. package/dist/tools/search/search-files.js +11 -4
  76. package/dist/tools/web/web-fetch.js +2 -1
  77. package/dist/tools/web/web-search.js +3 -1
  78. package/dist/tui/app.js +117 -10
  79. package/dist/tui/app.test.js +65 -4
  80. package/dist/tui/measure.js +1 -0
  81. package/dist/tui/mouse.d.ts +7 -5
  82. package/dist/tui/mouse.js +9 -6
  83. package/dist/tui/scroll-flow.test.js +0 -1
  84. package/dist/tui/transcript-commands.d.ts +14 -0
  85. package/dist/tui/transcript-commands.js +31 -0
  86. package/dist/util/log.d.ts +19 -10
  87. package/dist/util/log.js +87 -52
  88. package/dist/verification/classify.d.ts +1 -1
  89. package/dist/verification/classify.js +2 -2
  90. package/dist/verification/engine.js +0 -6
  91. package/dist/verification/scoped.js +6 -4
  92. package/package.json +6 -1
package/dist/cli/run.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * even on a long task. Tool calls and policy decisions go to stderr so
8
8
  * they don't pollute the captured `finalText` if the user pipes.
9
9
  */
10
+ import * as path from 'node:path';
10
11
  import { stdout, stderr } from 'node:process';
11
12
  import { httpChatAdapter } from '../agent/provider-adapter.js';
12
13
  import { anthropicAdapter } from '../agent/anthropic-adapter.js';
@@ -20,8 +21,48 @@ import { redact } from '../policy/secret-redactor.js';
20
21
  import { buildLevel6Context } from '../context/level6.js';
21
22
  import { memoryBlock } from '../context/memory.js';
22
23
  import { estimateCost } from '../providers/model-info.js';
24
+ import { resolveModelAlias } from '../providers/model-info.js';
25
+ import { logDebug, logInfo } from '../util/log.js';
26
+ import { applyAutoAnswer } from './args.js';
23
27
  import { resolveSessionId } from '../persistence/session.js';
24
28
  import * as fs from 'node:fs';
29
+ /**
30
+ * Split a comma-separated tool/dir list (string or string[]) into names.
31
+ * Exported for tests.
32
+ */
33
+ export function splitToolList(v) {
34
+ const arr = v === undefined ? [] : Array.isArray(v) ? v : [v];
35
+ return arr.flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter((s) => s.length > 0);
36
+ }
37
+ /** Smaller of two optional caps (--max-turns aliases --max-steps). Exported for tests. */
38
+ export function minDefined(a, b) {
39
+ if (a === undefined)
40
+ return b;
41
+ if (b === undefined)
42
+ return a;
43
+ return Math.min(a, b);
44
+ }
45
+ /**
46
+ * Layer --system-prompt/--append-system-prompt over the assembled prompt
47
+ * (2.3): replace wins wholesale, append rides after the assembled system
48
+ * (preserving any suffix the base carries). Exported for tests.
49
+ */
50
+ export function withSystemPromptOverrides(base, opts) {
51
+ if (opts.replace === undefined && opts.append === undefined)
52
+ return base;
53
+ return (ctx) => {
54
+ if (opts.replace !== undefined) {
55
+ return opts.append ? `${opts.replace}\n\n${opts.append}` : opts.replace;
56
+ }
57
+ const r = base(ctx);
58
+ const system = typeof r === 'string' ? r : r.system;
59
+ const suffix = typeof r === 'string' ? undefined : r.suffix;
60
+ const out = { system: `${system}\n\n${opts.append}` };
61
+ if (suffix !== undefined)
62
+ out.suffix = suffix;
63
+ return out;
64
+ };
65
+ }
25
66
  /**
26
67
  * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
27
68
  * within 1500ms of the first forces `process.exit(130)`. The live handler
@@ -31,6 +72,9 @@ export function shouldForceExit(lastSigintAt, now) {
31
72
  return lastSigintAt !== undefined && now - lastSigintAt < 1500;
32
73
  }
33
74
  export async function runOnce(opts) {
75
+ // 5.3 — --auto-answer sets KLYRO_AUTO_ANSWER before anything runs so
76
+ // headless ask_user calls resolve without prompting.
77
+ applyAutoAnswer(opts.autoAnswer);
34
78
  // P0.5 — load <cwd>/.env first so KLYRO_* vars resolve without `export`.
35
79
  // Never throws (missing file is a no-op); explicit env wins (no-clobber).
36
80
  try {
@@ -38,6 +82,9 @@ export async function runOnce(opts) {
38
82
  loadDotenv(opts.cwd);
39
83
  }
40
84
  catch { /* ignore */ }
85
+ // Model aliases (2.2: sonnet/opus/haiku/gpt/local) resolve once here so
86
+ // the session store, cost math, and every adapter agree on the id.
87
+ opts = { ...opts, model: resolveModelAlias(opts.model) };
41
88
  const output = opts.output ?? 'human';
42
89
  if (opts.dryRun) {
43
90
  return await dryRunReport(opts);
@@ -138,7 +185,18 @@ export async function runOnce(opts) {
138
185
  catch { /* best-effort — single-provider run proceeds */ }
139
186
  }
140
187
  const registry = builtinRegistry();
141
- const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
188
+ const policyConfig = clonePolicyConfig();
189
+ // Explicit CLI overrides (3.4). Persisted rules load next via applyRules;
190
+ // the engine's deny-first precedence keeps any denial terminal.
191
+ if (opts.permissionMode !== undefined)
192
+ policyConfig.mode = opts.permissionMode;
193
+ for (const t of splitToolList(opts.allowedTools))
194
+ policyConfig.allow.push(t);
195
+ for (const t of splitToolList(opts.disallowedTools))
196
+ policyConfig.deny.push(t);
197
+ for (const d of splitToolList(opts.addDir))
198
+ policyConfig.additionalDirs.push(path.resolve(opts.cwd, d));
199
+ const policy = new PolicyEngine(builtinRules(), policyConfig);
142
200
  // Persisted "always allow" patterns apply to one-shot runs too.
143
201
  try {
144
202
  const { loadPermissionRules } = await import('./config.js');
@@ -181,7 +239,7 @@ export async function runOnce(opts) {
181
239
  }
182
240
  catch { /* ignore — MCP is optional */ }
183
241
  }
184
- const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt, opts.bare);
242
+ const systemPrompt = withSystemPromptOverrides(await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt, opts.bare), { replace: opts.systemPromptText, append: opts.appendSystemPrompt });
185
243
  // Level 9 — session setup (create or resume). --bare skips persistence.
186
244
  const persistEnabled = opts.persist !== false && !opts.bare;
187
245
  let store;
@@ -219,9 +277,6 @@ export async function runOnce(opts) {
219
277
  stderr.write(`klyro: resuming session ${sessionId.slice(0, 8)} — task: "${rec.task}"\n`);
220
278
  }
221
279
  }
222
- else if (opts.resumePath) {
223
- // already handled
224
- }
225
280
  else {
226
281
  // Create new session
227
282
  const rec = await store.create({ cwd: opts.cwd, task: opts.task, config: { model: opts.model, maxSteps: opts.maxSteps ?? 30 } });
@@ -313,23 +368,34 @@ export async function runOnce(opts) {
313
368
  model: opts.model,
314
369
  });
315
370
  }
371
+ // 2.5 latency tracking: TTFT = first model event after run start.
372
+ const runStartMs = Date.now();
373
+ let ttftMs;
374
+ logDebug('run start', { model: opts.model, maxSteps: opts.maxSteps, bare: !!opts.bare });
316
375
  try {
317
376
  result = await run({
318
377
  task: opts.task,
319
378
  cwd: opts.cwd,
320
379
  model: opts.model,
321
380
  ...(opts.bare ? { bare: true } : {}),
322
- maxSteps: opts.maxSteps,
381
+ maxSteps: minDefined(opts.maxSteps, opts.maxTurns),
323
382
  maxTokens: opts.maxTokens,
324
383
  temperature: opts.temperature,
384
+ reasoningEffort: opts.reasoningEffort,
325
385
  signal: ac.signal,
326
386
  nonInteractive: true,
327
387
  initialTranscript,
388
+ // Sandbox roots for file tools: cwd plus --add-dir extras (resolved
389
+ // absolute). checkAllowedPaths narrows to exactly this set.
390
+ allowedPaths: [path.resolve(opts.cwd), ...(policyConfig.additionalDirs ?? [])],
328
391
  verify: verifyOpts,
329
392
  persist: store && sessionId ? { store, sessionId } : undefined,
330
393
  ...(agentBridge ? { agentBridge } : {}),
331
394
  ...(parentContext ? { parentContext } : {}),
332
395
  onEvent: (ev) => {
396
+ if (ttftMs === undefined && (ev.kind === 'text_delta' || ev.kind === 'tool_call_start')) {
397
+ ttftMs = Date.now() - runStartMs;
398
+ }
333
399
  if (output === 'json') {
334
400
  stdout.write(JSON.stringify(ev) + '\n');
335
401
  return;
@@ -436,9 +502,12 @@ export async function runOnce(opts) {
436
502
  // [budget] warnings. Human/silent → stderr one-liner; json → additive
437
503
  // cost_usd/usage fields on the final object (purely additive, no shape break).
438
504
  const finalCost = estimateCost(opts.model, result.usage.input, result.usage.output);
439
- const costLine = `$${finalCost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}`;
505
+ const totalMs = Date.now() - runStartMs;
506
+ logInfo('run finish', { status: result.status, steps: result.steps, toolCalls: result.toolCalls, totalMs });
507
+ const ttftPart = ttftMs !== undefined ? ` · ttft ${ttftMs}ms` : '';
508
+ const costLine = `$${finalCost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}${ttftPart} · total ${totalMs}ms`;
440
509
  if (output === 'json') {
441
- stdout.write(JSON.stringify({ kind: 'cost', cost_usd: finalCost, usage: result.usage }) + '\n');
510
+ stdout.write(JSON.stringify({ kind: 'cost', cost_usd: finalCost, usage: result.usage, ttft_ms: ttftMs ?? null, total_ms: totalMs }) + '\n');
442
511
  }
443
512
  else {
444
513
  stderr.write(`klyro: cost ${costLine}\n`);
@@ -457,6 +526,8 @@ export async function runOnce(opts) {
457
526
  toolCalls: result.toolCalls,
458
527
  cost_usd: finalCost,
459
528
  usage: result.usage,
529
+ ttft_ms: ttftMs ?? null,
530
+ total_ms: totalMs,
460
531
  ...(result.verification ? { verification: result.verification } : {}),
461
532
  ...(sessionId ? { session_id: sessionId } : {}),
462
533
  ...extra,
@@ -523,7 +594,7 @@ export async function runOnce(opts) {
523
594
  async function dryRunReport(opts) {
524
595
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
525
596
  // otherwise dry-run shows a different prompt than production runs use.
526
- const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
597
+ const systemPromptFn = withSystemPromptOverrides(await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt), { replace: opts.systemPromptText, append: opts.appendSystemPrompt });
527
598
  const { resolveSystemPrompt } = await import('../agent/runtime.js');
528
599
  const { system, suffix } = resolveSystemPrompt(systemPromptFn, { cwd: opts.cwd });
529
600
  const systemPrompt = suffix ? `${system}\n\n${suffix}` : system;
@@ -536,6 +607,7 @@ async function dryRunReport(opts) {
536
607
  maxSteps: opts.maxSteps,
537
608
  maxTokens: opts.maxTokens,
538
609
  temperature: opts.temperature,
610
+ reasoningEffort: opts.reasoningEffort,
539
611
  // Secrets must never leak into a printable report: redact both the
540
612
  // assembled system prompt and the task before printing.
541
613
  systemPrompt: redact(systemPrompt),
@@ -13,6 +13,7 @@ import * as fs from 'node:fs/promises';
13
13
  import * as path from 'node:path';
14
14
  import * as os from 'node:os';
15
15
  import { createHash } from 'node:crypto';
16
+ import { proxiedFetch } from '../shared/proxy.js';
16
17
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
17
18
  const REGISTRY_BASE = 'https://registry.npmjs.org/klyro';
18
19
  const FETCH_TIMEOUT_MS = 3000;
@@ -64,7 +65,7 @@ async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
64
65
  const ctrl = new AbortController();
65
66
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
66
67
  try {
67
- const res = await fetch(url, { signal: ctrl.signal });
68
+ const res = await proxiedFetch(url, { signal: ctrl.signal, timeoutMs });
68
69
  if (!res.ok)
69
70
  throw new Error(`HTTP ${res.status}`);
70
71
  return res;
@@ -95,6 +96,11 @@ async function verifyTarballIntegrity(dist) {
95
96
  export async function checkForUpdate(current) {
96
97
  if (process.env.KLYRO_NO_UPDATE_CHECK === '1')
97
98
  return null;
99
+ // Disabled in CI unless explicitly enabled (1.5): automated pipelines
100
+ // must never phone home or suggest interactive updates.
101
+ if ((process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') && process.env.KLYRO_UPDATE_CHECK !== '1') {
102
+ return null;
103
+ }
98
104
  const cache = cachePath();
99
105
  try {
100
106
  const raw = await fs.readFile(cache, 'utf-8');
@@ -2,8 +2,8 @@
2
2
  * 8.1 — Accounting & stable assembly
3
3
  * Live token estimate, ctx%, compactAt, reserveOutput, toolResultMax
4
4
  */
5
- import { totalTokens } from './tokenizer.js';
6
5
  import { getModelInfo } from '../providers/model-info.js';
6
+ import { totalTokens } from './tokenizer.js';
7
7
  /**
8
8
  * Single output-reserve used by every budget in the harness (displayed
9
9
  * accounting, runtime enforcement, compaction elide). One constant so the
@@ -12,5 +12,16 @@ export interface KlyroMdFile {
12
12
  }
13
13
  /** Per-file loader (used by the trust gate to approve/hash individual files). */
14
14
  export declare function loadKlyroMdFiles(cwd: string): Promise<KlyroMdFile[]>;
15
+ /**
16
+ * 4.4a — Subdirectory instructions: root files PLUS instruction files found
17
+ * in ancestor directories of targetPath from cwd down to targetPath's dir
18
+ * (nearest-wins order root→leaf, each capped like cap()).
19
+ *
20
+ * Laziness preserved: plain loadKlyroMdFiles(cwd) never reads subdirs —
21
+ * callers pass the relevant active path explicitly.
22
+ *
23
+ * Containment: a targetPath escaping cwd is rejected (root files only).
24
+ */
25
+ export declare function loadKlyroMdForPath(cwd: string, targetPath: string): Promise<KlyroMdFile[]>;
15
26
  export declare function loadKlyroMd(cwd: string): Promise<string>;
16
27
  export declare function handleInit(cwd: string): Promise<string>;
@@ -30,16 +30,65 @@ export async function loadKlyroMdFiles(cwd) {
30
30
  // Klyro's own instruction file is KLYRO.md. The rest are read-only
31
31
  // fallbacks for imported repos that follow other conventions — Klyro
32
32
  // never writes them. KLYRO.md wins by load order.
33
- for (const name of ['KLYRO.md', 'KLYRO.local.md', 'CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', '.cursorrules']) {
33
+ for (const name of INSTRUCTION_NAMES) {
34
34
  const p = path.join(cwd, name);
35
35
  try {
36
36
  const t = await fs.readFile(p, 'utf-8');
37
- files.push({ path: p, content: cap(await resolveImports(t, path.dirname(p), cwd), MAX_FILE_CHARS) });
37
+ files.push({ path: p, content: cap(await resolveImports(t, path.dirname(p), cwd, 0, [p]), MAX_FILE_CHARS) });
38
38
  }
39
39
  catch { /* ignore */ }
40
40
  }
41
41
  return files;
42
42
  }
43
+ /** Instruction file names read at every level (root + subdirectories). */
44
+ const INSTRUCTION_NAMES = ['KLYRO.md', 'KLYRO.local.md', 'CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', '.cursorrules'];
45
+ /**
46
+ * 4.4a — Subdirectory instructions: root files PLUS instruction files found
47
+ * in ancestor directories of targetPath from cwd down to targetPath's dir
48
+ * (nearest-wins order root→leaf, each capped like cap()).
49
+ *
50
+ * Laziness preserved: plain loadKlyroMdFiles(cwd) never reads subdirs —
51
+ * callers pass the relevant active path explicitly.
52
+ *
53
+ * Containment: a targetPath escaping cwd is rejected (root files only).
54
+ */
55
+ export async function loadKlyroMdForPath(cwd, targetPath) {
56
+ const files = await loadKlyroMdFiles(cwd);
57
+ const root = path.resolve(cwd);
58
+ const absTarget = path.resolve(root, targetPath);
59
+ const relTarget = path.relative(root, absTarget);
60
+ if (relTarget.startsWith('..') || path.isAbsolute(relTarget))
61
+ return files;
62
+ // Target dir: the target itself when it is an existing directory,
63
+ // otherwise its parent dir (i.e. the target is a file being edited).
64
+ let targetDir = absTarget;
65
+ try {
66
+ const st = await fs.stat(absTarget);
67
+ if (!st.isDirectory())
68
+ targetDir = path.dirname(absTarget);
69
+ }
70
+ catch {
71
+ targetDir = path.dirname(absTarget);
72
+ }
73
+ const relDir = path.relative(root, targetDir);
74
+ if (relDir.startsWith('..') || path.isAbsolute(relDir))
75
+ return files;
76
+ const segs = relDir.split(path.sep).filter((s) => s && s !== '.');
77
+ let prefix = root;
78
+ for (const seg of segs) {
79
+ prefix = path.join(prefix, seg);
80
+ const dir = prefix;
81
+ for (const name of INSTRUCTION_NAMES) {
82
+ const p = path.join(dir, name);
83
+ try {
84
+ const t = await fs.readFile(p, 'utf-8');
85
+ files.push({ path: p, content: cap(await resolveImports(t, path.dirname(p), root, 0, [p]), MAX_FILE_CHARS) });
86
+ }
87
+ catch { /* ignore */ }
88
+ }
89
+ }
90
+ return files;
91
+ }
43
92
  export async function loadKlyroMd(cwd) {
44
93
  const parts = [];
45
94
  let total = 0;
@@ -56,7 +105,7 @@ export async function loadKlyroMd(cwd) {
56
105
  }
57
106
  return parts.join('\n\n---\n\n');
58
107
  }
59
- async function resolveImports(text, base, root, depth = 0) {
108
+ async function resolveImports(text, base, root, depth = 0, chain = []) {
60
109
  if (depth > 5)
61
110
  return text;
62
111
  const importRe = /^@import\s+(.+)$/gm;
@@ -69,9 +118,15 @@ async function resolveImports(text, base, root, depth = 0) {
69
118
  const relToRoot = path.relative(root, p);
70
119
  if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot))
71
120
  continue;
121
+ // Cycle guard: an @import target already on the current chain resolves
122
+ // to a marker instead of recursing (depth cap stays as backstop).
123
+ if (chain.includes(p)) {
124
+ out = out.replace(m[0], `<!-- klyro: import cycle skipped: ${rel} -->`);
125
+ continue;
126
+ }
72
127
  try {
73
128
  const t = await fs.readFile(p, 'utf-8');
74
- const resolved = await resolveImports(cap(t, MAX_FILE_CHARS), path.dirname(p), root, depth + 1);
129
+ const resolved = await resolveImports(cap(t, MAX_FILE_CHARS), path.dirname(p), root, depth + 1, [...chain, p]);
75
130
  out = out.replace(m[0], resolved);
76
131
  }
77
132
  catch { /* ignore missing */ }
@@ -61,6 +61,10 @@ export declare class RuntimeTelemetry {
61
61
  private errorCount;
62
62
  private recent;
63
63
  constructor(opts?: Level7Options);
64
+ private muted;
65
+ /** Disable telemetry accumulation + reporting (also via KLYRO_TELEMETRY=0). */
66
+ mute(): void;
67
+ isMuted(): boolean;
64
68
  setMaxSteps(n: number): void;
65
69
  recordStepStart(step: number): void;
66
70
  recordUsage(input: number, output: number): void;
@@ -40,14 +40,29 @@ export class RuntimeTelemetry {
40
40
  maxRecentCalls: opts.maxRecentCalls ?? DEFAULT_MAX_RECENT,
41
41
  maxChars: opts.maxChars ?? DEFAULT_MAX_CHARS,
42
42
  };
43
+ // KLYRO_TELEMETRY=0 disables telemetry accumulation + reporting.
44
+ this.muted = process.env.KLYRO_TELEMETRY === '0';
45
+ }
46
+ muted = false;
47
+ /** Disable telemetry accumulation + reporting (also via KLYRO_TELEMETRY=0). */
48
+ mute() {
49
+ this.muted = true;
50
+ }
51
+ isMuted() {
52
+ return this.muted;
43
53
  }
44
54
  setMaxSteps(n) { this.maxSteps = n; }
45
- recordStepStart(step) { this.step = step; }
55
+ recordStepStart(step) { if (!this.muted)
56
+ this.step = step; }
46
57
  recordUsage(input, output) {
58
+ if (this.muted)
59
+ return;
47
60
  this.inputTokens += input;
48
61
  this.outputTokens += output;
49
62
  }
50
63
  recordToolCall(call, latencyMs, isError) {
64
+ if (this.muted)
65
+ return;
51
66
  this.toolCallCount++;
52
67
  if (isError)
53
68
  this.errorCount++;
@@ -63,6 +78,8 @@ export class RuntimeTelemetry {
63
78
  }
64
79
  }
65
80
  recordError(msg) {
81
+ if (this.muted)
82
+ return;
66
83
  this.lastError = msg;
67
84
  }
68
85
  /**
@@ -89,6 +106,8 @@ export class RuntimeTelemetry {
89
106
  };
90
107
  }
91
108
  format() {
109
+ if (this.muted)
110
+ return emptyTelemetryBlock();
92
111
  const s = this.snapshot();
93
112
  const lines = [];
94
113
  lines.push('# Runtime telemetry');
@@ -21,6 +21,7 @@
21
21
  * what to walk past.
22
22
  */
23
23
  import * as fs from 'node:fs/promises';
24
+ import { statSync, readFileSync } from 'node:fs';
24
25
  import * as path from 'node:path';
25
26
  export const IGNORED_DIRS = new Set([
26
27
  'node_modules', '.git', 'dist', 'build', '.next', '.turbo',
@@ -246,7 +247,7 @@ function detectPackageManager(root, pkg) {
246
247
  // Avoid the async cost by re-using the root listing we already do.
247
248
  // (Caller threads the listing into detectFrameworks; here we re-stat directly.)
248
249
  try {
249
- require('node:fs').statSync(path.join(root, lock));
250
+ statSync(path.join(root, lock));
250
251
  return pm;
251
252
  }
252
253
  catch { /* not present */ }
@@ -295,9 +296,9 @@ function extractBuildCommands(pkg, root) {
295
296
  // Makefile: pick the first `target:` block as a hint.
296
297
  // (Don't try to parse make fully — just surface the targets as a single hint.)
297
298
  try {
298
- const stat = require('node:fs').statSync(path.join(root, 'Makefile'));
299
+ const stat = statSync(path.join(root, 'Makefile'));
299
300
  if (stat.isFile() && stat.size < MAX_BYTES) {
300
- const raw = require('node:fs').readFileSync(path.join(root, 'Makefile'), 'utf-8');
301
+ const raw = readFileSync(path.join(root, 'Makefile'), 'utf-8');
301
302
  const targets = [...raw.matchAll(/^([a-zA-Z_][\w-]*)\s*:/gm)].map((m) => m[1]).slice(0, 6);
302
303
  if (targets.length)
303
304
  out.push(`make <target> # targets: ${targets.join(', ')}`);
@@ -368,11 +369,11 @@ async function gitHead(root) {
368
369
  }
369
370
  function lockfileHash(root) {
370
371
  const candidates = ['pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', 'bun.lockb', 'Cargo.lock', 'go.sum', 'poetry.lock', 'uv.lock'];
371
- let h = crypto.createHash('sha1');
372
+ const h = crypto.createHash('sha1');
372
373
  let found = false;
373
374
  for (const f of candidates) {
374
375
  try {
375
- const d = require('node:fs').readFileSync(path.join(root, f));
376
+ const d = readFileSync(path.join(root, f));
376
377
  h.update(d);
377
378
  found = true;
378
379
  }
@@ -456,17 +457,17 @@ function detectMonorepo(root, rootFiles, rootDirs) {
456
457
  return true;
457
458
  if (rootDirs.includes('packages') || rootDirs.includes('apps')) {
458
459
  try {
459
- const s = require('node:fs').statSync(path.join(root, 'packages'));
460
+ const s = statSync(path.join(root, 'packages'));
460
461
  if (s.isDirectory())
461
462
  return true;
462
463
  }
463
- catch { }
464
+ catch { /* ignore — best-effort probe */ }
464
465
  try {
465
- const s2 = require('node:fs').statSync(path.join(root, 'apps'));
466
+ const s2 = statSync(path.join(root, 'apps'));
466
467
  if (s2.isDirectory())
467
468
  return true;
468
469
  }
469
- catch { }
470
+ catch { /* ignore — best-effort probe */ }
470
471
  }
471
472
  return false;
472
473
  }
@@ -477,20 +478,20 @@ async function detectRuntimeVersions(root) {
477
478
  if (v)
478
479
  out['node'] = v.trim();
479
480
  }
480
- catch { }
481
+ catch { /* ignore — best-effort probe */ }
481
482
  try {
482
483
  const pkg = await readJsonSafe(path.join(root, 'package.json'));
483
484
  const eng = pkg?.engines;
484
485
  if (eng?.node)
485
486
  out['node-eng'] = eng.node;
486
487
  }
487
- catch { }
488
+ catch { /* ignore — best-effort probe */ }
488
489
  try {
489
490
  const py = await readTextSafe(path.join(root, '.python-version'));
490
491
  if (py)
491
492
  out['python'] = py.trim();
492
493
  }
493
- catch { }
494
+ catch { /* ignore — best-effort probe */ }
494
495
  try {
495
496
  const go = await readTextSafe(path.join(root, 'go.mod'));
496
497
  if (go) {
@@ -499,7 +500,7 @@ async function detectRuntimeVersions(root) {
499
500
  out['go'] = m[1];
500
501
  }
501
502
  }
502
- catch { }
503
+ catch { /* ignore — best-effort probe */ }
503
504
  return out;
504
505
  }
505
506
  async function detectEntryPoints(root) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 5.5b — eval baseline writer. Records the environment alongside suite
3
+ * results so regressions can be attributed (model/node/platform/commit).
4
+ *
5
+ * The tracked `evals/results/baseline.json` is refreshed by `klyro eval
6
+ * --suite <name>` (best-effort; suite failures never fail the write path).
7
+ */
8
+ export interface EvalBaselineMeta {
9
+ /** Model id the suite ran under (KLYRO_MODEL fallback, then 'mock'). */
10
+ model: string;
11
+ /** Node version (process.version). */
12
+ node: string;
13
+ /** OS platform (process.platform). */
14
+ platform: string;
15
+ /** Git HEAD, best-effort ('unknown' outside a repo). */
16
+ commit: string;
17
+ /** ISO timestamp of the run. */
18
+ timestamp: string;
19
+ }
20
+ export interface EvalBaseline extends EvalBaselineMeta {
21
+ suite: string;
22
+ total: number;
23
+ passed: number;
24
+ failed: number;
25
+ passRate: number;
26
+ durationMs: number;
27
+ }
28
+ /** Best-effort `git rev-parse HEAD` (never throws). Exported for tests. */
29
+ export declare function gitCommit(cwd?: string): string;
30
+ /** Collect the environment half of a baseline record. Exported for tests. */
31
+ export declare function collectBaselineMetadata(opts?: {
32
+ model?: string;
33
+ cwd?: string;
34
+ }): EvalBaselineMeta;
35
+ /** Combine metadata + suite counts into a full baseline record. */
36
+ export declare function buildEvalBaseline(meta: EvalBaselineMeta, suite: {
37
+ suite: string;
38
+ total: number;
39
+ passed: number;
40
+ failed: number;
41
+ durationMs: number;
42
+ }): EvalBaseline;
43
+ /**
44
+ * Write `baseline.json` into `dir` (created if needed). Returns the path.
45
+ * Never throws for missing dirs — the caller wraps in try/catch anyway.
46
+ */
47
+ export declare function writeEvalBaseline(dir: string, baseline: EvalBaseline): Promise<string>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 5.5b — eval baseline writer. Records the environment alongside suite
3
+ * results so regressions can be attributed (model/node/platform/commit).
4
+ *
5
+ * The tracked `evals/results/baseline.json` is refreshed by `klyro eval
6
+ * --suite <name>` (best-effort; suite failures never fail the write path).
7
+ */
8
+ import * as fsp from 'node:fs/promises';
9
+ import * as path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ /** Best-effort `git rev-parse HEAD` (never throws). Exported for tests. */
12
+ export function gitCommit(cwd = process.cwd()) {
13
+ try {
14
+ const out = execFileSync('git', ['rev-parse', 'HEAD'], {
15
+ cwd,
16
+ stdio: ['ignore', 'pipe', 'ignore'],
17
+ }).toString().trim();
18
+ return out || 'unknown';
19
+ }
20
+ catch {
21
+ return 'unknown';
22
+ }
23
+ }
24
+ /** Collect the environment half of a baseline record. Exported for tests. */
25
+ export function collectBaselineMetadata(opts = {}) {
26
+ return {
27
+ model: opts.model ?? process.env.KLYRO_MODEL ?? 'mock',
28
+ node: process.version,
29
+ platform: process.platform,
30
+ commit: gitCommit(opts.cwd),
31
+ timestamp: new Date().toISOString(),
32
+ };
33
+ }
34
+ /** Combine metadata + suite counts into a full baseline record. */
35
+ export function buildEvalBaseline(meta, suite) {
36
+ return {
37
+ ...meta,
38
+ suite: suite.suite,
39
+ total: suite.total,
40
+ passed: suite.passed,
41
+ failed: suite.failed,
42
+ passRate: suite.total === 0 ? 0 : suite.passed / suite.total,
43
+ durationMs: suite.durationMs,
44
+ };
45
+ }
46
+ /**
47
+ * Write `baseline.json` into `dir` (created if needed). Returns the path.
48
+ * Never throws for missing dirs — the caller wraps in try/catch anyway.
49
+ */
50
+ export async function writeEvalBaseline(dir, baseline) {
51
+ await fsp.mkdir(dir, { recursive: true });
52
+ const out = path.join(dir, 'baseline.json');
53
+ await fsp.writeFile(out, JSON.stringify(baseline, null, 2) + '\n');
54
+ return out;
55
+ }
@@ -77,10 +77,21 @@ export interface FileFixture {
77
77
  script?: StreamEvent[][];
78
78
  }
79
79
  export declare function loadFileFixture(dir: string): Promise<FileFixture>;
80
- export declare function runFileFixture(fixture: FileFixture, opts?: {
80
+ export declare function sanitizedFixtureEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
81
+ /**
82
+ * 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
83
+ * harness relies on that isolation). Throws otherwise unless the caller
84
+ * explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
85
+ */
86
+ export declare function assertTmpWorkdir(dir: string, opts?: {
87
+ allowWorkdirOutsideTmp?: boolean;
88
+ }): void;
89
+ export interface FileFixtureOptions {
81
90
  runs?: number;
82
91
  parallel?: number;
83
- }): Promise<TaskResult>;
92
+ allowWorkdirOutsideTmp?: boolean;
93
+ }
94
+ export declare function runFileFixture(fixture: FileFixture, _opts?: FileFixtureOptions): Promise<TaskResult>;
84
95
  /**
85
96
  * Agent-driven fixture: seed a tmp repo, run the fixture's canned script
86
97
  * through the REAL runtime + tools, then assert outcomes with check.sh
@@ -90,6 +101,7 @@ export declare function runFileFixture(fixture: FileFixture, opts?: {
90
101
  export declare function runAgentFixture(fixture: FileFixture, opts?: {
91
102
  judgeAdapter?: ProviderAdapter;
92
103
  judgeModel?: string;
104
+ allowWorkdirOutsideTmp?: boolean;
93
105
  }): Promise<TaskResult>;
94
106
  /** Hook the harness up to a session + audit log so task runs are durable. */ export declare function runHarnessWithPersistence(tasks: ScriptedTask[], opts: {
95
107
  storeDir: string;