@ruvnet/open-claude-code 2.1.0 → 2.1.1-nightly.20261011.202

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.
@@ -58,6 +58,10 @@ export const SETTINGS_SCHEMA = {
58
58
  agentId: null,
59
59
  cronEnabled: true,
60
60
  featureFlags: {},
61
+ // Metaharness self-optimization (opt-in; default OFF — behavior unchanged).
62
+ selfOptimize: false,
63
+ selfOptimizeQualityBar: 0.7,
64
+ selfOptimizeLadder: null,
61
65
  };
62
66
 
63
67
  export async function loadSettings() {
@@ -116,4 +120,6 @@ function applyEnvOverrides(settings) {
116
120
  if (process.env.CLAUDE_CODE_THINKING === '1') settings.alwaysThinkingEnabled = true;
117
121
  if (process.env.CLAUDE_CODE_DISABLE_CRON === '1') settings.cronEnabled = false;
118
122
  if (process.env.CLAUDE_CODE_ENABLE_TASKS === '1') settings.enableTeams = true;
123
+ if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '1') settings.selfOptimize = true;
124
+ if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '0') settings.selfOptimize = false;
119
125
  }
@@ -5,9 +5,12 @@
5
5
  import { streamResponse, accumulateStream } from './streaming.mjs';
6
6
  import { ContextManager } from './context-manager.mjs';
7
7
  import { buildSystemPrompt } from './system-prompt.mjs';
8
- import fs from 'fs';
9
- import path from 'path';
10
- export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
8
+ import { readApiKey } from './providers.mjs';
9
+
10
+ /** Maximum number of consecutive tool-use continuation turns before aborting. */
11
+ const MAX_TOOL_RECURSION_DEPTH = 50;
12
+
13
+ export function createAgentLoop({ model, tools, permissions, settings, hooks, cascade = null }) {
11
14
  const contextManager = new ContextManager(settings.maxContextTokens || 180000);
12
15
 
13
16
  // Build system prompt using the new builder
@@ -26,9 +29,41 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
26
29
  model,
27
30
  tools,
28
31
  _contextManager: contextManager,
32
+ _cascade: cascade,
29
33
  };
30
34
 
