agent-skillboard 0.2.18 → 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.
- package/CHANGELOG.md +43 -0
- package/README.md +129 -258
- package/docs/adapters.md +37 -113
- package/docs/ai-skill-routing-goal.md +35 -109
- package/docs/capabilities.md +17 -104
- package/docs/install.md +36 -485
- package/docs/policy-model.md +50 -280
- package/docs/positioning.md +19 -86
- package/docs/profiles.md +21 -153
- package/docs/reference.md +115 -363
- package/docs/rollout-runbook.md +23 -25
- package/docs/routing.md +23 -90
- package/docs/user-flow.md +60 -284
- package/docs/value-proof.md +23 -181
- package/docs/variant-lifecycle.md +47 -67
- package/docs/versioning.md +31 -264
- package/examples/v2-multi-source.config.yaml +35 -0
- package/examples/v2-policy-error.config.yaml +6 -0
- package/package.json +1 -1
- package/src/advisor/actions.mjs +102 -6
- package/src/advisor/application-commands.mjs +10 -9
- package/src/advisor/apply-action.mjs +74 -1
- package/src/advisor/guidance.mjs +24 -16
- package/src/advisor/schema.mjs +17 -6
- package/src/advisor/skills.mjs +18 -5
- package/src/advisor.mjs +27 -9
- package/src/agent-integration-cli.mjs +13 -4
- package/src/agent-integration-content.mjs +21 -11
- package/src/agent-integration-files.mjs +1 -1
- package/src/agent-inventory-platforms.mjs +10 -0
- package/src/agent-inventory.mjs +23 -1
- package/src/agent-skill-import.mjs +2 -2
- package/src/audit-paths.mjs +42 -0
- package/src/brief-cli.mjs +3 -2
- package/src/brief-renderer.mjs +1 -0
- package/src/cli.mjs +498 -232
- package/src/compatibility.mjs +24 -0
- package/src/control/can-use-guard.mjs +21 -1
- package/src/control/config-write.mjs +32 -2
- package/src/control/skill-crud.mjs +5 -0
- package/src/control/v2-guard.mjs +175 -0
- package/src/control/v2-skill-crud.mjs +32 -0
- package/src/control/v2-skill-forget.mjs +38 -0
- package/src/control/variant-status.mjs +47 -1
- package/src/control.mjs +55 -0
- package/src/doctor.mjs +63 -4
- package/src/domain/v2-policy.mjs +111 -0
- package/src/hook-plan.mjs +33 -3
- package/src/impact.mjs +52 -29
- package/src/index.mjs +25 -1
- package/src/init.mjs +50 -34
- package/src/inventory-install-units.mjs +63 -0
- package/src/inventory-json.mjs +279 -0
- package/src/inventory-refresh.mjs +163 -18
- package/src/lifecycle-cli.mjs +40 -12
- package/src/lifecycle-content.mjs +52 -67
- package/src/migration/v1-to-v2.mjs +212 -0
- package/src/migration/v2-files.mjs +211 -0
- package/src/migration/v2-journal.mjs +169 -0
- package/src/migration/v2-projection.mjs +108 -0
- package/src/migration/v2-transaction.mjs +205 -0
- package/src/policy.mjs +3 -0
- package/src/reconcile.mjs +139 -111
- package/src/report.mjs +168 -148
- package/src/review.mjs +2 -0
- package/src/route-advisory.mjs +47 -2
- package/src/route-selection.mjs +38 -2
- package/src/route.mjs +62 -2
- package/src/shared-skill.mjs +301 -0
- package/src/source-digest.mjs +42 -0
- package/src/source-profiles.mjs +171 -144
- package/src/source-verification.mjs +32 -48
- package/src/uninstall.mjs +22 -0
- package/src/user-state-paths.mjs +19 -0
- package/src/user-uninstall.mjs +146 -0
- package/src/workspace.mjs +119 -79
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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,6 +127,15 @@ 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
140
|
recommendations.push("legacy project bridge blocks are absent; run skillboard init only if maintaining deprecated project-local policy");
|
|
125
141
|
}
|
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
151
|
+
return { enabled: automatic + manualOnly + routerOnly, disabled: blocked, global: 0, scoped: automatic + manualOnly + routerOnly };
|
|
136
152
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const TRUST_RANK = new Map([["blocked", 0], ["unreviewed", 1], ["reviewed", 2], ["trusted", 3]]);
|
|
2
|
+
const RISK_RANK = new Map([["high", 0], ["medium", 1], ["unknown", 2], ["low", 3]]);
|
|
3
|
+
|
|
4
|
+
export function coalesceInventoryInstallUnits(records) {
|
|
5
|
+
const groups = new Map();
|
|
6
|
+
for (const record of records) {
|
|
7
|
+
const group = groups.get(record.id) ?? [];
|
|
8
|
+
group.push(record);
|
|
9
|
+
groups.set(record.id, group);
|
|
10
|
+
}
|
|
11
|
+
return [...groups.values()].map(coalesceGroup).sort(byId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function coalesceGroup(records) {
|
|
15
|
+
const sorted = [...records].sort((left, right) => stableKey(left).localeCompare(stableKey(right)));
|
|
16
|
+
const first = sorted[0];
|
|
17
|
+
const runtime = sorted.map((record) => record.runtime_components ?? {});
|
|
18
|
+
return compact({
|
|
19
|
+
...first,
|
|
20
|
+
trust_observation: rankedValue(sorted.map((record) => record.trust_observation), TRUST_RANK),
|
|
21
|
+
permission_risk: rankedValue(sorted.map((record) => record.permission_risk), RISK_RANK),
|
|
22
|
+
signature_observed: sorted.some((record) => record.signature_observed === true),
|
|
23
|
+
runtime_components: {
|
|
24
|
+
commands: combined(runtime.map((value) => value.commands)),
|
|
25
|
+
hooks: combined(runtime.map((value) => value.hooks)),
|
|
26
|
+
mcp_servers: combined(runtime.map((value) => value.mcp_servers))
|
|
27
|
+
},
|
|
28
|
+
skills: combined(sorted.map((record) => record.skills)),
|
|
29
|
+
alias_skills: combined(sorted.map((record) => record.alias_skills)),
|
|
30
|
+
source_observations: observed(sorted, "source"),
|
|
31
|
+
manifest_path_observations: observed(sorted, "manifest_path"),
|
|
32
|
+
cache_path_observations: observed(sorted, "cache_path"),
|
|
33
|
+
source_digest_observations: observed(sorted, "source_digest")
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rankedValue(values, ranks) {
|
|
38
|
+
return values
|
|
39
|
+
.filter((value) => typeof value === "string" && value.length > 0)
|
|
40
|
+
.sort((left, right) => (ranks.get(left) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(right) ?? Number.MAX_SAFE_INTEGER)
|
|
41
|
+
|| left.localeCompare(right))[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function observed(records, key) {
|
|
45
|
+
const values = combined(records.map((record) => [record[key]]));
|
|
46
|
+
return values.length > 1 ? values : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function combined(groups) {
|
|
50
|
+
return [...new Set(groups.flat().filter((value) => typeof value === "string" && value.length > 0))].sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compact(value) {
|
|
54
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stableKey(value) {
|
|
58
|
+
return JSON.stringify(value, Object.keys(value).sort());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function byId(left, right) {
|
|
62
|
+
return left.id.localeCompare(right.id);
|
|
63
|
+
}
|