/** * `pi-daddy init` — scaffold a governed project from the skill packages already installed (B2, P3). * * Legacy unregistered npm packages can be scaffolded per skill: create a directory, copy the * body and declaration copies plus a starting `PI_DADDY_GRANT`. Configured enabled Pi resources * are referenced where installed instead (ADR-0074); no competing .pi/skills copy is created. * * **The line it does not cross, and the reason this module exists at all:** `init` writes files an operator * then **reviews, edits and commits**. It never chooses a ceiling. A skill that declares `allowed-tools` is * copied *verbatim* — the author's declaration is the ceiling, and re-deriving it here would put a second * opinion between the file and the enforcer. A skill that declares none is copied with a **commented** * placeholder and stays unspawnable until a human fills it in. * * **What it DOES choose is the starting grant, which is a stronger act** — ADR-0029, added after a reviewer * pointed out that ADR-0028 drew its boundary around the wrong object. The handoff's reason a third party * may safely author `allowed-tools` is that *"the operator's `PI_DADDY_GRANT` still bounds it"*; a * generated union gives the bound and the bounded one author, and it is not the operator. So capabilities * that can change a machine are emitted **commented** (`./grant-env.ts`). * * The placeholder is deliberately not a working example. Uncommenting it unedited yields capability ids * like `tool: s.from === from && s.withheld !== "undeclared").length; } export type WithholdReason = "undeclared" | "pattern" | "needs-withheld"; export interface PlannedSkill { name: string; /** Which package it came from, as `name@version`. */ from: string; sourcePath: string; targetPath: string; /** Configured skills stay at their installed/local source, including when --force is used. */ referenced?: boolean; /** Exactly what would be written — the file verbatim, or the file plus a commented note. */ content: string; /** The declared ceiling, empty when the declaration is absent or unusable. */ ceiling: Capability[]; withheld: WithholdReason | null; /** e.g. a sub-tool pattern pi's `--tools` cannot express. Reported, never reinterpreted. */ notes: string[]; } export interface InitPlan { skills: PlannedSkill[]; /** Definitions two packages both declare. First wins; the loser is named rather than silently dropped. */ collisions: string[]; /** The live grant — what `settings.json` records and the grant store applies. */ grant: Capability[]; /** Withheld capability → the definitions that declared it (ADR-0029). Emitted commented. */ withheldCapabilities: Map; /** `workspace:` this project could route to (ADR-0035). Always commented — `init` does not choose. */ routableWorkspaces: Capability[]; /** `/.pi/pi-daddy` — created owner-only by apply. */ stateDir: string; settingsPath: string; settingsContent: string; gitignorePath: string; /** Capabilities a declared ceiling names that pi 0.84.1 has no tool for — a caution, not a verdict. */ cautions: string[]; } const PLACEHOLDER = [ "# pi-daddy: this skill declares no `allowed-tools`, so it CANNOT be spawned as a governed sub-agent —", "# an undeclared capability set is treated as NONE, never as everything. Decide what it needs, then", "# uncomment and complete the line below. pi-daddy does not choose this for you: the capability set is", "# the thing you are meant to review and commit.", "# pi 0.84.1's tools: " + PI_BUILTIN_TOOLS.join(", ") + ".", "# allowed-tools: ", ]; /** * A copied file that is unspawnable for a reason an operator cannot see by reading it. * * The undeclared case got four explanatory lines from the start; a pattern-carrying one got nothing, so * opening `.pi/skills/git-ops/SKILL.md` to find out why it will not spawn showed a perfectly ordinary file. * Same argument, opposite treatment — now the same treatment. */ const patternNote = (patterns: string[]) => [ "# pi-daddy: this skill CANNOT be spawned as a governed sub-agent. Its `allowed-tools` restricts a tool", `# with a pattern (${patterns.join(", ")}), and pi's --tools matches whole tool names only — granting the`, "# bare tool would widen the declaration and dropping it would silently narrow, so neither is done.", "# Replace the pattern with whole tool names to make it spawnable.", ]; /** * The file to write for one skill: verbatim when it declares a usable ceiling, plus a commented note when * it does not. * * Inserted at the END of the frontmatter block, so the rest of the file — including its own key order and * its body — is byte-identical to the package's. A `#` line is a YAML comment and this package's * frontmatter reader skips it, so an undeclared copy is still *undeclared*: not spawnable until a human * edits it, which is the whole point. */ export function withPlaceholder(text: string, declared: boolean, note: string[] = PLACEHOLDER): string { if (declared) return text; const match = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(text); // No frontmatter at all means `parseSkillDefinition` returned null and this skill was never discovered; // inventing one here would invent a file shape. Left exactly as it is. if (!match) return text; const [full, opening, body, close] = match; const eol = close.startsWith("\r\n") ? "\r\n" : "\n"; return opening + body + eol + note.join(eol) + close + text.slice(full.length); } /** `tool:` ids pi 0.84.1 has no tool for. `delegate` is this package's own, hence the exemption. */ function unknownToolIds(capabilities: Capability[]): Capability[] { const builtins = new Set(PI_BUILTIN_TOOLS); return capabilities.filter( (c) => c.startsWith("tool:") && c !== ALWAYS_LIVE && !builtins.has(c.slice("tool:".length)), ); } /** * Decide what `init` would write. Pure: no filesystem, no npm, no decisions taken on the operator's behalf. * * The grant is **the read-only part** of what the copied skills declare, plus one `agent:` id per definition * that can actually run within it, plus `tool:delegate` — without which the session registers no delegation * tools at all and the whole file is inert. Everything else is emitted commented, named, and one uncomment * away (ADR-0029). */ export function planInit( packages: SkillPackage[], cwd: string, /** * Ids from the operator's workspace registry, when one is configured — read by the CALLER, because * `planInit` is pure and stays that way. * * ADR-0035 made routing a capability and said `init` "scaffolds the registered ids so the common path is a * one-line grant edit". It did not: `init` had never heard of the registry, so the ADR's own stated * migration path for a breaking change did not exist. These are emitted **commented**, never live — * offering the ids while refusing to choose among them is exactly ADR-0028's position. */ registeredWorkspaceIds: readonly string[] = [], ): InitPlan { const skills: PlannedSkill[] = []; const collisions: string[] = []; const seen = new Set(); for (const pkg of packages) { for (const skill of pkg.skills) { const name = skill.definition.name; if (seen.has(name)) { collisions.push(`${name} (also in ${pkg.name}@${pkg.version}, not written)`); continue; } seen.add(name); const ceiling = ceilingForDefinition(skill.definition); const notes: string[] = []; let withheld: WithholdReason | null = null; let note = PLACEHOLDER; if (ceiling.undeclared) withheld = "undeclared"; else if (ceiling.patterns.length > 0) { withheld = "pattern"; note = patternNote(ceiling.patterns); notes.push( `declares ${ceiling.patterns.join(", ")} — pi's --tools matches whole tool names only, so a ` + `sub-tool pattern is refused rather than reinterpreted (granting the bare tool would widen it)`, ); } skills.push({ name, from: `${pkg.name}@${pkg.version}`, sourcePath: skill.path, targetPath: skill.referenced ? skill.path : join(piProjectDir(cwd), "skills", name, "SKILL.md"), referenced: skill.referenced, content: withPlaceholder(skill.text, withheld === null, note), ceiling: ceiling.capabilities, withheld, notes, }); } } // ADR-0029: split what the declared ceilings ask for into what `init` grants live and what it comments. const declared = skills.filter((s) => s.withheld === null); const withheldCapabilities = new Map(); for (const skill of declared) { for (const capability of skill.ceiling) { if (isLiveByDefault(capability)) continue; // A routing destination is withheld but does NOT belong in this map, and the difference is not // cosmetic. This map drives two things: the "WITHHELD BY DEFAULT — these can change your machine" // block, and `/grants init`'s dialog. Review found a `workspace:` id reaching both — described to the // operator with `tool:bash`'s rationale (routing does not change your machine, and unlike `bash` it // *is* gateable), and then granted **live and persisted** on one "Yes", off a third-party package's // declaration. That is the rule `grant-env.ts` states — "does not become live because a package asked // for it" — honoured by the rendered file and broken by the dialog beside it: two surfaces of one // command disagreeing, which is R-28's shape inside the fix for R-28. // // Which worktree a child starts in is not derivable from a declaration (ADR-0028), so `init` lists // routing and never grants it. `routableWorkspaces` below is where these go; the definition that // declared one still loses its live `agent:` id via the `needs-withheld` pass, so nothing becomes // spawnable behind the operator's back either. if (capability.startsWith("workspace:")) continue; withheldCapabilities.set(capability, [...(withheldCapabilities.get(capability) ?? []), skill.name]); } } // A definition needing a withheld capability does not get a live `agent:` id either: authorising it to run // and then refusing it at spawn time is a worse answer than not authorising it, and it would put an // `agent:` id in the grant whose definition cannot work — the shape ADR-0028 rule 3 already refuses. for (const skill of declared) { if (skill.ceiling.some((c) => !isLiveByDefault(c))) skill.withheld = "needs-withheld"; } const authorised = skills.filter((s) => s.withheld === null); const written = new Set(authorised.map((s) => s.name)); // `agent:` in a ceiling is legitimate — it is how a delegator learns which definitions IT may // spawn — but a name `init` did not write here would authorise a file from another skill root that the // operator is not reviewing, including `~/.pi/agent/skills`, which other tools install into. Reported, // never granted: the same objection ADR-0028 rule 3 makes to authorising an undeclared skill. const crossReferences: { from: string; capability: Capability }[] = []; const live = new Set([ALWAYS_LIVE]); // **A skill held back for needing a withheld tool still contributes its `context:` modes.** // // Reported by the operator and measured: `live` was built from `authorised` alone, so a definition withheld // for declaring `write` contributed nothing at all — including a `context:files` that is not withheld by // anything. With `principal-pi-skills` that stayed invisible because the two read-only definitions supply // `context:summary`, which subsumes `files`. Remove those two, or install a package whose only // context-declaring definitions need a tool, and the generated grant has no `context:` entry at all. The // operator then uncomments `tool:write`, the definition becomes spawnable, and its handoff is refused by a // capability that never appeared in the file they were editing — or in the withheld block beside it. // // Granting it is safe in a way granting the tool is not, and this is grant-env's own rule rather than a new // one: a capability that can change the machine or execute does not become live because a package asked for // it, and everything else does. Holding `context:files` runs nothing and changes nothing; it permits handing // context to a child that has declared it. `context:fork` is in `DEFAULT_GATED`, so `isLiveByDefault` // already keeps it out, which is why this can be a namespace pass rather than a list. // // Only `needs-withheld`: an `undeclared` definition has no ceiling to read, and a `pattern` one had its // declaration refused wholesale rather than narrowed. for (const skill of skills) { if (skill.withheld !== "needs-withheld") continue; for (const capability of skill.ceiling) if (capability.startsWith("context:") && isLiveByDefault(capability)) live.add(capability); } for (const skill of authorised) { live.add(agentCapability(skill.name)); for (const capability of skill.ceiling) { if (capability.startsWith("agent:") && !written.has(capability.slice("agent:".length))) { crossReferences.push({ from: skill.name, capability }); continue; } live.add(capability); } } const grant = [...live].sort(); const cautions = authorised.flatMap((s) => unknownToolIds(s.ceiling).map( (c) => `${s.name} declares ${c}, which pi 0.84.1 has no tool for — unless an extension provides it, ` + `spawning ${s.name} is refused as an unknown capability`, ), ); // R-78's structural backstop. Throws rather than rendering something a shell could read as more than a // value, so a gap in the per-entry whitelist costs a refusal instead of an injection. assertGrantIsWritable(grant); const describe: Record string> = { undeclared: (s) => "declares no `allowed-tools` — fill it in, then add `agent:" + s.name + "` below", pattern: (s) => s.notes.join("; "), "needs-withheld": (s) => `needs ${s.ceiling.filter((c) => !isLiveByDefault(c)).join(", ")}, withheld by default — see below`, }; const grantEnvSkills: GrantEnvSkill[] = skills.map((s) => ({ name: s.name, ceiling: s.ceiling, ...(s.withheld ? { unspawnable: describe[s.withheld](s) } : {}), })); // Registry ids the operator could route to, plus any a copied definition actually declares — a package // naming `workspace:prod` is evidence that id matters here, and it must still be uncommented by hand. const declaredWorkspaces = skills.flatMap((s) => s.ceiling.filter((c) => c.startsWith("workspace:"))); const routableWorkspaces = [ ...new Set([...registeredWorkspaceIds.map(workspaceCapability), ...declaredWorkspaces]), ].sort(); return { skills, collisions, grant, withheldCapabilities, routableWorkspaces, stateDir: projectStateDir(cwd), settingsPath: projectSettingsPath(cwd), gitignorePath: projectGitignorePath(cwd), settingsContent: renderProjectSettings({ skills: grantEnvSkills, live: grant, withheld: withheldCapabilities, withheldDefinitions: skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name), crossReferences, cautions, routableWorkspaces, }), cautions, }; } export interface InitOutcome { written: string[]; /** Present already, so left alone. `init` never silently overwrites an edited ceiling. */ kept: string[]; failed: { path: string; error: string }[]; } /** * Create a file only if nothing is there, and **never through a symlink**. * * Both properties come from `O_CREAT|O_EXCL` (`flag: "wx"`), and both were defects in the first version * (R-79). It probed for existence with `readFile` and then called `writeFile`: * * - `readFile` conflates *unreadable* with *absent*, so an operator's `SKILL.md` with restrictive * permissions was reported `wrote` and their narrowed `allowed-tools: Read` was replaced by the * package's wider one — **without `--force`**, falsifying this package's own documented "Kept" rule. * A FIFO at the target path hung the probe forever, with no timeout anywhere in the path. * - `writeFile` follows symlinks, so a dangling symlink at a target path created the file at the link's * destination, outside the project, while reporting an in-project path. That is **B-I6**, which * `approval-store.ts` already fixed for the approval store under ADR-0014 — a new writer in the same * package reintroducing a defect the package documents as closed, and whose comment says in so many * words *"never through a symlink"*. * * `wx` fails with `EEXIST` on anything at the path, including a dangling symlink, so there is no probe, no * race between the probe and the write, and no way to write through a link. */ async function createUnlessPresent(path: string, content: string, outcome: InitOutcome): Promise { try { await mkdir(join(path, ".."), { recursive: true }); const handle = await open(path, "wx"); await runWithFinalizers( () => handle.writeFile(content, "utf8"), [{ label: "new-file handle cleanup failed", run: () => handle.close() }], ); outcome.written.push(path); } catch (error) { if ((error as { code?: string }).code === "EEXIST") outcome.kept.push(path); else outcome.failed.push({ path, error: error instanceof Error ? error.message : String(error) }); } } /** Replace a file, unlinking first so a symlink is REPLACED rather than written through. */ async function replace(path: string, content: string, outcome: InitOutcome): Promise { try { await mkdir(join(path, ".."), { recursive: true }); // `rm` unlinks the LINK, never its target — which is exactly the semantics `--force` should have. await rm(path, { force: true }); await writeFile(path, content, { encoding: "utf8", flag: "wx" }); outcome.written.push(path); } catch (error) { outcome.failed.push({ path, error: error instanceof Error ? error.message : String(error) }); } } /** * Apply a plan. * * **Existing files are kept, not overwritten**, and that default is load-bearing rather than polite: the * edit an operator makes to one of these files IS the capability decision, and the second run of a * scaffolding command is exactly when it would be destroyed. * * **`--force` never regenerates `settings.json`** (R-79). It rewrites the definition copies, which is the * documented re-sync path for R-74 — but the grant file is the *reviewed artifact*, and an operator who had * deleted `agent:build` and added a ledger path would have had both silently restored to generated defaults * by a command whose usage text mentions only `allowed-tools`. Deleting the file is how to regenerate it, * and that is not something anyone does by accident. */ /** * Whether git would ignore `settings.json` anyway — the common case, because most projects' root `.gitignore` * lists `.pi/`, and git never descends into an ignored directory, so the nested `!settings.json` cannot * re-include it (ADR-0076 PR 3c review finding). `true` = ignored, `false` = tracked-or-untracked, `null` = * not a git checkout or git unavailable; the caller only warns on `true`. */ export async function settingsIgnoredByGit(settingsPath: string): Promise { try { const { status } = spawnSync( "git", ["-C", dirname(dirname(dirname(settingsPath))), "check-ignore", "-q", "--", settingsPath], { stdio: "ignore", timeout: 5000, }, ); if (status === 0) return true; if (status === 1) return false; return null; // 128: not a repository, or git refused } catch { return null; } } /** The two lines an operator adds to the root `.gitignore` so the reviewable record is committable. */ export const GITIGNORE_REINCLUDE_LINES = [ `!${PI_PROJECT_DIR}/`, `!${PI_PROJECT_DIR}/${PROJECT_STATE_DIRNAME}/${PROJECT_FILES.settings}`, ]; export async function applyInit(plan: InitPlan, options: { force?: boolean } = {}): Promise { const outcome: InitOutcome = { written: [], kept: [], failed: [] }; // pi's `.pi` first, then our owner-only state directory inside it. Existing operator/Pi state is never // chmodded, followed through a symlink, or reused if it is not a directory. for (const dir of [dirname(plan.stateDir), plan.stateDir]) { try { await mkdir(dir, { mode: 0o700 }); const created = await lstat(dir); if (!created.isDirectory() || created.isSymbolicLink()) throw Error("private state directory creation failed"); } catch (error) { if ((error as { code?: string }).code !== "EEXIST") { outcome.failed.push({ path: dir, error: error instanceof Error ? error.message : String(error) }); return outcome; } const existing = await lstat(dir); if (existing.isSymbolicLink() || !existing.isDirectory()) { outcome.failed.push({ path: dir, error: "existing state path is not a directory; it was not followed or changed", }); return outcome; } } } const force = options.force === true; for (const skill of plan.skills) { if (skill.referenced) continue; if (force) await replace(skill.targetPath, skill.content, outcome); else await createUnlessPresent(skill.targetPath, skill.content, outcome); } await createUnlessPresent(plan.settingsPath, plan.settingsContent, outcome); await createUnlessPresent(plan.gitignorePath, PROJECT_GITIGNORE_CONTENT, outcome); return outcome; }