pi-daddy 0.34.0 → 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.
- package/CHANGELOG.md +35 -0
- package/dist/advisors/decider.d.ts +7 -3
- package/dist/advisors/decider.d.ts.map +1 -1
- package/dist/advisors/decider.js.map +1 -1
- package/dist/advisors/settings.d.ts +16 -7
- package/dist/advisors/settings.d.ts.map +1 -1
- package/dist/advisors/settings.js +60 -22
- package/dist/advisors/settings.js.map +1 -1
- package/dist/kernel/delegate-types.d.ts +11 -1
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +3 -1
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +14 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +16 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +8 -3
- package/dist/kernel/propagation.js.map +1 -1
- package/extensions/context-staging.ts +15 -7
- package/extensions/effort-advice.ts +99 -0
- package/extensions/grants-command.ts +10 -0
- package/extensions/grants.ts +4 -0
- package/extensions/pruning-advice.ts +88 -0
- package/extensions/run-delegation.ts +61 -3
- package/extensions/session.ts +47 -4
- package/package.json +1 -1
- package/src/advisors/decider.ts +7 -3
- package/src/advisors/settings.ts +61 -21
- package/src/kernel/delegate-types.ts +12 -1
- package/src/kernel/delegate.ts +3 -1
- package/src/kernel/env-names.ts +16 -0
- package/src/kernel/propagation.ts +9 -2
|
@@ -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
|
+
}
|
|
@@ -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` : ""}` +
|
package/extensions/grants.ts
CHANGED
|
@@ -374,6 +374,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
374
374
|
depth: session.depth,
|
|
375
375
|
maxDepth: session.maxDepth,
|
|
376
376
|
ledgerPath: session.ledgerPath,
|
|
377
|
+
advisor: {
|
|
378
|
+
decider: session.advisorSession.deciderName,
|
|
379
|
+
...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
|
|
380
|
+
},
|
|
377
381
|
catalog: session.catalog,
|
|
378
382
|
definitions: session.definitions,
|
|
379
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 {
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/session.ts
CHANGED
|
@@ -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 {
|
|
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. */
|
|
@@ -296,7 +300,18 @@ export function createGrantsSession(
|
|
|
296
300
|
const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
|
|
297
301
|
const { depth, maxDepth } = bounds;
|
|
298
302
|
const emptyCatalog = makeCatalog([]);
|
|
303
|
+
// ADR-0077. The environment decides whether there is an advisor at all; the project's settings block may only
|
|
304
|
+
// narrow it. The block IS read — the first version passed `undefined` and every narrowing the release advertised
|
|
305
|
+
// was dead code reachable only from tests, which review measured: `enabled: false` turned nothing off.
|
|
306
|
+
//
|
|
307
|
+
// Read from `storeCwd` for `loadStoredGrantStateSync`'s reason: this factory runs before any hook, so `ctx.cwd`
|
|
308
|
+
// does not exist yet. Reading a workspace-writable file here is safe precisely because it can only narrow.
|
|
309
|
+
const advisorSession = createAdvisorSession({
|
|
310
|
+
block: projectAdvisorBlock(storeCwd),
|
|
311
|
+
...(storedLedger ? { ledgerPath: storedLedger } : {}),
|
|
312
|
+
});
|
|
299
313
|
const session: GrantsSession = {
|
|
314
|
+
advisorSession,
|
|
300
315
|
adoptedLegacyEnv,
|
|
301
316
|
governed,
|
|
302
317
|
inherited,
|
|
@@ -361,12 +376,16 @@ export function createGrantsSession(
|
|
|
361
376
|
observerExtensionPath: session.observerExtensionPath,
|
|
362
377
|
childEnv: activityChildEnv(session.activity),
|
|
363
378
|
// ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
|
|
364
|
-
|
|
379
|
+
// `options` is forwarded, and its absence is why the second decision point was dead in production: a
|
|
380
|
+
// one-parameter arrow is assignable to a two-parameter type, so the ids reached here and were discarded while
|
|
381
|
+
// the advisor had already been asked. Review measured it. `test/pruning-advice.test.ts` now goes through this
|
|
382
|
+
// function rather than calling the stager directly.
|
|
383
|
+
stageHandoff: (granted, options) =>
|
|
365
384
|
createHandoffStager({
|
|
366
385
|
cwd: session.cwd,
|
|
367
386
|
forkRoot: join(agentDir(), "context-forks"),
|
|
368
387
|
...(session.parentSession ? { parentSession: session.parentSession } : {}),
|
|
369
|
-
})(granted),
|
|
388
|
+
})(granted, options),
|
|
370
389
|
catalog: await session.catalogReady,
|
|
371
390
|
// R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
|
|
372
391
|
// Derived from the catalog's own `source`, so it cannot drift from what was discovered.
|
|
@@ -420,3 +439,27 @@ export function createGrantsSession(
|
|
|
420
439
|
};
|
|
421
440
|
return session;
|
|
422
441
|
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The `advisor` block of `.pi/pi-daddy/settings.json`, or undefined.
|
|
445
|
+
*
|
|
446
|
+
* Unreadable, absent or malformed all yield undefined: this file is the reviewable record, not authority, and the
|
|
447
|
+
* only thing it can do to an advisor is turn one off. A parse failure therefore costs nothing worth reporting.
|
|
448
|
+
*/
|
|
449
|
+
function projectAdvisorBlock(cwd: string): unknown {
|
|
450
|
+
// Only when an advisor could exist at all. This runs in every session including every child, before any hook, and
|
|
451
|
+
// a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it.
|
|
452
|
+
if (!process.env[ENV_ADVISOR]?.trim()) return undefined;
|
|
453
|
+
try {
|
|
454
|
+
const path = projectSettingsPath(cwd);
|
|
455
|
+
// Bounded and type-checked first: this is the third unbounded session-start read AGENTS.md warns about, and the
|
|
456
|
+
// only one whose path a governed child holding `tool:write` can replace with a FIFO — which would hang pi
|
|
457
|
+
// before any hook exists to report it.
|
|
458
|
+
const stats = statSync(path);
|
|
459
|
+
if (!stats.isFile() || stats.size > 1024 * 1024) return undefined;
|
|
460
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
461
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>).advisor : undefined;
|
|
462
|
+
} catch {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|
package/src/advisors/decider.ts
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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>>;
|
package/src/advisors/settings.ts
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Whether an advisor is on, and which one (ADR-0077).
|
|
3
3
|
*
|
|
4
|
-
* **Default off, and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
48
|
-
|
|
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 {
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
(!Number.isInteger(
|
|
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
|
-
...(
|
|
71
|
-
|
|
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
|
}
|
|
@@ -90,7 +90,18 @@ export interface DelegationContext {
|
|
|
90
90
|
* reads nothing. What it returns reaches the child as an appended system prompt or as fork arguments; it can
|
|
91
91
|
* carry no capability, so nothing here can widen a grant.
|
|
92
92
|
*/
|
|
93
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Turn ids a composition-layer selector chose for a `pruned` handoff (ADR-0077's second decision point).
|
|
95
|
+
*
|
|
96
|
+
* Composition, because choosing may mean asking an advisor and the kernel does no I/O and knows no advisor. The
|
|
97
|
+
* ids can only NARROW: staging keeps the intersection with what the deterministic rule already surfaced, so a
|
|
98
|
+
* selector cannot introduce a turn the rule did not offer, whatever it returns.
|
|
99
|
+
*/
|
|
100
|
+
handoffTurnIds?: readonly string[];
|
|
101
|
+
stageHandoff?: (
|
|
102
|
+
granted: ContextRequest,
|
|
103
|
+
options?: { keepTurnIds?: readonly string[] },
|
|
104
|
+
) => {
|
|
94
105
|
contextPrompt?: string;
|
|
95
106
|
forkFrom?: { sessionPath: string; sessionDir: string; sessionId: string };
|
|
96
107
|
record?: Delegation["handoffRecord"];
|
package/src/kernel/delegate.ts
CHANGED
|
@@ -326,7 +326,9 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
326
326
|
handoff.mode !== "none" && result.effective.includes(contextCapability(handoff.mode)) ? handoff : undefined;
|
|
327
327
|
const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY);
|
|
328
328
|
// Only for a handoff that survived, so a refused mode reads no file and forks no session.
|
|
329
|
-
const staged = grantedHandoff
|
|
329
|
+
const staged = grantedHandoff
|
|
330
|
+
? ctx.stageHandoff?.(grantedHandoff, ctx.handoffTurnIds ? { keepTurnIds: ctx.handoffTurnIds } : {})
|
|
331
|
+
: undefined;
|
|
330
332
|
if (staged?.refusal)
|
|
331
333
|
return denied({ ...empty, requested, result, reason: staged.refusal }, "CONTEXT_REQUEST_INVALID");
|
|
332
334
|
const plan = planSpawn({
|
package/src/kernel/env-names.ts
CHANGED
|
@@ -48,6 +48,20 @@ export const ENV_GOVERNANCE = "PI_DADDY_GOVERNANCE";
|
|
|
48
48
|
* inherited a paid credential its grant never named. Measured in review.
|
|
49
49
|
*/
|
|
50
50
|
export const ENV_ADVISOR_KEY = "PI_DADDY_ADVISOR_KEY";
|
|
51
|
+
/**
|
|
52
|
+
* Which advisor is in force, or absent for none (ADR-0077).
|
|
53
|
+
*
|
|
54
|
+
* **The enable lives here rather than in the project settings file, and that is a correction.** 0.34.0 read it from
|
|
55
|
+
* `.pi/pi-daddy/settings.json`, which `grant-store.ts` is explicit about: that file is writable by any child
|
|
56
|
+
* holding `tool:write`, so it is "the reviewable record of the decision, not the thing the enforcer reads". A
|
|
57
|
+
* grant is kept outside the workspace for exactly that reason, and an advisor switch needs the same treatment for a
|
|
58
|
+
* neighbouring one — a child that could flip it on would make the operator's NEXT session ship its own description
|
|
59
|
+
* to a third party. The settings file may still narrow (a model, a timeout, or `enabled: false`); it can no longer
|
|
60
|
+
* turn one on.
|
|
61
|
+
*/
|
|
62
|
+
export const ENV_ADVISOR = "PI_DADDY_ADVISOR";
|
|
63
|
+
/** Overrides the adapter's pinned model. In the environment, never the workspace file: a model is a destination. */
|
|
64
|
+
export const ENV_ADVISOR_MODEL = "PI_DADDY_ADVISOR_MODEL";
|
|
51
65
|
|
|
52
66
|
/** Every variable that shapes governance. The `childEnv` hook may set none of these. */
|
|
53
67
|
export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
|
|
@@ -74,6 +88,8 @@ export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
|
|
|
74
88
|
ENV_RETAIN_NATIVE_SESSIONS,
|
|
75
89
|
ENV_GOVERNANCE,
|
|
76
90
|
ENV_ADVISOR_KEY,
|
|
91
|
+
ENV_ADVISOR,
|
|
92
|
+
ENV_ADVISOR_MODEL,
|
|
77
93
|
]);
|
|
78
94
|
|
|
79
95
|
/**
|