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.
- package/README.md +154 -631
- package/docs/adapters.md +96 -96
- package/docs/ai-skill-routing-goal.md +112 -0
- package/docs/capabilities.md +6 -0
- package/docs/install.md +155 -105
- package/docs/plans/skillboard-variant-lifecycle-handoff.md +56 -0
- package/docs/policy-model.md +266 -214
- package/docs/positioning.md +94 -94
- package/docs/reference.md +349 -0
- package/docs/routing.md +85 -0
- package/docs/user-flow.md +75 -16
- package/docs/value-proof.md +190 -0
- package/docs/variant-lifecycle.md +86 -0
- package/docs/versioning.md +149 -138
- package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -8
- package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -8
- package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -8
- package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -8
- package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -8
- package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -8
- package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -8
- package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -8
- package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -8
- package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -8
- package/examples/skills/grill-me/SKILL.md +9 -9
- package/examples/skills/grill-with-docs/SKILL.md +9 -9
- package/examples/skills/requirement-intake/SKILL.md +9 -9
- package/examples/skills/tdd/SKILL.md +8 -8
- package/package.json +7 -3
- package/src/advisor/guidance.mjs +232 -0
- package/src/advisor/schema.mjs +2 -0
- package/src/advisor/skills.mjs +2 -0
- package/src/advisor.mjs +36 -7
- package/src/brief-cli.mjs +6 -5
- package/src/brief-renderer.mjs +225 -8
- package/src/cli.mjs +574 -27
- package/src/config-helpers.mjs +34 -18
- package/src/conflicts.mjs +70 -0
- package/src/control/can-use-guard.mjs +8 -3
- package/src/control/skill-crud.mjs +142 -0
- package/src/control/skill-variants.mjs +221 -0
- package/src/control/source-trust.mjs +1 -0
- package/src/control/variant-files.mjs +265 -0
- package/src/control/variant-lifecycle-config.mjs +156 -0
- package/src/control/variant-reset.mjs +171 -0
- package/src/control/variant-status.mjs +75 -0
- package/src/control.mjs +13 -1
- package/src/domain/rules/skills.mjs +60 -0
- package/src/domain/rules/workflows.mjs +13 -0
- package/src/impact.mjs +21 -12
- package/src/index.mjs +13 -1
- package/src/lifecycle-cli.mjs +18 -7
- package/src/lifecycle-content.mjs +29 -22
- package/src/route.mjs +537 -0
- package/src/source-verification.mjs +7 -3
- package/src/workspace.mjs +141 -43
- package/tsconfig.lsp.json +1 -1
package/src/config-helpers.mjs
CHANGED
|
@@ -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
|
|
9
|
-
const value = record[key];
|
|
10
|
-
if (value === undefined) {
|
|
11
|
-
return
|
|
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
|
|
20
|
-
const value = record[key];
|
|
21
|
-
if (value
|
|
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,
|