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
package/dist/cli/repl.js CHANGED
@@ -15,6 +15,7 @@ import { anthropicAdapter } from '../agent/anthropic-adapter.js';
15
15
  import { retryingAdapter } from '../agent/retry.js';
16
16
  import { globalBus } from '../events/bus.js';
17
17
  import { run } from '../agent/runtime.js';
18
+ import { resolveSystemPrompt } from '../agent/runtime.js';
18
19
  import { builtinRegistry } from '../tools/registry.js';
19
20
  import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
20
21
  import { buildLevel6Context } from '../context/level6.js';
@@ -28,7 +29,10 @@ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../
28
29
  import { inferProviderFromBaseURL } from '../agent/registry.js';
29
30
  import { getDefaultSessionStore } from '../persistence/session.js';
30
31
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
32
+ import { memoryBlock } from '../context/memory.js';
31
33
  import { estimateCost } from '../providers/model-info.js';
34
+ import { ContextTrust } from '../context/trust.js';
35
+ import { redact } from '../policy/secret-redactor.js';
32
36
  export async function startRepl(opts = {}) {
33
37
  // P0.5 — load <cwd>/.env first so provider resolution below sees KLYRO_*
34
38
  // vars without `export`. Never throws; explicit env wins (no-clobber).
@@ -90,21 +94,66 @@ export async function startRepl(opts = {}) {
90
94
  catch {
91
95
  /* ignore — engine defaults stand */
92
96
  }
93
- // Best-effort MCP tools: never fatal, never prompts. src/mcp/registry.ts
94
- // lands from a sibling agent — the lazy import keeps runtime + builds green
95
- // until then (import failure is caught below). Servers live for the process
96
- // lifetime, so no close wiring here (unlike runOnce's finally).
97
- try {
98
- // @ts-ignore — sibling-owned module may not exist yet
99
- const { loadAndRegisterMcp } = await import('../mcp/registry.js');
100
- const mcp = await loadAndRegisterMcp({ cwd, registry, policy });
101
- for (const e of mcp.errors)
102
- process.stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
103
- if (mcp.registered.length > 0) {
104
- process.stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
97
+ // Best-effort MCP tools: never fatal, never prompts. Servers live for the
98
+ // process lifetime, so no close wiring here (unlike runOnce's finally).
99
+ // NOTE: the actual loadAndRegisterMcp call lives below (after the approval
100
+ // object exists) so project-server approval can route through it.
101
+ // Local inline sanitizer for the approval prompt (duplicates the shared
102
+ // sanitizeForPrompt in sibling-owned policy/approval.ts — intentionally
103
+ // not imported; kept local to avoid coupling the REPL to that module).
104
+ function sanitizeForPrompt(s) {
105
+ return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g, '').slice(0, 120);
106
+ }
107
+ async function loadMcpTools() {
108
+ try {
109
+ const { loadAndRegisterMcp } = await import('../mcp/registry.js');
110
+ const mcp = await loadAndRegisterMcp({
111
+ cwd,
112
+ registry,
113
+ policy,
114
+ // CONTRACT (sibling D): project-server approval hook. Registries that
115
+ // predate the contract ignore the unknown opt (checked via try/catch
116
+ // below with a skip-with-warning fallback).
117
+ approveProjectServer: async (info) => {
118
+ // Show the disabled state alongside source before asking, so a
119
+ // disabled server's approval can't be mistaken for activation.
120
+ let disabledNote = '';
121
+ try {
122
+ const { loadMcpServers } = await import('../mcp/config.js');
123
+ const cfg = loadMcpServers(cwd);
124
+ const spec = cfg.servers[info.name];
125
+ if (spec?.disabled)
126
+ disabledNote = ' (disabled in config)';
127
+ }
128
+ catch { /* ignore — prompt without the note */ }
129
+ const safeName = sanitizeForPrompt(info.name);
130
+ const safeSource = sanitizeForPrompt(info.source);
131
+ try {
132
+ const choice = await approval.ask({
133
+ toolName: 'mcp_project_server',
134
+ reason: `MCP project server "${safeName}"${disabledNote} from ${safeSource} — approve to register its tools`,
135
+ summary: safeName,
136
+ });
137
+ return choice === 'allow' || choice === 'always' || choice === 'always-persist';
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ },
143
+ });
144
+ for (const e of mcp.errors)
145
+ process.stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
146
+ if (mcp.registered.length > 0) {
147
+ process.stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
148
+ }
149
+ }
150
+ catch {
151
+ try {
152
+ process.stderr.write('klyro: mcp project-server approval skipped (registry unavailable)\n');
153
+ }
154
+ catch { /* ignore */ }
105
155
  }
106
156
  }
107
- catch { /* ignore — MCP is optional */ }
108
157
  const providerKind = inferProviderFromBaseURL(baseUrl);
109
158
  // Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
110
159
  // via proxy — don't try anthropic adapter with empty key (would 401).
@@ -138,14 +187,16 @@ export async function startRepl(opts = {}) {
138
187
  let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
139
188
  const ctxBlock = await buildLevel6Context({ cwd });
140
189
  let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
141
- // 4.4 KLYRO.md hierarchy (mutable — /reload refreshes)
142
- const klyroMd = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
143
- let klyroBlock = klyroMd ? `\n\n<KLYRO.md>\n${klyroMd.slice(0, 4000)}\n</KLYRO.md>` : '';
144
- // 2.3 layered system prompt
190
+ // 4.4 KLYRO.md hierarchy (mutable — /reload refreshes). Content is gated
191
+ // through the context trust store (see loadKlyroBlockTrust below) — the
192
+ // initial value stays empty until the gated load runs after approval setup.
193
+ let klyroBlock = '';
194
+ // 2.3 layered system prompt (split shape: stable base + volatile telemetry suffix)
145
195
  const systemPromptFn = (_ctx) => {
146
- const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock });
147
- const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
148
- return base + t;
196
+ // 8.4 — inject session memory so memory_write actually takes effect.
197
+ const memBlock = memoryBlock(cwd);
198
+ const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock + memBlock });
199
+ return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
149
200
  };
150
201
  let ac = new AbortController();
151
202
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
@@ -176,6 +227,45 @@ export async function startRepl(opts = {}) {
176
227
  });
