agent-skillboard 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +64 -35
- package/bin/postinstall.mjs +5 -3
- package/docs/ai-skill-routing-goal.md +15 -3
- package/docs/install.md +98 -68
- package/docs/positioning.md +8 -6
- package/docs/reference.md +22 -10
- package/docs/routing.md +15 -0
- package/docs/user-flow.md +61 -32
- package/docs/value-proof.md +6 -3
- package/package.json +3 -2
- package/src/advisor/guidance.mjs +23 -3
- package/src/agent-integration-cli.mjs +184 -0
- package/src/agent-integration-content.mjs +48 -0
- package/src/agent-integration-files.mjs +202 -0
- package/src/agent-integration-home.mjs +185 -0
- package/src/agent-skill-roots.mjs +1 -0
- package/src/brief-renderer.mjs +36 -57
- package/src/cli.mjs +168 -169
- package/src/control/can-use-guard.mjs +12 -0
- package/src/doctor.mjs +4 -1
- package/src/lifecycle-cli.mjs +23 -302
- package/src/lifecycle-content.mjs +4 -2
- package/src/route-advisory.mjs +214 -0
- package/src/route-renderer.mjs +146 -0
- package/src/route-selection.mjs +220 -0
- package/src/route-tokens.mjs +68 -0
- package/src/route.mjs +48 -413
- package/src/source-profiles.mjs +151 -150
- package/src/uninstall.mjs +56 -9
- package/src/workspace.mjs +79 -79
package/src/lifecycle-cli.mjs
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
-
import { createInterface } from "node:readline";
|
|
5
|
-
import { promisify } from "node:util";
|
|
6
|
-
import { setupAgentSkillTargets, supportedAgentNames } from "./agent-skill-roots.mjs";
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { runAgentLayerUninstallCommand } from "./agent-integration-cli.mjs";
|
|
7
3
|
import { initProject } from "./init.mjs";
|
|
8
4
|
import { uninstallProject } from "./uninstall.mjs";
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
const AGENT_INTEGRATION_END = "<!-- END SKILLBOARD AGENT INTEGRATION -->";
|
|
12
|
-
const execFileAsync = promisify(execFile);
|
|
6
|
+
export { runSetupCommand } from "./agent-integration-cli.mjs";
|
|
13
7
|
|
|
14
8
|
export async function runInitCommand(options, stdout, runtime = defaultRuntime()) {
|
|
15
9
|
const root = resolve(options.get("dir") ?? ".");
|
|
@@ -45,61 +39,41 @@ export async function runInitCommand(options, stdout, runtime = defaultRuntime()
|
|
|
45
39
|
return 0;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
export async function
|
|
49
|
-
if (options.get("
|
|
50
|
-
|
|
42
|
+
export async function runUninstallCommand(options, stdout, runtime = defaultRuntime()) {
|
|
43
|
+
if (options.get("agent-layer") === "true") {
|
|
44
|
+
return await runAgentLayerUninstallCommand(options, stdout, runtime);
|
|
51
45
|
}
|
|
52
|
-
const env = runtime.env ?? process.env;
|
|
53
|
-
const home = await resolveSetupHome(env, runtime);
|
|
54
|
-
const targets = await agentSetupTargets(options, runtime, home);
|
|
55
|
-
if (targets.length === 0) {
|
|
56
|
-
stdout.write("No supported agent user skill roots were detected.\n");
|
|
57
|
-
stdout.write(`Create a supported agent home or pass --agent ${supportedAgentNames().join(",")} to choose targets.\n`);
|
|
58
|
-
return 1;
|
|
59
|
-
}
|
|
60
|
-
if (options.get("yes") !== "true") {
|
|
61
|
-
writeSetupConfirmation(stdout, targets, commandPrefix(runtime));
|
|
62
|
-
if (!canPrompt(runtime)) {
|
|
63
|
-
return 1;
|
|
64
|
-
}
|
|
65
|
-
const confirmed = await promptForSetup(runtime);
|
|
66
|
-
if (!confirmed) {
|
|
67
|
-
stdout.write("Skipped SkillBoard setup.\n");
|
|
68
|
-
return 1;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
const result = await installAgentIntegration(targets, setupOwnership(env, runtime, home));
|
|
72
|
-
stdout.write("SkillBoard agent integration installed.\n");
|
|
73
|
-
writeList(stdout, "Created", result.created);
|
|
74
|
-
writeList(stdout, "Updated", result.updated);
|
|
75
|
-
writeList(stdout, "Unchanged", result.unchanged);
|
|
76
|
-
writeList(stdout, "Preserved", result.preserved);
|
|
77
|
-
stdout.write("Next:\n");
|
|
78
|
-
stdout.write("- Restart or refresh agents that cache user skills.\n");
|
|
79
|
-
stdout.write('- Ask the agent in a workspace: "Which skill should you use for this?"\n');
|
|
80
|
-
return 0;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function runUninstallCommand(options, stdout) {
|
|
84
46
|
const removeConfig = options.get("remove-config") === "true";
|
|
85
47
|
const resetConfig = options.get("reset-config") === "true";
|
|
86
48
|
const purge = options.get("purge") === "true";
|
|
49
|
+
const keepSettings = options.get("keep-settings") === "true";
|
|
50
|
+
const removeReports = options.get("remove-reports") === "true";
|
|
51
|
+
const removeHooks = options.get("remove-hooks") === "true";
|
|
87
52
|
if (removeConfig && resetConfig) {
|
|
88
53
|
throw new Error("--remove-config and --reset-config are mutually exclusive");
|
|
89
54
|
}
|
|
90
55
|
if (removeConfig && purge) {
|
|
91
56
|
throw new Error("--remove-config cannot be combined with --purge");
|
|
92
57
|
}
|
|
58
|
+
if (keepSettings && purge) {
|
|
59
|
+
throw new Error("--keep-settings cannot be combined with --purge");
|
|
60
|
+
}
|
|
61
|
+
if (keepSettings && (removeConfig || resetConfig)) {
|
|
62
|
+
throw new Error("--keep-settings cannot be combined with --remove-config or --reset-config");
|
|
63
|
+
}
|
|
93
64
|
const root = resolve(options.get("dir") ?? ".");
|
|
65
|
+
const granularCleanup = removeConfig || resetConfig || removeReports || removeHooks;
|
|
66
|
+
const cleanSettings = !keepSettings && (purge || !granularCleanup);
|
|
94
67
|
const result = await uninstallProject({
|
|
95
68
|
root,
|
|
96
69
|
dryRun: options.get("dry-run") === "true",
|
|
70
|
+
keepBridge: keepSettings,
|
|
97
71
|
removeConfig,
|
|
98
|
-
resetConfig: resetConfig ||
|
|
99
|
-
removeReports:
|
|
100
|
-
removeHooks:
|
|
101
|
-
removeProjectState:
|
|
102
|
-
removeEmptyDirs: options.get("keep-empty-dirs") !== "true"
|
|
72
|
+
resetConfig: resetConfig || cleanSettings,
|
|
73
|
+
removeReports: removeReports || cleanSettings,
|
|
74
|
+
removeHooks: removeHooks || cleanSettings,
|
|
75
|
+
removeProjectState: cleanSettings,
|
|
76
|
+
removeEmptyDirs: !keepSettings && options.get("keep-empty-dirs") !== "true"
|
|
103
77
|
});
|
|
104
78
|
stdout.write(`${result.dryRun ? "Dry run: " : ""}Uninstalled SkillBoard: ${root}\n`);
|
|
105
79
|
writeList(stdout, "Removed", result.removed);
|
|
@@ -164,40 +138,6 @@ function writeNextCommands(stdout, next) {
|
|
|
164
138
|
stdout.write(`- ${next.command} brief --workflow ${shellQuote(workflow)}${dir} --verbose\n`);
|
|
165
139
|
}
|
|
166
140
|
|
|
167
|
-
function writeSetupConfirmation(stdout, targets, command) {
|
|
168
|
-
stdout.write("SkillBoard setup installs agent-layer integration, not project files.\n");
|
|
169
|
-
stdout.write("It writes a SkillBoard guidance skill into detected user agent skill roots so agents can resolve skill priority when choices overlap.\n");
|
|
170
|
-
stdout.write("Targets:\n");
|
|
171
|
-
for (const target of targets) {
|
|
172
|
-
stdout.write(`- ${target.agent}: ${target.skillPath}\n`);
|
|
173
|
-
}
|
|
174
|
-
stdout.write("Run with --yes to install agent-layer integration:\n");
|
|
175
|
-
stdout.write(`- ${command} setup --agent ${targets.map((target) => target.agent).join(",")} --yes\n`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function canPrompt(runtime) {
|
|
179
|
-
return runtime.stdin?.isTTY === true && runtime.stdout?.isTTY === true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async function promptForSetup(runtime) {
|
|
183
|
-
const rl = createInterface({
|
|
184
|
-
input: runtime.stdin,
|
|
185
|
-
output: runtime.stdout
|
|
186
|
-
});
|
|
187
|
-
try {
|
|
188
|
-
const answer = await question(rl, "Install SkillBoard agent integration now? [y/N] ");
|
|
189
|
-
return /^(y|yes)$/i.test(answer.trim());
|
|
190
|
-
} finally {
|
|
191
|
-
rl.close();
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function question(rl, prompt) {
|
|
196
|
-
return new Promise((resolve) => {
|
|
197
|
-
rl.question(prompt, resolve);
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
141
|
function formatList(values) {
|
|
202
142
|
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
203
143
|
}
|
|
@@ -221,225 +161,6 @@ function defaultRuntime() {
|
|
|
221
161
|
};
|
|
222
162
|
}
|
|
223
163
|
|
|
224
|
-
async function agentSetupTargets(options, runtime, setupHome) {
|
|
225
|
-
const env = runtime.env ?? process.env;
|
|
226
|
-
const home = setupHome ?? await resolveSetupHome(env, runtime);
|
|
227
|
-
const requested = readCsv(options.get("agent"));
|
|
228
|
-
const supported = new Set(supportedAgentNames());
|
|
229
|
-
const names = requested.length === 0 ? supportedAgentNames() : requested;
|
|
230
|
-
const targets = [];
|
|
231
|
-
for (const name of names) {
|
|
232
|
-
if (!supported.has(name)) {
|
|
233
|
-
throw new Error(`Unsupported setup agent: ${name}`);
|
|
234
|
-
}
|
|
235
|
-
targets.push(...await setupAgentSkillTargets(name, home, env, { includeFallback: requested.length > 0 }));
|
|
236
|
-
}
|
|
237
|
-
return targets;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
async function resolveSetupHome(env, runtime) {
|
|
241
|
-
const explicit = nonEmpty(env.SKILLBOARD_SETUP_HOME);
|
|
242
|
-
if (explicit !== null) {
|
|
243
|
-
return explicit;
|
|
244
|
-
}
|
|
245
|
-
if (shouldUseSudoUserHome(env)) {
|
|
246
|
-
const sudoHome = nonEmpty(env.SUDO_HOME)
|
|
247
|
-
?? await passwdHome(env.SUDO_USER, runtime.passwdPath ?? "/etc/passwd")
|
|
248
|
-
?? await getentHome(env.SUDO_USER, env)
|
|
249
|
-
?? await conventionalUserHome(env.SUDO_USER);
|
|
250
|
-
if (sudoHome !== null) {
|
|
251
|
-
return sudoHome;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return env.HOME ?? env.USERPROFILE;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function setupOwnership(env, runtime, home) {
|
|
258
|
-
if (process.platform === "win32" || !shouldUseSudoUserHome(env)) {
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
const uid = parseNonNegativeInteger(env.SUDO_UID);
|
|
262
|
-
const gid = parseNonNegativeInteger(env.SUDO_GID);
|
|
263
|
-
if (uid === null || gid === null) {
|
|
264
|
-
return null;
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
uid,
|
|
268
|
-
gid,
|
|
269
|
-
home: resolve(home),
|
|
270
|
-
chown: runtime.chown ?? chown
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function parseNonNegativeInteger(value) {
|
|
275
|
-
if (value === undefined || !/^\d+$/.test(value)) {
|
|
276
|
-
return null;
|
|
277
|
-
}
|
|
278
|
-
const parsed = Number(value);
|
|
279
|
-
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function shouldUseSudoUserHome(env) {
|
|
283
|
-
const sudoUser = nonEmpty(env.SUDO_USER);
|
|
284
|
-
return process.platform !== "win32"
|
|
285
|
-
&& sudoUser !== null
|
|
286
|
-
&& sudoUser !== "root"
|
|
287
|
-
&& (
|
|
288
|
-
env.SUDO_UID !== undefined
|
|
289
|
-
|| env.SUDO_GID !== undefined
|
|
290
|
-
|| env.USER === "root"
|
|
291
|
-
|| env.LOGNAME === "root"
|
|
292
|
-
|| env.HOME === "/root"
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
async function passwdHome(user, passwdPath) {
|
|
297
|
-
const text = await readFile(passwdPath, "utf8").catch(() => "");
|
|
298
|
-
for (const line of text.split("\n")) {
|
|
299
|
-
if (line.startsWith(`${user}:`)) {
|
|
300
|
-
return nonEmpty(line.split(":")[5]);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
async function getentHome(user, env) {
|
|
307
|
-
try {
|
|
308
|
-
const { stdout } = await execFileAsync("getent", ["passwd", user], {
|
|
309
|
-
env,
|
|
310
|
-
timeout: 1000
|
|
311
|
-
});
|
|
312
|
-
return nonEmpty(stdout.trim().split(":")[5]);
|
|
313
|
-
} catch {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function conventionalUserHome(user) {
|
|
319
|
-
const candidates = process.platform === "darwin"
|
|
320
|
-
? [`/Users/${user}`]
|
|
321
|
-
: [`/home/${user}`];
|
|
322
|
-
for (const candidate of candidates) {
|
|
323
|
-
if (await exists(candidate)) {
|
|
324
|
-
return candidate;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
async function exists(path) {
|
|
331
|
-
return access(path).then(() => true, () => false);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function nonEmpty(value) {
|
|
335
|
-
return value === undefined || value.trim() === "" ? null : value;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
async function installAgentIntegration(targets, ownership = null) {
|
|
339
|
-
const created = [];
|
|
340
|
-
const updated = [];
|
|
341
|
-
const unchanged = [];
|
|
342
|
-
const preserved = [];
|
|
343
|
-
const content = agentIntegrationSkill();
|
|
344
|
-
for (const target of targets) {
|
|
345
|
-
const existing = await readFile(target.skillPath, "utf8").catch((error) => {
|
|
346
|
-
if (error?.code === "ENOENT") {
|
|
347
|
-
return null;
|
|
348
|
-
}
|
|
349
|
-
throw error;
|
|
350
|
-
});
|
|
351
|
-
if (existing === content) {
|
|
352
|
-
unchanged.push(`${target.agent}:${target.skillPath}`);
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
if (existing !== null && !existing.includes(AGENT_INTEGRATION_START)) {
|
|
356
|
-
preserved.push(`${target.agent}:${target.skillPath}`);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
await mkdir(dirname(target.skillPath), { recursive: true });
|
|
360
|
-
await writeFile(target.skillPath, content, "utf8");
|
|
361
|
-
await applyOwnership(target.skillPath, ownership);
|
|
362
|
-
(existing === null ? created : updated).push(`${target.agent}:${target.skillPath}`);
|
|
363
|
-
}
|
|
364
|
-
return { created, updated, unchanged, preserved };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async function applyOwnership(path, ownership) {
|
|
368
|
-
if (ownership === null) {
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
for (const ownedPath of ownershipPaths(path, ownership.home)) {
|
|
372
|
-
await ownership.chown(ownedPath, ownership.uid, ownership.gid);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function ownershipPaths(path, home) {
|
|
377
|
-
const resolvedHome = resolve(home);
|
|
378
|
-
const resolvedPath = resolve(path);
|
|
379
|
-
if (!isInside(resolvedPath, resolvedHome)) {
|
|
380
|
-
return [];
|
|
381
|
-
}
|
|
382
|
-
const directories = [];
|
|
383
|
-
let current = dirname(resolvedPath);
|
|
384
|
-
while (current !== resolvedHome && isInside(current, resolvedHome)) {
|
|
385
|
-
directories.push(current);
|
|
386
|
-
const parent = dirname(current);
|
|
387
|
-
if (parent === current) {
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
current = parent;
|
|
391
|
-
}
|
|
392
|
-
return [...directories.reverse(), resolvedPath];
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function isInside(path, parent) {
|
|
396
|
-
const relativePath = relative(parent, path);
|
|
397
|
-
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function agentIntegrationSkill() {
|
|
401
|
-
return `---
|
|
402
|
-
name: skillboard
|
|
403
|
-
description: Use SkillBoard when several installed skills could apply, a skill choice is ambiguous, or the user asks which skill should be used, preferred, avoided, reviewed, or prioritized for the current workflow.
|
|
404
|
-
---
|
|
405
|
-
${AGENT_INTEGRATION_START}
|
|
406
|
-
# SkillBoard Agent Integration
|
|
407
|
-
|
|
408
|
-
Use this skill to let SkillBoard guide skill selection above individual projects.
|
|
409
|
-
|
|
410
|
-
## Layering
|
|
411
|
-
|
|
412
|
-
- SkillBoard is the user-level control plane for skill priority, overlap resolution, and workflow-aware routing.
|
|
413
|
-
- Project management belongs to the agent or workspace layer. Do not initialize, attach, rewrite, or manage a project just because SkillBoard is installed.
|
|
414
|
-
- Package install and \`skillboard setup\` install user-agent guidance only.
|
|
415
|
-
|
|
416
|
-
## Default Behavior
|
|
417
|
-
|
|
418
|
-
- Installed user skills are usable by default unless the runtime, user, or local instructions disable them.
|
|
419
|
-
- Do not ask for permission merely because you selected a skill.
|
|
420
|
-
- When you use a skill, disclose it briefly at the start and completion.
|
|
421
|
-
- Use SkillBoard when more than one skill matches, when skills overlap, or when workflow priority should choose between plausible skills.
|
|
422
|
-
|
|
423
|
-
## Cross-Agent Skill Reuse
|
|
424
|
-
|
|
425
|
-
- When the user wants to use a skill from another agent, run \`skillboard import-skill --from <source-agent> --to <this-agent> --skill <skill> --json\`.
|
|
426
|
-
- If SkillBoard reports the skill is compatible, install it with \`--yes\` and use the copied target-agent skill.
|
|
427
|
-
- If SkillBoard reports \`needs-adaptation\`, explain the compatibility reasons and ask before changing the skill body for this agent.
|
|
428
|
-
- If the user approves, inspect the source skill, write an adapted \`SKILL.md\` for this agent, then install it with \`skillboard import-skill --from <source-agent> --to <this-agent> --skill <skill> --adapted-file <adapted-skill.md> --yes --json\`.
|
|
429
|
-
- This is still user-agent setup. Do not create or mutate project policy files for cross-agent skill reuse.
|
|
430
|
-
|
|
431
|
-
## Ambiguity Resolution
|
|
432
|
-
|
|
433
|
-
1. Identify the candidate skills that match the request.
|
|
434
|
-
2. Prefer the skill whose description, workflow guidance, and local instructions most directly match the user's task.
|
|
435
|
-
3. If a project or agent has explicit SkillBoard policy, use \`skillboard brief --intent <request> --json\` or \`skillboard route <intent> --workflow <name> --json\` to break ties.
|
|
436
|
-
4. If the best choice is still ambiguous or the choice would change persistent policy, ask the user which priority to remember.
|
|
437
|
-
5. Continue with the selected skill; do not stop only because other candidate skills exist.
|
|
438
|
-
|
|
439
|
-
${AGENT_INTEGRATION_END}
|
|
440
|
-
`;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
164
|
function commandPrefix(runtime) {
|
|
444
165
|
const entrypoint = runtime.entrypointPath ?? "";
|
|
445
166
|
const normalized = entrypoint.replace(/\\/g, "/");
|
|
@@ -24,7 +24,8 @@ Your responsibility is to answer skill availability questions from SkillBoard, t
|
|
|
24
24
|
|
|
25
25
|
## Product Goal
|
|
26
26
|
|
|
27
|
-
- SkillBoard is a
|
|
27
|
+
- SkillBoard is a permissive AI skill routing layer, not a pre-task settings checklist.
|
|
28
|
+
- Keep installed skills broadly available by default; use SkillBoard to resolve overlap when several skills could steer the same task.
|
|
28
29
|
- Read \`docs/ai-skill-routing-goal.md\` before changing routing, brief, bridge, policy, or workflow UX.
|
|
29
30
|
- Preserve the loop: observe → route → work → explain briefly → ask after → remember policy.
|
|
30
31
|
- SkillBoard does not rewrite \`SKILL.md\` bodies to personalize behavior; record usage policy for when to use, prefer, reference, avoid, or block skills.
|
|
@@ -33,7 +34,8 @@ Your responsibility is to answer skill availability questions from SkillBoard, t
|
|
|
33
34
|
|
|
34
35
|
- Use SkillBoard as the source of truth for project-local policy and workflow priority; installed \`SKILL.md\` files are candidates, not enough to resolve overlap by themselves.
|
|
35
36
|
- Read the current brief before answering: \`skillboard brief --json --config skillboard.config.yaml --skills skills\`. If the workflow is known, include \`--workflow <name>\`; add \`--include-actions\` when the user wants you to mediate a change.
|
|
36
|
-
-
|
|
37
|
+
- For ordinary user requests, work normally unless skill choice is ambiguous, several skills overlap, workflow priority matters, or the user explicitly asks for a SkillBoard or skill decision. In those cases, read \`skillboard brief --intent <request> --json --config skillboard.config.yaml --skills skills\`. Include \`--workflow <name>\` when known. Read \`assistant_guidance.route\`; use \`recommended_skill\`, \`fallback_skills\`, \`route_candidates\`, \`overlap_resolution\`, \`policy_memory\`, \`post_use_policy_suggestion\`, and \`guard_command\` instead of guessing from raw skill text. Inspect \`overlap_resolution\` and \`route_candidates\` when several skills match so allowed overlap, denied candidates, and selected fallbacks are clear. If \`policy_memory\` is present, mention after completion that remembered or configured policy selected this skill even though other allowed skills were available. If \`post_use_policy_suggestion\` is present, work first with the allowed routed skill, then ask after completion whether to remember the suggested policy. If no skill matches, ask a clarifying question before choosing a skill.
|
|
38
|
+
- If the user explicitly requests a specific already-allowed skill, honor that request after guard use instead of rerouting away solely because another skill also matches.
|
|
37
39
|
- Treat the brief sections headed "What your AI can use now", "Needs your decision", and "Blocked for safety" as the availability summary; do not infer availability from \`SKILL.md\` bodies.
|
|
38
40
|
- Treat "Needs your decision" as a one-time decision queue, not a persistent blocked state. "Blocked for safety" means the skill/source/workflow is hard-blocked until policy or provenance changes.
|
|
39
41
|
- Use \`skillboard can-use <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills --json\` for machine-readable agent decisions.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { command } from "./advisor/action-core.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CONFIG_PATH = "skillboard.config.yaml";
|
|
4
|
+
const DEFAULT_SKILLS_ROOT = "skills";
|
|
5
|
+
|
|
6
|
+
export function routeCandidate(entry, selected) {
|
|
7
|
+
return {
|
|
8
|
+
skill: entry.skill,
|
|
9
|
+
role: entry.role,
|
|
10
|
+
selected,
|
|
11
|
+
guard_allowed: entry.guard.allowed,
|
|
12
|
+
guard_reasons: entry.guard.reasons,
|
|
13
|
+
guard_roles: entry.guard.roles,
|
|
14
|
+
capability_roles: entry.guard.capabilityRoles
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function usageDisclosure(skillId, policyMemory = null) {
|
|
19
|
+
const finishMessage = policyMemory?.finish_disclosure
|
|
20
|
+
?? `I used ${skillId} for this request.`;
|
|
21
|
+
return {
|
|
22
|
+
confirmation_required: false,
|
|
23
|
+
start: `State at the start that ${skillId} is being used for this request.`,
|
|
24
|
+
finish: policyMemory === null
|
|
25
|
+
? `State at completion that ${skillId} was used.`
|
|
26
|
+
: `State at completion that ${skillId} was used because remembered or configured policy preferred it over other allowed skills.`,
|
|
27
|
+
start_message: `I will use ${skillId} for this request.`,
|
|
28
|
+
finish_message: finishMessage,
|
|
29
|
+
policy_memory: policyMemory,
|
|
30
|
+
guard: "Run the guard automatically immediately before invocation. Ask the user only if the guard denies use or a policy-changing action is needed."
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function selectedRouteSkill(routedSkills) {
|
|
35
|
+
return routedSkills.find((entry) => entry.guard.allowed) ?? routedSkills[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function allowedFallbackSkills(routedSkills, recommended) {
|
|
39
|
+
return routedSkills
|
|
40
|
+
.filter((entry) => entry.guard.allowed && entry.skill !== recommended?.skill)
|
|
41
|
+
.map((entry) => entry.skill);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function overlapResolutionForRoute({ matchedCapability, recommended, routedSkills, workflowName }) {
|
|
45
|
+
if (recommended === null || routedSkills.length < 2) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const matchedSkills = routedSkills.map((entry) => entry.skill);
|
|
49
|
+
const allowedSkills = routedSkills.filter((entry) => entry.guard.allowed).map((entry) => entry.skill);
|
|
50
|
+
const deniedSkills = routedSkills.filter((entry) => !entry.guard.allowed).map((entry) => entry.skill);
|
|
51
|
+
if (deniedSkills.length > 0 && recommended.guard.allowed) {
|
|
52
|
+
return {
|
|
53
|
+
status: "resolved",
|
|
54
|
+
mode: "allowed-fallback",
|
|
55
|
+
selected_skill: recommended.skill,
|
|
56
|
+
matched_skills: matchedSkills,
|
|
57
|
+
allowed_skills: allowedSkills,
|
|
58
|
+
denied_skills: deniedSkills,
|
|
59
|
+
summary: `Some matching skills are denied for ${matchedCapability}; SkillBoard keeps allowed skills available and routes ${workflowName} to ${recommended.skill}.`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (allowedSkills.length > 1) {
|
|
63
|
+
return {
|
|
64
|
+
status: "resolved",
|
|
65
|
+
mode: "permissive-routing",
|
|
66
|
+
selected_skill: recommended.skill,
|
|
67
|
+
matched_skills: matchedSkills,
|
|
68
|
+
allowed_skills: allowedSkills,
|
|
69
|
+
denied_skills: deniedSkills,
|
|
70
|
+
summary: `Multiple allowed skills match ${matchedCapability}; SkillBoard keeps them available and routes ${workflowName} to ${recommended.skill}.`
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
status: "blocked",
|
|
75
|
+
mode: "guard-denied",
|
|
76
|
+
selected_skill: recommended.skill,
|
|
77
|
+
matched_skills: matchedSkills,
|
|
78
|
+
allowed_skills: allowedSkills,
|
|
79
|
+
denied_skills: deniedSkills,
|
|
80
|
+
summary: `Matching skills exist for ${matchedCapability}, but the guard currently denies the selected route.`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function policyMemoryForRoute({ matchedCapability, routeMatch, recommended, routedSkills, workflowName }) {
|
|
85
|
+
if (recommended === null || recommended.guard.allowed !== true || recommended.role !== "preferred" || !routeMatch.required_by_workflow) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const alternatives = routedSkills
|
|
89
|
+
.filter((entry) => entry.guard.allowed && entry.skill !== recommended.skill)
|
|
90
|
+
.map((entry) => entry.skill);
|
|
91
|
+
if (alternatives.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const alternativeText = alternatives.join(", ");
|
|
95
|
+
return {
|
|
96
|
+
status: "applied",
|
|
97
|
+
mode: "remembered-or-configured-preference",
|
|
98
|
+
selected_skill: recommended.skill,
|
|
99
|
+
available_alternatives: alternatives,
|
|
100
|
+
summary: `Remembered or configured policy selected ${recommended.skill} for ${matchedCapability} in ${workflowName}; other allowed skills were also available: ${alternativeText}.`,
|
|
101
|
+
finish_disclosure: `I used ${recommended.skill} for this request because SkillBoard has a remembered or configured preference for it; other allowed skills were also available: ${alternativeText}.`
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function postUsePolicySuggestionForCapabilityRoute({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
|
|
106
|
+
return postUsePolicySuggestionForDeniedPreferredFallback({
|
|
107
|
+
matchedCapability,
|
|
108
|
+
recommended,
|
|
109
|
+
routedSkills,
|
|
110
|
+
workflowName,
|
|
111
|
+
options
|
|
112
|
+
}) ?? postUsePolicySuggestionForAllowedAmbiguity({
|
|
113
|
+
matchedCapability,
|
|
114
|
+
routeMatch,
|
|
115
|
+
recommended,
|
|
116
|
+
routedSkills,
|
|
117
|
+
workflowName,
|
|
118
|
+
options
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function postUsePolicySuggestionForDeniedPreferredFallback({ matchedCapability, recommended, routedSkills, workflowName, options }) {
|
|
123
|
+
if (recommended === null || recommended.role !== "fallback" || recommended.guard.allowed !== true) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const preferred = routedSkills.find((entry) => entry.role === "preferred");
|
|
127
|
+
if (preferred === undefined || preferred.guard.allowed === true) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
timing: "after_use",
|
|
132
|
+
mode: "ask_after_use",
|
|
133
|
+
reason: `SkillBoard selected fallback ${recommended.skill} because preferred skill ${preferred.skill} is denied. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName}.`,
|
|
134
|
+
question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
|
|
135
|
+
requires_confirmation: true,
|
|
136
|
+
suggested_policy: {
|
|
137
|
+
kind: "prefer-skill",
|
|
138
|
+
skill: recommended.skill,
|
|
139
|
+
workflow: workflowName,
|
|
140
|
+
capability: matchedCapability,
|
|
141
|
+
command_hint: command([
|
|
142
|
+
"skillboard", "prefer", recommended.skill,
|
|
143
|
+
"--workflow", workflowName,
|
|
144
|
+
"--capability", matchedCapability,
|
|
145
|
+
"--config", routeConfigPath(options),
|
|
146
|
+
"--skills", routeSkillsRoot(options)
|
|
147
|
+
]).display
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function postUsePolicySuggestionForAllowedAmbiguity({ matchedCapability, routeMatch, recommended, routedSkills, workflowName, options }) {
|
|
153
|
+
if (recommended === null || recommended.guard.allowed !== true || routeMatch.required_by_workflow) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const allowedSkills = routedSkills.filter((entry) => entry.guard.allowed);
|
|
157
|
+
if (allowedSkills.length < 2) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
timing: "after_use",
|
|
162
|
+
mode: "ask_after_use",
|
|
163
|
+
reason: `SkillBoard found multiple allowed skills for ${matchedCapability} and selected ${recommended.skill}. After completing the task, ask whether to remember ${recommended.skill} as the preferred skill for ${matchedCapability} in ${workflowName} to reduce future ambiguity.`,
|
|
164
|
+
question: `Should I remember ${recommended.skill} as the preferred skill for similar ${matchedCapability} requests in ${workflowName}?`,
|
|
165
|
+
requires_confirmation: true,
|
|
166
|
+
suggested_policy: {
|
|
167
|
+
kind: "prefer-skill",
|
|
168
|
+
skill: recommended.skill,
|
|
169
|
+
workflow: workflowName,
|
|
170
|
+
capability: matchedCapability,
|
|
171
|
+
command_hint: command([
|
|
172
|
+
"skillboard", "prefer", recommended.skill,
|
|
173
|
+
"--workflow", workflowName,
|
|
174
|
+
"--capability", matchedCapability,
|
|
175
|
+
"--config", routeConfigPath(options),
|
|
176
|
+
"--skills", routeSkillsRoot(options)
|
|
177
|
+
]).display
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function recommendationReason({ match, matchedFields, matchedTerms, skill, guard }) {
|
|
183
|
+
const fieldText = matchedFields.length === 0
|
|
184
|
+
? ""
|
|
185
|
+
: ` through ${matchedFields.join(", ")}`;
|
|
186
|
+
const termText = matchedTerms.length === 0
|
|
187
|
+
? ""
|
|
188
|
+
: ` (${matchedTerms.join(", ")})`;
|
|
189
|
+
if (skill === null || guard === null) {
|
|
190
|
+
return `${match}${fieldText}${termText}, but no workflow-bound skill is available.`;
|
|
191
|
+
}
|
|
192
|
+
if (guard.allowed) {
|
|
193
|
+
return `${match}${fieldText}${termText}; recommended ${skill} because the guard allows ${skill}.`;
|
|
194
|
+
}
|
|
195
|
+
const reason = guard.reasons[0] ?? "the guard did not provide a reason";
|
|
196
|
+
return `${match}${fieldText}${termText}; recommended ${skill}, but the guard currently denies it: ${reason}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function guardCommand(skillId, workflowName, options) {
|
|
200
|
+
return command([
|
|
201
|
+
"skillboard", "guard", "use", skillId,
|
|
202
|
+
"--workflow", workflowName,
|
|
203
|
+
"--config", routeConfigPath(options),
|
|
204
|
+
"--skills", routeSkillsRoot(options)
|
|
205
|
+
]).display;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function routeConfigPath(options) {
|
|
209
|
+
return options.configPath ?? DEFAULT_CONFIG_PATH;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function routeSkillsRoot(options) {
|
|
213
|
+
return options.skillsRoot ?? DEFAULT_SKILLS_ROOT;
|
|
214
|
+
}
|