@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,470 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ /**
3
+ * Reader-side skill folder scan (plan 005). Port of the parts of the engine's
4
+ * `core/skill_index.py` a participant needs to advertise a local skill: the
5
+ * text-only content rules, the no-follow folder walk, front-matter parsing,
6
+ * the `content_sha256` framing and `scan_local_skill` (skip and flag).
7
+ *
8
+ * `readSkillFolder` is the promotion-side twin of `scanLocalSkill`: it refuses
9
+ * a folder that breaks any rule instead of skipping files. The library-level
10
+ * shared store (index rows, versions, promotion) is in `skill-store.ts`. Both
11
+ * use the same limits and name rule, so a hash computed here compares
12
+ * directly with a shared skill's.
13
+ *
14
+ * Rules a v1 shared skill must meet (anything else is skipped and flagged):
15
+ * - `SKILL.md` plus markdown, plain text and template files only;
16
+ * - no links, no hidden entries, bounded depth and path length, no paths that
17
+ * differ only in case;
18
+ * - UTF-8 text without NUL bytes, no interpreter line, no credential-like text;
19
+ * - per-file and total size caps, at most {@link MAX_REFERENCE_FILES} reference files.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
23
+ import { basename, join } from "node:path";
24
+ import { l5Schema } from "@getbourdon/l5";
25
+ import { containsSecret } from "@getbourdon/redaction";
26
+ import { parse as yamlParse } from "yaml";
27
+ export const SKILL_BODY_NAME = "SKILL.md";
28
+ /** Reference-file types a v1 shared skill may carry: markdown, plain text and templates. */
29
+ export const TEXT_FILE_SUFFIXES = new Set([
30
+ ".md",
31
+ ".markdown",
32
+ ".txt",
33
+ ".tmpl",
34
+ ".tpl",
35
+ ".template",
36
+ ".j2",
37
+ ".jinja",
38
+ ".jinja2",
39
+ ".mustache",
40
+ ]);
41
+ export const MAX_SKILL_BODY_BYTES = 128 * 1024;
42
+ export const MAX_REFERENCE_FILE_BYTES = 256 * 1024;
43
+ export const MAX_REFERENCE_FILES = 64;
44
+ export const MAX_SKILL_TOTAL_BYTES = 2 * 1024 * 1024;
45
+ const MAX_PATH_DEPTH = 8;
46
+ const MAX_REL_PATH_CHARS = 255;
47
+ const FRONT_MATTER_FENCE = "---";
48
+ /**
49
+ * Why a reader left a file out of an advertised skill. `limit` means the
50
+ * file-count or total-size cap was reached; every other code names the content
51
+ * rule the file breaks.
52
+ */
53
+ export const SKIP_REASONS = new Set([
54
+ "credential",
55
+ "duplicate_case",
56
+ "file_type",
57
+ "hidden",
58
+ "limit",
59
+ "link",
60
+ "not_text",
61
+ "path",
62
+ "script",
63
+ "too_large",
64
+ "unreadable",
65
+ ]);
66
+ // The skill name and description rules, read from the L5 schema's SkillRef
67
+ // (the same values as the shared skill index schema's).
68
+ function skillRefProp(name) {
69
+ const skillRef = l5Schema.$defs.SkillRef;
70
+ const found = skillRef?.properties?.[name];
71
+ if (found === undefined)
72
+ throw new Error(`L5 schema: SkillRef is missing ${name}`);
73
+ return found;
74
+ }
75
+ const NAME_PATTERN = String(skillRefProp("name").pattern);
76
+ const NAME_RE = new RegExp(`^(?:${NAME_PATTERN})$`);
77
+ const NAME_MAX = Number(skillRefProp("name").maxLength);
78
+ const DESCRIPTION_MAX = Number(skillRefProp("description").maxLength);
79
+ /** Longest skill name, in code points. */
80
+ export const SKILL_NAME_MAX = NAME_MAX;
81
+ /** Longest front-matter description, in code points. */
82
+ export const SKILL_DESCRIPTION_MAX = DESCRIPTION_MAX;
83
+ // -- errors -------------------------------------------------------------------
84
+ /**
85
+ * A skill folder cannot be advertised (or shared) as it stands. `problems`
86
+ * lists every offending path with the reason; content is never echoed back.
87
+ */
88
+ export class SkillContentError extends Error {
89
+ problems;
90
+ constructor(problems) {
91
+ super(`skill folder cannot be shared: ${problems.join("; ")}`);
92
+ this.name = "SkillContentError";
93
+ this.problems = [...problems];
94
+ }
95
+ }
96
+ // -- Python string semantics ----------------------------------------------------
97
+ // str.isspace() characters, for str.strip().
98
+ const PY_SPACE = "\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000";
99
+ const PY_STRIP_RE = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, "g");
100
+ // str.splitlines() boundaries.
101
+ // eslint-disable-next-line no-control-regex
102
+ const PY_LINE_BREAK_RE = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/;
103
+ /** Python `str.strip()` with no argument. */
104
+ export function pyStrip(text) {
105
+ return text.replace(PY_STRIP_RE, "");
106
+ }
107
+ /** Length in code points, as Python's `len(str)`. */
108
+ export function codePointLength(text) {
109
+ let n = 0;
110
+ for (const _ of text)
111
+ n += 1;
112
+ return n;
113
+ }
114
+ /** The first `limit` code points, as Python's `text[:limit]`. */
115
+ export function codePointSlice(text, limit) {
116
+ if (text.length <= limit)
117
+ return text;
118
+ return Array.from(text).slice(0, limit).join("");
119
+ }
120
+ /** Approximates Python `str.casefold()` (full case folding, e.g. ß -> ss). */
121
+ function casefold(text) {
122
+ return text.toUpperCase().toLowerCase();
123
+ }
124
+ /** Code-point order (Python's str order), via the UTF-8 bytes. */
125
+ export function compareUtf8(a, b) {
126
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
127
+ }
128
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
129
+ function decodeUtf8(data) {
130
+ try {
131
+ return UTF8.decode(data);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /** Whether `path` is a symbolic link (on Windows, a junction or symlink reparse point). */
138
+ export function isPathLinkOrReparse(path, stats) {
139
+ const inspected = stats ?? lstatSync(path);
140
+ return inspected.isSymbolicLink();
141
+ }
142
+ /** Why `value` is not a valid skill name (`label` names the field), or null. */
143
+ export function skillNameError(value, label = "name") {
144
+ if (typeof value !== "string")
145
+ return `${label} must be a string`;
146
+ const length = codePointLength(value);
147
+ if (length < 1 || length > NAME_MAX)
148
+ return `${label} must be 1..${NAME_MAX} characters`;
149
+ if (!NAME_RE.test(value))
150
+ return `${label} must match ${NAME_PATTERN}`;
151
+ return null;
152
+ }
153
+ /** Extract `name` and `description` from a `SKILL.md` body. Throws {@link SkillContentError}. */
154
+ export function parseFrontMatter(body) {
155
+ let text;
156
+ if (typeof body === "string") {
157
+ text = body;
158
+ }
159
+ else {
160
+ const decoded = decodeUtf8(body);
161
+ if (decoded === null)
162
+ throw new SkillContentError([`${SKILL_BODY_NAME}: not UTF-8 text`]);
163
+ text = decoded;
164
+ }
165
+ if (text.startsWith("\ufeff"))
166
+ text = text.slice(1);
167
+ const lines = text.split(PY_LINE_BREAK_RE);
168
+ if (lines.length === 0 || pyStrip(lines[0] ?? "") !== FRONT_MATTER_FENCE) {
169
+ throw new SkillContentError([`${SKILL_BODY_NAME}: missing YAML front matter`]);
170
+ }
171
+ let end = -1;
172
+ for (let i = 1; i < lines.length; i++) {
173
+ if (pyStrip(lines[i] ?? "") === FRONT_MATTER_FENCE) {
174
+ end = i;
175
+ break;
176
+ }
177
+ }
178
+ if (end === -1)
179
+ throw new SkillContentError([`${SKILL_BODY_NAME}: front matter is not closed`]);
180
+ let meta;
181
+ try {
182
+ meta = yamlParse(lines.slice(1, end).join("\n"), {
183
+ version: "1.1",
184
+ uniqueKeys: false,
185
+ logLevel: "silent",
186
+ });
187
+ }
188
+ catch {
189
+ throw new SkillContentError([`${SKILL_BODY_NAME}: front matter is not valid YAML`]);
190
+ }
191
+ if (meta === null || typeof meta !== "object" || Array.isArray(meta) || meta instanceof Date) {
192
+ throw new SkillContentError([`${SKILL_BODY_NAME}: front matter must be a mapping`]);
193
+ }
194
+ const record = meta;
195
+ const problems = [];
196
+ const name = record.name;
197
+ const error = skillNameError(name);
198
+ if (error !== null)
199
+ problems.push(`${SKILL_BODY_NAME}: front matter ${error}`);
200
+ let description = record.description;
201
+ if (typeof description === "string")
202
+ description = pyStrip(description);
203
+ if (typeof description !== "string" ||
204
+ codePointLength(description) < 1 ||
205
+ codePointLength(description) > DESCRIPTION_MAX) {
206
+ problems.push(`${SKILL_BODY_NAME}: front matter description must be 1..${DESCRIPTION_MAX} characters`);
207
+ }
208
+ if (problems.length > 0)
209
+ throw new SkillContentError(problems);
210
+ return { name: name, description: description };
211
+ }
212
+ // -- content hash -------------------------------------------------------------
213
+ /**
214
+ * Hash a skill's files as the schema's `content_sha256` defines: entries sorted
215
+ * by the UTF-8 bytes of their POSIX path relative to the skill folder, each
216
+ * framed as `path \0 len(bytes) \0 bytes`.
217
+ */
218
+ export function computeContentSha256(entries) {
219
+ const digest = createHash("sha256");
220
+ const sorted = [...entries].sort(([a], [b]) => compareUtf8(a, b));
221
+ for (const [path, data] of sorted) {
222
+ digest.update(Buffer.from(path, "utf8"));
223
+ digest.update(Buffer.from([0]));
224
+ digest.update(Buffer.from(String(data.length), "ascii"));
225
+ digest.update(Buffer.from([0]));
226
+ digest.update(data);
227
+ }
228
+ return digest.digest("hex");
229
+ }
230
+ function textProblems(data) {
231
+ const text = decodeUtf8(data);
232
+ if (text === null)
233
+ return [["not_text", "not UTF-8 text"]];
234
+ if (text.includes("\x00"))
235
+ return [["not_text", "contains NUL bytes"]];
236
+ const found = [];
237
+ const unprefixed = text.startsWith("\ufeff") ? text.slice(1) : text;
238
+ if (unprefixed.startsWith("#!")) {
239
+ found.push(["script", "starts with an interpreter line; scripts are not shared"]);
240
+ }
241
+ if (containsSecret(text))
242
+ found.push(["credential", "contains credential-like text"]);
243
+ return found;
244
+ }
245
+ /**
246
+ * List a skill folder's regular files without following any link. `found` holds
247
+ * every regular file that passed the structural rules; `problems` names every
248
+ * entry that did not.
249
+ */
250
+ function walkSkillFolder(root) {
251
+ const problems = [];
252
+ const found = [];
253
+ const seenCasefold = new Map();
254
+ const walk = (directory, parts) => {
255
+ let entries;
256
+ try {
257
+ entries = readdirSync(directory, { withFileTypes: true });
258
+ }
259
+ catch {
260
+ problems.push({ rel: parts.join("/") || ".", code: "unreadable", message: "cannot be listed" });
261
+ return;
262
+ }
263
+ entries.sort((a, b) => compareUtf8(a.name, b.name));
264
+ for (const entry of entries) {
265
+ const relParts = [...parts, entry.name];
266
+ const rel = relParts.join("/");
267
+ const path = join(directory, entry.name);
268
+ let info;
269
+ try {
270
+ info = lstatSync(path);
271
+ }
272
+ catch {
273
+ problems.push({ rel, code: "unreadable", message: "cannot be read" });
274
+ continue;
275
+ }
276
+ if (isPathLinkOrReparse(path, info)) {
277
+ problems.push({ rel, code: "link", message: "links are not shared" });
278
+ continue;
279
+ }
280
+ if (entry.name.startsWith(".")) {
281
+ problems.push({ rel, code: "hidden", message: "hidden files and folders are not shared" });
282
+ continue;
283
+ }
284
+ if (relParts.length > MAX_PATH_DEPTH || codePointLength(rel) > MAX_REL_PATH_CHARS) {
285
+ problems.push({ rel, code: "path", message: "path is too deep or too long" });
286
+ continue;
287
+ }
288
+ const folded = casefold(rel);
289
+ const previous = seenCasefold.get(folded);
290
+ if (previous !== undefined) {
291
+ problems.push({
292
+ rel,
293
+ code: "duplicate_case",
294
+ message: `differs only in case from ${previous}`,
295
+ });
296
+ continue;
297
+ }
298
+ seenCasefold.set(folded, rel);
299
+ if (info.isDirectory()) {
300
+ walk(path, relParts);
301
+ }
302
+ else if (info.isFile()) {
303
+ found.push({ rel, path, size: info.size });
304
+ }
305
+ else {
306
+ problems.push({ rel, code: "file_type", message: "not a regular file" });
307
+ }
308
+ }
309
+ };
310
+ walk(root, []);
311
+ return { found, problems };
312
+ }
313
+ /** Python `PurePosixPath(rel).suffix`. */
314
+ function posixSuffix(rel) {
315
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
316
+ const i = name.lastIndexOf(".");
317
+ return i > 0 && i < name.length - 1 ? name.slice(i) : "";
318
+ }
319
+ /**
320
+ * Read one listed file under the type and size rules, then check its text.
321
+ * `data` is null when the file is refused before reading.
322
+ */
323
+ function readCandidate(rel, path, size) {
324
+ const isBody = rel === SKILL_BODY_NAME;
325
+ if (!isBody && !TEXT_FILE_SUFFIXES.has(posixSuffix(rel).toLowerCase())) {
326
+ return {
327
+ data: null,
328
+ problems: [
329
+ ["file_type", "file type is not shared in v1 (markdown, text and templates only)"],
330
+ ],
331
+ };
332
+ }
333
+ const cap = isBody ? MAX_SKILL_BODY_BYTES : MAX_REFERENCE_FILE_BYTES;
334
+ if (size > cap) {
335
+ return { data: null, problems: [["too_large", `${size} bytes exceeds the ${cap}-byte limit`]] };
336
+ }
337
+ let data;
338
+ try {
339
+ data = readFileSync(path);
340
+ }
341
+ catch {
342
+ return { data: null, problems: [["unreadable", "cannot be read"]] };
343
+ }
344
+ if (data.length > cap) {
345
+ return {
346
+ data: null,
347
+ problems: [["too_large", `${data.length} bytes exceeds the ${cap}-byte limit`]],
348
+ };
349
+ }
350
+ return { data, problems: textProblems(data) };
351
+ }
352
+ function isRealDirectory(path) {
353
+ let info;
354
+ try {
355
+ info = lstatSync(path);
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ return !isPathLinkOrReparse(path, info) && info.isDirectory();
361
+ }
362
+ /**
363
+ * Scan one local skill folder for discovery: skip and flag, don't refuse.
364
+ *
365
+ * A disallowed reference file (a script, a binary, a link, a hidden file,
366
+ * credential-like text) is left out and reported in `skipped` instead of making
367
+ * the whole skill disappear. Only a `SKILL.md` that is missing, unreadable or
368
+ * itself breaks a content rule throws {@link SkillContentError}, because then
369
+ * there is nothing safe to advertise. Nothing is followed through a link and
370
+ * nothing is modified.
371
+ */
372
+ export function scanLocalSkill(folder) {
373
+ if (!isRealDirectory(folder)) {
374
+ throw new SkillContentError([`${basename(folder) || folder}: not a real directory`]);
375
+ }
376
+ const { found, problems } = walkSkillFolder(folder);
377
+ const skipped = problems.map((p) => ({ path: p.rel, reason: p.code }));
378
+ const bodyEntry = found.find((item) => item.rel === SKILL_BODY_NAME);
379
+ if (bodyEntry === undefined)
380
+ throw new SkillContentError([`${SKILL_BODY_NAME}: missing`]);
381
+ const body = readCandidate(bodyEntry.rel, bodyEntry.path, bodyEntry.size);
382
+ if (body.data === null || body.problems.length > 0) {
383
+ throw new SkillContentError(body.problems.map(([, message]) => `${SKILL_BODY_NAME}: ${message}`));
384
+ }
385
+ const frontMatter = parseFrontMatter(body.data);
386
+ const files = [[SKILL_BODY_NAME, body.data]];
387
+ let total = body.data.length;
388
+ for (const { rel, path, size } of found) {
389
+ if (rel === SKILL_BODY_NAME)
390
+ continue;
391
+ if (files.length - 1 >= MAX_REFERENCE_FILES) {
392
+ skipped.push({ path: rel, reason: "limit" });
393
+ continue;
394
+ }
395
+ const candidate = readCandidate(rel, path, size);
396
+ if (candidate.data === null || candidate.problems.length > 0) {
397
+ skipped.push({ path: rel, reason: candidate.problems[0]?.[0] ?? "unreadable" });
398
+ continue;
399
+ }
400
+ if (total + candidate.data.length > MAX_SKILL_TOTAL_BYTES) {
401
+ skipped.push({ path: rel, reason: "limit" });
402
+ continue;
403
+ }
404
+ total += candidate.data.length;
405
+ files.push([rel, candidate.data]);
406
+ }
407
+ files.sort(([a], [b]) => compareUtf8(a, b));
408
+ skipped.sort((a, b) => compareUtf8(a.path, b.path));
409
+ return { frontMatter, files, skipped, contentSha256: computeContentSha256(files) };
410
+ }
411
+ /** The `SKILL.md` bytes of `content`. */
412
+ export function skillContentBody(content) {
413
+ const body = content.files.find(([rel]) => rel === SKILL_BODY_NAME);
414
+ if (body === undefined)
415
+ throw new SkillContentError([`${SKILL_BODY_NAME}: missing`]);
416
+ return body[1];
417
+ }
418
+ /**
419
+ * Read a skill folder and enforce every v1 content rule (the oracle's
420
+ * `read_skill_folder`). Throws {@link SkillContentError} listing every problem
421
+ * found. The folder is read in place and never modified; links are reported,
422
+ * never followed.
423
+ */
424
+ export function readSkillFolder(folder) {
425
+ if (!isRealDirectory(folder)) {
426
+ throw new SkillContentError([`${basename(folder) || folder}: not a real directory`]);
427
+ }
428
+ const { found, problems: walkProblems } = walkSkillFolder(folder);
429
+ const problems = walkProblems.map((p) => `${p.rel}: ${p.message}`);
430
+ if (!found.some((item) => item.rel === SKILL_BODY_NAME)) {
431
+ problems.push(`${SKILL_BODY_NAME}: missing`);
432
+ }
433
+ const references = found.filter((item) => item.rel !== SKILL_BODY_NAME);
434
+ if (references.length > MAX_REFERENCE_FILES) {
435
+ problems.push(`${references.length} reference files exceed the limit of ${MAX_REFERENCE_FILES}`);
436
+ }
437
+ let total = 0;
438
+ const files = [];
439
+ for (const { rel, path, size } of found) {
440
+ const candidate = readCandidate(rel, path, size);
441
+ for (const [, message] of candidate.problems)
442
+ problems.push(`${rel}: ${message}`);
443
+ if (candidate.data === null)
444
+ continue;
445
+ total += candidate.data.length;
446
+ files.push([rel, candidate.data]);
447
+ }
448
+ if (total > MAX_SKILL_TOTAL_BYTES) {
449
+ problems.push(`skill totals ${total} bytes, over the ${MAX_SKILL_TOTAL_BYTES}-byte limit`);
450
+ }
451
+ let frontMatter = null;
452
+ const body = files.find(([rel]) => rel === SKILL_BODY_NAME)?.[1];
453
+ if (body !== undefined) {
454
+ try {
455
+ frontMatter = parseFrontMatter(body);
456
+ }
457
+ catch (err) {
458
+ if (!(err instanceof SkillContentError))
459
+ throw err;
460
+ // A body that is not UTF-8 was already reported by the text check.
461
+ if (decodeUtf8(body) !== null)
462
+ problems.push(...err.problems);
463
+ }
464
+ }
465
+ if (problems.length > 0 || frontMatter === null) {
466
+ throw new SkillContentError(problems.length > 0 ? problems : [`${SKILL_BODY_NAME}: unreadable`]);
467
+ }
468
+ files.sort(([a], [b]) => compareUtf8(a, b));
469
+ return { frontMatter, files };
470
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Skill proposals: the approval gate in front of the shared skill library.
3
+ * Port of the engine's `core/skill_proposals.py` (plan 005, phase 4).
4
+ *
5
+ * An agent *proposes* one of the skills it advertises; a person *approves* it;
6
+ * only then does {@link promoteSkill} write it into `<library>/skills`.
7
+ *
8
+ * Layout (interoperable with the engine: either side reads what the other wrote):
9
+ *
10
+ * <library>/.skill-proposals/<proposal_id>/
11
+ * proposal.yaml the record: what was proposed, by whom, and the decision
12
+ * content/ a snapshot of the skill folder, taken at proposal time
13
+ *
14
+ * Load-bearing rules:
15
+ *
16
+ * - **The snapshot is what gets approved.** Proposing copies the skill's files
17
+ * into `content/` and records their `content_sha256`. Approval re-reads that
18
+ * snapshot, re-checks every text-only rule and refuses unless it still hashes
19
+ * to the recorded value, so editing the agent's folder after proposing cannot
20
+ * change what lands in the library.
21
+ * - **One human-confirmed approval promotes.** Nothing in this module is
22
+ * reachable over MCP except {@link createProposal}. Approval, rejection,
23
+ * retirement and rollback are called by the operator's terminal command,
24
+ * which asks the person at the keyboard to confirm. Consensus votes on a
25
+ * proposal's ballot are advisory: they are shown to the approver and recorded
26
+ * beside the decision, and never promote anything on their own.
27
+ * - **Every decision is audited before it happens, fail-closed**, in the same
28
+ * federation audit log the quarantine staging decisions use.
29
+ *
30
+ * Nothing here performs caller authorization; the callers (the MCP tool for
31
+ * proposing, the CLI for deciding) are the gates.
32
+ */
33
+ import { type FederationAudit } from "@getbourdon/federation";
34
+ import { type SkillContent } from "./skill-index.js";
35
+ import { type SkillIndexRow, SkillIndexError } from "./skill-store.js";
36
+ type Dict = Record<string, unknown>;
37
+ export declare const PROPOSALS_DIRNAME = ".skill-proposals";
38
+ export declare const RECORD_NAME = "proposal.yaml";
39
+ export declare const CONTENT_DIRNAME = "content";
40
+ export declare const STATUS_PENDING = "pending";
41
+ export declare const STATUS_APPROVED = "approved";
42
+ export declare const STATUS_REJECTED = "rejected";
43
+ export declare const PROPOSER_TRUSTED = "trusted";
44
+ export declare const PROPOSER_QUARANTINED = "quarantined";
45
+ /** The advisory ballot's options. A ballot only informs the person deciding. */
46
+ export declare const BALLOT_OPTIONS: readonly ["approve", "reject"];
47
+ /** Tunable limits. `maxPendingPerProposer`: pending proposals one proposer may
48
+ * hold at once, so a caller cannot fill the library with snapshots nobody
49
+ * asked for. */
50
+ export declare const SKILL_PROPOSAL_LIMITS: {
51
+ maxPendingPerProposer: number;
52
+ maxReasonChars: number;
53
+ };
54
+ /** Base class for refusals raised by the proposal gate. */
55
+ export declare class SkillProposalError extends SkillIndexError {
56
+ constructor(message: string);
57
+ }
58
+ /** No proposal with that id. */
59
+ export declare class ProposalNotFoundError extends SkillProposalError {
60
+ constructor(message: string);
61
+ }
62
+ /** The proposal is not pending (it was already decided). */
63
+ export declare class ProposalStateError extends SkillProposalError {
64
+ constructor(message: string);
65
+ }
66
+ /** The snapshot no longer hashes to what was proposed, or to what the approver saw. */
67
+ export declare class ProposalDigestMismatchError extends SkillProposalError {
68
+ constructor(message: string);
69
+ }
70
+ /** One proposal record (`proposal.yaml`). Field names follow the on-disk keys. */
71
+ export interface SkillProposal {
72
+ proposal_id: string;
73
+ name: string;
74
+ description: string;
75
+ content_sha256: string;
76
+ source_agent: string;
77
+ proposed_by: string;
78
+ proposer_tier: string;
79
+ created_at: string;
80
+ path_hint: string;
81
+ scope: string;
82
+ visibility: string;
83
+ project: string | null;
84
+ origin: Dict | null;
85
+ files: Dict[];
86
+ ballot: boolean;
87
+ status: string;
88
+ decision: Dict | null;
89
+ }
90
+ /** The record as written: the engine's key order, absent optionals omitted. */
91
+ export declare function proposalToDict(proposal: SkillProposal): Dict;
92
+ /** Parse a record; throws {@link SkillProposalError} on anything malformed. */
93
+ export declare function proposalFromDict(data: unknown): SkillProposal;
94
+ /** The row a listing shows: no file list, no decision detail. */
95
+ export declare function proposalSummary(proposal: SkillProposal): Dict;
96
+ export declare function isProposalId(value: unknown): value is string;
97
+ /** `<library>/.skill-proposals`. */
98
+ export declare function proposalsRoot(libraryPath: string): string;
99
+ /** Read one proposal; throws {@link ProposalNotFoundError} or {@link SkillProposalError}. */
100
+ export declare function loadProposal(libraryPath: string, proposalId: string): SkillProposal;
101
+ /** Every readable proposal (optionally of one `status`), oldest first. */
102
+ export declare function listProposals(libraryPath: string, status?: string | null): SkillProposal[];
103
+ export interface CreateProposalOptions {
104
+ /** The advertised SkillRef, as it sits in the agent's manifest. */
105
+ ref: Readonly<Dict>;
106
+ sourceAgent: string;
107
+ proposedBy: string;
108
+ proposerTier: string;
109
+ createdAt?: string | null;
110
+ }
111
+ /**
112
+ * Snapshot `content` into a new pending proposal.
113
+ *
114
+ * `content` must come from `readSkillFolder` over the folder the agent's own
115
+ * reader resolved for `ref` (the advertised SkillRef). Returns
116
+ * `{ proposal, created }`: proposing the same bytes of the same skill again
117
+ * while a proposal is pending returns that proposal unchanged.
118
+ */
119
+ export declare function createProposal(libraryPath: string, content: SkillContent, options: CreateProposalOptions): {
120
+ proposal: SkillProposal;
121
+ created: boolean;
122
+ };
123
+ /** The snapshot, re-checked against every content rule and its recorded hash. */
124
+ export declare function readProposalContent(libraryPath: string, proposal: SkillProposal): SkillContent;
125
+ /** The shared skill already using `name`, if any (retired included). */
126
+ export declare function collisionFor(libraryPath: string, name: string): SkillIndexRow | null;
127
+ /**
128
+ * Open a consensus ballot under the proposal's id. Votes on it only advise.
129
+ *
130
+ * Returns false (and leaves the proposal without a ballot) when one cannot be
131
+ * opened; a proposal never fails over its advisory ballot.
132
+ */
133
+ export declare function openAdvisoryBallot(libraryPath: string, proposal: SkillProposal): boolean;
134
+ export type AdvisoryTally = Record<(typeof BALLOT_OPTIONS)[number], {
135
+ votes: number;
136
+ voters: string[];
137
+ }>;
138
+ /** Vote counts and voters on the proposal's ballot; null without one. */
139
+ export declare function advisoryTally(libraryPath: string, proposal: SkillProposal): AdvisoryTally | null;
140
+ export interface ApprovalResult {
141
+ proposal: SkillProposal;
142
+ row: SkillIndexRow;
143
+ changed: boolean;
144
+ renamedFrom: string | null;
145
+ advisory: AdvisoryTally | null;
146
+ }
147
+ export interface ApproveProposalOptions {
148
+ approvedBy: string;
149
+ channel: string;
150
+ /** At least 8 hex characters: pins the decision to the bytes reviewed. */
151
+ expectedSha256?: string | null;
152
+ name?: string | null;
153
+ update?: boolean;
154
+ visibility?: string | null;
155
+ audit?: FederationAudit | null;
156
+ }
157
+ /**
158
+ * Promote a pending proposal's snapshot into the shared library.
159
+ *
160
+ * This is the one human-confirmed approval. It is not exposed over MCP; the
161
+ * operator's terminal command calls it after asking the person at the keyboard
162
+ * to confirm (`channel` records how).
163
+ *
164
+ * Naming: the skill's own name by default, or `name`. When that name is taken,
165
+ * `update: true` promotes the snapshot as the next version of the existing
166
+ * skill; otherwise the skill is renamed to a free name and the original name is
167
+ * reported in `renamedFrom`.
168
+ */
169
+ export declare function approveProposal(libraryPath: string, proposalId: string, options: ApproveProposalOptions): ApprovalResult;
170
+ export interface RejectProposalOptions {
171
+ rejectedBy: string;
172
+ channel: string;
173
+ reason?: string;
174
+ expectedSha256?: string | null;
175
+ audit?: FederationAudit | null;
176
+ }
177
+ /** Mark a pending proposal rejected. The snapshot is kept for the record. */
178
+ export declare function rejectProposal(libraryPath: string, proposalId: string, options: RejectProposalOptions): SkillProposal;
179
+ /**
180
+ * Retire the live version (`valid_to`, default today). Versions are kept.
181
+ *
182
+ * A retired skill leaves the default index and `get_skill` without a version;
183
+ * every version stays fetchable by number, and {@link rollbackSharedSkill}
184
+ * brings one back.
185
+ */
186
+ export declare function retireSharedSkill(libraryPath: string, name: string, options: {
187
+ retiredBy: string;
188
+ channel: string;
189
+ validTo?: string | null;
190
+ audit?: FederationAudit | null;
191
+ }): SkillIndexRow;
192
+ export interface RollbackResult {
193
+ row: SkillIndexRow;
194
+ /** True when a new version was written. */
195
+ changed: boolean;
196
+ /** True when a retirement was lifted. */
197
+ restored: boolean;
198
+ }
199
+ /**
200
+ * Make version `toVersion`'s content current again, as a NEW version.
201
+ *
202
+ * History is never rewritten: rolling back to version 2 of a skill at version 4
203
+ * writes version 5 with version 2's files (re-hashed before they are used), so
204
+ * every version stays fetchable. Rolling back a retired skill also lifts the
205
+ * retirement; rolling back to the live version of a retired skill only lifts it.
206
+ */
207
+ export declare function rollbackSharedSkill(libraryPath: string, name: string, toVersion: number, options: {
208
+ promotedBy: string;
209
+ channel: string;
210
+ audit?: FederationAudit | null;
211
+ }): RollbackResult;
212
+ export {};