@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.
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +149 -145
- package/package.json +4 -4
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-development/SKILL.md +28 -1
- package/skills/app-dev/android-development/references/diagnosis.md +50 -0
- package/skills/app-dev/android-development/references/init.md +31 -2
- package/skills/app-dev/android-development/references/setup.md +8 -2
- package/skills/app-dev/android-development/references/ui-journeys.md +58 -0
- package/skills/app-dev/android-development/scripts/device-run.sh +93 -2
- package/skills/app-dev/android-development/scripts/failure_capture.py +331 -0
- package/skills/app-dev/android-development/scripts/init.mjs +129 -9
- package/skills/app-dev/android-development/scripts/maestro_failure.py +401 -0
- package/skills/app-dev/android-development/scripts/test_failure_capture.py +378 -0
- package/skills/app-dev/android-development/scripts/test_maestro_failure.py +462 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +1 -1
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +1 -1
- package/skills/core/house-style/SKILL.md +1 -1
- package/skills/core/plain-language/SKILL.md +1 -1
- package/skills/core/polish-cn/SKILL.md +1 -1
- package/skills/core/reg-parser/SKILL.md +1 -1
- package/skills/core/terminology/SKILL.md +1 -1
- package/skills/core/write-doc/SKILL.md +1 -1
- package/skills/core/write-report/SKILL.md +1 -1
- package/skills/engineering/better-review/SKILL.md +1 -1
- package/skills/engineering/e2e-test/SKILL.md +1 -1
- package/skills/engineering/engineering/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
- package/skills/engineering/ensure-coverage/SKILL.md +1 -1
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +4 -3
- package/skills/engineering/harness-runtime/agent-catalog.mjs +928 -0
- package/skills/engineering/harness-runtime/contracts.md +2 -0
- package/skills/engineering/harness-runtime/references/model-catalog.md +7 -1
- package/skills/engineering/resolve-issues/SKILL.md +9 -6
- package/skills/engineering/resolve-issues/references/delivery-progress.md +2 -0
- package/skills/engineering/resolve-issues/references/expire.md +6 -3
- package/skills/engineering/resolve-issues/references/implementation.md +6 -1
- package/skills/engineering/resolve-issues/references/preflight.md +26 -17
- package/skills/engineering/resolve-issues/references/review.md +21 -9
- package/skills/engineering/resolve-issues/references/shared-modules.md +88 -20
- package/skills/engineering/resolve-issues/references/task-plan.md +35 -1
- package/skills/engineering/resolve-issues/scripts/preflight-models.mjs +382 -592
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +4 -4
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
- package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
- package/skills/risk-compliance/contract-review/SKILL.md +1 -1
- package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
- package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
- package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
- package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
- package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
- package/skills/engineering/resolve-issues/scripts/discover-models.mjs +0 -9
- 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
|
+
}
|