pi-daddy 0.27.0 → 0.27.2

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/cli.ts CHANGED
@@ -28,7 +28,7 @@ import { panelText } from "./daily-panel.ts";
28
28
  const USAGE = `pi-daddy — capability governance for pi sub-agents
29
29
 
30
30
  Usage:
31
- pi-daddy init [--force] [--dir <path>] scaffold .pi/skills/ and .pi/grants.env from installed
31
+ pi-daddy init [--force] [--dir <path>] prepare .pi/grants.env from enabled installed
32
32
  packages that declare skills (package.json "pi": {"skills": …})
33
33
  pi-daddy work add --id <id> --outcome <text> [--dir <path>]
34
34
  declare one current obligation for ordinary delegation
@@ -39,12 +39,12 @@ Usage:
39
39
  pi-daddy guide | current installed product guide / current requirement register
40
40
  pi-daddy --help | --version
41
41
 
42
- init copies each declared SKILL.md into .pi/skills/ and writes a grant naming exactly what those files
43
- declare. It never chooses a ceiling: a skill declaring no \`allowed-tools\` is copied with a commented
44
- placeholder and stays unspawnable until you fill it in. Capabilities that can change your machine
42
+ init references skills already enabled in Pi at their installed or local paths. Legacy unregistered npm
43
+ skills are copied into .pi/skills/. It never chooses a ceiling: missing or unusable \`allowed-tools\`
44
+ stays unspawnable. Capabilities that can change your machine
45
45
  (bash, write, edit) are written COMMENTED — uncomment them deliberately. Review the files, then commit.
46
46
 
47
- --force rewrite the SKILL.md copies that already exist. This DISCARDS any \`allowed-tools\` you added.
47
+ --force rewrite legacy unregistered npm SKILL.md copies that already exist. This DISCARDS any \`allowed-tools\` you added.
48
48
  It never rewrites .pi/grants.env — delete that file if you want it regenerated.`;
49
49
 
