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
|
@@ -1,45 +1,194 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import { isAbsolute, resolve } from "node:path";
|
|
1
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
3
4
|
import { discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
|
|
4
5
|
import { textChangePlan } from "./change-plan.mjs";
|
|
6
|
+
import { buildGeneratedInventory, mergeV2InventoryPolicy, renderGeneratedInventory } from "./inventory-json.mjs";
|
|
7
|
+
import { atomicWrite, optionalRead } from "./migration/v2-files.mjs";
|
|
8
|
+
import { loadWorkspace } from "./workspace.mjs";
|
|
9
|
+
|
|
10
|
+
const MINIMAL_V2_POLICY = "version: 2\nskills: {}\n";
|
|
5
11
|
|
|
6
12
|
export async function refreshAgentInventory(options = {}) {
|
|
7
13
|
const root = resolve(options.root ?? ".");
|
|
8
|
-
const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
|
|
9
|
-
|
|
14
|
+
const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml", "Inventory refresh config path");
|
|
15
|
+
return withRefreshLock(root, async () => refreshLocked({ ...options, root, configPath }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function refreshLocked(options) {
|
|
19
|
+
const { root, configPath } = options;
|
|
20
|
+
const configStats = await lstat(configPath).catch(missingOnly);
|
|
21
|
+
if (configStats?.isSymbolicLink()) {
|
|
22
|
+
throw new Error("Inventory refresh config path must not be a symbolic link.");
|
|
23
|
+
}
|
|
24
|
+
const bootstrappedV2 = configStats === undefined;
|
|
25
|
+
const current = bootstrappedV2 ? MINIMAL_V2_POLICY : await readFile(configPath, "utf8");
|
|
26
|
+
if (!bootstrappedV2) {
|
|
27
|
+
await loadWorkspace({ configPath });
|
|
28
|
+
}
|
|
29
|
+
const inventory = options.inventory ?? await discoverAgentSkillInventory({
|
|
10
30
|
roots: options.roots,
|
|
11
31
|
home: options.home,
|
|
12
|
-
env: options.env
|
|
32
|
+
env: options.env,
|
|
33
|
+
registeredRoots: options.registeredRoots
|
|
13
34
|
});
|
|
14
|
-
const
|
|
15
|
-
const
|
|
35
|
+
const configVersion = YAML.parse(current)?.version;
|
|
36
|
+
const generatedInventory = configVersion === 2
|
|
37
|
+
? await buildGeneratedInventory(inventory, { root, home: options.home ?? options.env?.HOME })
|
|
38
|
+
: null;
|
|
39
|
+
const inventoryPath = resolveUnderRoot(root, options.inventoryPath ?? ".skillboard/inventory.json", "Generated inventory target");
|
|
40
|
+
const inventoryText = generatedInventory === null ? null : renderGeneratedInventory(generatedInventory);
|
|
41
|
+
if (inventoryText !== null) {
|
|
42
|
+
await assertGeneratedInventoryTarget(root, inventoryPath);
|
|
43
|
+
}
|
|
44
|
+
const previousInventoryText = inventoryText === null
|
|
45
|
+
? null
|
|
46
|
+
: await readFile(inventoryPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
|
|
47
|
+
const projected = configVersion === 2
|
|
48
|
+
? mergeV2InventoryPolicy(current, inventory)
|
|
49
|
+
: mergeAgentSkillInventory(current, inventory);
|
|
50
|
+
const merged = configVersion === 1 && options.preserveLegacyPolicy === true
|
|
51
|
+
? { ...projected, text: current }
|
|
52
|
+
: projected;
|
|
16
53
|
const plan = textChangePlan(current, merged.text);
|
|
17
54
|
const dryRun = options.dryRun === true;
|
|
18
55
|
|
|
19
|
-
|
|
20
|
-
|
|
56
|
+
const inventoryChanged = inventoryText !== null && inventoryText !== previousInventoryText;
|
|
57
|
+
if (!dryRun) {
|
|
58
|
+
const [previousConfig, previousInventory] = await Promise.all([
|
|
59
|
+
optionalRead(configPath),
|
|
60
|
+
inventoryText === null ? Promise.resolve(null) : optionalRead(inventoryPath)
|
|
61
|
+
]);
|
|
62
|
+
try {
|
|
63
|
+
if (inventoryChanged) {
|
|
64
|
+
await mkdir(dirname(inventoryPath), { recursive: true });
|
|
65
|
+
const writeInventory = options.writeInventory ?? writeInventoryFile;
|
|
66
|
+
await writeInventory(inventoryPath, inventoryText);
|
|
67
|
+
}
|
|
68
|
+
if (bootstrappedV2) {
|
|
69
|
+
const writeConfig = options.writeConfig ?? atomicWrite;
|
|
70
|
+
await writeConfig(configPath, Buffer.from(merged.text));
|
|
71
|
+
} else if (plan.changed) {
|
|
72
|
+
const writeConfig = options.writeConfig ?? atomicWrite;
|
|
73
|
+
await writeConfig(configPath, Buffer.from(merged.text));
|
|
74
|
+
}
|
|
75
|
+
if (inventoryText !== null) await chmod(inventoryPath, 0o600);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await restoreFile(configPath, previousConfig);
|
|
78
|
+
if (inventoryText !== null) await restoreFile(inventoryPath, previousInventory);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
21
81
|
}
|
|
22
82
|
|
|
23
83
|
return {
|
|
24
84
|
dryRun,
|
|
25
|
-
|
|
85
|
+
bootstrappedV2,
|
|
86
|
+
configPath: relativeArtifactPath(root, configPath),
|
|
87
|
+
inventoryPath: generatedInventory === null ? null : relativeArtifactPath(root, inventoryPath),
|
|
88
|
+
inventoryChanged,
|
|
26
89
|
changed: plan.changed,
|
|
27
90
|
plan,
|
|
28
91
|
scan: {
|
|
29
92
|
scannedSkills: inventory.scannedSkills ?? inventory.skills.length,
|
|
30
93
|
scannedInstallUnits: inventory.installUnits.length,
|
|
31
94
|
addedSkills: merged.addedSkills,
|
|
32
|
-
addedInstallUnits: merged.addedInstallUnits,
|
|
33
|
-
updatedInstallUnits: merged.updatedInstallUnits,
|
|
34
|
-
addedWorkflows: merged.addedWorkflows,
|
|
35
|
-
addedHarnesses: merged.addedHarnesses,
|
|
36
|
-
skippedSkills: merged.skippedSkills,
|
|
37
|
-
reviewNotes: merged.reviewNotes,
|
|
38
|
-
warnings: inventory.warnings ?? []
|
|
95
|
+
addedInstallUnits: merged.addedInstallUnits ?? [],
|
|
96
|
+
updatedInstallUnits: merged.updatedInstallUnits ?? [],
|
|
97
|
+
addedWorkflows: merged.addedWorkflows ?? [],
|
|
98
|
+
addedHarnesses: merged.addedHarnesses ?? [],
|
|
99
|
+
skippedSkills: merged.skippedSkills ?? [],
|
|
100
|
+
reviewNotes: merged.reviewNotes ?? [],
|
|
101
|
+
warnings: [...(inventory.warnings ?? []), ...(generatedInventory?.redactions.warnings ?? [])],
|
|
102
|
+
redactedPaths: generatedInventory?.redactions.path_count ?? 0
|
|
39
103
|
}
|
|
40
104
|
};
|
|
41
105
|
}
|
|
42
106
|
|
|
43
|
-
function
|
|
44
|
-
|
|
107
|
+
async function withRefreshLock(root, operation) {
|
|
108
|
+
const lockPath = join(root, ".skillboard-inventory-refresh.lock");
|
|
109
|
+
let handle;
|
|
110
|
+
try {
|
|
111
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code === "EEXIST") {
|
|
114
|
+
throw new Error("Another inventory refresh is already using this project.");
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
120
|
+
return await operation();
|
|
121
|
+
} finally {
|
|
122
|
+
await handle.close();
|
|
123
|
+
await rm(lockPath, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveUnderRoot(root, path, label) {
|
|
128
|
+
const target = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
129
|
+
if (!isPathInside(root, target)) {
|
|
130
|
+
throw new Error(`${label} must remain inside the project root.`);
|
|
131
|
+
}
|
|
132
|
+
return target;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function assertGeneratedInventoryTarget(root, target) {
|
|
136
|
+
if (!isPathInside(root, target)) {
|
|
137
|
+
throw new Error(`Generated inventory target is outside the project root: ${target}`);
|
|
138
|
+
}
|
|
139
|
+
const targetStats = await lstat(target).catch(missingOnly);
|
|
140
|
+
if (targetStats?.isSymbolicLink()) {
|
|
141
|
+
throw new Error(`Generated inventory target is a symlink and may resolve outside the project root: ${target}`);
|
|
142
|
+
}
|
|
143
|
+
const existingParent = await nearestExistingDirectory(dirname(target));
|
|
144
|
+
const [realRoot, realParent] = await Promise.all([realpath(root), realpath(existingParent)]);
|
|
145
|
+
if (!isPathInside(realRoot, realParent)) {
|
|
146
|
+
throw new Error(`Generated inventory target resolves outside the project root: ${target}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function nearestExistingDirectory(start) {
|
|
151
|
+
let candidate = start;
|
|
152
|
+
while (true) {
|
|
153
|
+
const stats = await lstat(candidate).catch(missingOnly);
|
|
154
|
+
if (stats !== undefined) {
|
|
155
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
156
|
+
throw new Error(`Generated inventory parent is not a directory: ${candidate}`);
|
|
157
|
+
}
|
|
158
|
+
return candidate;
|
|
159
|
+
}
|
|
160
|
+
const parent = dirname(candidate);
|
|
161
|
+
if (parent === candidate) {
|
|
162
|
+
throw new Error(`Generated inventory parent does not exist: ${start}`);
|
|
163
|
+
}
|
|
164
|
+
candidate = parent;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function missingOnly(error) {
|
|
169
|
+
if (error?.code === "ENOENT") {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPathInside(root, candidate) {
|
|
176
|
+
const rel = relative(root, candidate);
|
|
177
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function writeInventoryFile(path, text) {
|
|
181
|
+
await atomicWrite(path, Buffer.from(text));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function restoreFile(path, bytes) {
|
|
185
|
+
if (bytes === null) {
|
|
186
|
+
await rm(path, { force: true });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await atomicWrite(path, bytes);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function relativeArtifactPath(root, path) {
|
|
193
|
+
return relative(root, path).replace(/\\/g, "/") || ".";
|
|
45
194
|
}
|
package/src/lifecycle-cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
2
2
|
import { runAgentLayerUninstallCommand } from "./agent-integration-cli.mjs";
|
|
3
3
|
import { initProject } from "./init.mjs";
|
|
4
4
|
import { uninstallProject } from "./uninstall.mjs";
|
|
5
|
+
import { uninstallUser } from "./user-uninstall.mjs";
|
|
5
6
|
|
|
6
7
|
export { runSetupCommand } from "./agent-integration-cli.mjs";
|
|
7
8
|
|
|
@@ -40,6 +41,9 @@ export async function runInitCommand(options, stdout, runtime = defaultRuntime()
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export async function runUninstallCommand(options, stdout, runtime = defaultRuntime()) {
|
|
44
|
+
if (options.get("user") === "true") {
|
|
45
|
+
return await runUserUninstallCommand(options, stdout, runtime);
|
|
46
|
+
}
|
|
43
47
|
if (options.get("agent-layer") === "true") {
|
|
44
48
|
return await runAgentLayerUninstallCommand(options, stdout, runtime);
|
|
45
49
|
}
|
|
@@ -82,6 +86,36 @@ export async function runUninstallCommand(options, stdout, runtime = defaultRunt
|
|
|
82
86
|
return 0;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
async function runUserUninstallCommand(options, stdout, runtime) {
|
|
90
|
+
const allowed = new Set(["user", "dry-run", "yes", "json"]);
|
|
91
|
+
for (const option of options.keys()) {
|
|
92
|
+
if (!allowed.has(option)) {
|
|
93
|
+
throw new Error(`Unknown option for uninstall --user: --${option}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const dryRun = options.get("dry-run") === "true";
|
|
97
|
+
if (!dryRun && options.get("yes") !== "true") {
|
|
98
|
+
throw new Error("skillboard uninstall --user requires --yes; preview first with --dry-run");
|
|
99
|
+
}
|
|
100
|
+
const result = await uninstallUser({
|
|
101
|
+
dryRun,
|
|
102
|
+
env: runtime.env ?? process.env,
|
|
103
|
+
runtime
|
|
104
|
+
});
|
|
105
|
+
if (options.get("json") === "true") {
|
|
106
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
stdout.write(`${dryRun ? "Dry run: " : ""}Uninstalled SkillBoard user state.\n`);
|
|
110
|
+
writeList(stdout, "Managed copies", result.managed_copies.map((copy) => copy.path));
|
|
111
|
+
writeList(stdout, "Guidance removed", result.guidance.removed);
|
|
112
|
+
writeList(stdout, "Guidance updated", result.guidance.updated);
|
|
113
|
+
writeList(stdout, "State", result.state_paths);
|
|
114
|
+
writeList(stdout, "Preserved", result.preserved);
|
|
115
|
+
if (dryRun) stdout.write("Run skillboard uninstall --user --yes to apply this plan.\n");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
function writeList(stdout, label, values) {
|
|
86
120
|
if (values.length > 0) {
|
|
87
121
|
stdout.write(`${label}: ${formatList(values)}\n`);
|
|
@@ -104,18 +138,12 @@ function writeCountedList(stdout, label, values) {
|
|
|
104
138
|
|
|
105
139
|
function writeSafetyDefault(stdout, safety) {
|
|
106
140
|
stdout.write("Skill selection default:\n");
|
|
107
|
-
stdout.write("-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
stdout.write("- Imported local skills are available on request in generated local policy.\n");
|
|
114
|
-
stdout.write("- Runtime/plugin/system skills require source review before automatic invocation.\n");
|
|
115
|
-
stdout.write(`- ${safety.automatic} automatic skills enabled\n`);
|
|
116
|
-
stdout.write(`- ${safety.manualOnly} manual-only skills available\n`);
|
|
117
|
-
stdout.write(`- ${safety.routerOnly} router-only skills available\n`);
|
|
118
|
-
stdout.write(`- ${safety.blocked} blocked/quarantined for safety\n`);
|
|
141
|
+
stdout.write("- Valid installed skills default to enabled and agent-local.\n");
|
|
142
|
+
stdout.write("- Users may disable a skill or explicitly share that skill across agents.\n");
|
|
143
|
+
stdout.write("- Optional preference ranks candidates and never changes availability.\n");
|
|
144
|
+
stdout.write("- Source and provenance are audit metadata, never availability.\n");
|
|
145
|
+
stdout.write(`- ${safety.enabled} enabled skills\n`);
|
|
146
|
+
stdout.write(`- ${safety.disabled} disabled skills\n`);
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
function writeNextCommands(stdout, next) {
|
|
@@ -2,17 +2,8 @@ export const BRIDGE_START = "<!-- BEGIN SKILLBOARD -->";
|
|
|
2
2
|
export const BRIDGE_END = "<!-- END SKILLBOARD -->";
|
|
3
3
|
|
|
4
4
|
export function defaultConfig() {
|
|
5
|
-
return `version:
|
|
6
|
-
defaults:
|
|
7
|
-
invocation_policy: deny-by-default
|
|
8
|
-
allow_model_invocation: false
|
|
9
|
-
require_explicit_workflow: true
|
|
10
|
-
|
|
5
|
+
return `version: 2
|
|
11
6
|
skills: {}
|
|
12
|
-
capabilities: {}
|
|
13
|
-
harnesses: {}
|
|
14
|
-
workflows: {}
|
|
15
|
-
install_units: {}
|
|
16
7
|
`;
|
|
17
8
|
}
|
|
18
9
|
|
|
@@ -20,40 +11,49 @@ export function bridgeBlock() {
|
|
|
20
11
|
return `${BRIDGE_START}
|
|
21
12
|
# SkillBoard Control Plane
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
14
|
+
SkillBoard keeps one user-level policy. A valid installed skill defaults to
|
|
15
|
+
enabled and agent-local. Users may opt individual skills into cross-agent
|
|
16
|
+
sharing. Optional preference ranks enabled skills installed for the current
|
|
17
|
+
agent and never changes availability or copies files.
|
|
18
|
+
|
|
19
|
+
## Route and use
|
|
20
|
+
|
|
21
|
+
- Work normally unless skills overlap or the user asks
|
|
22
|
+
for a skill decision.
|
|
23
|
+
- Read \`skillboard brief --intent <request> --agent <agent> --json\`.
|
|
24
|
+
- Use the recommended enabled, installed skill and ordered fallbacks. Honor an
|
|
25
|
+
explicit user choice when the guard allows it.
|
|
26
|
+
- Run \`skillboard guard use <skill-id> --agent <agent> --json\` immediately
|
|
27
|
+
before use.
|
|
28
|
+
- Do not ask for another approval when guard allows use. Briefly disclose the
|
|
29
|
+
selected skill as an audit trace.
|
|
30
|
+
- If the routed brief suggests policy memory, finish the work first, then ask
|
|
31
|
+
once whether to remember that intent preference. Do not prompt before use.
|
|
32
|
+
|
|
33
|
+
## Change policy
|
|
34
|
+
|
|
35
|
+
- Policy changes only enable/disable a skill, share/unshare one skill, or
|
|
36
|
+
remember optional ranking preference.
|
|
37
|
+
- Read a fresh brief with \`--include-actions\`, choose one current action id, ask
|
|
38
|
+
for one confirmation, then run \`skillboard apply-action <action-id> --yes
|
|
39
|
+
--json\`.
|
|
40
|
+
- Reread the returned post-apply brief. Never reuse cached action ids or apply
|
|
41
|
+
multiple actions from one confirmation.
|
|
42
|
+
- If inventory no longer observes a permanently removed unshared skill, use its
|
|
43
|
+
current forget action after confirmation. Forget removes policy only and never
|
|
44
|
+
deletes skill files.
|
|
45
|
+
|
|
46
|
+
## Compatibility and boundaries
|
|
47
|
+
|
|
48
|
+
- If the brief reports stale version 1 policy, do not mutate it. Preview
|
|
49
|
+
\`skillboard migrate v2 --config skillboard.config.yaml --json\`, then obtain
|
|
50
|
+
confirmation before applying with \`--yes --json\`. Rollback uses
|
|
51
|
+
\`--rollback <backup> --json\`.
|
|
52
|
+
- Source and provenance observations are optional audit metadata and never
|
|
53
|
+
determine availability.
|
|
54
|
+
- Runtime and action authorization are outside SkillBoard's scope. Follow the
|
|
55
|
+
agent or harness permission boundary for commands, hooks, MCP servers,
|
|
56
|
+
external writes, destructive actions, network access, and secrets.
|
|
57
57
|
|
|
58
58
|
${BRIDGE_END}`;
|
|
59
59
|
}
|
|
@@ -61,39 +61,24 @@ ${BRIDGE_END}`;
|
|
|
61
61
|
export function profileReadme() {
|
|
62
62
|
return `# SkillBoard source profiles
|
|
63
63
|
|
|
64
|
-
Put
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
\`\`\`bash
|
|
70
|
-
skillboard import --profile .skillboard/profiles/example.yaml --source-root /path/to/repo
|
|
71
|
-
\`\`\`
|
|
72
|
-
|
|
73
|
-
The import command emits a YAML fragment with governed \`skills\` and
|
|
74
|
-
\`install_units\`. Review the fragment before merging it into
|
|
75
|
-
\`skillboard.config.yaml\`; imported skills are not automatically active.
|
|
64
|
+
Put discovery profiles here when a skill repository is not
|
|
65
|
+
covered by a built-in profile. Profiles produce inventory observations; they do
|
|
66
|
+
not authorize availability. Newly discovered valid skills default to
|
|
67
|
+
enabled and agent-local while existing user policy is preserved.
|
|
76
68
|
`;
|
|
77
69
|
}
|
|
78
70
|
|
|
79
71
|
export function hookReadme() {
|
|
80
72
|
return `# SkillBoard hooks
|
|
81
73
|
|
|
82
|
-
|
|
74
|
+
Preview a generated guard hook before installation:
|
|
83
75
|
|
|
84
76
|
\`\`\`bash
|
|
85
77
|
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json
|
|
86
|
-
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh
|
|
87
78
|
\`\`\`
|
|
88
79
|
|
|
89
|
-
For
|
|
90
|
-
\`skillboard apply-action <action-id> --yes --json\`.
|
|
91
|
-
|
|
92
|
-
\`planned.preview.shell\` before materializing an executable guard hook.
|
|
93
|
-
Generated hooks pin the install-time SkillBoard command, config, skills root,
|
|
94
|
-
and workflow; set those values with hook install options such as
|
|
95
|
-
\`--skillboard-bin\`, not runtime environment overrides. The generated script
|
|
96
|
-
delegates to \`skillboard guard use\` and can be wired into the hook mechanism of
|
|
97
|
-
the active harness.
|
|
80
|
+
For persistent changes prefer one current action card applied with
|
|
81
|
+
\`skillboard apply-action <action-id> --yes --json\`. Runtime hook permission is
|
|
82
|
+
owned by the active harness, not SkillBoard policy.
|
|
98
83
|
`;
|
|
99
84
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { EXPOSURE_VALUES, INVOCATION_VALUES, STATUS_VALUES } from "../domain/constants.mjs";
|
|
2
|
+
import { TERMINAL_STATUSES, validateSkillState } from "../domain/skill-state-matrix.mjs";
|
|
3
|
+
|
|
4
|
+
const V1_SKILL_POLICY_FIELDS = new Set(["status", "invocation", "exposure"]);
|
|
5
|
+
const V1_SKILL_LOCATION_FIELDS = new Set(["path", "owner_install_unit"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Purely project a parsed v1 config into v2 policy and audit observations.
|
|
9
|
+
* The caller retains ownership of input and may safely hash/reuse it afterward.
|
|
10
|
+
*/
|
|
11
|
+
export function mapV1ConfigToV2(input) {
|
|
12
|
+
const config = requireRecord(input, "v1 config");
|
|
13
|
+
if (config.version !== undefined && config.version !== 1) {
|
|
14
|
+
throw new Error(`Expected version 1 config; got version ${String(config.version)}`);
|
|
15
|
+
}
|
|
16
|
+
const skills = requireRecord(config.skills ?? {}, "v1 config skills");
|
|
17
|
+
const workflows = requireRecord(config.workflows ?? {}, "v1 config workflows");
|
|
18
|
+
const capabilities = requireRecord(config.capabilities ?? {}, "v1 config capabilities");
|
|
19
|
+
const installUnits = requireRecord(config.install_units ?? {}, "v1 config install_units");
|
|
20
|
+
const workflowNames = Object.keys(workflows).sort();
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const losses = collectLosses(config);
|
|
23
|
+
const policySkills = {};
|
|
24
|
+
const inventorySkills = [];
|
|
25
|
+
const uncertainQuarantines = [];
|
|
26
|
+
|
|
27
|
+
for (const [skillId, rawSkill] of Object.entries(skills).sort(byEntryKey)) {
|
|
28
|
+
const skill = requireRecord(rawSkill, `skill ${skillId}`);
|
|
29
|
+
validateState(skillId, skill);
|
|
30
|
+
const enabled = !TERMINAL_STATUSES.has(skill.status);
|
|
31
|
+
const preference = preferenceFor(skillId, capabilities, workflows);
|
|
32
|
+
policySkills[skillId] = {
|
|
33
|
+
enabled,
|
|
34
|
+
shared: false,
|
|
35
|
+
...(preference === null ? {} : { preference })
|
|
36
|
+
};
|
|
37
|
+
if (isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows)) {
|
|
38
|
+
uncertainQuarantines.push(skillId);
|
|
39
|
+
}
|
|
40
|
+
inventorySkills.push(skillObservation(skillId, skill));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ambiguities = uncertainQuarantines.length === 0 ? [] : [{
|
|
44
|
+
kind: "review_only_quarantine",
|
|
45
|
+
skill_ids: uncertainQuarantines,
|
|
46
|
+
mapped_enabled: true,
|
|
47
|
+
requires_grouped_confirmation: true
|
|
48
|
+
}];
|
|
49
|
+
if (uncertainQuarantines.length > 0) {
|
|
50
|
+
warnings.push(
|
|
51
|
+
`${uncertainQuarantines.length} review-only quarantine state(s) were mapped enabled; confirm them together when applying the migration.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
policy: {
|
|
57
|
+
version: 2,
|
|
58
|
+
skills: policySkills
|
|
59
|
+
},
|
|
60
|
+
inventory: {
|
|
61
|
+
format_version: 1,
|
|
62
|
+
skills: inventorySkills,
|
|
63
|
+
install_units: Object.entries(installUnits).sort(byEntryKey).map(([id, unit]) => ({
|
|
64
|
+
id,
|
|
65
|
+
observations: clone(requireRecord(unit, `install unit ${id}`))
|
|
66
|
+
}))
|
|
67
|
+
},
|
|
68
|
+
warnings,
|
|
69
|
+
losses,
|
|
70
|
+
ambiguities
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isReviewOnlyQuarantine(skillId, skill, workflowNames, workflows) {
|
|
75
|
+
if (skill.status !== "quarantined" || skill.invocation !== "blocked") return false;
|
|
76
|
+
return !workflowNames.some((name) => stringList(workflows[name]?.blocked_skills).includes(skillId));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateState(skillId, skill) {
|
|
80
|
+
if (!STATUS_VALUES.has(skill.status)) {
|
|
81
|
+
throw new Error(`Skill ${skillId} has unsupported status: ${String(skill.status)}`);
|
|
82
|
+
}
|
|
83
|
+
if (!INVOCATION_VALUES.has(skill.invocation)) {
|
|
84
|
+
throw new Error(`Skill ${skillId} has unsupported invocation: ${String(skill.invocation)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!EXPOSURE_VALUES.has(skill.exposure)) {
|
|
87
|
+
throw new Error(`Skill ${skillId} has unsupported exposure: ${String(skill.exposure)}`);
|
|
88
|
+
}
|
|
89
|
+
const diagnostic = validateSkillState(skill.status, skill.invocation, skillId);
|
|
90
|
+
if (typeof diagnostic === "string") throw new Error(diagnostic);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function preferenceFor(skillId, capabilities, workflows) {
|
|
94
|
+
const roles = [];
|
|
95
|
+
for (const [name, rawCapability] of Object.entries(capabilities)) {
|
|
96
|
+
const capability = requireRecord(rawCapability, `capability ${name}`);
|
|
97
|
+
if (capability.canonical === skillId) roles.push({ intent: name, priority: 100 });
|
|
98
|
+
if (stringList(capability.alternatives).includes(skillId)) roles.push({ intent: name, priority: 50 });
|
|
99
|
+
}
|
|
100
|
+
for (const rawWorkflow of Object.values(workflows)) {
|
|
101
|
+
const workflow = requireRecord(rawWorkflow, "workflow");
|
|
102
|
+
const requirements = requireRecord(workflow.required_capabilities ?? {}, "workflow required_capabilities");
|
|
103
|
+
for (const [name, rawRequirement] of Object.entries(requirements)) {
|
|
104
|
+
const requirement = requireRecord(rawRequirement, `workflow capability ${name}`);
|
|
105
|
+
if (requirement.preferred === skillId) roles.push({ intent: name, priority: 100 });
|
|
106
|
+
stringList(requirement.fallback).forEach((candidate, index) => {
|
|
107
|
+
if (candidate === skillId) roles.push({ intent: name, priority: 90 - index });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (roles.length === 0) return null;
|
|
112
|
+
return {
|
|
113
|
+
intents: [...new Set(roles.map(({ intent }) => intent))].sort(),
|
|
114
|
+
priority: Math.max(...roles.map(({ priority }) => priority))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function skillObservation(id, skill) {
|
|
119
|
+
const aliases = Array.isArray(skill.source_aliases) ? clone(skill.source_aliases) : [];
|
|
120
|
+
const observations = Object.fromEntries(
|
|
121
|
+
Object.entries(skill)
|
|
122
|
+
.filter(([key]) => !V1_SKILL_POLICY_FIELDS.has(key)
|
|
123
|
+
&& !V1_SKILL_LOCATION_FIELDS.has(key)
|
|
124
|
+
&& key !== "source_aliases")
|
|
125
|
+
.map(([key, value]) => [key, clone(value)])
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
path: typeof skill.path === "string" && skill.path.length > 0 ? skill.path : id,
|
|
130
|
+
owner_install_unit: typeof skill.owner_install_unit === "string" && skill.owner_install_unit.length > 0
|
|
131
|
+
? skill.owner_install_unit
|
|
132
|
+
: "migration.unowned",
|
|
133
|
+
aliases,
|
|
134
|
+
installed_on: installedAgents(skill),
|
|
135
|
+
observations
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function installedAgents(skill) {
|
|
140
|
+
const units = [skill.owner_install_unit, ...(skill.source_aliases ?? []).map((alias) => alias?.owner_install_unit)];
|
|
141
|
+
return [...new Set(units.map(agentForUnit).filter(Boolean))].sort();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function agentForUnit(unit) {
|
|
145
|
+
if (typeof unit !== "string") return "";
|
|
146
|
+
return ["codex", "claude", "opencode", "hermes"].find((agent) => unit === agent || unit.startsWith(`${agent}.`)) ?? "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectLosses(config) {
|
|
150
|
+
const losses = [];
|
|
151
|
+
visit(config, "", (path, value) => {
|
|
152
|
+
if (path === "/version") return;
|
|
153
|
+
losses.push({ path, value: clone(value), disposition: lossDisposition(path) });
|
|
154
|
+
});
|
|
155
|
+
return losses;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function visit(value, path, add) {
|
|
159
|
+
if (value === null || typeof value !== "object") {
|
|
160
|
+
add(path, value);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
if (value.length === 0) add(path, []);
|
|
165
|
+
value.forEach((entry, index) => visit(entry, `${path}/${index}`, add));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const entries = Object.entries(value);
|
|
169
|
+
if (entries.length === 0) add(path, {});
|
|
170
|
+
entries.forEach(([key, entry]) => visit(entry, `${path}/${escapePointer(key)}`, add));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function lossDisposition(path) {
|
|
174
|
+
if (/^\/skills\/[^/]+\/(status|invocation|exposure)$/.test(path)) return "mapped_to_v2_policy";
|
|
175
|
+
if (path.startsWith("/skills/") || path.startsWith("/install_units/")) return "preserved_in_inventory_observation";
|
|
176
|
+
if (path.startsWith("/capabilities/") || path.startsWith("/workflows/")) return "mapped_where_applicable";
|
|
177
|
+
return "not_part_of_v2_policy";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function requireRecord(value, label) {
|
|
181
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
182
|
+
throw new Error(`${label} must be an object mapping`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function stringList(value) {
|
|
188
|
+
if (value === undefined) return [];
|
|
189
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
190
|
+
throw new Error("Expected a list of strings in v1 config");
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clone(value) {
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
return value.map((entry) => clone(entry));
|
|
198
|
+
}
|
|
199
|
+
if (value !== null && typeof value === "object") {
|
|
200
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, clone(entry)]));
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @param {[string, unknown]} left @param {[string, unknown]} right */
|
|
206
|
+
function byEntryKey(left, right) {
|
|
207
|
+
return left[0].localeCompare(right[0]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapePointer(value) {
|
|
211
|
+
return value.split("~").join("~0").split("/").join("~1");
|
|
212
|
+
}
|