agent-skillboard 0.2.18 → 0.3.1

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 (82) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +161 -255
  3. package/bin/postinstall.mjs +2 -1
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +41 -108
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +70 -473
  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 +133 -362
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +68 -279
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +49 -269
  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 +96 -13
  29. package/src/agent-integration-content.mjs +21 -11
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-integration-home.mjs +14 -1
  32. package/src/agent-inventory-platforms.mjs +21 -8
  33. package/src/agent-inventory.mjs +44 -16
  34. package/src/agent-root-registry.mjs +127 -0
  35. package/src/agent-skill-import.mjs +2 -2
  36. package/src/agent-skill-roots.mjs +70 -13
  37. package/src/audit-paths.mjs +42 -0
  38. package/src/brief-cli.mjs +3 -2
  39. package/src/brief-renderer.mjs +1 -0
  40. package/src/cli.mjs +521 -235
  41. package/src/compatibility.mjs +24 -0
  42. package/src/control/can-use-guard.mjs +21 -1
  43. package/src/control/config-write.mjs +32 -2
  44. package/src/control/skill-crud.mjs +5 -0
  45. package/src/control/v2-guard.mjs +175 -0
  46. package/src/control/v2-skill-crud.mjs +32 -0
  47. package/src/control/v2-skill-forget.mjs +38 -0
  48. package/src/control/variant-status.mjs +47 -1
  49. package/src/control.mjs +55 -0
  50. package/src/doctor.mjs +71 -5
  51. package/src/domain/v2-policy.mjs +111 -0
  52. package/src/hook-plan.mjs +33 -3
  53. package/src/impact.mjs +52 -29
  54. package/src/index.mjs +25 -1
  55. package/src/init.mjs +50 -34
  56. package/src/install-health.mjs +177 -0
  57. package/src/inventory-install-units.mjs +63 -0
  58. package/src/inventory-json.mjs +279 -0
  59. package/src/inventory-refresh.mjs +168 -19
  60. package/src/lifecycle-cli.mjs +40 -12
  61. package/src/lifecycle-content.mjs +52 -67
  62. package/src/migration/v1-to-v2.mjs +212 -0
  63. package/src/migration/v2-files.mjs +211 -0
  64. package/src/migration/v2-journal.mjs +169 -0
  65. package/src/migration/v2-projection.mjs +108 -0
  66. package/src/migration/v2-transaction.mjs +205 -0
  67. package/src/policy.mjs +3 -0
  68. package/src/reconcile.mjs +139 -111
  69. package/src/report.mjs +168 -148
  70. package/src/review.mjs +2 -0
  71. package/src/route-advisory.mjs +47 -2
  72. package/src/route-selection.mjs +38 -2
  73. package/src/route.mjs +62 -2
  74. package/src/shared-skill-reconcile.mjs +97 -0
  75. package/src/shared-skill.mjs +325 -0
  76. package/src/source-digest.mjs +42 -0
  77. package/src/source-profiles.mjs +171 -144
  78. package/src/source-verification.mjs +32 -48
  79. package/src/uninstall.mjs +22 -0
  80. package/src/user-state-paths.mjs +19 -0
  81. package/src/user-uninstall.mjs +161 -0
  82. package/src/workspace.mjs +119 -79
package/src/doctor.mjs CHANGED
@@ -4,6 +4,7 @@ import { auditSources } from "./control.mjs";
4
4
  import { hasRuntimeComponents, installUnitSourceClass, isModelSelectableInvocation } from "./domain/source-classes.mjs";
5
5
  import { BRIDGE_END, BRIDGE_START } from "./lifecycle-content.mjs";
6
6
  import { checkPolicy } from "./policy.mjs";
7
+ import { inspectInstallation } from "./install-health.mjs";
7
8
  import { verifySources } from "./source-verification.mjs";
8
9
  import { uninstallProject } from "./uninstall.mjs";
9
10
  import { loadWorkspace } from "./workspace.mjs";
