@aiwg/cli 2026.10.1 → 2026.10.3

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 (166) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +210 -4
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +75 -27
  38. package/dist/src/decision/driver.js +3 -1
  39. package/dist/src/decision/entry.js +38 -6
  40. package/dist/src/decision/heldout/collector.js +10 -1
  41. package/dist/src/decision/index.js +1 -0
  42. package/dist/src/decision/interop.js +60 -8
  43. package/dist/src/decision/ledger.js +839 -0
  44. package/dist/src/decision/validate.js +6 -3
  45. package/dist/src/effects/dispatch-verifier.js +252 -0
  46. package/dist/src/effects/index.js +1 -0
  47. package/dist/src/features/installer.js +2 -2
  48. package/dist/src/flow/graph-metadata.js +5 -0
  49. package/dist/src/gates/providers/loader.js +3 -1
  50. package/dist/src/installation/manager-command.mjs +1 -1
  51. package/dist/src/installation/manager.mjs +17 -2
  52. package/dist/src/lint/runner.js +2 -2
  53. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  54. package/dist/src/mcp/cli.mjs +587 -77
  55. package/dist/src/mcp/config-file.mjs +279 -0
  56. package/dist/src/mcp/config-layers.mjs +348 -0
  57. package/dist/src/mcp/credentials.mjs +484 -0
  58. package/dist/src/mcp/grok-build-config.mjs +16 -26
  59. package/dist/src/mcp/helpers.mjs +106 -2
  60. package/dist/src/mcp/omp-config.mjs +50 -29
  61. package/dist/src/mcp/profiles.js +97 -23
  62. package/dist/src/mcp/profiles.mjs +99 -27
  63. package/dist/src/mcp/registry.js +264 -57
  64. package/dist/src/mcp/registry.mjs +198 -45
  65. package/dist/src/mcp/server.mjs +403 -20
  66. package/dist/src/mcp/toml-string.mjs +9 -0
  67. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  68. package/dist/src/mcp/tool-filters.mjs +357 -0
  69. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  70. package/dist/src/mcp/tools/decision.mjs +21 -3
  71. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  72. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  73. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  74. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  75. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  76. package/dist/src/memory/project-registry.js +2 -2
  77. package/dist/src/mission-protocol/codecs.js +639 -1
  78. package/dist/src/mission-protocol/types.js +110 -0
  79. package/dist/src/mission-protocol/worker-status.js +2 -0
  80. package/dist/src/models/config-loader.js +70 -13
  81. package/dist/src/models/grok-build-models.js +93 -41
  82. package/dist/src/models/model-capabilities.v1.json +3091 -24
  83. package/dist/src/models/model-discovery.js +862 -63
  84. package/dist/src/models/provider-policy.js +625 -11
  85. package/dist/src/models/qualification-digest.mjs +17 -0
  86. package/dist/src/models/resolver.js +259 -0
  87. package/dist/src/models/worker-direct-readiness.js +529 -0
  88. package/dist/src/models/worker-dispatch-offer.js +169 -0
  89. package/dist/src/models/worker-health-readiness.js +131 -0
  90. package/dist/src/models/worker-installation.mjs +879 -0
  91. package/dist/src/models/wrapper-route.js +18 -0
  92. package/dist/src/ops/registry.js +9 -14
  93. package/dist/src/plugins/standalone-packager.js +4 -3
  94. package/dist/src/process/bounded-child-process.mjs +387 -0
  95. package/dist/src/process/cli-command.mjs +75 -0
  96. package/dist/src/providers/capability-matrix.js +8 -0
  97. package/dist/src/providers/capability-matrix.yaml +270 -0
  98. package/dist/src/providers/grok-build-headless.js +43 -21
  99. package/dist/src/providers/provider-definitions.js +9 -1
  100. package/dist/src/providers/provider-definitions.mjs +9 -2
  101. package/dist/src/serve/executor-registry.js +16 -24
  102. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  103. package/dist/src/serve/mission-conductor.js +9 -0
  104. package/dist/src/serve/shared-host-scheduler.js +2 -0
  105. package/dist/src/serve/worker-invocation.js +538 -0
  106. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  107. package/dist/src/sessions/adapter-registry.js +45 -0
  108. package/dist/src/sessions/adapters/generic.js +37 -3
  109. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  110. package/dist/src/sessions/adapters/grokbot.js +423 -24
  111. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  112. package/dist/src/sessions/adapters/muse.js +68 -29
  113. package/dist/src/sessions/adapters/pi.js +1 -0
  114. package/dist/src/sessions/batch-contracts.js +13 -1
  115. package/dist/src/sessions/batch-import.js +23 -42
  116. package/dist/src/sessions/candidates.js +47 -11
  117. package/dist/src/sessions/contracts.js +35 -0
  118. package/dist/src/sessions/coverage-preflight.js +134 -0
  119. package/dist/src/sessions/discovery.js +3 -0
  120. package/dist/src/sessions/grok-build-cli.js +67 -5
  121. package/dist/src/sessions/importer.js +24 -8
  122. package/dist/src/sessions/index.js +2 -0
  123. package/dist/src/sessions/repository.js +54 -13
  124. package/dist/src/sessions/workspace-discovery.js +99 -37
  125. package/dist/src/skills/adapters/clawhub.js +10 -8
  126. package/dist/src/skills/adapters/openclaw.js +10 -7
  127. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  128. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  129. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  130. package/dist/src/smiths/mcpsmith/example.js +1 -1
  131. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  132. package/dist/src/storage/backends/logseq.js +68 -7
  133. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  134. package/dist/src/testing/generators/test-code-generator.js +57 -30
  135. package/dist/src/uhp/client.js +11 -1
  136. package/dist/src/uhp/mission.js +5 -0
  137. package/dist/src/update/checker.mjs +5 -5
  138. package/dist/src/update/notifier.mjs +54 -2
  139. package/dist/src/writing/fidelity.js +18 -1
  140. package/package.json +18 -1
  141. package/schemas/mission-v1.schema.json +2429 -0
  142. package/schemas/models/model-catalog.v1.schema.json +301 -0
  143. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  144. package/schemas/models/model-policy.v1.schema.json +14 -0
  145. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  146. package/tools/_resolve-impl.mjs +39 -0
  147. package/tools/agents/deploy-agents.mjs +15 -2
  148. package/tools/agents/providers/base.mjs +49 -32
  149. package/tools/agents/providers/grokbot.mjs +11 -12
  150. package/tools/agents/providers/hermes.mjs +6 -3
  151. package/tools/agents/providers/omp.mjs +5 -2
  152. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  153. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  154. package/tools/providers/omp-transport.mjs +192 -0
  155. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  156. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  157. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  158. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  159. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  160. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  161. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  162. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  163. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  164. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  165. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  166. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -1,7 +1,10 @@
1
+ import { hasSelectionRelevantConflicts, validateWorkerCatalogDiscovery } from './provider-policy.js';
1
2
  import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
2
4
  import { createInterface } from 'node:readline';
3
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
- import { dirname, join } from 'node:path';
5
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, writeFile } from 'node:fs/promises';
6
+ import { dirname, join, relative } from 'node:path';
7
+ import { constants } from 'node:fs';
5
8
  import { homedir, tmpdir } from 'node:os';
