@0xgeorgemathew/zcode 0.4.4 → 0.5.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.
package/lib/executor.js CHANGED
@@ -19,9 +19,11 @@ const { resolveRuntime, makeClient } = require('./bootstrap.js');
19
19
  const { buildEngineHome, rootDir, liveDir } = require('./isolation.js');
20
20
  const { openSession, runTurn, TurnError } = require('./appserver.js');
21
21
  const { runChecks } = require('./checks.js');
22
+ const { reservePort } = require('./ports.js');
22
23
  const {
23
24
  workspaceRoot,
24
25
  isOwned,
26
+ isZcodePath,
25
27
  acquireLease,
26
28
  liveLeases,
27
29
  snapshot,
@@ -34,6 +36,9 @@ const {
34
36
  HANDBACK_REQUEST,
35
37
  parseHandback,
36
38
  deriveStatus,
39
+ unmetVisual,
40
+ visualGaps,
41
+ buildVisualReportRequest,
37
42
  } = require('./contract.js');
38
43
 
39
44
  const RESULT_SCHEMA = 'zcode.result/1';
@@ -52,7 +57,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
57
  // Settings a session keeps across invocations (stored as invocation.json).
53
58
  const SETTING_KEYS = [
54
59
  'kind', 'label', 'cwd', 'model', 'effort', 'timeoutMs', 'maxRepairs', 'checkTimeoutMs',
55
- 'checks', 'owns', 'ownsDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
60
+ 'checks', 'preflight', 'owns', 'ownsDisplay', 'touch', 'touchDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
56
61
  'skillsDirs', 'noSkills', 'profile', 'includeReasoning', 'allowOverlap', 'stream',
57
62
  ];
58
63
 
@@ -159,6 +164,8 @@ function prepareSession(inv, env = process.env) {
159
164
  workspaceRoot: workspaceRoot(inv.cwd),
160
165
  owns: inv.ownsDisplay || [],
161
166
  ownsRoot: inv.owns || [],
167
+ touch: inv.touchDisplay || [],
168
+ preflight: Boolean(inv.preflight),
162
169
  checks: inv.checks || [],
163
170
  model: inv.model,
164
171
  effort: inv.effort,
@@ -210,7 +217,13 @@ async function execute(inv, opts = {}) {
210
217
  if (releaseSlot) releaseSlot();
211
218
  releaseOwner();
212
219
  };
220
+ // Resuming a preflight session proceeds to the real task: answers + the
221
+ // full protocol, checks and visual verification from here on.
222
+ const leavingPreflight = resuming && Boolean(inv.preflight);
223
+ if (leavingPreflight) inv.preflight = false;
224
+ const preflight = Boolean(inv.preflight);
213
225
  if (resuming) writeSettings(id, inv, env);
226
+ if (leavingPreflight) store.updateSession(id, { preflight: false }, env);
214
227
  const isFirstInvocation = !record.engineSessionId;
215
228
  const update = (patch) => store.updateSession(id, patch, env);
216
229
 
@@ -285,14 +298,19 @@ async function execute(inv, opts = {}) {
285
298
  }
286
299
 
287
300
  // --- lease ------------------------------------------------------------------
288
- const lease = acquireLease({ id, root, owns: inv.owns || [], allowOverlap: inv.allowOverlap, env });
301
+ // A preflight is read-only: it reserves nothing and conflicts with nobody.
302
+ const lease = preflight
303
+ ? { ok: true, conflicts: [], others: [], release() {} }
304
+ : acquireLease({ id, root, owns: inv.owns || [], touch: inv.touch || [], allowOverlap: inv.allowOverlap, env });
289
305
  if (!lease.ok) {
290
306
  const names = lease.conflicts
291
307
  .map((c) => `${c.id} (owns ${c.owns && c.owns.length ? c.owns.join(', ') : 'the whole workspace'})`)
292
308
  .join('; ');
293
- const message =
294
- `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
295
- 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
309
+ const message = lease.touch
310
+ ? `workspace conflict in ${root}: --touch ${(inv.touchDisplay || []).join(', ')} is owned by running session(s) ${names}. ` +
311
+ 'Wait for them, touch something else, or pass --allow-overlap.'
312
+ : `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
313
+ 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
296
314
  if (inv.adopt) {
297
315
  // A detached worker: the caller is polling this id, so leave a result.
298
316
  store.writeResult(id, {
@@ -323,6 +341,9 @@ async function execute(inv, opts = {}) {
323
341
  let subagentNote = null;
324
342
  let built = null;
325
343
  let before = null;
344
+ let releasePort = null;
345
+ let sessionEnv = null;
346
+ const visualDeclared = (inv.contract && inv.contract.visual) || [];
326
347
  const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
327
348
  const checkTouched = new Set(); // files written by zcode's own check runs
328
349
  const invocationStart = new Date(started).toISOString();
@@ -331,6 +352,23 @@ async function execute(inv, opts = {}) {
331
352
  if (signal && signal.aborted) throw new TurnError('cancelled', { kind: 'cancelled' });
332
353
  if (!releaseSlot) throw new TurnError('run exceeded --timeout-ms while queued', { kind: 'timeout' });
333
354
 
355
+ // Per-session environment for the executor AND the checks: a port no
356
+ // other live session holds, plus output and scratch dirs of its own.
357
+ const reserved = await reservePort({ env, preferred: record.port });
358
+ if (!reserved) throw new ExecError('no free port for ZCODE_PORT in 41000-48999', { exitCode: 5, kind: 'no_port' });
359
+ releasePort = reserved.release;
360
+ const outDir = path.join(record.cwd, '.zcode', 'out', id);
361
+ const tmpDir = path.join(dir, 'tmp');
362
+ fs.mkdirSync(outDir, { recursive: true });
363
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
364
+ sessionEnv = {
365
+ ZCODE_SESSION_ID: id,
366
+ ZCODE_PORT: String(reserved.port),
367
+ ZCODE_OUT: outDir,
368
+ ZCODE_TMP: tmpDir,
369
+ };
370
+ update({ port: reserved.port, out: outDir, tmp: tmpDir });
371
+
334
372
  built = buildEngineHome({
335
373
  home,
336
374
  root: rootDir(env),
@@ -348,7 +386,7 @@ async function execute(inv, opts = {}) {
348
386
  // nothing has been sent to the model yet).
349
387
  setPhase('starting');
350
388
  for (let attempt = 1; ; attempt++) {
351
- client = makeClient(runtime, { home, cwd: record.cwd });
389
+ client = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
352
390
  client.start();
353
391
  update({ enginePid: client.child.pid });
354
392
  try {
@@ -373,10 +411,21 @@ async function execute(inv, opts = {}) {
373
411
  // First prompt for this invocation.
374
412
  let prompt;
375
413
  if (inv.kind === 'task') {
376
- prompt = isFirstInvocation
377
- ? buildTaskPrompt(inv.contract, { ownsDisplay: inv.ownsDisplay || [] }) +
414
+ const taskPrompt = () => buildTaskPrompt(inv.contract, {
415
+ ownsDisplay: inv.ownsDisplay || [],
416
+ session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
417
+ preflight,
418
+ });
419
+ prompt = leavingPreflight
420
+ ? `${taskPrompt()}\n\n## Answers to your preflight questions\nThe preflight is over: implement the task now (edits are allowed). The orchestrator's answers are binding:\n${(inv.resume.message || '').trim() || '(none: take the most conservative choice for anything open and list it in handback `decisions`)'}`
421
+ : isFirstInvocation
422
+ ? taskPrompt() +
378
423
  (resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
379
- : buildResumePrompt(inv.resume ? inv.resume.message : '', { kind: 'task' });
424
+ : buildResumePrompt(inv.resume ? inv.resume.message : '', {
425
+ kind: 'task',
426
+ // The port is the one thing that may have changed since the first prompt.
427
+ portChange: record.port && String(record.port) !== sessionEnv.ZCODE_PORT ? sessionEnv.ZCODE_PORT : null,
428
+ });
380
429
  } else if (inv.kind === 'agent') {
381
430
  prompt =
382
431
  `You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
@@ -387,6 +436,7 @@ async function execute(inv, opts = {}) {
387
436
  }
388
437
 
389
438
  let askedForHandback = false;
439
+ let askedForVisual = false;
390
440
  let phase = 'executing';
391
441
  for (;;) {
392
442
  setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
@@ -404,6 +454,10 @@ async function execute(inv, opts = {}) {
404
454
  if (inv.kind !== 'task') break;
405
455
 
406
456
  const next = parseHandback(turn.response);
457
+ // A report-only visual follow-up that comes back without a valid block
458
+ // keeps the earlier (valid) handback instead of losing it.
459
+ const visualReport = phase === 'reporting' && askedForVisual;
460
+ if (visualReport && !next.handback && parsed.handback) break;
407
461
  // A handback-only follow-up keeps the substantive report from before.
408
462
  parsed = { ...next, report: next.report || parsed.report };
409
463
  if (parsed.error && !askedForHandback && Date.now() < deadline) {
@@ -412,28 +466,45 @@ async function execute(inv, opts = {}) {
412
466
  phase = 'reporting';
413
467
  continue;
414
468
  }
469
+ if (preflight) break; // read-only: zcode runs nothing
415
470
  if (parsed.handback && parsed.handback.status === 'blocked') break;
416
- if (!inv.checks.length) break;
417
-
418
- setPhase('checking', { count: inv.checks.length });
419
- const preChecks = before ? snapshot(root) : null;
420
- checks = await runChecks(inv.checks, {
421
- cwd: record.cwd,
422
- timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
423
- signal,
424
- onEvent: relay,
425
- });
426
- if (preChecks) {
427
- for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
428
- }
429
- if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
430
- throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
471
+ if (!inv.checks.length && !visualDeclared.length) break;
472
+
473
+ // The visual report-only turn changes nothing: the checks already passed.
474
+ if (inv.checks.length && !visualReport) {
475
+ setPhase('checking', { count: inv.checks.length });
476
+ const preChecks = before ? snapshot(root) : null;
477
+ checks = await runChecks(inv.checks, {
478
+ cwd: record.cwd,
479
+ env: { ...env, ...sessionEnv },
480
+ timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
481
+ signal,
482
+ onEvent: relay,
483
+ });
484
+ if (preChecks) {
485
+ for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
486
+ }
487
+ if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
488
+ throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
489
+ }
431
490
  }
432
491
  const failures = checks.filter((c) => !c.ok);
433
- if (!failures.length || repairs >= inv.maxRepairs) break;
492
+ // Unmet visual criteria share the repair budget with failing checks.
493
+ const gaps = visualGaps(parsed.handback, visualDeclared);
494
+ // Criteria merely left out of the handback get one cheap report-only
495
+ // follow-up that does not consume the repair budget.
496
+ if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
497
+ && !(signal && signal.aborted) && Date.now() < deadline) {
498
+ askedForVisual = true;
499
+ prompt = buildVisualReportRequest(gaps.missing);
500
+ phase = 'reporting';
501
+ continue;
502
+ }
503
+ const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
504
+ if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
434
505
  if ((signal && signal.aborted) || Date.now() >= deadline) break;
435
506
  repairs++;
436
- prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
507
+ prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs, visual: visualUnmet });
437
508
  phase = 'repairing';
438
509
  }
439
510
 
@@ -451,6 +522,7 @@ async function execute(inv, opts = {}) {
451
522
  error = err;
452
523
  } finally {
453
524
  if (client) client.kill();
525
+ if (releasePort) releasePort();
454
526
  lease.release();
455
527
  releaseClaims();
456
528
  }
@@ -475,21 +547,38 @@ async function execute(inv, opts = {}) {
475
547
  const after = before ? snapshot(root) : null;
476
548
  const diff = before ? diffSnapshots(before, after) : null;
477
549
  const owns = inv.owns || [];
550
+ const touch = inv.touch || [];
478
551
  const files = (diff || [])
479
- .filter((f) => !f.path.startsWith('.zcode/'))
552
+ .filter((f) => !isZcodePath(f.path))
480
553
  .map((f) => {
481
554
  const owned = isOwned(f.path, owns);
482
- const by = owned
555
+ // --touch: edits allowed but reported, never a scope violation.
556
+ const touchedByDesign = !owned && touch.length > 0 && isOwned(f.path, touch);
557
+ const by = owned || touchedByDesign
483
558
  ? null
484
559
  : [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
485
- const fromChecks = !owned && !by && checkTouched.has(f.path);
486
- return { ...f, owned, ...(by ? { concurrentSession: by[0] } : {}), ...(fromChecks ? { fromChecks: true } : {}) };
560
+ const fromChecks = !owned && !touchedByDesign && !by && checkTouched.has(f.path);
561
+ return {
562
+ ...f,
563
+ owned,
564
+ ...(touchedByDesign ? { touched: true } : {}),
565
+ ...(by ? { concurrentSession: by[0] } : {}),
566
+ ...(fromChecks ? { fromChecks: true } : {}),
567
+ };
487
568
  });
569
+ const touched = files.filter((f) => f.touched).map((f) => f.path);
488
570
  const scopeViolations = files
489
- .filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
571
+ .filter((f) => !f.owned && !f.touched && !f.concurrentSession && !f.fromChecks)
490
572
  .map((f) => f.path);
491
573
 
492
574
  const handback = parsed.handback;
575
+ // Preflight is read-only: anything it changed (that no concurrent session
576
+ // owns) is a protocol breach.
577
+ const preflightWrote = preflight
578
+ ? files
579
+ .filter((f) => ![...concurrent.values()].some((o) => o.length && isOwned(f.path, o)))
580
+ .map((f) => f.path)
581
+ : [];
493
582
  let { status, issues } = deriveStatus({
494
583
  kind: inv.kind,
495
584
  error: error && !cancelled && error.kind !== 'user_input' ? error : null,
@@ -498,12 +587,16 @@ async function execute(inv, opts = {}) {
498
587
  handback,
499
588
  handbackError: parsed.error,
500
589
  checks,
501
- scopeViolations,
590
+ scopeViolations: preflight ? [] : scopeViolations,
591
+ preflight,
592
+ preflightWrote,
593
+ visualDeclared,
502
594
  });
503
595
 
504
596
  let errorOut = null;
505
597
  if (status === 'failed') {
506
598
  if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
599
+ else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
507
600
  else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
508
601
  } else if (status === 'cancelled') {
509
602
  errorOut = { kind: 'cancelled', message: 'cancelled' };
@@ -521,10 +614,19 @@ async function execute(inv, opts = {}) {
521
614
  }
522
615
 
523
616
  const notVerified = [];
524
- if (inv.kind === 'task') {
617
+ if (inv.kind === 'task' && preflight) {
618
+ notVerified.push('preflight: the questions come from reading only; nothing was built, run or checked');
619
+ } else if (inv.kind === 'task') {
525
620
  if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
526
621
  if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
527
622
  if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
623
+ if (handback && handback.units.length) {
624
+ notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
625
+ }
626
+ if (visualDeclared.length) {
627
+ const reported = handback ? handback.visual : [];
628
+ notVerified.push(`visual criteria are the executor's judgement from its own screenshots (${reported.filter((v) => v.met === true).length} of ${reported.length} reported met); zcode ran no pixel check`);
629
+ }
528
630
  }
529
631
  if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
530
632
  if (subagentNote) issues.push(subagentNote);
@@ -542,7 +644,9 @@ async function execute(inv, opts = {}) {
542
644
  id,
543
645
  kind: inv.kind,
544
646
  label: inv.label || null,
545
- summary: handback && handback.summary ? handback.summary : '',
647
+ summary: preflight && status === 'completed'
648
+ ? 'no open questions'
649
+ : handback && handback.summary ? handback.summary : '',
546
650
  response: inv.kind === 'task' ? parsed.report : response,
547
651
  issues,
548
652
  questions,
@@ -553,6 +657,14 @@ async function execute(inv, opts = {}) {
553
657
  files,
554
658
  },
555
659
  scopeViolations,
660
+ touched,
661
+ ...(preflight ? { preflight: true } : {}),
662
+ decisions: handback ? handback.decisions : [],
663
+ visual: handback ? handback.visual : [],
664
+ units: handback ? handback.units : [],
665
+ sessionEnv: sessionEnv
666
+ ? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
667
+ : null,
556
668
  checks: checks.map((c) => ({
557
669
  command: c.command,
558
670
  ok: c.ok,
package/lib/isolation.js CHANGED
@@ -360,7 +360,9 @@ function migrateRoot(env = process.env) {
360
360
 
361
361
  // Env for the engine child process (§6.3): HOME=scratch, pinned builtin
362
362
  // config, and the uncontrolled overrides deleted. cwd is set by the caller.
363
- function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
363
+ // `sessionEnv` (ZCODE_SESSION_ID, ZCODE_PORT, …) is applied last so nothing
364
+ // inherited can shadow it; the engine's tools inherit it.
365
+ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env, sessionEnv = {} }) {
364
366
  const childEnv = { ...parentEnv };
365
367
  const realHome = parentEnv.HOME || os.homedir();
366
368
  // The scratch HOME hides the user's tool caches. Point Playwright and npm
@@ -385,6 +387,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
385
387
  ]) {
386
388
  delete childEnv[name];
387
389
  }
390
+ Object.assign(childEnv, sessionEnv);
388
391
  return childEnv;
389
392
  }
390
393
 
package/lib/models.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // every engine request itself, so nothing outside this allowlist can reach
4
4
  // the wire.
5
5
 
6
+ const fs = require('node:fs');
7
+
6
8
  const DEFAULT_MODEL = 'GLM-5.3-Flash';
7
9
 
8
10
  // Case-insensitive aliases → official ids (exact ids pass through verbatim;
@@ -43,7 +45,89 @@ function unknownModelMessage(input) {
43
45
  );
44
46
  }
45
47
 
48
+ // --- supported effort levels per model ---------------------------------------
49
+ //
50
+ // The engine rejects an unsupported reasoning level only after it has been
51
+ // spawned and a session opened (GLM-5.3 refused `medium` that way in a real
52
+ // run). The levels live in the engine's builtin provider config
53
+ // (config/provider/zcode-builtin.json, the path `zcode doctor` reports):
54
+ // modelConfigRules.modelRules[] = {modelMatch: <regex>, config:
55
+ // {optionSpecs: {reasoningLevel: {values: […]}}}}. Later rules refine
56
+ // earlier ones (a generic `glm-5` rule lists disabled/enabled; the
57
+ // `glm-5.3(-flash)?` rule after it lists low/high/max), so the LAST matching
58
+ // rule that lists values wins. Unreadable or unmatched → the table below
59
+ // (observed in zcode-builtin.json shipped with ZCode 0.16.x).
60
+ const FALLBACK_LEVELS = {
61
+ 'GLM-5.3': ['low', 'high', 'max'],
62
+ 'GLM-5.3-Flash': ['low', 'high', 'max'],
63
+ };
64
+ const OFFICIAL_IDS = Object.keys(FALLBACK_LEVELS);
65
+
66
+ const levelCache = new Map(); // path:mtime:size → parsed rules
67
+
68
+ function readRules(file) {
69
+ try {
70
+ const st = fs.statSync(file);
71
+ const key = `${file}:${st.mtimeMs}:${st.size}`;
72
+ if (!levelCache.has(key)) {
73
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ const rules = json && json.config && json.config.modelConfigRules && json.config.modelConfigRules.modelRules;
75
+ levelCache.set(key, Array.isArray(rules) ? rules : []);
76
+ }
77
+ return levelCache.get(key);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function builtinConfigPath(env) {
84
+ try {
85
+ const { resolveEngine, resolveBuiltinProviderConfig } = require('./engine.js');
86
+ return resolveBuiltinProviderConfig(resolveEngine(env), env);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Efforts (a subset of EFFORT_LEVELS, in enum order) the model accepts.
93
+ function supportedEfforts(model, { env = process.env, builtinConfig } = {}) {
94
+ const file = builtinConfig || builtinConfigPath(env);
95
+ let values = null;
96
+ if (file) {
97
+ for (const rule of readRules(file)) {
98
+ let match = false;
99
+ try {
100
+ match = new RegExp(`^(?:${rule.modelMatch})$`, 'i').test(model);
101
+ } catch {
102
+ /* a malformed pattern matches nothing */
103
+ }
104
+ const v = match && rule.config && rule.config.optionSpecs && rule.config.optionSpecs.reasoningLevel
105
+ && rule.config.optionSpecs.reasoningLevel.values;
106
+ if (Array.isArray(v)) values = v;
107
+ }
108
+ }
109
+ const levels = values ? EFFORT_LEVELS.filter((l) => values.includes(l)) : [];
110
+ return levels.length ? levels : FALLBACK_LEVELS[model] || [];
111
+ }
112
+
113
+ // {modelId: [levels]} for every supported model (doctor).
114
+ function modelLevels(opts) {
115
+ return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportedEfforts(id, opts)]));
116
+ }
117
+
118
+ // null when `effort` is usable with `model`, else the one-line refusal.
119
+ // `source` names where the value came from ("--effort", "contract effort").
120
+ function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
121
+ const levels = supportedEfforts(model, opts);
122
+ if (levels.includes(effort)) return null;
123
+ return `${source} ${effort} is not supported by ${model} (supported: ${levels.join(', ')}).`;
124
+ }
125
+
46
126
  module.exports = {
127
+ supportedEfforts,
128
+ modelLevels,
129
+ effortProblem,
130
+ FALLBACK_LEVELS,
47
131
  DEFAULT_MODEL,
48
132
  normalizeModel,
49
133
  unknownModelMessage,