pi-daddy 0.34.0 → 0.36.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 (66) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/advisors/decider.d.ts +7 -3
  3. package/dist/advisors/decider.d.ts.map +1 -1
  4. package/dist/advisors/decider.js.map +1 -1
  5. package/dist/advisors/settings.d.ts +16 -7
  6. package/dist/advisors/settings.d.ts.map +1 -1
  7. package/dist/advisors/settings.js +60 -22
  8. package/dist/advisors/settings.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +3 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/kernel/bounded-read.d.ts +57 -0
  13. package/dist/kernel/bounded-read.d.ts.map +1 -0
  14. package/dist/kernel/bounded-read.js +100 -0
  15. package/dist/kernel/bounded-read.js.map +1 -0
  16. package/dist/kernel/catalog.d.ts +12 -1
  17. package/dist/kernel/catalog.d.ts.map +1 -1
  18. package/dist/kernel/catalog.js +12 -5
  19. package/dist/kernel/catalog.js.map +1 -1
  20. package/dist/kernel/definitions.d.ts +19 -1
  21. package/dist/kernel/definitions.d.ts.map +1 -1
  22. package/dist/kernel/definitions.js +34 -9
  23. package/dist/kernel/definitions.js.map +1 -1
  24. package/dist/kernel/delegate-types.d.ts +11 -1
  25. package/dist/kernel/delegate-types.d.ts.map +1 -1
  26. package/dist/kernel/delegate-types.js.map +1 -1
  27. package/dist/kernel/delegate.d.ts.map +1 -1
  28. package/dist/kernel/delegate.js +3 -1
  29. package/dist/kernel/delegate.js.map +1 -1
  30. package/dist/kernel/env-names.d.ts +14 -0
  31. package/dist/kernel/env-names.d.ts.map +1 -1
  32. package/dist/kernel/env-names.js +16 -0
  33. package/dist/kernel/env-names.js.map +1 -1
  34. package/dist/kernel/propagation.d.ts +1 -1
  35. package/dist/kernel/propagation.d.ts.map +1 -1
  36. package/dist/kernel/propagation.js +8 -3
  37. package/dist/kernel/propagation.js.map +1 -1
  38. package/dist/kernel/skill-packages.d.ts.map +1 -1
  39. package/dist/kernel/skill-packages.js +20 -9
  40. package/dist/kernel/skill-packages.js.map +1 -1
  41. package/dist/kernel/workspace.d.ts +7 -1
  42. package/dist/kernel/workspace.d.ts.map +1 -1
  43. package/dist/kernel/workspace.js +39 -81
  44. package/dist/kernel/workspace.js.map +1 -1
  45. package/extensions/context-staging.ts +15 -7
  46. package/extensions/effort-advice.ts +99 -0
  47. package/extensions/grants-command.ts +17 -0
  48. package/extensions/grants.ts +4 -0
  49. package/extensions/init-command.ts +7 -1
  50. package/extensions/pruning-advice.ts +88 -0
  51. package/extensions/run-delegation.ts +61 -3
  52. package/extensions/session-report.ts +15 -0
  53. package/extensions/session.ts +61 -5
  54. package/package.json +1 -1
  55. package/src/advisors/decider.ts +7 -3
  56. package/src/advisors/settings.ts +61 -21
  57. package/src/cli.ts +9 -1
  58. package/src/kernel/bounded-read.ts +125 -0
  59. package/src/kernel/catalog.ts +42 -21
  60. package/src/kernel/definitions.ts +38 -8
  61. package/src/kernel/delegate-types.ts +12 -1
  62. package/src/kernel/delegate.ts +3 -1
  63. package/src/kernel/env-names.ts +16 -0
  64. package/src/kernel/propagation.ts +9 -2
  65. package/src/kernel/skill-packages.ts +26 -9
  66. package/src/kernel/workspace.ts +43 -109
@@ -12,6 +12,8 @@
12
12
  */
13
13
 
14
14
  import { nativeDelegationContext } from "./delegation-native.ts";
15
+ import { adviseEffort } from "./effort-advice.ts";
16
+ import { advisePruning } from "./pruning-advice.ts";
15
17
  import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/kernel/approval.ts";
16
18
  import { planDelegation } from "../src/kernel/delegate.ts";