35
+ // Self-optimization bookkeeping for the current top-level task (opt-in).
36
+ let _optTask = null; // { model, complexity, startedAt, tokensAtStart }
37
+
38
+ /** Record the outcome of the current task, if self-optimization is on. */
39
+ function recordOptOutcome(success) {
40
+ if (!cascade || !_optTask) return;
41
+ const inputDelta = state.tokenUsage.input - _optTask.tokensAtStart.input;
42
+ const outputDelta = state.tokenUsage.output - _optTask.tokensAtStart.output;
43
+ try {
44
+ cascade.recordOutcome({
45
+ model: _optTask.model,
46
+ success,
47
+ latencyMs: Date.now() - _optTask.startedAt,
48
+ inputTokens: inputDelta >= 0 ? inputDelta : 0,
49
+ outputTokens: outputDelta >= 0 ? outputDelta : 0,
50
+ complexity: _optTask.complexity,
51
+ task: _optTask.task,
52
+ });
53
+ } catch { /* best-effort */ }
54
+ _optTask = null;
55
+ }
56
+
31
57
  async function* run(userMessage, options = {}) {
58
+ const depth = (options._depth || 0);
59
+
60
+ // Guard against runaway tool-call recursion
61
+ if (depth >= MAX_TOOL_RECURSION_DEPTH) {
62
+ yield { type: 'error', message: `Max tool recursion depth (${MAX_TOOL_RECURSION_DEPTH}) reached. Stopping to prevent infinite loop.` };
63
+ yield { type: 'stop', reason: 'max_recursion' };
64
+ return;
65
+ }
66
+
32
67
  // Add user message (skip for continuation turns)
33
68
  if (userMessage && !options.continuation) {
34
69
  state.messages = contextManager.addMessage(state.messages, {
@@ -36,6 +71,22 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
36
71
  content: userMessage,
37
72
  });
38
73
  state.turnCount++;
74
+
75
+ // Opt-in cost-cascade routing: pick the cheapest good-enough model
76
+ // for this task and record outcome bookkeeping. Default path (no
77
+ // cascade) leaves state.model exactly as constructed.
78
+ if (cascade) {
79
+ const route = cascade.decide(userMessage);
80
+ state.model = route.model;
81
+ state._lastRoute = route;
82
+ _optTask = {
83
+ model: route.model,
84
+ complexity: route.complexity,
85
+ startedAt: Date.now(),
86
+ tokensAtStart: { input: state.tokenUsage.input, output: state.tokenUsage.output },
87
+ task: String(userMessage).slice(0, 120),
88
+ };
89
+ }
39
90
  }
40
91
 
41
92
  // Check max turns
@@ -53,14 +104,16 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
53
104
 
54
105
  yield { type: 'stream_request_start', turn: state.turnCount };
55
106
 
56
- // Detect provider and call API
57
- const provider = detectProvider(model);
107
+ // Detect provider and call API. Use state.model so an opt-in cascade
108
+ // pick takes effect; defaults to the constructed model otherwise.
109
+ const activeModel = state.model || model;
110
+ const provider = detectProvider(activeModel);
58
111
  let response;
59
112
 
60
113
  try {
61
- if (settings.stream !== false) {
114
+ if (settings.stream !== false && provider === 'anthropic') {
62
115
  // Streaming mode
63
- response = await callApiStreaming(provider, model, state, tools.list(), settings);
116
+ response = await callApiStreaming(provider, activeModel, state, tools.list(), settings);
64
117
  const collectedContent = [];
65
118
  let currentText = '';
66
119
  let currentThinking = '';
@@ -87,9 +140,10 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
87
140
  response = response.accumulated;
88
141
  } else {
89
142
  // Non-streaming mode
90
- response = await callApi(provider, model, state, tools.list(), settings);
143
+ response = await callApi(provider, activeModel, state, tools.list(), settings);
91
144
  }
92
145
  } catch (err) {
146
+ recordOptOutcome(false);
93
147
  yield { type: 'error', message: err.message };
94
148
  return;
95
149
  }
@@ -180,7 +234,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
180
234
  state.messages.push({ role: 'user', content: toolResults });
181
235
 
182
236
  // Recursive: continue the loop after tool execution
183
- yield* run(null, { continuation: true });
237
+ yield* run(null, { continuation: true, _depth: depth + 1 });
184
238
  return;
185
239
  }
186
240
 
@@ -193,11 +247,14 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
193
247
  role: 'user',
194
248
  content: '[System: A hook prevented stopping. Please continue with the task.]',
195
249
  });
196
- yield* run(null, { continuation: true });
250
+ yield* run(null, { continuation: true, _depth: depth + 1 });
197
251
  return;
198
252
  }
199
253
  }
200
254
 
255
+ // Task completed normally — record a successful outcome (opt-in).
256
+ recordOptOutcome(true);
257
+
201
258
  yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
202
259
  }
203
260
 
@@ -223,8 +280,8 @@ async function callApiStreaming(provider, model, state, toolDefs, settings) {
223
280
  }
224
281
 
