agent-skillboard 0.2.17 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +128 -260
  3. package/bin/postinstall.mjs +2 -2
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +35 -109
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +39 -493
  8. package/docs/policy-model.md +50 -280
  9. package/docs/positioning.md +19 -86
  10. package/docs/profiles.md +21 -153
  11. package/docs/reference.md +117 -356
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +60 -292
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +31 -264
  18. package/examples/v2-multi-source.config.yaml +35 -0
  19. package/examples/v2-policy-error.config.yaml +6 -0
  20. package/package.json +1 -1
  21. package/src/advisor/actions.mjs +102 -6
  22. package/src/advisor/application-commands.mjs +10 -9
  23. package/src/advisor/apply-action.mjs +74 -1
  24. package/src/advisor/guidance.mjs +24 -16
  25. package/src/advisor/schema.mjs +17 -6
  26. package/src/advisor/skills.mjs +18 -5
  27. package/src/advisor.mjs +27 -9
  28. package/src/agent-integration-cli.mjs +13 -4
  29. package/src/agent-integration-content.mjs +22 -12
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-inventory-platforms.mjs +10 -0
  32. package/src/agent-inventory.mjs +23 -1
  33. package/src/agent-skill-import.mjs +2 -2
  34. package/src/audit-paths.mjs +42 -0
  35. package/src/brief-cli.mjs +3 -2
  36. package/src/brief-renderer.mjs +1 -0
  37. package/src/cli.mjs +398 -127
  38. package/src/compatibility.mjs +24 -0
  39. package/src/control/can-use-guard.mjs +21 -1
  40. package/src/control/config-write.mjs +32 -2
  41. package/src/control/skill-crud.mjs +5 -0
  42. package/src/control/v2-guard.mjs +175 -0
  43. package/src/control/v2-skill-crud.mjs +32 -0
  44. package/src/control/v2-skill-forget.mjs +38 -0
  45. package/src/control/variant-status.mjs +47 -1
  46. package/src/control.mjs +55 -0
  47. package/src/doctor.mjs +65 -6
  48. package/src/domain/v2-policy.mjs +111 -0
  49. package/src/hook-plan.mjs +33 -3
  50. package/src/impact.mjs +52 -29
  51. package/src/index.mjs +25 -1
  52. package/src/init.mjs +50 -34
  53. package/src/inventory-install-units.mjs +63 -0
  54. package/src/inventory-json.mjs +279 -0
  55. package/src/inventory-refresh.mjs +163 -18
  56. package/src/lifecycle-cli.mjs +40 -12
  57. package/src/lifecycle-content.mjs +52 -67
  58. package/src/migration/v1-to-v2.mjs +212 -0
  59. package/src/migration/v2-files.mjs +211 -0
  60. package/src/migration/v2-journal.mjs +169 -0
  61. package/src/migration/v2-projection.mjs +108 -0
  62. package/src/migration/v2-transaction.mjs +205 -0
  63. package/src/policy.mjs +3 -0
  64. package/src/reconcile.mjs +139 -111
  65. package/src/report.mjs +168 -148
  66. package/src/review.mjs +2 -0
  67. package/src/route-advisory.mjs +47 -2
  68. package/src/route-selection.mjs +38 -2
  69. package/src/route.mjs +62 -2
  70. package/src/shared-skill.mjs +301 -0
  71. package/src/source-digest.mjs +42 -0
  72. package/src/source-profiles.mjs +27 -0
  73. package/src/source-verification.mjs +32 -48
  74. package/src/uninstall.mjs +22 -0
  75. package/src/user-state-paths.mjs +19 -0
  76. package/src/user-uninstall.mjs +146 -0
  77. package/src/workspace.mjs +41 -1
package/src/doctor.mjs CHANGED
@@ -40,6 +40,7 @@ export async function doctorProject(options = {}) {
40
40
  },
41
41
  bridges,
42
42
  workspace: emptyWorkspaceSummary(),
43
+ inventory: { required: false, ok: true, path: null, errors: [] },
43
44
  policy: { ok: false, errors: [], warnings: [] },
44
45
  sources: { checked: false, verified: options.verifySources === true, ok: false, errors: [], warnings: [], blockingWarnings: [], units: [] },
45
46
  uninstall,
