project-autopilot 12.0.0 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +10 -10
  2. package/SKILL.md +1 -3
  3. package/adapters/agents.json +11 -1
  4. package/config.example.json +5 -3
  5. package/config.schema.json +28 -9
  6. package/docs/configuration.md +5 -3
  7. package/package.json +1 -1
  8. package/scripts/adapter-drivers.mjs +209 -22
  9. package/scripts/agent-contract.mjs +24 -2
  10. package/scripts/agent-invocation.mjs +32 -5
  11. package/scripts/autopilot.mjs +787 -222
  12. package/scripts/config-layout.mjs +6 -0
  13. package/scripts/config-migration.mjs +3 -3
  14. package/scripts/config-validation.mjs +12 -4
  15. package/scripts/e2e-detect.mjs +30 -0
  16. package/scripts/escalation-naming.mjs +19 -0
  17. package/scripts/failure-contract.mjs +25 -9
  18. package/scripts/gen-config-schema.mjs +7 -0
  19. package/scripts/idle-hang-watchdog.mjs +34 -7
  20. package/scripts/implementation-baseline.mjs +95 -4
  21. package/scripts/model-discovery.mjs +176 -38
  22. package/scripts/opencode-generation.mjs +116 -0
  23. package/scripts/per-task-budget.mjs +11 -7
  24. package/scripts/plan-review-rounds.mjs +164 -9
  25. package/scripts/planning-boundary.mjs +16 -6
  26. package/scripts/planning-continuation.mjs +43 -23
  27. package/scripts/planning-review-decision.mjs +3 -0
  28. package/scripts/planning-review-wiring.mjs +12 -8
  29. package/scripts/relay-guards.mjs +133 -11
  30. package/scripts/review-canary.mjs +5 -2
  31. package/scripts/role-fallback.mjs +23 -8
  32. package/scripts/run-history.mjs +32 -1
  33. package/scripts/run-observability.mjs +46 -2
  34. package/scripts/setup-discovery.mjs +2 -1
  35. package/scripts/setup.mjs +183 -81
  36. package/scripts/skill-copy.mjs +49 -0
  37. package/scripts/task-requirements.mjs +24 -0
  38. package/scripts/watch-report.mjs +146 -21
  39. package/scripts/watch-summary.mjs +26 -3
  40. package/scripts/plan-mode-model-note.mjs +0 -27
@@ -5,12 +5,44 @@ import {resolveAdapterTransport} from './adapter-transport.mjs';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import {fileURLToPath} from 'node:url';
8
+ import {
9
+ isOpenCodeAdapter,
10
+ openCodeHelpArgs,
11
+ cachedOpenCodeGeneration,
12
+ rememberOpenCodeGeneration
13
+ } from './opencode-generation.mjs';
8
14
 
9
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
16
  const DOCUMENTED_MODELS_DIR = path.join(__dirname, '..', 'adapters', 'documented-models');
11
17
  const DOCUMENTED_EFFORT_DIR = path.join(__dirname, '..', 'adapters', 'documented-effort');
12
18
 
19
+ // Short probe limit for --help/--version capability detection. Model-list
20
+ // discovery is a different, slower call (a real `opencode models --verbose`
21
+ // can take >10 s on a busy machine and print thousands of lines), so it gets
22
+ // its own, more realistic limit below.
13
23
  export const DISCOVERY_TIMEOUT_MS = 10_000;
