klyro 1.0.1 → 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 (50) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +13 -5
  2. package/dist/agent/anthropic-adapter.js +19 -2
  3. package/dist/agent/capabilities.js +7 -1
  4. package/dist/agent/orchestrator.d.ts +18 -1
  5. package/dist/agent/orchestrator.js +51 -4
  6. package/dist/agent/retry.js +52 -10
  7. package/dist/agent/runtime.d.ts +34 -0
  8. package/dist/agent/runtime.js +164 -14
  9. package/dist/agent/stream-budget.d.ts +36 -0
  10. package/dist/agent/stream-budget.js +121 -0
  11. package/dist/checkpoints/store.d.ts +9 -0
  12. package/dist/checkpoints/store.js +26 -0
  13. package/dist/cli/commit.d.ts +31 -0
  14. package/dist/cli/commit.js +142 -0
  15. package/dist/cli/config.d.ts +45 -0
  16. package/dist/cli/config.js +82 -0
  17. package/dist/cli/doctor.d.ts +1 -0
  18. package/dist/cli/doctor.js +71 -6
  19. package/dist/cli/hooks.d.ts +47 -0
  20. package/dist/cli/hooks.js +181 -0
  21. package/dist/cli/repl.js +41 -1
  22. package/dist/cli/run.d.ts +6 -0
  23. package/dist/cli/run.js +76 -3
  24. package/dist/events/catalog.d.ts +9 -0
  25. package/dist/events/catalog.js +9 -0
  26. package/dist/index.js +89 -5
  27. package/dist/mcp/client.js +1 -1
  28. package/dist/mcp/registry.d.ts +0 -18
  29. package/dist/mcp/registry.js +49 -2
  30. package/dist/policy/engine.d.ts +16 -0
  31. package/dist/policy/engine.js +74 -1
  32. package/dist/policy/path-guard.d.ts +24 -0
  33. package/dist/policy/path-guard.js +46 -0
  34. package/dist/providers/model-info.d.ts +6 -0
  35. package/dist/providers/model-info.js +8 -0
  36. package/dist/tools/fs/apply-patch.js +6 -1
  37. package/dist/tools/fs/edit-file.js +4 -1
  38. package/dist/tools/fs/multi-edit.js +4 -1
  39. package/dist/tools/fs/write-file.js +16 -6
  40. package/dist/tools/plan/todo-write.js +1 -1
  41. package/dist/tools/shell/shell-exec.d.ts +28 -0
  42. package/dist/tools/shell/shell-exec.js +87 -1
  43. package/dist/trace/writer.d.ts +7 -0
  44. package/dist/trace/writer.js +7 -0
  45. package/dist/verification/classify.js +4 -3
  46. package/dist/verification/engine.d.ts +8 -0
  47. package/dist/verification/engine.js +25 -0
  48. package/dist/verification/registry.js +16 -5
  49. package/dist/verification/scoped.js +36 -5
  50. package/package.json +1 -1
