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,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
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
settingsIgnoredByGit,
|
|
28
28
|
} from "./governance/init.ts";
|
|
29
29
|
import { registeredWorkspaceIds } from "./kernel/workspace.ts";
|
|
30
|
+
import { explainDoubledNamespace } from "./kernel/catalog.ts";
|
|
31
|
+
import type { Capability } from "./kernel/resolve.ts";
|
|
30
32
|
import {
|
|
31
33
|
discoverSkillPackages,
|
|
32
34
|
skillPackageRoots,
|
|
@@ -201,7 +203,8 @@ async function init(cwd: string, force: boolean): Promise<number> {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
/** Say what was refused and what the fix is. Each reason has a different one. */
|
|
204
|
-
|
|
206
|
+
/** Exported so the message an operator actually reads can be tested; `init` composes it, nothing else calls it. */
|
|
207
|
+
export function reportRefusal(pkg: SkillPackage, refusal: RefusedSkill): string {
|
|
205
208
|
const head = `REFUSED ${pkg.name}: ${JSON.stringify(refusal.subject)}`;
|
|
206
209
|
switch (refusal.reason) {
|
|
207
210
|
case "unsafe-name":
|
|
@@ -209,12 +212,19 @@ function reportRefusal(pkg: SkillPackage, refusal: RefusedSkill): string {
|
|
|
209
212
|
`${head} cannot be governed — a definition name becomes a capability id in a comma-separated ` +
|
|
210
213
|
`grant, a line in a file you source, and a path. Names must match [A-Za-z0-9][A-Za-z0-9._-]*.`
|
|
211
214
|
);
|
|
212
|
-
case "unsafe-capability":
|
|
215
|
+
case "unsafe-capability": {
|
|
216
|
+
// The one shape of unsafe id that has a known cause worth naming: an `allowed-tools` entry written with a
|
|
217
|
+
// capitalised namespace, which the bare-entry path prefixes a second time. `isSafeCapability` rejects the
|
|
218
|
+
// extra colon, so this refusal — not the spawn refusal and not `planInit`'s cautions — is where a doubled
|
|
219
|
+
// id actually reaches an operator. Measured: a package declaring `Tool:Read` never reaches `planInit`.
|
|
220
|
+
const doubled = refusal.detail.map((id) => explainDoubledNamespace(id as Capability)).filter(Boolean);
|
|
213
221
|
return (
|
|
214
222
|
`${head} declares ${refusal.detail.join(", ")}, which cannot be written into a grant file — a ` +
|
|
215
223
|
`capability id is tool:/skill:/agent:<name> or ext:<pkg>/<tool>. A quote or a separator here ` +
|
|
216
|
-
`would end up in a file you are told to \`source\`.`
|
|
224
|
+
`would end up in a file you are told to \`source\`.` +
|
|
225
|
+
(doubled.length ? `\n ${doubled.join("\n ")}` : "")
|
|
217
226
|
);
|
|
227
|
+
}
|
|
218
228
|
case "wildcard":
|
|
219
229
|
return (
|
|
220
230
|
`${head} declares ${refusal.detail.join(", ")} — that is root authority, not a description of what ` +
|
|
@@ -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 {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* herdr executor would mean the session imported the thing it was deciding whether to use.
|
|
8
8
|
*
|
|
9
9
|
* Every rule here is tested against an injected `exec`, so the suite stays fast, pi-free and herdr-free. The
|
|
10
|
-
* facts the fakes reproduce were measured against real herdr 0.7.5 (`
|
|
10
|
+
* facts the fakes reproduce were measured against real herdr 0.7.5 (probe `g16-herdr`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execFile } from "node:child_process";
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* *observing* an agent, `run-herdr.ts` is about *starting and cleaning up after* one. Nothing here creates or
|
|
7
7
|
* destroys anything.
|
|
8
8
|
*
|
|
9
|
-
* The two facts it is built on were measured against real herdr 0.7.5 (`
|
|
9
|
+
* The two facts it is built on were measured against real herdr 0.7.5 (probe `g16-herdr`) and both are
|
|
10
10
|
* counter-intuitive enough to be worth the module comment: `agent wait --until idle` matches the state the
|
|
11
11
|
* agent was **already** in, and `agent read` is the one command that does **not** return a JSON envelope.
|
|
12
12
|
*/
|
|
@@ -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
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `agent_settled` (async) and at process `exit` (sync backstop).
|
|
8
8
|
*
|
|
9
9
|
* The gap that remains is the one this module was created for. A pi session **killed outright** runs no `exit`
|
|
10
|
-
* handler, so it leaves one pane per tracked child, and `
|
|
10
|
+
* handler, so it leaves one pane per tracked child, and probe `g16-herdr` records that an orphaned pane is
|
|
11
11
|
* not trivially closable afterwards. R-62 was re-rated L×L → M×L when ADR-0031 made panes the default path.
|
|
12
12
|
*
|
|
13
13
|
* **Registered on `exit` only, deliberately — not on SIGINT or SIGTERM.** That is the part worth reading,
|
|
@@ -203,7 +203,7 @@ export function reapOpenPanes(syncExec: (args: string[]) => void = defaultSyncEx
|
|
|
203
203
|
async function closePane(exec: HerdrExec, pane: OpenPane): Promise<boolean> {
|
|
204
204
|
// **No `agent stop`.** It is not a herdr command — measured against 0.7.5, where it prints the usage banner
|
|
205
205
|
// and exits 0, which `defaultExec` reports as success. Two call sites here and one in `run-herdr.ts` issued it
|
|
206
|
-
// for nothing, and `
|
|
206
|
+
// for nothing, and probe `g16-herdr` asserted it worked from a block that was never run. Closing the tab
|
|
207
207
|
// is the only kill herdr offers, and it does kill the child.
|
|
208
208
|
const reply = await exec(["tab", "close", pane.tab]).catch(() => undefined);
|
|
209
209
|
const closed = reply !== undefined && !parseReply(reply).error;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* **Why go through herdr's CLI rather than the third-party `pi-herdr` extension.** That extension exposes
|
|
9
9
|
* `agentArgs` and `env` as MODEL-facing tool parameters (R-30), which hands a model an argv array and the
|
|
10
10
|
* environment variable the grant travels on. Here the model chooses a definition and a task; this package
|
|
11
|
-
* builds the argv. Measured facts this relies on (`
|
|
11
|
+
* builds the argv. Measured facts this relies on (probe `g16-herdr`):
|
|
12
12
|
*
|
|
13
13
|
* - `herdr agent start … -- <args>` delivers argv **verbatim**, echoed back in the reply.
|
|
14
14
|
* - `--tools` is enforced inside a pane exactly as it is for a direct spawn; `--no-tools` yields none.
|
|
@@ -76,7 +76,7 @@ export interface HerdrRunRequest {
|
|
|
76
76
|
* The task, delivered with `herdr agent prompt` rather than as an argv element.
|
|
77
77
|
*
|
|
78
78
|
* This is strictly safer than the direct-spawn path, which has to defend a model-authored string from
|
|
79
|
-
* pi's argv parser by prefixing a space (`neutralisePrompt`, `
|
|
79
|
+
* pi's argv parser by prefixing a space (`neutralisePrompt`, probe `g1-argv`). Here the task never
|
|
80
80
|
* reaches argv at all, so there is no parser in front of it.
|
|
81
81
|
*/
|
|
82
82
|
prompt: string;
|
|
@@ -97,7 +97,7 @@ export interface HerdrRunRequest {
|
|
|
97
97
|
* Leave the pane open after the run so a human can read or resume it.
|
|
98
98
|
*
|
|
99
99
|
* Default **false**: a fan-out that leaks a pane per child fills the operator's workspace, and
|
|
100
|
-
* `
|
|
100
|
+
* probe `g16-herdr` records that panes are not trivially closable once orphaned.
|
|
101
101
|
*/
|
|
102
102
|
keepPane?: boolean;
|
|
103
103
|
exec?: HerdrExec;
|
|
@@ -289,7 +289,7 @@ export async function runHerdrPane(request: HerdrRunRequest): Promise<ChildRunRe
|
|
|
289
289
|
* running descendant."* **`herdr agent stop` does not exist.** Measured against herdr 0.7.5: the `agent`
|
|
290
290
|
* subcommands are `list get read send-keys prompt rename focus wait attach start explain`, and `agent stop`
|
|
291
291
|
* prints the usage banner and exits 0 — which `defaultExec` reports as a success, so nothing ever noticed.
|
|
292
|
-
* `
|
|
292
|
+
* probe `g16-herdr` asserted it worked, in a *How to rerun* block that was never run.
|
|
293
293
|
*
|
|
294
294
|
* So there is exactly one kill available: closing the tab. That forces the distinction below.
|
|
295
295
|
*
|
|
@@ -53,7 +53,7 @@ export interface LedgerReport {
|
|
|
53
53
|
* **Added because the field was written and never read, which is R-51's shape exactly.** R-51 was
|
|
54
54
|
* `definitionDigest`: recorded from the start, absent from every report, so the questions ADR-0018 advertised
|
|
55
55
|
* needed hand-written `jq`. `executor` arrived the same way — `src/governance/ledger.ts` justifies making it *required*
|
|
56
|
-
* with "reading it back is the only reason it exists", and nothing read it back.
|
|
56
|
+
* with "reading it back is the only reason it exists", and nothing read it back. The README claims the
|
|
57
57
|
* executor is "announced three times… per child in the ledger"; without this the third announcement was to
|
|
58
58
|
* `jq` only.
|
|
59
59
|
*
|
package/src/governance/ledger.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Append-only grant ledger.
|
|
3
3
|
*
|
|
4
4
|
* Exists because pi-fabric's persisted execution trace records `args: {}` — it captures *that* a child
|
|
5
|
-
* ran, not *what it was authorised to do* (
|
|
5
|
+
* ran, not *what it was authorised to do* (probe `pi-fabric-eval` probe 5). Without this record
|
|
6
6
|
* you cannot answer "what was this sub-agent permitted to do?" after the fact, which is the whole
|
|
7
7
|
* point of a governance layer.
|
|
8
8
|
*
|
|
@@ -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,
|
|
@@ -145,7 +145,7 @@ export async function acquireWorkspaceLease(input: {
|
|
|
145
145
|
* the lock file descriptor and holds the lock in its own right — killing only `flock` leaves the
|
|
146
146
|
* lock HELD by an orphan, and every later acquisition then reports WORKSPACE_WRITE_CONFLICT, which
|
|
147
147
|
* is the one message an operator would use to conclude another agent is writing. Measured in
|
|
148
|
-
* `
|
|
148
|
+
* probe `g35-flock-fd-inheritance` (R-99).
|
|
149
149
|
*/
|
|
150
150
|
const hardKill = () => {
|
|
151
151
|
// The GROUP, so this works on the readiness-timeout path too — there `helperPid` is usually still
|
|
@@ -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. */
|
|
@@ -59,9 +60,9 @@ export const DELEGATE_CAPABILITY: Capability = "tool:delegate";
|
|
|
59
60
|
* Consolidating the sites is a separate change, and a count nobody re-derives is the defect this list exists
|
|
60
61
|
* to prevent.
|
|
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 {
|
|
@@ -80,7 +81,7 @@ export function workspaceCapability(workspaceId: string): Capability {
|
|
|
80
81
|
*
|
|
81
82
|
* Mirrors `maySpawnDefinition` deliberately — same shape, same wildcard handling, same reason. Routing was
|
|
82
83
|
* the one governance dimension that did not attenuate (R-131, measured in
|
|
83
|
-
* `
|
|
84
|
+
* probe `g36-workspace-attenuation`): the registry inherited into every child and nothing checked the
|
|
84
85
|
* caller's authority, so a child routed to `staging` could route its grandchild to `prod`.
|
|
85
86
|
*
|
|
86
87
|
* `tool:*` satisfies it because governance is opt-in — an ungoverned session holds the wildcard and must
|
|
@@ -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
|
@@ -25,7 +25,8 @@ import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
|
|
|
25
25
|
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
|
-
import { isSafeWorkspaceId } from "./capabilities.ts";
|
|
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
|
}
|
|
@@ -295,6 +301,66 @@ export function suggestForUnknown(unknown: Capability, catalog: Catalog): Capabi
|
|
|
295
301
|
return bestDistance <= limit ? best : null;
|
|
296
302
|
}
|
|
297
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Why an id carries two namespaces, said as the mistake that produced it.
|
|
306
|
+
*
|
|
307
|
+
* `ceilingForDefinition` prefixes a BARE entry with `tool:` and recognises an explicit namespace by its literal
|
|
308
|
+
* lower-case prefix, so `allowed-tools: Read` and `allowed-tools: tool:read` are both right. The capitalised
|
|
309
|
+
* spelling the same field invites is not: the frontmatter is copied from Claude Code, where the tool names are
|
|
310
|
+
* `Read` and `Grep`, so an author who also reaches for the namespace writes `Tool:Read` — which misses the prefix
|
|
311
|
+
* test, is lower-cased with the rest of the bare entry, and arrives as `tool:tool:read`. Every namespace has this
|
|
312
|
+
* shape (`Workspace:prod` becomes `tool:workspace:prod`), which is why the prefix is read from the one shared list
|
|
313
|
+
* rather than a second spelling of it.
|
|
314
|
+
*
|
|
315
|
+
* The messages that followed were loud and useless. A spawn refusal said "not present in this session's catalog
|
|
316
|
+
* (typo, or an uninstalled package?)" about an id nobody typed, and `suggestForUnknown` cannot rescue it either:
|
|
317
|
+
* `tool:read` is five edits from `read`, well past a threshold that never exceeds two. `pi-daddy init` refuses the
|
|
318
|
+
* whole definition earlier still, because `isSafeCapability` rejects the extra colon, and named the mangled id too.
|
|
319
|
+
*
|
|
320
|
+
* It names the mistake and does not repair it, for `ceilingForDefinition`'s own reason: a capability this package
|
|
321
|
+
* inferred rather than read is a grant nobody wrote. The author edits the file.
|
|
322
|
+
*
|
|
323
|
+
* **What the suggestion cannot recover.** The doubling happens on the bare-entry path, which lower-cases the whole
|
|
324
|
+
* entry before this function ever sees it, so `Workspace:Prod` and `Workspace:prod` both arrive as
|
|
325
|
+
* `tool:workspace:prod`. For `tool:` that loses nothing, since a pi tool name is lower-case anyway. For the other
|
|
326
|
+
* namespaces the identifier's own capitalisation is already gone, so the suggestion says so rather than implying
|
|
327
|
+
* that copying it verbatim must work. Nor is "or the bare name" offered outside `tool:`: a bare `prod` becomes
|
|
328
|
+
* `tool:prod`, which is a second unknown capability rather than a fix.
|
|
329
|
+
*/
|
|
330
|
+
export function explainDoubledNamespace(unknown: Capability): string | null {
|
|
331
|
+
const intended = undoubleNamespace(unknown);
|
|
332
|
+
if (intended === unknown) return null;
|
|
333
|
+
const prefix = CAPABILITY_NAMESPACE_PREFIXES.find((p) => unknown.slice("tool:".length).toLowerCase().startsWith(p))!;
|
|
334
|
+
const tail =
|
|
335
|
+
prefix === "tool:"
|
|
336
|
+
? ` (or the bare name \`${intended.slice("tool:".length)}\`)`
|
|
337
|
+
: `, whose own capitalisation was folded when the bare entry was read, so restore it if that id has any`;
|
|
338
|
+
return (
|
|
339
|
+
`${unknown} is prefixed twice: \`allowed-tools\` adds \`tool:\` to a bare entry and recognises an ` +
|
|
340
|
+
`explicit \`${prefix}\` prefix only in lower case, so an entry already carrying a namespace was given ` +
|
|
341
|
+
`another — write \`${intended}\`${tail} in the definition`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The entry the author meant, with every extra `tool:` removed.
|
|
347
|
+
*
|
|
348
|
+
* Recursive, because `tool:tool:tool:read` is one mistake made twice and advising `tool:tool:read` would hand back
|
|
349
|
+
* an id with the same defect. The namespace is matched case-insensitively and re-emitted in lower case, so
|
|
350
|
+
* `tool:Tool:Read` — reachable on the model-chosen `tools:` path, which does not case-fold — suggests `tool:read`
|
|
351
|
+
* rather than the broken spelling it arrived in. A `tool:` name is lower-cased because pi's tool names are; any
|
|
352
|
+
* other identifier is left as it arrived, since its case may be significant and is not this function's to change.
|
|
353
|
+
*/
|
|
354
|
+
function undoubleNamespace(id: Capability): Capability {
|
|
355
|
+
if (!id.startsWith("tool:")) return id;
|
|
356
|
+
const inner = id.slice("tool:".length);
|
|
357
|
+
const prefix = CAPABILITY_NAMESPACE_PREFIXES.find((p) => inner.toLowerCase().startsWith(p));
|
|
358
|
+
// A bare `tool:tool:` names nothing to suggest, so it stays an ordinary unknown capability.
|
|
359
|
+
if (!prefix || inner.length === prefix.length) return id;
|
|
360
|
+
const rest = inner.slice(prefix.length);
|
|
361
|
+
return undoubleNamespace((prefix === "tool:" ? `tool:${rest.toLowerCase()}` : `${prefix}${rest}`) as Capability);
|
|
362
|
+
}
|
|
363
|
+
|
|
298
364
|
/**
|
|
299
365
|
* Skill name -> absolute path, for `planSpawn`'s `--skill` flags (R-32).
|
|
300
366
|
*
|
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
|
}
|