pi-daddy 0.13.0 → 0.15.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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `/grants init` — the one command in this package that writes.
3
+ *
4
+ * Split out of `extensions/grants.ts` when the file-size guard refused it, and the seam is the right one:
5
+ * `grants.ts` is wiring, and this is a decision procedure that asks a human questions and stores the
6
+ * answer. Keeping it here means the file that registers hooks stays readable, which is the property the
7
+ * guard exists to defend — every wiring bug this package has had lived in that file.
8
+ */
9
+
10
+ import { discoverSkillPackages } from "../src/skill-packages.ts";
11
+ import { applyInit, planInit } from "../src/init.ts";
12
+ import { saveGrant, grantStorePath } from "../src/grant-store.ts";
13
+ import { expandSubsumed, SUBSUMPTION, type Capability } from "../src/resolve.ts";
14
+ import type { GrantsSession } from "./session.ts";
15
+
16
+ /**
17
+ * `/grants init` — scaffold, ask about what is withheld, store it outside the workspace, apply it now.
18
+ *
19
+ * **The dialog covers the withheld capabilities and nothing else** (ADR-0030). Asking about all of them
20
+ * would be a dozen questions for a first run, and this project has a name for what that produces: R-25,
21
+ * where the operator learns to click through and the control becomes decorative. The read-only capabilities
22
+ * a skill declares are already bounded by the ceiling its author wrote and by pi's `--tools`; the ones that
23
+ * can change the machine are the decision, so they are the question.
24
+ *
25
+ * A refusal is not a failure. Answering *no* to `tool:bash` leaves four definitions unspawnable and says so
26
+ * — that is the same outcome `pi-daddy init` writes by default, reached deliberately rather than by
27
+ * omission.
28
+ */
29
+ export async function runInit(
30
+ session: GrantsSession,
31
+ ctx: any,
32
+ /**
33
+ * Reload definitions and the catalog, and re-describe the delegation tools.
34
+ *
35
+ * **Without this the grant goes live and the definitions do not** — `session.definitions` and the catalog
36
+ * are read at `session_start`, which is before `init` wrote a single file, so a session would hold
37
+ * `agent:review` while believing no definition of that name exists. `/grants` showed `0 skill,
38
+ * 0 agent-type` and no verdicts, and the model would have been told `Available: none` — R-39 exactly,
39
+ * reintroduced by a feature whose whole selling point is "no restart". Found by running it.
40
+ */
41
+ refresh: () => Promise<void>,
42
+ ): Promise<void> {
43
+ const packages = await discoverSkillPackages(ctx.cwd);
44
+ if (packages.length === 0) {
45
+ ctx.ui.notify(
46
+ "grants: no packages declaring skills found in node_modules. Install one — e.g. " +
47
+ "`npm i principal-pi-skills` — then run /grants init again.",
48
+ "warning",
49
+ );
50
+ return;
51
+ }
52
+
53
+ const plan = planInit(packages, ctx.cwd);
54
+ const outcome = await applyInit(plan);
55
+ const lines = [
56
+ `grants: ${plan.skills.length} definition(s) from ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`,
57
+ ` wrote ${outcome.written.length}, kept ${outcome.kept.length} already present` +
58
+ `${outcome.failed.length > 0 ? `, ${outcome.failed.length} FAILED` : ""}`,
59
+ ];
60
+ for (const f of outcome.failed) lines.push(` ${f.path}: ${f.error}`);
61
+
62
+ // The grant `init` would have written to `.pi/grants.env`: read-only, nothing that can change the
63
+ // machine. Everything below is added to it only by an explicit yes.
64
+ const grant = new Set<Capability>(plan.grant);
65
+ const granted: string[] = [];
66
+ const declined: string[] = [];
67
+ /** Withheld capabilities a previous *yes* already conferred, so no question was asked about them. */
68
+ const alreadyConferred: string[] = [];
69
+
70
+ for (const [capability, neededBy] of plan.withheldCapabilities) {
71
+ // **Do not ask a question whose answer cannot matter.** `tool:bash` subsumes `write`, `edit` and
72
+ // `edit-diff` (`SUBSUMPTION`, `src/resolve.ts`), so once bash is granted those are already conferred.
73
+ // The first version asked anyway: an operator could answer *no* to `tool:write`, watch `/grants` allow
74
+ // `build` with `tool:write`, and reasonably conclude the dialog was decorative. It was — that is R-47's
75
+ // shape, a control that appears to do something and does not, inside a control built to prevent it.
76
+ //
77
+ // Reported rather than silently skipped, because "you already granted this" is the useful sentence and
78
+ // silence is what made it confusing.
79
+ if (expandSubsumed([...grant]).includes(capability)) {
80
+ alreadyConferred.push(`${capability} (via ${subsumedBy([...grant], capability) ?? "a granted capability"})`);
81
+ for (const name of neededBy) grant.add(`agent:${name}` as Capability);
82
+ continue;
83
+ }
84
+ const answer = await ctx.ui.select(
85
+ `grants: grant ${capability} to sub-agents?\n needed by: ${neededBy.join(", ")}\n` +
86
+ ` this can change your machine — ${capability === "tool:bash" ? "and bash also confers write and edit" : "it is not gated, so no dialog at spawn time"}`,
87
+ ["No", "Yes"],
88
+ );
89
+ if (answer === "Yes") {
90
+ grant.add(capability);
91
+ granted.push(capability);
92
+ // The `agent:` ids too: a definition authorised but unable to receive what it declares would be
93
+ // allowed to run and then refused, which is a worse answer than not being authorised (ADR-0029).
94
+ for (const name of neededBy) grant.add(`agent:${name}` as Capability);
95
+ } else {
96
+ declined.push(`${capability} (${neededBy.join(", ")})`);
97
+ }
98
+ }
99
+
100
+ const finalGrant = [...grant].sort();
101
+ const saved = await saveGrant(ctx.cwd, finalGrant);
102
+ if (saved !== "saved") {
103
+ lines.push(
104
+ ` NOT STORED — ${saved === "busy" ? "another session holds the grant store" : "the store could not be written"}. ` +
105
+ `Nothing was changed; this session's grant is unchanged. Retry, or export PI_GRANTS_GRANT yourself.`,
106
+ );
107
+ ctx.ui.notify(lines.join("\n"), "error");
108
+ return;
109
+ }
110
+
111
+ // Live, without a restart — the whole point of the store. Only after a successful write, so what runs and
112
+ // what is recorded cannot disagree.
113
+ session.adoptGrant(finalGrant);
114
+ // Order matters: the grant first, then the reload, because `refreshSpawnable` filters the definitions it
115
+ // advertises through `maySpawnDefinition` against the grant the session now holds.
116
+ await refresh();
117
+
118
+ lines.push(` stored at ${grantStorePath(ctx.cwd)} — outside this project, so no child can rewrite it`);
119
+ if (granted.length > 0) lines.push(` GRANTED: ${granted.join(", ")}`);
120
+ if (alreadyConferred.length > 0) {
121
+ lines.push(` ALREADY CONFERRED, not asked about: ${alreadyConferred.join(", ")}`);
122
+ }
123
+ if (declined.length > 0) lines.push(` withheld: ${declined.join("; ")}`);
124
+ lines.push(` live now (${finalGrant.length} capabilities) — no restart. /grants shows the verdicts.`);
125
+ ctx.ui.notify(lines.join("\n"), "info");
126
+ }
127
+
128
+ /** Which held capability confers `capability`, for a message that names the cause rather than the effect. */
129
+ function subsumedBy(held: Capability[], capability: Capability): Capability | null {
130
+ for (const h of held) if ((SUBSUMPTION[h] ?? []).includes(capability)) return h;
131
+ return null;
132
+ }
@@ -37,6 +37,9 @@ import {
37
37
  parseList,
38
38
  } from "../src/propagation.ts";
