@agentworkforce/workload-router 0.2.0 → 0.4.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,10 +1,21 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { resolve as resolvePath } from 'node:path';
4
- import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra } from './generated/personas.js';
4
+ import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra, sageSlackEgressMigrator, sageProactiveRewirer, cloudSlackProxyGuard, agentRelayE2eConductor, capabilityDiscoverer, posthogAgent } from './generated/personas.js';
5
5
  import defaultRoutingProfileJson from '../routing-profiles/default.json' with { type: 'json' };
6
6
  export const HARNESS_VALUES = ['opencode', 'codex', 'claude'];
7
7
  export const PERSONA_TIERS = ['best', 'best-value', 'minimum'];
8
+ export const PERSONA_TAGS = [
9
+ 'planning',
10
+ 'implementation',
11
+ 'review',
12
+ 'testing',
13
+ 'debugging',
14
+ 'documentation',
15
+ 'release',
16
+ 'discovery',
17
+ 'analytics'
18
+ ];
8
19
  export const PERSONA_INTENTS = [
9
20
  'implement-frontend',
10
21
  'review',
@@ -19,7 +30,19 @@ export const PERSONA_INTENTS = [
19
30
  'flake-investigation',
20
31
  'opencode-workflow-correctness',
21
32
  'npm-provenance',
22
- 'cloud-sandbox-infra'
33
+ 'cloud-sandbox-infra',
34
+ 'sage-slack-egress-migration',
35
+ 'sage-proactive-rewire',
36
+ 'cloud-slack-proxy-guard',
37
+ 'sage-cloud-e2e-conduction',
38
+ 'capability-discovery',
39
+ 'posthog'
40
+ ];
41
+ export const PERMISSION_MODES = [
42
+ 'default',
43
+ 'acceptEdits',
44
+ 'bypassPermissions',
45
+ 'plan'
23
46
  ];
24
47
  // ---------------------------------------------------------------------------
25
48
  // Skill materialization
@@ -37,7 +60,7 @@ export const PERSONA_INTENTS = [
37
60
  // `materializeSkills` is a pure function: it returns the install plan but
38
61
  // never touches the filesystem or spawns processes. Callers (relay workflows,
39
62
  // the OpenClaw spawner, ad-hoc scripts) decide how to execute it.
40
- export const SKILL_SOURCE_KINDS = ['prpm'];
63
+ export const SKILL_SOURCE_KINDS = ['prpm', 'skill.sh'];
41
64
  export const HARNESS_SKILL_TARGETS = {
42
65
  claude: { asFlag: 'claude', dir: '.claude/skills' },
43
66
  codex: { asFlag: 'codex', dir: '.agents/skills' },
@@ -63,66 +86,188 @@ class CapturedCommandError extends Error {
63
86
  }
64
87
  const PRPM_URL_RE = /^https?:\/\/prpm\.dev\/packages\/([^/\s?#]+)\/([^/\s?#]+)\/?(?:[?#].*)?$/i;
65
88
  const PRPM_BARE_REF_RE = /^([^/\s]+)\/([^/\s]+)$/;
66
- function resolveSkillSource(source) {
67
- const urlMatch = source.match(PRPM_URL_RE);
68
- if (urlMatch) {
69
- return { kind: 'prpm', packageRef: `${urlMatch[1]}/${urlMatch[2]}` };
89
+ function lastSegment(ref) {
90
+ const slash = ref.lastIndexOf('/');
91
+ return slash >= 0 ? ref.slice(slash + 1) : ref;
92
+ }
93
+ const prpmProvider = {
94
+ kind: 'prpm',
95
+ parse(source) {
96
+ const urlMatch = source.match(PRPM_URL_RE);
97
+ if (urlMatch) {
98
+ const ref = `${urlMatch[1]}/${urlMatch[2]}`;
99
+ return { kind: 'prpm', packageRef: ref, installedName: lastSegment(ref) };
100
+ }
101
+ const bareMatch = source.match(PRPM_BARE_REF_RE);
102
+ if (bareMatch) {
103
+ return { kind: 'prpm', packageRef: source, installedName: lastSegment(source) };
104
+ }
105
+ return null;
106
+ },
107
+ buildInstallCommand(ref, harness) {
108
+ const target = HARNESS_SKILL_TARGETS[harness];
109
+ return Object.freeze([
110
+ 'npx',
111
+ '-y',
112
+ 'prpm',
113
+ 'install',
114
+ ref.packageRef,
115
+ '--as',
116
+ target.asFlag
117
+ ]);
118
+ },
119
+ cleanupPaths(ref, harness) {
120
+ const target = HARNESS_SKILL_TARGETS[harness];
121
+ return Object.freeze([`${target.dir}/${ref.installedName}`]);
122
+ }
123
+ };
124
+ // skill.sh source form: `<github-url>#<skill-name>`
125
+ // Example: `https://github.com/vercel-labs/skills#find-skills`
126
+ const SKILL_SH_URL_RE = /^(https?:\/\/github\.com\/[^/\s?#]+\/[^/\s?#]+?)(?:\.git)?#([^\s?#]+)$/i;
127
+ /**
128
+ * Paths `npx skills add` writes per install. Mirrors the on-disk layout from
129
+ * a live `npx -y skills add ... -y` run (universal dir + harness-side
130
+ * symlinks). `skills-lock.json` is deliberately excluded so repeat runs can
131
+ * re-resolve from the lock instead of refetching sources.
132
+ */
133
+ function skillShArtifactPaths(installedName) {
134
+ return Object.freeze([
135
+ `.agents/skills/${installedName}`,
136
+ `.claude/skills/${installedName}`,
137
+ `.factory/skills/${installedName}`,
138
+ `.kiro/skills/${installedName}`,
139
+ `skills/${installedName}`
140
+ ]);
141
+ }
142
+ const skillShProvider = {
143
+ kind: 'skill.sh',
144
+ parse(source) {
145
+ const match = source.match(SKILL_SH_URL_RE);
146
+ if (!match) {
147
+ return null;
148
+ }
149
+ const [, repoUrl, skillName] = match;
150
+ return {
151
+ kind: 'skill.sh',
152
+ // packageRef preserves the full `<repo>#<skill>` shape so the command builder
153
+ // can reconstruct both halves without re-parsing the original source.
154
+ packageRef: `${repoUrl}#${skillName}`,
155
+ installedName: skillName
156
+ };
157
+ },
158
+ buildInstallCommand(ref) {
159
+ const [repoUrl, skillName] = ref.packageRef.split('#');
160
+ return Object.freeze([
161
+ 'npx',
162
+ '-y',
163
+ 'skills',
164
+ 'add',
165
+ repoUrl,
166
+ '--skill',
167
+ skillName,
168
+ '-y'
169
+ ]);
170
+ },
171
+ cleanupPaths(ref) {
172
+ // skill.sh installs the same universal dir + harness symlinks regardless
173
+ // of which agent is the "host" — clean all of them so nothing leaks into
174
+ // `.claude/`, `.agents/`, etc. after the persona run.
175
+ return skillShArtifactPaths(ref.installedName);
70
176
  }
71
- const bareMatch = source.match(PRPM_BARE_REF_RE);
72
- if (bareMatch) {
73
- return { kind: 'prpm', packageRef: source };
177
+ };
178
+ const SKILL_PROVIDERS = Object.freeze([prpmProvider, skillShProvider]);
179
+ function resolveSkillSource(source) {
180
+ for (const provider of SKILL_PROVIDERS) {
181
+ const parsed = provider.parse(source);
182
+ if (parsed) {
183
+ return parsed;
184
+ }
74
185
  }
75
186
  throw new Error(`Unsupported skill source: ${source}. ` +
76
- `Supported forms: prpm.dev package URL (https://prpm.dev/packages/<scope>/<name>) ` +
77
- `or a bare "<scope>/<name>" reference.`);
187
+ `Supported forms: prpm.dev package URL (https://prpm.dev/packages/<scope>/<name>), ` +
188
+ `bare "<scope>/<name>" prpm reference, ` +
189
+ `or skill.sh github URL with skill fragment (https://github.com/<org>/<repo>#<skill>).`);
78
190
  }
79
- function deriveInstalledName(packageRef) {
80
- const slash = packageRef.lastIndexOf('/');
81
- return slash >= 0 ? packageRef.slice(slash + 1) : packageRef;
191
+ function providerFor(kind) {
192
+ const provider = SKILL_PROVIDERS.find((p) => p.kind === kind);
193
+ if (!provider) {
194
+ throw new Error(`No skill provider registered for kind: ${kind}`);
195
+ }
196
+ return provider;
82
197
  }
83
198
  /**
84
199
  * Given a set of persona skills and the harness the persona will run under,
85
- * produce the concrete install plan: which `prpm install` invocations to run
86
- * and where the skill will land on disk once installed.
200
+ * produce the concrete install plan: which install invocations to run, where
201
+ * the skill will land on disk, and which artifact paths should be cleaned up
202
+ * after the persona run to keep the workspace tidy.
87
203
  *
88
204
  * Pure function — does not execute commands or touch the filesystem.
89
205
  */
90
- export function materializeSkills(skills, harness) {
206
+ export function materializeSkills(skills, harness, options = {}) {
91
207
  const target = HARNESS_SKILL_TARGETS[harness];
92
208
  if (!target) {
93
209
  throw new Error(`No skill install target configured for harness: ${harness}`);
94
210
  }
211
+ const { installRoot } = options;
212
+ if (installRoot !== undefined && harness !== 'claude') {
213
+ throw new Error(`installRoot is only supported for the claude harness (got: ${harness}). ` +
214
+ `codex and opencode still install into the harness's conventional repo-relative directory.`);
215
+ }
95
216
  const installs = skills.map((skill) => {
96
- const { kind, packageRef } = resolveSkillSource(skill.source);
97
- const installedName = deriveInstalledName(packageRef);
98
- const installedDir = `${target.dir}/${installedName}`;
217
+ const resolved = resolveSkillSource(skill.source);
218
+ const provider = providerFor(resolved.kind);
219
+ const baseCommand = provider.buildInstallCommand(resolved, harness);
220
+ // In session-install-root mode, the install runs `cd <installRoot> && <prpm>`
221
+ // so that prpm's harness-relative dirs (`.claude/skills/<name>`) land
222
+ // inside the stage dir instead of the user's repo. The per-install command
223
+ // stays self-contained so callers who run a single install.installCommand
224
+ // directly still get the correct placement.
225
+ const installCommand = installRoot !== undefined
226
+ ? Object.freeze([
227
+ 'sh',
228
+ '-c',
229
+ `cd ${shellEscape(installRoot)} && ${commandToShellString(baseCommand)}`
230
+ ])
231
+ : baseCommand;
232
+ // For prompt-injection fallback we still want a single canonical manifest
233
+ // path. prpm installs into the harness target dir; skill.sh installs into
234
+ // its universal `.agents/skills` dir regardless of harness, so key off
235
+ // whichever cleanup path ends in the installed name.
236
+ const repoRelativeDir = resolved.kind === 'skill.sh'
237
+ ? `.agents/skills/${resolved.installedName}`
238
+ : `${target.dir}/${resolved.installedName}`;
239
+ const installedDir = installRoot !== undefined ? `${installRoot}/${repoRelativeDir}` : repoRelativeDir;
240
+ // When the plan stages into `installRoot`, cleanup targets the whole
241
+ // session dir (handled at plan level in buildCleanupArtifacts). Leave
242
+ // per-skill cleanupPaths empty so Mode B callers running individual
243
+ // install.cleanupPaths don't accidentally remove unrelated things.
244
+ const cleanupPaths = installRoot !== undefined
245
+ ? Object.freeze([])
246
+ : provider.cleanupPaths(resolved, harness);
99
247
  return {
100
248
  skillId: skill.id,
101
249
  source: skill.source,
102
- sourceKind: kind,
103
- packageRef,
250
+ sourceKind: resolved.kind,
251
+ packageRef: resolved.packageRef,
104
252
  harness,
105
- installCommand: Object.freeze([
106
- 'npx',
107
- '-y',
108
- 'prpm',
109
- 'install',
110
- packageRef,
111
- '--as',
112
- target.asFlag
113
- ]),
253
+ installCommand,
114
254
  installedDir,
115
- installedManifest: `${installedDir}/SKILL.md`
255
+ installedManifest: `${installedDir}/SKILL.md`,
256
+ cleanupPaths
116
257
  };
117
258
  });
118
- return { harness, installs };
259
+ return {
260
+ harness,
261
+ installs,
262
+ ...(installRoot !== undefined ? { sessionInstallRoot: installRoot } : {})
263
+ };
119
264
  }
120
265
  /**
121
266
  * Convenience wrapper: derive the install plan directly from a resolved
122
267
  * persona selection, using its tier's harness automatically.
123
268
  */
124
- export function materializeSkillsFor(selection) {
125
- return materializeSkills(selection.skills, selection.runtime.harness);
269
+ export function materializeSkillsFor(selection, options = {}) {
270
+ return materializeSkills(selection.skills, selection.runtime.harness, options);
126
271
  }
127
272
  function shellEscape(value) {
128
273
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
@@ -133,15 +278,107 @@ function shellEscape(value) {
133
278
  function commandToShellString(command) {
134
279
  return command.map(shellEscape).join(' ');
135
280
  }
281
+ /**
282
+ * Minimal Claude Code plugin manifest written to `<root>/.claude-plugin/plugin.json`
283
+ * in session-install-root mode. Claude's plugin loader treats any directory
284
+ * that contains this file (alongside a `skills/` tree) as a plugin; we pass
285
+ * the root to `claude --plugin-dir <root>` so the session sees exactly the
286
+ * skills we staged — and nothing the repo happens to carry.
287
+ */
288
+ const SESSION_PLUGIN_MANIFEST = JSON.stringify({
289
+ name: 'agent-workforce-session',
290
+ version: '0.0.0',
291
+ description: 'Ephemeral skills staged by agent-workforce for this session.'
292
+ });
293
+ function buildSessionScaffoldCommand(root) {
294
+ const q = shellEscape(root);
295
+ // mkdir -p creates both the plugin metadata dir and the prpm target.
296
+ // `ln -sfn .claude/skills skills` makes Claude's expected plugin layout
297
+ // (`<root>/skills/<name>/SKILL.md`) resolve to prpm's actual output
298
+ // (`<root>/.claude/skills/<name>/SKILL.md`) without moving any files.
299
+ // The -n guards against following into an existing `skills/` dir (e.g.
300
+ // when the session dir is reused), and -f replaces a prior symlink so
301
+ // the scaffold is idempotent.
302
+ return [
303
+ `mkdir -p ${q}/.claude-plugin ${q}/.claude/skills`,
304
+ `ln -sfn .claude/skills ${q}/skills`,
305
+ `printf '%s' ${shellEscape(SESSION_PLUGIN_MANIFEST)} > ${q}/.claude-plugin/plugin.json`
306
+ ].join(' && ');
307
+ }
136
308
  function buildInstallArtifacts(plan) {
137
- const installCommandString = plan.installs.length === 0
138
- ? ':'
139
- : plan.installs.map((install) => commandToShellString(install.installCommand)).join(' && ');
309
+ if (plan.sessionInstallRoot !== undefined) {
310
+ // Session mode always stages a plugin dir so the caller can pass
311
+ // `--plugin-dir <root>` to claude unconditionally. Even for personas
312
+ // with zero skills, we emit the scaffold (mkdir + manifest + symlink)
313
+ // so the `--plugin-dir` target exists.
314
+ const root = plan.sessionInstallRoot;
315
+ const scaffold = buildSessionScaffoldCommand(root);
316
+ if (plan.installs.length === 0) {
317
+ return {
318
+ installCommand: Object.freeze(['sh', '-c', scaffold]),
319
+ installCommandString: scaffold
320
+ };
321
+ }
322
+ // Chain the raw provider commands after a single `cd <root>` so we emit
323
+ // one shell invocation instead of repeating the cd per skill. Each
324
+ // install.installCommand is already self-contained (`sh -c 'cd <root> &&
325
+ // …'`) for callers who want to run one at a time, but here we flatten
326
+ // to the underlying prpm argv for a cleaner chain.
327
+ const perSkill = plan.installs
328
+ .map((install) => {
329
+ const resolved = resolveSkillSource(install.source);
330
+ const provider = providerFor(resolved.kind);
331
+ return commandToShellString(provider.buildInstallCommand(resolved, plan.harness));
332
+ })
333
+ .join(' && ');
334
+ const installCommandString = `${scaffold} && cd ${shellEscape(root)} && ${perSkill}`;
335
+ return {
336
+ installCommand: Object.freeze(['sh', '-c', installCommandString]),
337
+ installCommandString
338
+ };
339
+ }
340
+ if (plan.installs.length === 0) {
341
+ return {
342
+ installCommand: Object.freeze(['sh', '-c', ':']),
343
+ installCommandString: ':'
344
+ };
345
+ }
346
+ const installCommandString = plan.installs
347
+ .map((install) => commandToShellString(install.installCommand))
348
+ .join(' && ');
140
349
  return {
141
350
  installCommand: Object.freeze(['sh', '-c', installCommandString]),
142
351
  installCommandString
143
352
  };
144
353
  }
354
+ /**
355
+ * Post-run cleanup: one shell command that removes every ephemeral artifact
356
+ * path declared across all installs in the plan. Runs AFTER the agent step so
357
+ * the agent can still read skill manifests off disk during execution. The
358
+ * provider lockfile is deliberately not in the path set, so repeat runs keep
359
+ * cached resolution.
360
+ *
361
+ * Empty plans return `:` (shell no-op) to keep the post-step shape uniform.
362
+ */
363
+ function buildCleanupArtifacts(plan) {
364
+ // Session mode: cleanup is the whole stage dir. The scaffold always runs
365
+ // (even for zero-skill personas), so cleanup unconditionally drops the
366
+ // stage dir. The CLI is responsible for removing the enclosing session
367
+ // root; this command covers the install subtree.
368
+ if (plan.sessionInstallRoot !== undefined) {
369
+ const cleanupCommandString = `rm -rf ${shellEscape(plan.sessionInstallRoot)}`;
370
+ return {
371
+ cleanupCommand: Object.freeze(['sh', '-c', cleanupCommandString]),
372
+ cleanupCommandString
373
+ };
374
+ }
375
+ const allPaths = plan.installs.flatMap((install) => [...install.cleanupPaths]);
376
+ const cleanupCommandString = allPaths.length === 0 ? ':' : `rm -rf ${allPaths.map(shellEscape).join(' ')}`;
377
+ return {
378
+ cleanupCommand: Object.freeze(['sh', '-c', cleanupCommandString]),
379
+ cleanupCommandString
380
+ };
381
+ }
145
382
  function buildExecutionTask(systemPrompt, task, inputs) {
146
383
  const sections = [`System Instructions:\n${systemPrompt.trim()}`, `Task:\n${task.trim()}`];
147
384
  if (inputs && Object.keys(inputs).length > 0) {
@@ -389,6 +626,23 @@ function isTier(value) {
389
626
  function isIntent(value) {
390
627
  return typeof value === 'string' && PERSONA_INTENTS.includes(value);
391
628
  }
629
+ function isTag(value) {
630
+ return typeof value === 'string' && PERSONA_TAGS.includes(value);
631
+ }
632
+ function parseTags(value, context) {
633
+ if (!Array.isArray(value) || value.length === 0) {
634
+ throw new Error(`${context} must be a non-empty array of tags`);
635
+ }
636
+ const out = [];
637
+ for (const [idx, entry] of value.entries()) {
638
+ if (!isTag(entry)) {
639
+ throw new Error(`${context}[${idx}] must be one of: ${PERSONA_TAGS.join(', ')}`);
640
+ }
641
+ if (!out.includes(entry))
642
+ out.push(entry);
643
+ }
644
+ return out;
645
+ }
392
646
  function parseRuntime(value, context) {
393
647
  if (!isObject(value)) {
394
648
  throw new Error(`${context} must be an object`);
@@ -452,7 +706,7 @@ function parsePersonaSpec(value, expectedIntent) {
452
706
  if (!isObject(value)) {
453
707
  throw new Error(`persona[${expectedIntent}] must be an object`);
454
708
  }
455
- const { id, intent, description, tiers, skills } = value;
709
+ const { id, intent, tags, description, tiers, skills, env, mcpServers, permissions } = value;
456
710
  if (typeof id !== 'string' || !id.trim()) {
457
711
  throw new Error(`persona[${expectedIntent}].id must be a non-empty string`);
458
712
  }
@@ -462,6 +716,7 @@ function parsePersonaSpec(value, expectedIntent) {
462
716
  if (intent !== expectedIntent) {
463
717
  throw new Error(`persona[${expectedIntent}] intent mismatch: got ${intent}`);
464
718
  }
719
+ const parsedTags = parseTags(tags, `persona[${expectedIntent}].tags`);
465
720
  if (typeof description !== 'string' || !description.trim()) {
466
721
  throw new Error(`persona[${expectedIntent}].description must be a non-empty string`);
467
722
  }
@@ -473,14 +728,105 @@ function parsePersonaSpec(value, expectedIntent) {
473
728
  parsedTiers[tier] = parseRuntime(tiers[tier], `persona[${expectedIntent}].tiers.${tier}`);
474
729
  }
475
730
  const parsedSkills = parseSkills(skills, `persona[${expectedIntent}].skills`);
731
+ const parsedEnv = parseStringMap(env, `persona[${expectedIntent}].env`);
732
+ const parsedMcpServers = parseMcpServers(mcpServers, `persona[${expectedIntent}].mcpServers`);
733
+ const parsedPermissions = parsePermissions(permissions, `persona[${expectedIntent}].permissions`);
476
734
  return {
477
735
  id,
478
736
  intent,
737
+ tags: parsedTags,
479
738
  description,
480
739
  skills: parsedSkills,
481
- tiers: parsedTiers
740
+ tiers: parsedTiers,
741
+ ...(parsedEnv ? { env: parsedEnv } : {}),
742
+ ...(parsedMcpServers ? { mcpServers: parsedMcpServers } : {}),
743
+ ...(parsedPermissions ? { permissions: parsedPermissions } : {})
482
744
  };
483
745
  }
746
+ function parsePermissions(value, context) {
747
+ if (value === undefined)
748
+ return undefined;
749
+ if (!isObject(value)) {
750
+ throw new Error(`${context} must be an object if provided`);
751
+ }
752
+ const out = {};
753
+ const { allow, deny, mode } = value;
754
+ if (allow !== undefined) {
755
+ if (!Array.isArray(allow) || allow.some((s) => typeof s !== 'string' || !s.trim())) {
756
+ throw new Error(`${context}.allow must be an array of non-empty strings`);
757
+ }
758
+ out.allow = allow;
759
+ }
760
+ if (deny !== undefined) {
761
+ if (!Array.isArray(deny) || deny.some((s) => typeof s !== 'string' || !s.trim())) {
762
+ throw new Error(`${context}.deny must be an array of non-empty strings`);
763
+ }
764
+ out.deny = deny;
765
+ }
766
+ if (mode !== undefined) {
767
+ if (!PERMISSION_MODES.includes(mode)) {
768
+ throw new Error(`${context}.mode must be one of: ${PERMISSION_MODES.join(', ')}`);
769
+ }
770
+ out.mode = mode;
771
+ }
772
+ return Object.keys(out).length > 0 ? out : undefined;
773
+ }
774
+ function parseStringMap(value, context) {
775
+ if (value === undefined)
776
+ return undefined;
777
+ if (!isObject(value)) {
778
+ throw new Error(`${context} must be an object if provided`);
779
+ }
780
+ const out = {};
781
+ for (const [key, v] of Object.entries(value)) {
782
+ if (typeof v !== 'string') {
783
+ throw new Error(`${context}.${key} must be a string`);
784
+ }
785
+ out[key] = v;
786
+ }
787
+ return out;
788
+ }
789
+ function parseMcpServers(value, context) {
790
+ if (value === undefined)
791
+ return undefined;
792
+ if (!isObject(value)) {
793
+ throw new Error(`${context} must be an object if provided`);
794
+ }
795
+ const out = {};
796
+ for (const [name, raw] of Object.entries(value)) {
797
+ if (!isObject(raw)) {
798
+ throw new Error(`${context}.${name} must be an object`);
799
+ }
800
+ const type = raw.type;
801
+ if (type === 'http' || type === 'sse') {
802
+ if (typeof raw.url !== 'string' || !raw.url.trim()) {
803
+ throw new Error(`${context}.${name}.url must be a non-empty string for type=${type}`);
804
+ }
805
+ const headers = parseStringMap(raw.headers, `${context}.${name}.headers`);
806
+ out[name] = { type, url: raw.url, ...(headers ? { headers } : {}) };
807
+ }
808
+ else if (type === 'stdio') {
809
+ if (typeof raw.command !== 'string' || !raw.command.trim()) {
810
+ throw new Error(`${context}.${name}.command must be a non-empty string for type=stdio`);
811
+ }
812
+ const args = raw.args;
813
+ if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
814
+ throw new Error(`${context}.${name}.args must be an array of strings`);
815
+ }
816
+ const env = parseStringMap(raw.env, `${context}.${name}.env`);
817
+ out[name] = {
818
+ type: 'stdio',
819
+ command: raw.command,
820
+ ...(args ? { args: args } : {}),
821
+ ...(env ? { env } : {})
822
+ };
823
+ }
824
+ else {
825
+ throw new Error(`${context}.${name}.type must be one of: http, sse, stdio`);
826
+ }
827
+ }
828
+ return out;
829
+ }
484
830
  function parseRoutingProfile(value, context) {
485
831
  if (!isObject(value)) {
486
832
  throw new Error(`${context} must be an object`);
@@ -530,7 +876,13 @@ export const personaCatalog = {
530
876
  'flake-investigation': parsePersonaSpec(flakeHunter, 'flake-investigation'),
531
877
  'opencode-workflow-correctness': parsePersonaSpec(opencodeWorkflowSpecialist, 'opencode-workflow-correctness'),
532
878
  'npm-provenance': parsePersonaSpec(npmProvenancePublisher, 'npm-provenance'),
533
- 'cloud-sandbox-infra': parsePersonaSpec(cloudSandboxInfra, 'cloud-sandbox-infra')
879
+ 'cloud-sandbox-infra': parsePersonaSpec(cloudSandboxInfra, 'cloud-sandbox-infra'),
880
+ 'sage-slack-egress-migration': parsePersonaSpec(sageSlackEgressMigrator, 'sage-slack-egress-migration'),
881
+ 'sage-proactive-rewire': parsePersonaSpec(sageProactiveRewirer, 'sage-proactive-rewire'),
882
+ 'cloud-slack-proxy-guard': parsePersonaSpec(cloudSlackProxyGuard, 'cloud-slack-proxy-guard'),
883
+ 'sage-cloud-e2e-conduction': parsePersonaSpec(agentRelayE2eConductor, 'sage-cloud-e2e-conduction'),
884
+ 'capability-discovery': parsePersonaSpec(capabilityDiscoverer, 'capability-discovery'),
885
+ posthog: parsePersonaSpec(posthogAgent, 'posthog')
534
886
  };
535
887
  export const routingProfiles = {
536
888
  default: parseRoutingProfile(defaultRoutingProfileJson, 'routingProfiles.default')
@@ -544,7 +896,10 @@ export function resolvePersona(intent, profile = 'default') {
544
896
  tier: rule.tier,
545
897
  runtime: spec.tiers[rule.tier],
546
898
  skills: spec.skills,
547
- rationale: `${profileSpec.id}: ${rule.rationale}`
899
+ rationale: `${profileSpec.id}: ${rule.rationale}`,
900
+ ...(spec.env ? { env: spec.env } : {}),
901
+ ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}),
902
+ ...(spec.permissions ? { permissions: spec.permissions } : {})
548
903
  };
549
904
  }
550
905
  /**
@@ -558,7 +913,10 @@ export function resolvePersonaByTier(intent, tier = 'best-value') {
558
913
  tier,
559
914
  runtime: spec.tiers[tier],
560
915
  skills: spec.skills,
561
- rationale: `legacy-tier-override: ${tier}`
916
+ rationale: `legacy-tier-override: ${tier}`,
917
+ ...(spec.env ? { env: spec.env } : {}),
918
+ ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}),
919
+ ...(spec.permissions ? { permissions: spec.permissions } : {})
562
920
  };
563
921
  }
564
922
  /**
@@ -637,6 +995,18 @@ export function usePersona(intent, options = {}) {
637
995
  const baseSelection = options.tier
638
996
  ? resolvePersonaByTier(intent, options.tier)
639
997
  : resolvePersona(intent, options.profile ?? 'default');
998
+ return useSelection(baseSelection, {
999
+ harness: options.harness,
1000
+ installRoot: options.installRoot
1001
+ });
1002
+ }
1003
+ /**
1004
+ * Same as {@link usePersona}, but takes a pre-resolved {@link PersonaSelection}
1005
+ * instead of an intent. Use this when you have a selection produced outside
1006
+ * the standard repo catalog — for example, a user-local persona override
1007
+ * loaded from disk — and want the same install/sendMessage surface.
1008
+ */
1009
+ export function useSelection(baseSelection, options = {}) {
640
1010
  const effectiveHarness = options.harness ?? baseSelection.runtime.harness;
641
1011
  const selection = effectiveHarness === baseSelection.runtime.harness
642
1012
  ? baseSelection
@@ -647,16 +1017,20 @@ export function usePersona(intent, options = {}) {
647
1017
  harness: effectiveHarness
648
1018
  }
649
1019
  };
1020
+ const materializationOptions = options.installRoot !== undefined ? { installRoot: options.installRoot } : {};
650
1021
  const installPlan = effectiveHarness === baseSelection.runtime.harness
651
- ? materializeSkillsFor(selection)
652
- : materializeSkills(selection.skills, effectiveHarness);
1022
+ ? materializeSkillsFor(selection, materializationOptions)
1023
+ : materializeSkills(selection.skills, effectiveHarness, materializationOptions);
653
1024
  const { installCommand, installCommandString } = buildInstallArtifacts(installPlan);
1025
+ const { cleanupCommand, cleanupCommandString } = buildCleanupArtifacts(installPlan);
654
1026
  const frozenSelection = deepFreeze(selection);
655
1027
  const frozenInstallPlan = deepFreeze(installPlan);
656
1028
  const frozenInstall = Object.freeze({
657
1029
  plan: frozenInstallPlan,
658
1030
  command: installCommand,
659
- commandString: installCommandString
1031
+ commandString: installCommandString,
1032
+ cleanupCommand,
1033
+ cleanupCommandString
660
1034
  });
661
1035
  const sendMessage = (task, sendMessageOptions = {}) => {
662
1036
  const runId = createDeferred();
@@ -674,6 +1048,7 @@ export function usePersona(intent, options = {}) {
674
1048
  const stepName = sanitizeExecutionName(sendMessageOptions.name ?? `${frozenSelection.personaId}-${hash8(task)}`);
675
1049
  const workflowName = `use-persona-${stepName}`;
676
1050
  const installStepName = `${stepName}-install-skills`;
1051
+ const cleanupStepName = `${stepName}-cleanup-skills`;
677
1052
  const workingDirectory = resolvePath(sendMessageOptions.workingDirectory ?? process.cwd());
678
1053
  const timeoutMs = Math.max(1, Math.round((sendMessageOptions.timeoutSeconds ??
679
1054
  frozenSelection.runtime.harnessSettings.timeoutSeconds) * 1000));
@@ -758,6 +1133,25 @@ export function usePersona(intent, options = {}) {
758
1133
  verification: { type: 'exit_code', value: '0' },
759
1134
  ...(shouldInstallSkills ? { dependsOn: [installStepName] } : {})
760
1135
  });
1136
+ // Post-agent cleanup: removes the ephemeral skill artifact paths the
1137
+ // provider scattered during the install step. Only runs when this
1138
+ // sendMessage owns the install (Mode A) AND the agent step completed
1139
+ // — if the agent step fails or is skipped, the dag runner will skip
1140
+ // this step too, which is fine because (a) failure diagnostics stay
1141
+ // on disk for the user to inspect, and (b) `rm -rf` is idempotent so
1142
+ // a follow-up run can re-clean. The lockfile is deliberately not in
1143
+ // cleanupPaths, so repeat runs still benefit from cached resolution.
1144
+ if (shouldInstallSkills && frozenInstall.cleanupCommandString !== ':') {
1145
+ builder.step(cleanupStepName, {
1146
+ type: 'deterministic',
1147
+ command: frozenInstall.cleanupCommandString,
1148
+ cwd: workingDirectory,
1149
+ timeoutMs,
1150
+ captureOutput: true,
1151
+ failOnError: false,
1152
+ dependsOn: [stepName]
1153
+ });
1154
+ }
761
1155
  if (abortController.signal.aborted) {
762
1156
  runner.abort();
763
1157
  }