agent-skillboard 0.1.2 → 0.2.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.
Files changed (57) hide show
  1. package/README.md +154 -631
  2. package/docs/adapters.md +96 -96
  3. package/docs/ai-skill-routing-goal.md +112 -0
  4. package/docs/capabilities.md +6 -0
  5. package/docs/install.md +155 -105
  6. package/docs/plans/skillboard-variant-lifecycle-handoff.md +56 -0
  7. package/docs/policy-model.md +266 -214
  8. package/docs/positioning.md +94 -94
  9. package/docs/reference.md +349 -0
  10. package/docs/routing.md +85 -0
  11. package/docs/user-flow.md +75 -16
  12. package/docs/value-proof.md +190 -0
  13. package/docs/variant-lifecycle.md +86 -0
  14. package/docs/versioning.md +149 -138
  15. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -8
  16. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -8
  17. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -8
  18. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -8
  19. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -8
  20. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -8
  21. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -8
  22. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -8
  23. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -8
  24. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -8
  25. package/examples/skills/grill-me/SKILL.md +9 -9
  26. package/examples/skills/grill-with-docs/SKILL.md +9 -9
  27. package/examples/skills/requirement-intake/SKILL.md +9 -9
  28. package/examples/skills/tdd/SKILL.md +8 -8
  29. package/package.json +7 -3
  30. package/src/advisor/guidance.mjs +232 -0
  31. package/src/advisor/schema.mjs +2 -0
  32. package/src/advisor/skills.mjs +2 -0
  33. package/src/advisor.mjs +36 -7
  34. package/src/brief-cli.mjs +6 -5
  35. package/src/brief-renderer.mjs +225 -8
  36. package/src/cli.mjs +574 -27
  37. package/src/config-helpers.mjs +34 -18
  38. package/src/conflicts.mjs +70 -0
  39. package/src/control/can-use-guard.mjs +8 -3
  40. package/src/control/skill-crud.mjs +142 -0
  41. package/src/control/skill-variants.mjs +221 -0
  42. package/src/control/source-trust.mjs +1 -0
  43. package/src/control/variant-files.mjs +265 -0
  44. package/src/control/variant-lifecycle-config.mjs +156 -0
  45. package/src/control/variant-reset.mjs +171 -0
  46. package/src/control/variant-status.mjs +75 -0
  47. package/src/control.mjs +13 -1
  48. package/src/domain/rules/skills.mjs +60 -0
  49. package/src/domain/rules/workflows.mjs +13 -0
  50. package/src/impact.mjs +21 -12
  51. package/src/index.mjs +13 -1
  52. package/src/lifecycle-cli.mjs +18 -7
  53. package/src/lifecycle-content.mjs +29 -22
  54. package/src/route.mjs +537 -0
  55. package/src/source-verification.mjs +7 -3
  56. package/src/workspace.mjs +141 -43
  57. package/tsconfig.lsp.json +1 -1
