klyro 1.0.0 → 1.0.1

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 (96) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +68 -16
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +46 -5
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +106 -5
  8. package/dist/agent/orchestrator.js +378 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +2 -2
  13. package/dist/agent/runtime.d.ts +50 -8
  14. package/dist/agent/runtime.js +196 -33
  15. package/dist/agent/worktree-manager.d.ts +74 -0
  16. package/dist/agent/worktree-manager.js +189 -0
  17. package/dist/checkpoints/store.js +30 -5
  18. package/dist/cli/auth.js +16 -1
  19. package/dist/cli/config.d.ts +9 -3
  20. package/dist/cli/config.js +64 -3
  21. package/dist/cli/eval.d.ts +6 -1
  22. package/dist/cli/eval.js +9 -0
  23. package/dist/cli/repl.js +155 -28
  24. package/dist/cli/run.d.ts +7 -11
  25. package/dist/cli/run.js +68 -17
  26. package/dist/cli/update.d.ts +5 -0
  27. package/dist/cli/update.js +62 -10
  28. package/dist/context/import-graph.d.ts +2 -0
  29. package/dist/context/import-graph.js +31 -3
  30. package/dist/context/klyro-md.js +4 -1
  31. package/dist/context/memory.d.ts +8 -0
  32. package/dist/context/memory.js +50 -2
  33. package/dist/context/project-map.d.ts +6 -0
  34. package/dist/context/project-map.js +50 -2
  35. package/dist/context/repo-map.d.ts +2 -0
  36. package/dist/context/repo-map.js +31 -1
  37. package/dist/events/catalog.d.ts +28 -0
  38. package/dist/index.js +89 -4
  39. package/dist/mcp/client.d.ts +6 -4
  40. package/dist/mcp/client.js +83 -14
  41. package/dist/mcp/config.d.ts +10 -0
  42. package/dist/mcp/config.js +18 -1
  43. package/dist/mcp/registry.d.ts +23 -1
  44. package/dist/mcp/registry.js +78 -6
  45. package/dist/mcp/schema.d.ts +11 -4
  46. package/dist/mcp/schema.js +27 -16
  47. package/dist/mcp/trust.d.ts +20 -0
  48. package/dist/mcp/trust.js +74 -0
  49. package/dist/persistence/audit.d.ts +28 -0
  50. package/dist/persistence/audit.js +101 -1
  51. package/dist/persistence/store.d.ts +26 -2
  52. package/dist/persistence/store.js +140 -13
  53. package/dist/policy/approval.d.ts +14 -0
  54. package/dist/policy/approval.js +44 -2
  55. package/dist/policy/engine.d.ts +1 -0
  56. package/dist/policy/engine.js +88 -8
  57. package/dist/policy/secret-redactor.js +4 -0
  58. package/dist/providers/model-info.d.ts +17 -0
  59. package/dist/providers/model-info.js +35 -2
  60. package/dist/repl.d.ts +6 -0
  61. package/dist/repl.js +12 -7
  62. package/dist/tools/agent/spawn-agent.js +5 -5
  63. package/dist/tools/agent/task-apply.d.ts +4 -0
  64. package/dist/tools/agent/task-apply.js +44 -0
  65. package/dist/tools/agent/task-stop.d.ts +6 -0
  66. package/dist/tools/agent/task-stop.js +39 -0
  67. package/dist/tools/agent/task-wait.d.ts +17 -0
  68. package/dist/tools/agent/task-wait.js +79 -0
  69. package/dist/tools/fs/apply-patch.js +71 -0
  70. package/dist/tools/fs/edit-file.js +65 -0
  71. package/dist/tools/fs/multi-edit.d.ts +4 -0
  72. package/dist/tools/fs/multi-edit.js +66 -0
  73. package/dist/tools/fs/write-file.js +67 -0
  74. package/dist/tools/registry.js +6 -0
  75. package/dist/tools/shell/background.js +6 -3
  76. package/dist/tools/shell/sandbox.d.ts +51 -0
  77. package/dist/tools/shell/sandbox.js +143 -0
  78. package/dist/tools/shell/shell-exec.d.ts +1 -0
  79. package/dist/tools/shell/shell-exec.js +83 -11
  80. package/dist/tools/shell/worker-entry.d.ts +12 -0
  81. package/dist/tools/shell/worker-entry.js +43 -0
  82. package/dist/tools/types.d.ts +6 -0
  83. package/dist/tools/verify/run-verify.js +3 -1
  84. package/dist/trace/writer.d.ts +13 -0
  85. package/dist/trace/writer.js +55 -4
  86. package/dist/tui/app.js +1 -1
  87. package/dist/tui/approval.js +20 -21
  88. package/dist/util.d.ts +1 -0
  89. package/dist/util.js +1 -0
  90. package/dist/verification/baseline.js +17 -3
  91. package/dist/verification/classify.js +23 -12
  92. package/dist/verification/engine.js +3 -1
  93. package/dist/verification/registry.d.ts +2 -0
  94. package/dist/verification/registry.js +33 -0
  95. package/dist/verification/scoped.js +28 -6
  96. package/package.json +1 -1
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * 7.3 — Import graph (cached) — powers L6 scoped tests + imports_of / importers_of
3
3
  * Parses TS/JS/Py/Go imports via regex, builds adjacency, caches by mtime.