50
50
  export interface ParsedArgs {
@@ -131,7 +131,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
131
131
  // `pi install npm:principal-pi-skills`, which installs to the agent root, was told to install a package
132
132
  // they had already installed (R-75).
133
133
  console.log(
134
- `pi-daddy init: no installed package declares skills (a package.json "pi": {"skills": [...]} ` +
134
+ `pi-daddy init: no enabled configured skills or unregistered npm package declares skills (a package.json "pi": {"skills": [...]} ` +
135
135
  `field). Nothing to scaffold.\n\nLooked in:\n` +
136
136
  skillPackageRoots(cwd).map((r) => ` ${r}\n`).join("") +
137
137
  `\n pi install npm:principal-pi-skills # seven skills, and registers it with pi\n` +
@@ -171,7 +171,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
171
171
  // `--force` is destructive and says so at the moment it acts, not only in `--help` — which is the one
172
172
  // place the operator running the command is not reading.
173
173
  if (force) {
174
- const existing = plan.skills.length;
174
+ const existing = plan.skills.filter(s => !s.referenced).length;
175
175
  console.log(
176
176
  `\n--force: rewriting up to ${existing} SKILL.md cop${existing === 1 ? "y" : "ies"} from the installed\n` +
177
177
  `packages. Any \`allowed-tools\` you wrote in them is DISCARDED. .pi/grants.env is never rewritten.`,
@@ -180,6 +180,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
180
180
 
181
181
  const outcome = await applyInit(plan, { force });
182
182
  const short = (path: string) => relative(cwd, path) || path;
183
+ for (const skill of plan.skills.filter(s => s.referenced)) console.log(`using ${short(skill.sourcePath)} (enabled in Pi; no copy)`);
183
184
  for (const path of outcome.written) console.log(`wrote ${short(path)}`);
184
185
  for (const path of outcome.kept) console.log(`kept ${short(path)} (already present — left exactly as it is)`);
185
186
  for (const failure of outcome.failed) console.error(`FAILED ${short(failure.path)}: ${failure.error}`);
@@ -20,9 +20,8 @@
20
20
  */
21
21
 
22
22
  import { createHash } from "node:crypto";
23
- import { readFile, readdir } from "node:fs/promises";
24
- import { join } from "node:path";
25
- import { skillDirs } from "./catalog.ts";
23
+ import { readFile } from "node:fs/promises";
24
+ import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
26
25
  import { CAPABILITY_NAMESPACE_PREFIXES } from "./capabilities.ts";
27
26
  import type { Capability } from "./resolve.ts";
28
27
 
@@ -133,6 +132,11 @@ export function parseSkillDefinition(source: string, text: string): SkillDefinit
133
132
  continue;
134
133
  }
135
134
 
135
+ // A YAML collection/block we cannot parse is not an explicit empty ceiling.
136
+ if (key === "allowed-tools" && value === "") {
137
+ const nextValue = lines.slice(i + 1).find(line => line.trim() !== "" && !/^\s*#/.test(line));
138
+ if (/^\s+\S/.test(nextValue ?? "")) continue;
139
+ }
136
140
  fields.set(key, value);
137
141
  }
138
142
 
@@ -144,7 +148,7 @@ export function parseSkillDefinition(source: string, text: string): SkillDefinit
144
148
  // skills by their directory, so trusting a frontmatter `name` lets our view and the loader's
145
149
  // disagree about which file a name refers to. The spec requires `name` to match the parent
146
150
  // directory anyway, so a mismatch is the file's defect and not something to honour.
147
- name: nameFromPath(source),
151
+ name: skillResourceName(source),
148
152
  description,
149
153
  allowedTools: fields.get("allowed-tools"),
150
154
  metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
@@ -153,14 +157,6 @@ export function parseSkillDefinition(source: string, text: string): SkillDefinit
153
157
  };
154
158
  }
155
159
 
156
- /** `/skills/review/SKILL.md` -> `review`; `/skills/triage.md` -> `triage`. */
157
- function nameFromPath(source: string): string {
158
- const parts = source.split("/").filter((p) => p.length > 0);
159
- const last = parts.at(-1) ?? "";
160
- if (last.toLowerCase() === "skill.md") return parts.at(-2) ?? "";
161
- return last.replace(/\.md$/i, "");
162
- }
163
-
164
160
  /**
165
161
  * Turn a definition's `allowed-tools` into a capability ceiling.
166
162
  *
@@ -199,41 +195,17 @@ export function ceilingForDefinition(definition: SkillDefinition): DefinitionCei
199
195
  }
200
196
 
201
197
  /**
202
- * Discover `SKILL.md` definitions under pi's skill roots.
203
- *
204
- * Deliberately the SAME roots and the same convention the catalog uses (`skillDirs`): a directory
205
- * containing `SKILL.md` is one definition named after the directory, and a top-level `.md` is one named
206
- * after the file. If discovery and the catalog disagreed, a definition could be spawnable but not
207
- * grantable, or listed but unspawnable.
208
- *
209
- * Earlier directories win on a name collision, matching pi's own precedence — project before global.
198
+ * Read definitions from Pi's enabled resources, including installed packages and local overrides.
199
+ * Resolver precedence and filters are shared with the capability catalog; unregistered npm packages
200
+ * are not runtime resources until legacy init explicitly scaffolds them.
210
201
  */
211
202
  export async function loadDefinitions(cwd: string): Promise<Map<string, SkillDefinition>> {
212
203
  const definitions = new Map<string, SkillDefinition>();
213
- for (const dir of skillDirs(cwd)) {
214
- let names: string[];
215
- try {
216
- names = await readdir(dir);
217
- } catch {
218
- continue; // an absent skill root is normal
219
- }
220
- for (const name of [...names].sort()) {
221
- // A directory holding SKILL.md, or a top-level .md — try the former first, exactly as the
222
- // catalog does, so the two cannot disagree about what exists.
223
- const candidates = [join(dir, name, "SKILL.md"), ...(name.endsWith(".md") ? [join(dir, name)] : [])];
224
- for (const path of candidates) {
225
- let text: string;
226
- try {
227
- text = await readFile(path, "utf8");
228
- } catch {
229
- continue; // not this shape; try the next candidate
230
- }
231
- const parsed = parseSkillDefinition(path, text);
232
- // First writer wins, so project definitions shadow global ones rather than the reverse.
233
- if (parsed && !definitions.has(parsed.name)) definitions.set(parsed.name, parsed);
234
- break;
235
- }
236
- }
204
+ for (const { path } of (await resolveSkillResources(cwd)).skills) {
205
+ let text: string;
206
+ try { text = await readFile(path, "utf8"); } catch { continue; }
207
+ const parsed = parseSkillDefinition(path, text);
208
+ if (parsed && !definitions.has(parsed.name)) definitions.set(parsed.name, parsed);
237
209
  }
238
210
  return definitions;
239
211
  }
package/src/init.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `pi-daddy init` — scaffold a governed project from the skill packages already installed (B2, P3).
3
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.
4
+ * Legacy unregistered npm packages can be scaffolded per skill: create a directory, copy the
5
+ * body and declaration copies plus a starting `PI_GRANTS_GRANT`. Configured enabled Pi resources
6
+ * are referenced where installed instead (ADR-0074); no competing .pi/skills copy is created.
7
7
  *
8
8
  * **The line it does not cross, and the reason this module exists at all:** `init` writes files an operator
9
9
  * then **reviews, edits and commits**. It never chooses a ceiling. A skill that declares `allowed-tools` is
@@ -32,23 +32,9 @@ import { PI_BUILTIN_TOOLS } from "./pi-tools.ts";
32
32
  import type { Capability } from "./resolve.ts";
33
33
  import type { SkillPackage } from "./skill-packages.ts";
34
34
 
35
- /** Why a discovered skill is not authorised in the generated grant. `null` means it is. */
36
35
  /**
37
- * How many of `from`'s skills actually **declare** a ceiling.
38
- *
39
- * Exported because the number is printed to an operator and was wrong: `cli.ts` filtered on
40
- * `withheld === null`, which is false for all three `WithholdReason`s — so a skill that declares
41
- * `allowed-tools` perfectly well and merely needs a withheld capability counted as not declaring one.
42
- * Against `principal-pi-skills` that printed *"7 skill(s), 3 declaring allowed-tools"* while all seven
43
- * declared. R-28's shape: a diagnostic disagreeing with the thing it describes.
44
- *
45
- * **It was invisible until the integration worked.** Before ceilings shipped, none of the seven declared
46
- * and the line read *"0 declaring"* — correct by coincidence, for the wrong reason. A count that is right
47
- * only while the interesting case is absent is the kind this project keeps finding.
48
- *
49
- * `undeclared` is the only reason that means "did not declare". `pattern` declared something this package
50
- * refuses to reinterpret, and `needs-withheld` declared something fine that the operator must opt into —
51
- * both are declarations.
36
+ * Count declarations, not authorizations (R-73). Both pattern and needs-withheld mean the skill
37
+ * declared a ceiling; only undeclared means it did not. The CLI must report the same distinction.
52
38
  */
53
39
  export function countDeclaring(skills: PlannedSkill[], from: string): number {
54
40
  return skills.filter((s) => s.from === from && s.withheld !== "undeclared").length;
@@ -62,6 +48,8 @@ export interface PlannedSkill {
62
48
  from: string;
63
49
  sourcePath: string;
64
50
  targetPath: string;
51
+ /** Configured skills stay at their installed/local source, including when --force is used. */
52
+ referenced?: boolean;
65
53
  /** Exactly what would be written — the file verbatim, or the file plus a commented note. */
66
54
  content: string;
67
55
  /** The declared ceiling, empty when the declaration is absent or unusable. */
@@ -191,7 +179,8 @@ export function planInit(
191
179
  name,
192
180
  from: `${pkg.name}@${pkg.version}`,
193
181
  sourcePath: skill.path,
194
- targetPath: join(cwd, ".pi", "skills", name, "SKILL.md"),
182
+ targetPath: skill.referenced ? skill.path : join(cwd, ".pi", "skills", name, "SKILL.md"),
183
+ referenced: skill.referenced,
195
184
  content: withPlaceholder(skill.text, withheld === null, note),
196
185
  ceiling: ceiling.capabilities,
197
186
  withheld,
@@ -391,6 +380,7 @@ export async function applyInit(plan: InitPlan, options: { force?: boolean } = {
391
380
  }
392
381
  const force = options.force === true;
393
382
  for (const skill of plan.skills) {
383
+ if (skill.referenced) continue;
394
384
  if (force) await replace(skill.targetPath, skill.content, outcome);
395
385
  else await createUnlessPresent(skill.targetPath, skill.content, outcome);
396
386
  }
@@ -1,34 +1,15 @@
1
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.
2
+ * Setup discovers enabled Pi resources in place through its package resolver. Configured packages
3
+ * are never copied into another autoload root. For legacy npm installs unregistered with Pi only,
4
+ * the explicit package.json pi.skills declaration remains a scaffold source (ADR-0074).
5
+ * Names, ceilings and bytes are checked before they can enter the generated shell grant.
26
6
  */
27
7
 
28
8
  import { readdir, readFile, realpath } from "node:fs/promises";
29
9
  import { homedir } from "node:os";
30
10
  import { join, resolve, sep } from "node:path";
31
11
  import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
12
+ import { resolveSkillResources, skillResourceName } from "./skill-resources.ts";
32
13
  import { WILDCARD } from "./pi-tools.ts";
33
14
  import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
34
15
  import { isSafeCapability } from "./capabilities.ts";
@@ -38,6 +19,8 @@ export interface DiscoveredSkill {
38
19
  /** The file verbatim. `init` copies it rather than regenerating it, so nothing is lost in a round trip. */
39
20
  text: string;
40
21
  path: string;
22
+ /** Already enabled by Pi; setup must reference it rather than making a competing copy. */
23
+ referenced?: boolean;
41
24
  }
42
25
 
43
26
  /** Why a declared skill was refused before it could be planned. Each has a different fix. */
@@ -248,21 +231,63 @@ export function skillPackageRoots(cwd: string): string[] {
248
231
  }
249
232
 
250
233
  export async function discoverSkillPackages(cwd: string): Promise<SkillPackage[]> {
234
+ const resolved = await resolveSkillResources(cwd);
235
+ const packages: SkillPackage[] = [];
236
+ const seenSkills = new Set<string>();
237
+ const runtimeNames = new Set(resolved.skills.map(s => skillResourceName(s.path)));
238
+ const configuredRoots = new Set(resolved.configured.flatMap(p => p.installedPath ? [resolve(p.installedPath)] : []));
239
+ const configuredNames = new Set<string>(resolved.configured.flatMap(p => {
240
+ if (!p.source.startsWith("npm:")) return [];
241
+ const spec = p.source.slice(4);
242
+ const versionAt = spec.indexOf("@", 1);
243
+ return [versionAt < 0 ? spec : spec.slice(0, versionAt)];
244
+ }));
245
+ for (const root of configuredRoots) {
246
+ try { configuredNames.add(JSON.parse(await readFile(join(root, "package.json"), "utf8")).name); } catch { /* absent */ }
247
+ }
248
+ for (const resource of resolved.skills) {
249
+ const bytes = await readFile(resource.path).catch(() => null);
250
+ if (bytes === null) continue;
251
+ const text = bytes.toString("utf8");
252
+ const resourceName = skillResourceName(resource.path);
253
+ if (seenSkills.has(resourceName)) continue;
254
+ seenSkills.add(resourceName);
255
+ let name = resource.metadata.source;
256
+ let version = "local";
257
+ if (resource.metadata.origin === "package" && resource.metadata.baseDir) {
258
+ try {
259
+ const manifest = JSON.parse(await readFile(join(resource.metadata.baseDir, "package.json"), "utf8"));
260
+ name = manifest.name ?? name; version = manifest.version ?? version;
261
+ } catch { /* the resource can be used without optional display metadata */ }
262
+ } else name = `${resource.metadata.scope} skills`;
263
+ let pkg = packages.find(p => p.name === name && p.version === version);
264
+ if (!pkg) { pkg = { name, version, skills: [], refused: [], unreadable: [] }; packages.push(pkg); }
265
+ if (!Buffer.from(text, "utf8").equals(bytes)) {
266
+ pkg.refused.push({ subject: resourceName, reason: "not-utf8", detail: [] });
267
+ continue;
268
+ }
269
+ const definition = parseSkillDefinition(resource.path, text);
270
+ if (!definition) continue;
271
+ const skill = { definition, text, path: resource.path, referenced: true };
272
+ const refusal = refusalFor(skill);
273
+ if (refusal) pkg.refused.push(refusal); else pkg.skills.push(skill);
274
+ }
275
+
276
+ // Compatibility for npm installs that were never registered with Pi. Runtime discovery never scans
277
+ // node_modules. A configured package (including one disabled by a filter) must not re-enter here.
251
278
  const dirs: string[] = [];
252
- const seenNames = new Set<string>();
253
279
  for (const root of skillPackageRoots(cwd)) await collectFrom(root, dirs);
254
-
255
- const packages: SkillPackage[] = [];
280
+ const seenNames = new Set(packages.map(p => p.name));
256
281
  for (const dir of dirs) {
282
+ if (configuredRoots.has(resolve(dir))) continue;
257
283
  const found = await readSkillPackage(dir);
258
- // First root wins on a name collision: the project's pinned copy outranks the machine-wide one, and
259
- // silently preferring the other would make a committed lockfile stop meaning anything.
260
- if (found && !seenNames.has(found.name)) {
261
- seenNames.add(found.name);
262
- packages.push(found);
263
- }
284
+ if (!found || configuredNames.has(found.name) || seenNames.has(found.name)) continue;
285
+ seenNames.add(found.name);
286
+ found.skills = found.skills.filter(skill => !runtimeNames.has(skill.definition.name));
287
+ packages.push(found);
264
288
  }
265
- return packages.sort((a, b) => a.name.localeCompare(b.name));
289
+ return packages;
290
+
266
291
  }
267
292
 
268
293
  /** Append every package directory under one `node_modules`, scoped packages included. */
@@ -0,0 +1,48 @@
1
+ /** Read Pi's configured resource surface without locks, installs, extension execution or model calls. */
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
5
+
6
+ interface SkillResources {
7
+ skills: { path: string; enabled: boolean; metadata: { source: string; scope: string; origin: string; baseDir?: string } }[];
8
+ configured: { source: string; scope: string; installedPath?: string }[];
9
+ }
10
+
11
+ export function skillResourceName(path: string): string {
12
+ const parts = path.split(/[\\/]/);
13
+ const file = parts.at(-1) ?? "";
14
+ return file.toLowerCase() === "skill.md" ? parts.at(-2) ?? "" : file.replace(/\.md$/i, "");
15
+ }
16
+
17
+ export async function resolveSkillResources(cwd: string): Promise<SkillResources> {
18
+ const agentDir = getAgentDir();
19
+ const settingsManager = SettingsManager.fromStorage({
20
+ withLock(scope, read) {
21
+ const path = scope === "global" ? join(agentDir, "settings.json") : join(cwd, ".pi", "settings.json");
22
+ let text: string | undefined;
23
+ try { text = readFileSync(path, "utf8"); }
24
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
25
+ // This storage is intentionally read-only: no lock files or migrated settings are persisted.
26
+ read(text);
27
+ },
28
+ });
29
+ const errors = settingsManager.drainErrors();
30
+ if (errors.length) throw new Error(`Cannot discover skills: ${errors.map(e => `${e.scope} settings: ${e.error.message}`).join("; ")}`);
31
+ const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
32
+ const resolved = await manager.resolve(async () => "skip");
33
+ // Pi has already applied package identity, scope, manifest and settings filters and precedence.
34
+ const seen = new Set<string>();
35
+ const skills = resolved.skills.filter(resource => resource.enabled).flatMap(resource => {
36
+ try {
37
+ const path = statSync(resource.path).isDirectory() ? join(resource.path, "SKILL.md") : resource.path;
38
+ return statSync(path).isFile() ? [{ ...resource, path }] : [];
39
+ } catch { return []; }
40
+ }).filter(resource => {
41
+ // Reserve identity before parsing: a malformed local override cannot reveal a wider package ceiling.
42
+ const name = skillResourceName(resource.path);
43
+ if (seen.has(name)) return false;
44
+ seen.add(name);
45
+ return true;
46
+ });
47
+ return { skills, configured: manager.listConfiguredPackages() };
48
+ }