39
39
  import type { Capability } from "../src/resolve.ts";
40
+ import { loadDefinitions } from "../src/definitions.ts";
41
+ import { buildCatalog } from "../src/catalog.ts";
42
+ import { loadGrantSync, grantStorePath } from "../src/grant-store.ts";
40
43
  import { republishable } from "./approvals.ts";
41
44
 
42
45
  /**
@@ -54,8 +57,17 @@ export const ENV_HERDR_WORKSPACE = "PI_GRANTS_HERDR_WORKSPACE";
54
57
  export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
55
58
 
56
59
  export interface GrantsSession {
57
- /** False when `PI_GRANTS_GRANT` is unset: the session holds the wildcard and nothing is governed. */
58
- readonly governed: boolean;
60
+ /**
61
+ * False when neither `PI_GRANTS_GRANT` nor a stored grant applies: the session holds the wildcard and
62
+ * nothing is governed.
63
+ *
64
+ * **Mutable, because `/grants init` makes an ungoverned session governed mid-run.** The first version of
65
+ * ADR-0030 left this readonly on the reasoning that a store is read at creation and `init` only runs where
66
+ * one exists — which is false for the first `init` in a directory, the most common case there is. The
67
+ * session then bounded every spawn by the new grant while `/grants` reported "inactive", so the status
68
+ * line contradicted the enforcer. Found by running it.
69
+ */
70
+ governed: boolean;
59
71
  /** The upper bound handed down by the delegator, before this session's own tools are observed. */
