@agentworkforce/workload-router 0.19.0 → 2.1.0

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/dist/index.js CHANGED
@@ -1,845 +1,6 @@
1
+ import { deepFreeze, isObject, isTier, materializeSkills, materializeSkillsFor, parsePersonaSpec, PERSONA_INTENTS, PERSONA_TIERS, resolveSidecar, sidecarSelectionFields, buildInstallArtifacts, buildCleanupArtifacts } from '@agentworkforce/persona-kit';
1
2
  import { personaImprover, personaMaker } from './generated/personas.js';
2
3
  import defaultRoutingProfileJson from '../routing-profiles/default.json' with { type: 'json' };
3
- export const HARNESS_VALUES = ['opencode', 'codex', 'claude'];
4
- export const PERSONA_TIERS = ['best', 'best-value', 'minimum'];
5
- export const PERSONA_TAGS = [
6
- 'planning',
7
- 'implementation',
8
- 'review',
9
- 'testing',
10
- 'debugging',
11
- 'documentation',
12
- 'release',
13
- 'discovery',
14
- 'analytics'
15
- ];
16
- export const PERSONA_INTENTS = [
17
- 'implement-frontend',
18
- 'review',
19
- 'architecture-plan',
20
- 'requirements-analysis',
21
- 'debugging',
22
- 'security-review',
23
- 'documentation',
24
- 'verification',
25
- 'test-strategy',
26
- 'tdd-enforcement',
27
- 'flake-investigation',
28
- 'opencode-workflow-correctness',
29
- 'npm-provenance',
30
- 'cloud-sandbox-infra',
31
- 'sage-slack-egress-migration',
32
- 'sage-proactive-rewire',
33
- 'cloud-slack-proxy-guard',
34
- 'sage-cloud-e2e-conduction',
35
- 'capability-discovery',
36
- 'npm-package-compat',
37
- 'posthog',
38
- 'persona-authoring',
39
- 'persona-improvement',
40
- 'agent-relay-workflow',
41
- 'slop-audit',
42
- 'api-contract-review',
43
- 'local-stack-orchestration',
44
- 'e2e-validation',
45
- 'write-integration-tests',
46
- 'relay-orchestrator'
47
- ];
48
- export const BUILT_IN_PERSONA_INTENTS = ['persona-authoring', 'persona-improvement'];
49
- export const CODEX_SANDBOX_MODES = [
50
- 'read-only',
51
- 'workspace-write',
52
- 'danger-full-access'
53
- ];
54
- export const CODEX_APPROVAL_POLICIES = [
55
- 'untrusted',
56
- 'on-failure',
57
- 'on-request',
58
- 'never'
59
- ];
60
- /**
61
- * Sidecar markdown delivery mode. `overwrite` writes only the persona's
62
- * resolved markdown into the harness's mount file. `extend` reads the
63
- * caller's real-cwd file (if any) and prepends it to the persona content,
64
- * separated by `\n\n---\n\n`. With no real-cwd file `extend` degrades to
65
- * `overwrite` semantics — the persona content lands alone.
66
- */
67
- export const SIDECAR_MD_MODES = ['overwrite', 'extend'];
68
- export const PERMISSION_MODES = [
69
- 'default',
70
- 'acceptEdits',
71
- 'bypassPermissions',
72
- 'plan'
73
- ];
74
- // ---------------------------------------------------------------------------
75
- // Skill materialization
76
- // ---------------------------------------------------------------------------
77
- //
78
- // Personas declare *what* skill they need via `skills: [{ id, source, ... }]`.
79
- // The SDK is the only layer that knows *how* to make that skill available to
80
- // a given harness — because each harness has its own on-disk convention and
81
- // its own prpm install flag. Keeping this mapping here means:
82
- //
83
- // 1. Workflow authors never hand-type `prpm install ... --as codex`.
84
- // 2. Changing install rules is a one-line SDK edit, not a repo-wide grep.
85
- // 3. Persona JSON stays harness-agnostic and forward-compatible.
86
- //
87
- // `materializeSkills` is a pure function: it returns the install plan but
88
- // never touches the filesystem or spawns processes. Callers (relay workflows,
89
- // the OpenClaw spawner, ad-hoc scripts) decide how to execute it.
90
- export const SKILL_SOURCE_KINDS = ['prpm', 'skill.sh'];
91
- export const HARNESS_SKILL_TARGETS = {
92
- claude: { asFlag: 'claude', dir: '.claude/skills' },
93
- codex: { asFlag: 'codex', dir: '.agents/skills' },
94
- opencode: { asFlag: 'opencode', dir: '.skills' }
95
- };
96
- const PRPM_URL_RE = /^https?:\/\/prpm\.dev\/packages\/([^/\s?#]+)\/([^/\s?#]+)\/?(?:[?#].*)?$/i;
97
- const PRPM_BARE_REF_RE = /^([^/\s]+)\/([^/\s]+)$/;
98
- function lastSegment(ref) {
99
- const slash = ref.lastIndexOf('/');
100
- return slash >= 0 ? ref.slice(slash + 1) : ref;
101
- }
102
- const prpmProvider = {
103
- kind: 'prpm',
104
- parse(source) {
105
- const urlMatch = source.match(PRPM_URL_RE);
106
- if (urlMatch) {
107
- const ref = `${urlMatch[1]}/${urlMatch[2]}`;
108
- return { kind: 'prpm', packageRef: ref, installedName: lastSegment(ref) };
109
- }
110
- const bareMatch = source.match(PRPM_BARE_REF_RE);
111
- if (bareMatch) {
112
- return { kind: 'prpm', packageRef: source, installedName: lastSegment(source) };
113
- }
114
- return null;
115
- },
116
- buildInstallCommand(ref, harness) {
117
- const target = HARNESS_SKILL_TARGETS[harness];
118
- return Object.freeze([
119
- 'npx',
120
- '-y',
121
- 'prpm',
122
- 'install',
123
- ref.packageRef,
124
- '--as',
125
- target.asFlag
126
- ]);
127
- },
128
- cleanupPaths(ref, harness) {
129
- const target = HARNESS_SKILL_TARGETS[harness];
130
- return Object.freeze([`${target.dir}/${ref.installedName}`]);
131
- }
132
- };
133
- // skill.sh source forms:
134
- // - `<github-url>#<skill-name>`
135
- // - `<github-url>/tree/<ref>/<path-to-skill>`
136
- // Examples:
137
- // - `https://github.com/vercel-labs/skills#find-skills`
138
- // - `https://github.com/wsimmonds/claude-nextjs-skills/tree/main/nextjs-anti-patterns`
139
- const SKILL_SH_URL_RE = /^(https?:\/\/github\.com\/[^/\s?#]+\/[^/\s?#]+?)(?:\.git)?#([^\s?#]+)$/i;
140
- const SKILL_SH_TREE_URL_RE = /^(https?:\/\/github\.com\/[^/\s?#]+\/[^/\s?#]+?)(?:\.git)?\/tree\/([^/\s?#]+)\/([^?#]+?)(?:[?#].*)?$/i;
141
- const SKILL_NAME_RE = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/;
142
- function toSafeSkillName(raw) {
143
- const name = raw.trim();
144
- return SKILL_NAME_RE.test(name) ? name : null;
145
- }
146
- /**
147
- * Paths `npx skills add` writes per install. Mirrors the on-disk layout from
148
- * a live `npx -y skills add ... -y` run (universal dir + harness-side
149
- * symlinks). `skills-lock.json` is deliberately excluded so repeat runs can
150
- * re-resolve from the lock instead of refetching sources.
151
- */
152
- function skillShArtifactPaths(installedName) {
153
- return Object.freeze([
154
- `.agents/skills/${installedName}`,
155
- `.claude/skills/${installedName}`,
156
- `.factory/skills/${installedName}`,
157
- `.kiro/skills/${installedName}`,
158
- `skills/${installedName}`
159
- ]);
160
- }
161
- const skillShProvider = {
162
- kind: 'skill.sh',
163
- parse(source) {
164
- const match = source.match(SKILL_SH_URL_RE);
165
- if (match) {
166
- const [, repoUrl, rawSkillName] = match;
167
- const skillName = toSafeSkillName(rawSkillName);
168
- if (!skillName)
169
- return null;
170
- return {
171
- kind: 'skill.sh',
172
- // packageRef preserves the full `<repo>#<skill>` shape so the command builder
173
- // can reconstruct both halves without re-parsing the original source.
174
- packageRef: `${repoUrl}#${skillName}`,
175
- installedName: skillName
176
- };
177
- }
178
- const treeMatch = source.match(SKILL_SH_TREE_URL_RE);
179
- if (!treeMatch)
180
- return null;
181
- const [, repoUrl, ref, skillPath] = treeMatch;
182
- const skillName = toSafeSkillName(skillPath.split('/').filter(Boolean).at(-1) ?? '');
183
- if (!skillName)
184
- return null;
185
- return {
186
- kind: 'skill.sh',
187
- packageRef: `${repoUrl}/tree/${ref}#${skillName}`,
188
- installedName: skillName
189
- };
190
- },
191
- buildInstallCommand(ref) {
192
- const [repoUrl, skillName] = ref.packageRef.split('#');
193
- return Object.freeze([
194
- 'npx',
195
- '-y',
196
- 'skills',
197
- 'add',
198
- repoUrl,
199
- '--skill',
200
- skillName,
201
- '-y'
202
- ]);
203
- },
204
- cleanupPaths(ref) {
205
- // skill.sh installs the same universal dir + harness symlinks regardless
206
- // of which agent is the "host" — clean all of them so nothing leaks into
207
- // `.claude/`, `.agents/`, etc. after the persona run.
208
- return skillShArtifactPaths(ref.installedName);
209
- }
210
- };
211
- const SKILL_PROVIDERS = Object.freeze([prpmProvider, skillShProvider]);
212
- function resolveSkillSource(source) {
213
- for (const provider of SKILL_PROVIDERS) {
214
- const parsed = provider.parse(source);
215
- if (parsed) {
216
- return parsed;
217
- }
218
- }
219
- throw new Error(`Unsupported skill source: ${source}. ` +
220
- `Supported forms: prpm.dev package URL (https://prpm.dev/packages/<scope>/<name>), ` +
221
- `bare "<scope>/<name>" prpm reference, ` +
222
- `skill.sh github URL with skill fragment (https://github.com/<org>/<repo>#<skill>), ` +
223
- `or GitHub tree URL to a skill directory (https://github.com/<org>/<repo>/tree/<ref>/<skill>).`);
224
- }
225
- function providerFor(kind) {
226
- const provider = SKILL_PROVIDERS.find((p) => p.kind === kind);
227
- if (!provider) {
228
- throw new Error(`No skill provider registered for kind: ${kind}`);
229
- }
230
- return provider;
231
- }
232
- /**
233
- * Given a set of persona skills and the harness the persona will run under,
234
- * produce the concrete install plan: which install invocations to run, where
235
- * the skill will land on disk, and which artifact paths should be cleaned up
236
- * after the persona run to keep the workspace tidy.
237
- *
238
- * Pure function — does not execute commands or touch the filesystem.
239
- */
240
- export function materializeSkills(skills, harness, options = {}) {
241
- const target = HARNESS_SKILL_TARGETS[harness];
242
- if (!target) {
243
- throw new Error(`No skill install target configured for harness: ${harness}`);
244
- }
245
- const { installRoot } = options;
246
- if (installRoot !== undefined && harness !== 'claude') {
247
- throw new Error(`installRoot is only supported for the claude harness (got: ${harness}). ` +
248
- `codex and opencode still install into the harness's conventional repo-relative directory.`);
249
- }
250
- const installs = skills.map((skill) => {
251
- const resolved = resolveSkillSource(skill.source);
252
- const provider = providerFor(resolved.kind);
253
- const baseCommand = provider.buildInstallCommand(resolved, harness);
254
- // In session-install-root mode, the install runs `cd <installRoot> && <prpm>`
255
- // so that prpm's harness-relative dirs (`.claude/skills/<name>`) land
256
- // inside the stage dir instead of the user's repo. The per-install command
257
- // stays self-contained so callers who run a single install.installCommand
258
- // directly still get the correct placement.
259
- const installCommand = installRoot !== undefined
260
- ? Object.freeze([
261
- 'sh',
262
- '-c',
263
- `cd ${shellEscape(installRoot)} && ${commandToShellString(baseCommand)}`
264
- ])
265
- : baseCommand;
266
- // For prompt-injection fallback we still want a single canonical manifest
267
- // path. prpm installs into the harness target dir; skill.sh installs into
268
- // its universal `.agents/skills` dir regardless of harness, so key off
269
- // whichever cleanup path ends in the installed name.
270
- const repoRelativeDir = resolved.kind === 'skill.sh'
271
- ? `.agents/skills/${resolved.installedName}`
272
- : `${target.dir}/${resolved.installedName}`;
273
- const installedDir = installRoot !== undefined ? `${installRoot}/${repoRelativeDir}` : repoRelativeDir;
274
- // When the plan stages into `installRoot`, cleanup targets the whole
275
- // session dir (handled at plan level in buildCleanupArtifacts). Leave
276
- // per-skill cleanupPaths empty so callers running individual
277
- // install.cleanupPaths don't accidentally remove unrelated things.
278
- const cleanupPaths = installRoot !== undefined
279
- ? Object.freeze([])
280
- : provider.cleanupPaths(resolved, harness);
281
- return {
282
- skillId: skill.id,
283
- source: skill.source,
284
- sourceKind: resolved.kind,
285
- packageRef: resolved.packageRef,
286
- harness,
287
- installCommand,
288
- installedDir,
289
- installedManifest: `${installedDir}/SKILL.md`,
290
- cleanupPaths
291
- };
292
- });
293
- return {
294
- harness,
295
- installs,
296
- ...(installRoot !== undefined ? { sessionInstallRoot: installRoot } : {})
297
- };
298
- }
299
- /**
300
- * Convenience wrapper: derive the install plan directly from a resolved
301
- * persona selection, using its tier's harness automatically.
302
- */
303
- export function materializeSkillsFor(selection, options = {}) {
304
- return materializeSkills(selection.skills, selection.runtime.harness, options);
305
- }
306
- function shellEscape(value) {
307
- if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
308
- return value;
309
- }
310
- return `'${value.replace(/'/g, `'\\''`)}'`;
311
- }
312
- function commandToShellString(command) {
313
- return command.map(shellEscape).join(' ');
314
- }
315
- /**
316
- * Minimal Claude Code plugin manifest written to `<root>/.claude-plugin/plugin.json`
317
- * in session-install-root mode. Claude's plugin loader treats any directory
318
- * that contains this file (alongside a `skills/` tree) as a plugin; we pass
319
- * the root to `claude --plugin-dir <root>` so the session sees exactly the
320
- * skills we staged — and nothing the repo happens to carry.
321
- */
322
- const SESSION_PLUGIN_MANIFEST = JSON.stringify({
323
- name: 'agent-workforce-session',
324
- version: '0.0.0',
325
- description: 'Ephemeral skills staged by agent-workforce for this session.'
326
- });
327
- function buildSessionScaffoldCommand(root) {
328
- const q = shellEscape(root);
329
- // mkdir -p creates both the plugin metadata dir and the prpm target.
330
- // `ln -sfn .claude/skills skills` makes Claude's expected plugin layout
331
- // (`<root>/skills/<name>/SKILL.md`) resolve to prpm's actual output
332
- // (`<root>/.claude/skills/<name>/SKILL.md`) without moving any files.
333
- // The -n guards against following into an existing `skills/` dir (e.g.
334
- // when the session dir is reused), and -f replaces a prior symlink so
335
- // the scaffold is idempotent.
336
- return [
337
- `mkdir -p ${q}/.claude-plugin ${q}/.claude/skills`,
338
- `ln -sfn .claude/skills ${q}/skills`,
339
- `printf '%s' ${shellEscape(SESSION_PLUGIN_MANIFEST)} > ${q}/.claude-plugin/plugin.json`
340
- ].join(' && ');
341
- }
342
- function buildInstallArtifacts(plan) {
343
- if (plan.sessionInstallRoot !== undefined) {
344
- // Session mode always stages a plugin dir so the caller can pass
345
- // `--plugin-dir <root>` to claude unconditionally. Even for personas
346
- // with zero skills, we emit the scaffold (mkdir + manifest + symlink)
347
- // so the `--plugin-dir` target exists.
348
- const root = plan.sessionInstallRoot;
349
- const scaffold = buildSessionScaffoldCommand(root);
350
- if (plan.installs.length === 0) {
351
- return {
352
- installCommand: Object.freeze(['sh', '-c', scaffold]),
353
- installCommandString: scaffold
354
- };
355
- }
356
- // Chain the raw provider commands after a single `cd <root>` so we emit
357
- // one shell invocation instead of repeating the cd per skill. Each
358
- // install.installCommand is already self-contained (`sh -c 'cd <root> &&
359
- // …'`) for callers who want to run one at a time, but here we flatten
360
- // to the underlying prpm argv for a cleaner chain.
361
- const perSkill = plan.installs
362
- .map((install) => {
363
- const resolved = resolveSkillSource(install.source);
364
- const provider = providerFor(resolved.kind);
365
- return commandToShellString(provider.buildInstallCommand(resolved, plan.harness));
366
- })
367
- .join(' && ');
368
- const installCommandString = `${scaffold} && cd ${shellEscape(root)} && ${perSkill}`;
369
- return {
370
- installCommand: Object.freeze(['sh', '-c', installCommandString]),
371
- installCommandString
372
- };
373
- }
374
- if (plan.installs.length === 0) {
375
- return {
376
- installCommand: Object.freeze(['sh', '-c', ':']),
377
- installCommandString: ':'
378
- };
379
- }
380
- const installCommandString = plan.installs
381
- .map((install) => commandToShellString(install.installCommand))
382
- .join(' && ');
383
- return {
384
- installCommand: Object.freeze(['sh', '-c', installCommandString]),
385
- installCommandString
386
- };
387
- }
388
- /**
389
- * Post-run cleanup: one shell command that removes every ephemeral artifact
390
- * path declared across all installs in the plan. Runs AFTER the agent step so
391
- * the agent can still read skill manifests off disk during execution. The
392
- * provider lockfile is deliberately not in the path set, so repeat runs keep
393
- * cached resolution.
394
- *
395
- * Empty plans return `:` (shell no-op) to keep the post-step shape uniform.
396
- */
397
- function buildCleanupArtifacts(plan) {
398
- // Session mode: cleanup is the whole stage dir. The scaffold always runs
399
- // (even for zero-skill personas), so cleanup unconditionally drops the
400
- // stage dir. The CLI is responsible for removing the enclosing session
401
- // root; this command covers the install subtree.
402
- if (plan.sessionInstallRoot !== undefined) {
403
- const cleanupCommandString = `rm -rf ${shellEscape(plan.sessionInstallRoot)}`;
404
- return {
405
- cleanupCommand: Object.freeze(['sh', '-c', cleanupCommandString]),
406
- cleanupCommandString
407
- };
408
- }
409
- const allPaths = plan.installs.flatMap((install) => [...install.cleanupPaths]);
410
- const cleanupCommandString = allPaths.length === 0 ? ':' : `rm -rf ${allPaths.map(shellEscape).join(' ')}`;
411
- return {
412
- cleanupCommand: Object.freeze(['sh', '-c', cleanupCommandString]),
413
- cleanupCommandString
414
- };
415
- }
416
- function deepFreeze(value) {
417
- if (value === null || value === undefined || typeof value !== 'object') {
418
- return value;
419
- }
420
- if (Array.isArray(value)) {
421
- for (const entry of value) {
422
- deepFreeze(entry);
423
- }
424
- return Object.freeze(value);
425
- }
426
- for (const nested of Object.values(value)) {
427
- deepFreeze(nested);
428
- }
429
- return Object.freeze(value);
430
- }
431
- function isObject(value) {
432
- return typeof value === 'object' && value !== null;
433
- }
434
- function isHarness(value) {
435
- return typeof value === 'string' && HARNESS_VALUES.includes(value);
436
- }
437
- function isTier(value) {
438
- return typeof value === 'string' && PERSONA_TIERS.includes(value);
439
- }
440
- function isIntent(value) {
441
- return typeof value === 'string' && PERSONA_INTENTS.includes(value);
442
- }
443
- function isTag(value) {
444
- return typeof value === 'string' && PERSONA_TAGS.includes(value);
445
- }
446
- function parseTags(value, context) {
447
- if (!Array.isArray(value) || value.length === 0) {
448
- throw new Error(`${context} must be a non-empty array of tags`);
449
- }
450
- const out = [];
451
- for (const [idx, entry] of value.entries()) {
452
- if (!isTag(entry)) {
453
- throw new Error(`${context}[${idx}] must be one of: ${PERSONA_TAGS.join(', ')}`);
454
- }
455
- if (!out.includes(entry))
456
- out.push(entry);
457
- }
458
- return out;
459
- }
460
- function isSidecarMode(value) {
461
- return typeof value === 'string' && SIDECAR_MD_MODES.includes(value);
462
- }
463
- function assertSidecarPath(value, context) {
464
- if (typeof value !== 'string' || !value.trim()) {
465
- throw new Error(`${context} must be a non-empty string`);
466
- }
467
- if (value.startsWith('/')) {
468
- throw new Error(`${context} must be a relative POSIX path; got absolute "${value}"`);
469
- }
470
- const segments = value.split(/[\\/]+/);
471
- if (segments.some((s) => s === '..')) {
472
- throw new Error(`${context} must not contain ".." segments`);
473
- }
474
- if (!value.toLowerCase().endsWith('.md')) {
475
- throw new Error(`${context} must end with .md`);
476
- }
477
- }
478
- function parseHarnessSettings(value, context) {
479
- if (!isObject(value)) {
480
- throw new Error(`${context} must be an object`);
481
- }
482
- const { reasoning, timeoutSeconds, sandboxMode, approvalPolicy, workspaceWriteNetworkAccess, webSearch } = value;
483
- if (!['low', 'medium', 'high'].includes(String(reasoning))) {
484
- throw new Error(`${context}.reasoning must be low|medium|high`);
485
- }
486
- if (typeof timeoutSeconds !== 'number' || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
487
- throw new Error(`${context}.timeoutSeconds must be a positive number`);
488
- }
489
- const out = {
490
- reasoning: reasoning,
491
- timeoutSeconds
492
- };
493
- if (sandboxMode !== undefined) {
494
- if (!CODEX_SANDBOX_MODES.includes(sandboxMode)) {
495
- throw new Error(`${context}.sandboxMode must be one of: ${CODEX_SANDBOX_MODES.join(', ')}`);
496
- }
497
- out.sandboxMode = sandboxMode;
498
- }
499
- if (approvalPolicy !== undefined) {
500
- if (!CODEX_APPROVAL_POLICIES.includes(approvalPolicy)) {
501
- throw new Error(`${context}.approvalPolicy must be one of: ${CODEX_APPROVAL_POLICIES.join(', ')}`);
502
- }
503
- out.approvalPolicy = approvalPolicy;
504
- }
505
- if (workspaceWriteNetworkAccess !== undefined) {
506
- if (typeof workspaceWriteNetworkAccess !== 'boolean') {
507
- throw new Error(`${context}.workspaceWriteNetworkAccess must be a boolean`);
508
- }
509
- out.workspaceWriteNetworkAccess = workspaceWriteNetworkAccess;
510
- }
511
- if (webSearch !== undefined) {
512
- if (typeof webSearch !== 'boolean') {
513
- throw new Error(`${context}.webSearch must be a boolean`);
514
- }
515
- out.webSearch = webSearch;
516
- }
517
- return out;
518
- }
519
- function parseRuntime(value, context) {
520
- if (!isObject(value)) {
521
- throw new Error(`${context} must be an object`);
522
- }
523
- const { harness, model, systemPrompt, harnessSettings, claudeMd, claudeMdMode, agentsMd, agentsMdMode, claudeMdContent, agentsMdContent } = value;
524
- if (!isHarness(harness)) {
525
- throw new Error(`${context}.harness must be one of: ${HARNESS_VALUES.join(', ')}`);
526
- }
527
- if (typeof model !== 'string' || !model.trim()) {
528
- throw new Error(`${context}.model must be a non-empty string`);
529
- }
530
- if (typeof systemPrompt !== 'string' || !systemPrompt.trim()) {
531
- throw new Error(`${context}.systemPrompt must be a non-empty string`);
532
- }
533
- const parsedHarnessSettings = parseHarnessSettings(harnessSettings, `${context}.harnessSettings`);
534
- if (claudeMd !== undefined)
535
- assertSidecarPath(claudeMd, `${context}.claudeMd`);
536
- if (agentsMd !== undefined)
537
- assertSidecarPath(agentsMd, `${context}.agentsMd`);
538
- if (claudeMdMode !== undefined && !isSidecarMode(claudeMdMode)) {
539
- throw new Error(`${context}.claudeMdMode must be one of: ${SIDECAR_MD_MODES.join(', ')}`);
540
- }
541
- if (agentsMdMode !== undefined && !isSidecarMode(agentsMdMode)) {
542
- throw new Error(`${context}.agentsMdMode must be one of: ${SIDECAR_MD_MODES.join(', ')}`);
543
- }
544
- // Mode is allowed without a same-level path: a tier may declare just
545
- // `claudeMdMode` and inherit the path from the spec top-level (or vice
546
- // versa). The cascade validates that a path/content actually exists at
547
- // runtime — a stranded mode with no path anywhere becomes a no-op.
548
- if (claudeMdContent !== undefined && (typeof claudeMdContent !== 'string' || !claudeMdContent.length)) {
549
- throw new Error(`${context}.claudeMdContent must be a non-empty string`);
550
- }
551
- if (agentsMdContent !== undefined && (typeof agentsMdContent !== 'string' || !agentsMdContent.length)) {
552
- throw new Error(`${context}.agentsMdContent must be a non-empty string`);
553
- }
554
- return {
555
- harness,
556
- model,
557
- systemPrompt,
558
- harnessSettings: parsedHarnessSettings,
559
- ...(typeof claudeMd === 'string' ? { claudeMd } : {}),
560
- ...(claudeMdMode ? { claudeMdMode: claudeMdMode } : {}),
561
- ...(typeof agentsMd === 'string' ? { agentsMd } : {}),
562
- ...(agentsMdMode ? { agentsMdMode: agentsMdMode } : {}),
563
- ...(typeof claudeMdContent === 'string' ? { claudeMdContent } : {}),
564
- ...(typeof agentsMdContent === 'string' ? { agentsMdContent } : {})
565
- };
566
- }
567
- function parseSkills(value, context) {
568
- if (value === undefined) {
569
- return [];
570
- }
571
- if (!Array.isArray(value)) {
572
- throw new Error(`${context} must be an array if provided`);
573
- }
574
- return value.map((entry, idx) => {
575
- const entryContext = `${context}[${idx}]`;
576
- if (!isObject(entry)) {
577
- throw new Error(`${entryContext} must be an object`);
578
- }
579
- const { id, source, description } = entry;
580
- if (typeof id !== 'string' || !id.trim()) {
581
- throw new Error(`${entryContext}.id must be a non-empty string`);
582
- }
583
- if (typeof source !== 'string' || !source.trim()) {
584
- throw new Error(`${entryContext}.source must be a non-empty string`);
585
- }
586
- if (typeof description !== 'string' || !description.trim()) {
587
- throw new Error(`${entryContext}.description must be a non-empty string`);
588
- }
589
- return { id, source, description };
590
- });
591
- }
592
- function parseStringList(value, context) {
593
- if (value === undefined)
594
- return undefined;
595
- if (!Array.isArray(value)) {
596
- throw new Error(`${context} must be an array if provided`);
597
- }
598
- const parsed = value.map((entry, idx) => {
599
- if (typeof entry !== 'string' || !entry.trim()) {
600
- throw new Error(`${context}[${idx}] must be a non-empty string`);
601
- }
602
- return entry;
603
- });
604
- return parsed.length > 0 ? parsed : undefined;
605
- }
606
- function parseMount(value, context) {
607
- if (value === undefined)
608
- return undefined;
609
- if (!isObject(value)) {
610
- throw new Error(`${context} must be an object if provided`);
611
- }
612
- const ignoredPatterns = parseStringList(value.ignoredPatterns, `${context}.ignoredPatterns`);
613
- const readonlyPatterns = parseStringList(value.readonlyPatterns, `${context}.readonlyPatterns`);
614
- return ignoredPatterns || readonlyPatterns
615
- ? {
616
- ...(ignoredPatterns ? { ignoredPatterns } : {}),
617
- ...(readonlyPatterns ? { readonlyPatterns } : {})
618
- }
619
- : undefined;
620
- }
621
- const INPUT_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
622
- function assertInputName(name, context) {
623
- if (!INPUT_NAME_RE.test(name)) {
624
- throw new Error(`${context} must be an env-style name matching ${INPUT_NAME_RE.source}`);
625
- }
626
- }
627
- function parseInputs(value, context) {
628
- if (value === undefined)
629
- return undefined;
630
- if (!isObject(value)) {
631
- throw new Error(`${context} must be an object if provided`);
632
- }
633
- const out = {};
634
- for (const [name, raw] of Object.entries(value)) {
635
- assertInputName(name, `${context}.${name}`);
636
- if (typeof raw === 'string') {
637
- if (!raw) {
638
- throw new Error(`${context}.${name} default must be non-empty`);
639
- }
640
- out[name] = { default: raw };
641
- continue;
642
- }
643
- if (!isObject(raw)) {
644
- throw new Error(`${context}.${name} must be a string default or an object`);
645
- }
646
- const { description, env, default: defaultValue, optional } = raw;
647
- const parsed = {};
648
- if (description !== undefined) {
649
- if (typeof description !== 'string' || !description.trim()) {
650
- throw new Error(`${context}.${name}.description must be a non-empty string if provided`);
651
- }
652
- parsed.description = description;
653
- }
654
- if (env !== undefined) {
655
- if (typeof env !== 'string' || !env.trim()) {
656
- throw new Error(`${context}.${name}.env must be a non-empty string if provided`);
657
- }
658
- assertInputName(env, `${context}.${name}.env`);
659
- parsed.env = env;
660
- }
661
- if (defaultValue !== undefined) {
662
- if (typeof defaultValue !== 'string' || !defaultValue) {
663
- throw new Error(`${context}.${name}.default must be a non-empty string if provided`);
664
- }
665
- parsed.default = defaultValue;
666
- }
667
- if (optional !== undefined) {
668
- if (typeof optional !== 'boolean') {
669
- throw new Error(`${context}.${name}.optional must be a boolean if provided`);
670
- }
671
- if (optional && parsed.default !== undefined) {
672
- throw new Error(`${context}.${name} cannot set both 'optional: true' and 'default' — pick one (defaults already make an input always-resolved)`);
673
- }
674
- parsed.optional = optional;
675
- }
676
- out[name] = parsed;
677
- }
678
- return Object.keys(out).length > 0 ? out : undefined;
679
- }
680
- function parsePersonaSpec(value, expectedIntent) {
681
- if (!isObject(value)) {
682
- throw new Error(`persona[${expectedIntent}] must be an object`);
683
- }
684
- const { id, intent, tags, description, tiers, defaultTier, skills, inputs, env, mcpServers, permissions, mount, claudeMd, claudeMdMode, agentsMd, agentsMdMode, claudeMdContent, agentsMdContent } = value;
685
- if (typeof id !== 'string' || !id.trim()) {
686
- throw new Error(`persona[${expectedIntent}].id must be a non-empty string`);
687
- }
688
- if (!isIntent(intent)) {
689
- throw new Error(`persona[${expectedIntent}].intent is invalid`);
690
- }
691
- if (intent !== expectedIntent) {
692
- throw new Error(`persona[${expectedIntent}] intent mismatch: got ${intent}`);
693
- }
694
- const parsedTags = parseTags(tags, `persona[${expectedIntent}].tags`);
695
- if (typeof description !== 'string' || !description.trim()) {
696
- throw new Error(`persona[${expectedIntent}].description must be a non-empty string`);
697
- }
698
- if (!isObject(tiers)) {
699
- throw new Error(`persona[${expectedIntent}].tiers must be an object`);
700
- }
701
- const parsedTiers = {};
702
- for (const tier of PERSONA_TIERS) {
703
- parsedTiers[tier] = parseRuntime(tiers[tier], `persona[${expectedIntent}].tiers.${tier}`);
704
- }
705
- let parsedDefaultTier;
706
- if (defaultTier !== undefined) {
707
- if (!isTier(defaultTier)) {
708
- throw new Error(`persona[${expectedIntent}].defaultTier must be one of: ${PERSONA_TIERS.join(', ')}`);
709
- }
710
- parsedDefaultTier = defaultTier;
711
- }
712
- const parsedSkills = parseSkills(skills, `persona[${expectedIntent}].skills`);
713
- const parsedInputs = parseInputs(inputs, `persona[${expectedIntent}].inputs`);
714
- const parsedEnv = parseStringMap(env, `persona[${expectedIntent}].env`);
715
- const parsedMcpServers = parseMcpServers(mcpServers, `persona[${expectedIntent}].mcpServers`);
716
- const parsedPermissions = parsePermissions(permissions, `persona[${expectedIntent}].permissions`);
717
- const parsedMount = parseMount(mount, `persona[${expectedIntent}].mount`);
718
- if (claudeMd !== undefined)
719
- assertSidecarPath(claudeMd, `persona[${expectedIntent}].claudeMd`);
720
- if (agentsMd !== undefined)
721
- assertSidecarPath(agentsMd, `persona[${expectedIntent}].agentsMd`);
722
- if (claudeMdMode !== undefined && !isSidecarMode(claudeMdMode)) {
723
- throw new Error(`persona[${expectedIntent}].claudeMdMode must be one of: ${SIDECAR_MD_MODES.join(', ')}`);
724
- }
725
- if (agentsMdMode !== undefined && !isSidecarMode(agentsMdMode)) {
726
- throw new Error(`persona[${expectedIntent}].agentsMdMode must be one of: ${SIDECAR_MD_MODES.join(', ')}`);
727
- }
728
- // Spec-level mode without a spec-level path is allowed — a tier may
729
- // supply the path while inheriting the mode here. See parseRuntime.
730
- if (claudeMdContent !== undefined &&
731
- (typeof claudeMdContent !== 'string' || !claudeMdContent.length)) {
732
- throw new Error(`persona[${expectedIntent}].claudeMdContent must be a non-empty string`);
733
- }
734
- if (agentsMdContent !== undefined &&
735
- (typeof agentsMdContent !== 'string' || !agentsMdContent.length)) {
736
- throw new Error(`persona[${expectedIntent}].agentsMdContent must be a non-empty string`);
737
- }
738
- return {
739
- id,
740
- intent,
741
- tags: parsedTags,
742
- description,
743
- skills: parsedSkills,
744
- ...(parsedInputs ? { inputs: parsedInputs } : {}),
745
- tiers: parsedTiers,
746
- ...(parsedDefaultTier ? { defaultTier: parsedDefaultTier } : {}),
747
- ...(parsedEnv ? { env: parsedEnv } : {}),
748
- ...(parsedMcpServers ? { mcpServers: parsedMcpServers } : {}),
749
- ...(parsedPermissions ? { permissions: parsedPermissions } : {}),
750
- ...(parsedMount ? { mount: parsedMount } : {}),
751
- ...(typeof claudeMd === 'string' ? { claudeMd } : {}),
752
- ...(claudeMdMode ? { claudeMdMode: claudeMdMode } : {}),
753
- ...(typeof agentsMd === 'string' ? { agentsMd } : {}),
754
- ...(agentsMdMode ? { agentsMdMode: agentsMdMode } : {}),
755
- ...(typeof claudeMdContent === 'string' ? { claudeMdContent } : {}),
756
- ...(typeof agentsMdContent === 'string' ? { agentsMdContent } : {})
757
- };
758
- }
759
- function parsePermissions(value, context) {
760
- if (value === undefined)
761
- return undefined;
762
- if (!isObject(value)) {
763
- throw new Error(`${context} must be an object if provided`);
764
- }
765
- const out = {};
766
- const { allow, deny, mode } = value;
767
- if (allow !== undefined) {
768
- if (!Array.isArray(allow) || allow.some((s) => typeof s !== 'string' || !s.trim())) {
769
- throw new Error(`${context}.allow must be an array of non-empty strings`);
770
- }
771
- out.allow = allow;
772
- }
773
- if (deny !== undefined) {
774
- if (!Array.isArray(deny) || deny.some((s) => typeof s !== 'string' || !s.trim())) {
775
- throw new Error(`${context}.deny must be an array of non-empty strings`);
776
- }
777
- out.deny = deny;
778
- }
779
- if (mode !== undefined) {
780
- if (!PERMISSION_MODES.includes(mode)) {
781
- throw new Error(`${context}.mode must be one of: ${PERMISSION_MODES.join(', ')}`);
782
- }
783
- out.mode = mode;
784
- }
785
- return Object.keys(out).length > 0 ? out : undefined;
786
- }
787
- function parseStringMap(value, context) {
788
- if (value === undefined)
789
- return undefined;
790
- if (!isObject(value)) {
791
- throw new Error(`${context} must be an object if provided`);
792
- }
793
- const out = {};
794
- for (const [key, v] of Object.entries(value)) {
795
- if (typeof v !== 'string') {
796
- throw new Error(`${context}.${key} must be a string`);
797
- }
798
- out[key] = v;
799
- }
800
- return out;
801
- }
802
- function parseMcpServers(value, context) {
803
- if (value === undefined)
804
- return undefined;
805
- if (!isObject(value)) {
806
- throw new Error(`${context} must be an object if provided`);
807
- }
808
- const out = {};
809
- for (const [name, raw] of Object.entries(value)) {
810
- if (!isObject(raw)) {
811
- throw new Error(`${context}.${name} must be an object`);
812
- }
813
- const type = raw.type;
814
- if (type === 'http' || type === 'sse') {
815
- if (typeof raw.url !== 'string' || !raw.url.trim()) {
816
- throw new Error(`${context}.${name}.url must be a non-empty string for type=${type}`);
817
- }
818
- const headers = parseStringMap(raw.headers, `${context}.${name}.headers`);
819
- out[name] = { type, url: raw.url, ...(headers ? { headers } : {}) };
820
- }
821
- else if (type === 'stdio') {
822
- if (typeof raw.command !== 'string' || !raw.command.trim()) {
823
- throw new Error(`${context}.${name}.command must be a non-empty string for type=stdio`);
824
- }
825
- const args = raw.args;
826
- if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
827
- throw new Error(`${context}.${name}.args must be an array of strings`);
828
- }
829
- const env = parseStringMap(raw.env, `${context}.${name}.env`);
830
- out[name] = {
831
- type: 'stdio',
832
- command: raw.command,
833
- ...(args ? { args: args } : {}),
834
- ...(env ? { env } : {})
835
- };
836
- }
837
- else {
838
- throw new Error(`${context}.${name}.type must be one of: http, sse, stdio`);
839
- }
840
- }
841
- return out;
842
- }
843
4
  function parseRoutingProfile(value, context) {
844
5
  if (!isObject(value)) {
845
6
  throw new Error(`${context} must be an object`);
@@ -894,52 +55,6 @@ function requireBuiltInPersona(intent) {
894
55
  export const routingProfiles = {
895
56
  default: parseRoutingProfile(defaultRoutingProfileJson, 'routingProfiles.default')
896
57
  };
897
- /**
898
- * Resolve the effective sidecar config for a (spec, tier) pair.
899
- *
900
- * Path-or-content resolution: each sidecar (`claude*`, `agents*`) is a
901
- * single "channel" — its `*Md` and `*MdContent` fields are tied together
902
- * and travel as a unit through the cascade. If the tier-level runtime
903
- * declares EITHER `claudeMd` or `claudeMdContent`, the tier owns the
904
- * channel and the top-level path/content is ignored (otherwise a tier
905
- * path override would silently lose to inherited inlined content, since
906
- * downstream consumers prefer Content over a path).
907
- *
908
- * Mode resolution: independent — a tier can set just `claudeMdMode` and
909
- * inherit the top-level path. Defaults to `overwrite` if neither layer
910
- * sets a mode. Modes are only meaningful when a path/content is present.
911
- */
912
- export function resolveSidecar(spec, tier) {
913
- const runtime = spec.tiers[tier];
914
- const tierOwnsClaude = runtime.claudeMd !== undefined || runtime.claudeMdContent !== undefined;
915
- const tierOwnsAgents = runtime.agentsMd !== undefined || runtime.agentsMdContent !== undefined;
916
- const claudePath = tierOwnsClaude ? runtime.claudeMd : spec.claudeMd;
917
- const claudeContent = tierOwnsClaude ? runtime.claudeMdContent : spec.claudeMdContent;
918
- const agentsPath = tierOwnsAgents ? runtime.agentsMd : spec.agentsMd;
919
- const agentsContent = tierOwnsAgents ? runtime.agentsMdContent : spec.agentsMdContent;
920
- return {
921
- ...(claudePath ? { claudeMd: claudePath } : {}),
922
- ...(claudeContent ? { claudeMdContent: claudeContent } : {}),
923
- claudeMdMode: runtime.claudeMdMode ?? spec.claudeMdMode ?? 'overwrite',
924
- ...(agentsPath ? { agentsMd: agentsPath } : {}),
925
- ...(agentsContent ? { agentsMdContent: agentsContent } : {}),
926
- agentsMdMode: runtime.agentsMdMode ?? spec.agentsMdMode ?? 'overwrite'
927
- };
928
- }
929
- function sidecarSelectionFields(sidecar) {
930
- return {
931
- ...(sidecar.claudeMd ? { claudeMd: sidecar.claudeMd } : {}),
932
- ...(sidecar.claudeMdContent ? { claudeMdContent: sidecar.claudeMdContent } : {}),
933
- ...(sidecar.claudeMd || sidecar.claudeMdContent
934
- ? { claudeMdMode: sidecar.claudeMdMode }
935
- : {}),
936
- ...(sidecar.agentsMd ? { agentsMd: sidecar.agentsMd } : {}),
937
- ...(sidecar.agentsMdContent ? { agentsMdContent: sidecar.agentsMdContent } : {}),
938
- ...(sidecar.agentsMd || sidecar.agentsMdContent
939
- ? { agentsMdMode: sidecar.agentsMdMode }
940
- : {})
941
- };
942
- }
943
58
  export function resolvePersona(intent, profile = 'default') {
944
59
  const profileSpec = typeof profile === 'string' ? routingProfiles[profile] : profile;
945
60
  const rule = profileSpec.intents[intent];