agent-skillboard 0.2.17 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +128 -260
  3. package/bin/postinstall.mjs +2 -2
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +35 -109
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +39 -493
  8. package/docs/policy-model.md +50 -280
  9. package/docs/positioning.md +19 -86
  10. package/docs/profiles.md +21 -153
  11. package/docs/reference.md +117 -356
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +60 -292
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +31 -264
  18. package/examples/v2-multi-source.config.yaml +35 -0
  19. package/examples/v2-policy-error.config.yaml +6 -0
  20. package/package.json +1 -1
  21. package/src/advisor/actions.mjs +102 -6
  22. package/src/advisor/application-commands.mjs +10 -9
  23. package/src/advisor/apply-action.mjs +74 -1
  24. package/src/advisor/guidance.mjs +24 -16
  25. package/src/advisor/schema.mjs +17 -6
  26. package/src/advisor/skills.mjs +18 -5
  27. package/src/advisor.mjs +27 -9
  28. package/src/agent-integration-cli.mjs +13 -4
  29. package/src/agent-integration-content.mjs +22 -12
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-inventory-platforms.mjs +10 -0
  32. package/src/agent-inventory.mjs +23 -1
  33. package/src/agent-skill-import.mjs +2 -2
  34. package/src/audit-paths.mjs +42 -0
  35. package/src/brief-cli.mjs +3 -2
  36. package/src/brief-renderer.mjs +1 -0
  37. package/src/cli.mjs +398 -127
  38. package/src/compatibility.mjs +24 -0
  39. package/src/control/can-use-guard.mjs +21 -1
  40. package/src/control/config-write.mjs +32 -2
  41. package/src/control/skill-crud.mjs +5 -0
  42. package/src/control/v2-guard.mjs +175 -0
  43. package/src/control/v2-skill-crud.mjs +32 -0
  44. package/src/control/v2-skill-forget.mjs +38 -0
  45. package/src/control/variant-status.mjs +47 -1
  46. package/src/control.mjs +55 -0
  47. package/src/doctor.mjs +65 -6
  48. package/src/domain/v2-policy.mjs +111 -0
  49. package/src/hook-plan.mjs +33 -3
  50. package/src/impact.mjs +52 -29
  51. package/src/index.mjs +25 -1
  52. package/src/init.mjs +50 -34
  53. package/src/inventory-install-units.mjs +63 -0
  54. package/src/inventory-json.mjs +279 -0
  55. package/src/inventory-refresh.mjs +163 -18
  56. package/src/lifecycle-cli.mjs +40 -12
  57. package/src/lifecycle-content.mjs +52 -67
  58. package/src/migration/v1-to-v2.mjs +212 -0
  59. package/src/migration/v2-files.mjs +211 -0
  60. package/src/migration/v2-journal.mjs +169 -0
  61. package/src/migration/v2-projection.mjs +108 -0
  62. package/src/migration/v2-transaction.mjs +205 -0
  63. package/src/policy.mjs +3 -0
  64. package/src/reconcile.mjs +139 -111
  65. package/src/report.mjs +168 -148
  66. package/src/review.mjs +2 -0
  67. package/src/route-advisory.mjs +47 -2
  68. package/src/route-selection.mjs +38 -2
  69. package/src/route.mjs +62 -2
  70. package/src/shared-skill.mjs +301 -0
  71. package/src/source-digest.mjs +42 -0
  72. package/src/source-profiles.mjs +27 -0
  73. package/src/source-verification.mjs +32 -48
  74. package/src/uninstall.mjs +22 -0
  75. package/src/user-state-paths.mjs +19 -0
  76. package/src/user-uninstall.mjs +146 -0
  77. package/src/workspace.mjs +41 -1
@@ -1,11 +1,14 @@
1
- import { lstat, readFile, readdir, readlink, writeFile } from "node:fs/promises";
2
- import { createHash, verify as verifySignature } from "node:crypto";
1
+ import { lstat, readFile, writeFile } from "node:fs/promises";
2
+ import { verify as verifySignature } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import YAML from "yaml";
6
+ import { auditPath, expandPortablePath, portableObservation, redactPathError } from "./audit-paths.mjs";
6
7
  import { installUnitSourceClass, isLocalSourceReference } from "./domain/source-classes.mjs";
