pi-daddy 0.32.1 → 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 +48 -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/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 +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 +48 -2
- 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/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +9 -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 +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 +8 -0
- package/extensions/grants.ts +5 -0
- package/extensions/run-delegation.ts +3 -0
- 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/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 +51 -0
- package/src/kernel/delegate.ts +54 -2
- package/src/kernel/env-names.ts +11 -0
- package/src/kernel/propagation.ts +9 -1
- package/src/kernel/refusals.ts +1 -0
- package/src/kernel/resolve.ts +4 -0
- package/src/kernel/spawn.ts +31 -1
|
@@ -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
|
+
};
|
|
@@ -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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an advisor is on, and which one (ADR-0077).
|
|
3
|
+
*
|
|
4
|
+
* **Default off, and off is the whole configuration when nothing says otherwise.** An advisor sends a description
|
|
5
|
+
* of the caller's situation to a third party, so it is not something a package turns on for somebody: it is turned
|
|
6
|
+
* on in `.pi/pi-daddy/settings.json`, the file an operator reviews and commits, beside the grant that governs
|
|
7
|
+
* everything else. Malformed configuration disables the advisor and says so, which is this project's rule for
|
|
8
|
+
* configuration everywhere: a typo must not be a way to enable something.
|
|
9
|
+
*
|
|
10
|
+
* **Not a dashboard toggle**, which is what the programme originally sketched. The dashboard is a read-only
|
|
11
|
+
* renderer in a separate process that "never affects enforcement" (ADR-0036), and a control there that wrote to
|
|
12
|
+
* settings would be the first thing it ever wrote. Turning an advisor on is an operator decision that belongs in
|
|
13
|
+
* the reviewable file; `/grants` reports what is in force. That is a deliberate departure from the roadmap line.
|
|
14
|
+
*
|
|
15
|
+
* The key is never in this file. It is read from the environment, because a settings file is committed and an API
|
|
16
|
+
* key must not be.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Spelled once, in the kernel's table, so this layer cannot drift from the list `childEnv` refuses to write.
|
|
20
|
+
export { ENV_ADVISOR_KEY as ADVISOR_KEY_ENV } from "../kernel/env-names.ts";
|
|
21
|
+
import { ENV_ADVISOR_KEY } from "../kernel/env-names.ts";
|
|
22
|
+
|
|
23
|
+
export interface AdvisorSettings {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
/** The only decider this release knows besides the null one. */
|
|
26
|
+
decider: "none" | "jev";
|
|
27
|
+
/** Overrides the adapter's pinned model id; absent means the adapter's own default. */
|
|
28
|
+
model?: string;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
/** Why an advisor is off when the settings asked for one on — reported, never silently applied. */
|
|
31
|
+
refusal?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, decider: "none" });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read the `advisor` block of a project settings file. Absent is off; malformed is off WITH a reason.
|
|
38
|
+
*
|
|
39
|
+
* The reason matters more than it looks: an operator who wrote `"enabeld": true` and got silence would conclude the
|
|
40
|
+
* feature does not work, and an operator who wrote it and got an advisor anyway would have a third party reading
|
|
41
|
+
* their session without having successfully asked for it. Both are worse than a sentence naming the field.
|
|
42
|
+
*/
|
|
43
|
+
export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = process.env): AdvisorSettings {
|
|
44
|
+
if (raw === undefined || raw === null) return ADVISOR_OFF;
|
|
45
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
46
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor must be an object; no advisor is enabled" };
|
|
47
|
+
const block = raw as Record<string, unknown>;
|
|
48
|
+
const unknownKeys = Object.keys(block).filter((key) => !["enabled", "decider", "model", "timeoutMs"].includes(key));
|
|
49
|
+
if (unknownKeys.length > 0)
|
|
50
|
+
return { ...ADVISOR_OFF, refusal: `settings.advisor has unknown field(s) ${unknownKeys.join(", ")}` };
|
|
51
|
+
if (block.enabled !== true) return ADVISOR_OFF;
|
|
52
|
+
if (block.decider !== "jev")
|
|
53
|
+
return { ...ADVISOR_OFF, refusal: `settings.advisor.decider must be "jev"; no advisor is enabled` };
|
|
54
|
+
if (block.model !== undefined && typeof block.model !== "string")
|
|
55
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor.model must be a string" };
|
|
56
|
+
if (
|
|
57
|
+
block.timeoutMs !== undefined &&
|
|
58
|
+
(!Number.isInteger(block.timeoutMs) || (block.timeoutMs as number) < 1 || (block.timeoutMs as number) > 30_000)
|
|
59
|
+
)
|
|
60
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor.timeoutMs must be an integer between 1 and 30000" };
|
|
61
|
+
const key = env[ENV_ADVISOR_KEY]?.trim();
|
|
62
|
+
if (!key)
|
|
63
|
+
return {
|
|
64
|
+
...ADVISOR_OFF,
|
|
65
|
+
refusal: `settings.advisor is enabled but ${ENV_ADVISOR_KEY} is not set; no advisor is enabled`,
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
enabled: true,
|
|
69
|
+
decider: "jev",
|
|
70
|
+
...(typeof block.model === "string" ? { model: block.model } : {}),
|
|
71
|
+
...(block.timeoutMs !== undefined ? { timeoutMs: block.timeoutMs as number } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -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
|
}
|
package/src/governance/ledger.ts
CHANGED
|
@@ -141,6 +141,19 @@ export interface GrantRecord extends LedgerEventBase {
|
|
|
141
141
|
* it. Absent for a `tools:`-style delegation, which has no definition.
|
|
142
142
|
*/
|
|
143
143
|
definitionDigest?: DefinitionDigest;
|
|
144
|
+
/**
|
|
145
|
+
* The context handoff this child RECEIVED (ADR-0078): the mode whose capability survived, and what crossed.
|
|
146
|
+
* Absent means nothing crossed, which is the default and the overwhelming majority of records.
|
|
147
|
+
*/
|
|
148
|
+
handoff?: {
|
|
149
|
+
mode: string;
|
|
150
|
+
sections: number;
|
|
151
|
+
bytes: number;
|
|
152
|
+
truncatedBytes: number;
|
|
153
|
+
keptTurns?: number;
|
|
154
|
+
droppedTurns?: number;
|
|
155
|
+
rule?: string;
|
|
156
|
+
};
|
|
144
157
|
/**
|
|
145
158
|
* WHERE this child ran — ADR-0031.
|
|
146
159
|
*
|
|
@@ -214,6 +227,7 @@ export function buildRecord(args: {
|
|
|
214
227
|
humanDenied?: boolean;
|
|
215
228
|
gateOutcome?: PromptOutcomeKind;
|
|
216
229
|
definitionDigest?: DefinitionDigest;
|
|
230
|
+
handoff?: GrantRecord["handoff"];
|
|
217
231
|
/** Where the child ran (ADR-0031). Required: the probe's answer survives nowhere else. */
|
|
218
232
|
executor: ExecutorKind;
|
|
219
233
|
/** The logical child whose output composed this task (ADR-0033). */
|
|
@@ -261,6 +275,7 @@ export function buildRecord(args: {
|
|
|
261
275
|
...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
|
|
262
276
|
...(args.taskFromExecutionId ? { taskFromExecutionId: args.taskFromExecutionId } : {}),
|
|
263
277
|
...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
|
|
278
|
+
...(args.handoff ? { handoff: { ...args.handoff } } : {}),
|
|
264
279
|
...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
|
|
265
280
|
...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
|
|
266
281
|
requested: args.requested,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
16
16
|
import { WILDCARD } from "./pi-tools.ts";
|
|
17
|
+
import { isContextCapability } from "./context-handoff.ts";
|
|
17
18
|
import { GovernanceRefusal, refusal } from "./refusals.ts";
|
|
18
19
|
|
|
19
20
|
/** The capability that authorises spawning a definition (ADR-0017). `tool:*` satisfies any of them. */
|
|
@@ -61,7 +62,7 @@ export const DELEGATE_CAPABILITY: Capability = "tool:delegate";
|
|
|
61
62
|
*
|
|
62
63
|
* The README's grammar section is the prose statement of the same list and is kept in step with it.
|
|
63
64
|
*/
|
|
64
|
-
export const CAPABILITY_NAMESPACE_PREFIXES = ["tool:", "ext:", "skill:", "agent:", "workspace:"] as const;
|
|
65
|
+
export const CAPABILITY_NAMESPACE_PREFIXES = ["tool:", "ext:", "skill:", "agent:", "workspace:", "context:"] as const;
|
|
65
66
|
|
|
66
67
|
/** Accept `read` or `tool:read` or `ext:pkg/tool` and normalise to a capability id. */
|
|
67
68
|
export function normaliseCapability(raw: string): Capability {
|
|
@@ -184,6 +185,9 @@ export function isSafeCapability(id: Capability): boolean {
|
|
|
184
185
|
if (id.startsWith("workspace:")) return isSafeWorkspaceId(id.slice("workspace:".length));
|
|
185
186
|
return (
|
|
186
187
|
new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
|
|
188
|
+
// ADR-0078. The tail is a closed vocabulary rather than a name, so the grammar names it exactly: an id like
|
|
189
|
+
// `context:everything` is a refusal at the boundary that GENERATES grants, not an unknown capability later.
|
|
190
|
+
(id.startsWith("context:") && isContextCapability(id)) ||
|
|
187
191
|
new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
|
|
188
192
|
);
|
|
189
193
|
}
|
package/src/kernel/catalog.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
|
|
|
26
26
|
import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
|
|
27
27
|
import { loadWorkspaceRegistry, type WorkspaceRegistryFile } from "./workspace.ts";
|
|
28
28
|
import { CAPABILITY_NAMESPACE_PREFIXES, isSafeWorkspaceId } from "./capabilities.ts";
|
|
29
|
+
import { isContextCapability } from "./context-handoff.ts";
|
|
29
30
|
import { piProjectDir } from "./project-paths.ts";
|
|
30
31
|
|
|
31
32
|
export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType" | "workspace";
|
|
@@ -195,10 +196,15 @@ export function unknownCapabilities(requested: Capability[], catalog: Catalog):
|
|
|
195
196
|
// `WORKSPACE_WILDCARD` is listed with the other two because it is GRAMMAR, and `isSafeCapability` refuses
|
|
196
197
|
// wildcards by design — so folding it into the namespace test below un-exempts it. Caught by the tests for
|
|
197
198
|
// the previous two fixes, which is the checklist paying for itself.
|
|
199
|
+
// `context:` is exempt for the workspace reason one step further on: its vocabulary is CLOSED, so
|
|
200
|
+
// `isContextCapability` is the authority and a catalog entry could only restate it less precisely. A malformed
|
|
201
|
+
// `context:everything` is not exempted, so it still reaches the operator as an unknown capability rather than
|
|
202
|
+
// reaching a child's grant as authority over nothing (ADR-0078).
|
|
198
203
|
const exempt = (c: Capability) =>
|
|
199
204
|
c === WILDCARD ||
|
|
200
205
|
c === AGENT_WILDCARD ||
|
|
201
206
|
c === WORKSPACE_WILDCARD ||
|
|
207
|
+
isContextCapability(c) ||
|
|
202
208
|
(c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)));
|
|
203
209
|
return requested.filter((c) => !exempt(c) && !catalog.has(c)).sort();
|
|
204
210
|
}
|
package/src/kernel/chain.ts
CHANGED
|
@@ -158,6 +158,8 @@ export interface ChainStep {
|
|
|
158
158
|
model?: string;
|
|
159
159
|
/** Requested Pi thinking level; validated by the same schema as delegate/delegate_all. */
|
|
160
160
|
thinking?: string;
|
|
161
|
+
/** ADR-0078: what of the parent's session this step receives. Capped by the step's own definition, like a tool. */
|
|
162
|
+
context?: unknown;
|
|
161
163
|
correlation?: import("./correlation.ts").CorrelationMetadata;
|
|
162
164
|
workspace?: { workspace_id: string; access: import("./workspace.ts").WorkspaceAccess };
|
|
163
165
|
}
|