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,112 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { uninstallProject } from "../uninstall.mjs";
3
+ import { sortedStrings } from "./sort.mjs";
4
+
5
+ const SCHEMA_VERSION = 1;
6
+ const EMPTY_SKILL_GROUPS = Object.freeze({
7
+ automatic_allowed: Object.freeze([]),
8
+ manual_allowed: Object.freeze([]),
9
+ needs_review: Object.freeze([]),
10
+ blocked: Object.freeze([]),
11
+ not_in_workflow: Object.freeze([]),
12
+ installed_only: Object.freeze([])
13
+ });
14
+
15
+ export function buildBrief(data) {
16
+ const brief = {
17
+ ok: data.ok,
18
+ schema_version: SCHEMA_VERSION
19
+ };
20
+ if (data.error !== undefined) {
21
+ brief.error = data.error;
22
+ }
23
+ brief.health = data.health;
24
+ brief.workflow = data.workflow;
25
+ brief.skills = data.skills;
26
+ brief.sources = data.sources;
27
+ brief.review_queue = data.reviewQueue;
28
+ brief.cleanup = data.cleanup;
29
+ if (data.actions !== undefined) {
30
+ brief.actions = data.actions;
31
+ }
32
+ return brief;
33
+ }
34
+
35
+ export function resolveProjectPaths(options) {
36
+ const root = resolve(options.root ?? ".");
37
+ return {
38
+ root,
39
+ configPath: resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml"),
40
+ skillsRoot: resolveUnderRoot(root, options.skillsRoot ?? "skills")
41
+ };
42
+ }
43
+
44
+ export async function buildCleanup(root) {
45
+ const conservative = await uninstallProject({
46
+ root,
47
+ dryRun: true,
48
+ removeConfig: false,
49
+ removeEmptyDirs: true
50
+ });
51
+ const fullReset = await uninstallProject({
52
+ root,
53
+ dryRun: true,
54
+ resetConfig: true,
55
+ removeReports: true,
56
+ removeHooks: true,
57
+ removeEmptyDirs: true
58
+ });
59
+ return {
60
+ conservative,
61
+ full_reset: fullReset
62
+ };
63
+ }
64
+
65
+ export function healthFromDoctor(doctor, paths) {
66
+ return {
67
+ mode: doctor.mode,
68
+ review_required: doctor.reviewRequired,
69
+ strict_ok: doctor.strictOk,
70
+ initialized: doctor.initialized,
71
+ root: paths.root,
72
+ config_path: paths.configPath,
73
+ skills_root: paths.skillsRoot,
74
+ config: {
75
+ exists: doctor.config.exists,
76
+ valid: doctor.config.valid,
77
+ version: doctor.config.version,
78
+ error: doctor.config.error
79
+ },
80
+ policy: {
81
+ ok: doctor.policy.ok,
82
+ errors: sortedStrings(doctor.policy.errors),
83
+ warnings: sortedStrings(doctor.policy.warnings)
84
+ }
85
+ };
86
+ }
87
+
88
+ export function emptyWorkflowState() {
89
+ return {
90
+ selected: null,
91
+ defaulted: false,
92
+ needs_selection: false,
93
+ candidates: [],
94
+ unknown: false,
95
+ blocked_reason: null
96
+ };
97
+ }
98
+
99
+ export function emptySkillGroups() {
100
+ return {
101
+ automatic_allowed: [...EMPTY_SKILL_GROUPS.automatic_allowed],
102
+ manual_allowed: [...EMPTY_SKILL_GROUPS.manual_allowed],
103
+ needs_review: [...EMPTY_SKILL_GROUPS.needs_review],
104
+ blocked: [...EMPTY_SKILL_GROUPS.blocked],
105
+ not_in_workflow: [...EMPTY_SKILL_GROUPS.not_in_workflow],
106
+ installed_only: [...EMPTY_SKILL_GROUPS.installed_only]
107
+ };
108
+ }
109
+
110
+ function resolveUnderRoot(root, path) {
111
+ return isAbsolute(path) ? path : resolve(root, path);
112
+ }
@@ -0,0 +1,42 @@
1
+ import { command, makeAction } from "./action-core.mjs";
2
+
3
+ export function buildSetupGuidanceActions({ paths, workflow, skills, reviewQueue, workspace }) {
4
+ if (!isInitializedEmptyWorkspace(workflow, skills, reviewQueue, workspace)) {
5
+ return [];
6
+ }
7
+ return [makeAction({
8
+ kind: "setup-guidance",
9
+ targetId: paths.root,
10
+ label: "Start setup by refreshing inventory",
11
+ reason: "This initialized project has no usable skills or workflows yet. Discover installed agent assets, then add a harness and workflow when ready.",
12
+ risk: "low",
13
+ dryRun: command([
14
+ "skillboard", "inventory", "refresh",
15
+ "--dir", paths.root,
16
+ "--config", paths.configPath,
17
+ "--dry-run",
18
+ "--json"
19
+ ]),
20
+ apply: null,
21
+ appliesTo: { kind: "project", id: paths.root },
22
+ blockedReason: null,
23
+ advanced: { root: paths.root, config_path: paths.configPath }
24
+ })];
25
+ }
26
+
27
+ function isInitializedEmptyWorkspace(workflow, skills, reviewQueue, workspace) {
28
+ return workflow.selected === null
29
+ && workflow.needs_selection === false
30
+ && workflow.unknown === false
31
+ && arrayEmpty(workflow.candidates)
32
+ && Object.values(skills).every(arrayEmpty)
33
+ && arrayEmpty(reviewQueue)
34
+ && arrayEmpty(workspace.skills)
35
+ && arrayEmpty(workspace.workflows)
36
+ && arrayEmpty(workspace.harnesses)
37
+ && arrayEmpty(workspace.installUnits);
38
+ }
39
+
40
+ function arrayEmpty(value) {
41
+ return Array.isArray(value) && value.length === 0;
42
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ canUseSkill,
3
+ explainSkill,
4
+ listSkills
5
+ } from "../control.mjs";
6
+ import { emptySkillGroups } from "./schema.mjs";
7
+ import { sortById } from "./sort.mjs";
8
+
9
+ export function skillsWithoutWorkflow(workspace) {
10
+ const groups = emptySkillGroups();
11
+ groups.installed_only = installedOnlyEntries(workspace);
12
+ return sortSkillGroups(groups);
13
+ }
14
+
15
+ export function skillsForWorkflow(workspace, workflowName, sourceAudit = { units: [] }) {
16
+ if (workflowName === null) {
17
+ const groups = emptySkillGroups();
18
+ groups.installed_only = installedOnlyEntries(workspace);
19
+ return sortSkillGroups(groups);
20
+ }
21
+
22
+ const groups = emptySkillGroups();
23
+ const sourceFindings = findingsBySource(sourceAudit);
24
+ for (const summary of listSkills(workspace)) {
25
+ const skill = workspace.skills.find((candidate) => candidate.id === summary.id);
26
+ const explanation = explainSkill(workspace, summary.id);
27
+ const use = canUseSkill(workspace, summary.id, workflowName);
28
+ const group = groupForDeclaredSkill(workspace, skill, use, sourceFindings);
29
+ const entry = declaredSkillEntry(summary, explanation, use, group);
30
+ if (use.allowed) {
31
+ if (use.automaticAllowed) {
32
+ groups.automatic_allowed.push(entry);
33
+ } else {
34
+ groups.manual_allowed.push(entry);
35
+ }
36
+ } else {
37
+ groups[group].push(entry);
38
+ }
39
+ }
40
+ groups.installed_only = installedOnlyEntries(workspace);
41
+ return sortSkillGroups(groups);
42
+ }
43
+
44
+ function declaredSkillEntry(summary, explanation, use, group) {
45
+ return {
46
+ id: summary.id,
47
+ label: summary.id,
48
+ path: summary.path,
49
+ reason: reasonForGroup(group, use?.reasons ?? []),
50
+ advanced: {
51
+ status: summary.status,
52
+ invocation: summary.invocation,
53
+ exposure: summary.exposure,
54
+ category: summary.category,
55
+ source_class: explanation.source.class,
56
+ owner_install_unit: explanation.source.ownerInstallUnit,
57
+ workflow_roles: use?.roles ?? summary.workflowRoles,
58
+ capability_roles: use?.capabilityRoles ?? [],
59
+ trust: explanation.trust
60
+ }
61
+ };
62
+ }
63
+
64
+ function groupForDeclaredSkill(workspace, skill, use, sourceFindings) {
65
+ if (use.allowed) {
66
+ return use.automaticAllowed ? "automatic_allowed" : "manual_allowed";
67
+ }
68
+ const findings = sourceFindings.get(skill?.ownerInstallUnit) ?? [];
69
+ const hardBlocked = hasHardSafetyBlock(workspace, skill, use.reasons, findings);
70
+ if (isNotInWorkflow(use.reasons)) {
71
+ return hardBlocked ? "blocked" : "not_in_workflow";
72
+ }
73
+ if (!hardBlocked && isReviewableTrustGap(workspace, skill, use.reasons)) {
74
+ return "needs_review";
75
+ }
76
+ return "blocked";
77
+ }
78
+
79
+ function installedOnlyEntries(workspace) {
80
+ const declaredPaths = new Set(workspace.skills.map((skill) => skill.path));
81
+ return workspace.installedSkills
82
+ .filter((skill) => !declaredPaths.has(skill.path))
83
+ .map((skill) => ({
84
+ id: skill.id,
85
+ label: skill.name ?? skill.id,
86
+ path: skill.path,
87
+ reason: "Discovered on disk but not declared in skillboard.config.yaml.",
88
+ advanced: {
89
+ name: skill.name ?? null,
90
+ description: skill.description ?? null
91
+ }
92
+ }))
93
+ .sort(sortById);
94
+ }
95
+
96
+ function reasonForGroup(group, reasons) {
97
+ if (group === "not_in_workflow") {
98
+ return reasons.find(isNotInWorkflowReason) ?? reasons[0] ?? null;
99
+ }
100
+ if (group === "needs_review") {
101
+ return reasons.find(isReviewReason) ?? reasons[0] ?? null;
102
+ }
103
+ if (group === "blocked") {
104
+ return reasons.find(isHardReason) ?? reasons[0] ?? null;
105
+ }
106
+ return reasons[0] ?? null;
107
+ }
108
+
109
+ function isNotInWorkflow(reasons) {
110
+ return reasons.some(isNotInWorkflowReason);
111
+ }
112
+
113
+ function isReviewableTrustGap(workspace, skill, reasons) {
114
+ return hasKnownProvenance(workspace, skill) && reasons.some(isReviewReason);
115
+ }
116
+
117
+ function hasHardSafetyBlock(workspace, skill, reasons, findings) {
118
+ if (hasMissingProvenance(workspace, skill)) {
119
+ return true;
120
+ }
121
+ if (isNonCallableSkill(skill)) {
122
+ return true;
123
+ }
124
+ return reasons.some(isHardReason) || findings.some(isHardFinding);
125
+ }
126
+
127
+ function hasMissingProvenance(workspace, skill) {
128
+ if (skill === undefined) {
129
+ return true;
130
+ }
131
+ if (skill.exposure === "unit-managed" && skill.ownerInstallUnit === undefined) {
132
+ return true;
133
+ }
134
+ return skill.ownerInstallUnit !== undefined
135
+ && !workspace.installUnits.some((unit) => unit.id === skill.ownerInstallUnit);
136
+ }
137
+
138
+ function hasKnownProvenance(workspace, skill) {
139
+ return skill?.ownerInstallUnit !== undefined
140
+ && workspace.installUnits.some((unit) => unit.id === skill.ownerInstallUnit);
141
+ }
142
+
143
+ function isNonCallableSkill(skill) {
144
+ return ["blocked", "quarantined", "deprecated", "archived", "removed"].includes(skill?.status)
145
+ || ["blocked", "deprecated"].includes(skill?.invocation);
146
+ }
147
+
148
+ function findingsBySource(sourceAudit) {
149
+ return new Map((sourceAudit.units ?? []).map((unit) => [unit.id, unit.findings ?? []]));
150
+ }
151
+
152
+ function isNotInWorkflowReason(reason) {
153
+ return reason.includes("is not active, preferred, or fallback in workflow");
154
+ }
155
+
156
+ function isReviewReason(reason) {
157
+ const normalized = reason.toLowerCase();
158
+ return normalized.includes("unreviewed")
159
+ || normalized.includes("not reviewed")
160
+ || normalized.includes("not trusted");
161
+ }
162
+
163
+ function isHardReason(reason) {
164
+ const normalized = reason.toLowerCase();
165
+ return normalized.includes("policy check failed")
166
+ || normalized.includes("unknown skill")
167
+ || normalized.includes("unknown workflow")
168
+ || normalized.includes("blocks skill")
169
+ || normalized.includes("source trust policy")
170
+ || (normalized.includes("install unit") && normalized.includes("disabled"))
171
+ || normalized.includes("non-callable")
172
+ || normalized.includes("global-auto but not global-meta");
173
+ }
174
+
175
+ function isHardFinding(finding) {
176
+ if (isReviewReason(finding.message)) {
177
+ return false;
178
+ }
179
+ return finding.severity === "error";
180
+ }
181
+
182
+ function sortSkillGroups(groups) {
183
+ return {
184
+ automatic_allowed: groups.automatic_allowed.sort(sortById),
185
+ manual_allowed: groups.manual_allowed.sort(sortById),
186
+ needs_review: groups.needs_review.sort(sortById),
187
+ blocked: groups.blocked.sort(sortById),
188
+ not_in_workflow: groups.not_in_workflow.sort(sortById),
189
+ installed_only: groups.installed_only.sort(sortById)
190
+ };
191
+ }
@@ -0,0 +1,15 @@
1
+ export function sortById(left, right) {
2
+ return left.id.localeCompare(right.id);
3
+ }
4
+
5
+ export function sortedStrings(values) {
6
+ return [...values].sort((left, right) => left.localeCompare(right));
7
+ }
8
+
9
+ export function slug(value) {
10
+ return value
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-+|-+$/g, "")
14
+ .slice(0, 80) || "finding";
15
+ }
@@ -0,0 +1,160 @@
1
+ import { recommendTrustLevel } from "./trust-policy.mjs";
2
+ import { sortById, sortedStrings, slug } from "./sort.mjs";
3
+
4
+ export function emptySources() {
5
+ return {
6
+ ok: false,
7
+ errors: [],
8
+ warnings: [],
9
+ units: []
10
+ };
11
+ }
12
+
13
+ export function sourcesFromDoctor(doctor) {
14
+ return {
15
+ ok: doctor.sources.ok,
16
+ errors: sortedStrings(doctor.sources.errors),
17
+ warnings: sortedStrings(doctor.sources.warnings),
18
+ units: []
19
+ };
20
+ }
21
+
22
+ export function summarizeSources(sourceAudit) {
23
+ return {
24
+ ok: sourceAudit.ok,
25
+ errors: sortedStrings(sourceAudit.errors),
26
+ warnings: sortedStrings(sourceAudit.warnings),
27
+ units: sourceAudit.units.map((unit) => ({
28
+ id: unit.id,
29
+ kind: unit.kind,
30
+ enabled: unit.enabled,
31
+ risk: unit.permissionRisk,
32
+ reviewed: ["trusted", "reviewed"].includes(unit.trustLevel),
33
+ findings: unit.findings
34
+ .map((finding) => ({
35
+ severity: finding.severity,
36
+ message: finding.message
37
+ }))
38
+ .sort(sortFinding),
39
+ advanced: {
40
+ source_class: unit.sourceClass,
41
+ trust_level: unit.trustLevel,
42
+ signed: unit.signed,
43
+ pinned: unit.pinned,
44
+ verified_at: unit.verifiedAt,
45
+ automatic_skills: sortedStrings(unit.automaticSkills)
46
+ }
47
+ })).sort(sortById)
48
+ };
49
+ }
50
+
51
+ export function buildReviewQueue(workspace, sourceAudit) {
52
+ const entries = [];
53
+ const installUnitsById = new Map(workspace.installUnits.map((unit) => [unit.id, unit]));
54
+ for (const unit of sourceAudit.units) {
55
+ if (unit.enabled && unit.sourceClass !== "user" && unit.trustLevel === "unreviewed") {
56
+ const recommended = recommendTrustLevel(installUnitsById.get(unit.id) ?? unit);
57
+ entries.push({
58
+ kind: "install_unit",
59
+ id: `install_unit:${unit.id}`,
60
+ label: `Review ${unit.id}`,
61
+ reason: `Source is enabled but not reviewed. Recommended trust level: ${recommended}.`,
62
+ risk: normalizeRisk(unit.permissionRisk),
63
+ action_ids: [],
64
+ advanced: {
65
+ install_unit: unit.id,
66
+ source_class: unit.sourceClass,
67
+ trust_level: unit.trustLevel,
68
+ recommended_trust_level: recommended
69
+ }
70
+ });
71
+ }
72
+ for (const finding of unit.findings) {
73
+ entries.push({
74
+ kind: "source_finding",
75
+ id: sourceFindingId(unit.id, finding),
76
+ label: `Review ${unit.id}`,
77
+ reason: finding.message,
78
+ risk: riskForFinding(finding),
79
+ action_ids: [],
80
+ advanced: {
81
+ source_id: unit.id,
82
+ severity: finding.severity
83
+ }
84
+ });
85
+ }
86
+ }
87
+ for (const skill of workspace.skills) {
88
+ if (skill.status === "quarantined") {
89
+ entries.push({
90
+ kind: "skill",
91
+ id: `skill:${skill.id}`,
92
+ label: `Review ${skill.id}`,
93
+ reason: "Skill is quarantined.",
94
+ risk: "high",
95
+ action_ids: [],
96
+ advanced: {
97
+ skill_id: skill.id,
98
+ status: skill.status,
99
+ owner_install_unit: skill.ownerInstallUnit ?? null
100
+ }
101
+ });
102
+ }
103
+ }
104
+ return entries.sort(sortReviewQueue);
105
+ }
106
+
107
+ function sourceFindingId(sourceId, finding) {
108
+ return `source_finding:${sourceId}:${finding.severity}:${slug(finding.message)}`;
109
+ }
110
+
111
+ function riskForFinding(finding) {
112
+ if (finding.severity === "error") {
113
+ return "high";
114
+ }
115
+ const message = finding.message.toLowerCase();
116
+ if (message.includes("runtime") || message.includes("high-risk")) {
117
+ return "high";
118
+ }
119
+ if (message.includes("not pinned") || message.includes("provenance")) {
120
+ return "medium";
121
+ }
122
+ return "low";
123
+ }
124
+
125
+ function normalizeRisk(value) {
126
+ if (["low", "medium", "high"].includes(value)) {
127
+ return value;
128
+ }
129
+ return "medium";
130
+ }
131
+
132
+ function sortReviewQueue(left, right) {
133
+ return reviewQueueBucket(left) - reviewQueueBucket(right)
134
+ || left.id.localeCompare(right.id);
135
+ }
136
+
137
+ function reviewQueueBucket(entry) {
138
+ if (entry.kind === "source_finding" && entry.risk === "high") {
139
+ return 0;
140
+ }
141
+ if (entry.kind === "install_unit") {
142
+ return 1;
143
+ }
144
+ if (entry.kind === "skill") {
145
+ return 2;
146
+ }
147
+ if (entry.kind === "source_finding" && isUnpinnedFinding(entry)) {
148
+ return 3;
149
+ }
150
+ return 4;
151
+ }
152
+
153
+ function isUnpinnedFinding(entry) {
154
+ const reason = entry.reason.toLowerCase();
155
+ return reason.includes("not pinned") || reason.includes("provenance");
156
+ }
157
+
158
+ function sortFinding(left, right) {
159
+ return left.severity.localeCompare(right.severity) || left.message.localeCompare(right.message);
160
+ }
@@ -0,0 +1,65 @@
1
+ import { hasRuntimeComponents, installUnitSourceClass } from "../domain/source-classes.mjs";
2
+
3
+ /**
4
+ * Recommend the next trust_level for an install unit based on its
5
+ * source class, permission risk, and runtime surface.
6
+ *
7
+ * The recommendation is a suggestion used by advisor action cards.
8
+ * The final authorization is still performed by `skillboard review` validation.
9
+ */
10
+ export function recommendTrustLevel(unit) {
11
+ const sourceClass = installUnitSourceClass(unit);
12
+ const risk = unit.permissionRisk ?? "unknown";
13
+ const runtime = hasRuntimeComponents(unit);
14
+
15
+ if (unit.trustLevel === "trusted" || unit.trustLevel === "blocked") {
16
+ return unit.trustLevel;
17
+ }
18
+
19
+ // User-controlled local sources are trusted by default because the user owns the files.
20
+ if (sourceClass === "user" && risk === "low") {
21
+ return "trusted";
22
+ }
23
+
24
+ // High-risk sources should not be activated from advisor suggestions without explicit blocking/review.
25
+ if (risk === "high") {
26
+ return "blocked";
27
+ }
28
+
29
+ // Unknown risk always requires at least a review before any automatic use.
30
+ if (risk === "unknown") {
31
+ return "reviewed";
32
+ }
33
+
34
+ // Medium-risk or runtime-related sources should be reviewed, not blindly trusted.
35
+ if (risk === "medium" || runtime) {
36
+ return "reviewed";
37
+ }
38
+
39
+ return "trusted";
40
+ }
41
+
42
+ /**
43
+ * Map a trust recommendation to an advisor action kind and label.
44
+ */
45
+ export function trustRecommendationAction(recommended) {
46
+ if (recommended === "trusted") {
47
+ return {
48
+ kind: "trust-install-unit",
49
+ label: "Trust source",
50
+ reason: "Low-risk source can be trusted after explicit user confirmation."
51
+ };
52
+ }
53
+ if (recommended === "blocked") {
54
+ return {
55
+ kind: "block-install-unit",
56
+ label: "Decide whether to block source",
57
+ reason: "High-risk or runtime-extending source needs a one-time decision before its skills appear again."
58
+ };
59
+ }
60
+ return {
61
+ kind: "review-install-unit",
62
+ label: "Review source",
63
+ reason: "Review the source before enabling its model-selectable skills."
64
+ };
65
+ }
@@ -0,0 +1,41 @@
1
+ export function resolveWorkflow(workflows, requestedWorkflow) {
2
+ const candidates = workflows.map((workflow) => workflow.name).sort((left, right) => left.localeCompare(right));
3
+ if (requestedWorkflow !== undefined) {
4
+ return {
5
+ selected: requestedWorkflow,
6
+ defaulted: false,
7
+ needs_selection: false,
8
+ candidates,
9
+ unknown: !candidates.includes(requestedWorkflow),
10
+ blocked_reason: candidates.includes(requestedWorkflow) ? null : `Unknown workflow: ${requestedWorkflow}`
11
+ };
12
+ }
13
+ if (candidates.length === 1) {
14
+ return {
15
+ selected: candidates[0],
16
+ defaulted: true,
17
+ needs_selection: false,
18
+ candidates,
19
+ unknown: false,
20
+ blocked_reason: null
21
+ };
22
+ }
23
+ if (candidates.length > 1) {
24
+ return {
25
+ selected: null,
26
+ defaulted: false,
27
+ needs_selection: true,
28
+ candidates,
29
+ unknown: false,
30
+ blocked_reason: "Multiple workflows exist; pass a workflow to classify skill availability."
31
+ };
32
+ }
33
+ return {
34
+ selected: null,
35
+ defaulted: false,
36
+ needs_selection: false,
37
+ candidates,
38
+ unknown: false,
39
+ blocked_reason: "No workflows are configured."
40
+ };
41
+ }