agent-skillboard 0.3.1 → 0.3.3

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.
@@ -0,0 +1,38 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+
3
+ export function commandPrefix(runtime) {
4
+ const entrypoint = runtime.entrypointPath ?? "";
5
+ const normalized = entrypoint.replace(/\\/g, "/");
6
+ if (normalized.includes("/_npx/")) {
7
+ const packageSpec = runtime.packageSpec ?? "agent-skillboard";
8
+ return `npx --yes --package ${shellQuote(packageSpec, runtime.platform)} skillboard`;
9
+ }
10
+ if (isSourceTreeEntrypoint(entrypoint)) {
11
+ return `node ${sourceTreeEntrypoint(entrypoint, runtime.cwd ?? process.cwd(), runtime.platform)}`;
12
+ }
13
+ return "skillboard";
14
+ }
15
+
16
+ function isSourceTreeEntrypoint(entrypoint) {
17
+ if (entrypoint === "") return false;
18
+ const normalized = entrypoint.replace(/\\/g, "/");
19
+ return (normalized === "bin/skillboard.mjs" || normalized.endsWith("/bin/skillboard.mjs"))
20
+ && !normalized.includes("/node_modules/")
21
+ && !normalized.includes("/_npx/")
22
+ && !normalized.includes("/.npm/");
23
+ }
24
+
25
+ function sourceTreeEntrypoint(entrypoint, cwd, platform) {
26
+ const absoluteEntrypoint = isAbsolute(entrypoint) ? entrypoint : resolve(cwd, entrypoint);
27
+ const relativeEntrypoint = relative(cwd, absoluteEntrypoint).replace(/\\/g, "/");
28
+ if (!relativeEntrypoint.startsWith("../") && relativeEntrypoint !== ".." && !isAbsolute(relativeEntrypoint)) {
29
+ return shellQuote(relativeEntrypoint, platform);
30
+ }
31
+ return shellQuote(absoluteEntrypoint, platform);
32
+ }
33
+
34
+ export function shellQuote(value, platform = process.platform) {
35
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
36
+ if (platform === "win32") return `"${value.replace(/"/g, '""')}"`;
37
+ return `'${value.replace(/'/g, "'\\''")}'`;
38
+ }
@@ -25,7 +25,7 @@ Use this skill to let SkillBoard guide skill selection above individual projects
25
25
  - For ordinary user requests, work normally; invoke SkillBoard only when skill choice is ambiguous, skills overlap, or the user asks for a SkillBoard or skill decision.
26
26
  - If the user explicitly asks for a specific installed skill, honor that request when the guard allows it instead of rerouting away solely because other skills also match.
27
27
  - Do not ask for permission merely because you selected a skill.
28
- - When you use a skill, disclose it briefly at the start and completion. If SkillBoard says remembered or configured policy selected this skill while other allowed skills were available, mention that at completion.
28
+ - When you use a skill, disclose it briefly at the start and completion. If a saved preference influenced the model's choice, mention that briefly at completion.
29
29
 
30
30
  ## Cross-Agent Skill Reuse
31
31
 
@@ -41,11 +41,11 @@ Use this skill to let SkillBoard guide skill selection above individual projects
41
41
 
42
42
  1. Start from the user's task, not from a pre-task inventory prompt.
43
43
  2. Identify candidate skills only when multiple installed skills plausibly match or a manual skill-control request is present.
