pi-daddy 0.32.1 → 0.35.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 +83 -0
  2. package/contracts/ledger-record/v1/governance-event.schema.json +46 -1
  3. package/dist/advisors/advisor.d.ts +49 -0
  4. package/dist/advisors/advisor.d.ts.map +1 -0
  5. package/dist/advisors/advisor.js +76 -0
  6. package/dist/advisors/advisor.js.map +1 -0
  7. package/dist/advisors/decider.d.ts +79 -0
  8. package/dist/advisors/decider.d.ts.map +1 -0
  9. package/dist/advisors/decider.js +29 -0
  10. package/dist/advisors/decider.js.map +1 -0
  11. package/dist/advisors/jev.d.ts +41 -0
  12. package/dist/advisors/jev.d.ts.map +1 -0
  13. package/dist/advisors/jev.js +108 -0
  14. package/dist/advisors/jev.js.map +1 -0
  15. package/dist/advisors/settings.d.ts +47 -0
  16. package/dist/advisors/settings.d.ts.map +1 -0
  17. package/dist/advisors/settings.js +98 -0
  18. package/dist/advisors/settings.js.map +1 -0
  19. package/dist/executors/activity-session.d.ts.map +1 -1
  20. package/dist/executors/activity-session.js +27 -0
  21. package/dist/executors/activity-session.js.map +1 -1
  22. package/dist/executors/herdr-stage.d.ts +1 -1
  23. package/dist/executors/herdr-stage.d.ts.map +1 -1
  24. package/dist/executors/herdr-stage.js +25 -8
  25. package/dist/executors/herdr-stage.js.map +1 -1
  26. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  27. package/dist/governance/ledger-v3-validation.js +1 -0
  28. package/dist/governance/ledger-v3-validation.js.map +1 -1
  29. package/dist/governance/ledger.d.ts +14 -0
  30. package/dist/governance/ledger.d.ts.map +1 -1
  31. package/dist/governance/ledger.js +1 -0
  32. package/dist/governance/ledger.js.map +1 -1
  33. package/dist/kernel/capabilities.d.ts +1 -1
  34. package/dist/kernel/capabilities.d.ts.map +1 -1
  35. package/dist/kernel/capabilities.js +5 -1
  36. package/dist/kernel/capabilities.js.map +1 -1
  37. package/dist/kernel/catalog.d.ts.map +1 -1
  38. package/dist/kernel/catalog.js +6 -0
  39. package/dist/kernel/catalog.js.map +1 -1
  40. package/dist/kernel/chain.d.ts +2 -0
  41. package/dist/kernel/chain.d.ts.map +1 -1
  42. package/dist/kernel/chain.js.map +1 -1
  43. package/dist/kernel/context-handoff.d.ts +85 -0
  44. package/dist/kernel/context-handoff.d.ts.map +1 -0
  45. package/dist/kernel/context-handoff.js +177 -0
  46. package/dist/kernel/context-handoff.js.map +1 -0
  47. package/dist/kernel/delegate-types.d.ts +70 -0
  48. package/dist/kernel/delegate-types.d.ts.map +1 -1
  49. package/dist/kernel/delegate-types.js.map +1 -1
  50. package/dist/kernel/delegate.d.ts.map +1 -1
  51. package/dist/kernel/delegate.js +50 -2
  52. package/dist/kernel/delegate.js.map +1 -1
  53. package/dist/kernel/env-names.d.ts +24 -0
  54. package/dist/kernel/env-names.d.ts.map +1 -1
  55. package/dist/kernel/env-names.js +27 -0
  56. package/dist/kernel/env-names.js.map +1 -1
  57. package/dist/kernel/propagation.d.ts +1 -1
  58. package/dist/kernel/propagation.d.ts.map +1 -1
  59. package/dist/kernel/propagation.js +14 -2
  60. package/dist/kernel/propagation.js.map +1 -1
  61. package/dist/kernel/refusals.d.ts +1 -1
  62. package/dist/kernel/refusals.d.ts.map +1 -1
  63. package/dist/kernel/refusals.js +1 -0
  64. package/dist/kernel/refusals.js.map +1 -1
  65. package/dist/kernel/resolve.d.ts.map +1 -1
  66. package/dist/kernel/resolve.js +4 -0
  67. package/dist/kernel/resolve.js.map +1 -1
  68. package/dist/kernel/spawn.d.ts +21 -0
  69. package/dist/kernel/spawn.d.ts.map +1 -1
  70. package/dist/kernel/spawn.js +8 -1
  71. package/dist/kernel/spawn.js.map +1 -1
  72. package/extensions/advisor-session.ts +64 -0
  73. package/extensions/chain-plan.ts +7 -1
  74. package/extensions/context-shape.ts +30 -0
  75. package/extensions/context-staging.ts +208 -0
  76. package/extensions/delegate-chain.ts +2 -0
  77. package/extensions/delegation-ledger.ts +2 -0
  78. package/extensions/delegation.ts +4 -0
  79. package/extensions/effort-advice.ts +99 -0
  80. package/extensions/execute-child.ts +8 -0
  81. package/extensions/grants-command.ts +10 -0
  82. package/extensions/grants.ts +9 -0
  83. package/extensions/pruning-advice.ts +88 -0
  84. package/extensions/run-delegation.ts +64 -3
  85. package/extensions/session.ts +59 -1
  86. package/package.json +1 -1
  87. package/src/advisors/advisor.ts +123 -0
  88. package/src/advisors/decider.ts +68 -0
  89. package/src/advisors/jev.ts +130 -0
  90. package/src/advisors/settings.ts +113 -0
  91. package/src/executors/activity-session.ts +28 -0
  92. package/src/executors/herdr-stage.ts +26 -8
  93. package/src/governance/ledger-v3-validation.ts +1 -0
  94. package/src/governance/ledger.ts +15 -0
  95. package/src/kernel/capabilities.ts +5 -1
  96. package/src/kernel/catalog.ts +6 -0
  97. package/src/kernel/chain.ts +2 -0
  98. package/src/kernel/context-handoff.ts +231 -0
  99. package/src/kernel/delegate-types.ts +62 -0
  100. package/src/kernel/delegate.ts +56 -2
  101. package/src/kernel/env-names.ts +27 -0
  102. package/src/kernel/propagation.ts +16 -1
  103. package/src/kernel/refusals.ts +1 -0
  104. package/src/kernel/resolve.ts +4 -0
  105. package/src/kernel/spawn.ts +31 -1
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Stage what a granted handoff hands over (ADR-0078).
3
+ *
4
+ * The kernel decides WHETHER context crosses and in which mode; this decides what the bytes are, because building
5
+ * them means reading files and the parent's own session and the kernel does no I/O. It is called only after the
6
+ * mode has survived the ceiling, the parent's grant and the gate, so a refused handoff reads nothing.
7
+ *
8
+ * **What authorises the read.** `context:files` is the authorisation, not `tool:read`: an operator who grants a
9
+ * definition the right to receive file contents has said so explicitly, in the grant, where a reviewer sees it.
10
+ * Paths are still confined to the session's working directory, because they are MODEL-supplied and the fence must
11
+ * not become a way to read `/etc` without holding a tool that can. That confinement is a bound on the parameter,
12
+ * not a claim of containment: the parent process can already read whatever its own grant allows.
13
+ *
14
+ * **What is not staged.** A mode whose input cannot be read — a missing file, an unreadable session — yields a
15
+ * section saying so rather than silence. A handoff that quietly carried less than it promised would be R-03's
16
+ * shape, a missing result indistinguishable from an empty one, and the child would have no way to know.
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ import { closeSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync } from "node:fs";
20
+ import { isAbsolute, relative, resolve } from "node:path";
21
+ import {
22
+ fenceContext,
23
+ selectPrunedTurns,
24
+ type ContextRequest,
25
+ type ContextSection,
26
+ type PrunableTurn,
27
+ } from "../src/kernel/context-handoff.ts";
28
+
29
+ /** The parent's session, as much of it as staging needs. Satisfied by pi's `ReadonlySessionManager`. */
30
+ export interface ParentSession {
31
+ getSessionFile(): string | undefined;
32
+ getEntries(): Array<{ id: string; type: string }>;
33
+ }
34
+
35
+ export interface StagingInput {
36
+ cwd: string;
37
+ /** Where a forked session is written. One directory per fork, private to this uid. */
38
+ forkRoot: string;
39
+ parentSession?: ParentSession;
40
+ }
41
+
42
+ export interface StagedHandoff {
43
+ contextPrompt?: string;
44
+ forkFrom?: { sessionPath: string; sessionDir: string; sessionId: string };
45
+ /**
46
+ * Why nothing could be staged. Returned rather than thrown: `planDelegation` is pure and no caller expects it to
47
+ * throw, so a throw here escaped as a raw error with no ledger record, or as `APPROVAL_FLOW_FAILED` after the
48
+ * human had already said yes — measured in review.
49
+ */
50
+ refusal?: string;
51
+ /** Remove anything this staging created. A fork writes a copy of the parent's session and must not outlive it. */
52
+ dispose?: () => void;
53
+ /** For the ledger: what actually crossed, never what was asked for. Absent when nothing was staged. */
54
+ record?: {
55
+ mode: string;
56
+ sections: number;
57
+ bytes: number;
58
+ truncatedBytes: number;
59
+ keptTurns?: number;
60
+ droppedTurns?: number;
61
+ rule?: string;
62
+ };
63
+ }
64
+
65
+ /** 64 KiB per file before the fence's own budget sees it, so one large file cannot starve the others. */
66
+ const MAX_FILE_BYTES = 64 * 1024;
67
+
68
+ export function createHandoffStager(input: StagingInput) {
69
+ return (granted: ContextRequest, options: { keepTurnIds?: readonly string[] } = {}): StagedHandoff => {
70
+ if (granted.mode === "fork") return stageFork(input);
71
+ const sections: ContextSection[] = [];
72
+ let keptTurns: number | undefined;
73
+ let droppedTurns: number | undefined;
74
+ let rule: string | undefined;
75
+
76
+ if (granted.mode === "summary")
77
+ sections.push({ label: "what your parent says you need to know", body: granted.summary ?? "" });
78
+ if (granted.mode === "files" || granted.mode === "pruned")
79
+ for (const path of granted.files ?? []) sections.push(readSection(input.cwd, path));
80
+ if (granted.mode === "pruned") {
81
+ const all = parentTurns(input.parentSession);
82
+ const selection = selectPrunedTurns(all, {
83
+ ...(granted.turns !== undefined ? { turns: granted.turns } : {}),
84
+ ...(granted.files !== undefined ? { files: granted.files } : {}),
85
+ });
86
+ // A selector may only NARROW what the rule offered. Intersecting here rather than trusting the ids is the
87
+ // whole safety property: whatever a selector returns — a turn the rule dropped, a turn from another session,
88
+ // an id it invented — it cannot put a turn in front of a child that the deterministic rule did not surface.
89
+ const chosen =
90
+ options.keepTurnIds === undefined
91
+ ? selection.kept
92
+ : selection.kept.filter((turn) => options.keepTurnIds!.includes(turn.id));
93
+ keptTurns = chosen.length;
94
+ droppedTurns = all.length - chosen.length;
95
+ rule = options.keepTurnIds === undefined ? selection.rule : `${selection.rule}+advice`;
96
+ for (const turn of chosen) sections.push({ label: `parent turn ${turn.id}`, body: turn.text });
97
+ if (chosen.length === 0)
98
+ sections.push({ label: "parent turns", body: "(no turn of your parent's session matched the selection)" });
99
+ }
100
+
101
+ const fenced = fenceContext(sections);
102
+ return {
103
+ contextPrompt: fenced.text,
104
+ record: {
105
+ mode: granted.mode,
106
+ sections: sections.length,
107
+ bytes: Buffer.byteLength(fenced.text),
108
+ truncatedBytes: fenced.truncatedBytes,
109
+ ...(keptTurns !== undefined ? { keptTurns, droppedTurns, rule } : {}),
110
+ },
111
+ };
112
+ };
113
+ }
114
+
115
+ /**
116
+ * A fork replaces the session file the inactivity deadline would otherwise watch, because pi refuses `--fork`
117
+ * beside `--session`. The directory is ours and holds exactly one session, so the activity probe watches the
118
+ * directory instead of a fixed path — pi names the file `<timestamp>_<id>.jsonl` and only the id half is ours.
119
+ */
120
+ function stageFork(input: StagingInput): StagedHandoff {
121
+ const sessionPath = input.parentSession?.getSessionFile();
122
+ if (!sessionPath)
123
+ // Not a silent downgrade to `none`: the parent asked for its whole session to cross and it has none to give.
124
+ return { refusal: "context: fork needs the parent's session file, and this session is not persisted" };
125
+ const sessionId = randomUUID();
126
+ const sessionDir = resolve(input.forkRoot, `fork-${sessionId}`);
127
+ try {
128
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
129
+ } catch (error) {
130
+ return { refusal: `context: fork could not allocate ${sessionDir} (${String(error)})` };
131
+ }
132
+ // The ledger records the SIZE of what crossed, because a fork is the largest handoff there is and recording it
133
+ // as zero bytes would make the audit trail understate exactly the mode that deserves the most scrutiny.
134
+ let bytes = 0;
135
+ let entries = 0;
136
+ try {
137
+ bytes = statSync(sessionPath).size;
138
+ entries = input.parentSession?.getEntries().length ?? 0;
139
+ } catch {
140
+ /* an unreadable parent session is still a fork pi will attempt; the record says 0 rather than guessing */
141
+ }
142
+ return {
143
+ forkFrom: { sessionPath, sessionDir, sessionId },
144
+ dispose: () => rmSync(sessionDir, { recursive: true, force: true }),
145
+ record: { mode: "fork", sections: 1, bytes, truncatedBytes: 0, keptTurns: entries, droppedTurns: 0 },
146
+ };
147
+ }
148
+
149
+ /** Read one named file, confined to the working directory, saying so in the fence when it cannot be read. */
150
+ function readSection(cwd: string, path: string): ContextSection {
151
+ const refuse = (why: string) => ({ label: path, body: `(refused: ${why})` });
152
+ if (isAbsolute(path)) return refuse("an absolute path");
153
+ let absolute: string;
154
+ let root: string;
155
+ try {
156
+ // `realpath`, not `resolve`: `resolve`/`relative` are LEXICAL, so a symlink inside the working directory
157
+ // pointing anywhere at all passed the check — measured in review with `cwd/link.txt -> /tmp/outside.txt`,
158
+ // whose contents duly appeared inside the fence. A repository full of `node_modules/.bin` symlinks makes that
159
+ // the ordinary case rather than a contrived one.
160
+ root = realpathSync(resolve(cwd));
161
+ absolute = realpathSync(resolve(cwd, path));
162
+ } catch (error) {
163
+ return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
164
+ }
165
+ const within = relative(root, absolute);
166
+ if (within.startsWith("..") || within === "" || isAbsolute(within))
167
+ return refuse("outside this session's working directory");
168
+ try {
169
+ const stats = statSync(absolute);
170
+ // A FIFO satisfies `statSync` and then never returns from a read, which would block the whole pi session with
171
+ // no watchdog — and the path is model-supplied, so it is reachable rather than theoretical.
172
+ if (!stats.isFile()) return refuse("not a regular file");
173
+ const note = stats.size > MAX_FILE_BYTES ? ` (first ${MAX_FILE_BYTES} of ${stats.size} bytes)` : "";
174
+ return { label: `${path}${note}`, body: readBounded(absolute, MAX_FILE_BYTES) };
175
+ } catch (error) {
176
+ return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Read at most `budget` BYTES, without pulling the rest of the file into the parent first.
182
+ *
183
+ * `readFileSync(...).slice(budget)` reads the whole file and then slices by UTF-16 code units, so a large file in
184
+ * the repository stalled the parent and the "(first N of M bytes)" label was wrong for any multi-byte content.
185
+ */
186
+ function readBounded(path: string, budget: number): string {
187
+ const handle = openSync(path, "r");
188
+ try {
189
+ const buffer = Buffer.alloc(budget);
190
+ const read = readSync(handle, buffer, 0, budget, 0);
191
+ return new TextDecoder("utf-8", { fatal: false }).decode(buffer.subarray(0, read)).replace(/\uFFFD+$/, "");
192
+ } finally {
193
+ closeSync(handle);
194
+ }
195
+ }
196
+
197
+ /** The parent's message turns, reduced to what the selection rule needs. Never pi's own types past this point. */
198
+ function parentTurns(session?: ParentSession): PrunableTurn[] {
199
+ if (!session) return [];
200
+ try {
201
+ return session
202
+ .getEntries()
203
+ .filter((entry) => entry.type === "message")
204
+ .map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
205
+ } catch {
206
+ return [];
207
+ }
208
+ }
@@ -41,6 +41,7 @@ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/kerne
41
41
  import { chainApprovalFacts, newChainApprovalAudit, rememberChainApproval } from "./chain-approval-facts.ts";
42
42
  import { newExecutionId } from "../src/kernel/execution-id.ts";
43
43
  import { planChain, type GateRequest } from "./chain-plan.ts";
44
+ import { contextShape } from "./context-shape.ts";
44
45
  import { preflightModel } from "../src/kernel/model-preflight.ts";
45
46
  import { assertDelegationAuthority } from "./delegation-authority.ts";
46
47
 
@@ -83,6 +84,7 @@ export function registerChainTool(pi: ExtensionAPI, session: GrantsSession): voi
83
84
  { description: "Requested Pi thinking level; unsupported model/level combinations fail in the child." },
84
85
  ),
85
86
  ),
