agent-skillboard 0.2.15 → 0.2.17

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.
@@ -1,15 +1,9 @@
1
- import { execFile } from "node:child_process";
2
- import { access, chown, mkdir, readFile, writeFile } from "node:fs/promises";
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
- const AGENT_INTEGRATION_START = "<!-- BEGIN SKILLBOARD AGENT INTEGRATION -->";
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 runSetupCommand(options, stdout, runtime = defaultRuntime()) {
49
- if (options.get("dir") !== undefined) {
50
- throw new Error("skillboard setup is agent-layer setup and does not accept --dir");
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 || purge,
99
- removeReports: options.get("remove-reports") === "true" || purge,
100
- removeHooks: options.get("remove-hooks") === "true" || purge,
101
- removeProjectState: purge,
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,226 +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
- await applyOwnership(target.skillPath, ownership);
353
- unchanged.push(`${target.agent}:${target.skillPath}`);
354
- continue;
355
- }
356
- if (existing !== null && !existing.includes(AGENT_INTEGRATION_START)) {
357
- preserved.push(`${target.agent}:${target.skillPath}`);
358
- continue;
359
- }
360
- await mkdir(dirname(target.skillPath), { recursive: true });
361
- await writeFile(target.skillPath, content, "utf8");
362
- await applyOwnership(target.skillPath, ownership);
363
- (existing === null ? created : updated).push(`${target.agent}:${target.skillPath}`);
364
- }
365
- return { created, updated, unchanged, preserved };
366
- }
367
-
368
- async function applyOwnership(path, ownership) {
369
- if (ownership === null) {
370
- return;
371
- }
372
- for (const ownedPath of ownershipPaths(path, ownership.home)) {
373
- await ownership.chown(ownedPath, ownership.uid, ownership.gid);
374
- }
375
- }
376
-
377
- function ownershipPaths(path, home) {
378
- const resolvedHome = resolve(home);
379
- const resolvedPath = resolve(path);
380
- if (!isInside(resolvedPath, resolvedHome)) {
381
- return [];
382
- }
383
- const directories = [];
384
- let current = dirname(resolvedPath);
385
- while (current !== resolvedHome && isInside(current, resolvedHome)) {
386
- directories.push(current);
387
- const parent = dirname(current);
388
- if (parent === current) {
389
- break;
390
- }
391
- current = parent;
392
- }
393
- return [...directories.reverse(), resolvedPath];
394
- }
395
-
396
- function isInside(path, parent) {
397
- const relativePath = relative(parent, path);
398
- return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
399
- }
400
-
401
- function agentIntegrationSkill() {
402
- return `---
403
- name: skillboard
404
- 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.
405
- ---
406
- ${AGENT_INTEGRATION_START}
407
- # SkillBoard Agent Integration
408
-
409
- Use this skill to let SkillBoard guide skill selection above individual projects.
410
-
411
- ## Layering
412
-
413
- - SkillBoard is the user-level control plane for skill priority, overlap resolution, and workflow-aware routing.
414
- - Project management belongs to the agent or workspace layer. Do not initialize, attach, rewrite, or manage a project just because SkillBoard is installed.
415
- - Package install and \`skillboard setup\` install user-agent guidance only.
416
-
417
- ## Default Behavior
418
-
419
- - Installed user skills are usable by default unless the runtime, user, or local instructions disable them.
420
- - Do not ask for permission merely because you selected a skill.
421
- - When you use a skill, disclose it briefly at the start and completion.
422
- - Use SkillBoard when more than one skill matches, when skills overlap, or when workflow priority should choose between plausible skills.
423
-
424
- ## Cross-Agent Skill Reuse
425
-
426
- - When the user wants to use a skill from another agent, run \`skillboard import-skill --from <source-agent> --to <this-agent> --skill <skill> --json\`.
427
- - If SkillBoard reports the skill is compatible, install it with \`--yes\` and use the copied target-agent skill.
428
- - If SkillBoard reports \`needs-adaptation\`, explain the compatibility reasons and ask before changing the skill body for this agent.
429
- - 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\`.
430
- - This is still user-agent setup. Do not create or mutate project policy files for cross-agent skill reuse.
431
-
432
- ## Ambiguity Resolution
433
-
434
- 1. Identify the candidate skills that match the request.
435
- 2. Prefer the skill whose description, workflow guidance, and local instructions most directly match the user's task.
436
- 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.
437
- 4. If the best choice is still ambiguous or the choice would change persistent policy, ask the user which priority to remember.
438
- 5. Continue with the selected skill; do not stop only because other candidate skills exist.
439
-
440
- ${AGENT_INTEGRATION_END}
441
- `;
442
- }
443
-
444
164
  function commandPrefix(runtime) {
445
165
  const entrypoint = runtime.entrypointPath ?? "";
446
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 non-blocking AI skill routing control plane, not a pre-task settings checklist.
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
- - When the user asks which skill fits a task, 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\`, \`post_use_policy_suggestion\`, and \`guard_command\` instead of guessing from raw skill text. Inspect \`route_candidates\` when several skills match so denied candidates and selected fallbacks are clear. 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.
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
+ }