60
72
  readonly inherited: Capability[];
61
73
  readonly depth: number;
@@ -129,6 +141,23 @@ export interface GrantsSession {
129
141
  * prompted the human. A value scoped to one specific child is never written to this global channel.
130
142
  */
131
143
  publishChildEnv(): void;
144
+ /**
145
+ * The directory whose stored grant this session read, or would read. `process.cwd()` — see the note in
146
+ * `createGrantsSession` for why the factory cannot use `ctx.cwd`.
147
+ */
148
+ readonly storeCwd: string;
149
+ /**
150
+ * Adopt a grant decided DURING the session — `/grants init` answering a human — without a restart.
151
+ *
152
+ * Narrow by design: it sets the session's own grant and republishes, so the very next spawn is bounded by
153
+ * it. It does **not** reach children that already exist; those are separate processes whose environment
154
+ * was fixed when they started, and reaching into them is neither possible nor desirable — a child's
155
+ * ceiling should not move under it mid-run.
156
+ *
157
+ * Only a human can reach this. Slash commands are user-invoked; no tool exposes it, so a model cannot
158
+ * widen its own session's ceiling by calling something.
159
+ */
160
+ adoptGrant(grant: Capability[]): void;
132
161
  }
133
162
 
134
163
  /**
@@ -138,12 +167,40 @@ export interface GrantsSession {
138
167
  * extension**, so a child granted `tool:delegate` can be started with `-e <that file>`. `grants.ts` is that
139
168
  * file, and only `grants.ts` can say so about itself.
140
169
  */
170
+ /**
171
+ * Load this project's definitions and capability catalog into the session.
172
+ *
173
+ * **One loader, two callers.** `session_start` runs it, and so does `/grants init` — which writes the very
174
+ * files it reads, so a session that skipped this held `agent:review` while believing no definition of that
175
+ * name existed, and the model was told `Available: none` (R-39's shape, reintroduced by the feature whose
176
+ * selling point is "no restart"). Two copies of these three steps is how the two callers come to disagree
177
+ * about what loading means, so there is one.
178
+ */
179
+ export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
180
+ session.definitions = await loadDefinitions(cwd);
181
+ session.catalogReady = buildCatalog({ cwd, observedTools: session.observedTools });
182
+ session.catalog = await session.catalogReady;
183
+ }
184
+
141
185
  export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
