diff --git a/docs/e2e-b002-pre-fix.md b/docs/e2e-b002-pre-fix.md new file mode 100644 index 0000000..723b314 --- /dev/null +++ b/docs/e2e-b002-pre-fix.md @@ -0,0 +1,22 @@ +# B-002 pre-fix RPC repro (Phase 14) + +Run: `node scripts/b002-repro-pre-fix.mjs` (requires LiteLLM model `Eltex-Coder-Senior`, skill at `~/.cursor/skills/fup-blame-commits`). + +Patches `~/.pi/agent/settings.json` temporarily (`skillReinject.debug: true`) and restores on exit. + +## 2026-06-17 — RPC compact (manual source) + +Flow: `/skill-reinject on` → `/skill:fup-blame-commits` → `say ack` → RPC `compact`. + +| Phase | tracked | kept | registered | planned | pending | +|-------|---------|------|------------|---------|---------| +| `before_agent_start` (1st turn) | `[]` | `[]` | `["fup-blame-commits"]` | `[]` | `[]` | +| `session_compact` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `["fup-blame-commits"]` | `[]` | `[]` | + +**Compaction source:** manual (`{type:"compact"}` RPC). `reinjectOnManualCompaction: false` (default) → defer queue cleared / no inject. + +**Hypothesis check:** `registered` is **not** empty at `session_compact` when `--skill` is passed (`registered` includes `fup-blame-commits`). `planned=[]` because **kept** already contains the skill block (`kept` filter), not because `registered` is empty. + +**Extra:** `readSettings()` via `SettingsManager` / `ctx.isProjectTrusted()` blocked indefinitely inside RPC extension hooks; switched to sync global + `.pi/settings.json` merge (see `settings.ts`). + +**Still open for B-002:** auto-compaction path (threshold) where skill leaves kept window but `loadSkills` fallback returns `[]` — covered by Phase 14 `requireRegistered` / loose fallback items. diff --git a/scripts/b002-repro-pre-fix.mjs b/scripts/b002-repro-pre-fix.mjs new file mode 100644 index 0000000..b5764f5 --- /dev/null +++ b/scripts/b002-repro-pre-fix.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node +/** + * Phase 14 manual repro (B-002 pre-fix): RPC flow with skillReinject.debug. + * Captures diag lines from extension stderr (console.error) and extension_ui notify. + */ +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); +const skillPath = resolve(process.env.B002_SKILL_PATH ?? `${process.env.HOME}/.cursor/skills/fup-blame-commits`); +const extensionPath = join(repoRoot, "src/index.ts"); +const globalSettingsPath = join(process.env.HOME ?? "", ".pi/agent/settings.json"); +const globalBackupPath = `${globalSettingsPath}.b002-backup`; + +function patchGlobalSettings() { + if (!existsSync(globalSettingsPath)) { + throw new Error(`global settings not found: ${globalSettingsPath}`); + } + const original = readFileSync(globalSettingsPath, "utf8"); + writeFileSync(globalBackupPath, original); + const settings = JSON.parse(original); + settings.skillReinject = { + ...settings.skillReinject, + enabled: true, + debug: true, + autoCompactIntegration: "defer", + }; + writeFileSync(globalSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); + return original; +} + +function restoreGlobalSettings(original) { + writeFileSync(globalSettingsPath, original); + rmSync(globalBackupPath, { force: true }); +} + +const sessionDir = mkdtempSync(join(tmpdir(), "b002-session-")); +let originalSettings; +try { + originalSettings = patchGlobalSettings(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(2); +} + +let nextId = 1; +const pending = new Map(); +const diagNotifies = []; +let compactResponse; +let statusBeforeCompact; +let statusAfterCompact; + +function send(child, cmd) { + const id = `req-${nextId++}`; + child.stdin.write(`${JSON.stringify({ id, ...cmd })}\n`); + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`timeout waiting for ${cmd.type} (${id})`)); + }, Number(process.env.B002_STEP_TIMEOUT_MS ?? 180_000)); + pending.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + }); +} + +function captureDiagLine(line) { + if (line.includes("skill-reinject [session_compact]:") || line.includes("skill-reinject [before_agent_start]:")) { + diagNotifies.push(line.trim()); + } +} + +const piArgs = [ + "--mode", + "rpc", + "-e", + extensionPath, + "--skill", + skillPath, + "--session-dir", + sessionDir, + "--model", + process.env.B002_MODEL ?? "Eltex-Coder-Senior", + "--no-session", +]; + +const child = spawn("pi", piArgs, { + cwd: repoRoot, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, +}); + +let stdoutBuffer = ""; +child.stdout.on("data", (chunk) => { + stdoutBuffer += chunk.toString("utf8"); + let newlineIndex = stdoutBuffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); + if (!line.trim()) { + newlineIndex = stdoutBuffer.indexOf("\n"); + continue; + } + try { + const record = JSON.parse(line); + if (record.type === "extension_ui_request" && record.method === "notify") { + if (typeof record.message === "string") { + captureDiagLine(record.message); + } + } + if (record.type === "message" && record.message?.role === "assistant") { + const text = JSON.stringify(record.message.content ?? ""); + if (text.includes("skill-reinject:")) { + if (!statusBeforeCompact && text.includes("tracked:")) { + statusBeforeCompact = text; + } else if (text.includes("tracked:")) { + statusAfterCompact = text; + } + } + } + if (record.type === "response" && record.id && pending.has(record.id)) { + pending.get(record.id).resolve(record); + pending.delete(record.id); + } + } catch { + // ignore non-JSON stdout + } + newlineIndex = stdoutBuffer.indexOf("\n"); + } +}); + +const stderrChunks = []; +child.stderr.on("data", (chunk) => { + const text = chunk.toString("utf8"); + stderrChunks.push(chunk); + for (const line of text.split("\n")) { + captureDiagLine(line); + } +}); + +child.on("close", () => { + for (const { reject } of pending.values()) { + reject(new Error("pi process exited")); + } + pending.clear(); +}); + +async function runFlow() { + await new Promise((resolvePromise) => setTimeout(resolvePromise, 3000)); + + await send(child, { type: "prompt", message: "/skill-reinject on" }); + await send(child, { type: "prompt", message: "/skill:fup-blame-commits" }); + await send(child, { type: "prompt", message: "Reply with exactly: ack" }); + await send(child, { type: "prompt", message: "/skill-reinject" }); + + compactResponse = await send(child, { type: "compact" }); + await send(child, { type: "prompt", message: "/skill-reinject" }); + await send(child, { type: "prompt", message: "Reply with exactly: after-compact" }); +} + +let flowError; +let exitCode = 1; + +try { + await Promise.race([ + runFlow().then(() => child.stdin.end()), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`global timeout after ${process.env.B002_TIMEOUT_MS ?? 360_000}ms`)), + Number(process.env.B002_TIMEOUT_MS ?? 360_000), + ), + ), + ]); + exitCode = await new Promise((resolvePromise) => { + child.on("close", (code) => resolvePromise(code ?? 1)); + }); +} catch (error) { + flowError = error instanceof Error ? error.message : String(error); + child.kill("SIGTERM"); +} finally { + restoreGlobalSettings(originalSettings); + rmSync(sessionDir, { recursive: true, force: true }); +} + +const stderr = Buffer.concat(stderrChunks).toString("utf8"); +const parsedDiag = diagNotifies.map((line) => { + const match = line.match(/skill-reinject \[(session_compact|before_agent_start)\]: (.+)$/); + if (!match) { + return { raw: line }; + } + try { + return { phase: match[1], snapshot: JSON.parse(match[2]) }; + } catch { + return { phase: match[1], raw: match[2] }; + } +}); + +const summary = { + exitCode, + flowError, + skillPath, + compactionSource: "manual (RPC compact)", + compactResponse, + statusBeforeCompact, + statusAfterCompact, + diagNotifies, + parsedDiag, + stderrTail: stderr.trim().split("\n").filter((l) => !l.includes("Llama.cpp")).slice(-15).join("\n"), +}; + +console.log(JSON.stringify(summary, null, 2)); +process.exit(diagNotifies.length > 0 && !flowError ? 0 : 1); diff --git a/src/diag.ts b/src/diag.ts index 37189c6..70c02d3 100644 --- a/src/diag.ts +++ b/src/diag.ts @@ -35,8 +35,12 @@ export function notifyReinjectDiag( phase: ReinjectDiagPhase, snapshot: ReinjectDiagSnapshot, ): void { - if (!settings.debug || !ctx?.hasUI) { + if (!settings.debug) { return; } - ctx.ui.notify(`skill-reinject [${phase}]: ${JSON.stringify(snapshot)}`, "info"); + const message = `skill-reinject [${phase}]: ${JSON.stringify(snapshot)}`; + console.error(message); + if (ctx?.hasUI) { + ctx.ui.notify(message, "info"); + } } diff --git a/src/settings.ts b/src/settings.ts index 4c8947b..e6068fa 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,5 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import type { AutoCompactIntegration } from "./state"; @@ -104,15 +104,14 @@ function extractSkillReinject(settings: object): PartialSkillReinjectSettings { ); } -/** Merged global + project settings via Pi SettingsManager (SPEC §7.3). */ +/** Merged global + project settings (SPEC §7.3). Sync file read; avoids SettingsManager / isProjectTrusted() blocking in RPC hooks. */ export function readSettings(ctx: ExtensionContext): SkillReinjectSettings { - const manager = SettingsManager.create(ctx.cwd, getAgentDir(), { - projectTrusted: ctx.isProjectTrusted(), - }); - return mergeSkillReinjectSettings( - extractSkillReinject(manager.getGlobalSettings()), - extractSkillReinject(manager.getProjectSettings()), - ); + const global = extractSkillReinject(readSettingsFile(join(getAgentDir(), "settings.json"))); + const projectPath = join(ctx.cwd, ".pi/settings.json"); + const project = existsSync(projectPath) + ? extractSkillReinject(readSettingsFile(projectPath)) + : {}; + return mergeSkillReinjectSettings(global, project); } function readSettingsFile(settingsPath: string): Record { diff --git a/test/b002-repro-pre-fix.test.ts b/test/b002-repro-pre-fix.test.ts new file mode 100644 index 0000000..35cbfec --- /dev/null +++ b/test/b002-repro-pre-fix.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { planReinject } from "../src/reinject.js"; +import { createDefaultSettings } from "../src/settings.js"; +import { createInitialState, trackSkill } from "../src/state.js"; + +describe("B-002 pre-fix filter hypothesis", () => { + it("planned empty when skill stays in kept window even if registered", () => { + const state = createInitialState(); + trackSkill(state, { + name: "fup-blame-commits", + filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md", + baseDir: "/home/user/.cursor/skills/fup-blame-commits", + source: "slash", + }); + + const branch = [ + { + id: "keep-1", + type: "message", + message: { + role: "user", + content: + '\nbody\n', + }, + }, + ] as never; + + const planned = planReinject( + state, + createDefaultSettings(), + { + sessionManager: { getBranch: () => branch }, + } as never, + { compactionEntry: { firstKeptEntryId: "keep-1" } } as never, + [{ name: "fup-blame-commits" }], + ); + + expect(planned).toEqual([]); + }); + + it("pre-fix: registered empty drops skill even when absent from kept (post-fix should reinject)", () => { + const state = createInitialState(); + trackSkill(state, { + name: "fup-blame-commits", + filePath: "/home/user/.cursor/skills/fup-blame-commits/SKILL.md", + baseDir: "/home/user/.cursor/skills/fup-blame-commits", + source: "skill-block", + }); + + const branch = [ + { + id: "keep-1", + type: "message", + message: { role: "user", content: "plain text after compact" }, + }, + ] as never; + + const planned = planReinject( + state, + createDefaultSettings(), + { + sessionManager: { getBranch: () => branch }, + } as never, + { compactionEntry: { firstKeptEntryId: "keep-1" } } as never, + [], + ); + + expect(planned).toEqual([]); + }); +}); diff --git a/test/diag.test.ts b/test/diag.test.ts index 186e773..bab1914 100644 --- a/test/diag.test.ts +++ b/test/diag.test.ts @@ -56,6 +56,7 @@ describe("notifyReinjectDiag", () => { it("notifies with JSON snapshot when debug is on", () => { const notify = vi.fn(); + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const settings = { ...createDefaultSettings(), debug: true }; const snapshot = { tracked: ["a"], @@ -73,9 +74,13 @@ describe("notifyReinjectDiag", () => { "before_agent_start", snapshot, ); + expect(stderrSpy).toHaveBeenCalledWith( + `skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`, + ); expect(notify).toHaveBeenCalledWith( `skill-reinject [before_agent_start]: ${JSON.stringify(snapshot)}`, "info", ); + stderrSpy.mockRestore(); }); });