/** * The session object — the one place this extension's state is named. * * Split out of `extensions/grants.ts`, which is where every wiring bug in this package has lived: the G7 * `NaN` bound, the discarded `isError`, the unconditionally-registered `delegate` (S-5) and R-28's omitted * argument. Those four share a shape — a value that was *whatever happened to be in scope* at one call * site. A closure over a dozen `let`s cannot be reviewed as a whole; an object whose fields are written * down can, and it is the same move `grants-command.ts` was extracted under. * * Configuration is parsed once, at load time, and is `readonly`. The handful of genuinely mutable fields * are the ones the hooks in `grants.ts` update as the session learns about itself — the grant tightens when * the real tool surface is observed, the catalog and definitions arrive at `session_start`. Every other * module reads them **through this object**, live, rather than capturing a copy at load time; capturing a * copy of `ownGrant` before observation is exactly how a stale upper bound would become an enforced one. */ import { randomUUID } from "node:crypto"; import { parseInherited, type InheritableApproval } from "../src/kernel/approval.ts"; import type { ApprovalBinding } from "../src/kernel/correlation.ts"; import { createApprovalGateProvider } from "../src/governance/approval-prompt.ts"; import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/kernel/catalog.ts"; import type { SkillDefinition } from "../src/kernel/definitions.ts"; import { DELEGATE_CAPABILITY, type DelegationContext } from "../src/kernel/delegate.ts"; import { budgetFromEnv } from "../src/kernel/fanout.ts"; import { chooseExecutor, ENV_HERDR, type ExecutorChoice } from "../src/executors/executor.ts"; import { WILDCARD } from "../src/kernel/pi-tools.ts"; import { childEnv, depthConfig, deriveOwnGrant, gatedFromEnv, ENV_APPROVED, ENV_DEPTH, ENV_EXECUTION_ID, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID, GRANT_ENV_KEYS, parseList, } from "../src/kernel/propagation.ts"; import type { Capability } from "../src/kernel/resolve.ts"; import { loadDefinitions } from "../src/kernel/definitions.ts"; import { buildCatalog } from "../src/kernel/catalog.ts"; import { ENV_WORKSPACE_REGISTRY } from "../src/kernel/workspace.ts"; import type { GrantStoreRefusalReason } from "../src/governance/grant-store.ts"; import { republishable } from "./approvals.ts"; import { storedGrantSessionState } from "./stored-grant-session.ts"; import { nativeSessionRootFromEnv, type NativeSessionHost } from "../src/executors/native-session-target.ts"; import { createHandoffStager, type ParentSession } from "./context-staging.ts"; import { createAdvisorSession, type AdvisorSession } from "./advisor-session.ts"; import { join } from "node:path"; import { readFileSync, statSync } from "node:fs"; import { agentDir, projectSettingsPath } from "../src/kernel/project-paths.ts"; import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts"; import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts"; import { reconcileSessionEnvironment } from "./session-environment.ts"; import { realpath } from "node:fs/promises"; import { establishWorkspacePin, formatWorkspacePin, parseWorkspacePin, type WorkspacePins, ENV_WORKSPACE_PIN, } from "../src/kernel/workspace-pin.ts"; import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts"; import { reconcileAcceptedWorkspaces } from "../src/governance/workspace-acceptance.ts"; /** * Run governed children in herdr panes instead of captured child processes. * * **Three-state as of ADR-0031, and absent means PROBE.** It was opt-in under ADR-0016 point 6, on the * reasoning that *"a run that silently relocates because a binary appeared is exactly the kind of invisible * change this package exists to prevent"* — and that sentence is still honoured, because nothing is detected * from `herdr` being on `PATH`. What changed is that a **server which answers** is a different and stronger * test, and the "silently" half is discharged by the disclosure line ADR-0032 adds at session start and in * `/grants`. Both executors still enforce the identical grant: the plan is the same, only the place it runs * differs. * * The table itself is a pure function in `src/executors/executor.ts`; re-exported here because this is where every * other `PI_DADDY_*` name lives and a reader looking for it will look here. */ export { ENV_HERDR } from "../src/executors/executor.ts"; /** * herdr workspace for spawned panes — re-exported from where it is actually READ. * * It was declared here and read nowhere: `resolveWorkspace` reads the string literal, so the constant and the * literal could drift with nothing binding them. Re-exporting the single definition keeps this the place a reader * looks for a `PI_DADDY_*` name without letting two spellings exist. * * Omitting the variable no longer means "let herdr choose": it falls back to the parent's own * `HERDR_WORKSPACE_ID`, because a child in a different workspace from the session that spawned it makes switching * to it a workspace hop (ADR-0032). This name is the operator's explicit override. */ export { ENV_HERDR_WORKSPACE } from "../src/executors/herdr-cli.ts"; import { ENV_ACTIVITY_PARENT_TASK, ENV_ACTIVITY_PATH, ENV_ACTIVITY_ROOT, ENV_ACTIVITY_TASK, } from "../src/products/activity-timeline.ts"; import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR } from "../src/kernel/env-names.ts"; export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts"; import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts"; /** The activity timeline's per-child observation identity, handed to the kernel through `childEnv` (ADR-0076). */ export function activityChildEnv(activity: { rootId: string; path: string; taskId?: string } | undefined) { return (child: { childExecutionId?: string }): Readonly> => activity?.taskId && child.childExecutionId ? { [ENV_ACTIVITY_PATH]: activity.path, [ENV_ACTIVITY_ROOT]: activity.rootId, [ENV_ACTIVITY_TASK]: child.childExecutionId, [ENV_ACTIVITY_PARENT_TASK]: activity.taskId, } : {}; } /** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */ export interface GrantsSession extends NativeSessionHost { /** Legacy PI_GRANTS_* names adopted at construction (ADR-0076 PR 3b); the session-start warning names them. */ readonly adoptedLegacyEnv: readonly string[]; /** False only for the explicit PI_DADDY_GOVERNANCE opt-out; otherwise roots are observed-bound. */ governed: boolean; /** The upper bound handed down by the delegator, before this session's own tools are observed. */ inherited: Capability[]; depth: number; maxDepth: number; /** Bound variables that could not be read as non-negative integers — spawning is disabled, loudly. */ malformedBounds: string[]; gated: Capability[]; /** Resolved against the actual pi cwd at session start, then inherited verbatim by every descendant. */ ledgerPath?: string; ledgerFromEnvironment: boolean; /** * Which executor runs this session's children — ADR-0031. * * **Mutable, and for ADR-0030's reason exactly.** Settling it needs a probe, the probe is async, and this * object is built *synchronously* in the extension factory — an ordering S-5 forces, since whether * `delegate` is registered at all is decided there. So it starts as the un-probed reading and is replaced by * `resolveExecutor` once `session_start` has probed. * * Nothing may capture a copy: read it through the session, live. A copy taken in the factory is a copy taken * before the probe, which is the same hazard as capturing `ownGrant` before the tool surface is observed. */ executor: ExecutorChoice; /** This session's readable logical ledger identity; children descend from it (F8). */ ownSpawnId: string; /** Unique identity when this session is itself a governed child; roots have no governed parent. */ ownExecutionId?: string; /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */ fanoutBudget: number; /** Whether delegation tools are active. Reconciled against the owner-bound root at session_start. */ mayDelegate: boolean; /** True only after session_start binds this instance to ctx.sessionManager. */ ownerBound: boolean; /** Operator escape hatch for custom model resolution. Exact `1`, read once for the session. */ allowUnresolvedModels: boolean; /** Results from pi's synchronous model catalogue, shared by every delegation in this session. */ readonly modelResolutionCache: Map; /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */ readonly extensionPath?: string; /** Hook-only observer path for leaf children; it exposes no tools. */ readonly observerExtensionPath?: string; /** Stable root identity plus current turn, used only to join local activity facts. */ activityRootId: string; activity?: { rootId: string; path: string; taskId?: string }; /** * The parent's own session, once `session_start` supplies it. Read-only and used only to stage a granted * context handoff (ADR-0078): its file path for `fork`, its message turns for `pruned`. */ parentSession?: ParentSession; /** ADR-0077: the session's advisor, off unless the environment enables one. Never consulted for authority. */ advisorSession: AdvisorSession; /** Root identity keyed to ctx.sessionManager once session_start supplies it. */ reloadLifecycle: ReloadLifecycle; /** Approval keys approved for this session. In memory only — this dies with the process. */ readonly sessionApprovals: Set; /** Exact bindings for correlated approvals; these never inherit across a delegation boundary. */ readonly sessionApprovalBindings: Map; /** * Approvals inherited from the delegator, already clamped to this session's grant upstream. * * Key → body digest (ADR-0022), where the digest is absent for `` and for a pre-0.11 parent. * Deliberately kept RAW here and verified at the point of use (`storedApprovals`), because verification * needs `session.definitions`, which does not exist until `session_start` — and this object is built * before any hook has run. */ inheritedApprovals: Map; /** ONE single-flight queue for the whole session — see `obtainApprovals` for why it lives here. */ readonly approvalGateFor: ReturnType; /** Set at `session_start`; `process.cwd()` until then. */ cwd: string; /** This session's own grant. Starts as the inherited upper bound, tightened once tools are observed. */ ownGrant: Capability[]; observed: boolean; observedTools: string[] | null; /** ADR-0016: `SKILL.md` definitions, keyed by name. The format this package spawns from now. */ definitions: Map; /** * Definitions discovery dropped, and why — reported at session start (ADR-0076, rule 8). * * Review found the bound shipped without this: `loadDefinitions` grew a `skipped` callback and NO * production caller passed one, so an oversized or unreadable `SKILL.md` still vanished with nothing * anywhere explaining it. That is worse than the `catch { continue }` it replaced, because the bound is * new behaviour — a 2 MiB definition used to load. A capability that only a test can observe is not a * capability. */ definitionSkips: string[]; /** * This session's OWN destination pin (ADR-0042), captured once at start. * * **In memory, not re-read from the environment, and that is not a style choice.** `publishChildEnv` writes * the CHILD's narrowed pin into `process.env` — that is how a child inherits anything here — so a session * that re-read the variable at routing time would read its child's pin and lose its own. `ownGrant` has * lived in memory for exactly this reason; the pin reached the same trap, and a test caught it. */ workspacePin?: WorkspacePins; /** Whether this session has already settled its pin. Minting twice is how a reload laundered a tamper. */ pinSettled: boolean; /** Registered workspaces this session could not pin, and why — reported at session start (rule 8). */ workspaceSkips: string[]; /** Which registry ids this machine has accepted, and which it has not. Reported, and drives `/grants`. */ workspaceAcceptance?: { accepted: string[]; firstUse: boolean; unaccepted: string[] }; catalog: Catalog; /** * The in-flight catalog build, so `delegate` can wait for it instead of racing it. * * G7 / A-R5. The refresh in `before_provider_request` was fire-and-forget, so a `delegate` call * early in a session could read a catalog that was still empty and refuse a perfectly valid grant * as an "unknown capability". It failed closed, which is why it was Important rather than Critical, * but non-deterministically: the same delegation succeeded or failed on timing alone. */ catalogReady: Promise; /** * The one place a delegation context is built — and therefore the one place each field is spelled. * * R-28 is why this is a builder rather than an object literal at each call site. On the path this * replaced, three call sites passed `extensionTools` and the one that ENFORCED did not, so every * ordinary narrow definition was refused with a reason that misstated the file, while `/grants` * cheerfully reported the opposite. The defect was in an argument list, and nothing tested argument * lists. A builder makes the omission unspellable instead of merely corrected. * * `/grants` uses it too, deliberately: the listing runs the REAL planner over the REAL context, so a * diagnostic that disagrees with enforcement is not expressible. */ delegationContext(approved?: InheritableApproval[]): Promise; /** * Publish what children inherit. Written once at session start, and republished whenever this * session's own approvals change (see `obtainApprovals`) — never once per spawn. That distinction is * what keeps this race-free: every value ever written here is a PARENT-level fact (this session's own * grant, intersected with its own approvals), identical for every sibling no matter which spawn * prompted the human. A value scoped to one specific child is never written to this global channel. */ publishChildEnv(): void; /** * The directory whose stored grant this session read, or would read. `process.cwd()` — see the note in * `createGrantsSession` for why the factory cannot use `ctx.cwd`. */ readonly storeCwd: string; /** Invalid stored state fails closed and is reported/ledgered during session_start. */ grantStoreRefusal?: { reason: GrantStoreRefusalReason; path: string }; /** * Adopt the project choice made DURING the session — grant plus optional default ledger — without restart. * * Narrow by design: it sets the session's own grant and republishes, so the very next spawn is bounded by * it. It does **not** reach children that already exist; those are separate processes whose environment * was fixed when they started, and reaching into them is neither possible nor desirable — a child's * ceiling should not move under it mid-run. * * Only a human can reach this. Slash commands are user-invoked; no tool exposes it, so a model cannot * widen its own session's ceiling by calling something. */ adoptGrant(grant: Capability[], projectLedger?: string): void; reconcileEnvironment(environment: NodeJS.ProcessEnv, lifecycle: ReloadLifecycle): void; } /** * Parse the environment once and build the session every other module reads through. * * `extensionPath` is passed in rather than derived here: it must name the file **pi loads as the * extension**, so a child granted `tool:delegate` can be started with `-e `. `grants.ts` is that * file, and only `grants.ts` can say so about itself. */ /** * Load this project's definitions and capability catalog into the session. * * **One loader, two callers.** `session_start` runs it, and so does `/grants init` — which writes the very * files it reads, so a session that skipped this held `agent:review` while believing no definition of that * name existed, and the model was told `Available: none` (R-39's shape, reintroduced by the feature whose * selling point is "no restart"). Two copies of these three steps is how the two callers come to disagree * about what loading means, so there is one. */ /** * ADR-0042: a root records what each registered workspace id MEANT, once, before anything can rewrite it. * * **Only a session that inherited no pin establishes one.** A descendant that could mint its own would rewrite * the registry, re-establish, and route anywhere — the mechanism would be a comment. So an inherited value is * left exactly as it arrived, including an empty one, which says "your parent established a pin and gave you * nothing from it" and refuses at routing with its own message. * * Failing to establish is not an error: a machine with no registry has no workspaces to route to, and the * absent pin refuses anything that tries. That is the same direction as every other failure in this mechanism. */ async function establishRootPin(session: GrantsSession): Promise { if (session.pinSettled) return; // **One assignment, at the end, on every path.** Review found the previous shape — assign at each `return` // — missing two of five exits: the `catch` around an unreadable registry, which is precisely the state a // child can create by truncating the file, and the no-registry return. A root that took either reached the // RELOAD with the lifecycle still empty and minted over whatever the registry said by then, routing into // prod. That is the checklist failing, so the checklist is gone: the body computes a value and the caller // assigns both fields once. // **Settled AFTER the value exists.** Setting the flag first meant a throw would leave the session marked // settled with nothing settled — routing nowhere, which is safe, but with the LIFECYCLE unset, so the next // reload would mint again. Review could construct no throw today; "currently unreachable" is exactly the // property this feature has now been wrong about four times, and the ordering costs nothing. const settled = await settleWorkspacePin(session); session.pinSettled = true; session.workspacePin = settled; session.reloadLifecycle.workspacePin = settled; } /** * What this session's destination pin IS (ADR-0042). Every path returns a map; none writes anything. * * An empty map means "settled, and you may route nowhere", which is different from never having settled and * is what every failure resolves to. The distinction that matters is not empty-versus-absent but * settled-versus-not, and settling happens exactly once per owner. */ async function settleWorkspacePin(session: GrantsSession): Promise { // Settled by an EARLIER SESSION OBJECT for this same owner — an extension reload. Adopting rather than // re-deriving is the point: a root may mint, but only once, and only from the registry as it stood before // any child had a chance to rewrite it. if (session.reloadLifecycle.workspacePin) return session.reloadLifecycle.workspacePin; const raw = session.reloadLifecycle.root[ENV_WORKSPACE_PIN]; const inherited = parseWorkspacePin(raw); // Inherited, so it is authority and is kept exactly as it arrived — including an empty one, which says // "your parent established a pin and gave you none of it". if ("pins" in inherited) return inherited.pins; // **A DESCENDANT NEVER MINTS.** Review reproduced the escalation end to end across a real process boundary: // `workspacePinEnv` OMITS the variable when a parent has no pin of its own — which happens whenever that // parent's registry was unreadable at its start, a state any child with `tool:write` can arrange — and the // child then read the absence as "I am a root", minted from the registry it had just rewritten, and routed // to the prod worktree holding only `workspace:staging`. Depth already rides in the environment and already // attenuates downward, so it is enough on its own. // // A MALFORMED value refuses for the same reason even at the root. The module header says missing, empty, // malformed and mismatched all refuse; that was true of the routing check and false here, where a refusal // fell through to minting. A tamperer who can corrupt one byte must not thereby earn a promotion. if (session.depth > 0 || raw !== undefined) return new Map(); const registryPath = process.env[ENV_WORKSPACE_REGISTRY]; if (!registryPath) return new Map(); try { const registry = await loadWorkspaceRegistry(registryPath); // **The id SET, which the destination pin does not cover.** ADR-0042 bound what an id means; a child // holding `tool:write` inherits the registry path and can append an id of its own, and the NEXT root // session mints a pin for it. Measured: a child-created id reached the catalog, the pin and a real route // with no operator action. So an id nobody accepted is not pinned, and therefore not routable. const acceptance = await reconcileAcceptedWorkspaces(registryPath, Object.keys(registry.workspaces)); session.workspaceAcceptance = acceptance; for (const id of acceptance.unaccepted) session.workspaceSkips.push(`${id} — it is in the registry but this machine has never accepted it`); const accepted = new Set(acceptance.accepted); const narrowed = { workspaces: Object.fromEntries(Object.entries(registry.workspaces).filter(([id]) => accepted.has(id))), }; return await establishWorkspacePin(narrowed, realpath, (id, reason) => session.workspaceSkips.push(`${id} — ${reason}`), ); } catch { // An unreadable registry is already reported by the catalog and at session start. It does NOT follow that // nothing is blocked — an earlier draft of this comment claimed that and review measured it false, because // a grant supplied through `PI_DADDY_GRANT` never passes the catalog and `workspace:` is exempt from the // unknown check anyway. A session can genuinely hold `workspace:w1` and be refused for want of a pin. // Returning an empty map SETTLES it, so a later reload cannot mint over a registry that has since changed. return new Map(); } } export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise { await establishRootPin(session); const skips: string[] = []; session.definitions = await loadDefinitions(cwd, (_path, reason) => skips.push(reason)); session.definitionSkips = skips; session.catalogReady = buildCatalog({ cwd, observedTools: session.observedTools, // ADR-0035: `workspace:` is a capability, so the registered ids belong in the catalog the same way // discovered definitions do — for `/grants` to list what this session may route to and for `init` to // scaffold them. Read live rather than cached at load, because the registry is an operator file. registryPath: process.env[ENV_WORKSPACE_REGISTRY], }); session.catalog = await session.catalogReady; } export function createGrantsSession( extensionPath: string | undefined, lifecycle?: ReloadLifecycle, observerExtensionPath?: string, ): GrantsSession { // ADR-0076 PR 3b: legacy PI_GRANTS_* names are adopted BEFORE the first environment read and before the // reload snapshot, or an operator on the old names would get an ungoverned wildcard root (review finding). const adoptedLegacyEnv = adoptLegacyEnvironment(process.env); const started = lifecycle ? undefined : beginExtensionLifecycle(); const activeLifecycle = lifecycle ?? started!.lifecycle; const environment = lifecycle ? process.env : started!.environment; const activityRootId = activeLifecycle.activityRootId ?? randomUUID(); activeLifecycle.activityRootId = activityRootId; // Local governance is on unless PI_DADDY_GOVERNANCE opts out; explicit inherited grants still win. // The factory precedes ctx, so its cwd/store identity is reconciled at session_start. const grantRaw = environment[ENV_GRANT]; const storeCwd = process.cwd(); // One root-only store read supplies both decisions made by `/grants init`. A child always has ENV_GRANT, // so it cannot activate a ledger merely because its routed cwd happens to have a v2 store (ADR-0037). const storedState = storedGrantSessionState(grantRaw, storeCwd); const governanceOff = environment[ENV_GOVERNANCE]?.trim() === "off" || environment[ENV_GOVERNANCE]?.trim() === "0"; const governed = governanceOff ? false : true; const inherited = governanceOff ? storedState.inherited : storedState.governed ? storedState.inherited : [WILDCARD]; const grantStoreRefusal = storedState.refusal; const ledgerRaw = environment[ENV_LEDGER]; // Capture provenance before publishChildEnv writes this session's derived default into process.env. A later // `/grants init` for ctx.cwd must not mistake our own publication for an operator override. const ledgerFromEnvironment = ledgerRaw !== undefined; const storedLedger = storedState.defaultLedger; // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off. const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]); const { depth, maxDepth } = bounds; const emptyCatalog = makeCatalog([]); // ADR-0077. The environment decides whether there is an advisor at all; the project's settings block may only // narrow it. The block IS read — the first version passed `undefined` and every narrowing the release advertised // was dead code reachable only from tests, which review measured: `enabled: false` turned nothing off. // // Read from `storeCwd` for `loadStoredGrantStateSync`'s reason: this factory runs before any hook, so `ctx.cwd` // does not exist yet. Reading a workspace-writable file here is safe precisely because it can only narrow. const advisorSession = createAdvisorSession({ block: projectAdvisorBlock(storeCwd), ...(storedLedger ? { ledgerPath: storedLedger } : {}), }); const session: GrantsSession = { advisorSession, adoptedLegacyEnv, governed, inherited, depth, maxDepth, malformedBounds: bounds.malformed, definitionSkips: [], workspacePin: undefined, pinSettled: false, workspaceSkips: [], workspaceAcceptance: undefined, // ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one // (no PI_DADDY_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an // ungoverned-descendant escape hatch, and doing that silently is what changes here. // `PI_DADDY_GATED=""` turns the default off; absent and empty are deliberately distinguishable. gated: governed ? gatedFromEnv(environment[ENV_GATED]) : parseList(environment[ENV_GATED]), // Presence wins, including an explicitly empty value for a one-run opt-out. The store is eligible only // when ENV_GRANT was absent above, preserving the environment as the child's single authority channel. ledgerPath: ledgerRaw !== undefined ? ledgerRaw : storedLedger, ledgerFromEnvironment, // The un-probed reading. `resolveExecutor` replaces it at session start; until then a `1` already reads as // a refusal, which is the safe direction — a delegation that somehow ran before the probe would refuse // rather than quietly use the wrong executor. executor: chooseExecutor(environment[ENV_HERDR], null), // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of // every level restarting at `d0` and the ledger becoming unjoinable. ownSpawnId: environment[ENV_PARENT_ID]?.trim() || `d${depth}`, ownExecutionId: environment[ENV_EXECUTION_ID]?.trim() || undefined, // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never // create more descendants than its root was given — with no shared state, no lock and no counter file. fanoutBudget: budgetFromEnv(environment[ENV_FANOUT]), /** * Review finding S-5, fixed. The comment on the tools has always claimed conditional registration; the * call was unconditional, `DELEGATE_CAPABILITY` was imported and never used, and "withhold it and the * child is a leaf" was simply untrue on this path. * * Provisional before owner binding; session_start recomputes it from that owner's root before activation. */ mayDelegate: !governed || inherited.includes(DELEGATE_CAPABILITY) || inherited.includes(WILDCARD), ownerBound: false, allowUnresolvedModels: environment[ENV_ALLOW_UNRESOLVED_MODELS] === "1", nativeSessionRoot: nativeSessionRootFromEnv(process.env), modelResolutionCache: new Map(), extensionPath, observerExtensionPath, activityRootId, reloadLifecycle: activeLifecycle, sessionApprovals: new Set(), sessionApprovalBindings: new Map(), inheritedApprovals: parseInherited(environment[ENV_APPROVED]), approvalGateFor: createApprovalGateProvider(), cwd: process.cwd(), ownGrant: deriveOwnGrant(inherited, null), observed: false, observedTools: null, definitions: new Map(), catalog: emptyCatalog, catalogReady: Promise.resolve(emptyCatalog), delegationContext: async (approved?: InheritableApproval[]) => ({ ownGrant: session.ownGrant, depth: session.depth, maxDepth: session.maxDepth, gated: session.gated, ledgerPath: session.ledgerPath, extensionPath: session.extensionPath, observerExtensionPath: session.observerExtensionPath, childEnv: activityChildEnv(session.activity), // ADR-0042: the pin this session holds, handed to the kernel so a delegated child inherits a narrowed // one through the same builder `publishChildEnv` uses. Read here rather than in the kernel so there is // one place that knows the environment is where a session's own pin lives. ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}), // ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate. // `options` is forwarded, and its absence is why the second decision point was dead in production: a // one-parameter arrow is assignable to a two-parameter type, so the ids reached here and were discarded while // the advisor had already been asked. Review measured it. `test/pruning-advice.test.ts` now goes through this // function rather than calling the stager directly. stageHandoff: (granted, options) => createHandoffStager({ cwd: session.cwd, forkRoot: join(agentDir(), "context-forks"), ...(session.parentSession ? { parentSession: session.parentSession } : {}), })(granted, options), catalog: await session.catalogReady, // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those. // Derived from the catalog's own `source`, so it cannot drift from what was discovered. skillPaths: skillPathsFromCatalog(await session.catalogReady), // ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one. definitions: session.definitions, // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`. // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and // an executor quietly rewriting it would make the record describe a spawn that did not happen. // // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe // has not run when this session object is built, so a captured value would plan `--print` for a session // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design. interactive: session.executor.kind === "herdr", ...(approved ? { approved } : {}), }), storeCwd, ...(grantStoreRefusal ? { grantStoreRefusal } : {}), adoptGrant: (grant: Capability[], projectLedger?: string) => { // Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is // governed from that moment: every spawn is bounded by what was just stored. Leaving this false made // `/grants` print "inactive" while holding thirteen capabilities — a status line contradicting the // enforcer, which is the defect R-28 is named for. session.governed = true; session.ownGrant = grant; // An environment ledger remains the explicit answer. Otherwise init's v2 choice becomes live now, // before publishChildEnv gives the same absolute path to descendants. if (!session.ledgerFromEnvironment && projectLedger !== undefined) { session.ledgerPath = projectLedger; } session.publishChildEnv(); }, reconcileEnvironment: (environment, lifecycle) => reconcileSessionEnvironment(session, environment, lifecycle), publishChildEnv: () => { const env = childEnv({ ownGrant: session.ownGrant, depth: session.depth, maxDepth: session.maxDepth, gated: session.gated, ledgerPath: session.ledgerPath, approved: republishable(session), // ADR-0042. The pin this session holds, which `childEnv` narrows to what the child's grant names. A // session with no pin passes none, and its children can route nowhere — the fail-closed direction. ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}), // G7 / B-I8: an ungoverned session publishes nothing, so "governance is opt-in" holds for // descendants too. Previously it exported its own observed tool surface as their grant. governed: session.governed, }); // Clear omitted fields: another owner's provenance must never reach this session's children. for (const key of GRANT_ENV_KEYS) delete process.env[key]; for (const [key, value] of Object.entries(env)) process.env[key] = value; rememberChildPublication(session.reloadLifecycle); }, }; return session; } /** * The `advisor` block of `.pi/pi-daddy/settings.json`, or undefined. * * Unreadable, absent or malformed all yield undefined: this file is the reviewable record, not authority, and the * only thing it can do to an advisor is turn one off. A parse failure therefore costs nothing worth reporting. */ function projectAdvisorBlock(cwd: string): unknown { // Only when an advisor could exist at all. This runs in every session including every child, before any hook, and // a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it. if (!process.env[ENV_ADVISOR]?.trim()) return undefined; try { const path = projectSettingsPath(cwd); // Bounded and type-checked first: this is the third unbounded session-start read AGENTS.md warns about, and the // only one whose path a governed child holding `tool:write` can replace with a FIFO — which would hang pi // before any hook exists to report it. const stats = statSync(path); if (!stats.isFile() || stats.size > 1024 * 1024) return undefined; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); return typeof parsed === "object" && parsed !== null ? (parsed as Record).advisor : undefined; } catch { return undefined; } }