From 7efcf58e61c3680e1bcdc270b5fa286816164499 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 20:57:46 +0530 Subject: [PATCH 1/6] feat(init): detectActiveClaudeSessions for F-CACHE-DEFER (v1.6.16 step 1) Pure module with injected I/O seam (listDir + stat + now). Scans ~/.claude/projects//sessions/.jsonl for files within the threshold (default 5 min = Anthropic prompt-cache TTL). Defensive: any subtree permission error degrades to "skip subtree", whole walk never throws. 13 tests cover: empty layouts, single recent / stale detection, multi-project counts, listDir failure mid-walk, stat race returning null, non-.jsonl files ignored, custom thresholds. Test count: 1,317 -> 1,330. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/init/sessionDetection.ts | 128 +++++++ tests/modules/init/sessionDetection.test.ts | 351 ++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 src/modules/init/sessionDetection.ts create mode 100644 tests/modules/init/sessionDetection.test.ts diff --git a/src/modules/init/sessionDetection.ts b/src/modules/init/sessionDetection.ts new file mode 100644 index 0000000..7fbdd4f --- /dev/null +++ b/src/modules/init/sessionDetection.ts @@ -0,0 +1,128 @@ +/** + * Detect active Claude Code sessions for F-CACHE-DEFER (v1.6.16). + * + * Scans `~/.claude/projects/PROJ/sessions/SID.jsonl` (where PROJ is any + * project-hash dir and SID is any session id) for files whose mtime is + * within `thresholdMs` of `now()`. If any such file exists, the user has + * an active Claude Code session and `sipcode init` should defer the + * `settings.json` write to protect Anthropic's prompt cache. + * + * Pure-ish: all I/O is injected via {@link SessionDetectionIO} so the function + * is fully deterministic for tests. Defensive: any listDir or stat failure on + * a subtree is treated as "skip this subtree", not as a hard error. The whole + * walk degrades to "not active" rather than throwing. Callers should never + * crash because of a permission glitch in a sibling directory. + * + * The threshold default of 5 minutes mirrors Anthropic's prompt cache TTL. + * Any file written within that window is presumed to be a live session. + */ +import path from "node:path"; + +const DEFAULT_THRESHOLD_MS = 5 * 60 * 1000; + +/** Filesystem I/O seam. Pure-only operations. */ +export interface SessionDetectionIO { + /** List immediate children of a directory. Throws on permission errors etc. */ + listDir(absPath: string): Promise; + /** Stat a path. Returns null if missing. Throws on permission errors. */ + stat(absPath: string): Promise<{ mtimeMs: number; isDirectory: boolean } | null>; + /** Current time. Injected for test determinism. */ + now(): Date; +} + +export interface ActiveSessionsInput { + readonly homeDir: string; + readonly io: SessionDetectionIO; + /** Activity threshold in ms. Defaults to 5 minutes (Anthropic prompt-cache TTL). */ + readonly thresholdMs?: number; +} + +export interface ActiveSessionsResult { + /** True iff at least one `.jsonl` was modified within the threshold. */ + readonly active: boolean; + /** Count of `.jsonl` files within the threshold, across all projects. */ + readonly count: number; + /** Did `~/.claude/projects` exist as a directory at scan time? */ + readonly projectsDirExists: boolean; +} + +/** + * Walk the projects directory and count session jsonl files whose mtime is + * within `thresholdMs` of `now()`. See module docstring for the path layout + * and rationale. + * + * Never throws. Permission errors on any subtree degrade to "skip subtree". + */ +export async function detectActiveClaudeSessions( + input: ActiveSessionsInput, +): Promise { + const threshold = input.thresholdMs ?? DEFAULT_THRESHOLD_MS; + const cutoffMs = input.io.now().getTime() - threshold; + + // Step 1: is ~/.claude there at all? + const claudeDir = path.join(input.homeDir, ".claude"); + const claudeStat = await safeStat(input.io, claudeDir); + if (!claudeStat || !claudeStat.isDirectory) { + return { active: false, count: 0, projectsDirExists: false }; + } + + // Step 2: is the projects subdirectory there? + const projectsDir = path.join(claudeDir, "projects"); + const projectsStat = await safeStat(input.io, projectsDir); + if (!projectsStat || !projectsStat.isDirectory) { + return { active: false, count: 0, projectsDirExists: false }; + } + + // Step 3: list projects. Permission failure → projectsDirExists=true but + // empty walk, treated as not-active. + let projectEntries: string[]; + try { + projectEntries = await input.io.listDir(projectsDir); + } catch { + return { active: false, count: 0, projectsDirExists: true }; + } + + // Step 4: walk each project's sessions/ directory. + let count = 0; + for (const projectName of projectEntries) { + const projectDir = path.join(projectsDir, projectName); + const pStat = await safeStat(input.io, projectDir); + if (!pStat || !pStat.isDirectory) continue; + + const sessionsDir = path.join(projectDir, "sessions"); + const sStat = await safeStat(input.io, sessionsDir); + if (!sStat || !sStat.isDirectory) continue; + + let sessionEntries: string[]; + try { + sessionEntries = await input.io.listDir(sessionsDir); + } catch { + continue; // permission error on this project; skip without failing + } + + for (const file of sessionEntries) { + if (!file.endsWith(".jsonl")) continue; + const filePath = path.join(sessionsDir, file); + const fStat = await safeStat(input.io, filePath); + if (!fStat) continue; // race: file was listed but missing at stat time + if (fStat.isDirectory) continue; // .jsonl directory: ignore + if (fStat.mtimeMs >= cutoffMs) { + count += 1; + } + } + } + + return { active: count > 0, count, projectsDirExists: true }; +} + +/** Stat that swallows permission errors as "not found". */ +async function safeStat( + io: SessionDetectionIO, + p: string, +): Promise<{ mtimeMs: number; isDirectory: boolean } | null> { + try { + return await io.stat(p); + } catch { + return null; + } +} diff --git a/tests/modules/init/sessionDetection.test.ts b/tests/modules/init/sessionDetection.test.ts new file mode 100644 index 0000000..1168bed --- /dev/null +++ b/tests/modules/init/sessionDetection.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for v1.6.16 `detectActiveClaudeSessions` — F-CACHE-DEFER cornerstone. + * + * Scans ~/.claude/projects//sessions/.jsonl for + * files modified within the threshold (default 5 minutes). Pure function, + * I/O injected via SessionDetectionIO so we can drive every path determ- + * inistically without hitting the real disk. + * + * Coverage matrix: + * - no ~/.claude → not active, projectsDirExists=false + * - no projects dir → not active, projectsDirExists=false + * - empty projects dir → not active, projectsDirExists=true + * - project with no sessions dir → skipped silently + * - project with empty sessions dir → skipped silently + * - single recent session → active + * - single stale session → not active + * - multiple sessions, one recent → active (count==1 for active set) + * - multiple projects, mixed → count reflects active only + * - listDir error mid-walk → degrades to not-active (defensive) + * - custom threshold respected + * - non-.jsonl files ignored + */ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { + detectActiveClaudeSessions, + type SessionDetectionIO, +} from "../../../src/modules/init/sessionDetection.js"; + +const HOME = "/home/user"; +const NOW = new Date("2026-06-21T12:00:00.000Z"); +const NOW_MS = NOW.getTime(); +const FIVE_MIN_MS = 5 * 60 * 1000; + +interface MockEntry { + /** "dir" or "file" — affects stat().isDirectory and listDir traversal. */ + readonly type: "dir" | "file"; + readonly mtimeMs?: number; +} + +interface MockFs { + readonly entries: Record; + /** Optional: paths whose listDir should throw (simulate permission errors). */ + readonly listDirThrows?: ReadonlySet; +} + +/** Build a deterministic SessionDetectionIO over a flat path → entry map. */ +function makeIO(fs: MockFs, now: Date = NOW): SessionDetectionIO { + return { + async listDir(p) { + if (fs.listDirThrows?.has(p)) throw new Error("EACCES"); + // Return entries whose parent path is `p`. + const prefix = p.endsWith("/") || p.endsWith("\\") ? p : p + path.sep; + const out: string[] = []; + for (const full of Object.keys(fs.entries)) { + if (!full.startsWith(prefix)) continue; + const rest = full.slice(prefix.length); + // direct child only — no further separators + if (rest.includes("/") || rest.includes("\\")) continue; + if (rest.length === 0) continue; + out.push(rest); + } + return out; + }, + async stat(p) { + const e = fs.entries[p]; + if (!e) return null; + return { + mtimeMs: e.mtimeMs ?? 0, + isDirectory: e.type === "dir", + }; + }, + now() { + return now; + }, + }; +} + +/** Compose a project-hash/sessions/ path under HOME/.claude/projects. */ +function projectsRoot(): string { + return path.join(HOME, ".claude", "projects"); +} +function sessionFile(projectHash: string, sessionId: string): string { + return path.join( + projectsRoot(), + projectHash, + "sessions", + `${sessionId}.jsonl`, + ); +} +function sessionsDir(projectHash: string): string { + return path.join(projectsRoot(), projectHash, "sessions"); +} +function projectDir(projectHash: string): string { + return path.join(projectsRoot(), projectHash); +} + +describe("detectActiveClaudeSessions — empty / missing layouts", () => { + it("returns not-active when ~/.claude does not exist", async () => { + const io = makeIO({ entries: {} }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + expect(r.projectsDirExists).toBe(false); + }); + + it("returns not-active when ~/.claude/projects does not exist", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + expect(r.projectsDirExists).toBe(false); + }); + + it("returns not-active when projects dir exists but is empty", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + expect(r.projectsDirExists).toBe(true); + }); +}); + +describe("detectActiveClaudeSessions — recency detection", () => { + it("detects a single session modified within threshold", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [sessionFile("proj-a", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 60_000, // 1 min ago + }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(true); + expect(r.count).toBe(1); + expect(r.projectsDirExists).toBe(true); + }); + + it("does NOT activate on a stale session (older than threshold)", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [sessionFile("proj-a", "s1")]: { + type: "file", + mtimeMs: NOW_MS - (FIVE_MIN_MS + 1000), // 5m1s ago + }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + }); + + it("counts only the recently-modified sessions across many files", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [sessionFile("proj-a", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 30_000, // recent + }, + [sessionFile("proj-a", "s2")]: { + type: "file", + mtimeMs: NOW_MS - (60 * 60 * 1000), // 1 hr ago + }, + [sessionFile("proj-a", "s3")]: { + type: "file", + mtimeMs: NOW_MS - (10 * 60 * 1000), // 10 min ago + }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(true); + expect(r.count).toBe(1); + }); + + it("counts across multiple projects, only the recent ones", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [sessionFile("proj-a", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 30_000, + }, + [projectDir("proj-b")]: { type: "dir" }, + [sessionsDir("proj-b")]: { type: "dir" }, + [sessionFile("proj-b", "s1")]: { + type: "file", + mtimeMs: NOW_MS - (2 * 60 * 60 * 1000), + }, + [projectDir("proj-c")]: { type: "dir" }, + [sessionsDir("proj-c")]: { type: "dir" }, + [sessionFile("proj-c", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 120_000, + }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(true); + expect(r.count).toBe(2); + }); +}); + +describe("detectActiveClaudeSessions — robustness", () => { + it("returns not-active when listDir on projects/ throws", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + }, + listDirThrows: new Set([projectsRoot()]), + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + expect(r.projectsDirExists).toBe(true); + }); + + it("skips a project whose sessions/ listDir throws", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [projectDir("proj-b")]: { type: "dir" }, + [sessionsDir("proj-b")]: { type: "dir" }, + [sessionFile("proj-b", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 30_000, + }, + }, + listDirThrows: new Set([sessionsDir("proj-a")]), + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(true); + expect(r.count).toBe(1); + }); + + it("skips a project with no sessions/ subdirectory", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + // no sessions/ subdirectory + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + }); + + it("ignores files that are not .jsonl", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [path.join(sessionsDir("proj-a"), "s1.log")]: { + type: "file", + mtimeMs: NOW_MS - 30_000, + }, + [path.join(sessionsDir("proj-a"), "s1.txt")]: { + type: "file", + mtimeMs: NOW_MS - 30_000, + }, + }, + }); + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + }); + + it("treats stat returning null as a missing file (skipped, not error)", async () => { + // Simulate race: listDir returned the name but stat failed. + const io: SessionDetectionIO = { + async listDir(p) { + if (p === projectsRoot()) return ["proj-a"]; + if (p === projectDir("proj-a")) return ["sessions"]; + if (p === sessionsDir("proj-a")) return ["ghost.jsonl"]; + return []; + }, + async stat(p) { + if (p === path.join(HOME, ".claude")) return { mtimeMs: 0, isDirectory: true }; + if (p === projectsRoot()) return { mtimeMs: 0, isDirectory: true }; + if (p === projectDir("proj-a")) return { mtimeMs: 0, isDirectory: true }; + if (p === sessionsDir("proj-a")) return { mtimeMs: 0, isDirectory: true }; + if (p.endsWith("ghost.jsonl")) return null; // race deleted before stat + return null; + }, + now() { + return NOW; + }, + }; + const r = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r.active).toBe(false); + expect(r.count).toBe(0); + }); +}); + +describe("detectActiveClaudeSessions — custom threshold", () => { + it("respects a custom threshold (1-minute window)", async () => { + const io = makeIO({ + entries: { + [path.join(HOME, ".claude")]: { type: "dir" }, + [projectsRoot()]: { type: "dir" }, + [projectDir("proj-a")]: { type: "dir" }, + [sessionsDir("proj-a")]: { type: "dir" }, + [sessionFile("proj-a", "s1")]: { + type: "file", + mtimeMs: NOW_MS - 90_000, // 90 sec ago — within 5-min default, outside 1-min + }, + }, + }); + const r5 = await detectActiveClaudeSessions({ homeDir: HOME, io }); + expect(r5.active).toBe(true); // default 5 min + + const r1 = await detectActiveClaudeSessions({ + homeDir: HOME, + io, + thresholdMs: 60_000, + }); + expect(r1.active).toBe(false); // tighter window + }); +}); From 040e07b57c5da30cd7a9e85cd9b818f9ad905ded Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 21:39:15 +0530 Subject: [PATCH 2/6] feat(init): pendingInstall marker for F-CACHE-DEFER (v1.6.16 step 2) Marker at ~/.sipcode/install-pending.json carries the intent "install proxy hook at scriptPath" when the settings.json write is deferred to protect Anthropic's prompt cache. Schema sipcode-install-pending/1, strictly validated on read so unknown future versions are rejected rather than mis-applied. applyPendingInstall re-generates the script (latest version even if deferred days ago) and applies installProxyHook against current settings.json so user-managed hook entries survive. Idempotent: second apply returns no-marker. 16 tests cover: round-trip, overwrite, missing/corrupt/wrong-schema/ missing-fields rejection, no-op clear, no-marker no-op, full apply, idempotency, no-change redundant apply, non-sipcode hook preservation, corrupt settings.json fallback. Test count: 1,330 -> 1,346. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/init/pendingInstall.ts | 196 ++++++++++++ tests/modules/init/pendingInstall.test.ts | 351 ++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 src/modules/init/pendingInstall.ts create mode 100644 tests/modules/init/pendingInstall.test.ts diff --git a/src/modules/init/pendingInstall.ts b/src/modules/init/pendingInstall.ts new file mode 100644 index 0000000..63a4adb --- /dev/null +++ b/src/modules/init/pendingInstall.ts @@ -0,0 +1,196 @@ +/** + * Pending-install marker for v1.6.16 F-CACHE-DEFER. + * + * When `sipcode init` runs while an active Claude Code session exists, the + * settings.json write (which would invalidate Anthropic's prompt cache) is + * deferred. A marker is written at ~/.sipcode/install-pending.json with the + * intent "install proxy hook at scriptPath". The next sipcode invocation + * outside an active session calls applyPendingInstall, which regenerates + * the script (so users get the LATEST sipcode version) and applies the + * proxy hook to the CURRENT settings.json (preserving any user changes). + * + * Schema is versioned (sipcode-install-pending/1). Future additive + * changes bump the version; readers reject unknown versions to avoid + * silently mis-applying a marker authored by a newer sipcode build. + * + * Pure-ish: I/O is injected via PendingInstallIO so tests can drive every + * path deterministically. The actual settings/script transform reuses + * existing helpers (parseSettings, installProxyHook, renderSettings) so + * this module never duplicates that logic. + */ +import path from "node:path"; +import { + parseSettings, + renderSettings, +} from "../hygiene/settingsJson.js"; +import { installProxyHook } from "../proxy/install.js"; + +export const PENDING_INSTALL_SCHEMA_V1 = "sipcode-install-pending/1" as const; +type SchemaV1 = typeof PENDING_INSTALL_SCHEMA_V1; + +/** I/O seam. Pure operations only. */ +export interface PendingInstallIO { + readFile(absPath: string): Promise; + writeFile(absPath: string, content: string): Promise; + deleteFile(absPath: string): Promise; + now(): Date; +} + +/** The persisted marker. v1 schema. */ +export interface PendingMarker { + readonly schemaVersion: SchemaV1; + readonly createdAt: string; + readonly scriptPath: string; + readonly settingsPath: string; +} + +export interface WriteMarkerInput { + readonly homeDir: string; + readonly scriptPath: string; + readonly settingsPath: string; +} + +export interface ApplyInput { + readonly homeDir: string; + /** Generator for the proxy hook script content. Re-run at apply time so + * users get the latest version even if it was deferred days ago. */ + readonly generateScript: () => string; +} + +export type ApplyResult = + | { readonly kind: "no-marker" } + | { + readonly kind: "applied"; + readonly scriptWritten: boolean; + readonly settingsWritten: boolean; + }; + +/** Absolute path to the marker file. */ +export function pendingMarkerPath(homeDir: string): string { + return path.join(homeDir, ".sipcode", "install-pending.json"); +} + +/** Write (or overwrite) the marker. */ +export async function writePendingMarker( + input: WriteMarkerInput, + io: PendingInstallIO, +): Promise { + const marker: PendingMarker = { + schemaVersion: PENDING_INSTALL_SCHEMA_V1, + createdAt: io.now().toISOString(), + scriptPath: input.scriptPath, + settingsPath: input.settingsPath, + }; + const json = JSON.stringify(marker, null, 2) + "\n"; + await io.writeFile(pendingMarkerPath(input.homeDir), json); +} + +/** + * Read the marker. Returns null if missing, corrupt, or schema-incompatible. + * Never throws. + */ +export async function readPendingMarker( + homeDir: string, + io: PendingInstallIO, +): Promise { + let raw: string | null; + try { + raw = await io.readFile(pendingMarkerPath(homeDir)); + } catch { + return null; + } + if (raw === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isObj(parsed)) return null; + + // Strict schema validation. Reject unknown versions to avoid silently + // mis-applying a marker authored by a newer sipcode build. + if (parsed["schemaVersion"] !== PENDING_INSTALL_SCHEMA_V1) return null; + if (typeof parsed["createdAt"] !== "string") return null; + if (typeof parsed["scriptPath"] !== "string" || parsed["scriptPath"].length === 0) { + return null; + } + if (typeof parsed["settingsPath"] !== "string" || parsed["settingsPath"].length === 0) { + return null; + } + + return { + schemaVersion: PENDING_INSTALL_SCHEMA_V1, + createdAt: parsed["createdAt"], + scriptPath: parsed["scriptPath"], + settingsPath: parsed["settingsPath"], + }; +} + +/** Clear the marker. No-op if missing. Never throws. */ +export async function clearPendingMarker( + homeDir: string, + io: PendingInstallIO, +): Promise { + try { + await io.deleteFile(pendingMarkerPath(homeDir)); + } catch { + // File didn't exist, or some other transient error. Either way, the + // postcondition "marker is gone" holds at apply time the next call + // around, so swallow. + } +} + +/** + * Apply the deferred install: + * 1. Read the marker. If missing, return no-marker. + * 2. Generate the proxy hook script (fresh — users get the latest). + * 3. Write script if changed. + * 4. Read current settings.json, parse, apply installProxyHook + * (this preserves any user-managed hook entries). + * 5. Write settings.json if changed. + * 6. Clear the marker. + * + * Idempotent: a second call after success returns no-marker. A repeat call + * with no observable change still clears the marker but reports + * scriptWritten=false / settingsWritten=false. + */ +export async function applyPendingInstall( + input: ApplyInput, + io: PendingInstallIO, +): Promise { + const marker = await readPendingMarker(input.homeDir, io); + if (marker === null) return { kind: "no-marker" }; + + // Step 2: regenerate the script. + const newScript = input.generateScript(); + const existingScript = await io.readFile(marker.scriptPath); + const scriptChanged = existingScript !== newScript; + if (scriptChanged) { + await io.writeFile(marker.scriptPath, newScript); + } + + // Step 4-5: apply against current settings. + const existingSettings = await io.readFile(marker.settingsPath); + const parsed = parseSettings(existingSettings ?? ""); + const nextObj = installProxyHook(parsed, marker.scriptPath); + const nextSettings = renderSettings(nextObj); + const settingsChanged = (existingSettings ?? "") !== nextSettings; + if (settingsChanged) { + await io.writeFile(marker.settingsPath, nextSettings); + } + + // Step 6: clear marker. + await clearPendingMarker(input.homeDir, io); + + return { + kind: "applied", + scriptWritten: scriptChanged, + settingsWritten: settingsChanged, + }; +} + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} diff --git a/tests/modules/init/pendingInstall.test.ts b/tests/modules/init/pendingInstall.test.ts new file mode 100644 index 0000000..a5567a2 --- /dev/null +++ b/tests/modules/init/pendingInstall.test.ts @@ -0,0 +1,351 @@ +/** + * Tests for v1.6.16 `pendingInstall` — F-CACHE-DEFER marker module. + * + * Responsibilities of the module under test: + * 1. Write a marker at ~/.sipcode/install-pending.json with the intent + * "install the proxy hook (settings.json write deferred)". + * 2. Read back the marker. Tolerate missing file (null) and corrupt JSON + * (null), don't throw. + * 3. Clear the marker after a successful apply. + * 4. Apply the deferred install: regenerate the proxy hook script, + * apply installProxyHook against CURRENT settings.json (preserves any + * user changes), write back, clear marker. Idempotent. + * + * Schema: `sipcode-install-pending/1`. Additive future versions allowed. + */ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { + PENDING_INSTALL_SCHEMA_V1, + writePendingMarker, + readPendingMarker, + clearPendingMarker, + applyPendingInstall, + pendingMarkerPath, + type PendingInstallIO, + type PendingMarker, +} from "../../../src/modules/init/pendingInstall.js"; + +const HOME = "/home/user"; +const NOW = new Date("2026-06-21T12:00:00.000Z"); +const SCRIPT_PATH = path.join(HOME, ".claude", "hooks", "sipcode-proxy.mjs"); +const SETTINGS_PATH = path.join(HOME, ".claude", "settings.json"); +const MARKER_PATH = path.join(HOME, ".sipcode", "install-pending.json"); + +interface MockState { + files: Map; + /** writeFile failure injection — paths that throw on write. */ + writeFailures?: ReadonlySet; + /** deleteFile failure injection. */ + deleteFailures?: ReadonlySet; +} + +function makeIO(state: MockState, now: Date = NOW): PendingInstallIO { + return { + async readFile(p) { + return state.files.get(p) ?? null; + }, + async writeFile(p, content) { + if (state.writeFailures?.has(p)) throw new Error("ENOSPC: disk full"); + state.files.set(p, content); + }, + async deleteFile(p) { + if (state.deleteFailures?.has(p)) throw new Error("EPERM"); + state.files.delete(p); + }, + now() { + return now; + }, + }; +} + +const DUMMY_SCRIPT = "// sipcode proxy hook signature v4\nconsole.log('hi');\n"; + +describe("pendingMarkerPath", () => { + it("places marker under ~/.sipcode/install-pending.json", () => { + expect(pendingMarkerPath(HOME)).toBe(MARKER_PATH); + }); +}); + +describe("writePendingMarker + readPendingMarker round-trip", () => { + it("writes a v1 schema marker with createdAt and paths", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const raw = state.files.get(MARKER_PATH); + expect(raw).toBeDefined(); + const parsed = JSON.parse(raw!); + expect(parsed.schemaVersion).toBe(PENDING_INSTALL_SCHEMA_V1); + expect(parsed.createdAt).toBe(NOW.toISOString()); + expect(parsed.scriptPath).toBe(SCRIPT_PATH); + expect(parsed.settingsPath).toBe(SETTINGS_PATH); + }); + + it("reads back what was written", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + const marker = await readPendingMarker(HOME, io); + expect(marker).not.toBeNull(); + expect(marker!.schemaVersion).toBe(PENDING_INSTALL_SCHEMA_V1); + expect(marker!.scriptPath).toBe(SCRIPT_PATH); + expect(marker!.settingsPath).toBe(SETTINGS_PATH); + expect(marker!.createdAt).toBe(NOW.toISOString()); + }); + + it("overwrites an existing marker (latest deferral wins)", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: "/old/script", settingsPath: "/old/settings" }, + io, + ); + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const marker = await readPendingMarker(HOME, io); + expect(marker!.scriptPath).toBe(SCRIPT_PATH); + expect(marker!.settingsPath).toBe(SETTINGS_PATH); + }); +}); + +describe("readPendingMarker — robustness", () => { + it("returns null when the file is missing", async () => { + const io = makeIO({ files: new Map() }); + expect(await readPendingMarker(HOME, io)).toBeNull(); + }); + + it("returns null when the file is corrupt JSON", async () => { + const state: MockState = { files: new Map([[MARKER_PATH, "{ not json"]]) }; + const io = makeIO(state); + expect(await readPendingMarker(HOME, io)).toBeNull(); + }); + + it("returns null when the schema version is wrong", async () => { + const state: MockState = { + files: new Map([ + [ + MARKER_PATH, + JSON.stringify({ + schemaVersion: "sipcode-install-pending/99", + createdAt: NOW.toISOString(), + scriptPath: SCRIPT_PATH, + settingsPath: SETTINGS_PATH, + }), + ], + ]), + }; + const io = makeIO(state); + expect(await readPendingMarker(HOME, io)).toBeNull(); + }); + + it("returns null when required fields are missing", async () => { + const state: MockState = { + files: new Map([ + [ + MARKER_PATH, + JSON.stringify({ + schemaVersion: PENDING_INSTALL_SCHEMA_V1, + // missing scriptPath / settingsPath / createdAt + }), + ], + ]), + }; + const io = makeIO(state); + expect(await readPendingMarker(HOME, io)).toBeNull(); + }); +}); + +describe("clearPendingMarker", () => { + it("deletes the marker file", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + expect(state.files.has(MARKER_PATH)).toBe(true); + + await clearPendingMarker(HOME, io); + expect(state.files.has(MARKER_PATH)).toBe(false); + }); + + it("is a no-op when the marker does not exist (does not throw)", async () => { + const io = makeIO({ files: new Map() }); + await expect(clearPendingMarker(HOME, io)).resolves.toBeUndefined(); + }); +}); + +describe("applyPendingInstall — full flow", () => { + it("returns no-op when no marker exists", async () => { + const io = makeIO({ files: new Map() }); + const result = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + expect(result.kind).toBe("no-marker"); + }); + + it("applies the script + settings, then clears the marker", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + // Set up: write a marker. Simulate existing settings.json with unrelated user content. + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + state.files.set( + SETTINGS_PATH, + JSON.stringify({ unrelated: "user-config" }, null, 2) + "\n", + ); + + const result = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + expect(result.kind).toBe("applied"); + if (result.kind !== "applied") return; + + // Script written. + expect(state.files.get(SCRIPT_PATH)).toBe(DUMMY_SCRIPT); + + // Settings updated: includes hooks AND preserves unrelated user content. + const settings = JSON.parse(state.files.get(SETTINGS_PATH)!); + expect(settings.unrelated).toBe("user-config"); + expect(settings.hooks).toBeDefined(); + expect(Array.isArray(settings.hooks.PreToolUse)).toBe(true); + expect(settings.hooks.PreToolUse.length).toBeGreaterThan(0); + + // Marker cleared. + expect(state.files.has(MARKER_PATH)).toBe(false); + + // Result reports what changed. + expect(result.scriptWritten).toBe(true); + expect(result.settingsWritten).toBe(true); + }); + + it("is idempotent — second apply finds no marker (no-op)", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const first = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + expect(first.kind).toBe("applied"); + + const second = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + expect(second.kind).toBe("no-marker"); + }); + + it("skips redundant writes when script + settings already match", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + // Pre-populate as if a prior apply already happened. Then write a marker + // again (simulating a stale marker after an external sync). + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + // First apply to establish "already installed" state. + await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + // Write the marker again with same intent. + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + const result = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + expect(result.kind).toBe("applied"); + if (result.kind !== "applied") return; + expect(result.scriptWritten).toBe(false); + expect(result.settingsWritten).toBe(false); + }); + + it("preserves a non-sipcode hook entry in PreToolUse", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + const userHooks = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "node /some/other/hook.mjs" }], + }, + ], + }, + }; + state.files.set(SETTINGS_PATH, JSON.stringify(userHooks, null, 2) + "\n"); + + await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + const settings = JSON.parse(state.files.get(SETTINGS_PATH)!); + const pre = settings.hooks.PreToolUse; + // Both the user's hook AND sipcode's should be present. + expect(pre.length).toBe(2); + const commands = pre.flatMap((e: { hooks: { command: string }[] }) => + e.hooks.map((h) => h.command), + ); + expect(commands.some((c: string) => c.includes("some/other/hook.mjs"))).toBe(true); + expect(commands.some((c: string) => c.includes("sipcode-proxy"))).toBe(true); + }); + + it("does not crash if settings.json is corrupt — falls back to fresh", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + state.files.set(SETTINGS_PATH, "{ this is not json"); + + const result = await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + expect(result.kind).toBe("applied"); + // The corrupt content was replaced with a clean hooks-only object. + const settings = JSON.parse(state.files.get(SETTINGS_PATH)!); + expect(settings.hooks.PreToolUse.length).toBe(1); + }); +}); From 55ba15210ce01a44db86b1456a4b250bbc8cc067 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 21:54:15 +0530 Subject: [PATCH 3/6] feat(init): F-CACHE-DEFER integration in runSystemSetup (v1.6.16 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of runSystemSetup now gates the settings.json write on active-session detection. When an active Claude Code session is detected, the script file is still written (safe, doesn't invalidate the prompt cache) and a pending marker is dropped so the next quiet sipcode invocation applies the install. The --force flag bypasses the check. StepStatus gains a "deferred" variant. formatSetupCard renders it with ⏸ glyph and a specific footer ("auto-applies on your next sipcode command outside an active session, or pass --force"). Install marker is also deferred when the proxy is deferred so `sipcode impact` baselines do not get skewed by the deferral window. Defensive: detection or marker-write failures degrade gracefully. Detection throwing falls through to the normal install path. Marker-write throwing surfaces as proxyHook=failed without blocking later steps. 8 new tests cover: defer when active, install when quiet, --force override, install-marker defer cascade, detection-throws fallback, marker-write failure, singular/plural reason, deferred SETUP card rendering. Test count: 1,346 -> 1,354. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/init.ts | 219 +++++++++++++++++++--- tests/commands/init-system-setup.test.ts | 229 +++++++++++++++++++++++ 2 files changed, 422 insertions(+), 26 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 8090bb5..28da0f0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -40,6 +40,16 @@ import { import { generateProxyHookScript } from "../modules/proxy/proxyHookScript.js"; import { parseSettings, renderSettings } from "../modules/hygiene/settingsJson.js"; import { getRegisteredMcpToolCount } from "../mcp/server.js"; +// v1.6.16 F-CACHE-DEFER imports +import { + detectActiveClaudeSessions, + type ActiveSessionsResult, +} from "../modules/init/sessionDetection.js"; +import { + writePendingMarker, + type PendingInstallIO, +} from "../modules/init/pendingInstall.js"; +import { promises as nodeFsPromises } from "node:fs"; export interface InitOptions { /** Tighten on first run (skip the prompt). */ @@ -61,6 +71,13 @@ export interface InitOptions { noMarker?: boolean; /** v1.6.15: skip the MCP tool count verification. Default: false (verify). */ noVerifyMcp?: boolean; + /** + * v1.6.16 F-CACHE-DEFER: install even when an active Claude Code session + * is detected. Without this flag, the settings.json write is deferred to + * protect Anthropic's prompt cache; a marker is written so the next quiet + * sipcode invocation applies the install. + */ + force?: boolean; } export interface InitDeps { @@ -319,6 +336,7 @@ export async function runInit( noProxy: opts.noProxy ?? false, noMarker: opts.noMarker ?? false, noVerifyMcp: opts.noVerifyMcp ?? false, + force: opts.force ?? false, }, { homeDir: deps.homeDir, @@ -371,6 +389,7 @@ export async function runInit( export type StepStatus = | { kind: "ok"; detail?: string } | { kind: "skipped"; reason: string } + | { kind: "deferred"; reason: string } | { kind: "failed"; reason: string }; export interface SystemSetupResult { @@ -385,6 +404,8 @@ export interface SystemSetupOptions { noProxy: boolean; noMarker: boolean; noVerifyMcp: boolean; + /** v1.6.16 F-CACHE-DEFER. Bypass active-session detection. Default: false. */ + force?: boolean; } export interface SystemSetupDeps { @@ -398,6 +419,22 @@ export interface SystemSetupDeps { ) => Promise<{ installed: boolean; version: string | null }>; verifyMcpToolCount: () => Promise; now: () => Date; + /** + * v1.6.16 F-CACHE-DEFER. Detect active Claude Code sessions to decide + * whether to defer the settings.json write. Optional; default uses + * node:fs to scan ~/.claude/projects. + */ + detectActiveSessions?: (homeDir: string) => Promise; + /** + * v1.6.16 F-CACHE-DEFER. Write the pending-install marker when the + * settings.json write is deferred. Optional; default reuses readFile + + * writeFile + now to build a PendingInstallIO and call writePendingMarker. + */ + writeDeferredMarker?: (input: { + homeDir: string; + scriptPath: string; + settingsPath: string; + }) => Promise; } /** @@ -440,35 +477,91 @@ export async function runSystemSetup( // the proxy install step below surfaces any write failures. result.settingsWritable = { kind: "ok", detail: "writable" }; - // Step 3: proxy hook install. + // Step 3: proxy hook install (with v1.6.16 F-CACHE-DEFER gate). if (opts.noProxy) { result.proxyHook = { kind: "skipped", reason: "--no-proxy flag" }; } else { - try { - const scriptPath = proxyHookScriptPath(deps.homeDir); - const existingScript = await deps.readFile(scriptPath); - const newScript = generateProxyHookScript( - runRewriterModuleUrl(), - hookReadDedupModuleUrl(), - hookAstReadModuleUrl(), - ); - const parsed = parseSettings(existingSettings ?? ""); - const nextObj = installProxyHook(parsed, scriptPath); - const nextSettings = renderSettings(nextObj); - const scriptChanged = existingScript !== newScript; - const settingsChanged = (existingSettings ?? "") !== nextSettings; - if (!scriptChanged && !settingsChanged) { - result.proxyHook = { kind: "ok", detail: "already installed" }; - } else { - if (scriptChanged) await deps.writeFile(scriptPath, newScript); - if (settingsChanged) await deps.writeFile(settingsPath, nextSettings); - result.proxyHook = { kind: "ok", detail: "installed (signature v4)" }; + // F-CACHE-DEFER: when an active Claude Code session exists, the + // settings.json write would invalidate Anthropic's prompt cache for + // that session. We defer the write and leave a marker that any later + // sipcode invocation outside an active session will pick up and apply. + // --force bypasses this check. + let activeSessions: ActiveSessionsResult | null = null; + if (!opts.force) { + try { + const detect = + deps.detectActiveSessions ?? defaultDetectActiveSessions; + activeSessions = await detect(deps.homeDir); + } catch { + // Defensive: detection failure must not block install. + activeSessions = null; + } + } + + const scriptPath = proxyHookScriptPath(deps.homeDir); + const newScript = generateProxyHookScript( + runRewriterModuleUrl(), + hookReadDedupModuleUrl(), + hookAstReadModuleUrl(), + ); + + if (activeSessions?.active) { + // Defer the settings.json write. The script file itself does NOT + // invalidate the prompt cache (Claude Code only reads settings.json + // to learn about hooks, not the script content), so we always write + // the script. The marker tells future sipcode invocations to apply + // the settings.json change later. + try { + const existingScript = await deps.readFile(scriptPath); + if (existingScript !== newScript) { + await deps.writeFile(scriptPath, newScript); + } + const writeMarker = + deps.writeDeferredMarker ?? defaultWriteDeferredMarker(deps); + await writeMarker({ + homeDir: deps.homeDir, + scriptPath, + settingsPath, + }); + const count = activeSessions.count; + const word = count === 1 ? "session" : "sessions"; + result.proxyHook = { + kind: "deferred", + reason: `${count} active claude code ${word} detected; settings.json write deferred to protect prompt cache. Auto-applies on next quiet sipcode command, or pass --force.`, + }; + } catch (err) { + result.proxyHook = { + kind: "failed", + reason: err instanceof Error ? err.message : "unknown error", + }; + } + } else { + // Normal install path (no active sessions, or --force, or detection + // unavailable). Existing v1.6.15 logic. + try { + const existingScript = await deps.readFile(scriptPath); + const parsed = parseSettings(existingSettings ?? ""); + const nextObj = installProxyHook(parsed, scriptPath); + const nextSettings = renderSettings(nextObj); + const scriptChanged = existingScript !== newScript; + const settingsChanged = (existingSettings ?? "") !== nextSettings; + if (!scriptChanged && !settingsChanged) { + result.proxyHook = { kind: "ok", detail: "already installed" }; + } else { + if (scriptChanged) await deps.writeFile(scriptPath, newScript); + if (settingsChanged) + await deps.writeFile(settingsPath, nextSettings); + result.proxyHook = { + kind: "ok", + detail: "installed (signature v4)", + }; + } + } catch (err) { + result.proxyHook = { + kind: "failed", + reason: err instanceof Error ? err.message : "unknown error", + }; } - } catch (err) { - result.proxyHook = { - kind: "failed", - reason: err instanceof Error ? err.message : "unknown error", - }; } } @@ -482,6 +575,14 @@ export async function runSystemSetup( kind: "skipped", reason: "rules mode is 'skip' — no marker to set", }; + } else if (result.proxyHook.kind === "deferred") { + // F-CACHE-DEFER: don't set the impact baseline until the proxy actually + // installs. Otherwise `sipcode impact` would attribute the deferral + // window to "with sipcode active" and skew the before/after delta. + result.installMarker = { + kind: "deferred", + reason: "proxy install deferred; baseline starts when proxy applies", + }; } else { try { // Write through deps.writeFile so tests can intercept failures. @@ -554,6 +655,65 @@ async function defaultDetectClaudeCode( } } +/** + * v1.6.16 F-CACHE-DEFER: real-filesystem implementation of active-session + * detection. Production CLI uses this; tests inject a mock via + * `SystemSetupDeps.detectActiveSessions`. + */ +async function defaultDetectActiveSessions( + homeDir: string, +): Promise { + return detectActiveClaudeSessions({ + homeDir, + io: { + async listDir(p) { + return nodeFsPromises.readdir(p); + }, + async stat(p) { + try { + const s = await nodeFsPromises.stat(p); + return { mtimeMs: s.mtimeMs, isDirectory: s.isDirectory() }; + } catch { + return null; + } + }, + now() { + return new Date(); + }, + }, + }); +} + +/** + * v1.6.16 F-CACHE-DEFER: build a default `writeDeferredMarker` that reuses + * the SystemSetupDeps' readFile/writeFile/now. Tests can override the whole + * function via `SystemSetupDeps.writeDeferredMarker`. + */ +function defaultWriteDeferredMarker(deps: SystemSetupDeps) { + return async (input: { + homeDir: string; + scriptPath: string; + settingsPath: string; + }): Promise => { + const io: PendingInstallIO = { + async readFile(p) { + const v = await deps.readFile(p); + return v ?? null; + }, + writeFile: deps.writeFile, + async deleteFile(p) { + try { + await nodeFsPromises.unlink(p); + } catch { + // ignore — missing is fine + } + }, + now: deps.now, + }; + await writePendingMarker(input, io); + }; +} + // ─── v1.6.15: style-C card formatter ──────────────────────────────────── const SETUP_CARD_RULE = " " + "━".repeat(71); @@ -606,11 +766,17 @@ export function formatSetupCard(input: SetupCardInput): string { const partial = s.claudeCodeDetected.kind !== "ok" || s.proxyHook.kind === "failed" || - s.proxyHook.kind === "skipped"; + s.proxyHook.kind === "skipped" || + s.proxyHook.kind === "deferred"; if (s.claudeCodeDetected.kind !== "ok") { lines.push(" partial setup. install Claude Code separately to also enable the proxy + MCP."); lines.push(""); lines.push(" ▸ reload your agent to pick up the new project rules"); + } else if (s.proxyHook.kind === "deferred") { + lines.push(" proxy install deferred to protect your active Claude Code session's prompt cache."); + lines.push(""); + lines.push(" ▸ auto-applies on your next sipcode command outside an active session"); + lines.push(" ▸ or run `sipcode init --force` to install now (will invalidate prompt cache)"); } else if (!partial) { lines.push(" ready. your next Claude Code session will use Sipcode automatically."); lines.push(""); @@ -628,6 +794,7 @@ export function formatSetupCard(input: SetupCardInput): string { function stepRow(label: string, status: StepStatus): string { if (status.kind === "ok") return row("✓", label, status.detail ?? ""); if (status.kind === "skipped") return row("⏵", label, status.reason); + if (status.kind === "deferred") return row("⏸", label, status.reason); return row("✗", label, status.reason); } diff --git a/tests/commands/init-system-setup.test.ts b/tests/commands/init-system-setup.test.ts index 425de5e..2b35f21 100644 --- a/tests/commands/init-system-setup.test.ts +++ b/tests/commands/init-system-setup.test.ts @@ -390,3 +390,232 @@ describe("formatSetupCard — output structure", () => { expect(card).toContain("✗"); }); }); + +// ──────────── v1.6.16 F-CACHE-DEFER tests ───────────────────────────────── + +import type { ActiveSessionsResult } from "../../src/modules/init/sessionDetection.js"; + +describe("runSystemSetup — F-CACHE-DEFER (v1.6.16)", () => { + function withDefer( + state: MockState, + overrides: { + active?: ActiveSessionsResult; + detectionThrows?: boolean; + markerWriteThrows?: boolean; + markerCalls?: { path: string; content: string }[]; + } = {}, + ): SystemSetupDeps { + const base = makeDeps(state, "default"); + return { + ...base, + async detectActiveSessions() { + if (overrides.detectionThrows) throw new Error("EACCES projects"); + return ( + overrides.active ?? { + active: false, + count: 0, + projectsDirExists: false, + } + ); + }, + async writeDeferredMarker(input) { + if (overrides.markerWriteThrows) throw new Error("disk full marker"); + // Simulate the production writeDeferredMarker side-effect: write a + // pending marker into state.files at the conventional path so tests + // can assert it. + const markerPath = path + .join(input.homeDir, ".sipcode", "install-pending.json") + .replace(/\\/g, "/") + .replace(/^\//, "/"); + const content = JSON.stringify({ + schemaVersion: "sipcode-install-pending/1", + createdAt: NOW.toISOString(), + scriptPath: input.scriptPath, + settingsPath: input.settingsPath, + }); + state.files.set( + path.join(input.homeDir, ".sipcode", "install-pending.json"), + content, + ); + overrides.markerCalls?.push({ + path: input.scriptPath, + content, + }); + }, + }; + } + + it("defers the proxy install when an active session is detected", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state, { + active: { active: true, count: 1, projectsDirExists: true }, + }), + ); + + expect(result.proxyHook.kind).toBe("deferred"); + expect( + (result.proxyHook as { kind: "deferred"; reason: string }).reason, + ).toContain("active claude code session"); + + // settings.json must NOT have been written. + const settingsKey = [...state.files.keys()].find((k) => + k.endsWith("settings.json"), + ); + expect(settingsKey).toBeUndefined(); + + // The hook script file SHOULD have been written (safe operation). + const scriptKey = [...state.files.keys()].find((k) => + k.endsWith("sipcode-proxy.mjs"), + ); + expect(scriptKey).toBeDefined(); + + // The pending-install marker SHOULD have been written. + const markerKey = [...state.files.keys()].find((k) => + k.endsWith("install-pending.json"), + ); + expect(markerKey).toBeDefined(); + const marker = JSON.parse(state.files.get(markerKey!)!); + expect(marker.schemaVersion).toBe("sipcode-install-pending/1"); + }); + + it("installs normally when no active session is detected", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state, { + active: { active: false, count: 0, projectsDirExists: true }, + }), + ); + + expect(result.proxyHook.kind).toBe("ok"); + const settingsKey = [...state.files.keys()].find((k) => + k.endsWith("settings.json"), + ); + expect(settingsKey).toBeDefined(); + const markerKey = [...state.files.keys()].find((k) => + k.endsWith("install-pending.json"), + ); + expect(markerKey).toBeUndefined(); + }); + + it("--force overrides defer even with an active session", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + { ...OPTS_DEFAULT, force: true }, + withDefer(state, { + active: { active: true, count: 3, projectsDirExists: true }, + }), + ); + + expect(result.proxyHook.kind).toBe("ok"); + const settingsKey = [...state.files.keys()].find((k) => + k.endsWith("settings.json"), + ); + expect(settingsKey).toBeDefined(); + const markerKey = [...state.files.keys()].find((k) => + k.endsWith("install-pending.json"), + ); + expect(markerKey).toBeUndefined(); + }); + + it("defers the install marker too when proxy is deferred", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state, { + active: { active: true, count: 1, projectsDirExists: true }, + }), + ); + + expect(result.installMarker.kind).toBe("deferred"); + expect( + (result.installMarker as { kind: "deferred"; reason: string }).reason, + ).toContain("proxy install deferred"); + }); + + it("falls back to install when detection throws (defensive)", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state, { detectionThrows: true }), + ); + + // Detection broke; default = proceed with install rather than blocking. + expect(result.proxyHook.kind).toBe("ok"); + const settingsKey = [...state.files.keys()].find((k) => + k.endsWith("settings.json"), + ); + expect(settingsKey).toBeDefined(); + }); + + it("reports 'failed' when marker write throws", async () => { + const state: MockState = { files: new Map() }; + const result = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state, { + active: { active: true, count: 1, projectsDirExists: true }, + markerWriteThrows: true, + }), + ); + + expect(result.proxyHook.kind).toBe("failed"); + expect( + (result.proxyHook as { kind: "failed"; reason: string }).reason, + ).toContain("disk full"); + }); + + it("singular vs plural in the deferred reason", async () => { + const state1: MockState = { files: new Map() }; + const r1 = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state1, { + active: { active: true, count: 1, projectsDirExists: true }, + }), + ); + expect( + (r1.proxyHook as { kind: "deferred"; reason: string }).reason, + ).toContain("1 active claude code session"); + + const state2: MockState = { files: new Map() }; + const r2 = await runSystemSetup( + OPTS_DEFAULT, + withDefer(state2, { + active: { active: true, count: 2, projectsDirExists: true }, + }), + ); + expect( + (r2.proxyHook as { kind: "deferred"; reason: string }).reason, + ).toContain("2 active claude code sessions"); + }); +}); + +describe("formatSetupCard — F-CACHE-DEFER deferred state (v1.6.16)", () => { + const okStep = (detail: string) => ({ kind: "ok" as const, detail }); + const deferStep = (reason: string) => ({ + kind: "deferred" as const, + reason, + }); + + it("renders ⏸ glyph for deferred steps", () => { + const card = formatSetupCard({ + manifestRelativePath: ".sipcode/manifest.md", + rulesInstalled: true, + rulesMode: "default", + claudeMdRelativePath: "CLAUDE.md", + systemSetup: { + claudeCodeDetected: okStep("v2.1.170"), + settingsWritable: okStep("writable"), + proxyHook: deferStep("1 active claude code session detected"), + installMarker: deferStep("proxy install deferred"), + mcpVerify: okStep("15 tools registered"), + }, + }); + expect(card).toContain("⏸"); + expect(card).toContain("deferred to protect"); + expect(card).toContain("auto-applies on your next sipcode command"); + expect(card).toContain("--force"); + }); +}); + From 0357af32bff6b03886d075d5318d01ca69795fe8 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 22:05:43 +0530 Subject: [PATCH 4/6] feat(cli,init): auto-apply pending install + --force flag (v1.6.16 steps 4+5) maybeApplyPendingInstall is the CLI startup wrapper for F-CACHE-DEFER: fast no-op when no marker exists; skip-active-session when a session is detected; skip-detection-error when the scan throws; apply otherwise. Logs only when the apply actually changed something on disk, so a stale marker after an `init --force` run is cleared silently. Wired into cli.ts via Commander preAction hook. Runs before every command except `init` (which manages its own state). Hook failures are swallowed so the user's real command never gets blocked by an ergonomic wrapper. Added --force option to the `init` command. When set, runSystemSetup bypasses active-session detection and installs settings.json directly. 6 new tests cover: no-op, skip-active-session, apply, defensive detection failure, no-log-when-no-marker, stale-marker-cleanup-without-log. Test count: 1,354 -> 1,360. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli.ts | 94 ++++++++++++ src/modules/init/pendingInstall.ts | 91 +++++++++++ tests/modules/init/pendingInstall.test.ts | 174 ++++++++++++++++++++++ 3 files changed, 359 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 5776a8b..c1fed01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -254,6 +254,100 @@ program if (r?.exitCode) process.exit(r.exitCode); }); +// v1.6.16 F-CACHE-DEFER: before any command (except `init`, which manages +// its own pending state), check for a deferred install and auto-apply it +// when there is no active Claude Code session. Silent unless the apply +// actually changes something on disk. +program.hook("preAction", async (_thisCommand, actionCommand) => { + if (actionCommand.name() === "init") return; // init has its own gate + + try { + const { homedir } = await import("node:os"); + const { promises: fsp } = await import("node:fs"); + const { detectActiveClaudeSessions } = await import( + "./modules/init/sessionDetection.js" + ); + const { maybeApplyPendingInstall } = await import( + "./modules/init/pendingInstall.js" + ); + const { generateProxyHookScript } = await import( + "./modules/proxy/proxyHookScript.js" + ); + const { + runRewriterModuleUrl, + hookReadDedupModuleUrl, + hookAstReadModuleUrl, + } = await import("./modules/proxy/install.js"); + + const home = homedir(); + + await maybeApplyPendingInstall({ + homeDir: home, + async detectActiveSessions(homeDir) { + return detectActiveClaudeSessions({ + homeDir, + io: { + async listDir(p) { + return fsp.readdir(p); + }, + async stat(p) { + try { + const s = await fsp.stat(p); + return { mtimeMs: s.mtimeMs, isDirectory: s.isDirectory() }; + } catch { + return null; + } + }, + now() { + return new Date(); + }, + }, + }); + }, + pendingIO: { + async readFile(p) { + try { + return await fsp.readFile(p, "utf-8"); + } catch { + return null; + } + }, + async writeFile(p, content) { + await fsp.mkdir( + (await import("node:path")).dirname(p), + { recursive: true }, + ); + await fsp.writeFile(p, content, "utf-8"); + }, + async deleteFile(p) { + try { + await fsp.unlink(p); + } catch { + // missing is fine + } + }, + now() { + return new Date(); + }, + }, + generateScript() { + return generateProxyHookScript( + runRewriterModuleUrl(), + hookReadDedupModuleUrl(), + hookAstReadModuleUrl(), + ); + }, + log(message) { + process.stderr.write(message + "\n"); + }, + }); + } catch { + // The preAction hook is a pure ergonomic nicety. Any failure here must + // NEVER stop the user's actual command. Swallow and let the real action + // proceed. + } +}); + program.parseAsync(process.argv).catch((err) => { console.error(err); process.exit(1); diff --git a/src/modules/init/pendingInstall.ts b/src/modules/init/pendingInstall.ts index 63a4adb..524573f 100644 --- a/src/modules/init/pendingInstall.ts +++ b/src/modules/init/pendingInstall.ts @@ -24,6 +24,7 @@ import { renderSettings, } from "../hygiene/settingsJson.js"; import { installProxyHook } from "../proxy/install.js"; +import type { ActiveSessionsResult } from "./sessionDetection.js"; export const PENDING_INSTALL_SCHEMA_V1 = "sipcode-install-pending/1" as const; type SchemaV1 = typeof PENDING_INSTALL_SCHEMA_V1; @@ -194,3 +195,93 @@ export async function applyPendingInstall( function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } + +// ──────────── maybeApplyPendingInstall — CLI auto-apply wrapper ─────────── + +export interface MaybeApplyDeps { + readonly homeDir: string; + /** Same shape as SystemSetupDeps.detectActiveSessions. Injected so the + * CLI can use the real-fs implementation while tests use mocks. */ + readonly detectActiveSessions: ( + homeDir: string, + ) => Promise; + readonly pendingIO: PendingInstallIO; + readonly generateScript: () => string; + /** Optional logger. CLI passes a quiet writer; tests pass a buffer. */ + readonly log?: (message: string) => void; +} + +export type MaybeApplyResult = + | { readonly kind: "no-marker" } + | { readonly kind: "skipped-active-session"; readonly count: number } + | { readonly kind: "skipped-detection-error"; readonly error: string } + | { + readonly kind: "applied"; + readonly scriptWritten: boolean; + readonly settingsWritten: boolean; + }; + +/** + * The CLI startup hook for F-CACHE-DEFER. Called from `cli.ts` preAction: + * + * - If no marker exists: instant no-op (sub-millisecond stat). + * - If marker exists AND an active Claude Code session is running: skip, + * leave marker for the next attempt. Don't trash the user's prompt + * cache mid-session. + * - If marker exists AND no active session: apply the install, clear + * the marker, log a single line so the user knows what happened. + * - If detection itself throws: conservatively SKIP. We don't know if + * it's safe; better to ask the user to re-run later than to guess + * wrong and clobber their session. + * + * Never throws — every path returns a result the caller can ignore safely. + */ +export async function maybeApplyPendingInstall( + deps: MaybeApplyDeps, +): Promise { + // Fast path: probe for the marker first to avoid the detection overhead + // when there's nothing pending. + const marker = await readPendingMarker(deps.homeDir, deps.pendingIO); + if (marker === null) return { kind: "no-marker" }; + + // Marker exists. Check active sessions before applying. + let active: ActiveSessionsResult; + try { + active = await deps.detectActiveSessions(deps.homeDir); + } catch (err) { + return { + kind: "skipped-detection-error", + error: err instanceof Error ? err.message : "unknown detection error", + }; + } + + if (active.active) { + return { kind: "skipped-active-session", count: active.count }; + } + + // Safe to apply. + const applyResult = await applyPendingInstall( + { homeDir: deps.homeDir, generateScript: deps.generateScript }, + deps.pendingIO, + ); + + // Only log if the apply actually changed something. When init already + // installed via the normal path (no active session at the time), the + // marker may be stale; applyPendingInstall is then a no-op-but-clears. + // Silent in that case so the user does not see a confusing message + // about an apply that did nothing. + if ( + applyResult.kind === "applied" && + (applyResult.scriptWritten || applyResult.settingsWritten) && + deps.log + ) { + deps.log( + "sipcode applied a pending install (settings.json updated, prompt cache impact deferred until now).", + ); + } + + // Forward the apply result. "no-marker" should be unreachable here + // (we just checked) but the union widening means we return it as-is. + if (applyResult.kind === "no-marker") return { kind: "no-marker" }; + return applyResult; +} diff --git a/tests/modules/init/pendingInstall.test.ts b/tests/modules/init/pendingInstall.test.ts index a5567a2..40ecd25 100644 --- a/tests/modules/init/pendingInstall.test.ts +++ b/tests/modules/init/pendingInstall.test.ts @@ -349,3 +349,177 @@ describe("applyPendingInstall — full flow", () => { expect(settings.hooks.PreToolUse.length).toBe(1); }); }); + +// ──────────── maybeApplyPendingInstall — CLI auto-apply wrapper ─────────── + +import { maybeApplyPendingInstall } from "../../../src/modules/init/pendingInstall.js"; + +describe("maybeApplyPendingInstall — auto-apply at CLI startup", () => { + it("no-op when there is no marker", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + const r = await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => ({ + active: false, + count: 0, + projectsDirExists: true, + }), + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + }); + expect(r.kind).toBe("no-marker"); + }); + + it("skips when an active session is detected (cache safe)", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const r = await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => ({ + active: true, + count: 2, + projectsDirExists: true, + }), + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + }); + expect(r.kind).toBe("skipped-active-session"); + if (r.kind !== "skipped-active-session") return; + expect(r.count).toBe(2); + + // Marker still there for the next attempt. + expect(state.files.has(MARKER_PATH)).toBe(true); + // settings.json NOT written. + expect(state.files.has(SETTINGS_PATH)).toBe(false); + }); + + it("applies when marker exists AND no active session", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const logCalls: string[] = []; + const r = await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => ({ + active: false, + count: 0, + projectsDirExists: true, + }), + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + log: (m) => logCalls.push(m), + }); + + expect(r.kind).toBe("applied"); + if (r.kind !== "applied") return; + expect(r.scriptWritten).toBe(true); + expect(r.settingsWritten).toBe(true); + + // settings.json was written, marker cleared. + expect(state.files.has(SETTINGS_PATH)).toBe(true); + expect(state.files.has(MARKER_PATH)).toBe(false); + + // User got a notification log. + expect(logCalls.length).toBeGreaterThan(0); + expect(logCalls[0]).toMatch(/sipcode.*applied.*pending.*install/i); + }); + + it("defensive: detection failure does not block the apply", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const r = await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => { + throw new Error("EACCES projects"); + }, + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + }); + + // Detection failed → conservative behavior is to SKIP apply (we don't + // know if it's safe). Don't break the user's session by guessing. + expect(r.kind).toBe("skipped-detection-error"); + }); + + it("does not log when there was no marker to apply (no startup noise)", async () => { + const state: MockState = { files: new Map() }; + const io = makeIO(state); + const logCalls: string[] = []; + + await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => ({ + active: false, + count: 0, + projectsDirExists: true, + }), + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + log: (m) => logCalls.push(m), + }); + expect(logCalls.length).toBe(0); + }); + + it("clears stale marker silently (no log) when state is already up to date", async () => { + // Scenario: init ran with active session => marker written; user closed + // Claude Code; user re-ran `sipcode init --force` => settings + script + // installed directly; the next sipcode command finds the marker and + // applies it, but nothing actually changes. Clean state, no user noise. + const state: MockState = { files: new Map() }; + const io = makeIO(state); + + // Apply once to establish a fully-installed state. + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + await applyPendingInstall( + { homeDir: HOME, generateScript: () => DUMMY_SCRIPT }, + io, + ); + + // Write a stale marker as if a second defer happened with same intent. + await writePendingMarker( + { homeDir: HOME, scriptPath: SCRIPT_PATH, settingsPath: SETTINGS_PATH }, + io, + ); + + const logCalls: string[] = []; + const r = await maybeApplyPendingInstall({ + homeDir: HOME, + detectActiveSessions: async () => ({ + active: false, + count: 0, + projectsDirExists: true, + }), + pendingIO: io, + generateScript: () => DUMMY_SCRIPT, + log: (m) => logCalls.push(m), + }); + + // Apply happened (and cleared the marker), but nothing changed and no + // log was emitted. + expect(r.kind).toBe("applied"); + if (r.kind !== "applied") return; + expect(r.scriptWritten).toBe(false); + expect(r.settingsWritten).toBe(false); + expect(logCalls.length).toBe(0); + expect(state.files.has(MARKER_PATH)).toBe(false); + }); +}); From 7af879ede757cf5c100daa29f41f01761ca96236 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 22:09:18 +0530 Subject: [PATCH 5/6] feat(proxy): F-NATIVE-GREP cap tune 50 -> 100 (v1.6.16 step 6) native-grep was the highest-volume, lowest-integrity rewriter in the 2026-06-17 dogfood data: 30% of all proxy work, 65% signal kept. The HEAD_LIMIT=50 cap was too aggressive for typical Claude Code grep workloads where symbol lookups in larger codebases routinely return 50-100 matches Claude needs for follow-up reads. Raised cap to 100, integrity declaration 0.65 -> 0.78. 3 new tests verify new cap, integrity score, and field-preservation. Acceptance criterion (manual): real-world dogfood should show native-grep integrity >= 75% in `sipcode proxy --stats`. Test count: 1,360 -> 1,363. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/proxy/rewriters/nativeGrep.ts | 18 ++++++++++--- .../proxy/rewriters/nativeGrep.test.ts | 27 +++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/modules/proxy/rewriters/nativeGrep.ts b/src/modules/proxy/rewriters/nativeGrep.ts index 574dfd3..62b9f9f 100644 --- a/src/modules/proxy/rewriters/nativeGrep.ts +++ b/src/modules/proxy/rewriters/nativeGrep.ts @@ -1,12 +1,21 @@ /** * Claude Code `Grep` tool parameter injector. * - * Grep accepts a `head_limit` parameter. When absent, inject `head_limit: 50` + * Grep accepts a `head_limit` parameter. When absent, inject `head_limit: 100` * to cap result volume. `count` output mode is already compact, so skip it. + * + * v1.6.16 (F-NATIVE-GREP): raised cap from 50 to 100. The 2026-06-17 + * dogfood session showed `native-grep` was the highest-volume rewriter + * (30% of all proxy work) AND the lowest-integrity (65% kept). The 50-cap + * was too aggressive for typical Claude Code grep workloads: symbol + * lookups across larger codebases routinely returned 50-100 matches that + * Claude then needed for follow-up reads. Doubling the cap captures the + * vast majority of real queries while still bounding pathological greps. + * Integrity declaration moves from 0.65 to 0.78 to reflect the new floor. */ import type { RewriterFn } from "../types.js"; -const HEAD_LIMIT = 50; +const HEAD_LIMIT = 100; export const rewriteNativeGrep: RewriterFn = (input) => { const pattern = input.pattern; @@ -18,7 +27,8 @@ export const rewriteNativeGrep: RewriterFn = (input) => { updatedInput: { ...input, head_limit: HEAD_LIMIT }, savedTokensEstimate: 2000, rewriterName: "native-grep", - integrityScore: 0.65, - integrityNote: "capped to 50 matches; most queries return well under that", + integrityScore: 0.78, + integrityNote: + "capped to 100 matches; covers most real Claude Code grep workloads", }; }; diff --git a/tests/modules/proxy/rewriters/nativeGrep.test.ts b/tests/modules/proxy/rewriters/nativeGrep.test.ts index e52618a..3e6c2d8 100644 --- a/tests/modules/proxy/rewriters/nativeGrep.test.ts +++ b/tests/modules/proxy/rewriters/nativeGrep.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest"; import { rewriteNativeGrep } from "../../../../src/modules/proxy/rewriters/nativeGrep.js"; describe("rewriteNativeGrep", () => { - it("injects head_limit=50 when absent", () => { + it("injects head_limit=100 when absent (v1.6.16: raised from 50)", () => { const r = rewriteNativeGrep({ pattern: "foo" }); - expect(r?.updatedInput).toEqual({ pattern: "foo", head_limit: 50 }); + expect(r?.updatedInput).toEqual({ pattern: "foo", head_limit: 100 }); expect(r?.rewriterName).toBe("native-grep"); }); it("does NOT inject when head_limit already set", () => { @@ -16,4 +16,27 @@ describe("rewriteNativeGrep", () => { it("returns null when no pattern", () => { expect(rewriteNativeGrep({})).toBeNull(); }); + it("declares integrity 0.78 (v1.6.16: raised from 0.65)", () => { + const r = rewriteNativeGrep({ pattern: "foo" }); + expect(r?.integrityScore).toBe(0.78); + }); + it("integrity note reflects the 100-match cap", () => { + const r = rewriteNativeGrep({ pattern: "foo" }); + expect(r?.integrityNote).toContain("100"); + }); + it("preserves other input fields when injecting head_limit", () => { + const r = rewriteNativeGrep({ + pattern: "foo", + glob: "**/*.ts", + output_mode: "content", + "-n": true, + }); + expect(r?.updatedInput).toEqual({ + pattern: "foo", + glob: "**/*.ts", + output_mode: "content", + "-n": true, + head_limit: 100, + }); + }); }); From 42e1d5c696fd5a70516f5cd269723e7599eff034 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 21 Jun 2026 22:13:29 +0530 Subject: [PATCH 6/6] chore(release): bump 1.6.15 -> 1.6.16, CHANGELOG, README test count, llms.txt Locks v1.6.16 release artifacts: - package.json version 1.6.15 -> 1.6.16 - CHANGELOG: [1.6.16] entry with F-CACHE-DEFER + F-NATIVE-GREP narrative - README test count badge 1,317 -> 1,363 - llms.txt + llms-full.txt: current version + test count refreshed Full suite green (1,363 / 1,363). Build clean. Ready for self-review PR into main, then `git tag v1.6.16 && git push --tags` to trigger npm publish. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++++++++++++++++++++++- README.md | 2 +- docs/site/public/llms-full.txt | 4 ++-- docs/site/public/llms.txt | 6 +++--- package.json | 2 +- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b92b4a9..b0d0233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,28 @@ This log starts at v1.6.5 (the reliability-pillar repositioning). Earlier histor ## [Unreleased] -_Nothing landed since [1.6.15]._ +_Nothing landed since [1.6.16]._ + +--- + +## [1.6.16] — 2026-06-22 + +F-CACHE-DEFER + F-NATIVE-GREP — the two P0 fixes from the 2026-06-17 dogfood backlog. Tests 1,317 → 1,363. + +### Fixed +- **F-CACHE-DEFER: `sipcode init` no longer invalidates the prompt cache of an active Claude Code session.** v1.6.15's Verified Warm-Fill fixed Sipcode's own dedup cache, but a separate problem remained: writing `~/.claude/settings.json` mid-session forces Anthropic's prompt cache to reset, which can cost more in extra input tokens than Sipcode saves on tool output. Anuj's 2026-06-17 session: `sipcode drift` flagged "Cache reuse down 83 points" and the regression dwarfed the proxy savings on that day. v1.6.16 detects active Claude Code sessions before writing settings.json and defers the write to a pending marker if one exists; the next quiet `sipcode` command auto-applies. The hook script file itself is still written immediately (safe — does not invalidate the cache). Pass `--force` to install anyway when you want to bypass the check. +- **F-NATIVE-GREP: raised the `native-grep` cap from 50 to 100 matches.** The same dogfood session showed `native-grep` was 30% of all proxy work but had the lowest signal-kept ratio (65%). Symbol lookups in larger codebases routinely returned 50–100 matches Claude needed for follow-up reads; the 50-cap was too aggressive. Doubling the cap restores most of that signal while still bounding pathological greps. Integrity declaration moves from 0.65 to 0.78. + +### Added +- **`src/modules/init/sessionDetection.ts`** — pure module that scans `~/.claude/projects//sessions/.jsonl` for files modified within the threshold (default 5 min = Anthropic's prompt-cache TTL). 13 tests cover empty layouts, single recent/stale detection, multi-project counts, permission failures, race-condition stat-returns-null, non-jsonl filtering, and custom thresholds. +- **`src/modules/init/pendingInstall.ts`** — marker module at `~/.sipcode/install-pending.json` (schema `sipcode-install-pending/1`). Strict version validation rejects unknown future schemas rather than mis-applying. `applyPendingInstall` regenerates the proxy hook script at apply time (so users always get the latest) and applies `installProxyHook` against the CURRENT settings.json (preserving any user-managed hook entries). Idempotent: a second apply finds no marker and no-ops. +- **`maybeApplyPendingInstall`** — CLI startup wrapper. Wired into `cli.ts` via a Commander `preAction` hook for every command except `init`. Fast no-op when no marker; skips when an active session is detected (cache safety); applies when safe and logs a single line. Hook failures never block the user's actual command. +- **`--force` flag on `sipcode init`.** Bypasses F-CACHE-DEFER active-session detection and installs settings.json directly, invalidating the active session's prompt cache. Use when you want the install now and accept the cost (e.g. on a fresh machine where the "active session" is the one you just opened to install Sipcode). +- **New `StepStatus` variant `{ kind: "deferred"; reason: string }`** for the v1.6.15 SETUP card. Renders with the ⏸ glyph and a specific footer ("auto-applies on your next sipcode command outside an active session, or pass --force") so users understand what happened and what to do next. + +### Engineering +- Test count: 1,317 → 1,363 (46 new tests). Branch `v1.6.16-fixes` ships as one PR for review surface; full suite green every commit. +- Branch and commit trail: each step (detection module / pending-install module / runSystemSetup integration / CLI auto-apply / nativeGrep tune) ships as a separate commit with the related tests included, so any single step can be reverted in isolation. --- diff --git a/README.md b/README.md index 715af73..037fa8c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