8
+ import { V1_MUTATION_ERROR } from "./compatibility.mjs";
9
+ import { skillContentDigest, sourceDigest } from "./source-digest.mjs";
7
10
 
8
- const DIGEST_PREFIX = "sha256:";
11
+ export { skillContentDigest, sourceDigest };
9
12
 
10
13
  export async function verifySources(workspace, options = {}) {
11
14
  const configDir = options.configPath === undefined ? process.cwd() : dirname(resolve(options.configPath));
@@ -24,6 +27,7 @@ export async function verifySources(workspace, options = {}) {
24
27
  }
25
28
 
26
29
  export async function writeLockfile(workspace, options) {
30
+ if (workspace.version === 1) throw new Error(V1_MUTATION_ERROR);
27
31
  const verified = await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
28
32
  if (!verified.ok && options.allowUnverified !== true) {
29
33
  throw new Error(`Cannot write lockfile because source verification failed:\n${verified.errors.join("\n")}`);
@@ -34,6 +38,7 @@ export async function writeLockfile(workspace, options) {
34
38
  }
35
39
 
36
40
  export async function renderLockfile(workspace, options = {}) {
41
+ if (workspace.version === 2) return renderV2Lockfile(workspace, options);
37
42
  const verified = options.verified ?? await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
38
43
  const generatedAt = options.generatedAt ?? new Date().toISOString();
39
44
  const skillDigests = await skillContentDigests(workspace, options.skillsRoot);
@@ -76,6 +81,20 @@ export async function renderLockfile(workspace, options = {}) {
76
81
  });
77
82
  }
78
83
 
84
+ async function renderV2Lockfile(workspace, options) {
85
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
86
+ return YAML.stringify({
87
+ version: 2,
88
+ policy_projection_version: 2,
89
+ generated_at: generatedAt,
90
+ audit_authoritative_for_availability: false,
91
+ skills: Object.fromEntries(workspace.skills.map((skill) => [skill.id, {
92
+ enabled: skill.enabled,
93
+ shared: skill.shared
94
+ }]))
95
+ });
96
+ }
97
+
79
98
  async function verifyInstallUnit(unit, options) {
80
99
  const target = await verificationTarget(unit, options);
81
100
  const localPath = target.path;
@@ -87,7 +106,7 @@ async function verifyInstallUnit(unit, options) {
87
106
 
88
107
  if (localPath !== null && options.restrictToRoot === true && !isPathInside(options.rootDir, localPath)) {
89
108
  status = "unverified";
90
- findings.push({ severity: "error", message: `${target.field} is outside the allowed root: ${localPath}` });
109
+ findings.push({ severity: "error", message: `${target.field} is outside the allowed root: ${auditPath(localPath, options)}` });
91
110
  } else if (localPath === null) {
92
111
  if (unit.sourceDigest === undefined) {
93
112
  findings.push({ severity: "warning", message: "remote or command source has no source_digest pin" });
@@ -117,16 +136,16 @@ async function verifyInstallUnit(unit, options) {
117
136
  }
118
137
  } catch (error) {
119
138
  status = "unverified";
120
- findings.push({ severity: "error", message: `cannot verify local source: ${error instanceof Error ? error.message : String(error)}` });
139
+ findings.push({ severity: "error", message: `cannot verify local source: ${redactPathError(error, options)}` });
121
140
  }
122
141
  }
123
142
 
124
143
  return {
125
144
  id: unit.id,
126
145
  kind: unit.kind,
127
- source: unit.source,
128
- cachePath: unit.cachePath.length === 0 ? null : unit.cachePath,
129
- verifiedPath: localPath,
146
+ source: portableObservation(unit.source, options),
147
+ cachePath: unit.cachePath.length === 0 ? null : portableObservation(unit.cachePath, options),
148
+ verifiedPath: localPath === null ? null : auditPath(localPath, options),
130
149
  verifiedField: target.field,
131
150
  sourceClass: installUnitSourceClass(unit),
132
151
  trustLevel: unit.trustLevel,
@@ -156,39 +175,6 @@ async function verificationTarget(unit, options) {
156
175
  return { path: await localSourcePath(unit.source, options, { allowBareRelative: false }), field: "source" };
157
176
  }
158
177
 
159
- export async function sourceDigest(path) {
160
- const hash = createHash("sha256");
161
- hash.update("skillboard-source-digest-v1\n");
162
- await addPathDigest(hash, path, path);
163
- return `${DIGEST_PREFIX}${hash.digest("hex")}`;
164
- }
165
-
166
- async function addPathDigest(hash, root, path) {
167
- const stats = await lstat(path);
168
- const rel = relative(root, path).replace(/\\/g, "/") || ".";
169
- if (stats.isSymbolicLink()) {
170
- hash.update(`symlink\0${rel}\0${await readlink(path)}\n`);
171
- return;
172
- }
173
- if (stats.isDirectory()) {
174
- hash.update(`dir\0${rel}\n`);
175
- const entries = (await readdir(path, { withFileTypes: true }))
176
- .filter((entry) => entry.name !== ".git" && entry.name !== "node_modules")
177
- .sort((left, right) => left.name.localeCompare(right.name));
178
- for (const entry of entries) {
179
- await addPathDigest(hash, root, join(path, entry.name));
180
- }
181
- return;
182
- }
183
- if (!stats.isFile()) {
184
- hash.update(`other\0${rel}\n`);
185
- return;
186
- }
187
- hash.update(`file\0${rel}\0${stats.size}\0`);
188
- hash.update(await readFile(path));
189
- hash.update("\n");
190
- }
191
-
192
178
  function verifyDigestSignature(digest, signature, publicKey) {
193
179
  try {
194
180
  return verifySignature("sha256", Buffer.from(digest), publicKey, Buffer.from(signature, "base64"));
@@ -198,7 +184,10 @@ function verifyDigestSignature(digest, signature, publicKey) {
198
184
  }
199
185
 
200
186
  async function localSourcePath(source, paths, options) {
201
- const value = source.trim();
187
+ const value = expandPortablePath(source.trim(), paths);
188
+ if (value === null) {
189
+ return null;
190
+ }
202
191
  if (!isLocalSourceReference(value, options)) {
203
192
  return null;
204
193
  }
@@ -215,6 +204,7 @@ async function localSourcePath(source, paths, options) {
215
204
  return await firstExistingPath(candidates) ?? candidates[0] ?? null;
216
205
  }
217
206
 
207
+
218
208
  async function firstExistingPath(paths) {
219
209
  for (const path of paths) {
220
210
  try {
@@ -233,12 +223,6 @@ function uniquePaths(paths) {
233
223
  return [...new Set(paths)];
234
224
  }
235
225
 
236
- export async function skillContentDigest(skillFilePath) {
237
- const hash = createHash("sha256");
238
- hash.update(await readFile(skillFilePath));
239
- return `${DIGEST_PREFIX}${hash.digest("hex")}`;
240
- }
241
-
242
226
  async function skillContentDigests(workspace, skillsRoot) {
243
227
  const digests = new Map();
244
228
  if (skillsRoot === undefined) {
package/src/uninstall.mjs CHANGED
@@ -54,6 +54,12 @@ export async function uninstallProject(options) {
54
54
  }
55
55
  recordFileResult(entry.label, result, { removed, updated, preserved });
56
56
  }
57
+ if (!keepBridge) {
58
+ const inventoryPath = join(root, ".skillboard", "inventory.json");
59
+ const inventoryResult = await removeGeneratedInventory(inventoryPath, dryRun, root);
60
+ if (inventoryResult === "removed") plannedRemovedPaths.add(inventoryPath);
61
+ recordFileResult(".skillboard/inventory.json", inventoryResult, { removed, updated, preserved });
62
+ }
57
63
  }
58
64
 
59
65
  if (options.removeReports === true && options.removeProjectState !== true) {
@@ -200,6 +206,22 @@ async function removeGeneratedFile(path, expected, dryRun, root = null) {
200
206
  return "removed";
201
207
  }
202
208
 
209
+ async function removeGeneratedInventory(path, dryRun, root) {
210
+ if (await hasUnsafeAncestor(path, root)) return "preserved";
211
+ const stats = await pathStats(path);
212
+ if (stats === null) return "absent";
213
+ if (stats.isSymbolicLink() || !stats.isFile()) return "preserved";
214
+ let inventory;
215
+ try {
216
+ inventory = JSON.parse(await readFile(path, "utf8"));
217
+ } catch {
218
+ return "preserved";
219
+ }
220
+ if (inventory?.generated !== true || inventory?.authoritative_for_availability !== false) return "preserved";
221
+ if (!dryRun) await rm(path);
222
+ return "removed";
223
+ }
224
+
203
225
  async function removeConfigFile(path, dryRun) {
204
226
  const stats = await pathStats(path);
205
227
  if (stats === null) {
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+
4
+ export function resolveUserStatePaths(options = {}) {
5
+ const home = resolve(options.home ?? options.env?.HOME ?? options.env?.USERPROFILE ?? homedir());
6
+ const configPath = options.configPath === undefined
7
+ ? join(home, "skillboard.config.yaml")
8
+ : resolveFrom(options.configPath, options.cwd ?? process.cwd());
9
+ const inventoryPath = options.inventoryPath === undefined
10
+ ? options.configPath === undefined
11
+ ? join(home, ".skillboard", "inventory.json")
12
+ : join(dirname(configPath), ".skillboard", "inventory.json")
13
+ : resolveFrom(options.inventoryPath, options.cwd ?? process.cwd());
14
+ return { home, configPath, inventoryPath, stateRoot: dirname(inventoryPath) };
15
+ }
16
+
17
+ function resolveFrom(path, cwd) {
18
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
19
+ }
@@ -0,0 +1,146 @@
1
+ import { lstat, readdir, readFile, rm } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { uninstallAgentIntegration } from "./agent-integration-files.mjs";
4
+ import { resolveSetupHome } from "./agent-integration-home.mjs";
5
+ import {
6
+ agentSkillRootCandidates,
7
+ setupAgentSkillTargets,
8
+ supportedAgentNames
9
+ } from "./agent-skill-roots.mjs";
10
+
11
+ const SHARE_MARKER = ".skillboard-share.json";
12
+
13
+ export async function uninstallUser(options = {}) {
14
+ const env = options.env ?? process.env;
15
+ const home = await resolveSetupHome(env, options.runtime ?? {});
16
+ const planned = await planUserUninstall(home, env);
17
+ if (options.dryRun) {
18
+ const guidance = await uninstallAgentIntegration(planned.guidanceTargets, true);
19
+ return result(home, true, planned.managedCopies, guidance, planned.statePaths, planned.preserved);
20
+ }
21
+
22
+ const removedCopies = [];
23
+ const preserved = [...planned.preserved];
24
+ for (const copy of planned.managedCopies) {
25
+ if (await removeManagedCopy(copy)) {
26
+ removedCopies.push(copy);
27
+ } else {
28
+ preserved.push(copy.path);
29
+ }
30
+ }
31
+ const guidance = await uninstallAgentIntegration(planned.guidanceTargets, false);
32
+ const removedState = [];
33
+ for (const state of planned.statePaths) {
34
+ if (await removeStatePath(state)) removedState.push(state);
35
+ else preserved.push(state);
36
+ }
37
+ return result(home, false, removedCopies, guidance, removedState, preserved);
38
+ }
39
+
40
+ async function planUserUninstall(home, env) {
41
+ const preserved = [];
42
+ const roots = [{ root: join(home, ".agents", "shared-skills"), agent: null }];
43
+ for (const agent of supportedAgentNames()) {
44
+ for (const candidate of await agentSkillRootCandidates(agent, home, env)) {
45
+ roots.push({ root: candidate.skillRoot, agent });
46
+ }
47
+ }
48
+ const managedCopies = [];
49
+ const seenRoots = new Set();
50
+ for (const entry of roots) {
51
+ const root = resolve(entry.root);
52
+ if (seenRoots.has(root)) continue;
53
+ seenRoots.add(root);
54
+ managedCopies.push(...await managedCopiesInRoot(root, entry.agent, preserved));
55
+ }
56
+
57
+ const guidanceTargets = [];
58
+ for (const agent of supportedAgentNames()) {
59
+ guidanceTargets.push(...await setupAgentSkillTargets(agent, home, env));
60
+ }
61
+ return {
62
+ managedCopies: managedCopies.sort((left, right) => left.path.localeCompare(right.path)),
63
+ guidanceTargets,
64
+ statePaths: await existingStatePaths(home),
65
+ preserved: [...new Set(preserved)].sort()
66
+ };
67
+ }
68
+
69
+ async function managedCopiesInRoot(root, agent, preserved) {
70
+ const stats = await pathStats(root);
71
+ if (stats === null) return [];
72
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
73
+ preserved.push(root);
74
+ return [];
75
+ }
76
+ const copies = [];
77
+ const entries = await readdir(root, { withFileTypes: true });
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
80
+ const path = join(root, entry.name);
81
+ const marker = await readManagedMarker(path, entry.name, agent);
82
+ if (marker !== null) copies.push({ path, skill: entry.name, agent, mode: marker.mode });
83
+ }
84
+ return copies;
85
+ }
86
+
87
+ async function readManagedMarker(path, skill, agent) {
88
+ const markerPath = join(path, SHARE_MARKER);
89
+ const stats = await pathStats(markerPath);
90
+ if (stats === null || stats.isSymbolicLink() || !stats.isFile()) return null;
91
+ const value = await readFile(markerPath, "utf8").then(JSON.parse, () => null);
92
+ if (value?.version !== 1 || value.managed_by !== "skillboard" || value.skill !== skill) return null;
93
+ if (agent === null && value.mode === "shared-source") return value;
94
+ if (agent !== null && value.mode === "agent-copy" && value.target_agent === agent) return value;
95
+ return null;
96
+ }
97
+
98
+ async function removeManagedCopy(copy) {
99
+ const stats = await pathStats(copy.path);
100
+ if (stats === null) return true;
101
+ if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
102
+ const marker = await readManagedMarker(copy.path, copy.skill, copy.agent);
103
+ if (marker === null || marker.mode !== copy.mode) return false;
104
+ await rm(copy.path, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+
108
+ async function existingStatePaths(home) {
109
+ const result = [];
110
+ for (const path of [join(home, "skillboard.config.yaml"), join(home, ".skillboard")]) {
111
+ if (await pathStats(path) !== null) result.push(path);
112
+ }
113
+ return result;
114
+ }
115
+
116
+ async function removeStatePath(path) {
117
+ const stats = await pathStats(path);
118
+ if (stats === null) return true;
119
+ const name = basename(path);
120
+ const supported = name === "skillboard.config.yaml"
121
+ ? stats.isFile() || stats.isSymbolicLink()
122
+ : name === ".skillboard" && (stats.isDirectory() || stats.isSymbolicLink());
123
+ if (!supported) return false;
124
+ await rm(path, { recursive: stats.isDirectory(), force: true });
125
+ return true;
126
+ }
127
+
128
+ function result(home, dryRun, managedCopies, guidance, statePaths, preserved) {
129
+ return {
130
+ ok: true,
131
+ mode: "user",
132
+ dry_run: dryRun,
133
+ home,
134
+ managed_copies: managedCopies,
135
+ guidance,
136
+ state_paths: statePaths,
137
+ preserved: [...new Set([...preserved, ...guidance.preserved])].sort()
138
+ };
139
+ }
140
+
141
+ async function pathStats(path) {
142
+ return await lstat(path).catch((error) => {
143
+ if (error?.code === "ENOENT") return null;
144
+ throw error;
145
+ });
146
+ }
package/src/workspace.mjs CHANGED
@@ -19,16 +19,26 @@ import {
19
19
  } from "./domain/constants.mjs";
20
20
  import { parseInstallUnits } from "./install-units.mjs";
21
21
  import { normalizeSkillPath } from "./skill-paths.mjs";
22
+ import { parseV2Policy, serializeV2Policy } from "./domain/v2-policy.mjs";
23
+ import { loadV2InventoryIndex } from "./control/v2-guard.mjs";
24
+ import { compatibilityForVersion } from "./compatibility.mjs";
22
25
 
26
+ export { serializeV2Policy };
27
+
28
+ /** @returns {Promise<any>} */
23
29
  export async function loadWorkspace(options) {
24
30
  const configText = await readFile(options.configPath, "utf8");
25
31
  const parsed = YAML.parse(configText);
26
32
  const config = requireRecord(parsed, "config root");
27
33
  const version = parseVersion(config.version);
34
+ if (version === 2) {
35
+ return loadV2Workspace(config, options);
36
+ }
28
37
  const skills = parseSkills(config.skills);
29
38
  const installUnits = parseInstallUnits(config.install_units);
30
39
  return {
31
40
  version,
41
+ compatibility: compatibilityForVersion(version),
32
42
  defaults: parseDefaults(config.defaults),
33
43
  installedSkills: await discoverInstalledSkills(options.skillsRoot, skills, {
34
44
  configPath: options.configPath,
@@ -44,6 +54,29 @@ export async function loadWorkspace(options) {
44
54
  };
45
55
  }
46
56
 
57
+ async function loadV2Workspace(config, options) {
58
+ const { workflows, skills } = parseV2Policy(config);
59
+ const inventoryPath = options.inventoryPath ?? join(dirname(options.configPath), ".skillboard", "inventory.json");
60
+ const inventory = await loadV2InventoryIndex(inventoryPath);
61
+ return {
62
+ version: 2,
63
+ compatibility: null,
64
+ defaults: {},
65
+ installedSkills: await discoverInstalledSkills(options.skillsRoot, skills, {
66
+ configPath: options.configPath,
67
+ env: options.env ?? process.env,
68
+ home: options.home,
69
+ installUnits: []
70
+ }),
71
+ skills,
72
+ capabilities: [],
73
+ harnesses: [],
74
+ installUnits: inventory.installUnits ?? [],
75
+ workflows,
76
+ inventory
77
+ };
78
+ }
79
+
47
80
  async function discoverInstalledSkills(skillsRoot, declaredSkills, options = {}) {
48
81
  const installed = [];
49
82
  const installedKeys = new Set();
@@ -175,7 +208,7 @@ function parseVersion(value) {
175
208
  if (value === undefined) {
176
209
  return 1;
177
210
  }
178
- if (value !== 1) {
211
+ if (value !== 1 && value !== 2) {
179
212
  throw new Error(`Unsupported config version: ${value}`);
180
213
  }
181
214
  return value;
@@ -194,6 +227,13 @@ function parseSkills(value) {
194
227
  const raw = requireRecord(value ?? {}, "skills");
195
228
  return Object.entries(raw).map(([id, entry]) => {
196
229
  const skill = requireRecord(entry, `skills.${id}`);
230
+ const v2Keys = ["enabled", "shared", "preference"].filter((key) => Object.prototype.hasOwnProperty.call(skill, key));
231
+ if (v2Keys.length > 0) {
232
+ throw new Error(
233
+ `skills.${id} uses version 2 key ${v2Keys.join(", ")} in a version 1 config. ` +
234
+ "Run `skillboard migrate v2` to convert the complete policy."
235
+ );
236
+ }
197
237
  const status = readString(skill, "status", "vendor");
198
238
  const invocation = readString(skill, "invocation", "manual-only");
199
239
  const exposure = readString(skill, "exposure", "exported");