agent-skillboard 0.1.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 (94) hide show
  1. package/CONTRIBUTING.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +645 -0
  4. package/bin/skillboard.mjs +4 -0
  5. package/docs/adapters.md +127 -0
  6. package/docs/capabilities.md +107 -0
  7. package/docs/install.md +346 -0
  8. package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
  9. package/docs/plans/README.md +20 -0
  10. package/docs/policy-model.md +251 -0
  11. package/docs/positioning.md +94 -0
  12. package/docs/profiles.md +166 -0
  13. package/docs/rollout-runbook.md +60 -0
  14. package/docs/user-flow.md +231 -0
  15. package/docs/versioning.md +201 -0
  16. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
  17. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
  18. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
  19. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
  20. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
  21. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
  22. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
  23. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
  24. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
  25. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
  26. package/examples/multi-source.config.yaml +271 -0
  27. package/examples/skillboard.config.yaml +194 -0
  28. package/examples/skills/grill-me/SKILL.md +9 -0
  29. package/examples/skills/grill-with-docs/SKILL.md +9 -0
  30. package/examples/skills/requirement-intake/SKILL.md +9 -0
  31. package/examples/skills/tdd/SKILL.md +8 -0
  32. package/package.json +58 -0
  33. package/profiles/anthropics-skills.yaml +17 -0
  34. package/profiles/mattpocock-skills.yaml +26 -0
  35. package/profiles/oh-my-openagent.yaml +32 -0
  36. package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
  37. package/profiles/wshobson-agents.yaml +19 -0
  38. package/src/advisor/action-core.mjs +74 -0
  39. package/src/advisor/actions.mjs +256 -0
  40. package/src/advisor/application-commands.mjs +59 -0
  41. package/src/advisor/apply-action.mjs +183 -0
  42. package/src/advisor/schema.mjs +112 -0
  43. package/src/advisor/setup-actions.mjs +42 -0
  44. package/src/advisor/skills.mjs +191 -0
  45. package/src/advisor/sort.mjs +15 -0
  46. package/src/advisor/sources.mjs +160 -0
  47. package/src/advisor/trust-policy.mjs +65 -0
  48. package/src/advisor/workflow.mjs +41 -0
  49. package/src/advisor.mjs +134 -0
  50. package/src/agent-inventory-platforms.mjs +100 -0
  51. package/src/agent-inventory.mjs +804 -0
  52. package/src/brief-cli.mjs +40 -0
  53. package/src/brief-renderer.mjs +362 -0
  54. package/src/change-plan.mjs +169 -0
  55. package/src/cli.mjs +1171 -0
  56. package/src/config-helpers.mjs +72 -0
  57. package/src/control/can-use-guard.mjs +91 -0
  58. package/src/control/config-write.mjs +163 -0
  59. package/src/control/skill-crud.mjs +394 -0
  60. package/src/control/source-trust.mjs +161 -0
  61. package/src/control/workflow-crud.mjs +104 -0
  62. package/src/control.mjs +197 -0
  63. package/src/doctor.mjs +299 -0
  64. package/src/domain/constants.mjs +47 -0
  65. package/src/domain/indexes.mjs +29 -0
  66. package/src/domain/rules/capabilities.mjs +33 -0
  67. package/src/domain/rules/harnesses.mjs +16 -0
  68. package/src/domain/rules/install-units.mjs +95 -0
  69. package/src/domain/rules/skills.mjs +105 -0
  70. package/src/domain/rules/workflows.mjs +105 -0
  71. package/src/domain/skill-state-matrix.mjs +79 -0
  72. package/src/domain/source-classes.mjs +99 -0
  73. package/src/hook-plan.mjs +152 -0
  74. package/src/impact.mjs +41 -0
  75. package/src/index.mjs +82 -0
  76. package/src/init.mjs +136 -0
  77. package/src/install-output-detector.mjs +337 -0
  78. package/src/install-units.mjs +62 -0
  79. package/src/inventory-refresh.mjs +45 -0
  80. package/src/lifecycle-cli.mjs +166 -0
  81. package/src/lifecycle-content.mjs +87 -0
  82. package/src/policy.mjs +42 -0
  83. package/src/reconcile.mjs +111 -0
  84. package/src/report.mjs +151 -0
  85. package/src/review.mjs +88 -0
  86. package/src/rollout.mjs +453 -0
  87. package/src/skill-paths.mjs +17 -0
  88. package/src/source-cache.mjs +178 -0
  89. package/src/source-profile-loader.mjs +160 -0
  90. package/src/source-profiles.mjs +299 -0
  91. package/src/source-verification.mjs +252 -0
  92. package/src/uninstall.mjs +258 -0
  93. package/src/workspace.mjs +219 -0
  94. package/tsconfig.lsp.json +15 -0
