pi-daddy 0.14.0 → 0.16.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 (80) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +37 -14
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +10 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/executor.d.ts +38 -0
  7. package/dist/executor.d.ts.map +1 -0
  8. package/dist/executor.js +93 -0
  9. package/dist/executor.js.map +1 -0
  10. package/dist/grant-store.d.ts +68 -0
  11. package/dist/grant-store.d.ts.map +1 -0
  12. package/dist/grant-store.js +142 -0
  13. package/dist/grant-store.js.map +1 -0
  14. package/dist/herdr-cli.d.ts +78 -0
  15. package/dist/herdr-cli.d.ts.map +1 -0
  16. package/dist/herdr-cli.js +113 -0
  17. package/dist/herdr-cli.js.map +1 -0
  18. package/dist/herdr-name.d.ts +37 -0
  19. package/dist/herdr-name.d.ts.map +1 -0
  20. package/dist/herdr-name.js +59 -0
  21. package/dist/herdr-name.js.map +1 -0
  22. package/dist/herdr-poll.d.ts +104 -0
  23. package/dist/herdr-poll.d.ts.map +1 -0
  24. package/dist/herdr-poll.js +150 -0
  25. package/dist/herdr-poll.js.map +1 -0
  26. package/dist/herdr-stage.d.ts +40 -0
  27. package/dist/herdr-stage.d.ts.map +1 -0
  28. package/dist/herdr-stage.js +54 -0
  29. package/dist/herdr-stage.js.map +1 -0
  30. package/dist/ledger-report.d.ts +18 -0
  31. package/dist/ledger-report.d.ts.map +1 -1
  32. package/dist/ledger-report.js +10 -0
  33. package/dist/ledger-report.js.map +1 -1
  34. package/dist/ledger.d.ts +17 -0
  35. package/dist/ledger.d.ts.map +1 -1
  36. package/dist/ledger.js +1 -0
  37. package/dist/ledger.js.map +1 -1
  38. package/dist/pane-reaper.d.ts +66 -4
  39. package/dist/pane-reaper.d.ts.map +1 -1
  40. package/dist/pane-reaper.js +131 -9
  41. package/dist/pane-reaper.js.map +1 -1
  42. package/dist/progress.d.ts +96 -0
  43. package/dist/progress.d.ts.map +1 -0
  44. package/dist/progress.js +167 -0
  45. package/dist/progress.js.map +1 -0
  46. package/dist/run-child.d.ts +27 -0
  47. package/dist/run-child.d.ts.map +1 -1
  48. package/dist/run-child.js +84 -7
  49. package/dist/run-child.js.map +1 -1
  50. package/dist/run-herdr.d.ts +41 -28
  51. package/dist/run-herdr.d.ts.map +1 -1
  52. package/dist/run-herdr.js +150 -167
  53. package/dist/run-herdr.js.map +1 -1
  54. package/dist/skill-packages.d.ts +16 -0
  55. package/dist/skill-packages.d.ts.map +1 -1
  56. package/dist/skill-packages.js +39 -10
  57. package/dist/skill-packages.js.map +1 -1
  58. package/extensions/delegation.ts +94 -2
  59. package/extensions/grants-command.ts +57 -1
  60. package/extensions/grants.ts +109 -165
  61. package/extensions/init-command.ts +132 -0
  62. package/extensions/run-delegation.ts +70 -8
  63. package/extensions/session-report.ts +231 -0
  64. package/extensions/session.ts +138 -17
  65. package/extensions/tripwire.ts +44 -0
  66. package/package.json +17 -1
  67. package/src/cli.ts +10 -4
  68. package/src/executor.ts +122 -0
  69. package/src/grant-store.ts +151 -0
  70. package/src/herdr-cli.ts +125 -0
  71. package/src/herdr-name.ts +61 -0
  72. package/src/herdr-poll.ts +185 -0
  73. package/src/herdr-stage.ts +55 -0
  74. package/src/ledger-report.ts +21 -0
  75. package/src/ledger.ts +18 -0
  76. package/src/pane-reaper.ts +147 -9
  77. package/src/progress.ts +206 -0
  78. package/src/run-child.ts +96 -7
  79. package/src/run-herdr.ts +170 -174
  80. package/src/skill-packages.ts +41 -10
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Everything this extension says at session start.
3
+ *
4
+ * Lifted out of `extensions/grants.ts` for the reason `grants-command.ts` and `session.ts` were: that file is
5
+ * where every wiring bug in this package has lived, and it had reached **398 of the 400-line ceiling**
6
+ * `test/file-size.test.ts` enforces. ADR-0032 adds a control there, so the file had to be split before it
7
+ * could be added — the alternative was raising the cap, which is how a guard stops guarding. This project
8
+ * split `delegate.ts` at 413 rather than raise it, and that precedent is the whole argument.
9
+ *
10
+ * The seam is the same one twice over: `grants.ts` keeps the HOOKS and the wiring; this module decides what an
11
+ * operator is **told**. Nothing here returns a value or mutates the session, which is what makes it safe to
12
+ * lift — a reporter cannot become a governance path by accident.
13
+ *
14
+ * **Each control keeps its own `try`.** That is R-60's lesson rather than tidiness: one added `await` inside a
15
+ * shared `catch` cancels every control below it with no trace, and that is exactly how an unreadable ledger
16
+ * came to silence the `holding [...]` line too.
17
+ */
18
+
19
+ import { existsSync } from "node:fs";
20
+ import { legacyApprovalsPath, sharedApprovalsPath } from "../src/approval-store.ts";
21
+ import { verifyLedger } from "../src/ledger.ts";
22
+ import { AGENT_WILDCARD } from "../src/resolve.ts";
23
+ import { planWithApprovals } from "./run-delegation.ts";
24
+ import type { GrantsSession } from "./session.ts";
25
+ import { renderSpawnableSummary, summariseSpawnable } from "./spawn-summary.ts";
26
+
27
+ /**
28
+ * The slice of pi's context this module needs: a working directory and somewhere to speak.
29
+ *
30
+ * Named explicitly rather than taking `ExtensionContext` whole, for `grants-command.ts`'s reason — what a
31
+ * read-only reporter may see is a decision, and it belongs in a type rather than in whatever happened to be
32
+ * in scope.
33
+ */
34
+ export interface SessionReportContext {
35
+ cwd: string;
36
+ ui: { notify(message: string, level: "info" | "warning" | "error"): void };
37
+ }
38
+
39
+ export async function reportSessionStart(session: GrantsSession, ctx: SessionReportContext): Promise<void> {
40
+ // A malformed bound is now loud as well as safe. Silently disabling spawning would be just as
41
+ // confusing as silently disabling the limit was dangerous — the operator set the variable, so
42
+ // they need to know it did not take effect (G7 / A-S4).
43
+ if (session.malformedBounds.length > 0) {
44
+ ctx.ui.notify(
45
+ `grants: ${session.malformedBounds.join(" and ")} could not be read as a non-negative integer — ` +
46
+ `spawning is disabled for this session (failing closed)`,
47
+ "warning",
48
+ );
49
+ }
50
+ // ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
51
+ // import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
52
+ // operator whose approvals silently stopped applying deserves to know why.
53
+ // ADR-0020: the pre-0.11 single shared store is ignored, not migrated. Same reasoning shape as the
54
+ // legacy file below — an operator whose approvals silently stopped applying must be told why — but a
55
+ // different reason for not migrating: splitting it by `cwd` would be lossless, and it is still declined
56
+ // because one-shot migration code in the layer with nine defects buys less than one re-approval costs.
57
+ try {
58
+ if (existsSync(sharedApprovalsPath())) {
59
+ ctx.ui.notify(
60
+ `grants: ignoring ${sharedApprovalsPath()} — approvals are now stored one file per governed ` +
61
+ `directory (ADR-0020), because a single shared file could not hold two projects' approvals ` +
62
+ `for a same-named definition. Re-approve when next asked. **Deleting the old file is ` +
63
+ `recommended, not merely safe**: entries written by 0.10.x may contain the task text a model ` +
64
+ `composed at approval time, which this version no longer stores anywhere (ADR-0021).`,
65
+ "warning",
66
+ );
67
+ }
68
+ } catch {
69
+ /* never throw into the agent loop */
70
+ }
71
+ try {
72
+ if (existsSync(legacyApprovalsPath(ctx.cwd))) {
73
+ ctx.ui.notify(
74
+ `grants: ignoring ${legacyApprovalsPath(ctx.cwd)} — approvals now live outside the workspace ` +
75
+ `(it was writable by the very agents it gated). Re-approve when next asked; the old file is ` +
76
+ `safe to delete.`,
77
+ "warning",
78
+ );
79
+ }
80
+ } catch {
81
+ /* never throw into the agent loop */
82
+ }
83
+ // R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
84
+ // definition's CEILING — which never contains `agent:<name>`, because the authorisation check
85
+ // (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
86
+ // who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
87
+ // no dialog and no warning. It DOES bite when a definition passes the id down in its own
88
+ // `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
89
+ // the namespace ADR-0017 just promoted out of exactly that state.
90
+ //
91
+ // Warned rather than enforced: making it gate the spawn is a behaviour change and wants a decision.
92
+ // Silence is the part that is indefensible either way.
93
+ // `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
94
+ // `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
95
+ // file" is not true of everything it covers. Paired with a shell that is every body on disk running
96
+ // with `bash`. `docs/SPEC.md` calls the combination poor and nothing detected it, which is R-47's
97
+ // shape in a control shipped one day later.
98
+ if (session.ownGrant.includes(AGENT_WILDCARD) && session.gated.length === 0 && session.ownGrant.includes("tool:bash")) {
99
+ ctx.ui.notify(
100
+ `grants: PI_GRANTS_GRANT pairs agent:* with tool:bash and gates nothing — every SKILL.md in ` +
101
+ `this project AND in ~/.pi/agent/skills (which other tools install into) may run with a shell. ` +
102
+ `Enumerate the agent: ids you mean, or leave PI_GRANTS_GATED at its default so bash is asked for.`,
103
+ "warning",
104
+ );
105
+ }
106
+ const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
107
+ if (inertGates.length > 0) {
108
+ ctx.ui.notify(
109
+ `grants: ${inertGates.join(", ")} in PI_GRANTS_GATED does NOT gate spawning that definition — ` +
110
+ `the authorisation check for a definition is separate and ungated, so a human is never asked. ` +
111
+ `It applies only where a definition passes the id down in its own allowed-tools. To control ` +
112
+ `which definitions may run, withhold the agent: capability from PI_GRANTS_GRANT instead.`,
113
+ "warning",
114
+ );
115
+ }
116
+ // R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
117
+ // and a check an operator has to know to run is not a control, it is a feature. Setting
118
+ // `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
119
+ // part of keeping one.
120
+ //
121
+ // Corruption only, deliberately. The escalation count is a *query* — `/grants ledger` answers it —
122
+ // and reporting historical attempts unprompted at every start is the fatigue shape R-25 names, which
123
+ // ends with the operator ignoring the line that matters.
124
+ //
125
+ // Awaited rather than fired and forgotten: it is one read, on a path that already awaits two
126
+ // directory scans, and awaiting is what guarantees the warning reaches a live `ctx.ui`.
127
+ //
128
+ // R-60. `verifyLedger` RETHROWS every read error that is not ENOENT — right for `/grants ledger`,
129
+ // where an operator asked a direct question and deserves the failure — and this call is the only one
130
+ // that makes it inside the blanket catch below. So an unreadable ledger threw here and cancelled every
131
+ // remaining control **in silence**: no alarm, and not even the `holding [...]` line that is the one
132
+ // sign governance is on. Confirmed by execution — `PI_GRANTS_LEDGER` naming a directory produced ZERO
133
+ // notifications from a governed session. A trail that cannot be read at all is a worse failure than a
134
+ // torn line, and it was the one case this control said nothing about.
135
+ if (session.ledgerPath) {
136
+ try {
137
+ const report = await verifyLedger(session.ledgerPath);
138
+ if (report.exists && !report.ok) {
139
+ ctx.ui.notify(
140
+ `grants: ledger ${session.ledgerPath} has ${report.corrupt.length} unparseable line(s) — ` +
141
+ `first at line ${report.corrupt[0]?.line}. A torn line is indistinguishable from a spawn that ` +
142
+ `never happened, so this audit trail is incomplete. Run /grants ledger for detail; the file is ` +
143
+ `left alone because a corrupt line is evidence.`,
144
+ "error",
145
+ );
146
+ }
147
+ } catch (error) {
148
+ ctx.ui.notify(
149
+ `grants: ledger ${session.ledgerPath} could not be read ` +
150
+ `(${(error as { code?: string }).code ?? String(error)}) — nothing can be verified about this ` +
151
+ `audit trail, and the first spawn will refuse rather than proceed unrecorded. Check that ` +
152
+ `PI_GRANTS_LEDGER names a writable FILE.`,
153
+ "error",
154
+ );
155
+ }
156
+ }
157
+ // ADR-0031 rests on this line existing: an executor chosen by a probe is only defensible if it is announced.
158
+ //
159
+ // **`mayDelegate`, not `governed`** — and that distinction is a defect caught in review before it shipped.
160
+ // An UNGOVERNED session still registers `delegate` and still spawns (`mayDelegate` is true when
161
+ // `!governed`), so gating this on `governed` would have relocated an ungoverned session's children into
162
+ // herdr panes and said nothing about it. That is precisely the "silently" objection ADR-0031 claims to have
163
+ // discharged, reappearing inside the fix for it — R-28's shape, in the one configuration nobody tests.
164
+ //
165
+ // The guard is not simply dropped because a session that cannot spawn at all has no executor worth naming.
166
+ // **Every `info` line is joined into ONE notify, and that is a fix rather than formatting.**
167
+ //
168
+ // Measured against real pi 0.84.2 in a pty: `notify(…, "info")` maps to `showStatus`, which **replaces the
169
+ // previous status text in place** when the last two transcript children are the pair it created — which is
170
+ // exactly the case for back-to-back notifies. So consecutive `info` calls overwrite each other, and only the
171
+ // last survives. In a governed session with definitions that meant the executor line AND the
172
+ // `holding [...]` line were both gone, leaving only the spawnable summary — and `grants.ts` calls
173
+ // `holding [...]` "the one sign governance is on". **That half is a pre-existing defect**, true since the
174
+ // spawnable summary was added; ADR-0031's disclosure merely became its third victim.
175
+ //
176
+ // Six tests asserted these lines were *composed*. None asserted they were *delivered*: the unit harness
177
+ // pushes to an array and the integration harness runs `--mode rpc`, where each notify is its own JSON line.
178
+ // Both are replace-free, so neither could see this.
179
+ //
180
+ // Warnings and errors are NOT folded in — they go to different components (`showError`), survive on their
181
+ // own, and each says something an operator may need to act on separately.
182
+ const info: string[] = [];
183
+
184
+ if (session.mayDelegate && !session.executor.refusal) {
185
+ info.push(`grants: executor — ${session.executor.disclosure}`);
186
+ }
187
+ if (session.mayDelegate && session.executor.refusal) {
188
+ // An error, not an FYI: every delegation in this session will refuse. Emitted separately because `error`
189
+ // routes elsewhere and therefore is not at risk of being overwritten.
190
+ ctx.ui.notify(`grants: executor — ${session.executor.disclosure}`, "error");
191
+ }
192
+
193
+ if (session.governed) {
194
+ info.push(
195
+ `grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
196
+ );
197
+ // B1 / P4. The grant alone never named the definitions, never said where they came from, and never
198
+ // said which ones were being WITHHELD — so an operator who had just installed a package of
199
+ // `SKILL.md` files could not tell governance-is-working from did-the-install-fail. Classified by the
200
+ // real planner (see `./spawn-summary.ts`), never by a second reading of the rules.
201
+ //
202
+ // Its own try/catch, and not because `summariseSpawnable` throws today: this is the R-60 shape
203
+ // exactly — one added `await` inside the blanket catch cancelling every control below it in
204
+ // silence.
205
+ try {
206
+ const line = renderSpawnableSummary(
207
+ await summariseSpawnable(
208
+ session.definitions,
209
+ (name) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
210
+ // The session facts that make every per-definition verdict identical. `mayDelegate` in
211
+ // particular: without `tool:delegate` there is no delegate tool at all, and the line used to
212
+ // report definitions as spawnable in the one session where nothing can ever be spawned.
213
+ { mayDelegate: session.mayDelegate, depth: session.depth, maxDepth: session.maxDepth },
214
+ ),
215
+ session.definitions.size,
216
+ );
217
+ if (line) info.push(line);
218
+ } catch (error) {
219
+ ctx.ui.notify(
220
+ `grants: could not work out which definitions are spawnable ` +
221
+ `(${error instanceof Error ? error.message : String(error)}) — run /grants for the per-definition ` +
222
+ `verdict. Nothing about the grant or its enforcement depends on this line.`,
223
+ "warning",
224
+ );
225
+ }
226
+ }
227
+
228
+ // One call, so nothing can overwrite anything else. `/grants` already worked this way, which is why its
229
+ // multi-line status screen has always survived while these separate lines did not.
230
+ if (info.length > 0) ctx.ui.notify(info.join("\n"), "info");
231
+ }
@@ -20,6 +20,8 @@ import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog
20
20
  import type { SkillDefinition } from "../src/definitions.ts";
21
21
  import { DELEGATE_CAPABILITY, type DelegationContext } from "../src/delegate.ts";
22
22
  import { budgetFromEnv } from "../src/fanout.ts";
23
+ import { chooseExecutor, needsProbe, ENV_HERDR, type ExecutorChoice } from "../src/executor.ts";
24
+ import { probeHerdr } from "../src/herdr-cli.ts";
23
25
  import { WILDCARD } from "../src/pi-tools.ts";
24
26
  import {
25
27
  childEnv,
@@ -37,25 +39,53 @@ import {
37
39
  parseList,
38
40
  } from "../src/propagation.ts";
39
41
  import type { Capability } from "../src/resolve.ts";
42
+ import { loadDefinitions } from "../src/definitions.ts";
43
+ import { buildCatalog } from "../src/catalog.ts";
44
+ import { loadGrantSync, grantStorePath } from "../src/grant-store.ts";
40
45
  import { republishable } from "./approvals.ts";
41
46
 
42
47
  /**
43
- * Run governed children in herdr panes instead of captured child processes (ADR-0016 point 6).
48
+ * Run governed children in herdr panes instead of captured child processes.
44
49
  *
45
- * Opt-in, and deliberately not auto-detected from `herdr` being on PATH: where a governed child executes
46
- * is an operator decision, and a run that silently relocates because a binary appeared is exactly the kind
47
- * of invisible change this package exists to prevent. Both executors enforce the identical grant — the
48
- * plan is the same, only the place it runs differs.
50
+ * **Three-state as of ADR-0031, and absent means PROBE.** It was opt-in under ADR-0016 point 6, on the
51
+ * reasoning that *"a run that silently relocates because a binary appeared is exactly the kind of invisible
52
+ * change this package exists to prevent"* — and that sentence is still honoured, because nothing is detected
53
+ * from `herdr` being on `PATH`. What changed is that a **server which answers** is a different and stronger
54
+ * test, and the "silently" half is discharged by the disclosure line ADR-0032 adds at session start and in
55
+ * `/grants`. Both executors still enforce the identical grant: the plan is the same, only the place it runs
56
+ * differs.
57
+ *
58
+ * The table itself is a pure function in `../src/executor.ts`; re-exported here because this is where every
59
+ * other `PI_GRANTS_*` name lives and a reader looking for it will look here.
60
+ */
61
+ export { ENV_HERDR } from "../src/executor.ts";
62
+ /**
63
+ * herdr workspace for spawned panes — re-exported from where it is actually READ.
64
+ *
65
+ * It was declared here and read nowhere: `resolveWorkspace` reads the string literal, so the constant and the
66
+ * literal could drift with nothing binding them. Re-exporting the single definition keeps this the place a reader
67
+ * looks for a `PI_GRANTS_*` name without letting two spellings exist.
68
+ *
69
+ * Omitting the variable no longer means "let herdr choose": it falls back to the parent's own
70
+ * `HERDR_WORKSPACE_ID`, because a child in a different workspace from the session that spawned it makes switching
71
+ * to it a workspace hop (ADR-0032). This name is the operator's explicit override.
49
72
  */
50
- export const ENV_HERDR = "PI_GRANTS_HERDR";
51
- /** herdr workspace for spawned panes. Omitted lets herdr choose. */
52
- export const ENV_HERDR_WORKSPACE = "PI_GRANTS_HERDR_WORKSPACE";
73
+ export { ENV_HERDR_WORKSPACE } from "../src/herdr-cli.ts";
53
74
  /** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
54
75
  export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
55
76
 
56
77
  export interface GrantsSession {
57
- /** False when `PI_GRANTS_GRANT` is unset: the session holds the wildcard and nothing is governed. */
58
- readonly governed: boolean;
78
+ /**
79
+ * False when neither `PI_GRANTS_GRANT` nor a stored grant applies: the session holds the wildcard and
80
+ * nothing is governed.
81
+ *
82
+ * **Mutable, because `/grants init` makes an ungoverned session governed mid-run.** The first version of
83
+ * ADR-0030 left this readonly on the reasoning that a store is read at creation and `init` only runs where
84
+ * one exists — which is false for the first `init` in a directory, the most common case there is. The
85
+ * session then bounded every spawn by the new grant while `/grants` reported "inactive", so the status
86
+ * line contradicted the enforcer. Found by running it.
87
+ */
88
+ governed: boolean;
59
89
  /** The upper bound handed down by the delegator, before this session's own tools are observed. */
60
90
  readonly inherited: Capability[];
61
91
  readonly depth: number;
@@ -64,7 +94,18 @@ export interface GrantsSession {
64
94
  readonly malformedBounds: string[];
65
95
  readonly gated: Capability[];
66
96
  readonly ledgerPath?: string;
67
- readonly useHerdr: boolean;
97
+ /**
98
+ * Which executor runs this session's children — ADR-0031.
99
+ *
100
+ * **Mutable, and for ADR-0030's reason exactly.** Settling it needs a probe, the probe is async, and this
101
+ * object is built *synchronously* in the extension factory — an ordering S-5 forces, since whether
102
+ * `delegate` is registered at all is decided there. So it starts as the un-probed reading and is replaced by
103
+ * `resolveExecutor` once `session_start` has probed.
104
+ *
105
+ * Nothing may capture a copy: read it through the session, live. A copy taken in the factory is a copy taken
106
+ * before the probe, which is the same hazard as capturing `ownGrant` before the tool surface is observed.
107
+ */
108
+ executor: ExecutorChoice;
68
109
  /** This session's ledger identity; children descend from it (F8). */
69
110
  readonly ownSpawnId: string;
70
111
  /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
@@ -129,6 +170,23 @@ export interface GrantsSession {
129
170
  * prompted the human. A value scoped to one specific child is never written to this global channel.
130
171
  */
131
172
  publishChildEnv(): void;
173
+ /**
174
+ * The directory whose stored grant this session read, or would read. `process.cwd()` — see the note in
175
+ * `createGrantsSession` for why the factory cannot use `ctx.cwd`.
176
+ */
177
+ readonly storeCwd: string;
178
+ /**
179
+ * Adopt a grant decided DURING the session — `/grants init` answering a human — without a restart.
180
+ *
181
+ * Narrow by design: it sets the session's own grant and republishes, so the very next spawn is bounded by
182
+ * it. It does **not** reach children that already exist; those are separate processes whose environment
183
+ * was fixed when they started, and reaching into them is neither possible nor desirable — a child's
184
+ * ceiling should not move under it mid-run.
185
+ *
186
+ * Only a human can reach this. Slash commands are user-invoked; no tool exposes it, so a model cannot
187
+ * widen its own session's ceiling by calling something.
188
+ */
189
+ adoptGrant(grant: Capability[]): void;
132
190
  }
133
191
 
134
192
  /**
@@ -138,12 +196,56 @@ export interface GrantsSession {
138
196
  * extension**, so a child granted `tool:delegate` can be started with `-e <that file>`. `grants.ts` is that
139
197
  * file, and only `grants.ts` can say so about itself.
140
198
  */
199
+ /**
200
+ * Load this project's definitions and capability catalog into the session.
201
+ *
202
+ * **One loader, two callers.** `session_start` runs it, and so does `/grants init` — which writes the very
203
+ * files it reads, so a session that skipped this held `agent:review` while believing no definition of that
204
+ * name existed, and the model was told `Available: none` (R-39's shape, reintroduced by the feature whose
205
+ * selling point is "no restart"). Two copies of these three steps is how the two callers come to disagree
206
+ * about what loading means, so there is one.
207
+ */
208
+ export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
209
+ session.definitions = await loadDefinitions(cwd);
210
+ session.catalogReady = buildCatalog({ cwd, observedTools: session.observedTools });
211
+ session.catalog = await session.catalogReady;
212
+ }
213
+
214
+ /**
215
+ * Probe for herdr and settle this session's executor — ADR-0031.
216
+ *
217
+ * **Once, at session start, and never per spawn.** A fan-out whose children ran under two executors would put
218
+ * two different things under one call in the ledger, and the two plans differ (`--print` is withheld on the
219
+ * herdr path). A herdr server that dies mid-session therefore surfaces as a failed `tab create`, reported as
220
+ * the spawn error it is, rather than as a silent relocation of the remaining children.
221
+ *
222
+ * `probeHerdr` never throws, so this cannot either — which matters because it runs *before* the line that
223
+ * discloses what it decided (R-60: a throw here would cancel that line and every control after it).
224
+ */
225
+ export async function resolveExecutor(session: GrantsSession): Promise<void> {
226
+ const raw = process.env[ENV_HERDR];
227
+ session.executor = chooseExecutor(raw, needsProbe(raw) ? await probeHerdr() : null);
228
+ }
229
+
141
230
  export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
142
- // Governance is opt-in: with PI_GRANTS_GRANT unset the session holds the wildcard and nothing is
143
- // blocked. This extension must never silently tighten a normal workflow.
231
+ // Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
232
+ // holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
233
+ // workflow.
234
+ //
235
+ // **Two sources, and the environment always wins** (ADR-0030). The variable is how a CHILD is governed
236
+ // and how CI is configured, so a store that could override it would let a directory quietly widen or
237
+ // narrow a child its parent had already bounded. The store is consulted only when the variable is absent,
238
+ // which is exactly the case it was added for: a human at a terminal who ran `/grants init` here.
239
+ //
240
+ // `process.cwd()` rather than `ctx.cwd`, because this runs in the extension factory — before any hook,
241
+ // and therefore before `ctx` exists. That ordering is forced by S-5: whether `delegate` is registered at
242
+ // all is decided here, and a grant arriving later could not inform it. `session_start` re-checks the two
243
+ // against each other and says so if they differ, which is the only case this can get wrong.
144
244
  const grantRaw = process.env[ENV_GRANT];
145
- const governed = grantRaw !== undefined;
146
- const inherited: Capability[] = governed ? parseList(grantRaw) : [WILDCARD];
245
+ const storeCwd = process.cwd();
246
+ const stored = grantRaw === undefined ? loadGrantSync(storeCwd) : null;
247
+ const governed = grantRaw !== undefined || stored !== null;
248
+ const inherited: Capability[] = grantRaw !== undefined ? parseList(grantRaw) : (stored ?? [WILDCARD]);
147
249
  // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
148
250
  // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
149
251
  const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
@@ -164,7 +266,10 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
164
266
  // `PI_GRANTS_GATED=""` turns the default off; absent and empty are deliberately distinguishable.
165
267
  gated: governed ? gatedFromEnv(process.env[ENV_GATED]) : parseList(process.env[ENV_GATED]),
166
268
  ledgerPath: process.env[ENV_LEDGER],
167
- useHerdr: process.env[ENV_HERDR] === "1",
269
+ // The un-probed reading. `resolveExecutor` replaces it at session start; until then a `1` already reads as
270
+ // a refusal, which is the safe direction — a delegation that somehow ran before the probe would refuse
271
+ // rather than quietly use the wrong executor.
272
+ executor: chooseExecutor(process.env[ENV_HERDR], null),
168
273
  // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
169
274
  // every level restarting at `d0` and the ledger becoming unjoinable.
170
275
  ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
@@ -210,10 +315,26 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
210
315
  // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
211
316
  // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
212
317
  // an executor quietly rewriting it would make the record describe a spawn that did not happen.
213
- interactive: session.useHerdr,
318
+ //
319
+ // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
320
+ // has not run when this session object is built, so a captured value would plan `--print` for a session
321
+ // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
322
+ interactive: session.executor.kind === "herdr",
214
323
  ...(approved ? { approved } : {}),
215
324
  }),
216
325
 
326
+ storeCwd,
327
+
328
+ adoptGrant: (grant: Capability[]) => {
329
+ // Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is
330
+ // governed from that moment: every spawn is bounded by what was just stored. Leaving this false made
331
+ // `/grants` print "inactive" while holding thirteen capabilities — a status line contradicting the
332
+ // enforcer, which is the defect R-28 is named for.
333
+ session.governed = true;
334
+ session.ownGrant = grant;
335
+ session.publishChildEnv();
336
+ },
337
+
217
338
  publishChildEnv: () => {
218
339
  const env = childEnv({
219
340
  ownGrant: session.ownGrant,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The tripwire's vocabulary — which tool names count as a foreign spawner, and what to say when one appears.
3
+ *
4
+ * Lifted out of `extensions/grants.ts` so the *message* can be tested without loading pi. That is not
5
+ * fastidiousness: the message is the whole product of this control. A refusal nothing verifies is a refusal
6
+ * whose wording drifts, and the wording is what a model acts on.
7
+ *
8
+ * The hook that uses these stays in `grants.ts`, because it also writes a ledger record and that needs the
9
+ * session.
10
+ */
11
+
12
+ /**
13
+ * Tool names that create sub-agents this package did not provision.
14
+ *
15
+ * `subagent` is the one seen in the wild — a directory drop-in at `~/.pi/agent/extensions/subagent/`, which pi
16
+ * auto-loads in **every** session on a machine regardless of `settings.json`. `Agent` and `spawn_agent` are the
17
+ * other plausible names. **Deliberately a name check and nothing more**: `subagents:rpc:spawn` reaches a
18
+ * manager over the event bus and never produces a `tool_call` at all (ADR-0013 Finding 6), so this catches the
19
+ * ordinary case loudly and is not a boundary.
20
+ */
21
+ export const SPAWN_TOOLS: ReadonlySet<string> = new Set(["Agent", "subagent", "spawn_agent"]);
22
+
23
+ /**
24
+ * Why a foreign spawn tool is refused, and what to use instead.
25
+ *
26
+ * **Both governed tools are named, and this is the fix rather than a flourish.** The text said only *"Use
27
+ * `delegate` instead"*. On 2026-08-17 an operator asked for parallel work, `subagent` was refused, and the model
28
+ * then planned a single sequential `delegate` — a reasonable reading of the only instruction it was given, and
29
+ * the wrong shape for the request. `delegate_all` existed the whole time.
30
+ *
31
+ * A refusal that points at the wrong replacement is a refusal that gets obeyed badly. And it names what is
32
+ * *lost* rather than only what is forbidden, because a control an operator cannot evaluate is one they route
33
+ * around — the escape hatch is one unset variable away, so it should be an informed choice.
34
+ */
35
+ export function tripwireReason(toolName: string): string {
36
+ return (
37
+ `grants: "${toolName}" spawns sub-agents outside this session's governance — refused. ` +
38
+ `This session grants capabilities by spawning them itself, so a child created by another extension would ` +
39
+ `hold whatever that extension decided, with no grant, no depth bound and no ledger entry. ` +
40
+ `Use \`delegate\` for a single sub-agent, or \`delegate_all\` to run several CONCURRENTLY — that is the ` +
41
+ `governed equivalent of a parallel or chained spawn, and it is what to reach for when independent tasks ` +
42
+ `can proceed at the same time. If you meant to run ungoverned, unset PI_GRANTS_GRANT.`
43
+ );
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -69,6 +69,22 @@
69
69
  "types": "./dist/run-herdr.d.ts",
70
70
  "default": "./dist/run-herdr.js"
71
71
  },
72
+ "./herdr-poll": {
73
+ "types": "./dist/herdr-poll.d.ts",
74
+ "default": "./dist/herdr-poll.js"
75
+ },
76
+ "./herdr-cli": {
77
+ "types": "./dist/herdr-cli.d.ts",
78
+ "default": "./dist/herdr-cli.js"
79
+ },
80
+ "./executor": {
81
+ "types": "./dist/executor.d.ts",
82
+ "default": "./dist/executor.js"
83
+ },
84
+ "./progress": {
85
+ "types": "./dist/progress.d.ts",
86
+ "default": "./dist/progress.js"
87
+ },
72
88
  "./run-child": {
73
89
  "types": "./dist/run-child.d.ts",
74
90
  "default": "./dist/run-child.js"
package/src/cli.ts CHANGED
@@ -19,7 +19,7 @@ import { relative, resolve as resolvePath } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { UnsafeGrantError } from "./grant-env.ts";
21
21
  import { applyInit, countDeclaring, planInit, type InitPlan } from "./init.ts";
22
- import { discoverSkillPackages, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
22
+ import { discoverSkillPackages, skillPackageRoots, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
23
23
 
24
24
  const USAGE = `pi-daddy — capability governance for pi sub-agents
25
25
 
@@ -93,10 +93,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
93
93
  async function init(cwd: string, force: boolean): Promise<number> {
94
94
  const packages = await discoverSkillPackages(cwd);
95
95
  if (packages.length === 0) {
96
+ // Names BOTH roots it looked in, and offers pi's own install command first. The previous message named
97
+ // only `<cwd>/node_modules` and said "npm install …" — so an operator who had just run
98
+ // `pi install npm:principal-pi-skills`, which installs to the agent root, was told to install a package
99
+ // they had already installed (R-75).
96
100
  console.log(
97
- `pi-daddy init: no installed package under ${cwd}/node_modules declares skills\n` +
98
- `(a package.json "pi": {"skills": [...]} field). Nothing to scaffold.\n\n` +
99
- ` npm install principal-pi-skills # seven skills; then re-run this`,
101
+ `pi-daddy init: no installed package declares skills (a package.json "pi": {"skills": [...]} ` +
102
+ `field). Nothing to scaffold.\n\nLooked in:\n` +
103
+ skillPackageRoots(cwd).map((r) => ` ${r}\n`).join("") +
104
+ `\n pi install npm:principal-pi-skills # seven skills, and registers it with pi\n` +
105
+ ` npm install principal-pi-skills # or pin it in this project instead`,
100
106
  );
101
107
  return 0;
102
108
  }