From cd4a7901a34b9b07c3f99b1e52aff755e7b4d7b7 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 03:01:58 +0530 Subject: [PATCH 1/9] feat(proxy): one-time GitHub star nudge after proxy --stats shows savings Closes the gap between usage and stars: 4k downloads/mo vs ~33 stars because nothing ever asks. After `sipcode proxy --stats` shows real rewrites (totalInvocations > 0), print one line pointing to the repo. Shown ONCE per machine ever, gated on a ~/.sipcode/.star-nudge marker (same pattern as the proxy/install markers). Never in --json mode, never when zero rewrites, never blocks, and makes no network call (privacy guard stays green). New src/lib/starNudge.ts is a pure gate (hasMarker/writeMarker seam); the copy lives in MESSAGES.starNudge. 7 new tests. Co-Authored-By: Claude Opus 4.8 --- src/commands/proxy.ts | 23 ++++++++++++- src/lib/messages.ts | 6 ++++ src/lib/starNudge.ts | 42 +++++++++++++++++++++++ tests/lib/starNudge.test.ts | 42 +++++++++++++++++++++++ tests/modules/proxy/proxy-command.test.ts | 39 +++++++++++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/lib/starNudge.ts create mode 100644 tests/lib/starNudge.test.ts diff --git a/src/commands/proxy.ts b/src/commands/proxy.ts index 0d9c3f7..426e0c2 100644 --- a/src/commands/proxy.ts +++ b/src/commands/proxy.ts @@ -28,6 +28,8 @@ import { import { generateProxyHookScript } from "../modules/proxy/proxyHookScript.js"; import { readReport } from "../modules/proxy/stats-store.js"; import { renderProxyReport } from "../modules/proxy/format-terminal.js"; +import { shouldShowStarNudge } from "../lib/starNudge.js"; +import { MESSAGES } from "../lib/messages.js"; export interface ProxyOptions { install?: boolean; @@ -88,7 +90,26 @@ export async function runProxy( // --stats — if (opts.stats) { const report = await readReport(statsDir); - stdout(opts.json ? JSON.stringify(report, null, 2) : renderProxyReport(report)); + if (opts.json) { + stdout(JSON.stringify(report, null, 2)); + return { exitCode: 0 }; + } + stdout(renderProxyReport(report)); + // One-time star nudge, only at a real value moment (rewrites happened). + // Marker lives under ~/.sipcode so it shows once per machine, ever. + if (report.totalInvocations > 0) { + const shown = await shouldShowStarNudge( + { + hasMarker: async (p) => (await readFile(p)) !== undefined, + writeMarker: writeFile, + }, + homeDir, + ); + if (shown) { + stdout(""); + stdout(MESSAGES.starNudge()); + } + } return { exitCode: 0 }; } diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 8a105fc..eadf398 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -272,6 +272,12 @@ export const MESSAGES = { `next: npx sipcode stats --group-by project`, ].join("\n"), + starNudge: () => + [ + ` ★ enjoying Sipcode? a GitHub star helps other devs find it:`, + ` https://github.com/Anuj7411/sipcode`, + ].join("\n"), + statsNoSessionsYet: () => [ `no Claude Code sessions found yet.`, diff --git a/src/lib/starNudge.ts b/src/lib/starNudge.ts new file mode 100644 index 0000000..b5b349a --- /dev/null +++ b/src/lib/starNudge.ts @@ -0,0 +1,42 @@ +/** + * One-time "star us on GitHub" nudge. + * + * Shown once per machine, ever, at a moment of demonstrated value (after + * `sipcode proxy --stats` reports real savings). Gated on a marker file under + * ~/.sipcode so it never repeats and never blocks. Makes NO network call: it + * only decides whether to print a line and writes a local marker. + */ +import path from "node:path"; + +export interface StarNudgeIO { + /** True if the nudge has already been shown (marker exists). */ + hasMarker(absPath: string): Promise; + /** Write the marker so the nudge never shows again. */ + writeMarker(absPath: string, content: string): Promise; +} + +const MARKER_CONTENT = "sipcode-star-nudge/1"; + +/** Path to the one-time marker under the user's ~/.sipcode directory. */ +export function starNudgeMarkerPath(homeDir: string): string { + return path.join(homeDir, ".sipcode", ".star-nudge"); +} + +/** + * Returns true the first time it is called for a machine, then false forever + * after. Writing the marker is best-effort: if the write fails we still return + * true this once (a rare repeat beats crashing a stats command). + */ +export async function shouldShowStarNudge( + io: StarNudgeIO, + homeDir: string, +): Promise { + const marker = starNudgeMarkerPath(homeDir); + if (await io.hasMarker(marker)) return false; + try { + await io.writeMarker(marker, MARKER_CONTENT); + } catch { + /* best-effort: still nudge once even if the marker write fails */ + } + return true; +} diff --git a/tests/lib/starNudge.test.ts b/tests/lib/starNudge.test.ts new file mode 100644 index 0000000..7d7d225 --- /dev/null +++ b/tests/lib/starNudge.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; +import { + shouldShowStarNudge, + starNudgeMarkerPath, +} from "../../src/lib/starNudge.js"; + +describe("shouldShowStarNudge", () => { + it("returns true the first time and writes the marker", async () => { + const writeMarker = vi.fn(async () => {}); + const io = { hasMarker: vi.fn(async () => false), writeMarker }; + const r = await shouldShowStarNudge(io, "/home/u"); + expect(r).toBe(true); + expect(writeMarker).toHaveBeenCalledWith( + starNudgeMarkerPath("/home/u"), + expect.any(String), + ); + }); + + it("returns false when the marker already exists (never repeats)", async () => { + const writeMarker = vi.fn(async () => {}); + const io = { hasMarker: vi.fn(async () => true), writeMarker }; + const r = await shouldShowStarNudge(io, "/home/u"); + expect(r).toBe(false); + expect(writeMarker).not.toHaveBeenCalled(); + }); + + it("still returns true once if the marker write fails (best-effort, no crash)", async () => { + const io = { + hasMarker: vi.fn(async () => false), + writeMarker: vi.fn(async () => { + throw new Error("EPERM"); + }), + }; + await expect(shouldShowStarNudge(io, "/home/u")).resolves.toBe(true); + }); + + it("puts the marker under ~/.sipcode", () => { + expect(starNudgeMarkerPath("/home/u").replace(/\\/g, "/")).toBe( + "/home/u/.sipcode/.star-nudge", + ); + }); +}); diff --git a/tests/modules/proxy/proxy-command.test.ts b/tests/modules/proxy/proxy-command.test.ts index 9d3e84f..af689e6 100644 --- a/tests/modules/proxy/proxy-command.test.ts +++ b/tests/modules/proxy/proxy-command.test.ts @@ -102,5 +102,44 @@ describe("runProxy", () => { expect(report.schemaVersion).toBe("sipcode-proxy/2"); expect(report.totalInvocations).toBe(0); }); + + it("shows the star nudge once when there are rewrites, then never again", async () => { + const dir = join(home, ".sipcode", "proxy-stats"); + await writeStats(dir, { + timestamp: "t", + toolName: "Bash", + rewriterName: "git-status", + savedTokensEstimate: 800, + }); + const first: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => first.push(s) }); + expect(first.join("\n")).toContain("a GitHub star helps"); + + const second: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => second.push(s) }); + expect(second.join("\n")).not.toContain("a GitHub star helps"); + }); + + it("never shows the star nudge in --json mode", async () => { + const dir = join(home, ".sipcode", "proxy-stats"); + await writeStats(dir, { + timestamp: "t", + toolName: "Bash", + rewriterName: "git-status", + savedTokensEstimate: 800, + }); + const out: string[] = []; + await runProxy( + { stats: true, json: true }, + { homeDir: home, stdout: (s) => out.push(s) }, + ); + expect(out.join("\n")).not.toContain("a GitHub star helps"); + }); + + it("does not nudge when there are zero rewrites", async () => { + const out: string[] = []; + await runProxy({ stats: true }, { homeDir: home, stdout: (s) => out.push(s) }); + expect(out.join("\n")).not.toContain("a GitHub star helps"); + }); }); }); From 1bd38811fb14192012d9070f15444dc7d5bf7f8f Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 03:03:46 +0530 Subject: [PATCH 2/9] docs(readme): add live GitHub-stars + npm-downloads social-proof badges A live star-count badge is self-reinforcing social proof, and a monthly downloads badge shows real adoption (~4k/mo). Both are the highest-ROI README change for converting visitors to stars. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2032237..a505fc9 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@

npm + GitHub stars + npm downloads per month MIT licensed 1373 tests passing zero network calls From 68a2f8438152d178193036c26a8586a48d43560d Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 04:31:48 +0530 Subject: [PATCH 3/9] docs(readme): fix --here scope tip (today has no --here, only why/stats) `sipcode today` summarizes all of today's sessions and does not accept --here; only `why` and `stats` register it. Correct the tip that wrongly listed today. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a505fc9..b21a344 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ npm uninstall -g sipcode Run any of them with `--help` for full options. -**Tip: which session do these report on?** `sipcode why`, `today`, and `stats` look at your most recent session across **all** projects by default, not the folder you happen to be standing in. So if you run `sipcode why` inside project A but project B had the most recent activity, you will see project B. To scope a command to the project you are currently in, add `--here` (for example, `sipcode why --here`). Use `sipcode why --list` to see every session and pick one with `--session `. +**Tip: which session do these report on?** `sipcode why` and `sipcode stats` look at your most recent session across **all** projects by default, not the folder you happen to be standing in. So if you run `sipcode why` inside project A but project B had the most recent activity, you will see project B. To scope either to the project you are currently in, add `--here` (for example, `sipcode why --here`). Use `sipcode why --list` to see every session and pick one with `--session `. (`sipcode today` always summarizes all of today's sessions and has no `--here`.) --- From 436ac7374da553874f1b7d8c9eacaa857dfd3dc4 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 04:41:30 +0530 Subject: [PATCH 4/9] fix(scope): --here now matches project paths containing spaces `why --here` / `stats --here` computed the project hash by replacing only `:` and slashes, but Claude Code also turns whitespace into `-` (so "C:\Projects\just research" is stored as "C--Projects-just-research"). The mismatch made --here silently find zero sessions for any path with a space. Extract a single cwdToProjectHash() in discover.ts (now also collapses whitespace) and use it from both why's listSessionsHere and stats, so the two copies can't drift again. 4 new tests pin the encoding. Co-Authored-By: Claude Opus 4.8 --- src/commands/stats.ts | 7 ++++-- src/modules/transcript/discover.ts | 15 ++++++++--- .../transcript/cwdToProjectHash.test.ts | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 tests/modules/transcript/cwdToProjectHash.test.ts diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 72adc0f..0da626c 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -14,7 +14,10 @@ import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { MESSAGES } from "../lib/messages.js"; import { resolveAgentFromOpts } from "../modules/agents/cli.js"; -import { resolveProjectsDir } from "../modules/transcript/discover.js"; +import { + resolveProjectsDir, + cwdToProjectHash, +} from "../modules/transcript/discover.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; @@ -151,7 +154,7 @@ export async function runStats( // --here filter: scope to the cwd's projectHash. if (opts.here) { - const cwdHash = cwd.replace(/:/g, "-").replace(/[\\/]/g, "-"); + const cwdHash = cwdToProjectHash(cwd); metas = metas.filter( (m) => m.projectHash === cwdHash || cwdHash.endsWith(m.projectHash), ); diff --git a/src/modules/transcript/discover.ts b/src/modules/transcript/discover.ts index 44ff85e..17fcbfa 100644 --- a/src/modules/transcript/discover.ts +++ b/src/modules/transcript/discover.ts @@ -108,12 +108,21 @@ export async function listSessionsHere( // Match by cwd path → claude-code style hash. // Windows: C:\Projects\Sipcode -> "C--Projects-Sipcode" // POSIX: /home/u/proj -> "-home-u-proj" - const cwdHash = cwd - .replace(/:/g, "-") - .replace(/[\\/]/g, "-"); + const cwdHash = cwdToProjectHash(cwd); return all.filter((s) => s.projectHash === cwdHash || cwdHash.endsWith(s.projectHash)); } +/** + * Encode a working-directory path the way Claude Code names its project dirs: + * the drive colon, slashes, AND whitespace all collapse to "-". Claude Code + * turns "C:\Projects\just research" into "C--Projects-just-research", so we + * must match the space-to-dash step or `--here` silently finds nothing for any + * project whose path contains a space. + */ +export function cwdToProjectHash(cwd: string): string { + return cwd.replace(/:/g, "-").replace(/[\\/\s]/g, "-"); +} + export async function findSessionById( fs: FileSystem, projectsDir: string, diff --git a/tests/modules/transcript/cwdToProjectHash.test.ts b/tests/modules/transcript/cwdToProjectHash.test.ts new file mode 100644 index 0000000..c8774cd --- /dev/null +++ b/tests/modules/transcript/cwdToProjectHash.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { cwdToProjectHash } from "../../../src/modules/transcript/discover.js"; + +describe("cwdToProjectHash", () => { + it("matches Claude Code's dir name for a Windows path WITH A SPACE", () => { + // regression: "just research" must become "just-research", not keep the space + expect(cwdToProjectHash("C:\\Projects\\just research")).toBe( + "C--Projects-just-research", + ); + }); + + it("matches a plain Windows path (no regression for the common case)", () => { + expect(cwdToProjectHash("C:\\Projects\\Sipcode")).toBe( + "C--Projects-Sipcode", + ); + }); + + it("encodes a POSIX path", () => { + expect(cwdToProjectHash("/home/u/proj")).toBe("-home-u-proj"); + }); + + it("collapses tabs and runs of whitespace to dashes", () => { + expect(cwdToProjectHash("C:\\a b\tc")).toBe("C--a-b-c"); + }); +}); From 408f14ea4862cce40a235d50c0b63179e0427961 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 13:58:14 +0530 Subject: [PATCH 5/9] fix: complete --here encoder + tsc pipefail (hard-audit findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness bugs from a full component audit: 1. cwdToProjectHash only collapsed `:/\ + whitespace`, but Claude Code replaces EVERY non-alphanumeric with `-` (verified: ".claude-mem" dir is stored as "--claude-mem"). So --here still mismatched any path with a dot, paren, underscore, etc. Now uses /[^A-Za-z0-9]/g. 2. tsc rewriter appended `2>&1 | head` with no pipefail, so a FAILING typecheck returned head's exit 0 — reporting a broken build as success to Claude. Prepend `set -o pipefail;` so tsc's non-zero status propagates. Co-Authored-By: Claude Opus 4.8 --- src/modules/proxy/rewriters/tsc.ts | 6 ++++-- src/modules/transcript/discover.ts | 12 +++++++----- tests/modules/proxy/rewriters/tsc.test.ts | 3 +++ tests/modules/transcript/cwdToProjectHash.test.ts | 7 +++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/modules/proxy/rewriters/tsc.ts b/src/modules/proxy/rewriters/tsc.ts index be7054d..8b5a16d 100644 --- a/src/modules/proxy/rewriters/tsc.ts +++ b/src/modules/proxy/rewriters/tsc.ts @@ -28,8 +28,10 @@ export const rewriteTsc: RewriterFn = (input) => { return null; } // Append a head cap. Stderr-to-stdout merge ensures we cap diagnostic output, - // not just successful builds. - const updated = `${cmd.trim()} 2>&1 | head -${HEAD_LIMIT}`; + // not just successful builds. `set -o pipefail` is critical: without it the + // pipeline's exit code is head's (always 0), so a FAILING tsc would report + // success to Claude. pipefail propagates tsc's non-zero status through head. + const updated = `set -o pipefail; ${cmd.trim()} 2>&1 | head -${HEAD_LIMIT}`; return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 3000, diff --git a/src/modules/transcript/discover.ts b/src/modules/transcript/discover.ts index 17fcbfa..ee5cc1d 100644 --- a/src/modules/transcript/discover.ts +++ b/src/modules/transcript/discover.ts @@ -114,13 +114,15 @@ export async function listSessionsHere( /** * Encode a working-directory path the way Claude Code names its project dirs: - * the drive colon, slashes, AND whitespace all collapse to "-". Claude Code - * turns "C:\Projects\just research" into "C--Projects-just-research", so we - * must match the space-to-dash step or `--here` silently finds nothing for any - * project whose path contains a space. + * EVERY character that is not a letter or digit collapses to "-". Verified + * empirically against ~/.claude/projects — Claude Code turns + * "C:\Projects\just research" into "C--Projects-just-research" and ".claude-mem" + * into "--claude-mem" (the dot is replaced too). Matching only ":/\ + whitespace" + * is not enough: any path with a dot, parenthesis, underscore, etc. would + * mismatch and `--here` would silently find nothing. */ export function cwdToProjectHash(cwd: string): string { - return cwd.replace(/:/g, "-").replace(/[\\/\s]/g, "-"); + return cwd.replace(/[^A-Za-z0-9]/g, "-"); } export async function findSessionById( diff --git a/tests/modules/proxy/rewriters/tsc.test.ts b/tests/modules/proxy/rewriters/tsc.test.ts index 33dee60..6cc6f05 100644 --- a/tests/modules/proxy/rewriters/tsc.test.ts +++ b/tests/modules/proxy/rewriters/tsc.test.ts @@ -4,6 +4,9 @@ import { rewriteTsc } from "../../../../src/modules/proxy/rewriters/tsc.js"; describe("rewriteTsc", () => { it("appends 2>&1 | head -100 to a bare tsc call", () => { const r = rewriteTsc({ command: "tsc" }); + // pipefail must be present so a failing tsc propagates its exit code + // through head instead of head masking it as success. + expect(r!.updatedInput.command).toContain("set -o pipefail;"); expect(r).not.toBeNull(); expect(r!.updatedInput.command).toContain("| head -100"); expect(r!.updatedInput.command).toContain("2>&1"); diff --git a/tests/modules/transcript/cwdToProjectHash.test.ts b/tests/modules/transcript/cwdToProjectHash.test.ts index c8774cd..608c1ff 100644 --- a/tests/modules/transcript/cwdToProjectHash.test.ts +++ b/tests/modules/transcript/cwdToProjectHash.test.ts @@ -22,4 +22,11 @@ describe("cwdToProjectHash", () => { it("collapses tabs and runs of whitespace to dashes", () => { expect(cwdToProjectHash("C:\\a b\tc")).toBe("C--a-b-c"); }); + + it("replaces dots and other non-alphanumerics (Claude Code does too)", () => { + // ~/.claude/projects shows ".claude-mem" stored as "--claude-mem" + expect(cwdToProjectHash("C:\\Projects\\my.app")).toBe("C--Projects-my-app"); + expect(cwdToProjectHash("/home/u/.claude-mem")).toBe("-home-u--claude-mem"); + expect(cwdToProjectHash("C:\\Projects\\app (2)")).toBe("C--Projects-app--2-"); + }); }); From ae206b503d6b0e75c910966bb36e0b1b16654b7d Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 15:43:06 +0530 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20hard-audit=20batch=202=20=E2=80=94?= =?UTF-8?q?=20agent=20crash,=20--no=20flags,=20cat/grep,=20empty-session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the full component audit, all verified + tested: - today/forecast/trend no longer crash on `--agent auto` (advertised in help) or a typo: route through resolveAgentFromOpts (quiet) like stats, plus the cursor "not supported" message. Was getAgentById(undefined).discoverSessions. - init --no-proxy/--no-marker/--no-verify-mcp/--no-claude-md and receipt --no-share now honored: cli.ts translates Commander's opts.x=false → opts.noX (drift already did this; init/receipt didn't). - proxy cat rewriter no longer matches `type` (a bash builtin that reports command type, not a file reader — awk rewrite changed its meaning). - proxy grep rewriter no longer collapses `grep -rn`/-A/-B/-C/-o to `-c` (which dropped the exact lines/numbers requested); head-caps instead. - impact now skips empty/zero-token sessions like stats/today/forecast/trend. - docs: receipt makes a PNG not a "PDF" (README + llms-full); sample file opus-4-7 → opus-4-8. NOTE: did NOT add pipefail to git/find/ls/grep/npm head-caps — on a successful command with long output, head closing early SIGPIPE-kills it and pipefail would report exit 141 (false failure). Only tsc is safe (success = no output). Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- docs/screenshots/sipcode-why-sample.txt | 2 +- docs/site/public/llms-full.txt | 2 +- src/cli.ts | 19 ++++++++++++++-- src/commands/forecast.ts | 20 ++++++++++++++--- src/commands/impact.ts | 6 +++++- src/commands/today.ts | 20 ++++++++++++++--- src/commands/trend.ts | 20 ++++++++++++++--- src/modules/proxy/rewriters/cat.ts | 7 ++++-- src/modules/proxy/rewriters/grep.ts | 25 +++++++++++++++++++++- tests/modules/proxy/rewriters/cat.test.ts | 4 ++++ tests/modules/proxy/rewriters/grep.test.ts | 5 +++++ 12 files changed, 114 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b21a344..6de52c2 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ npm uninstall -g sipcode | `sipcode why` | Per-session forensics: where your tokens died | | `sipcode impact` | A/B compare your spend before vs after Sipcode | | `sipcode score` | Audit your repo for AI-friendliness (0-100, tiered badge) | -| `sipcode receipt` | Generate a shareable PDF receipt of a session | +| `sipcode receipt` | Generate a shareable PNG receipt of a session | Run any of them with `--help` for full options. diff --git a/docs/screenshots/sipcode-why-sample.txt b/docs/screenshots/sipcode-why-sample.txt index 3dac7c6..3e5aca5 100644 --- a/docs/screenshots/sipcode-why-sample.txt +++ b/docs/screenshots/sipcode-why-sample.txt @@ -1,6 +1,6 @@ (showing latest session 84bbf968 from C--Projects-Sipcode; pass --here to scope to this directory) -sipcode why · session 84bbf968 · 60h 26m · claude-opus-4-7 +sipcode why · session 84bbf968 · 60h 26m · claude-opus-4-8 project: C--Projects-Sipcode you burned 225,576,407 tokens. 936,947 were code output. the other 224,639,460 were exploration, re-reads, and idle context. diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt index 7414fae..5d6355c 100644 --- a/docs/site/public/llms-full.txt +++ b/docs/site/public/llms-full.txt @@ -221,7 +221,7 @@ npm uninstall -g sipcode | `sipcode impact` | Before vs after Sipcode comparison | | `sipcode why` | Per-session forensics | | `sipcode score` | Audit a repo for AI-friendliness | -| `sipcode receipt` | Generate a shareable PDF receipt of a session | +| `sipcode receipt` | Generate a shareable PNG receipt of a session | ## Requirements diff --git a/src/cli.ts b/src/cli.ts index 912faa7..e8b2a6a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,17 @@ program .action(async (opts) => { const { runInit } = await import("./commands/init.js"); const { homedir } = await import("node:os"); - const r = await runInit(opts, { homeDir: homedir() }); + // Commander stores --no-X as opts.x=false; the runner reads opts.noX. + const r = await runInit( + { + ...opts, + noClaudeMd: opts.claudeMd === false, + noProxy: opts.proxy === false, + noMarker: opts.marker === false, + noVerifyMcp: opts.verifyMcp === false, + }, + { homeDir: homedir() }, + ); if (r?.exitCode) process.exit(r.exitCode); }); @@ -83,7 +93,12 @@ program .option("--agent ", "which agent to source transcripts from: claude-code | cursor | auto") .action(async (sessionId, opts) => { const { runReceipt } = await import("./commands/receipt.js"); - const result = await runReceipt({ ...opts, session: sessionId }); + // Commander stores --no-share as opts.share=false; runner reads opts.noShare. + const result = await runReceipt({ + ...opts, + session: sessionId, + noShare: opts.share === false, + }); if (result?.exitCode) process.exit(result.exitCode); }); diff --git a/src/commands/forecast.ts b/src/commands/forecast.ts index e3929c5..ed94ade 100644 --- a/src/commands/forecast.ts +++ b/src/commands/forecast.ts @@ -7,8 +7,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { runForecast, type ForecastSession } from "../modules/forecast/runForecast.js"; @@ -43,7 +43,21 @@ export async function runForecastCmd( const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n")); const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n")); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } const now = clock.now(); const pricing = loadPricingForDate(now); diff --git a/src/commands/impact.ts b/src/commands/impact.ts index 6773d5a..1997f25 100644 --- a/src/commands/impact.ts +++ b/src/commands/impact.ts @@ -17,7 +17,10 @@ import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { resolveAgentFromOpts } from "../modules/agents/cli.js"; import { resolveProjectsDir } from "../modules/transcript/discover.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; @@ -118,6 +121,7 @@ export async function runImpactCommand( if (!parseResult.ok) continue; const parsed = parseResult.value; const totals = analyzeTokens(parsed, pricing); + if (isEmptySession(totals)) continue; const dups = analyzeDuplicateReads(parsed); const idle = analyzeIdleContext(parsed); aggregated.push( diff --git a/src/commands/today.ts b/src/commands/today.ts index 1779f94..d7ea977 100644 --- a/src/commands/today.ts +++ b/src/commands/today.ts @@ -9,8 +9,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; @@ -46,7 +46,21 @@ export async function runTodayCmd( const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n")); const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n")); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } const now = clock.now(); const pricing = loadPricingForDate(now); diff --git a/src/commands/trend.ts b/src/commands/trend.ts index 135fed0..465f0a3 100644 --- a/src/commands/trend.ts +++ b/src/commands/trend.ts @@ -13,8 +13,8 @@ void ASSERT_NO_NETWORK; import { RealFileSystem, type FileSystem } from "../lib/fs.js"; import { RealClock, type Clock } from "../lib/clock.js"; import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; -import { getAgentById } from "../modules/agents/registry.js"; -import type { AgentId } from "../modules/agents/types.js"; +import { resolveAgentFromOpts } from "../modules/agents/cli.js"; +import { MESSAGES } from "../lib/messages.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; @@ -83,7 +83,21 @@ export async function runTrend( const sinceIso = since.toISOString().slice(0, 10); const untilIso = until.toISOString().slice(0, 10); - const agent = getAgentById((opts.agent ?? "claude-code") as AgentId); + const agentResolve = await resolveAgentFromOpts({ + agent: opts.agent, + fs, + env, + cwd: opts.cwd ?? process.cwd(), + stdout, + stderr, + quiet: true, + }); + if (!agentResolve.ok) return { exitCode: 1 }; + const agent = agentResolve.agent; + if (!agent.transcriptParsingSupported) { + stderr(MESSAGES.cursorTranscriptNotSupported()); + return { exitCode: 1 }; + } // Pricing — keyed off the window upper bound. const pricing = loadPricingForDate(until); diff --git a/src/modules/proxy/rewriters/cat.ts b/src/modules/proxy/rewriters/cat.ts index e046383..7f7e408 100644 --- a/src/modules/proxy/rewriters/cat.ts +++ b/src/modules/proxy/rewriters/cat.ts @@ -23,12 +23,15 @@ const THRESHOLD = HEAD + TAIL; // only elide when the file exceeds this export const rewriteCat: RewriterFn = (input) => { const cmd = String(input.command ?? "").trim(); - if (!commandStartsWith(cmd, "cat") && !commandStartsWith(cmd, "type")) return null; + // Only `cat`. NOT `type`: in bash (which Claude Code runs) `type` is a + // builtin that reports what kind of command a name is — it does NOT read + // files — so rewriting it to awk would change its meaning entirely. + if (!commandStartsWith(cmd, "cat")) return null; if (cmd.includes("|") || cmd.includes("&&") || cmd.includes("||") || cmd.includes(";")) { return null; } // Match: `cat ` or `cat - `. Multi-file → skip. - const m = cmd.match(/^(?:cat|type)(?:\s+-[^\s]+)?\s+(\S+)\s*$/); + const m = cmd.match(/^cat(?:\s+-[^\s]+)?\s+(\S+)\s*$/); if (!m) return null; const file = m[1]!; // Single-pass, size-aware. Buffers lines, then prints full content for small diff --git a/src/modules/proxy/rewriters/grep.ts b/src/modules/proxy/rewriters/grep.ts index c56ea9d..19f9b2a 100644 --- a/src/modules/proxy/rewriters/grep.ts +++ b/src/modules/proxy/rewriters/grep.ts @@ -23,7 +23,30 @@ export const rewriteGrep: RewriterFn = (input) => { const summaryMode = countMode || listMode; if (recursive && !summaryMode) { - // Switch recursive grep to count mode (1 line per file). + // If the caller explicitly asked for matching lines, line numbers, or + // context, collapsing to -c (per-file counts) would throw away exactly that + // content. Cap volume with head instead so the requested lines survive. + const wantsLines = + hasShortFlag(cmd, "n") || + hasShortFlag(cmd, "o") || + hasShortFlag(cmd, "A") || + hasShortFlag(cmd, "B") || + hasShortFlag(cmd, "C") || + hasFlag(cmd, "--line-number", "--only-matching", "--context"); + if (wantsLines) { + if (!hasOutputLimit(cmd) && !cmd.includes("|")) { + return { + updatedInput: { ...input, command: `${cmd} | head -${HEAD_LIMIT}` }, + savedTokensEstimate: 1500, + rewriterName: "grep", + integrityScore: 0.6, + integrityNote: + "kept first 50 matching lines via head; later matches dropped", + }; + } + return null; + } + // Plain recursive grep (no line/context flags): collapse to per-file counts. const updated = cmd.replace(/^(\s*(?:grep|rg))/, "$1 -c"); return { updatedInput: { ...input, command: updated }, diff --git a/tests/modules/proxy/rewriters/cat.test.ts b/tests/modules/proxy/rewriters/cat.test.ts index ad6c0e6..4e268ea 100644 --- a/tests/modules/proxy/rewriters/cat.test.ts +++ b/tests/modules/proxy/rewriters/cat.test.ts @@ -29,4 +29,8 @@ describe("rewriteCat", () => { it("does NOT match `category` substring", () => { expect(rewriteCat({ command: "category foo" })).toBeNull(); }); + it("does NOT rewrite `type` (bash builtin, not a file reader)", () => { + expect(rewriteCat({ command: "type foo.txt" })).toBeNull(); + expect(rewriteCat({ command: "type ls" })).toBeNull(); + }); }); diff --git a/tests/modules/proxy/rewriters/grep.test.ts b/tests/modules/proxy/rewriters/grep.test.ts index 31a6853..6c6e27a 100644 --- a/tests/modules/proxy/rewriters/grep.test.ts +++ b/tests/modules/proxy/rewriters/grep.test.ts @@ -7,6 +7,11 @@ describe("rewriteGrep", () => { expect(r?.updatedInput.command).toBe("grep -c -r foo ."); expect(r?.rewriterName).toBe("grep"); }); + it("recursive grep with -n keeps the lines (head cap, not -c)", () => { + const r = rewriteGrep({ command: "grep -rn foo src/" }); + // must NOT collapse to -c — that would drop the line numbers Claude asked for + expect(r?.updatedInput.command).toBe("grep -rn foo src/ | head -50"); + }); it("does NOT add -c when already in count mode", () => { expect(rewriteGrep({ command: "grep -rc foo ." })).toBeNull(); }); From c7728c98a45dd07e4b1aacb906ad6d461d1e0b8a Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sat, 27 Jun 2026 20:03:34 +0530 Subject: [PATCH 7/9] fix(proxy): preserve real exit code in ALL head-caps (awk, not head) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit-code masking bug wasn't just tsc — every output cap (git, find, ls, grep, npm, tsc) used `cmd | head -N`, so a FAILING command returned head's exit 0 and looked successful to Claude. Naively adding `pipefail` would have made it WORSE: head closes the pipe early, SIGPIPE-killing a SUCCESSFUL long-output command → exit 141 (false failure). Correct fix, in one shared base.capLines(): `set -o pipefail; cmd | awk 'NR<=N'`. awk prints only the first N lines but reads the WHOLE stream, so the command always runs to completion (no SIGPIPE) and pipefail propagates its true exit code. awk is already a proxy dependency (cat rewriter). Trade-off: find/ls/grep can no longer stop early on huge trees (sub-second on a normal repo); correctness wins. All 6 rewriters now route through capLines; 100 rewriter tests updated to the awk form. Full suite 1,387 green. Co-Authored-By: Claude Opus 4.8 --- src/modules/proxy/rewriters/base.ts | 24 ++++++++++++++++++++++ src/modules/proxy/rewriters/find.ts | 4 ++-- src/modules/proxy/rewriters/git.ts | 9 ++++++-- src/modules/proxy/rewriters/grep.ts | 12 ++++++++--- src/modules/proxy/rewriters/ls.ts | 4 ++-- src/modules/proxy/rewriters/npm.ts | 9 ++++++-- src/modules/proxy/rewriters/tsc.ts | 15 ++++++++------ tests/modules/proxy/rewriters/find.test.ts | 6 ++++-- tests/modules/proxy/rewriters/git.test.ts | 6 +++--- tests/modules/proxy/rewriters/grep.test.ts | 8 ++++++-- tests/modules/proxy/rewriters/ls.test.ts | 6 +++--- tests/modules/proxy/rewriters/npm.test.ts | 2 +- tests/modules/proxy/rewriters/tsc.test.ts | 4 ++-- 13 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/modules/proxy/rewriters/base.ts b/src/modules/proxy/rewriters/base.ts index 1f1c54c..5379935 100644 --- a/src/modules/proxy/rewriters/base.ts +++ b/src/modules/proxy/rewriters/base.ts @@ -50,3 +50,27 @@ function escapeRegex(s: string): string { export function hasOutputLimit(cmd: string): boolean { return /\|\s*(head|tail|less|more)\b/.test(cmd); } + +/** + * Cap a command's stdout to the first `limit` lines while PRESERVING its real + * exit code. + * + * `cmd | head -N` is wrong twice over: without pipefail the pipeline returns + * head's 0 (a FAILING command looks like it succeeded), and WITH pipefail head + * closes the pipe early and SIGPIPE-kills a SUCCESSFUL long-output command + * (exit 141, a false failure). `awk 'NR<=N'` prints only the first N lines but + * reads the WHOLE stream, so the command always runs to completion (no + * SIGPIPE); `set -o pipefail` then propagates the command's true exit code + * through awk's 0. `awk` is already a proxy dependency (see the cat rewriter). + * + * @param mergeStderr fold stderr into the captured stdout — for diagnostic + * commands (tsc, npm) whose errors must be visible and capped together. + */ +export function capLines( + cmd: string, + limit: number, + mergeStderr = false, +): string { + const merge = mergeStderr ? " 2>&1" : ""; + return `set -o pipefail; ${cmd.trim()}${merge} | awk 'NR<=${limit}'`; +} diff --git a/src/modules/proxy/rewriters/find.ts b/src/modules/proxy/rewriters/find.ts index d6476f5..8683720 100644 --- a/src/modules/proxy/rewriters/find.ts +++ b/src/modules/proxy/rewriters/find.ts @@ -3,7 +3,7 @@ * find typically returns more entries than ls so the cap is higher. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasOutputLimit } from "./base.js"; +import { commandStartsWith, hasOutputLimit, capLines } from "./base.js"; const HEAD_LIMIT = 100; @@ -14,7 +14,7 @@ export const rewriteFind: RewriterFn = (input) => { if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";") || cmd.includes("|")) { return null; } - const updated = `${cmd} | head -${HEAD_LIMIT}`; + const updated = capLines(cmd, HEAD_LIMIT); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 2500, diff --git a/src/modules/proxy/rewriters/git.ts b/src/modules/proxy/rewriters/git.ts index 17dcf0c..7f34399 100644 --- a/src/modules/proxy/rewriters/git.ts +++ b/src/modules/proxy/rewriters/git.ts @@ -7,7 +7,12 @@ * tool I/O at the hook layer. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; const DIFF_HEAD = 200; @@ -74,7 +79,7 @@ export const rewriteGitDiff: RewriterFn = (input) => { return null; } return { - updatedInput: { ...input, command: `${cmd} | head -${DIFF_HEAD}` }, + updatedInput: { ...input, command: capLines(cmd, DIFF_HEAD) }, savedTokensEstimate: 3500, rewriterName: "git-diff", integrityScore: 0.5, diff --git a/src/modules/proxy/rewriters/grep.ts b/src/modules/proxy/rewriters/grep.ts index 19f9b2a..422e471 100644 --- a/src/modules/proxy/rewriters/grep.ts +++ b/src/modules/proxy/rewriters/grep.ts @@ -7,7 +7,13 @@ * For non-recursive grep without an output limit, pipe to `| head -50`. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit, hasShortFlag } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + hasShortFlag, + capLines, +} from "./base.js"; const HEAD_LIMIT = 50; @@ -36,7 +42,7 @@ export const rewriteGrep: RewriterFn = (input) => { if (wantsLines) { if (!hasOutputLimit(cmd) && !cmd.includes("|")) { return { - updatedInput: { ...input, command: `${cmd} | head -${HEAD_LIMIT}` }, + updatedInput: { ...input, command: capLines(cmd, HEAD_LIMIT) }, savedTokensEstimate: 1500, rewriterName: "grep", integrityScore: 0.6, @@ -60,7 +66,7 @@ export const rewriteGrep: RewriterFn = (input) => { // Non-recursive grep without an output cap → pipe to head. if (!recursive && !hasOutputLimit(cmd) && !cmd.includes("|")) { return { - updatedInput: { ...input, command: `${cmd} | head -${HEAD_LIMIT}` }, + updatedInput: { ...input, command: capLines(cmd, HEAD_LIMIT) }, savedTokensEstimate: 1500, rewriterName: "grep", integrityScore: 0.6, diff --git a/src/modules/proxy/rewriters/ls.ts b/src/modules/proxy/rewriters/ls.ts index c53a6b5..c5a8bd6 100644 --- a/src/modules/proxy/rewriters/ls.ts +++ b/src/modules/proxy/rewriters/ls.ts @@ -3,7 +3,7 @@ * Appends `| head -50` when output is not already length-limited. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasOutputLimit } from "./base.js"; +import { commandStartsWith, hasOutputLimit, capLines } from "./base.js"; const HEAD_LIMIT = 50; @@ -16,7 +16,7 @@ export const rewriteLs: RewriterFn = (input) => { if (cmd.includes("&&") || cmd.includes("||") || cmd.includes(";") || cmd.includes("|")) { return null; } - const updated = `${cmd} | head -${HEAD_LIMIT}`; + const updated = capLines(cmd, HEAD_LIMIT); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 1500, diff --git a/src/modules/proxy/rewriters/npm.ts b/src/modules/proxy/rewriters/npm.ts index f38f5d6..2c184a6 100644 --- a/src/modules/proxy/rewriters/npm.ts +++ b/src/modules/proxy/rewriters/npm.ts @@ -10,7 +10,12 @@ * piped output, or asked for json. */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; export const rewriteNpmLs: RewriterFn = (input) => { const cmd = String(input.command ?? ""); @@ -80,7 +85,7 @@ export const rewriteNpmView: RewriterFn = (input) => { // Only cap when no field is specified (full dump is the verbose case). const tokens = cmd.trim().split(/\s+/).filter((t) => !t.startsWith("-")); if (tokens.length !== 3) return null; - const updated = `${cmd.trim()} 2>&1 | head -80`; + const updated = capLines(cmd, 80, true); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 2500, diff --git a/src/modules/proxy/rewriters/tsc.ts b/src/modules/proxy/rewriters/tsc.ts index 8b5a16d..38c0115 100644 --- a/src/modules/proxy/rewriters/tsc.ts +++ b/src/modules/proxy/rewriters/tsc.ts @@ -12,7 +12,12 @@ * - --version is the only flag (already terse) */ import type { RewriterFn } from "../types.js"; -import { commandStartsWith, hasFlag, hasOutputLimit } from "./base.js"; +import { + commandStartsWith, + hasFlag, + hasOutputLimit, + capLines, +} from "./base.js"; const HEAD_LIMIT = 100; @@ -27,11 +32,9 @@ export const rewriteTsc: RewriterFn = (input) => { if (hasFlag(cmd, "--listFiles", "--listEmittedFiles", "--version", "-v")) { return null; } - // Append a head cap. Stderr-to-stdout merge ensures we cap diagnostic output, - // not just successful builds. `set -o pipefail` is critical: without it the - // pipeline's exit code is head's (always 0), so a FAILING tsc would report - // success to Claude. pipefail propagates tsc's non-zero status through head. - const updated = `set -o pipefail; ${cmd.trim()} 2>&1 | head -${HEAD_LIMIT}`; + // Cap diagnostic output (stderr merged) while preserving tsc's real exit + // code, so a FAILING typecheck is never reported to Claude as success. + const updated = capLines(cmd, HEAD_LIMIT, true); return { updatedInput: { ...input, command: updated }, savedTokensEstimate: 3000, diff --git a/tests/modules/proxy/rewriters/find.test.ts b/tests/modules/proxy/rewriters/find.test.ts index 80be6c1..468036a 100644 --- a/tests/modules/proxy/rewriters/find.test.ts +++ b/tests/modules/proxy/rewriters/find.test.ts @@ -4,11 +4,13 @@ import { rewriteFind } from "../../../../src/modules/proxy/rewriters/find.js"; describe("rewriteFind", () => { it("appends `| head -100` to `find . -name '*.ts'`", () => { const r = rewriteFind({ command: "find . -name '*.ts'" }); - expect(r?.updatedInput.command).toBe("find . -name '*.ts' | head -100"); + expect(r?.updatedInput.command).toBe( + "set -o pipefail; find . -name '*.ts' | awk 'NR<=100'", + ); expect(r?.rewriterName).toBe("find"); }); it("works for fd too", () => { - expect(rewriteFind({ command: "fd .ts" })?.updatedInput.command).toBe("fd .ts | head -100"); + expect(rewriteFind({ command: "fd .ts" })?.updatedInput.command).toBe("set -o pipefail; fd .ts | awk 'NR<=100'"); }); it("does NOT rewrite when already piped to head", () => { expect(rewriteFind({ command: "find . | head -5" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/git.test.ts b/tests/modules/proxy/rewriters/git.test.ts index 33d1518..dfc3522 100644 --- a/tests/modules/proxy/rewriters/git.test.ts +++ b/tests/modules/proxy/rewriters/git.test.ts @@ -66,14 +66,14 @@ describe("rewriteGitLog", () => { }); describe("rewriteGitDiff", () => { - it("caps `git diff` with | head -200", () => { + it("caps `git diff` to 200 lines (awk) preserving exit code", () => { const r = rewriteGitDiff({ command: "git diff" }); - expect(r?.updatedInput.command).toBe("git diff | head -200"); + expect(r?.updatedInput.command).toBe("set -o pipefail; git diff | awk 'NR<=200'"); expect(r?.rewriterName).toBe("git-diff"); }); it("caps `git show` too", () => { expect(rewriteGitDiff({ command: "git show" })?.updatedInput.command).toBe( - "git show | head -200", + "set -o pipefail; git show | awk 'NR<=200'", ); }); it("does NOT rewrite compact summary modes", () => { diff --git a/tests/modules/proxy/rewriters/grep.test.ts b/tests/modules/proxy/rewriters/grep.test.ts index 6c6e27a..85c6786 100644 --- a/tests/modules/proxy/rewriters/grep.test.ts +++ b/tests/modules/proxy/rewriters/grep.test.ts @@ -10,7 +10,9 @@ describe("rewriteGrep", () => { it("recursive grep with -n keeps the lines (head cap, not -c)", () => { const r = rewriteGrep({ command: "grep -rn foo src/" }); // must NOT collapse to -c — that would drop the line numbers Claude asked for - expect(r?.updatedInput.command).toBe("grep -rn foo src/ | head -50"); + expect(r?.updatedInput.command).toBe( + "set -o pipefail; grep -rn foo src/ | awk 'NR<=50'", + ); }); it("does NOT add -c when already in count mode", () => { expect(rewriteGrep({ command: "grep -rc foo ." })).toBeNull(); @@ -23,7 +25,9 @@ describe("rewriteGrep", () => { }); it("pipes non-recursive grep to head when no output limit", () => { const r = rewriteGrep({ command: "grep foo file.txt" }); - expect(r?.updatedInput.command).toBe("grep foo file.txt | head -50"); + expect(r?.updatedInput.command).toBe( + "set -o pipefail; grep foo file.txt | awk 'NR<=50'", + ); }); it("does NOT rewrite non-recursive grep already piped to head", () => { expect(rewriteGrep({ command: "grep foo file.txt | head -10" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/ls.test.ts b/tests/modules/proxy/rewriters/ls.test.ts index e76957d..5e56924 100644 --- a/tests/modules/proxy/rewriters/ls.test.ts +++ b/tests/modules/proxy/rewriters/ls.test.ts @@ -4,14 +4,14 @@ import { rewriteLs } from "../../../../src/modules/proxy/rewriters/ls.js"; describe("rewriteLs", () => { it("appends `| head -50` to bare `ls`", () => { const r = rewriteLs({ command: "ls" }); - expect(r?.updatedInput.command).toBe("ls | head -50"); + expect(r?.updatedInput.command).toBe("set -o pipefail; ls | awk 'NR<=50'"); expect(r?.rewriterName).toBe("ls"); }); it("appends `| head -50` to `ls /tmp`", () => { - expect(rewriteLs({ command: "ls /tmp" })?.updatedInput.command).toBe("ls /tmp | head -50"); + expect(rewriteLs({ command: "ls /tmp" })?.updatedInput.command).toBe("set -o pipefail; ls /tmp | awk 'NR<=50'"); }); it("works with flags", () => { - expect(rewriteLs({ command: "ls -la /var" })?.updatedInput.command).toBe("ls -la /var | head -50"); + expect(rewriteLs({ command: "ls -la /var" })?.updatedInput.command).toBe("set -o pipefail; ls -la /var | awk 'NR<=50'"); }); it("does NOT rewrite when already piped to head", () => { expect(rewriteLs({ command: "ls | head -10" })).toBeNull(); diff --git a/tests/modules/proxy/rewriters/npm.test.ts b/tests/modules/proxy/rewriters/npm.test.ts index 2816e97..09c8f0f 100644 --- a/tests/modules/proxy/rewriters/npm.test.ts +++ b/tests/modules/proxy/rewriters/npm.test.ts @@ -50,7 +50,7 @@ describe("rewriteNpmView", () => { it("appends head -80 to `npm view ` with no field arg", () => { const r = rewriteNpmView({ command: "npm view react" }); expect(r).not.toBeNull(); - expect(r!.updatedInput.command).toContain("| head -80"); + expect(r!.updatedInput.command).toContain("awk 'NR<=80'"); expect(r!.rewriterName).toBe("npm-view"); }); diff --git a/tests/modules/proxy/rewriters/tsc.test.ts b/tests/modules/proxy/rewriters/tsc.test.ts index 6cc6f05..d49fcec 100644 --- a/tests/modules/proxy/rewriters/tsc.test.ts +++ b/tests/modules/proxy/rewriters/tsc.test.ts @@ -8,7 +8,7 @@ describe("rewriteTsc", () => { // through head instead of head masking it as success. expect(r!.updatedInput.command).toContain("set -o pipefail;"); expect(r).not.toBeNull(); - expect(r!.updatedInput.command).toContain("| head -100"); + expect(r!.updatedInput.command).toContain("awk 'NR<=100'"); expect(r!.updatedInput.command).toContain("2>&1"); expect(r!.rewriterName).toBe("tsc"); }); @@ -17,7 +17,7 @@ describe("rewriteTsc", () => { const r = rewriteTsc({ command: "tsc --noEmit" }); expect(r).not.toBeNull(); expect(r!.updatedInput.command).toContain("tsc --noEmit"); - expect(r!.updatedInput.command).toContain("| head -100"); + expect(r!.updatedInput.command).toContain("awk 'NR<=100'"); }); it("works on npx tsc", () => { From e7a5ed34b61f845721cae7ab991c9584b29f03e6 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 28 Jun 2026 16:04:45 +0530 Subject: [PATCH 8/9] fix(why,mcp): auto-pick newest NON-empty session, skip empty/in-flight why (no --session) and MCP audit_latest_session picked sessions[0] blindly, so an empty or in-flight latest transcript rendered an all-zero report. Both now scan newest-first and pick the first non-empty session (drift already did this), falling back to the newest if all are empty. Parses lazily, stops at the first hit. Regression test added. This closes the last item from the full component audit. Co-Authored-By: Claude Opus 4.8 --- src/commands/why.ts | 38 +++++++++++++++++++++-- src/mcp/server.ts | 27 ++++++++++++++-- tests/integration/why.integration.test.ts | 29 +++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/commands/why.ts b/src/commands/why.ts index 5e16abe..f333fcb 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -17,7 +17,10 @@ import { type SessionMeta, } from "../modules/transcript/discover.js"; import { parseTranscriptVerbose } from "../modules/transcript/parse.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { analyzeTopExpensive } from "../modules/transcript/analyzers/topExpensive.js"; @@ -51,6 +54,34 @@ export interface WhyResult { exitCode: 0 | 1; } +/** + * Return the newest session that actually has work in it (skips empty / + * in-flight transcripts that would render an all-zero report). Sessions are + * newest-first; falls back to the newest if every one is empty so we always + * show something. Parses lazily and stops at the first non-empty hit, so the + * common case (latest session is real) parses exactly one transcript. + */ +async function pickLatestNonEmpty( + fs: FileSystem, + sessions: SessionMeta[], + now: Date, +): Promise { + for (const m of sessions) { + let contents: string; + try { + contents = await fs.readFile(m.filePath); + } catch { + continue; + } + const { session } = parseTranscriptVerbose(contents); + const date = session.startedAt ? new Date(session.startedAt) : now; + if (!isEmptySession(analyzeTokens(session, loadPricingForDate(date)))) { + return m; + } + } + return sessions[0]; +} + export async function runWhy( opts: WhyOptions, deps: WhyDeps = {}, @@ -120,7 +151,10 @@ export async function runWhy( return { exitCode: 1 }; } } else { - chosen = sessions[0]; + // Auto-pick the newest NON-empty session. An empty/in-flight latest + // session would otherwise render an all-zero report (drift guards the same + // way). Falls back to the newest if every session is empty. + chosen = await pickLatestNonEmpty(fs, sessions, clock.now()); } if (!chosen) { stderr(MESSAGES.noSessionsFound(projectsDir)); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 910de17..f4c1698 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -51,9 +51,13 @@ import { listAllSessions, findSessionById, resolveProjectsDir, + type SessionMeta, } from "../modules/transcript/discover.js"; import { parseTranscriptVerbose } from "../modules/transcript/parse.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { + analyzeTokens, + isEmptySession, +} from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { analyzeTopExpensive } from "../modules/transcript/analyzers/topExpensive.js"; @@ -242,11 +246,30 @@ async function toolAuditLatestSession( const sessions = await listAllSessions(fs, projectsDir); if (sessions.length === 0) return fail("No sessions to audit."); - let chosen = sessions[0]; + let chosen: SessionMeta | undefined; if (opts.sessionId) { const match = await findSessionById(fs, projectsDir, opts.sessionId); if (!match) return fail(`No session matches "${opts.sessionId}".`); chosen = match; + } else { + // Auto-pick the newest NON-empty session; an empty/in-flight latest + // session would otherwise return an all-zero audit. Falls back to newest. + for (const m of sessions) { + try { + const c = await readTranscript(fs, m.filePath); + const { session } = parseTranscriptVerbose(c); + const date = session.startedAt + ? new Date(session.startedAt) + : clock.now(); + if (!isEmptySession(analyzeTokens(session, loadPricingForDate(date)))) { + chosen = m; + break; + } + } catch { + continue; + } + } + chosen = chosen ?? sessions[0]; } if (!chosen) return fail("No sessions to audit."); diff --git a/tests/integration/why.integration.test.ts b/tests/integration/why.integration.test.ts index 085d258..9f7d704 100644 --- a/tests/integration/why.integration.test.ts +++ b/tests/integration/why.integration.test.ts @@ -36,6 +36,35 @@ function makeEnv(): FakeProcessEnv { } describe("runWhy integration", () => { + it("auto-pick skips an empty/in-flight latest session", async () => { + const fs = new InMemoryFs(); + fs.writeFile( + "/home/u/.claude/projects/test-proj/readheavy1.jsonl", + loadFixture("read-heavy.jsonl"), + new Date("2026-05-02T09:00:30Z").getTime(), + ); + // NEWEST file, but empty — must be skipped in favour of the real one. + fs.writeFile( + "/home/u/.claude/projects/test-proj/empty-latest.jsonl", + loadFixture("empty.jsonl"), + new Date("2026-05-10T09:00:30Z").getTime(), + ); + const out: string[] = []; + const result = await runWhy( + { json: true }, + { + fs, + env: makeEnv(), + clock: new FakeClock(new Date("2026-05-15T00:00:00Z")), + stdout: (s) => out.push(s), + stderr: () => {}, + }, + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(out.join("\n")); + expect(parsed.header.sessionIdShort).toBe("readheav"); + }); + it("--json on most recent (read-heavy) returns valid JSON with savings", async () => { const out: string[] = []; const err: string[] = []; From adc302100eb3fee49534fb5aaa8b30ac39141407 Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Sun, 28 Jun 2026 16:41:59 +0530 Subject: [PATCH 9/9] =?UTF-8?q?release:=20v1.6.19=20=E2=80=94=20star=20nud?= =?UTF-8?q?ge=20+=20full=20hardening=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the growth work (one-time star nudge, README social-proof badges) with a complete component-audit hardening batch: proxy exit-code preservation across all output caps, --here full path encoding, today/forecast/trend agent-crash fix, init/receipt --no-* flags, cat/grep rewriter corrections, and consistent empty-session skipping in impact/why/MCP. Bump 1.6.18 -> 1.6.19. CHANGELOG 1.6.19 entry + link ref. README badge, llms.txt / llms-full.txt, landing page, and server.json all -> 1.6.19 / 1,388. Tests: 1,373 -> 1,388, full suite green; privacy + no-shell-args guards green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 +++++++++++++++++++++++-- README.md | 4 ++-- docs/site/public/llms-full.txt | 4 ++-- docs/site/public/llms.txt | 6 +++--- docs/site/src/components/Footer.astro | 4 ++-- docs/site/src/components/Hero.astro | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- server.json | 4 ++-- 9 files changed, 40 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70c6db..bc6099a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,27 @@ This log starts at v1.6.5 (the reliability-pillar repositioning). Earlier histor ## [Unreleased] -_Nothing landed since [1.6.18]._ +_Nothing landed since [1.6.19]._ + +## [1.6.19] — 2026-06-28 + +A growth feature plus a thorough hardening pass. A full component audit found and fixed a batch of edge-case bugs (mostly in the analytics/reporting layer and the proxy's output caps) before more users hit them. + +### Added +- **One-time GitHub-star nudge.** After `sipcode proxy --stats` shows real savings, a single line points to the repo, shown once per machine ever (gated on a `~/.sipcode/.star-nudge` marker). Never in `--json`, never when there are no rewrites, never blocks, zero network calls. +- README gained live GitHub-stars and npm-downloads social-proof badges. + +### Fixed +- **Proxy output caps no longer hide a command's failure.** Every cap (`tsc`, `git`, `find`, `ls`, `grep`, `npm`) used `cmd | head`, which returns exit 0 even when the command failed — so a failing `tsc --noEmit` could look like a clean build to Claude. All caps now use `set -o pipefail; cmd | awk 'NR<=N'`, which caps the output while preserving the command's real exit code (awk reads the whole stream, so there is no early-close SIGPIPE false-failure either). +- **`--here` now works for any project path.** It previously matched only paths without special characters; a folder like `C:\Projects\just research` (or any path with a dot, space, or parenthesis) silently found nothing. It now matches Claude Code's exact directory encoding. +- **`today`, `forecast`, and `trend` no longer crash** on `--agent auto` (which the help advertised) or a mistyped agent name. +- **Opt-out flags are honored.** `init --no-proxy` / `--no-marker` / `--no-verify-mcp` / `--no-claude-md` and `receipt --no-share` were silently ignored. +- **`cat` / `grep` rewriters corrected.** The proxy no longer rewrites bash's `type` builtin (which is not a file reader), and `grep -rn` keeps the matching lines and numbers instead of collapsing to per-file counts. +- **Empty / in-flight sessions are skipped consistently.** `impact`, `why`, and the `audit_latest_session` MCP tool no longer build an all-zero report from an empty latest session. +- Docs: `sipcode receipt` produces a PNG, not a "PDF"; a sample output file updated to opus 4.8. + +### Internal +- Test count: 1,373 → 1,388. Full suite green; privacy and no-shell-args guards green. Findings verified by a 3-part component audit. ## [1.6.18] — 2026-06-25 @@ -237,7 +257,8 @@ This release rolls v1.6.9's B3 work (bumped but never published to npm) together --- -[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.18...HEAD +[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.19...HEAD +[1.6.19]: https://github.com/Anuj7411/sipcode/compare/v1.6.18...v1.6.19 [1.6.18]: https://github.com/Anuj7411/sipcode/compare/v1.6.17...v1.6.18 [1.6.17]: https://github.com/Anuj7411/sipcode/compare/v1.6.16...v1.6.17 [1.6.16]: https://github.com/Anuj7411/sipcode/compare/v1.6.15...v1.6.16 diff --git a/README.md b/README.md index 6de52c2..fc32542 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ GitHub stars npm downloads per month MIT licensed - 1373 tests passing + 1388 tests passing zero network calls

@@ -179,7 +179,7 @@ Verify it installed: sipcode --version ``` -You should see `1.6.18` or higher. +You should see `1.6.19` or higher. ### Step 3. Run `sipcode init` to wire it into Claude Code diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt index 5d6355c..e06f6b2 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,373 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,388 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.18 or higher. +Verify: `sipcode --version` should show 1.6.19 or higher. ### Step 3. Run `sipcode init` diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt index 0cc0abf..b21d2e8 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,373 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,388 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,373 tests -- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.18 +- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,388 tests +- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.19 - [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/docs/site/src/components/Footer.astro b/docs/site/src/components/Footer.astro index b171d0e..f28f86b 100644 --- a/docs/site/src/components/Footer.astro +++ b/docs/site/src/components/Footer.astro @@ -25,11 +25,11 @@
- v1.6.18 + v1.6.19 - 1,373 tests passing + 1,388 tests passing
@@ -19,7 +19,7 @@ const TEST_COUNT = 1373;
- v1.6.18 · MIT licensed + v1.6.19 · MIT licensed {TEST_COUNT.toLocaleString()} tests passing
diff --git a/package-lock.json b/package-lock.json index 1780a97..c99d67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 53d2ddd..5f2fa5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "mcpName": "io.github.Anuj7411/sipcode", "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": [ diff --git a/server.json b/server.json index 817a7d3..aabc0c7 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/Anuj7411/sipcode", "source": "github" }, - "version": "1.6.18", + "version": "1.6.19", "packages": [ { "registryType": "npm", "identifier": "sipcode", - "version": "1.6.18", + "version": "1.6.19", "runtimeHint": "npx", "transport": { "type": "stdio"