142
- // Governance is opt-in: with PI_GRANTS_GRANT unset the session holds the wildcard and nothing is
143
- // blocked. This extension must never silently tighten a normal workflow.
186
+ // Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
187
+ // holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
188
+ // workflow.
189
+ //
190
+ // **Two sources, and the environment always wins** (ADR-0030). The variable is how a CHILD is governed
191
+ // and how CI is configured, so a store that could override it would let a directory quietly widen or
192
+ // narrow a child its parent had already bounded. The store is consulted only when the variable is absent,
193
+ // which is exactly the case it was added for: a human at a terminal who ran `/grants init` here.
194
+ //
195
+ // `process.cwd()` rather than `ctx.cwd`, because this runs in the extension factory — before any hook,
196
+ // and therefore before `ctx` exists. That ordering is forced by S-5: whether `delegate` is registered at
197
+ // all is decided here, and a grant arriving later could not inform it. `session_start` re-checks the two
198
+ // against each other and says so if they differ, which is the only case this can get wrong.
144
199
  const grantRaw = process.env[ENV_GRANT];
145
- const governed = grantRaw !== undefined;
146
- const inherited: Capability[] = governed ? parseList(grantRaw) : [WILDCARD];
200
+ const storeCwd = process.cwd();
201
+ const stored = grantRaw === undefined ? loadGrantSync(storeCwd) : null;
202
+ const governed = grantRaw !== undefined || stored !== null;
203
+ const inherited: Capability[] = grantRaw !== undefined ? parseList(grantRaw) : (stored ?? [WILDCARD]);
147
204
  // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
148
205
  // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
149
206
  const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
@@ -214,6 +271,18 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
214
271
  ...(approved ? { approved } : {}),
215
272
  }),
216
273
 
