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.
- package/CHANGELOG.md +83 -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 +79 -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 +47 -0
- package/dist/advisors/settings.d.ts.map +1 -0
- package/dist/advisors/settings.js +98 -0
- package/dist/advisors/settings.js.map +1 -0
- 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-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/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 +14 -0
- package/dist/governance/ledger.d.ts.map +1 -1
- package/dist/governance/ledger.js +1 -0
- package/dist/governance/ledger.js.map +1 -1
- package/dist/kernel/capabilities.d.ts +1 -1
- package/dist/kernel/capabilities.d.ts.map +1 -1
- package/dist/kernel/capabilities.js +5 -1
- package/dist/kernel/capabilities.js.map +1 -1
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +6 -0
- 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 +70 -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 +50 -2
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +24 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +27 -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 +14 -2
- 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.map +1 -1
- package/dist/kernel/resolve.js +4 -0
- package/dist/kernel/resolve.js.map +1 -1
- package/dist/kernel/spawn.d.ts +21 -0
- package/dist/kernel/spawn.d.ts.map +1 -1
- package/dist/kernel/spawn.js +8 -1
- package/dist/kernel/spawn.js.map +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 +208 -0
- package/extensions/delegate-chain.ts +2 -0
- package/extensions/delegation-ledger.ts +2 -0
- package/extensions/delegation.ts +4 -0
- package/extensions/effort-advice.ts +99 -0
- package/extensions/execute-child.ts +8 -0
- package/extensions/grants-command.ts +10 -0
- package/extensions/grants.ts +9 -0
- package/extensions/pruning-advice.ts +88 -0
- package/extensions/run-delegation.ts +64 -3
- package/extensions/session.ts +59 -1
- package/package.json +1 -1
- package/src/advisors/advisor.ts +123 -0
- package/src/advisors/decider.ts +68 -0
- package/src/advisors/jev.ts +130 -0
- package/src/advisors/settings.ts +113 -0
- package/src/executors/activity-session.ts +28 -0
- package/src/executors/herdr-stage.ts +26 -8
- package/src/governance/ledger-v3-validation.ts +1 -0
- package/src/governance/ledger.ts +15 -0
- package/src/kernel/capabilities.ts +5 -1
- package/src/kernel/catalog.ts +6 -0
- package/src/kernel/chain.ts +2 -0
- package/src/kernel/context-handoff.ts +231 -0
- package/src/kernel/delegate-types.ts +62 -0
- package/src/kernel/delegate.ts +56 -2
- package/src/kernel/env-names.ts +27 -0
- package/src/kernel/propagation.ts +16 -1
- package/src/kernel/refusals.ts +1 -0
- package/src/kernel/resolve.ts +4 -0
- package/src/kernel/spawn.ts +31 -1
package/extensions/session.ts
CHANGED
|
@@ -48,6 +48,11 @@ 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 { createAdvisorSession, type AdvisorSession } from "./advisor-session.ts";
|
|
53
|
+
import { join } from "node:path";
|
|
54
|
+
import { readFileSync, statSync } from "node:fs";
|
|
55
|
+
import { agentDir, projectSettingsPath } from "../src/kernel/project-paths.ts";
|
|
51
56
|
import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts";
|
|
52
57
|
import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
|
|
53
58
|
import { reconcileSessionEnvironment } from "./session-environment.ts";
|
|
@@ -84,7 +89,7 @@ import {
|
|
|
84
89
|
ENV_ACTIVITY_ROOT,
|
|
85
90
|
ENV_ACTIVITY_TASK,
|
|
86
91
|
} from "../src/products/activity-timeline.ts";
|
|
87
|
-
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";
|
|
88
93
|
export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
|
|
89
94
|
import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts";
|
|
90
95
|
|
|
@@ -149,6 +154,13 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
149
154
|
/** Stable root identity plus current turn, used only to join local activity facts. */
|
|
150
155
|
activityRootId: string;
|
|
151
156
|
activity?: { rootId: string; path: string; taskId?: string };
|
|
157
|
+
/**
|
|
158
|
+
* The parent's own session, once `session_start` supplies it. Read-only and used only to stage a granted
|
|
159
|
+
* context handoff (ADR-0078): its file path for `fork`, its message turns for `pruned`.
|
|
160
|
+
*/
|
|
161
|
+
parentSession?: ParentSession;
|
|
162
|
+
/** ADR-0077: the session's advisor, off unless the environment enables one. Never consulted for authority. */
|
|
163
|
+
advisorSession: AdvisorSession;
|
|
152
164
|
/** Root identity keyed to ctx.sessionManager once session_start supplies it. */
|
|
153
165
|
reloadLifecycle: ReloadLifecycle;
|
|
154
166
|
/** Approval keys approved for this session. In memory only — this dies with the process. */
|
|
@@ -288,7 +300,18 @@ export function createGrantsSession(
|
|
|
288
300
|
const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
|
|
289
301
|
const { depth, maxDepth } = bounds;
|
|
290
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
|
+
});
|
|
291
313
|
const session: GrantsSession = {
|
|
314
|
+
advisorSession,
|
|
292
315
|
adoptedLegacyEnv,
|
|
293
316
|
governed,
|
|
294
317
|
inherited,
|
|
@@ -352,6 +375,17 @@ export function createGrantsSession(
|
|
|
352
375
|
extensionPath: session.extensionPath,
|
|
353
376
|
observerExtensionPath: session.observerExtensionPath,
|
|
354
377
|
childEnv: activityChildEnv(session.activity),
|
|
378
|
+
// ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
|
|
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) =>
|
|
384
|
+
createHandoffStager({
|
|
385
|
+
cwd: session.cwd,
|
|
386
|
+
forkRoot: join(agentDir(), "context-forks"),
|
|
387
|
+
...(session.parentSession ? { parentSession: session.parentSession } : {}),
|
|
388
|
+
})(granted, options),
|
|
355
389
|
catalog: await session.catalogReady,
|
|
356
390
|
// R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
|
|
357
391
|
// Derived from the catalog's own `source`, so it cannot drift from what was discovered.
|
|
@@ -405,3 +439,27 @@ export function createGrantsSession(
|
|
|
405
439
|
};
|
|
406
440
|
return session;
|
|
407
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",
|
|
@@ -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,68 @@
|
|
|
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, 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.
|
|
42
|
+
*/
|
|
43
|
+
state: Readonly<Record<string, unknown>>;
|
|
44
|
+
questions: Readonly<Record<string, Question>>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Advice {
|
|
48
|
+
answers: Readonly<Record<string, Answer>>;
|
|
49
|
+
/** What actually answered, as the transport reported it — a dated model id, not the one we asked for. */
|
|
50
|
+
model?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Decider {
|
|
54
|
+
/** Recorded in the ledger so a reviewer can tell which advisor a decision was taken beside. */
|
|
55
|
+
readonly name: string;
|
|
56
|
+
decide(request: AdviceRequest, signal?: AbortSignal): Promise<Advice | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The default, and the one every caller must work correctly with.
|
|
61
|
+
*
|
|
62
|
+
* Not a placeholder: it is how advisors stay optional. A caller that behaves differently under `nullDecider` than
|
|
63
|
+
* under no advisor at all has made advice load-bearing, which is the one thing ADR-0077 forbids.
|
|
64
|
+
*/
|
|
65
|
+
export const nullDecider: Decider = {
|
|
66
|
+
name: "none",
|
|
67
|
+
decide: async () => null,
|
|
68
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jev, through OpenRouter's Decisions endpoint — the first advisor adapter (ADR-0077).
|
|
3
|
+
*
|
|
4
|
+
* **What is verified and what is not.** The REQUEST shape below is taken from OpenRouter's own SDK reference for
|
|
5
|
+
* `POST /api/alpha/decisions`: `{model, state, questions}`, where each question is `noul` with `criteria.true` and
|
|
6
|
+
* `criteria.false`, `choice` with a `criteria` map of option to description, or `score` with a `criteria` array of
|
|
7
|
+
* level descriptions. That much is documented. The RESPONSE is described there only as an `answers` object beside
|
|
8
|
+
* `id`, `model`, `provider` and `usage`, with probabilities and confidence mentioned but never shown, and the one
|
|
9
|
+
* public guide to this endpoint says plainly that it has not run paid calls either. **So no shape below has been
|
|
10
|
+
* confirmed against a live response.** The parser therefore accepts what the documentation describes, tolerates the
|
|
11
|
+
* obvious variants, and returns `null` for anything else rather than guessing — which is the same thing it does when
|
|
12
|
+
* the endpoint is down. A live check is the `PI_DADDY_IT_JEV=1` tier, and until somebody runs it this adapter's
|
|
13
|
+
* response handling is a reading of documentation, not a measurement.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here can widen anything: it returns `Advice`, and no kernel or governance function accepts one.
|
|
16
|
+
*/
|
|
17
|
+
import type { Advice, AdviceRequest, Answer, Decider, Question } from "./decider.ts";
|
|
18
|
+
|
|
19
|
+
export const JEV_ENDPOINT = "https://openrouter.ai/api/alpha/decisions";
|
|
20
|
+
export const JEV_MODEL = "typesafe/jev-1.13";
|
|
21
|
+
|
|
22
|
+
/** The wire form of one question, exactly as OpenRouter's reference documents it. */
|
|
23
|
+
export function wireQuestion(question: Question): Record<string, unknown> {
|
|
24
|
+
if (question.kind === "noul")
|
|
25
|
+
return {
|
|
26
|
+
type: "noul",
|
|
27
|
+
instructions: question.instructions,
|
|
28
|
+
criteria: { true: question.whenTrue, false: question.whenFalse },
|
|
29
|
+
};
|
|
30
|
+
if (question.kind === "choice")
|
|
31
|
+
return { type: "choice", instructions: question.instructions, criteria: { ...question.options } };
|
|
32
|
+
return { type: "score", instructions: question.instructions, criteria: [...question.levels] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function wireRequest(request: AdviceRequest, model: string): Record<string, unknown> {
|
|
36
|
+
return {
|
|
37
|
+
model,
|
|
38
|
+
state: request.state,
|
|
39
|
+
questions: Object.fromEntries(Object.entries(request.questions).map(([key, q]) => [key, wireQuestion(q)])),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read one answer out of a response, or nothing.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately generous about WHERE the value and the probability sit, because the documentation names the fields
|
|
47
|
+
* without showing them, and strict about WHAT they are: a `choice` answer must be one of the options that were
|
|
48
|
+
* asked about, and a `score` must be an index into the levels. An answer outside the question's own vocabulary is
|
|
49
|
+
* not a low-confidence answer, it is a response we did not understand, and the honest reading of that is no advice.
|
|
50
|
+
*/
|
|
51
|
+
export function parseAnswer(question: Question, raw: unknown): Answer | undefined {
|
|
52
|
+
if (raw === null || raw === undefined) return undefined;
|
|
53
|
+
const object = typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;
|
|
54
|
+
const value = object ? (object.value ?? object.answer ?? object.choice ?? object.result) : raw;
|
|
55
|
+
const confidenceRaw = object ? (object.confidence ?? object.probability ?? object.p) : undefined;
|
|
56
|
+
const confidence = typeof confidenceRaw === "number" && Number.isFinite(confidenceRaw) ? confidenceRaw : undefined;
|
|
57
|
+
const withConfidence = <T extends Answer>(answer: T): T =>
|
|
58
|
+
(confidence === undefined ? answer : { ...answer, confidence }) as T;
|
|
59
|
+
|
|
60
|
+
if (question.kind === "noul") {
|
|
61
|
+
if (typeof value !== "boolean") return undefined;
|
|
62
|
+
return withConfidence({ kind: "noul", value });
|
|
63
|
+
}
|
|
64
|
+
if (question.kind === "choice") {
|
|
65
|
+
if (typeof value !== "string" || !Object.hasOwn(question.options, value)) return undefined;
|
|
66
|
+
return withConfidence({ kind: "choice", value });
|
|
67
|
+
}
|
|
68
|
+
// ONE reading: a score is a 0-based index into the `criteria` array that was asked about, and `level` carries the
|
|
69
|
+
// string so a caller never indexes the number itself.
|
|
70
|
+
//
|
|
71
|
+
// The first draft accepted both a 0-based and a 1-based reading "because the documentation shows neither", which
|
|
72
|
+
// pushed the ambiguity onto the caller and into the ledger: with three levels, 1 and 2 were valid under both, so
|
|
73
|
+
// `levels[value]` could read "high" where the model meant "mid". An advisor exists to remove that guess, not to
|
|
74
|
+
// relocate it. **The 0-based reading is an assumption** — it indexes the documented array form — and it is
|
|
75
|
+
// unverified for the same reason everything else about the response is: no live call has been made. If Jev is
|
|
76
|
+
// 1-based, its top level falls outside the array and the whole answer is refused as unrecognised, which is the
|
|
77
|
+
// loud failure rather than a silently shifted one, and the `PI_DADDY_IT_JEV=1` tier is what would show it.
|
|
78
|
+
if (typeof value !== "number" || !Number.isInteger(value)) return undefined;
|
|
79
|
+
if (value < 0 || value >= question.levels.length) return undefined;
|
|
80
|
+
return withConfidence({ kind: "score", value, level: question.levels[value] });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseAdvice(request: AdviceRequest, body: unknown): Advice | null {
|
|
84
|
+
if (typeof body !== "object" || body === null) return null;
|
|
85
|
+
const envelope = body as Record<string, unknown>;
|
|
86
|
+
const raw = envelope.answers;
|
|
87
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
88
|
+
const answers: Record<string, Answer> = {};
|
|
89
|
+
for (const [key, question] of Object.entries(request.questions)) {
|
|
90
|
+
const parsed = parseAnswer(question, (raw as Record<string, unknown>)[key]);
|
|
91
|
+
// Every question or none: a caller that asked two questions and silently received one would have to guess which
|
|
92
|
+
// of its branches the missing answer belonged to, and guessing is what an advisor exists to remove.
|
|
93
|
+
if (!parsed) return null;
|
|
94
|
+
answers[key] = parsed;
|
|
95
|
+
}
|
|
96
|
+
return { answers, ...(typeof envelope.model === "string" ? { model: envelope.model } : {}) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface JevConfig {
|
|
100
|
+
apiKey: string;
|
|
101
|
+
model?: string;
|
|
102
|
+
endpoint?: string;
|
|
103
|
+
/** Injected so the adapter is testable without a network, and so nothing here reaches for a global. */
|
|
104
|
+
fetch?: typeof globalThis.fetch;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function jevDecider(config: JevConfig): Decider {
|
|
108
|
+
return {
|
|
109
|
+
name: "jev",
|
|
110
|
+
async decide(request, signal) {
|
|
111
|
+
const send = config.fetch ?? globalThis.fetch;
|
|
112
|
+
const response = await send(config.endpoint ?? JEV_ENDPOINT, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.apiKey}` },
|
|
115
|
+
body: JSON.stringify(wireRequest(request, config.model ?? JEV_MODEL)),
|
|
116
|
+
...(signal ? { signal } : {}),
|
|
117
|
+
});
|
|
118
|
+
// A dead endpoint and an advisor with nothing to say must not read alike in the ledger: the whole reason for
|
|
119
|
+
// recording the nothing-cases is that an advisor which quietly stopped answering should not look like one
|
|
120
|
+
// nobody called. Thrown, so `createAdvisor` records `error` rather than `declined`; it catches, so nothing
|
|
121
|
+
// reaches the caller but `null` either way.
|
|
122
|
+
if (!response.ok) throw new Error(`advisor endpoint returned ${response.status}`);
|
|
123
|
+
try {
|
|
124
|
+
return parseAdvice(request, await response.json());
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an advisor is on, and which one (ADR-0077).
|
|
3
|
+
*
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* **Not a dashboard toggle**, which is what the programme originally sketched. The dashboard is a read-only
|
|
19
|
+
* renderer in a separate process that "never affects enforcement" (ADR-0036), and a control there that wrote to
|
|
20
|
+
* settings would be the first thing it ever wrote. Turning an advisor on is an operator decision that belongs in
|
|
21
|
+
* the reviewable file; `/grants` reports what is in force. That is a deliberate departure from the roadmap line.
|
|
22
|
+
*
|
|
23
|
+
* The key is never in the settings file either, because that file is committed and an API key must not be.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Spelled once, in the kernel's table, so this layer cannot drift from the list `childEnv` refuses to write.
|
|
27
|
+
export { ENV_ADVISOR_KEY as ADVISOR_KEY_ENV } 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";
|
|
32
|
+
|
|
33
|
+
export interface AdvisorSettings {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
/** The only decider this release knows besides the null one. */
|
|
36
|
+
decider: "none" | "jev";
|
|
37
|
+
/** Overrides the adapter's pinned model id; absent means the adapter's own default. */
|
|
38
|
+
model?: string;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** Why an advisor is off when the settings asked for one on — reported, never silently applied. */
|
|
41
|
+
refusal?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, decider: "none" });
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the `advisor` block of a project settings file. Absent is off; malformed is off WITH a reason.
|
|
48
|
+
*
|
|
49
|
+
* The reason matters more than it looks: an operator who wrote `"enabeld": true` and got silence would conclude the
|
|
50
|
+
* feature does not work, and an operator who wrote it and got an advisor anyway would have a third party reading
|
|
51
|
+
* their session without having successfully asked for it. Both are worse than a sentence naming the field.
|
|
52
|
+
*/
|
|
53
|
+
export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = process.env): AdvisorSettings {
|
|
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))
|
|
64
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor must be an object; no advisor is enabled" };
|
|
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.
|
|
71
|
+
if (unknownKeys.length > 0)
|
|
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")
|
|
84
|
+
return { ...ADVISOR_OFF, refusal: `settings.advisor.decider must be "jev"; no advisor is enabled` };
|
|
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` };
|
|
91
|
+
if (
|
|
92
|
+
fields.timeoutMs !== undefined &&
|
|
93
|
+
(!Number.isInteger(fields.timeoutMs) || (fields.timeoutMs as number) < 1 || (fields.timeoutMs as number) > 30_000)
|
|
94
|
+
)
|
|
95
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor.timeoutMs must be an integer between 1 and 30000" };
|
|
96
|
+
const model = env[ENV_ADVISOR_MODEL]?.trim();
|
|
97
|
+
const key = env[ENV_ADVISOR_KEY]?.trim();
|
|
98
|
+
if (!key)
|
|
99
|
+
return {
|
|
100
|
+
...ADVISOR_OFF,
|
|
101
|
+
refusal: `settings.advisor is enabled but ${ENV_ADVISOR_KEY} is not set; no advisor is enabled`,
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
enabled: true,
|
|
105
|
+
decider: "jev",
|
|
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
|
+
: {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -23,7 +23,35 @@ export interface ActivitySession {
|
|
|
23
23
|
dispose(): Promise<void>;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** The newest session file pi wrote into a directory we own, as a size:mtime marker. */
|
|
27
|
+
function probeDirectory(directory: string) {
|
|
28
|
+
return async (): Promise<string | undefined> => {
|
|
29
|
+
try {
|
|
30
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
31
|
+
const names = (await readdir(directory)).filter((name) => name.endsWith(".jsonl"));
|
|
32
|
+
if (names.length === 0) return undefined;
|
|
33
|
+
const marks = await Promise.all(
|
|
34
|
+
names.map(async (name) => {
|
|
35
|
+
const s = await stat(join(directory, name));
|
|
36
|
+
return `${name}:${s.size}:${s.mtimeMs}`;
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
return marks.sort().join("|");
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
export async function activitySessionFor(planArgs: string[], executionId: string): Promise<ActivitySession> {
|
|
47
|
+
// A forked child (ADR-0078) has no `--session`, and adding one would make pi refuse the spawn outright: it
|
|
48
|
+
// rejects `--fork` beside `--session` or `--no-session`. The fork writes exactly one session into a directory
|
|
49
|
+
// that is ours, so the probe watches the directory and the argv is left exactly as planned.
|
|
50
|
+
const fork = planArgs.indexOf("--fork");
|
|
51
|
+
if (fork >= 0) {
|
|
52
|
+
const dir = planArgs[planArgs.indexOf("--session-dir") + 1];
|
|
53
|
+
return { args: planArgs, path: dir, probe: probeDirectory(dir), dispose: async () => undefined };
|
|
54
|
+
}
|
|
27
55
|
const flag = planArgs.indexOf("--session");
|
|
28
56
|
const probeFor = (path: string) => async () => {
|
|
29
57
|
try {
|
|
@@ -26,10 +26,22 @@ import { join } from "node:path";
|
|
|
26
26
|
* direct executor passes the same text inline with no trouble, and a plan builder that pre-emptively wrote
|
|
27
27
|
* temp files for everybody would be paying one executor's tax on both paths.
|
|
28
28
|
*/
|
|
29
|
-
export function splitSystemPrompt(args: string[]): { args: string[];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
export function splitSystemPrompt(args: string[]): { args: string[]; systemPrompts: string[] } {
|
|
30
|
+
// EVERY occurrence, not the first. pi accumulates `--append-system-prompt`, and ADR-0078 added a second one for a
|
|
31
|
+
// granted context handoff — whose fence always contains newlines. Taking only the first left that fence inline,
|
|
32
|
+
// and `herdr agent start` refuses a multi-line argument, so every non-fork handoff failed on the Herdr executor
|
|
33
|
+
// AFTER the gate, the ledger record and the fan-out spend. Measured during review.
|
|
34
|
+
const kept: string[] = [];
|
|
35
|
+
const systemPrompts: string[] = [];
|
|
36
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
37
|
+
if (args[index] === "--append-system-prompt" && index + 1 < args.length) {
|
|
38
|
+
systemPrompts.push(args[index + 1]);
|
|
39
|
+
index += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
kept.push(args[index]);
|
|
43
|
+
}
|
|
44
|
+
return { args: kept, systemPrompts };
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
@@ -43,12 +55,18 @@ export async function stageSystemPrompt(
|
|
|
43
55
|
args: string[],
|
|
44
56
|
): Promise<{ args: string[]; promptDir?: string; error?: string }> {
|
|
45
57
|
const split = splitSystemPrompt(args);
|
|
46
|
-
if (split.
|
|
58
|
+
if (split.systemPrompts.length === 0) return { args: split.args };
|
|
47
59
|
try {
|
|
48
60
|
const promptDir = await mkdtemp(join(tmpdir(), "grants-herdr-"));
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
const staged: string[] = [];
|
|
62
|
+
// One file per prompt, in order: pi appends them in argv order and the definition body must still precede the
|
|
63
|
+
// context a parent chose to add to it.
|
|
64
|
+
for (const [index, prompt] of split.systemPrompts.entries()) {
|
|
65
|
+
const file = join(promptDir, `system-prompt-${index}.md`);
|
|
66
|
+
await writeFile(file, prompt, "utf8");
|
|
67
|
+
staged.push("--append-system-prompt", file);
|
|
68
|
+
}
|
|
69
|
+
return { args: [...split.args, ...staged], promptDir };
|
|
52
70
|
} catch (error) {
|
|
53
71
|
return { args: split.args, error: `could not stage the system prompt for herdr: ${String(error)}` };
|
|
54
72
|
}
|