pi-daddy 0.34.0 → 0.36.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 +81 -0
- package/dist/advisors/decider.d.ts +7 -3
- package/dist/advisors/decider.d.ts.map +1 -1
- package/dist/advisors/decider.js.map +1 -1
- package/dist/advisors/settings.d.ts +16 -7
- package/dist/advisors/settings.d.ts.map +1 -1
- package/dist/advisors/settings.js +60 -22
- package/dist/advisors/settings.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/kernel/bounded-read.d.ts +57 -0
- package/dist/kernel/bounded-read.d.ts.map +1 -0
- package/dist/kernel/bounded-read.js +100 -0
- package/dist/kernel/bounded-read.js.map +1 -0
- package/dist/kernel/catalog.d.ts +12 -1
- package/dist/kernel/catalog.d.ts.map +1 -1
- package/dist/kernel/catalog.js +12 -5
- package/dist/kernel/catalog.js.map +1 -1
- package/dist/kernel/definitions.d.ts +19 -1
- package/dist/kernel/definitions.d.ts.map +1 -1
- package/dist/kernel/definitions.js +34 -9
- package/dist/kernel/definitions.js.map +1 -1
- package/dist/kernel/delegate-types.d.ts +11 -1
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +3 -1
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +14 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +16 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +1 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +8 -3
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/skill-packages.d.ts.map +1 -1
- package/dist/kernel/skill-packages.js +20 -9
- package/dist/kernel/skill-packages.js.map +1 -1
- package/dist/kernel/workspace.d.ts +7 -1
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +39 -81
- package/dist/kernel/workspace.js.map +1 -1
- package/extensions/context-staging.ts +15 -7
- package/extensions/effort-advice.ts +99 -0
- package/extensions/grants-command.ts +17 -0
- package/extensions/grants.ts +4 -0
- package/extensions/init-command.ts +7 -1
- package/extensions/pruning-advice.ts +88 -0
- package/extensions/run-delegation.ts +61 -3
- package/extensions/session-report.ts +15 -0
- package/extensions/session.ts +61 -5
- package/package.json +1 -1
- package/src/advisors/decider.ts +7 -3
- package/src/advisors/settings.ts +61 -21
- package/src/cli.ts +9 -1
- package/src/kernel/bounded-read.ts +125 -0
- package/src/kernel/catalog.ts +42 -21
- package/src/kernel/definitions.ts +38 -8
- package/src/kernel/delegate-types.ts +12 -1
- package/src/kernel/delegate.ts +3 -1
- package/src/kernel/env-names.ts +16 -0
- package/src/kernel/propagation.ts +9 -2
- package/src/kernel/skill-packages.ts +26 -9
- package/src/kernel/workspace.ts +43 -109
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { nativeDelegationContext } from "./delegation-native.ts";
|
|
15
|
+
import { adviseEffort } from "./effort-advice.ts";
|
|
16
|
+
import { advisePruning } from "./pruning-advice.ts";
|
|
15
17
|
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/kernel/approval.ts";
|
|
16
18
|
import { planDelegation } from "../src/kernel/delegate.ts";
|
|
17
19
|
import {
|
|
@@ -249,6 +251,8 @@ export async function runOneDelegation(
|
|
|
249
251
|
agent: spec.agent,
|
|
250
252
|
tools: spec.tools,
|
|
251
253
|
model: spec.model ?? defaultModel,
|
|
254
|
+
// Filled below, once the refusals that doom a delegation are known: asking first shipped the task text for a
|
|
255
|
+
// child that never starts, which is the ordering this module already fixed for the approval dialog.
|
|
252
256
|
thinking: spec.thinking,
|
|
253
257
|
context: spec.context,
|
|
254
258
|
correlation: spec.workspace
|
|
@@ -289,6 +293,28 @@ export async function runOneDelegation(
|
|
|
289
293
|
Boolean(executorRefusal || modelRefusal),
|
|
290
294
|
);
|
|
291
295
|
executorRefusal ||= nativeRefusal;
|
|
296
|
+
|
|
297
|
+
// ADR-0077's first decision point, after the refusal checks for the reason above. Fills a blank from the levels
|
|
298
|
+
// the CHILD's model reports; never overrules a caller, and yields today's behaviour whenever there is no answer.
|
|
299
|
+
if (!executorRefusal && !modelRefusal)
|
|
300
|
+
request.thinking = await adviseEffort({
|
|
301
|
+
session,
|
|
302
|
+
requested: spec.thinking,
|
|
303
|
+
model: spec.model ?? defaultModel,
|
|
304
|
+
registry: ctx.modelRegistry,
|
|
305
|
+
task: spec.task,
|
|
306
|
+
agent: spec.agent,
|
|
307
|
+
signal,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const planContext = await handoffPlanContext({
|
|
311
|
+
session,
|
|
312
|
+
base: extra,
|
|
313
|
+
task: spec.task,
|
|
314
|
+
blocked: Boolean(executorRefusal || modelRefusal),
|
|
315
|
+
preview: () => planWithApprovals(session, request, extra, null, signal, preApproved).then((r) => r.plan),
|
|
316
|
+
...(signal ? { signal } : {}),
|
|
317
|
+
});
|
|
292
318
|
let preparedWorkspace: PreparedWorkspace | undefined;
|
|
293
319
|
let approvalOutcome: ApprovalOutcome | undefined;
|
|
294
320
|
let plan: ReturnType<typeof planDelegation>;
|
|
@@ -297,7 +323,7 @@ export async function runOneDelegation(
|
|
|
297
323
|
// Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
|
|
298
324
|
// preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
|
|
299
325
|
// no child process.
|
|
300
|
-
const preview = await planWithApprovals(session, request,
|
|
326
|
+
const preview = await planWithApprovals(session, request, planContext, null, signal, preApproved);
|
|
301
327
|
plan = preview.plan;
|
|
302
328
|
if (plan.ok || shouldSeekApproval(plan.result)) {
|
|
303
329
|
try {
|
|
@@ -311,7 +337,7 @@ export async function runOneDelegation(
|
|
|
311
337
|
ledgerPath: session.ledgerPath,
|
|
312
338
|
});
|
|
313
339
|
request.correlation = preparedWorkspace.correlation;
|
|
314
|
-
const gated = await planWithApprovals(session, request,
|
|
340
|
+
const gated = await planWithApprovals(session, request, planContext, ctx, signal, preApproved);
|
|
315
341
|
plan = gated.plan;
|
|
316
342
|
approvalOutcome = gated.approval;
|
|
317
343
|
} catch (error) {
|
|
@@ -326,7 +352,7 @@ export async function runOneDelegation(
|
|
|
326
352
|
const gated = await planWithApprovals(
|
|
327
353
|
session,
|
|
328
354
|
request,
|
|
329
|
-
|
|
355
|
+
planContext,
|
|
330
356
|
executorRefusal || modelRefusal ? null : ctx,
|
|
331
357
|
signal,
|
|
332
358
|
preApproved,
|
|
@@ -409,3 +435,35 @@ export async function runOneDelegation(
|
|
|
409
435
|
onProgress,
|
|
410
436
|
});
|
|
411
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The planner context for one delegation, including a `pruned` handoff narrowed by an advisor (ADR-0077).
|
|
441
|
+
*
|
|
442
|
+
* **Exported and taking its own `preview`, so the ordering is forced by a test rather than by a reviewer.** Three
|
|
443
|
+
* properties live here and each was, at some point in this change's history, true only because somebody had
|
|
444
|
+
* checked it by hand: an advisor is not asked for a delegation that is already refused; it is not asked until a
|
|
445
|
+
* plan says the `pruned` handoff actually survived the ceiling, the grant and the gate; and the ids it returns
|
|
446
|
+
* reach the planner. Reviewers measured all three by mutating the source and finding the suite still green. A
|
|
447
|
+
* function with a seam is the only version of this that a test can hold.
|
|
448
|
+
*/
|
|
449
|
+
export async function handoffPlanContext(input: {
|
|
450
|
+
session: Parameters<typeof advisePruning>[0]["session"];
|
|
451
|
+
base: Record<string, unknown>;
|
|
452
|
+
task: string;
|
|
453
|
+
/** A refusal is already certain, so nothing may be asked. */
|
|
454
|
+
blocked: boolean;
|
|
455
|
+
/** Plans with no human in the loop; its result decides whether an advisor is consulted at all. */
|
|
456
|
+
preview: () => Promise<{ handoff?: { mode: string } }>;
|
|
457
|
+
signal?: AbortSignal;
|
|
458
|
+
}): Promise<Record<string, unknown>> {
|
|
459
|
+
if (input.blocked) return { ...input.base };
|
|
460
|
+
const plan = await input.preview();
|
|
461
|
+
if (plan.handoff?.mode !== "pruned") return { ...input.base };
|
|
462
|
+
const ids = await advisePruning({
|
|
463
|
+
session: input.session,
|
|
464
|
+
granted: plan.handoff as Parameters<typeof advisePruning>[0]["granted"],
|
|
465
|
+
task: input.task,
|
|
466
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
467
|
+
});
|
|
468
|
+
return ids ? { ...input.base, handoffTurnIds: ids } : { ...input.base };
|
|
469
|
+
}
|
|
@@ -57,6 +57,21 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
|
|
|
57
57
|
"warning",
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
|
+
// **Rule 8's loud half for the two reads that discover what exists.** Both of these used to fail soft and
|
|
61
|
+
// say nothing, and review established that session start — not `/grants` — is where every other such
|
|
62
|
+
// notice lives (a malformed bound above, ledger corruption and an executor refusal below). A reason an
|
|
63
|
+
// operator has to ask for is a reason most operators never see.
|
|
64
|
+
//
|
|
65
|
+
// The definitions one matters more than it looks: the 1 MiB bound is NEW behaviour, so a definition that
|
|
66
|
+
// loaded yesterday can be absent today, and without this line the only symptom is `unknown agent "x"` at
|
|
67
|
+
// the moment somebody tries to delegate to it.
|
|
68
|
+
for (const reason of session.definitionSkips)
|
|
69
|
+
ctx.ui.notify(`grants: a definition was not loaded — ${reason}`, "warning");
|
|
70
|
+
if (session.catalog.registryRefusal)
|
|
71
|
+
ctx.ui.notify(
|
|
72
|
+
`grants: workspace registry unreadable, no workspace is routable this session — ${session.catalog.registryRefusal}`,
|
|
73
|
+
"warning",
|
|
74
|
+
);
|
|
60
75
|
// ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
|
|
61
76
|
// import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
|
|
62
77
|
// operator whose approvals silently stopped applying deserves to know why.
|
package/extensions/session.ts
CHANGED
|
@@ -49,8 +49,10 @@ import { republishable } from "./approvals.ts";
|
|
|
49
49
|
import { storedGrantSessionState } from "./stored-grant-session.ts";
|
|
50
50
|
import { nativeSessionRootFromEnv, type NativeSessionHost } from "../src/executors/native-session-target.ts";
|
|
51
51
|
import { createHandoffStager, type ParentSession } from "./context-staging.ts";
|
|
52
|
+
import { createAdvisorSession, type AdvisorSession } from "./advisor-session.ts";
|
|
52
53
|
import { join } from "node:path";
|
|
53
|
-
import {
|
|
54
|
+
import { readFileSync, statSync } from "node:fs";
|
|
55
|
+
import { agentDir, projectSettingsPath } from "../src/kernel/project-paths.ts";
|
|
54
56
|
import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts";
|
|
55
57
|
import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
|
|
56
58
|
import { reconcileSessionEnvironment } from "./session-environment.ts";
|
|
@@ -87,7 +89,7 @@ import {
|
|
|
87
89
|
ENV_ACTIVITY_ROOT,
|
|
88
90
|
ENV_ACTIVITY_TASK,
|
|
89
91
|
} from "../src/products/activity-timeline.ts";
|
|
90
|
-
import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
|
|
92
|
+
import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR } from "../src/kernel/env-names.ts";
|
|
91
93
|
export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
|
|
92
94
|
import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts";
|
|
93
95
|
|
|
@@ -157,6 +159,8 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
157
159
|
* context handoff (ADR-0078): its file path for `fork`, its message turns for `pruned`.
|
|
158
160
|
*/
|
|
159
161
|
parentSession?: ParentSession;
|
|
162
|
+
/** ADR-0077: the session's advisor, off unless the environment enables one. Never consulted for authority. */
|
|
163
|
+
advisorSession: AdvisorSession;
|
|
160
164
|
/** Root identity keyed to ctx.sessionManager once session_start supplies it. */
|
|
161
165
|
reloadLifecycle: ReloadLifecycle;
|
|
162
166
|
/** Approval keys approved for this session. In memory only — this dies with the process. */
|
|
@@ -182,6 +186,16 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
182
186
|
observedTools: string[] | null;
|
|
183
187
|
/** ADR-0016: `SKILL.md` definitions, keyed by name. The format this package spawns from now. */
|
|
184
188
|
definitions: Map<string, SkillDefinition>;
|
|
189
|
+
/**
|
|
190
|
+
* Definitions discovery dropped, and why — reported at session start (ADR-0076, rule 8).
|
|
191
|
+
*
|
|
192
|
+
* Review found the bound shipped without this: `loadDefinitions` grew a `skipped` callback and NO
|
|
193
|
+
* production caller passed one, so an oversized or unreadable `SKILL.md` still vanished with nothing
|
|
194
|
+
* anywhere explaining it. That is worse than the `catch { continue }` it replaced, because the bound is
|
|
195
|
+
* new behaviour — a 2 MiB definition used to load. A capability that only a test can observe is not a
|
|
196
|
+
* capability.
|
|
197
|
+
*/
|
|
198
|
+
definitionSkips: string[];
|
|
185
199
|
catalog: Catalog;
|
|
186
200
|
/**
|
|
187
201
|
* The in-flight catalog build, so `delegate` can wait for it instead of racing it.
|
|
@@ -251,7 +265,9 @@ export interface GrantsSession extends NativeSessionHost {
|
|
|
251
265
|
* about what loading means, so there is one.
|
|
252
266
|
*/
|
|
253
267
|
export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
|
|
254
|
-
|
|
268
|
+
const skips: string[] = [];
|
|
269
|
+
session.definitions = await loadDefinitions(cwd, (_path, reason) => skips.push(reason));
|
|
270
|
+
session.definitionSkips = skips;
|
|
255
271
|
session.catalogReady = buildCatalog({
|
|
256
272
|
cwd,
|
|
257
273
|
observedTools: session.observedTools,
|
|
@@ -296,13 +312,25 @@ export function createGrantsSession(
|
|
|
296
312
|
const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
|
|
297
313
|
const { depth, maxDepth } = bounds;
|
|
298
314
|
const emptyCatalog = makeCatalog([]);
|
|
315
|
+
// ADR-0077. The environment decides whether there is an advisor at all; the project's settings block may only
|
|
316
|
+
// narrow it. The block IS read — the first version passed `undefined` and every narrowing the release advertised
|
|
317
|
+
// was dead code reachable only from tests, which review measured: `enabled: false` turned nothing off.
|
|
318
|
+
//
|
|
319
|
+
// Read from `storeCwd` for `loadStoredGrantStateSync`'s reason: this factory runs before any hook, so `ctx.cwd`
|
|
320
|
+
// does not exist yet. Reading a workspace-writable file here is safe precisely because it can only narrow.
|
|
321
|
+
const advisorSession = createAdvisorSession({
|
|
322
|
+
block: projectAdvisorBlock(storeCwd),
|
|
323
|
+
...(storedLedger ? { ledgerPath: storedLedger } : {}),
|
|
324
|
+
});
|
|
299
325
|
const session: GrantsSession = {
|
|
326
|
+
advisorSession,
|
|
300
327
|
adoptedLegacyEnv,
|
|
301
328
|
governed,
|
|
302
329
|
inherited,
|
|
303
330
|
depth,
|
|
304
331
|
maxDepth,
|
|
305
332
|
malformedBounds: bounds.malformed,
|
|
333
|
+
definitionSkips: [],
|
|
306
334
|
// ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one
|
|
307
335
|
// (no PI_DADDY_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always
|
|
308
336
|
// did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
|
|
@@ -361,12 +389,16 @@ export function createGrantsSession(
|
|
|
361
389
|
observerExtensionPath: session.observerExtensionPath,
|
|
362
390
|
childEnv: activityChildEnv(session.activity),
|
|
363
391
|
// ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
|
|
364
|
-
|
|
392
|
+
// `options` is forwarded, and its absence is why the second decision point was dead in production: a
|
|
393
|
+
// one-parameter arrow is assignable to a two-parameter type, so the ids reached here and were discarded while
|
|
394
|
+
// the advisor had already been asked. Review measured it. `test/pruning-advice.test.ts` now goes through this
|
|
395
|
+
// function rather than calling the stager directly.
|
|
396
|
+
stageHandoff: (granted, options) =>
|
|
365
397
|
createHandoffStager({
|
|
366
398
|
cwd: session.cwd,
|
|
367
399
|
forkRoot: join(agentDir(), "context-forks"),
|
|
368
400
|
...(session.parentSession ? { parentSession: session.parentSession } : {}),
|
|
369
|
-
})(granted),
|
|
401
|
+
})(granted, options),
|
|
370
402
|
catalog: await session.catalogReady,
|
|
371
403
|
// R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
|
|
372
404
|
// Derived from the catalog's own `source`, so it cannot drift from what was discovered.
|
|
@@ -420,3 +452,27 @@ export function createGrantsSession(
|
|
|
420
452
|
};
|
|
421
453
|
return session;
|
|
422
454
|
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The `advisor` block of `.pi/pi-daddy/settings.json`, or undefined.
|
|
458
|
+
*
|
|
459
|
+
* Unreadable, absent or malformed all yield undefined: this file is the reviewable record, not authority, and the
|
|
460
|
+
* only thing it can do to an advisor is turn one off. A parse failure therefore costs nothing worth reporting.
|
|
461
|
+
*/
|
|
462
|
+
function projectAdvisorBlock(cwd: string): unknown {
|
|
463
|
+
// Only when an advisor could exist at all. This runs in every session including every child, before any hook, and
|
|
464
|
+
// a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it.
|
|
465
|
+
if (!process.env[ENV_ADVISOR]?.trim()) return undefined;
|
|
466
|
+
try {
|
|
467
|
+
const path = projectSettingsPath(cwd);
|
|
468
|
+
// Bounded and type-checked first: this is the third unbounded session-start read AGENTS.md warns about, and the
|
|
469
|
+
// only one whose path a governed child holding `tool:write` can replace with a FIFO — which would hang pi
|
|
470
|
+
// before any hook exists to report it.
|
|
471
|
+
const stats = statSync(path);
|
|
472
|
+
if (!stats.isFile() || stats.size > 1024 * 1024) return undefined;
|
|
473
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
474
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>).advisor : undefined;
|
|
475
|
+
} catch {
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/advisors/decider.ts
CHANGED
|
@@ -32,9 +32,13 @@ export type Answer =
|
|
|
32
32
|
|
|
33
33
|
export interface AdviceRequest {
|
|
34
34
|
/**
|
|
35
|
-
* What the advisor is told about the situation
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* What the advisor is told about the situation, composed by the caller.
|
|
36
|
+
*
|
|
37
|
+
* **Sent, never recorded.** The task is never STORED (ADR-0021) and that still holds — `createAdvisor` writes the
|
|
38
|
+
* question keys and the answers and never this object. But an advisor cannot judge a task it cannot see, so a
|
|
39
|
+
* caller that needs one judged does send it, and the operator's consent for that is the advisor being off by
|
|
40
|
+
* default. An earlier draft of this paragraph said the raw task "must not be shipped to a third party either",
|
|
41
|
+
* which the first decision point then did; the rule that survived review is the narrower and true one.
|
|
38
42
|
*/
|
|
39
43
|
state: Readonly<Record<string, unknown>>;
|
|
40
44
|
questions: Readonly<Record<string, Question>>;
|
package/src/advisors/settings.ts
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Whether an advisor is on, and which one (ADR-0077).
|
|
3
3
|
*
|
|
4
|
-
* **Default off, and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* **Default off, and only the environment can turn it on.** An advisor sends a description of the caller's
|
|
5
|
+
* situation to a third party, so enabling one is `PI_DADDY_ADVISOR=jev` plus a key — both outside the workspace,
|
|
6
|
+
* both stripped from every child.
|
|
7
|
+
*
|
|
8
|
+
* 0.34.0 read the enable from `.pi/pi-daddy/settings.json` and that was wrong for the reason `grant-store.ts`
|
|
9
|
+
* states about the same file: it is writable by any child holding `tool:write`, so it is "the reviewable record of
|
|
10
|
+
* the decision, not the thing the enforcer reads". A grant lives outside the workspace precisely so a child cannot
|
|
11
|
+
* widen the next session's ceiling; an advisor switch a child could flip would make the operator's next session
|
|
12
|
+
* ship its own description to a third party, which is the same self-defeating shape. The settings block may still
|
|
13
|
+
* NARROW — a shorter timeout, or `enabled: false` to turn an advisor off for one project — and can never turn one
|
|
14
|
+
* on, choose its model, or lengthen its bound. A model is a destination rather than a narrowing, so `model` in the
|
|
15
|
+
* block is refused with a message naming `PI_DADDY_ADVISOR_MODEL`, and a timeout is clamped to the default rather
|
|
16
|
+
* than trusted. Malformed configuration disables the advisor and says so: a typo must not be a way to enable anything.
|
|
9
17
|
*
|
|
10
18
|
* **Not a dashboard toggle**, which is what the programme originally sketched. The dashboard is a read-only
|
|
11
19
|
* renderer in a separate process that "never affects enforcement" (ADR-0036), and a control there that wrote to
|
|
12
20
|
* settings would be the first thing it ever wrote. Turning an advisor on is an operator decision that belongs in
|
|
13
21
|
* the reviewable file; `/grants` reports what is in force. That is a deliberate departure from the roadmap line.
|
|
14
22
|
*
|
|
15
|
-
* The key is never in
|
|
16
|
-
* key must not be.
|
|
23
|
+
* The key is never in the settings file either, because that file is committed and an API key must not be.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
// Spelled once, in the kernel's table, so this layer cannot drift from the list `childEnv` refuses to write.
|
|
20
27
|
export { ENV_ADVISOR_KEY as ADVISOR_KEY_ENV } from "../kernel/env-names.ts";
|
|
21
|
-
import { ENV_ADVISOR_KEY } from "../kernel/env-names.ts";
|
|
28
|
+
import { ENV_ADVISOR, ENV_ADVISOR_KEY, ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
|
|
29
|
+
import { DEFAULT_ADVICE_TIMEOUT_MS } from "./advisor.ts";
|
|
30
|
+
export { ENV_ADVISOR_MODEL } from "../kernel/env-names.ts";
|
|
31
|
+
export { ENV_ADVISOR } from "../kernel/env-names.ts";
|
|
22
32
|
|
|
23
33
|
export interface AdvisorSettings {
|
|
24
34
|
enabled: boolean;
|
|
@@ -41,23 +51,49 @@ export const ADVISOR_OFF: AdvisorSettings = Object.freeze({ enabled: false, deci
|
|
|
41
51
|
* their session without having successfully asked for it. Both are worse than a sentence naming the field.
|
|
42
52
|
*/
|
|
43
53
|
export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = process.env): AdvisorSettings {
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
// The environment decides WHETHER, before the workspace is consulted at all. A settings file that asks for an
|
|
55
|
+
// advisor nobody enabled is not a configuration error; it is simply a project that would use one if the operator
|
|
56
|
+
// turned it on, so it is reported rather than refused.
|
|
57
|
+
const requested = env[ENV_ADVISOR]?.trim();
|
|
58
|
+
if (!requested) return ADVISOR_OFF;
|
|
59
|
+
if (requested !== "jev")
|
|
60
|
+
return { ...ADVISOR_OFF, refusal: `${ENV_ADVISOR}=${requested} names no advisor this release knows` };
|
|
61
|
+
|
|
62
|
+
const block = raw === undefined || raw === null ? {} : raw;
|
|
63
|
+
if (typeof block !== "object" || Array.isArray(block))
|
|
46
64
|
return { ...ADVISOR_OFF, refusal: "settings.advisor must be an object; no advisor is enabled" };
|
|
47
|
-
const
|
|
48
|
-
|
|
65
|
+
const fields = block as Record<string, unknown>;
|
|
66
|
+
// `model` stays KNOWN so the refusal below can say why it is refused, rather than reporting it as a typo.
|
|
67
|
+
const unknownKeys = Object.keys(fields).filter((key) => !["enabled", "decider", "model", "timeoutMs"].includes(key));
|
|
68
|
+
// Key names are echoed back, and this file is workspace-writable: a key containing an escape sequence or a
|
|
69
|
+
// newline would otherwise forge lines in the `/grants` panel, which is a trust surface. Review measured a forged
|
|
70
|
+
// `grant tool:*` line. Sanitised and truncated before it reaches any renderer.
|
|
49
71
|
if (unknownKeys.length > 0)
|
|
50
|
-
return {
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
return {
|
|
73
|
+
...ADVISOR_OFF,
|
|
74
|
+
refusal: `settings.advisor has unknown field(s) ${unknownKeys
|
|
75
|
+
.map((key) => key.replace(/[^\w.-]/g, "?").slice(0, 40))
|
|
76
|
+
.join(", ")}`,
|
|
77
|
+
};
|
|
78
|
+
// A project may switch it OFF; it may never switch it on, which is why `true` is not read. Anything that is not
|
|
79
|
+
// exactly `true` disables: `"false"`, `0` and `null` used to leave the advisor ON with no word said, which is
|
|
80
|
+
// rule 8 inverted — the one control this file retains failing open.
|
|
81
|
+
if (fields.enabled !== undefined && fields.enabled !== true)
|
|
82
|
+
return { ...ADVISOR_OFF, refusal: "settings.advisor.enabled is not true for this project" };
|
|
83
|
+
if (fields.decider !== undefined && fields.decider !== "jev")
|
|
53
84
|
return { ...ADVISOR_OFF, refusal: `settings.advisor.decider must be "jev"; no advisor is enabled` };
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
// **A model is a DESTINATION, not a narrowing.** Letting this workspace-writable file choose it would let a child
|
|
86
|
+
// holding `tool:write` point the operator's next session at a generative model of its choosing, billed to the
|
|
87
|
+
// operator's key — the same self-defeating shape this release moved the enable switch to close, one step
|
|
88
|
+
// sideways. Review measured it. The model comes from the environment or not at all.
|
|
89
|
+
if ((fields as Record<string, unknown>).model !== undefined)
|
|
90
|
+
return { ...ADVISOR_OFF, refusal: `settings.advisor.model is not a narrowing; set ${ENV_ADVISOR_MODEL} instead` };
|
|
56
91
|
if (
|
|
57
|
-
|
|
58
|
-
(!Number.isInteger(
|
|
92
|
+
fields.timeoutMs !== undefined &&
|
|
93
|
+
(!Number.isInteger(fields.timeoutMs) || (fields.timeoutMs as number) < 1 || (fields.timeoutMs as number) > 30_000)
|
|
59
94
|
)
|
|
60
95
|
return { ...ADVISOR_OFF, refusal: "settings.advisor.timeoutMs must be an integer between 1 and 30000" };
|
|
96
|
+
const model = env[ENV_ADVISOR_MODEL]?.trim();
|
|
61
97
|
const key = env[ENV_ADVISOR_KEY]?.trim();
|
|
62
98
|
if (!key)
|
|
63
99
|
return {
|
|
@@ -67,7 +103,11 @@ export function advisorSettingsFrom(raw: unknown, env: NodeJS.ProcessEnv = proce
|
|
|
67
103
|
return {
|
|
68
104
|
enabled: true,
|
|
69
105
|
decider: "jev",
|
|
70
|
-
...(
|
|
71
|
-
|
|
106
|
+
...(model ? { model } : {}),
|
|
107
|
+
// Clamped, never raised: a longer bound is not a narrowing either, and a child-writable 30s would be a stall on
|
|
108
|
+
// every delegation.
|
|
109
|
+
...(fields.timeoutMs !== undefined
|
|
110
|
+
? { timeoutMs: Math.min(fields.timeoutMs as number, DEFAULT_ADVICE_TIMEOUT_MS) }
|
|
111
|
+
: {}),
|
|
72
112
|
};
|
|
73
113
|
}
|
package/src/cli.ts
CHANGED
|
@@ -150,7 +150,15 @@ async function init(cwd: string, force: boolean): Promise<number> {
|
|
|
150
150
|
|
|
151
151
|
let plan: InitPlan;
|
|
152
152
|
try {
|
|
153
|
-
|
|
153
|
+
// A registry that cannot be read scaffolds no workspace capabilities. That was silent, so the operator
|
|
154
|
+
// saw a plan with none and no way to tell it apart from having registered none (rule 8).
|
|
155
|
+
plan = planInit(
|
|
156
|
+
packages,
|
|
157
|
+
cwd,
|
|
158
|
+
await registeredWorkspaceIds(undefined, (reason) =>
|
|
159
|
+
console.error(`pi-daddy init: workspace registry unreadable, scaffolding none — ${reason}`),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
154
162
|
} catch (error) {
|
|
155
163
|
// R-78's backstop reaching the surface. Nothing is written: a grant that could mean something to a
|
|
156
164
|
// shell is not a grant, and half-scaffolding a project would be worse than scaffolding none of it.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One bounded reader for the operator-authored files this package reads at SESSION START.
|
|
3
|
+
*
|
|
4
|
+
* **Why this is a module rather than a pattern to copy.** `loadWorkspaceRegistry` worked out the correct
|
|
5
|
+
* shape the expensive way — R-79 hung forever on a FIFO, a `stat`-then-`readFile` rewrite reintroduced the
|
|
6
|
+
* hang through a TOCTOU, and `AbortSignal.timeout` turned out never to interrupt a libuv read. That comment
|
|
7
|
+
* block ends with "One reader is why that cannot happen again", and then a second session-start read
|
|
8
|
+
* (`loadDefinitions`, reading each `SKILL.md`) went on using a bare `readFile`. The guards were not copied
|
|
9
|
+
* because copying them correctly is exactly what the registry's own history says nobody manages.
|
|
10
|
+
*
|
|
11
|
+
* What the shape buys, restated once here so neither caller has to:
|
|
12
|
+
*
|
|
13
|
+
* - `O_NONBLOCK` on the open. A FIFO blocks inside `open(2)` before any read starts, so a signal or a
|
|
14
|
+
* deadline checked between chunks can never rescue it; a non-blocking open returns instead.
|
|
15
|
+
* - every check against the HELD DESCRIPTOR. `stat` by name followed by a read by name is a TOCTOU, and the
|
|
16
|
+
* attacker is any process at the same uid — swapping a regular file for a FIFO between the two hangs the
|
|
17
|
+
* reader. `fstat` on a descriptor has no name left to re-resolve.
|
|
18
|
+
* - a deadline checked BETWEEN chunks, which is all an `AbortSignal` ever managed. Its honest limit: a
|
|
19
|
+
* stalled open or a single wedged read cannot be interrupted from in-process.
|
|
20
|
+
* - a size bound checked twice, before the read from `fstat` and again after, because a file can grow
|
|
21
|
+
* between the two. **The buffer is `maxBytes + 1` regardless of the file's size, and that is deliberate.**
|
|
22
|
+
* Review proposed sizing it from `fstat` instead; that would be faster and would silently break the second
|
|
23
|
+
* check, because a file whose reported size understates its content — anything under procfs, and any file
|
|
24
|
+
* that grows — would fill its small buffer exactly and come back as a successful truncated read. The cost
|
|
25
|
+
* was measured rather than assumed: 500 allocations of 1 MiB + 1 take 8.1ms for an RSS delta of 2.8 MB,
|
|
26
|
+
* because `allocUnsafe` never touches the pages.
|
|
27
|
+
*
|
|
28
|
+
* **The clock is injected** so the deadline is forced by a test rather than by a reviewer. Before this
|
|
29
|
+
* module the registry's deadline could be deleted outright and all 876 tests still passed (measured at
|
|
30
|
+
* `7096f78`), which is rule 7's "a test that cannot fail is worse than no test" with the production change
|
|
31
|
+
* named: delete the `now() > deadline` branch below and `bounded-read.test.ts` fails.
|
|
32
|
+
*/
|
|
33
|
+
import { constants } from "node:fs";
|
|
34
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
35
|
+
|
|
36
|
+
export interface BoundedReadLimits {
|
|
37
|
+
/** Refuse anything at or over this, before allocating it. */
|
|
38
|
+
maxBytes: number;
|
|
39
|
+
/** How long the chunk loop may run before the read is a refusal rather than a wait. */
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
/** Injected only by tests; production passes nothing and gets the wall clock. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why a bounded read did not produce text.
|
|
47
|
+
*
|
|
48
|
+
* A discriminated reason rather than a thrown error, because the two callers disagree about what a failure
|
|
49
|
+
* MEANS — an unreadable registry is a governance refusal naming the file, while an unreadable `SKILL.md` is
|
|
50
|
+
* one definition that will not be offered — and a shared reader must not decide that for them.
|
|
51
|
+
*/
|
|
52
|
+
export type BoundedReadFailure =
|
|
53
|
+
| { why: "unopenable"; detail: string }
|
|
54
|
+
| { why: "not-a-regular-file"; detail: string }
|
|
55
|
+
| { why: "too-large"; detail: string; size: number }
|
|
56
|
+
| { why: "grew-while-reading"; detail: string }
|
|
57
|
+
| { why: "timed-out"; detail: string }
|
|
58
|
+
| { why: "unreadable"; detail: string };
|
|
59
|
+
|
|
60
|
+
export type BoundedReadResult = { ok: true; text: string } | ({ ok: false } & BoundedReadFailure);
|
|
61
|
+
export type BoundedReadBytes = { ok: true; bytes: Buffer } | ({ ok: false } & BoundedReadFailure);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The same read, handing back raw bytes.
|
|
65
|
+
*
|
|
66
|
+
* `skill-packages.ts` needs the bytes rather than a string, because it asserts that a definition survives a
|
|
67
|
+
* UTF-8 round trip unchanged — a latin-1 byte that decodes to U+FFFD changes the file's digest, and that
|
|
68
|
+
* check is only possible against the original buffer. Decoding here and re-encoding there would defeat it.
|
|
69
|
+
*/
|
|
70
|
+
export async function readBoundedFile(path: string, limits: BoundedReadLimits): Promise<BoundedReadResult> {
|
|
71
|
+
const read = await readBoundedBytes(path, limits);
|
|
72
|
+
return read.ok ? { ok: true, text: read.bytes.toString("utf8") } : read;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function readBoundedBytes(path: string, limits: BoundedReadLimits): Promise<BoundedReadBytes> {
|
|
76
|
+
const now = limits.now ?? Date.now;
|
|
77
|
+
let handle: FileHandle;
|
|
78
|
+
try {
|
|
79
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { ok: false, why: "unopenable", detail: String(error) };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const info = await handle.stat();
|
|
85
|
+
if (!info.isFile())
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
why: "not-a-regular-file",
|
|
89
|
+
detail: `${path} is not a regular file — a FIFO, device or socket here would block rather than fail`,
|
|
90
|
+
};
|
|
91
|
+
if (info.size > limits.maxBytes)
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
why: "too-large",
|
|
95
|
+
size: info.size,
|
|
96
|
+
detail: `${path} is ${info.size} bytes, over the ${limits.maxBytes} limit`,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const deadline = now() + limits.timeoutMs;
|
|
100
|
+
const buffer = Buffer.allocUnsafe(limits.maxBytes + 1);
|
|
101
|
+
let filled = 0;
|
|
102
|
+
while (filled < buffer.length) {
|
|
103
|
+
if (now() > deadline)
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
why: "timed-out",
|
|
107
|
+
detail: `${path} did not finish reading within ${limits.timeoutMs}ms`,
|
|
108
|
+
};
|
|
109
|
+
const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, filled);
|
|
110
|
+
if (bytesRead === 0) break;
|
|
111
|
+
filled += bytesRead;
|
|
112
|
+
}
|
|
113
|
+
if (filled > limits.maxBytes)
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
why: "grew-while-reading",
|
|
117
|
+
detail: `${path} exceeded the ${limits.maxBytes} limit while being read — it grew after its size was checked`,
|
|
118
|
+
};
|
|
119
|
+
return { ok: true, bytes: buffer.subarray(0, filled) };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { ok: false, why: "unreadable", detail: String(error) };
|
|
122
|
+
} finally {
|
|
123
|
+
await handle.close().catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/kernel/catalog.ts
CHANGED
|
@@ -44,6 +44,17 @@ export interface Catalog {
|
|
|
44
44
|
all: Capability[];
|
|
45
45
|
byKind(kind: CapabilityKind): Capability[];
|
|
46
46
|
has(capability: Capability): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Why the workspace registry contributed nothing, when one was named and could not be read.
|
|
49
|
+
*
|
|
50
|
+
* The catalog fails SOFT on a bad registry — a malformed one must not stop a session starting — and the
|
|
51
|
+
* refusal used to be discarded by `() => []` at the call below. That is rule 8's silent safe-mode: one
|
|
52
|
+
* malformed entry removed every workspace from `/grants`, from the catalog and from `init`, and produced
|
|
53
|
+
* no message anywhere, so an operator saw an empty list and could not tell it apart from having
|
|
54
|
+
* registered nothing. Failing soft is still right; failing soft and SILENTLY was not. `undefined` means
|
|
55
|
+
* no registry was named, or it read cleanly.
|
|
56
|
+
*/
|
|
57
|
+
registryRefusal?: string;
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/** Split observed tool names into pi built-ins and extension-provided tools. */
|
|
@@ -107,7 +118,7 @@ export function workspaceEntries(registry: WorkspaceRegistryFile, source?: strin
|
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
/** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
|
|
110
|
-
export function makeCatalog(entries: CatalogEntry[]): Catalog {
|
|
121
|
+
export function makeCatalog(entries: CatalogEntry[], registryRefusal?: string): Catalog {
|
|
111
122
|
const deduped = new Map<Capability, CatalogEntry>();
|
|
112
123
|
for (const entry of entries) if (!deduped.has(entry.capability)) deduped.set(entry.capability, entry);
|
|
113
124
|
const list = [...deduped.values()].sort((a, b) => a.capability.localeCompare(b.capability));
|
|
@@ -118,6 +129,7 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
|
|
|
118
129
|
all: ids,
|
|
119
130
|
byKind: (kind) => list.filter((e) => e.kind === kind).map((e) => e.capability),
|
|
120
131
|
has: (capability) => idSet.has(capability),
|
|
132
|
+
...(registryRefusal === undefined ? {} : { registryRefusal }),
|
|
121
133
|
};
|
|
122
134
|
}
|
|
123
135
|
|
|
@@ -135,30 +147,39 @@ export async function buildCatalog(input: {
|
|
|
135
147
|
// session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
|
|
136
148
|
// refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
|
|
137
149
|
// it there would be unsafe; swallowing it here costs a display list.
|
|
150
|
+
//
|
|
151
|
+
// **What the refusal is no longer allowed to do is vanish.** This handler was `() => []`, so the display
|
|
152
|
+
// list was lost AND the reason with it. The reason now rides on the catalog and `/grants` prints it.
|
|
138
153
|
input.registryPath
|
|
139
154
|
? loadWorkspaceRegistry(input.registryPath).then(
|
|
140
|
-
(r) => workspaceEntries(r, input.registryPath),
|
|
141
|
-
() =>
|
|
155
|
+
(r) => ({ entries: workspaceEntries(r, input.registryPath), refusal: undefined as string | undefined }),
|
|
156
|
+
(error: unknown) => ({
|
|
157
|
+
entries: [] as CatalogEntry[],
|
|
158
|
+
refusal: error instanceof Error ? error.message : String(error),
|
|
159
|
+
}),
|
|
142
160
|
)
|
|
143
|
-
: Promise.resolve([] as CatalogEntry[]),
|
|
144
|
-
]);
|
|
145
|
-
return makeCatalog([
|
|
146
|
-
// pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
|
|
147
|
-
// consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
|
|
148
|
-
// every capability looked "unknown" until the first model call, so the preview refused grants that
|
|
149
|
-
// enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
|
|
150
|
-
// reappearing through a different door.
|
|
151
|
-
//
|
|
152
|
-
// The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
|
|
153
|
-
// as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
|
|
154
|
-
// the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
|
|
155
|
-
// the grant check — not this catalog — is the authority. Nothing here grants anything.
|
|
156
|
-
...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
|
|
157
|
-
...(input.observedTools ? classifyToolNames(input.observedTools) : []),
|
|
158
|
-
...skills,
|
|
159
|
-
...definitionEntries(definitions),
|
|
160
|
-
...workspaces,
|
|
161
|
+
: Promise.resolve({ entries: [] as CatalogEntry[], refusal: undefined as string | undefined }),
|
|
161
162
|
]);
|
|
163
|
+
return makeCatalog(
|
|
164
|
+
[
|
|
165
|
+
// pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
|
|
166
|
+
// consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
|
|
167
|
+
// every capability looked "unknown" until the first model call, so the preview refused grants that
|
|
168
|
+
// enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
|
|
169
|
+
// reappearing through a different door.
|
|
170
|
+
//
|
|
171
|
+
// The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
|
|
172
|
+
// as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
|
|
173
|
+
// the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
|
|
174
|
+
// the grant check — not this catalog — is the authority. Nothing here grants anything.
|
|
175
|
+
...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
|
|
176
|
+
...(input.observedTools ? classifyToolNames(input.observedTools) : []),
|
|
177
|
+
...skills,
|
|
178
|
+
...definitionEntries(definitions),
|
|
179
|
+
...workspaces.entries,
|
|
180
|
+
],
|
|
181
|
+
workspaces.refusal,
|
|
182
|
+
);
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
/**
|