177
228
  },
178
229
  });
230
+ // Context-file trust gate (context/trust.js): KLYRO.md / AGENTS.md content
231
+ // only enters the prompt after the trust store approves it. Untrusted files
232
+ // prompt via the repl approval object (allow/always/always-persist → true,
233
+ // deny → false); every decision emits context.trust_prompt on globalBus.
234
+ const trustStore = new ContextTrust();
235
+ async function loadKlyroBlockTrust() {
236
+ try {
237
+ const { loadTrustedKlyroMd } = await import('../context/trust.js');
238
+ const text = await loadTrustedKlyroMd(cwd, {
239
+ trust: trustStore,
240
+ approve: async (file, reason) => {
241
+ try {
242
+ const choice = await approval.ask({
243
+ toolName: 'context_trust',
244
+ reason: `untrusted context file (${reason}): ${file.path}`,
245
+ summary: file.path,
246
+ });
247
+ return choice === 'allow' || choice === 'always' || choice === 'always-persist';
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ },
253
+ sessionId: typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral',
254
+ emit: (ev) => globalBus.emit(ev),
255
+ });
256
+ return text ? `\n\n<KLYRO.md>\n${text.slice(0, 4000)}\n</KLYRO.md>` : '';
257
+ }
258
+ catch {
259
+ return '';
260
+ }
261
+ }
262
+ // MCP servers need the approval object above — load them now.
263
+ await loadMcpTools();
264
+ // Non-TUI runs can prompt on stdin immediately; the TUI defers the gated
265
+ // load to onMounted (the modal can't render before mount — see below).
266
+ if (!useTui) {
267
+ klyroBlock = await loadKlyroBlockTrust().catch(() => '');
268
+ }
179
269
  let inflight = null;
180
270
  let lastStatus = null;
181
271
  const pendingQueue = [];
@@ -479,14 +569,41 @@ export async function startRepl(opts = {}) {
479
569
  return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
480
570
  }
481
571
  async function execShell(command, timeoutMs = 120_000) {
482
- const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
483
- if (!r.ok)
484
- throw new Error(r.error.message ?? 'shell failed');
485
- return r.value;
572
+ // Internal shell executions bypass the agent runtime's event path, so
573
+ // audit them here: a redacted tool.call before, the matching tool.result
574
+ // after. One helper covers every slash handler (/commit, /push, …).
575
+ const callId = `internal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
576
+ const sessionId = typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral';
577
+ const startedAt = Date.now();
578
+ globalBus.emit({
579
+ type: 'tool.call', ts: startedAt, sessionId, callId,
580
+ name: 'shell_exec', input: { internal: true, command: redact(command).slice(0, 2000) },
581
+ });
582
+ try {
583
+ const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
584
+ if (!r.ok)
585
+ throw new Error(r.error.message ?? 'shell failed');
586
+ const v = r.value;
587
+ globalBus.emit({
588
+ type: 'tool.result', ts: Date.now(), sessionId, callId,
589
+ name: 'shell_exec', output: { internal: true, exitCode: v.exitCode },
590
+ isError: false, latencyMs: Date.now() - startedAt,
591
+ });
592
+ return v;
593
+ }
594
+ catch (err) {
595
+ globalBus.emit({
596
+ type: 'tool.result', ts: Date.now(), sessionId, callId,
597
+ name: 'shell_exec', output: { internal: true, error: err instanceof Error ? err.message : String(err) },
598
+ isError: true, latencyMs: Date.now() - startedAt,
599
+ });
600
+ throw err;
601
+ }
486
602
  }
487
603
  /** Read-only LLM answer (no tools) — powers /ask, /explain, /compact. */
488
604
  async function answerReadOnly(question, context) {
489
- const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
605
+ // Legacy chat path (no modes): resolve the split prompt back to one string.
606
+ const sys = resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system + '\n\nAnswer read-only: do not call tools, do not edit files.';
490
607
  const attached = await buildAttachedBlock();
491
608
  const userText = (context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question) + attached;
492
609
  const req = {
@@ -564,6 +681,14 @@ export async function startRepl(opts = {}) {
564
681
  hooks.append(ev.item);
565
682
  }
566
683
  pendingQueue.length = 0;
684
+ // Deferred trust-gated KLYRO.md load: the approval modal can only
685
+ // render once the App is mounted. Prompts sent before this resolves
686
+ // run without the KLYRO.md block (noted in /reload output).
687
+ if (useTui && !klyroBlock) {
688
+ void loadKlyroBlockTrust()
689
+ .then((b) => { klyroBlock = b; })
690
+ .catch(() => undefined);
691
+ }
567
692
  },
568
693
  }), { patchConsole: false });
569
694
  // Install SIGINT handler only after app exists (avoids TDZ) and use once
@@ -618,7 +743,8 @@ export async function startRepl(opts = {}) {
618
743
  try {
619
744
  const simpleReq = {
620
745
  model,
621
- system: systemPromptFn({ cwd, telemetry: '' }),
746
+ // Legacy chat path (no modes): plain string system, untouched by the split.
747
+ system: resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system,
622
748
  messages: [{ role: 'user', content: [{ kind: 'text', text: taskText }] }],
623
749
  tools: [],
624
750
  signal: ac.signal,
@@ -2380,8 +2506,9 @@ export async function startRepl(opts = {}) {
2380
2506
  }
2381
2507
  const ctx = await buildLevel6Context({ cwd });
2382
2508
  ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
2383
- const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
2384
- klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
2509
+ // Trust-gated like the initial load (the modal is mounted here,
2510
+ // so untrusted files prompt instead of being silently dropped).
2511
+ klyroBlock = await loadKlyroBlockTrust().catch(() => '');
2385
2512
  queuedAppend({
2386
2513
  id: `reload-${Date.now()}`,
2387
2514
  kind: 'text',
package/dist/cli/run.d.ts CHANGED
@@ -7,6 +7,8 @@
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 type { VerifyMode } from '../agent/runtime.js';
11
+ import type { SystemPromptFn } from '../agent/runtime.js';
10
12
  import type { Message } from '../agent/message.js';
11
13
  export interface RunCliOptions {
12
14
  task: string;
@@ -15,10 +17,7 @@ export interface RunCliOptions {
15
17
  maxSteps?: number;
16
18
  maxTokens?: number;
17
19
  temperature?: number;
18
- systemPrompt?: (ctx: {
19
- cwd: string;
20
- telemetry?: string;
21
- }) => string;
20
+ systemPrompt?: SystemPromptFn;
22
21
  baseUrl?: string;
23
22
  apiKey?: string;
24
23
  timeoutMs?: number;
@@ -60,6 +59,9 @@ export interface RunCliOptions {
60
59
  maxRepairAttempts?: number;
61
60
  verifyTimeoutMs?: number;
62
61
  requireVerify?: boolean;
62
+ /** Verification mode: strict (default) runs the full repair pipeline;
63
+ * advisory reports once without repairs; off skips verification. */
64
+ verifyMode?: VerifyMode;
63
65
  /** Level 9 — persistence */
64
66
  persist?: boolean;
65
67
  sessionId?: string;
@@ -70,11 +72,5 @@ export interface RunCliOptions {
70
72
  }
71
73
  export declare function runOnce(opts: RunCliOptions): Promise<number>;
72
74
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
73
- export declare function makeRunSystemPrompt(cwd: string, base: (ctx: {
74
- cwd: string;
75
- telemetry?: string;
76
- }) => string): Promise<(ctx: {
77
- cwd: string;
78
- telemetry?: string;
79
- }) => string>;
75
+ export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
80
76
  export declare function loadTranscript(path: string): Message[];
package/dist/cli/run.js CHANGED
@@ -17,6 +17,7 @@ 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
19
  import { buildLevel6Context } from '../context/level6.js';
20
+ import { memoryBlock } from '../context/memory.js';
20
21
  import { resolveSessionId } from '../persistence/session.js';
21
22
  import * as fs from 'node:fs';
22
23
  function readEnv(name, fallback) {
@@ -97,15 +98,29 @@ export async function runOnce(opts) {
97
98
  catch {
98
99
  /* ignore — engine defaults stand */
99
100
  }
100
- // Best-effort MCP tools: never fatal, never prompts. src/mcp/registry.ts
101
- // lands from a sibling agent — the lazy import keeps runtime + builds green
102
- // until then (import failure is caught below). @ts-ignore is used instead
103
- // of @ts-expect-error so it stays inert after the sibling file lands.
101
+ // Best-effort MCP tools: never fatal, never prompts (headless cannot
102
+ // approve). Project-sourced servers auto-connect ONLY when their exact
103
+ // spec hash is already in the McpTrust store (e.g. approved in a prior
104
+ // REPL session); unknown specs are skipped with a warning.
104
105
  let closeMcp;
105
106
  try {
106
- // @ts-ignore — sibling-owned module may not exist yet
107
107
  const { loadAndRegisterMcp } = await import('../mcp/registry.js');
108
- const mcp = await loadAndRegisterMcp({ cwd: opts.cwd, registry, policy });
108
+ const { McpTrust, hashSpec } = await import('../mcp/trust.js');
109
+ const { loadMcpServers } = await import('../mcp/config.js');
110
+ const mcpTrust = new McpTrust();
111
+ const mcpSpecs = loadMcpServers(opts.cwd).servers;
112
+ const mcp = await loadAndRegisterMcp({
113
+ cwd: opts.cwd,
114
+ registry,
115
+ policy,
116
+ approveProjectServer: async ({ name }) => {
117
+ const spec = mcpSpecs[name];
118
+ if (spec && mcpTrust.isTrusted(name, hashSpec(spec)))
119
+ return true;
120
+ stderr.write(`klyro: mcp project server "${name}" not in trust store (skipped — headless cannot prompt)\n`);
121
+ return false;
122
+ },
123
+ });
109
124
  for (const e of mcp.errors)
110
125
  stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
111
126
  if (mcp.registered.length > 0 && output === 'human') {
@@ -179,6 +194,7 @@ export async function runOnce(opts) {
179
194
  maxRepairAttempts: opts.maxRepairAttempts ?? 3,
180
195
  timeoutMs: opts.verifyTimeoutMs,
181
196
  requireVerify: opts.requireVerify,
197
+ ...(opts.verifyMode ? { mode: opts.verifyMode } : {}),
182
198
  };
183
199
  let result;
184
200
  // P1.4 — if --agent is requested, stand up a parent orchestrator so the
@@ -297,6 +313,7 @@ export async function runOnce(opts) {
297
313
  const statusMap = {
298
314
  complete: 'complete',
299
315
  max_steps: 'max_steps',
316
+ limit: 'max_steps',
300
317
  aborted: 'aborted',
301
318
  no_final: 'aborted',
302
319
  verify_failed: 'verify_failed',
@@ -320,7 +337,21 @@ export async function runOnce(opts) {
320
337
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
321
338
  else
322
339
  stderr.write(`klyro: hit max steps (${result.steps}); consider raising --max-steps\n`);
323
- return 3;
340
+ return 7;
341
+ }
342
+ if (result.status === 'limit') {
343
+ if (output === 'json')
344
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps, text: result.finalText }) + '\n');
345
+ else
346
+ stderr.write(`klyro: stopped early: ${result.finalText || result.status} (after ${result.steps} steps)\n`);
347
+ return 7;
348
+ }
349
+ if (result.status === 'stuck') {
350
+ if (output === 'json')
351
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
352
+ else
353
+ stderr.write(`klyro: stuck — repeated the same action with no progress; aborting after ${result.steps} steps\n`);
354
+ return 7;
324
355
  }
325
356
  if (result.status === 'aborted') {
326
357
  return 130;
@@ -328,14 +359,14 @@ export async function runOnce(opts) {
328
359
  if (result.status === 'no_final') {
329
360
  if (output !== 'json')
330
361
  stderr.write('klyro: provider error — no final answer\n');
331
- return 4;
362
+ return 5;
332
363
  }
333
364
  if (result.status === 'verify_failed') {
334
365
  if (output === 'json')
335
366
  stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
336
367
  else
337
368
  stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
338
- return 5;
369
+ return 8;
339
370
  }
340
371
  // 6.5 — --require-verify: if edits were made but verification never passed, exit 8
341
372
  if (opts.requireVerify && result.verification && !result.verification.ok) {
@@ -361,7 +392,9 @@ async function dryRunReport(opts) {
361
392
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
362
393
  // otherwise dry-run shows a different prompt than production runs use.
363
394
  const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
364
- const systemPrompt = systemPromptFn({ cwd: opts.cwd });
395
+ const { resolveSystemPrompt } = await import('../agent/runtime.js');
396
+ const { system, suffix } = resolveSystemPrompt(systemPromptFn, { cwd: opts.cwd });
397
+ const systemPrompt = suffix ? `${system}\n\n${suffix}` : system;
365
398
  const registry = builtinRegistry();
366
399
  const rules = builtinRules();
367
400
  const report = {
@@ -386,35 +419,53 @@ function defaultRunSystemPrompt(_ctx) {
386
419
  'Solve it by calling tools as needed. When done, produce a short final text answer.',
387
420
  'Do not invent file paths. Do not call tools outside the working directory.',
388
421
  ].join(' ');
389
- return _ctx.telemetry ? base + '\n\n' + _ctx.telemetry : base;
422
+ return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
390
423
  }
391
424
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
392
425
  export async function makeRunSystemPrompt(cwd, base) {
393
426
  const ctxBlock = await buildLevel6Context({ cwd });
394
427
  const prefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
428
+ // Session memory: .klyro/memory/session-notes.md injected so memory_write
429
+ // actually takes effect (redacted at write time — see context/memory.ts).
430
+ const memBlock = memoryBlock(cwd);
395
431
  let klyroBlock = '';
396
432
  try {
397
- // P0.3 trust gate: unapproved context files are excluded in headless
398
- // mode (secure default-deny) and each exclusion is bus-visible.
433
+ // P0.3 trust gate with headless approve-if-store-known: approve(file) is
434
+ // trust.isTrusted(file), so REPL-persisted approvals are honored with no
435
+ // prompting; unknown/changed files stay excluded and each exclusion is
436
+ // bus-visible.
399
437
  const { loadKlyroMdFiles } = await import('../context/klyro-md.js');
400
438
  const { ContextTrust } = await import('../context/trust.js');
401
439
  const { globalBus } = await import('../events/bus.js');
440
+ const trust = new ContextTrust();
441
+ // Headless approve-if-store-known (no prompts): approve(file) is
442
+ // trust.isTrusted(file), so REPL-persisted approvals are honored;
443
+ // unknown/changed files stay excluded.
444
+ const approve = (file) => trust.isTrusted(file);
402
445
  const files = await loadKlyroMdFiles(cwd);
403
- const { trusted, untrusted } = new ContextTrust().check(files);
446
+ const { trusted, untrusted } = trust.check(files);
404
447
  for (const { file, reason } of untrusted) {
405
448
  globalBus.emit({ type: 'context.trust_prompt', ts: Date.now(), sessionId: 'ephemeral', path: file.path, reason, trusted: false });
406
449
  }
407
450
  if (untrusted.length > 0) {
408
451
  stderr.write(`klyro: trust gate excluded ${untrusted.length} unapproved context file(s): ${untrusted.map((u) => u.file.path).join(', ')}\n`);
409
452
  }
410
- const kept = trusted.map((f) => `# ${f.path}\n${f.content}`).join('\n\n---\n\n');
453
+ const keptFiles = trusted.filter(approve);
454
+ const kept = keptFiles.map((f) => `# ${f.path}\n${f.content}`).join('\n\n---\n\n');
411
455
  if (kept)
412
456
  klyroBlock = `\n\n<KLYRO.md>\n${kept.slice(0, 4000)}\n</KLYRO.md>`;
413
457
  }
414
458
  catch { /* ignore */ }
415
459
  return (ctx) => {
416
- const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
417
- return base(ctx) + prefix + klyroBlock + t;
460
+ const r = base(ctx);
461
+ if (typeof r === 'string') {
462
+ // Legacy string fn: keep the exact legacy concatenation behavior.
463
+ const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
464
+ return r + prefix + klyroBlock + memBlock + t;
465
+ }
466
+ // Split shape: Level-6/KLYRO.md join the stable prefix; telemetry stays
467
+ // the volatile suffix for cache-friendly adapters.
468
+ return { system: r.system + prefix + klyroBlock + memBlock, ...(r.suffix !== undefined ? { suffix: r.suffix } : {}) };
418
469
  };
419
470
  }