87
+ context: Type.Optional(contextShape()),
86
88
  correlation: Type.Optional(correlationShape()),
87
89
  workspace: Type.Optional(
88
90
  Type.Object({
@@ -39,6 +39,8 @@ export async function recordDelegationDecision(input: {
39
39
  taskFrom: input.taskFrom,
40
40
  taskFromExecutionId: input.taskFromExecutionId,
41
41
  requested: plan.requested,
42
+ // ADR-0078: what crossed, recorded as a fact about this child rather than as the parent's request.
43
+ ...(plan.handoffRecord ? { handoff: plan.handoffRecord } : {}),
42
44
  parentGrant: session.ownGrant,
43
45
  result: plan.result,
44
46
  blocked: !plan.ok,
@@ -36,6 +36,7 @@ import { type GrantsSession } from "./session.ts";
36
36
  import { newDelegationOccurrence } from "./execution-occurrence.ts";
37
37
  import { correlationShape as buildCorrelationShape } from "./correlation-shape.ts";
38
38
  import { assertDelegationAuthority } from "./delegation-authority.ts";
39
+ import { contextShape } from "./context-shape.ts";
39
40
 
40
41
  /**
41
42
  * Wire a set of children to pi's partial-result channel — ADR-0032.
@@ -189,6 +190,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
189
190
  tools: Type.Optional(Type.Array(Type.String(), { description: "Capabilities, when no 'agent' fits." })),
190
191
  model: Type.Optional(Type.String({ description: "Model as provider/id. Defaults to this session's." })),
191
192
  thinking: thinkingShape,
193
+ context: Type.Optional(contextShape()),
192
194
  correlation: Type.Optional(correlationShape),
193
195
  workspace: Type.Optional(workspaceShape),
194
196
  });
@@ -221,6 +223,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
221
223
  }),
222
224
  ),
223
225
  thinking: thinkingShape,
226
+ context: Type.Optional(contextShape()),
224
227
  correlation: Type.Optional(correlationShape),
225
228
  workspace: Type.Optional(workspaceShape),
226
229
  });
@@ -247,6 +250,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
247
250
  tools: params.tools,
248
251
  model: params.model,
249
252
  thinking: params.thinking,
253
+ context: params.context,
250
254
  correlation: params.correlation,
251
255
  workspace: params.workspace,
252
256
  },
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The first decision point that consults an advisor (ADR-0077, roadmap PR 8): how hard a child should think.
3
+ *
4
+ * **Why this one.** It is the shape the boundary was designed for. The options are not invented by the advisor —
5
+ * they are the thinking levels this session's own model reports it supports (`supportedModelEfforts`), so the
6
+ * advisor picks among things the caller already had, which is the entire permitted verb list. It touches no
7
+ * capability, no gate and no grant: the worst an advisor can do here is make a child think harder or less hard
8
+ * than a human would have chosen, and the ledger says it did.
9
+ *
10
+ * **Only when the caller said nothing.** An explicit `thinking` on the call is the operator's or the model's own
11
+ * choice and is never second-guessed; advice fills a blank, it does not overrule. With no advisor, no key, no
12
+ * answer, a timeout or an unrecognised response, the blank stays blank and the child is spawned exactly as it is
13
+ * today — which is the property that keeps advisors optional rather than load-bearing.
14
+ */
15
+ import { supportedModelEfforts } from "../src/kernel/model-preflight.ts";
16
+ import type { Advisor } from "../src/advisors/advisor.ts";
17
+
18
+ /** What pi's resolved catalogue entry carries that decides which efforts exist. */
19
+ interface ResolvedModel {
20
+ reasoning: boolean;
21
+ thinkingLevelMap?: Partial<Record<string, string | null>>;
22
+ }
23
+
24
+ export const EFFORT_PURPOSE = "child-effort";
25
+
26
+ export async function adviseEffort(input: {
27
+ /** The session, read here rather than passed as an advisor: an argument can be severed and nothing notices. */
28
+ session: { advisorSession: { advisor: Advisor } };
29
+ /** Absent means the caller chose one; nothing is asked and nothing is recorded. */
30
+ requested?: string;
31
+ /**
32
+ * The model the CHILD will run on, `provider/id`, not the session's.
33
+ *
34
+ * Review measured the first version reading the parent session's model while the child was spawned on
35
+ * `spec.model`: the levels offered then came from a model the child would never use, and pi clamps rather than
36
+ * refuses, so the effect was a silently shifted effort rather than a loud failure.
37
+ */
38
+ model?: string;
39
+ registry: { find(provider: string, modelId: string): unknown };
40
+ task: string;
41
+ agent?: string;
42
+ signal?: AbortSignal;
43
+ }): Promise<string | undefined> {
44
+ const advisor = input.session.advisorSession.advisor;
45
+ const slash = input.model === undefined ? -1 : input.model.indexOf("/");
46
+ if (input.requested !== undefined || slash <= 0) return input.requested;
47
+ const resolved = input.registry.find(input.model!.slice(0, slash), input.model!.slice(slash + 1)) as
48
+ ResolvedModel | undefined;
49
+ if (!resolved) return undefined;
50
+ const levels = supportedModelEfforts(resolved);
51
+ // One option is not a choice, and a model with no reasoning has exactly one. Asking would spend a call and a
52
+ // ledger line to be told the only thing that could be said.
53
+ if (levels.length < 2) return undefined;
54
+
55
+ const advice = await advisor.ask(
56
+ EFFORT_PURPOSE,
57
+ {
58
+ // The task text is what the decision is actually about, and it is the one thing this package has never
59
+ // stored (ADR-0021). It is sent to the advisor because an advisor cannot judge a task it cannot see, and it
60
+ // is NOT recorded: `createAdvisor` writes the question keys and the answer, never the state. An operator who
61
+ // is not willing to send task text to a third party leaves the advisor off, which is the default.
62
+ state: { task: input.task, ...(input.agent ? { definition: input.agent } : {}) },
63
+ questions: {
64
+ effort: {
65
+ kind: "choice",
66
+ instructions:
67
+ "How much reasoning effort does this task need? Choose the cheapest level that would still do it well.",
68
+ options: Object.fromEntries(levels.map((level) => [level, effortDescription(level)])),
69
+ },
70
+ },
71
+ },
72
+ input.signal,
73
+ );
74
+ const chosen = advice?.answers.effort;
75
+ // Belt and braces: `parseAnswer` already refuses a choice outside the options it was given, so this can only
76
+ // fire if a future decider is written that does not. An effort the model does not support would be refused by
77
+ // pi in the child, after the spawn, which is a worse place to find out.
78
+ if (!chosen || chosen.kind !== "choice" || !(levels as readonly string[]).includes(chosen.value)) return undefined;
79
+ return chosen.value;
80
+ }
81
+
82
+ function effortDescription(level: string): string {
83
+ switch (level) {
84
+ case "off":
85
+ return "No reasoning. Mechanical work: a rename, a formatting pass, reading one file back.";
86
+ case "minimal":
87
+ return "Almost none. A single obvious step with no choice in it.";
88
+ case "low":
89
+ return "A little. One decision, or a change confined to one file.";
90
+ case "medium":
91
+ return "Ordinary. Several steps, or a change that has to fit existing code.";
92
+ case "high":
93
+ return "Substantial. Design choices, or work across several files that must stay consistent.";
94
+ case "xhigh":
95
+ return "Very high. A subtle problem where the obvious approach is likely to be wrong.";
96
+ default:
97
+ return "The most this model can do. Reserve it for work that has defeated a lesser effort.";
98
+ }
99
+ }
@@ -148,6 +148,14 @@ export async function executePlannedChild(input: {
148
148
  return await executeWithActivitySession();
149
149
  } finally {
150
150
  if (!keepPaneRequested) await activitySession.dispose();
151
+ // ADR-0078: a fork wrote a COPY of the parent's whole session to disk. PR 3e deletes its own temp session on
152
+ // every path and this must too, or every forked child leaves a full transcript behind for good.
153
+ if (!keepPaneRequested)
154
+ try {
155
+ plan.disposeHandoff?.();
156
+ } catch {
157
+ /* teardown must not replace the child's outcome */
158
+ }
151
159
  }
152
160
 
153
161
  async function executeWithActivitySession(): Promise<DelegationOutcome> {
@@ -32,6 +32,8 @@ export interface GrantsCommandContext {
32
32
  * sentence from the one the session banner printed. Two spellings of one fact is R-28.
33
33
  */
34
34
  executor: ExecutorChoice;
35
+ /** ADR-0077: which advisor is in force, or why none is. Reported because an advisor sends task text out. */
36
+ advisor: { decider: string; refusal?: string };
35
37
  observed: boolean;
36
38
  depth: number;
37
39
  maxDepth: number;
@@ -89,6 +91,7 @@ export const grantsCommand = {
89
91
  governed,
90
92
  ownGrant,
91
93
  executor,
94
+ advisor,
92
95
  observed,
93
96
  depth,
94
97
  maxDepth,
@@ -354,6 +357,13 @@ export const grantsCommand = {
354
357
  // two facts about what a spawn will be sit together.
355
358
  ` executor ${executor.disclosure}`,
356
359
  ` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
360
+ // Rule 8's loud half, which review found missing: an operator who upgraded from 0.34.0, or who mistyped the
361
+ // variable, saw an advisor silently absent and nothing saying why. This is also where an operator sees that
362
+ // task text leaves the machine, which no other surface says.
363
+ advisor.decider === "none"
364
+ ? ` advisor off${advisor.refusal ? ` — ${advisor.refusal}` : ""}`
365
+ : ` advisor ${advisor.decider} — a delegation with no thinking level sends it the task text; ` +
366
+ `a pruned context handoff also sends session turns`,
357
367
  ` ledger ${ledgerPath || "(not recording — set PI_DADDY_LEDGER)"}`,
358
368
  ` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
359
369
  `${inheritedApprovals.size > 0 ? `, ${inheritedApprovals.size} inherited` : ""}` +
@@ -79,6 +79,11 @@ export default function (pi: ExtensionAPI) {
79
79
  const reload = bindReloadLifecycle(owner, session.reloadLifecycle);
80
80
  session.reconcileEnvironment(reload.environment, reload.lifecycle);
81
81
  session.ownerBound = true;
82
+ // ADR-0078: the parent's own session, for a granted `pruned` or `fork` handoff. Read-only, and only ever
83
+ // read after the mode has survived the gate.
84
+ const manager = owner as Partial<import("./context-staging.ts").ParentSession>;
85
+ if (typeof manager.getSessionFile === "function" && typeof manager.getEntries === "function")
86
+ session.parentSession = manager as import("./context-staging.ts").ParentSession;
82
87
  delegation.refreshSpawnable = registerDelegationTools(pi, session).refreshSpawnable;
83
88
  reconcileActiveDelegationTools(pi, session);
84
89
  session.cwd = ctx.cwd;
@@ -369,6 +374,10 @@ export default function (pi: ExtensionAPI) {
369
374
  depth: session.depth,
370
375
  maxDepth: session.maxDepth,
371
376
  ledgerPath: session.ledgerPath,
377
+ advisor: {
378
+ decider: session.advisorSession.deciderName,
379
+ ...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
380
+ },
372
381
  catalog: session.catalog,
373
382
  definitions: session.definitions,
374
383
  sessionApprovals: session.sessionApprovals,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The second decision point: which of the parent's turns a `pruned` handoff actually carries (ADR-0077).
3
+ *
4
+ * The deterministic rule keeps the last few turns plus older ones naming the given files. That rule is honest and
5
+ * its recall is unmeasured — it is a proxy for "what matters here", not an answer. An advisor can judge the turns
6
+ * against the task the child is about to be given, which the rule cannot see.
7
+ *
8
+ * **It can only take turns away.** The candidates are exactly what `selectPrunedTurns` produced; the advisor is
9
+ * asked one boolean per candidate and the ids it kept are handed back. Staging then intersects those ids with the
10
+ * candidates again, so even a selector that invented an id, returned one from another session, or returned every
11
+ * id in existence cannot put a turn in front of a child that the rule did not already offer. Narrowing is the only
12
+ * verb available, which is what makes this advice rather than authority.
13
+ *
14
+ * With no advisor, no answer, a timeout or an unrecognised response, nothing is returned and the rule's own
15
+ * selection stands — the same "identical to today" property the effort decision point has.
16
+ */
17
+ import { selectPrunedTurns, type ContextRequest } from "../src/kernel/context-handoff.ts";
18
+ import type { Advisor } from "../src/advisors/advisor.ts";
19
+ import type { Question } from "../src/advisors/decider.ts";
20
+
21
+ export const PRUNING_PURPOSE = "handoff-pruning";
22
+
23
+ /**
24
+ * How many candidates may be judged. One question per turn, and a decision a human is waiting on should not carry
25
+ * an unbounded number of them; beyond this the rule's own selection stands, which is the safe direction.
26
+ */
27
+ export const MAX_JUDGED_TURNS = 12;
28
+
29
+ /** Just enough of the parent's session for the rule; the same shape `context-staging` reduces entries to. */
30
+ export interface ParentTurnSource {
31
+ getEntries(): Array<{ id: string; type: string }>;
32
+ }
33
+
34
+ export async function advisePruning(input: {
35
+ session: { advisorSession: { advisor: Advisor }; parentSession?: ParentTurnSource };
36
+ granted: ContextRequest;
37
+ task: string;
38
+ signal?: AbortSignal;
39
+ }): Promise<string[] | undefined> {
40
+ if (input.granted.mode !== "pruned" || !input.session.parentSession) return undefined;
41
+ const all = turnsOf(input.session.parentSession);
42
+ const candidates = selectPrunedTurns(all, {
43
+ ...(input.granted.turns !== undefined ? { turns: input.granted.turns } : {}),
44
+ ...(input.granted.files !== undefined ? { files: input.granted.files } : {}),
45
+ }).kept;
46
+ // Nothing to narrow, or more than a bounded number to judge: the rule stands and no call is made.
47
+ if (candidates.length < 2 || candidates.length > MAX_JUDGED_TURNS) return undefined;
48
+
49
+ const questions: Record<string, Question> = {};
50
+ for (const [index, turn] of candidates.entries())
51
+ questions[`turn${index}`] = {
52
+ kind: "noul",
53
+ // Both bounded: the task is embedded once per candidate, so an unbounded task became a request twelve times
54
+ // its size, on a two-second budget and the operator's key.
55
+ instructions: `Would a sub-agent doing this task be helped by seeing this part of the parent's session?\n\nTASK: ${input.task.slice(0, 2000)}\n\nPART:\n${turn.text.slice(0, 2000)}`,
56
+ whenTrue: "It bears on the task: a decision, a constraint, a fact the task depends on.",
57
+ whenFalse: "It does not: unrelated work, chatter, or something the task already states.",
58
+ };
59
+
60
+ const advice = await input.session.advisorSession.advisor.ask(
61
+ PRUNING_PURPOSE,
62
+ // The state is empty: everything the advisor needs is already in the questions, and a question carries the
63
+ // task and one turn rather than the whole session. Nothing here is recorded — `createAdvisor` writes keys.
64
+ { state: {}, questions },
65
+ input.signal,
66
+ );
67
+ if (!advice) return undefined;
68
+ // Every candidate or none. A response missing eleven of twelve answers would otherwise read as "drop eleven",
69
+ // which is a narrowing nobody asked for rather than the "unrecognised response means no advice" contract.
70
+ if (candidates.some((_, index) => advice.answers[`turn${index}`]?.kind !== "noul")) return undefined;
71
+ const kept = candidates
72
+ .filter((_, index) => (advice.answers[`turn${index}`] as { value: boolean }).value)
73
+ .map((turn) => turn.id);
74
+ // An advisor that drops everything is answering a different question from the one that was asked; the rule's
75
+ // selection stands rather than handing a child a handoff with nothing in it.
76
+ return kept.length === 0 ? undefined : kept;
77
+ }
78
+
79
+ function turnsOf(session: ParentTurnSource): Array<{ id: string; text: string }> {
80
+ try {
81
+ return session
82
+ .getEntries()
83
+ .filter((entry) => entry.type === "message")
84
+ .map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
@@ -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 {
@@ -45,6 +47,8 @@ interface ChildSpec {
45
47
  tools?: string[];
46
48
  model?: string;
47
49
  thinking?: string;
50
+ /** ADR-0078: what of the parent's session crosses. Validated in the kernel, never here. */
51
+ context?: unknown;
48
52
  correlation?: CorrelationMetadata;
49
53
  workspace?: DelegationWorkspaceSpec;
50
54
  }
@@ -247,7 +251,10 @@ export async function runOneDelegation(
247
251
  agent: spec.agent,
248
252
  tools: spec.tools,
249
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.
250
256
  thinking: spec.thinking,
257
+ context: spec.context,
251
258
  correlation: spec.workspace
252
259
  ? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
253
260
  : spec.correlation,
@@ -286,6 +293,28 @@ export async function runOneDelegation(
286
293
  Boolean(executorRefusal || modelRefusal),
287
294
  );
288
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
+ });
289
318
  let preparedWorkspace: PreparedWorkspace | undefined;
290
319
  let approvalOutcome: ApprovalOutcome | undefined;
291
320
  let plan: ReturnType<typeof planDelegation>;
@@ -294,7 +323,7 @@ export async function runOneDelegation(
294
323
  // Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
295
324
  // preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
296
325
  // no child process.
297
- const preview = await planWithApprovals(session, request, extra, null, signal, preApproved);
326
+ const preview = await planWithApprovals(session, request, planContext, null, signal, preApproved);
298
327
  plan = preview.plan;
299
328
  if (plan.ok || shouldSeekApproval(plan.result)) {
300
329
  try {
@@ -308,7 +337,7 @@ export async function runOneDelegation(
308
337
  ledgerPath: session.ledgerPath,
309
338
  });
310
339
  request.correlation = preparedWorkspace.correlation;
311
- const gated = await planWithApprovals(session, request, extra, ctx, signal, preApproved);
340
+ const gated = await planWithApprovals(session, request, planContext, ctx, signal, preApproved);
312
341
  plan = gated.plan;
313
342
  approvalOutcome = gated.approval;
314
343
  } catch (error) {
@@ -323,7 +352,7 @@ export async function runOneDelegation(
323
352
  const gated = await planWithApprovals(
324
353
  session,
325
354
  request,
326
- extra,
355
+ planContext,
327
356
  executorRefusal || modelRefusal ? null : ctx,
328
357
  signal,
329
358
  preApproved,
@@ -406,3 +435,35 @@ export async function runOneDelegation(
406
435
  onProgress,
407
436
  });
408
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
+ }