@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,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-skill share approvals: which local skills a participant may list. Port
|
|
3
|
+
* of the engine's `core/skill_shares.py`.
|
|
4
|
+
*
|
|
5
|
+
* Like approving a hook, a skill is listed in an agent's L5 manifest only
|
|
6
|
+
* after the person at this machine approves that one skill (`bourdon skills
|
|
7
|
+
* share`). Each approval pins the exact content approved: a skill whose folder
|
|
8
|
+
* has changed since is not listed again until it is re-approved.
|
|
9
|
+
*
|
|
10
|
+
* Layout (interoperable with the engine: either side reads what the other wrote):
|
|
11
|
+
*
|
|
12
|
+
* <library>/.skill-shares.json
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "version": 1,
|
|
16
|
+
* "approvals": [
|
|
17
|
+
* {
|
|
18
|
+
* "kind": "skill",
|
|
19
|
+
* "source": {
|
|
20
|
+
* "agent": "alice", // null: any reader of a cross-agent store skill
|
|
21
|
+
* "store": "agent", // "agent" | "cross_agent"
|
|
22
|
+
* "scope": "user", // "user" | "project"
|
|
23
|
+
* "project": null, // the project's folder name, for scope "project"
|
|
24
|
+
* "path_hint": "~/.alice/skills/release-checklist"
|
|
25
|
+
* },
|
|
26
|
+
* "item": "release-checklist",
|
|
27
|
+
* "content_sha256": "<64 hex>",
|
|
28
|
+
* "approved_at": "2026-10-15T08:00:00Z",
|
|
29
|
+
* "actor": "operator"
|
|
30
|
+
* }
|
|
31
|
+
* ]
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* An approval is identified by `(kind, source, item, content_sha256)`. Only
|
|
35
|
+
* `kind: "skill"` is written today; an entry of a kind this version does not
|
|
36
|
+
* know is kept untouched on rewrite (and never treated as an approval).
|
|
37
|
+
* Approving an item again from the same source replaces its earlier approval,
|
|
38
|
+
* so a re-approved edit does not leave the old content approved.
|
|
39
|
+
*
|
|
40
|
+
* Load-bearing rules:
|
|
41
|
+
*
|
|
42
|
+
* - **Local only.** The file sits beside the library's other local state and
|
|
43
|
+
* is never synced: nothing in this package stages a library for the network,
|
|
44
|
+
* and the engine's sync stages only `agents/*.l5.yaml`.
|
|
45
|
+
* - **No absolute paths.** A share names its folder by the reader's symbolic
|
|
46
|
+
* `path_hint` (`~/...`, `$ENV_VAR/...` or project-relative), never by where
|
|
47
|
+
* it sits on disk.
|
|
48
|
+
* - **Corruption means no approvals.** A missing, linked, oversized or
|
|
49
|
+
* unparseable file reads as no shares at all, and a malformed entry is
|
|
50
|
+
* dropped; reading never throws, so an export never fails here.
|
|
51
|
+
* - **Writes are locked** with the same cross-process lock the library's other
|
|
52
|
+
* writers use, and replace the file atomically.
|
|
53
|
+
*
|
|
54
|
+
* Nothing here is reachable over MCP; the terminal commands are the only
|
|
55
|
+
* writers, and they ask the person at the keyboard to confirm.
|
|
56
|
+
*/
|
|
57
|
+
export declare const SHARES_FILENAME = ".skill-shares.json";
|
|
58
|
+
export declare const SHARES_VERSION = 1;
|
|
59
|
+
/** The only approval kind written by this version. */
|
|
60
|
+
export declare const KIND_SKILL = "skill";
|
|
61
|
+
/** Who approved a share. Only the operator's terminal writes this file. */
|
|
62
|
+
export declare const SHARE_ACTOR = "operator";
|
|
63
|
+
/** A shares file larger than this is not read (it then holds no approvals). */
|
|
64
|
+
export declare const MAX_SHARES_BYTES: number;
|
|
65
|
+
type Dict = Record<string, unknown>;
|
|
66
|
+
/** A share cannot be recorded (the file is unsafe to write, or the share is invalid). */
|
|
67
|
+
export declare class SkillShareError extends Error {
|
|
68
|
+
constructor(message: string);
|
|
69
|
+
}
|
|
70
|
+
/** One approval: this skill folder, with exactly this content, may be listed. */
|
|
71
|
+
export interface SkillShare {
|
|
72
|
+
name: string;
|
|
73
|
+
agent: string | null;
|
|
74
|
+
store: string;
|
|
75
|
+
scope: string;
|
|
76
|
+
project: string | null;
|
|
77
|
+
pathHint: string;
|
|
78
|
+
contentSha256: string;
|
|
79
|
+
approvedAt: string;
|
|
80
|
+
actor: string;
|
|
81
|
+
}
|
|
82
|
+
/** The skill folder a share is about, whoever reads it (a comparable key). */
|
|
83
|
+
export declare function shareFolderKey(share: SkillShare): string;
|
|
84
|
+
/** The reader source: the same folder as read by the same reader. */
|
|
85
|
+
export declare function shareSourceKey(share: SkillShare): string;
|
|
86
|
+
/** The approval's identity: `(kind, source, item, content_sha256)`. */
|
|
87
|
+
export declare function shareKey(share: SkillShare): string;
|
|
88
|
+
/**
|
|
89
|
+
* Whether the participant `agentId` may list the skill under `share`. A share
|
|
90
|
+
* of an agent's own folder applies to that agent; a share of a cross-agent
|
|
91
|
+
* store skill with no `agent` applies to every reader of the store.
|
|
92
|
+
*/
|
|
93
|
+
export declare function shareAppliesTo(share: SkillShare, agentId: string): boolean;
|
|
94
|
+
/** The on-disk record, in the engine's key order. */
|
|
95
|
+
export declare function shareToDict(share: SkillShare): Dict;
|
|
96
|
+
/**
|
|
97
|
+
* The library whose shares the participants' exports read. Resolved at call
|
|
98
|
+
* time (not import time) so a relocated home is honoured.
|
|
99
|
+
*/
|
|
100
|
+
export declare function defaultSharesLibrary(): string;
|
|
101
|
+
export declare function sharesPath(libraryPath?: string | null): string;
|
|
102
|
+
/**
|
|
103
|
+
* Every readable approval, in file order. Never throws.
|
|
104
|
+
*
|
|
105
|
+
* A missing file is no approvals. So is a file that is a link, not a regular
|
|
106
|
+
* file, too large, not JSON or of an unknown layout; a malformed entry is
|
|
107
|
+
* dropped and the rest are kept.
|
|
108
|
+
*/
|
|
109
|
+
export declare function loadShares(libraryPath?: string | null): SkillShare[];
|
|
110
|
+
export interface AddShareOptions {
|
|
111
|
+
name: string;
|
|
112
|
+
agent: string | null;
|
|
113
|
+
store: string;
|
|
114
|
+
scope: string;
|
|
115
|
+
project: string | null;
|
|
116
|
+
pathHint: string;
|
|
117
|
+
contentSha256: string;
|
|
118
|
+
approvedAt?: string | null;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Record one approval, replacing earlier approvals of the same item from the
|
|
122
|
+
* same source. An unreadable shares file is replaced: it held no usable
|
|
123
|
+
* approvals.
|
|
124
|
+
*/
|
|
125
|
+
export declare function addShare(libraryPath: string | null | undefined, options: AddShareOptions): SkillShare;
|
|
126
|
+
/** Remove the shares whose {@link shareKey} is in `keys`; how many went. */
|
|
127
|
+
export declare function removeShares(libraryPath: string | null | undefined, keys: Iterable<string>): number;
|
|
128
|
+
export {};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
/**
|
|
3
|
+
* Per-skill share approvals: which local skills a participant may list. Port
|
|
4
|
+
* of the engine's `core/skill_shares.py`.
|
|
5
|
+
*
|
|
6
|
+
* Like approving a hook, a skill is listed in an agent's L5 manifest only
|
|
7
|
+
* after the person at this machine approves that one skill (`bourdon skills
|
|
8
|
+
* share`). Each approval pins the exact content approved: a skill whose folder
|
|
9
|
+
* has changed since is not listed again until it is re-approved.
|
|
10
|
+
*
|
|
11
|
+
* Layout (interoperable with the engine: either side reads what the other wrote):
|
|
12
|
+
*
|
|
13
|
+
* <library>/.skill-shares.json
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "version": 1,
|
|
17
|
+
* "approvals": [
|
|
18
|
+
* {
|
|
19
|
+
* "kind": "skill",
|
|
20
|
+
* "source": {
|
|
21
|
+
* "agent": "alice", // null: any reader of a cross-agent store skill
|
|
22
|
+
* "store": "agent", // "agent" | "cross_agent"
|
|
23
|
+
* "scope": "user", // "user" | "project"
|
|
24
|
+
* "project": null, // the project's folder name, for scope "project"
|
|
25
|
+
* "path_hint": "~/.alice/skills/release-checklist"
|
|
26
|
+
* },
|
|
27
|
+
* "item": "release-checklist",
|
|
28
|
+
* "content_sha256": "<64 hex>",
|
|
29
|
+
* "approved_at": "2026-10-15T08:00:00Z",
|
|
30
|
+
* "actor": "operator"
|
|
31
|
+
* }
|
|
32
|
+
* ]
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* An approval is identified by `(kind, source, item, content_sha256)`. Only
|
|
36
|
+
* `kind: "skill"` is written today; an entry of a kind this version does not
|
|
37
|
+
* know is kept untouched on rewrite (and never treated as an approval).
|
|
38
|
+
* Approving an item again from the same source replaces its earlier approval,
|
|
39
|
+
* so a re-approved edit does not leave the old content approved.
|
|
40
|
+
*
|
|
41
|
+
* Load-bearing rules:
|
|
42
|
+
*
|
|
43
|
+
* - **Local only.** The file sits beside the library's other local state and
|
|
44
|
+
* is never synced: nothing in this package stages a library for the network,
|
|
45
|
+
* and the engine's sync stages only `agents/*.l5.yaml`.
|
|
46
|
+
* - **No absolute paths.** A share names its folder by the reader's symbolic
|
|
47
|
+
* `path_hint` (`~/...`, `$ENV_VAR/...` or project-relative), never by where
|
|
48
|
+
* it sits on disk.
|
|
49
|
+
* - **Corruption means no approvals.** A missing, linked, oversized or
|
|
50
|
+
* unparseable file reads as no shares at all, and a malformed entry is
|
|
51
|
+
* dropped; reading never throws, so an export never fails here.
|
|
52
|
+
* - **Writes are locked** with the same cross-process lock the library's other
|
|
53
|
+
* writers use, and replace the file atomically.
|
|
54
|
+
*
|
|
55
|
+
* Nothing here is reachable over MCP; the terminal commands are the only
|
|
56
|
+
* writers, and they ask the person at the keyboard to confirm.
|
|
57
|
+
*/
|
|
58
|
+
import { randomBytes } from "node:crypto";
|
|
59
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
|
|
60
|
+
import { homedir } from "node:os";
|
|
61
|
+
import { basename, dirname, join } from "node:path";
|
|
62
|
+
import { withManifestLock } from "@getbourdon/federation";
|
|
63
|
+
import { codePointLength, isPathLinkOrReparse } from "./skill-index.js";
|
|
64
|
+
export const SHARES_FILENAME = ".skill-shares.json";
|
|
65
|
+
export const SHARES_VERSION = 1;
|
|
66
|
+
/** The only approval kind written by this version. */
|
|
67
|
+
export const KIND_SKILL = "skill";
|
|
68
|
+
/** Who approved a share. Only the operator's terminal writes this file. */
|
|
69
|
+
export const SHARE_ACTOR = "operator";
|
|
70
|
+
/** A shares file larger than this is not read (it then holds no approvals). */
|
|
71
|
+
export const MAX_SHARES_BYTES = 4 * 1024 * 1024;
|
|
72
|
+
const STORES = new Set(["agent", "cross_agent"]);
|
|
73
|
+
const SCOPES = new Set(["user", "project"]);
|
|
74
|
+
const SHA_RE = /^[0-9a-f]{64}$/;
|
|
75
|
+
const MAX_FIELD_CHARS = 255;
|
|
76
|
+
/** A share cannot be recorded (the file is unsafe to write, or the share is invalid). */
|
|
77
|
+
export class SkillShareError extends Error {
|
|
78
|
+
constructor(message) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = "SkillShareError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The skill folder a share is about, whoever reads it (a comparable key). */
|
|
84
|
+
export function shareFolderKey(share) {
|
|
85
|
+
return JSON.stringify([share.store, share.scope, share.project, share.pathHint]);
|
|
86
|
+
}
|
|
87
|
+
/** The reader source: the same folder as read by the same reader. */
|
|
88
|
+
export function shareSourceKey(share) {
|
|
89
|
+
return JSON.stringify([share.agent, share.store, share.scope, share.project, share.pathHint]);
|
|
90
|
+
}
|
|
91
|
+
/** The approval's identity: `(kind, source, item, content_sha256)`. */
|
|
92
|
+
export function shareKey(share) {
|
|
93
|
+
return JSON.stringify([
|
|
94
|
+
KIND_SKILL,
|
|
95
|
+
[share.agent, share.store, share.scope, share.project, share.pathHint],
|
|
96
|
+
share.name,
|
|
97
|
+
share.contentSha256,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
/** What a new approval replaces: the same item from the same source, any content. */
|
|
101
|
+
function shareReplaces(share) {
|
|
102
|
+
return JSON.stringify([
|
|
103
|
+
KIND_SKILL,
|
|
104
|
+
[share.agent, share.store, share.scope, share.project, share.pathHint],
|
|
105
|
+
share.name,
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether the participant `agentId` may list the skill under `share`. A share
|
|
110
|
+
* of an agent's own folder applies to that agent; a share of a cross-agent
|
|
111
|
+
* store skill with no `agent` applies to every reader of the store.
|
|
112
|
+
*/
|
|
113
|
+
export function shareAppliesTo(share, agentId) {
|
|
114
|
+
return share.agent === null || share.agent === agentId;
|
|
115
|
+
}
|
|
116
|
+
/** The on-disk record, in the engine's key order. */
|
|
117
|
+
export function shareToDict(share) {
|
|
118
|
+
return {
|
|
119
|
+
kind: KIND_SKILL,
|
|
120
|
+
source: {
|
|
121
|
+
agent: share.agent,
|
|
122
|
+
store: share.store,
|
|
123
|
+
scope: share.scope,
|
|
124
|
+
project: share.project,
|
|
125
|
+
path_hint: share.pathHint,
|
|
126
|
+
},
|
|
127
|
+
item: share.name,
|
|
128
|
+
content_sha256: share.contentSha256,
|
|
129
|
+
approved_at: share.approvedAt,
|
|
130
|
+
actor: share.actor,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The library whose shares the participants' exports read. Resolved at call
|
|
135
|
+
* time (not import time) so a relocated home is honoured.
|
|
136
|
+
*/
|
|
137
|
+
export function defaultSharesLibrary() {
|
|
138
|
+
return join(homedir(), "agent-library");
|
|
139
|
+
}
|
|
140
|
+
export function sharesPath(libraryPath) {
|
|
141
|
+
return join(libraryPath ?? defaultSharesLibrary(), SHARES_FILENAME);
|
|
142
|
+
}
|
|
143
|
+
function nowIso() {
|
|
144
|
+
return `${new Date().toISOString().slice(0, 19)}Z`;
|
|
145
|
+
}
|
|
146
|
+
function isDict(value) {
|
|
147
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
148
|
+
}
|
|
149
|
+
class BadEntry extends Error {
|
|
150
|
+
}
|
|
151
|
+
function text(value, optional = false) {
|
|
152
|
+
if (value === null && optional)
|
|
153
|
+
return null;
|
|
154
|
+
if (typeof value !== "string" || !value || codePointLength(value) > MAX_FIELD_CHARS) {
|
|
155
|
+
throw new BadEntry("bad field");
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
/** One `kind: "skill"` entry; throws for anything malformed. */
|
|
160
|
+
function shareFrom(data) {
|
|
161
|
+
if (!isDict(data) || data.kind !== KIND_SKILL)
|
|
162
|
+
throw new BadEntry("not a skill approval");
|
|
163
|
+
const source = data.source;
|
|
164
|
+
if (!isDict(source))
|
|
165
|
+
throw new BadEntry("no source");
|
|
166
|
+
const store = source.store;
|
|
167
|
+
const scope = source.scope;
|
|
168
|
+
const sha = data.content_sha256;
|
|
169
|
+
if (typeof store !== "string" ||
|
|
170
|
+
!STORES.has(store) ||
|
|
171
|
+
typeof scope !== "string" ||
|
|
172
|
+
!SCOPES.has(scope)) {
|
|
173
|
+
throw new BadEntry("bad store or scope");
|
|
174
|
+
}
|
|
175
|
+
if (typeof sha !== "string" || !SHA_RE.test(sha))
|
|
176
|
+
throw new BadEntry("bad content hash");
|
|
177
|
+
const pathHint = text(source.path_hint);
|
|
178
|
+
if (pathHint.startsWith("/") ||
|
|
179
|
+
pathHint.startsWith("\\") ||
|
|
180
|
+
Array.from(pathHint).slice(0, 3).join("").includes(":")) {
|
|
181
|
+
throw new BadEntry("absolute path");
|
|
182
|
+
}
|
|
183
|
+
const agent = text(source.agent ?? null, true);
|
|
184
|
+
if (agent === null && store !== "cross_agent") {
|
|
185
|
+
throw new BadEntry("an agent's own folder needs its agent");
|
|
186
|
+
}
|
|
187
|
+
const project = text(source.project ?? null, true);
|
|
188
|
+
if ((scope === "project") !== (project !== null)) {
|
|
189
|
+
throw new BadEntry("project and scope disagree");
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
name: text(data.item),
|
|
193
|
+
agent,
|
|
194
|
+
store,
|
|
195
|
+
scope,
|
|
196
|
+
project,
|
|
197
|
+
pathHint,
|
|
198
|
+
contentSha256: sha,
|
|
199
|
+
approvedAt: text(data.approved_at),
|
|
200
|
+
actor: text(data.actor),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** An entry of an approval kind this version does not write: kept, never used. */
|
|
204
|
+
function isOtherKind(item) {
|
|
205
|
+
const kind = isDict(item) ? item.kind : undefined;
|
|
206
|
+
return typeof kind === "string" && kind !== KIND_SKILL;
|
|
207
|
+
}
|
|
208
|
+
function warn(message) {
|
|
209
|
+
console.warn(`[bourdon.participants] ${message}`);
|
|
210
|
+
}
|
|
211
|
+
const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
212
|
+
/** The `approvals` list, or `null` when the file is missing or cannot be trusted. */
|
|
213
|
+
function readRaw(path) {
|
|
214
|
+
let info;
|
|
215
|
+
try {
|
|
216
|
+
info = lstatSync(path);
|
|
217
|
+
}
|
|
218
|
+
catch (exc) {
|
|
219
|
+
if (exc.code !== "ENOENT") {
|
|
220
|
+
warn(`skill shares: ${SHARES_FILENAME} cannot be read; listing no skills`);
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (isPathLinkOrReparse(path, info) || !info.isFile())
|
|
226
|
+
throw new BadEntry("not a regular file");
|
|
227
|
+
if (info.size > MAX_SHARES_BYTES)
|
|
228
|
+
throw new BadEntry("too large");
|
|
229
|
+
const data = JSON.parse(STRICT_UTF8.decode(readFileSync(path)));
|
|
230
|
+
// Python's `!=`: `1.0` and `true` equal 1 there as well.
|
|
231
|
+
if (!isDict(data) || (data.version !== SHARES_VERSION && data.version !== true)) {
|
|
232
|
+
throw new BadEntry("unknown layout");
|
|
233
|
+
}
|
|
234
|
+
const approvals = data.approvals;
|
|
235
|
+
if (!Array.isArray(approvals))
|
|
236
|
+
throw new BadEntry("no approvals list");
|
|
237
|
+
return approvals;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
warn(`skill shares: ${SHARES_FILENAME} is unreadable; listing no skills`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Every readable approval, in file order. Never throws.
|
|
246
|
+
*
|
|
247
|
+
* A missing file is no approvals. So is a file that is a link, not a regular
|
|
248
|
+
* file, too large, not JSON or of an unknown layout; a malformed entry is
|
|
249
|
+
* dropped and the rest are kept.
|
|
250
|
+
*/
|
|
251
|
+
export function loadShares(libraryPath) {
|
|
252
|
+
const raw = readRaw(sharesPath(libraryPath));
|
|
253
|
+
if (raw === null)
|
|
254
|
+
return [];
|
|
255
|
+
const shares = [];
|
|
256
|
+
for (const item of raw) {
|
|
257
|
+
if (isOtherKind(item))
|
|
258
|
+
continue;
|
|
259
|
+
try {
|
|
260
|
+
shares.push(shareFrom(item));
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
warn(`skill shares: dropping a malformed entry in ${SHARES_FILENAME}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return shares;
|
|
267
|
+
}
|
|
268
|
+
function otherKinds(path) {
|
|
269
|
+
return (readRaw(path) ?? []).filter(isOtherKind);
|
|
270
|
+
}
|
|
271
|
+
/** Python `json.dumps(..., indent=2)`: the default `ensure_ascii` escapes. */
|
|
272
|
+
function pyJsonDumps(value) {
|
|
273
|
+
return JSON.stringify(value, null, 2).replace(/[\u007f-]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
274
|
+
}
|
|
275
|
+
function write(path, shares) {
|
|
276
|
+
const approvals = [...otherKinds(path), ...shares.map(shareToDict)];
|
|
277
|
+
const payload = { version: SHARES_VERSION, approvals };
|
|
278
|
+
const body = `${pyJsonDumps(payload)}\n`;
|
|
279
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
280
|
+
try {
|
|
281
|
+
const fd = openSync(temp, "wx");
|
|
282
|
+
try {
|
|
283
|
+
writeSync(fd, Buffer.from(body, "utf8"));
|
|
284
|
+
fsyncSync(fd);
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
closeSync(fd);
|
|
288
|
+
}
|
|
289
|
+
renameSync(temp, path);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (existsSync(temp))
|
|
293
|
+
rmSync(temp, { force: true });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function checkWritable(path) {
|
|
297
|
+
let info;
|
|
298
|
+
try {
|
|
299
|
+
info = lstatSync(path);
|
|
300
|
+
}
|
|
301
|
+
catch (exc) {
|
|
302
|
+
if (exc.code === "ENOENT")
|
|
303
|
+
return;
|
|
304
|
+
throw exc;
|
|
305
|
+
}
|
|
306
|
+
if (isPathLinkOrReparse(path, info) || !info.isFile()) {
|
|
307
|
+
throw new SkillShareError(`${SHARES_FILENAME} must be a regular file, not a link`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Record one approval, replacing earlier approvals of the same item from the
|
|
312
|
+
* same source. An unreadable shares file is replaced: it held no usable
|
|
313
|
+
* approvals.
|
|
314
|
+
*/
|
|
315
|
+
export function addShare(libraryPath, options) {
|
|
316
|
+
let share;
|
|
317
|
+
try {
|
|
318
|
+
share = shareFrom(shareToDict({
|
|
319
|
+
name: options.name,
|
|
320
|
+
agent: options.agent,
|
|
321
|
+
store: options.store,
|
|
322
|
+
scope: options.scope,
|
|
323
|
+
project: options.project,
|
|
324
|
+
pathHint: options.pathHint,
|
|
325
|
+
contentSha256: options.contentSha256,
|
|
326
|
+
approvedAt: options.approvedAt || nowIso(),
|
|
327
|
+
actor: SHARE_ACTOR,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
catch (exc) {
|
|
331
|
+
throw new SkillShareError(`invalid share: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
332
|
+
}
|
|
333
|
+
const path = sharesPath(libraryPath);
|
|
334
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
335
|
+
const replaces = shareReplaces(share);
|
|
336
|
+
withManifestLock(path, () => {
|
|
337
|
+
checkWritable(path);
|
|
338
|
+
const kept = loadShares(dirname(path)).filter((item) => shareReplaces(item) !== replaces);
|
|
339
|
+
write(path, [...kept, share]);
|
|
340
|
+
});
|
|
341
|
+
return share;
|
|
342
|
+
}
|
|
343
|
+
function isDirectory(path) {
|
|
344
|
+
try {
|
|
345
|
+
return statSync(path).isDirectory();
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Remove the shares whose {@link shareKey} is in `keys`; how many went. */
|
|
352
|
+
export function removeShares(libraryPath, keys) {
|
|
353
|
+
const wanted = new Set(keys);
|
|
354
|
+
const path = sharesPath(libraryPath);
|
|
355
|
+
if (!isDirectory(dirname(path)))
|
|
356
|
+
return 0;
|
|
357
|
+
return withManifestLock(path, () => {
|
|
358
|
+
checkWritable(path);
|
|
359
|
+
const current = loadShares(dirname(path));
|
|
360
|
+
const kept = current.filter((item) => !wanted.has(shareKey(item)));
|
|
361
|
+
const removed = current.length - kept.length;
|
|
362
|
+
if (removed)
|
|
363
|
+
write(path, kept);
|
|
364
|
+
return removed;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared skill store: the library-level home of federated procedural memory.
|
|
3
|
+
* Port of the store half of the engine's `core/skill_index.py` (plan 005,
|
|
4
|
+
* phase 1); the content rules it relies on are in `skill-index.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Layout (one folder per shared skill, beside the per-agent manifests):
|
|
7
|
+
*
|
|
8
|
+
* <library>/skills/<name>/
|
|
9
|
+
* SKILL.md verbatim body, as promoted
|
|
10
|
+
* skill.yaml index row (the skill index schema)
|
|
11
|
+
* files/... reference files, verbatim, text-only, size-capped
|
|
12
|
+
* .versions/<n>/ the same three entries for each replaced version
|
|
13
|
+
*
|
|
14
|
+
* Load-bearing rules:
|
|
15
|
+
*
|
|
16
|
+
* - **Org-tier write semantics.** The store is readable by every caller allowed
|
|
17
|
+
* to read the federation and writable only through {@link promoteSkill}. The
|
|
18
|
+
* ordinary federation write path never touches `<library>/skills`.
|
|
19
|
+
* - **One namespace, rename at promotion.** A shared name belongs to one skill.
|
|
20
|
+
* Promoting under a taken name throws {@link SkillNameCollisionError} with a
|
|
21
|
+
* free suggestion, unless the caller updates that skill by passing the
|
|
22
|
+
* current version as `expectedVersion` (also the compare-and-swap that stops
|
|
23
|
+
* two promotions from silently overwriting each other). A renamed skill keeps
|
|
24
|
+
* its front-matter name in `source_name`.
|
|
25
|
+
* - **Fail closed on reads.** A row that does not validate is not served.
|
|
26
|
+
*/
|
|
27
|
+
import { type SkillContent } from "./skill-index.js";
|
|
28
|
+
type Dict = Record<string, unknown>;
|
|
29
|
+
export declare const SKILL_INDEX_SPEC_VERSION = "0.1";
|
|
30
|
+
export declare const SKILLS_DIRNAME = "skills";
|
|
31
|
+
export declare const INDEX_ROW_NAME = "skill.yaml";
|
|
32
|
+
export declare const FILES_DIRNAME = "files";
|
|
33
|
+
export declare const VERSIONS_DIRNAME = ".versions";
|
|
34
|
+
/** The shared store behaves as org-tier memory: readable by every federation
|
|
35
|
+
* reader, writable by no one through the ordinary write API. */
|
|
36
|
+
export declare const SKILL_STORE_WRITE_POLICY = "read-only";
|
|
37
|
+
/** Promotion widens nothing unless the promoting caller asks it to. */
|
|
38
|
+
export declare const DEFAULT_SKILL_VISIBILITY = "private";
|
|
39
|
+
export declare const MAX_INDEX_ROW_BYTES: number;
|
|
40
|
+
/** Base class for every refusal the skill store raises. */
|
|
41
|
+
export declare class SkillIndexError extends Error {
|
|
42
|
+
constructor(message: string);
|
|
43
|
+
}
|
|
44
|
+
/** An index row does not conform to the skill index schema. */
|
|
45
|
+
export declare class InvalidSkillRowError extends SkillIndexError {
|
|
46
|
+
readonly errors: string[];
|
|
47
|
+
constructor(errors: string[]);
|
|
48
|
+
}
|
|
49
|
+
/** The shared name is already taken and the caller did not ask to update it. */
|
|
50
|
+
export declare class SkillNameCollisionError extends SkillIndexError {
|
|
51
|
+
readonly skillName: string;
|
|
52
|
+
readonly existingVersion: number;
|
|
53
|
+
readonly suggestedName: string | null;
|
|
54
|
+
constructor(name: string, existingVersion: number, suggestedName: string | null);
|
|
55
|
+
}
|
|
56
|
+
/** `expectedVersion` does not match what is in the store. */
|
|
57
|
+
export declare class SkillVersionConflictError extends SkillIndexError {
|
|
58
|
+
readonly skillName: string;
|
|
59
|
+
readonly expected: number | null;
|
|
60
|
+
readonly current: number | null;
|
|
61
|
+
constructor(name: string, expected: number | null, current: number | null);
|
|
62
|
+
}
|
|
63
|
+
/** No shared skill (or no such version of it) exists. */
|
|
64
|
+
export declare class SkillNotFoundError extends SkillIndexError {
|
|
65
|
+
constructor(message: string);
|
|
66
|
+
}
|
|
67
|
+
/** Return `name` if it is a valid shared skill name, else throw {@link SkillIndexError}. */
|
|
68
|
+
export declare function validateSkillName(name: unknown): string;
|
|
69
|
+
/**
|
|
70
|
+
* Check a parsed `skill.yaml` mapping against the schema's rules. Returns every
|
|
71
|
+
* violation found (empty means valid), in the oracle's order and wording.
|
|
72
|
+
*/
|
|
73
|
+
export declare function validateIndexRow(row: unknown): string[];
|
|
74
|
+
/** One validated `skill.yaml`. */
|
|
75
|
+
export declare class SkillIndexRow {
|
|
76
|
+
readonly specVersion: string;
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly sourceName: string | null;
|
|
79
|
+
readonly description: string;
|
|
80
|
+
readonly version: number;
|
|
81
|
+
readonly contentSha256: string;
|
|
82
|
+
readonly sourceAgent: string;
|
|
83
|
+
readonly promotedBy: string;
|
|
84
|
+
readonly promotedAt: string;
|
|
85
|
+
readonly proposalId: string | null;
|
|
86
|
+
readonly visibility: string;
|
|
87
|
+
readonly validTo: string | null;
|
|
88
|
+
readonly supersedes: number | null;
|
|
89
|
+
private constructor();
|
|
90
|
+
/** Validate `data` and build a row; throws {@link InvalidSkillRowError}. */
|
|
91
|
+
static fromDict(data: unknown): SkillIndexRow;
|
|
92
|
+
/** Serialize in schema order, omitting absent optional fields. */
|
|
93
|
+
toDict(): Dict;
|
|
94
|
+
/** False once `valid_to` is on or before `today` (UTC today by default, `YYYY-MM-DD`). */
|
|
95
|
+
isActive(today?: string): boolean;
|
|
96
|
+
}
|
|
97
|
+
/** Read and validate one `skill.yaml`. */
|
|
98
|
+
export declare function loadIndexRow(path: string): SkillIndexRow;
|
|
99
|
+
/** Validate `row` and write it atomically to `path`. */
|
|
100
|
+
export declare function writeIndexRow(path: string, row: SkillIndexRow): void;
|
|
101
|
+
/** `<library>/skills` -- the shared skill store. */
|
|
102
|
+
export declare function skillsRoot(libraryPath: string): string;
|
|
103
|
+
/** The folder of one shared skill; `name` is validated first. */
|
|
104
|
+
export declare function skillDir(libraryPath: string, name: string): string;
|
|
105
|
+
/**
|
|
106
|
+
* The index row for `name` (the live one, or an archived `version`). Throws
|
|
107
|
+
* {@link SkillNotFoundError} when absent and {@link InvalidSkillRowError} when
|
|
108
|
+
* present but invalid.
|
|
109
|
+
*/
|
|
110
|
+
export declare function getSkillRow(libraryPath: string, name: string, version?: number | null): SkillIndexRow;
|
|
111
|
+
/**
|
|
112
|
+
* Every valid live index row, sorted by name. Retired rows are omitted unless
|
|
113
|
+
* `includeRetired`. Rows that fail validation are skipped (and logged) rather
|
|
114
|
+
* than served.
|
|
115
|
+
*/
|
|
116
|
+
export declare function listSkillRows(libraryPath: string, options?: {
|
|
117
|
+
includeRetired?: boolean;
|
|
118
|
+
today?: string;
|
|
119
|
+
}): SkillIndexRow[];
|
|
120
|
+
/** Every version number on disk for `name`, oldest first. */
|
|
121
|
+
export declare function listSkillVersions(libraryPath: string, name: string): number[];
|
|
122
|
+
/** A Node filesystem error (Python's `OSError`). */
|
|
123
|
+
export declare function isOsError(err: unknown): boolean;
|
|
124
|
+
/** The row and the stored files of one shared skill version. */
|
|
125
|
+
export declare function readSharedSkill(libraryPath: string, name: string, version?: number | null): {
|
|
126
|
+
row: SkillIndexRow;
|
|
127
|
+
content: SkillContent;
|
|
128
|
+
};
|
|
129
|
+
/** Whether the stored files still hash to the row's `content_sha256`. */
|
|
130
|
+
export declare function verifySharedSkill(libraryPath: string, name: string, version?: number | null): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* A free shared name derived from `name` (`name-2`, `name-3`...). Returns null
|
|
133
|
+
* only if no candidate within the length limit is free.
|
|
134
|
+
*/
|
|
135
|
+
export declare function suggestAvailableName(libraryPath: string, name: string): string | null;
|
|
136
|
+
export interface PromotionResult {
|
|
137
|
+
row: SkillIndexRow;
|
|
138
|
+
/** False when the content already matched the live version (nothing written). */
|
|
139
|
+
changed: boolean;
|
|
140
|
+
}
|
|
141
|
+
export interface PromoteSkillOptions {
|
|
142
|
+
sourceAgent: string;
|
|
143
|
+
promotedBy: string;
|
|
144
|
+
/** Shared name; defaults to the front-matter name. Another name renames at promotion. */
|
|
145
|
+
name?: string | null;
|
|
146
|
+
/** The live version when replacing a skill; null/absent for a new one. */
|
|
147
|
+
expectedVersion?: number | null;
|
|
148
|
+
visibility?: string;
|
|
149
|
+
proposalId?: string | null;
|
|
150
|
+
/** ISO 8601 with a UTC offset; defaults to now, to the second. */
|
|
151
|
+
promotedAt?: string | null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Write `content` into the shared store as a new skill or a new version.
|
|
155
|
+
*
|
|
156
|
+
* This is the store's only write path and performs no authorization: the
|
|
157
|
+
* caller (the promotion gate) must already have established that a trusted
|
|
158
|
+
* caller approved it. `content` must come from `readSkillFolder`.
|
|
159
|
+
*
|
|
160
|
+
* - `name` defaults to the front-matter name. Another name renames at
|
|
161
|
+
* promotion; the front-matter name is then kept as `source_name`.
|
|
162
|
+
* - If the name is free, `expectedVersion` must be null.
|
|
163
|
+
* - If the name is taken, `expectedVersion` must equal the live version; null
|
|
164
|
+
* throws {@link SkillNameCollisionError} (with a free name to suggest) and
|
|
165
|
+
* any other value throws {@link SkillVersionConflictError}.
|
|
166
|
+
* - Content identical to the live version is a no-op (`changed: false`).
|
|
167
|
+
*
|
|
168
|
+
* The previous version moves to `.versions/<n>/`. `skill.yaml` is moved out
|
|
169
|
+
* first and written last, so a reader never sees a row next to the wrong body;
|
|
170
|
+
* an interrupted promotion leaves the skill unlisted, with the old version
|
|
171
|
+
* intact under `.versions/`.
|
|
172
|
+
*/
|
|
173
|
+
export declare function promoteSkill(libraryPath: string, content: SkillContent, options: PromoteSkillOptions): PromotionResult;
|
|
174
|
+
/**
|
|
175
|
+
* Set (retire) or clear (restore) the live row's `valid_to`.
|
|
176
|
+
*
|
|
177
|
+
* Retirement is a row edit, not a new version: the body and its hash are
|
|
178
|
+
* untouched, so every version stays fetchable by number. Like
|
|
179
|
+
* {@link promoteSkill} this performs no authorization; the caller is the gate.
|
|
180
|
+
* `expectedVersion` must equal the live version (compare-and-swap, under the
|
|
181
|
+
* same lock promotion writes under).
|
|
182
|
+
*/
|
|
183
|
+
export declare function setSkillValidTo(libraryPath: string, name: string, validTo: string | null, options: {
|
|
184
|
+
expectedVersion: number;
|
|
185
|
+
}): SkillIndexRow;
|
|
186
|
+
export {};
|