420
471
  export function loadTranscript(path) {
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * klyro update — check registry for newer version, cached 24h.
3
3
  * Env KLYRO_NO_UPDATE_CHECK=1 disables.
4
+ *
5
+ * Integrity: before recommending `npm i`, we verify the tarball's SRI hash
6
+ * (sha512) against the registry's recorded `dist.integrity`. An install is
7
+ * only recommended when the download hash matches, so a tampered CDN or
8
+ * MITM registry response can't push a malicious binary to the operator.
4
9
  */
5
10
  export declare function checkForUpdate(current: string): Promise<string | null>;
6
11
  export declare function runUpdate(): Promise<number>;
@@ -1,15 +1,65 @@
1
1
  /**
2
2
  * klyro update — check registry for newer version, cached 24h.
3
3
  * Env KLYRO_NO_UPDATE_CHECK=1 disables.
4
+ *
5
+ * Integrity: before recommending `npm i`, we verify the tarball's SRI hash
6
+ * (sha512) against the registry's recorded `dist.integrity`. An install is
7
+ * only recommended when the download hash matches, so a tampered CDN or
8
+ * MITM registry response can't push a malicious binary to the operator.
4
9
  */
5
10
  import * as fs from 'node:fs/promises';
6
11
  import * as path from 'node:path';
7
12
  import * as os from 'node:os';
13
+ import { createHash } from 'node:crypto';
8
14
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
15
+ const REGISTRY_BASE = 'https://registry.npmjs.org/klyro';
16
+ const FETCH_TIMEOUT_MS = 3000;
17
+ const TARBALL_TIMEOUT_MS = 10000;
9
18
  function cachePath() {
19
+ // KLYRO_UPDATE_CACHE lets tests isolate the on-disk cache (and power users
20
+ // relocate it); default is the home cache like the rest of Klyro.
21
+ const override = process.env.KLYRO_UPDATE_CACHE;
22
+ if (override)
23
+ return override;
10
24
  const home = os.homedir() || process.cwd();
11
25
  return path.join(home, '.klyro', 'update-cache.json');
12
26
  }
27
+ /** SRI string may carry multiple hashes parsable with `pick`; we accept sha512 or sha256. */
28
+ function parseSRI(integrity) {
29
+ if (!integrity)
30
+ return null;
31
+ for (const part of integrity.split(/\s+/)) {
32
+ const m = /^(sha256|sha512)-([A-Za-z0-9+/=]+)$/.exec(part);
33
+ if (m)
34
+ return { algo: m[1], digest: m[2] };
35
+ }
36
+ return null;
37
+ }
38
+ /** Fetch with a timeout; rejects on non-2xx. */
39
+ async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
40
+ const ctrl = new AbortController();
41
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
42
+ try {
43
+ const res = await fetch(url, { signal: ctrl.signal });
44
+ if (!res.ok)
45
+ throw new Error(`HTTP ${res.status}`);
46
+ return res;
47
+ }
48
+ finally {
49
+ clearTimeout(t);
50
+ }
51
+ }
52
+ /** Download the tarball and confirm its hash equals the registry's SRI digest. */
53
+ async function verifyTarballIntegrity(dist) {
54
+ const sri = parseSRI(dist.integrity);
55
+ const tarball = dist.tarball;
56
+ if (!sri || !tarball)
57
+ return false;
58
+ const res = await fetchWithTimeout(tarball, TARBALL_TIMEOUT_MS);
59
+ const buf = Buffer.from(await res.arrayBuffer());
60
+ const actual = createHash(sri.algo).update(buf).digest('base64');
61
+ return actual === sri.digest;
62
+ }
13
63
  export async function checkForUpdate(current) {
14
64
  if (process.env.KLYRO_NO_UPDATE_CHECK === '1')
15
65
  return null;
@@ -25,18 +75,20 @@ export async function checkForUpdate(current) {
25
75
  // ignore
26
76
  }
27
77
  try {
28
- const ctrl = new AbortController();
29
- const t = setTimeout(() => ctrl.abort(), 3000);
30
- const res = await fetch('https://registry.npmjs.org/klyro/latest', { signal: ctrl.signal });
31
- clearTimeout(t);
32
- if (!res.ok)
33
- return null;
78
+ const res = await fetchWithTimeout(`${REGISTRY_BASE}/latest`);
34
79
  const json = (await res.json());
35
80
  const latest = json.version ?? '';
36
- await fs.mkdir(path.dirname(cache), { recursive: true });
37
- await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
38
- if (latest && latest !== current)
81
+ if (latest && latest !== current) {
82
+ // Verify the tarball's integrity before caching/recommending this version.
83
+ const verRes = await fetchWithTimeout(`${REGISTRY_BASE}/${encodeURIComponent(latest)}`);
84
+ const verJson = (await verRes.json());
85
+ const ok = await verifyTarballIntegrity(verJson.dist ?? {});
86
+ if (!ok)
87
+ return null; // integrity mismatch — treat as no update, never recommend it
88
+ await fs.mkdir(path.dirname(cache), { recursive: true });
89
+ await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
39
90
  return latest;
91
+ }
40
92
  }
41
93
  catch {
42
94
  // network failure — silent
@@ -55,7 +107,7 @@ export async function runUpdate() {
55
107
  const cur = pkg.version ?? '0.0.0';
56
108
  const latest = await checkForUpdate(cur);
57
109
  if (latest) {
58
- process.stdout.write(`Update available: ${cur} → ${latest}\n npm i -g klyro@latest\n`);
110
+ process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
59
111
  }
60
112
  else {
61
113
  process.stdout.write(`klyro ${cur} is latest\n`);
@@ -3,6 +3,8 @@ export interface ImportGraph {
3
3
  edges: Map<string, Set<string>>;
4
4
  mtime: number;
5
5
  }
6
+ /** Invalidate the import-graph cache (tests + callers that mutate the tree). */
7
+ export declare function clearImportGraphCache(): void;
6
8
  export declare function buildImportGraph(cwd: string): Promise<ImportGraph>;
7
9
  export declare function importsOf(cwd: string, file: string): Promise<string[]>;
8
10
  export declare function importersOf(cwd: string, file: string): Promise<string[]>;