@getbourdon/participants 0.5.6 → 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.
- package/dist/index.cjs +3595 -772
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +954 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/readers/claude-code.d.ts +21 -2
- package/dist/readers/claude-code.js +31 -1
- package/dist/readers/cursor.d.ts +23 -2
- package/dist/readers/cursor.js +33 -1
- package/dist/skill-index.d.ts +110 -0
- package/dist/skill-index.js +470 -0
- package/dist/skill-proposals.d.ts +212 -0
- package/dist/skill-proposals.js +757 -0
- package/dist/skill-shares.d.ts +128 -0
- package/dist/skill-shares.js +366 -0
- package/dist/skill-store.d.ts +186 -0
- package/dist/skill-store.js +750 -0
- package/dist/skills.d.ts +295 -0
- package/dist/skills.js +999 -0
- package/package.json +5 -4
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
/**
|
|
3
|
+
* Skill proposals: the approval gate in front of the shared skill library.
|
|
4
|
+
* Port of the engine's `core/skill_proposals.py` (plan 005, phase 4).
|
|
5
|
+
*
|
|
6
|
+
* An agent *proposes* one of the skills it advertises; a person *approves* it;
|
|
7
|
+
* only then does {@link promoteSkill} write it into `<library>/skills`.
|
|
8
|
+
*
|
|
9
|
+
* Layout (interoperable with the engine: either side reads what the other wrote):
|
|
10
|
+
*
|
|
11
|
+
* <library>/.skill-proposals/<proposal_id>/
|
|
12
|
+
* proposal.yaml the record: what was proposed, by whom, and the decision
|
|
13
|
+
* content/ a snapshot of the skill folder, taken at proposal time
|
|
14
|
+
*
|
|
15
|
+
* Load-bearing rules:
|
|
16
|
+
*
|
|
17
|
+
* - **The snapshot is what gets approved.** Proposing copies the skill's files
|
|
18
|
+
* into `content/` and records their `content_sha256`. Approval re-reads that
|
|
19
|
+
* snapshot, re-checks every text-only rule and refuses unless it still hashes
|
|
20
|
+
* to the recorded value, so editing the agent's folder after proposing cannot
|
|
21
|
+
* change what lands in the library.
|
|
22
|
+
* - **One human-confirmed approval promotes.** Nothing in this module is
|
|
23
|
+
* reachable over MCP except {@link createProposal}. Approval, rejection,
|
|
24
|
+
* retirement and rollback are called by the operator's terminal command,
|
|
25
|
+
* which asks the person at the keyboard to confirm. Consensus votes on a
|
|
26
|
+
* proposal's ballot are advisory: they are shown to the approver and recorded
|
|
27
|
+
* beside the decision, and never promote anything on their own.
|
|
28
|
+
* - **Every decision is audited before it happens, fail-closed**, in the same
|
|
29
|
+
* federation audit log the quarantine staging decisions use.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here performs caller authorization; the callers (the MCP tool for
|
|
32
|
+
* proposing, the CLI for deciding) are the gates.
|
|
33
|
+
*/
|
|
34
|
+
import { randomBytes } from "node:crypto";
|
|
35
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
import { ConsensusEngine, DECISION_ALLOW, DECISION_DENY, withManifestLock, } from "@getbourdon/federation";
|
|
38
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
39
|
+
import { computeContentSha256, isPathLinkOrReparse, readSkillFolder, } from "./skill-index.js";
|
|
40
|
+
import { SkillIndexError, SkillNotFoundError, getSkillRow, promoteSkill, readSharedSkill, setSkillValidTo, suggestAvailableName, validateSkillName, } from "./skill-store.js";
|
|
41
|
+
export const PROPOSALS_DIRNAME = ".skill-proposals";
|
|
42
|
+
export const RECORD_NAME = "proposal.yaml";
|
|
43
|
+
export const CONTENT_DIRNAME = "content";
|
|
44
|
+
const CREATE_LOCK_STEM = ".create";
|
|
45
|
+
const TMP_PREFIX = ".tmp-";
|
|
46
|
+
export const STATUS_PENDING = "pending";
|
|
47
|
+
export const STATUS_APPROVED = "approved";
|
|
48
|
+
export const STATUS_REJECTED = "rejected";
|
|
49
|
+
const STATUSES = new Set([STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED]);
|
|
50
|
+
export const PROPOSER_TRUSTED = "trusted";
|
|
51
|
+
export const PROPOSER_QUARANTINED = "quarantined";
|
|
52
|
+
/** The advisory ballot's options. A ballot only informs the person deciding. */
|
|
53
|
+
export const BALLOT_OPTIONS = ["approve", "reject"];
|
|
54
|
+
/** Tunable limits. `maxPendingPerProposer`: pending proposals one proposer may
|
|
55
|
+
* hold at once, so a caller cannot fill the library with snapshots nobody
|
|
56
|
+
* asked for. */
|
|
57
|
+
export const SKILL_PROPOSAL_LIMITS = {
|
|
58
|
+
maxPendingPerProposer: 50,
|
|
59
|
+
maxReasonChars: 500,
|
|
60
|
+
};
|
|
61
|
+
const ID_RE = /^sp-[0-9a-f]{12}$/;
|
|
62
|
+
// -- errors -------------------------------------------------------------------
|
|
63
|
+
/** Base class for refusals raised by the proposal gate. */
|
|
64
|
+
export class SkillProposalError extends SkillIndexError {
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "SkillProposalError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** No proposal with that id. */
|
|
71
|
+
export class ProposalNotFoundError extends SkillProposalError {
|
|
72
|
+
constructor(message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "ProposalNotFoundError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The proposal is not pending (it was already decided). */
|
|
78
|
+
export class ProposalStateError extends SkillProposalError {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "ProposalStateError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The snapshot no longer hashes to what was proposed, or to what the approver saw. */
|
|
85
|
+
export class ProposalDigestMismatchError extends SkillProposalError {
|
|
86
|
+
constructor(message) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "ProposalDigestMismatchError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** `datetime.now(timezone.utc).isoformat(timespec="seconds")`. */
|
|
92
|
+
function nowIso() {
|
|
93
|
+
return `${new Date().toISOString().slice(0, 19)}+00:00`;
|
|
94
|
+
}
|
|
95
|
+
function today() {
|
|
96
|
+
return new Date().toISOString().slice(0, 10);
|
|
97
|
+
}
|
|
98
|
+
function isDict(value) {
|
|
99
|
+
return (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date));
|
|
100
|
+
}
|
|
101
|
+
/** Python `str()` of a scalar read from YAML. */
|
|
102
|
+
function pyStr(value) {
|
|
103
|
+
if (value === null || value === undefined)
|
|
104
|
+
return "None";
|
|
105
|
+
if (value === true)
|
|
106
|
+
return "True";
|
|
107
|
+
if (value === false)
|
|
108
|
+
return "False";
|
|
109
|
+
return String(value);
|
|
110
|
+
}
|
|
111
|
+
/** Python truthiness of a YAML value. */
|
|
112
|
+
function truthy(value) {
|
|
113
|
+
if (Array.isArray(value))
|
|
114
|
+
return value.length > 0;
|
|
115
|
+
if (isDict(value))
|
|
116
|
+
return Object.keys(value).length > 0;
|
|
117
|
+
return Boolean(value);
|
|
118
|
+
}
|
|
119
|
+
/** The record as written: the engine's key order, absent optionals omitted. */
|
|
120
|
+
export function proposalToDict(proposal) {
|
|
121
|
+
const out = {
|
|
122
|
+
proposal_id: proposal.proposal_id,
|
|
123
|
+
status: proposal.status,
|
|
124
|
+
name: proposal.name,
|
|
125
|
+
description: proposal.description,
|
|
126
|
+
content_sha256: proposal.content_sha256,
|
|
127
|
+
source_agent: proposal.source_agent,
|
|
128
|
+
path_hint: proposal.path_hint,
|
|
129
|
+
scope: proposal.scope,
|
|
130
|
+
};
|
|
131
|
+
if (proposal.project !== null)
|
|
132
|
+
out.project = proposal.project;
|
|
133
|
+
if (proposal.origin !== null)
|
|
134
|
+
out.origin = { ...proposal.origin };
|
|
135
|
+
out.visibility = proposal.visibility;
|
|
136
|
+
out.proposed_by = proposal.proposed_by;
|
|
137
|
+
out.proposer_tier = proposal.proposer_tier;
|
|
138
|
+
out.created_at = proposal.created_at;
|
|
139
|
+
out.files = proposal.files.map((item) => ({ ...item }));
|
|
140
|
+
out.ballot = proposal.ballot;
|
|
141
|
+
if (proposal.decision !== null)
|
|
142
|
+
out.decision = { ...proposal.decision };
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** Parse a record; throws {@link SkillProposalError} on anything malformed. */
|
|
146
|
+
export function proposalFromDict(data) {
|
|
147
|
+
if (!isDict(data))
|
|
148
|
+
throw new SkillProposalError("proposal record is not a mapping");
|
|
149
|
+
const required = [
|
|
150
|
+
"proposal_id",
|
|
151
|
+
"name",
|
|
152
|
+
"description",
|
|
153
|
+
"content_sha256",
|
|
154
|
+
"source_agent",
|
|
155
|
+
"proposed_by",
|
|
156
|
+
"proposer_tier",
|
|
157
|
+
"created_at",
|
|
158
|
+
"path_hint",
|
|
159
|
+
"scope",
|
|
160
|
+
];
|
|
161
|
+
// Python evaluates the constructor's keyword arguments in order, so the
|
|
162
|
+
// first missing key (or the invalid name) is the one reported.
|
|
163
|
+
for (const key of required) {
|
|
164
|
+
if (!Object.hasOwn(data, key)) {
|
|
165
|
+
throw new SkillProposalError(`proposal record is missing '${key}'`);
|
|
166
|
+
}
|
|
167
|
+
if (key === "name")
|
|
168
|
+
validateSkillName(data.name);
|
|
169
|
+
}
|
|
170
|
+
const proposal = {
|
|
171
|
+
proposal_id: pyStr(data.proposal_id),
|
|
172
|
+
name: data.name,
|
|
173
|
+
description: pyStr(data.description),
|
|
174
|
+
content_sha256: pyStr(data.content_sha256),
|
|
175
|
+
source_agent: pyStr(data.source_agent),
|
|
176
|
+
proposed_by: pyStr(data.proposed_by),
|
|
177
|
+
proposer_tier: pyStr(data.proposer_tier),
|
|
178
|
+
created_at: pyStr(data.created_at),
|
|
179
|
+
path_hint: pyStr(data.path_hint),
|
|
180
|
+
scope: pyStr(data.scope),
|
|
181
|
+
visibility: truthy(data.visibility) ? pyStr(data.visibility) : "team",
|
|
182
|
+
project: data.project ?? null,
|
|
183
|
+
origin: isDict(data.origin) ? { ...data.origin } : null,
|
|
184
|
+
files: Array.isArray(data.files) ? data.files.filter(isDict).map((item) => ({ ...item })) : [],
|
|
185
|
+
ballot: truthy(data.ballot ?? false),
|
|
186
|
+
status: truthy(data.status) ? pyStr(data.status) : STATUS_PENDING,
|
|
187
|
+
decision: isDict(data.decision) ? { ...data.decision } : null,
|
|
188
|
+
};
|
|
189
|
+
if (!isProposalId(proposal.proposal_id) || !STATUSES.has(proposal.status)) {
|
|
190
|
+
throw new SkillProposalError("proposal record is malformed");
|
|
191
|
+
}
|
|
192
|
+
return proposal;
|
|
193
|
+
}
|
|
194
|
+
/** The row a listing shows: no file list, no decision detail. */
|
|
195
|
+
export function proposalSummary(proposal) {
|
|
196
|
+
const out = {
|
|
197
|
+
proposal_id: proposal.proposal_id,
|
|
198
|
+
status: proposal.status,
|
|
199
|
+
name: proposal.name,
|
|
200
|
+
description: proposal.description,
|
|
201
|
+
content_sha256: proposal.content_sha256,
|
|
202
|
+
source_agent: proposal.source_agent,
|
|
203
|
+
proposed_by: proposal.proposed_by,
|
|
204
|
+
proposer_tier: proposal.proposer_tier,
|
|
205
|
+
created_at: proposal.created_at,
|
|
206
|
+
file_count: Math.max(0, proposal.files.length - 1),
|
|
207
|
+
};
|
|
208
|
+
if (proposal.origin !== null)
|
|
209
|
+
out.origin = { ...proposal.origin };
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
export function isProposalId(value) {
|
|
213
|
+
return typeof value === "string" && ID_RE.test(value);
|
|
214
|
+
}
|
|
215
|
+
/** `<library>/.skill-proposals`. */
|
|
216
|
+
export function proposalsRoot(libraryPath) {
|
|
217
|
+
return join(libraryPath, PROPOSALS_DIRNAME);
|
|
218
|
+
}
|
|
219
|
+
function proposalDir(libraryPath, proposalId) {
|
|
220
|
+
if (!isProposalId(proposalId)) {
|
|
221
|
+
throw new ProposalNotFoundError(`not a proposal id: '${String(proposalId)}'`);
|
|
222
|
+
}
|
|
223
|
+
return join(proposalsRoot(libraryPath), proposalId);
|
|
224
|
+
}
|
|
225
|
+
/** An existing proposal's folder; the lock beside its record never creates one. */
|
|
226
|
+
function existingProposalDir(libraryPath, proposalId) {
|
|
227
|
+
const folder = proposalDir(libraryPath, proposalId);
|
|
228
|
+
if (!isDirectory(folder) || isPathLinkOrReparse(folder)) {
|
|
229
|
+
throw new ProposalNotFoundError(`no skill proposal '${proposalId}'`);
|
|
230
|
+
}
|
|
231
|
+
return folder;
|
|
232
|
+
}
|
|
233
|
+
function isFile(path) {
|
|
234
|
+
try {
|
|
235
|
+
return statSync(path).isFile();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function isDirectory(path) {
|
|
242
|
+
try {
|
|
243
|
+
return statSync(path).isDirectory();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function writeRecord(folder, proposal) {
|
|
250
|
+
// YAML 1.1 quoting, so a timestamp stays a string for the engine's reader.
|
|
251
|
+
const text = yamlStringify(proposalToDict(proposal), {
|
|
252
|
+
version: "1.1",
|
|
253
|
+
sortMapEntries: false,
|
|
254
|
+
singleQuote: true,
|
|
255
|
+
lineWidth: 0,
|
|
256
|
+
});
|
|
257
|
+
const target = join(folder, RECORD_NAME);
|
|
258
|
+
const temp = join(folder, `.${RECORD_NAME}.${randomBytes(6).toString("hex")}.tmp`);
|
|
259
|
+
try {
|
|
260
|
+
const fd = openSync(temp, "wx");
|
|
261
|
+
try {
|
|
262
|
+
writeSync(fd, Buffer.from(text, "utf8"));
|
|
263
|
+
fsyncSync(fd);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
closeSync(fd);
|
|
267
|
+
}
|
|
268
|
+
renameSync(temp, target);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
if (existsSync(temp))
|
|
272
|
+
rmSync(temp, { force: true });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
276
|
+
/** Read one proposal; throws {@link ProposalNotFoundError} or {@link SkillProposalError}. */
|
|
277
|
+
export function loadProposal(libraryPath, proposalId) {
|
|
278
|
+
const folder = proposalDir(libraryPath, proposalId);
|
|
279
|
+
const record = join(folder, RECORD_NAME);
|
|
280
|
+
// A record that is a file implies its folder exists (the link checks lstat).
|
|
281
|
+
if (!isFile(record) || isPathLinkOrReparse(folder) || isPathLinkOrReparse(record)) {
|
|
282
|
+
throw new ProposalNotFoundError(`no skill proposal '${proposalId}'`);
|
|
283
|
+
}
|
|
284
|
+
let data;
|
|
285
|
+
try {
|
|
286
|
+
data = yamlParse(UTF8.decode(readFileSync(record)), {
|
|
287
|
+
version: "1.1",
|
|
288
|
+
uniqueKeys: false,
|
|
289
|
+
logLevel: "silent",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
throw new SkillProposalError(`proposal '${proposalId}' cannot be read`);
|
|
294
|
+
}
|
|
295
|
+
const proposal = proposalFromDict(data);
|
|
296
|
+
if (proposal.proposal_id !== proposalId) {
|
|
297
|
+
throw new SkillProposalError(`proposal '${proposalId}' does not match its folder`);
|
|
298
|
+
}
|
|
299
|
+
return proposal;
|
|
300
|
+
}
|
|
301
|
+
/** Every readable proposal (optionally of one `status`), oldest first. */
|
|
302
|
+
export function listProposals(libraryPath, status = null) {
|
|
303
|
+
const root = proposalsRoot(libraryPath);
|
|
304
|
+
if (!isDirectory(root) || isPathLinkOrReparse(root))
|
|
305
|
+
return [];
|
|
306
|
+
const found = [];
|
|
307
|
+
for (const child of readdirSync(root)) {
|
|
308
|
+
if (!isProposalId(child))
|
|
309
|
+
continue;
|
|
310
|
+
let proposal;
|
|
311
|
+
try {
|
|
312
|
+
proposal = loadProposal(libraryPath, child);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
if (err instanceof SkillIndexError)
|
|
316
|
+
continue;
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
if (status === null || proposal.status === status)
|
|
320
|
+
found.push(proposal);
|
|
321
|
+
}
|
|
322
|
+
return found.sort((a, b) => a.created_at !== b.created_at
|
|
323
|
+
? a.created_at < b.created_at
|
|
324
|
+
? -1
|
|
325
|
+
: 1
|
|
326
|
+
: a.proposal_id < b.proposal_id
|
|
327
|
+
? -1
|
|
328
|
+
: a.proposal_id > b.proposal_id
|
|
329
|
+
? 1
|
|
330
|
+
: 0);
|
|
331
|
+
}
|
|
332
|
+
// -- proposing ----------------------------------------------------------------
|
|
333
|
+
function writeSnapshot(folder, content) {
|
|
334
|
+
const targetRoot = join(folder, CONTENT_DIRNAME);
|
|
335
|
+
mkdirSync(targetRoot);
|
|
336
|
+
for (const [rel, data] of content.files) {
|
|
337
|
+
const target = join(targetRoot, ...rel.split("/"));
|
|
338
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
339
|
+
writeFileSync(target, data);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Snapshot `content` into a new pending proposal.
|
|
344
|
+
*
|
|
345
|
+
* `content` must come from `readSkillFolder` over the folder the agent's own
|
|
346
|
+
* reader resolved for `ref` (the advertised SkillRef). Returns
|
|
347
|
+
* `{ proposal, created }`: proposing the same bytes of the same skill again
|
|
348
|
+
* while a proposal is pending returns that proposal unchanged.
|
|
349
|
+
*/
|
|
350
|
+
export function createProposal(libraryPath, content, options) {
|
|
351
|
+
const { ref, sourceAgent, proposedBy, proposerTier } = options;
|
|
352
|
+
if (proposerTier !== PROPOSER_TRUSTED && proposerTier !== PROPOSER_QUARANTINED) {
|
|
353
|
+
throw new SkillProposalError(`unknown proposer tier '${proposerTier}'`);
|
|
354
|
+
}
|
|
355
|
+
const name = validateSkillName(content.frontMatter.name);
|
|
356
|
+
const digest = computeContentSha256(content.files);
|
|
357
|
+
const root = proposalsRoot(libraryPath);
|
|
358
|
+
mkdirSync(root, { recursive: true });
|
|
359
|
+
if (isPathLinkOrReparse(root)) {
|
|
360
|
+
throw new SkillProposalError(`${PROPOSALS_DIRNAME}/ must not be a link`);
|
|
361
|
+
}
|
|
362
|
+
return withManifestLock(join(root, CREATE_LOCK_STEM), () => {
|
|
363
|
+
const pending = listProposals(libraryPath, STATUS_PENDING);
|
|
364
|
+
for (const existing of pending) {
|
|
365
|
+
if (existing.source_agent === sourceAgent &&
|
|
366
|
+
existing.name === name &&
|
|
367
|
+
existing.content_sha256 === digest) {
|
|
368
|
+
return { proposal: existing, created: false };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const mine = pending.filter((p) => p.proposed_by === proposedBy).length;
|
|
372
|
+
const limit = SKILL_PROPOSAL_LIMITS.maxPendingPerProposer;
|
|
373
|
+
if (mine >= limit) {
|
|
374
|
+
throw new SkillProposalError(`'${proposedBy}' already has ${mine} pending skill proposals ` +
|
|
375
|
+
`(limit ${limit}); wait for some to be decided`);
|
|
376
|
+
}
|
|
377
|
+
let proposalId = `sp-${randomBytes(6).toString("hex")}`;
|
|
378
|
+
while (existsSync(join(root, proposalId))) {
|
|
379
|
+
proposalId = `sp-${randomBytes(6).toString("hex")}`;
|
|
380
|
+
}
|
|
381
|
+
const visibility = ref.visibility;
|
|
382
|
+
const origin = ref.origin;
|
|
383
|
+
const proposal = {
|
|
384
|
+
proposal_id: proposalId,
|
|
385
|
+
name,
|
|
386
|
+
description: content.frontMatter.description,
|
|
387
|
+
content_sha256: digest,
|
|
388
|
+
source_agent: sourceAgent,
|
|
389
|
+
proposed_by: proposedBy,
|
|
390
|
+
proposer_tier: proposerTier,
|
|
391
|
+
created_at: options.createdAt || nowIso(),
|
|
392
|
+
path_hint: truthy(ref.path_hint) ? String(ref.path_hint) : "",
|
|
393
|
+
scope: truthy(ref.scope) ? String(ref.scope) : "",
|
|
394
|
+
visibility: visibility === "public" || visibility === "team" || visibility === "private"
|
|
395
|
+
? visibility
|
|
396
|
+
: "team",
|
|
397
|
+
project: typeof ref.project === "string" ? ref.project : null,
|
|
398
|
+
origin: isDict(origin) ? { ...origin } : null,
|
|
399
|
+
files: content.files.map(([rel, data]) => ({ file: rel, bytes: data.length })),
|
|
400
|
+
ballot: false,
|
|
401
|
+
status: STATUS_PENDING,
|
|
402
|
+
decision: null,
|
|
403
|
+
};
|
|
404
|
+
const staging = join(root, `${TMP_PREFIX}${randomBytes(6).toString("hex")}`);
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(staging);
|
|
407
|
+
writeSnapshot(staging, content);
|
|
408
|
+
writeRecord(staging, proposal);
|
|
409
|
+
renameSync(staging, join(root, proposalId));
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
if (existsSync(staging))
|
|
413
|
+
rmSync(staging, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
return { proposal, created: true };
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/** The snapshot, re-checked against every content rule and its recorded hash. */
|
|
419
|
+
export function readProposalContent(libraryPath, proposal) {
|
|
420
|
+
const folder = join(proposalDir(libraryPath, proposal.proposal_id), CONTENT_DIRNAME);
|
|
421
|
+
const content = readSkillFolder(folder);
|
|
422
|
+
if (computeContentSha256(content.files) !== proposal.content_sha256) {
|
|
423
|
+
throw new ProposalDigestMismatchError(`proposal ${proposal.proposal_id}: the snapshot no longer matches ` +
|
|
424
|
+
`content_sha256 ${proposal.content_sha256.slice(0, 12)}`);
|
|
425
|
+
}
|
|
426
|
+
return content;
|
|
427
|
+
}
|
|
428
|
+
/** The shared skill already using `name`, if any (retired included). */
|
|
429
|
+
export function collisionFor(libraryPath, name) {
|
|
430
|
+
try {
|
|
431
|
+
return getSkillRow(libraryPath, name);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (err instanceof SkillNotFoundError)
|
|
435
|
+
return null;
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// -- the advisory ballot ------------------------------------------------------
|
|
440
|
+
function engine(libraryPath) {
|
|
441
|
+
return new ConsensusEngine(join(libraryPath, ".consensus"));
|
|
442
|
+
}
|
|
443
|
+
/** A Node filesystem error (Python's `OSError`) or a plain `Error` the engine
|
|
444
|
+
* raises for bad input (Python's `ValueError`). */
|
|
445
|
+
function isOsOrValueError(err) {
|
|
446
|
+
return err instanceof Error;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Open a consensus ballot under the proposal's id. Votes on it only advise.
|
|
450
|
+
*
|
|
451
|
+
* Returns false (and leaves the proposal without a ballot) when one cannot be
|
|
452
|
+
* opened; a proposal never fails over its advisory ballot.
|
|
453
|
+
*/
|
|
454
|
+
export function openAdvisoryBallot(libraryPath, proposal) {
|
|
455
|
+
try {
|
|
456
|
+
engine(libraryPath).propose({
|
|
457
|
+
topic: `Promote skill '${proposal.name}' into the shared library (advisory)`,
|
|
458
|
+
options: [...BALLOT_OPTIONS],
|
|
459
|
+
proposedBy: proposal.proposed_by,
|
|
460
|
+
proposalId: proposal.proposal_id,
|
|
461
|
+
metadata: {
|
|
462
|
+
kind: "skill_proposal",
|
|
463
|
+
advisory: true,
|
|
464
|
+
content_sha256: proposal.content_sha256,
|
|
465
|
+
source_agent: proposal.source_agent,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
if (isOsOrValueError(err))
|
|
471
|
+
return false;
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
const folder = proposalDir(libraryPath, proposal.proposal_id);
|
|
475
|
+
withManifestLock(join(folder, RECORD_NAME), () => {
|
|
476
|
+
const current = loadProposal(libraryPath, proposal.proposal_id);
|
|
477
|
+
current.ballot = true;
|
|
478
|
+
writeRecord(folder, current);
|
|
479
|
+
});
|
|
480
|
+
proposal.ballot = true;
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
/** Vote counts and voters on the proposal's ballot; null without one. */
|
|
484
|
+
export function advisoryTally(libraryPath, proposal) {
|
|
485
|
+
if (!proposal.ballot)
|
|
486
|
+
return null;
|
|
487
|
+
let tally;
|
|
488
|
+
try {
|
|
489
|
+
tally = engine(libraryPath).tally(proposal.proposal_id);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const summary = isDict(tally.options_summary) ? tally.options_summary : {};
|
|
495
|
+
const out = {};
|
|
496
|
+
for (const option of BALLOT_OPTIONS) {
|
|
497
|
+
const entry = isDict(summary[option]) ? summary[option] : {};
|
|
498
|
+
const voters = Array.isArray(entry.voters) ? entry.voters : [];
|
|
499
|
+
out[option] = {
|
|
500
|
+
votes: Math.trunc(Number(entry.votes ?? 0)),
|
|
501
|
+
voters: voters
|
|
502
|
+
.map((v) => String(isDict(v) ? v.agent_id : undefined))
|
|
503
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
function closeBallot(libraryPath, proposal, status, outcome) {
|
|
509
|
+
if (!proposal.ballot)
|
|
510
|
+
return;
|
|
511
|
+
// The decision already landed; the ballot only advised it.
|
|
512
|
+
try {
|
|
513
|
+
engine(libraryPath).close(proposal.proposal_id, status, {
|
|
514
|
+
decided_by_gate: "skill approval",
|
|
515
|
+
outcome,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// best effort
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function tallyText(tally) {
|
|
523
|
+
if (tally === null)
|
|
524
|
+
return "advisory=none";
|
|
525
|
+
return "advisory=" + BALLOT_OPTIONS.map((opt) => `${opt}:${tally[opt].votes}`).join(",");
|
|
526
|
+
}
|
|
527
|
+
// -- audit --------------------------------------------------------------------
|
|
528
|
+
/** Audit row BEFORE the mutation, fail-closed (`required: true`). */
|
|
529
|
+
function record(audit, actor, op, namespace, detail) {
|
|
530
|
+
if (!audit)
|
|
531
|
+
return;
|
|
532
|
+
audit.record(actor, op, namespace, DECISION_ALLOW, detail, { required: true });
|
|
533
|
+
}
|
|
534
|
+
function recordFailure(audit, actor, op, namespace, exc) {
|
|
535
|
+
if (!audit)
|
|
536
|
+
return;
|
|
537
|
+
// Best effort: the original error is the one to surface.
|
|
538
|
+
try {
|
|
539
|
+
const kind = exc instanceof Error ? exc.name : typeof exc;
|
|
540
|
+
audit.record(actor, op, namespace, DECISION_DENY, `failed: ${kind}`);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// ignored
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function checkExpected(proposal, expectedSha256) {
|
|
547
|
+
if (expectedSha256 === null || expectedSha256 === undefined)
|
|
548
|
+
return;
|
|
549
|
+
const prefix = expectedSha256.trim().toLowerCase();
|
|
550
|
+
if (prefix.length < 8 || !proposal.content_sha256.startsWith(prefix)) {
|
|
551
|
+
throw new ProposalDigestMismatchError(`proposal ${proposal.proposal_id} has content_sha256 ` +
|
|
552
|
+
`${proposal.content_sha256.slice(0, 12)}, not the ${prefix.slice(0, 12)} that was reviewed`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Promote a pending proposal's snapshot into the shared library.
|
|
557
|
+
*
|
|
558
|
+
* This is the one human-confirmed approval. It is not exposed over MCP; the
|
|
559
|
+
* operator's terminal command calls it after asking the person at the keyboard
|
|
560
|
+
* to confirm (`channel` records how).
|
|
561
|
+
*
|
|
562
|
+
* Naming: the skill's own name by default, or `name`. When that name is taken,
|
|
563
|
+
* `update: true` promotes the snapshot as the next version of the existing
|
|
564
|
+
* skill; otherwise the skill is renamed to a free name and the original name is
|
|
565
|
+
* reported in `renamedFrom`.
|
|
566
|
+
*/
|
|
567
|
+
export function approveProposal(libraryPath, proposalId, options) {
|
|
568
|
+
const { approvedBy, channel, audit } = options;
|
|
569
|
+
const folder = existingProposalDir(libraryPath, proposalId);
|
|
570
|
+
const outcome = withManifestLock(join(folder, RECORD_NAME), () => {
|
|
571
|
+
const proposal = loadProposal(libraryPath, proposalId);
|
|
572
|
+
if (proposal.status !== STATUS_PENDING) {
|
|
573
|
+
throw new ProposalStateError(`proposal ${proposalId} is already ${proposal.status}`);
|
|
574
|
+
}
|
|
575
|
+
checkExpected(proposal, options.expectedSha256);
|
|
576
|
+
const content = readProposalContent(libraryPath, proposal);
|
|
577
|
+
let target = validateSkillName(options.name ?? proposal.name);
|
|
578
|
+
const existing = collisionFor(libraryPath, target);
|
|
579
|
+
let expectedVersion = null;
|
|
580
|
+
let renamedFrom = null;
|
|
581
|
+
if (existing !== null) {
|
|
582
|
+
if (options.update) {
|
|
583
|
+
expectedVersion = existing.version;
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
const suggestion = suggestAvailableName(libraryPath, target);
|
|
587
|
+
if (suggestion === null) {
|
|
588
|
+
throw new SkillProposalError(`no free name derived from '${target}'`);
|
|
589
|
+
}
|
|
590
|
+
renamedFrom = target;
|
|
591
|
+
target = suggestion;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
else if (options.update) {
|
|
595
|
+
throw new SkillNotFoundError(`no shared skill named '${target}' to update`);
|
|
596
|
+
}
|
|
597
|
+
const tally = advisoryTally(libraryPath, proposal);
|
|
598
|
+
const visibility = options.visibility || proposal.visibility;
|
|
599
|
+
let detail = `proposal=${proposalId} name=${target} content_sha256=${proposal.content_sha256} ` +
|
|
600
|
+
`source_agent=${proposal.source_agent} proposed_by=${proposal.proposed_by} ` +
|
|
601
|
+
`proposer_tier=${proposal.proposer_tier} channel=${channel} ${tallyText(tally)}`;
|
|
602
|
+
if (renamedFrom !== null)
|
|
603
|
+
detail += ` renamed_from=${renamedFrom}`;
|
|
604
|
+
record(audit, approvedBy, "skill.approve", proposal.source_agent, detail);
|
|
605
|
+
let result;
|
|
606
|
+
try {
|
|
607
|
+
result = promoteSkill(libraryPath, content, {
|
|
608
|
+
sourceAgent: proposal.source_agent,
|
|
609
|
+
promotedBy: approvedBy,
|
|
610
|
+
name: target,
|
|
611
|
+
expectedVersion,
|
|
612
|
+
visibility,
|
|
613
|
+
proposalId,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
catch (exc) {
|
|
617
|
+
recordFailure(audit, approvedBy, "skill.approve", proposal.source_agent, exc);
|
|
618
|
+
throw exc;
|
|
619
|
+
}
|
|
620
|
+
proposal.status = STATUS_APPROVED;
|
|
621
|
+
const decision = {
|
|
622
|
+
by: approvedBy,
|
|
623
|
+
at: nowIso(),
|
|
624
|
+
channel,
|
|
625
|
+
promoted_name: result.row.name,
|
|
626
|
+
promoted_version: result.row.version,
|
|
627
|
+
changed: result.changed,
|
|
628
|
+
};
|
|
629
|
+
if (renamedFrom !== null)
|
|
630
|
+
decision.renamed_from = renamedFrom;
|
|
631
|
+
if (tally !== null) {
|
|
632
|
+
decision.advisory = Object.fromEntries(BALLOT_OPTIONS.map((opt) => [opt, tally[opt].votes]));
|
|
633
|
+
}
|
|
634
|
+
proposal.decision = decision;
|
|
635
|
+
writeRecord(folder, proposal);
|
|
636
|
+
return { proposal, result, renamedFrom, tally };
|
|
637
|
+
});
|
|
638
|
+
closeBallot(libraryPath, outcome.proposal, "resolved", "approved");
|
|
639
|
+
return {
|
|
640
|
+
proposal: outcome.proposal,
|
|
641
|
+
row: outcome.result.row,
|
|
642
|
+
changed: outcome.result.changed,
|
|
643
|
+
renamedFrom: outcome.renamedFrom,
|
|
644
|
+
advisory: outcome.tally,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// str.isspace() characters, as str.split() with no argument splits on them.
|
|
648
|
+
const PY_WHITESPACE_RUN =
|
|
649
|
+
// eslint-disable-next-line no-control-regex
|
|
650
|
+
/[\t\n\v\f\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/;
|
|
651
|
+
/** Python `" ".join(text.split())`. */
|
|
652
|
+
function collapseWhitespace(text) {
|
|
653
|
+
return text
|
|
654
|
+
.split(PY_WHITESPACE_RUN)
|
|
655
|
+
.filter((part) => part !== "")
|
|
656
|
+
.join(" ");
|
|
657
|
+
}
|
|
658
|
+
/** Mark a pending proposal rejected. The snapshot is kept for the record. */
|
|
659
|
+
export function rejectProposal(libraryPath, proposalId, options) {
|
|
660
|
+
const { rejectedBy, channel, audit } = options;
|
|
661
|
+
const folder = existingProposalDir(libraryPath, proposalId);
|
|
662
|
+
const reason = [...collapseWhitespace(options.reason ?? "")]
|
|
663
|
+
.slice(0, SKILL_PROPOSAL_LIMITS.maxReasonChars)
|
|
664
|
+
.join("");
|
|
665
|
+
const proposal = withManifestLock(join(folder, RECORD_NAME), () => {
|
|
666
|
+
const current = loadProposal(libraryPath, proposalId);
|
|
667
|
+
if (current.status !== STATUS_PENDING) {
|
|
668
|
+
throw new ProposalStateError(`proposal ${proposalId} is already ${current.status}`);
|
|
669
|
+
}
|
|
670
|
+
checkExpected(current, options.expectedSha256);
|
|
671
|
+
const tally = advisoryTally(libraryPath, current);
|
|
672
|
+
record(audit, rejectedBy, "skill.reject", current.source_agent, `proposal=${proposalId} name=${current.name} ` +
|
|
673
|
+
`content_sha256=${current.content_sha256} channel=${channel} ${tallyText(tally)}`);
|
|
674
|
+
current.status = STATUS_REJECTED;
|
|
675
|
+
const decision = { by: rejectedBy, at: nowIso(), channel };
|
|
676
|
+
if (reason)
|
|
677
|
+
decision.reason = reason;
|
|
678
|
+
if (tally !== null) {
|
|
679
|
+
decision.advisory = Object.fromEntries(BALLOT_OPTIONS.map((opt) => [opt, tally[opt].votes]));
|
|
680
|
+
}
|
|
681
|
+
current.decision = decision;
|
|
682
|
+
writeRecord(folder, current);
|
|
683
|
+
return current;
|
|
684
|
+
});
|
|
685
|
+
closeBallot(libraryPath, proposal, "rejected", "rejected");
|
|
686
|
+
return proposal;
|
|
687
|
+
}
|
|
688
|
+
// -- retirement and rollback ----------------------------------------------------
|
|
689
|
+
/**
|
|
690
|
+
* Retire the live version (`valid_to`, default today). Versions are kept.
|
|
691
|
+
*
|
|
692
|
+
* A retired skill leaves the default index and `get_skill` without a version;
|
|
693
|
+
* every version stays fetchable by number, and {@link rollbackSharedSkill}
|
|
694
|
+
* brings one back.
|
|
695
|
+
*/
|
|
696
|
+
export function retireSharedSkill(libraryPath, name, options) {
|
|
697
|
+
const { retiredBy, channel, audit } = options;
|
|
698
|
+
const live = getSkillRow(libraryPath, validateSkillName(name));
|
|
699
|
+
const when = options.validTo || today();
|
|
700
|
+
record(audit, retiredBy, "skill.retire", live.sourceAgent, `name=${name} version=${live.version} valid_to=${when} channel=${channel}`);
|
|
701
|
+
try {
|
|
702
|
+
return setSkillValidTo(libraryPath, name, when, { expectedVersion: live.version });
|
|
703
|
+
}
|
|
704
|
+
catch (exc) {
|
|
705
|
+
recordFailure(audit, retiredBy, "skill.retire", live.sourceAgent, exc);
|
|
706
|
+
throw exc;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Make version `toVersion`'s content current again, as a NEW version.
|
|
711
|
+
*
|
|
712
|
+
* History is never rewritten: rolling back to version 2 of a skill at version 4
|
|
713
|
+
* writes version 5 with version 2's files (re-hashed before they are used), so
|
|
714
|
+
* every version stays fetchable. Rolling back a retired skill also lifts the
|
|
715
|
+
* retirement; rolling back to the live version of a retired skill only lifts it.
|
|
716
|
+
*/
|
|
717
|
+
export function rollbackSharedSkill(libraryPath, name, toVersion, options) {
|
|
718
|
+
const { promotedBy, channel, audit } = options;
|
|
719
|
+
validateSkillName(name);
|
|
720
|
+
const live = getSkillRow(libraryPath, name);
|
|
721
|
+
const { row: targetRow, content } = readSharedSkill(libraryPath, name, toVersion);
|
|
722
|
+
if (computeContentSha256(content.files) !== targetRow.contentSha256) {
|
|
723
|
+
throw new ProposalDigestMismatchError(`stored files of '${name}' v${toVersion} do not match their content_sha256`);
|
|
724
|
+
}
|
|
725
|
+
const retired = live.validTo !== null;
|
|
726
|
+
if (toVersion === live.version && !retired) {
|
|
727
|
+
throw new SkillProposalError(`'${name}' is already at version ${toVersion}`);
|
|
728
|
+
}
|
|
729
|
+
record(audit, promotedBy, "skill.rollback", targetRow.sourceAgent, `name=${name} from_version=${live.version} to_version=${toVersion} ` +
|
|
730
|
+
`content_sha256=${targetRow.contentSha256} channel=${channel}`);
|
|
731
|
+
try {
|
|
732
|
+
let changed = false;
|
|
733
|
+
let row = live;
|
|
734
|
+
if (toVersion !== live.version) {
|
|
735
|
+
const result = promoteSkill(libraryPath, content, {
|
|
736
|
+
sourceAgent: targetRow.sourceAgent,
|
|
737
|
+
promotedBy,
|
|
738
|
+
name,
|
|
739
|
+
expectedVersion: live.version,
|
|
740
|
+
visibility: targetRow.visibility,
|
|
741
|
+
proposalId: targetRow.proposalId,
|
|
742
|
+
});
|
|
743
|
+
changed = result.changed;
|
|
744
|
+
row = result.row;
|
|
745
|
+
}
|
|
746
|
+
let restored = false;
|
|
747
|
+
if (row.validTo !== null) {
|
|
748
|
+
row = setSkillValidTo(libraryPath, name, null, { expectedVersion: row.version });
|
|
749
|
+
restored = true;
|
|
750
|
+
}
|
|
751
|
+
return { row, changed, restored };
|
|
752
|
+
}
|
|
753
|
+
catch (exc) {
|
|
754
|
+
recordFailure(audit, promotedBy, "skill.rollback", targetRow.sourceAgent, exc);
|
|
755
|
+
throw exc;
|
|
756
|
+
}
|
|
757
|
+
}
|