@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/CHANGELOG.md +8 -0
- package/dist/generated/personas.d.ts +244 -0
- package/dist/generated/personas.d.ts.map +1 -1
- package/dist/generated/personas.js +193 -0
- package/dist/generated/personas.js.map +1 -1
- package/dist/index.d.ts +128 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +441 -47
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +297 -2
- package/dist/index.test.js.map +1 -1
- package/package.json +4 -2
- package/routing-profiles/default.json +24 -0
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
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
`
|
|
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
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
|
86
|
-
*
|
|
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
|
|
97
|
-
const
|
|
98
|
-
const
|
|
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
|
|
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 {
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
}
|