agent-skillboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/bin/skillboard.mjs +4 -0
- package/docs/adapters.md +127 -0
- package/docs/capabilities.md +107 -0
- package/docs/install.md +346 -0
- package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
- package/docs/plans/README.md +20 -0
- package/docs/policy-model.md +251 -0
- package/docs/positioning.md +94 -0
- package/docs/profiles.md +166 -0
- package/docs/rollout-runbook.md +60 -0
- package/docs/user-flow.md +231 -0
- package/docs/versioning.md +201 -0
- package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
- package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
- package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
- package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
- package/examples/multi-source.config.yaml +271 -0
- package/examples/skillboard.config.yaml +194 -0
- package/examples/skills/grill-me/SKILL.md +9 -0
- package/examples/skills/grill-with-docs/SKILL.md +9 -0
- package/examples/skills/requirement-intake/SKILL.md +9 -0
- package/examples/skills/tdd/SKILL.md +8 -0
- package/package.json +58 -0
- package/profiles/anthropics-skills.yaml +17 -0
- package/profiles/mattpocock-skills.yaml +26 -0
- package/profiles/oh-my-openagent.yaml +32 -0
- package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
- package/profiles/wshobson-agents.yaml +19 -0
- package/src/advisor/action-core.mjs +74 -0
- package/src/advisor/actions.mjs +256 -0
- package/src/advisor/application-commands.mjs +59 -0
- package/src/advisor/apply-action.mjs +183 -0
- package/src/advisor/schema.mjs +112 -0
- package/src/advisor/setup-actions.mjs +42 -0
- package/src/advisor/skills.mjs +191 -0
- package/src/advisor/sort.mjs +15 -0
- package/src/advisor/sources.mjs +160 -0
- package/src/advisor/trust-policy.mjs +65 -0
- package/src/advisor/workflow.mjs +41 -0
- package/src/advisor.mjs +134 -0
- package/src/agent-inventory-platforms.mjs +100 -0
- package/src/agent-inventory.mjs +804 -0
- package/src/brief-cli.mjs +40 -0
- package/src/brief-renderer.mjs +362 -0
- package/src/change-plan.mjs +169 -0
- package/src/cli.mjs +1171 -0
- package/src/config-helpers.mjs +72 -0
- package/src/control/can-use-guard.mjs +91 -0
- package/src/control/config-write.mjs +163 -0
- package/src/control/skill-crud.mjs +394 -0
- package/src/control/source-trust.mjs +161 -0
- package/src/control/workflow-crud.mjs +104 -0
- package/src/control.mjs +197 -0
- package/src/doctor.mjs +299 -0
- package/src/domain/constants.mjs +47 -0
- package/src/domain/indexes.mjs +29 -0
- package/src/domain/rules/capabilities.mjs +33 -0
- package/src/domain/rules/harnesses.mjs +16 -0
- package/src/domain/rules/install-units.mjs +95 -0
- package/src/domain/rules/skills.mjs +105 -0
- package/src/domain/rules/workflows.mjs +105 -0
- package/src/domain/skill-state-matrix.mjs +79 -0
- package/src/domain/source-classes.mjs +99 -0
- package/src/hook-plan.mjs +152 -0
- package/src/impact.mjs +41 -0
- package/src/index.mjs +82 -0
- package/src/init.mjs +136 -0
- package/src/install-output-detector.mjs +337 -0
- package/src/install-units.mjs +62 -0
- package/src/inventory-refresh.mjs +45 -0
- package/src/lifecycle-cli.mjs +166 -0
- package/src/lifecycle-content.mjs +87 -0
- package/src/policy.mjs +42 -0
- package/src/reconcile.mjs +111 -0
- package/src/report.mjs +151 -0
- package/src/review.mjs +88 -0
- package/src/rollout.mjs +453 -0
- package/src/skill-paths.mjs +17 -0
- package/src/source-cache.mjs +178 -0
- package/src/source-profile-loader.mjs +160 -0
- package/src/source-profiles.mjs +299 -0
- package/src/source-verification.mjs +252 -0
- package/src/uninstall.mjs +258 -0
- package/src/workspace.mjs +219 -0
- package/tsconfig.lsp.json +15 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { initProject } from "./init.mjs";
|
|
3
|
+
import { uninstallProject } from "./uninstall.mjs";
|
|
4
|
+
|
|
5
|
+
export async function runInitCommand(options, stdout, runtime = defaultRuntime()) {
|
|
6
|
+
const root = resolve(options.get("dir") ?? ".");
|
|
7
|
+
const result = await initProject({
|
|
8
|
+
root,
|
|
9
|
+
scanInstalled: options.get("scan-installed") !== "false" && options.get("no-scan-installed") !== "true",
|
|
10
|
+
scanRoots: readCsv(options.get("scan-root"))
|
|
11
|
+
});
|
|
12
|
+
if (result.alreadyInitialized) {
|
|
13
|
+
stdout.write(`SkillBoard already initialized: ${root}\n`);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
stdout.write(`Initialized SkillBoard: ${root}\n`);
|
|
17
|
+
writeList(stdout, "Created", result.created);
|
|
18
|
+
writeList(stdout, "Updated", result.updated);
|
|
19
|
+
if (result.scan.scannedSkills > 0 || result.scan.scannedInstallUnits > 0) {
|
|
20
|
+
stdout.write(`Scanned installed agent skills: ${result.scan.scannedSkills}\n`);
|
|
21
|
+
stdout.write(`Managed install units: ${result.scan.scannedInstallUnits}\n`);
|
|
22
|
+
}
|
|
23
|
+
writeCountedList(stdout, "Added managed skills", result.scan.addedSkills);
|
|
24
|
+
writeList(stdout, "Added install units", result.scan.addedInstallUnits);
|
|
25
|
+
writeList(stdout, "Added workflows", result.scan.addedWorkflows ?? []);
|
|
26
|
+
writeList(stdout, "Added harnesses", result.scan.addedHarnesses ?? []);
|
|
27
|
+
writeList(stdout, "Skipped existing skills", result.scan.skippedSkills);
|
|
28
|
+
writeCountedList(stdout, "Review notes", result.scan.reviewNotes ?? []);
|
|
29
|
+
writeList(stdout, "Scan warnings", result.scan.warnings ?? []);
|
|
30
|
+
writeSafetyDefault(stdout, result.safety);
|
|
31
|
+
writeNextCommands(stdout, {
|
|
32
|
+
command: commandPrefix(runtime),
|
|
33
|
+
dir: options.get("dir")
|
|
34
|
+
});
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runUninstallCommand(options, stdout) {
|
|
39
|
+
if (options.get("remove-config") === "true" && options.get("reset-config") === "true") {
|
|
40
|
+
throw new Error("--remove-config and --reset-config are mutually exclusive");
|
|
41
|
+
}
|
|
42
|
+
const root = resolve(options.get("dir") ?? ".");
|
|
43
|
+
const result = await uninstallProject({
|
|
44
|
+
root,
|
|
45
|
+
dryRun: options.get("dry-run") === "true",
|
|
46
|
+
removeConfig: options.get("remove-config") === "true",
|
|
47
|
+
resetConfig: options.get("reset-config") === "true",
|
|
48
|
+
removeReports: options.get("remove-reports") === "true",
|
|
49
|
+
removeHooks: options.get("remove-hooks") === "true",
|
|
50
|
+
removeEmptyDirs: options.get("keep-empty-dirs") !== "true"
|
|
51
|
+
});
|
|
52
|
+
stdout.write(`${result.dryRun ? "Dry run: " : ""}Uninstalled SkillBoard: ${root}\n`);
|
|
53
|
+
writeList(stdout, "Removed", result.removed);
|
|
54
|
+
writeList(stdout, "Updated", result.updated);
|
|
55
|
+
writeList(stdout, "Preserved", result.preserved);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeList(stdout, label, values) {
|
|
60
|
+
if (values.length > 0) {
|
|
61
|
+
stdout.write(`${label}: ${formatList(values)}\n`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeCountedList(stdout, label, values) {
|
|
66
|
+
if (values.length === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
stdout.write(`${label}: ${values.length}\n`);
|
|
70
|
+
for (const value of sorted(values).slice(0, 5)) {
|
|
71
|
+
stdout.write(`- \`${value}\`\n`);
|
|
72
|
+
}
|
|
73
|
+
const hidden = values.length - 5;
|
|
74
|
+
if (hidden > 0) {
|
|
75
|
+
stdout.write(`- ... ${hidden} more\n`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeSafetyDefault(stdout, safety) {
|
|
80
|
+
stdout.write("Safety default:\n");
|
|
81
|
+
stdout.write("- Installed does not mean allowed.\n");
|
|
82
|
+
if (safety.automatic === 0) {
|
|
83
|
+
stdout.write("- No automatic model invocation was enabled.\n");
|
|
84
|
+
} else {
|
|
85
|
+
stdout.write(`- ${safety.automatic} automatic skills enabled by existing policy.\n`);
|
|
86
|
+
}
|
|
87
|
+
stdout.write("- Imported local skills are manual-only.\n");
|
|
88
|
+
stdout.write("- Runtime/plugin/system skills are quarantined until reviewed.\n");
|
|
89
|
+
stdout.write(`- ${safety.automatic} automatic skills enabled\n`);
|
|
90
|
+
stdout.write(`- ${safety.manualOnly} manual-only skills available\n`);
|
|
91
|
+
stdout.write(`- ${safety.routerOnly} router-only skills available\n`);
|
|
92
|
+
stdout.write(`- ${safety.blocked} blocked/quarantined for safety\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeNextCommands(stdout, next) {
|
|
96
|
+
const dir = next.dir === undefined ? "" : ` --dir ${shellQuote(next.dir)}`;
|
|
97
|
+
stdout.write("Next:\n");
|
|
98
|
+
stdout.write(`- ${next.command} doctor${dir} --summary\n`);
|
|
99
|
+
stdout.write(`- ${next.command} brief${dir}\n`);
|
|
100
|
+
stdout.write(`- ${next.command} brief${dir} --verbose\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatList(values) {
|
|
104
|
+
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sorted(values) {
|
|
108
|
+
return [...values].sort((left, right) => left.localeCompare(right));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readCsv(value) {
|
|
112
|
+
if (value === undefined || value.trim() === "") {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultRuntime() {
|
|
119
|
+
return {
|
|
120
|
+
cwd: process.cwd(),
|
|
121
|
+
entrypointPath: process.argv[1],
|
|
122
|
+
packageSpec: process.env.npm_config_package
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function commandPrefix(runtime) {
|
|
127
|
+
const entrypoint = runtime.entrypointPath ?? "";
|
|
128
|
+
const normalized = entrypoint.replaceAll("\\", "/");
|
|
129
|
+
if (normalized.includes("/_npx/")) {
|
|
130
|
+
if (runtime.packageSpec !== undefined && runtime.packageSpec !== "agent-skillboard") {
|
|
131
|
+
return `npm exec --yes --package ${shellQuote(runtime.packageSpec)} -- skillboard`;
|
|
132
|
+
}
|
|
133
|
+
return "npx agent-skillboard";
|
|
134
|
+
}
|
|
135
|
+
if (isSourceTreeEntrypoint(entrypoint)) {
|
|
136
|
+
return `node ${sourceTreeEntrypoint(entrypoint, runtime.cwd ?? process.cwd())}`;
|
|
137
|
+
}
|
|
138
|
+
return "skillboard";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isSourceTreeEntrypoint(entrypoint) {
|
|
142
|
+
if (entrypoint === "") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
const normalized = entrypoint.replaceAll("\\", "/");
|
|
146
|
+
return (normalized === "bin/skillboard.mjs" || normalized.endsWith("/bin/skillboard.mjs"))
|
|
147
|
+
&& !normalized.includes("/node_modules/")
|
|
148
|
+
&& !normalized.includes("/_npx/")
|
|
149
|
+
&& !normalized.includes("/.npm/");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sourceTreeEntrypoint(entrypoint, cwd) {
|
|
153
|
+
const absoluteEntrypoint = isAbsolute(entrypoint) ? entrypoint : resolve(cwd, entrypoint);
|
|
154
|
+
const relativeEntrypoint = relative(cwd, absoluteEntrypoint).replaceAll("\\", "/");
|
|
155
|
+
if (!relativeEntrypoint.startsWith("../") && relativeEntrypoint !== ".." && !isAbsolute(relativeEntrypoint)) {
|
|
156
|
+
return shellQuote(relativeEntrypoint);
|
|
157
|
+
}
|
|
158
|
+
return shellQuote(absoluteEntrypoint);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function shellQuote(value) {
|
|
162
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const BRIDGE_START = "<!-- BEGIN SKILLBOARD -->";
|
|
2
|
+
export const BRIDGE_END = "<!-- END SKILLBOARD -->";
|
|
3
|
+
|
|
4
|
+
export function defaultConfig() {
|
|
5
|
+
return `version: 1
|
|
6
|
+
defaults:
|
|
7
|
+
invocation_policy: deny-by-default
|
|
8
|
+
allow_model_invocation: false
|
|
9
|
+
require_explicit_workflow: true
|
|
10
|
+
|
|
11
|
+
skills: {}
|
|
12
|
+
capabilities: {}
|
|
13
|
+
harnesses: {}
|
|
14
|
+
workflows: {}
|
|
15
|
+
install_units: {}
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bridgeBlock() {
|
|
20
|
+
return `${BRIDGE_START}
|
|
21
|
+
# SkillBoard Control Plane
|
|
22
|
+
|
|
23
|
+
This project uses SkillBoard as the source of truth for agent skill activation.
|
|
24
|
+
|
|
25
|
+
- Read \`skillboard.config.yaml\` before assuming an installed skill is active.
|
|
26
|
+
- Installed \`SKILL.md\` files are not automatically callable.
|
|
27
|
+
- When a user asks what skills are available, run \`skillboard brief --json --config skillboard.config.yaml --skills skills\` before answering. If the workflow is known, include \`--workflow <name>\`; add \`--include-actions\` only when the user wants machine-readable change suggestions.
|
|
28
|
+
- Treat the brief sections headed "What your AI can use now", "Needs your decision", and "Blocked for safety" as the availability summary; do not infer availability from \`SKILL.md\` bodies.
|
|
29
|
+
- Treat "Needs your decision" as a one-time decision queue, not a persistent blocked state. "Blocked for safety" means the skill/source/workflow is hard-blocked until policy or provenance changes.
|
|
30
|
+
- Use \`skillboard can-use <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills --json\` for machine-readable agent decisions.
|
|
31
|
+
- Approval loop for action cards: read the current \`skillboard brief --json\`, pick one current action id, ask the user for confirmation, then run \`skillboard apply-action <action-id> --config skillboard.config.yaml --skills skills --yes --json\` and include \`--workflow <name>\` when a workflow is selected.
|
|
32
|
+
- After \`skillboard apply-action <action-id> ... --yes --json\`, read the returned post-apply brief before answering the next availability question or applying another action. \`apply-action\` re-resolves current actions; do not apply cached or stale action ids, do not apply multiple actions, and do not run raw action-card shell text as the primary apply path.
|
|
33
|
+
- Immediately before actual invocation, run \`skillboard guard use <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills\`.
|
|
34
|
+
- Run \`skillboard audit sources --config skillboard.config.yaml --skills skills\` before trusting newly imported external skill sources.
|
|
35
|
+
- Run \`skillboard review install-unit <unit-id> --trust-level reviewed --config skillboard.config.yaml --skills skills\` after reviewing an external source and before enabling automatic invocation from it.
|
|
36
|
+
- Manual underlying hook detail: preview guard hook installation with \`skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json\`; inspect \`planned.preview.shell\` before materializing an executable guard hook outside the action-card approval loop.
|
|
37
|
+
- Prefer workflow-scoped skills over global skill invocation.
|
|
38
|
+
- Only \`global-meta\` skills may be treated as globally available.
|
|
39
|
+
- Run \`skillboard doctor --config skillboard.config.yaml --skills skills\` or \`skillboard status --config skillboard.config.yaml --skills skills --json\` to inspect control-plane health; add \`--strict\` when review-needed safe mode should fail automation.
|
|
40
|
+
- Run \`skillboard check --config skillboard.config.yaml --skills skills\` when policy state matters.
|
|
41
|
+
- Run \`skillboard dashboard --config skillboard.config.yaml --skills skills --out .skillboard/reports/skill-map.md\` to refresh the visible control map.
|
|
42
|
+
- Run \`skillboard add skill <skill-id> --path <relative-skill-path> --config skillboard.config.yaml --skills skills\` to register a user-owned skill without treating the file as automatically active.
|
|
43
|
+
- Run \`skillboard add harness <harness-name> --config skillboard.config.yaml --skills skills\` and \`skillboard add workflow <workflow-name> --harness <harness-name> --config skillboard.config.yaml --skills skills --skill <skill-id>\` to add local growth paths without editing YAML by hand.
|
|
44
|
+
- Use \`skillboard activate <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills\` and \`skillboard block <skill-id> --workflow <name> --config skillboard.config.yaml --skills skills\` for workflow-scoped enablement.
|
|
45
|
+
- Use \`skillboard remove skill <skill-id> --config skillboard.config.yaml --skills skills --force\` only after reviewing \`skillboard impact disable <skill-id> --config skillboard.config.yaml --skills skills\`; removal updates policy references while leaving \`SKILL.md\` files on disk.
|
|
46
|
+
- Run \`skillboard inventory refresh --dry-run --config skillboard.config.yaml\` after installing a new local agent skill pack, plugin, workflow bundle, or harness.
|
|
47
|
+
- Run \`skillboard import --profile <id-or-path> --source-root <repo> --out .skillboard/reports/import-fragment.yaml\` after installing a new skill repository, then review the fragment before merging it into \`skillboard.config.yaml\`.
|
|
48
|
+
|
|
49
|
+
${BRIDGE_END}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function profileReadme() {
|
|
53
|
+
return `# SkillBoard source profiles
|
|
54
|
+
|
|
55
|
+
Put project-specific source profiles here when a skill repository or harness
|
|
56
|
+
bundle is not covered by a built-in profile.
|
|
57
|
+
|
|
58
|
+
Use:
|
|
59
|
+
|
|
60
|
+
\`\`\`bash
|
|
61
|
+
skillboard import --profile .skillboard/profiles/example.yaml --source-root /path/to/repo
|
|
62
|
+
\`\`\`
|
|
63
|
+
|
|
64
|
+
The import command emits a YAML fragment with governed \`skills\` and
|
|
65
|
+
\`install_units\`. Review the fragment before merging it into
|
|
66
|
+
\`skillboard.config.yaml\`; imported skills are not automatically active.
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hookReadme() {
|
|
71
|
+
return `# SkillBoard hooks
|
|
72
|
+
|
|
73
|
+
Use this directory for executable guard scripts generated by:
|
|
74
|
+
|
|
75
|
+
\`\`\`bash
|
|
76
|
+
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh --dry-run --json
|
|
77
|
+
skillboard hook install --workflow <name> --config skillboard.config.yaml --skills skills --out .skillboard/hooks/<name>-guard.sh
|
|
78
|
+
\`\`\`
|
|
79
|
+
|
|
80
|
+
For hook action cards, prefer the bridge approval loop with
|
|
81
|
+
\`skillboard apply-action <action-id> --yes --json\`. These raw hook commands
|
|
82
|
+
are the underlying manual detail: preview the JSON plan first and inspect
|
|
83
|
+
\`planned.preview.shell\` before materializing an executable guard hook.
|
|
84
|
+
The generated script delegates to \`skillboard guard use\` and can be wired into
|
|
85
|
+
the hook mechanism of the active harness.
|
|
86
|
+
`;
|
|
87
|
+
}
|
package/src/policy.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { buildPolicyContext } from "./domain/indexes.mjs";
|
|
2
|
+
import { capabilityRules } from "./domain/rules/capabilities.mjs";
|
|
3
|
+
import { harnessRules } from "./domain/rules/harnesses.mjs";
|
|
4
|
+
import { installUnitRules } from "./domain/rules/install-units.mjs";
|
|
5
|
+
import { skillRules } from "./domain/rules/skills.mjs";
|
|
6
|
+
import { workflowRules } from "./domain/rules/workflows.mjs";
|
|
7
|
+
|
|
8
|
+
const RULES = [
|
|
9
|
+
...workflowRules,
|
|
10
|
+
...skillRules,
|
|
11
|
+
...capabilityRules,
|
|
12
|
+
...harnessRules,
|
|
13
|
+
...installUnitRules
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function checkPolicy(workspace) {
|
|
17
|
+
const ctx = buildPolicyContext(workspace);
|
|
18
|
+
const errors = [];
|
|
19
|
+
const warnings = [];
|
|
20
|
+
|
|
21
|
+
for (const rule of RULES) {
|
|
22
|
+
for (const diagnostic of rule.check(ctx)) {
|
|
23
|
+
const normalized = normalizeDiagnostic(rule, diagnostic);
|
|
24
|
+
if (normalized.severity === "warning") {
|
|
25
|
+
warnings.push(normalized.message);
|
|
26
|
+
} else {
|
|
27
|
+
errors.push(normalized.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeDiagnostic(rule, diagnostic) {
|
|
36
|
+
const severity = typeof diagnostic === "string" ? rule.severity ?? "error" : diagnostic.severity ?? rule.severity ?? "error";
|
|
37
|
+
const message = typeof diagnostic === "string" ? diagnostic : diagnostic.message;
|
|
38
|
+
return {
|
|
39
|
+
severity,
|
|
40
|
+
message: `[${rule.id}] ${message}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const SAFE_HARNESS_STATUSES = new Set(["available", "configured", "primary", "fallback"]);
|
|
2
|
+
|
|
3
|
+
export function reconcileWorkspace(workspace, options = {}) {
|
|
4
|
+
const configuredSkills = new Set(workspace.skills.map((skill) => skill.id));
|
|
5
|
+
const actualHarnesses = options.actualHarnesses ?? [];
|
|
6
|
+
const plan = {
|
|
7
|
+
skillChanges: [],
|
|
8
|
+
harnessChanges: [],
|
|
9
|
+
autoActions: [],
|
|
10
|
+
decisionsRequired: [],
|
|
11
|
+
warnings: []
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
for (const skill of workspace.installedSkills) {
|
|
15
|
+
if (configuredSkills.has(skill.id)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const capability = findCapabilityForSkill(workspace, skill.id);
|
|
19
|
+
const recommendedInvocation = recommendedInvocationFor(capability);
|
|
20
|
+
const change = {
|
|
21
|
+
type: "new-skill",
|
|
22
|
+
skillId: skill.id,
|
|
23
|
+
capability: capability?.name ?? "uncategorized",
|
|
24
|
+
recommendedStatus: "quarantined",
|
|
25
|
+
recommendedInvocation
|
|
26
|
+
};
|
|
27
|
+
plan.skillChanges.push(change);
|
|
28
|
+
plan.autoActions.push({
|
|
29
|
+
action: "quarantine-skill",
|
|
30
|
+
skillId: skill.id,
|
|
31
|
+
capability: capability?.name ?? "uncategorized",
|
|
32
|
+
recommendedStatus: "quarantined",
|
|
33
|
+
recommendedInvocation
|
|
34
|
+
});
|
|
35
|
+
plan.decisionsRequired.push(
|
|
36
|
+
`Classify ${skill.id}: keep quarantined, approve manual-only for a workflow, or archive as duplicate.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (actualHarnesses.length === 0) {
|
|
41
|
+
plan.warnings.push("Actual harness inventory was not provided; harness reconciliation skipped.");
|
|
42
|
+
} else {
|
|
43
|
+
reconcileHarnesses(workspace, new Set(actualHarnesses), plan);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return plan;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function reconcileHarnesses(workspace, actualHarnesses, plan) {
|
|
50
|
+
const desiredHarnesses = new Set(workspace.harnesses.map((harness) => harness.name));
|
|
51
|
+
|
|
52
|
+
for (const harness of workspace.harnesses) {
|
|
53
|
+
if (!SAFE_HARNESS_STATUSES.has(harness.status) || actualHarnesses.has(harness.name)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const affectedWorkflows = harness.workflows.length === 0
|
|
57
|
+
? workflowsUsingHarness(workspace, harness.name)
|
|
58
|
+
: harness.workflows;
|
|
59
|
+
const change = {
|
|
60
|
+
type: "removed-harness",
|
|
61
|
+
harness: harness.name,
|
|
62
|
+
affectedWorkflows,
|
|
63
|
+
missingCommands: harness.commands,
|
|
64
|
+
recommendations: [
|
|
65
|
+
"assign a fallback harness before applying workflow changes",
|
|
66
|
+
"replace missing commands with capability-backed workflow steps"
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
plan.harnessChanges.push(change);
|
|
70
|
+
plan.decisionsRequired.push(
|
|
71
|
+
`Migrate ${harness.name}: ${affectedWorkflows.length} workflow(s) need a fallback harness or command mapping.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const harness of actualHarnesses) {
|
|
76
|
+
if (desiredHarnesses.has(harness)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
plan.harnessChanges.push({
|
|
80
|
+
type: "new-harness",
|
|
81
|
+
harness,
|
|
82
|
+
affectedWorkflows: [],
|
|
83
|
+
missingCommands: [],
|
|
84
|
+
recommendations: ["record the harness as disabled until workflows explicitly opt in"]
|
|
85
|
+
});
|
|
86
|
+
plan.autoActions.push({
|
|
87
|
+
action: "disable-harness",
|
|
88
|
+
harness,
|
|
89
|
+
recommendedStatus: "disabled"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function findCapabilityForSkill(workspace, skillId) {
|
|
95
|
+
return workspace.capabilities.find((candidate) => {
|
|
96
|
+
return candidate.canonical === skillId || candidate.alternatives.includes(skillId);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function recommendedInvocationFor(capability) {
|
|
101
|
+
if (capability === undefined || capability.defaultPolicy === "global-auto") {
|
|
102
|
+
return "blocked";
|
|
103
|
+
}
|
|
104
|
+
return capability.defaultPolicy;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function workflowsUsingHarness(workspace, harnessName) {
|
|
108
|
+
return workspace.workflows
|
|
109
|
+
.filter((workflow) => workflow.harness === harnessName)
|
|
110
|
+
.map((workflow) => workflow.name);
|
|
111
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export function renderDashboard(workspace) {
|
|
2
|
+
const lines = ["# SkillBoard", "", "## Defaults", ""];
|
|
3
|
+
lines.push(`- Invocation policy: \`${workspace.defaults.invocationPolicy}\``);
|
|
4
|
+
lines.push(`- Model auto invocation: \`${workspace.defaults.allowModelInvocation}\``);
|
|
5
|
+
lines.push(`- Explicit workflow required: \`${workspace.defaults.requireExplicitWorkflow}\``);
|
|
6
|
+
lines.push("", "## Workflows", "");
|
|
7
|
+
|
|
8
|
+
for (const workflow of workspace.workflows) {
|
|
9
|
+
lines.push(`### ${workflow.name}`, "");
|
|
10
|
+
lines.push(`- Harness: \`${workflow.harness}\``);
|
|
11
|
+
lines.push(`- required outputs: ${formatList(workflow.requiredOutputs)}`);
|
|
12
|
+
emitSkillGroup(lines, "active", workflow.activeSkills, workspace);
|
|
13
|
+
emitSkillGroup(lines, "blocked", workflow.blockedSkills, workspace);
|
|
14
|
+
emitCapabilityRequirements(lines, workflow.requiredCapabilities);
|
|
15
|
+
lines.push("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
lines.push("## Capabilities", "");
|
|
19
|
+
if (workspace.capabilities.length === 0) {
|
|
20
|
+
lines.push("- none");
|
|
21
|
+
}
|
|
22
|
+
for (const capability of workspace.capabilities) {
|
|
23
|
+
lines.push(`- \`${capability.name}\` — canonical: \`${capability.canonical || "none"}\`, alternatives: ${formatList(capability.alternatives)}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push("", "## Harnesses", "");
|
|
26
|
+
if (workspace.harnesses.length === 0) {
|
|
27
|
+
lines.push("- none");
|
|
28
|
+
}
|
|
29
|
+
for (const harness of workspace.harnesses) {
|
|
30
|
+
lines.push(`- \`${harness.name}\` — ${harness.status}, workflows: ${formatList(harness.workflows)}`);
|
|
31
|
+
}
|
|
32
|
+
lines.push("");
|
|
33
|
+
|
|
34
|
+
lines.push("## Agent Runtime Install Units", "");
|
|
35
|
+
if (workspace.installUnits.length === 0) {
|
|
36
|
+
lines.push("- none");
|
|
37
|
+
}
|
|
38
|
+
for (const unit of workspace.installUnits) {
|
|
39
|
+
lines.push(`### ${unit.id}`, "");
|
|
40
|
+
lines.push(`- Kind: \`${unit.kind}\``);
|
|
41
|
+
lines.push(`- Scope: \`${unit.scope}\``);
|
|
42
|
+
lines.push(`- Source: \`${unit.source || "unknown"}\``);
|
|
43
|
+
lines.push(`- Enabled: \`${unit.enabled}\``);
|
|
44
|
+
lines.push(`- Auto update: \`${unit.autoUpdate}\``);
|
|
45
|
+
lines.push(`- Manifest: \`${unit.manifestPath || "none"}\``);
|
|
46
|
+
lines.push(`- Cache: \`${unit.cachePath || "none"}\``);
|
|
47
|
+
lines.push(`- Provides: ${formatList(unit.providedComponents)}`);
|
|
48
|
+
lines.push(`- Skills: ${formatList(unit.components.skills)}`);
|
|
49
|
+
lines.push(`- Commands: ${formatList(unit.components.commands)}`);
|
|
50
|
+
lines.push(`- Hooks: ${formatList(unit.components.hooks)}`);
|
|
51
|
+
lines.push(`- MCP servers: ${formatList(unit.components.mcpServers)}`);
|
|
52
|
+
lines.push(`- Modifies: ${formatList(unit.modifiedConfigFiles)}`);
|
|
53
|
+
lines.push(`- Workflow dependencies: ${formatList(unit.workflowDependencies)}`);
|
|
54
|
+
lines.push(`- Trust level: \`${unit.trustLevel}\``);
|
|
55
|
+
lines.push(`- permission risk: \`${unit.permissionRisk}\``);
|
|
56
|
+
lines.push(`- Rollback: \`${unit.rollback}\``);
|
|
57
|
+
lines.push("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push("## Skill Inventory", "");
|
|
61
|
+
for (const skill of workspace.skills) {
|
|
62
|
+
lines.push(`- \`${skill.id}\` — ${skill.status}, ${skill.invocation}, ${skill.exposure}, ${skill.category}, owner: \`${skill.ownerInstallUnit ?? "none"}\``);
|
|
63
|
+
}
|
|
64
|
+
lines.push("", "## Installed Skill Files", "");
|
|
65
|
+
if (workspace.installedSkills.length === 0) {
|
|
66
|
+
lines.push("- none");
|
|
67
|
+
}
|
|
68
|
+
for (const skill of workspace.installedSkills) {
|
|
69
|
+
lines.push(`- \`${skill.path}\` — ${skill.name}: ${skill.description}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push("");
|
|
72
|
+
return `${lines.join("\n")}\n`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderReconcilePlan(plan) {
|
|
76
|
+
const lines = ["# SkillBoard Reconcile Plan", "", "## Warnings", ""];
|
|
77
|
+
if (plan.warnings.length === 0) {
|
|
78
|
+
lines.push("- none");
|
|
79
|
+
}
|
|
80
|
+
for (const warning of plan.warnings) {
|
|
81
|
+
lines.push(`- ${warning}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push("", "## Automatic Actions", "");
|
|
84
|
+
if (plan.autoActions.length === 0) {
|
|
85
|
+
lines.push("- none");
|
|
86
|
+
}
|
|
87
|
+
for (const action of plan.autoActions) {
|
|
88
|
+
if (action.action === "quarantine-skill") {
|
|
89
|
+
lines.push(`- quarantine \`${action.skillId}\` as \`${action.recommendedStatus}\` / \`${action.recommendedInvocation}\` for capability \`${action.capability}\``);
|
|
90
|
+
} else if (action.action === "disable-harness") {
|
|
91
|
+
lines.push(`- disable new harness \`${action.harness}\` until a workflow opts in`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push("", "## Skill Changes", "");
|
|
96
|
+
if (plan.skillChanges.length === 0) {
|
|
97
|
+
lines.push("- none");
|
|
98
|
+
}
|
|
99
|
+
for (const change of plan.skillChanges) {
|
|
100
|
+
lines.push(`- \`${change.skillId}\`: ${change.type}, capability \`${change.capability}\`, recommend \`${change.recommendedStatus}\``);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
lines.push("", "## Harness Changes", "");
|
|
104
|
+
if (plan.harnessChanges.length === 0) {
|
|
105
|
+
lines.push("- none");
|
|
106
|
+
}
|
|
107
|
+
for (const change of plan.harnessChanges) {
|
|
108
|
+
lines.push(`- \`${change.harness}\`: ${change.type}`);
|
|
109
|
+
lines.push(` - affected workflows: ${formatList(change.affectedWorkflows)}`);
|
|
110
|
+
lines.push(` - missing commands: ${formatList(change.missingCommands)}`);
|
|
111
|
+
lines.push(` - recommendations: ${formatList(change.recommendations)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lines.push("", "## Decisions Required", "");
|
|
115
|
+
if (plan.decisionsRequired.length === 0) {
|
|
116
|
+
lines.push("- none");
|
|
117
|
+
}
|
|
118
|
+
for (const decision of plan.decisionsRequired) {
|
|
119
|
+
lines.push(`- ${decision}`);
|
|
120
|
+
}
|
|
121
|
+
lines.push("");
|
|
122
|
+
return `${lines.join("\n")}\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function emitSkillGroup(lines, label, skillIds, workspace) {
|
|
126
|
+
lines.push(`- ${label}:`);
|
|
127
|
+
if (skillIds.length === 0) {
|
|
128
|
+
lines.push(" - none");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
for (const skillId of skillIds) {
|
|
132
|
+
const skill = workspace.skills.find((candidate) => candidate.id === skillId);
|
|
133
|
+
const invocation = skill === undefined ? "missing" : skill.invocation;
|
|
134
|
+
lines.push(` - \`${skillId}\` (${invocation})`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function emitCapabilityRequirements(lines, capabilities) {
|
|
139
|
+
lines.push("- required capabilities:");
|
|
140
|
+
if (capabilities.length === 0) {
|
|
141
|
+
lines.push(" - none");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const capability of capabilities) {
|
|
145
|
+
lines.push(` - \`${capability.name}\` preferred \`${capability.preferred || "none"}\`, fallback: ${formatList(capability.fallback)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatList(values) {
|
|
150
|
+
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
151
|
+
}
|
package/src/review.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { textChangePlan } from "./change-plan.mjs";
|
|
6
|
+
import { TRUST_LEVEL_VALUES } from "./domain/constants.mjs";
|
|
7
|
+
import { checkPolicy } from "./policy.mjs";
|
|
8
|
+
import { loadWorkspace } from "./workspace.mjs";
|
|
9
|
+
|
|
10
|
+
export async function reviewInstallUnit(options) {
|
|
11
|
+
const trustLevel = options.trustLevel ?? "reviewed";
|
|
12
|
+
if (!TRUST_LEVEL_VALUES.has(trustLevel)) {
|
|
13
|
+
throw new Error(`review install-unit requires --trust-level ${[...TRUST_LEVEL_VALUES].join("|")}; got ${trustLevel}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { document, originalText } = await loadConfig(options.configPath);
|
|
17
|
+
const installUnits = requireMapAt(document, ["install_units"], "install_units");
|
|
18
|
+
const unit = installUnits.get(options.unitId, true);
|
|
19
|
+
if (unit === undefined) {
|
|
20
|
+
throw new Error(`Unknown install unit: ${options.unitId}`);
|
|
21
|
+
}
|
|
22
|
+
const unitMap = requireYamlMap(unit, `install_units.${options.unitId}`);
|
|
23
|
+
unitMap.set("trust_level", trustLevel);
|
|
24
|
+
if (trustLevel === "blocked") {
|
|
25
|
+
unitMap.set("enabled", false);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return await writeCheckedConfig(
|
|
29
|
+
document,
|
|
30
|
+
originalText,
|
|
31
|
+
options,
|
|
32
|
+
`Reviewed install unit ${options.unitId} as ${trustLevel}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function loadConfig(path) {
|
|
37
|
+
const text = await readFile(path, "utf8");
|
|
38
|
+
const document = YAML.parseDocument(text);
|
|
39
|
+
if (document.errors.length > 0) {
|
|
40
|
+
throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
|
|
41
|
+
}
|
|
42
|
+
requireYamlMap(document.contents, "config root");
|
|
43
|
+
return { document, originalText: text };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function writeCheckedConfig(document, originalText, options, message) {
|
|
47
|
+
const nextText = preserveLineEndings(String(document), originalText);
|
|
48
|
+
const plan = textChangePlan(originalText, nextText);
|
|
49
|
+
const tempPath = join(dirname(options.configPath), `.${basename(options.configPath)}.${randomUUID()}.tmp`);
|
|
50
|
+
await writeFile(tempPath, nextText, { encoding: "utf8", flag: "wx" });
|
|
51
|
+
try {
|
|
52
|
+
const workspace = await loadWorkspace({ configPath: tempPath, skillsRoot: options.skillsRoot });
|
|
53
|
+
const policy = checkPolicy(workspace);
|
|
54
|
+
if (!policy.ok) {
|
|
55
|
+
throw new Error(`Policy update would create invalid config:\n${policy.errors.join("\n")}`);
|
|
56
|
+
}
|
|
57
|
+
if (options.dryRun === true) {
|
|
58
|
+
return { message, policy, dryRun: true, changed: plan.changed, plan };
|
|
59
|
+
}
|
|
60
|
+
if (plan.changed) {
|
|
61
|
+
await rename(tempPath, options.configPath);
|
|
62
|
+
}
|
|
63
|
+
return { message, policy, dryRun: false, changed: plan.changed, plan };
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(tempPath, { force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireMapAt(document, path, label) {
|
|
70
|
+
const value = document.getIn(path, true);
|
|
71
|
+
if (value === undefined) {
|
|
72
|
+
const map = document.createNode({});
|
|
73
|
+
document.setIn(path, map);
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
return requireYamlMap(value, label);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function requireYamlMap(value, label) {
|
|
80
|
+
if (!YAML.isMap(value)) {
|
|
81
|
+
throw new Error(`${label} must be a mapping`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function preserveLineEndings(text, reference) {
|
|
87
|
+
return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
|
|
88
|
+
}
|