@getbourdon/participants 0.5.5 → 0.6.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.
@@ -0,0 +1,750 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ /**
3
+ * Shared skill store: the library-level home of federated procedural memory.
4
+ * Port of the store half of the engine's `core/skill_index.py` (plan 005,
5
+ * phase 1); the content rules it relies on are in `skill-index.ts`.
6
+ *
7
+ * Layout (one folder per shared skill, beside the per-agent manifests):
8
+ *
9
+ * <library>/skills/<name>/
10
+ * SKILL.md verbatim body, as promoted
11
+ * skill.yaml index row (the skill index schema)
12
+ * files/... reference files, verbatim, text-only, size-capped
13
+ * .versions/<n>/ the same three entries for each replaced version
14
+ *
15
+ * Load-bearing rules:
16
+ *
17
+ * - **Org-tier write semantics.** The store is readable by every caller allowed
18
+ * to read the federation and writable only through {@link promoteSkill}. The
19
+ * ordinary federation write path never touches `<library>/skills`.
20
+ * - **One namespace, rename at promotion.** A shared name belongs to one skill.
21
+ * Promoting under a taken name throws {@link SkillNameCollisionError} with a
22
+ * free suggestion, unless the caller updates that skill by passing the
23
+ * current version as `expectedVersion` (also the compare-and-swap that stops
24
+ * two promotions from silently overwriting each other). A renamed skill keeps
25
+ * its front-matter name in `source_name`.
26
+ * - **Fail closed on reads.** A row that does not validate is not served.
27
+ */
28
+ import { randomBytes } from "node:crypto";
29
+ import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
30
+ import { basename, dirname, join } from "node:path";
31
+ import { WRITE_POLICY_READ_ONLY, withManifestLock } from "@getbourdon/federation";
32
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
33
+ import { SKILL_BODY_NAME, SKILL_DESCRIPTION_MAX, codePointLength, compareUtf8, computeContentSha256, isPathLinkOrReparse, parseFrontMatter, skillContentBody, skillNameError, } from "./skill-index.js";
34
+ export const SKILL_INDEX_SPEC_VERSION = "0.1";
35
+ export const SKILLS_DIRNAME = "skills";
36
+ export const INDEX_ROW_NAME = "skill.yaml";
37
+ export const FILES_DIRNAME = "files";
38
+ export const VERSIONS_DIRNAME = ".versions";
39
+ const STAGING_PREFIX = ".staging-";
40
+ const PROMOTION_LOCK_STEM = ".promotion";
41
+ /** The shared store behaves as org-tier memory: readable by every federation
42
+ * reader, writable by no one through the ordinary write API. */
43
+ export const SKILL_STORE_WRITE_POLICY = WRITE_POLICY_READ_ONLY;
44
+ /** Promotion widens nothing unless the promoting caller asks it to. */
45
+ export const DEFAULT_SKILL_VISIBILITY = "private";
46
+ export const MAX_INDEX_ROW_BYTES = 16 * 1024;
47
+ // The skill index schema's rules (spec/skill_index_schema.json in the engine).
48
+ const ROW_FIELDS = new Set([
49
+ "spec_version",
50
+ "name",
51
+ "source_name",
52
+ "description",
53
+ "version",
54
+ "content_sha256",
55
+ "source_agent",
56
+ "promoted_by",
57
+ "promoted_at",
58
+ "proposal_id",
59
+ "visibility",
60
+ "valid_to",
61
+ "supersedes",
62
+ ]);
63
+ const REQUIRED_FIELDS = [
64
+ "spec_version",
65
+ "name",
66
+ "description",
67
+ "version",
68
+ "content_sha256",
69
+ "source_agent",
70
+ "promoted_by",
71
+ "promoted_at",
72
+ "visibility",
73
+ ];
74
+ const AGENT_ID_PATTERN = "^[a-z0-9][a-z0-9_-]*$";
75
+ const AGENT_ID_RE = new RegExp(AGENT_ID_PATTERN);
76
+ const AGENT_ID_MAX = 64;
77
+ const PROPOSAL_ID_MAX = 128;
78
+ const PROMOTED_AT_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|([+-])(\d{2}):(\d{2}))$/;
79
+ const VISIBILITIES = new Set(["public", "team", "private"]);
80
+ const DATE_RE = /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
81
+ const SHA256_RE = /^[0-9a-f]{64}$/;
82
+ const NAME_LIMIT = 64;
83
+ // -- errors -------------------------------------------------------------------
84
+ /** Base class for every refusal the skill store raises. */
85
+ export class SkillIndexError extends Error {
86
+ constructor(message) {
87
+ super(message);
88
+ this.name = "SkillIndexError";
89
+ }
90
+ }
91
+ /** An index row does not conform to the skill index schema. */
92
+ export class InvalidSkillRowError extends SkillIndexError {
93
+ errors;
94
+ constructor(errors) {
95
+ super(`invalid skill index row: ${errors.join("; ")}`);
96
+ this.name = "InvalidSkillRowError";
97
+ this.errors = [...errors];
98
+ }
99
+ }
100
+ /** The shared name is already taken and the caller did not ask to update it. */
101
+ export class SkillNameCollisionError extends SkillIndexError {
102
+ skillName;
103
+ existingVersion;
104
+ suggestedName;
105
+ constructor(name, existingVersion, suggestedName) {
106
+ const hint = suggestedName
107
+ ? `; promote under another name (for example '${suggestedName}')`
108
+ : "; promote under another name";
109
+ super(`shared skill '${name}' already exists at version ${existingVersion}` +
110
+ `${hint}, or pass expected_version=${existingVersion} to replace it`);
111
+ this.name = "SkillNameCollisionError";
112
+ this.skillName = name;
113
+ this.existingVersion = existingVersion;
114
+ this.suggestedName = suggestedName;
115
+ }
116
+ }
117
+ /** `expectedVersion` does not match what is in the store. */
118
+ export class SkillVersionConflictError extends SkillIndexError {
119
+ skillName;
120
+ expected;
121
+ current;
122
+ constructor(name, expected, current) {
123
+ super(`shared skill '${name}': expected version ${expected === null ? "None" : expected}, ` +
124
+ `store has ${current !== null ? current : "no such skill"}`);
125
+ this.name = "SkillVersionConflictError";
126
+ this.skillName = name;
127
+ this.expected = expected;
128
+ this.current = current;
129
+ }
130
+ }
131
+ /** No shared skill (or no such version of it) exists. */
132
+ export class SkillNotFoundError extends SkillIndexError {
133
+ constructor(message) {
134
+ super(message);
135
+ this.name = "SkillNotFoundError";
136
+ }
137
+ }
138
+ // -- validation -----------------------------------------------------------------
139
+ /** Return `name` if it is a valid shared skill name, else throw {@link SkillIndexError}. */
140
+ export function validateSkillName(name) {
141
+ const error = skillNameError(name);
142
+ if (error !== null)
143
+ throw new SkillIndexError(error);
144
+ return name;
145
+ }
146
+ function isInt(value) {
147
+ return typeof value === "number" && Number.isInteger(value);
148
+ }
149
+ function isDict(value) {
150
+ return (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
151
+ }
152
+ function daysInMonth(year, month) {
153
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
154
+ }
155
+ function validDate(value) {
156
+ if (typeof value !== "string")
157
+ return false;
158
+ const match = DATE_RE.exec(value);
159
+ if (match === null)
160
+ return false;
161
+ const year = Number(match[1]);
162
+ const month = Number(match[2]);
163
+ return year >= 1 && Number(match[3]) <= daysInMonth(year, month);
164
+ }
165
+ function validTimestamp(value) {
166
+ if (typeof value !== "string")
167
+ return false;
168
+ const match = PROMOTED_AT_RE.exec(value);
169
+ if (match === null)
170
+ return false;
171
+ const [, y, mo, d, h, mi, s] = match.map(Number);
172
+ if (y < 1 || mo < 1 || mo > 12 || d < 1 || d > daysInMonth(y, mo))
173
+ return false;
174
+ if (h > 23 || mi > 59 || s > 59)
175
+ return false;
176
+ if (match[8] !== "Z" && (Number(match[10]) > 23 || Number(match[11]) > 59))
177
+ return false;
178
+ return true;
179
+ }
180
+ /**
181
+ * Check a parsed `skill.yaml` mapping against the schema's rules. Returns every
182
+ * violation found (empty means valid), in the oracle's order and wording.
183
+ */
184
+ export function validateIndexRow(row) {
185
+ if (!isDict(row))
186
+ return ["row must be a mapping"];
187
+ const errors = [];
188
+ const keys = Object.keys(row);
189
+ const has = (key) => Object.hasOwn(row, key);
190
+ const unknown = keys.filter((key) => !ROW_FIELDS.has(key)).sort(compareUtf8);
191
+ if (unknown.length > 0)
192
+ errors.push(`unknown fields: ${unknown.join(", ")}`);
193
+ const missing = REQUIRED_FIELDS.filter((key) => !has(key));
194
+ if (missing.length > 0)
195
+ errors.push(`missing required fields: ${missing.join(", ")}`);
196
+ if (has("spec_version") && row.spec_version !== SKILL_INDEX_SPEC_VERSION) {
197
+ errors.push(`spec_version must be '${SKILL_INDEX_SPEC_VERSION}'`);
198
+ }
199
+ if (has("name")) {
200
+ const err = skillNameError(row.name);
201
+ if (err !== null)
202
+ errors.push(err);
203
+ }
204
+ if (has("source_name")) {
205
+ const err = skillNameError(row.source_name, "source_name");
206
+ if (err !== null)
207
+ errors.push(err);
208
+ }
209
+ const description = row.description;
210
+ if (has("description") &&
211
+ (typeof description !== "string" ||
212
+ codePointLength(description) < 1 ||
213
+ codePointLength(description) > SKILL_DESCRIPTION_MAX)) {
214
+ errors.push(`description must be a string of 1..${SKILL_DESCRIPTION_MAX} characters`);
215
+ }
216
+ const version = row.version;
217
+ if (has("version") && (!isInt(version) || version < 1)) {
218
+ errors.push("version must be an integer >= 1");
219
+ }
220
+ const digest = row.content_sha256;
221
+ if (has("content_sha256") && (typeof digest !== "string" || !SHA256_RE.test(digest))) {
222
+ errors.push("content_sha256 must be 64 lowercase hex characters");
223
+ }
224
+ for (const key of ["source_agent", "promoted_by"]) {
225
+ const value = row[key];
226
+ if (has(key) &&
227
+ (typeof value !== "string" ||
228
+ codePointLength(value) < 1 ||
229
+ codePointLength(value) > AGENT_ID_MAX ||
230
+ !AGENT_ID_RE.test(value))) {
231
+ errors.push(`${key} must be an agent id matching ${AGENT_ID_PATTERN}`);
232
+ }
233
+ }
234
+ if (has("promoted_at") && !validTimestamp(row.promoted_at)) {
235
+ errors.push("promoted_at must be an ISO 8601 timestamp with a UTC offset");
236
+ }
237
+ const proposalId = row.proposal_id;
238
+ if (has("proposal_id") &&
239
+ (typeof proposalId !== "string" ||
240
+ codePointLength(proposalId) < 1 ||
241
+ codePointLength(proposalId) > PROPOSAL_ID_MAX)) {
242
+ errors.push(`proposal_id must be a string of 1..${PROPOSAL_ID_MAX} characters`);
243
+ }
244
+ if (has("visibility") && !VISIBILITIES.has(row.visibility)) {
245
+ errors.push("visibility must be one of private, public, team");
246
+ }
247
+ if (has("valid_to") && !validDate(row.valid_to)) {
248
+ errors.push("valid_to must be an ISO 8601 date (YYYY-MM-DD)");
249
+ }
250
+ const supersedes = row.supersedes;
251
+ if (has("supersedes")) {
252
+ if (!isInt(supersedes) || supersedes < 1) {
253
+ errors.push("supersedes must be an integer >= 1");
254
+ }
255
+ else if (isInt(version) && supersedes !== version - 1) {
256
+ errors.push("supersedes must equal version - 1");
257
+ }
258
+ }
259
+ else if (isInt(version) && version > 1) {
260
+ errors.push("supersedes is required when version > 1");
261
+ }
262
+ return errors;
263
+ }
264
+ // -- the index row --------------------------------------------------------------
265
+ /** Today's date in UTC, `YYYY-MM-DD`. */
266
+ function utcToday() {
267
+ return new Date().toISOString().slice(0, 10);
268
+ }
269
+ /** One validated `skill.yaml`. */
270
+ export class SkillIndexRow {
271
+ specVersion;
272
+ name;
273
+ sourceName;
274
+ description;
275
+ version;
276
+ contentSha256;
277
+ sourceAgent;
278
+ promotedBy;
279
+ promotedAt;
280
+ proposalId;
281
+ visibility;
282
+ validTo;
283
+ supersedes;
284
+ constructor(data) {
285
+ this.specVersion = data.spec_version;
286
+ this.name = data.name;
287
+ this.sourceName = data.source_name ?? null;
288
+ this.description = data.description;
289
+ this.version = data.version;
290
+ this.contentSha256 = data.content_sha256;
291
+ this.sourceAgent = data.source_agent;
292
+ this.promotedBy = data.promoted_by;
293
+ this.promotedAt = data.promoted_at;
294
+ this.proposalId = data.proposal_id ?? null;
295
+ this.visibility = data.visibility;
296
+ this.validTo = data.valid_to ?? null;
297
+ this.supersedes = data.supersedes ?? null;
298
+ }
299
+ /** Validate `data` and build a row; throws {@link InvalidSkillRowError}. */
300
+ static fromDict(data) {
301
+ const errors = validateIndexRow(data);
302
+ if (errors.length > 0)
303
+ throw new InvalidSkillRowError(errors);
304
+ return new SkillIndexRow(data);
305
+ }
306
+ /** Serialize in schema order, omitting absent optional fields. */
307
+ toDict() {
308
+ const out = { spec_version: this.specVersion, name: this.name };
309
+ if (this.sourceName !== null)
310
+ out.source_name = this.sourceName;
311
+ out.description = this.description;
312
+ out.version = this.version;
313
+ out.content_sha256 = this.contentSha256;
314
+ out.source_agent = this.sourceAgent;
315
+ out.promoted_by = this.promotedBy;
316
+ out.promoted_at = this.promotedAt;
317
+ if (this.proposalId !== null)
318
+ out.proposal_id = this.proposalId;
319
+ out.visibility = this.visibility;
320
+ if (this.validTo !== null)
321
+ out.valid_to = this.validTo;
322
+ if (this.supersedes !== null)
323
+ out.supersedes = this.supersedes;
324
+ return out;
325
+ }
326
+ /** False once `valid_to` is on or before `today` (UTC today by default, `YYYY-MM-DD`). */
327
+ isActive(today) {
328
+ if (this.validTo === null)
329
+ return true;
330
+ return this.validTo > (today ?? utcToday());
331
+ }
332
+ }
333
+ /** Python's `yaml.safe_load` reads YAML 1.1 (so an unquoted timestamp is not a string). */
334
+ function parseYaml11(text) {
335
+ return yamlParse(text, { version: "1.1", uniqueKeys: false, logLevel: "silent" });
336
+ }
337
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
338
+ /** Read and validate one `skill.yaml`. */
339
+ export function loadIndexRow(path) {
340
+ if (isPathLinkOrReparse(path)) {
341
+ throw new InvalidSkillRowError([`${INDEX_ROW_NAME} must not be a link`]);
342
+ }
343
+ const raw = readFileSync(path);
344
+ if (raw.length > MAX_INDEX_ROW_BYTES) {
345
+ throw new InvalidSkillRowError([`${INDEX_ROW_NAME} exceeds ${MAX_INDEX_ROW_BYTES} bytes`]);
346
+ }
347
+ let data;
348
+ try {
349
+ data = parseYaml11(UTF8.decode(raw));
350
+ }
351
+ catch {
352
+ throw new InvalidSkillRowError([`${INDEX_ROW_NAME} is not readable YAML`]);
353
+ }
354
+ return SkillIndexRow.fromDict(data);
355
+ }
356
+ function atomicWriteBytes(path, data) {
357
+ const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
358
+ try {
359
+ const fd = openSync(temp, "wx");
360
+ try {
361
+ writeSync(fd, data);
362
+ fsyncSync(fd);
363
+ }
364
+ finally {
365
+ closeSync(fd);
366
+ }
367
+ renameSync(temp, path);
368
+ }
369
+ finally {
370
+ if (existsSync(temp))
371
+ rmSync(temp, { force: true });
372
+ }
373
+ }
374
+ function renderRow(row) {
375
+ const errors = validateIndexRow(row.toDict());
376
+ if (errors.length > 0)
377
+ throw new InvalidSkillRowError(errors);
378
+ // YAML 1.1 quoting, so a timestamp or a date stays a string for any reader.
379
+ const text = yamlStringify(row.toDict(), {
380
+ version: "1.1",
381
+ sortMapEntries: false,
382
+ singleQuote: true,
383
+ });
384
+ return Buffer.from(text, "utf8");
385
+ }
386
+ /** Validate `row` and write it atomically to `path`. */
387
+ export function writeIndexRow(path, row) {
388
+ atomicWriteBytes(path, renderRow(row));
389
+ }
390
+ // -- library layout ---------------------------------------------------------------
391
+ /** `<library>/skills` -- the shared skill store. */
392
+ export function skillsRoot(libraryPath) {
393
+ return join(libraryPath, SKILLS_DIRNAME);
394
+ }
395
+ /** The folder of one shared skill; `name` is validated first. */
396
+ export function skillDir(libraryPath, name) {
397
+ return join(skillsRoot(libraryPath), validateSkillName(name));
398
+ }
399
+ function versionDir(libraryPath, name, version) {
400
+ return join(skillDir(libraryPath, name), VERSIONS_DIRNAME, String(version));
401
+ }
402
+ /** `path` is a regular file, following links (Python `Path.is_file()`). */
403
+ function isFile(path) {
404
+ try {
405
+ return statSync(path).isFile();
406
+ }
407
+ catch {
408
+ return false;
409
+ }
410
+ }
411
+ function isDirectory(path) {
412
+ try {
413
+ return statSync(path).isDirectory();
414
+ }
415
+ catch {
416
+ return false;
417
+ }
418
+ }
419
+ /**
420
+ * The index row for `name` (the live one, or an archived `version`). Throws
421
+ * {@link SkillNotFoundError} when absent and {@link InvalidSkillRowError} when
422
+ * present but invalid.
423
+ */
424
+ export function getSkillRow(libraryPath, name, version = null) {
425
+ const live = join(skillDir(libraryPath, name), INDEX_ROW_NAME);
426
+ if (!isFile(live))
427
+ throw new SkillNotFoundError(`no shared skill named '${name}'`);
428
+ const row = loadIndexRow(live);
429
+ if (row.name !== name) {
430
+ throw new InvalidSkillRowError([`row name '${row.name}' does not match its folder '${name}'`]);
431
+ }
432
+ if (version === null || version === row.version)
433
+ return row;
434
+ if (!isInt(version) || version < 1 || version >= row.version) {
435
+ throw new SkillNotFoundError(`shared skill '${name}' has no version ${version}`);
436
+ }
437
+ const archivedPath = join(versionDir(libraryPath, name, version), INDEX_ROW_NAME);
438
+ if (!isFile(archivedPath)) {
439
+ throw new SkillNotFoundError(`shared skill '${name}' has no version ${version}`);
440
+ }
441
+ const archived = loadIndexRow(archivedPath);
442
+ if (archived.name !== name || archived.version !== version) {
443
+ throw new InvalidSkillRowError([`archived row for '${name}' v${version} is inconsistent`]);
444
+ }
445
+ return archived;
446
+ }
447
+ function sortedEntries(directory) {
448
+ return readdirSync(directory, { withFileTypes: true }).sort((a, b) => compareUtf8(a.name, b.name));
449
+ }
450
+ /**
451
+ * Every valid live index row, sorted by name. Retired rows are omitted unless
452
+ * `includeRetired`. Rows that fail validation are skipped (and logged) rather
453
+ * than served.
454
+ */
455
+ export function listSkillRows(libraryPath, options = {}) {
456
+ const root = skillsRoot(libraryPath);
457
+ if (!isDirectory(root))
458
+ return [];
459
+ const rows = [];
460
+ for (const child of sortedEntries(root)) {
461
+ if (child.name.startsWith(".") || skillNameError(child.name) !== null)
462
+ continue;
463
+ const path = join(root, child.name);
464
+ if (isPathLinkOrReparse(path) || !isDirectory(path))
465
+ continue;
466
+ let row;
467
+ try {
468
+ row = getSkillRow(libraryPath, child.name);
469
+ }
470
+ catch (err) {
471
+ if (err instanceof SkillNotFoundError)
472
+ continue;
473
+ if (err instanceof InvalidSkillRowError || isOsError(err)) {
474
+ console.warn(`[bourdon.skill_index] skipping ${child.name} (${String(err)})`);
475
+ continue;
476
+ }
477
+ throw err;
478
+ }
479
+ if (options.includeRetired || row.isActive(options.today))
480
+ rows.push(row);
481
+ }
482
+ return rows;
483
+ }
484
+ /** Every version number on disk for `name`, oldest first. */
485
+ export function listSkillVersions(libraryPath, name) {
486
+ const live = getSkillRow(libraryPath, name);
487
+ const archive = join(skillDir(libraryPath, name), VERSIONS_DIRNAME);
488
+ const versions = new Set([live.version]);
489
+ if (isDirectory(archive)) {
490
+ for (const child of readdirSync(archive)) {
491
+ if (/^[0-9]+$/.test(child) && isFile(join(archive, child, INDEX_ROW_NAME))) {
492
+ versions.add(Number(child));
493
+ }
494
+ }
495
+ }
496
+ return [...versions].sort((a, b) => a - b);
497
+ }
498
+ /** A Node filesystem error (Python's `OSError`). */
499
+ export function isOsError(err) {
500
+ return err instanceof Error && typeof err.code === "string";
501
+ }
502
+ function readStoredFiles(folder) {
503
+ const entries = [
504
+ [SKILL_BODY_NAME, readFileSync(join(folder, SKILL_BODY_NAME))],
505
+ ];
506
+ const filesRoot = join(folder, FILES_DIRNAME);
507
+ if (!isDirectory(filesRoot))
508
+ return entries;
509
+ // os.walk(followlinks=False): a directory's files first, then its sorted
510
+ // subdirectories; a link to a directory is listed but never entered, a link
511
+ // to anything else is refused.
512
+ const walk = (directory, parts) => {
513
+ const subdirs = [];
514
+ for (const entry of sortedEntries(directory)) {
515
+ const path = join(directory, entry.name);
516
+ if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(path))) {
517
+ if (!entry.isSymbolicLink())
518
+ subdirs.push(entry.name);
519
+ continue;
520
+ }
521
+ if (entry.isSymbolicLink() || isPathLinkOrReparse(path)) {
522
+ throw new InvalidSkillRowError([`stored file '${entry.name}' is a link`]);
523
+ }
524
+ entries.push([[...parts, entry.name].join("/"), readFileSync(path)]);
525
+ }
526
+ for (const name of subdirs)
527
+ walk(join(directory, name), [...parts, name]);
528
+ };
529
+ walk(filesRoot, []);
530
+ return entries;
531
+ }
532
+ /** The row and the stored files of one shared skill version. */
533
+ export function readSharedSkill(libraryPath, name, version = null) {
534
+ const row = getSkillRow(libraryPath, name, version);
535
+ const live = getSkillRow(libraryPath, name);
536
+ const folder = row.version === live.version
537
+ ? skillDir(libraryPath, name)
538
+ : versionDir(libraryPath, name, row.version);
539
+ const files = readStoredFiles(folder);
540
+ const body = files.find(([rel]) => rel === SKILL_BODY_NAME)[1];
541
+ const content = {
542
+ frontMatter: parseFrontMatter(body),
543
+ files: [...files].sort(([a], [b]) => compareUtf8(a, b)),
544
+ };
545
+ return { row, content };
546
+ }
547
+ /** Whether the stored files still hash to the row's `content_sha256`. */
548
+ export function verifySharedSkill(libraryPath, name, version = null) {
549
+ const { row, content } = readSharedSkill(libraryPath, name, version);
550
+ return computeContentSha256(content.files) === row.contentSha256;
551
+ }
552
+ // -- naming -----------------------------------------------------------------------
553
+ /** Python `str.rstrip("-")`. */
554
+ function rstripDash(text) {
555
+ return text.replace(/-+$/, "");
556
+ }
557
+ /**
558
+ * A free shared name derived from `name` (`name-2`, `name-3`...). Returns null
559
+ * only if no candidate within the length limit is free.
560
+ */
561
+ export function suggestAvailableName(libraryPath, name) {
562
+ validateSkillName(name);
563
+ for (let n = 2; n < 1000; n++) {
564
+ const suffix = `-${n}`;
565
+ const base = rstripDash(name.slice(0, NAME_LIMIT - suffix.length)) || name.slice(0, 1);
566
+ const candidate = base + suffix;
567
+ if (!existsSync(join(skillsRoot(libraryPath), candidate)))
568
+ return candidate;
569
+ }
570
+ return null;
571
+ }
572
+ /** `datetime.now(timezone.utc).isoformat(timespec="seconds")`. */
573
+ function nowIso() {
574
+ return `${new Date().toISOString().slice(0, 19)}+00:00`;
575
+ }
576
+ function writeTree(folder, content, row) {
577
+ mkdirSync(folder);
578
+ writeFileSync(join(folder, SKILL_BODY_NAME), skillContentBody(content));
579
+ for (const [rel, data] of content.files) {
580
+ if (rel === SKILL_BODY_NAME)
581
+ continue;
582
+ const target = join(folder, FILES_DIRNAME, ...rel.split("/"));
583
+ mkdirSync(dirname(target), { recursive: true });
584
+ writeFileSync(target, data);
585
+ }
586
+ writeFileSync(join(folder, INDEX_ROW_NAME), renderRow(row));
587
+ }
588
+ function pathExists(path) {
589
+ try {
590
+ lstatSync(path);
591
+ return true;
592
+ }
593
+ catch {
594
+ return false;
595
+ }
596
+ }
597
+ /**
598
+ * Write `content` into the shared store as a new skill or a new version.
599
+ *
600
+ * This is the store's only write path and performs no authorization: the
601
+ * caller (the promotion gate) must already have established that a trusted
602
+ * caller approved it. `content` must come from `readSkillFolder`.
603
+ *
604
+ * - `name` defaults to the front-matter name. Another name renames at
605
+ * promotion; the front-matter name is then kept as `source_name`.
606
+ * - If the name is free, `expectedVersion` must be null.
607
+ * - If the name is taken, `expectedVersion` must equal the live version; null
608
+ * throws {@link SkillNameCollisionError} (with a free name to suggest) and
609
+ * any other value throws {@link SkillVersionConflictError}.
610
+ * - Content identical to the live version is a no-op (`changed: false`).
611
+ *
612
+ * The previous version moves to `.versions/<n>/`. `skill.yaml` is moved out
613
+ * first and written last, so a reader never sees a row next to the wrong body;
614
+ * an interrupted promotion leaves the skill unlisted, with the old version
615
+ * intact under `.versions/`.
616
+ */
617
+ export function promoteSkill(libraryPath, content, options) {
618
+ const frontName = content.frontMatter.name;
619
+ const target = validateSkillName(options.name ?? frontName);
620
+ const digest = computeContentSha256(content.files);
621
+ const rowFields = {
622
+ spec_version: SKILL_INDEX_SPEC_VERSION,
623
+ name: target,
624
+ description: content.frontMatter.description,
625
+ content_sha256: digest,
626
+ source_agent: options.sourceAgent,
627
+ promoted_by: options.promotedBy,
628
+ promoted_at: options.promotedAt || nowIso(),
629
+ visibility: options.visibility ?? DEFAULT_SKILL_VISIBILITY,
630
+ };
631
+ if (target !== frontName)
632
+ rowFields.source_name = frontName;
633
+ if (options.proposalId !== undefined && options.proposalId !== null) {
634
+ rowFields.proposal_id = options.proposalId;
635
+ }
636
+ const expected = options.expectedVersion ?? null;
637
+ if (expected !== null && (!isInt(expected) || expected < 1)) {
638
+ throw new SkillIndexError("expected_version must be an integer >= 1 or None");
639
+ }
640
+ const root = skillsRoot(libraryPath);
641
+ mkdirSync(root, { recursive: true });
642
+ if (isPathLinkOrReparse(root))
643
+ throw new SkillIndexError(`${SKILLS_DIRNAME}/ must not be a link`);
644
+ return withManifestLock(join(root, PROMOTION_LOCK_STEM), () => {
645
+ const folder = join(root, target);
646
+ let current = null;
647
+ if (pathExists(folder)) {
648
+ if (isPathLinkOrReparse(folder) || !isDirectory(folder)) {
649
+ throw new SkillIndexError(`shared skill folder '${target}' is not a real directory`);
650
+ }
651
+ try {
652
+ current = getSkillRow(libraryPath, target);
653
+ }
654
+ catch (err) {
655
+ if (!(err instanceof SkillNotFoundError))
656
+ throw err;
657
+ throw new SkillIndexError(`shared skill folder '${target}' has no ${INDEX_ROW_NAME}; ` +
658
+ "restore it from .versions/ before promoting again");
659
+ }
660
+ }
661
+ if (current === null) {
662
+ if (expected !== null)
663
+ throw new SkillVersionConflictError(target, expected, null);
664
+ rowFields.version = 1;
665
+ }
666
+ else {
667
+ if (expected === null) {
668
+ throw new SkillNameCollisionError(target, current.version, suggestAvailableName(libraryPath, target));
669
+ }
670
+ if (expected !== current.version) {
671
+ throw new SkillVersionConflictError(target, expected, current.version);
672
+ }
673
+ if (current.contentSha256 === digest)
674
+ return { row: current, changed: false };
675
+ rowFields.version = current.version + 1;
676
+ rowFields.supersedes = current.version;
677
+ }
678
+ const row = SkillIndexRow.fromDict(rowFields);
679
+ mkdirSync(folder, { recursive: true });
680
+ const staging = join(folder, `${STAGING_PREFIX}${randomBytes(6).toString("hex")}`);
681
+ try {
682
+ writeTree(staging, content, row);
683
+ if (current !== null) {
684
+ const archive = versionDir(libraryPath, target, current.version);
685
+ if (pathExists(archive)) {
686
+ throw new SkillIndexError(`archive for '${target}' v${current.version} already exists`);
687
+ }
688
+ mkdirSync(archive, { recursive: true });
689
+ renameSync(join(folder, INDEX_ROW_NAME), join(archive, INDEX_ROW_NAME));
690
+ renameSync(join(folder, SKILL_BODY_NAME), join(archive, SKILL_BODY_NAME));
691
+ if (pathExists(join(folder, FILES_DIRNAME))) {
692
+ renameSync(join(folder, FILES_DIRNAME), join(archive, FILES_DIRNAME));
693
+ }
694
+ }
695
+ renameSync(join(staging, SKILL_BODY_NAME), join(folder, SKILL_BODY_NAME));
696
+ if (pathExists(join(staging, FILES_DIRNAME))) {
697
+ renameSync(join(staging, FILES_DIRNAME), join(folder, FILES_DIRNAME));
698
+ }
699
+ renameSync(join(staging, INDEX_ROW_NAME), join(folder, INDEX_ROW_NAME));
700
+ }
701
+ finally {
702
+ if (pathExists(staging))
703
+ rmSync(staging, { recursive: true, force: true });
704
+ if (current === null && !pathExists(join(folder, INDEX_ROW_NAME))) {
705
+ rmSync(folder, { recursive: true, force: true });
706
+ }
707
+ }
708
+ return { row, changed: true };
709
+ });
710
+ }
711
+ // -- retirement -----------------------------------------------------------------
712
+ /** The live `skill.yaml` of `name`. */
713
+ function liveRowPath(libraryPath, name) {
714
+ return join(skillDir(libraryPath, name), INDEX_ROW_NAME);
715
+ }
716
+ /**
717
+ * Set (retire) or clear (restore) the live row's `valid_to`.
718
+ *
719
+ * Retirement is a row edit, not a new version: the body and its hash are
720
+ * untouched, so every version stays fetchable by number. Like
721
+ * {@link promoteSkill} this performs no authorization; the caller is the gate.
722
+ * `expectedVersion` must equal the live version (compare-and-swap, under the
723
+ * same lock promotion writes under).
724
+ */
725
+ export function setSkillValidTo(libraryPath, name, validTo, options) {
726
+ if (validTo !== null && !validDate(validTo)) {
727
+ throw new SkillIndexError("valid_to must be an ISO 8601 date (YYYY-MM-DD)");
728
+ }
729
+ const expected = options.expectedVersion;
730
+ if (!isInt(expected) || expected < 1) {
731
+ throw new SkillIndexError("expected_version must be an integer >= 1");
732
+ }
733
+ const root = skillsRoot(libraryPath);
734
+ if (!isDirectory(root) || isPathLinkOrReparse(root)) {
735
+ throw new SkillNotFoundError(`no shared skill named '${name}'`);
736
+ }
737
+ return withManifestLock(join(root, PROMOTION_LOCK_STEM), () => {
738
+ const current = getSkillRow(libraryPath, name);
739
+ if (current.version !== expected) {
740
+ throw new SkillVersionConflictError(name, expected, current.version);
741
+ }
742
+ const fields = current.toDict();
743
+ delete fields.valid_to;
744
+ if (validTo !== null)
745
+ fields.valid_to = validTo;
746
+ const row = SkillIndexRow.fromDict(fields);
747
+ writeIndexRow(liveRowPath(libraryPath, name), row);
748
+ return row;
749
+ });
750
+ }