pi-daddy 0.32.0 → 0.34.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 +57 -0
- package/contracts/ledger-record/v1/governance-event.schema.json +46 -1
- package/dist/advisors/advisor.d.ts +49 -0
- package/dist/advisors/advisor.d.ts.map +1 -0
- package/dist/advisors/advisor.js +76 -0
- package/dist/advisors/advisor.js.map +1 -0
- package/dist/advisors/decider.d.ts +75 -0
- package/dist/advisors/decider.d.ts.map +1 -0
- package/dist/advisors/decider.js +29 -0
- package/dist/advisors/decider.js.map +1 -0
- package/dist/advisors/jev.d.ts +41 -0
- package/dist/advisors/jev.d.ts.map +1 -0
- package/dist/advisors/jev.js +108 -0
- package/dist/advisors/jev.js.map +1 -0
- package/dist/advisors/settings.d.ts +38 -0
- package/dist/advisors/settings.d.ts.map +1 -0
- package/dist/advisors/settings.js +60 -0
- package/dist/advisors/settings.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +12 -3
- package/dist/cli.js.map +1 -1
- package/dist/executors/activity-session.d.ts.map +1 -1
- package/dist/executors/activity-session.js +27 -0
- package/dist/executors/activity-session.js.map +1 -1
- package/dist/executors/herdr-cli.d.ts +1 -1
- package/dist/executors/herdr-cli.js +1 -1
- package/dist/executors/herdr-poll.d.ts +1 -1
- package/dist/executors/herdr-poll.js +1 -1
- package/dist/executors/herdr-stage.d.ts +1 -1
- package/dist/executors/herdr-stage.d.ts.map +1 -1
- package/dist/executors/herdr-stage.js +25 -8
- package/dist/executors/herdr-stage.js.map +1 -1
- package/dist/executors/pane-reaper.d.ts +1 -1
- package/dist/executors/pane-reaper.js +2 -2
- package/dist/executors/pane-reaper.js.map +1 -1
- package/dist/executors/run-herdr.d.ts +3 -3
- package/dist/executors/run-herdr.js +2 -2
- package/dist/governance/ledger-report.d.ts +1 -1
- package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
- package/dist/governance/ledger-v3-validation.js +1 -0
- package/dist/governance/ledger-v3-validation.js.map +1 -1
- package/dist/governance/ledger.d.ts +15 -1
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +2 -1
- package/dist/governance/ledger.js.map +1 -1
- package/dist/governance/workspace-lease.js +1 -1
- package/dist/kernel/capabilities.d.ts +3 -3
- package/dist/kernel/capabilities.d.ts.map +1 -1
- package/dist/kernel/capabilities.js +7 -3
- package/dist/kernel/capabilities.js.map +1 -1
- package/dist/kernel/catalog.d.ts +27 -0
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +65 -1
- package/dist/kernel/catalog.js.map +1 -1
- package/dist/kernel/chain.d.ts +2 -0
- package/dist/kernel/chain.d.ts.map +1 -1
- package/dist/kernel/chain.js.map +1 -1
- package/dist/kernel/context-handoff.d.ts +85 -0
- package/dist/kernel/context-handoff.d.ts.map +1 -0
- package/dist/kernel/context-handoff.js +177 -0
- package/dist/kernel/context-handoff.js.map +1 -0
- package/dist/kernel/delegate-types.d.ts +60 -0
- 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 +55 -3
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +10 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +11 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/grant-env.d.ts +1 -1
- package/dist/kernel/grant-env.js +1 -1
- package/dist/kernel/propagation.d.ts +2 -2
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +10 -3
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/refusals.d.ts +1 -1
- package/dist/kernel/refusals.d.ts.map +1 -1
- package/dist/kernel/refusals.js +1 -0
- package/dist/kernel/refusals.js.map +1 -1
- package/dist/kernel/resolve.d.ts +2 -2
- package/dist/kernel/resolve.d.ts.map +1 -1
- package/dist/kernel/resolve.js +7 -3
- package/dist/kernel/resolve.js.map +1 -1
- package/dist/kernel/routing-authority.d.ts +1 -1
- package/dist/kernel/routing-authority.js +1 -1
- package/dist/kernel/skill-packages.d.ts +1 -1
- package/dist/kernel/skill-packages.js +1 -1
- package/dist/kernel/spawn.d.ts +22 -1
- package/dist/kernel/spawn.d.ts.map +1 -1
- package/dist/kernel/spawn.js +11 -4
- package/dist/kernel/spawn.js.map +1 -1
- package/dist/kernel/workspace.d.ts +1 -1
- package/extensions/advisor-session.ts +64 -0
- package/extensions/chain-plan.ts +7 -1
- package/extensions/context-shape.ts +30 -0
- package/extensions/context-staging.ts +200 -0
- package/extensions/delegate-chain.ts +2 -0
- package/extensions/delegation-ledger.ts +2 -0
- package/extensions/delegation.ts +4 -0
- package/extensions/execute-child.ts +9 -1
- package/extensions/grants-command.ts +1 -1
- package/extensions/grants.ts +5 -0
- package/extensions/run-delegation.ts +3 -0
- package/extensions/session-report.ts +1 -1
- package/extensions/session.ts +15 -0
- package/package.json +1 -1
- package/src/advisors/advisor.ts +123 -0
- package/src/advisors/decider.ts +64 -0
- package/src/advisors/jev.ts +130 -0
- package/src/advisors/settings.ts +73 -0
- package/src/cli.ts +13 -3
- package/src/executors/activity-session.ts +28 -0
- package/src/executors/herdr-cli.ts +1 -1
- package/src/executors/herdr-poll.ts +1 -1
- package/src/executors/herdr-stage.ts +26 -8
- package/src/executors/pane-reaper.ts +2 -2
- package/src/executors/run-herdr.ts +4 -4
- package/src/governance/ledger-report.ts +1 -1
- package/src/governance/ledger-v3-validation.ts +1 -0
- package/src/governance/ledger.ts +16 -1
- package/src/governance/workspace-lease.ts +1 -1
- package/src/kernel/capabilities.ts +7 -3
- package/src/kernel/catalog.ts +67 -1
- package/src/kernel/chain.ts +2 -0
- package/src/kernel/context-handoff.ts +231 -0
- package/src/kernel/delegate-types.ts +51 -0
- package/src/kernel/delegate.ts +60 -3
- package/src/kernel/env-names.ts +11 -0
- package/src/kernel/grant-env.ts +1 -1
- package/src/kernel/propagation.ts +10 -2
- package/src/kernel/refusals.ts +1 -0
- package/src/kernel/resolve.ts +7 -3
- package/src/kernel/routing-authority.ts +1 -1
- package/src/kernel/skill-packages.ts +1 -1
- package/src/kernel/spawn.ts +34 -4
- package/src/kernel/workspace.ts +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
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): 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 selection = selectPrunedTurns(parentTurns(input.parentSession), {
|
|
82
|
+
...(granted.turns !== undefined ? { turns: granted.turns } : {}),
|
|
83
|
+
...(granted.files !== undefined ? { files: granted.files } : {}),
|
|
84
|
+
});
|
|
85
|
+
keptTurns = selection.kept.length;
|
|
86
|
+
droppedTurns = selection.droppedCount;
|
|
87
|
+
rule = selection.rule;
|
|
88
|
+
for (const turn of selection.kept) sections.push({ label: `parent turn ${turn.id}`, body: turn.text });
|
|
89
|
+
if (selection.kept.length === 0)
|
|
90
|
+
sections.push({ label: "parent turns", body: "(no turn of your parent's session matched the selection)" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fenced = fenceContext(sections);
|
|
94
|
+
return {
|
|
95
|
+
contextPrompt: fenced.text,
|
|
96
|
+
record: {
|
|
97
|
+
mode: granted.mode,
|
|
98
|
+
sections: sections.length,
|
|
99
|
+
bytes: Buffer.byteLength(fenced.text),
|
|
100
|
+
truncatedBytes: fenced.truncatedBytes,
|
|
101
|
+
...(keptTurns !== undefined ? { keptTurns, droppedTurns, rule } : {}),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A fork replaces the session file the inactivity deadline would otherwise watch, because pi refuses `--fork`
|
|
109
|
+
* beside `--session`. The directory is ours and holds exactly one session, so the activity probe watches the
|
|
110
|
+
* directory instead of a fixed path — pi names the file `<timestamp>_<id>.jsonl` and only the id half is ours.
|
|
111
|
+
*/
|
|
112
|
+
function stageFork(input: StagingInput): StagedHandoff {
|
|
113
|
+
const sessionPath = input.parentSession?.getSessionFile();
|
|
114
|
+
if (!sessionPath)
|
|
115
|
+
// Not a silent downgrade to `none`: the parent asked for its whole session to cross and it has none to give.
|
|
116
|
+
return { refusal: "context: fork needs the parent's session file, and this session is not persisted" };
|
|
117
|
+
const sessionId = randomUUID();
|
|
118
|
+
const sessionDir = resolve(input.forkRoot, `fork-${sessionId}`);
|
|
119
|
+
try {
|
|
120
|
+
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { refusal: `context: fork could not allocate ${sessionDir} (${String(error)})` };
|
|
123
|
+
}
|
|
124
|
+
// The ledger records the SIZE of what crossed, because a fork is the largest handoff there is and recording it
|
|
125
|
+
// as zero bytes would make the audit trail understate exactly the mode that deserves the most scrutiny.
|
|
126
|
+
let bytes = 0;
|
|
127
|
+
let entries = 0;
|
|
128
|
+
try {
|
|
129
|
+
bytes = statSync(sessionPath).size;
|
|
130
|
+
entries = input.parentSession?.getEntries().length ?? 0;
|
|
131
|
+
} catch {
|
|
132
|
+
/* an unreadable parent session is still a fork pi will attempt; the record says 0 rather than guessing */
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
forkFrom: { sessionPath, sessionDir, sessionId },
|
|
136
|
+
dispose: () => rmSync(sessionDir, { recursive: true, force: true }),
|
|
137
|
+
record: { mode: "fork", sections: 1, bytes, truncatedBytes: 0, keptTurns: entries, droppedTurns: 0 },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read one named file, confined to the working directory, saying so in the fence when it cannot be read. */
|
|
142
|
+
function readSection(cwd: string, path: string): ContextSection {
|
|
143
|
+
const refuse = (why: string) => ({ label: path, body: `(refused: ${why})` });
|
|
144
|
+
if (isAbsolute(path)) return refuse("an absolute path");
|
|
145
|
+
let absolute: string;
|
|
146
|
+
let root: string;
|
|
147
|
+
try {
|
|
148
|
+
// `realpath`, not `resolve`: `resolve`/`relative` are LEXICAL, so a symlink inside the working directory
|
|
149
|
+
// pointing anywhere at all passed the check — measured in review with `cwd/link.txt -> /tmp/outside.txt`,
|
|
150
|
+
// whose contents duly appeared inside the fence. A repository full of `node_modules/.bin` symlinks makes that
|
|
151
|
+
// the ordinary case rather than a contrived one.
|
|
152
|
+
root = realpathSync(resolve(cwd));
|
|
153
|
+
absolute = realpathSync(resolve(cwd, path));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
|
|
156
|
+
}
|
|
157
|
+
const within = relative(root, absolute);
|
|
158
|
+
if (within.startsWith("..") || within === "" || isAbsolute(within))
|
|
159
|
+
return refuse("outside this session's working directory");
|
|
160
|
+
try {
|
|
161
|
+
const stats = statSync(absolute);
|
|
162
|
+
// A FIFO satisfies `statSync` and then never returns from a read, which would block the whole pi session with
|
|
163
|
+
// no watchdog — and the path is model-supplied, so it is reachable rather than theoretical.
|
|
164
|
+
if (!stats.isFile()) return refuse("not a regular file");
|
|
165
|
+
const note = stats.size > MAX_FILE_BYTES ? ` (first ${MAX_FILE_BYTES} of ${stats.size} bytes)` : "";
|
|
166
|
+
return { label: `${path}${note}`, body: readBounded(absolute, MAX_FILE_BYTES) };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { label: path, body: `(could not be read: ${error instanceof Error ? error.message : String(error)})` };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read at most `budget` BYTES, without pulling the rest of the file into the parent first.
|
|
174
|
+
*
|
|
175
|
+
* `readFileSync(...).slice(budget)` reads the whole file and then slices by UTF-16 code units, so a large file in
|
|
176
|
+
* the repository stalled the parent and the "(first N of M bytes)" label was wrong for any multi-byte content.
|
|
177
|
+
*/
|
|
178
|
+
function readBounded(path: string, budget: number): string {
|
|
179
|
+
const handle = openSync(path, "r");
|
|
180
|
+
try {
|
|
181
|
+
const buffer = Buffer.alloc(budget);
|
|
182
|
+
const read = readSync(handle, buffer, 0, budget, 0);
|
|
183
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(buffer.subarray(0, read)).replace(/\uFFFD+$/, "");
|
|
184
|
+
} finally {
|
|
185
|
+
closeSync(handle);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The parent's message turns, reduced to what the selection rule needs. Never pi's own types past this point. */
|
|
190
|
+
function parentTurns(session?: ParentSession): PrunableTurn[] {
|
|
191
|
+
if (!session) return [];
|
|
192
|
+
try {
|
|
193
|
+
return session
|
|
194
|
+
.getEntries()
|
|
195
|
+
.filter((entry) => entry.type === "message")
|
|
196
|
+
.map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -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,
|
package/extensions/delegation.ts
CHANGED
|
@@ -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
|
},
|
|
@@ -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> {
|
|
@@ -352,7 +360,7 @@ export async function executePlannedChild(input: {
|
|
|
352
360
|
// NOT strict, and this line is the whole point of R-99. The child has already run: failing
|
|
353
361
|
// closed here prevents nothing and used to discard a completed child's entire output while
|
|
354
362
|
// blaming "ledger" — under `delegate_all` it discarded every sibling's work too. The docstring
|
|
355
|
-
// above,
|
|
363
|
+
// above, the README and the ADR-0034 amendment all promised this; only the comment changed.
|
|
356
364
|
// `capability_decision`, which PROVISIONS, still fails closed.
|
|
357
365
|
strict: false,
|
|
358
366
|
onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
|
|
@@ -137,7 +137,7 @@ export const grantsCommand = {
|
|
|
137
137
|
];
|
|
138
138
|
for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.reason}`);
|
|
139
139
|
// ADR-0031, and R-51's lesson applied on the day the field was added rather than a release later: a field
|
|
140
|
-
// the writer sets and no diagnostic reads is one that needs `jq`, and
|
|
140
|
+
// the writer sets and no diagnostic reads is one that needs `jq`, and the README claims the executor is
|
|
141
141
|
// announced "per child in the ledger". `unknown` is shown only when present, because on a fresh ledger it
|
|
142
142
|
// is always zero and a permanent zero is noise; on an upgraded one it is the count of pre-0.16 lines, which
|
|
143
143
|
// is worth seeing.
|
package/extensions/grants.ts
CHANGED
|
@@ -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;
|
|
@@ -45,6 +45,8 @@ interface ChildSpec {
|
|
|
45
45
|
tools?: string[];
|
|
46
46
|
model?: string;
|
|
47
47
|
thinking?: string;
|
|
48
|
+
/** ADR-0078: what of the parent's session crosses. Validated in the kernel, never here. */
|
|
49
|
+
context?: unknown;
|
|
48
50
|
correlation?: CorrelationMetadata;
|
|
49
51
|
workspace?: DelegationWorkspaceSpec;
|
|
50
52
|
}
|
|
@@ -248,6 +250,7 @@ export async function runOneDelegation(
|
|
|
248
250
|
tools: spec.tools,
|
|
249
251
|
model: spec.model ?? defaultModel,
|
|
250
252
|
thinking: spec.thinking,
|
|
253
|
+
context: spec.context,
|
|
251
254
|
correlation: spec.workspace
|
|
252
255
|
? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
|
|
253
256
|
: spec.correlation,
|
|
@@ -127,7 +127,7 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
|
|
|
127
127
|
// `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
|
|
128
128
|
// `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
|
|
129
129
|
// file" is not true of everything it covers. Paired with a shell that is every body on disk running
|
|
130
|
-
// with `bash`.
|
|
130
|
+
// with `bash`. The README calls the combination poor and nothing detected it, which is R-47's
|
|
131
131
|
// shape in a control shipped one day later.
|
|
132
132
|
if (
|
|
133
133
|
session.ownGrant.includes(AGENT_WILDCARD) &&
|
package/extensions/session.ts
CHANGED
|
@@ -48,6 +48,9 @@ import type { GrantStoreRefusalReason } from "../src/governance/grant-store.ts";
|
|
|
48
48
|
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
|
+
import { createHandoffStager, type ParentSession } from "./context-staging.ts";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
import { agentDir } from "../src/kernel/project-paths.ts";
|
|
51
54
|
import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts";
|
|
52
55
|
import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
|
|
53
56
|
import { reconcileSessionEnvironment } from "./session-environment.ts";
|
|
@@ -149,6 +152,11 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
149
152
|
/** Stable root identity plus current turn, used only to join local activity facts. */
|
|
150
153
|
activityRootId: string;
|
|
151
154
|
activity?: { rootId: string; path: string; taskId?: string };
|
|
155
|
+
/**
|
|
156
|
+
* The parent's own session, once `session_start` supplies it. Read-only and used only to stage a granted
|
|
157
|
+
* context handoff (ADR-0078): its file path for `fork`, its message turns for `pruned`.
|
|
158
|
+
*/
|
|
159
|
+
parentSession?: ParentSession;
|
|
152
160
|
/** Root identity keyed to ctx.sessionManager once session_start supplies it. */
|
|
153
161
|
reloadLifecycle: ReloadLifecycle;
|
|
154
162
|
/** Approval keys approved for this session. In memory only — this dies with the process. */
|
|
@@ -352,6 +360,13 @@ export function createGrantsSession(
|
|
|
352
360
|
extensionPath: session.extensionPath,
|
|
353
361
|
observerExtensionPath: session.observerExtensionPath,
|
|
354
362
|
childEnv: activityChildEnv(session.activity),
|
|
363
|
+
// ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
|
|
364
|
+
stageHandoff: (granted) =>
|
|
365
|
+
createHandoffStager({
|
|
366
|
+
cwd: session.cwd,
|
|
367
|
+
forkRoot: join(agentDir(), "context-forks"),
|
|
368
|
+
...(session.parentSession ? { parentSession: session.parentSession } : {}),
|
|
369
|
+
})(granted),
|
|
355
370
|
catalog: await session.catalogReady,
|
|
356
371
|
// R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
|
|
357
372
|
// Derived from the catalog's own `source`, so it cannot drift from what was discovered.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wrapper that makes "every use is recorded" structural rather than a rule (ADR-0077).
|
|
3
|
+
*
|
|
4
|
+
* A caller cannot reach a `Decider` directly through this layer's public surface: it asks an `Advisor`, and asking
|
|
5
|
+
* always produces an `advice` record — including when the answer was "no advice", which is the case a reviewer most
|
|
6
|
+
* needs to see, because an advisor that silently stops answering would otherwise look exactly like one nobody used.
|
|
7
|
+
*
|
|
8
|
+
* **What is recorded, and what is not.** The record names the purpose, the decider, the question keys, the answers
|
|
9
|
+
* and how long it took. It does NOT contain the state. A caller composes that state from its own context, which can
|
|
10
|
+
* include task text, file contents and a repository's private material; the ledger has never stored a task
|
|
11
|
+
* (ADR-0021) and an advisor must not become the way it starts. The question keys are the caller's own constants,
|
|
12
|
+
* so they name the decision without describing the situation.
|
|
13
|
+
*/
|
|
14
|
+
import type { Advice, AdviceRequest, Decider } from "./decider.ts";
|
|
15
|
+
|
|
16
|
+
/** Two seconds. An advisor is on the path of a decision a human is waiting for; it is not worth more than that. */
|
|
17
|
+
export const DEFAULT_ADVICE_TIMEOUT_MS = 2000;
|
|
18
|
+
|
|
19
|
+
export interface AdviceRecord {
|
|
20
|
+
/** Which decision this advice was for, from the caller's own closed list. */
|
|
21
|
+
purpose: string;
|
|
22
|
+
decider: string;
|
|
23
|
+
/** Question keys only — never the state, and never a question's free text. */
|
|
24
|
+
questions: string[];
|
|
25
|
+
answered: boolean;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
/** Present only when advice came back. */
|
|
28
|
+
answers?: Readonly<Record<string, { value: string | number | boolean; confidence?: number }>>;
|
|
29
|
+
model?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Why there is no advice. `declined` means the advisor answered with nothing; `error` means it could not be
|
|
32
|
+
* reached or its response was unrecognised; `cancelled` means the CALLER went away, which is not the advisor's
|
|
33
|
+
* failure and must not read as one.
|
|
34
|
+
*/
|
|
35
|
+
outcome: "answered" | "disabled" | "timeout" | "error" | "declined" | "cancelled";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Advisor {
|
|
39
|
+
ask(purpose: string, request: AdviceRequest, signal?: AbortSignal): Promise<Advice | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createAdvisor(input: {
|
|
43
|
+
decider: Decider;
|
|
44
|
+
/** Where the record goes. Injected so this layer does no I/O and governance does not import it. */
|
|
45
|
+
record: (entry: AdviceRecord) => void | Promise<void>;
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
/** Absent or false means the null decider is used whatever `decider` says. */
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
}): Advisor {
|
|
50
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_ADVICE_TIMEOUT_MS;
|
|
51
|
+
return {
|
|
52
|
+
async ask(purpose, request, signal) {
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
const base = { purpose, decider: input.decider.name, questions: Object.keys(request.questions) };
|
|
55
|
+
const write = async (entry: AdviceRecord) => {
|
|
56
|
+
try {
|
|
57
|
+
await input.record(entry);
|
|
58
|
+
} catch {
|
|
59
|
+
// Recording is an observation of a decision that has already been taken. Failing to write it must not
|
|
60
|
+
// change what the caller does, for `execute-child`'s reason: an audit failure that discards the work is
|
|
61
|
+
// worse than one that is merely missing.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (input.enabled !== true) {
|
|
65
|
+
await write({ ...base, decider: "none", answered: false, durationMs: 0, outcome: "disabled" });
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const timer = new AbortController();
|
|
69
|
+
const cancel = setTimeout(() => timer.abort(), timeoutMs);
|
|
70
|
+
const linked = signal ? AbortSignal.any([signal, timer.signal]) : timer.signal;
|
|
71
|
+
try {
|
|
72
|
+
// RACED, not merely signalled. A decider that ignores its signal would otherwise run as long as it liked
|
|
73
|
+
// and then be recorded as a timeout — measured at fifty times the configured bound. The `Decider` contract
|
|
74
|
+
// cannot make an implementation honour an abort, so the bound is enforced on this side of it.
|
|
75
|
+
const advice = await Promise.race([
|
|
76
|
+
input.decider.decide(request, linked),
|
|
77
|
+
new Promise<null>((settle) => linked.addEventListener("abort", () => settle(null), { once: true })),
|
|
78
|
+
]);
|
|
79
|
+
const durationMs = Date.now() - started;
|
|
80
|
+
if (!advice) {
|
|
81
|
+
await write({ ...base, answered: false, durationMs, outcome: outcomeFor(timer.signal, signal, "declined") });
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
await write({
|
|
85
|
+
...base,
|
|
86
|
+
answered: true,
|
|
87
|
+
durationMs,
|
|
88
|
+
outcome: "answered",
|
|
89
|
+
answers: Object.fromEntries(
|
|
90
|
+
Object.entries(advice.answers).map(([key, answer]) => [
|
|
91
|
+
key,
|
|
92
|
+
{ value: answer.value, ...(answer.confidence === undefined ? {} : { confidence: answer.confidence }) },
|
|
93
|
+
]),
|
|
94
|
+
),
|
|
95
|
+
...(advice.model ? { model: advice.model } : {}),
|
|
96
|
+
});
|
|
97
|
+
return advice;
|
|
98
|
+
} catch {
|
|
99
|
+
// Including an abort. A caller asked for advice and is getting none; it proceeds exactly as it would have.
|
|
100
|
+
await write({
|
|
101
|
+
...base,
|
|
102
|
+
answered: false,
|
|
103
|
+
durationMs: Date.now() - started,
|
|
104
|
+
outcome: outcomeFor(timer.signal, signal, "error"),
|
|
105
|
+
});
|
|
106
|
+
return null;
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(cancel);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The bound fired, the caller went away, or neither — three different facts a reviewer needs to tell apart. */
|
|
115
|
+
function outcomeFor(
|
|
116
|
+
timer: AbortSignal,
|
|
117
|
+
caller: AbortSignal | undefined,
|
|
118
|
+
otherwise: "declined" | "error",
|
|
119
|
+
): AdviceRecord["outcome"] {
|
|
120
|
+
if (timer.aborted && !caller?.aborted) return "timeout";
|
|
121
|
+
if (caller?.aborted) return "cancelled";
|
|
122
|
+
return otherwise;
|
|
123
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An advisor is a thing that answers a typed question. It is never a thing that decides (ADR-0077).
|
|
3
|
+
*
|
|
4
|
+
* The boundary is the whole design, and it is structural rather than remembered: **no type in this layer names a
|
|
5
|
+
* `Capability` or a refusal code**, and no function in `kernel/` or `governance/` accepts an advisor's result. An
|
|
6
|
+
* advisor may select among options the caller already had, rank them, annotate them, or propose one. It can never
|
|
7
|
+
* widen an `effective` set, satisfy a gate, or stand in for a human's answer — not because it is asked not to, but
|
|
8
|
+
* because nothing on those paths can receive what it returns. `test/advisors.test.ts` forces that.
|
|
9
|
+
*
|
|
10
|
+
* Non-generative on purpose. The first advisor is a classifier that returns a typed answer with a probability, not
|
|
11
|
+
* prose, which is what makes an advisor auditable: "it chose `b` at 0.91" is a fact a reviewer can disagree with,
|
|
12
|
+
* where a paragraph of reasoning is not.
|
|
13
|
+
*
|
|
14
|
+
* Degradation is "no advice", never a guess. Every path that cannot produce an answer — disabled, missing key,
|
|
15
|
+
* timeout, transport error, a response shape we do not recognise — returns `null`, and the caller does what it
|
|
16
|
+
* would have done without an advisor at all. That is why a caller must be written to work with `nullDecider`
|
|
17
|
+
* first, and why `nullDecider` is the default.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A question, in the three shapes the first advisor understands. */
|
|
21
|
+
export type Question =
|
|
22
|
+
| { kind: "noul"; instructions: string; whenTrue: string; whenFalse: string }
|
|
23
|
+
| { kind: "choice"; instructions: string; options: Readonly<Record<string, string>> }
|
|
24
|
+
| { kind: "score"; instructions: string; levels: readonly string[] };
|
|
25
|
+
|
|
26
|
+
/** One typed answer. `confidence` is absent when the transport did not report one; it is never invented. */
|
|
27
|
+
export type Answer =
|
|
28
|
+
| { kind: "noul"; value: boolean; confidence?: number }
|
|
29
|
+
| { kind: "choice"; value: string; confidence?: number }
|
|
30
|
+
// `level` is the string the value indexes, so a caller never has to know which end the scale starts at.
|
|
31
|
+
| { kind: "score"; value: number; level: string; confidence?: number };
|
|
32
|
+
|
|
33
|
+
export interface AdviceRequest {
|
|
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.
|
|
38
|
+
*/
|
|
39
|
+
state: Readonly<Record<string, unknown>>;
|
|
40
|
+
questions: Readonly<Record<string, Question>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Advice {
|
|
44
|
+
answers: Readonly<Record<string, Answer>>;
|
|
45
|
+
/** What actually answered, as the transport reported it — a dated model id, not the one we asked for. */
|
|
46
|
+
model?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Decider {
|
|
50
|
+
/** Recorded in the ledger so a reviewer can tell which advisor a decision was taken beside. */
|
|
51
|
+
readonly name: string;
|
|
52
|
+
decide(request: AdviceRequest, signal?: AbortSignal): Promise<Advice | null>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The default, and the one every caller must work correctly with.
|
|
57
|
+
*
|
|
58
|
+
* Not a placeholder: it is how advisors stay optional. A caller that behaves differently under `nullDecider` than
|
|
59
|
+
* under no advisor at all has made advice load-bearing, which is the one thing ADR-0077 forbids.
|
|
60
|
+
*/
|
|
61
|
+
export const nullDecider: Decider = {
|
|
62
|
+
name: "none",
|
|
63
|
+
decide: async () => null,
|
|
64
|
+
};
|