@harness-engineering/intelligence 0.8.0 → 0.9.0

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.
package/dist/index.d.mts CHANGED
@@ -367,6 +367,14 @@ interface AnalysisRequest {
367
367
  responseSchema: z.ZodType;
368
368
  model?: string;
369
369
  maxTokens?: number;
370
+ /**
371
+ * Request that the backend suppress any chain-of-thought / `<think>` reasoning for THIS call.
372
+ * Intended for narrow structured-extraction calls where a reasoning trace adds latency but not
373
+ * quality. Advisory + best-effort: a provider that can honor it (e.g. Ollama's native
374
+ * `think:false`) does; one that cannot ignores it and answers normally. Never changes the
375
+ * response contract — only whether the model reasons out loud first.
376
+ */
377
+ disableThinking?: boolean;
370
378
  }
371
379
  interface AnalysisResponse<T> {
372
380
  result: T;
@@ -437,12 +445,26 @@ interface OpenAICompatibleProviderOptions {
437
445
  */
438
446
  declare class OpenAICompatibleAnalysisProvider implements AnalysisProvider {
439
447
  private readonly client;
448
+ private readonly baseUrl;
449
+ private readonly timeoutMs;
440
450
  private readonly defaultModel;
441
451
  private readonly getModel?;
442
452
  private readonly promptSuffix;
443
453
  private readonly jsonMode;
444
454
  constructor(options: OpenAICompatibleProviderOptions);
445
455
  analyze<T>(request: AnalysisRequest): Promise<AnalysisResponse<T>>;
456
+ /** Build the system + user message content shared by both the OpenAI-compat and native paths. */
457
+ private buildMessages;
458
+ /** Ollama's native chat route, derived from the configured `/v1` base URL. */
459
+ private ollamaNativeChatUrl;
460
+ /**
461
+ * Take Ollama's NATIVE `/api/chat` with `think:false` — the only way to actually suppress a
462
+ * Qwen3-style reasoning trace (the OpenAI-compatible `/v1` shim ignores every thinking knob).
463
+ * Schema is enforced via native `format`; `num_predict` bounds output. Throws on any non-2xx,
464
+ * missing content, or schema-parse failure so `analyze` can fall back to the OpenAI-compatible
465
+ * path (e.g. when the endpoint is not actually Ollama).
466
+ */
467
+ private analyzeViaOllamaNative;
446
468
  }
447
469
 
448
470
  interface ClaudeCliProviderOptions {
@@ -1759,6 +1781,14 @@ interface ProbeConfig {
1759
1781
  interface ProbeDeps {
1760
1782
  /** The SEL analysis provider for the semantic-read + open-decisions levers. Absent ⇒ offline. */
1761
1783
  provider?: AnalysisProvider;
1784
+ /**
1785
+ * Signals that a model IS available but its levers were intentionally DEFERRED for this run
1786
+ * (e.g. a cheap-first report pass that holds obviously-out-of-band items before spending an LLM
1787
+ * call). Only affects wording: a provider-less lever then reports "not evaluated (held before
1788
+ * the model pass)" instead of "no provider (offline)", so a deferred lever is not mistaken for a
1789
+ * missing/mis-configured provider. No effect on the gate — an unread lever never dispatches.
1790
+ */
1791
+ modelDeferred?: boolean;
1762
1792
  /** The graph-resolution seam for the scope lever. Absent ⇒ scope degrades to unknown. */
1763
1793
  graph?: GraphScope;
1764
1794
  /** The precedent lever. Absent ⇒ `unknown` (cold-start), which never blocks. */
package/dist/index.d.ts CHANGED
@@ -367,6 +367,14 @@ interface AnalysisRequest {
367
367
  responseSchema: z.ZodType;
368
368
  model?: string;
369
369
  maxTokens?: number;
370
+ /**
371
+ * Request that the backend suppress any chain-of-thought / `<think>` reasoning for THIS call.
372
+ * Intended for narrow structured-extraction calls where a reasoning trace adds latency but not
373
+ * quality. Advisory + best-effort: a provider that can honor it (e.g. Ollama's native
374
+ * `think:false`) does; one that cannot ignores it and answers normally. Never changes the
375
+ * response contract — only whether the model reasons out loud first.
376
+ */
377
+ disableThinking?: boolean;
370
378
  }
371
379
  interface AnalysisResponse<T> {
372
380
  result: T;
@@ -437,12 +445,26 @@ interface OpenAICompatibleProviderOptions {
437
445
  */
438
446
  declare class OpenAICompatibleAnalysisProvider implements AnalysisProvider {
439
447
  private readonly client;
448
+ private readonly baseUrl;
449
+ private readonly timeoutMs;
440
450
  private readonly defaultModel;
441
451
  private readonly getModel?;
442
452
  private readonly promptSuffix;
443
453
  private readonly jsonMode;
444
454
  constructor(options: OpenAICompatibleProviderOptions);
445
455
  analyze<T>(request: AnalysisRequest): Promise<AnalysisResponse<T>>;
456
+ /** Build the system + user message content shared by both the OpenAI-compat and native paths. */
457
+ private buildMessages;
458
+ /** Ollama's native chat route, derived from the configured `/v1` base URL. */
459
+ private ollamaNativeChatUrl;
460
+ /**
461
+ * Take Ollama's NATIVE `/api/chat` with `think:false` — the only way to actually suppress a
462
+ * Qwen3-style reasoning trace (the OpenAI-compatible `/v1` shim ignores every thinking knob).
463
+ * Schema is enforced via native `format`; `num_predict` bounds output. Throws on any non-2xx,
464
+ * missing content, or schema-parse failure so `analyze` can fall back to the OpenAI-compatible
465
+ * path (e.g. when the endpoint is not actually Ollama).
466
+ */
467
+ private analyzeViaOllamaNative;
446
468
  }
447
469
 
448
470
  interface ClaudeCliProviderOptions {
@@ -1759,6 +1781,14 @@ interface ProbeConfig {
1759
1781
  interface ProbeDeps {
1760
1782
  /** The SEL analysis provider for the semantic-read + open-decisions levers. Absent ⇒ offline. */
1761
1783
  provider?: AnalysisProvider;
1784
+ /**
1785
+ * Signals that a model IS available but its levers were intentionally DEFERRED for this run
1786
+ * (e.g. a cheap-first report pass that holds obviously-out-of-band items before spending an LLM
1787
+ * call). Only affects wording: a provider-less lever then reports "not evaluated (held before
1788
+ * the model pass)" instead of "no provider (offline)", so a deferred lever is not mistaken for a
1789
+ * missing/mis-configured provider. No effect on the gate — an unread lever never dispatches.
1790
+ */
1791
+ modelDeferred?: boolean;
1762
1792
  /** The graph-resolution seam for the scope lever. Absent ⇒ scope degrades to unknown. */
1763
1793
  graph?: GraphScope;
1764
1794
  /** The precedent lever. Absent ⇒ `unknown` (cold-start), which never blocks. */
package/dist/index.js CHANGED
@@ -449,15 +449,19 @@ var DEFAULT_MAX_TOKENS2 = 8192;
449
449
  var DEFAULT_TIMEOUT_MS = 9e4;
450
450
  var OpenAICompatibleAnalysisProvider = class {
451
451
  client;
452
+ baseUrl;
453
+ timeoutMs;
452
454
  defaultModel;
453
455
  getModel;
454
456
  promptSuffix;
455
457
  jsonMode;
456
458
  constructor(options) {
459
+ this.baseUrl = options.baseUrl;
460
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
457
461
  this.client = new import_openai.default({
458
462
  apiKey: options.apiKey,
459
463
  baseURL: options.baseUrl,
460
- timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS
464
+ timeout: this.timeoutMs
461
465
  });
462
466
  this.defaultModel = options.defaultModel ?? DEFAULT_MODEL2;
463
467
  if (options.getModel !== void 0) {
@@ -471,16 +475,19 @@ var OpenAICompatibleAnalysisProvider = class {
471
475
  const model = request.model ?? (resolved != null && resolved !== "" ? resolved : this.defaultModel);
472
476
  const maxTokens = request.maxTokens ?? DEFAULT_MAX_TOKENS2;
473
477
  const jsonSchema = zodToJsonSchema(request.responseSchema);
474
- const systemParts = [];
475
- if (request.systemPrompt) systemParts.push(request.systemPrompt);
476
- if (this.jsonMode) {
477
- systemParts.push(
478
- "Respond ONLY with the JSON object, no other text. Be concise \u2014 use short sentences in string fields and limit arrays to the most important items."
479
- );
480
- } else {
481
- systemParts.push(
482
- "You MUST respond with valid JSON matching this schema:\n" + JSON.stringify(jsonSchema, null, 2) + "\n\nRespond ONLY with the JSON object, no other text."
483
- );
478
+ const { systemContent, userContent } = this.buildMessages(request, jsonSchema);
479
+ if (request.disableThinking === true) {
480
+ try {
481
+ return await this.analyzeViaOllamaNative(
482
+ request,
483
+ model,
484
+ maxTokens,
485
+ jsonSchema,
486
+ systemContent,
487
+ userContent
488
+ );
489
+ } catch {
490
+ }
484
491
  }
485
492
  const startMs = performance.now();
486
493
  const responseFormat = this.jsonMode ? {
@@ -492,13 +499,8 @@ var OpenAICompatibleAnalysisProvider = class {
492
499
  max_tokens: maxTokens,
493
500
  ...responseFormat && { response_format: responseFormat },
494
501
  messages: [
495
- { role: "system", content: systemParts.join("\n\n") },
496
- {
497
- role: "user",
498
- content: this.promptSuffix ? `${request.prompt}
499
-
500
- ${this.promptSuffix}` : request.prompt
501
- }
502
+ { role: "system", content: systemContent },
503
+ { role: "user", content: userContent }
502
504
  ]
503
505
  });
504
506
  const latencyMs = Math.round(performance.now() - startMs);
@@ -524,6 +526,81 @@ ${this.promptSuffix}` : request.prompt
524
526
  };
525
527
  return { result, tokenUsage, model, latencyMs };
526
528
  }
529
+ /** Build the system + user message content shared by both the OpenAI-compat and native paths. */
530
+ buildMessages(request, jsonSchema) {
531
+ const systemParts = [];
532
+ if (request.systemPrompt) systemParts.push(request.systemPrompt);
533
+ if (this.jsonMode) {
534
+ systemParts.push(
535
+ "Respond ONLY with the JSON object, no other text. Be concise \u2014 use short sentences in string fields and limit arrays to the most important items."
536
+ );
537
+ } else {
538
+ systemParts.push(
539
+ "You MUST respond with valid JSON matching this schema:\n" + JSON.stringify(jsonSchema, null, 2) + "\n\nRespond ONLY with the JSON object, no other text."
540
+ );
541
+ }
542
+ const userContent = this.promptSuffix ? `${request.prompt}
543
+
544
+ ${this.promptSuffix}` : request.prompt;
545
+ return { systemContent: systemParts.join("\n\n"), userContent };
546
+ }
547
+ /** Ollama's native chat route, derived from the configured `/v1` base URL. */
548
+ ollamaNativeChatUrl() {
549
+ return this.baseUrl.replace(/\/v1\/?$/, "") + "/api/chat";
550
+ }
551
+ /**
552
+ * Take Ollama's NATIVE `/api/chat` with `think:false` — the only way to actually suppress a
553
+ * Qwen3-style reasoning trace (the OpenAI-compatible `/v1` shim ignores every thinking knob).
554
+ * Schema is enforced via native `format`; `num_predict` bounds output. Throws on any non-2xx,
555
+ * missing content, or schema-parse failure so `analyze` can fall back to the OpenAI-compatible
556
+ * path (e.g. when the endpoint is not actually Ollama).
557
+ */
558
+ async analyzeViaOllamaNative(request, model, maxTokens, jsonSchema, systemContent, userContent) {
559
+ const startMs = performance.now();
560
+ const controller = new AbortController();
561
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
562
+ try {
563
+ const res = await fetch(this.ollamaNativeChatUrl(), {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({
567
+ model,
568
+ think: false,
569
+ stream: false,
570
+ format: jsonSchema,
571
+ options: { num_predict: maxTokens },
572
+ messages: [
573
+ { role: "system", content: systemContent },
574
+ { role: "user", content: userContent }
575
+ ]
576
+ }),
577
+ signal: controller.signal
578
+ });
579
+ if (!res.ok) {
580
+ throw new Error(`Ollama native /api/chat returned ${res.status}`);
581
+ }
582
+ const data = await res.json();
583
+ if (data.done_reason === "length") {
584
+ throw new Error("Ollama native /api/chat response truncated (done_reason=length)");
585
+ }
586
+ const content = data.message?.content;
587
+ if (!content) {
588
+ throw new Error("Ollama native /api/chat response missing message content");
589
+ }
590
+ const parsed = JSON.parse(content);
591
+ const result = request.responseSchema.parse(parsed);
592
+ const inputTokens = data.prompt_eval_count ?? 0;
593
+ const outputTokens = data.eval_count ?? 0;
594
+ return {
595
+ result,
596
+ tokenUsage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
597
+ model,
598
+ latencyMs: Math.round(performance.now() - startMs)
599
+ };
600
+ } finally {
601
+ clearTimeout(timer);
602
+ }
603
+ }
527
604
  };
528
605
 
529
606
  // src/analysis-provider/claude-cli.ts
@@ -2275,7 +2352,18 @@ async function llmTiebreak(provider, prompt, fastModel) {
2275
2352
  responseSchema: TiebreakSchema,
2276
2353
  // Only include `model` when supplied (exactOptionalPropertyTypes).
2277
2354
  ...fastModel !== void 0 ? { model: fastModel } : {},
2278
- maxTokens: 256
2355
+ // A trivial/simple/moderate/complex classification does not benefit from a reasoning
2356
+ // trace (verified: identical verdict with thinking on vs off) — suppress it where the
2357
+ // backend can (Ollama native), so a reasoning model answers in ~10 tokens instead of
2358
+ // ~1000. No-op on backends that cannot honor it.
2359
+ disableThinking: true,
2360
+ // Headroom for reasoning models: a thinking model (e.g. Qwen3) emits a
2361
+ // `<think>` trace BEFORE the JSON, so a tight cap truncates mid-reasoning →
2362
+ // `finish_reason: length` → empty content → the catch below silently returns
2363
+ // the `moderate/low` fallback, masking the real verdict. `maxTokens` is a
2364
+ // ceiling, not a target (a non-thinking model still stops at ~14 tokens), so
2365
+ // this is free on the fast path and only spends tokens when reasoning occurs.
2366
+ maxTokens: 4096
2279
2367
  });
2280
2368
  return result;
2281
2369
  } catch {
@@ -2443,7 +2531,7 @@ async function runScopingProbe(input, deps = {}) {
2443
2531
  const config = { ...DEFAULT_CONFIG, ...deps.config };
2444
2532
  const scope = await runScopeLever(input, deps.graph);
2445
2533
  const semanticRead = await runSemanticReadLever(input, scope.value, deps);
2446
- const openDecisions = await runOpenDecisionsLever(input, deps.provider);
2534
+ const openDecisions = await runOpenDecisionsLever(input, deps.provider, deps.modelDeferred);
2447
2535
  const precedent = runPrecedentLever(input, semanticRead.value, deps.precedent);
2448
2536
  const levers = { scope, semanticRead, openDecisions, precedent };
2449
2537
  const verdict = semanticRead.value === "unknown" ? { level: "moderate", confidence: "low", signals: {}, source: "static" } : semanticRead.value;
@@ -2507,16 +2595,29 @@ async function runSemanticReadLever(input, scope, deps) {
2507
2595
  return { value: "unknown", reason: `semantic-read: classify failed \u2014 ${errMsg(err)}` };
2508
2596
  }
2509
2597
  }
2510
- async function runOpenDecisionsLever(input, provider) {
2598
+ async function runOpenDecisionsLever(input, provider, modelDeferred) {
2511
2599
  if (provider === void 0) {
2512
- return { value: "unknown", reason: "open-decisions: no provider (offline)" };
2600
+ return {
2601
+ value: "unknown",
2602
+ reason: modelDeferred === true ? "open-decisions: not evaluated (item held before the model pass)" : "open-decisions: no provider (offline)"
2603
+ };
2513
2604
  }
2514
2605
  try {
2515
2606
  const { result } = await provider.analyze({
2516
2607
  prompt: input.taskText.prompt,
2517
2608
  systemPrompt: OPEN_DECISIONS_SYSTEM_PROMPT,
2518
2609
  responseSchema: OpenDecisionsSchema,
2519
- maxTokens: 512
2610
+ // Suppress the reasoning trace where the backend can (Ollama native) — enumerating the
2611
+ // human-judgment decisions is structured extraction, not open-ended reasoning. Verified to
2612
+ // surface the same decisions as with thinking on. No-op on backends that cannot honor it.
2613
+ disableThinking: true,
2614
+ // Headroom for reasoning models (Qwen3 et al.): they emit a `<think>` trace
2615
+ // before the JSON, and this open-ended decisions schema elicits more of it than
2616
+ // the tiny classify schema. A tight cap truncates mid-reasoning →
2617
+ // `finish_reason: length` → the catch degrades this lever to `unknown`, silently
2618
+ // blinding the open-decisions signal. `maxTokens` is a ceiling (a non-thinking
2619
+ // model still stops early), so this is free on the fast path.
2620
+ maxTokens: 4096
2520
2621
  });
2521
2622
  const decisions = result.openDecisions ?? [];
2522
2623
  const note = decisions.length === 0 ? "open-decisions: none surfaced" : `open-decisions: ${decisions.length} requiring human judgment`;
package/dist/index.mjs CHANGED
@@ -334,15 +334,19 @@ var DEFAULT_MAX_TOKENS2 = 8192;
334
334
  var DEFAULT_TIMEOUT_MS = 9e4;
335
335
  var OpenAICompatibleAnalysisProvider = class {
336
336
  client;
337
+ baseUrl;
338
+ timeoutMs;
337
339
  defaultModel;
338
340
  getModel;
339
341
  promptSuffix;
340
342
  jsonMode;
341
343
  constructor(options) {
344
+ this.baseUrl = options.baseUrl;
345
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
342
346
  this.client = new OpenAI({
343
347
  apiKey: options.apiKey,
344
348
  baseURL: options.baseUrl,
345
- timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS
349
+ timeout: this.timeoutMs
346
350
  });
347
351
  this.defaultModel = options.defaultModel ?? DEFAULT_MODEL2;
348
352
  if (options.getModel !== void 0) {
@@ -356,16 +360,19 @@ var OpenAICompatibleAnalysisProvider = class {
356
360
  const model = request.model ?? (resolved != null && resolved !== "" ? resolved : this.defaultModel);
357
361
  const maxTokens = request.maxTokens ?? DEFAULT_MAX_TOKENS2;
358
362
  const jsonSchema = zodToJsonSchema(request.responseSchema);
359
- const systemParts = [];
360
- if (request.systemPrompt) systemParts.push(request.systemPrompt);
361
- if (this.jsonMode) {
362
- systemParts.push(
363
- "Respond ONLY with the JSON object, no other text. Be concise \u2014 use short sentences in string fields and limit arrays to the most important items."
364
- );
365
- } else {
366
- systemParts.push(
367
- "You MUST respond with valid JSON matching this schema:\n" + JSON.stringify(jsonSchema, null, 2) + "\n\nRespond ONLY with the JSON object, no other text."
368
- );
363
+ const { systemContent, userContent } = this.buildMessages(request, jsonSchema);
364
+ if (request.disableThinking === true) {
365
+ try {
366
+ return await this.analyzeViaOllamaNative(
367
+ request,
368
+ model,
369
+ maxTokens,
370
+ jsonSchema,
371
+ systemContent,
372
+ userContent
373
+ );
374
+ } catch {
375
+ }
369
376
  }
370
377
  const startMs = performance.now();
371
378
  const responseFormat = this.jsonMode ? {
@@ -377,13 +384,8 @@ var OpenAICompatibleAnalysisProvider = class {
377
384
  max_tokens: maxTokens,
378
385
  ...responseFormat && { response_format: responseFormat },
379
386
  messages: [
380
- { role: "system", content: systemParts.join("\n\n") },
381
- {
382
- role: "user",
383
- content: this.promptSuffix ? `${request.prompt}
384
-
385
- ${this.promptSuffix}` : request.prompt
386
- }
387
+ { role: "system", content: systemContent },
388
+ { role: "user", content: userContent }
387
389
  ]
388
390
  });
389
391
  const latencyMs = Math.round(performance.now() - startMs);
@@ -409,6 +411,81 @@ ${this.promptSuffix}` : request.prompt
409
411
  };
410
412
  return { result, tokenUsage, model, latencyMs };
411
413
  }
414
+ /** Build the system + user message content shared by both the OpenAI-compat and native paths. */
415
+ buildMessages(request, jsonSchema) {
416
+ const systemParts = [];
417
+ if (request.systemPrompt) systemParts.push(request.systemPrompt);
418
+ if (this.jsonMode) {
419
+ systemParts.push(
420
+ "Respond ONLY with the JSON object, no other text. Be concise \u2014 use short sentences in string fields and limit arrays to the most important items."
421
+ );
422
+ } else {
423
+ systemParts.push(
424
+ "You MUST respond with valid JSON matching this schema:\n" + JSON.stringify(jsonSchema, null, 2) + "\n\nRespond ONLY with the JSON object, no other text."
425
+ );
426
+ }
427
+ const userContent = this.promptSuffix ? `${request.prompt}
428
+
429
+ ${this.promptSuffix}` : request.prompt;
430
+ return { systemContent: systemParts.join("\n\n"), userContent };
431
+ }
432
+ /** Ollama's native chat route, derived from the configured `/v1` base URL. */
433
+ ollamaNativeChatUrl() {
434
+ return this.baseUrl.replace(/\/v1\/?$/, "") + "/api/chat";
435
+ }
436
+ /**
437
+ * Take Ollama's NATIVE `/api/chat` with `think:false` — the only way to actually suppress a
438
+ * Qwen3-style reasoning trace (the OpenAI-compatible `/v1` shim ignores every thinking knob).
439
+ * Schema is enforced via native `format`; `num_predict` bounds output. Throws on any non-2xx,
440
+ * missing content, or schema-parse failure so `analyze` can fall back to the OpenAI-compatible
441
+ * path (e.g. when the endpoint is not actually Ollama).
442
+ */
443
+ async analyzeViaOllamaNative(request, model, maxTokens, jsonSchema, systemContent, userContent) {
444
+ const startMs = performance.now();
445
+ const controller = new AbortController();
446
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
447
+ try {
448
+ const res = await fetch(this.ollamaNativeChatUrl(), {
449
+ method: "POST",
450
+ headers: { "Content-Type": "application/json" },
451
+ body: JSON.stringify({
452
+ model,
453
+ think: false,
454
+ stream: false,
455
+ format: jsonSchema,
456
+ options: { num_predict: maxTokens },
457
+ messages: [
458
+ { role: "system", content: systemContent },
459
+ { role: "user", content: userContent }
460
+ ]
461
+ }),
462
+ signal: controller.signal
463
+ });
464
+ if (!res.ok) {
465
+ throw new Error(`Ollama native /api/chat returned ${res.status}`);
466
+ }
467
+ const data = await res.json();
468
+ if (data.done_reason === "length") {
469
+ throw new Error("Ollama native /api/chat response truncated (done_reason=length)");
470
+ }
471
+ const content = data.message?.content;
472
+ if (!content) {
473
+ throw new Error("Ollama native /api/chat response missing message content");
474
+ }
475
+ const parsed = JSON.parse(content);
476
+ const result = request.responseSchema.parse(parsed);
477
+ const inputTokens = data.prompt_eval_count ?? 0;
478
+ const outputTokens = data.eval_count ?? 0;
479
+ return {
480
+ result,
481
+ tokenUsage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
482
+ model,
483
+ latencyMs: Math.round(performance.now() - startMs)
484
+ };
485
+ } finally {
486
+ clearTimeout(timer);
487
+ }
488
+ }
412
489
  };
413
490
 
414
491
  // src/analysis-provider/claude-cli.ts
@@ -2160,7 +2237,18 @@ async function llmTiebreak(provider, prompt, fastModel) {
2160
2237
  responseSchema: TiebreakSchema,
2161
2238
  // Only include `model` when supplied (exactOptionalPropertyTypes).
2162
2239
  ...fastModel !== void 0 ? { model: fastModel } : {},
2163
- maxTokens: 256
2240
+ // A trivial/simple/moderate/complex classification does not benefit from a reasoning
2241
+ // trace (verified: identical verdict with thinking on vs off) — suppress it where the
2242
+ // backend can (Ollama native), so a reasoning model answers in ~10 tokens instead of
2243
+ // ~1000. No-op on backends that cannot honor it.
2244
+ disableThinking: true,
2245
+ // Headroom for reasoning models: a thinking model (e.g. Qwen3) emits a
2246
+ // `<think>` trace BEFORE the JSON, so a tight cap truncates mid-reasoning →
2247
+ // `finish_reason: length` → empty content → the catch below silently returns
2248
+ // the `moderate/low` fallback, masking the real verdict. `maxTokens` is a
2249
+ // ceiling, not a target (a non-thinking model still stops at ~14 tokens), so
2250
+ // this is free on the fast path and only spends tokens when reasoning occurs.
2251
+ maxTokens: 4096
2164
2252
  });
2165
2253
  return result;
2166
2254
  } catch {
@@ -2328,7 +2416,7 @@ async function runScopingProbe(input, deps = {}) {
2328
2416
  const config = { ...DEFAULT_CONFIG, ...deps.config };
2329
2417
  const scope = await runScopeLever(input, deps.graph);
2330
2418
  const semanticRead = await runSemanticReadLever(input, scope.value, deps);
2331
- const openDecisions = await runOpenDecisionsLever(input, deps.provider);
2419
+ const openDecisions = await runOpenDecisionsLever(input, deps.provider, deps.modelDeferred);
2332
2420
  const precedent = runPrecedentLever(input, semanticRead.value, deps.precedent);
2333
2421
  const levers = { scope, semanticRead, openDecisions, precedent };
2334
2422
  const verdict = semanticRead.value === "unknown" ? { level: "moderate", confidence: "low", signals: {}, source: "static" } : semanticRead.value;
@@ -2392,16 +2480,29 @@ async function runSemanticReadLever(input, scope, deps) {
2392
2480
  return { value: "unknown", reason: `semantic-read: classify failed \u2014 ${errMsg(err)}` };
2393
2481
  }
2394
2482
  }
2395
- async function runOpenDecisionsLever(input, provider) {
2483
+ async function runOpenDecisionsLever(input, provider, modelDeferred) {
2396
2484
  if (provider === void 0) {
2397
- return { value: "unknown", reason: "open-decisions: no provider (offline)" };
2485
+ return {
2486
+ value: "unknown",
2487
+ reason: modelDeferred === true ? "open-decisions: not evaluated (item held before the model pass)" : "open-decisions: no provider (offline)"
2488
+ };
2398
2489
  }
2399
2490
  try {
2400
2491
  const { result } = await provider.analyze({
2401
2492
  prompt: input.taskText.prompt,
2402
2493
  systemPrompt: OPEN_DECISIONS_SYSTEM_PROMPT,
2403
2494
  responseSchema: OpenDecisionsSchema,
2404
- maxTokens: 512
2495
+ // Suppress the reasoning trace where the backend can (Ollama native) — enumerating the
2496
+ // human-judgment decisions is structured extraction, not open-ended reasoning. Verified to
2497
+ // surface the same decisions as with thinking on. No-op on backends that cannot honor it.
2498
+ disableThinking: true,
2499
+ // Headroom for reasoning models (Qwen3 et al.): they emit a `<think>` trace
2500
+ // before the JSON, and this open-ended decisions schema elicits more of it than
2501
+ // the tiny classify schema. A tight cap truncates mid-reasoning →
2502
+ // `finish_reason: length` → the catch degrades this lever to `unknown`, silently
2503
+ // blinding the open-decisions signal. `maxTokens` is a ceiling (a non-thinking
2504
+ // model still stops early), so this is free on the fast path.
2505
+ maxTokens: 4096
2405
2506
  });
2406
2507
  const decisions = result.openDecisions ?? [];
2407
2508
  const note = decisions.length === 0 ? "open-decisions: none surfaced" : `open-decisions: ${decisions.length} requiring human judgment`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-engineering/intelligence",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Intelligence pipeline for spec enrichment, complexity modeling, and pre-execution simulation",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -40,8 +40,8 @@
40
40
  "@anthropic-ai/sdk": "^0.95.1",
41
41
  "openai": "^6.0.0",
42
42
  "zod": "^3.25.76",
43
- "@harness-engineering/graph": "0.11.8",
44
- "@harness-engineering/types": "0.22.0"
43
+ "@harness-engineering/graph": "0.11.9",
44
+ "@harness-engineering/types": "0.23.0"
45
45
  },
46
46
  "optionalDependencies": {
47
47
  "canary-test-cli": "^5.4.0"