225
282
  async function callAnthropic(model, state, toolDefs, settings, stream) {
226
- const apiKey = process.env.ANTHROPIC_API_KEY;
227
- if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
283
+ const apiKey = readApiKey('ANTHROPIC_API_KEY');
284
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set. Export it in your environment before running.');
228
285
 
229
286
  const body = {
230
287
  model,
@@ -240,7 +297,8 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
240
297
  body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
241
298
  }
242
299
 
243
- const res = await fetch('https://api.anthropic.com/v1/messages', {
300
+ const res = await fetch((process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '') + '/v1/messages', {
301
+ redirect: 'error',
244
302
  method: 'POST',
245
303
  headers: {
246
304
  'Content-Type': 'application/json',
@@ -252,7 +310,7 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
252
310
 
253
311
  if (!res.ok) {
254
312
  const err = await res.text();
255
- throw new Error(`Anthropic API error ${res.status}: ${err}`);
313
+ throw new Error(`Anthropic API error ${res.status}`);
256
314
  }
257
315
 
258
316
  if (stream) {
@@ -275,24 +333,29 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
275
333
  }
276
334
 
277
335
  async function callOpenAI(model, state, toolDefs, settings, stream) {
278
- const apiKey = process.env.OPENAI_API_KEY;
279
- if (!apiKey) throw new Error('OPENAI_API_KEY not set');
336
+ const apiKey = readApiKey('OPENAI_API_KEY');
337
+ if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Export it in your environment before running.');
280
338
 
281
339
  const messages = [];
282
- if (state.systemPrompt) {
283
- messages.push({ role: 'system', content: state.systemPrompt });
284
- }
340
+ if (state.systemPrompt) messages.push({ role: 'system', content: state.systemPrompt });
285
341
  for (const msg of state.messages) {
286
342
  if (typeof msg.content === 'string') {
287
343
  messages.push({ role: msg.role, content: msg.content });
288
344
  } else if (Array.isArray(msg.content)) {
289
- for (const block of msg.content) {
290
- if (block.type === 'tool_result') {
291
- messages.push({
292
- role: 'tool',
345
+ if (msg.role === 'assistant') {
346
+ const calls = msg.content.filter(b => b.type === 'tool_use').map(b => ({
347
+ id: b.id, type: 'function',
348
+ function: { name: b.name, arguments: JSON.stringify(b.input || {}) }
349
+ }));
350
+ messages.push({ role: 'assistant',
351
+ content: msg.content.filter(b => b.type === 'text').map(b => b.text).join('') || null,
352
+ ...(calls.length && { tool_calls: calls }) });
353
+ } else {
354
+ for (const block of msg.content) {
355
+ if (block.type === 'tool_result') messages.push({ role: 'tool',
293
356
  tool_call_id: block.tool_use_id,
294
- content: block.content,
295
- });
357
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) });
358
+ else if (block.type === 'text') messages.push({ role: msg.role, content: block.text });
296
359
  }
297
360
  }
298
361
  }
@@ -311,6 +374,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
311
374
 
312
375
  const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
313
376
  const res = await fetch(`${baseUrl}/chat/completions`, {
377
+ redirect: 'error',
314
378
  method: 'POST',
315
379
  headers: {
316
380
  'Content-Type': 'application/json',
@@ -321,7 +385,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
321
385
 
322
386
  if (!res.ok) {
323
387
  const err = await res.text();
324
- throw new Error(`OpenAI API error ${res.status}: ${err}`);
388
+ throw new Error(`OpenAI API error ${res.status}`);
325
389
  }
326
390
 
327
391
  const data = await res.json();
@@ -329,8 +393,8 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
329
393
  }
330
394
 
331
395
  async function callGoogle(model, state, toolDefs, settings, stream) {
332
- const apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
333
- if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
396
+ const apiKey = readApiKey('GOOGLE_API_KEY', 'GEMINI_API_KEY');
397
+ if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY is not set. Export one in your environment before running.');
334
398
 
335
399
  const contents = [];
336
400
  for (const msg of state.messages) {
@@ -204,6 +204,24 @@ export function listProviders() {
204
204
  }));
205
205
  }
206
206
 
207
+ /**
208
+ * Read an API key from the environment, in order of preference.
209
+ *
210
+ * Surrounding whitespace is trimmed — keys routinely arrive from .env files or
211
+ * command substitution with a trailing newline — and a whitespace-only value is
212
+ * treated as unset, so the next name is tried. No format validation: the
213
+ * provider is what authenticates the key.
214
+ * @param {...string} envKeys - env var names to try, most preferred first
215
+ * @returns {string} the trimmed key, or '' if none is configured
216
+ */
217
+ export function readApiKey(...envKeys) {
218
+ for (const envKey of envKeys) {
219
+ const value = (process.env[envKey] || '').trim();
220
+ if (value) return value;
221
+ }
222
+ return '';
223
+ }
224
+
207
225
  /**
208
226
  * Check which providers have API keys configured.
209
227
  * @returns {Array<{ id: string, name: string, configured: boolean }>}
@@ -212,7 +230,7 @@ export function checkProviderKeys() {
212
230
  return Object.entries(PROVIDERS).map(([key, p]) => ({
213
231
  id: key,
214
232
  name: p.name,
215
- configured: !!(process.env[p.envKey] || (p.altEnvKey && process.env[p.altEnvKey])),
233
+ configured: !!readApiKey(p.envKey, ...(p.altEnvKey ? [p.altEnvKey] : [])),
216
234
  }));
217
235
  }
218
236
 
@@ -108,10 +108,36 @@ export class SessionManager {
108
108
  * @param {object} state - agent loop state to restore into
109
109
  */
110
110
  importFromTeleport(data, state) {
111
- const session = JSON.parse(Buffer.from(data, 'base64').toString());
112
- state.messages = session.messages || [];
113
- state.turnCount = session.turnCount || 0;
114
- if (session.model) state.model = session.model;
111
+ if (typeof data !== 'string' || data.length === 0) {
112
+ throw new Error('Invalid teleport data: must be a non-empty string');
113
+ }
114
+
115
+ let session;
116
+ try {
117
+ session = JSON.parse(Buffer.from(data, 'base64').toString());
118
+ } catch {
119
+ throw new Error('Invalid teleport data: failed to decode or parse');
120
+ }
121
+
122
+ if (typeof session !== 'object' || session === null) {
123
+ throw new Error('Invalid teleport data: expected an object');
124
+ }
125
+
126
+ // Validate and sanitize messages — must be an array of {role, content}
127
+ const rawMessages = Array.isArray(session.messages) ? session.messages : [];
128
+ state.messages = rawMessages.filter(
129
+ (m) => m && typeof m.role === 'string' && (typeof m.content === 'string' || Array.isArray(m.content))
130
+ );
131
+
132
+ // Validate turn count
133
+ const turnCount = parseInt(session.turnCount, 10);
134
+ state.turnCount = isNaN(turnCount) || turnCount < 0 ? 0 : turnCount;
135
+
136
+ // Only allow known model strings (non-empty string, no shell injection)
137
+ if (typeof session.model === 'string' && /^[\w.:/-]{3,80}$/.test(session.model)) {
138
+ state.model = session.model;
139
+ }
140
+
115
141
  this.sessionId = `sess_teleport_${Date.now()}`;
116
142
  }
117
143
 
@@ -121,6 +121,12 @@ export class HookEngine {
121
121
  /**
122
122
  * Execute a single hook. Supports command (shell) and function hooks.
123
123
  *
124
+ * Note: hook.command is an operator-configured shell string (defined in
125
+ * settings.json by the user who controls this machine) — it is intentionally
126
+ * executed as a shell command. The tool input/context is passed ONLY via
127
+ * environment variables (never interpolated into the command string) to
128
+ * prevent escalation from tool input into the hook command itself.
129
+ *
124
130
  * @param {object} hook - hook definition
125
131
  * @param {object} context - execution context
126
132
  * @returns {Promise<object|null>}
@@ -128,10 +134,12 @@ export class HookEngine {
128
134
  async _executeHook(hook, context) {
129
135
  try {
130
136
  if (hook.command) {
137
+ // Ensure env values are strings (guards against prototype pollution
138
+ // or non-string values from context objects breaking child_process).
131
139
  const env = {
132
140
  ...process.env,
133
- HOOK_EVENT: context.event,
134
- HOOK_TOOL: context.toolName || '',
141
+ HOOK_EVENT: String(context.event || ''),
142
+ HOOK_TOOL: String(context.toolName || ''),
135
143
  HOOK_INPUT: JSON.stringify(context.input || {}),
136
144
  };
137
145
  const output = execSync(hook.command, {
package/src/index.mjs CHANGED
@@ -21,6 +21,7 @@
21
21
  * - Telemetry stub
22
22
  */
23
23
 
24
+ import { readFileSync } from 'node:fs';
24
25
  import { createAgentLoop } from './core/agent-loop.mjs';
25
26
  import { createToolRegistry } from './tools/registry.mjs';
26
27
  import { createPermissionChecker } from './permissions/checker.mjs';
@@ -37,11 +38,30 @@ import { readEnv } from './config/env.mjs';
37
38
  import * as telemetry from './telemetry/index.mjs';
38
39
 
39
40
  async function main() {
40
- const args = parseArgs(process.argv.slice(2));
41
+ const rawArgv = process.argv.slice(2);
42
+
43
+ // ── Metaharness subcommand dispatch (opt-in; runs before normal flow) ──
44
+ // `occ optimize|redblue|darwin ...` are handled here and exit. A normal
45
+ // `occ "prompt"` never reaches this branch, so default behavior is intact.
46
+ const subcommand = rawArgv[0];
47
+ if (subcommand === 'optimize' || subcommand === 'redblue' || subcommand === 'darwin') {
48
+ const settings = await loadSettings();
49
+ const { runOptimize, delegateToCli } = await import('./optimize/commands.mjs');
50
+ if (subcommand === 'optimize') {
51
+ const { output, code } = await runOptimize(rawArgv.slice(1), settings);
52
+ console.log(output);
53
+ process.exit(code);
54
+ }
55
+ const pkg = subcommand === 'redblue' ? '@metaharness/redblue' : '@metaharness/darwin';
56
+ const { code } = await delegateToCli(pkg, rawArgv.slice(1));
57
+ process.exit(code);
58
+ }
59
+
60
+ const args = parseArgs(rawArgv);
41
61
 
42
62
  // Handle --version
43
63
  if (args.showVersion) {
44
- console.log('open-claude-code v2.0.0-alpha.1');
64
+ console.log('open-claude-code v' + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
45
65
  process.exit(0);
46
66
  }
47
67
 
@@ -107,12 +127,29 @@ async function main() {
107
127
  const mcpResourceTool = tools.get('ReadMcpResource');
108
128
  if (mcpResourceTool) mcpResourceTool._mcpClients = mcpClients;
109
129
 
130
+ // ── Opt-in self-optimization cascade ──
131
+ // Only constructed when enabled (flag > env > setting). When null, the agent
132
+ // loop behaves exactly as before — same model, no outcome recording.
133
+ let cascade = null;
134
+ const { isSelfOptimizeEnabled } = await import('./optimize/cascade.mjs');
135
+ if (isSelfOptimizeEnabled(args, settings, process.env)) {
136
+ const { SelfOptimizeCascade } = await import('./optimize/cascade.mjs');
137
+ cascade = new SelfOptimizeCascade({
138
+ settings,
139
+ fallbackModel: args.model || settings.model || 'claude-sonnet-4-6',
140
+ });
141
+ if (args.verbose || settings.verbose) {
142
+ console.error('\x1b[2m[self-optimize] cost-cascade router enabled\x1b[0m');
143
+ }
144
+ }
145
+
110
146
  const loop = createAgentLoop({
111
147
  model: args.model || settings.model || 'claude-sonnet-4-6',
112
148
  tools,
113
149
  permissions,
114
150
  settings,
115
151
  hooks,
152
+ cascade,
116
153
  });
117
154
 
118
155
  // Attach extra state for commands to access
@@ -146,6 +183,7 @@ async function main() {
146
183
  const results = [];
147
184
 
148
185
  for await (const event of loop.run(args.prompt)) {
186
+ if (event.type === 'error') process.exitCode = 1;
149
187
  if (outputFormat === 'json') {
150
188
  results.push(event);
151
189
  } else if (outputFormat === 'stream-json') {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Self-optimization cascade controller.
3
+ *
4
+ * Glue between {@link MetaHarnessRouter} and {@link OutcomeStore}. Given a task,
5
+ * it decides which model to use (cost-cascade), and after the task completes it
6
+ * records the real outcome so future routing self-tunes.
7
+ *
8
+ * This is OPT-IN. It is only constructed when the user enables self-optimization
9
+ * via `--self-optimize`, the `selfOptimize` setting, or CLAUDE_CODE_SELF_OPTIMIZE.
10
+ * When disabled, the agent loop never imports or runs any of this, so default
11
+ * behavior is byte-identical.
12
+ */
13
+ import { MetaHarnessRouter, DEFAULT_LADDER } from './router.mjs';
14
+ import { OutcomeStore, estimateCostUsd } from './store.mjs';
15
+
16
+ /**
17
+ * Build a model ladder from settings, falling back to the default. Honors an
18
+ * explicit `selfOptimizeLadder` setting (array of {id,costPerMTok}) and keeps
19
+ * the configured base/fast/frontier models if present.
20
+ *
21
+ * @param {object} [settings]
22
+ * @returns {Array<{id:string,costPerMTok:number,tier?:number}>}
23
+ */
24
+ export function buildLadder(settings = {}) {
25
+ if (Array.isArray(settings.selfOptimizeLadder) && settings.selfOptimizeLadder.length) {
26
+ return settings.selfOptimizeLadder
27
+ .filter(m => m && m.id && typeof m.costPerMTok === 'number')
28
+ .map((m, i) => ({ tier: i + 1, ...m }));
29
+ }
30
+ return DEFAULT_LADDER;
31
+ }
32
+
33
+ /**
34
+ * Cascade controller. Holds a router + outcome store and exposes a small API the
35
+ * agent loop calls.
36
+ */
37
+ export class SelfOptimizeCascade {
38
+ /**
39
+ * @param {object} [opts]
40
+ * @param {object} [opts.settings] CLI settings (ladder, qualityBar)
41
+ * @param {OutcomeStore} [opts.store] outcome store (defaults to a fresh one)
42
+ * @param {string} [opts.fallbackModel] model to use if routing yields nothing
43
+ */
44
+ constructor(opts = {}) {
45
+ const settings = opts.settings || {};
46
+ this.store = opts.store || new OutcomeStore();
47
+ this.fallbackModel = opts.fallbackModel || settings.model || 'claude-sonnet-4-6';
48
+ const ladder = buildLadder(settings);
49
+ this.ladder = ladder;
50
+ this.router = new MetaHarnessRouter({
51
+ ladder,
52
+ qualityBar: typeof settings.selfOptimizeQualityBar === 'number' ? settings.selfOptimizeQualityBar : 0.7,
53
+ statsFor: (id) => this.store.statsFor(id),
54
+ });
55
+ /** @type {Array<{task:string, route:object}>} last routing decisions (in-memory, for inspection) */
56
+ this.lastRoutes = [];
57
+ }
58
+
59
+ /**
60
+ * Decide which model to use for a task without making any model call.
61
+ * @param {string} taskText
62
+ * @returns {{ model: string, predictedQuality: number, costPerMTok: number, metBar: boolean, complexity: number, ladder: string[] }}
63
+ */
64
+ decide(taskText) {
65
+ const route = this.router.route(taskText || '');
66
+ this.lastRoutes.push({ task: (taskText || '').slice(0, 80), route });
67
+ if (this.lastRoutes.length > 50) this.lastRoutes.shift();
68
+ return route;
69
+ }
70
+
71
+ /**
72
+ * Record a completed task's real outcome.
73
+ * @param {object} o
74
+ * @param {string} o.model
75
+ * @param {boolean} o.success
76
+ * @param {number} [o.latencyMs]
77
+ * @param {number} [o.inputTokens]
78
+ * @param {number} [o.outputTokens]
79
+ * @param {number} [o.complexity]
80
+ * @param {string} [o.task]
81
+ */
82
+ recordOutcome(o) {
83
+ const entry = this.ladder.find(m => m.id === o.model);
84
+ const costUsd = estimateCostUsd(o.inputTokens, o.outputTokens, entry ? entry.costPerMTok : 0);
85
+ return this.store.record({
86
+ model: o.model,
87
+ success: !!o.success,
88
+ latencyMs: o.latencyMs,
89
+ inputTokens: o.inputTokens,
90
+ outputTokens: o.outputTokens,
91
+ costUsd,
92
+ complexity: o.complexity,
93
+ task: o.task,
94
+ });
95
+ }
96
+
97
+ /** Get the next model to escalate to after a failure, or null. */
98
+ escalate(failedModelId) {
99
+ return this.router.escalate(failedModelId);
100
+ }
101
+
102
+ /** Human-readable status for `occ optimize status`. */
103
+ status() {
104
+ return {
105
+ ladder: this.ladder.map(m => ({ id: m.id, costPerMTok: m.costPerMTok })),
106
+ qualityBar: this.router.qualityBar,
107
+ store: this.store.filePath,
108
+ summary: this.store.summary(),
109
+ };
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Decide whether self-optimization is enabled, from the precedence:
115
+ * CLI flag > env var > setting. Default OFF.
116
+ *
117
+ * @param {object} [args] parsed CLI args (may have selfOptimize)
118
+ * @param {object} [settings]
119
+ * @param {object} [env] process.env-like
120
+ * @returns {boolean}
121
+ */
122
+ export function isSelfOptimizeEnabled(args = {}, settings = {}, env = process.env) {
123
+ if (args.selfOptimize === true) return true;
124
+ if (args.selfOptimize === false) return false; // explicit --no-self-optimize would set this
125
+ if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '1') return true;
126
+ if (env && env.CLAUDE_CODE_SELF_OPTIMIZE === '0') return false;
127
+ return settings.selfOptimize === true;
128
+ }