6
9
  export const PROVIDER_DISCOVERY_DECISIONS = {
7
10
  claude: {
@@ -14,8 +17,8 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
14
17
  codex: {
15
18
  provider: 'codex',
16
19
  status: 'native',
17
- interface: 'codex app-server model/list',
18
- reason: 'The app-server JSON-RPC model/list method returns models available to the current Codex account without running a model turn.',
20
+ interface: 'codex app-server model/list; codex debug models (version-scoped fixture)',
21
+ reason: 'The app-server JSON-RPC model/list method returns models available to the current Codex account without running a model turn. The version-scoped codex debug models JSON shape normalizes through the same observation contract.',
19
22
  documentation: 'https://developers.openai.com/codex/app-server/',
20
23
  },
21
24
  copilot: {
@@ -62,9 +65,9 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
62
65
  },
63
66
  muse: {
64
67
  provider: 'muse',
65
- status: 'unsupported',
66
- interface: null,
67
- reason: 'Muse Code is a terminal/CI coding agent with no verified non-interactive model-list command; AIWG does not invent Meta Model API enumeration as Muse Code entitlement.',
68
+ status: 'native',
69
+ interface: 'model-catalog JSON rows (~/.local/share/muse/model-catalog/*.json)',
70
+ reason: 'Bounded model-catalog JSON rows report configured model facts for the current Muse installation without probing model APIs or consuming tokens. Entitlement stays account-scoped and contributor data-use facts remain config-scoped, never row-derived.',
68
71
  documentation: 'https://github.com/jmagly/aiwg/blob/main/docs/architecture/adr-muse-provider-target.md',
69
72
  },
70
73
  opencode: {
@@ -88,6 +91,13 @@ export const PROVIDER_DISCOVERY_DECISIONS = {
88
91
  reason: 'OpenHuman profiles accept semantic model hints but expose no standardized local model-list command.',
89
92
  documentation: 'https://github.com/roctinam/openhuman',
90
93
  },
94
+ ollama: {
95
+ provider: 'ollama',
96
+ status: 'native',
97
+ interface: 'GET /api/tags on the configured local endpoint',
98
+ reason: 'The read-only HTTP model-list endpoint reports the locally present models and per-model digests for the configured Ollama server without probing a remote registry or consuming inference tokens. Declared here (roctinam/aiwg#2899, B-OG-09); no fetcher is implemented against this map by this task — see the local-http worker-adapter registry entry and its adapter docstring for what is actually dispatchable today.',
99
+ documentation: 'https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models',
100
+ },
91
101
  omp: { provider: 'omp', status: 'native', interface: 'omp models --json --no-extensions',
92
102
  reason: 'OMP JSON catalog reports credential-available LLM models; ambient extensions are disabled by default.',
93
103
  documentation: 'https://github.com/can1357/oh-my-pi/blob/5964a0f7649275bcde818f20073193fd032451f2/packages/coding-agent/src/commands/models.ts' },
@@ -178,7 +188,7 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
178
188
  runner(command, ['models', '--pure'], { cwd: tmpdir(), timeoutMs: 15_000 }),
179
189
  ]);
180
190
  if (result.exitCode !== 0) {
181
- const error = result.stderr.trim() || `OpenCode models exited ${result.exitCode}`;
191
+ const error = redactModelDiscoveryText(result.stderr.trim()) || `OpenCode models exited ${result.exitCode}`;
182
192
  return {
183
193
  provider: 'opencode',
184
194
  source: 'native',
@@ -190,10 +200,16 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
190
200
  error,
191
201
  };
192
202
  }
193
- const models = [...new Set(result.stdout.split(/\r?\n/)
194
- .map(line => line.trim())
195
- .filter(line => /^[a-z0-9][a-z0-9._-]*\/\S+$/i.test(line)))]
196
- .map(id => ({ id }));
203
+ const diagnostics = [];
204
+ const models = [...new Set(result.stdout.split(/\r?\n/).flatMap((raw, index) => {
205
+ const line = raw.trim();
206
+ if (!line)
207
+ return [];
208
+ if (/^[a-z0-9][a-z0-9._-]*\/\S+$/i.test(line))
209
+ return [line];
210
+ diagnostics.push(`OpenCode models line ${index + 1}: malformed provider/model row`);
211
+ return [];
212
+ }))].map(id => ({ id }));
197
213
  return {
198
214
  provider: 'opencode',
199
215
  source: 'native',
@@ -201,8 +217,8 @@ export async function discoverOpenCodeModels(command = 'opencode', runner = runM
201
217
  ...(version ? { runtimeVersion: version } : {}),
202
218
  accountScope: 'local-runtime',
203
219
  models,
204
- ...(models.length === 0
205
- ? { errorKind: 'invalid-output', error: 'OpenCode returned no normalized provider/model rows.' }
220
+ ...(diagnostics.length || models.length === 0
221
+ ? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'OpenCode returned no normalized provider/model rows.' }
206
222
  : {}),
207
223
  };
208
224
  }
@@ -213,22 +229,33 @@ export async function discoverPiModels(command = 'pi', runner = runModelDiscover
213
229
  runner(command, ['--list-models'], { cwd: tmpdir(), timeoutMs: 15_000 }),
214
230
  ]);
215
231
  if (result.exitCode !== 0) {
216
- const error = result.stderr.trim() || `Pi --list-models exited ${result.exitCode}`;
232
+ const error = redactModelDiscoveryText(result.stderr.trim()) || `Pi --list-models exited ${result.exitCode}`;
217
233
  return { provider: 'pi', source: 'native', observedAt,
218
234
  ...(version ? { runtimeVersion: version } : {}), accountScope: 'local-runtime',
219
235
  models: [], errorKind: classifyDiscoveryError(error), error };
220
236
  }
221
- const lines = result.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
222
- const models = lines.slice(1).flatMap(line => {
237
+ const diagnostics = [];
238
+ const lines = result.stdout.split(/\r?\n/);
239
+ let headerSeen = false;
240
+ const models = lines.flatMap((raw, index) => {
241
+ const line = raw.trim();
242
+ if (!line)
243
+ return [];
244
+ if (!headerSeen && /^provider\s+model(?:\s|$)/i.test(line)) {
245
+ headerSeen = true;
246
+ return [];
247
+ }
223
248
  const columns = line.split(/\s{2,}/);
224
- if (columns.length < 2 || !/^[a-z0-9][a-z0-9._-]*$/i.test(columns[0]))
249
+ if (columns.length < 2 || !/^[a-z0-9][a-z0-9._-]*$/i.test(columns[0]) || !/^\S+$/.test(columns[1])) {
250
+ diagnostics.push(`Pi --list-models line ${index + 1}: malformed provider/model row`);
225
251
  return [];
252
+ }
226
253
  return [{ id: `${columns[0]}/${columns[1]}` }];
227
254
  });
228
255
  return { provider: 'pi', source: 'native', observedAt,
229
256
  ...(version ? { runtimeVersion: version } : {}), accountScope: 'local-runtime', models,
230
- ...(models.length === 0 ? { errorKind: 'invalid-output',
231
- error: 'Pi returned no parseable provider/model rows.' } : {}) };
257
+ ...(diagnostics.length || models.length === 0 ? { errorKind: 'invalid-output',
258
+ error: diagnostics.join('; ') || 'Pi returned no parseable provider/model rows.' } : {}) };
232
259
  }
233
260
  export async function discoverOpenClawModels(command = 'openclaw', runner = runModelDiscoveryCommand) {
234
261
  const observedAt = new Date().toISOString();
@@ -237,7 +264,7 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
237
264
  runner(command, ['models', 'list', '--json'], { timeoutMs: 15_000 }),
238
265
  ]);
