amicus 4.9.2 → 4.9.4

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 (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module openrouter-credit
3
+ * The OpenRouter credit/limit probe, split out of api-key-validation.js to keep
4
+ * that module under the 300-line size gate — the same reason it was itself
5
+ * split out of api-key-store.js. Different concern, too: this asks what the
6
+ * ACCOUNT can afford, not whether the credential is accepted.
7
+ *
8
+ * Re-exported from api-key-validation.js so every existing call site keeps
9
+ * working unchanged.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const https = require('https');
15
+
16
+ /** Warning string for a zero-credit OpenRouter key (paid models will 402). */
17
+ const OPENROUTER_NO_CREDIT_WARNING =
18
+ 'OpenRouter key has no remaining credit — paid models will fail (402). ' +
19
+ 'Add credit at openrouter.ai/credits, or build a free council (amicus setup → option 2).';
20
+
21
+ /** Warning string for a free-tier OpenRouter key. */
22
+ const OPENROUTER_FREE_TIER_WARNING =
23
+ 'OpenRouter key is free tier — only :free models will route; paid models will fail (402). ' +
24
+ 'Add credit at openrouter.ai/credits to use paid models.';
25
+
26
+ /**
27
+ * Non-blocking credit/limit check for an OpenRouter key.
28
+ *
29
+ * Hits GET https://openrouter.ai/api/v1/key (returns limit, usage,
30
+ * is_free_tier, limit_remaining) and produces a WARNING — never an error —
31
+ * when is_free_tier is true or limit_remaining <= 0. Any failure (non-200,
32
+ * network error, malformed body) resolves with warning:null so setup is
33
+ * never blocked. Free-tier councils against free models are legitimate.
34
+ *
35
+ * @param {string} key OpenRouter API key
36
+ * @returns {Promise<{checked: boolean, warning: string|null, isFreeTier: boolean,
37
+ * limitRemaining: number|null, limit: number|null, usage: number|null}>}
38
+ * `checked` is false whenever no answer was obtained. Never infer health
39
+ * from `warning: null` alone — see the note on `none` below.
40
+ */
41
+ function checkOpenRouterCredit(key) {
42
+ // ⚠️ `checked: false` is the whole point. Every failure path below resolves
43
+ // THIS object, and `warning: null` is also what a perfectly healthy account
44
+ // resolves — so a caller branching on `warning` alone cannot tell "the
45
+ // account is fine" from "the probe never got an answer", and renders the
46
+ // first for the second. That is the false green the fourth council pass
47
+ // found still alive on the network-failure path after it had been fixed only
48
+ // for the gate-disabled one. Intent-to-probe and result-of-probe are
49
+ // different facts and now have different fields.
50
+ const none = {
51
+ checked: false,
52
+ warning: null, isFreeTier: false, limitRemaining: null, limit: null, usage: null
53
+ };
54
+ if (!key || key.trim().length === 0) {
55
+ return Promise.resolve(none);
56
+ }
57
+
58
+ const headers = { 'Authorization': `Bearer ${key.trim()}` };
59
+
60
+ return new Promise((resolve) => {
61
+ const req = https.get('https://openrouter.ai/api/v1/key', { headers }, (res) => {
62
+ let body = '';
63
+ // Same response-stream gap as validateApiKey (#224). `none` carries
64
+ // checked:false, so a mid-flight death reports "could not be checked"
65
+ // rather than falling through to "credit ok".
66
+ res.on('error', () => { resolve(none); });
67
+ res.on('data', (chunk) => { body += chunk; });
68
+ res.on('end', () => {
69
+ if (res.statusCode !== 200) { resolve(none); return; }
70
+ let data;
71
+ try {
72
+ data = (JSON.parse(body) || {}).data || {};
73
+ } catch (_e) {
74
+ resolve(none);
75
+ return;
76
+ }
77
+ const isFreeTier = data.is_free_tier === true;
78
+ const limitRemaining = (typeof data.limit_remaining === 'number')
79
+ ? data.limit_remaining : null;
80
+ const limit = (typeof data.limit === 'number') ? data.limit : null;
81
+ const usage = (typeof data.usage === 'number') ? data.usage : null;
82
+
83
+ let warning = null;
84
+ if (limitRemaining !== null && limitRemaining <= 0) {
85
+ warning = OPENROUTER_NO_CREDIT_WARNING;
86
+ } else if (isFreeTier) {
87
+ warning = OPENROUTER_FREE_TIER_WARNING;
88
+ }
89
+ resolve({ checked: true, warning, isFreeTier, limitRemaining, limit, usage });
90
+ });
91
+ });
92
+ req.setTimeout(10000, () => {
93
+ req.destroy();
94
+ resolve(none);
95
+ });
96
+ req.on('error', () => { resolve(none); });
97
+ });
98
+ }
99
+
100
+ module.exports = {
101
+ checkOpenRouterCredit,
102
+ OPENROUTER_NO_CREDIT_WARNING,
103
+ OPENROUTER_FREE_TIER_WARNING,
104
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @module utils/output-length
3
+ * #218 PR 3: name the "Mode 2" death.
4
+ *
5
+ * THE PROBLEM. A council leg whose provider stopped for length before any
6
+ * answer text -- the whole max_tokens reservation went to reasoning (the #218
7
+ * ledger rows: 32000 reasoning, 0-2 output, $0.63 billed for nothing) -- came
8
+ * back `complete` with an empty summary and was announced as "the leg ended
9
+ * 'complete' with no usable output"; with VISIBLE reasoning it came back
10
+ * `complete` with its thinking promoted to the review and was adjudicated as one.
11
+ *
12
+ * WHAT THE ENGINE RECORDS (scripts/probe-max-tokens.js rows A/H1/L1-L4, engine
13
+ * 1.18.15): `finish: 'length'` on the assistant message on both provider
14
+ * families; a reasoning/output token split on OpenAI-compatible routes
15
+ * (L3: output = completion - reasoning) but NOT on the direct Anthropic route
16
+ * (L4: everything is `output`, reasoning 0); and, with visible reasoning, a
17
+ * `reasoning` part and no `text` part (L2/L4), which
18
+ * sidecar/conversation-mirror.js :: mirrorMessages promotes to `output` -- so
19
+ * `output` cannot be the test; the mirror records the last message's own facts
20
+ * and this module reads only those. No row carries an engine error for the stop.
21
+ *
22
+ * So the death is keyed on `finish` plus "no answer text arrived", never on a
23
+ * token count; the counts are reported, not decided on. Pure: no I/O, no clock.
24
+ * headless.js :: runHeadless calls both functions once, post-loop.
25
+ */
26
+ 'use strict';
27
+
28
+ const {
29
+ outputTokenFlagValue, ENGINE_DEFAULT_OUTPUT_TOKENS, OUTPUT_TOKEN_FLAG, PLAIN_OUTPUT_TOKEN_FLAG,
30
+ } = require('./engine-output-flag');
31
+
32
+ /** The prefix a consumer can classify on, like `NO_OUTPUT_BACKSTOP:`. */
33
+ const OUTPUT_LENGTH_PREFIX = 'OUTPUT_LENGTH:';
34
+
35
+ /**
36
+ * Is this leg the Mode 2 death? The provider stopped for length AND the LAST
37
+ * assistant message carries no answer text: nothing at all (L1), or only
38
+ * reasoning (L2/L4). Decided per message, never on the session's accumulated
39
+ * output (council #232 r1 B2/D1). Named mutants (tests/utils/output-length.test.js):
40
+ * "NOTLENGTH" drops the finish check, "TEXTIGNORED" drops the text check.
41
+ * @param {{finish?: string|null, hasText?: boolean}} last the last assistant message's facts
42
+ * @returns {boolean}
43
+ */
44
+ function isOutputLengthDeath({ finish, hasText }) {
45
+ return finish === 'length' && hasText !== true;
46
+ }
47
+
48
+ /**
49
+ * The reason string. Every clause is an observation: `finish` and the two
50
+ * counts are the engine's own record of the message; the budget clause is what
51
+ * the engine serving the leg was spawned with: the budget (`null` = unset,
52
+ * `undefined` = unknown — no handle value and config unreadable) or, when no
53
+ * budget was set, the ambient flag. The remedy names the one
54
+ * lever that exists today; PR 4 adds the effort lever. Named mutant
55
+ * "BUDGETUNSET": always print the unset clause.
56
+ * @param {{tokens?: {reasoning?: number, output?: number}|null,
57
+ * budget?: number|null, reasoningOnly?: boolean,
58
+ * ambientFlag?: string|null}} args `reasoningOnly` = the
59
+ * message carried reasoning parts and no text -- L2/L4; `ambientFlag` = the
60
+ * ambient `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` the engine was started with
61
+ * when no budget was set (`null` when none, or when a budget overrode it);
62
+ * named mutant "AMBIENTIGNORED".
63
+ * @returns {string}
64
+ */
65
+ function formatOutputLengthReason({ tokens, budget, reasoningOnly, ambientFlag }) {
66
+ const t = tokens || {};
67
+ const count = (n) => (Number.isFinite(n) ? n : 0);
68
+ const streamed = reasoningOnly
69
+ ? 'only reasoning was streamed, no answer text'
70
+ : 'no answer text arrived';
71
+ // PLAIN_OUTPUT_TOKEN_FLAG is the one form measured to be honoured (C1, K5,
72
+ // K12); 64000abc and 0 fell back to 32000 (D1/D2); every other form is
73
+ // unmeasured, and the clause below says so -- shared with the doctor row
74
+ // (doctor-output-budget-check.js :: evaluateOutputBudget) so the gates agree.
75
+ const ambient = typeof ambientFlag === 'string' ? ambientFlag : null;
76
+ const knob = budget === undefined
77
+ ? 'outputBudget could not be read'
78
+ : budget !== null
79
+ ? `outputBudget is ${outputTokenFlagValue(budget)}`
80
+ : ambient === null
81
+ ? `outputBudget is unset — the engine's ${ENGINE_DEFAULT_OUTPUT_TOKENS} default reservation governs`
82
+ : PLAIN_OUTPUT_TOKEN_FLAG.test(ambient)
83
+ ? `outputBudget is unset — the ambient ${OUTPUT_TOKEN_FLAG}=${ambient} the engine was started with governs (each leg reserves min(${ambient}, the ceiling the engine's catalog knows for it))`
84
+ : `outputBudget is unset and the ambient ${OUTPUT_TOKEN_FLAG}=${ambient} the engine was started with is not a plain positive integer — the only form measured to be honoured (probe D1/D2: 64000abc and 0 fell back to ${ENGINE_DEFAULT_OUTPUT_TOKENS} silently); any other form is unmeasured`;
85
+ return `${OUTPUT_LENGTH_PREFIX} the provider stopped at the max_tokens reservation (finish 'length') and ${streamed} — `
86
+ + `${count(t.reasoning)} reasoning / ${count(t.output)} output tokens; ${knob} — `
87
+ + 'raise outputBudget in config.json (docs/configuration.md, Output budget)';
88
+ }
89
+
90
+ module.exports = { OUTPUT_LENGTH_PREFIX, isOutputLengthDeath, formatOutputLengthReason };
@@ -50,6 +50,7 @@ function durationBetween(createdAt, completedAt) {
50
50
  * metadata.pack was recorded (solo session launched via --pack), sourced straight off
51
51
  * `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
52
52
  * `tag` (v4.7 F8/D13) is additive the same way — present only when metadata.tag was recorded.
53
+ * `finish` (#218 PR 3) likewise — the engine's finish reason for the leg's last assistant message. `variant` / `variantUnverified` (#218 PR 4) likewise — the effort level SENT, and whether the engine's catalogue knew the model when it was sent.
53
54
  */
54
55
  function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
55
56
  const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
@@ -80,6 +81,8 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
80
81
  // (B3): emit-when-VALID via the shared predicate — `metadata` is read off
81
82
  // disk, so NaN/±Infinity/negatives/fractions all reach here. See ./ttft.js.
82
83
  ...(isMeasuredTtft(metadata.ttftMs) ? { ttftMs: metadata.ttftMs } : {}),
84
+ ...(typeof metadata.finish === 'string' ? { finish: metadata.finish } : {}), // #218 PR 3: emit-when-set (named mutant FINISHCOERCED)
85
+ ...(typeof metadata.variant === 'string' ? { variant: metadata.variant } : {}), ...(metadata.variantUnverified === true ? { variantUnverified: true } : {}), // #218 PR 4: emit-when-sent (named mutants VARIANTCOERCED / UNVERIFIEDCOERCED)
83
86
  usage: usage !== null ? usage : (metadata.usage || null),
84
87
  ...(metadata.pack ? { pack: metadata.pack } : {}),
85
88
  ...(metadata.tag ? { tag: metadata.tag } : {}),
@@ -184,10 +187,11 @@ const { buildRunResultFromSession, buildWaveResultFromSession } = require('./res
184
187
  * #13: lastRefreshAttempt/lastRefreshError are additive — null/null when the
185
188
  * last refresh attempt on record succeeded (or none has happened yet).
186
189
  * @param {{models: Array, fetchedAt: number|null, refreshed?: boolean, search?: string|null,
187
- * lastRefreshAttempt?: number|null, lastRefreshError?: string|null}} opts
190
+ * lastRefreshAttempt?: number|null, lastRefreshError?: string|null,
191
+ * ceilingEnrichment?: object|null}} opts
188
192
  */
189
193
  function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
190
- lastRefreshAttempt = null, lastRefreshError = null }) {
194
+ lastRefreshAttempt = null, lastRefreshError = null, ceilingEnrichment = null }) {
191
195
  return {
192
196
  schemaVersion: SCHEMA_VERSION,
193
197
  type: 'model-catalog',
@@ -198,6 +202,7 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
198
202
  models,
199
203
  lastRefreshAttempt: lastRefreshAttempt || null,
200
204
  lastRefreshError: lastRefreshError || null,
205
+ ceilingEnrichment: ceilingEnrichment || null, // #218 P3: additive within SCHEMA_VERSION
201
206
  };
202
207
  }
203
208
 
@@ -62,11 +62,13 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
62
62
  * @param {number} [opts.attempt] fallback attempt count (omitted if absent)
63
63
  * @param {string} [opts.substitutedFor] substituted model (omitted if absent)
64
64
  * @param {string} [opts.retryOfWaveId] wave id being retried (omitted if absent)
65
+ * @param {string} [opts.finish] the leg's finish reason (omitted if absent) — #218 PR 3: 'length' on a row is the Mode 2 receipt
66
+ * @param {string} [opts.variant] the effort level sent (omitted if absent) — #218 PR 4
65
67
  * @param {{dir?:string}} [ctx] test seam — dir overrides getConfigDir()
66
68
  */
67
69
  function appendSpend({ taskId, waveId, model, mode, usage,
68
70
  op, status, councilRunId, councilName, project, gateway, tag,
69
- attempt, substitutedFor, retryOfWaveId }, ctx = {}) {
71
+ attempt, substitutedFor, retryOfWaveId, finish, variant }, ctx = {}) {
70
72
  if (!usage) { return; }
71
73
  try {
72
74
  const dir = ctx.dir || getConfigDir();
@@ -96,6 +98,8 @@ function appendSpend({ taskId, waveId, model, mode, usage,
96
98
  if (attempt !== undefined) { row.attempt = attempt; }
97
99
  if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
98
100
  if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
101
+ if (typeof finish === 'string') { row.finish = finish; }
102
+ if (typeof variant === 'string') { row.variant = variant; } // #218 PR 4: emit-when-sent (named mutant "VARIANTNULLED")
99
103
  // v4.4.1 CA-2: a leg whose OWN cost is known but which spawned a child
100
104
  // session the walk could not price writes a PRICED row — so `unpricedRows`
101
105
  // never catches it and `amicus spend` reads as a complete measurement while
@@ -1,92 +1,39 @@
1
1
  /**
2
2
  * Thinking Level Validators
3
3
  *
4
- * Model-specific thinking level validation.
5
- * Extracted from validators.js to keep modules under 300 lines.
4
+ * #218 PR 4: the CLI's `--thinking` check is a VOCABULARY check only — the seven
5
+ * levels the curated routes declare between them (engine-variants.js ::
6
+ * VARIANT_LEVELS, measured on the engine's /config/providers dump, probe M0).
7
+ * Whether a given MODEL declares the level is the engine's call, read from that
8
+ * same dump at send time (opencode-client.js :: sendPrompt), where a level the
9
+ * model does not declare is refused before anything is sent. The per-model table
10
+ * this file carried until PR 4 (gpt-5 "without minimal", gemini "with everything")
11
+ * was a static guess the dump CONFIRMS on one row and contradicts on the other
12
+ * (M0: both exclude `minimal` for gpt-5 — gpt-5.6-terra declares none, low, medium,
13
+ * high, xhigh, max — while the table gave gemini `none` and `xhigh` and
14
+ * gemini-3.6-flash declares neither, only minimal, low, medium, high), and its "use
15
+ * medium instead" adjustment sent a level the user never asked for. Both are gone;
16
+ * nothing is adjusted here — guessing right on one row does not make a guess a
17
+ * declaration (council #235 r1 wave 2: this docblock had cited the CONFIRMING row
18
+ * as the contradiction, the same inversion the CHANGELOG carried).
6
19
  */
7
20
 
8
- /**
9
- * Model-specific thinking level support (static fallback)
10
- * Maps model patterns to their supported thinking levels.
11
- *
12
- * NOTE: For dynamic, up-to-date capabilities, use model-capabilities.js
13
- * which fetches from OpenRouter API and caches the results.
14
- * This static map is used as a fast fallback for CLI validation.
15
- */
16
- const MODEL_THINKING_SUPPORT = {
17
- // OpenAI GPT-5.x does NOT support 'minimal'
18
- 'gpt-5': ['none', 'low', 'medium', 'high', 'xhigh'],
19
- // o3/o3-mini supports all levels
20
- 'o3': ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'],
21
- // Gemini supports all levels
22
- 'gemini': ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'],
23
- // Default: all levels supported
24
- 'default': ['none', 'minimal', 'low', 'medium', 'high', 'xhigh']
25
- };
26
-
27
- /**
28
- * Get supported thinking levels for a model (synchronous, static fallback)
29
- *
30
- * For dynamic lookup from OpenRouter API cache, use:
31
- * const { getSupportedThinkingLevels } = require('./model-capabilities');
32
- *
33
- * @param {string} model - Model identifier
34
- * @returns {string[]} Array of supported thinking levels
35
- */
36
- function getSupportedThinkingLevels(model) {
37
- if (!model) {return MODEL_THINKING_SUPPORT.default;}
38
-
39
- const modelLower = model.toLowerCase();
40
-
41
- // Check each known model pattern
42
- for (const [pattern, levels] of Object.entries(MODEL_THINKING_SUPPORT)) {
43
- if (pattern !== 'default' && modelLower.includes(pattern)) {
44
- return levels;
45
- }
46
- }
47
-
48
- return MODEL_THINKING_SUPPORT.default;
49
- }
21
+ const { VARIANT_LEVELS } = require('./engine-variants');
50
22
 
51
23
  /**
52
- * Validate thinking level for a specific model (synchronous)
53
- *
54
- * For async validation with dynamic API cache, use:
55
- * const { validateThinkingForModel } = require('./model-capabilities');
56
- *
57
- * @param {string} thinking - Thinking level ('minimal', 'low', 'medium', 'high', 'xhigh', 'none')
58
- * @param {string} model - Model identifier
59
- * @returns {{valid: boolean, error?: string, warning?: string, adjustedLevel?: string}}
24
+ * @param {string} [thinking] the requested level; omitted (undefined/null) is valid (nothing is sent then)
25
+ * @returns {{valid: boolean, error?: string}}
60
26
  */
61
- function validateThinkingLevel(thinking, model) {
62
- if (!thinking) {
63
- return { valid: true };
27
+ function validateThinkingLevel(thinking) {
28
+ // council #235 r2 (A2): presence, not truthiness. `--thinking=` parses to '' (cli.js's
29
+ // inline-value branch) and a truthiness test accepted it as "flag omitted", so the level
30
+ // was silently dropped. An omitted flag is undefined/null; anything else the user typed
31
+ // must face the vocabulary check. Named mutant "FALSYLEVELACCEPTED": restore `if (!thinking)`.
32
+ if (thinking === undefined || thinking === null) { return { valid: true }; }
33
+ if (!VARIANT_LEVELS.includes(thinking)) {
34
+ return { valid: false, error: `Error: --thinking must be one of: ${VARIANT_LEVELS.join(', ')}` };
64
35
  }
65
-
66
- const allLevels = MODEL_THINKING_SUPPORT.default;
67
- if (!allLevels.includes(thinking)) {
68
- return {
69
- valid: false,
70
- error: `Error: --thinking must be one of: ${allLevels.join(', ')}`
71
- };
72
- }
73
-
74
- const supportedLevels = getSupportedThinkingLevels(model);
75
- if (!supportedLevels.includes(thinking)) {
76
- // Map to nearest supported level
77
- const fallback = thinking === 'minimal' ? 'low' : 'medium';
78
- return {
79
- valid: true,
80
- warning: `Warning: Model '${model}' does not support thinking level '${thinking}'. Using '${fallback}' instead.`,
81
- adjustedLevel: fallback
82
- };
83
- }
84
-
85
36
  return { valid: true };
86
37
  }
87
38
 
88
- module.exports = {
89
- MODEL_THINKING_SUPPORT,
90
- getSupportedThinkingLevels,
91
- validateThinkingLevel
92
- };
39
+ module.exports = { VARIANT_LEVELS, validateThinkingLevel };
@@ -214,7 +214,7 @@ function validateHeadlessAgent(agent) {
214
214
  }
215
215
 
216
216
  const { validateMcpSpec, validateMcpConfigFile } = require('./mcp-validators');
217
- const { MODEL_THINKING_SUPPORT, getSupportedThinkingLevels, validateThinkingLevel } = require('./thinking-validators');
217
+ const { VARIANT_LEVELS, validateThinkingLevel } = require('./thinking-validators');
218
218
 
219
219
  /**
220
220
  * Validate API key is present for the given model's provider.
@@ -260,7 +260,7 @@ function validateApiKey(model) {
260
260
  module.exports = {
261
261
  VALID_AGENT_MODES,
262
262
  PROVIDER_KEY_MAP,
263
- MODEL_THINKING_SUPPORT,
263
+ VARIANT_LEVELS,
264
264
  TASK_ID_PATTERN,
265
265
  validateTaskId,
266
266
  TAG_PATTERN,
@@ -278,7 +278,6 @@ module.exports = {
278
278
  validateMcpConfigFile,
279
279
  validateApiKey,
280
280
  validateThinkingLevel,
281
- getSupportedThinkingLevels,
282
281
  findSessionInProjectDirs,
283
282
  // Re-exported from input-validators.js
284
283
  validateStartInputs: require('./input-validators').validateStartInputs,