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.
- package/CHANGELOG.md +113 -0
- package/README.md +113 -7
- package/dist/catalog.d.ts +9 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +90 -0
- package/dist/catalog.js.map +1 -1
- package/dist/cli.d.ts +31 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +234 -0
- package/dist/cli.js.map +1 -0
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +14 -2
- package/dist/delegate.js.map +1 -1
- package/dist/grant-env.d.ts +73 -0
- package/dist/grant-env.d.ts.map +1 -0
- package/dist/grant-env.js +131 -0
- package/dist/grant-env.js.map +1 -0
- package/dist/grant-store.d.ts +68 -0
- package/dist/grant-store.d.ts.map +1 -0
- package/dist/grant-store.js +142 -0
- package/dist/grant-store.js.map +1 -0
- package/dist/init.d.ts +117 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +286 -0
- package/dist/init.js.map +1 -0
- package/dist/skill-packages.d.ts +128 -0
- package/dist/skill-packages.d.ts.map +1 -0
- package/dist/skill-packages.js +245 -0
- package/dist/skill-packages.js.map +1 -0
- package/extensions/grants-command.ts +31 -0
- package/extensions/grants.ts +57 -5
- package/extensions/init-command.ts +132 -0
- package/extensions/session.ts +75 -6
- package/extensions/spawn-summary.ts +214 -0
- package/package.json +12 -1
- package/src/catalog.ts +92 -0
- package/src/cli.ts +266 -0
- package/src/delegate.ts +14 -2
- package/src/grant-env.ts +188 -0
- package/src/grant-store.ts +151 -0
- package/src/init.ts +344 -0
- package/src/skill-packages.ts +282 -0
|
@@ -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
|
+
}
|
package/extensions/session.ts
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
|
|
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
|
|
143
|
-
// blocked. This extension must never silently tighten a normal
|
|
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
|
|
146
|
-
const
|
|
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.
|
|
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
|
*
|