17
19
  import {
@@ -249,6 +251,8 @@ export async function runOneDelegation(
249
251
  agent: spec.agent,
250
252
  tools: spec.tools,
251
253
  model: spec.model ?? defaultModel,
254
+ // Filled below, once the refusals that doom a delegation are known: asking first shipped the task text for a
255
+ // child that never starts, which is the ordering this module already fixed for the approval dialog.
252
256
  thinking: spec.thinking,
253
257
  context: spec.context,
254
258
  correlation: spec.workspace
@@ -289,6 +293,28 @@ export async function runOneDelegation(
289
293
  Boolean(executorRefusal || modelRefusal),
290
294
  );
291
295
  executorRefusal ||= nativeRefusal;
296
+
297
+ // ADR-0077's first decision point, after the refusal checks for the reason above. Fills a blank from the levels
298
+ // the CHILD's model reports; never overrules a caller, and yields today's behaviour whenever there is no answer.
299
+ if (!executorRefusal && !modelRefusal)
300
+ request.thinking = await adviseEffort({
301
+ session,
302
+ requested: spec.thinking,
303
+ model: spec.model ?? defaultModel,
304
+ registry: ctx.modelRegistry,
305
+ task: spec.task,
306
+ agent: spec.agent,
307
+ signal,
308
+ });
309
+
310
+ const planContext = await handoffPlanContext({
311
+ session,
312
+ base: extra,
313
+ task: spec.task,
314
+ blocked: Boolean(executorRefusal || modelRefusal),
315
+ preview: () => planWithApprovals(session, request, extra, null, signal, preApproved).then((r) => r.plan),
316
+ ...(signal ? { signal } : {}),
317
+ });
292
318
  let preparedWorkspace: PreparedWorkspace | undefined;
293
319
  let approvalOutcome: ApprovalOutcome | undefined;
294
320
  let plan: ReturnType<typeof planDelegation>;
@@ -297,7 +323,7 @@ export async function runOneDelegation(
297
323
  // Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
298
324
  // preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
299
325
  // no child process.
300
- const preview = await planWithApprovals(session, request, extra, null, signal, preApproved);
326
+ const preview = await planWithApprovals(session, request, planContext, null, signal, preApproved);
301
327
  plan = preview.plan;
302
328
  if (plan.ok || shouldSeekApproval(plan.result)) {
303
329
  try {
@@ -311,7 +337,7 @@ export async function runOneDelegation(
311
337
  ledgerPath: session.ledgerPath,
312
338
  });
313
339
  request.correlation = preparedWorkspace.correlation;
314
- const gated = await planWithApprovals(session, request, extra, ctx, signal, preApproved);
340
+ const gated = await planWithApprovals(session, request, planContext, ctx, signal, preApproved);
315
341
  plan = gated.plan;
316
342
  approvalOutcome = gated.approval;
317
343
  } catch (error) {
@@ -326,7 +352,7 @@ export async function runOneDelegation(
326
352
  const gated = await planWithApprovals(
327
353
  session,
328
354
  request,
329
- extra,
355
+ planContext,
330
356
  executorRefusal || modelRefusal ? null : ctx,
331
357
  signal,
332
358
  preApproved,
@@ -409,3 +435,35 @@ export async function runOneDelegation(
409
435
  onProgress,
410
436
  });
411
437
  }
438
+
439
+ /**
440
+ * The planner context for one delegation, including a `pruned` handoff narrowed by an advisor (ADR-0077).
441
+ *
442
+ * **Exported and taking its own `preview`, so the ordering is forced by a test rather than by a reviewer.** Three
443
+ * properties live here and each was, at some point in this change's history, true only because somebody had
444
+ * checked it by hand: an advisor is not asked for a delegation that is already refused; it is not asked until a
445
+ * plan says the `pruned` handoff actually survived the ceiling, the grant and the gate; and the ids it returns
446
+ * reach the planner. Reviewers measured all three by mutating the source and finding the suite still green. A
447
+ * function with a seam is the only version of this that a test can hold.
448
+ */
449
+ export async function handoffPlanContext(input: {
450
+ session: Parameters<typeof advisePruning>[0]["session"];
451
+ base: Record<string, unknown>;
452
+ task: string;
453
+ /** A refusal is already certain, so nothing may be asked. */
454
+ blocked: boolean;
455
+ /** Plans with no human in the loop; its result decides whether an advisor is consulted at all. */
456
+ preview: () => Promise<{ handoff?: { mode: string } }>;
457
+ signal?: AbortSignal;
458
+ }): Promise<Record<string, unknown>> {
459
+ if (input.blocked) return { ...input.base };
460
+ const plan = await input.preview();
461
+ if (plan.handoff?.mode !== "pruned") return { ...input.base };
462
+ const ids = await advisePruning({
463
+ session: input.session,
464
+ granted: plan.handoff as Parameters<typeof advisePruning>[0]["granted"],
465
+ task: input.task,
466
+ ...(input.signal ? { signal: input.signal } : {}),
467
+ });
468
+ return ids ? { ...input.base, handoffTurnIds: ids } : { ...input.base };
469
+ }
@@ -57,6 +57,21 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
57
57
  "warning",
58
58
  );
59
59
  }
60
+ // **Rule 8's loud half for the two reads that discover what exists.** Both of these used to fail soft and
61
+ // say nothing, and review established that session start — not `/grants` — is where every other such
62
+ // notice lives (a malformed bound above, ledger corruption and an executor refusal below). A reason an
63
+ // operator has to ask for is a reason most operators never see.
64
+ //
65
+ // The definitions one matters more than it looks: the 1 MiB bound is NEW behaviour, so a definition that
66
+ // loaded yesterday can be absent today, and without this line the only symptom is `unknown agent "x"` at
67
+ // the moment somebody tries to delegate to it.
68
+ for (const reason of session.definitionSkips)
69
+ ctx.ui.notify(`grants: a definition was not loaded — ${reason}`, "warning");
70
+ if (session.catalog.registryRefusal)
71
+ ctx.ui.notify(
72
+ `grants: workspace registry unreadable, no workspace is routable this session — ${session.catalog.registryRefusal}`,
73
+ "warning",
74
+ );
60
75
  // ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
61
76
  // import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
62
77
  // operator whose approvals silently stopped applying deserves to know why.
@@ -49,8 +49,10 @@ import { republishable } from "./approvals.ts";
49
49
  import { storedGrantSessionState } from "./stored-grant-session.ts";
50
50
  import { nativeSessionRootFromEnv, type NativeSessionHost } from "../src/executors/native-session-target.ts";
51
51
  import { createHandoffStager, type ParentSession } from "./context-staging.ts";
52
+ import { createAdvisorSession, type AdvisorSession } from "./advisor-session.ts";
52
53
  import { join } from "node:path";
53
- import { agentDir } from "../src/kernel/project-paths.ts";
54
+ import { readFileSync, statSync } from "node:fs";
55
+ import { agentDir, projectSettingsPath } from "../src/kernel/project-paths.ts";
54
56
  import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts";
55
57
  import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
56
58
  import { reconcileSessionEnvironment } from "./session-environment.ts";
@@ -87,7 +89,7 @@ import {
87
89
  ENV_ACTIVITY_ROOT,
88
90
  ENV_ACTIVITY_TASK,
89
91
  } from "../src/products/activity-timeline.ts";
90
- import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
92
+ import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR } from "../src/kernel/env-names.ts";
91
93
  export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
92
94
  import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts";
93
95
 
@@ -157,6 +159,8 @@ export interface GrantsSession extends NativeSessionHost {
157
159
  * context handoff (ADR-0078): its file path for `fork`, its message turns for `pruned`.
158
160
  */
159
161
  parentSession?: ParentSession;
162
+ /** ADR-0077: the session's advisor, off unless the environment enables one. Never consulted for authority. */
163
+ advisorSession: AdvisorSession;
160
164
  /** Root identity keyed to ctx.sessionManager once session_start supplies it. */
161
165
  reloadLifecycle: ReloadLifecycle;
162
166
  /** Approval keys approved for this session. In memory only — this dies with the process. */
@@ -182,6 +186,16 @@ export interface GrantsSession extends NativeSessionHost {
182
186
  observedTools: string[] | null;
183
187
  /** ADR-0016: `SKILL.md` definitions, keyed by name. The format this package spawns from now. */
184
188
  definitions: Map<string, SkillDefinition>;
189
+ /**
190
+ * Definitions discovery dropped, and why — reported at session start (ADR-0076, rule 8).
191
+ *
192
+ * Review found the bound shipped without this: `loadDefinitions` grew a `skipped` callback and NO
193
+ * production caller passed one, so an oversized or unreadable `SKILL.md` still vanished with nothing
194
+ * anywhere explaining it. That is worse than the `catch { continue }` it replaced, because the bound is
195
+ * new behaviour — a 2 MiB definition used to load. A capability that only a test can observe is not a
196
+ * capability.
197
+ */
198
+ definitionSkips: string[];
185
199
  catalog: Catalog;
186
200
  /**
187
201
  * The in-flight catalog build, so `delegate` can wait for it instead of racing it.
@@ -251,7 +265,9 @@ export interface GrantsSession extends NativeSessionHost {
251
265
  * about what loading means, so there is one.
252
266
  */
253
267
  export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
254
- session.definitions = await loadDefinitions(cwd);
268
+ const skips: string[] = [];
269
+ session.definitions = await loadDefinitions(cwd, (_path, reason) => skips.push(reason));
270
+ session.definitionSkips = skips;
255
271
  session.catalogReady = buildCatalog({
256
272
  cwd,
257
273
  observedTools: session.observedTools,
@@ -296,13 +312,25 @@ export function createGrantsSession(
296
312
  const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
297
313
  const { depth, maxDepth } = bounds;
298
314
  const emptyCatalog = makeCatalog([]);
315
+ // ADR-0077. The environment decides whether there is an advisor at all; the project's settings block may only
316
+ // narrow it. The block IS read — the first version passed `undefined` and every narrowing the release advertised
317
+ // was dead code reachable only from tests, which review measured: `enabled: false` turned nothing off.
318
+ //
319
+ // Read from `storeCwd` for `loadStoredGrantStateSync`'s reason: this factory runs before any hook, so `ctx.cwd`
320
+ // does not exist yet. Reading a workspace-writable file here is safe precisely because it can only narrow.
321
+ const advisorSession = createAdvisorSession({
322
+ block: projectAdvisorBlock(storeCwd),
323
+ ...(storedLedger ? { ledgerPath: storedLedger } : {}),
324
+ });
299
325
  const session: GrantsSession = {
326
+ advisorSession,
300
327
  adoptedLegacyEnv,
301
328
  governed,
302
329
  inherited,
303
330
  depth,
304
331
  maxDepth,
305
332
  malformedBounds: bounds.malformed,
333
+ definitionSkips: [],
306
334
  // ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one
307
335
  // (no PI_DADDY_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always
308
336
  // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
@@ -361,12 +389,16 @@ export function createGrantsSession(
361
389
  observerExtensionPath: session.observerExtensionPath,
362
390
  childEnv: activityChildEnv(session.activity),
363
391
  // ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
364
- stageHandoff: (granted) =>
392
+ // `options` is forwarded, and its absence is why the second decision point was dead in production: a
393
+ // one-parameter arrow is assignable to a two-parameter type, so the ids reached here and were discarded while
394
+ // the advisor had already been asked. Review measured it. `test/pruning-advice.test.ts` now goes through this
395
+ // function rather than calling the stager directly.
396
+ stageHandoff: (granted, options) =>
365
397
  createHandoffStager({
366
398
  cwd: session.cwd,
367
399
  forkRoot: join(agentDir(), "context-forks"),
368
400
  ...(session.parentSession ? { parentSession: session.parentSession } : {}),
369
- })(granted),
401
+ })(granted, options),
370
402
  catalog: await session.catalogReady,
371
403
  // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
372
404
  // Derived from the catalog's own `source`, so it cannot drift from what was discovered.
@@ -420,3 +452,27 @@ export function createGrantsSession(
420
452
  };
421
453
  return session;
422
454
  }
455
+
456
+ /**
457
+ * The `advisor` block of `.pi/pi-daddy/settings.json`, or undefined.
458
+ *
459
+ * Unreadable, absent or malformed all yield undefined: this file is the reviewable record, not authority, and the
460
+ * only thing it can do to an advisor is turn one off. A parse failure therefore costs nothing worth reporting.
461
+ */
462
+ function projectAdvisorBlock(cwd: string): unknown {
463
+ // Only when an advisor could exist at all. This runs in every session including every child, before any hook, and
464
+ // a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it.
465
+ if (!process.env[ENV_ADVISOR]?.trim()) return undefined;
466
+ try {
467
+ const path = projectSettingsPath(cwd);
468
+ // Bounded and type-checked first: this is the third unbounded session-start read AGENTS.md warns about, and the
469
+ // only one whose path a governed child holding `tool:write` can replace with a FIFO — which would hang pi
470
+ // before any hook exists to report it.
471
+ const stats = statSync(path);
472
+ if (!stats.isFile() || stats.size > 1024 * 1024) return undefined;
473
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
474
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>).advisor : undefined;
475
+ } catch {
476
+ return undefined;
477
+ }
478
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.34.0",
3
+ "version": "0.36.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",
@@ -32,9 +32,13 @@ export type Answer =
32
32
 
33
33
  export interface AdviceRequest {
34
34
  /**
35
- * What the advisor is told about the situation. **Caller-composed and deliberately not the raw task**: the task
36
- * is never stored (ADR-0021) and must not be shipped to a third party either, so a caller passes the facts it
37
- * chose, and the record below names them by key without their values.
35
+ * What the advisor is told about the situation, composed by the caller.
36
+ *
37
+ * **Sent, never recorded.** The task is never STORED (ADR-0021) and that still holds — `createAdvisor` writes the
38
+ * question keys and the answers and never this object. But an advisor cannot judge a task it cannot see, so a
39
+ * caller that needs one judged does send it, and the operator's consent for that is the advisor being off by
40
+ * default. An earlier draft of this paragraph said the raw task "must not be shipped to a third party either",
41
+ * which the first decision point then did; the rule that survived review is the narrower and true one.
38
42
  */
39
43
  state: Readonly<Record<string, unknown>>;
40
44
  questions: Readonly<Record<string, Question>>;
@@ -1,24 +1,34 @@
1
1
  /**
2
2
  * Whether an advisor is on, and which one (ADR-0077).
3
3
  *
4
- * **Default off, and off is the whole configuration when nothing says otherwise.** An advisor sends a description
5
- * of the caller's situation to a third party, so it is not something a package turns on for somebody: it is turned
6
- * on in `.pi/pi-daddy/settings.json`, the file an operator reviews and commits, beside the grant that governs
7
- * everything else. Malformed configuration disables the advisor and says so, which is this project's rule for
8
- * configuration everywhere: a typo must not be a way to enable something.
4
+ * **Default off, and only the environment can turn it on.** An advisor sends a description of the caller's
5
+ * situation to a third party, so enabling one is `PI_DADDY_ADVISOR=jev` plus a key — both outside the workspace,
6
+ * both stripped from every child.
7
+ *
8
+ * 0.34.0 read the enable from `.pi/pi-daddy/settings.json` and that was wrong for the reason `grant-store.ts`
9
+ * states about the same file: it is writable by any child holding `tool:write`, so it is "the reviewable record of
10
+ * the decision, not the thing the enforcer reads". A grant lives outside the workspace precisely so a child cannot
11
+ * widen the next session's ceiling; an advisor switch a child could flip would make the operator's next session
12
+ * ship its own description to a third party, which is the same self-defeating shape. The settings block may still
13
+ * NARROW — a shorter timeout, or `enabled: false` to turn an advisor off for one project — and can never turn one
14
+ * on, choose its model, or lengthen its bound. A model is a destination rather than a narrowing, so `model` in the
15
+ * block is refused with a message naming `PI_DADDY_ADVISOR_MODEL`, and a timeout is clamped to the default rather
16
+ * than trusted. Malformed configuration disables the advisor and says so: a typo must not be a way to enable anything.
9
17
  *
10
18
  * **Not a dashboard toggle**, which is what the programme originally sketched. The dashboard is a read-only
11
19
  * renderer in a separate process that "never affects enforcement" (ADR-0036), and a control there that wrote to
12
20
  * settings would be the first thing it ever wrote. Turning an advisor on is an operator decision that belongs in
13
21
  * the reviewable file; `/grants` reports what is in force. That is a deliberate departure from the roadmap line.
14
22
  *
15
- * The key is never in this file. It is read from the environment, because a settings file is committed and an API
16
- * key must not be.
23
+ * The key is never in the settings file either, because that file is committed and an API key must not be.
17
24
  */
18
25
 
19
26
  // Spelled once, in the kernel's table, so this layer cannot drift from the list `childEnv` refuses to write.
20
27
  export { ENV_ADVISOR_KEY as ADVISOR_KEY_ENV } from "../kernel/env-names.ts";
21
- import { ENV_ADVISOR_KEY } from "../kernel/env-names.ts";
28
+ import { ENV_ADVISOR, ENV_ADVISOR_KEY, ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
29
+ import { DEFAULT_ADVICE_TIMEOUT_MS } from "./advisor.ts";
30
+ export { ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
31
+ export { ENV_ADVISOR } from "../kernel/env-names.ts";
22
32
 
23
33
  export interface AdvisorSettings {
24
34
  enabled: boolean;
@@ -41,23 +51,49 @@ export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, deci
41
51
  * their session without having successfully asked for it. Both are worse than a sentence naming the field.
42
52
  */
43
53
  export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = process.env): AdvisorSettings {
44
- if (raw === undefined || raw === null) return ADVISOR_OFF;
45
- if (typeof raw !== "object" || Array.isArray(raw))
54
+ // The environment decides WHETHER, before the workspace is consulted at all. A settings file that asks for an
55
+ // advisor nobody enabled is not a configuration error; it is simply a project that would use one if the operator
56
+ // turned it on, so it is reported rather than refused.
57
+ const requested = env[ENV_ADVISOR]?.trim();
58
+ if (!requested) return ADVISOR_OFF;
59
+ if (requested !== "jev")
60
+ return { ...ADVISOR_OFF, refusal: `${ENV_ADVISOR}=${requested} names no advisor this release knows` };
61
+
62
+ const block = raw === undefined || raw === null ? {} : raw;
63
+ if (typeof block !== "object" || Array.isArray(block))
46
64
  return { ...ADVISOR_OFF, refusal: "settings.advisor must be an object; no advisor is enabled" };
47
- const block = raw as Record<string, unknown>;
48
- const unknownKeys = Object.keys(block).filter((key) => !["enabled", "decider", "model", "timeoutMs"].includes(key));
65
+ const fields = block as Record<string, unknown>;
66
+ // `model` stays KNOWN so the refusal below can say why it is refused, rather than reporting it as a typo.
67
+ const unknownKeys = Object.keys(fields).filter((key) => !["enabled", "decider", "model", "timeoutMs"].includes(key));
68
+ // Key names are echoed back, and this file is workspace-writable: a key containing an escape sequence or a
69
+ // newline would otherwise forge lines in the `/grants` panel, which is a trust surface. Review measured a forged
70
+ // `grant tool:*` line. Sanitised and truncated before it reaches any renderer.
49
71
  if (unknownKeys.length > 0)
50
- return { ...ADVISOR_OFF, refusal: `settings.advisor has unknown field(s) ${unknownKeys.join(", ")}` };
51
- if (block.enabled !== true) return ADVISOR_OFF;
52
- if (block.decider !== "jev")
72
+ return {
73
+ ...ADVISOR_OFF,
74
+ refusal: `settings.advisor has unknown field(s) ${unknownKeys
75
+ .map((key) => key.replace(/[^\w.-]/g, "?").slice(0, 40))
76
+ .join(", ")}`,
77
+ };
78
+ // A project may switch it OFF; it may never switch it on, which is why `true` is not read. Anything that is not
79
+ // exactly `true` disables: `"false"`, `0` and `null` used to leave the advisor ON with no word said, which is
80
+ // rule 8 inverted — the one control this file retains failing open.
81
+ if (fields.enabled !== undefined && fields.enabled !== true)
82
+ return { ...ADVISOR_OFF, refusal: "settings.advisor.enabled is not true for this project" };
83
+ if (fields.decider !== undefined && fields.decider !== "jev")
53
84
  return { ...ADVISOR_OFF, refusal: `settings.advisor.decider must be "jev"; no advisor is enabled` };
54
- if (block.model !== undefined && typeof block.model !== "string")
55
- return { ...ADVISOR_OFF, refusal: "settings.advisor.model must be a string" };
85
+ // **A model is a DESTINATION, not a narrowing.** Letting this workspace-writable file choose it would let a child
86
+ // holding `tool:write` point the operator's next session at a generative model of its choosing, billed to the
87
+ // operator's key — the same self-defeating shape this release moved the enable switch to close, one step
88
+ // sideways. Review measured it. The model comes from the environment or not at all.
89
+ if ((fields as Record<string, unknown>).model !== undefined)
90
+ return { ...ADVISOR_OFF, refusal: `settings.advisor.model is not a narrowing; set ${ENV_ADVISOR_MODEL} instead` };
56
91
  if (
57
- block.timeoutMs !== undefined &&
58
- (!Number.isInteger(block.timeoutMs) || (block.timeoutMs as number) < 1 || (block.timeoutMs as number) > 30_000)
92
+ fields.timeoutMs !== undefined &&
93
+ (!Number.isInteger(fields.timeoutMs) || (fields.timeoutMs as number) < 1 || (fields.timeoutMs as number) > 30_000)
59
94
  )
60
95
  return { ...ADVISOR_OFF, refusal: "settings.advisor.timeoutMs must be an integer between 1 and 30000" };
96
+ const model = env[ENV_ADVISOR_MODEL]?.trim();
61
97
  const key = env[ENV_ADVISOR_KEY]?.trim();
62
98
  if (!key)
63
99
  return {
@@ -67,7 +103,11 @@ export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = proce
67
103
  return {
68
104
  enabled: true,
69
105
  decider: "jev",
70
- ...(typeof block.model === "string" ? { model: block.model } : {}),
71
- ...(block.timeoutMs !== undefined ? { timeoutMs: block.timeoutMs as number } : {}),
106
+ ...(model ? { model } : {}),
107
+ // Clamped, never raised: a longer bound is not a narrowing either, and a child-writable 30s would be a stall on
108
+ // every delegation.
109
+ ...(fields.timeoutMs !== undefined
110
+ ? { timeoutMs: Math.min(fields.timeoutMs as number, DEFAULT_ADVICE_TIMEOUT_MS) }
111
+ : {}),
72
112
  };
73
113
  }
package/src/cli.ts CHANGED
@@ -150,7 +150,15 @@ async function init(cwd: string, force: boolean): Promise<number> {
150
150
 
151
151
  let plan: InitPlan;
152
152
  try {
153
- plan = planInit(packages, cwd, await registeredWorkspaceIds());
153
+ // A registry that cannot be read scaffolds no workspace capabilities. That was silent, so the operator
154
+ // saw a plan with none and no way to tell it apart from having registered none (rule 8).
155
+ plan = planInit(
156
+ packages,
157
+ cwd,
158
+ await registeredWorkspaceIds(undefined, (reason) =>
159
+ console.error(`pi-daddy init: workspace registry unreadable, scaffolding none — ${reason}`),
160
+ ),
161
+ );
154
162
  } catch (error) {
155
163
  // R-78's backstop reaching the surface. Nothing is written: a grant that could mean something to a
156
164
  // shell is not a grant, and half-scaffolding a project would be worse than scaffolding none of it.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * One bounded reader for the operator-authored files this package reads at SESSION START.
3
+ *
4
+ * **Why this is a module rather than a pattern to copy.** `loadWorkspaceRegistry` worked out the correct
5
+ * shape the expensive way — R-79 hung forever on a FIFO, a `stat`-then-`readFile` rewrite reintroduced the
6
+ * hang through a TOCTOU, and `AbortSignal.timeout` turned out never to interrupt a libuv read. That comment
7
+ * block ends with "One reader is why that cannot happen again", and then a second session-start read
8
+ * (`loadDefinitions`, reading each `SKILL.md`) went on using a bare `readFile`. The guards were not copied
9
+ * because copying them correctly is exactly what the registry's own history says nobody manages.
10
+ *
11
+ * What the shape buys, restated once here so neither caller has to:
12
+ *
13
+ * - `O_NONBLOCK` on the open. A FIFO blocks inside `open(2)` before any read starts, so a signal or a
14
+ * deadline checked between chunks can never rescue it; a non-blocking open returns instead.
15
+ * - every check against the HELD DESCRIPTOR. `stat` by name followed by a read by name is a TOCTOU, and the
16
+ * attacker is any process at the same uid — swapping a regular file for a FIFO between the two hangs the
17
+ * reader. `fstat` on a descriptor has no name left to re-resolve.
18
+ * - a deadline checked BETWEEN chunks, which is all an `AbortSignal` ever managed. Its honest limit: a
19
+ * stalled open or a single wedged read cannot be interrupted from in-process.
20
+ * - a size bound checked twice, before the read from `fstat` and again after, because a file can grow
21
+ * between the two. **The buffer is `maxBytes + 1` regardless of the file's size, and that is deliberate.**
22
+ * Review proposed sizing it from `fstat` instead; that would be faster and would silently break the second
23
+ * check, because a file whose reported size understates its content — anything under procfs, and any file
24
+ * that grows — would fill its small buffer exactly and come back as a successful truncated read. The cost
25
+ * was measured rather than assumed: 500 allocations of 1 MiB + 1 take 8.1ms for an RSS delta of 2.8 MB,
26
+ * because `allocUnsafe` never touches the pages.
27
+ *
28
+ * **The clock is injected** so the deadline is forced by a test rather than by a reviewer. Before this
29
+ * module the registry's deadline could be deleted outright and all 876 tests still passed (measured at
30
+ * `7096f78`), which is rule 7's "a test that cannot fail is worse than no test" with the production change
31
+ * named: delete the `now() > deadline` branch below and `bounded-read.test.ts` fails.
32
+ */
33
+ import { constants } from "node:fs";
34
+ import { open, type FileHandle } from "node:fs/promises";
35
+
36
+ export interface BoundedReadLimits {
37
+ /** Refuse anything at or over this, before allocating it. */
38
+ maxBytes: number;
39
+ /** How long the chunk loop may run before the read is a refusal rather than a wait. */
40
+ timeoutMs: number;
41
+ /** Injected only by tests; production passes nothing and gets the wall clock. */
42
+ now?: () => number;
43
+ }
44
+
45
+ /**
46
+ * Why a bounded read did not produce text.
47
+ *
48
+ * A discriminated reason rather than a thrown error, because the two callers disagree about what a failure
49
+ * MEANS — an unreadable registry is a governance refusal naming the file, while an unreadable `SKILL.md` is
50
+ * one definition that will not be offered — and a shared reader must not decide that for them.
51
+ */
52
+ export type BoundedReadFailure =
53
+ | { why: "unopenable"; detail: string }
54
+ | { why: "not-a-regular-file"; detail: string }
55
+ | { why: "too-large"; detail: string; size: number }
56
+ | { why: "grew-while-reading"; detail: string }
57
+ | { why: "timed-out"; detail: string }
58
+ | { why: "unreadable"; detail: string };
59
+
60
+ export type BoundedReadResult = { ok: true; text: string } | ({ ok: false } & BoundedReadFailure);
61
+ export type BoundedReadBytes = { ok: true; bytes: Buffer } | ({ ok: false } & BoundedReadFailure);
62
+
63
+ /**
64
+ * The same read, handing back raw bytes.
65
+ *
66
+ * `skill-packages.ts` needs the bytes rather than a string, because it asserts that a definition survives a
67
+ * UTF-8 round trip unchanged — a latin-1 byte that decodes to U+FFFD changes the file's digest, and that
68
+ * check is only possible against the original buffer. Decoding here and re-encoding there would defeat it.
69
+ */
70
+ export async function readBoundedFile(path: string, limits: BoundedReadLimits): Promise<BoundedReadResult> {
71
+ const read = await readBoundedBytes(path, limits);
72
+ return read.ok ? { ok: true, text: read.bytes.toString("utf8") } : read;
73
+ }
74
+
75
+ export async function readBoundedBytes(path: string, limits: BoundedReadLimits): Promise<BoundedReadBytes> {
76
+ const now = limits.now ?? Date.now;
77
+ let handle: FileHandle;
78
+ try {
79
+ handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
80
+ } catch (error) {
81
+ return { ok: false, why: "unopenable", detail: String(error) };
82
+ }
83
+ try {
84
+ const info = await handle.stat();
85
+ if (!info.isFile())
86
+ return {
87
+ ok: false,
88
+ why: "not-a-regular-file",
89
+ detail: `${path} is not a regular file — a FIFO, device or socket here would block rather than fail`,
90
+ };
91
+ if (info.size > limits.maxBytes)
92
+ return {
93
+ ok: false,
94
+ why: "too-large",
95
+ size: info.size,
96
+ detail: `${path} is ${info.size} bytes, over the ${limits.maxBytes} limit`,
97
+ };
98
+
99
+ const deadline = now() + limits.timeoutMs;
100
+ const buffer = Buffer.allocUnsafe(limits.maxBytes + 1);
101
+ let filled = 0;
102
+ while (filled < buffer.length) {
103
+ if (now() > deadline)
104
+ return {
105
+ ok: false,
106
+ why: "timed-out",
107
+ detail: `${path} did not finish reading within ${limits.timeoutMs}ms`,
108
+ };
109
+ const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
110
+ if (bytesRead === 0) break;
111
+ filled += bytesRead;
112
+ }
113
+ if (filled > limits.maxBytes)
114
+ return {
115
+ ok: false,
116
+ why: "grew-while-reading",
117
+ detail: `${path} exceeded the ${limits.maxBytes} limit while being read — it grew after its size was checked`,
118
+ };
119
+ return { ok: true, bytes: buffer.subarray(0, filled) };
120
+ } catch (error) {
121
+ return { ok: false, why: "unreadable", detail: String(error) };
122
+ } finally {
123
+ await handle.close().catch(() => {});
124
+ }
125
+ }
@@ -44,6 +44,17 @@ export interface Catalog {
44
44
  all: Capability[];
45
45
  byKind(kind: CapabilityKind): Capability[];
46
46
  has(capability: Capability): boolean;
47
+ /**
48
+ * Why the workspace registry contributed nothing, when one was named and could not be read.
49
+ *
50
+ * The catalog fails SOFT on a bad registry — a malformed one must not stop a session starting — and the
51
+ * refusal used to be discarded by `() => []` at the call below. That is rule 8's silent safe-mode: one
52
+ * malformed entry removed every workspace from `/grants`, from the catalog and from `init`, and produced
53
+ * no message anywhere, so an operator saw an empty list and could not tell it apart from having
54
+ * registered nothing. Failing soft is still right; failing soft and SILENTLY was not. `undefined` means
55
+ * no registry was named, or it read cleanly.
56
+ */
57
+ registryRefusal?: string;
47
58
  }
48
59
 
49
60
  /** Split observed tool names into pi built-ins and extension-provided tools. */
@@ -107,7 +118,7 @@ export function workspaceEntries(registry: WorkspaceRegistryFile, source?: strin
107
118
  }
108
119
 
109
120
  /** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
110
- export function makeCatalog(entries: CatalogEntry[]): Catalog {
121
+ export function makeCatalog(entries: CatalogEntry[], registryRefusal?: string): Catalog {
111
122
  const deduped = new Map<Capability, CatalogEntry>();
112
123
  for (const entry of entries) if (!deduped.has(entry.capability)) deduped.set(entry.capability, entry);
113
124
  const list = [...deduped.values()].sort((a, b) => a.capability.localeCompare(b.capability));
@@ -118,6 +129,7 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
118
129
  all: ids,
119
130
  byKind: (kind) => list.filter((e) => e.kind === kind).map((e) => e.capability),
120
131
  has: (capability) => idSet.has(capability),
132
+ ...(registryRefusal === undefined ? {} : { registryRefusal }),
121
133
  };
122
134
  }
123
135
 
@@ -135,30 +147,39 @@ export async function buildCatalog(input: {
135
147
  // session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
136
148
  // refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
137
149
  // it there would be unsafe; swallowing it here costs a display list.
150
+ //
151
+ // **What the refusal is no longer allowed to do is vanish.** This handler was `() => []`, so the display
152
+ // list was lost AND the reason with it. The reason now rides on the catalog and `/grants` prints it.
138
153
  input.registryPath
139
154
  ? loadWorkspaceRegistry(input.registryPath).then(
140
- (r) => workspaceEntries(r, input.registryPath),
141
- () => [] as CatalogEntry[],
155
+ (r) => ({ entries: workspaceEntries(r, input.registryPath), refusal: undefined as string | undefined }),
156
+ (error: unknown) => ({
157
+ entries: [] as CatalogEntry[],
158
+ refusal: error instanceof Error ? error.message : String(error),
159
+ }),
142
160
  )
143
- : Promise.resolve([] as CatalogEntry[]),
144
- ]);
145
- return makeCatalog([
146
- // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
147
- // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
148
- // every capability looked "unknown" until the first model call, so the preview refused grants that
149
- // enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
150
- // reappearing through a different door.
151
- //
152
- // The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
153
- // as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
154
- // the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
155
- // the grant check — not this catalog — is the authority. Nothing here grants anything.
156
- ...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
157
- ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
158
- ...skills,
159
- ...definitionEntries(definitions),
160
- ...workspaces,
161
+ : Promise.resolve({ entries: [] as CatalogEntry[], refusal: undefined as string | undefined }),
161
162
  ]);
163
+ return makeCatalog(
164
+ [
165
+ // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
166
+ // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
167
+ // every capability looked "unknown" until the first model call, so the preview refused grants that
168
+ // enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
169
+ // reappearing through a different door.
170
+ //
171
+ // The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
172
+ // as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
173
+ // the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
174
+ // the grant check — not this catalog — is the authority. Nothing here grants anything.
175
+ ...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
176
+ ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
177
+ ...skills,
178
+ ...definitionEntries(definitions),
179
+ ...workspaces.entries,
180
+ ],
181
+ workspaces.refusal,
182
+ );
162
183
  }
163
184
 
164
185
  /**