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.
- package/CHANGELOG.md +85 -0
- package/README.md +161 -255
- package/bin/postinstall.mjs +2 -1
- package/docs/adapters.md +37 -113
- package/docs/ai-skill-routing-goal.md +41 -108
- package/docs/capabilities.md +17 -104
- package/docs/install.md +70 -473
- package/docs/policy-model.md +50 -280
- package/docs/positioning.md +19 -86
- package/docs/profiles.md +21 -153
- package/docs/reference.md +133 -362
- package/docs/rollout-runbook.md +23 -25
- package/docs/routing.md +23 -90
- package/docs/user-flow.md +68 -279
- package/docs/value-proof.md +23 -181
- package/docs/variant-lifecycle.md +47 -67
- package/docs/versioning.md +49 -269
- 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 +96 -13
- package/src/agent-integration-content.mjs +21 -11
- package/src/agent-integration-files.mjs +1 -1
- package/src/agent-integration-home.mjs +14 -1
- package/src/agent-inventory-platforms.mjs +21 -8
- package/src/agent-inventory.mjs +44 -16
- package/src/agent-root-registry.mjs +127 -0
- package/src/agent-skill-import.mjs +2 -2
- package/src/agent-skill-roots.mjs +70 -13
- 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 +521 -235
- 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 +71 -5
- 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/install-health.mjs +177 -0
- package/src/inventory-install-units.mjs +63 -0
- package/src/inventory-json.mjs +279 -0
- package/src/inventory-refresh.mjs +168 -19
- 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-reconcile.mjs +97 -0
- package/src/shared-skill.mjs +325 -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 +161 -0
- 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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
}
|