@@ -76,6 +77,7 @@ export async function doctorProject(options = {}) {
76
77
  : auditSources(workspace);
77
78
  const blockingWarnings = blockingSourceWarnings(sourceAudit.warnings);
78
79
  const workspaceSummary = summarizeWorkspace(workspace);
80
+ const inventory = inventoryHealth(workspace);
79
81
  const result = {
80
82
  ...base,
81
83
  initialized: true,
@@ -86,6 +88,7 @@ export async function doctorProject(options = {}) {
86
88
  error: null
87
89
  },
88
90
  workspace: workspaceSummary,
91
+ inventory,
89
92
  policy,
90
93
  sources: {
91
94
  checked: true,
@@ -102,10 +105,14 @@ export async function doctorProject(options = {}) {
102
105
  }
103
106
 
104
107
  function finalizeDoctor(result, recommendations) {
105
- const bridgeOk = result.bridges.every((bridge) => bridge.status === "installed" || bridge.status === "absent")
106
- && result.bridges.some((bridge) => bridge.status === "installed");
107
- const ok = result.config.valid && bridgeOk && result.policy.ok && result.sources.ok;
108
- const reviewRequired = ok && reviewRequiredFor(result);
108
+ const sourceAuditIsInformational = result.config.version === 2;
109
+ const bridgeOk = sourceAuditIsInformational || (
110
+ result.bridges.every((bridge) => bridge.status === "installed" || bridge.status === "absent")
111
+ && result.bridges.some((bridge) => bridge.status === "installed")
112
+ );
113
+ const ok = result.config.valid && bridgeOk && result.policy.ok && result.inventory.ok
114
+ && (sourceAuditIsInformational || result.sources.ok);
115
+ const reviewRequired = sourceAuditIsInformational ? false : ok && reviewRequiredFor(result);
109
116
  const strictOk = ok && !reviewRequired;
110
117
  return {
111
118
  ...result,
@@ -120,11 +127,20 @@ function finalizeDoctor(result, recommendations) {
120
127
 
121
128
  function doctorRecommendations(result) {
122
129
  const recommendations = [];
130
+ if (!result.inventory.ok) {
131
+ recommendations.push("run skillboard inventory refresh and fix generated inventory integrity errors");
132
+ for (const error of result.inventory.errors) {
133
+ const match = /^skill (?<skill>[^ ]+) is missing from generated inventory$/u.exec(error);
134
+ if (match?.groups?.skill !== undefined) {
135
+ recommendations.push(`reinstall ${match.groups.skill} or run skillboard skill forget ${match.groups.skill}`);
136
+ }
137
+ }
138
+ }
123
139
  if (!result.bridges.some((bridge) => bridge.status === "installed")) {
124
- recommendations.push("run skillboard init to install agent bridge blocks");
140
+ recommendations.push("legacy project bridge blocks are absent; run skillboard init only if maintaining deprecated project-local policy");
125
141
  }
126
142
  if (result.bridges.some((bridge) => bridge.status === "unmanaged")) {
127
- recommendations.push("run skillboard init to add SkillBoard bridge blocks to unmanaged agent files");
143
+ recommendations.push("legacy project bridge is unmanaged; run skillboard init only if maintaining deprecated project-local policy");
128
144
  }
129
145
  if (result.bridges.some((bridge) => bridge.status === "broken")) {
130
146
  recommendations.push("repair AGENTS.md or CLAUDE.md SkillBoard bridge markers");
@@ -132,6 +148,9 @@ function doctorRecommendations(result) {
132
148
  if (!result.policy.ok) {
133
149
  recommendations.push("run skillboard check and fix policy errors");
134
150
  }
151
+ if (result.config.version === 2) {
152
+ return recommendations;
153
+ }
135
154
  if (!result.sources.ok) {
136
155
  recommendations.push("run skillboard audit sources --verify and fix source verification errors");
137
156
  } else if (result.sources.blockingWarnings.length > 0) {
@@ -148,6 +167,25 @@ function doctorRecommendations(result) {
148
167
  return recommendations;
149
168
  }
150
169
 
170
+ function inventoryHealth(workspace) {
171
+ if (workspace.version !== 2) {
172
+ return { required: false, ok: true, path: null, errors: [] };
173
+ }
174
+ const errors = [...(workspace.inventory?.integrityErrors ?? ["generated inventory is unavailable"])];
175
+ const observed = new Set(workspace.inventory?.skillIds ?? []);
176
+ for (const skill of workspace.skills) {
177
+ if (!observed.has(skill.id)) {
178
+ errors.push(`skill ${skill.id} is missing from generated inventory`);
179
+ }
180
+ }
181
+ return {
182
+ required: true,
183
+ ok: errors.length === 0,
184
+ path: workspace.inventory?.path ?? null,
185
+ errors
186
+ };
187
+ }
188
+
151
189
  function reviewRequiredFor(result) {
152
190
  return result.sources.blockingWarnings.length > 0
153
191
  || result.sources.warnings.length > 0
@@ -156,6 +194,13 @@ function reviewRequiredFor(result) {
156
194
  }
157
195
 
158
196
  function reviewSummaryFor(result) {
197
+ if (result.config.version === 2) {
198
+ return {
199
+ ...emptyReviewSummary(),
200
+ runtimeReady: result.workspace.skills.declared === 0 || result.workspace.skills.installed > 0,
201
+ auditInformational: true
202
+ };
203
+ }
159
204
  const highRiskReviewUnits = result.sources.units
160
205
  .filter((unit) => {
161
206
  return unit.permissionRisk === "high" && !["trusted", "reviewed"].includes(unit.trustLevel);
@@ -185,6 +230,20 @@ function blockingSourceWarnings(warnings) {
185
230
  }
186
231
 
187
232
  function summarizeWorkspace(workspace) {
233
+ if (workspace.version === 2) {
234
+ const enabled = workspace.skills.filter((skill) => skill.enabled).length;
235
+ return {
236
+ skills: {
237
+ declared: workspace.skills.length, installed: workspace.installedSkills.length,
238
+ enabled, disabled: workspace.skills.length - enabled,
239
+ shared: workspace.skills.filter((skill) => skill.shared).length,
240
+ local: workspace.skills.filter((skill) => !skill.shared).length,
241
+ modelSelectable: enabled, byStatus: {}, byInvocation: {}
242
+ },
243
+ workflows: workspace.workflows.length, harnesses: 0,
244
+ installUnits: { total: 0, bySourceClass: {}, highRisk: [], runtimeExtensions: [] }
245
+ };
246
+ }
188
247
  const skillsByStatus = countBy(workspace.skills, (skill) => skill.status);
189
248
  const skillsByInvocation = countBy(workspace.skills, (skill) => skill.invocation);
190
249
  const installUnitsByClass = countBy(workspace.installUnits, (unit) => installUnitSourceClass(unit));
@@ -0,0 +1,111 @@
1
+ import YAML from "yaml";
2
+ import { requireRecord } from "../config-helpers.mjs";
3
+
4
+ const POLICY_KEYS = new Set(["enabled", "shared", "preference"]);
5
+ const V1_POLICY_KEYS = new Set([
6
+ "path", "status", "invocation", "exposure", "category", "canonical_for",
7
+ "conflicts_with", "replaced_by", "owner_install_unit", "variant", "scope"
8
+ ]);
9
+ const PREFERENCE_KEYS = new Set(["intents", "priority"]);
10
+ const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
11
+ const MIGRATION_GUIDANCE = "Run `skillboard migrate v2` to convert version 1 policy.";
12
+
13
+ export function parseV2Policy(config) {
14
+ const unknownRootKeys = Object.keys(config).filter((key) => !["version", "skills"].includes(key));
15
+ if (unknownRootKeys.length > 0) {
16
+ throw new Error(`Version 2 config contains unsupported policy section: ${unknownRootKeys.join(", ")}. ${MIGRATION_GUIDANCE}`);
17
+ }
18
+ const rawSkills = requireRecord(config.skills ?? {}, "skills");
19
+ const skills = Object.entries(rawSkills)
20
+ .sort(([left], [right]) => left.localeCompare(right))
21
+ .map(([id, entry]) => parseSkillPolicy(id, entry));
22
+ return { workflows: [], skills };
23
+ }
24
+
25
+ export function serializeV2Policy(workspace) {
26
+ if (workspace.version !== 2) {
27
+ throw new Error("serializeV2Policy requires a version 2 workspace");
28
+ }
29
+ const config = {
30
+ version: 2,
31
+ skills: Object.fromEntries(workspace.skills.map((skill) => [skill.id, serializeSkill(skill)]))
32
+ };
33
+ return YAML.stringify(config, { lineWidth: 0 });
34
+ }
35
+
36
+ function parseSkillPolicy(id, entry) {
37
+ if (!SKILL_ID_PATTERN.test(id)) {
38
+ throw new Error(`Version 2 config contains invalid skill id: ${id}`);
39
+ }
40
+ const label = `skills.${id}`;
41
+ const raw = requireRecord(entry, label);
42
+ const keys = Object.keys(raw);
43
+ const v1Keys = keys.filter((key) => V1_POLICY_KEYS.has(key));
44
+ if (v1Keys.length > 0) {
45
+ throw new Error(`${label} mixes version 1 key ${v1Keys.join(", ")} with version 2 policy. ${MIGRATION_GUIDANCE}`);
46
+ }
47
+ const unknownKeys = keys.filter((key) => !POLICY_KEYS.has(key));
48
+ if (unknownKeys.length > 0) {
49
+ throw new Error(`${label} contains unsupported version 2 policy key: ${unknownKeys.join(", ")}. ${MIGRATION_GUIDANCE}`);
50
+ }
51
+ if (typeof raw.enabled !== "boolean") {
52
+ throw new Error(`${label}.enabled is required and must be a boolean`);
53
+ }
54
+ if (typeof raw.shared !== "boolean") {
55
+ throw new Error(`${label}.shared is required and must be a boolean`);
56
+ }
57
+ return {
58
+ id,
59
+ enabled: raw.enabled,
60
+ shared: raw.shared,
61
+ preference: parsePreference(raw.preference, label)
62
+ };
63
+ }
64
+
65
+ function parsePreference(value, label) {
66
+ if (value === undefined) {
67
+ return null;
68
+ }
69
+ const raw = requireRecord(value, `${label}.preference`);
70
+ const unknownKeys = Object.keys(raw).filter((key) => !PREFERENCE_KEYS.has(key));
71
+ if (unknownKeys.length > 0) {
72
+ throw new Error(`${label}.preference contains unsupported key: ${unknownKeys.join(", ")}`);
73
+ }
74
+ const intents = raw.intents;
75
+ if (!Array.isArray(intents) || intents.length === 0 || intents.some((intent) => typeof intent !== "string" || intent.trim() === "")) {
76
+ throw new Error(`${label}.preference.intents must be a non-empty list of intent terms`);
77
+ }
78
+ assertUnique(intents, `${label}.preference.intents`);
79
+ assertSorted(intents, `${label}.preference.intents`);
80
+ if (!Number.isInteger(raw.priority)) {
81
+ throw new Error(`${label}.preference.priority is required and must be an integer`);
82
+ }
83
+ return { intents: [...intents], priority: raw.priority };
84
+ }
85
+
86
+ function serializeSkill(skill) {
87
+ return {
88
+ enabled: skill.enabled,
89
+ shared: skill.shared,
90
+ ...(skill.preference === null ? {} : {
91
+ preference: { intents: [...skill.preference.intents], priority: skill.preference.priority }
92
+ })
93
+ };
94
+ }
95
+
96
+ function assertUnique(values, label) {
97
+ const seen = new Set();
98
+ for (const value of values) {
99
+ if (seen.has(value)) {
100
+ throw new Error(`${label} must not contain duplicates: ${value}`);
101
+ }
102
+ seen.add(value);
103
+ }
104
+ }
105
+
106
+ function assertSorted(values, label) {
107
+ const sorted = [...values].sort((left, right) => left.localeCompare(right));
108
+ if (values.some((value, index) => value !== sorted[index])) {
109
+ throw new Error(`${label} must be sorted`);
110
+ }
111
+ }
package/src/hook-plan.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { lstat } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { loadWorkspace } from "./workspace.mjs";
4
+ import { V1_MUTATION_ERROR } from "./compatibility.mjs";
4
5
 
5
6
  export const GUARD_HOOK_MODE = 0o755;
6
7
 
@@ -13,6 +14,7 @@ export async function buildGuardHookInstallPlan(options) {
13
14
  const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
14
15
  requireWorkflow(workspace, options.workflow);
15
16
  const out = options.out ?? join(dirname(options.configPath), ".skillboard", "hooks", `skillboard-guard-${safeHookFilePart(options.workflow)}.sh`);
17
+ if (workspace.version === 2) await assertHookTargetContained(options.configPath, out);
16
18
  const command = options.command ?? "skillboard";
17
19
  const skillsRoot = options.skillsRoot ?? "skills";
18
20
  const script = renderGuardHookScript({
@@ -24,6 +26,7 @@ export async function buildGuardHookInstallPlan(options) {
24
26
  const target = await inspectHookTarget(out);
25
27
  const plannedMode = modeToOctal(GUARD_HOOK_MODE);
26
28
  const plan = {
29
+ policy_projection_version: workspace.version,
27
30
  path: out,
28
31
  workflow: options.workflow,
29
32
  command,
@@ -41,7 +44,25 @@ export async function buildGuardHookInstallPlan(options) {
41
44
  return { plan, script };
42
45
  }
43
46
 
47
+ export async function assertHookTargetContained(configPath, targetPath) {
48
+ const root = await realpath(dirname(resolve(configPath)));
49
+ const target = resolve(targetPath);
50
+ const targetRelative = relative(root, target);
51
+ if (targetRelative === "" || targetRelative.startsWith("..") || isAbsolute(targetRelative)) {
52
+ throw new Error("Guard hook target must remain inside the config directory.");
53
+ }
54
+ let current = root;
55
+ for (const part of targetRelative.split(/[\\/]/u).slice(0, -1)) {
56
+ current = join(current, part);
57
+ const stats = await lstat(current).catch(missingOnly);
58
+ if (stats === undefined) continue;
59
+ if (stats.isSymbolicLink()) throw new Error("Guard hook parent must not be a symbolic link.");
60
+ if (!stats.isDirectory()) throw new Error("Guard hook parent must be a directory.");
61
+ }
62
+ }
63
+
44
64
  export function assertGuardHookPlanIsInstallable(plan) {
65
+ if (plan.policy_projection_version !== 2) throw new Error(V1_MUTATION_ERROR);
45
66
  if (plan.target_exists) {
46
67
  throw new Error(`Refusing to overwrite existing hook path: ${plan.path}`);
47
68
  }
@@ -58,10 +79,14 @@ function renderGuardHookScript(options) {
58
79
  return `#!/usr/bin/env sh
59
80
  set -eu
60
81
 
82
+ # SkillBoard policy projection version: 2
83
+
61
84
  SKILLBOARD_BIN=${shellQuote(options.command)}
62
85
  SKILLBOARD_CONFIG=${shellQuote(options.configPath)}
63
86
  SKILLBOARD_SKILLS=${shellQuote(options.skillsRoot)}
64
87
  SKILLBOARD_WORKFLOW=${shellQuote(options.workflow)}
88
+ SKILLBOARD_POLICY_PROJECTION_VERSION=2
89
+ export SKILLBOARD_POLICY_PROJECTION_VERSION
65
90
 
66
91
  if [ "\${SKILLBOARD_SKILL_ID:-}" != "" ]; then
67
92
  skill_id="$SKILLBOARD_SKILL_ID"
@@ -76,7 +101,7 @@ fi
76
101
  # --skillboard-bin "node bin/skillboard.mjs"
77
102
  # Paths containing spaces should be provided through an environment wrapper.
78
103
  set -- $SKILLBOARD_BIN
79
- exec "$@" guard use "$skill_id" --workflow "$SKILLBOARD_WORKFLOW" --config "$SKILLBOARD_CONFIG" --skills "$SKILLBOARD_SKILLS"
104
+ exec "$@" guard use "$skill_id" --hook-projection-version 2 --workflow "$SKILLBOARD_WORKFLOW" --config "$SKILLBOARD_CONFIG" --skills "$SKILLBOARD_SKILLS"
80
105
  `;
81
106
  }
82
107
 
@@ -127,6 +152,11 @@ async function inspectHookTarget(path) {
127
152
  return { exists: true, type: "other" };
128
153
  }
129
154
 
155
+ function missingOnly(error) {
156
+ if (error?.code === "ENOENT") return undefined;
157
+ throw error;
158
+ }
159
+
130
160
  function modeToOctal(mode) {
131
161
  return mode.toString(8).padStart(4, "0");
132
162
  }
package/src/impact.mjs CHANGED
@@ -4,12 +4,15 @@ import {
4
4
  } from "./conflicts.mjs";
5
5
 
6
6
  export function impactDisable(workspace, skillId) {
7
- const affectedWorkflowEntries = workspace.workflows
8
- .filter((workflow) => workflow.activeSkills.includes(skillId) || workflow.requiredCapabilities.some((capability) => {
9
- return capability.preferred === skillId || capability.fallback.includes(skillId);
10
- }));
11
- const affectedWorkflows = affectedWorkflowEntries.map((workflow) => workflow.name);
12
- const affectedOutputs = [...new Set(affectedWorkflowEntries.flatMap((workflow) => workflow.requiredOutputs))];
7
+ if (workspace.version === 2) {
8
+ return impactDisableV2(workspace, skillId);
9
+ }
10
+ const affectedWorkflowEntries = workspace.workflows
11
+ .filter((workflow) => workflow.activeSkills.includes(skillId) || workflow.requiredCapabilities.some((capability) => {
12
+ return capability.preferred === skillId || capability.fallback.includes(skillId);
13
+ }));
14
+ const affectedWorkflows = affectedWorkflowEntries.map((workflow) => workflow.name);
15
+ const affectedOutputs = [...new Set(affectedWorkflowEntries.flatMap((workflow) => workflow.requiredOutputs))];
13
16
  const skill = workspace.skills.find((candidate) => candidate.id === skillId);
14
17
  const alternatives = alternativesForSkill(workspace, skillId, skill);
15
18
  const conflictingSkills = conflictingSkillIds(workspace, skillId);
@@ -25,26 +28,46 @@ export function impactDisable(workspace, skillId) {
25
28
  risk: riskFor(skill, affectedWorkflows, alternatives)
26
29
  };
27
30
  }
28
-
29
- function alternativesForSkill(workspace, skillId, skill) {
30
- if (skill?.replacedBy !== undefined) {
31
- return [skill.replacedBy];
32
- }
33
- const capability = workspace.capabilities.find((candidate) => {
34
- return candidate.canonical === skillId || candidate.alternatives.includes(skillId);
35
- });
36
- if (capability === undefined) {
37
- return [];
38
- }
39
- return [capability.canonical, ...capability.alternatives].filter((candidate) => candidate !== skillId);
40
- }
41
-
42
- function riskFor(skill, affectedWorkflows, alternatives) {
43
- if (skill === undefined) {
44
- return "unknown";
45
- }
46
- if (affectedWorkflows.length === 0) {
47
- return "low";
48
- }
49
- return alternatives.length === 0 ? "high" : "medium";
50
- }
31
+
32
+ function impactDisableV2(workspace, skillId) {
33
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
34
+ const affectedAgents = skill?.enabled === true
35
+ ? workspace.inventory?.skills?.find((entry) => entry.id === skillId)?.installed_on ?? []
36
+ : [];
37
+ return {
38
+ skillId,
39
+ exists: skill !== undefined,
40
+ affectedWorkflows: [],
41
+ affectedAgents,
42
+ affectedOutputs: [],
43
+ alternatives: [],
44
+ conflictingSkills: [],
45
+ activeConflicts: [],
46
+ policyBefore: skill === undefined ? null : { enabled: skill.enabled, shared: skill.shared },
47
+ policyAfter: skill === undefined ? null : { enabled: false, shared: skill.shared },
48
+ risk: skill === undefined ? "unknown" : affectedAgents.length <= 1 ? "low" : "medium"
49
+ };
50
+ }
51
+
52
+ function alternativesForSkill(workspace, skillId, skill) {
53
+ if (skill?.replacedBy !== undefined) {
54
+ return [skill.replacedBy];
55
+ }
56
+ const capability = workspace.capabilities.find((candidate) => {
57
+ return candidate.canonical === skillId || candidate.alternatives.includes(skillId);
58
+ });
59
+ if (capability === undefined) {
60
+ return [];
61
+ }
62
+ return [capability.canonical, ...capability.alternatives].filter((candidate) => candidate !== skillId);
63
+ }
64
+
65
+ function riskFor(skill, affectedWorkflows, alternatives) {
66
+ if (skill === undefined) {
67
+ return "unknown";
68
+ }
69
+ if (affectedWorkflows.length === 0) {
70
+ return "low";
71
+ }
72
+ return alternatives.length === 0 ? "high" : "medium";
73
+ }
package/src/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { loadWorkspace } from "./workspace.mjs";
2
2
  import { checkPolicy } from "./policy.mjs";
3
3
  import { doctorProject } from "./doctor.mjs";
4
4
  import { impactDisable } from "./impact.mjs";
5
- import { renderDashboard, renderReconcilePlan } from "./report.mjs";
5
+ import { renderDashboard, renderReconcilePlan } from "./report.mjs";
6
6
  import { reconcileWorkspace } from "./reconcile.mjs";
7
7
  import { initProject } from "./init.mjs";
8
8
  import { agentInventoryDetectors, discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
@@ -12,6 +12,15 @@ import { importSource, loadSourceProfile, mergeImportFragment, renderImportFragm
12
12
  import { verifySources, writeLockfile } from "./source-verification.mjs";
13
13
  import { refreshSourcePins } from "./source-cache.mjs";
14
14
  import { detectInstallOutput } from "./install-output-detector.mjs";
15
+ import { migrateV2 } from "./migration/v2-transaction.mjs";
16
+ import {
17
+ assertCurrentProjectionVersion,
18
+ STALE_V1_PROJECTION_ERROR,
19
+ V1_COMPATIBILITY_NOTICE,
20
+ V1_COMPATIBILITY_REMOVAL_VERSION,
21
+ V1_MIGRATION_COMMAND,
22
+ V1_MUTATION_ERROR
23
+ } from "./compatibility.mjs";
15
24
  import { rolloutApply, rolloutAudit, rolloutPlan, rolloutReport, rolloutRollback } from "./rollout.mjs";
16
25
  import { reviewInstallUnit } from "./review.mjs";
17
26
  import { buildSkillBrief } from "./advisor.mjs";
@@ -28,6 +37,7 @@ import {
28
37
  canUseSkill,
29
38
  explainSkill,
30
39
  forkSkillVariant,
40
+ forgetV2Skill,
31
41
  installGuardHook,
32
42
  listHarnesses,
33
43
  listInstallUnits,
@@ -37,6 +47,9 @@ import {
37
47
  quarantineSkill,
38
48
  removeSkill,
39
49
  resetSkillVariant,
50
+ setV2SkillEnabled,
51
+ setV2SkillPreference,
52
+ setV2SkillShared,
40
53
  variantLifecycleStatus
41
54
  } from "./control.mjs";
42
55
 
@@ -58,6 +71,7 @@ export {
58
71
  doctorProject,
59
72
  explainSkill,
60
73
  forkSkillVariant,
74
+ forgetV2Skill,
61
75
  impactDisable,
62
76
  importSource,
63
77
  initProject,
@@ -70,10 +84,20 @@ export {
70
84
  loadWorkspace,
71
85
  mergeAgentSkillInventory,
72
86
  mergeImportFragment,
87
+ migrateV2,
88
+ assertCurrentProjectionVersion,
89
+ STALE_V1_PROJECTION_ERROR,
90
+ V1_COMPATIBILITY_NOTICE,
91
+ V1_COMPATIBILITY_REMOVAL_VERSION,
92
+ V1_MIGRATION_COMMAND,
93
+ V1_MUTATION_ERROR,
73
94
  preferSkill,
74
95
  quarantineSkill,
75
96
  removeSkill,
76
97
  resetSkillVariant,
98
+ setV2SkillEnabled,
99
+ setV2SkillPreference,
100
+ setV2SkillShared,
77
101
  reconcileWorkspace,
78
102
  refreshAgentInventory,
79
103
  refreshSourcePins,
package/src/init.mjs CHANGED
@@ -5,10 +5,10 @@ import { NON_CALLABLE_WORKFLOW_INVOCATIONS, NON_CALLABLE_WORKFLOW_STATUSES } fro
5
5
  import { isValidSkillState } from "./domain/skill-state-matrix.mjs";
6
6
  import { refreshAgentInventory } from "./inventory-refresh.mjs";
7
7
  import { BRIDGE_START, bridgeBlock, defaultConfig, hookReadme, profileReadme } from "./lifecycle-content.mjs";
8
-
9
- export async function initProject(options) {
10
- const root = options.root;
11
- const configPath = join(root, "skillboard.config.yaml");
8
+
9
+ export async function initProject(options) {
10
+ const root = options.root;
11
+ const configPath = join(root, "skillboard.config.yaml");
12
12
  const skillsRoot = join(root, "skills");
13
13
  const reportRoot = join(root, ".skillboard", "reports");
14
14
  const profileRoot = join(root, ".skillboard", "profiles");
@@ -24,7 +24,15 @@ export async function initProject(options) {
24
24
  await writeFile(configPath, defaultConfig(), "utf8");
25
25
  created.push("skillboard.config.yaml");
26
26
  }
27
- const profileReadmePath = join(profileRoot, "README.md");
27
+ if (configCreated && options.scanInstalled === false) {
28
+ await refreshAgentInventory({
29
+ root,
30
+ configPath,
31
+ inventory: { skills: [], installUnits: [], scannedSkills: 0, warnings: [] }
32
+ });
33
+ created.push(".skillboard/inventory.json");
34
+ }
35
+ const profileReadmePath = join(profileRoot, "README.md");
28
36
  if (!(await exists(profileReadmePath))) {
29
37
  await writeFile(profileReadmePath, profileReadme(), "utf8");
30
38
  created.push(".skillboard/profiles/README.md");
@@ -34,14 +42,14 @@ export async function initProject(options) {
34
42
  await writeFile(hookReadmePath, hookReadme(), "utf8");
35
43
  created.push(".skillboard/hooks/README.md");
36
44
  }
37
- for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
38
- const result = await ensureBridge(join(root, filename));
39
- if (result === "created") {
40
- created.push(filename);
41
- } else if (result === "updated") {
42
- updated.push(filename);
43
- }
44
- }
45
+ for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
46
+ const result = await ensureBridge(join(root, filename));
47
+ if (result === "created") {
48
+ created.push(filename);
49
+ } else if (result === "updated") {
50
+ updated.push(filename);
51
+ }
52
+ }
45
53
  const scan = options.scanInstalled === false
46
54
  ? { scannedSkills: 0, scannedInstallUnits: 0, changed: false, addedSkills: [], addedInstallUnits: [], updatedInstallUnits: [], addedWorkflows: [], addedHarnesses: [], skippedSkills: [], reviewNotes: [], warnings: [] }
47
55
  : await mergeInstalledAgentSkills(configPath, {
@@ -61,26 +69,26 @@ export async function initProject(options) {
61
69
  alreadyInitialized: created.length === 0 && updated.length === 0 && !scan.changed
62
70
  };
63
71
  }
64
-
65
- async function exists(path) {
66
- return access(path).then(() => true, () => false);
67
- }
68
-
69
- async function ensureBridge(path) {
70
- const block = bridgeBlock();
71
- if (!(await exists(path))) {
72
- await writeFile(path, `${block}\n`, "utf8");
73
- return "created";
74
- }
75
- const current = await readFile(path, "utf8");
76
- if (current.includes(BRIDGE_START)) {
77
- return "unchanged";
78
- }
79
- const separator = current.endsWith("\n") ? "\n" : "\n\n";
80
- await writeFile(path, `${current}${separator}${block}\n`, "utf8");
81
- return "updated";
82
- }
83
-
72
+
73
+ async function exists(path) {
74
+ return access(path).then(() => true, () => false);
75
+ }
76
+
77
+ async function ensureBridge(path) {
78
+ const block = bridgeBlock();
79
+ if (!(await exists(path))) {
80
+ await writeFile(path, `${block}\n`, "utf8");
81
+ return "created";
82
+ }
83
+ const current = await readFile(path, "utf8");
84
+ if (current.includes(BRIDGE_START)) {
85
+ return "unchanged";
86
+ }
87
+ const separator = current.endsWith("\n") ? "\n" : "\n\n";
88
+ await writeFile(path, `${current}${separator}${block}\n`, "utf8");
89
+ return "updated";
90
+ }
91
+
84
92
  async function mergeInstalledAgentSkills(configPath, options) {
85
93
  const result = await refreshAgentInventory({
86
94
  root: dirname(configPath),
@@ -107,6 +115,14 @@ async function mergeInstalledAgentSkills(configPath, options) {
107
115
  async function summarizeSafety(configPath) {
108
116
  const config = YAML.parse(await readFile(configPath, "utf8")) ?? {};
109
117
  const skills = config.skills && typeof config.skills === "object" ? Object.values(config.skills) : [];
118
+ if (config.version === 2) {
119
+ return {
120
+ enabled: skills.filter((skill) => skill?.enabled === true).length,
121
+ disabled: skills.filter((skill) => skill?.enabled === false).length,
122
+ shared: skills.filter((skill) => skill?.enabled === true && skill.shared === true).length,
123
+ local: skills.filter((skill) => skill?.enabled === true && skill.shared !== true).length
124
+ };
125
+ }
110
126
  let automatic = 0;
111
127
  let manualOnly = 0;
112
128
  let routerOnly = 0;
@@ -132,5 +148,5 @@ async function summarizeSafety(configPath) {
132
148
  routerOnly += 1;
133
149
  }
134
150
  }
135
- return { automatic, manualOnly, routerOnly, blocked };
151
+ return { enabled: automatic + manualOnly + routerOnly, disabled: blocked, global: 0, scoped: automatic + manualOnly + routerOnly };
136
152
  }