// `/perk-selfcheck` — the session-wiring verifier. // // perk converges two pieces of session context onto disk and trusts Pi to splice them into the // model's system prompt: // // 1. `.pi/APPEND_SYSTEM.md` — the COMPRESSED ambient routing index (maintained by `/learn-docs`, // NOT `perk init`). Pi loads it verbatim and joins it into `appendSystemPrompt`. // 2. the `` block in `AGENTS.md` — written by `perk init` (the Python // plane). Pi loads `AGENTS.md` into `contextFiles` as `{ path, content }`. // // `perk doctor` checks the DISK side (the files converged). This selfcheck checks the PROMPT side: // it reads the live `getSystemPromptOptions()` (only available on a command context) and confirms // the converged content actually *reached* the prompt. That closes perk's two-plane blind spot — // doctor checks disk, selfcheck checks the prompt. // // The same report additionally carries a per-surface payload CENSUS — derived counts/chars for // `appendSystemPrompt`, `contextFiles`, the skills catalog section, the active tool definitions, // and perk-injected `custom_message` branch context; the session's model SELECTION beside the // physical models that answered on the selected branch (DISPATCH); the branch's once-counted // usage ledger (`substrate/usageLedger.ts`); the context projection's size beside Pi's context // limits; the report-wave children's usage (a constant `unavailable`); plus the admitted host SDK // (§8.76(f)) and the host-SDK bridge state (§8.73). // Report-only: the census never affects the ok/level verdict (contracts.md §8.7). // // Sensitivity: `getSystemPromptOptions()` exposes the full system-prompt construction inputs. This // module logs ONLY derived output — identifiers (paths, skill/tool names, source strings, // customTypes, model/provider/api ids) and counts/chars/costs; never prompt/content text. See // docs/learned/pi/. import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { type ExtensionAPI, formatSkillsForPrompt, type Skill, type ToolInfo, } from "@earendil-works/pi-coding-agent"; import { BINDING_HEADER } from "../../substrate/bindingDelivery.ts"; import { registerPerkCommand } from "../../substrate/command.ts"; import type { HostSdkAdmission } from "../../substrate/hostAdmission.ts"; import { type BridgeStatus, describeBridge } from "../../substrate/nativeSdkBridge.ts"; import { branchModelCensus, branchUsageCensus, formatUsd, freshTokens, type UsageSources, type UsageTotals, } from "../../substrate/usageLedger.ts"; import { branchOf } from "../../substrate/workflowState.ts"; import { report as reportTo } from "../../surfaces/report.ts"; import { activeContextMessages, type ContextProjectionSource } from "./contextEvidence.ts"; /** Project-scoped ambient routing index, relative to the repo root. */ export const AMBIENT_INDEX_REL_PATH = join(".pi", "APPEND_SYSTEM.md"); /** The managed `AGENTS.md` block marker (cross-plane: written by `perk init`, read here). */ export const MANAGED_AGENTS_MARKER = ""; /** The slice of `BuildSystemPromptOptions` the verifier + census probe (all optional). */ export interface SystemPromptProbeInput { customPrompt?: string; appendSystemPrompt?: string; contextFiles?: { path: string; content: string }[]; skills?: Skill[]; selectedTools?: string[]; toolSnippets?: Record; promptGuidelines?: string[]; } /** Read the on-disk ambient routing index (`.pi/APPEND_SYSTEM.md`), or `null` if absent. */ export function readAmbientIndex(cwd: string): string | null { const path = join(cwd, AMBIENT_INDEX_REL_PATH); if (!existsSync(path)) return null; try { return readFileSync(path, "utf8"); } catch { return null; } } /** Derived booleans/counts for the ambient routing index — never the raw text. */ export interface AmbientIndexProbe { /** The `.pi/APPEND_SYSTEM.md` file exists on disk. */ onDisk: boolean; /** Its verbatim content is present in `appendSystemPrompt`. */ reachedPrompt: boolean; /** Either nothing to wire (no on-disk index) or it reached the prompt. */ wired: boolean; /** Length of `appendSystemPrompt` (count only — never the content). */ promptChars: number; } /** * Probe whether the on-disk ambient index reached `appendSystemPrompt`. Pi loads the file verbatim * and joins it with `\n\n`, so a trimmed substring match is the faithful "reached the prompt" test. * An absent on-disk index is `wired` (a fresh consumer repo has none until `/learn-docs` lands one). */ export function ambientIndexProbe( onDiskIndex: string | null, appendSystemPrompt: string | undefined, ): AmbientIndexProbe { const append = appendSystemPrompt ?? ""; const onDisk = onDiskIndex !== null; const trimmed = (onDiskIndex ?? "").trim(); const reachedPrompt = onDisk && trimmed.length > 0 && append.includes(trimmed); return { onDisk, reachedPrompt, wired: !onDisk || reachedPrompt, promptChars: append.length, }; } /** Derived booleans/counts for the managed `AGENTS.md` block — never the raw text. */ export interface ManagedAgentsProbe { /** Number of context files spliced into the prompt. */ contextFileCount: number; /** Some context file carries the `` marker. */ reachedPrompt: boolean; } /** Probe whether the managed `AGENTS.md` block reached `contextFiles`. */ export function managedAgentsProbe( contextFiles: { path: string; content: string }[] | undefined, ): ManagedAgentsProbe { const files = contextFiles ?? []; return { contextFileCount: files.length, reachedPrompt: files.some((f) => f.content.includes(MANAGED_AGENTS_MARKER)), }; } /** The full derived selfcheck report (booleans/counts + a one-line summary, no raw content). */ export interface SelfcheckReport { version: string; sharedOk: boolean; ambient: AmbientIndexProbe; agents: ManagedAgentsProbe; /** The host-SDK bridge state of this activation (report-only; never affects ok/level). */ bridge: BridgeStatus; /** Everything the session needs is wired through to the prompt. */ ok: boolean; /** One-line, content-free summary (safe to surface in UI/logs). */ summary: string; level: "info" | "warning"; } /** Build the derived selfcheck report from the version, shared-dir state, and the live prompt. */ export function buildSelfcheckReport(input: { version: string; sharedOk: boolean; onDiskIndex: string | null; options: SystemPromptProbeInput | undefined; bridge: BridgeStatus; }): SelfcheckReport { const { version, sharedOk, onDiskIndex, options, bridge } = input; const ambient = ambientIndexProbe(onDiskIndex, options?.appendSystemPrompt); const agents = managedAgentsProbe(options?.contextFiles); const ok = sharedOk && ambient.wired && agents.reachedPrompt; const summary = `${version}: ${ok ? "ok" : "WIRING GAP"}; ` + `shared=${sharedOk ? "ok" : "miss"}; ` + `ambient=${ambient.onDisk ? (ambient.reachedPrompt ? "reached" : "MISSING") : "none"} ` + `(append=${ambient.promptChars}c); ` + `agents=${agents.reachedPrompt ? "reached" : "MISSING"} (files=${agents.contextFileCount}); ` + `bridge=${bridge.state}`; return { version, sharedOk, ambient, agents, bridge, ok, summary, level: ok ? "info" : "warning", }; } // --------------------------------------------------------------------------- // The per-surface payload census (contracts.md §8.7). Pure builders over the same // `getSystemPromptOptions()` slice plus the tool registry and the session branch. All counts are // `string.length` char counts (rendered with a `c` suffix). Identifiers (paths, skill/tool names, // source strings, customTypes) and counts/chars only — never prompt/content text. // --------------------------------------------------------------------------- /** Derived per-surface counts for the system-prompt construction inputs. */ export interface PromptCensus { /** `customPrompt` length; `null` = pi's built-in default is in use (not measurable here). */ basePromptChars: number | null; appendChars: number; contextFiles: { count: number; totalChars: number; files: { path: string; chars: number }[] }; /** `hidden` = skills with `disableModelInvocation`; `promptSectionChars` is the exact * system-prompt contribution (pi's own exported `formatSkillsForPrompt`). */ skills: { visible: number; hidden: number; promptSectionChars: number }; toolGuidelineChars: number; toolSnippetChars: number; } /** Census the system-prompt surfaces from the probed options (undefined → zeros). */ export function promptCensus(options: SystemPromptProbeInput | undefined): PromptCensus { const files = (options?.contextFiles ?? []).map((f) => ({ path: f.path, chars: f.content.length, })); const skills = options?.skills ?? []; const hidden = skills.filter((s) => s.disableModelInvocation).length; let guidelineChars = 0; for (const g of options?.promptGuidelines ?? []) guidelineChars += g.length; let snippetChars = 0; for (const s of Object.values(options?.toolSnippets ?? {})) snippetChars += s.length; return { basePromptChars: options?.customPrompt === undefined ? null : options.customPrompt.length, appendChars: (options?.appendSystemPrompt ?? "").length, contextFiles: { count: files.length, totalChars: files.reduce((sum, f) => sum + f.chars, 0), files, }, skills: { visible: skills.length - hidden, hidden, // formatSkillsForPrompt filters disableModelInvocation skills itself, so passing the full // list measures exactly the visible skills' prompt-section contribution. promptSectionChars: formatSkillsForPrompt(skills).length, }, toolGuidelineChars: guidelineChars, toolSnippetChars: snippetChars, }; } /** Derived counts for the tool-definitions payload (active tools = the per-request payload). */ export interface ToolsCensus { active: number; all: number; /** Sum of `JSON.stringify({name, description, parameters})` over ACTIVE tools. */ schemaChars: number; /** Active tools grouped by `sourceInfo.source`, sorted by source string (stable output). */ bySource: { source: string; active: number; schemaChars: number }[]; } /** Census the tool registry: active vs registered counts + per-source schema chars. */ export function toolsCensus(allTools: ToolInfo[], activeNames: string[]): ToolsCensus { const activeSet = new Set(activeNames); const bySource = new Map(); let schemaChars = 0; for (const tool of allTools) { if (!activeSet.has(tool.name)) continue; const chars = JSON.stringify({ name: tool.name, description: tool.description, parameters: tool.parameters, }).length; schemaChars += chars; const row = bySource.get(tool.sourceInfo.source) ?? { active: 0, schemaChars: 0 }; row.active += 1; row.schemaChars += chars; bySource.set(tool.sourceInfo.source, row); } return { active: activeNames.length, all: allTools.length, schemaChars, bySource: [...bySource.entries()] .map(([source, row]) => ({ source, ...row })) .sort((a, b) => (a.source < b.source ? -1 : a.source > b.source ? 1 : 0)), }; } /** * The structural slice of a session entry the branch census reads. Injected contexts persist as * `type: "custom_message"` entries carrying `content` — distinct from `type: "custom"` state * entries (workflow state, objective budget), which the census must NOT count as context. */ export interface CensusBranchEntry { type: string; customType?: string; content?: unknown; } /** * The structural slice of a projected (live-context) message the census reads: injected contexts * project as `role: "custom"` messages carrying their `customType`. */ export interface CensusLiveMessage { role: string; customType?: string; } /** Derived counts for perk-injected branch context (perk's customTypes share the `perk:` prefix). */ export interface BranchContextCensus { entries: number; /** * `custom_message` entries whose customType starts with `perk:`, sorted by customType. `copies` * is HISTORICAL (every copy on the branch, compacted or context-edited ones included); `live` is * the count of that customType in Pi's current context projection (`null` = the read failed). * The projection is read BEFORE extension `context` filters run, so `live` is an upper bound on * THAT OWNED COPY: a filter can drop a projected copy (perk's own gate strips every * `perk:mode-context` once the gate is off, and agent-scratch keeps one current copy) and * nothing re-adds it before the request. It is no bound on the request as a whole (see * `ContextCensus`). A command cannot run that filter chain, so the census does not simulate it. */ perkContexts: { customType: string; copies: number; totalChars: number; live: number | null }[]; /** `custom_message` entries without the `perk:` prefix (borrowed packages). */ otherCustomMessages: { copies: number; totalChars: number }; /** Entries (any type) whose JSON form carries `BINDING_HEADER` — the cold↔warm dedup marker. */ bindingHeaderCopies: number; } /** Char count of a `custom_message` entry's content — string, `text`-part array, or JSON size. */ function contentChars(content: unknown): number { if (content === undefined) return 0; if (typeof content === "string") return content.length; if (Array.isArray(content)) { let sum = 0; for (const part of content) { if (typeof part !== "object" || part === null || Array.isArray(part)) continue; const text = (part as { text?: unknown }).text; if (typeof text === "string") sum += text.length; } return sum; } return JSON.stringify(content).length; } /** * The pre-filter context projection for the census, or `null` when the read throws — the census * is report-only, so a failed read renders `live=?` instead of failing the command. */ export function readLiveProjection( source: ContextProjectionSource, ): readonly CensusLiveMessage[] | null { try { return activeContextMessages(source); } catch { return null; } } /** * Census the session branch's injected context (counts/chars only — never message content): * historical copies from `entries`, live copies from the `live` projection (`null` = unknown). */ export function branchContextCensus( entries: readonly CensusBranchEntry[], live: readonly CensusLiveMessage[] | null, ): BranchContextCensus { const liveCount = (customType: string): number | null => live === null ? null : live.filter((message) => message.role === "custom" && message.customType === customType) .length; const perk = new Map(); const other = { copies: 0, totalChars: 0 }; let bindingHeaderCopies = 0; for (const entry of entries) { if (JSON.stringify(entry).includes(BINDING_HEADER)) bindingHeaderCopies += 1; if (entry.type !== "custom_message") continue; const chars = contentChars(entry.content); const customType = entry.customType ?? ""; if (customType.startsWith("perk:")) { const row = perk.get(customType) ?? { copies: 0, totalChars: 0 }; row.copies += 1; row.totalChars += chars; perk.set(customType, row); } else { other.copies += 1; other.totalChars += chars; } } return { entries: entries.length, perkContexts: [...perk.entries()] .map(([customType, row]) => ({ customType, ...row, live: liveCount(customType) })) .sort((a, b) => (a.customType < b.customType ? -1 : a.customType > b.customType ? 1 : 0)), otherCustomMessages: other, bindingHeaderCopies, }; } /** The session's selection beside the branch's dispatch census (`usageLedger.ts`). */ export interface ModelIdentityCensus { /** `ctx.model` as `provider/id` plus Pi's public `Model.api`; `null` = no selection. */ selected: { key: string; api: string } | null; /** The latest successful response's physical identity; `null` = none on the branch. */ dispatched: string | null; /** * The selection's `provider/id` against the dispatched response's REQUESTED `provider/model` * (before any `→responseModel` suffix); `n/a` when either side is absent. */ relation: "same" | "differs" | "n/a"; responses: { model: string; count: number }[]; errors: number; aborted: number; } /** * Census the model identity: the selection (`ctx.model`, a virtual model names the router) and * the physical models that answered on `entries` (failed/aborted responses counted, never * dispatched). Pure. */ export function modelIdentityCensus( selected: { provider: string; id: string; api: string } | undefined, entries: readonly unknown[], ): ModelIdentityCensus { const branch = branchModelCensus(entries); const identity = selected === undefined ? null : { key: `${selected.provider}/${selected.id}`, api: selected.api }; return { selected: identity, dispatched: branch.dispatched, relation: identity === null || branch.requested === null ? "n/a" : identity.key === branch.requested ? "same" : "differs", responses: branch.responses, errors: branch.errors, aborted: branch.aborted, }; } /** The branch's once-counted usage ledger with its per-boundary record counts. */ export interface UsageCensus { totals: UsageTotals; bySource: UsageSources; } /** * The context projection's size beside Pi's context limits. `projected` is the message count of * the pre-filter projection the `live=` tokens already read (`null` = the read failed). That * total is the PRE-TRANSFORM context, never a bound on the outgoing request: Pi's * `context` / `context_with_system` handlers return replacement message arrays (they may add * messages or enlarge content) and perk itself injects on the next `before_agent_start` after * this command-time snapshot. The outgoing request is not observed. `window` is * `ctx.getContextUsage()` (`null` = undefined or the read threw). */ export interface ContextCensus { projected: number | null; window: { contextWindow: number; tokens: number | null; percent: number | null } | null; } export function contextCensus( projected: number | null, usage: { tokens: number | null; contextWindow: number; percent: number | null } | null, ): ContextCensus { return { projected, window: usage === null ? null : { contextWindow: usage.contextWindow, tokens: usage.tokens, percent: usage.percent }, }; } /** `ctx.getContextUsage()`, or `null` when Pi has none or the read throws (report-only). */ export function readContextUsage(source: { getContextUsage(): | { tokens: number | null; contextWindow: number; percent: number | null } | undefined; }): { tokens: number | null; contextWindow: number; percent: number | null } | null { try { return source.getContextUsage() ?? null; } catch { return null; } } /** The five identity/usage rows (between the branch rows and the host-SDK block). */ function identityLines( model: ModelIdentityCensus, usage: UsageCensus, context: ContextCensus, ): string[] { const selected = model.selected === null ? "none" : `${model.selected.key} (api=${model.selected.api})`; const lines = [ ` model: selected=${selected}; dispatched=${model.dispatched ?? "none"}; ${model.relation}`, ]; const counted = model.responses.map((r) => `${r.model} ×${r.count}`); lines.push( counted.length === 0 && model.errors === 0 && model.aborted === 0 ? " responses: none" : ` responses: ${[...counted, `errors=${model.errors}`, `aborted=${model.aborted}`].join("; ")}`, ); const { totals, bySource } = usage; lines.push( totals.records === 0 ? " usage: unavailable (no usage records on the branch)" : ` usage: records=${totals.records} (assistant=${bySource.assistant}, ` + `tool=${bySource.toolResult}, compaction=${bySource.compaction}, ` + `branch-summary=${bySource.branchSummary}, usage-entry=${bySource.usageEntry}); ` + `fresh=${freshTokens(totals)} (in=${totals.input} out=${totals.output}); ` + `cache read=${totals.cacheRead} write=${totals.cacheWrite}; cost=${formatUsd(totals.cost)}`, ); const window = context.window; const limits = window === null ? "limits=unavailable" : `window=${window.contextWindow} used=${window.tokens ?? "?"} ` + `(${window.percent === null ? "?" : Math.round(window.percent)}%)`; lines.push( ` context: projection=${context.projected ?? "?"} messages ` + `(pre-transform; the outgoing request is not observed); ${limits}`, ); lines.push( " wave children: usage=unavailable (separate sessions; no owned-run attribution read)", ); return lines; } /** * Render the census as a fixed multi-line block. The line grammar (the `census:` / * `append-system-prompt:` / `context-files:` / `skills:` / `tools:` / `per source:` / * `discovery:` / `branch:` / `perk contexts:` / `model:` / `responses:` / `usage:` / `context:` / * `wave children:` / `host sdk:` / `native sdk bridge:` keys) is stable — the closing audit diffs * against these exact keys. * * - each `perk contexts:` row appends a `live=` token — the pre-filter projection count (`?` * when the projection read failed) — after its historical `×copies (chars)`. `live` is an upper * bound on THAT owned copy only (a `context` filter can drop it); * - `model:` — `selected= (api=)` or `none`, `dispatched=` or `none`, * then `same` / `differs` / `n/a`; `responses:` — the successful-response census then * `errors=; aborted=`, or `none` when the branch holds no assistant response; * - `usage:` — the selected branch's once-counted ledger, or `unavailable (…)` when it holds no * record (a recorded zero cost renders `$0.0000`); * - `context:` — `projection= messages` (the PRE-TRANSFORM total, never a bound on the * outgoing request, which is not observed; `?` when the read failed), then * `window= used= (