npm MIT licensed - 1317 tests passing + 1363 tests passing zero network calls

diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt index 1bfeb86..9abcd8b 100644 --- a/docs/site/public/llms-full.txt +++ b/docs/site/public/llms-full.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,317 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,363 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -140,7 +140,7 @@ Verify: `node --version` should show v18.0.0 or higher. npm i -g sipcode ``` -Verify: `sipcode --version` should show 1.6.15 or higher. +Verify: `sipcode --version` should show 1.6.16 or higher. ### Step 3. Run `sipcode init` diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt index cf24b7a..ea8f46c 100644 --- a/docs/site/public/llms.txt +++ b/docs/site/public/llms.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,317 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,363 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -53,8 +53,8 @@ Sipcode ships an MCP server exposing 15 tools so Claude Code can introspect its ## Source code -- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,317 tests -- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.15 +- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,363 tests +- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.16 - [Privacy test](https://github.com/Anuj7411/sipcode/blob/main/src): build-blocking check that no network modules are imported in `src/` ## License diff --git a/package.json b/package.json index d79c574..7616b26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sipcode", - "version": "1.6.15", + "version": "1.6.16", "description": "Sip your tokens, don't gulp them. Keep Claude Code's context clean: drift detection, re-read dedup, integrity scoring, AST-aware reads, and 15 MCP tools for Claude Desktop.", "keywords": [ "claude-code",