import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { AnalysisCoordinator } from "./analysis.ts"; import { AstGrepAdapter } from "./analyzers/ast-grep.ts"; import { DiagnosticDelivery, renderDiagnostics } from "./diagnostics.ts"; import { ExecutionWaveCoordinator, type CompletedWave, type ObservedToolResult, type WavePatches } from "./execution-wave.ts"; import { loadPolicies, type PolicyRegistry } from "./policy-loader.ts"; import type { Diagnostic, DiagnosticPolicy, ProjectContext, VerificationPolicy } from "./policy.ts"; import { discoverProject } from "./project-context.ts"; import { latestState, STATE_TYPE, type PersistedStateV1 } from "./state.ts"; import { formatGate, VerificationTracker, type CheckAttempt } from "./verification.ts"; import { explicitMutationPaths, WorkspaceInspector, type WorkspaceSnapshot } from "./workspace.ts"; const MESSAGE_TYPE = "pi-native-hooks-verification"; export class NativeHooksRuntime { private registry?: PolicyRegistry; private project?: ProjectContext; private tracker?: VerificationTracker; private workspace?: WorkspaceSnapshot; private revision = 0; private diagnostics = new DiagnosticDelivery(); private lastDiagnosticCount = 0; private lastAnalyzer = "never"; private settlementRunning = false; private analysis?: AnalysisCoordinator; private astGrep?: AstGrepAdapter; private readonly baselines = new Map(); private readonly attempts = new Map(); private readonly inspector: WorkspaceInspector; private readonly waves: ExecutionWaveCoordinator; constructor(private readonly pi: ExtensionAPI) { const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); this.inspector = new WorkspaceInspector(exec); this.waves = new ExecutionWaveCoordinator((wave) => this.finalizeWave(wave), (message) => { this.tracker?.markUnknown(message); this.baselines.clear(); this.attempts.clear(); this.notify(message, "error"); }); this.register(); } private register(): void { this.pi.on("session_start", async (_event, ctx) => this.initialize(ctx)); this.pi.on("session_tree", async (_event, ctx) => this.restoreBranch(ctx)); this.pi.on("turn_start", (event) => this.waves.turnStarted(event.turnIndex)); this.pi.on("tool_execution_start", async (event, ctx) => { const started = this.waves.start(event.toolCallId, event.toolName, event.args); if (started?.potentiallyMutating && !this.baselines.has(started.waveId)) this.baselines.set(started.waveId, await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal)); }); this.pi.on("tool_call", (event, ctx) => { if (event.toolName === "bash" && this.tracker && typeof event.input.command === "string") this.attempts.set(event.toolCallId, this.tracker.beginCheck(event.toolCallId, event.input.command, ctx.cwd)); }); this.pi.on("tool_result", async (event) => this.onToolResult(event)); this.pi.on("tool_execution_end", (event) => this.waves.end(event.toolCallId)); this.pi.on("turn_end", () => this.waves.turnEnded()); this.pi.on("agent_settled", async (_event, ctx) => this.onSettled(ctx)); this.pi.on("context", (event) => { const custom = event.messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "custom" && message.customType === MESSAGE_TYPE); const keep = this.tracker?.outstanding().length ? custom.at(-1)?.index : undefined; return { messages: event.messages.filter((message, index) => message.role !== "custom" || message.customType !== MESSAGE_TYPE || index === keep) }; }); this.pi.on("session_shutdown", (_event, ctx) => { this.waves.shutdown(); ctx.ui.setStatus("pi-native-hooks", undefined); }); const thisRuntime = this; this.pi.registerTool({ name: "pi_hooks_waive_verification", label: "Waive verification", description: "Waive all currently outstanding pi-native-hooks checks for their current revisions. A non-empty reason is required and remains auditable.", promptSnippet: "Waive pending native-hook verification with an explicit reason", parameters: Type.Object({ reason: Type.String({ minLength: 1 }) }), async execute(toolCallId, params) { return thisRuntime.waive(toolCallId, params.reason); } }); this.pi.registerCommand("hooks-status", { description: "Show pi-native-hooks policy, analyzer, and verification status", handler: async (_args, ctx) => ctx.ui.notify(this.statusText(), "info") }); } private async initialize(ctx: ExtensionContext): Promise { const exec = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => this.pi.exec(command, args, options); this.registry = await loadPolicies(); this.project = await discoverProject(ctx.cwd, exec, ctx.signal); this.workspace = await this.inspector.capture(ctx.cwd, (path) => this.pathRelevant(path), ctx.signal); this.astGrep = new AstGrepAdapter(exec, this.project.root, `${this.registry.root}/sgconfig.yml`); this.analysis = new AnalysisCoordinator(this.registry.analyzerConfigSafe ? new Map([["ast-grep:personal", this.astGrep]]) : new Map()); if (this.registry.analyzerConfigSafe && this.diagnosticPolicies().length) { const failure = await this.astGrep.prepare(ctx.signal); if (failure) this.lastAnalyzer = `ast-grep:personal degraded (${failure.message})`; } this.tracker = new VerificationTracker(this.project); this.tracker.configure(this.verificationPolicies()); await this.restoreBranch(ctx); this.updateStatus(ctx); if (!this.registry.policies.length && !this.registry.failures.length) this.notify("No personal policies found. Run /hooks-init --dry-run for initialization guidance.", "info", ctx); for (const failure of this.registry.failures) this.notify(`Policy load failed: ${failure.path}: ${failure.error}`, "error", ctx); } private async restoreBranch(ctx: ExtensionContext): Promise { if (!this.registry || !this.project || !this.tracker || !this.workspace) return; const restored = latestState(ctx.sessionManager.getBranch()); if (restored.malformed) this.notify(`Ignored ${restored.malformed} malformed native-hooks state entr${restored.malformed === 1 ? "y" : "ies"}.`, "warning", ctx); const state = restored.state; if (!state) return; if (state.projectIdentity !== this.project.identity) { this.tracker.restore(state.requirements.map((record) => ({ ...record, status: record.status === "inactive" ? "inactive" : "unknown" })), state.workspaceRevision); return; } this.tracker.restore(state.requirements, state.workspaceRevision); this.revision = Math.max(this.revision, state.workspaceRevision); if (state.policyDigest !== this.registry.digest) this.tracker.markUnknown("Policy registry changed since evidence was recorded"); if (state.workspaceFingerprint !== this.workspace.fingerprint && this.tracker.records().some((record) => record.status !== "inactive")) this.tracker.markUnknown("Workspace changed outside the observed session"); } private async onToolResult(event: ToolResultEvent) { const observed: ObservedToolResult = { toolCallId: event.toolCallId, toolName: event.toolName, input: event.input, content: event.content, details: event.details, isError: event.isError, ...(event.usage ? { usage: event.usage } : {}) }; const patch = await this.waves.result(observed); if (!patch) return; return patch as { content?: typeof event.content; details?: unknown; isError?: boolean; usage?: typeof event.usage }; } private async finalizeWave(wave: CompletedWave): Promise { if (!this.project || !this.workspace || !this.tracker || !this.registry) return new Map(); const baseline = this.baselines.get(wave.id); this.baselines.delete(wave.id); let changedPaths: readonly string[] = []; let changed = false; let provable = true; if (wave.potentiallyMutating) { const after = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); if (baseline) { const diff = this.inspector.diff(baseline, after); changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(baseline, after)])].sort()); changed = diff.changed; provable = diff.provable && (baseline.head === after.head || changedPaths.length > 0); } else { changed = true; provable = false; } const explicit = Object.freeze(wave.calls.flatMap((call) => call.result && !call.result.isError ? explicitMutationPaths(call.toolName, call.result.input) : [])); if (!provable && explicit.length) { changed = true; changedPaths = explicit; } else if (changed && !changedPaths.length) changedPaths = explicit; if (changed) { this.revision++; this.tracker.observeMutation(changedPaths, this.revision, provable || changedPaths.length > 0); } this.workspace = after; } const mutatingCount = wave.calls.filter((call) => call.potentiallyMutating).length; for (const call of wave.calls) if (call.toolName === "bash" && call.result) { const attempts = this.attempts.get(call.id) ?? []; this.attempts.delete(call.id); const finalCommand = typeof call.result.input.command === "string" ? call.result.input.command : ""; const finalized = attempts.filter((attempt) => attempt.command === finalCommand); this.tracker.finishCheck(finalized, !call.result.isError, mutatingCount === 1 && !changed); } let diagnostics: readonly Diagnostic[] = []; if (changedPaths.length) diagnostics = await this.evaluateDiagnostics(changedPaths); const fresh = this.diagnostics.select(diagnostics); this.lastDiagnosticCount = diagnostics.length; this.persist(changed ? "workspace-revision" : "verification-evidence"); if (!fresh.length || !wave.carrierId) return new Map(); const carrier = wave.calls.find((call) => call.id === wave.carrierId)?.result; if (!carrier) return new Map(); const block = renderDiagnostics(fresh, diagnostics.length); const content = [...carrier.content, { type: "text", text: block }]; return new Map([[wave.carrierId, { content }]]); } private async evaluateDiagnostics(paths: readonly string[]): Promise { if (!this.project || !this.registry || !this.analysis) return []; const policies = this.diagnosticPolicies().filter((policy) => { try { return policy.appliesTo(this.project!, paths); } catch (error) { this.notify(`Policy ${policy.id} appliesTo failed: ${String(error)}`, "error"); return false; } }); const requests = policies.flatMap((policy) => policy.analyzerKeys.map((analyzerKey) => ({ analyzerKey, paths }))); const snapshots = await this.analysis.run(requests, this.revision); this.lastAnalyzer = [...snapshots.values()].map((snapshot) => snapshot.failure ? `${snapshot.analyzerKey}: degraded (${snapshot.failure.message})` : `${snapshot.analyzerKey}: ${snapshot.durationMs}ms`).join(", ") || "not applicable"; const diagnostics: Diagnostic[] = []; for (const snapshot of snapshots.values()) if (snapshot.failure) diagnostics.push({ policyId: "pi-native-hooks-analyzer", ruleId: snapshot.analyzerKey, path: paths[0] ?? ".", line: 0, column: 0, message: `Analyzer degraded: ${snapshot.failure.message}`, guidance: "Diagnostics could not establish a clean scan; inspect /hooks-status and /hooks-doctor.", matchedTextHash: snapshot.failure.message }); for (const policy of policies) { try { diagnostics.push(...policy.evaluate(snapshots, Object.freeze({ project: this.project, changedPaths: Object.freeze([...paths]), workspaceRevision: this.revision }))); } catch (error) { this.notify(`Policy ${policy.id} evaluation failed: ${String(error)}`, "error"); } } return Object.freeze(diagnostics); } private async onSettled(ctx: ExtensionContext): Promise { if (this.settlementRunning || !this.tracker || !this.project || !this.workspace) return; this.settlementRunning = true; try { const live = await this.inspector.capture(this.project.root, (path) => this.pathRelevant(path)); if (live.fingerprint !== this.workspace.fingerprint) { const before = this.workspace; const diff = this.inspector.diff(before, live); const changedPaths = Object.freeze([...new Set([...diff.changedPaths, ...await this.inspector.headChangedPaths(before, live)])].sort()); this.revision++; this.tracker.observeMutation(changedPaths, this.revision, diff.provable && changedPaths.length > 0); this.workspace = live; this.persist("settlement-refresh"); } const outstanding = this.tracker.outstanding(); this.updateStatus(ctx); if (outstanding.length) this.pi.sendMessage({ customType: MESSAGE_TYPE, content: formatGate(outstanding), display: true, details: { revisions: outstanding.map((record) => [record.key, record.requiredRevision]) } }, { deliverAs: "followUp", triggerTurn: true }); } finally { this.settlementRunning = false; } } private waive(toolCallId: string, reason: string) { if (!this.tracker) throw new Error("pi-native-hooks has not initialized"); const waived = this.tracker.waive(reason, toolCallId); this.persist("waiver"); return { content: [{ type: "text" as const, text: `Verification skipped (not passed) for: ${waived.map((record) => record.key).join(", ")}. Reason: ${reason.trim()}` }], details: { waived: waived.map((record) => ({ key: record.key, revision: record.requiredRevision })), reason: reason.trim() } }; } private persist(reason: string): void { if (!this.registry || !this.project || !this.workspace || !this.tracker) return; const state: PersistedStateV1 = { version: 1, policyDigest: this.registry.digest, projectIdentity: this.project.identity, workspaceRevision: this.revision, workspaceFingerprint: this.workspace.fingerprint, requirements: this.tracker.records(), reason, timestamp: new Date().toISOString() }; this.pi.appendEntry(STATE_TYPE, state); } private pathRelevant(path: string): boolean { for (const policy of this.verificationPolicies()) for (const requirement of policy.requirements) { try { if (requirement.affects(path)) return true; } catch { return true; } } return /\.(?:ts|tsx)$/.test(path); } private diagnosticPolicies(): DiagnosticPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is DiagnosticPolicy => policy.kind === "diagnostic") ?? []; } private verificationPolicies(): VerificationPolicy[] { return this.registry?.policies.map((item) => item.policy).filter((policy): policy is VerificationPolicy => policy.kind === "verification") ?? []; } private statusText(): string { const records = this.tracker?.records() ?? []; return [`Config: ${this.registry?.root ?? "not initialized"}`, this.registry?.trustedCodeWarning ?? "", `Policies: ${this.registry?.policies.map((item) => `${item.policy.id} ${item.digest.slice(0, 12)} (${item.path})`).join(", ") || "none"}`, `Load failures: ${this.registry?.failures.map((failure) => `${failure.path}: ${failure.error}`).join("; ") || "none"}`, `Project: ${this.project?.identity ?? "unknown"} (${this.project?.root ?? "unknown"})`, `Workspace revision: ${this.revision}`, `Analyzer: ${this.lastAnalyzer}`, `Diagnostics: ${this.lastDiagnosticCount}`, "Verification:", ...records.map((record) => `- ${record.key}: ${record.status} r${record.requiredRevision ?? "-"}${record.waiver ? ` waived: ${record.waiver.reason}` : ""}${record.lastFailure ? ` (${record.lastFailure})` : ""}`)].filter(Boolean).join("\n"); } private updateStatus(ctx: ExtensionContext): void { const outstanding = this.tracker?.outstanding().length ?? 0; ctx.ui.setStatus("pi-native-hooks", `hooks ${this.lastDiagnosticCount}d ${outstanding ? `${outstanding} pending` : "verified"}`); } private notify(message: string, level: "info" | "warning" | "error", ctx?: ExtensionContext): void { if (ctx) ctx.ui.notify(message, level); else console.error(`[pi-native-hooks] ${message}`); } }