pi-daddy 0.13.0 → 0.14.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/src/init.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * `pi-daddy init` — scaffold a governed project from the skill packages already installed (B2, P3).
3
+ *
4
+ * Today an operator wanting to govern a package of skills must, per skill: create a directory, copy the
5
+ * body, hand-write frontmatter, choose a capability set with no guidance, and assemble a `PI_GRANTS_GRANT`
6
+ * string by hand. Seven times, for `principal-pi-skills`. This does the mechanical parts.
7
+ *
8
+ * **The line it does not cross, and the reason this module exists at all:** `init` writes files an operator
9
+ * then **reviews, edits and commits**. It never chooses a ceiling. A skill that declares `allowed-tools` is
10
+ * copied *verbatim* — the author's declaration is the ceiling, and re-deriving it here would put a second
11
+ * opinion between the file and the enforcer. A skill that declares none is copied with a **commented**
12
+ * placeholder and stays unspawnable until a human fills it in.
13
+ *
14
+ * **What it DOES choose is the starting grant, which is a stronger act** — ADR-0029, added after a reviewer
15
+ * pointed out that ADR-0028 drew its boundary around the wrong object. The handoff's reason a third party
16
+ * may safely author `allowed-tools` is that *"the operator's `PI_GRANTS_GRANT` still bounds it"*; a
17
+ * generated union gives the bound and the bounded one author, and it is not the operator. So capabilities
18
+ * that can change a machine are emitted **commented** (`./grant-env.ts`).
19
+ *
20
+ * The placeholder is deliberately not a working example. Uncommenting it unedited yields capability ids
21
+ * like `tool:<list` which the catalog refuses as unknown — a loud failure, in the direction rule 8 asks
22
+ * for, rather than a grant nobody decided.
23
+ */
24
+
25
+ import { mkdir, open, rm, writeFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { agentCapability } from "./capabilities.ts";
28
+ import { ceilingForDefinition } from "./definitions.ts";
29
+ import { ALWAYS_LIVE, assertGrantIsWritable, isLiveByDefault, renderGrantEnv, type GrantEnvSkill } from "./grant-env.ts";
30
+ import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
31
+ import type { Capability } from "./resolve.ts";
32
+ import type { SkillPackage } from "./skill-packages.ts";
33
+
34
+ /** Why a discovered skill is not authorised in the generated grant. `null` means it is. */
35
+ /**
36
+ * How many of `from`'s skills actually **declare** a ceiling.
37
+ *
38
+ * Exported because the number is printed to an operator and was wrong: `cli.ts` filtered on
39
+ * `withheld === null`, which is false for all three `WithholdReason`s — so a skill that declares
40
+ * `allowed-tools` perfectly well and merely needs a withheld capability counted as not declaring one.
41
+ * Against `principal-pi-skills` that printed *"7 skill(s), 3 declaring allowed-tools"* while all seven
42
+ * declared. R-28's shape: a diagnostic disagreeing with the thing it describes.
43
+ *
44
+ * **It was invisible until the integration worked.** Before ceilings shipped, none of the seven declared
45
+ * and the line read *"0 declaring"* — correct by coincidence, for the wrong reason. A count that is right
46
+ * only while the interesting case is absent is the kind this project keeps finding.
47
+ *
48
+ * `undeclared` is the only reason that means "did not declare". `pattern` declared something this package
49
+ * refuses to reinterpret, and `needs-withheld` declared something fine that the operator must opt into —
50
+ * both are declarations.
51
+ */
52
+ export function countDeclaring(skills: PlannedSkill[], from: string): number {
53
+ return skills.filter((s) => s.from === from && s.withheld !== "undeclared").length;
54
+ }
55
+
56
+ export type WithholdReason = "undeclared" | "pattern" | "needs-withheld";
57
+
58
+ export interface PlannedSkill {
59
+ name: string;
60
+ /** Which package it came from, as `name@version`. */
61
+ from: string;
62
+ sourcePath: string;
63
+ targetPath: string;
64
+ /** Exactly what would be written — the file verbatim, or the file plus a commented note. */
65
+ content: string;
66
+ /** The declared ceiling, empty when the declaration is absent or unusable. */
67
+ ceiling: Capability[];
68
+ withheld: WithholdReason | null;
69
+ /** e.g. a sub-tool pattern pi's `--tools` cannot express. Reported, never reinterpreted. */
70
+ notes: string[];
71
+ }
72
+
73
+ export interface InitPlan {
74
+ skills: PlannedSkill[];
75
+ /** Definitions two packages both declare. First wins; the loser is named rather than silently dropped. */
76
+ collisions: string[];
77
+ /** The live grant — what `source .pi/grants.env` actually sets. */
78
+ grant: Capability[];
79
+ /** Withheld capability → the definitions that declared it (ADR-0029). Emitted commented. */
80
+ withheldCapabilities: Map<Capability, string[]>;
81
+ grantEnvPath: string;
82
+ grantEnvContent: string;
83
+ /** Capabilities a declared ceiling names that pi 0.84.1 has no tool for — a caution, not a verdict. */
84
+ cautions: string[];
85
+ }
86
+
87
+ const PLACEHOLDER = [
88
+ "# pi-daddy: this skill declares no `allowed-tools`, so it CANNOT be spawned as a governed sub-agent —",
89
+ "# an undeclared capability set is treated as NONE, never as everything. Decide what it needs, then",
90
+ "# uncomment and complete the line below. pi-daddy does not choose this for you: the capability set is",
91
+ "# the thing you are meant to review and commit.",
92
+ "# pi 0.84.1's tools: " + PI_BUILTIN_TOOLS.join(", ") + ".",
93
+ "# allowed-tools: <list the tools this skill needs, e.g. Read, Grep>",
94
+ ];
95
+
96
+ /**
97
+ * A copied file that is unspawnable for a reason an operator cannot see by reading it.
98
+ *
99
+ * The undeclared case got four explanatory lines from the start; a pattern-carrying one got nothing, so
100
+ * opening `.pi/skills/git-ops/SKILL.md` to find out why it will not spawn showed a perfectly ordinary file.
101
+ * Same argument, opposite treatment — now the same treatment.
102
+ */
103
+ const patternNote = (patterns: string[]) => [
104
+ "# pi-daddy: this skill CANNOT be spawned as a governed sub-agent. Its `allowed-tools` restricts a tool",
105
+ `# with a pattern (${patterns.join(", ")}), and pi's --tools matches whole tool names only — granting the`,
106
+ "# bare tool would widen the declaration and dropping it would silently narrow, so neither is done.",
107
+ "# Replace the pattern with whole tool names to make it spawnable.",
108
+ ];
109
+
110
+ /**
111
+ * The file to write for one skill: verbatim when it declares a usable ceiling, plus a commented note when
112
+ * it does not.
113
+ *
114
+ * Inserted at the END of the frontmatter block, so the rest of the file — including its own key order and
115
+ * its body — is byte-identical to the package's. A `#` line is a YAML comment and this package's
116
+ * frontmatter reader skips it, so an undeclared copy is still *undeclared*: not spawnable until a human
117
+ * edits it, which is the whole point.
118
+ */
119
+ export function withPlaceholder(text: string, declared: boolean, note: string[] = PLACEHOLDER): string {
120
+ if (declared) return text;
121
+ const match = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(text);
122
+ // No frontmatter at all means `parseSkillDefinition` returned null and this skill was never discovered;
123
+ // inventing one here would invent a file shape. Left exactly as it is.
124
+ if (!match) return text;
125
+ const [full, opening, body, close] = match;
126
+ const eol = close.startsWith("\r\n") ? "\r\n" : "\n";
127
+ return opening + body + eol + note.join(eol) + close + text.slice(full.length);
128
+ }
129
+
130
+ /** `tool:` ids pi 0.84.1 has no tool for. `delegate` is this package's own, hence the exemption. */
131
+ function unknownToolIds(capabilities: Capability[]): Capability[] {
132
+ const builtins = new Set<string>(PI_BUILTIN_TOOLS);
133
+ return capabilities.filter(
134
+ (c) => c.startsWith("tool:") && c !== ALWAYS_LIVE && !builtins.has(c.slice("tool:".length)),
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Decide what `init` would write. Pure: no filesystem, no npm, no decisions taken on the operator's behalf.
140
+ *
141
+ * The grant is **the read-only part** of what the copied skills declare, plus one `agent:` id per definition
142
+ * that can actually run within it, plus `tool:delegate` — without which the session registers no delegation
143
+ * tools at all and the whole file is inert. Everything else is emitted commented, named, and one uncomment
144
+ * away (ADR-0029).
145
+ */
146
+ export function planInit(packages: SkillPackage[], cwd: string): InitPlan {
147
+ const skills: PlannedSkill[] = [];
148
+ const collisions: string[] = [];
149
+ const seen = new Set<string>();
150
+
151
+ for (const pkg of packages) {
152
+ for (const skill of pkg.skills) {
153
+ const name = skill.definition.name;
154
+ if (seen.has(name)) {
155
+ collisions.push(`${name} (also in ${pkg.name}@${pkg.version}, not written)`);
156
+ continue;
157
+ }
158
+ seen.add(name);
159
+
160
+ const ceiling = ceilingForDefinition(skill.definition);
161
+ const notes: string[] = [];
162
+ let withheld: WithholdReason | null = null;
163
+ let note = PLACEHOLDER;
164
+ if (ceiling.undeclared) withheld = "undeclared";
165
+ else if (ceiling.patterns.length > 0) {
166
+ withheld = "pattern";
167
+ note = patternNote(ceiling.patterns);
168
+ notes.push(
169
+ `declares ${ceiling.patterns.join(", ")} — pi's --tools matches whole tool names only, so a ` +
170
+ `sub-tool pattern is refused rather than reinterpreted (granting the bare tool would widen it)`,
171
+ );
172
+ }
173
+
174
+ skills.push({
175
+ name,
176
+ from: `${pkg.name}@${pkg.version}`,
177
+ sourcePath: skill.path,
178
+ targetPath: join(cwd, ".pi", "skills", name, "SKILL.md"),
179
+ content: withPlaceholder(skill.text, withheld === null, note),
180
+ ceiling: ceiling.capabilities,
181
+ withheld,
182
+ notes,
183
+ });
184
+ }
185
+ }
186
+
187
+ // ADR-0029: split what the declared ceilings ask for into what `init` grants live and what it comments.
188
+ const declared = skills.filter((s) => s.withheld === null);
189
+ const withheldCapabilities = new Map<Capability, string[]>();
190
+ for (const skill of declared) {
191
+ for (const capability of skill.ceiling) {
192
+ if (isLiveByDefault(capability)) continue;
193
+ withheldCapabilities.set(capability, [...(withheldCapabilities.get(capability) ?? []), skill.name]);
194
+ }
195
+ }
196
+
197
+ // A definition needing a withheld capability does not get a live `agent:` id either: authorising it to run
198
+ // and then refusing it at spawn time is a worse answer than not authorising it, and it would put an
199
+ // `agent:` id in the grant whose definition cannot work — the shape ADR-0028 rule 3 already refuses.
200
+ for (const skill of declared) {
201
+ if (skill.ceiling.some((c) => !isLiveByDefault(c))) skill.withheld = "needs-withheld";
202
+ }
203
+
204
+ const authorised = skills.filter((s) => s.withheld === null);
205
+ const written = new Set(authorised.map((s) => s.name));
206
+ // `agent:<other>` in a ceiling is legitimate — it is how a delegator learns which definitions IT may
207
+ // spawn — but a name `init` did not write here would authorise a file from another skill root that the
208
+ // operator is not reviewing, including `~/.pi/agent/skills`, which other tools install into. Reported,
209
+ // never granted: the same objection ADR-0028 rule 3 makes to authorising an undeclared skill.
210
+ const crossReferences: { from: string; capability: Capability }[] = [];
211
+ const live = new Set<Capability>([ALWAYS_LIVE]);
212
+ for (const skill of authorised) {
213
+ live.add(agentCapability(skill.name));
214
+ for (const capability of skill.ceiling) {
215
+ if (capability.startsWith("agent:") && !written.has(capability.slice("agent:".length))) {
216
+ crossReferences.push({ from: skill.name, capability });
217
+ continue;
218
+ }
219
+ live.add(capability);
220
+ }
221
+ }
222
+ const grant = [...live].sort();
223
+
224
+ const cautions = authorised.flatMap((s) =>
225
+ unknownToolIds(s.ceiling).map(
226
+ (c) =>
227
+ `${s.name} declares ${c}, which pi 0.84.1 has no tool for — unless an extension provides it, ` +
228
+ `spawning ${s.name} is refused as an unknown capability`,
229
+ ),
230
+ );
231
+
232
+ // R-78's structural backstop. Throws rather than rendering something a shell could read as more than a
233
+ // value, so a gap in the per-entry whitelist costs a refusal instead of an injection.
234
+ assertGrantIsWritable(grant);
235
+
236
+ const describe: Record<WithholdReason, (s: PlannedSkill) => string> = {
237
+ undeclared: (s) => "declares no `allowed-tools` — fill it in, then add `agent:" + s.name + "` below",
238
+ pattern: (s) => s.notes.join("; "),
239
+ "needs-withheld": (s) =>
240
+ `needs ${s.ceiling.filter((c) => !isLiveByDefault(c)).join(", ")}, withheld by default — see below`,
241
+ };
242
+
243
+ const grantEnvSkills: GrantEnvSkill[] = skills.map((s) => ({
244
+ name: s.name,
245
+ ceiling: s.ceiling,
246
+ ...(s.withheld ? { unspawnable: describe[s.withheld](s) } : {}),
247
+ }));
248
+
249
+ return {
250
+ skills,
251
+ collisions,
252
+ grant,
253
+ withheldCapabilities,
254
+ grantEnvPath: join(cwd, ".pi", "grants.env"),
255
+ grantEnvContent: renderGrantEnv({
256
+ skills: grantEnvSkills,
257
+ live: grant,
258
+ withheld: withheldCapabilities,
259
+ withheldDefinitions: skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name),
260
+ crossReferences,
261
+ cautions,
262
+ }),
263
+ cautions,
264
+ };
265
+ }
266
+
267
+ export interface InitOutcome {
268
+ written: string[];
269
+ /** Present already, so left alone. `init` never silently overwrites an edited ceiling. */
270
+ kept: string[];
271
+ failed: { path: string; error: string }[];
272
+ }
273
+
274
+ /**
275
+ * Create a file only if nothing is there, and **never through a symlink**.
276
+ *
277
+ * Both properties come from `O_CREAT|O_EXCL` (`flag: "wx"`), and both were defects in the first version
278
+ * (R-79). It probed for existence with `readFile` and then called `writeFile`:
279
+ *
280
+ * - `readFile` conflates *unreadable* with *absent*, so an operator's `SKILL.md` with restrictive
281
+ * permissions was reported `wrote` and their narrowed `allowed-tools: Read` was replaced by the
282
+ * package's wider one — **without `--force`**, falsifying this package's own documented "Kept" rule.
283
+ * A FIFO at the target path hung the probe forever, with no timeout anywhere in the path.
284
+ * - `writeFile` follows symlinks, so a dangling symlink at a target path created the file at the link's
285
+ * destination, outside the project, while reporting an in-project path. That is **B-I6**, which
286
+ * `approval-store.ts` already fixed for the approval store under ADR-0014 — a new writer in the same
287
+ * package reintroducing a defect the package documents as closed, and whose comment says in so many
288
+ * words *"never through a symlink"*.
289
+ *
290
+ * `wx` fails with `EEXIST` on anything at the path, including a dangling symlink, so there is no probe, no
291
+ * race between the probe and the write, and no way to write through a link.
292
+ */
293
+ async function createUnlessPresent(path: string, content: string, outcome: InitOutcome): Promise<void> {
294
+ try {
295
+ await mkdir(join(path, ".."), { recursive: true });
296
+ const handle = await open(path, "wx");
297
+ try {
298
+ await handle.writeFile(content, "utf8");
299
+ } finally {
300
+ await handle.close();
301
+ }
302
+ outcome.written.push(path);
303
+ } catch (error) {
304
+ if ((error as { code?: string }).code === "EEXIST") outcome.kept.push(path);
305
+ else outcome.failed.push({ path, error: error instanceof Error ? error.message : String(error) });
306
+ }
307
+ }
308
+
309
+ /** Replace a file, unlinking first so a symlink is REPLACED rather than written through. */
310
+ async function replace(path: string, content: string, outcome: InitOutcome): Promise<void> {
311
+ try {
312
+ await mkdir(join(path, ".."), { recursive: true });
313
+ // `rm` unlinks the LINK, never its target — which is exactly the semantics `--force` should have.
314
+ await rm(path, { force: true });
315
+ await writeFile(path, content, { encoding: "utf8", flag: "wx" });
316
+ outcome.written.push(path);
317
+ } catch (error) {
318
+ outcome.failed.push({ path, error: error instanceof Error ? error.message : String(error) });
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Apply a plan.
324
+ *
325
+ * **Existing files are kept, not overwritten**, and that default is load-bearing rather than polite: the
326
+ * edit an operator makes to one of these files IS the capability decision, and the second run of a
327
+ * scaffolding command is exactly when it would be destroyed.
328
+ *
329
+ * **`--force` never regenerates `.pi/grants.env`** (R-79). It rewrites the definition copies, which is the
330
+ * documented re-sync path for R-74 — but the grant file is the *reviewed artifact*, and an operator who had
331
+ * deleted `agent:build` and added a ledger path would have had both silently restored to generated defaults
332
+ * by a command whose usage text mentions only `allowed-tools`. Deleting the file is how to regenerate it,
333
+ * and that is not something anyone does by accident.
334
+ */
335
+ export async function applyInit(plan: InitPlan, options: { force?: boolean } = {}): Promise<InitOutcome> {
336
+ const outcome: InitOutcome = { written: [], kept: [], failed: [] };
337
+ const force = options.force === true;
338
+ for (const skill of plan.skills) {
339
+ if (force) await replace(skill.targetPath, skill.content, outcome);
340
+ else await createUnlessPresent(skill.targetPath, skill.content, outcome);
341
+ }
342
+ await createUnlessPresent(plan.grantEnvPath, plan.grantEnvContent, outcome);
343
+ return outcome;
344
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Which installed npm packages ship `SKILL.md` definitions — read from their own manifests.
3
+ *
4
+ * `pi-daddy init` scaffolds a governed project from whatever skill packages are already installed, and
5
+ * this is how it finds them: **a package declares its skills in `package.json`'s `pi.skills` array**, which
6
+ * is pi's own convention and how pi itself loads them. Measured against `principal-pi-skills@2.3.1`:
7
+ *
8
+ * ```json
9
+ * "pi": { "skills": ["./decide", "./architect", "./plan", "./build", "./review", "./debug", "./git-ops"] }
10
+ * ```
11
+ *
12
+ * **A declaration, never a heuristic.** Walking `node_modules` looking for files called `SKILL.md` would
13
+ * find a package's test fixtures, its examples, and its vendored copies of someone else's skills — and
14
+ * would then offer to install them as spawnable sub-agents. A package that says which of its files are
15
+ * skills has said so on purpose, and that is the only list this reads.
16
+ *
17
+ * What it deliberately does NOT do: scan `~/.pi/agent/skills/`. Definitions already in a skill root are
18
+ * discovered by `loadDefinitions` and governed as they stand; copying them into a project would duplicate
19
+ * them under a name that shadows the original (project wins on collision), which is a change nobody asked
20
+ * for.
21
+ *
22
+ * **Everything read here comes from a third party**, so this module is also where the refusals live: a
23
+ * name, a declared capability id, or a path that cannot safely be written into a generated file is refused
24
+ * with a reason rather than passed on (R-77, R-78, R-80). `init` generates a shell file an operator
25
+ * `source`s; the only strings that may reach it are ones that survived a whitelist here.
26
+ */
27
+
28
+ import { readdir, readFile, realpath } from "node:fs/promises";
29
+ import { join, resolve, sep } from "node:path";
30
+ import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
31
+ import { WILDCARD } from "./pi-tools.ts";
32
+ import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
33
+
34
+ export interface DiscoveredSkill {
35
+ definition: SkillDefinition;
36
+ /** The file verbatim. `init` copies it rather than regenerating it, so nothing is lost in a round trip. */
37
+ text: string;
38
+ path: string;
39
+ }
40
+
41
+ /** Why a declared skill was refused before it could be planned. Each has a different fix. */
42
+ export type RefusalReason =
43
+ /** The name cannot be a capability id, a line in a sourced file, or a path segment. */
44
+ | "unsafe-name"
45
+ /** A declared `allowed-tools` entry cannot be one either — R-78, the sibling of R-77. */
46
+ | "unsafe-capability"
47
+ /** The declaration claims `tool:*` or `agent:*`: root authority, which a package may not hand itself. */
48
+ | "wildcard"
49
+ /** The bytes are not valid UTF-8, so "copied verbatim" could not be honoured. */
50
+ | "not-utf8";
51
+
52
+ export interface RefusedSkill {
53
+ /** The `pi.skills` entry or the definition name, whichever the operator can act on. */
54
+ subject: string;
55
+ reason: RefusalReason;
56
+ /** The offending id(s), when the reason names any. */
57
+ detail: string[];
58
+ }
59
+
60
+ export interface SkillPackage {
61
+ name: string;
62
+ version: string;
63
+ /** Paths `pi.skills` named that could not be read as a `SKILL.md`, so the report can say so out loud. */
64
+ unreadable: string[];
65
+ /** Skills refused for a reason that would otherwise reach a generated file. Never silently dropped. */
66
+ refused: RefusedSkill[];
67
+ skills: DiscoveredSkill[];
68
+ }
69
+
70
+ /**
71
+ * May this definition's name be written into a capability id, a shell file and a path?
72
+ *
73
+ * **Measured before it was written, and it was a defect in this module's first version (R-77).** A
74
+ * definition's identity is its directory name, and `init` interpolates that name into three places at once:
75
+ * `agent:<name>` inside a **comma-separated** `PI_GRANTS_GRANT`, a `.pi/grants.env` an operator **sources**,
76
+ * and the path it writes the copy to. An installed package with a directory called `a,tool:bash` produced
77
+ *
78
+ * ```
79
+ * export PI_GRANTS_GRANT="agent:a,tool:bash,tool:delegate,tool:read"
80
+ * ```
81
+ *
82
+ * — `tool:bash` in an operator's grant, declared by nobody. A quote character reaches a file that gets
83
+ * `source`d, and a name of `..` writes outside `.pi/skills/`. One rule closes all three, and it is
84
+ * deliberately a **whitelist**: the safe set here is small and the unsafe set is the rest of Unicode.
85
+ *
86
+ * The first character must be alphanumeric, so `..` and dotfiles are refused along with everything else.
87
+ */
88
+ export function isSafeName(name: string): boolean {
89
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
90
+ }
91
+
92
+ /**
93
+ * May this DECLARED capability id be written into the generated grant? — R-78.
94
+ *
95
+ * **R-77's other half, and the reason a whitelist beats a blocklist twice.** R-77 was closed on the name
96
+ * channel; the `allowed-tools` *value* travels to the identical interpolation site and was unchecked, so a
97
+ * package declaring
98
+ *
99
+ * ```yaml
100
+ * allowed-tools: Read,ext:x";touch /tmp/pwned;PI_GRANTS_GRANT="
101
+ * ```
102
+ *
103
+ * produced a `.pi/grants.env` that executed arbitrary code the moment the operator ran the `source` line
104
+ * `init` itself prints. Reproduced end to end before this existed. `ceilingForDefinition` passes `ext:`,
105
+ * `skill:` and `agent:` entries through **as written** by design (a translation table would invent or drop
106
+ * grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
107
+ * exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
108
+ *
109
+ * The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`, and
110
+ * `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused separately and
111
+ * loudly, because "you tried to grant yourself everything" is a different fact from "that is not a name".
112
+ */
113
+ export function isSafeCapability(id: Capability): boolean {
114
+ const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
115
+ return (
116
+ new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
117
+ new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
118
+ );
119
+ }
120
+
121
+ /** The two ids that confer root authority. A package declaring one is claiming it, not describing a need. */
122
+ function wildcardsIn(capabilities: Capability[]): Capability[] {
123
+ return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
124
+ }
125
+
126
+ /**
127
+ * Everything about one declared skill that would make it unsafe to scaffold. `null` when it is fine.
128
+ *
129
+ * Ordered so the operator is told the most actionable thing: a wildcard is a deliberate claim, an unsafe id
130
+ * is probably a typo or an attack, and a bad name is neither.
131
+ */
132
+ function refusalFor(skill: DiscoveredSkill): RefusedSkill | null {
133
+ const name = skill.definition.name;
134
+ if (!isSafeName(name)) return { subject: name, reason: "unsafe-name", detail: [name] };
135
+
136
+ const ceiling = ceilingForDefinition(skill.definition);
137
+ const wildcards = wildcardsIn(ceiling.capabilities);
138
+ if (wildcards.length > 0) return { subject: name, reason: "wildcard", detail: wildcards };
139
+
140
+ const unsafe = ceiling.capabilities.filter((c) => !isSafeCapability(c));
141
+ if (unsafe.length > 0) return { subject: name, reason: "unsafe-capability", detail: unsafe };
142
+
143
+ return null;
144
+ }
145
+
146
+ /** One `pi.skills` entry: a directory holding `SKILL.md`, or a `.md` file — the same two shapes pi allows. */
147
+ async function readSkill(packageDir: string, entry: string): Promise<DiscoveredSkill | "not-utf8" | null> {
148
+ const target = resolve(packageDir, entry);
149
+ // A manifest is data from another package, so an entry escaping its own directory is refused rather than
150
+ // followed. **`realpath`, not a lexical prefix test** (R-80): `resolve()` normalises `..` and knows
151
+ // nothing about symlinks, so a packaged symlink walked straight past the first version of this check and
152
+ // a definition from outside the package was copied in, with its `allowed-tools` landing in the operator's
153
+ // grant. Measured. This is the same lesson as the `realpathSync` fix in `cli.ts`, which was found by the
154
+ // smoke test one day earlier and not applied here.
155
+ const realPackageDir = await realpath(packageDir).catch(() => packageDir);
156
+ for (const path of [join(target, "SKILL.md"), ...(target.endsWith(".md") ? [target] : [])]) {
157
+ let bytes: Buffer;
158
+ try {
159
+ bytes = await readFile(path);
160
+ } catch {
161
+ continue;
162
+ }
163
+ const realPath = await realpath(path).catch(() => path);
164
+ if (!realPath.startsWith(realPackageDir + sep)) return null;
165
+
166
+ // "The file verbatim … nothing is lost in a round trip" is a claim this module makes, so bytes that
167
+ // cannot survive the round trip are refused rather than silently replaced. A latin-1 `0xE9` used to come
168
+ // back as U+FFFD, changing the file's length and its digest, with no warning.
169
+ const text = bytes.toString("utf8");
170
+ if (!Buffer.from(text, "utf8").equals(bytes)) return "not-utf8";
171
+
172
+ const definition = parseSkillDefinition(path, text);
173
+ return definition ? { definition, text, path } : null;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** Read one installed package, if it declares skills. `null` means "not a skill package", not an error. */
179
+ export async function readSkillPackage(packageDir: string): Promise<SkillPackage | null> {
180
+ let manifest: { name?: string; version?: string; pi?: { skills?: unknown } };
181
+ try {
182
+ manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
183
+ } catch {
184
+ return null;
185
+ }
186
+ const declared = manifest.pi?.skills;
187
+ if (!Array.isArray(declared) || declared.length === 0) return null;
188
+
189
+ const skills: DiscoveredSkill[] = [];
190
+ const unreadable: string[] = [];
191
+ const refused: RefusedSkill[] = [];
192
+ for (const entry of declared) {
193
+ if (typeof entry !== "string") continue;
194
+ const skill = await readSkill(packageDir, entry);
195
+ if (skill === null) {
196
+ unreadable.push(entry);
197
+ } else if (skill === "not-utf8") {
198
+ refused.push({ subject: entry, reason: "not-utf8", detail: [] });
199
+ } else {
200
+ const refusal = refusalFor(skill);
201
+ if (refusal) refused.push(refusal);
202
+ else skills.push(skill);
203
+ }
204
+ }
205
+
206
+ return {
207
+ name: manifest.name ?? packageDir.split(sep).pop() ?? "(unnamed)",
208
+ version: manifest.version ?? "(no version)",
209
+ unreadable,
210
+ refused,
211
+ skills,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Every installed package under `<cwd>/node_modules` that declares `pi.skills`, sorted by name.
217
+ *
218
+ * Top level and one scope deep, which is what npm's layout has. Nothing recurses into a dependency's own
219
+ * `node_modules`: a transitive skill package is not something an operator asked to install definitions
220
+ * from, and scaffolding one into their project would be a surprise wearing a helpful face.
221
+ */
222
+ export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]> {
223
+ const root = join(cwd, "node_modules");
224
+ let entries: string[];
225
+ try {
226
+ entries = await readdir(root);
227
+ } catch {
228
+ return []; // no node_modules is a normal state, not a failure
229
+ }
230
+
231
+ const dirs: string[] = [];
232
+ for (const entry of entries.sort()) {
233
+ if (entry.startsWith(".")) continue; // .bin, .package-lock.json
234
+ if (entry.startsWith("@")) {
235
+ try {
236
+ for (const scoped of (await readdir(join(root, entry))).sort()) dirs.push(join(root, entry, scoped));
237
+ } catch {
238
+ continue;
239
+ }
240
+ } else {
241
+ dirs.push(join(root, entry));
242
+ }
243
+ }
244
+
245
+ const packages: SkillPackage[] = [];
246
+ for (const dir of dirs) {
247
+ const found = await readSkillPackage(dir);
248
+ if (found) packages.push(found);
249
+ }
250
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
251
+ }