@@ -1,24 +1,40 @@
1
- export function requireRecord(value, label) {
2
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
3
- throw new Error(`${label} must be a mapping`);
4
- }
5
- return value;
6
- }
7
-
8
- export function readString(record, key, fallback) {
9
- const value = record[key];
10
- if (value === undefined) {
11
- return fallback;
1
+ export function requireRecord(value, label) {
2
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
3
+ throw new Error(`${label} must be a mapping`);
4
+ }
5
+ return value;
6
+ }
7
+
8
+ export function readOptionalRecord(record, key, label = key) {
9
+ const value = record[key];
10
+ if (value === undefined) {
11
+ return undefined;
12
+ }
13
+ return requireRecord(value, label);
14
+ }
15
+
16
+ export function readString(record, key, fallback) {
17
+ const value = record[key];
18
+ if (value === undefined) {
19
+ return fallback;
12
20
  }
13
21
  if (typeof value !== "string") {
14
22
  throw new Error(`${key} must be a string`);
15
- }
16
- return value;
17
- }
18
-
19
- export function readOptionalString(record, key) {
20
- const value = record[key];
21
- if (value === undefined) {
23
+ }
24
+ return value;
25
+ }
26
+
27
+ export function readRequiredString(record, key, label = key) {
28
+ const value = record[key];
29
+ if (typeof value !== "string") {
30
+ throw new Error(`${label} must be a string`);
31
+ }
32
+ return value;
33
+ }
34
+
35
+ export function readOptionalString(record, key) {
36
+ const value = record[key];
37
+ if (value === undefined) {
22
38
  return undefined;
23
39
  }
24
40
  if (typeof value !== "string") {
@@ -0,0 +1,70 @@
1
+ export function workflowConflictEntries(workspace, workflow) {
2
+ const selectable = workflowSelectableSkillIds(workflow);
3
+ const blocked = new Set(workflow.blockedSkills);
4
+ const active = new Set(selectable.filter((skillId) => !blocked.has(skillId)));
5
+ const entries = [];
6
+ const seen = new Set();
7
+
8
+ for (const skillId of active) {
9
+ const skill = workspace.skills.find((candidate) => candidate.id === skillId);
10
+ for (const conflictingSkill of skill?.conflictsWith ?? []) {
11
+ if (!active.has(conflictingSkill)) {
12
+ continue;
13
+ }
14
+ const key = [skillId, conflictingSkill].sort().join("\0");
15
+ if (seen.has(key)) {
16
+ continue;
17
+ }
18
+ seen.add(key);
19
+ entries.push({
20
+ workflow: workflow.name,
21
+ skill: skillId,
22
+ conflictingSkill
23
+ });
24
+ }
25
+ }
26
+
27
+ return entries.sort((left, right) =>
28
+ left.workflow.localeCompare(right.workflow)
29
+ || left.skill.localeCompare(right.skill)
30
+ || left.conflictingSkill.localeCompare(right.conflictingSkill)
31
+ );
32
+ }
33
+
34
+ export function workflowConflictEntriesForSkill(workspace, workflow, skillId) {
35
+ return workflowConflictEntries(workspace, workflow).filter((entry) =>
36
+ entry.skill === skillId || entry.conflictingSkill === skillId
37
+ );
38
+ }
39
+
40
+ export function conflictingSkillIds(workspace, skillId) {
41
+ const direct = workspace.skills.find((skill) => skill.id === skillId)?.conflictsWith ?? [];
42
+ const reverse = workspace.skills
43
+ .filter((skill) => skill.conflictsWith.includes(skillId))
44
+ .map((skill) => skill.id);
45
+ return uniqueStrings([...direct, ...reverse]).sort((left, right) => left.localeCompare(right));
46
+ }
47
+
48
+ export function activeConflictEntriesForSkill(workspace, skillId) {
49
+ return workspace.workflows
50
+ .flatMap((workflow) => workflowConflictEntriesForSkill(workspace, workflow, skillId))
51
+ .sort((left, right) =>
52
+ left.workflow.localeCompare(right.workflow)
53
+ || left.skill.localeCompare(right.skill)
54
+ || left.conflictingSkill.localeCompare(right.conflictingSkill)
55
+ );
56
+ }
57
+
58
+ function workflowSelectableSkillIds(workflow) {
59
+ return uniqueStrings([
60
+ ...workflow.activeSkills,
61
+ ...workflow.requiredCapabilities.flatMap((capability) => [
62
+ capability.preferred,
63
+ ...capability.fallback
64
+ ])
65
+ ]);
66
+ }
67
+
68
+ function uniqueStrings(values) {
69
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))];
70
+ }
@@ -3,6 +3,7 @@ import {
3
3
  NON_CALLABLE_WORKFLOW_INVOCATIONS,
4
4
  NON_CALLABLE_WORKFLOW_STATUSES
5
5
  } from "../domain/constants.mjs";
