klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  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 +124 -6
  8. package/dist/agent/orchestrator.js +425 -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 +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `klyro commit` — conventional commit of already-staged changes.
3
+ *
4
+ * Steps:
5
+ * (a) `git status --porcelain` must show staged entries (index column set).
6
+ * Nothing staged → error 'nothing staged (git add first)', exit 2.
7
+ * (`--yes` never bypasses this — there is nothing to commit.)
8
+ * (b) Secret-scan the staged diff via `redact()`: if redaction shrinks or
9
+ * alters the diff, refuse and list the files (exit 2) unless
10
+ * `--force-secret` is passed.
11
+ * (c) Build a conventional message: type heuristic + top-dir scope +
12
+ * `--message` summary (or `update <n> files`).
13
+ * (d) `git commit -m` via execFileSync (no shell). Verification hooks always
14
+ * run — by construction this file contains no flag that skips them.
15
+ * (e) `--dry-run` prints the message + files and exits 0 without committing.
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+ import { redact } from '../policy/secret-redactor.js';
19
+ function git(cwd, args) {
20
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString();
21
+ }
22
+ /** Porcelain XY: staged iff the index (first) column is set. Handles renames. */
23
+ export function stagedFilesFromPorcelain(porcelain) {
24
+ const out = [];
25
+ for (const line of porcelain.split('\n')) {
26
+ if (line.length < 4)
27
+ continue;
28
+ const indexStatus = line[0];
29
+ if (indexStatus === ' ' || indexStatus === '?' || indexStatus === '!')
30
+ continue;
31
+ let file = line.slice(3).trim();
32
+ // Rename/copy form: "old -> new" — the new path is what's committed.
33
+ const arrow = file.indexOf(' -> ');
34
+ if (arrow !== -1)
35
+ file = file.slice(arrow + 4);
36
+ // Quoted paths from core.quotePath — strip surrounding quotes.
37
+ if (file.startsWith('"') && file.endsWith('"'))
38
+ file = file.slice(1, -1);
39
+ if (file)
40
+ out.push(file);
41
+ }
42
+ return out;
43
+ }
44
+ const LOCKFILES = new Set([
45
+ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'Cargo.lock',
46
+ 'Gemfile.lock', 'poetry.lock', 'composer.lock',
47
+ ]);
48
+ function isTestPath(f) {
49
+ const l = f.toLowerCase();
50
+ return (l.includes('.test.') || l.includes('.spec.') ||
51
+ l.includes('__tests__') || /(^|\/)tests?\//.test(l) || /(^|\/)spec\//.test(l));
52
+ }
53
+ function isDocsPath(f) {
54
+ const l = f.toLowerCase();
55
+ return l.endsWith('.md') || l.startsWith('docs/') || l.includes('/docs/');
56
+ }
57
+ /** Type heuristic: test-only→test, docs-only→docs, lockfiles→chore, else feat. */
58
+ export function commitTypeFor(files) {
59
+ if (files.length > 0 && files.every((f) => LOCKFILES.has(f.split('/').pop() ?? f)))
60
+ return 'chore';
61
+ if (files.length > 0 && files.every(isTestPath))
62
+ return 'test';
63
+ if (files.length > 0 && files.every(isDocsPath))
64
+ return 'docs';
65
+ return 'feat';
66
+ }
67
+ /** Scope = most common top-level dir among staged files with a dir; '' if none. */
68
+ export function commitScopeFor(files) {
69
+ const counts = new Map();
70
+ for (const f of files) {
71
+ const slash = f.indexOf('/');
72
+ if (slash > 0) {
73
+ const top = f.slice(0, slash);
74
+ counts.set(top, (counts.get(top) ?? 0) + 1);
75
+ }
76
+ }
77
+ let best = '';
78
+ let bestN = 0;
79
+ for (const [dir, n] of counts) {
80
+ if (n > bestN) {
81
+ best = dir;
82
+ bestN = n;
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+ export function buildCommitMessage(files, summaryOpt) {
88
+ const type = commitTypeFor(files);
89
+ const scope = commitScopeFor(files);
90
+ const summary = (summaryOpt ?? '').trim() || `update ${files.length} file${files.length === 1 ? '' : 's'}`;
91
+ return scope ? `${type}(${scope}): ${summary}` : `${type}: ${summary}`;
92
+ }
93
+ export async function runCommit(opts) {
94
+ void opts.yes; // accepted for uniformity; never bypasses the staged check
95
+ const cwd = opts.cwd;
96
+ let porcelain;
97
+ try {
98
+ porcelain = git(cwd, ['status', '--porcelain']);
99
+ }
100
+ catch (err) {
101
+ process.stderr.write(`klyro: commit: not a git repo or git unavailable (${err instanceof Error ? err.message.split('\n')[0] : String(err)})\n`);
102
+ return 2;
103
+ }
104
+ const staged = stagedFilesFromPorcelain(porcelain);
105
+ if (staged.length === 0) {
106
+ process.stderr.write('klyro: commit: nothing staged (git add first)\n');
107
+ return 2;
108
+ }
109
+ let namesOnly;
110
+ try {
111
+ namesOnly = git(cwd, ['diff', '--cached', '--name-only']).split('\n').map((s) => s.trim()).filter(Boolean);
112
+ }
113
+ catch {
114
+ namesOnly = staged;
115
+ }
116
+ const files = namesOnly.length > 0 ? namesOnly : staged;
117
+ // (b) secret scan of the staged diff (read-only — redact() never writes).
118
+ try {
119
+ const diff = git(cwd, ['diff', '--cached', '--']);
120
+ if (redact(diff) !== diff && !opts.forceSecret) {
121
+ process.stderr.write(`klyro: commit: refused — possible secret in staged diff (files: ${files.join(', ')}). Review with \`git diff --cached\`, unstage the secret, or re-run with --force-secret.\n`);
122
+ return 2;
123
+ }
124
+ }
125
+ catch {
126
+ // Could not read the diff — proceed; the commit itself is authoritative.
127
+ }
128
+ const message = buildCommitMessage(files, opts.message);
129
+ if (opts.dryRun) {
130
+ process.stdout.write(`${message}\nfiles:\n${files.map((f) => ` ${f}`).join('\n')}\n`);
131
+ return 0;
132
+ }
133
+ try {
134
+ // NOTE: verification hooks must run — no skip flag is ever passed here.
135
+ execFileSync('git', ['commit', '-m', message], { cwd, stdio: 'inherit' });
136
+ }
137
+ catch {
138
+ process.stderr.write('klyro: commit: `git commit` failed (see output above)\n');
139
+ return 1;
140
+ }
141
+ return 0;
142
+ }
@@ -17,8 +17,53 @@ export declare const ConfigSchema: z.ZodObject<{
17
17
  api_key: z.ZodOptional<z.ZodString>;
18
18
  'model.default': z.ZodOptional<z.ZodString>;
19
19
  'model.small': z.ZodOptional<z.ZodString>;
20
+ providers: z.ZodOptional<z.ZodObject<{
21
+ failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
22
+ provider: z.ZodEnum<{
23
+ openai: "openai";
24
+ anthropic: "anthropic";
25
+ }>;
26
+ baseURL: z.ZodOptional<z.ZodString>;
27
+ apiKey: z.ZodOptional<z.ZodString>;
28
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
29
+ }, z.core.$strip>>>;
30
+ }, z.core.$strip>>;
20
31
  }, z.core.$loose>;
21
32
  export type KlyroConfig = z.infer<typeof ConfigSchema>;
33
+ export declare const FailoverEntrySchema: z.ZodObject<{
34
+ provider: z.ZodEnum<{
35
+ openai: "openai";
36
+ anthropic: "anthropic";
37
+ }>;
38
+ baseURL: z.ZodOptional<z.ZodString>;
39
+ apiKey: z.ZodOptional<z.ZodString>;
40
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
41
+ }, z.core.$strip>;
42
+ export type FailoverEntry = z.infer<typeof FailoverEntrySchema>;
43
+ export declare const FailoverConfigSchema: z.ZodObject<{
44
+ failover: z.ZodOptional<z.ZodArray<z.ZodObject<{
45
+ provider: z.ZodEnum<{
46
+ openai: "openai";
47
+ anthropic: "anthropic";
48
+ }>;
49
+ baseURL: z.ZodOptional<z.ZodString>;
50
+ apiKey: z.ZodOptional<z.ZodString>;
51
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
52
+ }, z.core.$strip>>>;
53
+ }, z.core.$strip>;
54
+ export interface ResolvedProviderEntry {
55
+ provider: 'openai' | 'anthropic';
56
+ baseURL?: string;
57
+ apiKey?: string;
58
+ }
59
+ /**
60
+ * Build the ordered provider chain: primary (existing precedence:
61
+ * flags > env > merged config > defaults) followed by `providers.failover`
62
+ * entries. `apiKeyEnv` names an env var resolved at build time; entries
63
+ * whose key is missing/empty are skipped (with a stderr reason) so a
64
+ * half-configured fallback can never become the active provider.
65
+ */
66
+ export declare function resolveProviderChain(cwd?: string, flags?: Record<string, unknown>): Promise<ResolvedProviderEntry[]>;
22
67
  export declare function getConfigDir(): string;
23
68
  export declare function getConfigPath(): string;
24
69
  export declare function getConfigSearchPaths(cwd?: string): Array<{
@@ -46,12 +91,18 @@ export interface PermissionRules {
46
91
  * without re-prompting.
47
92
  */
48
93
  export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
94
+ export declare function isValidAllowRule(rule: string): boolean;
49
95
  /**
50
- * Persist an "always allow" pattern to the home settings file
51
- * (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
96
+ * Persist an "always allow" pattern. Validates the rule against the strict
97
+ * grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
98
+ * (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
99
+ * otherwise writes to the home settings file. Returns whether it was
52
100
  * added (false when already present) and the file written.
101
+ *
102
+ * NOTE for the repl integrator (sibling-owned repl.ts): pass the session
103
+ * cwd as the second arg to persist project-scoped allows.
53
104
  */
54
- export declare function persistAllowRule(rule: string): Promise<{
105
+ export declare function persistAllowRule(rule: string, cwd?: string): Promise<{
55
106
  added: boolean;
56
107
  path: string;
57
108
  }>;
@@ -24,8 +24,90 @@ export const ConfigSchema = z
24
24
  // Global flags persisted
25
25
  'model.default': z.string().optional(),
26
26
  'model.small': z.string().optional(),
27
+ // Provider failover chain (L15): ordered fallbacks after the primary.
28
+ // Optional so existing configs keep validating; invalid entries exit 3.
29
+ providers: z
30
+ .object({
31
+ failover: z
32
+ .array(z.object({
33
+ provider: z.enum(['openai', 'anthropic']),
34
+ baseURL: z.string().optional(),
35
+ apiKey: z.string().optional(),
36
+ apiKeyEnv: z.string().optional(),
37
+ }))
38
+ .optional(),
39
+ })
40
+ .optional(),
27
41
  })
28
42
  .passthrough();
43
+ // --- Provider failover chain (L15 differentiator) ---
44
+ // `providers.failover` is an ordered list of fallback providers. Entries
45
+ // with unresolvable/missing API keys are skipped at chain-build time.
46
+ export const FailoverEntrySchema = z.object({
47
+ provider: z.enum(['openai', 'anthropic']),
48
+ baseURL: z.string().optional(),
49
+ apiKey: z.string().optional(),
50
+ apiKeyEnv: z.string().optional(),
51
+ });
52
+ export const FailoverConfigSchema = z.object({
53
+ failover: z.array(FailoverEntrySchema).optional(),
54
+ });
55
+ /**
56
+ * Build the ordered provider chain: primary (existing precedence:
57
+ * flags > env > merged config > defaults) followed by `providers.failover`
58
+ * entries. `apiKeyEnv` names an env var resolved at build time; entries
59
+ * whose key is missing/empty are skipped (with a stderr reason) so a
60
+ * half-configured fallback can never become the active provider.
61
+ */
62
+ export async function resolveProviderChain(cwd = process.cwd(), flags = {}) {
63
+ const merged = await loadMergedConfig(cwd, flags);
64
+ const chain = [];
65
+ const skipped = [];
66
+ // Primary — mirror the precedence resolveProvider uses: explicit flags,
67
+ // then KLYRO_* env, then merged config.
68
+ const flagProvider = typeof flags['provider'] === 'string' ? flags['provider'] : undefined;
69
+ const flagBaseUrl = typeof flags['baseUrl'] === 'string' ? flags['baseUrl'] : typeof flags['baseURL'] === 'string' ? flags['baseURL'] : undefined;
70
+ const flagApiKey = typeof flags['apiKey'] === 'string' ? flags['apiKey'] : undefined;
71
+ const primaryProvider = (flagProvider ?? merged.provider ?? process.env.KLYRO_PROVIDER ?? 'openai');
72
+ const primaryBaseURL = flagBaseUrl ?? merged.baseUrl ?? merged.baseURL ?? process.env.KLYRO_BASE_URL;
73
+ const primaryApiKey = flagApiKey ?? merged.apiKey ?? merged.api_key ?? process.env.KLYRO_API_KEY;
74
+ if (primaryProvider === 'openai' || primaryProvider === 'anthropic') {
75
+ chain.push({
76
+ provider: primaryProvider,
77
+ ...(primaryBaseURL ? { baseURL: primaryBaseURL } : {}),
78
+ ...(primaryApiKey ? { apiKey: primaryApiKey } : {}),
79
+ });
80
+ }
81
+ // Failover list — validated leniently (invalid entries skipped, never fatal).
82
+ const providersRaw = merged.providers;
83
+ const parsed = FailoverConfigSchema.safeParse(providersRaw ?? {});
84
+ if (parsed.success && parsed.data.failover) {
85
+ for (const entry of parsed.data.failover) {
86
+ let key = entry.apiKey;
87
+ if (!key && entry.apiKeyEnv) {
88
+ const v = process.env[entry.apiKeyEnv];
89
+ if (v && v.length > 0)
90
+ key = v;
91
+ }
92
+ if (!key) {
93
+ skipped.push(`failover ${entry.provider}${entry.apiKeyEnv ? ` (env ${entry.apiKeyEnv} unset)` : ' (no apiKey)'}`);
94
+ continue;
95
+ }
96
+ chain.push({
97
+ provider: entry.provider,
98
+ ...(entry.baseURL ? { baseURL: entry.baseURL } : {}),
99
+ apiKey: key,
100
+ });
101
+ }
102
+ }
103
+ for (const reason of skipped) {
104
+ try {
105
+ process.stderr.write(`klyro: skipping failover entry — ${reason}\n`);
106
+ }
107
+ catch { /* ignore */ }
108
+ }
109
+ return chain;
110
+ }
29
111
  // --- Paths ---
30
112
  export function getConfigDir() {
31
113
  if (process.env.KLYRO_CONFIG_DIR)
@@ -338,11 +420,72 @@ export async function loadPermissionRules(cwd = process.cwd()) {
338
420
  };
339
421
  }
340
422
  /**
341
- * Persist an "always allow" pattern to the home settings file
342
- * (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
423
+ * Strict allow-rule grammar: `tool` or `tool(glob)` where tool is
424
+ * lowercase alnum+underscore and glob contains no parens.
425
+ */
426
+ const ALLOW_RULE_RE = /^[a-z0-9_]+\([^()]*\)$|^[a-z0-9_]+$/;
427
+ export function isValidAllowRule(rule) {
428
+ return ALLOW_RULE_RE.test(rule.trim());
429
+ }
430
+ function isProjectDir(cwd) {
431
+ try {
432
+ const st = fsSync.statSync(cwd);
433
+ if (!st.isDirectory())
434
+ return false;
435
+ }
436
+ catch {
437
+ return false;
438
+ }
439
+ return fsSync.existsSync(path.join(cwd, '.git')) || fsSync.existsSync(path.join(cwd, '.klyro'));
440
+ }
441
+ /**
442
+ * Persist an "always allow" pattern. Validates the rule against the strict
443
+ * grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
444
+ * (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
445
+ * otherwise writes to the home settings file. Returns whether it was
343
446
  * added (false when already present) and the file written.
447
+ *
448
+ * NOTE for the repl integrator (sibling-owned repl.ts): pass the session
449
+ * cwd as the second arg to persist project-scoped allows.
344
450
  */
345
- export async function persistAllowRule(rule) {
451
+ export async function persistAllowRule(rule, cwd) {
452
+ if (!isValidAllowRule(rule)) {
453
+ throw new Error(`invalid rule: ${rule}`);
454
+ }
455
+ // Project-scoped persist when cwd is a project dir.
456
+ if (cwd && isProjectDir(cwd)) {
457
+ const projPath = path.join(cwd, '.klyro', 'settings.json');
458
+ let cfg = {};
459
+ try {
460
+ const raw = await fs.readFile(projPath, 'utf-8');
461
+ cfg = validateConfig(parseJsonc(raw, projPath), projPath);
462
+ }
463
+ catch (err) {
464
+ const e = err;
465
+ if (e.code !== 'ENOENT')
466
+ throw err;
467
+ }
468
+ const allow = asStringArray(cfg.allow);
469
+ if (allow.includes(rule))
470
+ return { added: false, path: projPath };
471
+ cfg.allow = [...allow, rule];
472
+ await fs.mkdir(path.dirname(projPath), { recursive: true });
473
+ const tmp = `${projPath}.tmp-${process.pid}-${Date.now()}`;
474
+ const data = JSON.stringify(validateConfig(cfg, projPath), null, 2) + '\n';
475
+ await fs.writeFile(tmp, data, { mode: 0o600 });
476
+ try {
477
+ await fs.rename(tmp, projPath);
478
+ }
479
+ catch (err) {
480
+ await fs.unlink(tmp).catch(() => undefined);
481
+ throw err;
482
+ }
483
+ try {
484
+ await fs.chmod(projPath, 0o600);
485
+ }
486
+ catch { /* ignore on Windows */ }
487
+ return { added: true, path: projPath };
488
+ }
346
489
  const cfg = await loadConfig();
347
490
  const allow = asStringArray(cfg.allow);
348
491
  if (allow.includes(rule))
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export declare function runDoctor(opts?: {
7
7
  json?: boolean;
8
+ cwd?: string;
8
9
  }): Promise<number>;
@@ -10,7 +10,6 @@ import { spawn } from 'node:child_process';
10
10
  import { getConfigPath, loadConfig } from './config.js';
11
11
  import { resolveProvider, providerHelp } from '../providers.js';
12
12
  import { getDefaultSessionsDir } from '../persistence/session.js';
13
- import { builtinRegistry } from '../tools/registry.js';
14
13
  function checkNode() {
15
14
  const v = process.versions.node;
16
15
  const major = Number(v.split('.')[0] ?? 0);
@@ -94,26 +93,92 @@ function checkGit() {
94
93
  });
95
94
  });
96
95
  }
97
- function checkTools() {
98
- const reg = builtinRegistry();
99
- return { name: 'Tools', ok: true, detail: `${reg.list().length} tools: ${reg.list().map((t) => t.name).join(', ')}` };
96
+ async function checkTools() {
97
+ try {
98
+ const { builtinRegistry } = await import('../tools/registry.js');
99
+ const reg = builtinRegistry();
100
+ return { name: 'Tools', ok: true, detail: `${reg.list().length} tools: ${reg.list().map((t) => t.name).join(', ')}` };
101
+ }
102
+ catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ return { name: 'Tools', ok: false, detail: `registry unavailable: ${msg}` };
105
+ }
100
106
  }
101
107
  function checkPlatform() {
102
108
  const ok = ['win32', 'linux', 'darwin'].includes(process.platform);
103
109
  return { name: 'Platform', ok, detail: `${process.platform} ${process.arch} ${ok ? '✓' : '✗ unsupported'}` };
104
110
  }
111
+ async function checkMcp(cwd) {
112
+ try {
113
+ const { loadMcpServers } = await import('../mcp/config.js');
114
+ const cfg = loadMcpServers(cwd);
115
+ const names = Object.keys(cfg.servers);
116
+ if (names.length === 0)
117
+ return { name: 'MCP servers', ok: true, detail: 'none' };
118
+ let project = 0;
119
+ for (const n of names) {
120
+ if (cfg.sources[n] === 'project')
121
+ project++;
122
+ }
123
+ return { name: 'MCP servers', ok: true, detail: `${names.length} servers (global ${names.length - project}/project ${project})` };
124
+ }
125
+ catch {
126
+ return { name: 'MCP servers', ok: true, detail: 'none' };
127
+ }
128
+ }
129
+ function countTrustEntries(p) {
130
+ try {
131
+ const raw = fsSync.readFileSync(p, 'utf-8');
132
+ const parsed = JSON.parse(raw);
133
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
134
+ return Object.keys(parsed).length;
135
+ }
136
+ return null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ async function checkTrust() {
143
+ try {
144
+ const [{ defaultTrustStorePath }] = [await import('../context/trust.js')];
145
+ const [{ defaultMcpTrustStorePath }] = [await import('../mcp/trust.js')];
146
+ const ctx = countTrustEntries(defaultTrustStorePath());
147
+ const mcp = countTrustEntries(defaultMcpTrustStorePath());
148
+ if (ctx === null && mcp === null)
149
+ return { name: 'Trust stores', ok: true, detail: 'none' };
150
+ return {
151
+ name: 'Trust stores',
152
+ ok: true,
153
+ detail: `context-trust ${ctx ?? 0} entries, mcp-trust ${mcp ?? 0} entries`,
154
+ };
155
+ }
156
+ catch {
157
+ return { name: 'Trust stores', ok: true, detail: 'none' };
158
+ }
159
+ }
105
160
  export async function runDoctor(opts = {}) {
161
+ const cwd = opts.cwd ?? process.cwd();
106
162
  const checks = [];
107
163
  checks.push(checkNode());
108
164
  checks.push(await checkConfig());
109
165
  checks.push(await checkProvider());
110
166
  checks.push(await checkSessions());
111
167
  checks.push(await checkGit());
112
- checks.push(checkTools());
168
+ checks.push(await checkTools());
113
169
  checks.push(checkPlatform());
170
+ const mcpCheck = await checkMcp(cwd);
171
+ const trustCheck = await checkTrust();
172
+ checks.push(mcpCheck);
173
+ checks.push(trustCheck);
114
174
  const allOk = checks.every((c) => c.ok);
115
175
  if (opts.json) {
116
- process.stdout.write(JSON.stringify({ ok: allOk, checks }, null, 2) + '\n');
176
+ process.stdout.write(JSON.stringify({
177
+ ok: allOk,
178
+ checks,
179
+ mcp: { ok: mcpCheck.ok, detail: mcpCheck.detail },
180
+ trust: { ok: trustCheck.ok, detail: trustCheck.detail },
181
+ }, null, 2) + '\n');
117
182
  return allOk ? 0 : 1;
118
183
  }
119
184
  process.stdout.write('\nklyro doctor\n');
@@ -39,7 +39,7 @@
39
39
  * is set, emits one JSON object per line. Exit code 0 if all pass, 1
40
40
  * otherwise.
41
41
  */
42
- import { type RunResult } from '../agent/runtime.js';
42
+ import { type RunResult, type VerifyMode } from '../agent/runtime.js';
43
43
  import type { ProviderAdapter } from '../agent/provider-adapter.js';
44
44
  export interface EvalScenario {
45
45
  name: string;
@@ -47,6 +47,11 @@ export interface EvalScenario {
47
47
  model?: string;
48
48
  maxSteps?: number;
49
49
  maxTokens?: number;
50
+ /** Verification passthrough (default unchanged: strict pipeline when omitted). */
51
+ verify?: {
52
+ mode?: VerifyMode;
53
+ command?: string;
54
+ };
50
55
  /** Adapter events to script, as [eventKind, ...args] tuples. */
51
56
  scripted_events?: Array<Array<unknown[]>>;
52
57
  expect?: {
package/dist/cli/eval.js CHANGED
@@ -236,6 +236,15 @@ export async function runScenario(sc) {
236
236
  maxSteps: sc.maxSteps,
237
237
  maxTokens: sc.maxTokens,
238
238
  nonInteractive: true,
239
+ ...(sc.verify
240
+ ? {
241
+ verify: {
242
+ enabled: true,
243
+ ...(sc.verify.command !== undefined ? { command: sc.verify.command } : {}),
244
+ ...(sc.verify.mode !== undefined ? { mode: sc.verify.mode } : {}),
245
+ },
246
+ }
247
+ : {}),
239
248
  }, {
240
249
  adapter,
241
250
  registry,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Hooks engine (10.2) — preToolUse / postToolUse shell hooks.
3
+ *
4
+ * Config files:
5
+ * - project: `<cwd>/.klyro/hooks.json`
6
+ * - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
7
+ *
8
+ * Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
9
+ *
10
+ * `loadHooks` never throws — a missing file is `[]`, an invalid file is
11
+ * `[]` plus a one-time stderr warning per path. `runHook` spawns the
12
+ * command with `shell: true` (commands are strings, must work on win +
13
+ * posix), a default 30s timeout, a filtered env, plus `KLYRO_TOOL_NAME`
14
+ * and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
15
+ */
16
+ import { z } from 'zod';
17
+ export declare const HookSchema: z.ZodObject<{
18
+ name: z.ZodString;
19
+ event: z.ZodEnum<{
20
+ preToolUse: "preToolUse";
21
+ postToolUse: "postToolUse";
22
+ }>;
23
+ command: z.ZodString;
24
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
25
+ }, z.core.$strip>;
26
+ export type Hook = z.infer<typeof HookSchema>;
27
+ export interface HookResult {
28
+ ok: boolean;
29
+ exitCode: number | null;
30
+ stdout: string;
31
+ stderr: string;
32
+ }
33
+ export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
34
+ /**
35
+ * Load hooks for a run. Global first, then project — a project hook with
36
+ * the same `name` replaces the global one. Never throws.
37
+ */
38
+ export declare function loadHooks(cwd: string): Hook[];
39
+ export interface HookContext {
40
+ toolName: string;
41
+ input: unknown;
42
+ }
43
+ /**
44
+ * Run one hook. Resolves (never rejects) with the exit code + sliced
45
+ * output. `ok` is true only when the process exited 0.
46
+ */
47
+ export declare function runHook(hook: Hook, ctx: HookContext): Promise<HookResult>;