pi-daddy 0.13.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 +187 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/dist/approval-prompt.d.ts +116 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +124 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/approval-store.d.ts +149 -0
- package/dist/approval-store.d.ts.map +1 -0
- package/dist/approval-store.js +311 -0
- package/dist/approval-store.js.map +1 -0
- package/dist/approval.d.ts +221 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +244 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +31 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +45 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog.d.ts +81 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +164 -0
- package/dist/catalog.js.map +1 -0
- package/dist/definitions.d.ts +101 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +182 -0
- package/dist/definitions.js.map +1 -0
- package/dist/delegate.d.ts +133 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +261 -0
- package/dist/delegate.js.map +1 -0
- package/dist/fanout.d.ts +62 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +84 -0
- package/dist/fanout.js.map +1 -0
- package/dist/file-lock.d.ts +54 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +142 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-report.d.ts +106 -0
- package/dist/ledger-report.d.ts.map +1 -0
- package/dist/ledger-report.js +157 -0
- package/dist/ledger-report.js.map +1 -0
- package/dist/ledger.d.ts +139 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +94 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pane-reaper.d.ts +49 -0
- package/dist/pane-reaper.d.ts.map +1 -0
- package/dist/pane-reaper.js +120 -0
- package/dist/pane-reaper.js.map +1 -0
- package/dist/pi-tools.d.ts +20 -0
- package/dist/pi-tools.d.ts.map +1 -0
- package/dist/pi-tools.js +21 -0
- package/dist/pi-tools.js.map +1 -0
- package/dist/propagation.d.ts +185 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +255 -0
- package/dist/propagation.js.map +1 -0
- package/dist/resolve.d.ts +110 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +181 -0
- package/dist/resolve.js.map +1 -0
- package/dist/run-child.d.ts +50 -0
- package/dist/run-child.d.ts.map +1 -0
- package/dist/run-child.js +109 -0
- package/dist/run-child.js.map +1 -0
- package/dist/run-herdr.d.ts +91 -0
- package/dist/run-herdr.d.ts.map +1 -0
- package/dist/run-herdr.js +306 -0
- package/dist/run-herdr.js.map +1 -0
- package/dist/spawn.d.ts +60 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +116 -0
- package/dist/spawn.js.map +1 -0
- package/extensions/approvals.ts +285 -0
- package/extensions/delegation.ts +237 -0
- package/extensions/grants-command.ts +310 -0
- package/extensions/grants.ts +346 -0
- package/extensions/run-delegation.ts +270 -0
- package/extensions/session.ts +234 -0
- package/package.json +122 -0
- package/src/approval-prompt.ts +222 -0
- package/src/approval-store.ts +383 -0
- package/src/approval.ts +364 -0
- package/src/capabilities.ts +51 -0
- package/src/catalog.ts +186 -0
- package/src/definitions.ts +233 -0
- package/src/delegate.ts +383 -0
- package/src/fanout.ts +96 -0
- package/src/file-lock.ts +142 -0
- package/src/index.ts +59 -0
- package/src/ledger-report.ts +227 -0
- package/src/ledger.ts +214 -0
- package/src/pane-reaper.ts +132 -0
- package/src/pi-tools.ts +25 -0
- package/src/propagation.ts +308 -0
- package/src/resolve.ts +235 -0
- package/src/run-child.ts +149 -0
- package/src/run-herdr.ts +357 -0
- package/src/spawn.ts +156 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Skills (`SKILL.md`) as this package's definition format — ADR-0016.
|
|
3
|
+
*
|
|
4
|
+
* A subagent is a skill you spawn: the `SKILL.md` body becomes the child's system prompt
|
|
5
|
+
* (`--append-system-prompt`) and `allowed-tools` becomes its capability ceiling, enforced through
|
|
6
|
+
* `--tools`. That collapses a duplication `principal-pi-skills` already strains against, where
|
|
7
|
+
* `plan`/`review`/`debug` exist twice — once as a skill and once as a subagent prompt — and are
|
|
8
|
+
* generated from one contract precisely so the two cannot drift.
|
|
9
|
+
*
|
|
10
|
+
* **The spec's own words about `allowed-tools`:** *"a space-separated string of tools that are
|
|
11
|
+
* pre-approved to run"*, marked **experimental**. Pre-approved, not enforced — the field declares intent
|
|
12
|
+
* and blocks nothing, and implementations differ on whether they honour it at all. Turning that
|
|
13
|
+
* declaration into something structural is this package's entire value here.
|
|
14
|
+
*
|
|
15
|
+
* **One inversion is deliberate and load-bearing.** In pi-subagents' frontmatter an absent `tools:` key
|
|
16
|
+
* means *pi's full default toolset*, so an undeclared definition was the most powerful kind and any
|
|
17
|
+
* parse failure produced a wildcard — the direction that caused R-28 and review finding F18. Here an
|
|
18
|
+
* absent `allowed-tools` means **undeclared, therefore not spawnable**. A typo or an unreadable YAML
|
|
19
|
+
* form now costs a refusal instead of a grant.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { skillDirs } from "./catalog.ts";
|
|
26
|
+
import type { Capability } from "./resolve.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Identifies WHICH instructions a child was given, without reproducing them (ADR-0018).
|
|
30
|
+
*
|
|
31
|
+
* The ledger's standing rule is *capability ids, counts and identifiers only — never prompts, tool
|
|
32
|
+
* arguments or results*. A hash is an identifier: it names a version of an operator-authored file. The
|
|
33
|
+
* **task** is model-assembled from the parent's context and is never recorded anywhere, by decision.
|
|
34
|
+
*/
|
|
35
|
+
export interface DefinitionDigest {
|
|
36
|
+
name: string;
|
|
37
|
+
/** Where the definition was read from, so a reader can go and rehash it. */
|
|
38
|
+
source: string;
|
|
39
|
+
/** SHA-256 of the body — the exact text passed as `--append-system-prompt`. */
|
|
40
|
+
sha256: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Digest a definition's body.
|
|
45
|
+
*
|
|
46
|
+
* Over the **body alone**, deliberately: that is precisely the text the child receives, so a digest that
|
|
47
|
+
* also covered the frontmatter would change when `description` was reworded and report an instruction
|
|
48
|
+
* change that never happened. `allowed-tools` is already recorded in full on every record.
|
|
49
|
+
*/
|
|
50
|
+
export function digestDefinition(definition: SkillDefinition): DefinitionDigest {
|
|
51
|
+
return {
|
|
52
|
+
name: definition.name,
|
|
53
|
+
source: definition.source,
|
|
54
|
+
sha256: createHash("sha256").update(definition.body, "utf8").digest("hex"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SkillDefinition {
|
|
59
|
+
/** From the path, never the frontmatter — see `parseSkillDefinition`. */
|
|
60
|
+
name: string;
|
|
61
|
+
description: string;
|
|
62
|
+
/** Raw `allowed-tools` value. `undefined` means the key was absent; `""` means it declared none. */
|
|
63
|
+
allowedTools?: string;
|
|
64
|
+
/** The spec's sanctioned extension point: a map of string keys to string values. */
|
|
65
|
+
metadata?: Record<string, string>;
|
|
66
|
+
/** Everything after the frontmatter — the child's system prompt. */
|
|
67
|
+
body: string;
|
|
68
|
+
source: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DefinitionCeiling {
|
|
72
|
+
/** The declared capabilities, as this package's ids. */
|
|
73
|
+
capabilities: Capability[];
|
|
74
|
+
/**
|
|
75
|
+
* Entries carrying a sub-tool pattern, e.g. `Bash(git:*)`.
|
|
76
|
+
*
|
|
77
|
+
* ADR-0016 refuses these rather than reinterpreting them, because every reinterpretation is wrong:
|
|
78
|
+
* granting bare `bash` **widens** a deliberately narrow declaration, dropping the tool silently
|
|
79
|
+
* **narrows** and yields a child that mysteriously cannot work, and matching patterns inside a wrapper
|
|
80
|
+
* would be a security control implemented by string-matching a shell command. Non-empty means the
|
|
81
|
+
* caller must refuse and say so.
|
|
82
|
+
*/
|
|
83
|
+
patterns: string[];
|
|
84
|
+
/** The `allowed-tools` key was absent entirely: the definition is not spawnable. */
|
|
85
|
+
undeclared: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a `SKILL.md`.
|
|
90
|
+
*
|
|
91
|
+
* The frontmatter reader handles the subset these files use — `key: value`, block scalars (`>` / `|`),
|
|
92
|
+
* and a one-level `metadata:` map. Anything it cannot read leaves the key **absent**, which for
|
|
93
|
+
* `allowed-tools` means *undeclared* and therefore refused. That is the whole reason this parser can be
|
|
94
|
+
* hand-rolled without the hazard its sibling in `agent-types.ts` carries.
|
|
95
|
+
*/
|
|
96
|
+
export function parseSkillDefinition(source: string, text: string): SkillDefinition | null {
|
|
97
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
98
|
+
if (!match) return null;
|
|
99
|
+
|
|
100
|
+
const fields = new Map<string, string>();
|
|
101
|
+
const metadata: Record<string, string> = {};
|
|
102
|
+
const lines = match[1].split(/\r?\n/);
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
|
106
|
+
if (!kv) continue;
|
|
107
|
+
const [, key, rawValue] = kv;
|
|
108
|
+
const value = rawValue.trim();
|
|
109
|
+
|
|
110
|
+
// A folded/literal scalar: fold the indented continuation into one line. `principal-pi-skills`
|
|
111
|
+
// writes every description this way, and the sibling parser SKIPS these — which is safe there only
|
|
112
|
+
// because it never needs the value. Here a skipped `description` would be a missing required field.
|
|
113
|
+
if (value === ">" || value === "|") {
|
|
114
|
+
const parts: string[] = [];
|
|
115
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
116
|
+
if (!/^\s+\S/.test(lines[j])) break;
|
|
117
|
+
parts.push(lines[j].trim());
|
|
118
|
+
i = j;
|
|
119
|
+
}
|
|
120
|
+
fields.set(key, parts.join(" "));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `metadata:` introduces a one-level map of string keys to string values (the spec's shape).
|
|
125
|
+
if (key === "metadata" && value === "") {
|
|
126
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
127
|
+
const item = /^\s+([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(lines[j]);
|
|
128
|
+
if (!item) break;
|
|
129
|
+
metadata[item[1]] = item[2].trim().replace(/^["']|["']$/g, "");
|
|
130
|
+
i = j;
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fields.set(key, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const description = fields.get("description");
|
|
139
|
+
if (description === undefined) return null;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
// Identity comes from the PATH. ADR-0013 learned this the hard way on the other format: pi keys
|
|
143
|
+
// skills by their directory, so trusting a frontmatter `name` lets our view and the loader's
|
|
144
|
+
// disagree about which file a name refers to. The spec requires `name` to match the parent
|
|
145
|
+
// directory anyway, so a mismatch is the file's defect and not something to honour.
|
|
146
|
+
name: nameFromPath(source),
|
|
147
|
+
description,
|
|
148
|
+
allowedTools: fields.get("allowed-tools"),
|
|
149
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
150
|
+
body: text.slice(match[0].length).trim(),
|
|
151
|
+
source,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `/skills/review/SKILL.md` -> `review`; `/skills/triage.md` -> `triage`. */
|
|
156
|
+
function nameFromPath(source: string): string {
|
|
157
|
+
const parts = source.split("/").filter((p) => p.length > 0);
|
|
158
|
+
const last = parts.at(-1) ?? "";
|
|
159
|
+
if (last.toLowerCase() === "skill.md") return parts.at(-2) ?? "";
|
|
160
|
+
return last.replace(/\.md$/i, "");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Turn a definition's `allowed-tools` into a capability ceiling.
|
|
165
|
+
*
|
|
166
|
+
* Name mapping is **lowercasing and nothing else**, deliberately. A translation table from Claude
|
|
167
|
+
* Code's names to pi's would have to decide what `Glob` means, and pi has no glob tool — so the table
|
|
168
|
+
* would either invent a grant or quietly drop one. Lowercasing leaves `Glob` as `tool:glob`, which the
|
|
169
|
+
* catalog then refuses as unknown, naming the actual problem to whoever wrote the file.
|
|
170
|
+
*/
|
|
171
|
+
export function ceilingForDefinition(definition: SkillDefinition): DefinitionCeiling {
|
|
172
|
+
const raw = definition.allowedTools;
|
|
173
|
+
if (raw === undefined) return { capabilities: [], patterns: [], undeclared: true };
|
|
174
|
+
|
|
175
|
+
const capabilities = new Set<Capability>();
|
|
176
|
+
const patterns: string[] = [];
|
|
177
|
+
|
|
178
|
+
// The spec says space-separated. Commas are tolerated because `Read, Grep` is what people type, and
|
|
179
|
+
// accepting them grants nothing extra — it only avoids a comma becoming part of a capability name.
|
|
180
|
+
for (const entry of raw.split(/[\s,]+/).filter((e) => e.length > 0)) {
|
|
181
|
+
if (entry.includes("(")) {
|
|
182
|
+
patterns.push(entry);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (entry.startsWith("ext:") || entry.startsWith("skill:") || entry.startsWith("agent:")) {
|
|
186
|
+
capabilities.add(entry);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
capabilities.add(`tool:${entry.toLowerCase()}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { capabilities: [...capabilities].sort(), patterns, undeclared: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Discover `SKILL.md` definitions under pi's skill roots.
|
|
197
|
+
*
|
|
198
|
+
* Deliberately the SAME roots and the same convention the catalog uses (`skillDirs`): a directory
|
|
199
|
+
* containing `SKILL.md` is one definition named after the directory, and a top-level `.md` is one named
|
|
200
|
+
* after the file. If discovery and the catalog disagreed, a definition could be spawnable but not
|
|
201
|
+
* grantable, or listed but unspawnable.
|
|
202
|
+
*
|
|
203
|
+
* Earlier directories win on a name collision, matching pi's own precedence — project before global.
|
|
204
|
+
*/
|
|
205
|
+
export async function loadDefinitions(cwd: string): Promise<Map<string, SkillDefinition>> {
|
|
206
|
+
const definitions = new Map<string, SkillDefinition>();
|
|
207
|
+
for (const dir of skillDirs(cwd)) {
|
|
208
|
+
let names: string[];
|
|
209
|
+
try {
|
|
210
|
+
names = await readdir(dir);
|
|
211
|
+
} catch {
|
|
212
|
+
continue; // an absent skill root is normal
|
|
213
|
+
}
|
|
214
|
+
for (const name of [...names].sort()) {
|
|
215
|
+
// A directory holding SKILL.md, or a top-level .md — try the former first, exactly as the
|
|
216
|
+
// catalog does, so the two cannot disagree about what exists.
|
|
217
|
+
const candidates = [join(dir, name, "SKILL.md"), ...(name.endsWith(".md") ? [join(dir, name)] : [])];
|
|
218
|
+
for (const path of candidates) {
|
|
219
|
+
let text: string;
|
|
220
|
+
try {
|
|
221
|
+
text = await readFile(path, "utf8");
|
|
222
|
+
} catch {
|
|
223
|
+
continue; // not this shape; try the next candidate
|
|
224
|
+
}
|
|
225
|
+
const parsed = parseSkillDefinition(path, text);
|
|
226
|
+
// First writer wins, so project definitions shadow global ones rather than the reverse.
|
|
227
|
+
if (parsed && !definitions.has(parsed.name)) definitions.set(parsed.name, parsed);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return definitions;
|
|
233
|
+
}
|
package/src/delegate.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Governed delegation — provisioning, not merely enforcement.
|
|
3
|
+
*
|
|
4
|
+
* The `tool_call` interceptor can only *permit or refuse* a `pi-subagents` spawn, because that package's
|
|
5
|
+
* `Agent` tool has no `tools` parameter. When we do the spawning ourselves the grant becomes an argument,
|
|
6
|
+
* which is what "give them some tools but not others" actually requires.
|
|
7
|
+
*
|
|
8
|
+
* Two properties fall out of owning the spawn:
|
|
9
|
+
*
|
|
10
|
+
* 1. **No propagation race at all.** Each child receives its own explicit `env` object, so nothing is
|
|
11
|
+
* written to the shared `process.env`. The interceptor's constraint (only parent-level facts may be
|
|
12
|
+
* pushed, because the channel is global) does not apply here.
|
|
13
|
+
* 2. **Depth control by capability.** `tool:delegate` is itself a capability. Grant it and the child can
|
|
14
|
+
* sub-delegate; withhold it and the child is a leaf. No separate depth mechanism is required, though
|
|
15
|
+
* `maxDepth` remains as a cheap backstop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { planSpawn } from "./spawn.ts";
|
|
19
|
+
import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts";
|
|
20
|
+
import { resolve, assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
|
|
21
|
+
import { AGENT_WILDCARD } from "./resolve.ts";
|
|
22
|
+
import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
|
|
23
|
+
|
|
24
|
+
// Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
|
|
25
|
+
// four modules plus the test suite name it. Moving the definitions without moving the door would be churn
|
|
26
|
+
// charged to every caller for a line count they did not cause.
|
|
27
|
+
export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
|
|
28
|
+
import { ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID } from "./propagation.ts";
|
|
29
|
+
import { inheritApprovals, type InheritableApproval } from "./approval.ts";
|
|
30
|
+
import { unknownCapabilities, type Catalog } from "./catalog.ts";
|
|
31
|
+
|
|
32
|
+
export interface DelegationRequest {
|
|
33
|
+
task: string;
|
|
34
|
+
/**
|
|
35
|
+
* Capabilities the delegator wants the child to hold.
|
|
36
|
+
*
|
|
37
|
+
* Optional since ADR-0016: prefer `agent`, which names an operator-authored definition. This form
|
|
38
|
+
* lets the MODEL choose the capability set, which is the weaker arrangement — it is still bounded by
|
|
39
|
+
* the session grant (ADR-0008), so it cannot escalate, but nothing about it was reviewed by a human.
|
|
40
|
+
*/
|
|
41
|
+
tools?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Name of a `SKILL.md` definition to spawn (ADR-0016).
|
|
44
|
+
*
|
|
45
|
+
* When given, the definition's `allowed-tools` is the ceiling and its body is the child's system
|
|
46
|
+
* prompt. The model chooses only *which* definition and *what* task; the capability set is the
|
|
47
|
+
* operator's, written down in a file.
|
|
48
|
+
*/
|
|
49
|
+
agent?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
provider?: string;
|
|
52
|
+
thinking?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DelegationContext {
|
|
56
|
+
ownGrant: Capability[];
|
|
57
|
+
depth: number;
|
|
58
|
+
maxDepth: number;
|
|
59
|
+
gated: Capability[];
|
|
60
|
+
/**
|
|
61
|
+
* Approvals in force for this delegation, with subject and scope (ADR-0014).
|
|
62
|
+
*
|
|
63
|
+
* One source of truth for two different questions. The **gate check here** honours every entry,
|
|
64
|
+
* including `once` — that approval applies to *this* spawn, which is exactly what the human said yes
|
|
65
|
+
* to. What crosses to the CHILD is `inheritApprovals`, which drops `once` and keeps the subject, so
|
|
66
|
+
* the same list cannot silently authorise a subtree.
|
|
67
|
+
*/
|
|
68
|
+
approved?: InheritableApproval[];
|
|
69
|
+
ledgerPath?: string;
|
|
70
|
+
/** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
|
|
71
|
+
extensionPath?: string;
|
|
72
|
+
/** Live capability catalog. When supplied, capabilities absent from it are refused as unknown. */
|
|
73
|
+
catalog?: Catalog;
|
|
74
|
+
/**
|
|
75
|
+
* Absolute path per skill NAME, from the catalog's `source` field (R-32).
|
|
76
|
+
*
|
|
77
|
+
* Without it every granted `skill:` capability is unresolvable and the delegation is refused, which
|
|
78
|
+
* is the correct direction: a caller that cannot say where a skill lives cannot honestly grant it.
|
|
79
|
+
*/
|
|
80
|
+
skillPaths?: Record<string, string>;
|
|
81
|
+
/** Let the child load `AGENTS.md` / `CLAUDE.md`. Default false — see `planSpawn`. */
|
|
82
|
+
contextFiles?: boolean;
|
|
83
|
+
/** Known `SKILL.md` definitions by name, for `DelegationRequest.agent` (ADR-0016). */
|
|
84
|
+
definitions?: Map<string, SkillDefinition>;
|
|
85
|
+
/**
|
|
86
|
+
* Build an INTERACTIVE plan — no `--print` — for an executor that drives the child after starting it.
|
|
87
|
+
*
|
|
88
|
+
* `runHerdrPane` requires this: `--print` makes pi process the prompt and exit, so it never reaches the
|
|
89
|
+
* interactive readiness `herdr agent start` waits for and the agent is never detected. Default is the
|
|
90
|
+
* non-interactive plan, because a governed child should not sit waiting for a human by accident.
|
|
91
|
+
*/
|
|
92
|
+
interactive?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Total descendants this session may still create (`src/fanout.ts`). Split among children by the caller.
|
|
95
|
+
*
|
|
96
|
+
* Omitted means unbounded, which is the pre-fan-out behaviour and correct for a single blocking
|
|
97
|
+
* delegation — the accident that used to bound cardinality to one.
|
|
98
|
+
*/
|
|
99
|
+
fanoutBudget?: number;
|
|
100
|
+
/** This session's ledger id, so a child's `parentId` names its real parent (F8). */
|
|
101
|
+
spawnId?: string;
|
|
102
|
+
/** Ledger id assigned to THIS child, distinguishing it from its siblings (F8). */
|
|
103
|
+
childSpawnId?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface Delegation {
|
|
107
|
+
ok: boolean;
|
|
108
|
+
reason?: string;
|
|
109
|
+
args: string[];
|
|
110
|
+
/** Per-child environment — never merged into the parent's process.env. */
|
|
111
|
+
env: Record<string, string>;
|
|
112
|
+
effective: Capability[];
|
|
113
|
+
/**
|
|
114
|
+
* The result this plan was made from. **Required** (B-I3): while it was optional the extension
|
|
115
|
+
* guarded its ledger write with `if (ledgerPath && plan.result)`, silently dropping every refusal
|
|
116
|
+
* that returned before `resolve()` ran. The type is what keeps a new early exit auditable.
|
|
117
|
+
*/
|
|
118
|
+
result: ResolveResult;
|
|
119
|
+
childDepth: number;
|
|
120
|
+
/**
|
|
121
|
+
* The capabilities this delegation asked for, whatever route named them.
|
|
122
|
+
*
|
|
123
|
+
* Carried on the plan rather than re-derived by the caller (the B-I3 lesson): with `agent`, the
|
|
124
|
+
* request names a DEFINITION and the capabilities come from its `allowed-tools`, so a ledger that
|
|
125
|
+
* read the tool parameters would record an empty request for every definition spawn.
|
|
126
|
+
*/
|
|
127
|
+
requested: Capability[];
|
|
128
|
+
/** Ledger id for this child, if the caller assigned one (F8). */
|
|
129
|
+
childId?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Which operator-authored instructions this spawn used (ADR-0018).
|
|
132
|
+
*
|
|
133
|
+
* Absent for a `tools:`-style delegation, which has no definition and therefore no instructions to
|
|
134
|
+
* identify — and absent on an ADR-0017 authorisation refusal, which is decided before the file is read.
|
|
135
|
+
*/
|
|
136
|
+
definitionDigest?: DefinitionDigest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Plan a governed delegation. Pure: returns argv and env, spawns nothing.
|
|
141
|
+
*
|
|
142
|
+
* Fails closed on depth, on any requested capability the delegator does not hold, on gated capabilities
|
|
143
|
+
* without approval, and on a grant that cannot narrow (a universal capability slipping through).
|
|
144
|
+
*/
|
|
145
|
+
export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
|
|
146
|
+
const childDepth = ctx.depth + 1;
|
|
147
|
+
// G6 / B-I3: every refusal carries a result, including the four below that return before `resolve()`
|
|
148
|
+
// is ever called. The extension guarded its ledger write with `if (ledgerPath && plan.result)`, so
|
|
149
|
+
// those four governance decisions — disabled, too deep, no task, unknown capability — were never
|
|
150
|
+
// audited at all. An empty result is the honest record: nothing was resolved, and that is the fact.
|
|
151
|
+
const empty: Delegation = {
|
|
152
|
+
ok: false,
|
|
153
|
+
args: [],
|
|
154
|
+
env: {},
|
|
155
|
+
effective: [],
|
|
156
|
+
childDepth,
|
|
157
|
+
requested: [],
|
|
158
|
+
result: { effective: [], denied: [], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (ctx.maxDepth <= 0) return { ...empty, reason: "delegation is disabled (maxDepth 0)" };
|
|
162
|
+
if (childDepth > ctx.maxDepth) {
|
|
163
|
+
return { ...empty, reason: `delegation depth limit reached (${ctx.maxDepth})` };
|
|
164
|
+
}
|
|
165
|
+
if (!request.task?.trim()) return { ...empty, reason: "a delegation needs a task" };
|
|
166
|
+
|
|
167
|
+
// ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
|
|
168
|
+
let requested: Capability[];
|
|
169
|
+
let systemPrompt: string | undefined;
|
|
170
|
+
let definitionDigest: DefinitionDigest | undefined;
|
|
171
|
+
/** The definition being spawned, hoisted so the gate below can name its authorising id (ADR-0024). */
|
|
172
|
+
let spawned: SkillDefinition | undefined;
|
|
173
|
+
|
|
174
|
+
if (request.agent) {
|
|
175
|
+
const definition = ctx.definitions?.get(request.agent);
|
|
176
|
+
spawned = definition;
|
|
177
|
+
// No fallback, deliberately. pi-subagents resolves an unknown type to `general-purpose`, whose
|
|
178
|
+
// omitted tool list means EVERY tool — so a typo there granted the full surface. An unknown name
|
|
179
|
+
// here is simply an error.
|
|
180
|
+
if (!definition) {
|
|
181
|
+
const known = [...(ctx.definitions?.keys() ?? [])].sort();
|
|
182
|
+
return {
|
|
183
|
+
...empty,
|
|
184
|
+
reason:
|
|
185
|
+
`unknown agent "${request.agent}"` +
|
|
186
|
+
(known.length > 0 ? ` — known definitions: ${known.join(", ")}` : " — no definitions were found"),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ADR-0017: authorisation comes BEFORE anything is said about the file. Which definitions this
|
|
191
|
+
// session may spawn is a governance question about the SESSION; whether the file declares its tools
|
|
192
|
+
// properly is a diagnostic about the DEFINITION, and answering the second one first would report a
|
|
193
|
+
// malformed-file error to a caller who was never allowed to spawn it either way.
|
|
194
|
+
//
|
|
195
|
+
// Recorded as a denial rather than a bare refusal, deliberately: `denied` is the escalation signal
|
|
196
|
+
// ADR-0008 designates, and asking to run a definition this session was not granted IS an attempt to
|
|
197
|
+
// exceed the grant. A refusal that left `denied` empty would keep it out of every audit query.
|
|
198
|
+
if (!maySpawnDefinition(ctx.ownGrant, definition.name)) {
|
|
199
|
+
const authorising = agentCapability(definition.name);
|
|
200
|
+
const held = ctx.ownGrant.filter((c) => c.startsWith("agent:")).sort();
|
|
201
|
+
return {
|
|
202
|
+
...empty,
|
|
203
|
+
requested: [authorising],
|
|
204
|
+
result: { ...empty.result, denied: [authorising] },
|
|
205
|
+
reason:
|
|
206
|
+
`cannot spawn "${definition.name}" — this session does not hold ${authorising} ` +
|
|
207
|
+
`(the definition lives at ${definition.source}). ` +
|
|
208
|
+
(held.length > 0
|
|
209
|
+
? `It may spawn: ${held.join(", ")}.`
|
|
210
|
+
: `It may spawn no definitions at all; add ${authorising} to its grant to allow this one.`),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ADR-0018. Recorded from here on — after authorisation, because the digest is a fact about a file
|
|
215
|
+
// this caller was allowed to read, and before every remaining outcome, because a spawn refused for a
|
|
216
|
+
// malformed declaration is still a spawn of THIS version of the definition.
|
|
217
|
+
//
|
|
218
|
+
// Assigned into `empty`, which every subsequent refusal spreads. That is the R-28 discipline applied
|
|
219
|
+
// to a record field rather than an argument: instead of eight `definitionDigest` spellings that a
|
|
220
|
+
// ninth return could forget, there is one, and forgetting it is not expressible. The success return
|
|
221
|
+
// does not spread `empty`, so it names the field explicitly.
|
|
222
|
+
definitionDigest = digestDefinition(definition);
|
|
223
|
+
Object.assign(empty, { definitionDigest });
|
|
224
|
+
|
|
225
|
+
const ceiling = ceilingForDefinition(definition);
|
|
226
|
+
if (ceiling.undeclared) {
|
|
227
|
+
return {
|
|
228
|
+
...empty,
|
|
229
|
+
reason:
|
|
230
|
+
`agent "${definition.name}" declares no \`allowed-tools\`, so it cannot be spawned — add one ` +
|
|
231
|
+
`to ${definition.source}. An undeclared capability set is treated as NONE, never as everything.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (ceiling.patterns.length > 0) {
|
|
235
|
+
return {
|
|
236
|
+
...empty,
|
|
237
|
+
reason:
|
|
238
|
+
`agent "${definition.name}" restricts a tool with a pattern (${ceiling.patterns.join(", ")}), ` +
|
|
239
|
+
`which pi's --tools cannot express — it matches whole tool names only. Granting the bare tool ` +
|
|
240
|
+
`would widen the declaration and dropping it would silently narrow, so neither is done.`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
requested = ceiling.capabilities;
|
|
244
|
+
systemPrompt = definition.body;
|
|
245
|
+
} else {
|
|
246
|
+
requested = (request.tools ?? []).map(normaliseCapability);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Unknown is reported before denied, and separately: "does not exist here" and "you lack authority"
|
|
250
|
+
// have different causes and different fixes. Collapsing them hides typos and stale grants.
|
|
251
|
+
if (ctx.catalog) {
|
|
252
|
+
const unknown = unknownCapabilities(requested, ctx.catalog);
|
|
253
|
+
if (unknown.length > 0) {
|
|
254
|
+
return {
|
|
255
|
+
...empty,
|
|
256
|
+
requested,
|
|
257
|
+
reason:
|
|
258
|
+
`unknown capabilit${unknown.length === 1 ? "y" : "ies"}: ${unknown.join(", ")} — not present in ` +
|
|
259
|
+
`this session's catalog (typo, or an uninstalled package?)`,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const approvedCapabilities = (ctx.approved ?? []).map((a) => a.capability);
|
|
265
|
+
const result = resolve({
|
|
266
|
+
requested,
|
|
267
|
+
parentGrant: ctx.ownGrant,
|
|
268
|
+
gated: ctx.gated,
|
|
269
|
+
approved: approvedCapabilities,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* ADR-0024: gating `agent:<name>` asks a human before that definition runs.
|
|
274
|
+
*
|
|
275
|
+
* `gatedBlocked` is a filter over `requested`, and for a definition spawn `requested` is the definition's
|
|
276
|
+
* CEILING — so the id that authorises it was never a candidate, and `PI_GRANTS_GATED=agent:deploy` did
|
|
277
|
+
* nothing at all on the path an operator writing it means. It half-worked when some *other* definition
|
|
278
|
+
* passed the id down in its own `allowed-tools`, which is worse than not working (R-47, R-25's shape).
|
|
279
|
+
*
|
|
280
|
+
* Evaluated here rather than by adding the id to `requested`, and that is the load-bearing part: a
|
|
281
|
+
* capability in `requested` flows to `effective`, which becomes the CHILD's grant — so the child would
|
|
282
|
+
* hold `agent:deploy` and could spawn `deploy` itself without anyone being asked. This is the parent's
|
|
283
|
+
* authority to run the definition *now*, not something the child receives.
|
|
284
|
+
*
|
|
285
|
+
* `agent:*` in the gate covers every definition, so "ask me before any definition runs" is one variable.
|
|
286
|
+
*/
|
|
287
|
+
if (spawned) {
|
|
288
|
+
const authorising = agentCapability(spawned.name);
|
|
289
|
+
const gatedHere = ctx.gated.includes(authorising) || ctx.gated.includes(AGENT_WILDCARD);
|
|
290
|
+
if (gatedHere && !approvedCapabilities.includes(authorising)) {
|
|
291
|
+
result.gatedBlocked = [...result.gatedBlocked, authorising];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (result.denied.length > 0) {
|
|
296
|
+
return {
|
|
297
|
+
...empty,
|
|
298
|
+
requested,
|
|
299
|
+
result,
|
|
300
|
+
reason: `cannot grant ${result.denied.join(", ")} — this session does not hold it (capability escalation blocked)`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// ADR-0011: narrowing is checked BEFORE the gate, and the order is load-bearing rather than
|
|
304
|
+
// stylistic. `assertNarrowing` refuses regardless of approval, so with the old order this returned
|
|
305
|
+
// "requires explicit approval" for a delegation that could never be approved — telling the operator
|
|
306
|
+
// to go and find a human who cannot help. `shouldSeekApproval` now also refuses to prompt in this
|
|
307
|
+
// case; this reordering makes the reported *reason* agree with what actually blocks the spawn.
|
|
308
|
+
try {
|
|
309
|
+
assertNarrowing(result);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ...empty, requested, result, reason: String(error instanceof Error ? error.message : error) };
|
|
312
|
+
}
|
|
313
|
+
if (result.gatedBlocked.length > 0) {
|
|
314
|
+
return { ...empty, requested, result, reason: `${result.gatedBlocked.join(", ")} requires explicit approval` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY);
|
|
318
|
+
const plan = planSpawn({
|
|
319
|
+
effective: result.effective,
|
|
320
|
+
prompt: request.task,
|
|
321
|
+
model: request.model,
|
|
322
|
+
provider: request.provider,
|
|
323
|
+
thinking: request.thinking,
|
|
324
|
+
skillPaths: ctx.skillPaths,
|
|
325
|
+
contextFiles: ctx.contextFiles,
|
|
326
|
+
systemPrompt,
|
|
327
|
+
print: ctx.interactive ? false : undefined,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// R-32. A `skill:` capability the catalog cannot place is refused rather than dropped. Dropping it
|
|
331
|
+
// would hand back a child whose grant claims a skill it does not have — the ledger would record a
|
|
332
|
+
// capability that never reached the process, which is precisely the kind of lie an audit trail must
|
|
333
|
+
// not contain. `unknownCapabilities` above catches names absent from the catalog entirely; this
|
|
334
|
+
// catches one that is known but whose path we could not resolve, which is a different fault.
|
|
335
|
+
if (plan.unresolvedSkills.length > 0) {
|
|
336
|
+
return {
|
|
337
|
+
...empty,
|
|
338
|
+
requested,
|
|
339
|
+
result,
|
|
340
|
+
reason:
|
|
341
|
+
`cannot locate ${plan.unresolvedSkills.join(", ")} on disk — granted but unresolvable, so the ` +
|
|
342
|
+
`child would silently lack it`,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A child may only delegate further if it was granted the capability AND has the extension to do it.
|
|
347
|
+
const args = [...plan.args];
|
|
348
|
+
if (canSubDelegate && ctx.extensionPath) {
|
|
349
|
+
// `-e` loads even under `--no-extensions`, which planSpawn sets — that is precisely why the
|
|
350
|
+
// extension is added explicitly here and nowhere else.
|
|
351
|
+
args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const env: Record<string, string> = {
|
|
355
|
+
[ENV_GRANT]: result.effective.join(","),
|
|
356
|
+
[ENV_DEPTH]: String(childDepth),
|
|
357
|
+
[ENV_MAX_DEPTH]: String(ctx.maxDepth),
|
|
358
|
+
};
|
|
359
|
+
// The child's own share of the subtree budget, and its identity. Both attenuate downward like depth: a
|
|
360
|
+
// child can never be handed more budget than its parent had left, so the total bound holds across
|
|
361
|
+
// process boundaries with no shared state.
|
|
362
|
+
if (ctx.fanoutBudget !== undefined) env[ENV_FANOUT] = String(ctx.fanoutBudget);
|
|
363
|
+
if (ctx.childSpawnId) env[ENV_PARENT_ID] = ctx.childSpawnId;
|
|
364
|
+
if (ctx.gated.length > 0) env[ENV_GATED] = ctx.gated.join(",");
|
|
365
|
+
// Approvals ride down with the grant, but only ever for what this child actually received — so
|
|
366
|
+
// `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
|
|
367
|
+
// the child's approval set outright rather than leaving it to whatever the caller merges over; see
|
|
368
|
+
// `mergeChildEnv`, which is what actually stops the parent's value leaking through.
|
|
369
|
+
env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], result.effective).join(",");
|
|
370
|
+
if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
args,
|
|
375
|
+
env,
|
|
376
|
+
effective: result.effective,
|
|
377
|
+
result,
|
|
378
|
+
childDepth,
|
|
379
|
+
requested,
|
|
380
|
+
childId: ctx.childSpawnId,
|
|
381
|
+
...(definitionDigest ? { definitionDigest } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|