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,261 @@
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
+ import { planSpawn } from "./spawn.js";
18
+ import { ceilingForDefinition, digestDefinition } from "./definitions.js";
19
+ import { resolve, assertNarrowing } from "./resolve.js";
20
+ import { AGENT_WILDCARD } from "./resolve.js";
21
+ import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.js";
22
+ // Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
23
+ // four modules plus the test suite name it. Moving the definitions without moving the door would be churn
24
+ // charged to every caller for a line count they did not cause.
25
+ export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.js";
26
+ import { ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID } from "./propagation.js";
27
+ import { inheritApprovals } from "./approval.js";
28
+ import { unknownCapabilities } from "./catalog.js";
29
+ /**
30
+ * Plan a governed delegation. Pure: returns argv and env, spawns nothing.
31
+ *
32
+ * Fails closed on depth, on any requested capability the delegator does not hold, on gated capabilities
33
+ * without approval, and on a grant that cannot narrow (a universal capability slipping through).
34
+ */
35
+ export function planDelegation(request, ctx) {
36
+ const childDepth = ctx.depth + 1;
37
+ // G6 / B-I3: every refusal carries a result, including the four below that return before `resolve()`
38
+ // is ever called. The extension guarded its ledger write with `if (ledgerPath && plan.result)`, so
39
+ // those four governance decisions — disabled, too deep, no task, unknown capability — were never
40
+ // audited at all. An empty result is the honest record: nothing was resolved, and that is the fact.
41
+ const empty = {
42
+ ok: false,
43
+ args: [],
44
+ env: {},
45
+ effective: [],
46
+ childDepth,
47
+ requested: [],
48
+ result: { effective: [], denied: [], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
49
+ };
50
+ if (ctx.maxDepth <= 0)
51
+ return { ...empty, reason: "delegation is disabled (maxDepth 0)" };
52
+ if (childDepth > ctx.maxDepth) {
53
+ return { ...empty, reason: `delegation depth limit reached (${ctx.maxDepth})` };
54
+ }
55
+ if (!request.task?.trim())
56
+ return { ...empty, reason: "a delegation needs a task" };
57
+ // ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
58
+ let requested;
59
+ let systemPrompt;
60
+ let definitionDigest;
61
+ /** The definition being spawned, hoisted so the gate below can name its authorising id (ADR-0024). */
62
+ let spawned;
63
+ if (request.agent) {
64
+ const definition = ctx.definitions?.get(request.agent);
65
+ spawned = definition;
66
+ // No fallback, deliberately. pi-subagents resolves an unknown type to `general-purpose`, whose
67
+ // omitted tool list means EVERY tool — so a typo there granted the full surface. An unknown name
68
+ // here is simply an error.
69
+ if (!definition) {
70
+ const known = [...(ctx.definitions?.keys() ?? [])].sort();
71
+ return {
72
+ ...empty,
73
+ reason: `unknown agent "${request.agent}"` +
74
+ (known.length > 0 ? ` — known definitions: ${known.join(", ")}` : " — no definitions were found"),
75
+ };
76
+ }
77
+ // ADR-0017: authorisation comes BEFORE anything is said about the file. Which definitions this
78
+ // session may spawn is a governance question about the SESSION; whether the file declares its tools
79
+ // properly is a diagnostic about the DEFINITION, and answering the second one first would report a
80
+ // malformed-file error to a caller who was never allowed to spawn it either way.
81
+ //
82
+ // Recorded as a denial rather than a bare refusal, deliberately: `denied` is the escalation signal
83
+ // ADR-0008 designates, and asking to run a definition this session was not granted IS an attempt to
84
+ // exceed the grant. A refusal that left `denied` empty would keep it out of every audit query.
85
+ if (!maySpawnDefinition(ctx.ownGrant, definition.name)) {
86
+ const authorising = agentCapability(definition.name);
87
+ const held = ctx.ownGrant.filter((c) => c.startsWith("agent:")).sort();
88
+ return {
89
+ ...empty,
90
+ requested: [authorising],
91
+ result: { ...empty.result, denied: [authorising] },
92
+ reason: `cannot spawn "${definition.name}" — this session does not hold ${authorising} ` +
93
+ `(the definition lives at ${definition.source}). ` +
94
+ (held.length > 0
95
+ ? `It may spawn: ${held.join(", ")}.`
96
+ : `It may spawn no definitions at all; add ${authorising} to its grant to allow this one.`),
97
+ };
98
+ }
99
+ // ADR-0018. Recorded from here on — after authorisation, because the digest is a fact about a file
100
+ // this caller was allowed to read, and before every remaining outcome, because a spawn refused for a
101
+ // malformed declaration is still a spawn of THIS version of the definition.
102
+ //
103
+ // Assigned into `empty`, which every subsequent refusal spreads. That is the R-28 discipline applied
104
+ // to a record field rather than an argument: instead of eight `definitionDigest` spellings that a
105
+ // ninth return could forget, there is one, and forgetting it is not expressible. The success return
106
+ // does not spread `empty`, so it names the field explicitly.
107
+ definitionDigest = digestDefinition(definition);
108
+ Object.assign(empty, { definitionDigest });
109
+ const ceiling = ceilingForDefinition(definition);
110
+ if (ceiling.undeclared) {
111
+ return {
112
+ ...empty,
113
+ reason: `agent "${definition.name}" declares no \`allowed-tools\`, so it cannot be spawned — add one ` +
114
+ `to ${definition.source}. An undeclared capability set is treated as NONE, never as everything.`,
115
+ };
116
+ }
117
+ if (ceiling.patterns.length > 0) {
118
+ return {
119
+ ...empty,
120
+ reason: `agent "${definition.name}" restricts a tool with a pattern (${ceiling.patterns.join(", ")}), ` +
121
+ `which pi's --tools cannot express — it matches whole tool names only. Granting the bare tool ` +
122
+ `would widen the declaration and dropping it would silently narrow, so neither is done.`,
123
+ };
124
+ }
125
+ requested = ceiling.capabilities;
126
+ systemPrompt = definition.body;
127
+ }
128
+ else {
129
+ requested = (request.tools ?? []).map(normaliseCapability);
130
+ }
131
+ // Unknown is reported before denied, and separately: "does not exist here" and "you lack authority"
132
+ // have different causes and different fixes. Collapsing them hides typos and stale grants.
133
+ if (ctx.catalog) {
134
+ const unknown = unknownCapabilities(requested, ctx.catalog);
135
+ if (unknown.length > 0) {
136
+ return {
137
+ ...empty,
138
+ requested,
139
+ reason: `unknown capabilit${unknown.length === 1 ? "y" : "ies"}: ${unknown.join(", ")} — not present in ` +
140
+ `this session's catalog (typo, or an uninstalled package?)`,
141
+ };
142
+ }
143
+ }
144
+ const approvedCapabilities = (ctx.approved ?? []).map((a) => a.capability);
145
+ const result = resolve({
146
+ requested,
147
+ parentGrant: ctx.ownGrant,
148
+ gated: ctx.gated,
149
+ approved: approvedCapabilities,
150
+ });
151
+ /**
152
+ * ADR-0024: gating `agent:<name>` asks a human before that definition runs.
153
+ *
154
+ * `gatedBlocked` is a filter over `requested`, and for a definition spawn `requested` is the definition's
155
+ * CEILING — so the id that authorises it was never a candidate, and `PI_GRANTS_GATED=agent:deploy` did
156
+ * nothing at all on the path an operator writing it means. It half-worked when some *other* definition
157
+ * passed the id down in its own `allowed-tools`, which is worse than not working (R-47, R-25's shape).
158
+ *
159
+ * Evaluated here rather than by adding the id to `requested`, and that is the load-bearing part: a
160
+ * capability in `requested` flows to `effective`, which becomes the CHILD's grant — so the child would
161
+ * hold `agent:deploy` and could spawn `deploy` itself without anyone being asked. This is the parent's
162
+ * authority to run the definition *now*, not something the child receives.
163
+ *
164
+ * `agent:*` in the gate covers every definition, so "ask me before any definition runs" is one variable.
165
+ */
166
+ if (spawned) {
167
+ const authorising = agentCapability(spawned.name);
168
+ const gatedHere = ctx.gated.includes(authorising) || ctx.gated.includes(AGENT_WILDCARD);
169
+ if (gatedHere && !approvedCapabilities.includes(authorising)) {
170
+ result.gatedBlocked = [...result.gatedBlocked, authorising];
171
+ }
172
+ }
173
+ if (result.denied.length > 0) {
174
+ return {
175
+ ...empty,
176
+ requested,
177
+ result,
178
+ reason: `cannot grant ${result.denied.join(", ")} — this session does not hold it (capability escalation blocked)`,
179
+ };
180
+ }
181
+ // ADR-0011: narrowing is checked BEFORE the gate, and the order is load-bearing rather than
182
+ // stylistic. `assertNarrowing` refuses regardless of approval, so with the old order this returned
183
+ // "requires explicit approval" for a delegation that could never be approved — telling the operator
184
+ // to go and find a human who cannot help. `shouldSeekApproval` now also refuses to prompt in this
185
+ // case; this reordering makes the reported *reason* agree with what actually blocks the spawn.
186
+ try {
187
+ assertNarrowing(result);
188
+ }
189
+ catch (error) {
190
+ return { ...empty, requested, result, reason: String(error instanceof Error ? error.message : error) };
191
+ }
192
+ if (result.gatedBlocked.length > 0) {
193
+ return { ...empty, requested, result, reason: `${result.gatedBlocked.join(", ")} requires explicit approval` };
194
+ }
195
+ const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY);
196
+ const plan = planSpawn({
197
+ effective: result.effective,
198
+ prompt: request.task,
199
+ model: request.model,
200
+ provider: request.provider,
201
+ thinking: request.thinking,
202
+ skillPaths: ctx.skillPaths,
203
+ contextFiles: ctx.contextFiles,
204
+ systemPrompt,
205
+ print: ctx.interactive ? false : undefined,
206
+ });
207
+ // R-32. A `skill:` capability the catalog cannot place is refused rather than dropped. Dropping it
208
+ // would hand back a child whose grant claims a skill it does not have — the ledger would record a
209
+ // capability that never reached the process, which is precisely the kind of lie an audit trail must
210
+ // not contain. `unknownCapabilities` above catches names absent from the catalog entirely; this
211
+ // catches one that is known but whose path we could not resolve, which is a different fault.
212
+ if (plan.unresolvedSkills.length > 0) {
213
+ return {
214
+ ...empty,
215
+ requested,
216
+ result,
217
+ reason: `cannot locate ${plan.unresolvedSkills.join(", ")} on disk — granted but unresolvable, so the ` +
218
+ `child would silently lack it`,
219
+ };
220
+ }
221
+ // A child may only delegate further if it was granted the capability AND has the extension to do it.
222
+ const args = [...plan.args];
223
+ if (canSubDelegate && ctx.extensionPath) {
224
+ // `-e` loads even under `--no-extensions`, which planSpawn sets — that is precisely why the
225
+ // extension is added explicitly here and nowhere else.
226
+ args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
227
+ }
228
+ const env = {
229
+ [ENV_GRANT]: result.effective.join(","),
230
+ [ENV_DEPTH]: String(childDepth),
231
+ [ENV_MAX_DEPTH]: String(ctx.maxDepth),
232
+ };
233
+ // The child's own share of the subtree budget, and its identity. Both attenuate downward like depth: a
234
+ // child can never be handed more budget than its parent had left, so the total bound holds across
235
+ // process boundaries with no shared state.
236
+ if (ctx.fanoutBudget !== undefined)
237
+ env[ENV_FANOUT] = String(ctx.fanoutBudget);
238
+ if (ctx.childSpawnId)
239
+ env[ENV_PARENT_ID] = ctx.childSpawnId;
240
+ if (ctx.gated.length > 0)
241
+ env[ENV_GATED] = ctx.gated.join(",");
242
+ // Approvals ride down with the grant, but only ever for what this child actually received — so
243
+ // `approved ⊆ grant` holds at every level (ADR-0010). Written even when empty, so this object states
244
+ // the child's approval set outright rather than leaving it to whatever the caller merges over; see
245
+ // `mergeChildEnv`, which is what actually stops the parent's value leaking through.
246
+ env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], result.effective).join(",");
247
+ if (ctx.ledgerPath)
248
+ env[ENV_LEDGER] = ctx.ledgerPath;
249
+ return {
250
+ ok: true,
251
+ args,
252
+ env,
253
+ effective: result.effective,
254
+ result,
255
+ childDepth,
256
+ requested,
257
+ childId: ctx.childSpawnId,
258
+ ...(definitionDigest ? { definitionDigest } : {}),
259
+ };
260
+ }
261
+ //# sourceMappingURL=delegate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delegate.js","sourceRoot":"","sources":["../src/delegate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Bounded synchronous fan-out — ADR-0015's option A′, and the cardinality bound ADR-0008 never had.
3
+ *
4
+ * **The gap this closes.** ADR-0008 bounds what each child may *hold* and says nothing about how many
5
+ * children exist. `resolve()` is a set operation; nowhere in `src/` was there a count. Blocking `delegate`
6
+ * bounded cardinality to one *by accident of being blocking*, so the invariant was never tested. Fan-out
7
+ * removes that accident: with `maxDepth: 2` and five children per call, five delegators each spawning five
8
+ * is thirty concurrent model sessions — **every ledger line individually correct and narrow, the aggregate
9
+ * a machine DoS and an uncapped bill.** The critic's verdict on this was that silence is itself a finding.
10
+ *
11
+ * **Why a budget rather than a per-call limit.** A per-call cap of K with depth D still permits K^D
12
+ * descendants, which is the same exponential wearing a smaller number. A *budget* is subtractive and
13
+ * therefore total: a session holding budget B may create at most B descendants in its whole subtree,
14
+ * because it spends from B to spawn and hands each child a share of what is left. That composes across
15
+ * process boundaries with **no shared state** — the same property that makes depth work — so it needs no
16
+ * registry, no lock and no counter file.
17
+ *
18
+ * It is deliberately NOT a concurrency limit. How many run at once is a resource question for the executor;
19
+ * how many may exist at all is a governance question, and this is the governance answer.
20
+ */
21
+ /** Total descendants a session may create across its entire subtree, when nothing is configured. */
22
+ export declare const DEFAULT_FANOUT_BUDGET = 8;
23
+ /**
24
+ * Hard ceiling on children in a single call, independent of budget.
25
+ *
26
+ * A budget alone would let one call spend all of it at once, and a hundred simultaneous `pi` processes is
27
+ * a different failure from a hundred spread over a session. This is the blast-radius bound; the budget is
28
+ * the total bound. Both are needed because they answer different questions.
29
+ */
30
+ export declare const MAX_CHILDREN_PER_CALL = 8;
31
+ /** Read the budget from the environment, failing to the default on absent *or* malformed input. */
32
+ export declare function budgetFromEnv(raw: string | undefined): number;
33
+ export interface BudgetSplit {
34
+ ok: boolean;
35
+ reason?: string;
36
+ /** Budget each child receives. */
37
+ perChild: number;
38
+ }
39
+ /**
40
+ * Spend `count` from a budget and divide the remainder among the children.
41
+ *
42
+ * The parent pays one unit per child it creates *before* sharing what is left, so a subtree can never
43
+ * exceed the budget it started with: spawning is itself an expenditure, not a free act that only its
44
+ * descendants pay for. `Math.floor` on the division means rounding always loses budget rather than
45
+ * inventing it — the safe direction, and the reason a deep tree converges to zero instead of oscillating.
46
+ */
47
+ export declare function splitBudget(budget: number, count: number): BudgetSplit;
48
+ /**
49
+ * A ledger id that distinguishes siblings.
50
+ *
51
+ * **Review finding F8.** Every child was recorded as `delegate@d1`, so four concurrent children produced
52
+ * four lines identical except `ts` — and two landing in the same millisecond were indistinguishable.
53
+ * ADR-0008 names `parent_id`/`child_id` as the correlation keys, but they were depth *labels* wearing id
54
+ * names, which made the ledger unjoinable to the returned result, to the OS process, or to the child's own
55
+ * lines one level down.
56
+ *
57
+ * The id is hierarchical and derived, not random: a child of `d0` is `d0.1`, its own second child `d0.1.2`.
58
+ * That means a line's ancestry is readable from the id alone with no join at all, and it is reproducible —
59
+ * two runs of the same fan-out produce the same ids, which is what makes a ledger diffable.
60
+ */
61
+ export declare function childSpawnId(parentId: string, index: number): string;
62
+ //# sourceMappingURL=fanout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fanout.d.ts","sourceRoot":"","sources":["../src/fanout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,oGAAoG;AACpG,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,mGAAmG;AACnG,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAM7D;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,WAAW,CAmBtE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpE"}
package/dist/fanout.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Bounded synchronous fan-out — ADR-0015's option A′, and the cardinality bound ADR-0008 never had.
3
+ *
4
+ * **The gap this closes.** ADR-0008 bounds what each child may *hold* and says nothing about how many
5
+ * children exist. `resolve()` is a set operation; nowhere in `src/` was there a count. Blocking `delegate`
6
+ * bounded cardinality to one *by accident of being blocking*, so the invariant was never tested. Fan-out
7
+ * removes that accident: with `maxDepth: 2` and five children per call, five delegators each spawning five
8
+ * is thirty concurrent model sessions — **every ledger line individually correct and narrow, the aggregate
9
+ * a machine DoS and an uncapped bill.** The critic's verdict on this was that silence is itself a finding.
10
+ *
11
+ * **Why a budget rather than a per-call limit.** A per-call cap of K with depth D still permits K^D
12
+ * descendants, which is the same exponential wearing a smaller number. A *budget* is subtractive and
13
+ * therefore total: a session holding budget B may create at most B descendants in its whole subtree,
14
+ * because it spends from B to spawn and hands each child a share of what is left. That composes across
15
+ * process boundaries with **no shared state** — the same property that makes depth work — so it needs no
16
+ * registry, no lock and no counter file.
17
+ *
18
+ * It is deliberately NOT a concurrency limit. How many run at once is a resource question for the executor;
19
+ * how many may exist at all is a governance question, and this is the governance answer.
20
+ */
21
+ import { parseBound } from "./propagation.js";
22
+ /** Total descendants a session may create across its entire subtree, when nothing is configured. */
23
+ export const DEFAULT_FANOUT_BUDGET = 8;
24
+ /**
25
+ * Hard ceiling on children in a single call, independent of budget.
26
+ *
27
+ * A budget alone would let one call spend all of it at once, and a hundred simultaneous `pi` processes is
28
+ * a different failure from a hundred spread over a session. This is the blast-radius bound; the budget is
29
+ * the total bound. Both are needed because they answer different questions.
30
+ */
31
+ export const MAX_CHILDREN_PER_CALL = 8;
32
+ /** Read the budget from the environment, failing to the default on absent *or* malformed input. */
33
+ export function budgetFromEnv(raw) {
34
+ const parsed = parseBound(raw);
35
+ // Malformed and zero both fall back rather than disabling fan-out silently — G7's rule. A budget that a
36
+ // typo can switch off is the A-S4 defect wearing different clothes. To genuinely forbid delegation an
37
+ // operator sets `maxDepth: 0`, which says what it means.
38
+ return parsed === undefined || parsed === null || parsed === 0 ? DEFAULT_FANOUT_BUDGET : parsed;
39
+ }
40
+ /**
41
+ * Spend `count` from a budget and divide the remainder among the children.
42
+ *
43
+ * The parent pays one unit per child it creates *before* sharing what is left, so a subtree can never
44
+ * exceed the budget it started with: spawning is itself an expenditure, not a free act that only its
45
+ * descendants pay for. `Math.floor` on the division means rounding always loses budget rather than
46
+ * inventing it — the safe direction, and the reason a deep tree converges to zero instead of oscillating.
47
+ */
48
+ export function splitBudget(budget, count) {
49
+ if (count <= 0)
50
+ return { ok: false, reason: "a fan-out needs at least one child", perChild: 0 };
51
+ if (count > MAX_CHILDREN_PER_CALL) {
52
+ return {
53
+ ok: false,
54
+ reason: `${count} children exceeds the per-call limit of ${MAX_CHILDREN_PER_CALL}`,
55
+ perChild: 0,
56
+ };
57
+ }
58
+ if (budget < count) {
59
+ return {
60
+ ok: false,
61
+ reason: `fan-out budget exhausted: ${count} children requested, ${budget} remaining in this subtree ` +
62
+ `(raise PI_GRANTS_FANOUT at the root, or delegate fewer at a time)`,
63
+ perChild: 0,
64
+ };
65
+ }
66
+ return { ok: true, perChild: Math.floor((budget - count) / count) };
67
+ }
68
+ /**
69
+ * A ledger id that distinguishes siblings.
70
+ *
71
+ * **Review finding F8.** Every child was recorded as `delegate@d1`, so four concurrent children produced
72
+ * four lines identical except `ts` — and two landing in the same millisecond were indistinguishable.
73
+ * ADR-0008 names `parent_id`/`child_id` as the correlation keys, but they were depth *labels* wearing id
74
+ * names, which made the ledger unjoinable to the returned result, to the OS process, or to the child's own
75
+ * lines one level down.
76
+ *
77
+ * The id is hierarchical and derived, not random: a child of `d0` is `d0.1`, its own second child `d0.1.2`.
78
+ * That means a line's ancestry is readable from the id alone with no join at all, and it is reproducible —
79
+ * two runs of the same fan-out produce the same ids, which is what makes a ledger diffable.
80
+ */
81
+ export function childSpawnId(parentId, index) {
82
+ return `${parentId}.${index + 1}`;
83
+ }
84
+ //# sourceMappingURL=fanout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fanout.js","sourceRoot":"","sources":["../src/fanout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAE9C,oGAAoG;AACpG,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAEvC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAEvC,mGAAmG;AACnG,MAAM,UAAU,aAAa,CAAC,GAAuB;IACnD,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;IAC/B,wGAAwG;IACxG,sGAAsG;IACtG,yDAAyD;IACzD,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,MAAM,CAAC;AAClG,CAAC;AASD;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,MAAc,EAAE,KAAa;IACvD,IAAI,KAAK,IAAI,CAAC;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,oCAAoC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;IAChG,IAAI,KAAK,GAAG,qBAAqB,EAAE,CAAC;QAClC,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG,KAAK,2CAA2C,qBAAqB,EAAE;YAClF,QAAQ,EAAE,CAAC;SACZ,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,GAAG,KAAK,EAAE,CAAC;QACnB,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EACJ,6BAA6B,KAAK,wBAAwB,MAAM,6BAA6B;gBAC7F,mEAAmE;YACrE,QAAQ,EAAE,CAAC;SACZ,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,KAAK,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC;AACtE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,QAAgB,EAAE,KAAa;IAC1D,OAAO,GAAG,QAAQ,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;AACpC,CAAC"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One cross-process file lock, used by both files this package writes.
3
+ *
4
+ * **Extracted rather than copied (R-49).** The ledger has had this lock since fan-out made a second writer
5
+ * possible; the approvals store had an unlocked read-modify-write, so session 1 could load, session 2 could
6
+ * revoke, and session 1's next save would **restore the revoked entry** — falsifying a property
7
+ * `approval-store.ts` documents in so many words. The mitigation already existed twenty lines away. A
8
+ * second implementation is how a fix comes to contain a smaller copy of the bug it fixed, which has happened
9
+ * twice in this package (R-38's preview, ADR-0022's republish path), so there is exactly one of these.
10
+ *
11
+ * **The two callers want opposite failure behaviour, and that is the caller's decision, not this module's.**
12
+ * A ledger write that cannot take the lock must fail the delegation closed — a child running with granted
13
+ * capabilities and no audit line is the thing the ledger exists to prevent. An approvals write that cannot
14
+ * take the lock must NOT fail the work: the human already said yes, and the store is a convenience cache
15
+ * (ADR-0020). So this throws `LockTimeoutError`, distinguishable from every other failure, and each caller
16
+ * decides what that means.
17
+ */
18
+ /** How long to wait for another writer to finish before giving up. Short: failing closed beats hanging. */
19
+ export declare const LOCK_TIMEOUT_MS = 2000;
20
+ /** A lock older than this is treated as abandoned by a killed process and broken. */
21
+ export declare const STALE_LOCK_MS = 10000;
22
+ /**
23
+ * Raised only when the wait ran out. Its own type so a caller can tell "somebody else is writing" from
24
+ * "this filesystem rejected the write", which want different messages and, for the approvals store,
25
+ * different outcomes.
26
+ */
27
+ export declare class LockTimeoutError extends Error {
28
+ constructor(label: string);
29
+ }
30
+ /**
31
+ * Run `work` while holding an exclusive lock beside `path`.
32
+ *
33
+ * **Why a lock at all.** `O_APPEND` is atomic for one write to a regular file on a POSIX filesystem, and the
34
+ * guarantee does **not** hold on drvfs (`/mnt/c` under WSL2) or NFS — which is exactly where this project
35
+ * runs. The approvals store never had the guarantee anyway: read-modify-write is not one write.
36
+ *
37
+ * **A lock introduces its own failure mode and it is handled deliberately.** A process killed while holding
38
+ * the lock would otherwise block every future write forever, so a lock older than `STALE_LOCK_MS` is broken.
39
+ * Every delete proves ownership first (`removeIfOurs`) — see the token comment in the loop for the two
40
+ * mutual-exclusion breaks that came from not doing so, both reproduced across real OS processes.
41
+ *
42
+ * **What staleness actually measures, stated because it is not what it sounds like.** `STALE_LOCK_MS`
43
+ * compares the lock's mtime to now; it never checks whether the owner is alive. So *any* 10s stall of the
44
+ * holder hands the lock on — a `SIGSTOP`, a laptop suspend, swap thrash, a debugger breakpoint, a long GC
45
+ * pause. Measured on this project's own filesystems, no realistic `work()` comes near it: one ledger append
46
+ * is 0.1ms on ext4 and 21ms on drvfs, and a 10,000-entry `saveApproval` is 30ms / 97ms. Sixteen-way
47
+ * contention raises *waiters'* time, never the holder's — max hold measured at 49ms. So the threshold is
48
+ * two orders of magnitude clear of normal operation and is not guarded against abnormal suspension.
49
+ *
50
+ * The timeout is short *on purpose*: work refused because a file was busy is recoverable and loud, while
51
+ * work that hangs waiting for a lock is neither.
52
+ */
53
+ export declare function withFileLock<T>(path: string, label: string, work: () => Promise<T>): Promise<T>;
54
+ //# sourceMappingURL=file-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,2GAA2G;AAC3G,eAAO,MAAM,eAAe,OAAO,CAAC;AACpC,qFAAqF;AACrF,eAAO,MAAM,aAAa,QAAS,CAAC;AAEpC;;;;GAIG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,KAAK,EAAE,MAAM;CAI1B;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CA+DrG"}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * One cross-process file lock, used by both files this package writes.
3
+ *
4
+ * **Extracted rather than copied (R-49).** The ledger has had this lock since fan-out made a second writer
5
+ * possible; the approvals store had an unlocked read-modify-write, so session 1 could load, session 2 could
6
+ * revoke, and session 1's next save would **restore the revoked entry** — falsifying a property
7
+ * `approval-store.ts` documents in so many words. The mitigation already existed twenty lines away. A
8
+ * second implementation is how a fix comes to contain a smaller copy of the bug it fixed, which has happened
9
+ * twice in this package (R-38's preview, ADR-0022's republish path), so there is exactly one of these.
10
+ *
11
+ * **The two callers want opposite failure behaviour, and that is the caller's decision, not this module's.**
12
+ * A ledger write that cannot take the lock must fail the delegation closed — a child running with granted
13
+ * capabilities and no audit line is the thing the ledger exists to prevent. An approvals write that cannot
14
+ * take the lock must NOT fail the work: the human already said yes, and the store is a convenience cache
15
+ * (ADR-0020). So this throws `LockTimeoutError`, distinguishable from every other failure, and each caller
16
+ * decides what that means.
17
+ */
18
+ import { open, readFile, rm, stat } from "node:fs/promises";
19
+ import { randomUUID } from "node:crypto";
20
+ /** How long to wait for another writer to finish before giving up. Short: failing closed beats hanging. */
21
+ export const LOCK_TIMEOUT_MS = 2000;
22
+ /** A lock older than this is treated as abandoned by a killed process and broken. */
23
+ export const STALE_LOCK_MS = 10_000;
24
+ /**
25
+ * Raised only when the wait ran out. Its own type so a caller can tell "somebody else is writing" from
26
+ * "this filesystem rejected the write", which want different messages and, for the approvals store,
27
+ * different outcomes.
28
+ */
29
+ export class LockTimeoutError extends Error {
30
+ constructor(label) {
31
+ super(`${label} is locked by another writer (waited ${LOCK_TIMEOUT_MS}ms)`);
32
+ this.name = "LockTimeoutError";
33
+ }
34
+ }
35
+ /**
36
+ * Run `work` while holding an exclusive lock beside `path`.
37
+ *
38
+ * **Why a lock at all.** `O_APPEND` is atomic for one write to a regular file on a POSIX filesystem, and the
39
+ * guarantee does **not** hold on drvfs (`/mnt/c` under WSL2) or NFS — which is exactly where this project
40
+ * runs. The approvals store never had the guarantee anyway: read-modify-write is not one write.
41
+ *
42
+ * **A lock introduces its own failure mode and it is handled deliberately.** A process killed while holding
43
+ * the lock would otherwise block every future write forever, so a lock older than `STALE_LOCK_MS` is broken.
44
+ * Every delete proves ownership first (`removeIfOurs`) — see the token comment in the loop for the two
45
+ * mutual-exclusion breaks that came from not doing so, both reproduced across real OS processes.
46
+ *
47
+ * **What staleness actually measures, stated because it is not what it sounds like.** `STALE_LOCK_MS`
48
+ * compares the lock's mtime to now; it never checks whether the owner is alive. So *any* 10s stall of the
49
+ * holder hands the lock on — a `SIGSTOP`, a laptop suspend, swap thrash, a debugger breakpoint, a long GC
50
+ * pause. Measured on this project's own filesystems, no realistic `work()` comes near it: one ledger append
51
+ * is 0.1ms on ext4 and 21ms on drvfs, and a 10,000-entry `saveApproval` is 30ms / 97ms. Sixteen-way
52
+ * contention raises *waiters'* time, never the holder's — max hold measured at 49ms. So the threshold is
53
+ * two orders of magnitude clear of normal operation and is not guarded against abnormal suspension.
54
+ *
55
+ * The timeout is short *on purpose*: work refused because a file was busy is recoverable and loud, while
56
+ * work that hangs waiting for a lock is neither.
57
+ */
58
+ export async function withFileLock(path, label, work) {
59
+ const lockPath = `${path}.lock`;
60
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
61
+ for (;;) {
62
+ // **A token, and the reason mutual exclusion depends on it (R-67).** `rm(path)` deletes whatever is at
63
+ // the path *now*, not the lock this process created — so the previous version broke its own invariant
64
+ // two ways, both reproduced across real OS processes:
65
+ //
66
+ // - the stale-break `stat` and `rm` are two awaits, so a process descheduled between them could delete
67
+ // a LIVE lock another waiter had just created, and then succeed at its own create: two holders;
68
+ // - worse, the `finally` removed the lock unconditionally. A holder whose lock had been broken out
69
+ // from under it still freed the NEW owner's lock on the way out — and the damage then propagated to
70
+ // processes that raced nothing and observed nothing wrong, which is how one break became a chain.
71
+ //
72
+ // Writing a unique token and re-reading it before every delete makes both inexpressible: this process
73
+ // only ever removes a file it can prove is its own. The docstring used to claim "whichever wins the
74
+ // exclusive create proceeds, which is correct because only one can" — true of the create and false of
75
+ // the delete, which is what made it convincing.
76
+ const token = `${process.pid}:${randomUUID()}`;
77
+ let handle;
78
+ try {
79
+ handle = await open(lockPath, "wx");
80
+ await handle.writeFile(`${token}\n`, "utf8");
81
+ }
82
+ catch (error) {
83
+ // The handle may exist even though the WRITE failed (ENOSPC, EDQUOT, EFBIG). The old code jumped
84
+ // straight here and rethrew, so the already-created lock file was left on disk and its descriptor
85
+ // leaked to GC — an orphan that blocks every writer for a full STALE_LOCK_MS and feeds the EMFILE
86
+ // path below. Close and remove before doing anything else; `ours` is safe because we just made it.
87
+ if (handle) {
88
+ await handle.close().catch(() => undefined);
89
+ await removeIfOurs(lockPath, token);
90
+ handle = undefined;
91
+ }
92
+ const code = error.code;
93
+ if (code !== "EEXIST")
94
+ throw error;
95
+ // Someone else holds it. Break it only if it is old enough to be abandoned — and only the exact file
96
+ // we judged, so a lock created in the gap survives.
97
+ try {
98
+ const held = await stat(lockPath);
99
+ if (Date.now() - held.mtimeMs > STALE_LOCK_MS) {
100
+ const abandoned = await readFile(lockPath, "utf8").catch(() => undefined);
101
+ if (abandoned !== undefined)
102
+ await removeIfOurs(lockPath, abandoned.trim());
103
+ }
104
+ }
105
+ catch {
106
+ /* it vanished between the check and the stat — the next attempt will simply take it */
107
+ }
108
+ if (Date.now() >= deadline)
109
+ throw new LockTimeoutError(label);
110
+ await new Promise((r) => setTimeout(r, 25));
111
+ continue;
112
+ }
113
+ try {
114
+ return await work();
115
+ }
116
+ finally {
117
+ await handle.close().catch(() => undefined);
118
+ // Only if it is still OURS. A lock broken out from under us belongs to somebody else now, and
119
+ // deleting it is what turned one lost race into a cascade.
120
+ await removeIfOurs(lockPath, token);
121
+ }
122
+ }
123
+ }
124
+ /**
125
+ * Delete the lock only if it still holds `token`.
126
+ *
127
+ * Read-then-delete is itself two operations, so this is not atomic either — but it narrows the window from
128
+ * "the whole of `work()`" to "between a read and an unlink", and it removes the *systematic* break entirely:
129
+ * a process can no longer delete a lock it demonstrably never owned.
130
+ */
131
+ async function removeIfOurs(lockPath, token) {
132
+ try {
133
+ const held = await readFile(lockPath, "utf8");
134
+ if (held.trim() !== token)
135
+ return;
136
+ await rm(lockPath, { force: true });
137
+ }
138
+ catch {
139
+ /* already gone, or unreadable — either way this process is not the one that should force it */
140
+ }
141
+ }
142
+ //# sourceMappingURL=file-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.js","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"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"}