6
+ import { workflowConflictEntriesForSkill } from "../conflicts.mjs";
6
7
  import { isModelSelectableInvocation, isUserControlledSource } from "../domain/source-classes.mjs";
7
8
  import { classifySkillTrust, workflowSkillRole } from "./source-trust.mjs";
8
9
 
@@ -12,9 +13,6 @@ export function canUseSkill(workspace, skillId, workflowName) {
12
13
  const workflow = workspace.workflows.find((candidate) => candidate.name === workflowName);
13
14
  const reasons = [];
14
15
 
15
- if (!policy.ok) {
16
- reasons.push("Policy check failed.");
17
- }
18
16
  if (skill === undefined) {
19
17
  reasons.push(`Unknown skill: ${skillId}`);
20
18
  }
@@ -26,6 +24,13 @@ export function canUseSkill(workspace, skillId, workflowName) {
26
24
  }
27
25
 
28
26
  const role = workflowSkillRole(workflow, skillId);
27
+ for (const conflict of workflowConflictEntriesForSkill(workspace, workflow, skillId)) {
28
+ const other = conflict.skill === skillId ? conflict.conflictingSkill : conflict.skill;
29
+ reasons.push(`Skill ${skillId} conflicts with active skill ${other} in workflow ${workflowName}.`);
30
+ }
31
+ if (!policy.ok) {
32
+ reasons.push("Policy check failed.");
33
+ }
29
34
  if (workflow.blockedSkills.includes(skillId)) {
30
35
  reasons.push(`Workflow ${workflowName} blocks skill ${skillId}.`);
31
36
  }
@@ -81,6 +81,68 @@ export async function addSkill(options) {
81
81
  return await writeCheckedConfig(document, originalText, validation, `Added ${options.skillId}`);
82
82
  }
83
83
 
84
+ export async function addSkillVariant(options) {
85
+ const { document, originalText } = await loadConfig(options.configPath);
86
+ const skills = requireMapAt(document, ["skills"], "skills");
87
+ const baseSkill = requireConfigSkill(document, options.baseId);
88
+ const workflow = requireConfigWorkflow(document, options.workflow);
89
+ const capabilityDefinition = requireConfigCapability(document, options.capability);
90
+ const existingRequiredPolicy = readRequiredCapabilityPolicy(workflow, options.capability);
91
+ const required = ensureRequiredCapability(workflow, options.capability, document);
92
+ const existingVariant = skills.get(options.variantId, true);
93
+
94
+ if (existingVariant === undefined) {
95
+ if (options.path === undefined) {
96
+ throw new Error("--path is required when adding an undeclared variant skill");
97
+ }
98
+ if (options.ownerInstallUnit !== undefined) {
99
+ appendSkillToOwnerInstallUnit(document, options.ownerInstallUnit, options.variantId);
100
+ }
101
+ const invocation = variantInvocation(options, existingRequiredPolicy, capabilityDefinition);
102
+ validateSkillState("active", invocation, "exported");
103
+ skills.set(options.variantId, document.createNode(stripUndefined({
104
+ path: normalizeSkillPath(options.path, "skill path"),
105
+ status: "active",
106
+ invocation,
107
+ exposure: "exported",
108
+ category: options.category ?? readMapString(baseSkill, "category", "uncategorized"),
109
+ owner_install_unit: options.ownerInstallUnit
110
+ })));
111
+ } else {
112
+ requireYamlMap(existingVariant, `skills.${options.variantId}`);
113
+ }
114
+
115
+ const alternatives = ensureSeq(capabilityDefinition, "alternatives", document);
116
+ const canonical = readMapString(capabilityDefinition, "canonical", "");
117
+ if (canonical !== options.baseId && !sequenceIncludes(alternatives, options.baseId)) {
118
+ addUnique(alternatives, options.baseId);
119
+ }
120
+ if (canonical !== options.variantId && !sequenceIncludes(alternatives, options.variantId)) {
121
+ addUnique(alternatives, options.variantId);
122
+ }
123
+
124
+ const previousPreferred = readMapString(required, "preferred", "");
125
+ const fallback = ensureSeq(required, "fallback", document);
126
+ required.set("preferred", options.variantId);
127
+ setFallbackValues(
128
+ fallback,
129
+ orderedVariantFallbacks(
130
+ sequenceValues(fallback),
131
+ [previousPreferred, options.baseId, canonical],
132
+ options.variantId
133
+ )
134
+ );
135
+ addUnique(ensureSeq(workflow, "active_skills", document), options.variantId);
136
+ removeValue(ensureSeq(workflow, "blocked_skills", document), options.variantId);
137
+
138
+ return await writeCheckedConfig(
139
+ document,
140
+ originalText,
141
+ { ...options, validateUse: { skillId: options.variantId, workflow: options.workflow } },
142
+ `Added variant ${options.variantId} for ${options.baseId} in ${options.workflow}`
143
+ );
144
+ }
145
+
84
146
  export async function blockSkill(options) {
85
147
  const { document, originalText } = await loadConfig(options.configPath);
86
148
  requireConfigSkill(document, options.skillId);
@@ -184,6 +246,33 @@ function requireConfigWorkflow(document, workflowName) {
184
246
  return raw;
185
247
  }
186
248
 
249
+ function requireConfigCapability(document, capabilityName) {
250
+ const capabilities = requireMapAt(document, ["capabilities"], "capabilities");
251
+ const capability = capabilities.get(capabilityName, true);
252
+ if (capability === undefined) {
253
+ throw new Error(`Unknown capability: ${capabilityName}`);
254
+ }
255
+ return requireYamlMap(capability, `capabilities.${capabilityName}`);
256
+ }
257
+
258
+ function appendSkillToOwnerInstallUnit(document, unitId, skillId) {
259
+ const installUnits = optionalRootMap(document, "install_units");
260
+ if (installUnits === undefined) {
261
+ throw new Error(`Unknown install unit: ${unitId}`);
262
+ }
263
+ const installUnit = installUnits.get(unitId, true);
264
+ if (installUnit === undefined) {
265
+ throw new Error(`Unknown install unit: ${unitId}`);
266
+ }
267
+ const unit = requireYamlMap(installUnit, `install_units.${unitId}`);
268
+ let components = unit.get("components", true);
269
+ if (components === undefined) {
270
+ components = document.createNode({});
271
+ unit.set("components", components);
272
+ }
273
+ addUnique(ensureSeq(requireYamlMap(components, `install_units.${unitId}.components`), "skills", document), skillId);
274
+ }
275
+
187
276
  function ensureRequiredCapability(workflow, capabilityName, document) {
188
277
  let capabilities = workflow.get("required_capabilities", true);
189
278
  if (capabilities === undefined) {
@@ -203,6 +292,59 @@ function ensureRequiredCapability(workflow, capabilityName, document) {
203
292
  return capability;
204
293
  }
205
294
 
295
+ function readRequiredCapabilityPolicy(workflow, capabilityName) {
296
+ const capabilities = optionalMap(workflow, "required_capabilities");
297
+ if (capabilities === undefined) {
298
+ return "";
299
+ }
300
+ const capability = capabilities.get(capabilityName, true);
301
+ if (capability === undefined) {
302
+ return "";
303
+ }
304
+ return readMapString(requireYamlMap(capability, `required_capabilities.${capabilityName}`), "policy", "");
305
+ }
306
+
307
+ function variantInvocation(options, requiredPolicy, capabilityDefinition) {
308
+ const explicit = options.mode ?? options.invocation;
309
+ const computed = firstNonEmpty([
310
+ explicit,
311
+ requiredPolicy,
312
+ readMapString(capabilityDefinition, "default_policy", ""),
313
+ "manual-only"
314
+ ]);
315
+ return computed === "global-auto" ? "manual-only" : computed;
316
+ }
317
+
318
+ function firstNonEmpty(values) {
319
+ return values.find((value) => typeof value === "string" && value.length > 0) ?? "manual-only";
320
+ }
321
+
322
+ function sequenceValues(sequence) {
323
+ return sequence.items.map((item) => nodeScalarValue(item));
324
+ }
325
+
326
+ function orderedVariantFallbacks(existingValues, priorityValues, variantId) {
327
+ const next = [];
328
+ const seen = new Set();
329
+ for (const value of [...priorityValues, ...existingValues]) {
330
+ if (typeof value !== "string" || value.length === 0 || value === variantId || seen.has(value)) {
331
+ continue;
332
+ }
333
+ next.push(value);
334
+ seen.add(value);
335
+ }
336
+ return next;
337
+ }
338
+
339
+ function setFallbackValues(sequence, values) {
340
+ while (sequence.items.length > 0) {
341
+ sequence.delete(0);
342
+ }
343
+ for (const value of values) {
344
+ sequence.add(value);
345
+ }
346
+ }
347
+
206
348
  function removeSkillFromRequiredCapabilities(workflow, skillId, document) {
207
349
  const capabilities = optionalMap(workflow, "required_capabilities");
208
350
  if (capabilities === undefined) {
@@ -0,0 +1,221 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { normalizeSkillPath } from "../skill-paths.mjs";
3
+ import { loadWorkspace } from "../workspace.mjs";
4
+ import {
5
+ loadConfig,
6
+ readMapString,
7
+ requireMapAt,
8
+ writeCheckedConfig
9
+ } from "./config-write.mjs";
10
+ import {
11
+ addVariantCapabilityAlternative,
12
+ appendSkillToOwnerInstallUnit,
13
+ promoteVariantInWorkflow,
14
+ readRequiredCapabilityPolicy,
15
+ requireConfigCapability,
16
+ requireConfigSkill,
17
+ requireConfigWorkflow,
18
+ stripUndefined,
19
+ variantConfigMetadata
20
+ } from "./variant-lifecycle-config.mjs";
21
+ import {
22
+ cleanupCreatedVariantFile,
23
+ copySkillFileForFork,
24
+ digestVariantFile,
25
+ resolveVariantLiveSkillFile,
26
+ variantSnapshotTarget,
27
+ writeVariantSnapshot
28
+ } from "./variant-files.mjs";
29
+
30
+ const APPROVE_INVOCATIONS = new Set(["manual-only", "router-only", "workflow-auto"]);
31
+
32
+ export async function forkSkillVariant(options) {
33
+ const { document, originalText } = await loadConfig(options.configPath);
34
+ const skills = requireMapAt(document, ["skills"], "skills");
35
+ const baseSkill = requireConfigSkill(document, options.baseId);
36
+ const workflow = requireConfigWorkflow(document, options.workflow);
37
+ const capability = requireConfigCapability(document, options.capability);
38
+ if (skills.get(options.variantId, true) !== undefined) {
39
+ throw new Error(`Skill already exists: ${options.variantId}`);
40
+ }
41
+ if (options.path === undefined) {
42
+ throw new Error("--path is required when forking a variant skill");
43
+ }
44
+
45
+ const variantPath = normalizeSkillPath(options.path, "skill path");
46
+ const baseFile = resolveVariantLiveSkillFile({
47
+ skillsRoot: options.skillsRoot,
48
+ skill: { id: options.baseId, path: readMapString(baseSkill, "path", options.baseId) }
49
+ });
50
+ const targetFile = resolveVariantLiveSkillFile({
51
+ skillsRoot: options.skillsRoot,
52
+ skill: { id: options.variantId, path: variantPath }
53
+ });
54
+ const baseContent = await readBaseSkillFile(baseFile, options.baseId);
55
+ const baseDigest = await digestVariantFile(baseFile);
56
+ const snapshot = variantSnapshotTarget({ configPath: options.configPath, skillId: options.variantId, snapshotName: "base" });
57
+ const variant = draftVariantMetadata(options, baseDigest, snapshot.storedPath);
58
+
59
+ if (options.ownerInstallUnit !== undefined) {
60
+ appendSkillToOwnerInstallUnit(document, options.ownerInstallUnit, options.variantId);
61
+ }
62
+ skills.set(options.variantId, document.createNode(stripUndefined({
63
+ path: variantPath,
64
+ status: "candidate",
65
+ invocation: "manual-only",
66
+ exposure: "exported",
67
+ category: options.category ?? readMapString(baseSkill, "category", "uncategorized"),
68
+ owner_install_unit: options.ownerInstallUnit,
69
+ variant: variantConfigMetadata(variant)
70
+ })));
71
+ addVariantCapabilityAlternative(document, capability, options);
72
+
73
+ const filePlan = [];
74
+ let copied = false;
75
+ let snapshotWritten = false;
76
+ try {
77
+ filePlan.push(await copySkillFileForFork({
78
+ sourceFile: baseFile,
79
+ targetFile,
80
+ dryRun: options.dryRun === true
81
+ }));
82
+ copied = options.dryRun !== true;
83
+ filePlan.push(await writeVariantSnapshot({
84
+ configPath: options.configPath,
85
+ skillId: options.variantId,
86
+ snapshotName: "base",
87
+ content: baseContent,
88
+ expectedDigest: baseDigest,
89
+ dryRun: options.dryRun === true
90
+ }));
91
+ snapshotWritten = options.dryRun !== true;
92
+ const result = await writeCheckedConfig(
93
+ document,
94
+ originalText,
95
+ options,
96
+ `Forked draft variant ${options.variantId} from ${options.baseId}`
97
+ );
98
+ return { ...result, skill: options.variantId, variant, filePlan, warnings: [] };
99
+ } catch (error) {
100
+ if (snapshotWritten) {
101
+ await cleanupCreatedVariantFile(snapshot.absolutePath, { expectedDigest: baseDigest }).catch(() => undefined);
102
+ }
103
+ if (copied) {
104
+ await cleanupCreatedVariantFile(targetFile, { expectedDigest: baseDigest }).catch(() => undefined);
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ export async function approveSkillVariant(options) {
111
+ const current = await lifecycleSkill(options);
112
+ const liveFile = resolveVariantLiveSkillFile({ skillsRoot: options.skillsRoot, skill: current });
113
+ const liveContent = await readLiveVariantFile(liveFile, options.variantId);
114
+ const liveDigest = await digestVariantFile(liveFile);
115
+ const { document, originalText } = await loadConfig(options.configPath);
116
+ const skill = requireConfigSkill(document, options.variantId);
117
+ const workflow = requireConfigWorkflow(document, current.variant.workflow);
118
+ const capability = requireConfigCapability(document, current.variant.capability);
119
+ const invocation = approvedInvocation(options, workflow, capability, current.variant.capability);
120
+ const snapshot = variantSnapshotTarget({ configPath: options.configPath, skillId: options.variantId, snapshotName: "approved" });
121
+ const variant = {
122
+ ...current.variant,
123
+ status: "approved",
124
+ approved: { contentDigest: liveDigest, snapshot: snapshot.storedPath }
125
+ };
126
+
127
+ skill.set("status", "active");
128
+ skill.set("invocation", invocation);
129
+ skill.set("variant", document.createNode(variantConfigMetadata(variant)));
130
+ addVariantCapabilityAlternative(document, capability, { baseId: variant.of, variantId: options.variantId });
131
+ promoteVariantInWorkflow(document, workflow, capability, { baseId: variant.of, variantId: options.variantId, capability: variant.capability });
132
+
133
+ const writeOptions = { ...options, validateUse: { skillId: options.variantId, workflow: variant.workflow } };
134
+ const filePlan = [await writeVariantSnapshot({
135
+ configPath: options.configPath,
136
+ skillId: options.variantId,
137
+ snapshotName: "approved",
138
+ content: liveContent,
139
+ expectedDigest: liveDigest,
140
+ allowOverwrite: true,
141
+ dryRun: true
142
+ })];
143
+ if (options.dryRun === true) {
144
+ const result = await writeCheckedConfig(document, originalText, writeOptions, `Approved variant ${options.variantId} for ${variant.workflow}`);
145
+ return { ...result, skill: options.variantId, variant, filePlan, warnings: [] };
146
+ }
147
+ await writeCheckedConfig(document, originalText, { ...writeOptions, dryRun: true }, `Approved variant ${options.variantId} for ${variant.workflow}`);
148
+ filePlan[0] = await writeVariantSnapshot({
149
+ configPath: options.configPath,
150
+ skillId: options.variantId,
151
+ snapshotName: "approved",
152
+ content: liveContent,
153
+ expectedDigest: liveDigest,
154
+ allowOverwrite: true
155
+ });
156
+ const result = await writeCheckedConfig(document, originalText, writeOptions, `Approved variant ${options.variantId} for ${variant.workflow}`);
157
+ return { ...result, skill: options.variantId, variant, filePlan, warnings: [] };
158
+ }
159
+
160
+ function draftVariantMetadata(options, baseDigest, snapshotPath) {
161
+ return {
162
+ of: options.baseId,
163
+ adaptedFor: options.adaptedFor ?? null,
164
+ capability: options.capability,
165
+ workflow: options.workflow,
166
+ status: "draft",
167
+ base: { contentDigest: baseDigest, snapshot: snapshotPath }
168
+ };
169
+ }
170
+
171
+ async function lifecycleSkill(options) {
172
+ const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
173
+ const skill = workspace.skills.find((candidate) => candidate.id === options.variantId);
174
+ if (skill === undefined) {
175
+ throw new Error(`Unknown skill: ${options.variantId}`);
176
+ }
177
+ if (skill.variant === null || skill.variant === undefined) {
178
+ throw new Error(`Skill ${options.variantId} is not a lifecycle variant`);
179
+ }
180
+ return skill;
181
+ }
182
+
183
+ function approvedInvocation(options, workflow, capability, capabilityName) {
184
+ const computed = firstNonEmpty([
185
+ options.mode,
186
+ options.invocation,
187
+ readRequiredCapabilityPolicy(workflow, capabilityName),
188
+ readMapString(capability, "default_policy", ""),
189
+ "manual-only"
190
+ ]);
191
+ if (!APPROVE_INVOCATIONS.has(computed)) {
192
+ throw new Error("approve requires --mode manual-only, router-only, or workflow-auto");
193
+ }
194
+ return computed;
195
+ }
196
+
197
+ function firstNonEmpty(values) {
198
+ return values.find((value) => typeof value === "string" && value.length > 0) ?? "manual-only";
199
+ }
200
+
201
+ async function readBaseSkillFile(path, skillId) {
202
+ try {
203
+ return await readFile(path, "utf8");
204
+ } catch (error) {
205
+ if (error?.code === "ENOENT") {
206
+ throw new Error(`Missing base skill file for ${skillId}: ${path}`);
207
+ }
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ async function readLiveVariantFile(path, skillId) {
213
+ try {
214
+ return await readFile(path, "utf8");
215
+ } catch (error) {
216
+ if (error?.code === "ENOENT") {
217
+ throw new Error(`Missing live variant skill file for ${skillId}: ${path}`);
218
+ }
219
+ throw error;
220
+ }
221
+ }
@@ -130,6 +130,7 @@ export function skillSummary(workspace, skill, workflow) {
130
130
  invocation: skill.invocation,
131
131
  exposure: skill.exposure,
132
132
  category: skill.category,
133
+ variant: skill.variant ?? null,
133
134
  sourceClass: source.class,
134
135
  sourcePriority: source.priority,
135
136
  ownerInstallUnit: source.ownerInstallUnit,