274
+ storeCwd,
275
+
276
+ adoptGrant: (grant: Capability[]) => {
277
+ // Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is
278
+ // governed from that moment: every spawn is bounded by what was just stored. Leaving this false made
279
+ // `/grants` print "inactive" while holding thirteen capabilities — a status line contradicting the
280
+ // enforcer, which is the defect R-28 is named for.
281
+ session.governed = true;
282
+ session.ownGrant = grant;
283
+ session.publishChildEnv();
284
+ },
285
+
217
286
  publishChildEnv: () => {
218
287
  const env = childEnv({
219
288
  ownGrant: session.ownGrant,
@@ -0,0 +1,214 @@
1
+ /**
2
+ * "What can this session actually spawn?" — answered at session start, out loud (B1, P4).
3
+ *
4
+ * The startup line reported the *grant* and nothing else: `holding [agent:review, tool:read, …]`. An
5
+ * operator who has just installed a package of `SKILL.md` definitions cannot tell from that whether the
6
+ * install worked, whether their grant names the right ids, or whether anything at all is spawnable — and
7
+ * the failure they are most likely to be in (a definition declaring no `allowed-tools`, so it is discovered
8
+ * and refused) looks exactly like the success. **The withheld half is the important half**: it is the
9
+ * difference between "governance is working" and "did the install fail?".
10
+ *
11
+ * **Decided by the real planner, and — since a reviewer caught it — no longer re-classified afterwards.**
12
+ * The first version read two fields of `plan.result` and invented a category from them, which is the very
13
+ * thing this header claimed to have made inexpressible: `planDelegation` has six refusals that leave both
14
+ * fields empty, so a session at its depth limit, or one with a malformed `PI_GRANTS_MAX_DEPTH`, was told its
15
+ * **files** were written wrong while `/grants` in the same session said "delegation is disabled (maxDepth
16
+ * 0)". That is R-28's shape inside the fix for R-28's shape. The planner's own `reason` is now printed for
17
+ * anything the two designated signals do not explain.
18
+ *
19
+ * **What this does not establish.** It runs at `session_start`, before the first provider request, so the
20
+ * grant it classifies against is the one *inherited*; `deriveOwnGrant` narrows it to the observed tool
21
+ * surface only when a request is made, and a definition counted spawnable here can be refused afterwards if
22
+ * its ceiling names a tool this session turns out not to have (R-75, measured live). It is an upper bound,
23
+ * and `/grants` run after any request is the settled answer.
24
+ */
25
+
26
+ import type { SkillDefinition } from "../src/definitions.ts";
27
+ import type { GatedPlan } from "./run-delegation.ts";
28
+
29
+ /** Why a definition is not spawnable right now. Three causes, three different fixes. */
30
+ export type WithheldReason =
31
+ /** The grant lacks `agent:<name>`, or lacks a tool the ceiling declares. Fix: widen `PI_GRANTS_GRANT`. */
32
+ | "capability"
33
+ /** Everything is held, but a gated capability needs a human yes first. Fix: spawn it and answer. */
34
+ | "approval"
35
+ /** Anything else the planner refused — the file, an unknown capability, an unresolvable skill. */
36
+ | "declaration";
37
+
38
+ export interface WithheldDefinition {
39
+ name: string;
40
+ reason: WithheldReason;
41
+ /** The capabilities that caused it, when the planner named any. */
42
+ missing: string[];
43
+ /** The planner's own words, used verbatim when the two designated signals do not explain the refusal. */
44
+ reasonText?: string;
45
+ }
46
+
47
+ export interface SpawnableSummary {
48
+ spawnable: string[];
49
+ withheld: WithheldDefinition[];
50
+ /**
51
+ * Set when NOTHING can be spawned for a reason about the SESSION rather than about any definition — no
52
+ * `tool:delegate`, or a depth bound that forbids spawning. Per-definition work is skipped entirely.
53
+ */
54
+ sessionBlocked?: string;
55
+ /** Definitions beyond `PREVIEW_LIMIT`, counted but not classified. Stated, never silently dropped. */
56
+ notChecked: number;
57
+ }
58
+
59
+ /** Names listed per clause before the rest are counted instead. Whatever is dropped is stated (R-48). */
60
+ const NAMES_SHOWN = 8;
61
+
62
+ /**
63
+ * How many definitions the startup line classifies.
64
+ *
65
+ * `/grants` has had the same cap from the start, with the comment *"Each one runs the real planner, so this
66
+ * bounds work, not truth"*; this path removed it and put the work on the blocking `session_start` hook.
67
+ * Measured by a reviewer: 1000 definitions against 1000 stored approvals cost **1.66s at every session
68
+ * start**, because a gate-blocked definition re-reads and re-verifies the whole approvals file. 50
69
+ * definitions cost 7ms, which is the real world — but a bound that only holds for the real world is not a
70
+ * bound, and this one is paid by every governed child too, including `--print` children that discard the
71
+ * output.
72
+ */
73
+ const PREVIEW_LIMIT = 24;
74
+
75
+ export interface SessionFacts {
76
+ /** False when the grant omits `tool:delegate`: no `delegate` tool is registered at all (S-5). */
77
+ mayDelegate: boolean;
78
+ depth: number;
79
+ maxDepth: number;
80
+ }
81
+
82
+ /**
83
+ * Classify every discovered definition by running the real planner over it.
84
+ *
85
+ * `preview` is `planWithApprovals(session, {agent: name, …}, {}, null)` — the enforcing path minus the one
86
+ * thing a startup line must not do, which is ask a human. Stored approvals count exactly as they would for
87
+ * a spawn, so a definition covered by a standing 30-day yes is reported spawnable, which is what it is.
88
+ */
89
+ export async function summariseSpawnable(
90
+ definitions: Map<string, SkillDefinition>,
91
+ preview: (name: string) => Promise<GatedPlan>,
92
+ session: SessionFacts,
93
+ ): Promise<SpawnableSummary> {
94
+ const names = [...definitions.keys()].sort();
95
+
96
+ // Two session-level facts make every per-definition verdict identical and misleading, so they are
97
+ // answered before any planning happens. Previewing N definitions to print N copies of one environment
98
+ // problem is both wrong and wasteful.
99
+ //
100
+ // `mayDelegate` is the one a reviewer caught: `registerDelegationTools` returns early without it, so the
101
+ // session has NO `delegate` tool and can spawn nothing — while this line said `1 of 3 spawnable`. That is
102
+ // the exact question the line exists to answer, answered wrong in the one configuration where nothing can
103
+ // ever run. Not R-75: no later event changes it, and it is wrong from the first millisecond.
104
+ if (!session.mayDelegate) {
105
+ return {
106
+ spawnable: [],
107
+ withheld: [],
108
+ notChecked: 0,
109
+ sessionBlocked:
110
+ `this session holds no tool:delegate, so it has no delegate tool at all — nothing can be spawned, ` +
111
+ `whatever any definition declares. Add tool:delegate to PI_GRANTS_GRANT to make this session a ` +
112
+ `delegator rather than a leaf.`,
113
+ };
114
+ }
115
+ if (session.maxDepth <= 0 || session.depth + 1 > session.maxDepth) {
116
+ return {
117
+ spawnable: [],
118
+ withheld: [],
119
+ notChecked: 0,
120
+ sessionBlocked:
121
+ session.maxDepth <= 0
122
+ ? `spawning is disabled for this session (max depth ${session.maxDepth}), so no definition can ` +
123
+ `run whatever its file says. If you did not set PI_GRANTS_MAX_DEPTH to 0, check the warning ` +
124
+ `above: a malformed value disables spawning deliberately.`
125
+ : `this session is at its depth limit (${session.depth} of ${session.maxDepth}), so it may not ` +
126
+ `spawn — a definition refused here is not a problem with its file.`,
127
+ };
128
+ }
129
+
130
+ const spawnable: string[] = [];
131
+ const withheld: WithheldDefinition[] = [];
132
+
133
+ for (const name of names.slice(0, PREVIEW_LIMIT)) {
134
+ const { plan } = await preview(name);
135
+ if (plan.ok) {
136
+ spawnable.push(name);
137
+ continue;
138
+ }
139
+ // Read off the plan's own result, in the order `planDelegation` decides them: an escalation is reported
140
+ // before a gate, because a capability the session does not hold cannot be approved into existence.
141
+ // `denied` carries the ADR-0017 authorisation refusal too — asking to run a definition this session was
142
+ // not granted IS an attempt to exceed the grant, and it is recorded as one.
143
+ const denied = plan.result.denied;
144
+ const gated = plan.result.gatedBlocked;
145
+ if (denied.length > 0) withheld.push({ name, reason: "capability", missing: [...denied].sort() });
146
+ else if (gated.length > 0) withheld.push({ name, reason: "approval", missing: [...gated].sort() });
147
+ // Everything else: say what the ENFORCER said. Inventing a category here is what told an operator with
148
+ // an unknown capability, an unresolvable `skill:`, or a universal capability that their file was
149
+ // written wrong, in wording that contradicted `/grants` on the same screen.
150
+ else withheld.push({ name, reason: "declaration", missing: [], reasonText: plan.reason });
151
+ }
152
+
153
+ return { spawnable, withheld, notChecked: Math.max(0, names.length - PREVIEW_LIMIT) };
154
+ }
155
+
156
+ /** `a, b, c` — or the first few and a count, so a large skill root does not become the whole line. */
157
+ function list(names: string[]): string {
158
+ if (names.length <= NAMES_SHOWN) return names.join(", ");
159
+ return `${names.slice(0, NAMES_SHOWN).join(", ")} … and ${names.length - NAMES_SHOWN} more`;
160
+ }
161
+
162
+ /**
163
+ * A definition name is a DIRECTORY name, so it is third-party text on a line this package composes.
164
+ *
165
+ * R-77 and R-78 were both "a name from somewhere else reached a generated artefact"; a newline here forges
166
+ * a whole `grants:` line in the operator's terminal. Same class, lower stakes, same treatment: rendered
167
+ * inert rather than trusted.
168
+ */
169
+ function safeName(name: string): string {
170
+ return /[\n\r\t]/.test(name) ? JSON.stringify(name) : name;
171
+ }
172
+
173
+ /**
174
+ * Render the summary, or `null` when there is nothing to say.
175
+ *
176
+ * `null` means **no definitions were discovered at all** — a session delegating by `tools:` only, which is
177
+ * a legitimate configuration and not something to report every start. Every other case speaks, including
178
+ * "none of them is spawnable": that is P2's exact state (seven skills installed, zero declaring
179
+ * `allowed-tools`), and it is the one an operator most needs told.
180
+ */
181
+ export function renderSpawnableSummary(summary: SpawnableSummary, total: number): string | null {
182
+ if (total === 0) return null;
183
+ if (summary.sessionBlocked) {
184
+ return `grants: ${total} definition${total === 1 ? "" : "s"} found, none spawnable — ${summary.sessionBlocked}`;
185
+ }
186
+
187
+ const lines = [
188
+ `grants: ${summary.spawnable.length} of ${total} definition${total === 1 ? "" : "s"} spawnable` +
189
+ (summary.spawnable.length > 0 ? ` — ${list(summary.spawnable.map(safeName))}` : ""),
190
+ ];
191
+
192
+ // PER DEFINITION, not per group. Grouping printed the UNION of every missing capability against every
193
+ // name in the group, so a definition missing only `agent:x` was reported as needing `tool:bash` as well —
194
+ // and naming the fix is this line's whole stated purpose.
195
+ const clause = (w: WithheldDefinition): string => {
196
+ const name = safeName(w.name);
197
+ if (w.reason === "capability") return `${name} (needs ${list(w.missing)})`;
198
+ // ADR-0024: a gated `agent:` id is the PARENT's authority to run the definition now, and is deliberately
199
+ // kept out of what the child receives. "before a child receives it" was wrong for exactly that case.
200
+ if (w.reason === "approval") return `${name} (needs your approval for ${list(w.missing)})`;
201
+ return `${name} (${w.reasonText ?? "cannot be spawned as its file is written"})`;
202
+ };
203
+
204
+ if (summary.withheld.length > 0) {
205
+ const shown = summary.withheld.slice(0, NAMES_SHOWN).map(clause);
206
+ const extra = summary.withheld.length - shown.length;
207
+ lines.push(` withheld: ${shown.join("; ")}${extra > 0 ? `; … and ${extra} more` : ""}`);
208
+ }
209
+ if (summary.notChecked > 0) {
210
+ lines.push(` ${summary.notChecked} more not checked (first ${PREVIEW_LIMIT} only) — /grants lists them`);
211
+ }
212
+
213
+ return lines.join("\n");
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -84,8 +84,19 @@
84
84
  "./approval-prompt": {
85
85
  "types": "./dist/approval-prompt.d.ts",
86
86
  "default": "./dist/approval-prompt.js"
87
+ },
88
+ "./init": {
89
+ "types": "./dist/init.d.ts",
90
+ "default": "./dist/init.js"
91
+ },
92
+ "./skill-packages": {
93
+ "types": "./dist/skill-packages.d.ts",
94
+ "default": "./dist/skill-packages.js"
87
95
  }
88
96
  },
97
+ "bin": {
98
+ "pi-daddy": "./dist/cli.js"
99
+ },
89
100
  "files": [
90
101
  "dist",
91
102
  "extensions",
package/src/catalog.ts CHANGED
@@ -168,6 +168,98 @@ export function unknownCapabilities(requested: Capability[], catalog: Catalog):
168
168
  return requested.filter((c) => c !== WILDCARD && c !== AGENT_WILDCARD && !catalog.has(c)).sort();
169
169
  }
170
170
 
171
+ /**
172
+ * Tool names that exist in OTHER harnesses' vocabularies, mapped to the pi tool that does the same job.
173
+ *
174
+ * This is a hint for an error message and nothing else. `ceilingForDefinition` deliberately refuses to
175
+ * translate names — lowercasing and no more — because a translation table there would have to decide what
176
+ * `Glob` *means* and would either invent a grant or silently drop one. Naming a likely intent in the
177
+ * refusal costs nothing and keeps that property: the delegation is still refused, and the author still
178
+ * edits the file.
179
+ *
180
+ * Populated from the names an author actually reaches for. `allowed-tools` is an Agent Skills field, so
181
+ * the frontmatter people copy in is usually written against Claude Code's toolset; `Glob` is the one that
182
+ * bit a real consumer (principal-pi-skills, seven definitions), because pi's equivalent is `find`.
183
+ */
184
+ const FOREIGN_TOOL_NAMES: Readonly<Record<string, string>> = {
185
+ "tool:glob": "tool:find",
186
+ "tool:searchfiles": "tool:find",
187
+ "tool:bashtool": "tool:bash",
188
+ "tool:readfile": "tool:read",
189
+ "tool:writefile": "tool:write",
190
+ "tool:str_replace_editor": "tool:edit",
191
+ "tool:multiedit": "tool:edit",
192
+ };
193
+
194
+ /**
195
+ * Optimal string alignment distance — Levenshtein plus adjacent transposition.
196
+ *
197
+ * Transposition counts as ONE edit, not two, because it is the typo people actually make: `raed` for
198
+ * `read` is a single slip of the fingers, and plain Levenshtein scores it 2 — the same as two unrelated
199
+ * substitutions. With the threshold this small, that difference is the whole feature.
200
+ */
201
+ function editDistance(a: string, b: string): number {
202
+ // Three rows, because a transposition needs the row before last.
203
+ const rows: number[][] = [
204
+ Array.from({ length: b.length + 1 }, (_, j) => j),
205
+ new Array<number>(b.length + 1).fill(0),
206
+ new Array<number>(b.length + 1).fill(0),
207
+ ];
208
+ let twoBack = rows[2];
209
+ let prev = rows[0];
210
+ let cur = rows[1];
211
+
212
+ for (let i = 1; i <= a.length; i++) {
213
+ cur[0] = i;
214
+ for (let j = 1; j <= b.length; j++) {
215
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
216
+ let d = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
217
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
218
+ d = Math.min(d, twoBack[j - 2] + 1);
219
+ }
220
+ cur[j] = d;
221
+ }
222
+ const spent = twoBack;
223
+ twoBack = prev;
224
+ prev = cur;
225
+ cur = spent;
226
+ }
227
+ return prev[b.length];
228
+ }
229
+
230
+ /**
231
+ * The capability an unknown one was most likely meant to be, or null when nothing is close enough.
232
+ *
233
+ * Two sources, in order. A known foreign name wins outright — `Glob` is not a typo for `find`, so no
234
+ * distance metric would ever connect them, and that is exactly the case worth naming. Otherwise the
235
+ * nearest catalog entry within a small edit distance, which catches `raed`/`serach` and stops well short
236
+ * of guessing: the threshold scales with the name's length and never exceeds two.
237
+ */
238
+ export function suggestForUnknown(unknown: Capability, catalog: Catalog): Capability | null {
239
+ const foreign = FOREIGN_TOOL_NAMES[unknown.toLowerCase()];
240
+ if (foreign && catalog.has(foreign)) return foreign;
241
+
242
+ // Only among capabilities of the same namespace: suggesting `skill:review` for a mistyped tool name
243
+ // would be a worse message than none, because it points the author at the wrong kind of fix.
244
+ const ns = unknown.slice(0, unknown.indexOf(":") + 1);
245
+ if (!ns) return null;
246
+ const bare = unknown.slice(ns.length);
247
+ const limit = Math.min(2, Math.floor(bare.length / 3));
248
+ if (limit < 1) return null;
249
+
250
+ let best: Capability | null = null;
251
+ let bestDistance = limit + 1;
252
+ for (const candidate of catalog.all) {
253
+ if (!candidate.startsWith(ns)) continue;
254
+ const d = editDistance(bare, candidate.slice(ns.length));
255
+ if (d < bestDistance) {
256
+ bestDistance = d;
257
+ best = candidate;
258
+ }
259
+ }
260
+ return bestDistance <= limit ? best : null;
261
+ }
262
+
171
263
  /**
172
264
  * Skill name -> absolute path, for `planSpawn`'s `--skill` flags (R-32).
173
265
  *