24
+ export const MODEL_DISCOVERY_TIMEOUT_MS = 30_000;
25
+
26
+ // Tests (and operators with unusually slow providers) can override the
27
+ // model-list limit. Read at call time so tests can set it after importing
28
+ // this module.
29
+ export function modelDiscoveryTimeoutMs(){
30
+ const raw = Number(process.env.AP_MODEL_DISCOVERY_TIMEOUT_MS);
31
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : MODEL_DISCOVERY_TIMEOUT_MS;
32
+ }
33
+
34
+ export function modelDiscoveryTimeoutLabel(ms = modelDiscoveryTimeoutMs()){
35
+ const seconds = ms / 1000;
36
+ return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)} s`;
37
+ }
38
+
39
+ // Backlog #5: a cold first start (opencode rebuilding its data for a new
40
+ // project) can exceed the normal model-list budget even though the CLI is
41
+ // working. Setup/config retry that timed-out probe ONCE with twice the
42
+ // effective budget; the env override still scales both attempts.
43
+ export function modelDiscoveryRetryTimeoutMs(){
44
+ return modelDiscoveryTimeoutMs() * 2;
45
+ }
14
46
 
15
47
  function cleanLines(text){
16
48
  return String(text||'').split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
@@ -133,14 +165,22 @@ function flagFromHelp(text, aliases){
133
165
  export async function discoverCapabilities(meta, deadline){
134
166
  if(!deadline) deadline = newDeadline();
135
167
  const help=await collectHelpText(meta, deadline);
168
+ const openCodeGeneration = isOpenCodeAdapter(meta)
169
+ ? rememberOpenCodeGeneration(meta, help)
170
+ : null;
171
+ // OpenCode 2.x has no reasoning/effort flag: the level is a model suffix.
172
+ // Its help names --model's value as "provider/model#variant" instead.
173
+ const openCodeModelSuffix = openCodeGeneration === "2";
136
174
  const modelFlag = flagFromHelp(help, ['--model','-m']);
137
175
  // Try the adapter's OWN declared effort_flag first (e.g. OpenCode's is
138
176
  // "--variant" — its reasoning/effort levels ARE its model variants, there
139
177
  // is no separate "--effort" flag), then the two generic literal names as
140
178
  // a fallback for adapters that don't declare one.
141
- const effortFlag = meta.effort_mode === 'config'
142
- ? flagFromHelp(help, ['--config'])
143
- : flagFromHelp(help, [meta.effort_flag, '--effort', '--reasoning-effort'].filter(Boolean));
179
+ const effortFlag = openCodeModelSuffix
180
+ ? null
181
+ : meta.effort_mode === 'config'
182
+ ? flagFromHelp(help, ['--config'])
183
+ : flagFromHelp(help, [meta.effort_flag, '--effort', '--reasoning-effort'].filter(Boolean));
144
184
  const variantFlagRaw = flagFromHelp(help, ['--variant']);
145
185
  // If the flag just detected for "variant" is the exact same literal flag
146
186
  // already claimed by "effort" above (OpenCode's case once help text from
@@ -159,16 +199,26 @@ export async function discoverCapabilities(meta, deadline){
159
199
  const flag = String(arg).replace(/^.*?--/, "--");
160
200
  return help.includes(flag);
161
201
  });
162
- return {
202
+ const capabilities = {
163
203
  model: !!modelFlag,
164
204
  model_flag: modelFlag,
165
- effort: !!effortFlag,
205
+ effort: openCodeModelSuffix ? !!modelFlag : !!effortFlag,
166
206
  effort_flag: effortFlag,
167
- variant: !!variantFlag,
168
- variant_flag: variantFlag,
207
+ variant: openCodeModelSuffix ? false : !!variantFlag,
208
+ variant_flag: openCodeModelSuffix ? null : variantFlag,
169
209
  resume: resumeSupported,
170
210
  discovery: meta.model_discovery || 'manual'
171
211
  };
212
+ if (isOpenCodeAdapter(meta)) {
213
+ capabilities.opencode_generation = openCodeGeneration;
214
+ if (openCodeModelSuffix) {
215
+ capabilities.effort_mode = 'model_suffix';
216
+ capabilities.standalone = true;
217
+ } else {
218
+ capabilities.standalone = false;
219
+ }
220
+ }
221
+ return capabilities;
172
222
  }
173
223
 
174
224
  export async function discoverAdapter(meta){
@@ -258,20 +308,83 @@ export function parseOpenCodeVerboseOutput(text){
258
308
  return out;
259
309
  }
260
310
 
261
- async function discoverFromOpenCode(binary, deadline){
262
- if(!deadline) deadline = newDeadline();
263
- let r=await spawnWithDeadline(binary,['models','--verbose'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
311
+ // OpenCode 2.x's machine-readable catalog: `api GET /api/model` returns
312
+ // {location, data:[{providerID,id,name,variants:[{id,settings}],status,enabled}]}.
313
+ // The per-model variant ids are the reasoning/effort levels (the same
314
+ // dimension 1.x reports through `models --verbose`). A disabled or
315
+ // non-active entry is not selectable, mirroring the usable-only 1.x list.
316
+ export function parseOpenCodeApiModels(text){
317
+ let parsed;
318
+ try{ parsed=JSON.parse(String(text||'').trim()); }catch{ return []; }
319
+ const data=Array.isArray(parsed) ? parsed : parsed?.data;
320
+ if(!Array.isArray(data)) return [];
321
+ const out=[];
322
+ const seen=new Set();
323
+ for(const model of data){
324
+ if(!model || typeof model!=='object') continue;
325
+ const provider=typeof model.providerID==='string' ? model.providerID : '';
326
+ const modelId=typeof model.id==='string' ? model.id : (typeof model.modelID==='string' ? model.modelID : '');
327
+ if(!provider || !modelId) continue;
328
+ if(model.enabled === false) continue;
329
+ if(typeof model.status === 'string' && model.status !== 'active') continue;
330
+ const id=`${provider}/${modelId}`;
331
+ if(seen.has(id)) continue;
332
+ seen.add(id);
333
+ const variants=Array.isArray(model.variants)
334
+ ? model.variants.map(v=>typeof v==='string' ? v : v?.id).filter(Boolean)
335
+ : [];
336
+ out.push({id,label:model.name||model.modelID||modelId,reasoning:variants,variants});
337
+ }
338
+ return out;
339
+ }
340
+
341
+ async function resolveOpenCodeGeneration(meta, deadline){
342
+ const cached=cachedOpenCodeGeneration(meta);
343
+ if(cached !== undefined) return {generation:cached, timedOut:false};
344
+ const r=await spawnWithDeadline(meta.binary,openCodeHelpArgs(meta),{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
345
+ if(r.timedOut) return {generation:null, timedOut:true};
346
+ return {generation:rememberOpenCodeGeneration(meta, `${r.stdout||''}\n${r.stderr||''}`), timedOut:false};
347
+ }
348
+
349
+ async function discoverFromOpenCode(meta, deadline){
350
+ if(!deadline) deadline = newDeadline(modelDiscoveryTimeoutMs());
351
+ const generation=await resolveOpenCodeGeneration(meta, deadline);
352
+ if(generation.timedOut) return {items:[], status:'timeout'};
353
+ let timedOut=false;
354
+ if(generation.generation === '2'){
355
+ let r=await spawnWithDeadline(meta.binary,['api','GET','/api/model'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
356
+ if(r.timedOut) timedOut=true;
357
+ if(r.status===0 && r.stdout){
358
+ const items=parseOpenCodeApiModels(r.stdout);
359
+ if(items.length) return {items, status:'reported'};
360
+ }
361
+ if(remaining(deadline) <= 0) return {items:[], status:timedOut ? 'timeout' : 'unsupported'};
362
+ // 2.x `models --verbose` no longer exists; the plain list still gives
363
+ // ids, so it remains the last-resort fallback (levels unavailable).
364
+ r=await spawnWithDeadline(meta.binary,['models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
365
+ if(r.timedOut) timedOut=true;
366
+ if(r.status!==0 || !r.stdout) return {items:[], status:timedOut ? 'timeout' : 'unsupported'};
367
+ const items=unique(cleanLines(r.stdout)
368
+ .filter(line=>/^[A-Za-z0-9._-]+\/[A-Za-z0-9._:@+-]+(?:\s|$)/.test(line))
369
+ .map(line=>line.split(/\s+/)[0]))
370
+ .map(id=>({id,label:id,reasoning:[],variants:discoverOpenCodeVariants(id)}));
371
+ return {items, status:items.length ? 'reported' : 'unsupported'};
372
+ }
373
+ let r=await spawnWithDeadline(meta.binary,['models','--verbose'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
374
+ if(r.timedOut) timedOut=true;
264
375
  if(r.status===0 && r.stdout){
265
376
  const verbose=parseOpenCodeVerboseOutput(r.stdout);
266
- if(verbose.length) return verbose;
377
+ if(verbose.length) return {items:verbose, status:'reported'};
267
378
  }
268
- if(remaining(deadline) <= 0) return [];
269
- r=await spawnWithDeadline(binary,['models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
270
- if(r.status!==0 || !r.stdout) return [];
271
- return unique(cleanLines(r.stdout)
379
+ if(remaining(deadline) <= 0) return {items:[], status:timedOut ? 'timeout' : 'unsupported'};
380
+ r=await spawnWithDeadline(meta.binary,['models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
381
+ if(r.timedOut) timedOut=true;
382
+ if(r.status!==0 || !r.stdout) return {items:[], status:timedOut ? 'timeout' : 'unsupported'};
383
+ const items=unique(cleanLines(r.stdout)
272
384
  .filter(line=>/^[A-Za-z0-9._-]+\/[A-Za-z0-9._:@+-]+(?:\s|$)/.test(line))
273
385
  .map(line=>line.split(/\s+/)[0]))
274
386
  .map(id=>({id,label:id,reasoning:[],variants:discoverOpenCodeVariants(id)}));
387
+ return {items, status:items.length ? 'reported' : 'unsupported'};
275
388
  }
276
389
 
277
390
 
@@ -280,12 +393,14 @@ export function parseAntigravityModels(text){
280
393
  }
281
394
 
282
395
  async function discoverAntigravityModels(binary, deadline){
283
- if(!deadline) deadline = newDeadline();
396
+ if(!deadline) deadline = newDeadline(modelDiscoveryTimeoutMs());
284
397
  const transport=resolveAdapterTransport({driver:adapterDriver('antigravity'),request:{binary,args:['models']}});
285
- if(!transport.available) return [];
398
+ if(!transport.available) return {items:[], status:'unsupported'};
286
399
  const r=await spawnWithDeadline(transport.invocation.binary,transport.invocation.args,{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
287
- if(r.status!==0 || !r.stdout) return [];
288
- return parseAntigravityOutput(r.stdout);
400
+ if(r.timedOut) return {items:[], status:'timeout'};
401
+ if(r.status!==0 || !r.stdout) return {items:[], status:'unsupported'};
402
+ const items=parseAntigravityOutput(r.stdout);
403
+ return {items, status:items.length ? 'reported' : 'unsupported'};
289
404
  }
290
405
 
291
406
  function parseAntigravityOutput(text){
@@ -316,14 +431,20 @@ function parseAntigravityOutput(text){
316
431
  }
317
432
 
318
433
  async function discoverFromCursor(binary, deadline){
319
- if(!deadline) deadline = newDeadline();
434
+ if(!deadline) deadline = newDeadline(modelDiscoveryTimeoutMs());
435
+ let timedOut=false;
320
436
  let r=await spawnWithDeadline(binary,['models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
321
- if(r.status!==0 && remaining(deadline) > 0) r=await spawnWithDeadline(binary,['--list-models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
322
- if(r.status!==0 || !r.stdout) return [];
323
- return unique(cleanLines(r.stdout)
437
+ if(r.timedOut) timedOut=true;
438
+ if(r.status!==0 && remaining(deadline) > 0){
439
+ r=await spawnWithDeadline(binary,['--list-models'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}, deadline);
440
+ if(r.timedOut) timedOut=true;
441
+ }
442
+ if(r.status!==0 || !r.stdout) return {items:[], status:timedOut ? 'timeout' : 'unsupported'};
443
+ const items=unique(cleanLines(r.stdout)
324
444
  .filter(line=>!/^(available|models|model|name|id|usage|help|press|cursor agent|available models|model list)\b/i.test(line))
325
445
  .map(line=>line.replace(/^[-*•]\s*/,'').split(/\s{2,}|\t/)[0].trim())
326
446
  .filter(line=>line && line.length<160));
447
+ return {items, status:items.length ? 'reported' : 'unsupported'};
327
448
  }
328
449
 
329
450
  function normalizeCodexModels(result){
@@ -336,7 +457,8 @@ function normalizeCodexModels(result){
336
457
  })).filter(m=>m.id);
337
458
  }
338
459
 
339
- function discoverFromCodexAppServer(binary){
460
+ function discoverFromCodexAppServer(binary, deadline){
461
+ if(!deadline) deadline = newDeadline(modelDiscoveryTimeoutMs());
340
462
  return new Promise(resolve => {
341
463
  const child = spawnAgent(binary,['app-server','--stdio'],{encoding:'utf8',stdio:['pipe','pipe','ignore']});
342
464
  let done = false;
@@ -344,8 +466,8 @@ function discoverFromCodexAppServer(binary){
344
466
  if(done) return;
345
467
  done = true;
346
468
  killTree(child);
347
- resolve([]);
348
- }, 7000);
469
+ resolve({items:[], status:'timeout'});
470
+ }, remaining(deadline));
349
471
  const finish = (result) => {
350
472
  if(done) return;
351
473
  done = true;
@@ -363,15 +485,18 @@ function discoverFromCodexAppServer(binary){
363
485
  try{ const msg=JSON.parse(line); replies.push(msg); }catch{}
364
486
  }
365
487
  const modelReply=replies.find(x=>x?.id===2 && x?.result);
366
- if(modelReply) finish(normalizeCodexModels(modelReply));
488
+ if(modelReply){
489
+ const items=normalizeCodexModels(modelReply);
490
+ finish({items, status:items.length ? 'reported' : 'unsupported'});
491
+ }
367
492
  });
368
- child.on('error',()=>finish([]));
369
- child.on('exit',()=>{ if(!replies.find(x=>x?.id===2)) finish([]); });
493
+ child.on('error',()=>finish({items:[], status:'error'}));
494
+ child.on('exit',()=>{ if(!replies.find(x=>x?.id===2)) finish({items:[], status:'unsupported'}); });
370
495
  try{
371
496
  child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{clientInfo:{name:'project-autopilot',version:'10.2.0'}}})+'\n');
372
497
  child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'initialized',params:{}})+'\n');
373
498
  child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:2,method:'model/list',params:{includeHidden:false}})+'\n');
374
- }catch{ finish([]); }
499
+ }catch{ finish({items:[], status:'unsupported'}); }
375
500
  });
376
501
  }
377
502
 
@@ -380,17 +505,30 @@ function discoverFromCodexAppServer(binary){
380
505
  * Empty means no supported machine-readable discovery is available.
381
506
  * Autopilot never invents a catalog.
382
507
  * The return value is Promise<array|string> because Codex discovery is async.
508
+ * The array carries a non-enumerable `status` side field
509
+ * ("reported"|"timeout"|"unsupported"|"error") so callers can tell a slow
510
+ * model list from a CLI that genuinely has no machine-readable one.
383
511
  */
384
- export async function discoverModelCatalog(meta){
512
+ function catalogStatus(items, status){
513
+ Object.defineProperty(items, 'status', {value:status, enumerable:false, configurable:true});
514
+ return items;
515
+ }
516
+
517
+ export async function discoverModelCatalog(meta, {timeoutMs = modelDiscoveryTimeoutMs()} = {}){
385
518
  const binary=meta.binary; const kind=meta.model_discovery;
386
- const deadline = newDeadline();
519
+ const deadline = newDeadline(timeoutMs);
387
520
  try{
388
- if(kind==='opencode_models') return await discoverFromOpenCode(binary, deadline);
389
- if(kind==='cursor_models') return (await discoverFromCursor(binary, deadline)).map(id=>({id,label:id,reasoning:[],variants:[]}));
390
- if(kind==='codex_app_server') return await discoverFromCodexAppServer(binary);
391
- if(kind==='antigravity_models') return await discoverAntigravityModels(binary, deadline);
392
- }catch{}
393
- return [];
521
+ let result;
522
+ if(kind==='opencode_models') result=await discoverFromOpenCode(meta, deadline);
523
+ else if(kind==='cursor_models'){
524
+ const cursor=await discoverFromCursor(binary, deadline);
525
+ result={items:cursor.items.map(id=>({id,label:id,reasoning:[],variants:[]})), status:cursor.status};
526
+ }
527
+ else if(kind==='codex_app_server') result=await discoverFromCodexAppServer(binary, deadline);
528
+ else if(kind==='antigravity_models') result=await discoverAntigravityModels(binary, deadline);
529
+ else result={items:[], status:'unsupported'};
530
+ return catalogStatus(result.items, result.status);
531
+ }catch{ return catalogStatus([], 'error'); }
394
532
  }
395
533
 
396
534
  export async function discoverModels(meta){
@@ -0,0 +1,116 @@
1
+ // OpenCode 1.x / 2.x generation detection, cached per binary.
2
+ //
3
+ // 1.x (`opencode-ai`) exposes reasoning/effort through `run --variant` and
4
+ // lists per-model levels via `models --verbose`.
5
+ // 2.x (`@opencode/cli`) has no `run --variant`: the level is a model suffix,
6
+ // `--model provider/model#level`, and `models --verbose` is gone (levels come
7
+ // from `opencode api GET /api/model`). It also needs `--standalone` for an
8
+ // OPENCODE_CONFIG_CONTENT-defined agent (e.g. autopilot_readonly) to be seen.
9
+ //
10
+ // The probe is a real `run --help` launch, memoized per process so a mission
11
+ // pays for it once. Capability discovery normally fills this cache before any
12
+ // launch, so buildInvocation/verifyAgentInvocation just read it. A failed or
13
+ // unknown probe deliberately falls back to the 1.x path: that keeps the
14
+ // pre-2.x behaviour byte-for-byte identical when the help text cannot be read.
15
+ //
16
+ // The cache key is the adapter BINARY only (resolved path, else name), never
17
+ // the help argv: model discovery, capability discovery, the driver and the
18
+ // invocation verifier all describe the same installed CLI, so one probe must
19
+ // serve all of them even when a caller passes a partial adapter object
20
+ // (`{binary}`) and another the full manifest (with `help_args`).
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import {spawnAgentSync, resolveAgentBinary} from "./agent-process.mjs";
24
+
25
+ export const OPENCODE_HELP_ARGS = Object.freeze(["run", "--help"]);
26
+ export const OPENCODE_GENERATION_1 = "1";
27
+ export const OPENCODE_GENERATION_2 = "2";
28
+
29
+ const generationCache = new Map();
30
+
31
+ export function isOpenCodeAdapter(adapter = {}) {
32
+ if (!adapter || typeof adapter !== "object") return false;
33
+ if (adapter.driver === "opencode" || adapter.model_discovery === "opencode_models") return true;
34
+ // Manifest-shaped adapter without a driver/kind (tests and older callers).
35
+ return adapter.effort_flag === "--variant"
36
+ && /(^|[\\/])opencode(?:\.(?:exe|cmd|bat))?$/i.test(String(adapter.binary || ""));
37
+ }
38
+
39
+ export function openCodeHelpArgs(adapter = {}) {
40
+ return Array.isArray(adapter.help_args) && adapter.help_args.length
41
+ ? adapter.help_args.map(String)
42
+ : [...OPENCODE_HELP_ARGS];
43
+ }
44
+
45
+ function generationKey(adapter = {}) {
46
+ const binary = String(adapter?.binary || "opencode");
47
+ let resolved = binary;
48
+ try {
49
+ if (path.isAbsolute(binary) || /[\\/]/.test(binary)) {
50
+ resolved = fs.existsSync(binary) ? fs.realpathSync(binary) : path.resolve(binary);
51
+ } else {
52
+ resolved = resolveAgentBinary(binary) || binary;
53
+ if (resolved && fs.existsSync(resolved)) resolved = fs.realpathSync(resolved);
54
+ }
55
+ } catch { /* keep the caller-supplied name */ }
56
+ return resolved;
57
+ }
58
+
59
+ export function generationFromHelp(text) {
60
+ const value = String(text || "");
61
+ // 1.x help names the flag itself; 2.x help names it only inside the model
62
+ // value description. Check the real flag first so a 2.x help that happens
63
+ // to mention the word "variant" elsewhere is not misclassified.
64
+ if (/(?:^|\s)--variant(?:\s|=|,|$)/m.test(value)) return OPENCODE_GENERATION_1;
65
+ if (/--model\b[\s\S]{0,200}?#variant/.test(value) || /provider\/model#variant/.test(value)) {
66
+ return OPENCODE_GENERATION_2;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function cachedOpenCodeGeneration(adapter = {}) {
72
+ return generationCache.get(generationKey(adapter));
73
+ }
74
+
75
+ // Populate the cache from help text already collected by capability
76
+ // discovery, so model discovery and the invocation builder do not launch the
77
+ // same probe again.
78
+ export function rememberOpenCodeGeneration(adapter, helpText) {
79
+ const generation = generationFromHelp(helpText);
80
+ if (generation) generationCache.set(generationKey(adapter), generation);
81
+ return generation;
82
+ }
83
+
84
+ // Synchronous probe for launch-time callers (applyAgentSelections,
85
+ // verifyAgentInvocation, the driver's buildInvocation). It is a LAST-RESORT
86
+ // fallback: discovery normally populates the same per-binary cache first.
87
+ // An unknown result is cached too so a broken/odd help output is not retried
88
+ // on every role in the same process.
89
+ export function detectOpenCodeGeneration(adapter = {}) {
90
+ const key = generationKey(adapter);
91
+ if (generationCache.has(key)) return generationCache.get(key);
92
+ let generation = null;
93
+ try {
94
+ const result = spawnAgentSync(adapter.binary || "opencode", openCodeHelpArgs(adapter), {
95
+ encoding: "utf8",
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ input: "",
98
+ timeout: 5_000
99
+ });
100
+ generation = generationFromHelp(`${result?.stdout || ""}\n${result?.stderr || ""}`);
101
+ } catch { /* unknown -> 1.x path */ }
102
+ generationCache.set(key, generation);
103
+ return generation;
104
+ }
105
+
106
+ export function openCodeUsesModelSuffix(adapter = {}) {
107
+ return isOpenCodeAdapter(adapter) && detectOpenCodeGeneration(adapter) === OPENCODE_GENERATION_2;
108
+ }
109
+
110
+ // "provider/model#level" -> {model: "provider/model", reasoning: "level"}.
111
+ export function splitOpenCodeModelSuffix(value) {
112
+ const text = String(value || "");
113
+ const at = text.lastIndexOf("#");
114
+ if (at < 0) return {model: text, reasoning: null};
115
+ return {model: text.slice(0, at), reasoning: text.slice(at + 1) || null};
116
+ }
@@ -104,7 +104,9 @@ export const PER_ROLE_CAPS_DEFAULT = {
104
104
  // Backlog #59(b): planner raised with the implementer (operator decision
105
105
  // 2026-10-10; rarely reaches the limit, and a cut re-reads uncached).
106
106
  planner: {maxTurns: 30, maxTokens: 200_000, extraSessions: 2},
107
- plan_reviewer: {maxTurns: 30, maxTokens: 150_000, extraSessions: 2},
107
+ // Operator decision 2026-10-28: 4, because with working hand-off notes two 30-turn sessions cost less than one long session and long full reviews were stopping missions at 2.
108
+ // Backlog #8: 250K so a full plan re-check finishes in one session on a large plan instead of splitting at ~135K.
109
+ plan_reviewer: {maxTurns: 30, maxTokens: 250_000, extraSessions: 4},
108
110
  code_reviewer: {maxTurns: 22, maxTokens: 150_000, extraSessions: 2},
109
111
  // Backlog #59(b): the verifier was cut at 22 in test mission 048 trial 3
110
112
  // and re-ran in full (392K priced) — raised with the implementer.
@@ -114,11 +116,12 @@ export const PER_ROLE_CAPS_DEFAULT = {
114
116
  // Per-role wrap-up token thresholds (mission 031 Phase 1 task 1.1; mission
115
117
  // 044 Phase 1 task 1.3: per-turn values).
116
118
  // Must be strictly below the role's maxTokens so wrap-up can fire before the cap.
117
- // Non-planner review roles use 135K (~one turn headroom below 150K);
118
- // planner uses 180K (~one turn headroom below 200K).
119
+ // planner uses 180K (~one turn headroom below 200K), plan_reviewer uses 225K
120
+ // (~one turn headroom below its 250K cap, backlog #8); the other review roles
121
+ // use 135K (~one turn headroom below 150K).
119
122
  export const PER_ROLE_WRAPUP_TOKENS_DEFAULT = {
120
123
  planner: 180_000,
121
- plan_reviewer: 135_000,
124
+ plan_reviewer: 225_000,
122
125
  code_reviewer: 135_000,
123
126
  verifier: 135_000,
124
127
  };
@@ -832,10 +835,11 @@ export function clearNoProgress(state, scope) {
832
835
  // - anything else non-empty => `tasks ${taskRef}` for multi-token refs,
833
836
  // else `task ${taskRef}`.
834
837
  // - missing/empty taskRef => `${Prefix} task` (framework fallback).
835
- // Prefix is "SpecKit" for framework "speckit" (case-insensitive), else
836
- // "Standalone".
838
+ // Prefix is "SpecKit" for framework "speckit", "GSD" for "gsd"
839
+ // (case-insensitive), else "Standalone".
837
840
  export function noProgressDisplayLabel({taskId, taskRef, framework} = {}) {
838
- const prefix = String(framework ?? "").toLowerCase() === "speckit" ? "SpecKit" : "Standalone";
841
+ const fw = String(framework ?? "").toLowerCase();
842
+ const prefix = fw === "speckit" ? "SpecKit" : fw === "gsd" ? "GSD" : "Standalone";
839
843
  const id = taskId === null || taskId === undefined ? "" : String(taskId).trim();
840
844
  if (id && id.toLowerCase() !== "null" && id.toLowerCase() !== "undefined") return `task ${id}`;
841
845
  const ref = taskRef === null || taskRef === undefined ? "" : String(taskRef).trim();