@@ -0,0 +1,95 @@
1
+ import { RESERVED_SOURCE_CLASSES, hasRuntimeComponents, isModelSelectableInvocation, isUserControlledSource } from "../source-classes.mjs";
2
+
3
+ export const installUnitRules = [
4
+ {
5
+ id: "UNIT-COMPONENT-001",
6
+ check(ctx) {
7
+ const diagnostics = [];
8
+ for (const unit of ctx.installUnits) {
9
+ if (unit.sourceClass !== undefined && RESERVED_SOURCE_CLASSES.has(unit.sourceClass) && !isUserControlledSource(unit)) {
10
+ diagnostics.push(`Install unit ${unit.id} uses reserved source_class: ${unit.sourceClass}.`);
11
+ }
12
+ diagnostics.push(...validateProvidedComponents(unit));
13
+ for (const skillId of unit.components.skills) {
14
+ const skill = ctx.skillsById.get(skillId);
15
+ if (skill === undefined) {
16
+ diagnostics.push(`Install unit ${unit.id} declares undeclared component skill: ${skillId}`);
17
+ } else if (skill.ownerInstallUnit !== undefined && skill.ownerInstallUnit !== unit.id) {
18
+ diagnostics.push(`Install unit ${unit.id} declares component skill ${skillId} owned by ${skill.ownerInstallUnit}.`);
19
+ }
20
+ }
21
+ }
22
+ return diagnostics;
23
+ }
24
+ },
25
+ {
26
+ id: "UNIT-REF-001",
27
+ check(ctx) {
28
+ const diagnostics = [];
29
+ for (const unit of ctx.installUnits) {
30
+ for (const workflow of unit.workflowDependencies) {
31
+ if (!ctx.workflowsByName.has(workflow)) {
32
+ diagnostics.push(`Install unit ${unit.id} references undeclared workflow dependency: ${workflow}`);
33
+ }
34
+ }
35
+ }
36
+ return diagnostics;
37
+ }
38
+ },
39
+ {
40
+ id: "UNIT-TRUST-001",
41
+ check(ctx) {
42
+ const diagnostics = [];
43
+ for (const unit of ctx.installUnits) {
44
+ if (unit.enabled && unit.trustLevel === "blocked") {
45
+ diagnostics.push(`Install unit ${unit.id} is enabled but trust_level is blocked.`);
46
+ }
47
+ const automaticSkills = unit.components.skills
48
+ .map((skillId) => ctx.skillsById.get(skillId))
49
+ .filter((skill) => skill !== undefined && isModelSelectableInvocation(skill.invocation));
50
+ if (unit.enabled && !isUserControlledSource(unit) && unit.trustLevel === "unreviewed" && automaticSkills.length > 0) {
51
+ diagnostics.push({
52
+ severity: "warning",
53
+ message: `Install unit ${unit.id} is unreviewed but owns model-selectable skills: ${automaticSkills.map((skill) => skill.id).join(", ")}.`
54
+ });
55
+ }
56
+ if (unit.enabled && unit.permissionRisk === "high" && !["trusted", "reviewed"].includes(unit.trustLevel)) {
57
+ diagnostics.push({
58
+ severity: "warning",
59
+ message: `Install unit ${unit.id} is high risk but not reviewed or trusted.`
60
+ });
61
+ }
62
+ if (unit.enabled && hasRuntimeComponents(unit) && unit.trustLevel === "unreviewed") {
63
+ diagnostics.push({
64
+ severity: "warning",
65
+ message: `Install unit ${unit.id} provides runtime components but is unreviewed.`
66
+ });
67
+ }
68
+ }
69
+ return diagnostics;
70
+ }
71
+ }
72
+ ];
73
+
74
+ function validateProvidedComponents(unit) {
75
+ return [
76
+ ...validateComponentGroup(unit, "skills", unit.components.skills, ["skills", "skill"]),
77
+ ...validateComponentGroup(unit, "commands", unit.components.commands, ["commands", "command"]),
78
+ ...validateComponentGroup(unit, "hooks", unit.components.hooks, ["hooks", "hook"]),
79
+ ...validateComponentGroup(unit, "mcp_servers", unit.components.mcpServers, ["mcp-server", "mcp-servers", "mcp_server", "mcp_servers"])
80
+ ];
81
+ }
82
+
83
+ function validateComponentGroup(unit, label, values, aliases) {
84
+ const provides = aliases.some((alias) => unit.providedComponents.includes(alias));
85
+ if (values.length > 0 && !provides) {
86
+ return [`Install unit ${unit.id} lists ${label} but does not include ${aliases[0]} in provided_components.`];
87
+ }
88
+ if (values.length === 0 && provides) {
89
+ return [{
90
+ severity: "warning",
91
+ message: `Install unit ${unit.id} declares ${aliases[0]} in provided_components but lists no ${label}.`
92
+ }];
93
+ }
94
+ return [];
95
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ LEGACY_STATUSES,
3
+ TERMINAL_STATUSES,
4
+ validateSkillState
5
+ } from "../skill-state-matrix.mjs";
6
+
7
+ export const skillRules = [
8
+ {
9
+ id: "SKILL-STATUS-001",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const skill of ctx.skills) {
13
+ diagnostics.push(...validateStatusInvocation(skill));
14
+ }
15
+ return diagnostics;
16
+ }
17
+ },
18
+ {
19
+ id: "SKILL-SCOPE-001",
20
+ check(ctx) {
21
+ const diagnostics = [];
22
+ for (const skill of ctx.skills) {
23
+ if (ctx.defaults.requireExplicitWorkflow && skill.invocation === "workflow-auto" && !ctx.workflowScopedSkillIds.has(skill.id)) {
24
+ diagnostics.push(`Skill ${skill.id} uses workflow-auto but is not scoped to any workflow.`);
25
+ }
26
+ }
27
+ return diagnostics;
28
+ }
29
+ },
30
+ {
31
+ id: "SKILL-GLOBAL-001",
32
+ check(ctx) {
33
+ const diagnostics = [];
34
+ for (const skill of ctx.skills) {
35
+ if (skill.invocation !== "global-auto") {
36
+ continue;
37
+ }
38
+ diagnostics.push({
39
+ severity: "warning",
40
+ message: `Skill ${skill.id} is global-auto; prefer workflow-auto or router-only.`
41
+ });
42
+ if (skill.exposure !== "global-meta") {
43
+ diagnostics.push(`Skill ${skill.id} uses global-auto but is not exposure: global-meta.`);
44
+ }
45
+ }
46
+ return diagnostics;
47
+ }
48
+ },
49
+ {
50
+ id: "SKILL-REF-001",
51
+ check(ctx) {
52
+ const diagnostics = [];
53
+ for (const skill of ctx.skills) {
54
+ if (skill.replacedBy !== undefined && !ctx.skillsById.has(skill.replacedBy)) {
55
+ diagnostics.push(`Skill ${skill.id} replaced_by points to undeclared skill: ${skill.replacedBy}`);
56
+ }
57
+ for (const capabilityName of skill.canonicalFor) {
58
+ const capability = ctx.capabilitiesByName.get(capabilityName);
59
+ if (capability === undefined) {
60
+ diagnostics.push(`Skill ${skill.id} canonical_for references undeclared capability: ${capabilityName}`);
61
+ } else if (capability.canonical !== skill.id) {
62
+ diagnostics.push(`Skill ${skill.id} claims canonical_for ${capabilityName} but capability canonical is ${capability.canonical || "none"}.`);
63
+ }
64
+ }
65
+ for (const skillId of skill.conflictsWith) {
66
+ if (!ctx.skillsById.has(skillId)) {
67
+ diagnostics.push(`Skill ${skill.id} conflicts_with undeclared skill: ${skillId}`);
68
+ }
69
+ }
70
+ }
71
+ return diagnostics;
72
+ }
73
+ },
74
+ {
75
+ id: "SKILL-OWNER-001",
76
+ check(ctx) {
77
+ const diagnostics = [];
78
+ for (const skill of ctx.skills) {
79
+ if (skill.ownerInstallUnit !== undefined && !ctx.installUnitsById.has(skill.ownerInstallUnit)) {
80
+ diagnostics.push(`Skill ${skill.id} owner_install_unit points to undeclared install unit: ${skill.ownerInstallUnit}`);
81
+ }
82
+ if (skill.exposure === "unit-managed" && skill.ownerInstallUnit === undefined) {
83
+ diagnostics.push(`Skill ${skill.id} is unit-managed but does not declare owner_install_unit.`);
84
+ }
85
+ const owner = skill.ownerInstallUnit === undefined ? undefined : ctx.installUnitsById.get(skill.ownerInstallUnit);
86
+ if (owner !== undefined && !owner.components.skills.includes(skill.id)) {
87
+ diagnostics.push(`Skill ${skill.id} declares owner_install_unit ${owner.id} but is not listed in its component skills.`);
88
+ }
89
+ }
90
+ return diagnostics;
91
+ }
92
+ }
93
+ ];
94
+
95
+ function validateStatusInvocation(skill) {
96
+ const diagnostics = [];
97
+ const result = validateSkillState(skill.status, skill.invocation, skill.id);
98
+ if (result !== null) {
99
+ diagnostics.push(result);
100
+ }
101
+ if (["active", "canonical"].includes(skill.status) && TERMINAL_STATUSES.has(skill.invocation)) {
102
+ diagnostics.push(`Active skill ${skill.id} cannot use invocation: ${skill.invocation}.`);
103
+ }
104
+ return diagnostics;
105
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ INVOCATION_VALUES,
3
+ NON_CALLABLE_WORKFLOW_INVOCATIONS,
4
+ NON_CALLABLE_WORKFLOW_STATUSES
5
+ } from "../constants.mjs";
6
+
7
+ export const workflowRules = [
8
+ {
9
+ id: "WF-REF-001",
10
+ check(ctx) {
11
+ const diagnostics = [];
12
+ for (const workflow of ctx.workflows) {
13
+ if (workflow.harness !== "unspecified" && !ctx.harnessesByName.has(workflow.harness)) {
14
+ diagnostics.push(`Workflow ${workflow.name} references undeclared harness: ${workflow.harness}`);
15
+ }
16
+ for (const skillId of workflow.activeSkills) {
17
+ if (!ctx.skillsById.has(skillId)) {
18
+ diagnostics.push(`Workflow ${workflow.name} references undeclared active skill: ${skillId}`);
19
+ }
20
+ }
21
+ for (const skillId of workflow.blockedSkills) {
22
+ if (!ctx.skillsById.has(skillId)) {
23
+ diagnostics.push(`Workflow ${workflow.name} references undeclared blocked skill: ${skillId}`);
24
+ }
25
+ }
26
+ for (const capability of workflow.requiredCapabilities) {
27
+ if (!ctx.capabilitiesByName.has(capability.name)) {
28
+ diagnostics.push(`Workflow ${workflow.name} references undeclared required capability: ${capability.name}`);
29
+ }
30
+ if (capability.preferred !== "" && !ctx.skillsById.has(capability.preferred)) {
31
+ diagnostics.push(`Capability requirement ${capability.name} in workflow ${workflow.name} references undeclared preferred skill: ${capability.preferred}`);
32
+ }
33
+ for (const skillId of capability.fallback) {
34
+ if (!ctx.skillsById.has(skillId)) {
35
+ diagnostics.push(`Capability requirement ${capability.name} in workflow ${workflow.name} references undeclared fallback skill: ${skillId}`);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ return diagnostics;
41
+ }
42
+ },
43
+ {
44
+ id: "WF-ACTIVE-001",
45
+ check(ctx) {
46
+ const diagnostics = [];
47
+ for (const workflow of ctx.workflows) {
48
+ const blocked = new Set(workflow.blockedSkills);
49
+ for (const skillId of workflow.activeSkills) {
50
+ const skill = ctx.skillsById.get(skillId);
51
+ if (skill === undefined) {
52
+ continue;
53
+ }
54
+ if (blocked.has(skillId)) {
55
+ diagnostics.push(`Workflow ${workflow.name} lists skill ${skillId} as both active and blocked.`);
56
+ }
57
+ diagnostics.push(...nonCallableDiagnostics(`Workflow ${workflow.name} activates`, skill));
58
+ }
59
+ }
60
+ return diagnostics;
61
+ }
62
+ },
63
+ {
64
+ id: "WF-CAPABILITY-001",
65
+ check(ctx) {
66
+ const diagnostics = [];
67
+ for (const workflow of ctx.workflows) {
68
+ const blocked = new Set(workflow.blockedSkills);
69
+ for (const capability of workflow.requiredCapabilities) {
70
+ if (!INVOCATION_VALUES.has(capability.policy)) {
71
+ diagnostics.push(`Capability requirement ${capability.name} in workflow ${workflow.name} has unsupported policy: ${capability.policy}`);
72
+ }
73
+ if (capability.preferred !== "" && blocked.has(capability.preferred)) {
74
+ diagnostics.push(`Capability requirement ${capability.name} in workflow ${workflow.name} prefers blocked skill: ${capability.preferred}`);
75
+ }
76
+ for (const skillId of capability.fallback) {
77
+ if (blocked.has(skillId)) {
78
+ diagnostics.push(`Capability requirement ${capability.name} in workflow ${workflow.name} lists blocked fallback skill: ${skillId}`);
79
+ }
80
+ const fallback = ctx.skillsById.get(skillId);
81
+ if (fallback !== undefined) {
82
+ diagnostics.push(...nonCallableDiagnostics(`Capability requirement ${capability.name} in workflow ${workflow.name} lists fallback`, fallback));
83
+ }
84
+ }
85
+ const preferred = capability.preferred === "" ? undefined : ctx.skillsById.get(capability.preferred);
86
+ if (preferred !== undefined) {
87
+ diagnostics.push(...nonCallableDiagnostics(`Capability requirement ${capability.name} in workflow ${workflow.name} prefers`, preferred));
88
+ }
89
+ }
90
+ }
91
+ return diagnostics;
92
+ }
93
+ }
94
+ ];
95
+
96
+ function nonCallableDiagnostics(prefix, skill) {
97
+ const diagnostics = [];
98
+ if (NON_CALLABLE_WORKFLOW_STATUSES.has(skill.status)) {
99
+ diagnostics.push(`${prefix} non-callable skill ${skill.id} with status: ${skill.status}.`);
100
+ }
101
+ if (NON_CALLABLE_WORKFLOW_INVOCATIONS.has(skill.invocation)) {
102
+ diagnostics.push(`${prefix} non-callable skill ${skill.id} with invocation: ${skill.invocation}.`);
103
+ }
104
+ return diagnostics;
105
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Status/invocation state matrix for skill lifecycle.
3
+ *
4
+ * SkillBoard allows 14 statuses and 7 invocations, but only a subset
5
+ * of the 98 combinations is meaningful. This module makes the allowed
6
+ * combinations explicit and provides helpers for policy checks.
7
+ */
8
+
9
+ /**
10
+ * Allowed status -> invocation combinations.
11
+ * A status maps to one or more valid invocations.
12
+ * Any combination not listed here is rejected by policy.
13
+ */
14
+ export const SKILL_STATE_MATRIX = {
15
+ discovered: ["manual-only", "blocked"],
16
+ quarantined: ["blocked"],
17
+ vendor: ["manual-only"],
18
+ candidate: ["manual-only", "router-only", "workflow-auto"],
19
+ active: ["manual-only", "router-only", "workflow-auto", "global-auto"],
20
+ "active-manual": ["manual-only"],
21
+ "active-router": ["router-only"],
22
+ "active-auto": ["workflow-auto"],
23
+ canonical: ["workflow-auto", "global-auto"],
24
+ blocked: ["blocked"],
25
+ deprecated: ["blocked", "deprecated"],
26
+ archived: ["blocked", "deprecated"],
27
+ removed: ["blocked", "deprecated"]
28
+ };
29
+
30
+ /**
31
+ * Statuses that exist only for backward compatibility or external import.
32
+ * They are still accepted but produce a warning because their semantics
33
+ * overlap with explicit invocation values.
34
+ */
35
+ export const LEGACY_STATUSES = new Set([
36
+ "active-manual",
37
+ "active-router",
38
+ "active-auto",
39
+ "archived",
40
+ "removed"
41
+ ]);
42
+
43
+ /**
44
+ * Statuses that imply a skill is no longer selectable in any workflow.
45
+ */
46
+ export const TERMINAL_STATUSES = new Set(["blocked", "deprecated", "archived", "removed"]);
47
+
48
+ /**
49
+ * Return true if the given status/invocation combination is allowed.
50
+ */
51
+ export function isValidSkillState(status, invocation) {
52
+ const allowed = SKILL_STATE_MATRIX[status];
53
+ if (allowed === undefined) {
54
+ return false;
55
+ }
56
+ return allowed.includes(invocation);
57
+ }
58
+
59
+ /**
60
+ * Return a diagnostic message if the combination is disallowed, otherwise null.
61
+ */
62
+ export function validateSkillState(status, invocation, skillId = "") {
63
+ if (!isValidSkillState(status, invocation)) {
64
+ const allowed = SKILL_STATE_MATRIX[status] ?? [];
65
+ const prefix = skillId ? `Skill ${skillId}` : "Skill";
66
+ if (allowed.length === 0) {
67
+ return `${prefix} has unsupported status: ${status}.`;
68
+ }
69
+ return `${prefix} with status ${status} must use one of: ${allowed.join(", ")}; got ${invocation}.`;
70
+ }
71
+ if (LEGACY_STATUSES.has(status)) {
72
+ const prefix = skillId ? `Skill ${skillId}` : "Skill";
73
+ return {
74
+ severity: "warning",
75
+ message: `${prefix} uses legacy status ${status}; prefer status "active" with an explicit invocation.`
76
+ };
77
+ }
78
+ return null;
79
+ }
@@ -0,0 +1,99 @@
1
+ import { isAbsolute } from "node:path";
2
+
3
+ export function installUnitSourceClass(unit) {
4
+ if (unit.sourceClass !== undefined && unit.sourceClass.length > 0 && !RESERVED_SOURCE_CLASSES.has(unit.sourceClass)) {
5
+ return unit.sourceClass;
6
+ }
7
+ if (unit.kind === "harness") {
8
+ return "harness-bundle";
9
+ }
10
+ if (unit.kind === "workflow") {
11
+ return "workflow-bundle";
12
+ }
13
+ if (unit.kind === "plugin" || unit.kind === "marketplace") {
14
+ return "external-package";
15
+ }
16
+ if (unit.kind === "package-manager-dependency") {
17
+ return "package-manager";
18
+ }
19
+ if (["mcp-server", "hook", "agent", "lsp"].includes(unit.kind)) {
20
+ return "runtime-extension";
21
+ }
22
+ if (isUserControlledSource(unit)) {
23
+ return "user";
24
+ }
25
+ if (unit.kind === "skill") {
26
+ return "skill-pack";
27
+ }
28
+ return "unknown";
29
+ }
30
+
31
+ export function sourcePriority(sourceClass) {
32
+ if (sourceClass === "user") {
33
+ return 100;
34
+ }
35
+ if (sourceClass === "skill-pack") {
36
+ return 70;
37
+ }
38
+ if (sourceClass === "workflow-bundle") {
39
+ return 65;
40
+ }
41
+ if (sourceClass === "harness-bundle") {
42
+ return 60;
43
+ }
44
+ if (sourceClass === "runtime-extension") {
45
+ return 55;
46
+ }
47
+ if (sourceClass === "package-manager") {
48
+ return 50;
49
+ }
50
+ if (sourceClass === "external-package") {
51
+ return 40;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ export function installUnitPriority(unit) {
57
+ if (unit.priority !== null && unit.priority !== undefined) {
58
+ return unit.priority;
59
+ }
60
+ return sourcePriority(installUnitSourceClass(unit));
61
+ }
62
+
63
+ export function isUserControlledSource(unit) {
64
+ return unit.kind === "skill" && isLocalSourceReference(unit.source);
65
+ }
66
+
67
+ export function isLocalSourceReference(source, options = {}) {
68
+ const value = source.trim();
69
+ if (value.length === 0 || isCommandSourceReference(value)) {
70
+ return false;
71
+ }
72
+ if (value.startsWith("~/") || value.startsWith("./") || value.startsWith("../") || value === "." || value === "..") {
73
+ return true;
74
+ }
75
+ if (isAbsolute(value)) {
76
+ return true;
77
+ }
78
+ return options.allowBareRelative === true && !value.includes("://");
79
+ }
80
+
81
+ export function isCommandSourceReference(source) {
82
+ const value = source.trim();
83
+ return /^(?:npx|npm|pnpm|yarn|bunx|git|gh|curl|wget|uvx|pipx)\s+/u.test(value)
84
+ || /^\/[A-Za-z][\w-]*\s+/u.test(value);
85
+ }
86
+
87
+ export function hasRuntimeComponents(unit) {
88
+ const components = unit.components ?? {};
89
+ return ["mcp-server", "hook", "agent", "lsp"].includes(unit.kind)
90
+ || components.commands?.length > 0
91
+ || components.hooks?.length > 0
92
+ || components.mcpServers?.length > 0;
93
+ }
94
+
95
+ export function isModelSelectableInvocation(invocation) {
96
+ return invocation === "router-only" || invocation === "workflow-auto" || invocation === "global-auto";
97
+ }
98
+
99
+ export const RESERVED_SOURCE_CLASSES = new Set(["user"]);
@@ -0,0 +1,152 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { loadWorkspace } from "./workspace.mjs";
4
+
5
+ export const GUARD_HOOK_MODE = 0o755;
6
+
7
+ export async function planGuardHookInstall(options) {
8
+ const { plan } = await buildGuardHookInstallPlan(options);
9
+ return plan;
10
+ }
11
+
12
+ export async function buildGuardHookInstallPlan(options) {
13
+ const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
14
+ requireWorkflow(workspace, options.workflow);
15
+ const out = options.out ?? join(dirname(options.configPath), ".skillboard", "hooks", `skillboard-guard-${safeHookFilePart(options.workflow)}.sh`);
16
+ const command = options.command ?? "skillboard";
17
+ const skillsRoot = options.skillsRoot ?? "skills";
18
+ const script = renderGuardHookScript({
19
+ command,
20
+ workflow: options.workflow,
21
+ configPath: options.configPath,
22
+ skillsRoot
23
+ });
24
+ const target = await inspectHookTarget(out);
25
+ const plannedMode = modeToOctal(GUARD_HOOK_MODE);
26
+ const plan = {
27
+ path: out,
28
+ workflow: options.workflow,
29
+ command,
30
+ target_exists: target.exists,
31
+ target_type: target.type,
32
+ planned_mode: plannedMode,
33
+ permissions: modeToPermissions(GUARD_HOOK_MODE),
34
+ would_be_executable: (GUARD_HOOK_MODE & 0o111) !== 0,
35
+ preview: {
36
+ display: `Install executable guard hook for workflow ${options.workflow} at ${out}`,
37
+ shell: renderGuardHookInstallShellPreview({ path: out, mode: plannedMode, script })
38
+ }
39
+ };
40
+
41
+ return { plan, script };
42
+ }
43
+
44
+ export function assertGuardHookPlanIsInstallable(plan) {
45
+ if (plan.target_exists) {
46
+ throw new Error(`Refusing to overwrite existing hook path: ${plan.path}`);
47
+ }
48
+ }
49
+
50
+ function requireWorkflow(workspace, name) {
51
+ const workflow = workspace.workflows.find((candidate) => candidate.name === name);
52
+ if (workflow === undefined) {
53
+ throw new Error(`Unknown workflow: ${name}`);
54
+ }
55
+ }
56
+
57
+ function renderGuardHookScript(options) {
58
+ return `#!/usr/bin/env sh
59
+ set -eu
60
+
61
+ SKILLBOARD_BIN=\${SKILLBOARD_BIN:-${shellQuote(options.command)}}
62
+ SKILLBOARD_CONFIG=\${SKILLBOARD_CONFIG:-${shellQuote(options.configPath)}}
63
+ SKILLBOARD_SKILLS=\${SKILLBOARD_SKILLS:-${shellQuote(options.skillsRoot)}}
64
+ SKILLBOARD_WORKFLOW=\${SKILLBOARD_WORKFLOW:-${shellQuote(options.workflow)}}
65
+
66
+ if [ "\${SKILLBOARD_SKILL_ID:-}" != "" ]; then
67
+ skill_id="$SKILLBOARD_SKILL_ID"
68
+ elif [ "\${1:-}" != "" ]; then
69
+ skill_id="$1"
70
+ else
71
+ echo "SKILLBOARD_SKILL_ID or first argument is required" >&2
72
+ exit 64
73
+ fi
74
+
75
+ # Split SKILLBOARD_BIN so local hook configs can use commands like:
76
+ # SKILLBOARD_BIN="node bin/skillboard.mjs"
77
+ # Paths containing spaces should be provided through an environment wrapper.
78
+ set -- $SKILLBOARD_BIN
79
+ exec "$@" guard use "$skill_id" --workflow "$SKILLBOARD_WORKFLOW" --config "$SKILLBOARD_CONFIG" --skills "$SKILLBOARD_SKILLS"
80
+ `;
81
+ }
82
+
83
+ function renderGuardHookInstallShellPreview(options) {
84
+ const delimiter = uniqueHeredocDelimiter(options.script);
85
+ return [
86
+ `mkdir -p ${shellQuote(dirname(options.path))}`,
87
+ `cat > ${shellQuote(options.path)} <<'${delimiter}'`,
88
+ options.script,
89
+ delimiter,
90
+ `chmod ${options.mode} ${shellQuote(options.path)}`
91
+ ].join("\n");
92
+ }
93
+
94
+ function uniqueHeredocDelimiter(body) {
95
+ const usedLines = new Set(body.split(/\r?\n/u));
96
+ const base = "SKILLBOARD_GUARD_HOOK";
97
+ if (!usedLines.has(base)) {
98
+ return base;
99
+ }
100
+ for (let index = 1; ; index += 1) {
101
+ const candidate = `${base}_${index}`;
102
+ if (!usedLines.has(candidate)) {
103
+ return candidate;
104
+ }
105
+ }
106
+ }
107
+
108
+ async function inspectHookTarget(path) {
109
+ const existing = await lstat(path).catch((error) => {
110
+ if (error?.code === "ENOENT") {
111
+ return undefined;
112
+ }
113
+ throw error;
114
+ });
115
+ if (existing === undefined) {
116
+ return { exists: false, type: "missing" };
117
+ }
118
+ if (existing.isSymbolicLink()) {
119
+ return { exists: true, type: "symlink" };
120
+ }
121
+ if (existing.isFile()) {
122
+ return { exists: true, type: "file" };
123
+ }
124
+ if (existing.isDirectory()) {
125
+ return { exists: true, type: "directory" };
126
+ }
127
+ return { exists: true, type: "other" };
128
+ }
129
+
130
+ function modeToOctal(mode) {
131
+ return mode.toString(8).padStart(4, "0");
132
+ }
133
+
134
+ function modeToPermissions(mode) {
135
+ const triplets = [
136
+ [0o400, 0o200, 0o100],
137
+ [0o040, 0o020, 0o010],
138
+ [0o004, 0o002, 0o001]
139
+ ];
140
+ return triplets.map(([read, write, execute]) => {
141
+ return `${mode & read ? "r" : "-"}${mode & write ? "w" : "-"}${mode & execute ? "x" : "-"}`;
142
+ }).join("");
143
+ }
144
+
145
+ function shellQuote(value) {
146
+ return `'${value.replaceAll("'", "'\\''")}'`;
147
+ }
148
+
149
+ function safeHookFilePart(value) {
150
+ const cleaned = value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
151
+ return cleaned.length === 0 ? "workflow" : cleaned;
152
+ }
package/src/impact.mjs ADDED
@@ -0,0 +1,41 @@
1
+ export function impactDisable(workspace, skillId) {
2
+ const affectedWorkflowEntries = workspace.workflows
3
+ .filter((workflow) => workflow.activeSkills.includes(skillId) || workflow.requiredCapabilities.some((capability) => {
4
+ return capability.preferred === skillId || capability.fallback.includes(skillId);
5
+ }));
6
+ const affectedWorkflows = affectedWorkflowEntries.map((workflow) => workflow.name);
7
+ const affectedOutputs = [...new Set(affectedWorkflowEntries.flatMap((workflow) => workflow.requiredOutputs))];
8
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
9
+ const alternatives = alternativesForSkill(workspace, skillId, skill);
10
+ return {
11
+ skillId,
12
+ exists: skill !== undefined,
13
+ affectedWorkflows,
14
+ affectedOutputs,
15
+ alternatives,
16
+ risk: riskFor(skill, affectedWorkflows, alternatives)
17
+ };
18
+ }
19
+
20
+ function alternativesForSkill(workspace, skillId, skill) {
21
+ if (skill?.replacedBy !== undefined) {
22
+ return [skill.replacedBy];
23
+ }
24
+ const capability = workspace.capabilities.find((candidate) => {
25
+ return candidate.canonical === skillId || candidate.alternatives.includes(skillId);
26
+ });
27
+ if (capability === undefined) {
28
+ return [];
29
+ }
30
+ return [capability.canonical, ...capability.alternatives].filter((candidate) => candidate !== skillId);
31
+ }
32
+
33
+ function riskFor(skill, affectedWorkflows, alternatives) {
34
+ if (skill === undefined) {
35
+ return "unknown";
36
+ }
37
+ if (affectedWorkflows.length === 0) {
38
+ return "low";
39
+ }
40
+ return alternatives.length === 0 ? "high" : "medium";
41
+ }