@1aboveio/skills 1.0.15 → 1.0.16

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 (90) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +149 -145
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +28 -1
  6. package/skills/app-dev/android-development/references/diagnosis.md +50 -0
  7. package/skills/app-dev/android-development/references/init.md +31 -2
  8. package/skills/app-dev/android-development/references/setup.md +8 -2
  9. package/skills/app-dev/android-development/references/ui-journeys.md +58 -0
  10. package/skills/app-dev/android-development/scripts/device-run.sh +93 -2
  11. package/skills/app-dev/android-development/scripts/failure_capture.py +331 -0
  12. package/skills/app-dev/android-development/scripts/init.mjs +129 -9
  13. package/skills/app-dev/android-development/scripts/maestro_failure.py +401 -0
  14. package/skills/app-dev/android-development/scripts/test_failure_capture.py +378 -0
  15. package/skills/app-dev/android-development/scripts/test_maestro_failure.py +462 -0
  16. package/skills/app-dev/app-debug/SKILL.md +1 -1
  17. package/skills/app-dev/better-auth/SKILL.md +1 -1
  18. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  19. package/skills/app-dev/data-quality/SKILL.md +1 -1
  20. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  21. package/skills/app-dev/monorepo/SKILL.md +1 -1
  22. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  23. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  24. package/skills/app-dev/pyspark/SKILL.md +1 -1
  25. package/skills/app-dev/python-backend/SKILL.md +1 -1
  26. package/skills/app-dev/shadcn/SKILL.md +1 -1
  27. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  28. package/skills/cicd/cloud-build/SKILL.md +1 -1
  29. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  30. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  31. package/skills/cicd/docker/SKILL.md +1 -1
  32. package/skills/cicd/github-actions/SKILL.md +1 -1
  33. package/skills/cicd/github-flow/SKILL.md +1 -1
  34. package/skills/cicd/google-cloud/SKILL.md +1 -1
  35. package/skills/cicd/mergify/SKILL.md +1 -1
  36. package/skills/cicd/podman/SKILL.md +1 -1
  37. package/skills/content/article-review/SKILL.md +1 -1
  38. package/skills/content/article-to-image/SKILL.md +1 -1
  39. package/skills/content/flowchart-svg/SKILL.md +1 -1
  40. package/skills/content/image-gen/SKILL.md +1 -1
  41. package/skills/content/mermaid/SKILL.md +1 -1
  42. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  43. package/skills/content/wechat-publisher/SKILL.md +1 -1
  44. package/skills/content/wechat-search/SKILL.md +1 -1
  45. package/skills/content/xai-search/SKILL.md +1 -1
  46. package/skills/core/distill-writing/SKILL.md +1 -1
  47. package/skills/core/doc-to-md/SKILL.md +1 -1
  48. package/skills/core/editorial-review/SKILL.md +1 -1
  49. package/skills/core/house-style/SKILL.md +1 -1
  50. package/skills/core/plain-language/SKILL.md +1 -1
  51. package/skills/core/polish-cn/SKILL.md +1 -1
  52. package/skills/core/reg-parser/SKILL.md +1 -1
  53. package/skills/core/terminology/SKILL.md +1 -1
  54. package/skills/core/write-doc/SKILL.md +1 -1
  55. package/skills/core/write-report/SKILL.md +1 -1
  56. package/skills/engineering/better-review/SKILL.md +1 -1
  57. package/skills/engineering/e2e-test/SKILL.md +1 -1
  58. package/skills/engineering/engineering/SKILL.md +1 -1
  59. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  60. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  61. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  62. package/skills/engineering/golden-path/SKILL.md +1 -1
  63. package/skills/engineering/harness-runtime/SKILL.md +4 -3
  64. package/skills/engineering/harness-runtime/agent-catalog.mjs +928 -0
  65. package/skills/engineering/harness-runtime/contracts.md +2 -0
  66. package/skills/engineering/harness-runtime/references/model-catalog.md +7 -1
  67. package/skills/engineering/resolve-issues/SKILL.md +9 -6
  68. package/skills/engineering/resolve-issues/references/delivery-progress.md +2 -0
  69. package/skills/engineering/resolve-issues/references/expire.md +6 -3
  70. package/skills/engineering/resolve-issues/references/implementation.md +6 -1
  71. package/skills/engineering/resolve-issues/references/preflight.md +26 -17
  72. package/skills/engineering/resolve-issues/references/review.md +21 -9
  73. package/skills/engineering/resolve-issues/references/shared-modules.md +88 -20
  74. package/skills/engineering/resolve-issues/references/task-plan.md +35 -1
  75. package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
  76. package/skills/engineering/resolve-release/SKILL.md +1 -1
  77. package/skills/engineering/rush-issues/SKILL.md +4 -4
  78. package/skills/engineering/rush-release/SKILL.md +1 -1
  79. package/skills/engineering/test-quality/SKILL.md +1 -1
  80. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  81. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  82. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  83. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  84. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  85. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  86. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  87. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  88. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
  89. package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
  90. package/skills/engineering/resolve-issues/scripts/model-catalog.mjs +0 -9
