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.
Files changed (105) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/dist/approval-prompt.d.ts +116 -0
  5. package/dist/approval-prompt.d.ts.map +1 -0
  6. package/dist/approval-prompt.js +124 -0
  7. package/dist/approval-prompt.js.map +1 -0
  8. package/dist/approval-store.d.ts +149 -0
  9. package/dist/approval-store.d.ts.map +1 -0
  10. package/dist/approval-store.js +311 -0
  11. package/dist/approval-store.js.map +1 -0
  12. package/dist/approval.d.ts +221 -0
  13. package/dist/approval.d.ts.map +1 -0
  14. package/dist/approval.js +244 -0
  15. package/dist/approval.js.map +1 -0
  16. package/dist/capabilities.d.ts +31 -0
  17. package/dist/capabilities.d.ts.map +1 -0
  18. package/dist/capabilities.js +45 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/catalog.d.ts +81 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +164 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/definitions.d.ts +101 -0
  25. package/dist/definitions.d.ts.map +1 -0
  26. package/dist/definitions.js +182 -0
  27. package/dist/definitions.js.map +1 -0
  28. package/dist/delegate.d.ts +133 -0
  29. package/dist/delegate.d.ts.map +1 -0
  30. package/dist/delegate.js +261 -0
  31. package/dist/delegate.js.map +1 -0
  32. package/dist/fanout.d.ts +62 -0
  33. package/dist/fanout.d.ts.map +1 -0
  34. package/dist/fanout.js +84 -0
  35. package/dist/fanout.js.map +1 -0
  36. package/dist/file-lock.d.ts +54 -0
  37. package/dist/file-lock.d.ts.map +1 -0
  38. package/dist/file-lock.js +142 -0
  39. package/dist/file-lock.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ledger-report.d.ts +106 -0
  45. package/dist/ledger-report.d.ts.map +1 -0
  46. package/dist/ledger-report.js +157 -0
  47. package/dist/ledger-report.js.map +1 -0
  48. package/dist/ledger.d.ts +139 -0
  49. package/dist/ledger.d.ts.map +1 -0
  50. package/dist/ledger.js +94 -0
  51. package/dist/ledger.js.map +1 -0
  52. package/dist/pane-reaper.d.ts +49 -0
  53. package/dist/pane-reaper.d.ts.map +1 -0
  54. package/dist/pane-reaper.js +120 -0
  55. package/dist/pane-reaper.js.map +1 -0
  56. package/dist/pi-tools.d.ts +20 -0
  57. package/dist/pi-tools.d.ts.map +1 -0
  58. package/dist/pi-tools.js +21 -0
  59. package/dist/pi-tools.js.map +1 -0
  60. package/dist/propagation.d.ts +185 -0
  61. package/dist/propagation.d.ts.map +1 -0
  62. package/dist/propagation.js +255 -0
  63. package/dist/propagation.js.map +1 -0
  64. package/dist/resolve.d.ts +110 -0
  65. package/dist/resolve.d.ts.map +1 -0
  66. package/dist/resolve.js +181 -0
  67. package/dist/resolve.js.map +1 -0
  68. package/dist/run-child.d.ts +50 -0
  69. package/dist/run-child.d.ts.map +1 -0
  70. package/dist/run-child.js +109 -0
  71. package/dist/run-child.js.map +1 -0
  72. package/dist/run-herdr.d.ts +91 -0
  73. package/dist/run-herdr.d.ts.map +1 -0
  74. package/dist/run-herdr.js +306 -0
  75. package/dist/run-herdr.js.map +1 -0
  76. package/dist/spawn.d.ts +60 -0
  77. package/dist/spawn.d.ts.map +1 -0
  78. package/dist/spawn.js +116 -0
  79. package/dist/spawn.js.map +1 -0
  80. package/extensions/approvals.ts +285 -0
  81. package/extensions/delegation.ts +237 -0
  82. package/extensions/grants-command.ts +310 -0
  83. package/extensions/grants.ts +346 -0
  84. package/extensions/run-delegation.ts +270 -0
  85. package/extensions/session.ts +234 -0
  86. package/package.json +122 -0
  87. package/src/approval-prompt.ts +222 -0
  88. package/src/approval-store.ts +383 -0
  89. package/src/approval.ts +364 -0
  90. package/src/capabilities.ts +51 -0
  91. package/src/catalog.ts +186 -0
  92. package/src/definitions.ts +233 -0
  93. package/src/delegate.ts +383 -0
  94. package/src/fanout.ts +96 -0
  95. package/src/file-lock.ts +142 -0
  96. package/src/index.ts +59 -0
  97. package/src/ledger-report.ts +227 -0
  98. package/src/ledger.ts +214 -0
  99. package/src/pane-reaper.ts +132 -0
  100. package/src/pi-tools.ts +25 -0
  101. package/src/propagation.ts +308 -0
  102. package/src/resolve.ts +235 -0
  103. package/src/run-child.ts +149 -0
  104. package/src/run-herdr.ts +357 -0
  105. package/src/spawn.ts +156 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Approval model for gated capabilities — pure, so it is exhaustively testable without pi.
