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
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { copyFile, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { normalizeSkillPath } from "../skill-paths.mjs";
|
|
6
|
+
import { skillContentDigest } from "../source-verification.mjs";
|
|
7
|
+
|
|
8
|
+
const SNAPSHOT_ROOT = ".skillboard/variant-snapshots";
|
|
9
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
10
|
+
|
|
11
|
+
export function resolveVariantLiveSkillFile({ skillsRoot, skill }) {
|
|
12
|
+
if (skill === null || typeof skill !== "object") {
|
|
13
|
+
throw new Error("skill must be an object with id and path");
|
|
14
|
+
}
|
|
15
|
+
const root = resolveRequiredPath(skillsRoot, "skills root");
|
|
16
|
+
const skillPath = normalizeSkillPath(skill.path, `skills.${skill.id ?? "<unknown>"}.path`);
|
|
17
|
+
const liveFile = resolve(root, skillPath, "SKILL.md");
|
|
18
|
+
assertPathInside(root, liveFile, "live skill file");
|
|
19
|
+
return liveFile;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function variantSnapshotTarget({ configPath, skillId, snapshotName }) {
|
|
23
|
+
const encodedSkillId = encodeSkillIdSegment(skillId);
|
|
24
|
+
const fileName = snapshotFileName(snapshotName);
|
|
25
|
+
const storedPath = `${SNAPSHOT_ROOT}/${encodedSkillId}/${fileName}`;
|
|
26
|
+
return resolveVariantSnapshotFile({ configPath, snapshotPath: storedPath });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveVariantSnapshotFile({ configPath, snapshotPath }) {
|
|
30
|
+
const configDir = dirname(resolveRequiredPath(configPath, "config path"));
|
|
31
|
+
const normalized = normalizeStoredSnapshotPath(snapshotPath);
|
|
32
|
+
const root = resolve(configDir, SNAPSHOT_ROOT);
|
|
33
|
+
const absolutePath = resolve(configDir, normalized);
|
|
34
|
+
if (!isPathInside(root, absolutePath)) {
|
|
35
|
+
throw new Error(`snapshot path must stay under ${SNAPSHOT_ROOT}`);
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
storedPath: relative(configDir, absolutePath).replaceAll("\\", "/"),
|
|
39
|
+
absolutePath,
|
|
40
|
+
rootPath: root
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function variantTempPath(absolutePath) {
|
|
45
|
+
return join(dirname(resolveRequiredPath(absolutePath, "target path")), `.${basename(absolutePath)}.${randomUUID()}.tmp`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requireVariantDigest(value, label = "content digest") {
|
|
49
|
+
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
|
|
50
|
+
throw new Error(`${label} must use sha256:<64 hex chars>`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const digestVariantFile = skillContentDigest;
|
|
56
|
+
|
|
57
|
+
export async function writeVariantSnapshot(options) {
|
|
58
|
+
const target = variantSnapshotTarget(options);
|
|
59
|
+
const content = normalizeFileContent(options.content, "snapshot content");
|
|
60
|
+
if (options.expectedDigest !== undefined) {
|
|
61
|
+
requireVariantDigest(options.expectedDigest, "expected snapshot digest");
|
|
62
|
+
}
|
|
63
|
+
const plan = filePlan("write-snapshot", {
|
|
64
|
+
path: target.storedPath,
|
|
65
|
+
absolutePath: target.absolutePath,
|
|
66
|
+
bytes: Buffer.byteLength(content),
|
|
67
|
+
overwrite: options.allowOverwrite === true,
|
|
68
|
+
dryRun: options.dryRun === true
|
|
69
|
+
});
|
|
70
|
+
if (options.dryRun === true) {
|
|
71
|
+
return plan;
|
|
72
|
+
}
|
|
73
|
+
if (options.allowOverwrite !== true) {
|
|
74
|
+
await assertMissing(target.absolutePath, "Snapshot target");
|
|
75
|
+
}
|
|
76
|
+
await writeFileByRename(target.absolutePath, content, { expectedDigest: options.expectedDigest });
|
|
77
|
+
return plan;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function copySkillFileForFork(options) {
|
|
81
|
+
const sourceFile = resolveRequiredPath(options.sourceFile, "source skill file");
|
|
82
|
+
const targetFile = resolveRequiredPath(options.targetFile, "fork target skill file");
|
|
83
|
+
const sourceStats = await lstat(sourceFile);
|
|
84
|
+
if (!sourceStats.isFile()) {
|
|
85
|
+
throw new Error(`Source skill file must be a regular file: ${sourceFile}`);
|
|
86
|
+
}
|
|
87
|
+
await assertMissing(targetFile, "Fork target");
|
|
88
|
+
const plan = filePlan("copy-skill-for-fork", {
|
|
89
|
+
from: sourceFile,
|
|
90
|
+
path: targetFile,
|
|
91
|
+
absolutePath: targetFile,
|
|
92
|
+
bytes: sourceStats.size,
|
|
93
|
+
dryRun: options.dryRun === true
|
|
94
|
+
});
|
|
95
|
+
if (options.dryRun === true) {
|
|
96
|
+
return plan;
|
|
97
|
+
}
|
|
98
|
+
await writeFileByRename(targetFile, await readFile(sourceFile));
|
|
99
|
+
return plan;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function createResetBackup(liveFile) {
|
|
103
|
+
const absolutePath = resolveRequiredPath(liveFile, "live skill file");
|
|
104
|
+
await assertResetTargetFile(absolutePath);
|
|
105
|
+
const backupPath = join(dirname(absolutePath), `.${basename(absolutePath)}.${randomUUID()}.bak`);
|
|
106
|
+
await copyFile(absolutePath, backupPath, fsConstants.COPYFILE_EXCL);
|
|
107
|
+
return {
|
|
108
|
+
liveFile: absolutePath,
|
|
109
|
+
backupPath,
|
|
110
|
+
restore: () => copyFile(backupPath, absolutePath),
|
|
111
|
+
cleanup: () => rm(backupPath, { force: true })
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function replaceLiveSkillFileForReset(options) {
|
|
116
|
+
const liveFile = resolveRequiredPath(options.liveFile, "live skill file");
|
|
117
|
+
const content = normalizeFileContent(options.content, "reset content");
|
|
118
|
+
await assertResetTargetFile(liveFile);
|
|
119
|
+
const plan = filePlan("replace-live-skill-for-reset", {
|
|
120
|
+
path: liveFile,
|
|
121
|
+
absolutePath: liveFile,
|
|
122
|
+
bytes: Buffer.byteLength(content),
|
|
123
|
+
overwrite: true,
|
|
124
|
+
dryRun: options.dryRun === true
|
|
125
|
+
});
|
|
126
|
+
if (options.dryRun === true) {
|
|
127
|
+
return plan;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const backup = await createResetBackup(liveFile);
|
|
131
|
+
try {
|
|
132
|
+
await writeFileByRename(liveFile, content);
|
|
133
|
+
return { ...plan, backupPath: backup.backupPath };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
await backup.restore().catch(() => undefined);
|
|
136
|
+
throw error;
|
|
137
|
+
} finally {
|
|
138
|
+
await backup.cleanup();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function cleanupCreatedVariantFile(path, options = {}) {
|
|
143
|
+
const absolutePath = resolveRequiredPath(path, "created variant file");
|
|
144
|
+
if (options.expectedDigest !== undefined) {
|
|
145
|
+
const expectedDigest = requireVariantDigest(options.expectedDigest, "expected cleanup digest");
|
|
146
|
+
const actualDigest = await skillContentDigest(absolutePath).catch(() => null);
|
|
147
|
+
if (actualDigest !== expectedDigest) {
|
|
148
|
+
return { removed: false, reason: "digest-mismatch", actualDigest };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await rm(absolutePath, { force: true });
|
|
152
|
+
return { removed: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isPathInside(root, candidate) {
|
|
156
|
+
const relativePath = relative(resolve(root), resolve(candidate));
|
|
157
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function assertPathInside(root, candidate, label = "path") {
|
|
161
|
+
if (!isPathInside(root, candidate)) {
|
|
162
|
+
throw new Error(`${label} must stay under ${root}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function filePlan(action, values) {
|
|
167
|
+
return { action, changed: true, ...values };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function writeFileByRename(targetPath, content, options = {}) {
|
|
171
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
172
|
+
const tempPath = variantTempPath(targetPath);
|
|
173
|
+
try {
|
|
174
|
+
await writeFile(tempPath, content, { flag: "wx" });
|
|
175
|
+
if (options.expectedDigest !== undefined) {
|
|
176
|
+
const actualDigest = await skillContentDigest(tempPath);
|
|
177
|
+
if (actualDigest !== options.expectedDigest) {
|
|
178
|
+
throw new Error(`snapshot content digest mismatch: expected ${options.expectedDigest}, got ${actualDigest}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await rename(tempPath, targetPath);
|
|
182
|
+
} finally {
|
|
183
|
+
await rm(tempPath, { force: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function assertMissing(path, label) {
|
|
188
|
+
try {
|
|
189
|
+
await lstat(path);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error?.code === "ENOENT") {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`${label} already exists: ${path}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function assertResetTargetFile(path) {
|
|
200
|
+
const stats = await lstat(path);
|
|
201
|
+
if (stats.isSymbolicLink()) {
|
|
202
|
+
throw new Error(`Refusing to overwrite symlink target for reset: ${path}`);
|
|
203
|
+
}
|
|
204
|
+
if (!stats.isFile()) {
|
|
205
|
+
throw new Error(`Reset target must be a regular file: ${path}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeStoredSnapshotPath(value) {
|
|
210
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
211
|
+
throw new Error("snapshot path must be a non-empty relative path");
|
|
212
|
+
}
|
|
213
|
+
if (value.includes("\0")) {
|
|
214
|
+
throw new Error("snapshot path must not contain null bytes");
|
|
215
|
+
}
|
|
216
|
+
const normalized = value.replaceAll("\\", "/");
|
|
217
|
+
if (normalized.startsWith("/") || normalized.startsWith("//") || /^[A-Za-z]:\//.test(normalized)) {
|
|
218
|
+
throw new Error("snapshot path must be relative to the config directory");
|
|
219
|
+
}
|
|
220
|
+
if (!normalized.startsWith(`${SNAPSHOT_ROOT}/`)) {
|
|
221
|
+
throw new Error(`snapshot path must stay under ${SNAPSHOT_ROOT}`);
|
|
222
|
+
}
|
|
223
|
+
return normalized;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function encodeSkillIdSegment(skillId) {
|
|
227
|
+
if (typeof skillId !== "string" || skillId.length === 0) {
|
|
228
|
+
throw new Error("skill id must be a non-empty string");
|
|
229
|
+
}
|
|
230
|
+
if (skillId.includes("\0")) {
|
|
231
|
+
throw new Error("skill id must not contain null bytes");
|
|
232
|
+
}
|
|
233
|
+
const encoded = encodeURIComponent(skillId);
|
|
234
|
+
if (encoded === ".") {
|
|
235
|
+
return "%2E";
|
|
236
|
+
}
|
|
237
|
+
return encoded === ".." ? "%2E%2E" : encoded;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function snapshotFileName(snapshotName) {
|
|
241
|
+
if (typeof snapshotName !== "string" || snapshotName.length === 0) {
|
|
242
|
+
throw new Error("snapshot name must be a non-empty string");
|
|
243
|
+
}
|
|
244
|
+
if (!/^[A-Za-z0-9_-]+$/.test(snapshotName)) {
|
|
245
|
+
throw new Error("snapshot name may only contain letters, numbers, underscores, and dashes");
|
|
246
|
+
}
|
|
247
|
+
return `${snapshotName}.md`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveRequiredPath(value, label) {
|
|
251
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
252
|
+
throw new Error(`${label} must be a non-empty path`);
|
|
253
|
+
}
|
|
254
|
+
if (value.includes("\0")) {
|
|
255
|
+
throw new Error(`${label} must not contain null bytes`);
|
|
256
|
+
}
|
|
257
|
+
return resolve(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeFileContent(value, label) {
|
|
261
|
+
if (typeof value === "string" || Buffer.isBuffer(value)) {
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`${label} must be a string or Buffer`);
|
|
265
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addUnique,
|
|
3
|
+
ensureSeq,
|
|
4
|
+
nodeScalarValue,
|
|
5
|
+
optionalMap,
|
|
6
|
+
optionalRootMap,
|
|
7
|
+
readMapString,
|
|
8
|
+
removeValue,
|
|
9
|
+
requireMapAt,
|
|
10
|
+
requireYamlMap,
|
|
11
|
+
sequenceIncludes
|
|
12
|
+
} from "./config-write.mjs";
|
|
13
|
+
|
|
14
|
+
export function requireConfigSkill(document, skillId) {
|
|
15
|
+
const skills = requireMapAt(document, ["skills"], "skills");
|
|
16
|
+
const skill = skills.get(skillId, true);
|
|
17
|
+
if (skill === undefined) {
|
|
18
|
+
throw new Error(`Unknown skill: ${skillId}`);
|
|
19
|
+
}
|
|
20
|
+
return requireYamlMap(skill, `skills.${skillId}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireConfigWorkflow(document, workflowName) {
|
|
24
|
+
const workflows = requireMapAt(document, ["workflows"], "workflows");
|
|
25
|
+
const workflow = workflows.get(workflowName, true);
|
|
26
|
+
if (workflow === undefined) {
|
|
27
|
+
throw new Error(`Unknown workflow: ${workflowName}`);
|
|
28
|
+
}
|
|
29
|
+
const raw = requireYamlMap(workflow, `workflows.${workflowName}`);
|
|
30
|
+
ensureSeq(raw, "active_skills", document);
|
|
31
|
+
ensureSeq(raw, "blocked_skills", document);
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function requireConfigCapability(document, capabilityName) {
|
|
36
|
+
const capabilities = requireMapAt(document, ["capabilities"], "capabilities");
|
|
37
|
+
const capability = capabilities.get(capabilityName, true);
|
|
38
|
+
if (capability === undefined) {
|
|
39
|
+
throw new Error(`Unknown capability: ${capabilityName}`);
|
|
40
|
+
}
|
|
41
|
+
return requireYamlMap(capability, `capabilities.${capabilityName}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function addVariantCapabilityAlternative(document, capability, options) {
|
|
45
|
+
const alternatives = ensureSeq(capability, "alternatives", document);
|
|
46
|
+
const canonical = readMapString(capability, "canonical", "");
|
|
47
|
+
if (canonical !== options.baseId && !sequenceIncludes(alternatives, options.baseId)) {
|
|
48
|
+
addUnique(alternatives, options.baseId);
|
|
49
|
+
}
|
|
50
|
+
if (canonical !== options.variantId && !sequenceIncludes(alternatives, options.variantId)) {
|
|
51
|
+
addUnique(alternatives, options.variantId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function promoteVariantInWorkflow(document, workflow, capabilityDefinition, options) {
|
|
56
|
+
const required = ensureRequiredCapability(workflow, options.capability, document);
|
|
57
|
+
const previousPreferred = readMapString(required, "preferred", "");
|
|
58
|
+
const canonical = readMapString(capabilityDefinition, "canonical", "");
|
|
59
|
+
const fallback = ensureSeq(required, "fallback", document);
|
|
60
|
+
required.set("preferred", options.variantId);
|
|
61
|
+
setFallbackValues(fallback, orderedVariantFallbacks(sequenceValues(fallback), [previousPreferred, options.baseId, canonical], options.variantId));
|
|
62
|
+
addUnique(ensureSeq(workflow, "active_skills", document), options.variantId);
|
|
63
|
+
removeValue(ensureSeq(workflow, "blocked_skills", document), options.variantId);
|
|
64
|
+
return required;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ensureRequiredCapability(workflow, capabilityName, document) {
|
|
68
|
+
let capabilities = workflow.get("required_capabilities", true);
|
|
69
|
+
if (capabilities === undefined) {
|
|
70
|
+
capabilities = document.createNode({});
|
|
71
|
+
workflow.set("required_capabilities", capabilities);
|
|
72
|
+
}
|
|
73
|
+
const capabilityMap = requireYamlMap(capabilities, "required_capabilities");
|
|
74
|
+
if (capabilityMap.get(capabilityName, true) === undefined) {
|
|
75
|
+
capabilityMap.set(capabilityName, document.createNode({ preferred: "", fallback: [], policy: "manual-only" }));
|
|
76
|
+
}
|
|
77
|
+
const capability = requireYamlMap(capabilityMap.get(capabilityName, true), `required_capabilities.${capabilityName}`);
|
|
78
|
+
ensureSeq(capability, "fallback", document);
|
|
79
|
+
return capability;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readRequiredCapabilityPolicy(workflow, capabilityName) {
|
|
83
|
+
const capabilities = optionalMap(workflow, "required_capabilities");
|
|
84
|
+
if (capabilities === undefined || capabilities.get(capabilityName, true) === undefined) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
return readMapString(requireYamlMap(capabilities.get(capabilityName, true), `required_capabilities.${capabilityName}`), "policy", "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function orderedVariantFallbacks(existingValues, priorityValues, variantId) {
|
|
91
|
+
const next = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const value of [...priorityValues, ...existingValues]) {
|
|
94
|
+
if (typeof value === "string" && value.length > 0 && value !== variantId && !seen.has(value)) {
|
|
95
|
+
next.push(value);
|
|
96
|
+
seen.add(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function setFallbackValues(sequence, values) {
|
|
103
|
+
while (sequence.items.length > 0) {
|
|
104
|
+
sequence.delete(0);
|
|
105
|
+
}
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
sequence.add(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function sequenceValues(sequence) {
|
|
112
|
+
return sequence.items.map((item) => nodeScalarValue(item));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function appendSkillToOwnerInstallUnit(document, unitId, skillId) {
|
|
116
|
+
const installUnits = optionalRootMap(document, "install_units");
|
|
117
|
+
if (installUnits === undefined || installUnits.get(unitId, true) === undefined) {
|
|
118
|
+
throw new Error(`Unknown install unit: ${unitId}`);
|
|
119
|
+
}
|
|
120
|
+
const unit = requireYamlMap(installUnits.get(unitId, true), `install_units.${unitId}`);
|
|
121
|
+
let components = unit.get("components", true);
|
|
122
|
+
if (components === undefined) {
|
|
123
|
+
components = document.createNode({});
|
|
124
|
+
unit.set("components", components);
|
|
125
|
+
}
|
|
126
|
+
addUnique(ensureSeq(requireYamlMap(components, `install_units.${unitId}.components`), "skills", document), skillId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function variantConfigMetadata(variant) {
|
|
130
|
+
return stripUndefined({
|
|
131
|
+
of: variant.of,
|
|
132
|
+
adapted_for: variant.adaptedFor ?? undefined,
|
|
133
|
+
capability: variant.capability,
|
|
134
|
+
workflow: variant.workflow,
|
|
135
|
+
status: variant.status,
|
|
136
|
+
base: {
|
|
137
|
+
content_digest: variant.base.contentDigest,
|
|
138
|
+
snapshot: variant.base.snapshot
|
|
139
|
+
},
|
|
140
|
+
approved: checkpointConfigMetadata(variant.approved)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function checkpointConfigMetadata(checkpoint) {
|
|
145
|
+
if (checkpoint === undefined || checkpoint === null) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
content_digest: checkpoint.contentDigest,
|
|
150
|
+
snapshot: checkpoint.snapshot
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function stripUndefined(value) {
|
|
155
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
156
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { loadWorkspace } from "../workspace.mjs";
|
|
3
|
+
import {
|
|
4
|
+
ensureSeq,
|
|
5
|
+
loadConfig,
|
|
6
|
+
readMapString,
|
|
7
|
+
removeValue,
|
|
8
|
+
writeCheckedConfig
|
|
9
|
+
} from "./config-write.mjs";
|
|
10
|
+
import {
|
|
11
|
+
addVariantCapabilityAlternative,
|
|
12
|
+
ensureRequiredCapability,
|
|
13
|
+
promoteVariantInWorkflow,
|
|
14
|
+
readRequiredCapabilityPolicy,
|
|
15
|
+
requireConfigCapability,
|
|
16
|
+
requireConfigSkill,
|
|
17
|
+
requireConfigWorkflow,
|
|
18
|
+
variantConfigMetadata
|
|
19
|
+
} from "./variant-lifecycle-config.mjs";
|
|
20
|
+
import {
|
|
21
|
+
createResetBackup,
|
|
22
|
+
digestVariantFile,
|
|
23
|
+
replaceLiveSkillFileForReset,
|
|
24
|
+
resolveVariantLiveSkillFile,
|
|
25
|
+
resolveVariantSnapshotFile
|
|
26
|
+
} from "./variant-files.mjs";
|
|
27
|
+
|
|
28
|
+
const APPROVED_INVOCATIONS = new Set(["manual-only", "router-only", "workflow-auto"]);
|
|
29
|
+
|
|
30
|
+
export async function resetSkillVariant(options) {
|
|
31
|
+
if (options.dryRun !== true && options.yes !== true) {
|
|
32
|
+
throw new Error("reset requires --yes unless --dry-run is used");
|
|
33
|
+
}
|
|
34
|
+
const target = resetTarget(options);
|
|
35
|
+
const current = await lifecycleSkill(options);
|
|
36
|
+
const checkpoint = resetCheckpoint(current.variant, target);
|
|
37
|
+
const snapshot = resolveVariantSnapshotFile({ configPath: options.configPath, snapshotPath: checkpoint.snapshot });
|
|
38
|
+
const content = await readSnapshotContent(snapshot.absolutePath, target, checkpoint.contentDigest);
|
|
39
|
+
const liveFile = resolveVariantLiveSkillFile({ skillsRoot: options.skillsRoot, skill: current });
|
|
40
|
+
const { document, originalText } = await loadConfig(options.configPath);
|
|
41
|
+
const skill = requireConfigSkill(document, options.variantId);
|
|
42
|
+
const workflow = requireConfigWorkflow(document, current.variant.workflow);
|
|
43
|
+
const capability = requireConfigCapability(document, current.variant.capability);
|
|
44
|
+
const variant = { ...current.variant, status: target === "base" ? "draft" : "approved" };
|
|
45
|
+
|
|
46
|
+
if (target === "base") {
|
|
47
|
+
demoteToBase(document, skill, workflow, variant, options.variantId);
|
|
48
|
+
} else {
|
|
49
|
+
promoteToApproved(document, skill, workflow, capability, variant, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const writeOptions = target === "approved"
|
|
53
|
+
? { ...options, validateUse: { skillId: options.variantId, workflow: variant.workflow } }
|
|
54
|
+
: options;
|
|
55
|
+
const filePlan = [await replaceLiveSkillFileForReset({ liveFile, content, dryRun: true })];
|
|
56
|
+
|
|
57
|
+
if (options.dryRun === true) {
|
|
58
|
+
const result = await writeCheckedConfig(document, originalText, writeOptions, resetMessage(options.variantId, target));
|
|
59
|
+
return { ...result, skill: options.variantId, variant, target, filePlan, warnings: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await writeCheckedConfig(document, originalText, { ...writeOptions, dryRun: true }, resetMessage(options.variantId, target));
|
|
63
|
+
const backup = await createResetBackup(liveFile);
|
|
64
|
+
try {
|
|
65
|
+
filePlan[0] = await replaceLiveSkillFileForReset({ liveFile, content });
|
|
66
|
+
const result = await writeCheckedConfig(document, originalText, writeOptions, resetMessage(options.variantId, target));
|
|
67
|
+
return { ...result, skill: options.variantId, variant, target, filePlan, warnings: [] };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await backup.restore().catch(() => undefined);
|
|
70
|
+
throw error;
|
|
71
|
+
} finally {
|
|
72
|
+
await backup.cleanup();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function demoteToBase(document, skill, workflow, variant, variantId) {
|
|
77
|
+
skill.set("status", "candidate");
|
|
78
|
+
skill.set("invocation", "manual-only");
|
|
79
|
+
skill.set("variant", document.createNode(variantConfigMetadata(variant)));
|
|
80
|
+
const required = ensureRequiredCapability(workflow, variant.capability, document);
|
|
81
|
+
if (readMapString(required, "preferred", "") === variantId) {
|
|
82
|
+
required.set("preferred", variant.of);
|
|
83
|
+
}
|
|
84
|
+
const fallback = ensureSeq(required, "fallback", document);
|
|
85
|
+
removeValue(fallback, variantId);
|
|
86
|
+
removeValue(fallback, variant.of);
|
|
87
|
+
removeValue(ensureSeq(workflow, "active_skills", document), variantId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function promoteToApproved(document, skill, workflow, capability, variant, options) {
|
|
91
|
+
const invocation = approvedInvocation(options, workflow, capability, variant.capability);
|
|
92
|
+
skill.set("status", "active");
|
|
93
|
+
skill.set("invocation", invocation);
|
|
94
|
+
skill.set("variant", document.createNode(variantConfigMetadata(variant)));
|
|
95
|
+
addVariantCapabilityAlternative(document, capability, { baseId: variant.of, variantId: options.variantId });
|
|
96
|
+
promoteVariantInWorkflow(document, workflow, capability, { baseId: variant.of, variantId: options.variantId, capability: variant.capability });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resetTarget(options) {
|
|
100
|
+
const values = [
|
|
101
|
+
options.toBase === true ? "base" : null,
|
|
102
|
+
options.toApproved === true ? "approved" : null,
|
|
103
|
+
options.to === "base" || options.target === "base" ? "base" : null,
|
|
104
|
+
options.to === "approved" || options.target === "approved" ? "approved" : null
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
if (values.length !== 1) {
|
|
107
|
+
throw new Error("reset requires exactly one of --to-base or --to-approved");
|
|
108
|
+
}
|
|
109
|
+
return values[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resetCheckpoint(variant, target) {
|
|
113
|
+
if (target === "base") {
|
|
114
|
+
return variant.base;
|
|
115
|
+
}
|
|
116
|
+
if (variant.approved === undefined) {
|
|
117
|
+
throw new Error("reset --to-approved requires approved snapshot metadata");
|
|
118
|
+
}
|
|
119
|
+
return variant.approved;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function lifecycleSkill(options) {
|
|
123
|
+
const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
|
|
124
|
+
const skill = workspace.skills.find((candidate) => candidate.id === options.variantId);
|
|
125
|
+
if (skill === undefined) {
|
|
126
|
+
throw new Error(`Unknown skill: ${options.variantId}`);
|
|
127
|
+
}
|
|
128
|
+
if (skill.variant === null || skill.variant === undefined) {
|
|
129
|
+
throw new Error(`Skill ${options.variantId} is not a lifecycle variant`);
|
|
130
|
+
}
|
|
131
|
+
return skill;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readSnapshotContent(path, label, expectedDigest) {
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = await readFile(path, "utf8");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error?.code === "ENOENT") {
|
|
140
|
+
throw new Error(`Missing ${label} snapshot: ${path}`);
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
const actualDigest = await digestVariantFile(path);
|
|
145
|
+
if (actualDigest !== expectedDigest) {
|
|
146
|
+
throw new Error(`${label} snapshot digest mismatch: expected ${expectedDigest}, got ${actualDigest}`);
|
|
147
|
+
}
|
|
148
|
+
return content;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function approvedInvocation(options, workflow, capability, capabilityName) {
|
|
152
|
+
const computed = firstNonEmpty([
|
|
153
|
+
options.mode,
|
|
154
|
+
options.invocation,
|
|
155
|
+
readRequiredCapabilityPolicy(workflow, capabilityName),
|
|
156
|
+
readMapString(capability, "default_policy", ""),
|
|
157
|
+
"manual-only"
|
|
158
|
+
]);
|
|
159
|
+
if (!APPROVED_INVOCATIONS.has(computed)) {
|
|
160
|
+
throw new Error("reset --to-approved requires --mode manual-only, router-only, or workflow-auto");
|
|
161
|
+
}
|
|
162
|
+
return computed;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function firstNonEmpty(values) {
|
|
166
|
+
return values.find((value) => typeof value === "string" && value.length > 0) ?? "manual-only";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function resetMessage(variantId, target) {
|
|
170
|
+
return `Reset variant ${variantId} to ${target}`;
|
|
171
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { loadWorkspace } from "../workspace.mjs";
|
|
2
|
+
import {
|
|
3
|
+
digestVariantFile,
|
|
4
|
+
resolveVariantLiveSkillFile,
|
|
5
|
+
resolveVariantSnapshotFile
|
|
6
|
+
} from "./variant-files.mjs";
|
|
7
|
+
|
|
8
|
+
export async function variantLifecycleStatus(options) {
|
|
9
|
+
const workspace = await loadWorkspace({ configPath: options.configPath, skillsRoot: options.skillsRoot });
|
|
10
|
+
const skill = workspace.skills.find((candidate) => candidate.id === options.variantId);
|
|
11
|
+
if (skill === undefined) {
|
|
12
|
+
throw new Error(`Unknown skill: ${options.variantId}`);
|
|
13
|
+
}
|
|
14
|
+
if (skill.variant === null || skill.variant === undefined) {
|
|
15
|
+
throw new Error(`Skill ${options.variantId} is not a lifecycle variant`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const liveFile = resolveVariantLiveSkillFile({ skillsRoot: options.skillsRoot, skill });
|
|
20
|
+
const live = await inspectDigestFile(liveFile);
|
|
21
|
+
const baseSnapshot = await inspectSnapshot(options.configPath, "base", skill.variant.base, warnings);
|
|
22
|
+
const approvedSnapshot = skill.variant.approved === undefined
|
|
23
|
+
? null
|
|
24
|
+
: await inspectSnapshot(options.configPath, "approved", skill.variant.approved, warnings);
|
|
25
|
+
const approvedDigest = skill.variant.approved?.contentDigest ?? null;
|
|
26
|
+
return {
|
|
27
|
+
skill: skill.id,
|
|
28
|
+
variant: skill.variant,
|
|
29
|
+
computedStatus: computeStatus(skill.variant.status, live.digest, skill.variant.base.contentDigest, approvedDigest),
|
|
30
|
+
liveDigest: live.digest,
|
|
31
|
+
baseDigest: skill.variant.base.contentDigest,
|
|
32
|
+
approvedDigest,
|
|
33
|
+
files: {
|
|
34
|
+
live: { path: liveFile, exists: live.exists, digest: live.digest },
|
|
35
|
+
baseSnapshot,
|
|
36
|
+
approvedSnapshot
|
|
37
|
+
},
|
|
38
|
+
warnings
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function computeStatus(lifecycleStatus, liveDigest, baseDigest, approvedDigest) {
|
|
43
|
+
if (liveDigest === null) {
|
|
44
|
+
return "missing-live-file";
|
|
45
|
+
}
|
|
46
|
+
if (approvedDigest !== null && liveDigest === approvedDigest) {
|
|
47
|
+
return "approved";
|
|
48
|
+
}
|
|
49
|
+
if (lifecycleStatus === "approved" && approvedDigest !== null) {
|
|
50
|
+
return "drifted";
|
|
51
|
+
}
|
|
52
|
+
return liveDigest === baseDigest ? "draft-base" : "draft-changed";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function inspectSnapshot(configPath, label, checkpoint, warnings) {
|
|
56
|
+
const resolved = resolveVariantSnapshotFile({ configPath, snapshotPath: checkpoint.snapshot });
|
|
57
|
+
const digest = await inspectDigestFile(resolved.absolutePath);
|
|
58
|
+
if (!digest.exists) {
|
|
59
|
+
warnings.push(`${label} snapshot missing: ${checkpoint.snapshot}`);
|
|
60
|
+
} else if (digest.digest !== checkpoint.contentDigest) {
|
|
61
|
+
warnings.push(`${label} snapshot digest mismatch: expected ${checkpoint.contentDigest}, got ${digest.digest}`);
|
|
62
|
+
}
|
|
63
|
+
return { path: resolved.storedPath, absolutePath: resolved.absolutePath, exists: digest.exists, digest: digest.digest };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function inspectDigestFile(path) {
|
|
67
|
+
try {
|
|
68
|
+
return { exists: true, digest: await digestVariantFile(path) };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.code === "ENOENT") {
|
|
71
|
+
return { exists: false, digest: null };
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|