44
- 3. Prefer the skill whose explicit request, description, and local instructions most directly match the user's task.
45
- 4. Use \`skillboard brief --agent ${agent} --intent <request> --json\` or \`skillboard route <intent> --agent ${agent} --json\` to break ties.
46
- 5. Run \`skillboard guard use <skill-id> --agent ${agent} --json\` immediately before invoking the selected skill.
47
- 6. If the best choice is still ambiguous or the choice would change persistent policy, ask the user which priority to remember.
48
- 7. Continue with the selected skill; do not stop only because other candidate skills exist.
44
+ 3. Use \`skillboard brief --agent ${agent} --intent <request> --json\` or \`skillboard route <intent> --agent ${agent} --json\` to read eligible skill descriptions and raw saved preferences. SkillBoard does not interpret v2 request text.
45
+ 4. Make the semantic choice yourself from the full request, explicit user direction, descriptions, local instructions, and any saved preferences; use no skill when none fits.
46
+ 5. Run \`skillboard guard use <skill-id> --agent ${agent} --json\` immediately before invoking the model-selected skill.
47
+ 6. Continue with the selected skill; do not stop only because other candidate skills exist.
48
+ 7. If the choice remained ambiguous and remembering it would help, finish the work first, then ask whether to save that intent preference.
49
49
 
50
50
  ## Removal
51
51
 
@@ -84,7 +84,7 @@ function emitIntentRoute(lines, brief) {
84
84
  if (route === undefined) {
85
85
  return;
86
86
  }
87
- lines.push("## Suggested skill for this request", "");
87
+ lines.push("## Skill selection for this request", "");
88
88
  lines.push(...renderRouteSectionLines(route, {
89
89
  format: "brief",
90
90
  nextStep: brief.assistant_guidance?.recommended_next_step
package/src/cli.mjs CHANGED
@@ -60,6 +60,7 @@ import { ApplyActionError, applyActionErrorPayload, applyAdvisorAction } from ".
60
60
  import { importAgentSkill, renderImportAgentSkill } from "./agent-skill-import.mjs";
61
61
  import { runBriefCommand } from "./brief-cli.mjs";
62
62
  import { renderSkillBrief } from "./brief-renderer.mjs";
63
+ import { shellQuote } from "./agent-integration-command.mjs";
63
64
  import { planGuardHookInstall } from "./control.mjs";
64
65
  import { writeCheckedConfig } from "./control/config-write.mjs";
65
66
  import { runInitCommand, runSetupCommand, runUninstallCommand } from "./lifecycle-cli.mjs";
@@ -93,7 +94,7 @@ const COMMAND_USAGE = new Map([
93
94
  ["check", ["check --config <path> --skills <dir>"]],
94
95
  ["list", ["list [skills|workflows|harnesses|install-units] [--config <path>] [--skills <dir>] [--json]"]],
95
96
  ["explain", ["explain <skill-id> --config <path> --skills <dir> [--json]"]],
96
- ["can-use", ["can-use <skill-id> --agent codex|claude|opencode|hermes [--json]"]],
97
+ ["can-use", ["can-use <skill-id> --agent <name> (v2 policy) | --workflow <name> (v1 policy) [--json]"]],
97
98
  ["audit", ["audit sources --config <path> --skills <dir> [--verify] [--json]"]],
98
99
  ["rollout", ["rollout [audit|plan|apply|rollback|report] [--dir <path>] [--config <path>] [--skills <dir>] [--transaction <id>] [--json]"]],
99
100
  ["hook", ["hook install --workflow <name> --config <path> --skills <dir> [--out <path>] [--skillboard-bin <path>] [--dry-run] [--json]"]],
@@ -580,15 +581,12 @@ async function route(argv, options, stdout) {
580
581
  const intent = positionalArgs(argv).join(" ").trim();
581
582
  const workflow = options.get("workflow");
582
583
  if (intent.length === 0) {
583
- throw new Error("Usage: skillboard route <intent> [--agent <name>]");
584
+ throw new Error(selectorUsage("skillboard route <intent>"));
584
585
  }
585
586
  const config = configPath(options);
586
587
  const skills = skillsRoot(options);
587
588
  const workspace = await loadWorkspace({ configPath: config, skillsRoot: skills });
588
- assertV2AgentOption(workspace, options, "skillboard route <intent> --agent <name>");
589
- if (workspace.version === 1 && workflow === undefined) {
590
- throw new Error("Usage: skillboard route <intent> --workflow <name>");
591
- }
589
+ assertPolicySelectorOption(workspace, options, "skillboard route <intent>", config);
592
590
  const result = routeSkill(workspace, {
593
591
  intent,
594
592
  workflow,
@@ -605,13 +603,11 @@ async function canUse(argv, options, stdout) {
605
603
  const skillId = positionalArgs(argv)[0];
606
604
  const workflow = options.get("workflow");
607
605
  if (skillId === undefined) {
608
- throw new Error("Usage: skillboard can-use <skill-id> [--agent <name>]");
609
- }
610
- const workspace = await loadWorkspace({ configPath: configPath(options), skillsRoot: skillsRoot(options) });
611
- assertV2AgentOption(workspace, options, "skillboard can-use <skill-id> --agent <name>");
612
- if (workspace.version === 1 && workflow === undefined) {
613
- throw new Error("Usage: skillboard can-use <skill-id> --workflow <name>");
606
+ throw new Error(selectorUsage("skillboard can-use <skill-id>"));
614
607
  }
608
+ const config = configPath(options);
609
+ const workspace = await loadWorkspace({ configPath: config, skillsRoot: skillsRoot(options) });
610
+ assertPolicySelectorOption(workspace, options, "skillboard can-use <skill-id>", config);
615
611
  const result = canUseSkill(workspace, skillId, workflow, options.get("agent"));
616
612
  writeOutput(stdout, result, options, () => renderCanUse(result));
617
613
  return result.allowed ? 0 : 2;
@@ -623,9 +619,10 @@ async function guard(argv, options, stdout) {
623
619
  const skillId = args[0] === "use" ? args[1] : args[0];
624
620
  const workflow = options.get("workflow");
625
621
  if (skillId === undefined) {
626
- throw new Error("Usage: skillboard guard use <skill-id> [--agent <name>]");
622
+ throw new Error(selectorUsage("skillboard guard use <skill-id>"));
627
623
  }
628
- const workspace = await loadWorkspace({ configPath: configPath(options), skillsRoot: skillsRoot(options) });
624
+ const config = configPath(options);
625
+ const workspace = await loadWorkspace({ configPath: config, skillsRoot: skillsRoot(options) });
629
626
  const hookProjection = options.get("hook-projection-version") ?? process.env.SKILLBOARD_POLICY_PROJECTION_VERSION;
630
627
  if (args[0] !== "use" && hookProjection === undefined) {
631
628
  throw new Error("This pre-v2 policy projection is stale; regenerate the guard hook from version 2 policy.");
@@ -634,10 +631,7 @@ async function guard(argv, options, stdout) {
634
631
  throw new Error("This pre-v2 policy projection is stale; regenerate the guard hook from version 2 policy.");
635
632
  }
636
633
  assertCurrentProjectionVersion(hookProjection, workspace.version);
637
- assertV2AgentOption(workspace, options, "skillboard guard use <skill-id> --agent <name>");
638
- if (workspace.version === 1 && workflow === undefined) {
639
- throw new Error("Usage: skillboard guard use <skill-id> --workflow <name>");
640
- }
634
+ assertPolicySelectorOption(workspace, options, "skillboard guard use <skill-id>", config);
641
635
  const result = canUseSkill(workspace, skillId, workflow, options.get("agent"));
642
636
  if (options.get("json") === "true") {
643
637
  stdout.write(`${JSON.stringify(result, null, 2)}\n`);
@@ -1272,17 +1266,39 @@ function statePaths(options) {
1272
1266
  });
1273
1267
  }
1274
1268
 
1275
- function assertV2AgentOption(workspace, options, usage) {
1276
- if (workspace.version !== 2) return;
1269
+ function assertPolicySelectorOption(workspace, options, command, config) {
1277
1270
  const agent = options.get("agent");
1271
+ const workflow = options.get("workflow");
1272
+ if (workspace.version === 1) {
1273
+ if (agent !== undefined) {
1274
+ throw new Error([
1275
+ "This workspace uses a version 1 policy, which selects with --workflow <name>.",
1276
+ `Either pass --workflow, or preview migration with: skillboard migrate v2 --config ${shellQuote(config)} --json`
1277
+ ].join("\n"));
1278
+ }
1279
+ if (workflow === undefined) {
1280
+ throw new Error(selectorUsage(command));
1281
+ }
1282
+ return;
1283
+ }
1284
+ if (workflow !== undefined) {
1285
+ throw new Error([
1286
+ "This workspace uses a version 2 policy, which selects with --agent <name>.",
1287
+ "Pass --agent instead of --workflow."
1288
+ ].join("\n"));
1289
+ }
1278
1290
  if (agent === undefined) {
1279
- throw new Error(`Version 2 availability requires --agent. Usage: ${usage}`);
1291
+ throw new Error(`Version 2 availability requires --agent. ${selectorUsage(command)}`);
1280
1292
  }
1281
1293
  if (!supportedAgentNames().includes(agent)) {
1282
1294
  throw new Error(`Unsupported agent: ${agent}. Expected one of: ${supportedAgentNames().join(", ")}.`);
1283
1295
  }
1284
1296
  }
1285
1297
 
1298
+ function selectorUsage(command) {
1299
+ return `Usage: ${command} --agent <name> (v2 policy) | --workflow <name> (v1 policy)`;
1300
+ }
1301
+
1286
1302
  const VALUE_OPTIONS = new Set([
1287
1303
  "actual-harnesses", "adapted-file", "adapted-for", "agent", "cache-dir", "capability", "category",
1288
1304
  "command", "config", "config-file", "dir", "exposure", "from", "harness", "harness-status",
@@ -1324,10 +1340,6 @@ function formatList(values) {
1324
1340
  return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
1325
1341
  }
1326
1342
 
1327
- function shellQuote(value) {
1328
- return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
1329
- }
1330
-
1331
1343
  function readCsv(value) {
1332
1344
  if (value === undefined || value.trim() === "") {
1333
1345
  return [];
@@ -1617,7 +1629,15 @@ function renderRoute(result) {
1617
1629
  if (result.matched_capability === null) {
1618
1630
  lines.push("Possible skills:");
1619
1631
  for (const skill of result.possible_skills.slice(0, 5)) {
1620
- lines.push(`- ${skill.id} (${skill.category ?? "uncategorized"}, allowed=${skill.allowed})`);
1632
+ if (result.selection_mode === "model") {
1633
+ const description = skill.description ?? "no description";
1634
+ const preference = skill.preference === null || skill.preference === undefined
1635
+ ? "no saved preference"
1636
+ : `saved preference=${JSON.stringify(skill.preference)}`;
1637
+ lines.push(`- ${skill.id}: ${description} (${preference}, allowed=${skill.allowed})`);
1638
+ } else {
1639
+ lines.push(`- ${skill.id} (${skill.category ?? "uncategorized"}, allowed=${skill.allowed})`);
1640
+ }
1621
1641
  }
1622
1642
  }
1623
1643
  lines.push("");
@@ -1804,9 +1824,9 @@ function helpText() {
1804
1824
  " check --config <path> --skills <dir>",
1805
1825
  " list [skills|workflows|harnesses|install-units] [--config <path>] [--skills <dir>] [--json]",
1806
1826
  " explain <skill-id> [--config <path>] [--skills <dir>] [--json]",
1807
- " route <intent> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
1808
- " can-use <skill-id> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
1809
- " guard use <skill-id> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
1827
+ " route <intent> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
1828
+ " can-use <skill-id> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
1829
+ " guard use <skill-id> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
1810
1830
  "",
1811
1831
  "Advanced operator commands:",
1812
1832
  " Import, audit, rollout, hook, lock, variant, reconcile, impact, dashboard, and v1 lifecycle commands remain available through command-specific help and docs/reference.md.",
@@ -1818,8 +1838,10 @@ function helpText() {
1818
1838
  "",
1819
1839
  "v2 AI/automation control loop:",
1820
1840
  " Policy decides only enabled/disabled and per-skill opt-in sharing; see docs/ai-skill-routing-goal.md.",
1821
- " Valid installed skills default to enabled and agent-local. Optional preference ranks only and never changes availability.",
1822
- " Read brief --intent --agent <agent>, select an enabled installed skill, and run guard use automatically before use.",
1841
+ " Valid installed skills default to enabled and agent-local. Optional preference is raw model context and never changes availability.",
1842
+ " Read brief --intent --agent <agent>; the model selects from raw eligible skill descriptions and saved preferences, or uses no skill.",
1843
+ " SkillBoard does not tokenize, score, match, or recommend from v2 request text.",
1844
+ " Run guard use automatically before using the model-selected skill.",
1823
1845
  " For an allowed skill, work without another approval; ask once only before a persistent policy change.",
1824
1846
  " Source/provenance observations are audit metadata, never availability. Runtime/action authorization is outside SkillBoard.",
1825
1847
  " Stale v1 policy is read-only: preview with skillboard migrate v2 --config <path> --json, apply with --yes --json, or restore with --rollback <backup> --json.",
@@ -2053,7 +2075,7 @@ function briefHelpText() {
2053
2075
  "",
2054
2076
  "Options:",
2055
2077
  " --agent <name> Evaluate actual installation for the current agent; required for v2 availability and routing.",
2056
- " --intent <request> Add a natural-language request so SkillBoard can suggest a skill.",
2078
+ " --intent <request> Pass the request beside raw eligible descriptions and saved preferences; v2 does not score it.",
2057
2079
  " --config <path> Advanced policy override; defaults to ~/skillboard.config.yaml.",
2058
2080
  " --skills <dir> Use a specific skills directory.",
2059
2081
  " --include-actions Include current action ids in JSON output.",
@@ -2071,37 +2093,41 @@ function briefHelpText() {
2071
2093
 
2072
2094
  function routeHelpText() {
2073
2095
  return [
2074
- "Usage: skillboard route <intent> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
2096
+ "Usage: skillboard route <intent> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
2075
2097
  "",
2076
- "Suggests the routed skill for a user request when several allowed skills may overlap.",
2077
- "Use it when the AI needs a skill recommendation without changing policy.",
2098
+ "In v2, returns raw eligible skill descriptions and saved preferences without interpreting the request.",
2099
+ "The model makes the semantic choice, including explicit requests and preferences, without changing policy.",
2100
+ "Version 1 compatibility routing retains its legacy deterministic tokenizer and recommendation behavior.",
2078
2101
  "",
2079
2102
  "Options:",
2080
2103
  " <intent> Natural-language request, such as \"write tests first\".",
2081
- " --agent <name> Route among skills installed for this agent.",
2104
+ " --agent <name> (v2 policy) | --workflow <name> (v1 policy)",
2105
+ " Route with the selector required by the detected policy version.",
2082
2106
  " --config <path> Use a specific skillboard.config.yaml.",
2083
2107
  " --skills <dir> Use a specific skills directory.",
2084
2108
  " --json Print an agent-readable payload.",
2085
2109
  "",
2086
2110
  "AI use:",
2087
- " If a skill is recommended, run the guard automatically before invoking it.",
2111
+ " In v2, choose from the eligible skill descriptions and raw saved preferences, or use no skill.",
2112
+ " Run the guard automatically before invoking the model-selected skill.",
2088
2113
  " If the guard allows use, disclose the skill at start and completion; do not ask for another approval.",
2089
- " If policy memory would reduce ambiguity, ask after completion whether to remember the routed skill.",
2090
- " If no skill matches, ask a clarifying question instead of guessing.",
2114
+ " If a saved preference would help later model selection, ask after completion whether to remember it.",
2115
+ " In v1 compatibility mode, ask a clarifying question when no workflow route matches.",
2091
2116
  ""
2092
2117
  ].join("\n");
2093
2118
  }
2094
2119
 
2095
2120
  function canUseHelpText() {
2096
2121
  return [
2097
- "Usage: skillboard can-use <skill-id> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
2122
+ "Usage: skillboard can-use <skill-id> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
2098
2123
  "",
2099
2124
  "Checks whether one skill is enabled and installed for the selected agent without changing policy.",
2100
2125
  "Use it when the AI needs an availability answer for a named skill.",
2101
2126
  "",
2102
2127
  "Options:",
2103
2128
  " <skill-id> Skill id to check.",
2104
- " --agent <name> Agent that would use the skill.",
2129
+ " --agent <name> (v2 policy) | --workflow <name> (v1 policy)",
2130
+ " Select the agent or legacy workflow that would use the skill.",
2105
2131
  " --config <path> Use a specific skillboard.config.yaml.",
2106
2132
  " --skills <dir> Use a specific skills directory.",
2107
2133
  " --json Print an agent-readable payload.",
@@ -2116,7 +2142,7 @@ function canUseHelpText() {
2116
2142
 
2117
2143
  function guardHelpText() {
2118
2144
  return [
2119
- "Usage: skillboard guard use <skill-id> --agent codex|claude|opencode|hermes [--config <path>] [--skills <dir>] [--json]",
2145
+ "Usage: skillboard guard use <skill-id> --agent codex|claude|opencode|hermes (v2 policy) | --workflow <name> (v1 policy) [--config <path>] [--skills <dir>] [--json]",
2120
2146
  "",
2121
2147
  "Checks whether one skill may be used right now.",
2122
2148
  "Run this immediately before the AI invokes a skill.",
@@ -2124,7 +2150,8 @@ function guardHelpText() {
2124
2150
  "Options:",
2125
2151
  " use Guard a skill invocation.",
2126
2152
  " <skill-id> Skill id to check.",
2127
- " --agent <name> Agent that will use the skill.",
2153
+ " --agent <name> (v2 policy) | --workflow <name> (v1 policy)",
2154
+ " Select the agent or legacy workflow that will use the skill.",
2128
2155
  " --config <path> Use a specific skillboard.config.yaml.",
2129
2156
  " --skills <dir> Use a specific skills directory.",
2130
2157
  " --json Print an agent-readable payload.",
@@ -85,6 +85,7 @@ async function refreshLocked(options) {
85
85
  bootstrappedV2,
86
86
  configPath: relativeArtifactPath(root, configPath),
87
87
  inventoryPath: generatedInventory === null ? null : relativeArtifactPath(root, inventoryPath),
88
+ observedSkillIds: inventory.skills.map(({ id }) => id).sort((left, right) => left.localeCompare(right)),
88
89
  inventoryChanged,
89
90
  changed: plan.changed,
90
91
  plan,
@@ -140,7 +140,7 @@ function writeSafetyDefault(stdout, safety) {
140
140
  stdout.write("Skill selection default:\n");
141
141
  stdout.write("- Valid installed skills default to enabled and agent-local.\n");
142
142
  stdout.write("- Users may disable a skill or explicitly share that skill across agents.\n");
143
- stdout.write("- Optional preference ranks candidates and never changes availability.\n");
143
+ stdout.write("- Optional preference is raw model context and never changes availability.\n");
144
144
  stdout.write("- Source and provenance are audit metadata, never availability.\n");
145
145
  stdout.write(`- ${safety.enabled} enabled skills\n`);
146
146
  stdout.write(`- ${safety.disabled} disabled skills\n`);
@@ -13,27 +13,29 @@ export function bridgeBlock() {
13
13
 
14
14
  SkillBoard keeps one user-level policy. A valid installed skill defaults to
15
15
  enabled and agent-local. Users may opt individual skills into cross-agent
16
- sharing. Optional preference ranks enabled skills installed for the current
17
- agent and never changes availability or copies files.
16
+ sharing. Optional preference is raw context for the model and never changes
17
+ availability or copies files.
18
18
 
19
19
  ## Route and use
20
20
 
21
21
  - Work normally unless skills overlap or the user asks
22
22
  for a skill decision.
23
23
  - Read \`skillboard brief --intent <request> --agent <agent> --json\`.
24
- - Use the recommended enabled, installed skill and ordered fallbacks. Honor an
25
- explicit user choice when the guard allows it.
24
+ - SkillBoard returns eligible skill descriptions and raw saved preferences. It
25
+ does not tokenize, score, match, or recommend from v2 request text.
26
+ - The model makes the semantic choice, including explicit user requests and
27
+ saved preferences, or continues without a skill when none fits.
26
28
  - Run \`skillboard guard use <skill-id> --agent <agent> --json\` immediately
27
29
  before use.
28
30
  - Do not ask for another approval when guard allows use. Briefly disclose the
29
31
  selected skill as an audit trace.
30
- - If the routed brief suggests policy memory, finish the work first, then ask
32
+ - If remembering the choice would help later model selection, finish the work first, then ask
31
33
  once whether to remember that intent preference. Do not prompt before use.
32
34
 
33
35
  ## Change policy
34
36
 
35
37
  - Policy changes only enable/disable a skill, share/unshare one skill, or
36
- remember optional ranking preference.
38
+ remember optional model preference.
37
39
  - Read a fresh brief with \`--include-actions\`, choose one current action id, ask
38
40
  for one confirmation, then run \`skillboard apply-action <action-id> --yes
39
41
  --json\`.
@@ -0,0 +1,14 @@
1
+ const SAFE_AMBIGUITY_KIND = "review_only_quarantine";
2
+
3
+ export function canAutomaticallyMigrateV2(report) {
4
+ if (report?.mode !== "preview" || report.changed !== true || report.target_version !== 2) return false;
5
+ if (!Array.isArray(report.ambiguities)) return false;
6
+ return report.ambiguities.every((ambiguity) => (
7
+ ambiguity?.kind === SAFE_AMBIGUITY_KIND
8
+ && ambiguity.mapped_enabled === true
9
+ && ambiguity.requires_grouped_confirmation === true
10
+ && Array.isArray(ambiguity.skill_ids)
11
+ && ambiguity.skill_ids.length > 0
12
+ && ambiguity.skill_ids.every((skillId) => typeof skillId === "string" && skillId.length > 0)
13
+ ));
14
+ }
@@ -103,6 +103,7 @@ export function unchangedMigrationResult(inputBytes) {
103
103
  ambiguities: [],
104
104
  grouped_decision: null,
105
105
  backup: null,
106
- manifest: null
106
+ manifest: null,
107
+ inventory_backup: null
107
108
  };
108
109
  }
@@ -46,6 +46,9 @@ export async function migrateV2(options) {
46
46
 
47
47
  async function migrateForward(configPath, inventoryPath, options) {
48
48
  const inputBytes = await readFile(configPath);
49
+ if (options.expectedInputSha256 !== undefined && sha256(inputBytes) !== options.expectedInputSha256) {
50
+ throw new Error("Config changed after migration preview; no files were changed.");
51
+ }
49
52
  const inputText = inputBytes.toString("utf8");
50
53
  const { document, parsed } = parseMigrationConfig(inputText);
51
54
  const version = parsed.version ?? 1;
@@ -85,6 +88,7 @@ async function migrateForward(configPath, inventoryPath, options) {
85
88
  changed: true,
86
89
  backup: basename(backup.configBackupPath),
87
90
  manifest: basename(backup.manifestPath),
91
+ inventory_backup: backup.inventoryBackupPath === null ? null : basename(backup.inventoryBackupPath),
88
92
  ...report
89
93
  };
90
94
  } catch (error) {
@@ -119,50 +119,6 @@ export function postUsePolicySuggestionForCapabilityRoute({ matchedCapability, r
119
119
  });
120
120
  }
121
121
 
122
- export function policyMemoryForV2Route({ intent, selected, candidates, workflowName }) {
123
- if (selected.explicit || selected.matchedIntents.length === 0 || candidates.length < 2) {
124
- return null;
125
- }
126
- const alternatives = candidates.slice(1).map((candidate) => candidate.skill.id);
127
- const alternativeText = alternatives.join(", ");
128
- const context = workflowName === undefined ? "for the current agent" : `in legacy workflow ${workflowName}`;
129
- return {
130
- status: "applied",
131
- mode: "remembered-or-configured-preference",
132
- selected_skill: selected.skill.id,
133
- available_alternatives: alternatives,
134
- summary: `Remembered or configured policy selected ${selected.skill.id} for ${intent} ${context}; other allowed skills were also available: ${alternativeText}.`,
135
- finish_disclosure: `I used ${selected.skill.id} for this request because SkillBoard has a remembered or configured preference for it; other allowed skills were also available: ${alternativeText}.`
136
- };
137
- }
138
-
139
- export function postUsePolicySuggestionForV2Route({ intent, selected, candidates, workflowName, options, policyMemory }) {
140
- if (selected.explicit || policyMemory !== null || candidates.length < 2) {
141
- return null;
142
- }
143
- const context = workflowName === undefined ? "" : ` in ${workflowName}`;
144
- return {
145
- timing: "after_use",
146
- mode: "ask_after_use",
147
- reason: `SkillBoard found multiple allowed skills for ${intent} and selected ${selected.skill.id}. After completing the task, ask whether to remember ${selected.skill.id} for similar requests${context}.`,
148
- question: `Should I remember ${selected.skill.id} as the preferred skill for similar ${intent} requests${context}?`,
149
- requires_confirmation: true,
150
- suggested_policy: {
151
- kind: "prefer-skill",
152
- skill: selected.skill.id,
153
- workflow: workflowName ?? null,
154
- intent,
155
- command_hint: command([
156
- "skillboard", "skill", "preference", selected.skill.id,
157
- "--intent", intent,
158
- "--priority", "100",
159
- "--config", routeConfigPath(options),
160
- ...(options.skillsRoot === undefined ? [] : ["--skills", routeSkillsRoot(options)])
161
- ]).display
162
- }
163
- };
164
- }
165
-
166
122
  function postUsePolicySuggestionForDeniedPreferredFallback({ matchedCapability, recommended, routedSkills, workflowName, options }) {
167
123
  if (recommended === null || recommended.role !== "fallback" || recommended.guard.allowed !== true) {
168
124
  return null;
@@ -30,7 +30,7 @@ export function renderRouteSectionLines(route, options = {}) {
30
30
  const lines = [];
31
31
  pushRouteMatchLines(lines, route, formatter, options);
32
32
  if (route.recommended_skill === null) {
33
- pushNoRecommendationLines(lines, route, formatter);
33
+ pushNoRecommendationLines(lines, route, formatter, options);
34
34
  return lines;
35
35
  }
36
36
  pushRecommendationLines(lines, route, formatter, options);
@@ -50,11 +50,18 @@ function pushRouteMatchLines(lines, route, formatter, options) {
50
50
  lines.push(`${formatter.prefix}Matched terms: ${formatter.list(route.matched_terms)}`);
51
51
  }
52
52
 
53
- function pushNoRecommendationLines(lines, route, formatter) {
53
+ function pushNoRecommendationLines(lines, route, formatter, options) {
54
54
  lines.push(`${formatter.prefix}Recommended skill: none`);
55
55
  if (formatter.includeFallbackWhenNoRecommendation) {
56
56
  lines.push(`${formatter.prefix}Fallback skills: ${formatter.list(route.fallback_skills)}`);
57
57
  }
58
+ if (route.model_selection_required === true) {
59
+ lines.push(`${formatter.prefix}Model selection: required - choose from eligible skill descriptions and raw saved preferences, or use no skill.`);
60
+ if (typeof options.nextStep === "string" && options.nextStep.length > 0) {
61
+ lines.push(`${formatter.prefix}Next step: ${safeText(options.nextStep, 360)}`);
62
+ }
63
+ return;
64
+ }
58
65
  if (formatter.noMatchNextStep !== null) {
59
66
  lines.push(`${formatter.prefix}Next step: ${formatter.noMatchNextStep}`);
60
67
  }
@@ -3,6 +3,7 @@ import { canonicalRouteToken, phraseKey, tokensFor } from "./route-tokens.mjs";
3
3
 
4
4
  const HIGH_CONFIDENCE = 4;
5
5
  const MEDIUM_CONFIDENCE = 2;
6
+ const CJK_SUFFIX = /^[\p{Script=Hangul}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+$/u;
6
7
 
7
8
  export function selectRoute(workspace, workflow, intent) {
8
9
  const intentTokens = tokensFor(intent);
@@ -24,38 +25,6 @@ export function selectRoute(workspace, workflow, intent) {
24
25
  };
25
26
  }
26
27
 
27
- export function selectV2Route(workspace, intent, agentName) {
28
- const intentKey = phraseKey(intent);
29
- const intentTokens = tokensFor(intent);
30
- const candidates = workspace.skills
31
- .map((skill) => {
32
- const guard = canUseSkill(workspace, skill.id, undefined, agentName);
33
- const explicit = exactSkillIdMention(intentKey, skill.id);
34
- const preference = skill.preference;
35
- const matchedIntents = preference?.intents.filter((term) => matchesIntentTerm(intentTokens, term)) ?? [];
36
- const installed = installedSkillFor(workspace, skill.id);
37
- const metadataTokens = tokensFor(`${installed?.name ?? ""} ${installed?.description ?? ""}`);
38
- const metadataMatches = [...metadataTokens].filter((token) => intentTokens.has(token));
39
- const hasMatch = explicit || matchedIntents.length > 0 || metadataMatches.length > 0;
40
- const preferenceScore = matchedIntents.length > 0 ? (preference?.priority ?? 0) : 0;
41
- const rawScore = (explicit ? 1_000_000 : 0)
42
- + matchedIntents.length * 100
43
- + preferenceScore
44
- + metadataMatches.length;
45
- const score = hasMatch ? Math.max(1, rawScore) : 0;
46
- return { skill, guard, explicit, score, matchedIntents, metadataMatches };
47
- })
48
- .filter((candidate) => candidate.guard.allowed)
49
- .filter((candidate) => candidate.score > 0)
50
- .sort((left, right) => right.score - left.score || left.skill.id.localeCompare(right.skill.id));
51
- return { candidates, selected: candidates[0] };
52
- }
53
-
54
- function matchesIntentTerm(intentTokens, term) {
55
- const termTokens = tokensFor(term);
56
- return termTokens.size > 0 && [...termTokens].every((token) => intentTokens.has(token));
57
- }
58
-
59
28
  function explicitAllowedSkillRouteCandidate(intent, skillCandidates) {
60
29
  const intentKey = phraseKey(intent);
61
30
  return skillCandidates.find((candidate) => candidate.allowed && exactSkillIdMention(intentKey, candidate.skill_id));
@@ -66,7 +35,16 @@ function exactSkillIdMention(intentKey, skillId) {
66
35
  if (skillKey.length === 0) return false;
67
36
  const intentTokens = intentKey.split(" ");
68
37
  const skillTokens = skillKey.split(" ");
69
- return intentTokens.some((_, index) => skillTokens.every((token, offset) => intentTokens[index + offset] === token));
38
+ const lastSkillToken = skillTokens.length - 1;
39
+ return intentTokens.some((_, index) => skillTokens.every((token, offset) => exactSkillTokenMatch(
40
+ intentTokens[index + offset], token, offset === lastSkillToken
41
+ )));
42
+ }
43
+
44
+ function exactSkillTokenMatch(intentToken, skillToken, allowCjkSuffix) {
45
+ if (intentToken === skillToken) return true;
46
+ if (!allowCjkSuffix || !intentToken?.startsWith(skillToken)) return false;
47
+ return CJK_SUFFIX.test(intentToken.slice(skillToken.length));
70
48
  }
71
49
 
72
50
  function capabilityRouteCandidates(workspace, workflow) {
@@ -32,24 +32,42 @@ const ROUTE_STOP_WORDS = new Set([
32
32
  "your"
33
33
  ]);
34
34
 
35
+ const ASCII_ROUTE_TOKEN = /^[a-z0-9]+$/u;
36
+ const CJK_ROUTE_TOKEN = /[\p{Script=Hangul}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;
37
+
35
38
  export function tokensFor(value) {
36
39
  return new Set(String(value)
37
40
  .toLowerCase()
38
- .split(/[^a-z0-9]+/u)
41
+ .split(/[^\p{L}\p{N}]+/u)
39
42
  .flatMap(tokenForms)
40
43
  .filter(isRouteToken));
41
44
  }
42
45
 
43
46
  function tokenForms(token) {
47
+ if (CJK_ROUTE_TOKEN.test(token)) {
48
+ return cjkTokenForms(token);
49
+ }
44
50
  const singular = singularRouteToken(token);
45
51
  return singular === token ? [token] : [token, singular];
46
52
  }
47
53
 
54
+ function cjkTokenForms(token) {
55
+ const characters = [...token];
56
+ const forms = [token];
57
+ for (let index = 0; index < characters.length - 1; index += 1) {
58
+ forms.push(characters.slice(index, index + 2).join(""));
59
+ }
60
+ return forms;
61
+ }
62
+
48
63
  export function canonicalRouteToken(token) {
49
64
  return singularRouteToken(token);
50
65
  }
51
66
 
52
67
  function singularRouteToken(token) {
68
+ if (!ASCII_ROUTE_TOKEN.test(token)) {
69
+ return token;
70
+ }
53
71
  if (token.length > 4 && token.endsWith("ies")) {
54
72
  return `${token.slice(0, -3)}y`;
55
73
  }
@@ -60,7 +78,7 @@ function singularRouteToken(token) {
60
78
  }
61
79
 
62
80
  function isRouteToken(token) {
63
- return token.length > 1 && !ROUTE_STOP_WORDS.has(token);
81
+ return (token.length > 1 || CJK_ROUTE_TOKEN.test(token)) && !ROUTE_STOP_WORDS.has(token);
64
82
  }
65
83
 
66
84
  export function phraseKey(value) {