%)` (`?` for Pi's unknown tokens/percent) or `limits=unavailable`; * - `wave children:` — a constant `usage=unavailable`: perk reads no supplier accounting field; * - `host sdk:` is the loaded SDK version the entry admitted against the floor (`?` when * unobserved — an activation is only ever reached admitted). The bridge block carries * `describeBridge`, the host entry (or `-`) and the root paths — identifiers only. * `discovery:` reports the session's discovery-cohort membership and, in the cohort, the family * members currently registered deferred in catalog order; a nonparticipant names any member * still deferred (a failed re-declaration). */ export function renderCensus(census: { prompt: PromptCensus; tools: ToolsCensus; branch: BranchContextCensus; model: ModelIdentityCensus; usage: UsageCensus; context: ContextCensus; bridge: BridgeStatus; discovery: { cohort: boolean; family: readonly string[] }; hostSdk: HostSdkAdmission; }): string { const { prompt, tools, branch, bridge, discovery, hostSdk } = census; const lines: string[] = ["census:"]; lines.push( prompt.basePromptChars === null ? " base-prompt: pi-default (not measured)" : ` base-prompt: custom ${prompt.basePromptChars}c`, ); lines.push(` append-system-prompt: ${prompt.appendChars}c`); const fileList = prompt.contextFiles.files.map((f) => `${f.path}=${f.chars}c`).join(", "); lines.push( ` context-files: ${prompt.contextFiles.count} file(s), ${prompt.contextFiles.totalChars}c` + (fileList.length > 0 ? ` — ${fileList}` : ""), ); lines.push( ` skills: ${prompt.skills.visible} visible + ${prompt.skills.hidden} hidden; ` + `prompt-section=${prompt.skills.promptSectionChars}c`, ); lines.push( ` tools: ${tools.active} active / ${tools.all} registered; schemas=${tools.schemaChars}c; ` + `guidelines=${prompt.toolGuidelineChars}c; snippets=${prompt.toolSnippetChars}c`, ); if (tools.bySource.length > 0) { const rows = tools.bySource .map((r) => `${r.source}=${r.active} (${r.schemaChars}c)`) .join("; "); lines.push(` per source: ${rows}`); } lines.push( discovery.cohort ? ` discovery: cohort (family: ${discovery.family.join(", ")})` : discovery.family.length === 0 ? " discovery: nonparticipant" : ` discovery: nonparticipant (still deferred: ${discovery.family.join(", ")})`, ); lines.push( ` branch: ${branch.entries} entries; binding-header-copies=${branch.bindingHeaderCopies}`, ); const perkRows = branch.perkContexts.map( (r) => `${r.customType} ×${r.copies} (${r.totalChars}c) live=${r.live ?? "?"}`, ); const perkSegment = perkRows.length > 0 ? perkRows.join("; ") : "none"; lines.push( ` perk contexts: ${perkSegment}; other custom_message ` + `×${branch.otherCustomMessages.copies} (${branch.otherCustomMessages.totalChars}c)`, ); lines.push(...identityLines(census.model, census.usage, census.context)); lines.push(` host sdk: ${hostSdk.observed ?? "?"} (floor >= ${hostSdk.required})`); lines.push(` native sdk bridge: ${describeBridge(bridge)}`); lines.push(` host: ${bridge.hostEntry ?? "-"}`); lines.push( ` roots: ${bridge.roots.length}` + (bridge.roots.length > 0 ? ` — ${bridge.roots.join(", ")}` : ""), ); return lines.join("\n"); } /** * Register `/perk-selfcheck`: the session-wiring verifier. Runs on a command context (the only * context exposing `getSystemPromptOptions()`), so it sees the live splice — not just disk. */ export function registerSelfcheck( pi: ExtensionAPI, opts: { version: string; sharedOk: boolean; bridge: BridgeStatus; /** The entry's admission of the loaded host SDK (report-only). */ hostSdk: HostSdkAdmission; /** The gating controller's discovery-cohort read (`ToolGating.discovery`). */ discovery: () => { cohort: boolean; family: readonly string[] }; }, ): void { registerPerkCommand(pi, "perk-selfcheck", { description: "Verify perk's session wiring: the ambient index + managed AGENTS block reached the prompt.", handler: async (_args, ctx) => { const options = ctx.getSystemPromptOptions() as SystemPromptProbeInput; const report = buildSelfcheckReport({ version: opts.version, sharedOk: opts.sharedOk, onDiskIndex: readAmbientIndex(ctx.cwd), options, bridge: opts.bridge, }); // The census is report-only: it rides the same report but never affects ok/level. A command // doesn't fire `before_agent_start`, so the branch reads as of the last completed turn. // Model, usage and context are read here at invocation over the SELECTED branch (Pi's own // `/session` sums the whole tree). const branch = branchOf(ctx); const live = readLiveProjection(ctx); const census = renderCensus({ prompt: promptCensus(options), tools: toolsCensus(pi.getAllTools(), pi.getActiveTools()), branch: branchContextCensus(branch, live), model: modelIdentityCensus(ctx.model, branch), usage: branchUsageCensus(branch), context: contextCensus(live === null ? null : live.length, readContextUsage(ctx)), bridge: opts.bridge, discovery: opts.discovery(), hostSdk: opts.hostSdk, }); // Headless-safe: report() surfaces the derived counts/identifiers (never raw prompt content). reportTo(ctx, "selfcheck", report.level, `${report.summary}\n${census}`); }, }); }