@@ -0,0 +1,928 @@
1
+ // agent-catalog — consumer bridge to the harness-owned agent catalog.
2
+ //
3
+ // The skill side no longer owns a model ladder. A workflow names four roles
4
+ // (implementer, explorer, diagnoser, reviewer) and the *harness* decides which
5
+ // model fills each one and how that role is launched on this host. The resolver
6
+ // is a separate executable spoken to over one versioned JSON request on stdin
7
+ // and one JSON response on stdout:
8
+ //
9
+ // { version: 1, harness, cwd, available: [{ model, nativeModel?, supportedEfforts? }],
10
+ // userRoot?, projectTrusted?, foregroundModel?, fallbackToForeground?,
11
+ // launchCapabilities?: { model, effort, instructions },
12
+ // hostPreferences?: { developer|explorer|reviewer: { model?, effort? } },
13
+ // overrides?: { implementer|explorer|diagnoser|reviewer: { model?, effort? } },
14
+ // tiers?: { tiers: { name: { members: [], candidates: [] } } } }
15
+ // → { version: 1, ok, harness, catalogDigest, slots: { … }, diagnostics: [] }
16
+ //
17
+ // PRECEDENCE IS THE HARNESS'S, NOT OURS. A task override outranks a host
18
+ // preference, which outranks the role default; the foreground model is only a
19
+ // fallback where the host says it may be used. This module carries those inputs
20
+ // through unchanged and infers none of them: it never states what a host can
21
+ // launch, never invents a tier or a candidate, and never turns an unavailable
22
+ // pin into a nearby substitute.
23
+ //
24
+ // This module is the only place the skill side talks to it. It resolves the
25
+ // command, bounds the child, validates the response, and reports an explicit
26
+ // blocked result — it never falls back to a skill-owned picker, never guesses a
27
+ // model, and never installs or fetches anything.
28
+ //
29
+ // WHY A SCRIPT AND NOT PROSE. "Ask the harness which model to use" degrades, in
30
+ // prose, to the agent re-deriving a ladder from training data: the exact list
31
+ // this change deletes. Mechanized, one module owns the child boundary, the
32
+ // protocol version, and the fail-closed answer, so a missing resolver is a
33
+ // reported block rather than a quiet return to the old behaviour.
34
+ //
35
+ // COMMAND RESOLUTION (no shell, no implicit install — ever):
36
+ // PI_AGENT_CATALOG_COMMAND JSON argv array: '["node","/abs/agent-catalog.mjs"]'
37
+ // (preferred when arguments are needed), or a bare
38
+ // executable token: 'pi-agent-catalog'.
39
+ // default pi-agent-catalog on PATH.
40
+ // A bare token cannot carry arguments — that is deliberate, so no quoting rule
41
+ // sneaks shell semantics in. Nothing here runs npm/npx, resolves a sibling
42
+ // checkout, or reads a registry.
43
+ //
44
+ // SUPPORTED LAYOUTS. The module is imported by first-party consumers as
45
+ // `../../harness-runtime/agent-catalog.mjs` from their own `scripts/` directory,
46
+ // so one specifier resolves both in the source tree
47
+ // (`skills/engineering/{resolve-issues,harness-runtime}`) and in the native flat
48
+ // install (`~/.agents/skills/{resolve-issues,harness-runtime}`). No path in this
49
+ // file is relative to a source-domain layout.
50
+ import { spawnSync } from 'node:child_process'
51
+ import { readFileSync } from 'node:fs'
52
+ import { resolve as resolvePath } from 'node:path'
53
+
54
+ export const AGENT_CATALOG_PROTOCOL_VERSION = 1
55
+ export const DEFAULT_AGENT_CATALOG_COMMAND = 'pi-agent-catalog'
56
+ export const AGENT_CATALOG_COMMAND_ENV = 'PI_AGENT_CATALOG_COMMAND'
57
+
58
+ // Bounds: a resolver that floods stdout or hangs is a reported block, never an
59
+ // unbounded read or an indefinite wait.
60
+ export const MAX_AGENT_CATALOG_OUTPUT_BYTES = 256 * 1024
61
+ export const MAX_AGENT_CATALOG_DIAGNOSTIC_CHARS = 4096
62
+ export const DEFAULT_AGENT_CATALOG_TIMEOUT_MS = 30_000
63
+
64
+ export const CATALOG_HARNESSES = Object.freeze(['pi', 'codex', 'claude-code'])
65
+ export const CATALOG_SLOTS = Object.freeze(['implementer', 'explorer', 'diagnoser', 'reviewer'])
66
+ export const CATALOG_ROLES = Object.freeze(['developer', 'explorer', 'reviewer'])
67
+ // Each slot has one role. `diagnoser` maps the developer role: escalation work is
68
+ // implementation work, and it keeps the developer instructions with it.
69
+ export const CATALOG_SLOT_ROLES = Object.freeze({
70
+ implementer: 'developer',
71
+ explorer: 'explorer',
72
+ diagnoser: 'developer',
73
+ reviewer: 'reviewer',
74
+ })
75
+ export const CATALOG_EFFORTS = Object.freeze(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
76
+
77
+ export const AGENT_CATALOG_BLOCK_CODES = Object.freeze({
78
+ CAPABILITIES_MISSING: 'capabilities-missing',
79
+ CAPABILITIES_INVALID: 'capabilities-invalid',
80
+ REQUEST_INVALID: 'catalog-request-invalid',
81
+ COMMAND_INVALID: 'catalog-command-invalid',
82
+ COMMAND_UNAVAILABLE: 'catalog-unavailable',
83
+ COMMAND_FAILED: 'catalog-failed',
84
+ OUTPUT_INVALID: 'catalog-output-invalid',
85
+ RESPONSE_INVALID: 'catalog-response-invalid',
86
+ DECLINED: 'catalog-declined',
87
+ })
88
+
89
+ const CAPABILITY_FIELDS = new Set([
90
+ 'harness',
91
+ 'available',
92
+ 'foregroundModel',
93
+ 'fallbackToForeground',
94
+ 'launchCapabilities',
95
+ 'hostPreferences',
96
+ 'overrides',
97
+ 'tiers',
98
+ 'userRoot',
99
+ 'projectTrusted',
100
+ 'cwd',
101
+ ])
102
+ const AVAILABLE_FIELDS = new Set(['model', 'nativeModel', 'supportedEfforts'])
103
+ const OVERRIDE_FIELDS = new Set(['model', 'effort'])
104
+ // The three controls a native host either exposes or does not. Omission is not
105
+ // "probably fine": a partially advertised set cannot be told apart from an
106
+ // unsupported one, so it is rejected instead of being completed by guesswork.
107
+ const LAUNCH_CAPABILITY_FIELDS = Object.freeze(['model', 'effort', 'instructions'])
108
+ const TIER_POLICY_FIELDS = new Set(['tiers'])
109
+ const TIER_SPEC_FIELDS = new Set(['members', 'candidates'])
110
+ const TIER_CANDIDATE_FIELDS = new Set(['model', 'crossProvider'])
111
+ const WORKER_FIELDS = new Set([
112
+ 'role',
113
+ 'model',
114
+ 'nativeModel',
115
+ 'effort',
116
+ 'instructions',
117
+ 'selection',
118
+ 'launch',
119
+ 'catalogDigest',
120
+ 'diagnostics',
121
+ ])
122
+
123
+ function isRecord(value) {
124
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
125
+ }
126
+
127
+ function nonEmptyString(value) {
128
+ return typeof value === 'string' && value.trim().length > 0
129
+ }
130
+
131
+ /** `provider/model`, the only form the catalog resolves a tier member against. */
132
+ function providerModelId(value) {
133
+ if (!nonEmptyString(value)) return false
134
+ const trimmed = value.trim()
135
+ const slash = trimmed.indexOf('/')
136
+ return slash > 0 && slash < trimmed.length - 1 && !/\s/.test(trimmed)
137
+ }
138
+
139
+ function blocked(code, reason, extra = {}) {
140
+ return { ok: false, blocked: { code, reason, ...extra } }
141
+ }
142
+
143
+ function snippet(text, limit = 400) {
144
+ const value = String(text ?? '').replace(/\s+/g, ' ').trim()
145
+ return value.length > limit ? `${value.slice(0, limit)}…` : value
146
+ }
147
+
148
+ /** Everything a blocked result may name about the child it tried to run. */
149
+ function commandLabel(command) {
150
+ return command ? [command.executable, ...command.args].join(' ') : null
151
+ }
152
+
153
+ /**
154
+ * Parse a command spec: a JSON argv array (preferred; it can carry arguments),
155
+ * or one bare executable token resolved on PATH.
156
+ */
157
+ export function parseCommandSpec(value) {
158
+ const text = typeof value === 'string' ? value.trim() : ''
159
+ if (!text) {
160
+ return blocked(AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID, 'the catalog command is empty')
161
+ }
162
+ if (text.startsWith('[')) {
163
+ let parsed
164
+ try {
165
+ parsed = JSON.parse(text)
166
+ } catch (error) {
167
+ return blocked(
168
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID,
169
+ `${AGENT_CATALOG_COMMAND_ENV} is not a valid JSON argv array: ${error.message}`,
170
+ )
171
+ }
172
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(nonEmptyString)) {
173
+ return blocked(
174
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID,
175
+ `${AGENT_CATALOG_COMMAND_ENV} must be a non-empty JSON array of non-empty strings`,
176
+ )
177
+ }
178
+ return {
179
+ ok: true,
180
+ command: { executable: parsed[0], args: parsed.slice(1), source: 'json-argv' },
181
+ }
182
+ }
183
+ if (/^[{"]/.test(text)) {
184
+ return blocked(
185
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID,
186
+ `${AGENT_CATALOG_COMMAND_ENV} must be a JSON argv array or a bare executable token`,
187
+ )
188
+ }
189
+ if (/\s/.test(text)) {
190
+ return blocked(
191
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID,
192
+ `${AGENT_CATALOG_COMMAND_ENV} holds whitespace; use a JSON argv array so no shell-like quoting is implied`,
193
+ )
194
+ }
195
+ return { ok: true, command: { executable: text, args: [], source: 'executable' } }
196
+ }
197
+
198
+ /** Resolve `PI_AGENT_CATALOG_COMMAND`, defaulting to `pi-agent-catalog` on PATH. */
199
+ export function resolveCatalogCommand(env = process.env) {
200
+ const configured = env?.[AGENT_CATALOG_COMMAND_ENV]
201
+ if (configured !== undefined && typeof configured !== 'string') {
202
+ return blocked(
203
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_INVALID,
204
+ `${AGENT_CATALOG_COMMAND_ENV} must be a string`,
205
+ )
206
+ }
207
+ const spec = configured === undefined || configured.trim() === ''
208
+ ? DEFAULT_AGENT_CATALOG_COMMAND
209
+ : configured
210
+ return parseCommandSpec(spec)
211
+ }
212
+
213
+ function normalizeAvailable(value, label = 'available') {
214
+ if (value === undefined) return { ok: true, available: [] }
215
+ if (!Array.isArray(value)) {
216
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, `${label} must be an array`)
217
+ }
218
+ const available = []
219
+ for (const [index, entry] of value.entries()) {
220
+ if (!isRecord(entry)) {
221
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, `${label}[${index}] must be an object`)
222
+ }
223
+ const unknown = Object.keys(entry).filter((field) => !AVAILABLE_FIELDS.has(field))
224
+ if (unknown.length > 0) {
225
+ return blocked(
226
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
227
+ `${label}[${index}] has unknown field(s): ${unknown.join(', ')}`,
228
+ )
229
+ }
230
+ if (!nonEmptyString(entry.model)) {
231
+ return blocked(
232
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
233
+ `${label}[${index}].model must be the canonical provider/model id`,
234
+ )
235
+ }
236
+ if (entry.nativeModel !== undefined && !nonEmptyString(entry.nativeModel)) {
237
+ return blocked(
238
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
239
+ `${label}[${index}].nativeModel must be a non-empty host model id`,
240
+ )
241
+ }
242
+ if (entry.supportedEfforts !== undefined) {
243
+ if (!Array.isArray(entry.supportedEfforts) || entry.supportedEfforts.length === 0) {
244
+ return blocked(
245
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
246
+ `${label}[${index}].supportedEfforts must be a non-empty array`,
247
+ )
248
+ }
249
+ const unknownEffort = entry.supportedEfforts.filter((effort) => !CATALOG_EFFORTS.includes(effort))
250
+ if (unknownEffort.length > 0) {
251
+ return blocked(
252
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
253
+ `${label}[${index}].supportedEfforts has unknown value(s): ${unknownEffort.join(', ')}`,
254
+ )
255
+ }
256
+ }
257
+ available.push({
258
+ model: entry.model.trim(),
259
+ ...(entry.nativeModel !== undefined ? { nativeModel: entry.nativeModel.trim() } : {}),
260
+ ...(entry.supportedEfforts !== undefined ? { supportedEfforts: [...entry.supportedEfforts] } : {}),
261
+ })
262
+ }
263
+ return { ok: true, available }
264
+ }
265
+
266
+ function normalizeOverride(value, label, allowedFields = OVERRIDE_FIELDS) {
267
+ if (!isRecord(value)) {
268
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, `${label} must be an object`)
269
+ }
270
+ const unknown = Object.keys(value).filter((field) => !allowedFields.has(field))
271
+ if (unknown.length > 0) {
272
+ return blocked(
273
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
274
+ `${label} has unknown field(s): ${unknown.join(', ')}`,
275
+ )
276
+ }
277
+ if (value.model !== undefined && !nonEmptyString(value.model)) {
278
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, `${label}.model must be a model id`)
279
+ }
280
+ if (value.effort !== undefined && !CATALOG_EFFORTS.includes(value.effort)) {
281
+ return blocked(
282
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
283
+ `${label}.effort must be one of ${CATALOG_EFFORTS.join('|')}`,
284
+ )
285
+ }
286
+ const override = {
287
+ ...(value.model !== undefined ? { model: value.model.trim() } : {}),
288
+ ...(value.effort !== undefined ? { effort: value.effort } : {}),
289
+ }
290
+ return { ok: true, override: Object.keys(override).length > 0 ? override : null }
291
+ }
292
+
293
+ /**
294
+ * One map of `{ model?, effort? }` preferences. Task overrides are keyed by slot
295
+ * and host preferences by role, so the allowed keys and the label are arguments.
296
+ */
297
+ function normalizeOverrides(value, { allowed = CATALOG_SLOTS, label = 'overrides' } = {}) {
298
+ if (value === undefined || value === null) return { ok: true, overrides: {} }
299
+ if (!isRecord(value)) {
300
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, `${label} must be an object`)
301
+ }
302
+ const overrides = {}
303
+ for (const [key, entry] of Object.entries(value)) {
304
+ if (!allowed.includes(key)) {
305
+ return blocked(
306
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
307
+ `${label} names unknown key "${key}"; expected ${allowed.join('|')}`,
308
+ )
309
+ }
310
+ const normalized = normalizeOverride(entry, `${label}.${key}`)
311
+ if (!normalized.ok) return normalized
312
+ if (normalized.override) overrides[key] = normalized.override
313
+ }
314
+ return { ok: true, overrides }
315
+ }
316
+
317
+ /** The launch controls this host advertises for the current turn. */
318
+ function normalizeLaunchCapabilities(value, label = 'launchCapabilities') {
319
+ if (value === undefined || value === null) return { ok: true, launchCapabilities: null }
320
+ if (!isRecord(value)) {
321
+ return blocked(
322
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
323
+ `${label} must be an object with a boolean ${LAUNCH_CAPABILITY_FIELDS.join(', ')}`,
324
+ )
325
+ }
326
+ const unknown = Object.keys(value).filter((field) => !LAUNCH_CAPABILITY_FIELDS.includes(field))
327
+ if (unknown.length > 0) {
328
+ return blocked(
329
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
330
+ `${label} has unknown field(s): ${unknown.join(', ')}`,
331
+ )
332
+ }
333
+ const missing = LAUNCH_CAPABILITY_FIELDS.filter((field) => !Object.hasOwn(value, field))
334
+ if (missing.length > 0) {
335
+ return blocked(
336
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
337
+ `${label} is missing field(s): ${missing.join(', ')}; advertise each control explicitly rather than leaving it to be inferred`,
338
+ )
339
+ }
340
+ for (const field of LAUNCH_CAPABILITY_FIELDS) {
341
+ if (typeof value[field] !== 'boolean') {
342
+ return blocked(
343
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
344
+ `${label}.${field} must be a boolean`,
345
+ )
346
+ }
347
+ }
348
+ return {
349
+ ok: true,
350
+ launchCapabilities: {
351
+ model: value.model,
352
+ effort: value.effort,
353
+ instructions: value.instructions,
354
+ },
355
+ }
356
+ }
357
+
358
+ function normalizeTierCandidates(value, label) {
359
+ if (!Array.isArray(value)) {
360
+ return blocked(
361
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
362
+ `${label} must be an array of provider/model ids or { model, crossProvider? } objects`,
363
+ )
364
+ }
365
+ const candidates = []
366
+ for (const [index, entry] of value.entries()) {
367
+ const entryLabel = `${label}[${index}]`
368
+ if (typeof entry === 'string') {
369
+ if (!providerModelId(entry)) {
370
+ return blocked(
371
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
372
+ `${entryLabel} must be a provider/model id; a bare model name is never a tier candidate`,
373
+ )
374
+ }
375
+ candidates.push(entry.trim())
376
+ continue
377
+ }
378
+ if (!isRecord(entry)) {
379
+ return blocked(
380
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
381
+ `${entryLabel} must be a provider/model id or a { model, crossProvider? } object`,
382
+ )
383
+ }
384
+ const unknown = Object.keys(entry).filter((field) => !TIER_CANDIDATE_FIELDS.has(field))
385
+ if (unknown.length > 0) {
386
+ return blocked(
387
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
388
+ `${entryLabel} has unknown field(s): ${unknown.join(', ')}`,
389
+ )
390
+ }
391
+ if (!providerModelId(entry.model)) {
392
+ return blocked(
393
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
394
+ `${entryLabel}.model must be a provider/model id`,
395
+ )
396
+ }
397
+ if (entry.crossProvider !== undefined && typeof entry.crossProvider !== 'boolean') {
398
+ return blocked(
399
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
400
+ `${entryLabel}.crossProvider must be a boolean`,
401
+ )
402
+ }
403
+ candidates.push({
404
+ model: entry.model.trim(),
405
+ ...(entry.crossProvider !== undefined ? { crossProvider: entry.crossProvider } : {}),
406
+ })
407
+ }
408
+ return { ok: true, candidates }
409
+ }
410
+
411
+ /**
412
+ * Tier membership and ordered candidates. Membership is a statement about which
413
+ * models belong together; candidates are the only substitution the catalog may
414
+ * make, so both are forwarded exactly as given — nothing here completes a tier.
415
+ */
416
+ function normalizeTierPolicy(value, label = 'tiers') {
417
+ if (value === undefined || value === null) return { ok: true, tiers: null }
418
+ if (!isRecord(value)) {
419
+ return blocked(
420
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
421
+ `${label} must be an object of { tiers: { name: { members, candidates } } }`,
422
+ )
423
+ }
424
+ const unknownPolicy = Object.keys(value).filter((field) => !TIER_POLICY_FIELDS.has(field))
425
+ if (unknownPolicy.length > 0) {
426
+ return blocked(
427
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
428
+ `${label} has unknown field(s): ${unknownPolicy.join(', ')}`,
429
+ )
430
+ }
431
+ if (!isRecord(value.tiers)) {
432
+ return blocked(
433
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
434
+ `${label}.tiers must be an object keyed by tier name`,
435
+ )
436
+ }
437
+ const tiers = {}
438
+ for (const [name, spec] of Object.entries(value.tiers)) {
439
+ const specLabel = `${label}.tiers.${name}`
440
+ if (!isRecord(spec)) {
441
+ return blocked(
442
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
443
+ `${specLabel} must be an object with members and candidates`,
444
+ )
445
+ }
446
+ const unknownSpec = Object.keys(spec).filter((field) => !TIER_SPEC_FIELDS.has(field))
447
+ if (unknownSpec.length > 0) {
448
+ return blocked(
449
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
450
+ `${specLabel} has unknown field(s): ${unknownSpec.join(', ')}`,
451
+ )
452
+ }
453
+ if (!Array.isArray(spec.members)) {
454
+ return blocked(
455
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
456
+ `${specLabel}.members must be an array of provider/model ids`,
457
+ )
458
+ }
459
+ const members = []
460
+ for (const [index, member] of spec.members.entries()) {
461
+ if (!providerModelId(member)) {
462
+ return blocked(
463
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
464
+ `${specLabel}.members[${index}] must be a provider/model id`,
465
+ )
466
+ }
467
+ members.push(member.trim())
468
+ }
469
+ const candidates = normalizeTierCandidates(spec.candidates, `${specLabel}.candidates`)
470
+ if (!candidates.ok) return candidates
471
+ tiers[name] = { members, candidates: candidates.candidates }
472
+ }
473
+ return { ok: true, tiers: { tiers } }
474
+ }
475
+
476
+ /**
477
+ * Validate one capabilities document: the per-turn host evidence a caller writes
478
+ * to disk for `--capabilities <path>`. Unknown fields fail closed, because a
479
+ * silently ignored typo (`avaliable`) would otherwise reach the catalog as an
480
+ * empty availability list.
481
+ */
482
+ export function normalizeCapabilities(value, { path = null } = {}) {
483
+ if (!isRecord(value)) {
484
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID, 'capabilities must be a JSON object', { path })
485
+ }
486
+ const unknown = Object.keys(value).filter((field) => !CAPABILITY_FIELDS.has(field))
487
+ if (unknown.length > 0) {
488
+ return blocked(
489
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
490
+ `capabilities has unknown field(s): ${unknown.join(', ')}`,
491
+ { path },
492
+ )
493
+ }
494
+ if (value.harness !== undefined && !CATALOG_HARNESSES.includes(value.harness)) {
495
+ return blocked(
496
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
497
+ `capabilities.harness must be one of ${CATALOG_HARNESSES.join('|')}`,
498
+ { path },
499
+ )
500
+ }
501
+ const available = normalizeAvailable(value.available, 'capabilities.available')
502
+ if (!available.ok) return { ...available, blocked: { ...available.blocked, path } }
503
+ for (const field of ['foregroundModel', 'userRoot', 'cwd']) {
504
+ if (value[field] !== undefined && !nonEmptyString(value[field])) {
505
+ return blocked(
506
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
507
+ `capabilities.${field} must be a non-empty string`,
508
+ { path },
509
+ )
510
+ }
511
+ }
512
+ if (value.projectTrusted !== undefined && typeof value.projectTrusted !== 'boolean') {
513
+ return blocked(
514
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
515
+ 'capabilities.projectTrusted must be a boolean',
516
+ { path },
517
+ )
518
+ }
519
+ const overrides = normalizeOverrides(value.overrides)
520
+ if (!overrides.ok) return { ...overrides, blocked: { ...overrides.blocked, path } }
521
+ const hostPreferences = normalizeOverrides(value.hostPreferences, {
522
+ allowed: CATALOG_ROLES,
523
+ label: 'capabilities.hostPreferences',
524
+ })
525
+ if (!hostPreferences.ok) return { ...hostPreferences, blocked: { ...hostPreferences.blocked, path } }
526
+ const launchCapabilities = normalizeLaunchCapabilities(value.launchCapabilities, 'capabilities.launchCapabilities')
527
+ if (!launchCapabilities.ok) return { ...launchCapabilities, blocked: { ...launchCapabilities.blocked, path } }
528
+ if (value.fallbackToForeground !== undefined && typeof value.fallbackToForeground !== 'boolean') {
529
+ return blocked(
530
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
531
+ 'capabilities.fallbackToForeground must be a boolean',
532
+ { path },
533
+ )
534
+ }
535
+ const tiers = normalizeTierPolicy(value.tiers, 'capabilities.tiers')
536
+ if (!tiers.ok) return { ...tiers, blocked: { ...tiers.blocked, path } }
537
+ return {
538
+ ok: true,
539
+ capabilities: {
540
+ ...(value.harness !== undefined ? { harness: value.harness } : {}),
541
+ available: available.available,
542
+ ...(value.foregroundModel !== undefined ? { foregroundModel: value.foregroundModel.trim() } : {}),
543
+ ...(Object.keys(overrides.overrides).length > 0 ? { overrides: overrides.overrides } : {}),
544
+ ...(Object.keys(hostPreferences.overrides).length > 0
545
+ ? { hostPreferences: hostPreferences.overrides }
546
+ : {}),
547
+ ...(launchCapabilities.launchCapabilities
548
+ ? { launchCapabilities: launchCapabilities.launchCapabilities }
549
+ : {}),
550
+ ...(value.fallbackToForeground !== undefined
551
+ ? { fallbackToForeground: value.fallbackToForeground }
552
+ : {}),
553
+ ...(tiers.tiers ? { tiers: tiers.tiers } : {}),
554
+ ...(value.userRoot !== undefined ? { userRoot: value.userRoot.trim() } : {}),
555
+ ...(value.projectTrusted !== undefined ? { projectTrusted: value.projectTrusted } : {}),
556
+ ...(value.cwd !== undefined ? { cwd: value.cwd.trim() } : {}),
557
+ },
558
+ }
559
+ }
560
+
561
+ /** Read and validate a `--capabilities <path>` document. Missing is explicit. */
562
+ export function readCapabilitiesFile(filePath) {
563
+ if (!nonEmptyString(filePath)) {
564
+ return blocked(AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_MISSING, 'no capabilities path was given')
565
+ }
566
+ const path = resolvePath(String(filePath))
567
+ let text
568
+ try {
569
+ text = readFileSync(path, 'utf8')
570
+ } catch (error) {
571
+ return blocked(
572
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_MISSING,
573
+ `capabilities file ${path} cannot be read: ${error.code || error.message}`,
574
+ { path },
575
+ )
576
+ }
577
+ let value
578
+ try {
579
+ value = JSON.parse(text)
580
+ } catch (error) {
581
+ return blocked(
582
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_INVALID,
583
+ `capabilities file ${path} is not valid JSON: ${error.message}`,
584
+ { path },
585
+ )
586
+ }
587
+ return normalizeCapabilities(value, { path })
588
+ }
589
+
590
+ /**
591
+ * Build the versioned request the resolver reads on stdin. Returns a blocked
592
+ * result instead of a half-valid request, so a malformed caller cannot reach the
593
+ * child as an empty availability list.
594
+ */
595
+ export function buildCatalogRequest({
596
+ harness,
597
+ cwd = process.cwd(),
598
+ available = [],
599
+ foregroundModel = null,
600
+ fallbackToForeground = null,
601
+ launchCapabilities = null,
602
+ hostPreferences = null,
603
+ overrides = null,
604
+ tiers = null,
605
+ userRoot = null,
606
+ projectTrusted = null,
607
+ } = {}) {
608
+ if (!CATALOG_HARNESSES.includes(harness)) {
609
+ return blocked(
610
+ AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID,
611
+ `harness must be one of ${CATALOG_HARNESSES.join('|')}`,
612
+ { harness: harness ?? null },
613
+ )
614
+ }
615
+ if (!nonEmptyString(cwd)) {
616
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'cwd must be a non-empty path')
617
+ }
618
+ const normalizedAvailable = normalizeAvailable(available)
619
+ if (!normalizedAvailable.ok) return normalizedAvailable
620
+ if (normalizedAvailable.available.length === 0) {
621
+ return blocked(
622
+ AGENT_CATALOG_BLOCK_CODES.CAPABILITIES_MISSING,
623
+ 'no current-turn models were supplied; the harness must advertise its available models for this turn',
624
+ )
625
+ }
626
+ if (foregroundModel !== null && foregroundModel !== undefined && !nonEmptyString(foregroundModel)) {
627
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'foregroundModel must be a model id')
628
+ }
629
+ const normalizedOverrides = normalizeOverrides(overrides)
630
+ if (!normalizedOverrides.ok) return normalizedOverrides
631
+ if (userRoot !== null && userRoot !== undefined && !nonEmptyString(userRoot)) {
632
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'userRoot must be a path')
633
+ }
634
+ if (projectTrusted !== null && projectTrusted !== undefined && typeof projectTrusted !== 'boolean') {
635
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'projectTrusted must be a boolean')
636
+ }
637
+ if (fallbackToForeground !== null && fallbackToForeground !== undefined && typeof fallbackToForeground !== 'boolean') {
638
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'fallbackToForeground must be a boolean')
639
+ }
640
+ const normalizedLaunch = normalizeLaunchCapabilities(launchCapabilities)
641
+ if (!normalizedLaunch.ok) return normalizedLaunch
642
+ const normalizedHostPreferences = normalizeOverrides(hostPreferences, {
643
+ allowed: CATALOG_ROLES,
644
+ label: 'hostPreferences',
645
+ })
646
+ if (!normalizedHostPreferences.ok) return normalizedHostPreferences
647
+ const normalizedTiers = normalizeTierPolicy(tiers)
648
+ if (!normalizedTiers.ok) return normalizedTiers
649
+ return {
650
+ ok: true,
651
+ request: {
652
+ version: AGENT_CATALOG_PROTOCOL_VERSION,
653
+ harness,
654
+ cwd: String(cwd),
655
+ ...(userRoot !== null && userRoot !== undefined ? { userRoot: String(userRoot) } : {}),
656
+ ...(projectTrusted !== null && projectTrusted !== undefined ? { projectTrusted } : {}),
657
+ available: normalizedAvailable.available,
658
+ ...(foregroundModel !== null && foregroundModel !== undefined
659
+ ? { foregroundModel: String(foregroundModel) }
660
+ : {}),
661
+ ...(fallbackToForeground !== null && fallbackToForeground !== undefined
662
+ ? { fallbackToForeground }
663
+ : {}),
664
+ ...(normalizedLaunch.launchCapabilities
665
+ ? { launchCapabilities: normalizedLaunch.launchCapabilities }
666
+ : {}),
667
+ ...(Object.keys(normalizedHostPreferences.overrides).length > 0
668
+ ? { hostPreferences: normalizedHostPreferences.overrides }
669
+ : {}),
670
+ ...(Object.keys(normalizedOverrides.overrides).length > 0
671
+ ? { overrides: normalizedOverrides.overrides }
672
+ : {}),
673
+ ...(normalizedTiers.tiers ? { tiers: normalizedTiers.tiers } : {}),
674
+ },
675
+ }
676
+ }
677
+
678
+ /**
679
+ * Default child runner: `spawnSync` with an argv array, `shell: false`, bounded
680
+ * output and a deadline. No shell parses the command and nothing is installed.
681
+ * Injectable so focused tests can drive the protocol without a child process.
682
+ */
683
+ export function defaultRunner({
684
+ executable,
685
+ args = [],
686
+ input,
687
+ timeoutMs = DEFAULT_AGENT_CATALOG_TIMEOUT_MS,
688
+ env = process.env,
689
+ }) {
690
+ const result = spawnSync(executable, args, {
691
+ input,
692
+ encoding: 'utf8',
693
+ timeout: timeoutMs,
694
+ maxBuffer: MAX_AGENT_CATALOG_OUTPUT_BYTES,
695
+ env,
696
+ shell: false,
697
+ windowsHide: true,
698
+ })
699
+ return {
700
+ status: result.status,
701
+ stdout: result.stdout ?? '',
702
+ stderr: result.stderr ?? '',
703
+ error: result.error ?? null,
704
+ signal: result.signal ?? null,
705
+ }
706
+ }
707
+
708
+ function workerValue(value, label) {
709
+ if (!isRecord(value)) {
710
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label} must be an object`)
711
+ }
712
+ const unknown = Object.keys(value).filter((field) => !WORKER_FIELDS.has(field))
713
+ if (unknown.length > 0) {
714
+ return blocked(
715
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
716
+ `${label} has unknown field(s): ${unknown.join(', ')}`,
717
+ )
718
+ }
719
+ const missing = [...WORKER_FIELDS].filter((field) => !Object.hasOwn(value, field))
720
+ if (missing.length > 0) {
721
+ return blocked(
722
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
723
+ `${label} is missing field(s): ${missing.join(', ')}`,
724
+ )
725
+ }
726
+ if (!CATALOG_ROLES.includes(value.role)) {
727
+ return blocked(
728
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
729
+ `${label}.role must be one of ${CATALOG_ROLES.join('|')}`,
730
+ { role: value.role ?? null },
731
+ )
732
+ }
733
+ for (const field of ['model', 'nativeModel', 'effort']) {
734
+ if (value[field] !== null && !nonEmptyString(value[field])) {
735
+ return blocked(
736
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
737
+ `${label}.${field} must be null or a non-empty string`,
738
+ )
739
+ }
740
+ }
741
+ if (value.effort !== null && !CATALOG_EFFORTS.includes(value.effort)) {
742
+ return blocked(
743
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
744
+ `${label}.effort must be one of ${CATALOG_EFFORTS.join('|')} or null`,
745
+ )
746
+ }
747
+ if (typeof value.instructions !== 'string') {
748
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label}.instructions must be a string`)
749
+ }
750
+ if (!isRecord(value.selection)) {
751
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label}.selection must be an object`)
752
+ }
753
+ if (!(value.launch === null || isRecord(value.launch))) {
754
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label}.launch must be an object or null`)
755
+ }
756
+ if (!nonEmptyString(value.catalogDigest)) {
757
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label}.catalogDigest must be a string`)
758
+ }
759
+ if (!Array.isArray(value.diagnostics)) {
760
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, `${label}.diagnostics must be an array`)
761
+ }
762
+ return { ok: true, worker: value }
763
+ }
764
+
765
+ function declined(code, reason, response, extra = {}) {
766
+ return {
767
+ ok: false,
768
+ declined: true,
769
+ harness: response?.harness ?? null,
770
+ catalogDigest: response?.catalogDigest ?? null,
771
+ diagnostics: Array.isArray(response?.diagnostics) ? response.diagnostics : [],
772
+ blocked: { code, reason, ...extra },
773
+ }
774
+ }
775
+
776
+ function validateCatalogResponse(response, { harness, command }) {
777
+ if (!isRecord(response)) {
778
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, 'the catalog response is not an object', {
779
+ command: commandLabel(command),
780
+ })
781
+ }
782
+ if (response.version !== AGENT_CATALOG_PROTOCOL_VERSION) {
783
+ return blocked(
784
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
785
+ `unsupported catalog protocol version ${JSON.stringify(response.version)}; expected ${AGENT_CATALOG_PROTOCOL_VERSION}`,
786
+ { command: commandLabel(command) },
787
+ )
788
+ }
789
+ if (typeof response.ok !== 'boolean') {
790
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, 'the catalog response has no boolean `ok`', {
791
+ command: commandLabel(command),
792
+ })
793
+ }
794
+ if (response.harness !== harness) {
795
+ return blocked(
796
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
797
+ `the catalog answered for harness ${JSON.stringify(response.harness)}, not ${harness}`,
798
+ { command: commandLabel(command) },
799
+ )
800
+ }
801
+ if (!nonEmptyString(response.catalogDigest)) {
802
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, 'the catalog response has no catalogDigest', {
803
+ command: commandLabel(command),
804
+ })
805
+ }
806
+ if (!Array.isArray(response.diagnostics)) {
807
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, 'the catalog response has no diagnostics array', {
808
+ command: commandLabel(command),
809
+ })
810
+ }
811
+ if (response.ok === false) {
812
+ return declined(
813
+ AGENT_CATALOG_BLOCK_CODES.DECLINED,
814
+ 'the agent catalog declined to fill every role for this request',
815
+ response,
816
+ { command: commandLabel(command) },
817
+ )
818
+ }
819
+ if (!isRecord(response.slots)) {
820
+ return blocked(AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID, 'a successful response must carry `slots`', {
821
+ command: commandLabel(command),
822
+ })
823
+ }
824
+ const slots = {}
825
+ for (const slot of CATALOG_SLOTS) {
826
+ const checked = workerValue(response.slots[slot], `slots.${slot}`)
827
+ if (!checked.ok) return { ...checked, blocked: { ...checked.blocked, command: commandLabel(command) } }
828
+ if (checked.worker.launch === null) {
829
+ return blocked(
830
+ AGENT_CATALOG_BLOCK_CODES.RESPONSE_INVALID,
831
+ `slots.${slot} reports ok with no launch payload; a successful catalog must say how to launch the role`,
832
+ { command: commandLabel(command) },
833
+ )
834
+ }
835
+ slots[slot] = checked.worker
836
+ }
837
+ return {
838
+ ok: true,
839
+ harness,
840
+ catalogDigest: response.catalogDigest,
841
+ slots,
842
+ diagnostics: response.diagnostics,
843
+ command: commandLabel(command),
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Resolve all four roles through the configured agent-catalog executable.
849
+ *
850
+ * Returns `{ ok: true, harness, catalogDigest, slots, diagnostics, command }` or
851
+ * `{ ok: false, blocked: { code, reason, … }, declined?, diagnostics? }`. A
852
+ * missing, broken, or declining resolver is always the second shape: there is no
853
+ * skill-owned fallback path to return to.
854
+ */
855
+ export function resolveAgentCatalog({
856
+ request,
857
+ env = process.env,
858
+ runner = defaultRunner,
859
+ timeoutMs = DEFAULT_AGENT_CATALOG_TIMEOUT_MS,
860
+ } = {}) {
861
+ if (!isRecord(request)) {
862
+ return blocked(AGENT_CATALOG_BLOCK_CODES.REQUEST_INVALID, 'a catalog request object is required')
863
+ }
864
+ const commandResult = resolveCatalogCommand(env)
865
+ if (!commandResult.ok) return commandResult
866
+ const { command } = commandResult
867
+
868
+ let run
869
+ try {
870
+ run = runner({
871
+ executable: command.executable,
872
+ args: command.args,
873
+ input: `${JSON.stringify(request)}\n`,
874
+ timeoutMs,
875
+ env,
876
+ })
877
+ } catch (error) {
878
+ return blocked(
879
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_UNAVAILABLE,
880
+ `${commandLabel(command)} could not run: ${snippet(error?.message, 400)}`,
881
+ { command: commandLabel(command) },
882
+ )
883
+ }
884
+
885
+ if (run?.error) {
886
+ const code = run.error.code === 'ENOENT'
887
+ ? AGENT_CATALOG_BLOCK_CODES.COMMAND_UNAVAILABLE
888
+ : AGENT_CATALOG_BLOCK_CODES.COMMAND_FAILED
889
+ const reason = run.error.code === 'ENOENT'
890
+ ? `${commandLabel(command)} is not installed or not on PATH; set ${AGENT_CATALOG_COMMAND_ENV} to its JSON argv`
891
+ : `${commandLabel(command)} could not run: ${snippet(run.error.message, 400)}`
892
+ return blocked(code, reason, { command: commandLabel(command), errorCode: run.error.code ?? null })
893
+ }
894
+ if (run.status !== 0) {
895
+ const stderr = String(run.stderr ?? '')
896
+ const detail = stderr.length > MAX_AGENT_CATALOG_DIAGNOSTIC_CHARS
897
+ ? `${stderr.slice(0, MAX_AGENT_CATALOG_DIAGNOSTIC_CHARS)}…`
898
+ : stderr
899
+ return blocked(
900
+ AGENT_CATALOG_BLOCK_CODES.COMMAND_FAILED,
901
+ `${commandLabel(command)} exited ${run.status === null ? 'on a signal' : run.status} without a catalog`,
902
+ {
903
+ command: commandLabel(command),
904
+ status: run.status,
905
+ signal: run.signal ?? null,
906
+ stderr: detail,
907
+ },
908
+ )
909
+ }
910
+
911
+ const stdout = String(run.stdout ?? '').trim()
912
+ if (!stdout) {
913
+ return blocked(AGENT_CATALOG_BLOCK_CODES.OUTPUT_INVALID, `${commandLabel(command)} printed no JSON`, {
914
+ command: commandLabel(command),
915
+ })
916
+ }
917
+ let response
918
+ try {
919
+ response = JSON.parse(stdout)
920
+ } catch (error) {
921
+ return blocked(
922
+ AGENT_CATALOG_BLOCK_CODES.OUTPUT_INVALID,
923
+ `${commandLabel(command)} printed invalid JSON: ${snippet(stdout)}`,
924
+ { command: commandLabel(command) },
925
+ )
926
+ }
927
+ return validateCatalogResponse(response, { harness: request.harness, command })
928
+ }