4
+ *
5
+ * Freshness: the cached graph stores per-file {mtimeMs, size} alongside it.
6
+ * On a cache hit every file in the graph is re-statted (stat-only, bounded
7
+ * to files already in the graph) — any mtime/size mismatch rebuilds.
4
8
  */
5
9
  import * as fs from 'node:fs/promises';
6
10
  import * as path from 'node:path';
@@ -19,10 +23,32 @@ async function parseImports(file, content) {
19
23
  }
20
24
  return out;
21
25
  }
26
+ /** Re-stat every file in the cached graph; true when all stats still match. */
27
+ async function statsStillMatch(cwd, stats) {
28
+ for (const [rel, prev] of stats) {
29
+ try {
30
+ const s = await fs.stat(path.join(cwd, rel));
31
+ if (s.mtimeMs !== prev.mtimeMs || s.size !== prev.size)
32
+ return false;
33
+ }
34
+ catch {
35
+ return false; // deleted (or unreadable) — rebuild
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ /** Invalidate the import-graph cache (tests + callers that mutate the tree). */
41
+ export function clearImportGraphCache() {
42
+ cache = null;
43
+ }
22
44
  export async function buildImportGraph(cwd) {
23
- if (cache && cache.cwd === cwd && Date.now() - cache.graph.mtime < 60_000)
24
- return cache.graph;
45
+ if (cache && cache.cwd === cwd && Date.now() - cache.graph.mtime < 60_000) {
46
+ if (await statsStillMatch(cwd, cache.stats))
47
+ return cache.graph;
48
+ cache = null;
49
+ }
25
50
  const graph = { nodes: new Set(), edges: new Map(), mtime: Date.now() };
51
+ const stats = new Map();
26
52
  async function walk(dir, depth = 0) {
27
53
  if (depth > 6)
28
54
  return;
@@ -43,6 +69,8 @@ export async function buildImportGraph(cwd) {
43
69
  const rel = path.relative(cwd, full).replace(/\\/g, '/');
44
70
  graph.nodes.add(rel);
45
71
  try {
72
+ const st = await fs.stat(full);
73
+ stats.set(rel, { mtimeMs: st.mtimeMs, size: st.size });
46
74
  const txt = await fs.readFile(full, 'utf-8');
47
75
  const imps = await parseImports(rel, txt);
48
76
  for (const imp of imps) {
@@ -57,7 +85,7 @@ export async function buildImportGraph(cwd) {
57
85
  }
58
86
  }
59
87
  await walk(cwd);
60
- cache = { cwd, graph };
88
+ cache = { cwd, graph, stats };
61
89
  return graph;
62
90
  }
63
91
  export async function importsOf(cwd, file) {
@@ -27,7 +27,10 @@ export async function loadKlyroMdFiles(cwd) {
27
27
  catch { /* ignore */ }
28
28
  }
29
29
  }
30
- for (const name of ['KLYRO.md', 'KLYRO.local.md', 'AGENTS.md', '.cursorrules']) {
30
+ // Klyro's own instruction file is KLYRO.md. The rest are read-only
31
+ // fallbacks for imported repos that follow other conventions — Klyro
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']) {
31
34
  const p = path.join(cwd, name);
32
35
  try {
33
36
  const t = await fs.readFile(p, 'utf-8');
@@ -1,5 +1,13 @@
1
1
  import type { PlanStep } from '../agent/runtime.js';
2
+ /** Single-write ceiling — anything larger is rejected, not sliced. */
3
+ export declare const MEMORY_WRITE_LIMIT_CHARS = 8000;
4
+ /** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
5
+ export declare const MEMORY_STEADY_STATE_CHARS = 4000;
2
6
  export declare function memoryWrite(cwd: string, content: string): Promise<string>;
3
7
  export declare function loadMemory(cwd: string): Promise<string>;
8
+ /** Synchronous read for the system-prompt build path (run on every turn). */
9
+ export declare function loadMemorySync(cwd: string): string;
10
+ /** Wrap persisted notes into the injected prompt block; '' when empty. */
11
+ export declare function memoryBlock(cwd: string): string;
4
12
  export declare function shouldRemind(turn: number, lastRemindTurn: number): boolean;
5
13
  export declare function reminderForTodos(todos: PlanStep[]): string | undefined;
@@ -1,15 +1,49 @@
1
1
  /**
2
2
  * 8.4 — Working memory & reminders: memory_write → .klyro/memory/session-notes.md (≤1k tokens) + todos re-inject
3
+ *
4
+ * Durability: writes are atomic (tmp + rename in the same dir) so a crash
5
+ * never leaves a half-written note file. Oversized single writes are
6
+ * rejected with an Error (the memory_write tool's safe() wrapper converts
7
+ * that into a ToolResult error) instead of being silently truncated.
3
8
  */
4
9
  import * as fs from 'node:fs/promises';
10
+ import { readFileSync } from 'node:fs';
5
11
  import * as path from 'node:path';
12
+ import { redact } from '../policy/secret-redactor.js';
13
+ /** Single-write ceiling — anything larger is rejected, not sliced. */
14
+ export const MEMORY_WRITE_LIMIT_CHARS = 8000;
15
+ /** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
16
+ export const MEMORY_STEADY_STATE_CHARS = 4000;
6
17
  export async function memoryWrite(cwd, content) {
18
+ if (content.length > MEMORY_WRITE_LIMIT_CHARS) {
19
+ throw new Error(`memory budget exceeded: single write is ${content.length} chars (max ${MEMORY_WRITE_LIMIT_CHARS}) — split it into smaller notes`);
20
+ }
7
21
  const dir = path.join(cwd, '.klyro', 'memory');
8
22
  await fs.mkdir(dir, { recursive: true });
9
23
  const p = path.join(dir, 'session-notes.md');
10
24
  const prev = await fs.readFile(p, 'utf-8').catch(() => '');
11
- const next = (prev + '\n' + content).slice(-4000); // ≤1k tokens ~4k chars
12
- await fs.writeFile(p, next, 'utf-8');
25
+ // S4-at-rest: redact before appending — redact() only fires on secret
26
+ // shapes (key/token/password with [:=-]), so normal prose survives.
27
+ const next = (prev + '\n' + redact(content)).slice(-MEMORY_STEADY_STATE_CHARS); // ≤1k tokens ~4k chars
28
+ const tmp = path.join(dir, `.session-notes.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
29
+ await fs.writeFile(tmp, next, 'utf-8');
30
+ try {
31
+ const fh = await fs.open(tmp, 'r+');
32
+ try {
33
+ await fh.sync();
34
+ }
35
+ finally {
36
+ await fh.close();
37
+ }
38
+ }
39
+ catch { /* ignore on Windows */ }
40
+ try {
41
+ await fs.rename(tmp, p);
42
+ }
43
+ catch {
44
+ await fs.unlink(tmp).catch(() => undefined);
45
+ throw new Error('Failed to write memory file');
46
+ }
13
47
  return p;
14
48
  }
15
49
  export async function loadMemory(cwd) {
@@ -20,6 +54,20 @@ export async function loadMemory(cwd) {
20
54
  return '';
21
55
  }
22
56
  }
57
+ /** Synchronous read for the system-prompt build path (run on every turn). */
58
+ export function loadMemorySync(cwd) {
59
+ try {
60
+ return readFileSync(path.join(cwd, '.klyro', 'memory', 'session-notes.md'), 'utf-8');
61
+ }
62
+ catch {
63
+ return '';
64
+ }
65
+ }
66
+ /** Wrap persisted notes into the injected prompt block; '' when empty. */
67
+ export function memoryBlock(cwd) {
68
+ const notes = loadMemorySync(cwd).trim();
69
+ return notes ? `\n\n<memory>\n${notes.slice(0, 4000)}\n</memory>` : '';
70
+ }
23
71
  export function shouldRemind(turn, lastRemindTurn) {
24
72
  return turn - lastRemindTurn >= 20;
25
73
  }
@@ -45,6 +45,12 @@ export interface ProjectMap {
45
45
  hasDocker?: boolean;
46
46
  hasEnvExample?: boolean;
47
47
  }
48
+ /**
49
+ * Dirty-tree fingerprint: max mtimeMs over a bounded walk (≤200 files,
50
+ * depth ≤4, ignored dirs skipped). Cheap enough to run on every cache
51
+ * lookup; any edit under the tree changes the key and busts the cache.
52
+ */
53
+ export declare function dirtyMtime(root: string): Promise<number>;
48
54
  /** Build a project map for the repo rooted at `root`. */
49
55
  export declare function buildProjectMapCached(root: string): Promise<ProjectMap>;
50
56
  /** Build a project map for the repo rooted at `root`. */
@@ -383,7 +383,8 @@ function lockfileHash(root) {
383
383
  async function getCachedProjectMap(root) {
384
384
  const head = await gitHead(root);
385
385
  const hash = lockfileHash(root);
386
- const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
386
+ const dirty = await dirtyMtime(root);
387
+ const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}-${dirty.toString(36)}.json`);
387
388
  try {
388
389
  const raw = await fs.readFile(p, 'utf-8');
389
390
  const j = JSON.parse(raw);
@@ -396,13 +397,60 @@ async function getCachedProjectMap(root) {
396
397
  async function setCachedProjectMap(root, m) {
397
398
  const head = await gitHead(root);
398
399
  const hash = lockfileHash(root);
399
- const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
400
+ const dirty = await dirtyMtime(root);
401
+ const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}-${dirty.toString(36)}.json`);
400
402
  try {
401
403
  await fs.mkdir(path.dirname(p), { recursive: true });
402
404
  await fs.writeFile(p, JSON.stringify(m, null, 2), 'utf-8');
403
405
  }
404
406
  catch { /* ignore */ }
405
407
  }
408
+ /**
409
+ * Dirty-tree fingerprint: max mtimeMs over a bounded walk (≤200 files,
410
+ * depth ≤4, ignored dirs skipped). Cheap enough to run on every cache
411
+ * lookup; any edit under the tree changes the key and busts the cache.
412
+ */
413
+ export async function dirtyMtime(root) {
414
+ let max = 0;
415
+ let seen = 0;
416
+ let stop = false;
417
+ async function walk(dir, depth) {
418
+ if (stop || depth > 4)
419
+ return;
420
+ let entries;
421
+ try {
422
+ entries = await fs.readdir(dir, { withFileTypes: true });
423
+ }
424
+ catch {
425
+ return;
426
+ }
427
+ for (const e of entries) {
428
+ if (stop)
429
+ return;
430
+ if (IGNORED_DIRS.has(e.name))
431
+ continue;
432
+ const full = path.join(dir, e.name);
433
+ if (e.isDirectory()) {
434
+ await walk(full, depth + 1);
435
+ }
436
+ else if (e.isFile()) {
437
+ seen++;
438
+ try {
439
+ const s = await fs.stat(full);
440
+ if (s.mtimeMs > max)
441
+ max = s.mtimeMs;
442
+ }
443
+ catch { /* race */ }
444
+ if (seen >= 200) {
445
+ stop = true;
446
+ return;
447
+ }
448
+ }
449
+ }
450
+ }
451
+ await walk(root, 0);
452
+ return Math.floor(max);
453
+ }
406
454
  function detectMonorepo(root, rootFiles, rootDirs) {
407
455
  if (rootFiles.has('pnpm-workspace.yaml') || rootFiles.has('lerna.json') || rootFiles.has('nx.json'))
408
456
  return true;
@@ -18,6 +18,8 @@ export interface RepoFile {
18
18
  path: string;
19
19
  symbols: Symbol[];
20
20
  }
21
+ /** Invalidate the repo-map cache (tests + callers that mutate the tree). */
22
+ export declare function clearRepoMapCache(): void;
21
23
  /** Heuristic: walk cwd, return at most `maxFiles` files with symbols. */
22
24
  export declare function buildRepoMap(opts: {
23
25
  cwd: string;
@@ -12,11 +12,38 @@ import * as fs from 'node:fs/promises';
12
12
  import * as path from 'node:path';
13
13
  const SUPPORTED_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs']);
14
14
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.klyro', 'coverage', 'out']);
15
+ const REPO_MAP_TTL_MS = 60_000;
16
+ let repoMapCache = null;
17
+ /** Invalidate the repo-map cache (tests + callers that mutate the tree). */
18
+ export function clearRepoMapCache() {
19
+ repoMapCache = null;
20
+ }
21
+ /** Re-stat every file in the cached map; true when all stats still match. */
22
+ async function repoMapStatsMatch(cwd, stats) {
23
+ for (const [rel, prev] of stats) {
24
+ try {
25
+ const s = await fs.stat(path.join(cwd, rel));
26
+ if (s.mtimeMs !== prev.mtimeMs || s.size !== prev.size)
27
+ return false;
28
+ }
29
+ catch {
30
+ return false; // deleted (or unreadable) — rebuild
31
+ }
32
+ }
33
+ return true;
34
+ }
15
35
  /** Heuristic: walk cwd, return at most `maxFiles` files with symbols. */
16
36
  export async function buildRepoMap(opts) {
17
37
  const maxFiles = opts.maxFiles ?? 500;
18
38
  const maxFileBytes = opts.maxFileBytes ?? 256 * 1024;
39
+ const cached = repoMapCache;
40
+ if (cached && cached.cwd === opts.cwd && cached.maxFiles === maxFiles &&
41
+ cached.maxFileBytes === maxFileBytes && Date.now() - cached.at < REPO_MAP_TTL_MS &&
42
+ (await repoMapStatsMatch(opts.cwd, cached.stats))) {
43
+ return cached.files;
44
+ }
19
45
  const out = [];
46
+ const stats = new Map();
20
47
  await walk(opts.cwd, async (file) => {
21
48
  if (out.length >= maxFiles)
22
49
  return false;
@@ -39,9 +66,12 @@ export async function buildRepoMap(opts) {
39
66
  catch {
40
67
  return true;
41
68
  }
42
- out.push({ path: path.relative(opts.cwd, file), symbols: extractSymbols(content, ext) });
69
+ const rel = path.relative(opts.cwd, file);
70
+ stats.set(rel, { mtimeMs: stat.mtimeMs, size: stat.size });
71
+ out.push({ path: rel, symbols: extractSymbols(content, ext) });
43
72
  return true;
44
73
  });
74
+ repoMapCache = { cwd: opts.cwd, maxFiles, maxFileBytes, files: out, stats, at: Date.now() };
45
75
  return out;
46
76
  }
47
77
  async function walk(dir, visit) {
@@ -167,6 +167,28 @@ export type KlyroEvent = {
167
167
  code: string;
168
168
  message: string;
169
169
  };
170
+ } | {
171
+ type: 'subtask.cancelled';
172
+ ts: number;
173
+ sessionId: string;
174
+ taskId: string;
175
+ status: 'cancelled';
176
+ durationMs: number;
177
+ error?: {
178
+ code: string;
179
+ message: string;
180
+ };
181
+ } | {
182
+ type: 'subtask.timed_out';
183
+ ts: number;
184
+ sessionId: string;
185
+ taskId: string;
186
+ status: 'timed_out';
187
+ durationMs: number;
188
+ error?: {
189
+ code: string;
190
+ message: string;
191
+ };
170
192
  } | {
171
193
  type: 'subtask.tool_call';
172
194
  ts: number;
@@ -189,6 +211,7 @@ export type KlyroEvent = {
189
211
  sessionId: string;
190
212
  taskId: string;
191
213
  changedFiles: string[];
214
+ merged?: boolean;
192
215
  } | {
193
216
  type: 'provider.retry';
194
217
  ts: number;
@@ -203,5 +226,10 @@ export type KlyroEvent = {
203
226
  path: string;
204
227
  reason: 'unknown' | 'changed';
205
228
  trusted: boolean;
229
+ } | {
230
+ type: 'context.compacted';
231
+ ts: number;
232
+ sessionId: string;
233
+ dropped: number;
206
234
  };
207
235
  export type KlyroEventType = KlyroEvent['type'];
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { runCompletion } from './cli/completion.js';
23
23
  import { runUpdate } from './cli/update.js';
24
24
  import { runLogin, runLogout } from './cli/auth.js';
25
25
  import { readVersion } from './version.js';
26
+ import { verifyAuditChain } from './persistence/audit.js';
26
27
  const VERSION = readVersion();
27
28
  function parsePositiveInt(name, v) {
28
29
  const n = Number(v);
@@ -61,8 +62,21 @@ async function main() {
61
62
  .option('--output-format <fmt>', 'Headless output format: text|json|stream-json (default text)')
62
63
  .option('--no-stream', 'Disable streaming (buffer full response)')
63
64
  .option('--show-thinking', 'Show thinking blocks');
65
+ // Unknown commands / unknown options are usage errors (exit 2 per PRD).
66
+ // Help and version still exit 0; anything else rethrows to the last-resort
67
+ // handler (exit 1).
68
+ program.exitOverride((err) => {
69
+ if (err.code === 'commander.unknownCommand' || err.code === 'commander.unknownOption') {
70
+ process.stderr.write(`klyro: ${err.message}\n`);
71
+ process.exit(2);
72
+ }
73
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
74
+ process.exit(0);
75
+ }
76
+ throw err;
77
+ });
64
78
  // Handle global flags before any command runs
65
- program.hook('preAction', (thisCommand) => {
79
+ program.hook('preAction', async (thisCommand) => {
66
80
  const opts = thisCommand.optsWithGlobals();
67
81
  if (opts.cwd) {
68
82
  try {
@@ -73,6 +87,13 @@ async function main() {
73
87
  process.exit(2);
74
88
  }
75
89
  }
90
+ // Load <cwd>/.env globally (after chdir) so KLYRO_* vars resolve for
91
+ // every command. Never throws; explicit env wins (no-clobber).
92
+ try {
93
+ const { loadDotenv } = await import('./cli/dotenv.js');
94
+ loadDotenv(process.cwd());
95
+ }
96
+ catch { /* ignore */ }
76
97
  if (opts.config)
77
98
  process.env.KLYRO_CONFIG = opts.config;
78
99
  if (opts.debug)
@@ -271,6 +292,7 @@ async function main() {
271
292
  .option('--resume-session <id>', 'Resume from a persisted session (Level 9) by id prefix')
272
293
  .option('--verify', 'Enable verification after edits (Level 8, default: enabled)')
273
294
  .option('--verify-command <cmd>', 'Custom verification command (default: auto-detected)')
295
+ .option('--verify-mode <mode>', 'Verification mode: strict (default), advisory, off')
274
296
  .option('--max-repairs <n>', 'Max autonomous repair attempts (default 3)', (v) => parsePositiveInt('--max-repairs', v))
275
297
  .option('--persist', 'Enable session persistence (Level 9, default: enabled)')
276
298
  .option('--require-verify', 'Fail with exit 8 if no verification passed after edits (6.5)')
@@ -287,6 +309,11 @@ async function main() {
287
309
  process.stderr.write(`klyro: invalid --output: ${output} (expected human|json|silent)\n`);
288
310
  process.exit(2);
289
311
  }
312
+ const verifyMode = opts.verifyMode ?? 'strict';
313
+ if (verifyMode !== 'strict' && verifyMode !== 'advisory' && verifyMode !== 'off') {
314
+ process.stderr.write(`klyro: invalid --verify-mode: ${opts.verifyMode} (expected strict|advisory|off)\n`);
315
+ process.exit(2);
316
+ }
290
317
  // Provider validation is handled inside runOnce (single source of truth)
291
318
  try {
292
319
  const code = await runOnce({
@@ -306,6 +333,7 @@ async function main() {
306
333
  sessionId: opts.resumeSession,
307
334
  verify: opts.verify,
308
335
  verifyCommand: opts.verifyCommand,
336
+ verifyMode: verifyMode,
309
337
  maxRepairAttempts: opts.maxRepairs,
310
338
  persist: opts.persist,
311
339
  requireVerify: !!opts.requireVerify,
@@ -570,15 +598,72 @@ async function main() {
570
598
  });
571
599
  // 10.1 — MCP
572
600
  const mcp = program.command('mcp').description('MCP client/server (10.1)');
573
- mcp.command('list').description('List MCP servers').action(async () => { process.stdout.write('mcp servers: (stub) github filesystem — use .mcp.json\n'); });
601
+ mcp.command('list').description('List MCP servers').action(async () => {
602
+ const { loadMcpServers } = await import('./mcp/config.js');
603
+ const cfg = loadMcpServers(process.cwd());
604
+ const names = Object.keys(cfg.servers);
605
+ if (names.length === 0) {
606
+ process.stdout.write('mcp servers: none configured (use .mcp.json)\n');
607
+ return;
608
+ }
609
+ for (const name of names) {
610
+ const spec = cfg.servers[name];
611
+ const source = cfg.sources[name] ?? 'unknown';
612
+ process.stdout.write(`${name} source=${source}${spec?.disabled ? ' disabled' : ''}\n`);
613
+ }
614
+ });
574
615
  mcp.command('add <name> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
575
616
  mcp.command('serve').description('Serve as MCP server').action(async () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
576
617
  // 10.2 — Hooks / agents
577
618
  program.command('hooks').description('List hooks (10.2)').action(async () => { process.stdout.write('hooks: SessionStart UserPromptSubmit PreToolUse PostToolUse (stub)\n'); });
578
- program.command('agents').description('List agents (10.2)').action(async () => { process.stdout.write('agents: explorer implementer tester reviewer (stub)\n'); });
619
+ program.command('agents [name]').description('List agents (10.2) or show one agent definition').action(async (name) => {
620
+ const { BUILTIN_AGENTS } = await import('./agent/orchestrator.js');
621
+ if (!name) {
622
+ for (const a of BUILTIN_AGENTS)
623
+ process.stdout.write(`${a.id} — ${a.description}\n`);
624
+ return;
625
+ }
626
+ const def = BUILTIN_AGENTS.find((a) => a.id === name);
627
+ if (!def) {
628
+ process.stderr.write(`klyro: unknown agent: ${name} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
629
+ process.exit(2);
630
+ }
631
+ const tools = def.allowedTools ?? ['<inherited: all parent tools>'];
632
+ const lines = [
633
+ `agent: ${def.id}`,
634
+ `description: ${def.description}`,
635
+ `tools (${tools.length}): ${tools.join(', ')}`,
636
+ `readonly: ${def.readonly ?? false}`,
637
+ `canSpawn: ${def.canSpawn ?? false}`,
638
+ `model: ${def.model ?? '<session default>'}`,
639
+ `maxSteps: ${def.maxSteps ?? '<default>'}`,
640
+ `maxTokens: ${def.maxTokens ?? '<default>'}`,
641
+ ];
642
+ process.stdout.write(lines.join('\n') + '\n');
643
+ });
579
644
  // 10.3 — Web / git workflows / SDK
580
645
  program.command('commit').description('Create commit (10.3)').action(async () => { process.stdout.write('commit — conventional message (stub, use /commit)\n'); });
581
- program.command('audit').description('Audit log (13.4)').action(async () => { process.stdout.write('audit — hash-chained JSONL (stub)\n'); });
646
+ program.command('audit [session]').description('Verify audit chain (13.4)').action(async (session) => {
647
+ if (!session) {
648
+ process.stderr.write('klyro: audit requires a session id (usage: klyro audit <session>)\n');
649
+ process.exit(2);
650
+ }
651
+ try {
652
+ const { getDefaultSessionsDir } = await import('./persistence/session.js');
653
+ const res = await verifyAuditChain(getDefaultSessionsDir(), session);
654
+ if (res.ok) {
655
+ process.stdout.write(`audit ok: ${res.events} events verified\n`);
656
+ process.exit(0);
657
+ }
658
+ process.stderr.write(`klyro: audit failed: ${res.error ?? 'chain broken'}\n`);
659
+ process.exit(1);
660
+ }
661
+ catch (err) {
662
+ const msg = err instanceof Error ? err.message : String(err);
663
+ process.stderr.write(`klyro: audit failed: ${msg}\n`);
664
+ process.exit(1);
665
+ }
666
+ });
582
667
  // 10.4 — Benchmark parity (10.5)
583
668
  program.command('benchmark').description('Run benchmark (10.5)').action(async () => {
584
669
  const { runHarness } = await import('./eval/harness.js');
@@ -16,9 +16,9 @@ export interface McpCallResult {
16
16
  raw: unknown;
17
17
  }
18
18
  export interface McpClientLike {
19
- listTools(): Promise<McpToolDef[]>;
19
+ listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
20
20
  callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
21
- listResources(): Promise<McpResource[]>;
21
+ listResources(signal?: AbortSignal): Promise<McpResource[]>;
22
22
  close(): Promise<void>;
23
23
  }
24
24
  export declare class McpError extends Error {
@@ -39,11 +39,13 @@ export declare class McpClient implements McpClientLike {
39
39
  constructor(name: string, spec: McpServerSpec);
40
40
  connect(): Promise<void>;
41
41
  private connected;
42
- listTools(): Promise<McpToolDef[]>;
42
+ listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
43
43
  callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
44
- listResources(): Promise<McpResource[]>;
44
+ listResources(signal?: AbortSignal): Promise<McpResource[]>;
45
45
  readResource(uri: string, signal?: AbortSignal): Promise<string>;
46
46
  close(): Promise<void>;
47
+ /** Test hook: number of in-flight JSON-RPC requests. */
48
+ pendingCount(): number;
47
49
  private timeout;
48
50
  private assertLive;
49
51
  private onStdout;