239
266
  if (result.exitCode !== 0) {
240
- const error = result.stderr.trim() || `OpenClaw models list exited ${result.exitCode}`;
267
+ const error = redactModelDiscoveryText(result.stderr.trim()) || `OpenClaw models list exited ${result.exitCode}`;
241
268
  return {
242
269
  provider: 'openclaw',
243
270
  source: 'native',
@@ -251,13 +278,25 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
251
278
  }
252
279
  try {
253
280
  const payload = JSON.parse(result.stdout);
254
- const models = (payload.models ?? []).flatMap(model => typeof model.key === 'string' && model.key.includes('/') && model.available !== false
255
- ? [{
281
+ if (!Array.isArray(payload?.models))
282
+ throw new Error('expected /models array');
283
+ const diagnostics = [];
284
+ const models = payload.models.flatMap((model, index) => {
285
+ if (!model || typeof model !== 'object' || Array.isArray(model)
286
+ || typeof model.key !== 'string' || !/^[^\s/]+\/\S+$/.test(model.key)
287
+ || (model.available !== undefined && typeof model.available !== 'boolean')
288
+ || (model.name !== undefined && typeof model.name !== 'string')
289
+ || (model.tags !== undefined && (!Array.isArray(model.tags) || model.tags.some((tag) => typeof tag !== 'string')))) {
290
+ diagnostics.push(`OpenClaw /models/${index}: malformed model row`);
291
+ return [];
292
+ }
293
+ return model.available !== false ? [{
256
294
  id: model.key,
257
295
  ...(typeof model.name === 'string' ? { displayName: model.name } : {}),
258
296
  isDefault: Array.isArray(model.tags) && model.tags.includes('default'),
259
297
  }]
260
- : []);
298
+ : [];
299
+ });
261
300
  return {
262
301
  provider: 'openclaw',
263
302
  source: 'native',
@@ -265,8 +304,8 @@ export async function discoverOpenClawModels(command = 'openclaw', runner = runM
265
304
  ...(version ? { runtimeVersion: version } : {}),
266
305
  accountScope: 'local-runtime',
267
306
  models,
268
- ...(models.length === 0
269
- ? { errorKind: 'invalid-output', error: 'OpenClaw returned no normalized model keys.' }
307
+ ...(diagnostics.length || models.length === 0
308
+ ? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'OpenClaw returned no normalized model keys.' }
270
309
  : {}),
271
310
  };
272
311
  }
@@ -294,6 +333,8 @@ async function readCatalog(file) {
294
333
  const parsed = JSON.parse(await readFile(file, 'utf8'));
295
334
  if (!parsed.version || !parsed.providers || typeof parsed.providers !== 'object')
296
335
  return null;
336
+ if (validateWorkerCatalogDiscovery(parsed.discovery).length)
337
+ return null;
297
338
  return parsed;
298
339
  }
299
340
  catch {
@@ -328,7 +369,27 @@ const STANDARD_FAMILY = /(?:^|[/:._-])(?:sonnet|terra)(?:$|[/:._[-]|\d)/i;
328
369
  export function selectRoleModels(models) {
329
370
  // Flagship families are user-elected only (epic #2642): discovery never
330
371
  // assigns them to a role, even when the provider marks one as its default.
331
- const visible = models.filter(model => !model.hidden && !FLAGSHIP_FAMILY.test(model.id));
372
+ const visible = models.filter(model => {
373
+ // Keep the legacy name-based selection only for unenriched providers.
374
+ const worker = model.worker;
375
+ return !retirementGuard(null, model).blocked && !model.hidden && !FLAGSHIP_FAMILY.test(model.id)
376
+ && (!worker || (worker.qualification.status === 'qualified'
377
+ && worker.visibility === 'listed' && worker.availability === 'accessible'
378
+ && (worker.lifecycle === 'active' || worker.lifecycle === 'deprecated')
379
+ && worker.effortSupport !== 'unknown' && !hasSelectionRelevantConflicts(worker)));
380
+ });
381
+ if (visible.length === 0)
382
+ return {};
383
+ const selected = {};
384
+ for (const role of ['reasoning', 'coding', 'efficiency']) {
385
+ const candidates = visible.filter(model => !model.worker || model.worker.qualification.roles.includes(role));
386
+ const model = rankRoleModels(candidates)[role];
387
+ if (model)
388
+ selected[role] = model;
389
+ }
390
+ return selected;
391
+ }
392
+ function rankRoleModels(visible) {
332
393
  if (visible.length === 0)
333
394
  return {};
334
395
  const coding = visible.find(model => STANDARD_FAMILY.test(model.id))
@@ -379,6 +440,8 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
379
440
  const lines = createInterface({ input: child.stdout });
380
441
  let settled = false;
381
442
  let stderr = '';
443
+ const diagnostics = [];
444
+ let lineNumber = 0;
382
445
  let detectedRuntimeVersion = version;
383
446
  let timer;
384
447
  const finish = (result) => {
@@ -390,7 +453,7 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
390
453
  child.kill();
391
454
  resolveDiscovery(result);
392
455
  };
393
- child.stderr.on('data', chunk => { stderr += String(chunk); });
456
+ child.stderr.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-4096); });
394
457
  child.on('error', error => finish({
395
458
  provider: 'codex',
396
459
  source: 'native',
@@ -398,14 +461,20 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
398
461
  accountScope: 'local-account',
399
462
  models: [],
400
463
  errorKind: classifyDiscoveryError(error.message),
401
- error: error.message,
464
+ error: redactModelDiscoveryText(error.message),
402
465
  }));
403
466
  lines.on('line', line => {
467
+ lineNumber++;
404
468
  let message;
405
469
  try {
406
470
  message = JSON.parse(line);
407
471
  }
408
472
  catch {
473
+ diagnostics.push(`Codex app-server line ${lineNumber}: invalid JSON`);
474
+ return;
475
+ }
476
+ if (!message || typeof message !== 'object') {
477
+ diagnostics.push(`Codex app-server line ${lineNumber}: expected JSON-RPC object`);
409
478
  return;
410
479
  }
411
480
  if (message.id === 1 && message.result) {
@@ -425,16 +494,34 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
425
494
  })}\n`);
426
495
  }
427
496
  if (message.id === 2) {
497
+ if (!Array.isArray(message.result?.data))
498
+ diagnostics.push('Codex model/list /result/data: expected array');
499
+ if (message.result?.nextCursor)
500
+ diagnostics.push('Codex model/list /result/nextCursor: truncated catalog (more pages remain)');
428
501
  const models = Array.isArray(message.result?.data)
429
- ? message.result.data.map((model) => ({
430
- id: String(model.model ?? model.id),
431
- displayName: typeof model.displayName === 'string' ? model.displayName : undefined,
432
- hidden: Boolean(model.hidden),
433
- isDefault: Boolean(model.isDefault),
434
- reasoningEfforts: Array.isArray(model.supportedReasoningEfforts)
435
- ? model.supportedReasoningEfforts.map((effort) => typeof effort === 'string' ? effort : String(effort.reasoningEffort ?? effort.effort ?? effort))
436
- : undefined,
437
- }))
502
+ ? message.result.data.flatMap((model, index) => {
503
+ const id = model?.model ?? model?.id;
504
+ const efforts = model?.supportedReasoningEfforts;
505
+ if (!model || typeof model !== 'object' || Array.isArray(model) || typeof id !== 'string' || !id.trim()
506
+ || ['hidden', 'isDefault'].some(key => model[key] !== undefined && typeof model[key] !== 'boolean')
507
+ || (model.displayName !== undefined && typeof model.displayName !== 'string')
508
+ || (efforts !== undefined && (!Array.isArray(efforts) || efforts.some((effort) => {
509
+ const value = typeof effort === 'string' ? effort : effort?.reasoningEffort ?? effort?.effort;
510
+ return typeof value !== 'string' || !value.trim();
511
+ })))) {
512
+ diagnostics.push(`Codex model/list /result/data/${index}: malformed model row`);
513
+ return [];
514
+ }
515
+ return [{
516
+ id,
517
+ displayName: typeof model.displayName === 'string' ? model.displayName : undefined,
518
+ hidden: Boolean(model.hidden),
519
+ isDefault: Boolean(model.isDefault),
520
+ reasoningEfforts: Array.isArray(model.supportedReasoningEfforts)
521
+ ? model.supportedReasoningEfforts.map((effort) => typeof effort === 'string' ? effort : String(effort.reasoningEffort ?? effort.effort ?? effort))
522
+ : undefined,
523
+ }];
524
+ })
438
525
  : [];
439
526
  finish({
440
527
  provider: 'codex',
@@ -443,7 +530,8 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
443
530
  ...(detectedRuntimeVersion ? { runtimeVersion: detectedRuntimeVersion } : {}),
444
531
  accountScope: 'local-account',
445
532
  models,
446
- ...(message.error ? { error: JSON.stringify(message.error) } : {}),
533
+ ...(message.error ? { errorKind: 'command', error: redactModelDiscoveryText(JSON.stringify(message.error)) }
534
+ : diagnostics.length ? { errorKind: 'invalid-output', error: diagnostics.join('; ') } : {}),
447
535
  });
448
536
  }
449
537
  });
@@ -463,7 +551,7 @@ export async function discoverCodexModels(command = 'codex', timeoutMs = 10_000,
463
551
  accountScope: 'local-account',
464
552
  models: [],
465
553
  errorKind: 'timeout',
466
- error: `Codex app-server model/list timed out${stderr ? `: ${stderr.trim()}` : ''}`,
554
+ error: `Codex app-server model/list timed out${stderr ? `: ${redactModelDiscoveryText(stderr.trim())}` : ''}`,
467
555
  }), timeoutMs);
468
556
  });
469
557
  }
@@ -472,7 +560,7 @@ export async function resolveDynamicModelCatalog(options) {
472
560
  const homeDir = options.homeDir ?? homedir();
473
561
  const cacheFile = options.cacheFile ?? join(homeDir, '.cache/aiwg/model-catalog.v1.json');
474
562
  const staticFile = join(options.aiwgRoot, 'agentic/code/providers/model-catalog.v1.json');
475
- const signature = inventorySignature(options.inventory) + JSON.stringify({ omp: options.omp, profile: process.env.OMP_PROFILE ?? process.env.PI_PROFILE, config: process.env.PI_CONFIG_DIR, data: process.env.XDG_DATA_HOME, cache: process.env.XDG_CACHE_HOME, agent: process.env.PI_CODING_AGENT_DIR });
563
+ const signature = inventorySignature(options.inventory) + JSON.stringify({ codex: options.codex, muse: options.muse, omp: options.omp, profile: process.env.OMP_PROFILE ?? process.env.PI_PROFILE, config: process.env.PI_CONFIG_DIR, data: process.env.XDG_DATA_HOME, cache: process.env.XDG_CACHE_HOME, agent: process.env.PI_CODING_AGENT_DIR });
476
564
  const staticCatalog = await readCatalog(staticFile);
477
565
  if (!staticCatalog)
478
566
  throw new Error(`Invalid or missing static model catalog: ${staticFile}`);
@@ -480,6 +568,8 @@ export async function resolveDynamicModelCatalog(options) {
480
568
  const cached = useCache ? await readCatalog(cacheFile) : null;
481
569
  if (!options.forceRefresh
482
570
  && cached
571
+ && (!options.codex?.accountBinding || cached.discovery?.providers.codex?.accountBinding === options.codex.accountBinding)
572
+ && (!options.muse?.accountBinding || cached.discovery?.providers.muse?.accountBinding === options.muse.accountBinding)
483
573
  && isFresh(cached, now, options.ttlMs ?? DEFAULT_TTL_MS, signature)) {
484
574
  return {
485
575
  ...cached,
@@ -502,6 +592,7 @@ export async function resolveDynamicModelCatalog(options) {
502
592
  fetchedAt: now.toISOString(),
503
593
  inventorySignature: signature,
504
594
  providers: {},
595
+ ...(staticCatalog.discovery?.instances ? { instances: staticCatalog.discovery.instances } : {}),
505
596
  },
506
597
  };
507
598
  }
@@ -518,7 +609,7 @@ export async function resolveDynamicModelCatalog(options) {
518
609
  if (!response.ok)
519
610
  throw new Error(`HTTP ${response.status}`);
520
611
  const remote = await response.json();
521
- if (!remote.version || !remote.providers)
612
+ if (!remote.version || !remote.providers || validateWorkerCatalogDiscovery(remote.discovery).length)
522
613
  throw new Error('invalid catalog shape');
523
614
  catalog = remote;
524
615
  source = 'remote';
@@ -530,7 +621,12 @@ export async function resolveDynamicModelCatalog(options) {
530
621
  }
531
622
  const available = new Set(options.inventory.providers.filter(provider => provider.available).map(provider => provider.id));
532
623
  const nativeDiscoverers = options.nativeDiscoverers ?? {
533
- codex: () => discoverCodexModels(),
624
+ codex: () => discoverCodexDebugModels(options.codex?.command ?? 'codex', options.runner, {
625
+ accountBinding: options.codex?.accountBinding, now: options.now,
626
+ }),
627
+ muse: () => discoverMuseCatalogModels({
628
+ ...options.muse, catalogDir: options.muse?.catalogDir ?? join(homeDir, MUSE_CATALOG_DIR), now: options.now,
629
+ }),
534
630
  opencode: () => discoverOpenCodeModels(),
535
631
  openclaw: () => discoverOpenClawModels(),
536
632
  pi: () => discoverPiModels(),
@@ -546,21 +642,6 @@ export async function resolveDynamicModelCatalog(options) {
546
642
  if (discoverer) {
547
643
  try {
548
644
  providerDiscovery[provider] = await discoverer();
549
- const selected = provider === 'omp' ? {} : selectRoleModels(providerDiscovery[provider].models);
550
- const providerCatalog = catalog.providers[provider];
551
- if (providerCatalog) {
552
- for (const role of ['reasoning', 'coding', 'efficiency']) {
553
- if (!selected[role])
554
- continue;
555
- providerCatalog.roles[role] = {
556
- ...(providerCatalog.roles[role] ?? {}),
557
- id: selected[role].id,
558
- status: 'active',
559
- observed: true,
560
- };
561
- }
562
- providerCatalog.verifiedAt = providerDiscovery[provider].observedAt.slice(0, 10);
563
- }
564
645
  }
565
646
  catch (error) {
566
647
  providerDiscovery[provider] = {
@@ -571,8 +652,65 @@ export async function resolveDynamicModelCatalog(options) {
571
652
  models: [],
572
653
  errorKind: classifyDiscoveryError(error.message),
573
654
  error: error.message,
655
+ accountBinding: provider === 'codex' ? options.codex?.accountBinding : provider === 'muse' ? options.muse?.accountBinding : undefined,
574
656
  };
575
657
  }
658
+ const next = providerDiscovery[provider];
659
+ const instanceId = discoveryInstanceId(next);
660
+ const prior = cached?.discovery?.instances?.[instanceId]
661
+ ?? cached?.discovery?.providers[provider];
662
+ if (prior)
663
+ providerDiscovery[provider] = mergeProviderDiscovery(prior, next);
664
+ const entry = providerDiscovery[provider];
665
+ if (entry.errorKind && prior && prior.accountBinding === next.accountBinding
666
+ && cached?.providers[provider])
667
+ catalog.providers[provider] = structuredClone(cached.providers[provider]);
668
+ const providerCatalog = catalog.providers[provider];
669
+ const preservePins = provider === 'omp' || entry.models.some(model => model.worker)
670
+ || prior?.models.some(model => model.worker);
671
+ if (!next.errorKind && prior && prior.accountBinding === next.accountBinding
672
+ && prior.accountScope === next.accountScope) {
673
+ for (const role of Object.values(providerCatalog?.roles ?? {})) {
674
+ const alreadyUnavailable = Object.values(cached?.providers[provider]?.roles ?? {}).some(previous => previous.id === role.id && previous.status === 'unverified' && previous.observed === false);
675
+ if (!(alreadyUnavailable || (preservePins && prior.models.some(model => model.id === role.id)))
676
+ || entry.models.some(model => model.id === role.id))
677
+ continue;
678
+ const guard = retirementGuard(role.id);
679
+ role.status = 'unverified';
680
+ role.observed = false;
681
+ entry.policy ??= { layers: [], selected: {}, constraints: [], diagnostics: [] };
682
+ if (!entry.policy.diagnostics.includes(guard.reason))
683
+ entry.policy.diagnostics.push(guard.reason);
684
+ }
685
+ }
686
+ for (const model of entry.models) {
687
+ if (!model.worker)
688
+ continue;
689
+ const pinnedRoles = Object.values(providerCatalog?.roles ?? {}).filter(role => role.id === model.id);
690
+ const guard = retirementGuard(pinnedRoles.length ? model.id : null, model);
691
+ if (guard.blocked) {
692
+ for (const role of pinnedRoles)
693
+ role.status = 'unverified';
694
+ entry.policy ??= { layers: [], selected: {}, constraints: [], diagnostics: [] };
695
+ if (!entry.policy.diagnostics.includes(guard.reason))
696
+ entry.policy.diagnostics.push(guard.reason);
697
+ }
698
+ }
699
+ if (providerCatalog) {
700
+ // Observations are facts, not approval to adopt a model. Preserve pins.
701
+ const selected = preservePins || entry.errorKind ? {}
702
+ : selectRoleModels(entry.models);
703
+ for (const role of ['reasoning', 'coding', 'efficiency']) {
704
+ if (!selected[role])
705
+ continue;
706
+ providerCatalog.roles[role] = {
707
+ ...(providerCatalog.roles[role] ?? {}), id: selected[role].id,
708
+ status: 'active', observed: true,
709
+ };
710
+ }
711
+ if (Object.keys(selected).length)
712
+ providerCatalog.verifiedAt = entry.observedAt.slice(0, 10);
713
+ }
576
714
  }
577
715
  else {
578
716
  const decision = PROVIDER_DISCOVERY_DECISIONS[provider];
@@ -588,7 +726,7 @@ export async function resolveDynamicModelCatalog(options) {
588
726
  };
589
727
  }
590
728
  }
591
- const resolved = {
729
+ let resolved = {
592
730
  ...catalog,
593
731
  discovery: {
594
732
  source,
@@ -597,8 +735,12 @@ export async function resolveDynamicModelCatalog(options) {
597
735
  ...(remoteError ? { remoteError } : {}),
598
736
  inventorySignature: signature,
599
737
  providers: providerDiscovery,
738
+ instances: { ...cached?.discovery?.instances, ...catalog.discovery?.instances },
600
739
  },
601
740
  };
741
+ for (const entry of Object.values(providerDiscovery)) {
742
+ resolved = recordAccountInstance(resolved, discoveryInstanceId(entry), entry);
743
+ }
602
744
  await atomicWrite(cacheFile, `${JSON.stringify(resolved, null, 2)}\n`);
603
745
  return resolved;
604
746
  }
@@ -616,10 +758,19 @@ export async function discoverOmpModels(command = 'omp', runner = runModelDiscov
616
758
  if (result.exitCode !== 0)
617
759
  return { ...base, errorKind: result.exitCode === 124 ? 'timeout' : /unknown|unsupported|not found/i.test(result.stderr) ? 'unsupported' : classifyDiscoveryError(result.stderr), error: 'OMP model discovery failed; check CLI version, selected profile/config, and credential availability.' };
618
760
  const payload = JSON.parse(result.stdout);
619
- if (!Array.isArray(payload.models) || payload.models.some((m) => !m || typeof m.id !== 'string' || !m.id || typeof m.provider !== 'string' || !m.provider))
620
- throw new Error('shape');
621
- base.models = payload.models.map((m) => ({ id: `${m.provider}/${m.id}`, llmProvider: m.provider, displayName: m.name, reasoningEfforts: Array.isArray(m.thinking) ? m.thinking : undefined }));
622
- return base;
761
+ if (!Array.isArray(payload?.models))
762
+ return { ...base, errorKind: 'invalid-output', error: 'OMP /models: expected array' };
763
+ const diagnostics = [];
764
+ base.models = payload.models.flatMap((m, index) => {
765
+ if (!m || typeof m.id !== 'string' || !m.id.trim() || typeof m.provider !== 'string' || !m.provider.trim()
766
+ || (m.name !== undefined && typeof m.name !== 'string')
767
+ || (m.thinking !== undefined && (!Array.isArray(m.thinking) || m.thinking.some((effort) => typeof effort !== 'string')))) {
768
+ diagnostics.push(`OMP /models/${index}: malformed model row`);
769
+ return [];
770
+ }
771
+ return [{ id: `${m.provider}/${m.id}`, llmProvider: m.provider, displayName: m.name, reasoningEfforts: m.thinking }];
772
+ });
773
+ return { ...base, ...(diagnostics.length ? { errorKind: 'invalid-output', error: diagnostics.join('; ') } : {}) };
623
774
  }
624
775
  catch {
625
776
  return { ...base, errorKind: 'invalid-output', error: 'OMP returned invalid model catalog JSON or discovery could not run.' };
@@ -634,4 +785,652 @@ export function resolveOmpRoleModel(catalog, role, mappings, explicit) {
634
785
  throw new Error(`OMP ${role} model unavailable; configure a model from omp models --json or supply an explicit model.`);
635
786
  return id;
636
787
  }
788
+ /**
789
+ * B-OG-03 shared discovery refinements (S1 direct/shared checkpoint).
790
+ *
791
+ * Codex `debug models` JSON and Muse model-catalog rows normalize through one
792
+ * observation contract: per-CLI sources, field provenance, coherent snapshots
793
+ * and freshness. Native and bridged MCP use identical facts. Synthetic fixture
794
+ * copies under test fixtures exercise these paths; synthetic seeds never ship
795
+ * as defaults.
796
+ */
797
+ /** Evidence dated beyond this skew past now is rejected, never trusted. */
798
+ export const MAX_DISCOVERY_CLOCK_SKEW_MS = 5 * 60 * 1000;
799
+ const MUSE_CATALOG_DIR = '.local/share/muse/model-catalog';
800
+ const MAX_MUSE_CATALOG_FILES = 64;
801
+ const MAX_MUSE_CATALOG_BYTES = 1024 * 1024;
802
+ /** Sanitized provenance digest for a normalized evidence entry. */
803
+ export function discoveryDigest(value) {
804
+ return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
805
+ }
806
+ /** True when an evidence timestamp is materially in the future. Unknown/unparseable stays explicit, not future. */
807
+ export function isFutureDated(value, now = new Date(), skewMs = MAX_DISCOVERY_CLOCK_SKEW_MS) {
808
+ if (typeof value !== 'string')
809
+ return false;
810
+ const time = Date.parse(value);
811
+ return Number.isFinite(time) && time - now.getTime() > skewMs;
812
+ }
813
+ function normalizeVisibility(value) {
814
+ if (value === true || value === 'listed' || value === 'list' || value === 'visible' || value === 'shown')
815
+ return 'listed';
816
+ if (value === false || value === 'hidden')
817
+ return 'hidden';
818
+ return 'unknown';
819
+ }
820
+ function normalizeLifecycle(entry) {
821
+ const lifecycle = entry.lifecycle ?? entry.status ?? entry.state;
822
+ if (lifecycle === 'retired' || entry.retired === true) {
823
+ return {
824
+ lifecycle: 'retired',
825
+ sunsetAt: typeof entry.sunset_at === 'string' || entry.sunset_at === null ? entry.sunset_at : null,
826
+ ...(typeof entry.replacement === 'string' && entry.replacement ? { replacementModelId: entry.replacement } : {}),
827
+ };
828
+ }
829
+ if (lifecycle === 'deprecated' || entry.deprecated === true) {
830
+ return {
831
+ lifecycle: 'deprecated',
832
+ sunsetAt: typeof entry.sunset_at === 'string' || entry.sunset_at === null ? entry.sunset_at : null,
833
+ ...(typeof entry.replacement === 'string' && entry.replacement ? { replacementModelId: entry.replacement } : {}),
834
+ };
835
+ }
836
+ return { lifecycle: 'active', sunsetAt: null };
837
+ }
838
+ function normalizePositiveInt(value) {
839
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 ? value : null;
840
+ }
841
+ function observationTimestamps(options) {
842
+ const observedAt = options.observedAt ?? (options.now ?? new Date()).toISOString();
843
+ return {
844
+ observedAt,
845
+ firstObservedAt: options.firstObservedAt ?? observedAt,
846
+ fetchedAt: options.fetchedAt ?? observedAt,
847
+ };
848
+ }
849
+ /** Build one explicit observation: unknowns stay unknown, every field carries provenance. */
850
+ export function buildWorkerObservation(options, input) {
851
+ const { observedAt, firstObservedAt, fetchedAt } = observationTimestamps(options);
852
+ const releaseDate = input.releaseDate ?? null;
853
+ const efforts = input.efforts ?? [];
854
+ const effortSupport = input.effortSupport
855
+ ?? (efforts.length > 0 ? 'explicit' : 'unknown');
856
+ const lifecycle = input.lifecycle;
857
+ const availability = input.availability
858
+ ?? (lifecycle === 'retired' ? 'unavailable' : 'accessible');
859
+ const provenanceFields = input.dataUseNote
860
+ ? [...input.provenanceFields, '/dataUse:unknown(config-scoped)']
861
+ : input.provenanceFields;
862
+ const observation = {
863
+ version: 1,
864
+ recordId: `${options.cliId}:${input.modelId}:${options.accountBinding ?? options.accountScope}`,
865
+ cliId: options.cliId,
866
+ family: input.family,
867
+ runtimeVersion: options.runtimeVersion,
868
+ accountScope: options.accountBinding ?? options.accountScope,
869
+ endpointFingerprint: `local-cli:${options.cliId}`,
870
+ observedAt,
871
+ verifiedAt: observedAt,
872
+ releaseDate,
873
+ releaseDateKind: releaseDate === null ? 'unknown' : (input.releaseDateKind ?? 'reported'),
874
+ visibility: input.visibility,
875
+ availability,
876
+ lifecycle,
877
+ sunsetAt: input.sunsetAt ?? null,
878
+ ...(input.replacementModelId ? { replacementModelId: input.replacementModelId } : {}),
879
+ contextTokens: input.contextTokens ?? null,
880
+ maxOutputTokens: input.maxOutputTokens ?? null,
881
+ modalities: ['text'],
882
+ efforts: effortSupport === 'explicit' ? efforts : [],
883
+ effortSupport,
884
+ pricing: { costClass: 'unknown', billing: 'unknown', currency: 'USD', inputPerMillion: null, outputPerMillion: null },
885
+ dataUse: { processing: 'unknown', productImprovement: null, retentionDays: null },
886
+ qualification: { status: 'unqualified', roles: [] },
887
+ provenance: [],
888
+ firstObservedAt,
889
+ fetchedAt,
890
+ retiredAt: lifecycle === 'retired' ? observedAt : null,
891
+ };
892
+ const { provenance: _provenance, observedAt: _observedAt, verifiedAt: _verifiedAt, firstObservedAt: _firstObservedAt, fetchedAt: _fetchedAt, retiredAt: _retiredAt, ...facts } = observation;
893
+ observation.evidenceDigest = discoveryDigest(JSON.stringify({
894
+ facts, modelId: input.modelId, source: options.source,
895
+ sourceEvidence: options.contentDigest ?? observationTimestamps(options),
896
+ }));
897
+ observation.provenance = [{
898
+ fields: [...new Set([...observationPointers(observation).filter(field => field !== '/provenance'),
899
+ '/id', '/hidden', '/isDefault', '/reasoningEfforts', ...provenanceFields])],
900
+ source: `${options.source}${options.pointer ? `#${options.pointer}` : ''}`,
901
+ kind: 'native', observedAt,
902
+ digest: discoveryDigest(JSON.stringify(observation)), confidence: 'reported',
903
+ }];
904
+ return observation;
905
+ }
906
+ /** Include container and leaf JSON Pointers so explicit unknown/default leaves stay traceable. */
907
+ function observationPointers(value, prefix = '') {
908
+ if (!value || typeof value !== 'object')
909
+ return [];
910
+ return Object.entries(value).flatMap(([key, entry]) => {
911
+ const pointer = `${prefix}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`;
912
+ return [pointer, ...observationPointers(entry, pointer)];
913
+ });
914
+ }
915
+ function stringList(value) {
916
+ return Array.isArray(value) ? value.flatMap(entry => {
917
+ if (typeof entry === 'string')
918
+ return entry ? [entry] : [];
919
+ if (entry && typeof entry === 'object' && typeof entry.effort === 'string' && entry.effort)
920
+ return [entry.effort];
921
+ return [];
922
+ }) : [];
923
+ }
924
+ /**
925
+ * Normalize `codex debug models` JSON (`.models[]` with slug, visibility,
926
+ * supported_reasoning_levels, default_reasoning_level, context_window).
927
+ * Observed on Codex 0.160.0; hidden CLI defaults are never treated as eligible.
928
+ */
929
+ export function normalizeCodexDebugModels(payload, options) {
930
+ const raw = payload?.models;
931
+ if (!Array.isArray(raw))
932
+ return { models: [], diagnostics: [`${options.source}: expected .models[] in codex debug models JSON`] };
933
+ return normalizeObservationRows(raw, options, 'codex');
934
+ }
935
+ function museEfforts(row) {
936
+ const variants = row.reasoning_effort_variants;
937
+ if (!Array.isArray(variants))
938
+ return [];
939
+ return [...new Set(variants.flatMap(variant => {
940
+ if (typeof variant === 'string')
941
+ return variant ? [variant] : [];
942
+ if (variant && typeof variant === 'object') {
943
+ const tier = variant.tier;
944
+ return typeof tier === 'string' && tier ? [tier] : [];
945
+ }
946
+ return [];
947
+ }))];
948
+ }
949
+ /**
950
+ * Normalize Muse model-catalog rows (`.rows[]` with model_id, visibility,
951
+ * is_default, is_current, reasoning_effort_variants[].tier, context_limit,
952
+ * output_limit, release_date). Contributor data-use facts stay config-scoped
953
+ * and are never derived from a row's name or default bit.
954
+ */
955
+ export function normalizeMuseCatalogRows(payload, options) {
956
+ const rows = Array.isArray(payload) ? payload
957
+ : Array.isArray(payload?.rows) ? payload.rows : [];
958
+ if (!rows.length)
959
+ return { models: [], diagnostics: [`${options.source}: expected .rows[] in Muse model-catalog JSON`] };
960
+ return normalizeObservationRows(rows, options, 'muse');
961
+ }
962
+ function validDate(value) {
963
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
964
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
965
+ }
966
+ function validTimestamp(value) {
967
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.test(value)
968
+ && validDate(value.slice(0, 10)) && Number.isFinite(Date.parse(value));
969
+ }
970
+ function normalizeObservationRows(rows, options, cli) {
971
+ const diagnostics = [];
972
+ const models = [];
973
+ const sourcePointers = new Map();
974
+ for (const [index, entry] of rows.entries()) {
975
+ const rowOptions = options.rowOptions?.[index] ?? options;
976
+ const now = rowOptions.now ?? new Date();
977
+ const sourceOptions = { ...rowOptions, pointer: rowOptions.pointer ?? `/${cli === 'muse' ? 'rows' : 'models'}/${index}` };
978
+ const source = `${sourceOptions.source}#${sourceOptions.pointer}`;
979
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
980
+ diagnostics.push(`${source}: skipped non-object model entry`);
981
+ continue;
982
+ }
983
+ const row = entry;
984
+ const id = cli === 'muse' ? row.model_id : row.slug;
985
+ if (typeof id !== 'string' || !id.trim()) {
986
+ diagnostics.push(`${source}: skipped entry without a ${cli === 'muse' ? 'model_id' : 'slug'}`);
987
+ continue;
988
+ }
989
+ const times = observationTimestamps(sourceOptions);
990
+ for (const field of [cli === 'muse' ? 'context_limit' : 'context_window', 'output_limit']) {
991
+ if (row[field] != null && (typeof row[field] !== 'number' || !Number.isSafeInteger(row[field]) || row[field] < 1)) {
992
+ diagnostics.push(`${source}: malformed ${field} for ${id}; retained as unknown`);
993
+ // Do not round or coerce malformed evidence into a valid observation.
994
+ }
995
+ }
996
+ for (const field of ['is_default', 'is_current', 'retired', 'deprecated']) {
997
+ if (row[field] != null && typeof row[field] !== 'boolean')
998
+ diagnostics.push(`${source}: malformed ${field} for ${id}`);
999
+ }
1000
+ const effortField = cli === 'muse' ? 'reasoning_effort_variants' : 'supported_reasoning_levels';
1001
+ const rawEfforts = row[effortField];
1002
+ if (rawEfforts != null) {
1003
+ if (!Array.isArray(rawEfforts))
1004
+ diagnostics.push(`${source}/${effortField}: expected effort array`);
1005
+ else
1006
+ for (const [effortIndex, effort] of rawEfforts.entries()) {
1007
+ const value = typeof effort === 'string' ? effort : effort?.[cli === 'muse' ? 'tier' : 'effort'];
1008
+ if (typeof value !== 'string' || !value.trim())
1009
+ diagnostics.push(`${source}/${effortField}/${effortIndex}: malformed effort`);
1010
+ }
1011
+ }
1012
+ if (row.default_reasoning_level != null && (typeof row.default_reasoning_level !== 'string' || !row.default_reasoning_level.trim())) {
1013
+ diagnostics.push(`${source}: malformed default_reasoning_level for ${id}`);
1014
+ }
1015
+ let invalidTime = false;
1016
+ for (const [field, value] of Object.entries(times)) {
1017
+ if (!validTimestamp(value) || isFutureDated(value, now)) {
1018
+ diagnostics.push(`${source}: ${field} is unknown (invalid or future-dated evidence); rejected ${id}`);
1019
+ invalidTime = true;
1020
+ }
1021
+ }
1022
+ if (invalidTime)
1023
+ continue;
1024
+ let releaseDate = null;
1025
+ if (row.release_date !== null && row.release_date !== undefined) {
1026
+ if (!validDate(row.release_date))
1027
+ diagnostics.push(`${source}: malformed release_date for ${id}; retained as unknown`);
1028
+ else if (isFutureDated(row.release_date, now)) {
1029
+ diagnostics.push(`${source}: rejected future-dated release_date for ${id}; date is unknown`);
1030
+ continue;
1031
+ }
1032
+ else
1033
+ releaseDate = row.release_date;
1034
+ }
1035
+ const visibility = normalizeVisibility(row.visibility);
1036
+ const lifecycle = normalizeLifecycle(row);
1037
+ if (row.sunset_at != null && !validTimestamp(row.sunset_at)) {
1038
+ diagnostics.push(`${source}: malformed sunset_at for ${id}; retained as unknown`);
1039
+ lifecycle.sunsetAt = null;
1040
+ }
1041
+ const efforts = cli === 'muse' ? museEfforts(row) : [...new Set([...stringList(row.supported_reasoning_levels),
1042
+ ...(typeof row.default_reasoning_level === 'string' && row.default_reasoning_level ? [row.default_reasoning_level] : [])])];
1043
+ const model = {
1044
+ id, ...(visibility === 'unknown' ? {} : { hidden: visibility === 'hidden' }),
1045
+ ...(cli === 'muse' ? { isDefault: row.is_default === true } : {}),
1046
+ ...(efforts.length ? { reasoningEfforts: efforts } : {}),
1047
+ worker: buildWorkerObservation(sourceOptions, {
1048
+ modelId: id, family: typeof row.family === 'string' && row.family ? row.family : cli,
1049
+ visibility, ...lifecycle, releaseDate,
1050
+ availability: lifecycle.lifecycle === 'retired' ? 'unavailable'
1051
+ : cli === 'muse' ? (row.is_current === true ? 'accessible' : 'unknown') : 'accessible',
1052
+ contextTokens: normalizePositiveInt(cli === 'muse' ? row.context_limit : row.context_window),
1053
+ maxOutputTokens: normalizePositiveInt(row.output_limit),
1054
+ efforts: efforts.map(id => ({ id, selfDelegates: null })),
1055
+ effortSupport: efforts.length ? 'explicit' : 'unknown',
1056
+ ...(cli === 'muse' ? { dataUseNote: 'contributor policy is config-scoped' } : {}),
1057
+ provenanceFields: cli === 'muse'
1058
+ ? ['/id:model_id', '/isDefault:is_default', '/availability:is_current', '/efforts:reasoning_effort_variants[].tier', '/contextTokens:context_limit', '/maxOutputTokens:output_limit', '/releaseDate:release_date']
1059
+ : ['/id:slug', '/efforts:supported_reasoning_levels+default_reasoning_level', '/contextTokens:context_window'],
1060
+ }),
1061
+ };
1062
+ // Bind compatibility-row values without including unrelated credential-bearing row keys.
1063
+ const compatibility = { id: model.id, hidden: model.hidden, isDefault: model.isDefault, reasoningEfforts: model.reasoningEfforts };
1064
+ model.worker.evidenceDigest = discoveryDigest(JSON.stringify({
1065
+ workerDigest: model.worker.evidenceDigest, ...compatibility,
1066
+ }));
1067
+ const { provenance: _provenance, ...values } = model.worker;
1068
+ model.worker.provenance[0].digest = discoveryDigest(JSON.stringify({ worker: values, ...compatibility }));
1069
+ sourcePointers.set(model, {
1070
+ visibility: '/visibility', releaseDate: '/release_date', sunsetAt: '/sunset_at', replacementModelId: '/replacement',
1071
+ lifecycle: row.retired === true ? '/retired' : row.deprecated === true ? '/deprecated'
1072
+ : row.lifecycle !== undefined ? '/lifecycle' : row.status !== undefined ? '/status' : '/state',
1073
+ availability: cli === 'muse' ? '/is_current' : '/visibility',
1074
+ efforts: cli === 'muse' ? '/reasoning_effort_variants' : '/supported_reasoning_levels',
1075
+ effortSupport: cli === 'muse' ? '/reasoning_effort_variants' : '/supported_reasoning_levels',
1076
+ contextTokens: cli === 'muse' ? '/context_limit' : '/context_window', maxOutputTokens: '/output_limit', family: '/family',
1077
+ });
1078
+ models.push(model);
1079
+ }
1080
+ return { models: reconcileDuplicateObservations(models, diagnostics, sourcePointers), diagnostics };
1081
+ }
1082
+ /** Retain every conflicting value and source; never let the first row erase retirement. */
1083
+ function reconcileDuplicateObservations(models, diagnostics, sourcePointers) {
1084
+ const groups = new Map();
1085
+ for (const model of models)
1086
+ groups.set(model.id, [...(groups.get(model.id) ?? []), model]);
1087
+ return [...groups.values()].map(group => {
1088
+ if (group.length === 1)
1089
+ return group[0];
1090
+ const model = { ...group[0], worker: { ...group[0].worker } };
1091
+ const worker = model.worker;
1092
+ const fields = ['family', 'visibility', 'lifecycle', 'availability', 'sunsetAt', 'replacementModelId',
1093
+ 'efforts', 'effortSupport', 'releaseDate', 'contextTokens', 'maxOutputTokens'];
1094
+ const conflicts = [];
1095
+ for (const field of fields) {
1096
+ const evidence = group.map(m => ({ value: m.worker[field] ?? null,
1097
+ source: m.worker.provenance[0].source, pointer: sourcePointers.get(m)?.[field] ?? `/${field}`,
1098
+ observedAt: m.worker.observedAt, digest: m.worker.provenance[0].digest }));
1099
+ if (new Set(evidence.map(e => JSON.stringify(e.value))).size < 2)
1100
+ continue;
1101
+ conflicts.push({ field: `/${field}`, evidence });
1102
+ diagnostics.push(`${model.id}: conflicting ${field === 'releaseDate' ? 'release_date' : field} values; retained values with individual sources: ${evidence.map(item => item.source).join(', ')}`);
1103
+ switch (field) {
1104
+ case 'lifecycle':
1105
+ worker.lifecycle = group.some(m => m.worker.lifecycle === 'retired') ? 'retired' : 'unverified';
1106
+ break;
1107
+ case 'visibility':
1108
+ worker.visibility = group.some(m => m.worker.visibility === 'hidden') ? 'hidden' : 'unknown';
1109
+ break;
1110
+ case 'availability':
1111
+ worker.availability = 'unknown';
1112
+ break;
1113
+ case 'efforts':
1114
+ case 'effortSupport':
1115
+ worker.efforts = [];
1116
+ worker.effortSupport = 'unknown';
1117
+ break;
1118
+ case 'releaseDate':
1119
+ worker.releaseDate = null;
1120
+ worker.releaseDateKind = 'unknown';
1121
+ break;
1122
+ case 'sunsetAt':
1123
+ case 'contextTokens':
1124
+ case 'maxOutputTokens':
1125
+ worker[field] = null;
1126
+ break;
1127
+ case 'replacementModelId':
1128
+ delete worker.replacementModelId;
1129
+ break;
1130
+ case 'family':
1131
+ worker.family = 'unknown';
1132
+ break;
1133
+ }
1134
+ }
1135
+ // Compatibility defaults also remain evidence rather than silently keeping the first.
1136
+ if (new Set(group.map(m => m.isDefault)).size > 1) {
1137
+ diagnostics.push(`${model.id}: conflicting isDefault values from ${group.map(m => m.worker.provenance[0].source).join(', ')}`);
1138
+ conflicts.push({ field: '/isDefault', evidence: group.map(m => ({ value: m.isDefault ?? null,
1139
+ source: m.worker.provenance[0].source, pointer: '/is_default',
1140
+ observedAt: m.worker.observedAt, digest: m.worker.provenance[0].digest })) });
1141
+ model.isDefault = false;
1142
+ }
1143
+ if (worker.lifecycle === 'retired') {
1144
+ worker.availability = 'unavailable';
1145
+ worker.retiredAt = group.find(m => m.worker.lifecycle === 'retired').worker.retiredAt;
1146
+ }
1147
+ model.hidden = worker.visibility === 'hidden';
1148
+ if (worker.visibility === 'unknown')
1149
+ delete model.hidden;
1150
+ model.reasoningEfforts = worker.efforts.map(e => e.id);
1151
+ worker.provenance = group.flatMap(m => m.worker.provenance);
1152
+ worker.evidenceDigest = discoveryDigest(JSON.stringify(group.map(m => m.worker.evidenceDigest)));
1153
+ if (conflicts.length)
1154
+ worker.conflicts = conflicts;
1155
+ const { provenance: _provenance, ...resolved } = worker;
1156
+ worker.provenance.push({ fields: observationPointers(resolved), source: 'src/models/model-discovery.ts#reconcileDuplicateObservations',
1157
+ kind: 'derived', observedAt: worker.observedAt,
1158
+ digest: discoveryDigest(JSON.stringify(resolved)), confidence: 'derived' });
1159
+ return model;
1160
+ });
1161
+ }
1162
+ /**
1163
+ * Merge a refresh into its prior snapshot. A failed refresh never renews age:
1164
+ * prior models and prior observation time are kept while the new error and
1165
+ * fetch time stay visible. A partial success retains prior-only rows with
1166
+ * their original age; per-model firstObservedAt is the earliest evidence.
1167
+ * A complete success contains exactly the returned identities.
1168
+ */
1169
+ export function mergeProviderDiscovery(prior, next) {
1170
+ if (prior.provider !== next.provider || prior.accountBinding !== next.accountBinding
1171
+ || prior.accountScope !== next.accountScope)
1172
+ return next;
1173
+ if (next.errorKind && next.models.length === 0) {
1174
+ return {
1175
+ ...next,
1176
+ models: prior.models,
1177
+ observedAt: prior.observedAt,
1178
+ ...(prior.accountBinding !== undefined ? { accountBinding: prior.accountBinding } : {}),
1179
+ };
1180
+ }
1181
+ const priorById = new Map(prior.models.map(model => [model.id, model]));
1182
+ const seen = new Set();
1183
+ const merged = next.models.map((model) => {
1184
+ seen.add(model.id);
1185
+ const previous = priorById.get(model.id);
1186
+ if (!previous?.worker || !model.worker)
1187
+ return model;
1188
+ if (next.provider === 'muse' && previous.worker.evidenceDigest
1189
+ && previous.worker.evidenceDigest === model.worker.evidenceDigest)
1190
+ return previous;
1191
+ const firstObservedAt = Date.parse(previous.worker.firstObservedAt) < Date.parse(model.worker.firstObservedAt)
1192
+ ? previous.worker.firstObservedAt
1193
+ : model.worker.firstObservedAt;
1194
+ if (firstObservedAt === model.worker.firstObservedAt)
1195
+ return model;
1196
+ return { ...model, worker: {
1197
+ ...model.worker, firstObservedAt,
1198
+ provenance: [
1199
+ ...model.worker.provenance.map(p => ({ ...p, fields: p.fields.filter(field => field !== '/firstObservedAt') })),
1200
+ { fields: ['/firstObservedAt'], source: previous.worker.provenance[0].source,
1201
+ kind: 'derived', observedAt: previous.worker.observedAt,
1202
+ digest: discoveryDigest(JSON.stringify({ firstObservedAt, evidence: previous.worker.evidenceDigest })), confidence: 'derived' },
1203
+ ],
1204
+ } };
1205
+ });
1206
+ if (next.errorKind) {
1207
+ for (const model of prior.models) {
1208
+ if (!seen.has(model.id))
1209
+ merged.push(model);
1210
+ }
1211
+ }
1212
+ const observedAt = next.errorKind ? prior.observedAt
1213
+ : next.provider === 'muse' && merged.some(model => model.worker)
1214
+ ? merged.flatMap(model => model.worker ? [model.worker.observedAt] : []).sort((a, b) => Date.parse(a) - Date.parse(b))[0]
1215
+ : next.observedAt;
1216
+ return { ...next, observedAt, models: merged };
1217
+ }
1218
+ /** Retirement blocks dispatch without rewriting pins; replacement is a proposal, never substitution. */
1219
+ export function retirementGuard(pinnedId, model) {
1220
+ if (!model && pinnedId)
1221
+ return {
1222
+ blocked: true, pin: pinnedId,
1223
+ reason: `Unavailable model ${pinnedId} is absent from the complete catalog and blocks dispatch; pin is preserved, never rewritten.`,
1224
+ };
1225
+ if (model?.worker?.lifecycle === 'retired') {
1226
+ const replacement = model.worker.supersededBy ?? model.worker.replacementModelId;
1227
+ return {
1228
+ blocked: true,
1229
+ pin: pinnedId,
1230
+ reason: `Retired model ${model.id} blocks dispatch; pin ${pinnedId ?? '(none)'} is preserved, never rewritten`
1231
+ + `${replacement ? ` (suggested replacement ${replacement} requires operator approval)` : ''}.`,
1232
+ };
1233
+ }
1234
+ return { blocked: false, pin: pinnedId };
1235
+ }
1236
+ /**
1237
+ * Record an account-scoped discovery under `discovery.instances` without
1238
+ * merging account entitlements into the provider projection.
1239
+ */
1240
+ function discoveryInstanceId(entry) {
1241
+ return `${entry.provider}:${entry.accountBinding ?? entry.accountScope}`;
1242
+ }
1243
+ export function recordAccountInstance(catalog, instanceId, entry) {
1244
+ const discovery = catalog.discovery ?? {
1245
+ source: 'static',
1246
+ fetchedAt: entry.observedAt,
1247
+ providers: {},
1248
+ };
1249
+ return {
1250
+ ...catalog,
1251
+ discovery: {
1252
+ ...discovery,
1253
+ instances: { ...(discovery.instances ?? {}), [instanceId]: entry },
1254
+ },
1255
+ };
1256
+ }
1257
+ /** Run the version-scoped `codex debug models` strategy and normalize its JSON. */
1258
+ export async function discoverCodexDebugModels(command = 'codex', runner = runModelDiscoveryCommand, options = {}) {
1259
+ const observedAt = (options.now ?? (() => new Date()))().toISOString();
1260
+ const version = options.runtimeVersion ?? await runtimeVersion(command, runner);
1261
+ const base = {
1262
+ provider: 'codex',
1263
+ source: 'native',
1264
+ observedAt,
1265
+ ...(version ? { runtimeVersion: version } : {}),
1266
+ accountScope: 'local-account',
1267
+ ...(options.accountBinding ? { accountBinding: options.accountBinding } : {}),
1268
+ models: [],
1269
+ };
1270
+ const result = await runner(command, ['debug', 'models'], { cwd: tmpdir(), timeoutMs: 10_000 });
1271
+ if (result.exitCode !== 0) {
1272
+ const error = redactModelDiscoveryText(result.stderr.trim()) || `codex debug models exited ${result.exitCode}`;
1273
+ return { ...base, errorKind: classifyDiscoveryError(error), error };
1274
+ }
1275
+ let payload;
1276
+ try {
1277
+ payload = JSON.parse(result.stdout);
1278
+ }
1279
+ catch (error) {
1280
+ return { ...base, errorKind: 'invalid-output', error: `Invalid codex debug models JSON: ${error.message}` };
1281
+ }
1282
+ const normalized = normalizeCodexDebugModels(payload, {
1283
+ cliId: 'codex',
1284
+ source: `${command} debug models`,
1285
+ runtimeVersion: version ?? 'unknown',
1286
+ accountScope: 'local-account',
1287
+ ...(options.accountBinding ? { accountBinding: options.accountBinding } : {}),
1288
+ observedAt,
1289
+ now: (options.now ?? (() => new Date()))(),
1290
+ });
1291
+ return {
1292
+ ...base,
1293
+ models: normalized.models,
1294
+ ...(normalized.diagnostics.length > 0 || normalized.models.length === 0
1295
+ ? {
1296
+ errorKind: 'invalid-output',
1297
+ error: normalized.diagnostics.join('; ')
1298
+ || 'codex debug models returned no normalized model rows.',
1299
+ }
1300
+ : {}),
1301
+ policy: { layers: [], selected: {}, constraints: [], diagnostics: normalized.diagnostics },
1302
+ };
1303
+ }
1304
+ /** Read bounded Muse model-catalog rows. Reads only declared catalog keys, never credential files. */
1305
+ export async function discoverMuseCatalogModels(options = {}) {
1306
+ const now = (options.now ?? (() => new Date()))();
1307
+ const catalogDir = options.catalogDir ?? join(homedir(), MUSE_CATALOG_DIR);
1308
+ const base = {
1309
+ provider: 'muse', source: 'native', observedAt: now.toISOString(),
1310
+ ...(options.runtimeVersion ? { runtimeVersion: options.runtimeVersion } : {}),
1311
+ accountScope: 'local-account', ...(options.accountBinding ? { accountBinding: options.accountBinding } : {}), models: [],
1312
+ };
1313
+ const diagnostics = [];
1314
+ const rows = [];
1315
+ const rowOptions = [];
1316
+ const deadline = Date.now() + (options.timeoutMs ?? 5_000);
1317
+ const bounded = async (operation) => {
1318
+ let timer;
1319
+ try {
1320
+ return await Promise.race([operation, new Promise((_resolve, reject) => {
1321
+ timer = setTimeout(() => reject(new Error('Muse catalog discovery deadline exceeded')), Math.max(0, deadline - Date.now()));
1322
+ })]);
1323
+ }
1324
+ finally {
1325
+ clearTimeout(timer);
1326
+ }
1327
+ };
1328
+ try {
1329
+ const root = await bounded(realpath(catalogDir));
1330
+ const files = (await bounded(readdir(root))).filter(file => file.endsWith('.json')).sort();
1331
+ if (!files.length)
1332
+ diagnostics.push(`${root}: holds no JSON rows`);
1333
+ if (files.length > MAX_MUSE_CATALOG_FILES)
1334
+ diagnostics.push(`${join(root, files[MAX_MUSE_CATALOG_FILES])}: exceeds bounded file count; this and remaining files skipped`);
1335
+ for (const file of files.slice(0, MAX_MUSE_CATALOG_FILES)) {
1336
+ if (Date.now() >= deadline) {
1337
+ diagnostics.push(`${join(root, file)}: discovery deadline exceeded; this and remaining files skipped`);
1338
+ break;
1339
+ }
1340
+ const path = join(root, file);
1341
+ try {
1342
+ const link = await bounded(lstat(path));
1343
+ let target = path;
1344
+ if (link.isSymbolicLink()) {
1345
+ target = await bounded(realpath(path));
1346
+ const subpath = relative(root, target);
1347
+ if (subpath === '..' || subpath.startsWith('../'))
1348
+ throw new Error('symlink escapes catalog directory; regular file required');
1349
+ }
1350
+ else if (!link.isFile())
1351
+ throw new Error('not a regular file; skipped');
1352
+ const stat = link.isSymbolicLink() ? await bounded(lstat(target)) : link;
1353
+ if (!stat.isFile())
1354
+ throw new Error('not a regular file; skipped');
1355
+ if (stat.size > MAX_MUSE_CATALOG_BYTES)
1356
+ throw new Error('exceeds bounded catalog size; skipped before reading');
1357
+ // O_NONBLOCK prevents a stat/open race to a FIFO from blocking. O_NOFOLLOW
1358
+ // rejects a replacement symlink; fstat rechecks the opened descriptor.
1359
+ const pending = open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1360
+ // If open itself outlives the deadline, close any late descriptor.
1361
+ let expired = false;
1362
+ let lateHandle;
1363
+ void pending.then(handle => {
1364
+ lateHandle = handle;
1365
+ if (expired)
1366
+ void handle.close().catch(() => { });
1367
+ }, () => { });
1368
+ const handle = await bounded(pending).catch(error => {
1369
+ expired = true;
1370
+ if (lateHandle)
1371
+ void lateHandle.close().catch(() => { });
1372
+ throw error;
1373
+ });
1374
+ try {
1375
+ const opened = await bounded(handle.stat());
1376
+ if (!opened.isFile())
1377
+ throw new Error('not a regular file; skipped');
1378
+ if (opened.size > MAX_MUSE_CATALOG_BYTES)
1379
+ throw new Error('exceeds bounded catalog size; skipped before reading');
1380
+ const buffer = Buffer.alloc(MAX_MUSE_CATALOG_BYTES + 1);
1381
+ let bytes = 0;
1382
+ while (bytes < buffer.length) {
1383
+ const result = await bounded(handle.read(buffer, bytes, buffer.length - bytes, bytes));
1384
+ if (!result.bytesRead)
1385
+ break;
1386
+ bytes += result.bytesRead;
1387
+ }
1388
+ if (bytes > MAX_MUSE_CATALOG_BYTES)
1389
+ throw new Error('exceeds bounded catalog size; skipped');
1390
+ const payload = JSON.parse(buffer.subarray(0, bytes).toString('utf8'));
1391
+ const fileRows = Array.isArray(payload) ? payload : payload?.rows;
1392
+ if (!Array.isArray(fileRows))
1393
+ throw new Error('expected .rows[]; rejected');
1394
+ const metadata = Array.isArray(payload) ? {} : payload;
1395
+ // Re-reading a cache does not create new evidence. Prefer its provider
1396
+ // timestamp, falling back to the file's own modification time.
1397
+ const evidenceTime = metadata.fetchedAt ?? metadata.fetched_at ?? metadata.refreshedAt ?? metadata.refreshed_at ?? opened.mtime.toISOString();
1398
+ const normalizedOptions = {
1399
+ cliId: 'muse', source: path, contentDigest: discoveryDigest(buffer.subarray(0, bytes).toString('utf8')), runtimeVersion: options.runtimeVersion ?? 'unknown',
1400
+ accountScope: 'local-account', accountBinding: options.accountBinding, now,
1401
+ observedAt: metadata.observedAt ?? evidenceTime,
1402
+ fetchedAt: evidenceTime,
1403
+ firstObservedAt: metadata.firstObservedAt ?? evidenceTime,
1404
+ };
1405
+ for (const [index, row] of fileRows.entries()) {
1406
+ rows.push(row);
1407
+ rowOptions.push({ ...normalizedOptions, pointer: `/${Array.isArray(payload) ? '' : 'rows/'}${index}` });
1408
+ }
1409
+ }
1410
+ finally {
1411
+ await bounded(handle.close());
1412
+ }
1413
+ }
1414
+ catch (error) {
1415
+ diagnostics.push(`${path}: ${error.message}; rejected`);
1416
+ }
1417
+ }
1418
+ }
1419
+ catch (error) {
1420
+ diagnostics.push(`Muse model catalog is unavailable at ${catalogDir}: ${error.message}`);
1421
+ }
1422
+ const normalized = normalizeMuseCatalogRows(rows, {
1423
+ cliId: 'muse', source: catalogDir, runtimeVersion: options.runtimeVersion ?? 'unknown',
1424
+ accountScope: 'local-account', now, rowOptions,
1425
+ });
1426
+ diagnostics.push(...normalized.diagnostics);
1427
+ const observedAt = normalized.models.length
1428
+ ? normalized.models.map(model => model.worker.observedAt).sort((a, b) => Date.parse(a) - Date.parse(b))[0] : base.observedAt;
1429
+ return {
1430
+ ...base, observedAt, models: normalized.models,
1431
+ ...(diagnostics.length || normalized.models.length === 0
1432
+ ? { errorKind: 'invalid-output', error: diagnostics.join('; ') || 'Muse model catalog returned no usable rows.' } : {}),
1433
+ policy: { layers: [], selected: {}, constraints: [], diagnostics },
1434
+ };
1435
+ }
637
1436
  //# sourceMappingURL=model-discovery.js.map