pi-daddy 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/README.md +105 -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 +228 -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/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 +112 -0
- package/dist/skill-packages.d.ts.map +1 -0
- package/dist/skill-packages.js +216 -0
- package/dist/skill-packages.js.map +1 -0
- package/extensions/grants.ts +30 -0
- package/extensions/spawn-summary.ts +214 -0
- package/package.json +12 -1
- package/src/catalog.ts +92 -0
- package/src/cli.ts +260 -0
- package/src/delegate.ts +14 -2
- package/src/grant-env.ts +188 -0
- package/src/init.ts +344 -0
- package/src/skill-packages.ts +251 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which installed npm packages ship `SKILL.md` definitions — read from their own manifests.
|
|
3
|
+
*
|
|
4
|
+
* `pi-daddy init` scaffolds a governed project from whatever skill packages are already installed, and
|
|
5
|
+
* this is how it finds them: **a package declares its skills in `package.json`'s `pi.skills` array**, which
|
|
6
|
+
* is pi's own convention and how pi itself loads them. Measured against `principal-pi-skills@2.3.1`:
|
|
7
|
+
*
|
|
8
|
+
* ```json
|
|
9
|
+
* "pi": { "skills": ["./decide", "./architect", "./plan", "./build", "./review", "./debug", "./git-ops"] }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* **A declaration, never a heuristic.** Walking `node_modules` looking for files called `SKILL.md` would
|
|
13
|
+
* find a package's test fixtures, its examples, and its vendored copies of someone else's skills — and
|
|
14
|
+
* would then offer to install them as spawnable sub-agents. A package that says which of its files are
|
|
15
|
+
* skills has said so on purpose, and that is the only list this reads.
|
|
16
|
+
*
|
|
17
|
+
* What it deliberately does NOT do: scan `~/.pi/agent/skills/`. Definitions already in a skill root are
|
|
18
|
+
* discovered by `loadDefinitions` and governed as they stand; copying them into a project would duplicate
|
|
19
|
+
* them under a name that shadows the original (project wins on collision), which is a change nobody asked
|
|
20
|
+
* for.
|
|
21
|
+
*
|
|
22
|
+
* **Everything read here comes from a third party**, so this module is also where the refusals live: a
|
|
23
|
+
* name, a declared capability id, or a path that cannot safely be written into a generated file is refused
|
|
24
|
+
* with a reason rather than passed on (R-77, R-78, R-80). `init` generates a shell file an operator
|
|
25
|
+
* `source`s; the only strings that may reach it are ones that survived a whitelist here.
|
|
26
|
+
*/
|
|
27
|
+
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
28
|
+
import { join, resolve, sep } from "node:path";
|
|
29
|
+
import { ceilingForDefinition, parseSkillDefinition } from "./definitions.js";
|
|
30
|
+
import { WILDCARD } from "./pi-tools.js";
|
|
31
|
+
import { AGENT_WILDCARD } from "./resolve.js";
|
|
32
|
+
/**
|
|
33
|
+
* May this definition's name be written into a capability id, a shell file and a path?
|
|
34
|
+
*
|
|
35
|
+
* **Measured before it was written, and it was a defect in this module's first version (R-77).** A
|
|
36
|
+
* definition's identity is its directory name, and `init` interpolates that name into three places at once:
|
|
37
|
+
* `agent:<name>` inside a **comma-separated** `PI_GRANTS_GRANT`, a `.pi/grants.env` an operator **sources**,
|
|
38
|
+
* and the path it writes the copy to. An installed package with a directory called `a,tool:bash` produced
|
|
39
|
+
*
|
|
40
|
+
* ```
|
|
41
|
+
* export PI_GRANTS_GRANT="agent:a,tool:bash,tool:delegate,tool:read"
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* — `tool:bash` in an operator's grant, declared by nobody. A quote character reaches a file that gets
|
|
45
|
+
* `source`d, and a name of `..` writes outside `.pi/skills/`. One rule closes all three, and it is
|
|
46
|
+
* deliberately a **whitelist**: the safe set here is small and the unsafe set is the rest of Unicode.
|
|
47
|
+
*
|
|
48
|
+
* The first character must be alphanumeric, so `..` and dotfiles are refused along with everything else.
|
|
49
|
+
*/
|
|
50
|
+
export function isSafeName(name) {
|
|
51
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* May this DECLARED capability id be written into the generated grant? — R-78.
|
|
55
|
+
*
|
|
56
|
+
* **R-77's other half, and the reason a whitelist beats a blocklist twice.** R-77 was closed on the name
|
|
57
|
+
* channel; the `allowed-tools` *value* travels to the identical interpolation site and was unchecked, so a
|
|
58
|
+
* package declaring
|
|
59
|
+
*
|
|
60
|
+
* ```yaml
|
|
61
|
+
* allowed-tools: Read,ext:x";touch /tmp/pwned;PI_GRANTS_GRANT="
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* produced a `.pi/grants.env` that executed arbitrary code the moment the operator ran the `source` line
|
|
65
|
+
* `init` itself prints. Reproduced end to end before this existed. `ceilingForDefinition` passes `ext:`,
|
|
66
|
+
* `skill:` and `agent:` entries through **as written** by design (a translation table would invent or drop
|
|
67
|
+
* grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
|
|
68
|
+
* exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
|
|
69
|
+
*
|
|
70
|
+
* The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`, and
|
|
71
|
+
* `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused separately and
|
|
72
|
+
* loudly, because "you tried to grant yourself everything" is a different fact from "that is not a name".
|
|
73
|
+
*/
|
|
74
|
+
export function isSafeCapability(id) {
|
|
75
|
+
const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
|
|
76
|
+
return (new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
|
|
77
|
+
new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id));
|
|
78
|
+
}
|
|
79
|
+
/** The two ids that confer root authority. A package declaring one is claiming it, not describing a need. */
|
|
80
|
+
function wildcardsIn(capabilities) {
|
|
81
|
+
return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Everything about one declared skill that would make it unsafe to scaffold. `null` when it is fine.
|
|
85
|
+
*
|
|
86
|
+
* Ordered so the operator is told the most actionable thing: a wildcard is a deliberate claim, an unsafe id
|
|
87
|
+
* is probably a typo or an attack, and a bad name is neither.
|
|
88
|
+
*/
|
|
89
|
+
function refusalFor(skill) {
|
|
90
|
+
const name = skill.definition.name;
|
|
91
|
+
if (!isSafeName(name))
|
|
92
|
+
return { subject: name, reason: "unsafe-name", detail: [name] };
|
|
93
|
+
const ceiling = ceilingForDefinition(skill.definition);
|
|
94
|
+
const wildcards = wildcardsIn(ceiling.capabilities);
|
|
95
|
+
if (wildcards.length > 0)
|
|
96
|
+
return { subject: name, reason: "wildcard", detail: wildcards };
|
|
97
|
+
const unsafe = ceiling.capabilities.filter((c) => !isSafeCapability(c));
|
|
98
|
+
if (unsafe.length > 0)
|
|
99
|
+
return { subject: name, reason: "unsafe-capability", detail: unsafe };
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
/** One `pi.skills` entry: a directory holding `SKILL.md`, or a `.md` file — the same two shapes pi allows. */
|
|
103
|
+
async function readSkill(packageDir, entry) {
|
|
104
|
+
const target = resolve(packageDir, entry);
|
|
105
|
+
// A manifest is data from another package, so an entry escaping its own directory is refused rather than
|
|
106
|
+
// followed. **`realpath`, not a lexical prefix test** (R-80): `resolve()` normalises `..` and knows
|
|
107
|
+
// nothing about symlinks, so a packaged symlink walked straight past the first version of this check and
|
|
108
|
+
// a definition from outside the package was copied in, with its `allowed-tools` landing in the operator's
|
|
109
|
+
// grant. Measured. This is the same lesson as the `realpathSync` fix in `cli.ts`, which was found by the
|
|
110
|
+
// smoke test one day earlier and not applied here.
|
|
111
|
+
const realPackageDir = await realpath(packageDir).catch(() => packageDir);
|
|
112
|
+
for (const path of [join(target, "SKILL.md"), ...(target.endsWith(".md") ? [target] : [])]) {
|
|
113
|
+
let bytes;
|
|
114
|
+
try {
|
|
115
|
+
bytes = await readFile(path);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const realPath = await realpath(path).catch(() => path);
|
|
121
|
+
if (!realPath.startsWith(realPackageDir + sep))
|
|
122
|
+
return null;
|
|
123
|
+
// "The file verbatim … nothing is lost in a round trip" is a claim this module makes, so bytes that
|
|
124
|
+
// cannot survive the round trip are refused rather than silently replaced. A latin-1 `0xE9` used to come
|
|
125
|
+
// back as U+FFFD, changing the file's length and its digest, with no warning.
|
|
126
|
+
const text = bytes.toString("utf8");
|
|
127
|
+
if (!Buffer.from(text, "utf8").equals(bytes))
|
|
128
|
+
return "not-utf8";
|
|
129
|
+
const definition = parseSkillDefinition(path, text);
|
|
130
|
+
return definition ? { definition, text, path } : null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/** Read one installed package, if it declares skills. `null` means "not a skill package", not an error. */
|
|
135
|
+
export async function readSkillPackage(packageDir) {
|
|
136
|
+
let manifest;
|
|
137
|
+
try {
|
|
138
|
+
manifest = JSON.parse(await readFile(join(packageDir, "package.json"), "utf8"));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const declared = manifest.pi?.skills;
|
|
144
|
+
if (!Array.isArray(declared) || declared.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
const skills = [];
|
|
147
|
+
const unreadable = [];
|
|
148
|
+
const refused = [];
|
|
149
|
+
for (const entry of declared) {
|
|
150
|
+
if (typeof entry !== "string")
|
|
151
|
+
continue;
|
|
152
|
+
const skill = await readSkill(packageDir, entry);
|
|
153
|
+
if (skill === null) {
|
|
154
|
+
unreadable.push(entry);
|
|
155
|
+
}
|
|
156
|
+
else if (skill === "not-utf8") {
|
|
157
|
+
refused.push({ subject: entry, reason: "not-utf8", detail: [] });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const refusal = refusalFor(skill);
|
|
161
|
+
if (refusal)
|
|
162
|
+
refused.push(refusal);
|
|
163
|
+
else
|
|
164
|
+
skills.push(skill);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
name: manifest.name ?? packageDir.split(sep).pop() ?? "(unnamed)",
|
|
169
|
+
version: manifest.version ?? "(no version)",
|
|
170
|
+
unreadable,
|
|
171
|
+
refused,
|
|
172
|
+
skills,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every installed package under `<cwd>/node_modules` that declares `pi.skills`, sorted by name.
|
|
177
|
+
*
|
|
178
|
+
* Top level and one scope deep, which is what npm's layout has. Nothing recurses into a dependency's own
|
|
179
|
+
* `node_modules`: a transitive skill package is not something an operator asked to install definitions
|
|
180
|
+
* from, and scaffolding one into their project would be a surprise wearing a helpful face.
|
|
181
|
+
*/
|
|
182
|
+
export async function discoverSkillPackages(cwd) {
|
|
183
|
+
const root = join(cwd, "node_modules");
|
|
184
|
+
let entries;
|
|
185
|
+
try {
|
|
186
|
+
entries = await readdir(root);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return []; // no node_modules is a normal state, not a failure
|
|
190
|
+
}
|
|
191
|
+
const dirs = [];
|
|
192
|
+
for (const entry of entries.sort()) {
|
|
193
|
+
if (entry.startsWith("."))
|
|
194
|
+
continue; // .bin, .package-lock.json
|
|
195
|
+
if (entry.startsWith("@")) {
|
|
196
|
+
try {
|
|
197
|
+
for (const scoped of (await readdir(join(root, entry))).sort())
|
|
198
|
+
dirs.push(join(root, entry, scoped));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
dirs.push(join(root, entry));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const packages = [];
|
|
209
|
+
for (const dir of dirs) {
|
|
210
|
+
const found = await readSkillPackage(dir);
|
|
211
|
+
if (found)
|
|
212
|
+
packages.push(found);
|
|
213
|
+
}
|
|
214
|
+
return packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=skill-packages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill-packages.js","sourceRoot":"","sources":["../src/skill-packages.ts"],"names":[],"mappings":"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"}
|
package/extensions/grants.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { registerDelegationTools } from "./delegation.ts";
|
|
|
36
36
|
import { grantsCommand } from "./grants-command.ts";
|
|
37
37
|
import { planWithApprovals } from "./run-delegation.ts";
|
|
38
38
|
import { createGrantsSession } from "./session.ts";
|
|
39
|
+
import { renderSpawnableSummary, summariseSpawnable } from "./spawn-summary.ts";
|
|
39
40
|
|
|
40
41
|
const SPAWN_TOOLS = new Set(["Agent", "subagent", "spawn_agent"]);
|
|
41
42
|
|
|
@@ -207,6 +208,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
207
208
|
`grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
|
|
208
209
|
"info",
|
|
209
210
|
);
|
|
211
|
+
// B1 / P4. The grant alone never named the definitions, never said where they came from, and never
|
|
212
|
+
// said which ones were being WITHHELD — so an operator who had just installed a package of
|
|
213
|
+
// `SKILL.md` files could not tell governance-is-working from did-the-install-fail. Classified by the
|
|
214
|
+
// real planner (see `./spawn-summary.ts`), never by a second reading of the rules.
|
|
215
|
+
//
|
|
216
|
+
// Its own try/catch, and not because `summariseSpawnable` throws today: this is the R-60 shape
|
|
217
|
+
// exactly — one added `await` inside the blanket catch cancelling every control below it in
|
|
218
|
+
// silence. There is nothing below it now; there will be.
|
|
219
|
+
try {
|
|
220
|
+
const line = renderSpawnableSummary(
|
|
221
|
+
await summariseSpawnable(
|
|
222
|
+
session.definitions,
|
|
223
|
+
(name) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
|
|
224
|
+
// The session facts that make every per-definition verdict identical. `mayDelegate` in
|
|
225
|
+
// particular: without `tool:delegate` there is no delegate tool at all, and the line used to
|
|
226
|
+
// report definitions as spawnable in the one session where nothing can ever be spawned.
|
|
227
|
+
{ mayDelegate: session.mayDelegate, depth: session.depth, maxDepth: session.maxDepth },
|
|
228
|
+
),
|
|
229
|
+
session.definitions.size,
|
|
230
|
+
);
|
|
231
|
+
if (line) ctx.ui.notify(line, "info");
|
|
232
|
+
} catch (error) {
|
|
233
|
+
ctx.ui.notify(
|
|
234
|
+
`grants: could not work out which definitions are spawnable ` +
|
|
235
|
+
`(${error instanceof Error ? error.message : String(error)}) — run /grants for the per-definition ` +
|
|
236
|
+
`verdict. Nothing about the grant or its enforcement depends on this line.`,
|
|
237
|
+
"warning",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
210
240
|
}
|
|
211
241
|
} catch (error) {
|
|
212
242
|
// Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
|
|
@@ -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.14.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
|
*
|