@@ -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>;
@@ -0,0 +1,181 @@
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 { spawn } from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ import { z } from 'zod';
21
+ export const HookSchema = z.object({
22
+ name: z.string().min(1),
23
+ event: z.enum(['preToolUse', 'postToolUse']),
24
+ command: z.string().min(1),
25
+ timeoutMs: z.number().int().positive().optional(),
26
+ });
27
+ const HooksFileSchema = z.object({
28
+ hooks: z.array(HookSchema),
29
+ });
30
+ export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
31
+ const MAX_HOOK_OUTPUT_CHARS = 4000;
32
+ /** Paths already warned about (invalid JSON/schema) — warn once per path. */
33
+ const warnedPaths = new Set();
34
+ function warnOnce(filePath, detail) {
35
+ if (warnedPaths.has(filePath))
36
+ return;
37
+ warnedPaths.add(filePath);
38
+ try {
39
+ process.stderr.write(`klyro: hooks: ignoring invalid file ${filePath}: ${detail}\n`);
40
+ }
41
+ catch { /* ignore */ }
42
+ }
43
+ /** Read + validate one hooks file. Missing → []. Invalid → [] + warn once. */
44
+ function readHooksFile(filePath) {
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(filePath, 'utf-8');
48
+ }
49
+ catch {
50
+ return []; // missing/unreadable — silent, zero-cost fast path stays silent
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ }
56
+ catch (err) {
57
+ warnOnce(filePath, err instanceof Error ? err.message : String(err));
58
+ return [];
59
+ }
60
+ const checked = HooksFileSchema.safeParse(parsed);
61
+ if (!checked.success) {
62
+ const first = checked.error.issues[0];
63
+ warnOnce(filePath, first ? `${first.path.join('.') || 'hooks'}: ${first.message}` : 'schema mismatch');
64
+ return [];
65
+ }
66
+ return checked.data.hooks;
67
+ }
68
+ function globalHooksPath() {
69
+ try {
70
+ return path.join(os.homedir() || process.cwd(), '.klyro', 'hooks.json');
71
+ }
72
+ catch {
73
+ return path.join(process.cwd(), '.klyro', 'hooks.json');
74
+ }
75
+ }
76
+ /**
77
+ * Load hooks for a run. Global first, then project — a project hook with
78
+ * the same `name` replaces the global one. Never throws.
79
+ */
80
+ export function loadHooks(cwd) {
81
+ let out = [];
82
+ try {
83
+ const byName = new Map();
84
+ for (const h of readHooksFile(globalHooksPath()))
85
+ byName.set(h.name, h);
86
+ const projectFile = path.join(cwd, '.klyro', 'hooks.json');
87
+ for (const h of readHooksFile(projectFile))
88
+ byName.set(h.name, h);
89
+ out = [...byName.values()];
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ return out;
95
+ }
96
+ /**
97
+ * Minimal filtered env for hook children (mirrors the shell_exec policy:
98
+ * secrets stripped, only safe prefixes pass). Kept local so the hooks
99
+ * engine never depends on the tool layer at import time.
100
+ */
101
+ function hookEnv() {
102
+ const out = {};
103
+ for (const [k, v] of Object.entries(process.env)) {
104
+ if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
105
+ continue;
106
+ if (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
107
+ continue;
108
+ if (k.startsWith('PATH') || k.startsWith('HOME') || k.startsWith('USER') ||
109
+ k.startsWith('SHELL') || k.startsWith('TERM') || k.startsWith('LANG') ||
110
+ k.startsWith('NODE_') || k.startsWith('NPM_') || k.startsWith('PNPM_') ||
111
+ k.startsWith('YARN_') || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP' ||
112
+ k === 'SystemRoot' || k === 'windir' || k === 'PATHEXT' || k === 'OS') {
113
+ out[k] = v;
114
+ }
115
+ }
116
+ out.PATH = process.env.PATH;
117
+ return out;
118
+ }
119
+ /**
120
+ * Run one hook. Resolves (never rejects) with the exit code + sliced
121
+ * output. `ok` is true only when the process exited 0.
122
+ */
123
+ export function runHook(hook, ctx) {
124
+ const timeoutMs = hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
125
+ return new Promise((resolve) => {
126
+ let env;
127
+ try {
128
+ // Filtered env (same policy as shell_exec): secrets stripped, only
129
+ // safe prefixes pass — plus the hook's own inspection vars below.
130
+ env = hookEnv();
131
+ }
132
+ catch {
133
+ env = { PATH: process.env.PATH };
134
+ }
135
+ env.KLYRO_TOOL_NAME = ctx.toolName;
136
+ try {
137
+ env.KLYRO_TOOL_INPUT_JSON = JSON.stringify(ctx.input ?? {}).slice(0, 16_000);
138
+ }
139
+ catch {
140
+ env.KLYRO_TOOL_INPUT_JSON = '{}';
141
+ }
142
+ let child;
143
+ try {
144
+ child = spawn(hook.command, {
145
+ cwd: process.cwd(),
146
+ shell: true,
147
+ windowsHide: true,
148
+ timeout: timeoutMs,
149
+ env,
150
+ });
151
+ }
152
+ catch (err) {
153
+ resolve({ ok: false, exitCode: -1, stdout: '', stderr: String(err instanceof Error ? err.message : err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
154
+ return;
155
+ }
156
+ let stdout = '';
157
+ let stderr = '';
158
+ child.stdout?.on('data', (d) => {
159
+ stdout += d.toString();
160
+ if (stdout.length > MAX_HOOK_OUTPUT_CHARS * 2)
161
+ stdout = stdout.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
162
+ });
163
+ child.stderr?.on('data', (d) => {
164
+ stderr += d.toString();
165
+ if (stderr.length > MAX_HOOK_OUTPUT_CHARS * 2)
166
+ stderr = stderr.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
167
+ });
168
+ child.on('error', (err) => {
169
+ resolve({ ok: false, exitCode: -1, stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS), stderr: String(err.message ?? err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
170
+ });
171
+ child.on('close', (code) => {
172
+ const exitCode = typeof code === 'number' ? code : -1;
173
+ resolve({
174
+ ok: exitCode === 0,
175
+ exitCode,
176
+ stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS),
177
+ stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
178
+ });
179
+ });
180
+ });
181
+ }
package/dist/cli/repl.js CHANGED
@@ -185,6 +185,27 @@ export async function startRepl(opts = {}) {
185
185
  },
186
186
  });
187
187
  let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
188
+ // L15 failover — initial adapter only: extra `providers.failover` entries
189
+ // become fallback adapters for agent runs in this session. Live
190
+ // /provider switches rebuild a single adapter (buildAdapter) and bypass
191
+ // the chain — documented, not a bug.
192
+ let replFailoverAdapters = [];
193
+ try {
194
+ const { resolveProviderChain } = await import('./config.js');
195
+ const chain = await resolveProviderChain(cwd);
196
+ for (const entry of chain.slice(1)) {
197
+ if (!entry.apiKey)
198
+ continue;
199
+ try {
200
+ replFailoverAdapters.push(buildAdapter(entry.provider, entry.baseURL ?? currentBaseUrl, entry.apiKey));
201
+ }
202
+ catch { /* skip unbuildable entries */ }
203
+ }
204
+ if (replFailoverAdapters.length > 0) {
205
+ process.stderr.write(`klyro: failover chain: ${replFailoverAdapters.map((b) => b.id).join(' → ')}\n`);
206
+ }
207
+ }
208
+ catch { /* best-effort — single-provider session proceeds */ }
188
209
  const ctxBlock = await buildLevel6Context({ cwd });
189
210
  let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
190
211
  // 4.4 KLYRO.md hierarchy (mutable — /reload refreshes). Content is gated
@@ -646,6 +667,19 @@ export async function startRepl(opts = {}) {
646
667
  if (isMounted && directHooks)
647
668
  directHooks.clearTranscript();
648
669
  }
670
+ // Update nudge (best-effort, cached 24h, silent fail): a single stderr
671
+ // line when a newer version exists. No prompt, no blocking — the check
672
+ // itself is fire-and-forget.
673
+ void (async () => {
674
+ try {
675
+ const { checkForUpdate } = await import('./update.js');
676
+ const cur = readVersion();
677
+ const latest = await checkForUpdate(cur);
678
+ if (latest)
679
+ process.stderr.write(`Update available: ${cur} → ${latest} (klyro update)\n`);
680
+ }
681
+ catch { /* silent */ }
682
+ })();
649
683
  app = render(React.createElement(App, {
650
684
  initialModel: model,
651
685
  maxSteps: currentMaxSteps,
@@ -897,8 +931,14 @@ export async function startRepl(opts = {}) {
897
931
  else if (ev.kind === 'aborted') {
898
932
  queuedStatus({ status: 'aborted' });
899
933
  }
934
+ else if (ev.kind === 'model_override') {
935
+ queuedAppend({ id: `movr-${Date.now()}`, kind: 'text', text: `[model] override: requested ${ev.requested} → effective ${ev.effective}`, role: 'assistant' });
936
+ }
900
937
  },
901
- }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
938
+ }, {
939
+ adapter, registry, policy, approval, systemPrompt: systemPromptFn,
940
+ ...(replFailoverAdapters.length > 0 ? { failoverAdapters: replFailoverAdapters } : {}),
941
+ });
902
942
  if (result.finalText)
903
943
  lastAssistantText = result.finalText;
904
944
  if (result.verification) {
package/dist/cli/run.d.ts CHANGED
@@ -70,6 +70,12 @@ export interface RunCliOptions {
70
70
  agent?: string;
71
71
  maxDepth?: number;
72
72
  }
73
+ /**
74
+ * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
75
+ * within 1500ms of the first forces `process.exit(130)`. The live handler
76
+ * below owns the timestamp closure; tests exercise only this predicate.
77
+ */
78
+ export declare function shouldForceExit(lastSigintAt: number | undefined, now: number): boolean;
73
79
  export declare function runOnce(opts: RunCliOptions): Promise<number>;
74
80
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
75
81
  export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
package/dist/cli/run.js CHANGED
@@ -16,6 +16,7 @@ import { run } from '../agent/runtime.js';
16
16
  import { builtinRegistry } from '../tools/registry.js';
17
17
  import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
18
18
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
19
+ import { redact } from '../policy/secret-redactor.js';
19
20
  import { buildLevel6Context } from '../context/level6.js';
20
21
  import { memoryBlock } from '../context/memory.js';
21
22
  import { resolveSessionId } from '../persistence/session.js';
@@ -24,6 +25,14 @@ function readEnv(name, fallback) {
24
25
  const v = process.env[name];
25
26
  return v && v.length > 0 ? v : fallback;
26
27
  }
28
+ /**
29
+ * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
30
+ * within 1500ms of the first forces `process.exit(130)`. The live handler
31
+ * below owns the timestamp closure; tests exercise only this predicate.
32
+ */
33
+ export function shouldForceExit(lastSigintAt, now) {
34
+ return lastSigintAt !== undefined && now - lastSigintAt < 1500;
35
+ }
27
36
  export async function runOnce(opts) {
28
37
  // P0.5 — load <cwd>/.env first so KLYRO_* vars resolve without `export`.
29
38
  // Never throws (missing file is a no-op); explicit env wins (no-clobber).
@@ -88,6 +97,36 @@ export async function runOnce(opts) {
88
97
  adapter = retryingAdapter(httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: opts.timeoutMs ?? 60_000 }), { onRetry: onRetryEmit });
89
98
  }
90
99
  }
100
+ // L15 provider failover: extra chain entries (after the primary) become
101
+ // fallback adapters for the runtime. Custom injected adapters (tests)
102
+ // skip chain wiring. Failures resolving the chain never block the run.
103
+ let failoverAdapters;
104
+ if (!opts.adapter) {
105
+ try {
106
+ const { resolveProviderChain } = await import('./config.js');
107
+ const chain = await resolveProviderChain(opts.cwd);
108
+ const fallbacks = chain.slice(1);
109
+ if (fallbacks.length > 0) {
110
+ const built = [];
111
+ for (const entry of fallbacks) {
112
+ if (!entry.apiKey)
113
+ continue;
114
+ const base = entry.provider === 'anthropic'
115
+ ? anthropicAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
116
+ : entry.baseURL
117
+ ? httpChatAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
118
+ : null;
119
+ if (base)
120
+ built.push(retryingAdapter(base, { onRetry: onRetryEmit }));
121
+ }
122
+ if (built.length > 0) {
123
+ failoverAdapters = built;
124
+ stderr.write(`klyro: failover chain: ${built.map((b) => b.id).join(' → ')}\n`);
125
+ }
126
+ }
127
+ }
128
+ catch { /* best-effort — single-provider run proceeds */ }
129
+ }
91
130
  const registry = builtinRegistry();
92
131
  const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
93
132
  // Persisted "always allow" patterns apply to one-shot runs too.
@@ -149,6 +188,16 @@ export async function runOnce(opts) {
149
188
  return 2;
150
189
  }
151
190
  sessionId = full;
191
+ // M22 resume-lock warning (read-only probe — takeover proceeds anyway).
192
+ try {
193
+ const { readSessionLock } = await import('../persistence/store.js');
194
+ const { getDefaultSessionsDir } = await import('../persistence/session.js');
195
+ const lock = readSessionLock(opts.sessionsDir ?? getDefaultSessionsDir(), sessionId);
196
+ if (lock.held && lock.alive) {
197
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} is locked by live pid ${lock.pid ?? '?'} — taking over (proceeding anyway)\n`);
198
+ }
199
+ }
200
+ catch { /* probe is best-effort; never block resume */ }
152
201
  const msgs = await store.loadMessages(sessionId);
153
202
  // Convert StoredMessage to Message
154
203
  initialTranscript = msgs.map((m) => ({ role: m.role, content: m.content }));
@@ -176,7 +225,17 @@ export async function runOnce(opts) {
176
225
  // persistence is disabled or the session was never created).
177
226
  sessionIdForRetry.id = sessionId ?? 'ephemeral';
178
227
  const ac = new AbortController();
228
+ // Double-Ctrl+C: first press aborts the run; a second press within
229
+ // 1500ms forces process.exit(130) (shouldForceExit owns the predicate).
230
+ let lastSigintAt;
179
231
  const onSigint = () => {
232
+ const now = Date.now();
233
+ if (shouldForceExit(lastSigintAt, now)) {
234
+ stderr.write('\nklyro: SIGINT twice — forcing exit\n');
235
+ process.exit(130);
236
+ return;
237
+ }
238
+ lastSigintAt = now;
180
239
  stderr.write('\nklyro: SIGINT — aborting\n');
181
240
  ac.abort();
182
241
  };
@@ -298,8 +357,20 @@ export async function runOnce(opts) {
298
357
  if (output === 'human')
299
358
  stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
300
359
  }
360
+ else if (ev.kind === 'provider_failover') {
361
+ stderr.write(`[failover] ${ev.from} → ${ev.to}: ${ev.reason.slice(0, 200)}\n`);
362
+ }
363
+ else if (ev.kind === 'budget_warning') {
364
+ stderr.write(`[budget] ${(ev.ratio * 100).toFixed(0)}% of max cost used (threshold ${(ev.threshold * 100).toFixed(0)}%)\n`);
365
+ }
366
+ else if (ev.kind === 'model_override') {
367
+ stderr.write(`[model] override: requested ${ev.requested} → effective ${ev.effective}\n`);
368
+ }
301
369
  },
302
- }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
370
+ }, {
371
+ adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt,
372
+ ...(failoverAdapters ? { failoverAdapters } : {}),
373
+ });
303
374
  }
304
375
  finally {
305
376
  doneSigint();
@@ -404,8 +475,10 @@ async function dryRunReport(opts) {
404
475
  maxSteps: opts.maxSteps,
405
476
  maxTokens: opts.maxTokens,
406
477
  temperature: opts.temperature,
407
- systemPrompt,
408
- task: opts.task,
478
+ // Secrets must never leak into a printable report: redact both the
479
+ // assembled system prompt and the task before printing.
480
+ systemPrompt: redact(systemPrompt),
481
+ task: redact(opts.task),
409
482
  toolCount: registry.list().length,
410
483
  toolNames: registry.list().map((t) => t.name),
411
484
  policyRules: rules.map((r) => r.name),
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * 3.1 — KlyroEvent catalog (Appendix C)
3
3
  * Every observable action in the harness is a typed event.
4
+ *
5
+ * Reserved-future members (declared but intentionally unproduced — no
6
+ * emitter exists yet, so do not treat their absence as a bug):
7
+ * `subtask.tool_call`, `subtask.tool_result`.
8
+ * `subtask.progress` IS emitted (throttled: max 1 per child tool call,
9
+ * in-process children only; process-isolated children don't run the emitter).
10
+ * The orchestrator otherwise emits started/completed/failed/cancelled/
11
+ * timed_out/merged only; grep for emitters before assuming one of the
12
+ * reserved members fires.
4
13
  */
5
14
  export type KlyroEvent = {
6
15
  type: 'session.start';
@@ -1,5 +1,14 @@
1
1
  /**
2
2
  * 3.1 — KlyroEvent catalog (Appendix C)
3
3
  * Every observable action in the harness is a typed event.
4
+ *
5
+ * Reserved-future members (declared but intentionally unproduced — no
6
+ * emitter exists yet, so do not treat their absence as a bug):
7
+ * `subtask.tool_call`, `subtask.tool_result`.
8
+ * `subtask.progress` IS emitted (throttled: max 1 per child tool call,
9
+ * in-process children only; process-isolated children don't run the emitter).
10
+ * The orchestrator otherwise emits started/completed/failed/cancelled/
11
+ * timed_out/merged only; grep for emitters before assuming one of the
12
+ * reserved members fires.
4
13
  */
5
14
  export {};
package/dist/index.js CHANGED
@@ -349,7 +349,7 @@ async function main() {
349
349
  });
350
350
  program
351
351
  .command('chat [prompt]')
352
- .description('Legacy streamed chat. Without a prompt, start an interactive REPL.')
352
+ .description('Legacy streamed chat. Without a prompt, start an interactive REPL. (deprecated: history truncation is approximate; prefer `klyro tui`)')
353
353
  .option('-s, --system <text>', 'System message', 'You are a helpful assistant.')
354
354
  .option('-m, --model <id>', 'Override the model (default: env KLYRO_MODEL)')
355
355
  .option('-t, --timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('-t/--timeout', v))
@@ -613,11 +613,78 @@ async function main() {
613
613
  }
614
614
  });
615
615
  mcp.command('add <name> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
616
+ mcp.command('probe <name>').description('Connect to an MCP server (15s timeout), list its tools, print count+names').action(async (name) => {
617
+ const { loadMcpServers } = await import('./mcp/config.js');
618
+ const cfg = loadMcpServers(process.cwd());
619
+ const spec = cfg.servers[name];
620
+ if (!spec) {
621
+ process.stderr.write(`klyro: mcp server not found: ${name}\n`);
622
+ process.exit(2);
623
+ }
624
+ const { McpClient } = await import('./mcp/client.js');
625
+ const client = new McpClient(name, spec);
626
+ // 15s overall probe budget (connect has its own internal timeout too).
627
+ const timer = setTimeout(() => {
628
+ process.stderr.write(`klyro: mcp probe ${name} timed out after 15s\n`);
629
+ process.exit(2);
630
+ }, 15_000);
631
+ try {
632
+ await client.connect();
633
+ const tools = await client.listTools();
634
+ const names = tools.map((t) => t.name);
635
+ process.stdout.write(`${name}: ${tools.length} tool(s)${names.length > 0 ? `: ${names.join(', ')}` : ''}\n`);
636
+ process.exit(0);
637
+ }
638
+ catch (err) {
639
+ process.stderr.write(`klyro: mcp probe ${name} failed: ${err instanceof Error ? err.message : String(err)}\n`);
640
+ process.exit(2);
641
+ }
642
+ finally {
643
+ clearTimeout(timer);
644
+ try {
645
+ await client.close();
646
+ }
647
+ catch { /* ignore */ }
648
+ }
649
+ });
616
650
  mcp.command('serve').description('Serve as MCP server').action(async () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
617
- // 10.2 — Hooks / agents
618
- program.command('hooks').description('List hooks (10.2)').action(async () => { process.stdout.write('hooks: SessionStart UserPromptSubmit PreToolUse PostToolUse (stub)\n'); });
619
- program.command('agents [name]').description('List agents (10.2) or show one agent definition').action(async (name) => {
651
+ // 10.2 — Hooks: list configured preToolUse/postToolUse hooks.
652
+ program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
653
+ if (cmd && cmd !== 'list') {
654
+ process.stderr.write(`klyro: unknown hooks command: ${cmd} (usage: klyro hooks [list])\n`);
655
+ process.exit(2);
656
+ }
657
+ const { loadHooks } = await import('./cli/hooks.js');
658
+ const hooks = loadHooks(process.cwd());
659
+ if (hooks.length === 0) {
660
+ process.stdout.write('hooks: none configured (.klyro/hooks.json, ~/.klyro/hooks.json)\n');
661
+ return;
662
+ }
663
+ for (const h of hooks)
664
+ process.stdout.write(`${h.name} ${h.event} ${h.command}\n`);
665
+ });
666
+ program.command('agents [name] [extra...]').description('List agents (10.2), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
620
667
  const { BUILTIN_AGENTS } = await import('./agent/orchestrator.js');
668
+ // `klyro agents run <name> <task...>`: one-shot run under a named agent.
669
+ if (name === 'run') {
670
+ const [agentName, ...taskParts] = extra ?? [];
671
+ if (!agentName || !BUILTIN_AGENTS.some((a) => a.id === agentName)) {
672
+ process.stderr.write(`klyro: unknown agent: ${agentName ?? '(missing)'} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
673
+ process.exit(2);
674
+ }
675
+ const task = (taskParts ?? []).join(' ').trim();
676
+ if (!task) {
677
+ process.stderr.write('klyro: agents run requires a task (usage: klyro agents run <name> <task...>)\n');
678
+ process.exit(2);
679
+ }
680
+ const model = process.env.KLYRO_MODEL;
681
+ if (!model) {
682
+ process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model via klyro run)\n');
683
+ process.exit(2);
684
+ }
685
+ const code = await runOnce({ task, cwd: process.cwd(), model, agent: agentName });
686
+ process.exit(code);
687
+ }
621
688
  if (!name) {
622
689
  for (const a of BUILTIN_AGENTS)
623
690
  process.stdout.write(`${a.id} — ${a.description}\n`);
@@ -642,7 +709,24 @@ async function main() {
642
709
  process.stdout.write(lines.join('\n') + '\n');
643
710
  });
644
711
  // 10.3 — Web / git workflows / SDK
645
- program.command('commit').description('Create commit (10.3)').action(async () => { process.stdout.write('commit — conventional message (stub, use /commit)\n'); });
712
+ program
713
+ .command('commit')
714
+ .description('Commit staged changes with a conventional message (verification hooks always run)')
715
+ .option('--dry-run', 'Print the message + files without committing')
716
+ .option('--message <msg>', 'Summary for the conventional message (default: update <n> files)')
717
+ .option('--force-secret', 'Commit even if the staged diff looks like it contains a secret')
718
+ .action(async (opts) => {
719
+ const { runCommit } = await import('./cli/commit.js');
720
+ const globalYes = program.opts().yes ?? process.env.KLYRO_YES === '1';
721
+ const code = await runCommit({
722
+ cwd: process.cwd(),
723
+ yes: !!globalYes,
724
+ dryRun: !!opts.dryRun,
725
+ ...(opts.message !== undefined ? { message: opts.message } : {}),
726
+ forceSecret: !!opts.forceSecret,
727
+ });
728
+ process.exit(code);
729
+ });
646
730
  program.command('audit [session]').description('Verify audit chain (13.4)').action(async (session) => {
647
731
  if (!session) {
648
732
  process.stderr.write('klyro: audit requires a session id (usage: klyro audit <session>)\n');
@@ -90,7 +90,7 @@ export class McpClient {
90
90
  await this.request('initialize', {
91
91
  protocolVersion: PROTOCOL_VERSION,
92
92
  capabilities: {},
93
- clientInfo: { name: 'klyro', version: '1.0.1' },
93
+ clientInfo: { name: 'klyro', version: '1.0.2' },
94
94
  }, timeoutMs);
95
95
  this.notify('notifications/initialized', {});
96
96
  this.connected = true;
@@ -1,21 +1,3 @@
1
- /**
2
- * P1 — MCP tool registration (r-11-17.md §3.1).
3
- *
4
- * Exposes MCP server tools through the {@link ToolRegistry} under
5
- * `mcp__<server>__<tool>` names. Security properties (non-negotiable):
6
- *
7
- * 1. Deny-by-default: `evaluateMcpPolicy` runs BEFORE any client I/O; a
8
- * denied tool returns POLICY_DENIED without touching the subprocess.
9
- * 2. Redact-before-transcript: every success value AND error message passes
10
- * through `redact()` before it can reach the model or trace.
11
- * 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
12
- * runtime loop prompts before executing.
13
- *
14
- * Permission class: no code in src consumes `Tool.permission` (it is
15
- * write-only metadata today), so we pick the most restrictive class,
16
- * 'admin' — the same class as `spawn_agent`, since MCP tools execute
17
- * arbitrary external side effects (read/write/network) outside our control.
18
- */
19
1
  import { type McpClientLike } from './client.js';
20
2
  import { type McpServerSpec } from './config.js';
21
3
  import type { PolicyEngine } from '../policy/engine.js';