@@ -13,6 +14,11 @@ export async function doctorProject(options = {}) {
13
14
  const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
14
15
  const skillsRoot = resolveUnderRoot(root, options.skillsRoot ?? "skills");
15
16
  const bridges = await bridgeStatuses(root);
17
+ const installation = options.installation ?? await inspectInstallation({
18
+ entrypointPath: options.entrypointPath,
19
+ env: options.env,
20
+ packageVersion: options.packageVersion
21
+ });
16
22
  const uninstall = await uninstallProject({
17
23
  root,
18
24
  dryRun: true,
@@ -39,7 +45,9 @@ export async function doctorProject(options = {}) {
39
45
  error: configExists ? null : "skillboard.config.yaml was not found"
40
46
  },
41
47
  bridges,
48
+ installation,
42
49
  workspace: emptyWorkspaceSummary(),
50
+ inventory: { required: false, ok: true, path: null, errors: [] },
43
51
  policy: { ok: false, errors: [], warnings: [] },
44
52
  sources: { checked: false, verified: options.verifySources === true, ok: false, errors: [], warnings: [], blockingWarnings: [], units: [] },
45
53
  uninstall,
@@ -76,6 +84,7 @@ export async function doctorProject(options = {}) {
76
84
  : auditSources(workspace);
77
85
  const blockingWarnings = blockingSourceWarnings(sourceAudit.warnings);
78
86
  const workspaceSummary = summarizeWorkspace(workspace);
87
+ const inventory = inventoryHealth(workspace);
79
88
  const result = {
80
89
  ...base,
81
90
  initialized: true,
@@ -86,6 +95,7 @@ export async function doctorProject(options = {}) {
86
95
  error: null
87
96
  },
88
97
  workspace: workspaceSummary,
98
+ inventory,
89
99
  policy,
90
100
  sources: {
91
101
  checked: true,
@@ -102,10 +112,14 @@ export async function doctorProject(options = {}) {
102
112
  }
103
113
 
104
114
  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);
115
+ const sourceAuditIsInformational = result.config.version === 2;
116
+ const bridgeOk = sourceAuditIsInformational || (
117
+ result.bridges.every((bridge) => bridge.status === "installed" || bridge.status === "absent")
118
+ && result.bridges.some((bridge) => bridge.status === "installed")
119
+ );
120
+ const ok = result.config.valid && bridgeOk && result.policy.ok && result.inventory.ok
121
+ && (sourceAuditIsInformational || result.sources.ok);
122
+ const reviewRequired = sourceAuditIsInformational ? false : ok && reviewRequiredFor(result);
109
123
  const strictOk = ok && !reviewRequired;
110
124
  return {
111
125
  ...result,
@@ -113,13 +127,22 @@ function finalizeDoctor(result, recommendations) {
113
127
  strictOk,
114
128
  reviewRequired,
115
129
  mode: ok ? reviewRequired ? "safe-mode" : "passed" : result.initialized ? "failed" : "not-initialized",
116
- recommendations: [...new Set(recommendations)],
130
+ recommendations: [...new Set([...recommendations, ...result.installation.warnings])],
117
131
  reviewSummary: reviewSummaryFor(result)
118
132
  };
119
133
  }
120
134
 
121
135
  function doctorRecommendations(result) {
122
136
  const recommendations = [];
137
+ if (!result.inventory.ok) {
138
+ recommendations.push("run skillboard inventory refresh and fix generated inventory integrity errors");
139
+ for (const error of result.inventory.errors) {
140
+ const match = /^skill (?<skill>[^ ]+) is missing from generated inventory$/u.exec(error);
141
+ if (match?.groups?.skill !== undefined) {
142
+ recommendations.push(`reinstall ${match.groups.skill} or run skillboard skill forget ${match.groups.skill}`);
143
+ }
144
+ }
145
+ }
123
146
  if (!result.bridges.some((bridge) => bridge.status === "installed")) {
124
147
  recommendations.push("legacy project bridge blocks are absent; run skillboard init only if maintaining deprecated project-local policy");
125
148
  }
@@ -132,6 +155,9 @@ function doctorRecommendations(result) {
132
155
  if (!result.policy.ok) {
133
156
  recommendations.push("run skillboard check and fix policy errors");
134
157
  }
158
+ if (result.config.version === 2) {
159
+ return recommendations;
160
+ }
135
161
  if (!result.sources.ok) {
136
162
  recommendations.push("run skillboard audit sources --verify and fix source verification errors");
137
163
  } else if (result.sources.blockingWarnings.length > 0) {
@@ -148,6 +174,25 @@ function doctorRecommendations(result) {
148
174
  return recommendations;
149
175
  }
150
176
 
177
+ function inventoryHealth(workspace) {
178
+ if (workspace.version !== 2) {
179
+ return { required: false, ok: true, path: null, errors: [] };
180
+ }
181
+ const errors = [...(workspace.inventory?.integrityErrors ?? ["generated inventory is unavailable"])];
182
+ const observed = new Set(workspace.inventory?.skillIds ?? []);
183
+ for (const skill of workspace.skills) {
184
+ if (!observed.has(skill.id)) {
185
+ errors.push(`skill ${skill.id} is missing from generated inventory`);
186
+ }
187
+ }
188
+ return {
189
+ required: true,
190
+ ok: errors.length === 0,
191
+ path: workspace.inventory?.path ?? null,
192
+ errors
193
+ };
194
+ }
195
+
151
196
  function reviewRequiredFor(result) {
152
197
  return result.sources.blockingWarnings.length > 0
153
198
  || result.sources.warnings.length > 0
@@ -156,6 +201,13 @@ function reviewRequiredFor(result) {
156
201
  }
157
202
 
158
203
  function reviewSummaryFor(result) {
204
+ if (result.config.version === 2) {
205
+ return {
206
+ ...emptyReviewSummary(),
207
+ runtimeReady: result.workspace.skills.declared === 0 || result.workspace.skills.installed > 0,
208
+ auditInformational: true
209
+ };
210
+ }
159
211
  const highRiskReviewUnits = result.sources.units
160
212
  .filter((unit) => {
161
213
  return unit.permissionRisk === "high" && !["trusted", "reviewed"].includes(unit.trustLevel);
@@ -185,6 +237,20 @@ function blockingSourceWarnings(warnings) {
185
237
  }
186
238
 
187
239
  function summarizeWorkspace(workspace) {
240
+ if (workspace.version === 2) {
241
+ const enabled = workspace.skills.filter((skill) => skill.enabled).length;
242
+ return {
243
+ skills: {
244
+ declared: workspace.skills.length, installed: workspace.installedSkills.length,
245
+ enabled, disabled: workspace.skills.length - enabled,
246
+ shared: workspace.skills.filter((skill) => skill.shared).length,
247
+ local: workspace.skills.filter((skill) => !skill.shared).length,
248
+ modelSelectable: enabled, byStatus: {}, byInvocation: {}
249
+ },
250
+ workflows: workspace.workflows.length, harnesses: 0,
251
+ installUnits: { total: 0, bySourceClass: {}, highRisk: [], runtimeExtensions: [] }
252
+ };
253
+ }
188
254
  const skillsByStatus = countBy(workspace.skills, (skill) => skill.status);
189
255
  const skillsByInvocation = countBy(workspace.skills, (skill) => skill.invocation);
190
256
  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
  }