3
+ *
4
+ * `resolve()` computes `gatedBlocked`: capabilities a session legitimately holds but which may not enter
5
+ * a child's grant without a human saying so. This module decides WHAT a yes means — how far it reaches,
6
+ * how long it lasts, and what it is keyed to. It never performs I/O and never prompts; see
7
+ * `approval-store.ts` and `approval-prompt.ts` for those.
8
+ *
9
+ * ADR-0010 records the four decisions encoded here.
10
+ */
11
+
12
+ import { WILDCARD } from "./pi-tools.ts";
13
+ import type { Capability, ResolveResult } from "./resolve.ts";
14
+
15
+ /** How far a single yes reaches in time. */
16
+ export type ApprovalScope = "once" | "session" | "always";
17
+
18
+ /** Where a yes came from, for the ledger. These call for different follow-ups, so they stay distinct. */
19
+ export type ApprovalSource = "prompt" | "session" | "persisted" | "inherited";
20
+
21
+ /** Which call site is asking. Determines the scopes offered — see `offeredScopes`. */
22
+ export type ApprovalPath = "definition" | "delegate";
23
+
24
+ /**
25
+ * Subject used for delegate-path approvals.
26
+ *
27
+ * The delegate tool has no human-authored subject: the only things naming a child are the task string and
28
+ * the tool list, both chosen by the model. A key the model controls is not a key, so delegate approvals
29
+ * use this fixed literal — "allow write for delegations this session" — and are never persisted.
30
+ * Angle brackets cannot appear in an agent-type name (`parseAgentType` reads an identifier), so this can
31
+ * never collide with a real type.
32
+ */
33
+ export const DELEGATE_SUBJECT = "<delegate>";
34
+
35
+ /**
36
+ * Lifetime of a persisted approval.
37
+ *
38
+ * Deliberately a constant rather than an environment variable: a knob that silently extends every gate is
39
+ * the first thing an impatient operator would reach for.
40
+ */
41
+ export const APPROVAL_TTL_DAYS = 30;
42
+
43
+ const DAY_MS = 86_400_000;
44
+
45
+ /** `tool:write` + `docs-writer` -> `tool:write@docs-writer`. */
46
+ export function approvalKey(capability: Capability, subject: string): string {
47
+ return `${capability}@${subject}`;
48
+ }
49
+
50
+ /**
51
+ * May this resolution raise an approval dialog?
52
+ *
53
+ * `resolve()` computes `denied` and `gatedBlocked` INDEPENDENTLY (deliberately — each rejection reason is
54
+ * reported rather than masked by whichever filter ran first), and both `decideSpawn` and `planDelegation`
55
+ * refuse outright on `denied` while still attaching the full result. So a request mixing a gated capability
56
+ * with one the session does not hold at all would otherwise prompt a human about the gated one and then be
57
+ * refused anyway for the other.
58
+ *
59
+ * That is not merely noise. A *session* yes is recorded and republished to children, and an *always* yes is
60
+ * written to disk for 30 days — both banked against a spawn that never happened, and both reachable by a
61
+ * model that appends one unheld capability to an otherwise ordinary request. A person is therefore only
62
+ * asked about a spawn that would actually proceed if they said yes.
63
+ *
64
+ * `clipped` is deliberately NOT a bar: it does not refuse a spawn, it just drops those capabilities from
65
+ * the child's grant, so the spawn does proceed and the question is real.
66
+ */
67
+ export function shouldSeekApproval(result: ResolveResult | undefined): boolean {
68
+ if (!result) return false;
69
+ if (result.denied.length > 0) return false;
70
+ // ADR-0011. A grant retaining a universal capability is refused by `assertNarrowing` no matter what
71
+ // a human says, so asking is worse than useless: the dialog cannot change the outcome, and a
72
+ // `session`- or `always`-scoped yes given there is banked and reused for later spawns that DO
73
+ // proceed. Same harm the `denied` guard above exists to prevent.
74
+ if (result.universal.length > 0) return false;
75
+ return result.gatedBlocked.length > 0;
76
+ }
77
+
78
+ /**
79
+ * Scopes a given call site may offer. `always` requires a **human-authored subject**.
80
+ *
81
+ * ADR-0019. The rule is unchanged; which paths satisfy it is not. `"interceptor"` used to be the only one
82
+ * and ADR-0016 deleted it, leaving `always` offerable from nowhere — 220 lines of persistence no live path
83
+ * could write to. `"definition"` is `delegate({agent})`, where ADR-0017 makes the subject an
84
+ * operator-authored file the session must hold `agent:<name>` to name at all. `"delegate"` is the `tools:`
85
+ * form, where the original reasoning stands untouched: the only things naming that child are the task and
86
+ * the tool list, both model-chosen, and a key the model controls is not a key.
87
+ */
88
+ export function offeredScopes(path: ApprovalPath): ApprovalScope[] {
89
+ return path === "definition" ? ["once", "session", "always"] : ["once", "session"];
90
+ }
91
+
92
+ /**
93
+ * The approval an inheriting child may hold.
94
+ *
95
+ * Intersecting with the child's grant is what keeps ADR-0008 intact once approvals became inheritable:
96
+ * `approved ⊆ grant` at every level, by construction. An approval can therefore never name a capability
97
+ * the session does not hold — it only ever unblocks part of a grant, never widens one.
98
+ *
99
+ * The wildcard is filtered for the same reason `childEnv` filters it out of grants (R-26): inheriting it
100
+ * would let a descendant treat every future gate as pre-approved.
101
+ */
102
+ /** An approval as it crosses a boundary: the capability, WHO it was for, and HOW LONG it was meant to last. */
103
+ export interface InheritableApproval {
104
+ capability: Capability;
105
+ /** The agent type, or `<delegate>`. Carried because an approval is for a subject, not for a word. */
106
+ subject: string;
107
+ scope: ApprovalScope;
108
+ /**
109
+ * The definition's body digest at the time of approval (ADR-0022). Absent for `<delegate>`, which names
110
+ * no file to hash — and, per ADR-0019, is never persisted or offered `always` for the same reason.
111
+ */
112
+ bodySha256?: string;
113
+ }
114
+
115
+ /**
116
+ * What a child may inherit, as `capability@subject` keys.
117
+ *
118
+ * ADR-0014 changed this in two ways, and both were cases of a human's explicit choice being discarded
119
+ * one hop down:
120
+ *
121
+ * - **`once` is dropped** (A-S1). The scope chosen was not carried, so a `once` approval was written
122
+ * into the child's `PI_GRANTS_APPROVED` and republished onward — the most conservative answer a human
123
+ * can give produced the least conservative outcome, across an entire descendant subtree.
124
+ * - **The subject is kept** (A-S6). Bare capabilities were published, so a `<delegate>`-subject approval
125
+ * matched *any* subject below. `approvalKey`'s own doc argues at length that a model-controlled name
126
+ * is not a key; erasing the subject made that argument moot exactly where it mattered.
127
+ *
128
+ * The clamp to `grant` is unchanged and still load-bearing: **approval cannot conjure a capability**, so
129
+ * an inherited yes is only ever honoured for something the child independently holds.
130
+ */
131
+ export function inheritApprovals(approved: InheritableApproval[], grant: Capability[]): string[] {
132
+ const held = new Set(grant);
133
+ return [
134
+ ...new Set(
135
+ approved
136
+ .filter((a) => a.scope !== "once" && a.capability !== WILDCARD && held.has(a.capability))
137
+ // A definition subject MUST carry a pin to cross a boundary (ADR-0022, hardened after F1).
138
+ //
139
+ // `verifyInherited` honours an unpinned entry by decision — `<delegate>` names no file and a
140
+ // pre-0.11 parent sends none — so an unpinned entry for a subject that *does* name a file is an
141
+ // approval exempt from the digest check. Enforcing it at the point of PUBLICATION rather than
142
+ // trusting each caller to attach a digest is what makes it structural: two call sites build these
143
+ // entries, one of them forgot, and every fresh approval crossed unpinned as a result. A caller that
144
+ // cannot produce a digest (the definition is not on disk here) now publishes nothing, which is the
145
+ // fail-closed direction.
146
+ .filter((a) => a.subject === DELEGATE_SUBJECT || Boolean(a.bodySha256))
147
+ .map((a) => approvalKey(a.capability, a.subject) + (a.bodySha256 ? `#${a.bodySha256}` : "")),
148
+ ),
149
+ ].sort();
150
+ }
151
+
152
+ /**
153
+ * Read the inherited set back on the child side.
154
+ *
155
+ * Anything that is not a well-formed `capability@subject` pair is dropped rather than guessed at. An
156
+ * unparseable entry granting nothing is a missing prompt; an unparseable entry granting *something* is a
157
+ * silent escalation, so the direction of the failure is not a matter of taste.
158
+ */
159
+ export function parseInherited(raw: string | undefined): Map<string, string | undefined> {
160
+ const out = new Map<string, string | undefined>();
161
+ for (const item of (raw ?? "").split(",")) {
162
+ const trimmed = item.trim();
163
+ // ADR-0022 appends `#<sha256>`. Split it off FIRST: a `#` cannot appear in a capability id or in a
164
+ // definition name (identity comes from a path component), so this is unambiguous.
165
+ const hash = trimmed.indexOf("#");
166
+ const key = hash === -1 ? trimmed : trimmed.slice(0, hash);
167
+ const digest = hash === -1 ? undefined : trimmed.slice(hash + 1);
168
+ const at = key.indexOf("@");
169
+ if (at <= 0 || at === key.length - 1) continue;
170
+ if (hash !== -1 && !digest) continue; // `key#` promises a pin and carries none — drop it, do not guess
171
+ out.set(key, digest);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Drop inherited approvals that were given for different instructions (ADR-0022).
178
+ *
179
+ * The hole this closes: `resolveApprovals` checks `inherited` FIRST and none of it had ever been through
180
+ * `entryVerdict`, so ADR-0019's headline property — an approval is void once the instructions change — held
181
+ * on the one path that persists and neither of the two that do not. A child is a fresh process: it re-reads
182
+ * the definition from disk, so a `git pull` between the parent's approval and the child's spawn meant the
183
+ * child ran a rewritten body under a yes given about the old one, recording `approvalSource: "inherited"`.
184
+ *
185
+ * An entry with **no** digest is honoured, and that is not a hole: `<delegate>` legitimately has none, and
186
+ * neither does a pre-0.11 parent. It is the same trade `entryVerdict` refuses to make for a *persisted*
187
+ * entry — but that entry is 30 days old and read off disk, whereas this one was handed over by a live parent
188
+ * process in the same tree, which is a much shorter chain to trust.
189
+ */
190
+ export function verifyInherited(
191
+ parsed: ReadonlyMap<string, string | undefined>,
192
+ snapshotOf: (subject: string) => SubjectSnapshot | null,
193
+ ): Set<string> {
194
+ const out = new Set<string>();
195
+ for (const [key, digest] of parsed) {
196
+ if (digest === undefined) {
197
+ out.add(key);
198
+ continue;
199
+ }
200
+ const current = snapshotOf(key.slice(key.indexOf("@") + 1));
201
+ if (current && current.bodySha256 === digest) out.add(key);
202
+ }
203
+ return out;
204
+ }
205
+
206
+ /** When an approval granted now stops being valid. Computed once at write time and stored, so an entry's
207
+ * lifetime is visible in the file rather than implied by whichever version of the code reads it. */
208
+ export function expiryFor(approvedAt: Date): string {
209
+ return new Date(approvedAt.getTime() + APPROVAL_TTL_DAYS * DAY_MS).toISOString();
210
+ }
211
+
212
+ /** A persisted approval, keyed externally by subject and capability. Only the `"definition"` path writes one. */
213
+ export interface ApprovalEntry {
214
+ approvedAt: string;
215
+ expiresAt: string;
216
+ /** The directory the human was sitting in. See `entryVerdict` and R-27. */
217
+ cwd: string;
218
+ /** The agent type's ceiling AT APPROVAL TIME. Load-bearing, not decorative — see `entryVerdict`. */
219
+ grantAtApproval: Capability[];
220
+ /*
221
+ * `taskAtApproval` was here and is GONE (ADR-0021). It stored the model-authored task string, which
222
+ * `src/ledger.ts` forbids in unqualified terms — "the task is not recorded, anywhere, ever" — and the
223
+ * approval store is a worse home for it than the ledger by ADR-0018's own criteria: always-on, outside
224
+ * the repository, kept for 30 days. It also read as a scope it never was, since the entry authorises ANY
225
+ * task for that definition. Both reviewers found it independently. `approval-store.ts`'s `sanitise`
226
+ * strips it from any entry this version rewrites.
227
+ */
228
+ /**
229
+ * The definition's body digest AT APPROVAL TIME (ADR-0019, using ADR-0018's hash).
230
+ *
231
+ * `grantAtApproval` pins the tools; this pins the INSTRUCTIONS. Without it an `always` approval survives
232
+ * a total rewrite of what the child is told to do, because `ceilingForDefinition` reads only
233
+ * `allowed-tools` — R-35's hazard reappearing inside the persistence layer. Absent means the entry
234
+ * predates 0.10.0 and cannot be verified, which `entryVerdict` treats as changed: fail closed.
235
+ */
236
+ bodyAtApproval?: string;
237
+ }
238
+
239
+ export type EntryVerdict =
240
+ | "valid"
241
+ | "expired"
242
+ | "foreign-cwd"
243
+ | "type-changed"
244
+ | "instructions-changed"
245
+ | "type-missing";
246
+
247
+ /**
248
+ * What a subject looks like RIGHT NOW — one lookup, not two (ADR-0019).
249
+ *
250
+ * Deliberately a single snapshot rather than parallel `ceilingOf` / `digestOf` callbacks. R-28 was one
251
+ * call site supplying one argument and omitting another; a shape that cannot be half-supplied is the
252
+ * structural form of remembering.
253
+ */
254
+ export interface SubjectSnapshot {
255
+ ceiling: Capability[];
256
+ /** SHA-256 of the definition body — `digestDefinition(...).sha256`. */
257
+ bodySha256: string;
258
+ }
259
+
260
+ export interface EntryValidityInput {
261
+ entry: ApprovalEntry;
262
+ /** The directory this session is running in. */
263
+ cwd: string;
264
+ now: Date;
265
+ /** The subject's CURRENT ceiling and body digest, or null when it no longer exists. */
266
+ current: SubjectSnapshot | null;
267
+ }
268
+
269
+ /**
270
+ * Decide whether a persisted approval still means what the human meant.
271
+ *
272
+ * Four ways it can stop meaning that, and each is reported distinctly so `/grants approvals` can explain
273
+ * itself rather than silently showing fewer rows:
274
+ *
275
+ * - `foreign-cwd` — the file was copied or committed and opened somewhere else. Nobody in THIS checkout
276
+ * was asked, so it authorises nothing here (R-27).
277
+ * - `expired` — a gate opened during one project must not still be open next quarter.
278
+ * - `type-changed` — the confused deputy. The key names a file whose contents can change after approval:
279
+ * approve `tool:write@docs-writer` when it declares `read, write`, and later that file
280
+ * gains `bash`. The entry would still match the key while describing something the
281
+ * human never saw.
282
+ * - `instructions-changed` — the same confused deputy, one level deeper. The tools are untouched but the
283
+ * BODY was rewritten, so the child would now be told to do something the human never
284
+ * saw. Only checkable since ADR-0018 gave the body a digest; an entry carrying no body
285
+ * pin lands here too, because unverifiable is not the same as unchanged.
286
+ * - `type-missing` — the type was deleted or renamed; a new file could later claim the same name.
287
+ */
288
+ export function entryVerdict(input: EntryValidityInput): EntryVerdict {
289
+ if (input.entry.cwd !== input.cwd) return "foreign-cwd";
290
+ const expiresAt = new Date(input.entry.expiresAt).getTime();
291
+ // NaN <= x is false in JS, so an unparseable date would otherwise fail OPEN. A cache we cannot
292
+ // read the expiry of is a cache we do not trust — treat it as expired, per the spec's rule that a
293
+ // broken cache grants nothing.
294
+ if (!Number.isFinite(expiresAt) || expiresAt <= input.now.getTime()) return "expired";
295
+ if (input.current === null) return "type-missing";
296
+ // Compare as sorted lists: reformatting or reordering a `tools:` line is not a change; adding,
297
+ // removing, or renaming a capability is.
298
+ const approved = [...input.entry.grantAtApproval].sort().join(",");
299
+ const current = [...input.current.ceiling].sort().join(",");
300
+ if (approved !== current) return "type-changed";
301
+ // Fail closed on an unpinned entry: it was written before bodies were digested, so "unchanged" is not
302
+ // something this code can assert about it. One re-approval is the honest cost.
303
+ if (input.entry.bodyAtApproval !== input.current.bodySha256) return "instructions-changed";
304
+ return "valid";
305
+ }
306
+
307
+ export interface ResolveApprovalsInput {
308
+ /** `ResolveResult.gatedBlocked` — held and within ceiling, but awaiting a human. */
309
+ gated: Capability[];
310
+ subject: string;
311
+ /** Approval KEYS approved for this session, in memory only. */
312
+ sessionApprovals: ReadonlySet<string>;
313
+ /** Persisted entries by key, ALREADY validity-filtered by the store. */
314
+ persisted: ReadonlyMap<string, ApprovalEntry>;
315
+ /** Capabilities approved further up the tree and inherited with the grant. */
316
+ /**
317
+ * `capability@subject` keys inherited from the delegator (ADR-0014).
318
+ *
319
+ * Was `Capability[]` — bare names that matched any subject, and included `once` approvals that were
320
+ * never meant to leave the level they were given at.
321
+ */
322
+ inherited?: Set<string>;
323
+ }
324
+
325
+ export interface ResolveApprovalsResult {
326
+ approved: Capability[];
327
+ /** Gated capabilities still requiring a live human. */
328
+ needsPrompt: Capability[];
329
+ sources: Record<Capability, ApprovalSource>;
330
+ }
331
+
332
+ /**
333
+ * Satisfy as much of `gated` as possible without asking anyone.
334
+ *
335
+ * Precedence is inherited -> session -> persisted -> prompt. Order matters only for what gets REPORTED
336
+ * (the ledger's `approvalSource`); any hit satisfies equally. Checking all three before prompting is what
337
+ * stops an orchestrator's tenth delegation from raising a tenth identical dialog.
338
+ */
339
+ export function resolveApprovals(input: ResolveApprovalsInput): ResolveApprovalsResult {
340
+ // ADR-0014: these are `capability@subject` keys now, not bare capabilities, so an approval given for
341
+ // one subject can no longer satisfy another.
342
+ const inherited = input.inherited ?? new Set<string>();
343
+ const approved: Capability[] = [];
344
+ const needsPrompt: Capability[] = [];
345
+ const sources: Record<Capability, ApprovalSource> = {};
346
+
347
+ for (const capability of [...new Set(input.gated)].sort()) {
348
+ const key = approvalKey(capability, input.subject);
349
+ if (inherited.has(key)) {
350
+ approved.push(capability);
351
+ sources[capability] = "inherited";
352
+ } else if (input.sessionApprovals.has(key)) {
353
+ approved.push(capability);
354
+ sources[capability] = "session";
355
+ } else if (input.persisted.has(key)) {
356
+ approved.push(capability);
357
+ sources[capability] = "persisted";
358
+ } else {
359
+ needsPrompt.push(capability);
360
+ }
361
+ }
362
+
363
+ return { approved, needsPrompt, sources };
364
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Capability ids: their grammar, and the two authority predicates over them.
3
+ *
4
+ * Split out of `delegate.ts` when ADR-0024 pushed that file past the 400-line ceiling and
5
+ * `test/file-size.test.ts` refused it — for the second time, and answered the same way as the first: split
6
+ * the file the way the failure message says rather than raise the cap on the guard.
7
+ *
8
+ * The seam is not arbitrary. Three modules outside `delegate.ts` already imported these
9
+ * (`extensions/session.ts` for `DELEGATE_CAPABILITY`, `extensions/delegation.ts` and
10
+ * `extensions/run-delegation.ts` for `maySpawnDefinition`), which is the evidence that they were a separate
11
+ * concern living in the wrong file: *what an id means and who may use it* is a different question from
12
+ * *what one delegation does*.
13
+ */
14
+
15
+ import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
16
+ import { WILDCARD } from "./pi-tools.ts";
17
+
18
+ /** The capability that authorises spawning a definition (ADR-0017). `tool:*` satisfies any of them. */
19
+ export const agentCapability = (name: string): Capability => `agent:${name}`;
20
+
21
+ /**
22
+ * May this grant spawn that definition? (ADR-0017.)
23
+ *
24
+ * `resolve()` is exact-match plus subsumption and has no wildcard rule — a wildcard session works only
25
+ * because `deriveOwnGrant` *enumerates* its observed tools alongside `tool:*`. Definitions are not tools,
26
+ * so nothing enumerates them, and the wildcard has to be honoured here explicitly. Without that an
27
+ * UNGOVERNED session would stop being able to spawn, and "governance is opt-in" is the one rule this
28
+ * package must never break by accident.
29
+ */
30
+ export function maySpawnDefinition(ownGrant: Capability[], name: string): boolean {
31
+ // ADR-0023 adds the middle case. `tool:*` is authority to grant every tool and satisfies this too;
32
+ // `agent:*` is authority to spawn any definition and grants no tools at all, which is the configuration
33
+ // an operator wanting "any of our definitions, narrow tools" previously had to fake with `tool:*`.
34
+ return (
35
+ ownGrant.includes(WILDCARD) ||
36
+ ownGrant.includes(AGENT_WILDCARD) ||
37
+ ownGrant.includes(agentCapability(name))
38
+ );
39
+ }
40
+
41
+ /** The tool name that confers the ability to delegate further. */
42
+ export const DELEGATE_CAPABILITY: Capability = "tool:delegate";
43
+
44
+ /** Accept `read` or `tool:read` or `ext:pkg/tool` and normalise to a capability id. */
45
+ export function normaliseCapability(raw: string): Capability {
46
+ const value = raw.trim();
47
+ if (value.startsWith("tool:") || value.startsWith("ext:") || value.startsWith("skill:") || value.startsWith("agent:")) {
48
+ return value;
49
+ }
50
+ return `tool:${value}`;
51
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Live capability catalog — what *can* be granted, enumerated at runtime.
3
+ *
4
+ * Until now grants were checked against agent-type files alone, which covers `tool:` capabilities and
5
+ * nothing else. That leaves two gaps: extension tools are invisible (so `ext:` grants cannot be
6
+ * validated), and **skills were ungovernable** despite "skills and tools" being half the requirement.
7
+ *
8
+ * Sources, and why each is trusted:
9
+ * - **tools** — the `tools` array of a live provider request. Authoritative: it is exactly what pi sent
10
+ * the model, so it includes extension-registered tools and reflects any `--tools` allowlist already in
11
+ * force. Nothing else can see the real surface.
12
+ * - **skills** — `SKILL.md` directories and top-level `.md` files under pi's skill roots.
13
+ * - **definitions** — spawnable `SKILL.md` agents (ADR-0016), as `agent:<name>`.
14
+ *
15
+ * Provenance note: a provider payload gives tool NAMES, not owning packages, so extension tools cannot be
16
+ * qualified as `ext:<pkg>/<tool>` from that source alone. They are catalogued as `tool:<name>` — which is
17
+ * also how pi's `--tools` matches — and marked `kind: "extension"` for display. `ext:` ids remain
18
+ * supported for hand-authored grants; `deriveOwnGrant` already matches them by bare name.
19
+ */
20
+
21
+ import { readdir, stat } from "node:fs/promises";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
25
+ import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
26
+ import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
27
+
28
+ export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType";
29
+
30
+ export interface CatalogEntry {
31
+ capability: Capability;
32
+ kind: CapabilityKind;
33
+ /** Where it was found, for display and debugging. */
34
+ source?: string;
35
+ }
36
+
37
+ export interface Catalog {
38
+ entries: CatalogEntry[];
39
+ /** Every capability id in the catalog. */
40
+ all: Capability[];
41
+ byKind(kind: CapabilityKind): Capability[];
42
+ has(capability: Capability): boolean;
43
+ }
44
+
45
+ /** Split observed tool names into pi built-ins and extension-provided tools. */
46
+ export function classifyToolNames(observed: string[]): CatalogEntry[] {
47
+ const builtins = new Set<string>(PI_BUILTIN_TOOLS);
48
+ return [...new Set(observed)].sort().map((name) => ({
49
+ capability: `tool:${name}`,
50
+ kind: builtins.has(name) ? ("builtin" as const) : ("extension" as const),
51
+ }));
52
+ }
53
+
54
+ /** Skill roots pi discovers, project first. */
55
+ export function skillDirs(cwd: string): string[] {
56
+ return [join(cwd, ".pi", "skills"), join(homedir(), ".pi", "agent", "skills")];
57
+ }
58
+
59
+ /**
60
+ * Discover skills: a directory containing `SKILL.md` is one skill named after the directory; a top-level
61
+ * `.md` file is a skill named after the file. Mirrors pi's documented convention.
62
+ *
63
+ * Directories are not descended into beyond one level, matching pi's rule that a directory containing
64
+ * `SKILL.md` is a single skill rather than a tree to explore.
65
+ */
66
+ export async function loadSkills(cwd: string): Promise<CatalogEntry[]> {
67
+ const found = new Map<string, CatalogEntry>();
68
+ for (const dir of skillDirs(cwd)) {
69
+ let names: string[];
70
+ try {
71
+ names = await readdir(dir);
72
+ } catch {
73
+ continue; // absent skill root is normal
74
+ }
75
+ for (const name of names.sort()) {
76
+ const path = join(dir, name);
77
+ try {
78
+ const info = await stat(path);
79
+ if (info.isDirectory()) {
80
+ const inner = await readdir(path).catch(() => [] as string[]);
81
+ if (inner.includes("SKILL.md") && !found.has(name)) {
82
+ found.set(name, { capability: `skill:${name}`, kind: "skill", source: path });
83
+ }
84
+ } else if (name.endsWith(".md")) {
85
+ const skill = name.replace(/\.md$/, "");
86
+ if (!found.has(skill)) {
87
+ found.set(skill, { capability: `skill:${skill}`, kind: "skill", source: path });
88
+ }
89
+ }
90
+ } catch {
91
+ // An unreadable entry is simply not catalogued; it therefore cannot be granted, which is the
92
+ // fail-closed direction.
93
+ }
94
+ }
95
+ }
96
+ return [...found.values()];
97
+ }
98
+
99
+ /**
100
+ * Spawnable definitions, as `agent:<name>` capabilities.
101
+ *
102
+ * A definition is BOTH a skill (loadable into a session) and an agent (spawnable as a child) — ADR-0016
103
+ * collapsed those into one file — so the same `SKILL.md` legitimately appears twice in the catalog under
104
+ * two capability ids. That is not duplication: `skill:review` means "may load these instructions" and
105
+ * `agent:review` means "may spawn a child running them", and a grant can hold either without the other.
106
+ */
107
+ export function definitionEntries(definitions: Map<string, SkillDefinition>): CatalogEntry[] {
108
+ return [...definitions.values()].map((d) => ({
109
+ capability: `agent:${d.name}`,
110
+ kind: "agentType" as const,
111
+ source: d.source,
112
+ }));
113
+ }
114
+
115
+ /** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
116
+ export function makeCatalog(entries: CatalogEntry[]): Catalog {
117
+ const deduped = new Map<Capability, CatalogEntry>();
118
+ for (const entry of entries) if (!deduped.has(entry.capability)) deduped.set(entry.capability, entry);
119
+ const list = [...deduped.values()].sort((a, b) => a.capability.localeCompare(b.capability));
120
+ const ids = list.map((e) => e.capability);
121
+ const idSet = new Set(ids);
122
+ return {
123
+ entries: list,
124
+ all: ids,
125
+ byKind: (kind) => list.filter((e) => e.kind === kind).map((e) => e.capability),
126
+ has: (capability) => idSet.has(capability),
127
+ };
128
+ }
129
+
130
+ /** Build the live catalog. `observedTools` comes from a provider payload; null when not yet seen. */
131
+ export async function buildCatalog(input: {
132
+ cwd: string;
133
+ observedTools: string[] | null;
134
+ }): Promise<Catalog> {
135
+ const [skills, definitions] = await Promise.all([loadSkills(input.cwd), loadDefinitions(input.cwd)]);
136
+ return makeCatalog([
137
+ // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
138
+ // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
139
+ // every capability looked "unknown" until the first model call, so the preview refused grants that
140
+ // enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
141
+ // reappearing through a different door.
142
+ //
143
+ // The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
144
+ // as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
145
+ // the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
146
+ // the grant check — not this catalog — is the authority. Nothing here grants anything.
147
+ ...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
148
+ ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
149
+ ...skills,
150
+ ...definitionEntries(definitions),
151
+ ]);
152
+ }
153
+
154
+ /**
155
+ * Capabilities requested that the catalog does not contain.
156
+ *
157
+ * Reported separately from `denied` because the causes differ and so do the fixes: `denied` means the
158
+ * delegator lacks authority, `unknown` means the capability does not exist here — usually a typo or a
159
+ * stale grant referring to an uninstalled package. Silently treating unknown as denied hides that.
160
+ */
161
+ export function unknownCapabilities(requested: Capability[], catalog: Catalog): Capability[] {
162
+ // Wildcards are GRAMMAR, not entries. Nothing enumerates them into the catalog — `definitionEntries`
163
+ // emits `agent:<name>` per discovered definition and `PI_BUILTIN_TOOLS` contains no `*` — so this check
164
+ // reported `agent:*` as *"not present in this session's catalog (typo, or an uninstalled package?)"* and
165
+ // refused it BEFORE `resolve` could apply ADR-0023's rule. That made the ADR's "a parent holding
166
+ // `agent:*` may hand down `agent:*`" false, and made a definition declaring `allowed-tools: agent:*`
167
+ // unspawnable from any grant. The wildcard is live only at the root without this.
168
+ return requested.filter((c) => c !== WILDCARD && c !== AGENT_WILDCARD && !catalog.has(c)).sort();
169
+ }
170
+
171
+ /**
172
+ * Skill name -> absolute path, for `planSpawn`'s `--skill` flags (R-32).
173
+ *
174
+ * Derived from the catalog's own `source` field rather than re-scanning, so what a child is handed
175
+ * cannot drift from what was discovered and offered. A skill entry without a source is omitted, which
176
+ * makes it *unresolvable* rather than silently absent — `planDelegation` refuses on that, because a
177
+ * grant naming a skill the child never receives is a ledger line that lies.
178
+ */
179
+ export function skillPathsFromCatalog(catalog: Catalog): Record<string, string> {
180
+ const paths: Record<string, string> = {};
181
+ for (const entry of catalog.entries) {
182
+ if (entry.kind !== "skill" || !entry.source) continue;
183
+ paths[entry.capability.slice("skill:".length)] = entry.source;
184
+ }
185
+ return paths;
186
+ }