Skip to content

feat(dist): pure-Go releases, a cache preset, --idle-exit, and gateway conformance - #141

Open
amiddavid wants to merge 1 commit into
mainfrom
feat/local-distribution
Open

feat(dist): pure-Go releases, a cache preset, --idle-exit, and gateway conformance#141
amiddavid wants to merge 1 commit into
mainfrom
feat/local-distribution

Conversation

@amiddavid

Copy link
Copy Markdown
Collaborator

Implements the proposal in #130, all three stages. Based on docs/local-distribution, so this PR's diff is the implementation only — the spec and its two verification scripts come from #130, which should merge first.

Four decisions were taken before writing anything, and all four are reviewable:

Open question in the spec Decision
Default routing scope project-local (.claude/settings.local.json), --global opt-in
--idle-exit default and floor 24h, floor max(2 × store.ttl_seconds, 1h) — enforced at startup
Homebrew tap skipped. Tarball + checksum + go install; the tap repo and signing are still unowned
Scope all three stages

Stage 1 — the toolchain gate

Our own docs were the largest one. quickstart-proxy.md, setup.md and hosted.md all told evaluators to install a C toolchain; setup.md additionally named bifrost's tokenizer as a cgo dependency, which it is not.

Re-verified directly on go 1.26.4 rather than taken from the spec: CGO_ENABLED=0 with default tags builds all four release targets (27.1–33.5 MB stripped), file reports "statically linked", ldd reports "not a dynamic executable", the binary starts and answers /healthz, and -tags cg_skeleton fails under CGO_ENABLED=0 with the build-constraints signature — confirming tree-sitter is the only C dependency.

  • .goreleaser.yaml: plain GOOS/GOARCH matrix, no cross-toolchains. No brews: block, so nothing depends on a repo that does not exist.
  • .github/workflows/release.yaml: a tag publishes; workflow_dispatch builds the same matrix as a snapshot and publishes nothing. Its first step asserts the pure-Go claim with CC=/nonexistent-c-compiler, so a cgo dependency escaping the build tag fails in CI rather than at a stranger's install.
  • make build-static. The Makefile keeps CGO_ENABLED=1 because go test -race needs it — reading that as a shipping requirement is how the wrong claim reached the docs, and the comment now says so.
  • cache preset = {cachesplit}. Not safe: format/textclean/searchfold are lossless in meaning but still rewrite the JSON, so "we do not touch your context" stops being literally checkable. Confirmed end to end — a released-shape binary logs pipeline=[cachesplit] under PRESET=cache.

Stage 2 — --idle-exit and the plugin

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the watchdog converge on the same teardown, so the self-killing path cannot drift from the one known to work.

Two properties are load-bearing:

  • The keep-alive inverts "idle." Pinging is what the proxy does precisely while no client traffic arrives — the quiet gap after end_turn, where 83.7% of the recoverable dollars sit. A request-only watchdog would kill the feature in its working window. So a pending ping both vetoes the exit and resets the clock: retiring the last ping buys a full fresh threshold rather than exiting moments later.
  • Exit wipes the in-memory store. store.ValidateIdleExit refuses anything below max(2 × ttl, 1h) — ~5h34m at the default — at startup, not in a doc comment. 2× because the TTL is a sliding window. NewMemory now calls the same Options.EffectiveTTL the floor is computed from, so the two cannot drift.

The plugin (/plugin marketplace add rossoctl/context-guru/context-guru:install) is three skills over three scripts plus a SessionStart hook. The hook self-gates on $ANTHROPIC_BASE_URL matching its own port: the plugin installs at user scope, so its hooks run in every project, and the env value settings already write into the process environment is the per-project enablement signal — no second copy of the port to drift, and it degrades correctly (remove the key by hand and the hook stops firing). It matches the port, not "localhost", so someone routing to litellm on 4000 is not hijacked. Synchronous (closes the race with the first request), idempotent (SessionStart also fires on clear/compact/resume/fork), and exit 0 on every path — a hook that fails here is a plugin that can brick every session on the machine.

settings.py merges exactly one key, backs the file up first, refuses to overwrite a base URL the user already set, removes only a URL it installed, and refuses to rewrite a settings file it cannot parse rather than replacing it.

Stage 3 — gateway conformance

All five items, under the cache preset. Four were already correct and are now pinned; one was missing:

  1. SSE is not buffered — asserted against an upstream that withholds its second event until the client has seen the first.
  2. Breakpoints are never added. The cap is 4, and a rejected cache_control makes Claude Code disable prompt caching for the rest of the conversation — so a budget mistake silently switches off what the funnel is selling.
  3. The attribution block arrives byte-identical, so CLAUDE_CODE_ATTRIBUTION_HEADER=0 is not needed in the installer.
  4. Error bodies forwarded byte-for-byte, status and headers included.
  5. POST /anthropic/v1/messages/count_tokens — NEW. Absent it, Claude Code counts context by issuing inference requests: billed calls added by a proxy sold on removing them. Forwarded verbatim with no pipeline, because the client budgets its own transcript from the answer.

Verification

Every test was revert-verified, with each mutation asserted to have landed in the source before its result was allowed to count. 14 mutations, each failing with the intended message and passing when restored — full matrix in the commit body.

Doing that properly caught three defects in my own tests:

  • One test hung instead of failing (a bare send on a channel the exited watcher no longer drains).
  • Its tick channel was buffered, so a send proved nothing about the watcher having run — it seeded its clock after the test advanced it. Fixed in production too: main now stamps the clock at launch rather than relying on goroutine scheduling.
  • The attribution test passed under two mutations because its fixture was built with json.Marshal of a map, which sorts keys — so re-encoding produced identical bytes. It now carries Claude Code's real key order ({"type":...,"text":...}), and both mutations fail.

The plugin's shell and Python helpers are tested from Go (context-guru-plugin/plugin_test.go) so go test ./... and CI cover them: settings merge/conflict/removal/backup, and the hook's silence in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for /healthz.

go build ./..., go vet ./..., gofmt -l and the full go test ./... are clean.

Not verified, and one thing deliberately elsewhere

  • The plugin has not been installed into a real Claude Code session end to end, because install.sh resolves a GitHub release and no tag has ever been published. Until one is, it reports no_release_found. Cutting a tag is the first thing to do after this merges.
  • A stale preset table was found while adding the cache row, and is fixed in a sibling PR (fix/preset-doc-drift) rather than here, because it is unrelated to distribution: three presets were documented as running toon (retired after acting 0 times on 5,752 production requests) and every row omitted textclean/searchfold/linecap, while docs/reference/presets.md was correct at the same moment. This PR adds one row and leaves the rest of that table exactly as it is on main, so whichever PR merges second needs a trivial rebase there.

Still open for you

  • Port 8787 (not 4000 — litellm). Configurable via the plugin's userConfig.
  • skeleton omitted from releases, source build documented, per the spec's proposal.
  • Artifact 27–34 MB; no -slim build, so the demo keeps the dashboard.

🤖 Generated with Claude Code

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Reviewed against main e88f5d8: full diff, both build paths, the full test suite, mkdocs build --strict, hand-verification of the release config, settings.py exercised against copies in temp
dirs (the real ~/.claude/settings.json was never written to), and 11 real claude sessions
through the integrated build with main as control.

The headline claim holds, and the compaction core is sound. CGO_ENABLED=0 builds, the binary is
statically linked, and all 15 presets start and answer /healthz on the CGO-free binary — no
unknown component "skeleton". Invariant 5 (frozen byte-identical replay) held byte-exactly across 8
real turns; invariant 3 round-tripped byte-exactly. In-flight requests are not dropped at
shutdown (SIGTERM at t=2.5s of a real inference → client got HTTP 200 in 5.557995s, complete body,
port released). The .goreleaser.yaml seam most likely to be silently wrong is right: goreleaser
emits context-guru_{{.Version}}_{{.Os}}_{{.Arch}}.tar.gz and install.sh:66-67 reconstructs
exactly that. Workflow permissions are correct (top-level contents: read, job-level
contents: write, GITHUB_TOKEN, no PAT). The purego assert step genuinely fails when it should.

Almost everything below is in the plugin shell, not the core.

1. Blocking — /context-guru:uninstall kills the user's Claude Code session and leaves the proxy running

context-guru-plugin/skills/uninstall/SKILL.md:44-46:

pkill -f "context-guru-proxy.*${PORT}"

start-proxy.sh:67-69 passes the port through the LISTEN_ADDR environment variable, never on
the command line — so the port never appears in the proxy's cmdline and this pattern cannot match
it. The only process whose command line does contain the pattern is the shell running the pkill,
i.e. the Claude Code session's own Bash tool. pkill -f does not exclude its parent:

pid=1116481 cmdline='context-guru-proxy --idle-exit=24h '                      matches: False  <- the proxy
pid=1116473 cmdline='/bin/bash -c ... pkill -f "context-guru-proxy.*4160" ...' matches: True
listener pid=1116481 cmdline='context-guru-proxy --idle-exit=24h '   <- no port anywhere

A user runs uninstall because their sessions are broken. This kills the session mid-command (exit
143), reports nothing removed, and the proxy still holds the port. The skill's verification step then
prints STILL RUNNING, and the obvious next move — broadening the pattern to context-guru-proxy
would match a host's long-running production service. The skill's own comment warns against the
broad pattern; the narrower guard it offers instead never matches anything.

Resolve the PID from the socket owner, which needs no pattern:

pid=$(ss -lntpH "sport = :${PORT}" | grep -o 'pid=[0-9]*' | cut -d= -f2 | head -1)
[ -n "$pid" ] && kill "$pid" || echo "(nothing listening on ${PORT})"

Or have start-proxy.sh write a pidfile. Either way delete the pkill -f — it is unfixable as a
pattern, because the pattern is in the command that runs it.

2. Blocking — install.sh cannot install anything today, and its documented fallback is not in the script

platform=linux/amd64   version=v0.1.0
curl: (22) The requested URL returned error: 404
result=error  reason=download_failed: .../v0.1.0/context-guru_0.1.0_linux_amd64.tar.gz
GitHub API: tag v0.1.0  draft: False  assets: (NONE)

Three separate problems: (a) the release tag resolves but has zero assets, so the script reaches
download_failed, which is not in install/SKILL.md's outcome table (it documents
present/installed/no_release_found/checksum_mismatch); (b) grep -n "go install" install.sh matches
only the header comment — the option-3 source fallback is documented but absent, on a box with Go
1.26.4 on PATH; (c) the raw curl: (22) on stdout breaks the script's own "every fact is key=value"
contract. The idempotent path also misreports, because there is no --version flag despite
buildinfo.Version being compiled in: result=present ... version=Usage of context-guru-proxy:
(install.sh:39 takes head -1 of --help).

Since this PR is the one-command-trial PR, the trial currently cannot complete.

3. Blocking — a dead proxy is a silent indefinite hang, and /context-guru:status cannot run in the session that needs it

env HOME=$TH CLAUDE_CONFIG_DIR=$TH/.claude timeout 100 claude -p 'say OK'
exit=124 (my timeout)   stdout: (empty)   stderr: (empty)

Nothing on either stream, indefinitely — this is the state after any crash, reboot or idle-exit.
(The "issue with the selected model" message appears only in the different case where the base URL
is missing the /anthropic prefix.) status/SKILL.md's "Set, nothing answering" branch has the
right diagnosis — but invoking a skill needs Claude to respond, which needs an API call, which is
the broken thing.

A UserPromptSubmit hook that probes /healthz and prints the diagnosis would reach the user with
no model turn. At minimum, install/SKILL.md's "requests in the routed scope fail" should say how
they fail (silent hang), because that is the only clue the user gets.

4. Blocking — the cache preset injects context_guru_expand, the model calls it, and under this preset the call can only fail

Five places promise otherwise: config/config.go:365, docs/reference/presets.md:15,
docs/how-to/choose-a-preset.md:52, docs/how-to/install-plugin.md:60, skills/install/SKILL.md:27.
Measured on the real gateway route (note: /compact does not run the injection path, which is
why this is easy to miss):

tools SENT by client    : ['Read', 'Bash']
tools FORWARDED upstream: ['Read', 'Bash', 'context_guru_expand']

Root cause is a code-vs-its-own-comment contradiction: proxy/proxy.go:85-87 documents the gate as
requiring the request to "carry an expandable marker"; expand/inject.go:86 says "No marker
condition, deliberately."
The real conditions are mode ≠ never, store persists, tool_choice
absent, and hasTools — nothing about markers or whether the pipeline has any Offload at all.

And it is not merely cosmetic. In a real session with marker-shaped text in a file (realistic — this
repo's own docs contain literal <<cg:HASH>>), the model called it unprompted:

req 2  model_called=['context_guru_expand']
req 3  TOOL_RESULT: '[expand: original for id c6a2c7911fb10bc8 is no longer available]'
       expand_unresolved_missing=1

cache mints no markers, so 100% of expand calls under it must fail. Cost is one wasted round
trip and one step of the user's turn. The repair path handles it honestly (is_error cleared, named
id, counted) — the defect is that the path exists at all.

Bounded fairly: forwarded bytes are identical on repeat, so invariant 5 holds and there is no cache
regression; it is idempotent if the client already declares the tool; and the <<cg: reaching
upstream is inside the injected tool's own description, so "no <<cg:HASH>> markers in your
content" stays literally true. HIGH, not critical — but this preset exists specifically so a
stranger can verify one claim by reading one line, and
install-plugin.md:66 says "You can check that claim in one line of config/config.go." They will
get the wrong answer.

Gate InjectAuto on the pipeline containing at least one Offload — it is known at the call site.
TestCachePresetIsCachesplitAlone (config/config_more_test.go:239) passes throughout, because it
asserts the preset map and PresetPipeline("cache") while both injections happen in proxy.go after
apply returns. It needs a sibling that posts a body and diffs the forwarded tools.

(#137 adds a second unconditional injection, so on the merged tree cache forwards three tools —
including on off, the A/B control arm. Raised on that PR.)

5. Blocking — settings.py's backup destroys the user's undo, and uninstall does not restore what it replaced

The design is otherwise genuinely careful: atomic (temp + os.replace, never truncate-in-place),
idempotent (result=unchanged), preserves unknown keys and nesting, refuses malformed JSON
(result=error reason=unparseable_json, rc 3, file byte-identical afterwards), conflict → rc 2
rather than silent overwrite, env: {} cleaned up when the last key goes. Two defects undo that:

(a) The backup clobbers itself. stamp = strftime("%Y%m%d-%H%M%S") is one-second granularity and
shutil.copy2 overwrites. An install→uninstall round trip runs well inside one second:

add --force: backup=...settings.json.context-guru-backup-20260831-214032
remove     : backup=...settings.json.context-guru-backup-20260831-214032   # same path
ls: one backup file.  grep -c corp-gateway -> 0

The survivor holds the post-add state. install/SKILL.md:86 tells the user to report that path
as their undo.

(b) Uninstall does not restore the previous value. cmd_remove deletes the key. It records
replaced=<old value> at add time but never persists or reuses it, so after a --force install over
a user's own gateway, uninstall leaves them with no ANTHROPIC_BASE_URL at all:

FINAL env: {"ANTHROPIC_AUTH_TOKEN": "...", "ANTHROPIC_MODEL": "..."}   # corp-gateway gone

Combined with (a), the backup that held it is gone too. Microsecond stamps (or
os.open(dest, O_CREAT|O_EXCL)) fix (a); persisting and reusing replaced fixes (b).

6. Should be fixed — the atomic write widens the mode of a credential-bearing file

The temp file is created fresh, so os.replace takes the umask mode rather than the replaced file's:

before: 600   ->   after add (umask 022): 644     (file contains ANTHROPIC_AUTH_TOKEN)
backup: 600   (backup() uses copy2, which does preserve mode)

Umask 022 is the common default, so this is the normal case. shutil.copymode(path, tmp) before
os.replace, or an explicit chmod 0600.

7. Should be fixed — --idle-exit is defeated by any health probe, including the dashboard the skills tell you to open

Proven, not read. A 1h-threshold proxy:

21:55:10 idle-exit armed after=1h0m0s check_every=3m0s
23:58:10 shutting down reason="idle for 1h3m0s (--idle-exit 1h0m0s)"

2h03m of wall clock reporting 1h03m of idleness — the clock was last stamped an hour after launch, by
nothing but a /healthz poller that stopped then. stampActivity wraps h.Mux() wholesale
(main.go:578), so /healthz, /metrics, /stats and /dashboard/* all count as activity. A
Kubernetes liveness probe or a Prometheus scrape makes --idle-exit never fire, and
dash/ui/app.js:4798's setInterval(…, 30000) means a left-open dashboard tab prevents the exit
forever
— while both skills tell the user to open it.

The author documents this deliberately (idleexit.go:39-47) and for the hosted case it is arguably
the right default. But it is accidental safety: nothing refuses --idle-exit when --upstreams is
set. A one-line guard beside the floor check would make the stated intent enforceable.

Related nit: the fatal floor refusal logs at INFO, after a context-guru-proxy listening line
(validation at main.go:570, log at :563), so it reads as a started proxy.

The floor itself is sound and I want to say so: at ≥2×TTL of idleness the provider's cache entry is
long dead, so an idle-exit destroys nothing billable. And the Store-loss worry does not
materialise — marker ids are content-hashed and Claude Code re-sends the original from its own
transcript, so the first post-restart turn re-mints the same id into an empty store. Verified: expand
worked after a kill-and-restart with a fresh store.

8. Should be fixed — scripts/gate-a-purego.sh is cited as proof in five places and is not in this PR

git ls-files finds no such file, yet it is named at docs/setup.md:12,
docs/get-started/quickstart-proxy.md:23, .goreleaser.yaml:5, Makefile:13 and install.sh:4
two of those are user-facing docs this PR edits. It is added by #130, so either declare that
dependency or cite the workflow's purego assert step, which does exist and works.

9. Should be fixed — nothing runs the test suite before a release publishes, and CI never tests the shipped configuration

A tag push builds and publishes without go test. Worse, ci.yaml:15 runs the suite only with
CGO_ENABLED: "1", so TestEveryPresetBuilds — which passes with CGO off and guards exactly the
artifact this PR ships — is never executed in the configuration being released. One line in the
assert step: CGO_ENABLED=0 go test ./config/ -run TestEveryPresetBuilds.

10. count_tokens — the behaviour is right, the consequence is undocumented

It returns a count of the original body; no pipeline runs (verified: the route is absent from the
requests counter and leaves cachesplit runs unchanged).

upstream gateway DIRECT      : {"input_tokens":115933}
through the proxy /anthropic : {"input_tokens":115933}    <- byte-identical
what the proxy actually forwards: tokens_before=115853 tokens_after=32802 saved=83051

Keep this. Returning the compacted count would be smaller, the client would believe it has more
room, and because fail-open can revert a component at any moment the next request could forward the
full body and 400. Over-reporting is the safe direction and the literal API answer.

But the cost is real and documented nowhere: Claude Code uses this to decide when to run its own
compaction, so a routed session self-compacts roughly 3.5× earlier than necessary, paying for a
summarization call and discarding transcript the proxy was handling for free. It also excludes the
tool declarations the proxy will add. Suggest exposing count_tokens_forwarded alongside so the
divergence is measurable, and one line in docs/reference/routes.md — where the route is currently
absent entirely.

Two smaller things on it: the hosted tenancy branch (:35-53) is coded correctly but wholly
untested
— both conformance tests build h.Mux() with Tenants == nil, and Mux() has one caller
shared with hosted (main.go:576), so the multi-tenant service serves this route with zero coverage
on the branch standing between it and an unmetered open forwarder. Auth handling is correct and leaks
nothing (copyHeaders strips x-context-guru-* and Cookie; setUpstreamAuth deletes
Authorization/x-api-key/x-goog-api-key before injecting; the nil-key path forwards a caller's
own OAuth token untouched, so the "no API key needed" claim survives). Fail-open is appropriate — a
transport failure returns 502 with a fixed string rather than err.Error(), specifically so a
*url.Error cannot publish the upstream address.

Smaller items

  • os.replace replaces a symlinked settings.json with a regular file. Verified: the symlink is
    gone and the dotfiles copy still holds the old content, so the edit never reached the user's repo.
    Dotfile-managed settings is a common setup; os.path.realpath before writing fixes it.
  • start-proxy.sh:76 prints a dead link. It announces Dashboard: http://127.0.0.1:PORT/dashboard/
    but line 69 never passes --dashboard (curl -o /dev/null -w '%{http_code}'404).
    install-plugin.md:96 and status/SKILL.md:56 advertise the same URL. It is the first line the
    plugin ever prints and the only clickable thing in it.
  • Backups accumulate forever — one per add and per remove, nothing prunes; 20 cycles leaves 40
    files in ~/.claude/.
  • settings.py cannot recognise its own previous URL, so changing userConfig.port and
    re-running install reports a conflict against context-guru itself. Match
    http://(127.0.0.1|localhost|\[::1\]):\d+/anthropic$ as ours.
  • --idle-exit is in no flag table — neither README's nor docs/reference/config.md's — and the
    startup validator that refuses values below 2 × store.ttl_seconds (20000s / 5h33m20s at defaults)
    is documented nowhere in the reference. install-plugin.md:88's "--idle-exit (default 24h)" is
    the plugin's value; the flag's default is 0 = never.
  • The -34.1% justification is measured in the wrong regime for this preset. The A/B genuinely
    isolates cachesplit (cacheinject.md:185-211, placement contributes $0) — but :209 says "one
    task measured three times, not a fleet average"
    , and dashboard.md:219 records that it "ran tasks
    back-to-back inside the TTL"
    , so it is not a paired one-timeline measurement. This repo's own
    interactive figure is dashboard.md:204: $0.0298 across 1,127 sessions / 11,361 requests. The
    env snapshot is captured once per session, 1,105 of 1,127 first requests read zero from cache,
    cachesplit does nothing at all for an agent outside a git repo or under the 1,024-token
    minSplitTokens floor, and config/config.go:379-380 concedes it is a no-op on vLLM/llm-d. A
    first-run plugin user is definitionally the cold case. Suggested replacement for
    presets.md:15: "Best case ~$0.03 across 1,127 sessions of real interactive traffic, and zero if
    you are outside a git repo, on a short system prompt, or on a non-Anthropic backend. The −34.1%
    figure comes from benchmark harnesses running tasks back-to-back inside the cache TTL, which is not
    how an interactive session behaves."
    Also, both presets.md:15 and choose-a-preset.md:58 cite
    results/context-guru.md, which contains neither number.
  • README now gives three answers for the default preset on one page. :120's "The default preset
    is cache" is correctly plugin-scoped, but :126 and :147 say codesmart and the binary
    actually ships house (main.go:46). Saying "the plugin installs with --preset cache" removes
    the ambiguity in your own diff; the two wrong claims are pre-existing and filed as docs: preset facts outside docdrift's scope are stale — wrong default preset in 5 places, stale README pipeline lists #145.
  • .goreleaser.yaml floats its actions on tags (checkout@v4, setup-go@v5,
    goreleaser-action@v6) rather than SHAs. Repo-wide convention, so consistent rather than a
    regression — but this is the one workflow that publishes unsigned binaries users curl down, so it
    is where pinning would buy something.
  • NIT counttokens.go:67strings.NewReader(string(body)) copies twice; bytes.NewReader(body).
  • Real-world caveat, not this PR's fault: against the IBM LiteLLM gateway count_tokens answers
    {"input_tokens":13} for a body whose system prompt alone is ~7,929 tokens. Calling the gateway
    directly gives the same 13, so the undercount is upstream's — but the route's justification (cheap
    client-side budgeting) does not work on that upstream.

Please split this PR

It bundles five independent things: pure-Go releases, a new cache preset, --idle-exit, a Claude
Code plugin, and gateway conformance tests. The release plumbing and the conformance tests are
clean and could land today. The plugin has three blocking defects and is where all the risk is. As
one PR, the good parts are held hostage by the plugin shell. Suggested split: (1) goreleaser +
workflow + the CGO_ENABLED=0 go test gate; (2) conformance tests + count_tokens + its docs;
(3) the cache preset with the expand-injection gate and an honest presets.md:15; (4) --idle-exit
with the --upstreams guard and a flag-table row; (5) the plugin, after items 1, 2, 3 and 5 above.

Verdict

Needs changes, and worth splitting. The engineering underneath is good — the pure-Go claim is
real and verified across all 15 presets, graceful shutdown works, settings.py's conservative half is
well judged, and the release config's trickiest seam is correct. But as it stands a new user cannot
install it, and if they could, uninstalling would kill their session rather than the proxy.

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Two additional findings that landed after my main review, both in install.sh, both HIGH. The first
is a security defect.

11. Blocking — the checksum verification is fail-open, so an unverified binary installs and runs

install.sh:78-93. A checksum mismatch is fatal, but an absent or unfetchable checksums.txt
emits one advisory line and falls through to tar xzf + install -m 755. Proven with a stubbed
curl that serves the tarball and 404s the checksum file:

checksum=unavailable
result=installed
rc=0
$ .../dest/context-guru-proxy   ->  THIS BINARY WAS NEVER CHECKSUM-VERIFIED

An unverified binary landed on a PATH directory and executed — and this binary then handles all of
the user's LLM traffic and holds their API key.

Three things make it worse than a missing check:

  • The file's own comment at :76 says "a failure here is fatal, never a warning." The code
    contradicts it.
  • install/SKILL.md:40-48 enumerates four outcomes for the skill to react to, and
    checksum=unavailable is not among them — so the skill reads result=installed and reports
    success to the user.
  • There is no signature anywhere. (Acknowledged in the PR: signing ownership is an open question. But
    that is the argument for making the checksum path strict, not lenient.)

Fix: die in both else branches. An install that cannot verify should refuse, not warn.

12. Blocking — there is no upgrade path, on the PR that creates the release channel

install.sh:36-41 — any context-guru-proxy on PATH yields result=present, rc 0, regardless of
version
. CONTEXT_GURU_VERSION is read after that early return, so nothing can force an
upgrade. And because there is no --version flag, the version it reports is garbage:

result=present
version=Usage of context-guru-proxy:

(install.sh:39 takes head -1 of --help; buildinfo.Version is compiled in and exposed at
/stats, so the data exists — the flag does not.)

For the PR whose purpose is a release channel, "installs once, never upgradable" is the gap that
matters most after finding 2. Add a --version flag, compare it against the resolved tag, and honour
CONTEXT_GURU_VERSION before the early return.

Addendum on the pkill finding (finding 1)

A second reviewer reached it independently and supplied the other half of the mechanism, which
strengthens the fix: the same SKILL.md uses the bracket trick correctly three lines later
pgrep -af "context-guru-prox[y]" — so the self-match hazard was known to the author and missed in
the one place it bites.

That points at a cleaner fix than the socket lookup I suggested: pass --listen 127.0.0.1:$PORT as a
flag so the port lands in argv, and a bracketed pattern can then match it. Better still, there
is no /shutdown route — adding one would remove the need to pattern-match a process at all.

Two corrections to my own review, for the record

  • I wrote that --idle-exit's interaction with a dashboard tab was a hazard. Sharpening it: the
    "proxy dies under a watching user" half is impossible — nothing can age out while the tab polls
    every 30s. What remains is only that the feature silently never fires while any tab or
    Prometheus scrape is alive, and logs idle-exit armed once and then nothing forever. MEDIUM, not
    HIGH.
  • On the cache preset's savings visibility, worth adding because it affects how you'd verify a fix:
    on a run where cachesplit demonstrably worked (mutated: 2, verdict: moved), /stats still
    reported acted: 0, saved_tokens: 0, savings_pct: 0. acted is the wrong probe for a Reformat that
    relocates a breakpoint — the only positive signals are components.cachesplit.verdict and the
    billed-tier shift. install/SKILL.md:129 sends the evaluator to /context-guru:status "for the
    numbers", and the status skill does correctly lead with billed tiers — so it routes around the
    problem, but the metric and the docs disagree and that should be stated somewhere.

Also worth knowing for the "why does my first run show nothing" case: the plugin detects none of the
three zero-value conditions.
Grepped context-guru-plugin/ for git rev-parse, vllm, llm-d,
minSplit, 1024 — no runtime check anywhere.

Zero-value condition Detected Documented
Non-Anthropic backend no yes (install-plugin.md:67, status/SKILL.md:71)
System prompt under the 1,024-token floor no no
Not a git repository no nowhere

Row 3 is the common case for a casual trial, and it reproduces live: non-git cwd →
cachesplit mutated=0 verdict=skipped; the same task inside a git repo → mutated=2 verdict=moved.
status/SKILL.md:65-73 lists four honest reasons the numbers may be flat and the one that actually
applies is not among them — so a first-run user outside a git repo is shown a zero and told the cache
warms on later turns. True in general, wrong there. A fifth bullet plus
git rev-parse --is-inside-work-tree in the status skill closes it.

amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

## 1. The toolchain gate was our own documentation

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

## 2. A `cache` preset: cachesplit alone

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

## 3. `--idle-exit`: the proxy cleans itself up

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

## 4. Gateway conformance

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

## 5. Review of #141: the `cache` preset advertised a tool whose every call must fail

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

## 6. The rest of the review

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

## Verification

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from 6f10c82 to 5e344d4 Compare September 1, 2026 08:14
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Thank you — this was an unusually useful review, and three of the findings were things no test of mine would have caught. Split as you asked, into this PR (core) and #160 (the plugin): every blocking finding was in the plugin, so nothing clean is held behind it. I went with two rather than five because main.go carries both the idle-exit wiring and the --listen/--version flags, so a finer split could not be done by path without inventing artificial commits.

Where each finding went:

# Finding Where Status
1 pkill kills the user's session #160 fixed — pidfile + socket-owner fallback, --listen puts the port in argv (flag added here)
2 install.sh cannot install; go install fallback absent #160 fixed, incl. download_failed as an outcome and --version (flag added here)
3 Dead proxy hangs silently; status unreachable #160 fixed — UserPromptSubmit hook, since a skill needs the broken thing
4 cache injects context_guru_expand here fixed — gated on the pipeline containing an Offload
5 Backup clobbers itself; uninstall does not restore #160 fixed — O_EXCL + µs stamps; replaced value recorded and restored
6 Mode widened on a credential file #160 fixed, plus the symlink case
7 --idle-exit defeated by any probe here fixed — /healthz and /metrics no longer count; --upstreams now refuses --idle-exit outright
8 gate-a-purego.sh cited but not present here fixed — cites the workflow's assert step instead
9 No tests before publish; CI never tests CGO-off here fixed — CGO-off suite plus the full suite before publishing
10 count_tokens consequence undocumented; hosted branch untested here both fixed
11 Checksum verification fail-open #160 fixed — refuses in every branch
12 No upgrade path #160 fixed — CONTEXT_GURU_UPGRADE / CONTEXT_GURU_VERSION, and --version to compare against

I verified finding 4 before fixing it rather than taking it on faith, and got your result exactly: [Read Bash] in, [Read Bash context_guru_expand] out. Two things fell out of fixing it that are worth flagging:

  • Ten existing expand tests changed fixture. They hand-seed the Store to simulate an offload but built their handler with pipeline: [] — a pipeline that cannot offload. Harmless while injection ignored the pipeline; now they use [linecap], which does not act on their short bodies. No assertion was weakened, but it is a real diff in tests you did not ask me to touch, so please look.
  • I added the mirror-image mutation (HasOffload always false) to prove the gate did not trade one silent defect for another: an offloader whose output nothing can expand.

Two places I did not do what you suggested, both with reasons:

  1. is_ours by URL shape. Your regex ((127.0.0.1|localhost|[::1]):\d+/anthropic) makes litellm's default — http://127.0.0.1:4000/anthropic — read as ours, which would let uninstall delete somebody else's routing; TestSettingsRemoveTakesOnlyOurKey failed the moment I tried it. add now records the URL it wrote and later runs read that record, which fixes the port-change case you identified without the claim.
  2. The probe/viewer asymmetry. You sharpened this to "it silently never fires", and I agree, but I did not simply stop counting all non-chat routes: /healthz and /metrics no longer count, while the dashboard's polling still does. A probe is a machine asking whether the process is up; a tab is a person watching. Exiting under the latter is the worse failure. The --upstreams refusal is what makes the gateway case enforceable rather than accidentally safe — and it needed to be, precisely because I removed the accident.

On the −34.1% figure — you were right and it mattered. Both numbers are now stated with their regimes, along with the three zero cases; the old citation pointed at docs/results/context-guru.md, which contains neither figure. The "not a git repository" case is now checked at runtime by the status skill (#160), since that is the commonest first-run case and was documented nowhere.

Not fixed, deliberately: count_tokens still answers about the original body, for the reason you gave — over-reporting is recoverable, under-reporting costs a failed turn when a fail-open component reverts. The cost is now documented in docs/reference/routes.md, where the route was previously absent entirely. I did not add count_tokens_forwarded; happy to if you want the divergence measurable rather than merely stated.

Still unverified, and the thing I would not paper over: the plugin has never been installed into a real Claude Code session, because install.sh resolves a GitHub release and no tag has published assets. Cutting a tag after this merges is the gate for #160.

Also from your review: the stale preset tables are #142 (now guarded by a set-equality drift test), and the prose instances the guard cannot reach are #143.

amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

## 1. The toolchain gate was our own documentation

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

## 2. A `cache` preset: cachesplit alone

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

## 3. `--idle-exit`: the proxy cleans itself up

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

## 4. Gateway conformance

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

## 5. Review of #141: the `cache` preset advertised a tool whose every call must fail

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

## 6. The rest of the review

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

## Verification

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from 5e344d4 to eadc015 Compare September 1, 2026 08:55
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

## The six blocking findings

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

## Smaller review items

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

## Verification

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

## The six blocking findings

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

## Smaller review items

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

## Verification

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from eadc015 to e624e0c Compare September 1, 2026 10:46
amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from e624e0c to 61827f6 Compare September 1, 2026 10:53
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

## The six blocking findings

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

## Smaller review items

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

## Verification

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid

Copy link
Copy Markdown
Collaborator Author

Restructured per your ask: the pre-existing defects are now #161, and this PR keeps only distribution work.

#161 carries the expand-tool gate (including off, the A/B control arm, which was forwarding an extra tool declaration — so every measurement against that baseline compared arms differing by more than the pipeline), the missing count_tokens route, the C-toolchain claim our own docs got wrong in three files, and the preset facts from #143/#145 — including one neither I nor the subagent had spotted: the binary defaults to house and five documented sites said codesmart, two of which I had edited here without noticing.

It also adds a purego CI job, which is where that claim belongs: build-test runs exclusively with CGO_ENABLED=1 because the race detector needs it, so TestEveryPresetBuilds had never executed in the configuration a user would actually build.

Two things from doing the split that you should know, both mine:

Also filed #162: TestConcurrentCallsDoNotRaceOnTheGateHistogram failed once in a full-suite run ("the race was never exercised"), then passed 8/8 in isolation, twice more in full suites here, and in a full suite on clean main. Unrelated to any of this, reported rather than left in a transcript.

#143 duplicates #145 and should be closed in its favour — I filed it without checking the tracker.

@amiddavid amiddavid changed the title feat(dist): implement local distribution — pure-Go releases, a cache preset, --idle-exit, a Claude Code plugin, and gateway conformance feat(dist): pure-Go releases, a cache preset, --idle-exit, and gateway conformance Sep 1, 2026
amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from 61827f6 to f1af7f2 Compare September 1, 2026 11:19
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

## The six blocking findings

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

## Smaller review items

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

## Verification

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 1, 2026
None of this is distribution work. Every item is a defect in code or docs that already shipped,
found while doing #141, and split out at review request so it can be judged on its own — and so it
can land whether or not the funnel does.

## 1. The expand tool was advertised where no marker can exist

`expand.Inject` under `auto` gated on "the request declares tools" and "the store persists".
Nothing asked whether the pipeline could produce a `<<cg:HASH>>` marker at all, so an
offloader-free pipeline declared `context_guru_expand` to the provider — and every call against it
must fail, because there is nothing in the Store to resolve. Measured on the real gateway route:

  tools SENT by client    : [Read Bash]
  tools FORWARDED upstream: [Read Bash context_guru_expand]

Affected `safe` and any cachesplit-only configuration, and — the one that matters most — **`off`,
the A/B control arm**. A control that carries an extra tool declaration is not a control, and every
measurement taken against it was comparing two arms that differed by more than the pipeline.

The cost when it fires is a wasted round trip and a step of the user's turn: on a transcript
containing marker-shaped text (this repo's own docs contain literal `<<cg:HASH>>`), a model calls
the tool and gets "[expand: original for id ... is no longer available]".

It was also a code-vs-comment contradiction, which is why nobody noticed: `Options.InjectExpand`
documented the gate as requiring "an expandable marker", while `expand/inject.go` says "No marker
condition, deliberately" three lines from the code. Both now describe what happens.

`components.Pipeline.HasOffload()` answers by TYPE ASSERTION, not a list of component names: a
name list is a second copy of "which components are lossy" and drifts the moment somebody adds
one. `components.Offload` cannot be implemented by accident — it requires returning cache keys
proving the original was stashed. Marker independence is preserved (the property that keeps the
tools array byte-stable across a session, and hence the prefix cached): a pipeline does not change
turn to turn.

**Ten existing tests changed fixture.** Every test of the expand loop hand-seeds the Store to
simulate an offload, but built its handler with `pipeline: []` — which cannot offload anything.
Harmless while injection ignored the pipeline; now they use `offloadCapablePipeline` (`[linecap]`,
which does not act on their short bodies). No assertion was weakened; each fixture now matches its
own premise.

## 2. `POST /v1/messages/count_tokens` was not served

Absent it, a client asking how big its context is gets a 404 and falls back to working it out with
**inference requests** — billed calls, caused by a proxy whose purpose is to reduce them. Cheap to
add, and it costs every routed user, not only the funnel.

Forwarded verbatim, with no pipeline. Returning the compacted count would be smaller and would be
wrong in the dangerous direction: the client budgets its own transcript from this number, and
because every component fails open, the next request could forward the full body and take a 400.
Over-reporting is recoverable; under-reporting is a failed turn. The cost of that choice is now
documented in `docs/reference/routes.md`, where the route was absent entirely — a routed session
self-compacts earlier than it needs to (115,933 reported vs 32,802 forwarded on a measured body).

The hosted branch has tests, because that branch is the only thing standing between the
multi-tenant service and an unmetered open forwarder that would send OUR credential upstream.

## 3. Our own docs said the binary needs a C toolchain

`docs/setup.md`, `docs/hosted.md` and `docs/get-started/quickstart-proxy.md` all told evaluators to
install one. It is needed for `go test -race` and for the optional `cg_skeleton` tag, not for the
binary. setup.md went further and named **bifrost's tokenizer** as a cgo dependency, which it never
was — o200k_base is embedded (`internal/tokens/tokens.go`).

Asserted rather than re-claimed: a new `purego` CI job builds with `CGO_ENABLED=0` and
`CC=/nonexistent-c-compiler`, checks the artifact is statically linked, starts it and probes
/healthz. It also runs the packages whose behaviour depends on which components compile in —
because `build-test` runs exclusively with `CGO_ENABLED=1` (the race detector needs it), so
`TestEveryPresetBuilds` had **never executed in the configuration a user would build**. That guard
exists for exactly the `preset: coding` / `unknown component "skeleton"` breakage.

## 4. Preset facts stated outside the guarded files (#143, #145)

- The binary defaults to **`house`**; five sites said `codesmart` (README x3,
  `docs/reference/config.md`, `docs/get-started/quickstart-proxy.md` — the last is step 2 of the
  first page anyone runs). Anyone running the binary bare while reading those measured a different
  configuration than the published SWE-bench numbers describe.
- README's `codesmart`/`codesafe` pipeline lists and
  `docs/get-started/connect-ibm-service.md`'s "Default pipeline" were stale — naming `toon`,
  retired after acting 0 of 5,752 production requests, and omitting components that do run.
  The IBM page's omission of `toolfilter` matters most: that page is what a prospective hosted
  tenant reads to decide what the service does to their traffic.

All regenerated from the `presets` map. The two tables inside #142's drift guard are untouched
here; these are the sites that guard cannot reach.

## Verification

Five mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated        -> TestExpandToolIsAdvertisedOnlyWhereMarkersCanExist FAIL
    on cachesplit-only, `safe`, and `off`
  HasOffload always false         -> same test FAIL on `mcp` and the offloader pipeline: "mints
    markers but no longer advertises the expand tool, so a model cannot recover what it offloaded"
  count_tokens route unregistered -> TestCountTokensIsServed FAIL (404)
  count_tokens rewrites the body  -> TestCountTokensIsServed FAIL
  hosted auth removed             -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The second is the mirror-image check: it proves the gate did not trade one silent defect for
another, an offloader whose output nothing can expand.

Two things I got wrong on the way, recorded because both were caught by tests rather than by me:

- I first asserted `mcp` had no offloader. `smartcrush` implements `components.Offload`
  (`components/offload/smartcrush.go`), so that pipeline genuinely mints markers and genuinely
  needs the tool. The case now asserts the opposite, with the reason — and it is the argument for
  asking the interface rather than keeping a hand-written list.
- Copying `proxy/proxy.go` wholesale from the older distribution branch onto current main silently
  reverted #155's `effPreset`/`notePreset` work. `TestCompactRowNamesThePresetThatRan` — a test I
  had never read — failed with "the dashboard names a pipeline that did not run". The file was
  restored from main and the two edits re-applied on top; #155's change is intact.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

One unrelated flake seen once and not reproduced: `TestConcurrentCallsDoNotRaceOnTheGateHistogram`
failed in a full-suite run with "no single-flight follower ran ... the race was never exercised",
then passed 8/8 in isolation and in two further full suites, and passes on clean main. Reported
separately rather than papered over.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 1, 2026
Review of #161 found the same defect this PR is themed on — claims that are not true — in seven more
places, one of them in shipped code. All seven fixed, plus the `HasOffload` unit test the reviewer
raised without asking for.

Rebased onto current main first, so #142's preset-table guard and the preset pass below cannot
re-fix or re-break each other.

## Merge-blocking

**1. `proxy/proxy.go` named `mcp` as offloader-free.** It is not: `smartcrush` implements
components.Offload, which this PR's own test asserts (`wantAdd: true`) and its own mirror-image
mutation proves. I had corrected the test and left the comment wrong — the copy a future reader
actually trusts.

The fix deletes the list rather than correcting it. The comment now names the shapes affected
(`off`, `safe`, any cachesplit-only configuration) and then says why enumerating presets here is the
wrong move: a list in a comment is a second source of truth, and this one was wrong about `mcp` on
its first draft. That is the whole argument for gating on the interface.

**2. Five sites named the `cache` preset, which does not exist on this base.** It is #141's, and it
reached here in the wholesale copy of `proxy/proxy.go` this PR already admits to, then travelled
into the test files when they were split out of that branch. Reworded to name configurations that
exist here; the underlying defect they describe is unchanged and still reproduces on `off` and
`safe`.

**3. `proxy/counttokens_test.go` carried copy-paste artifacts vet and gofmt cannot see.** A
duplicated 3-line doc comment, and a 20-line orphan documenting a function that lives in
`expandgate_test.go` under a different name and citing a test that exists nowhere. Both from the
same cause: my splitter took each test's doc comment by scanning back to the previous blank line,
which swallowed the FOLLOWING test's comment as a trailing block. A third artifact the review did not
list is fixed too — `expandgate_test.go`'s doc comment still described "the preset's promise" and
cited `docs/how-to/install-plugin.md` and an install skill, both of which belong to #160.

## The rest

**4.** `docs/reference/config.md` and `docs/components.md` said `auto` injection has exactly two
conditions. It has three. Both now say so, and say what the third is for. The cache-stability
argument those passages make is unaffected — a pipeline does not change turn to turn either — so it
gained a member rather than needing a rewrite.

**5. `make build` now sets `CGO_ENABLED=0`.** The docs could claim "no C toolchain" all they liked
while step 1 of the quickstart was `make build`, which needed one because the Makefile exported
`CGO_ENABLED=1` for every target. Pointing readers at `build-static` would have fixed the sentence;
making the DEFAULT build pure Go makes the claim true of the command the docs tell people to run.
`CGO_ENABLED=1` stays for the test targets, where `-race` requires it, and the comment says exactly
that. Verified: `CC=/nonexistent make build` produces a statically linked binary.

README, CLAUDE.md and the quickstart no longer require a C toolchain. All five remaining
`codesmart`-is-the-default sites are corrected — including two in `config/config.go`, which is how
the claim spread to five documents: it sat three lines from the flag that disproves it.

**6.** `docs/setup.md` overstated its own evidence, which is the exact sin this PR is about. It
claimed CI removes the C compiler from `PATH` (with cgo off the toolchain never consults `CC`; that
variable is a tripwire, not the mechanism) and that cross-compilation to four targets is asserted,
when CI builds native linux/amd64 only. Now says what CI actually does, and states separately that
the other three targets were verified by hand and are asserted at release time. Same overstatement
fixed in the `ci.yaml` comment.

**7.** `ci.yaml` promised a linked issue and linked nothing, and named a different flake than the PR
body did. Both are real; the comment is about the campaign one, and now links #163.

## HasOffload unit tests

`./components`: nil-safe, empty pipeline (the A/B control arm), reformatters-only, and an offloader
in three positions. Revert-verified both ways — always-true fails the empty and reformatter cases,
always-false fails the offloader cases.

A registry-walking test was supposed to make it rot-proof, and **it skipped**: registrations happen
in `components/all`, so a test inside `components` can neither see them nor import the package that
does. A test that skips reads as coverage and is not, so it moved to `components/all`, where it
runs — 21 of 21 registered components, 13 implementing Offload. It fails if either count is zero,
because an all-false or all-true population would agree with a broken HasOffload.

Full `go test ./...`, `go vet ./...` and `gofmt -l` clean; doc link/anchor checker re-run over every
document touched.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from f1af7f2 to b90ad0c Compare September 1, 2026 16:52
amiddavid added a commit that referenced this pull request Sep 1, 2026
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
OsherElhadad pushed a commit that referenced this pull request Sep 1, 2026
…pand-tool gate, count_tokens, C-toolchain claim, preset facts) (#161)

* fix: defects that surfaced while building the distribution funnel

None of this is distribution work. Every item is a defect in code or docs that already shipped,
found while doing #141, and split out at review request so it can be judged on its own — and so it
can land whether or not the funnel does.

## 1. The expand tool was advertised where no marker can exist

`expand.Inject` under `auto` gated on "the request declares tools" and "the store persists".
Nothing asked whether the pipeline could produce a `<<cg:HASH>>` marker at all, so an
offloader-free pipeline declared `context_guru_expand` to the provider — and every call against it
must fail, because there is nothing in the Store to resolve. Measured on the real gateway route:

  tools SENT by client    : [Read Bash]
  tools FORWARDED upstream: [Read Bash context_guru_expand]

Affected `safe` and any cachesplit-only configuration, and — the one that matters most — **`off`,
the A/B control arm**. A control that carries an extra tool declaration is not a control, and every
measurement taken against it was comparing two arms that differed by more than the pipeline.

The cost when it fires is a wasted round trip and a step of the user's turn: on a transcript
containing marker-shaped text (this repo's own docs contain literal `<<cg:HASH>>`), a model calls
the tool and gets "[expand: original for id ... is no longer available]".

It was also a code-vs-comment contradiction, which is why nobody noticed: `Options.InjectExpand`
documented the gate as requiring "an expandable marker", while `expand/inject.go` says "No marker
condition, deliberately" three lines from the code. Both now describe what happens.

`components.Pipeline.HasOffload()` answers by TYPE ASSERTION, not a list of component names: a
name list is a second copy of "which components are lossy" and drifts the moment somebody adds
one. `components.Offload` cannot be implemented by accident — it requires returning cache keys
proving the original was stashed. Marker independence is preserved (the property that keeps the
tools array byte-stable across a session, and hence the prefix cached): a pipeline does not change
turn to turn.

**Ten existing tests changed fixture.** Every test of the expand loop hand-seeds the Store to
simulate an offload, but built its handler with `pipeline: []` — which cannot offload anything.
Harmless while injection ignored the pipeline; now they use `offloadCapablePipeline` (`[linecap]`,
which does not act on their short bodies). No assertion was weakened; each fixture now matches its
own premise.

## 2. `POST /v1/messages/count_tokens` was not served

Absent it, a client asking how big its context is gets a 404 and falls back to working it out with
**inference requests** — billed calls, caused by a proxy whose purpose is to reduce them. Cheap to
add, and it costs every routed user, not only the funnel.

Forwarded verbatim, with no pipeline. Returning the compacted count would be smaller and would be
wrong in the dangerous direction: the client budgets its own transcript from this number, and
because every component fails open, the next request could forward the full body and take a 400.
Over-reporting is recoverable; under-reporting is a failed turn. The cost of that choice is now
documented in `docs/reference/routes.md`, where the route was absent entirely — a routed session
self-compacts earlier than it needs to (115,933 reported vs 32,802 forwarded on a measured body).

The hosted branch has tests, because that branch is the only thing standing between the
multi-tenant service and an unmetered open forwarder that would send OUR credential upstream.

## 3. Our own docs said the binary needs a C toolchain

`docs/setup.md`, `docs/hosted.md` and `docs/get-started/quickstart-proxy.md` all told evaluators to
install one. It is needed for `go test -race` and for the optional `cg_skeleton` tag, not for the
binary. setup.md went further and named **bifrost's tokenizer** as a cgo dependency, which it never
was — o200k_base is embedded (`internal/tokens/tokens.go`).

Asserted rather than re-claimed: a new `purego` CI job builds with `CGO_ENABLED=0` and
`CC=/nonexistent-c-compiler`, checks the artifact is statically linked, starts it and probes
/healthz. It also runs the packages whose behaviour depends on which components compile in —
because `build-test` runs exclusively with `CGO_ENABLED=1` (the race detector needs it), so
`TestEveryPresetBuilds` had **never executed in the configuration a user would build**. That guard
exists for exactly the `preset: coding` / `unknown component "skeleton"` breakage.

## 4. Preset facts stated outside the guarded files (#143, #145)

- The binary defaults to **`house`**; five sites said `codesmart` (README x3,
  `docs/reference/config.md`, `docs/get-started/quickstart-proxy.md` — the last is step 2 of the
  first page anyone runs). Anyone running the binary bare while reading those measured a different
  configuration than the published SWE-bench numbers describe.
- README's `codesmart`/`codesafe` pipeline lists and
  `docs/get-started/connect-ibm-service.md`'s "Default pipeline" were stale — naming `toon`,
  retired after acting 0 of 5,752 production requests, and omitting components that do run.
  The IBM page's omission of `toolfilter` matters most: that page is what a prospective hosted
  tenant reads to decide what the service does to their traffic.

All regenerated from the `presets` map. The two tables inside #142's drift guard are untouched
here; these are the sites that guard cannot reach.

## Verification

Five mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated        -> TestExpandToolIsAdvertisedOnlyWhereMarkersCanExist FAIL
    on cachesplit-only, `safe`, and `off`
  HasOffload always false         -> same test FAIL on `mcp` and the offloader pipeline: "mints
    markers but no longer advertises the expand tool, so a model cannot recover what it offloaded"
  count_tokens route unregistered -> TestCountTokensIsServed FAIL (404)
  count_tokens rewrites the body  -> TestCountTokensIsServed FAIL
  hosted auth removed             -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The second is the mirror-image check: it proves the gate did not trade one silent defect for
another, an offloader whose output nothing can expand.

Two things I got wrong on the way, recorded because both were caught by tests rather than by me:

- I first asserted `mcp` had no offloader. `smartcrush` implements `components.Offload`
  (`components/offload/smartcrush.go`), so that pipeline genuinely mints markers and genuinely
  needs the tool. The case now asserts the opposite, with the reason — and it is the argument for
  asking the interface rather than keeping a hand-written list.
- Copying `proxy/proxy.go` wholesale from the older distribution branch onto current main silently
  reverted #155's `effPreset`/`notePreset` work. `TestCompactRowNamesThePresetThatRan` — a test I
  had never read — failed with "the dashboard names a pipeline that did not run". The file was
  restored from main and the two edits re-applied on top; #155's change is intact.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

One unrelated flake seen once and not reproduced: `TestConcurrentCallsDoNotRaceOnTheGateHistogram`
failed in a full-suite run with "no single-flight follower ran ... the race was never exercised",
then passed 8/8 in isolation and in two further full suites, and passes on clean main. Reported
separately rather than papered over.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>

* ci(purego): run one package binary at a time

The new job runs `go test` over five package trees, and `go test` starts up to GOMAXPROCS package
binaries in parallel. On a 2-core CI runner that added a second heavily-parallel run of the proxy
package per PR, and under that contention a timing-sensitive control-plane test from #150
(TestCtlGetCampaignAggregatesPredictedAndRealPerTenant) failed on two unrelated PRs — then passed on
a re-run of the same commit, and passes 3/3 whole-package on a 16-core box against both main and the
affected branch. Filed as #163.

Hunting that flake is not this job's business. Not provoking it is: `-p 1` costs about a minute and
removes the contention this job introduced, without dropping any coverage.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>

* fix: make this PR's own prose true, and unit-test HasOffload

Review of #161 found the same defect this PR is themed on — claims that are not true — in seven more
places, one of them in shipped code. All seven fixed, plus the `HasOffload` unit test the reviewer
raised without asking for.

Rebased onto current main first, so #142's preset-table guard and the preset pass below cannot
re-fix or re-break each other.

## Merge-blocking

**1. `proxy/proxy.go` named `mcp` as offloader-free.** It is not: `smartcrush` implements
components.Offload, which this PR's own test asserts (`wantAdd: true`) and its own mirror-image
mutation proves. I had corrected the test and left the comment wrong — the copy a future reader
actually trusts.

The fix deletes the list rather than correcting it. The comment now names the shapes affected
(`off`, `safe`, any cachesplit-only configuration) and then says why enumerating presets here is the
wrong move: a list in a comment is a second source of truth, and this one was wrong about `mcp` on
its first draft. That is the whole argument for gating on the interface.

**2. Five sites named the `cache` preset, which does not exist on this base.** It is #141's, and it
reached here in the wholesale copy of `proxy/proxy.go` this PR already admits to, then travelled
into the test files when they were split out of that branch. Reworded to name configurations that
exist here; the underlying defect they describe is unchanged and still reproduces on `off` and
`safe`.

**3. `proxy/counttokens_test.go` carried copy-paste artifacts vet and gofmt cannot see.** A
duplicated 3-line doc comment, and a 20-line orphan documenting a function that lives in
`expandgate_test.go` under a different name and citing a test that exists nowhere. Both from the
same cause: my splitter took each test's doc comment by scanning back to the previous blank line,
which swallowed the FOLLOWING test's comment as a trailing block. A third artifact the review did not
list is fixed too — `expandgate_test.go`'s doc comment still described "the preset's promise" and
cited `docs/how-to/install-plugin.md` and an install skill, both of which belong to #160.

## The rest

**4.** `docs/reference/config.md` and `docs/components.md` said `auto` injection has exactly two
conditions. It has three. Both now say so, and say what the third is for. The cache-stability
argument those passages make is unaffected — a pipeline does not change turn to turn either — so it
gained a member rather than needing a rewrite.

**5. `make build` now sets `CGO_ENABLED=0`.** The docs could claim "no C toolchain" all they liked
while step 1 of the quickstart was `make build`, which needed one because the Makefile exported
`CGO_ENABLED=1` for every target. Pointing readers at `build-static` would have fixed the sentence;
making the DEFAULT build pure Go makes the claim true of the command the docs tell people to run.
`CGO_ENABLED=1` stays for the test targets, where `-race` requires it, and the comment says exactly
that. Verified: `CC=/nonexistent make build` produces a statically linked binary.

README, CLAUDE.md and the quickstart no longer require a C toolchain. All five remaining
`codesmart`-is-the-default sites are corrected — including two in `config/config.go`, which is how
the claim spread to five documents: it sat three lines from the flag that disproves it.

**6.** `docs/setup.md` overstated its own evidence, which is the exact sin this PR is about. It
claimed CI removes the C compiler from `PATH` (with cgo off the toolchain never consults `CC`; that
variable is a tripwire, not the mechanism) and that cross-compilation to four targets is asserted,
when CI builds native linux/amd64 only. Now says what CI actually does, and states separately that
the other three targets were verified by hand and are asserted at release time. Same overstatement
fixed in the `ci.yaml` comment.

**7.** `ci.yaml` promised a linked issue and linked nothing, and named a different flake than the PR
body did. Both are real; the comment is about the campaign one, and now links #163.

## HasOffload unit tests

`./components`: nil-safe, empty pipeline (the A/B control arm), reformatters-only, and an offloader
in three positions. Revert-verified both ways — always-true fails the empty and reformatter cases,
always-false fails the offloader cases.

A registry-walking test was supposed to make it rot-proof, and **it skipped**: registrations happen
in `components/all`, so a test inside `components` can neither see them nor import the package that
does. A test that skips reads as coverage and is not, so it moved to `components/all`, where it
runs — 21 of 21 registered components, 13 implementing Offload. It fails if either count is zero,
because an all-false or all-true population would agree with a broken HasOffload.

Full `go test ./...`, `go vet ./...` and `gofmt -l` clean; doc link/anchor checker re-run over every
document touched.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>

---------

Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
…way conformance

Implements the local-distribution proposal (#130) minus the Claude Code plugin, which ships as its
own PR because all six blocking findings from the review of #141 live in it. Nothing here is held
behind that.

`docs/get-started/quickstart-proxy.md`, `docs/setup.md` and `docs/hosted.md` all told evaluators to
install a C toolchain and set `CGO_ENABLED=1`. That is true only for a `cg_skeleton` build.
setup.md went further and named bifrost's tokenizer as a cgo dependency, which it is not —
o200k_base is embedded (`internal/tokens/tokens.go`).

Verified directly on go 1.26.4 rather than taken from the proposal: `CGO_ENABLED=0` with default
tags builds all four release targets (linux/darwin x amd64/arm64, 27.1-33.5 MB stripped), `file`
reports "statically linked", `ldd` reports "not a dynamic executable", the binary starts and
answers /healthz, and `-tags cg_skeleton` fails under CGO_ENABLED=0 with the build-constraints
signature — confirming tree-sitter is the only C dependency.

- `.goreleaser.yaml`: a plain GOOS/GOARCH matrix, no cross-toolchains, no `brews:` block (the tap
  repo and release signing are an unowned question, and nothing may depend on a repo that does not
  exist).
- `.github/workflows/release.yaml`: a tag publishes, `workflow_dispatch` builds the same matrix as
  a snapshot. It asserts the pure-Go claim with `CC=/nonexistent-c-compiler`.
- `make build-static`. The Makefile keeps `CGO_ENABLED=1` because `go test -race` needs it, and the
  comment now says that is a test-time requirement — reading it as a shipping requirement is how
  the wrong claim reached the docs.

The funnel's default, chosen so a stranger can verify the claim by reading one line rather than
trusting four components. Not `safe`, whose extra components are lossless in meaning but still
rewrite the JSON.

Off by default; a gateway or eval-containers deployment must never self-terminate. A signal and the
watchdog converge on the SAME teardown, so the self-killing path cannot drift from the one known to
work. Two properties are load-bearing:

- **The keep-alive inverts "idle."** Pinging is what the proxy does precisely while no client
  traffic arrives — the quiet gap after `end_turn`, where 83.7% of the recoverable dollars sit. A
  pending ping both vetoes the exit and RESETS the clock, so retiring the last ping buys a full
  fresh threshold rather than exiting moments later.
- **Exit wipes the in-memory store.** `store.ValidateIdleExit` refuses anything below
  `max(2 x store.ttl_seconds, 1h)` at startup — ~5h34m at the default. 2x because the TTL is a
  sliding window. `NewMemory` now calls the same `Options.EffectiveTTL` the floor is computed from,
  so the two cannot drift.

All five items from the proposal, under the `cache` preset. Four were already correct and are now
pinned by tests; `POST /anthropic/v1/messages/count_tokens` was missing entirely — without it a
client counts context by issuing INFERENCE requests, billed calls added by a proxy sold on removing
them.

Five places promised it did not: `config/config.go`, `docs/reference/presets.md`,
`docs/how-to/choose-a-preset.md`, the plugin doc, and the install skill. Verified before fixing —
`[Read Bash]` in, `[Read Bash context_guru_expand]` out on the real gateway route.

Root cause was a code-vs-comment contradiction. `Options.InjectExpand` documented the gate as
requiring "an expandable marker"; `expand/inject.go` says "No marker condition, deliberately" and
the real conditions were mode, store-persists and has-tools. Nothing asked whether the pipeline
could produce a marker at all.

`components.Pipeline.HasOffload()` answers that by type assertion rather than a name list (a name
list is a second copy of "which components are lossy" and drifts the moment somebody adds one).
Under `auto`, injection now requires it. `always` still injects unconditionally — an operator who
asks for it by name gets it.

This also fixes `off`, the A/B control arm, which was carrying an extra tool declaration. Marker
independence is preserved, which is the invariant that matters for cache stability: a pipeline does
not change turn to turn, so the tools array stays byte-stable across a session.

**Ten existing expand tests changed fixture, and that is worth reading.** They hand-seed the Store
to simulate an offload, but built their handler with `pipeline: []` — a pipeline that cannot
offload anything. That was harmless only while injection ignored the pipeline. They now use
`offloadCapablePipeline` (`[linecap]`, which does not act on their short bodies), so each fixture
matches its own premise. No assertion was weakened.

- **`--idle-exit` was defeated by any health probe** (finding 7). `/healthz` and `/metrics` no
  longer count as activity: a probe on a schedule shorter than the threshold meant the exit NEVER
  fired and logged nothing to say so — measured, a 1h-threshold proxy reporting "idle for 1h3m0s"
  after 2h03m. A dashboard poll still counts, deliberately: a probe is not a viewer, and exiting
  under somebody who is watching is the worse failure.
- **A gateway may no longer self-terminate.** `--idle-exit` with `--upstreams` is refused at
  startup. That safety was previously accidental — it held only because hosted deployments run a
  liveness probe, which the change above stops counting.
- **The floor's refusal was logged after "listening"**, so a rejected configuration read as a
  crash. Both refusals moved earlier and into one testable `checkIdleExit`.
- **`--listen` and `--version` flags** (findings 2 and 12, which are the plugin's, but the flags
  are the core's). The address reached the process only through the environment, so no supervisor
  or `ps` could tell which port an instance held; and `buildinfo.Version` was reachable only via
  `/stats` on a running proxy, so an installer asking `--help` recorded "Usage of
  context-guru-proxy:" as the installed version.
- **Nothing tested the shipped configuration** (finding 9). A tag published without running any
  tests, and CI runs the suite only with `CGO_ENABLED=1` — so `TestEveryPresetBuilds`, which
  guards exactly the CGO-free artifact, was never executed in that configuration. The release
  workflow now runs a CGO-off suite over the packages whose behaviour depends on which components
  are compiled in, plus the full suite, before publishing. It also asserts `--version` answers.
- **`scripts/gate-a-purego.sh` was cited as proof in four places and is not in this PR**
  (finding 8). Those now cite the release workflow's own assert step, which exists here and fails
  the release if a cgo dependency escapes the `cg_skeleton` tag.
- **The savings claim was measured in the wrong regime.** −34.1% / 96.7% comes from a harness
  running tasks back-to-back inside the provider's 5-minute TTL, and is one task measured three
  times; this project's own interactive figure is $0.0298 across 1,127 sessions, with 1,105 of
  1,127 session starts reading zero from cache. Both are now stated, with the zero cases (outside
  a git repo, under the 1,024-token floor, non-Anthropic backend). The old citation pointed at
  `docs/results/context-guru.md`, which contains neither number.
- **`count_tokens` behaviour kept, consequence documented** (finding 10). It answers about the
  ORIGINAL body — over-reporting is recoverable, under-reporting costs a failed turn when a
  fail-open component reverts. What was undocumented is the cost: the client self-compacts earlier
  than needed (115,933 reported vs 32,802 forwarded). Now in `docs/reference/routes.md`, where the
  route was absent entirely. Its **hosted branch was wholly untested** — that branch is all that
  stands between the multi-tenant service and an unmetered open forwarder — so it now has one.
- `--idle-exit` and `--version` added to both flag tables; `bytes.NewReader` in counttokens.

Six mutations, each proven to have landed in the source before its result was allowed to count:

  expand injection ungated (the defect)   -> TestCachePresetAdvertisesNoExtraTool FAIL
    cache: sent [Read Bash], forwarded [Read Bash context_guru_expand]
    off:   sent [Read Bash], forwarded [Read Bash context_guru_expand]
  HasOffload always true                  -> same test FAIL, same two subcases
  HasOffload always false                 -> FAIL on the offloader subcase: "mints markers but no
    longer advertises the expand tool, so a model cannot recover what it offloaded"
  probes count as activity again           -> TestProbesDoNotDeferIdleExit FAIL ("two hours of
    nothing but liveness probes: idle past the threshold, but watchIdle never exited")
  gateway guard disabled                   -> TestCheckIdleExitRefusesAGatewaySelfTerminating FAIL
  count_tokens hosted auth removed         -> TestCountTokensHostedRequiresAuth FAIL (502, want 401)

The third mutation is the one worth noting: it proves the fix did not trade a silent defect for its
mirror image, an offloader whose output nothing can expand.

`go build ./...`, `go vet ./...`, `gofmt -l` and the full `go test ./...` are clean.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
@amiddavid
amiddavid force-pushed the feat/local-distribution branch from b90ad0c to 7281324 Compare September 1, 2026 17:59
amiddavid added a commit that referenced this pull request Sep 1, 2026
…view's blockers fixed

Split out of #141 as its own PR: all six of the review's blocking findings were in the plugin, and
the release plumbing and conformance work should not wait behind them. The core lands in #141.

`/plugin marketplace add rossoctl/context-guru` → `/plugin install` → `/context-guru:install`.
Three skills over four scripts and two hooks. Default routing scope is
`.claude/settings.local.json`: one repo, gitignored, `--global` an explicit opt-in — a base URL
pointing at localhost breaks Claude Code everywhere a dead proxy is routed.

**1. `/context-guru:uninstall` killed the user's own session and left the proxy running.** It ran
`pkill -f "context-guru-proxy.*${PORT}"`. The port was passed through `LISTEN_ADDR` in the
environment, so it appeared nowhere in the proxy's command line and the pattern matched no proxy —
while it DID match the shell running the `pkill`, i.e. the session's own Bash tool. A user runs
uninstall *because* their sessions are broken; this killed the session mid-command, reported
nothing removed, and left the port held.

Fixed with a handle rather than a better pattern: the starter passes `--listen` (so the port is in
`argv` and `ps` is honest) and writes a pidfile under `~/.local/state/context-guru`; uninstall kills
that PID, falls back to the socket's owner via `lsof`/`ss`, and confirms the process is ours before
killing anything. The skill also no longer offers a broader pattern as a fallback — on a host
running a production instance or a benchmark arm, that would take those down too.

**2. `install.sh` could not install anything, and its documented fallback was missing.** Strict
checksums now; `download_failed` (a tag with no assets) is documented as an outcome; the `go
install` fallback the header comment described is implemented; curl's stderr no longer breaks the
`key=value` contract the skill parses.

**3. A dead proxy is a silent, indefinite hang** — no output on either stream — and
`/context-guru:status` cannot diagnose it, because invoking a skill needs a model call, which is the
broken thing. New `check-proxy.sh` on `UserPromptSubmit`: it probes `/healthz`, tries to restart,
and otherwise prints what to do. A hook is the only thing that runs without a model turn. It never
blocks a prompt.

**4. The `cache` preset advertised `context_guru_expand`.** Fixed in #141 (the gate belongs in the
proxy); the docs and the install skill here no longer claim otherwise where they were wrong.

**5. `settings.py` destroyed the user's undo, and uninstall did not restore what it replaced.** The
backup stamp was second-granularity with an overwriting `copy2`, so an install→uninstall round trip
wrote both backups to the same path and the survivor held the POST-install state — the value it
existed to protect was gone from the file AND the backup. Now microsecond-stamped and created with
`O_EXCL`. And `replaced` was reported then forgotten, so after a `--force` install over somebody's
gateway, uninstall left them with no base URL at all; the replaced value is now recorded and
restored.

`is_ours` deserves a note. The review suggested matching `http://(127.0.0.1|localhost|[::1]):\\d+
/anthropic` as ours, to stop a port change reporting a conflict against context-guru itself. A test
caught why that is wrong: litellm's default is `http://127.0.0.1:4000/anthropic`, so a URL-shape
rule would let uninstall delete somebody else's routing. Two local proxies are indistinguishable by
URL, so `add` records the URL it wrote and later runs read that record. Anything unrecorded stays a
conflict — for both add and remove.

**6. The atomic write widened a credential-bearing file's mode** from 600 to 644 under the common
umask, and `os.replace` onto a symlinked `settings.json` replaced the LINK with a regular file, so a
dotfile-managed setup silently never received the edit. Mode is preserved; the path is resolved
first.

- **`start-proxy.sh` printed a dead dashboard link** — it advertised `/dashboard/` and never passed
  `--dashboard`, so the first line the plugin ever prints was a 404. Now passed, with
  `--dashboard-db` under the state directory: the default would write
  `./context-guru-dashboard.db` into the user's repository.
- **Backups accumulated forever** (one per add and per remove). Pruned to the newest 10.
- **The zero-value cases are now stated** where a first-run user reads them, and `status` checks the
  one that is both commonest and previously undocumented: **outside a git repository** there is no
  environment snapshot, so `cachesplit` skips and the saving is exactly zero. The status skill also
  no longer treats `acted: 0` / `savings_pct: 0` as a verdict — those count content removal, and
  this component relocates a breakpoint.
- **`--idle-exit`'s 24h is the plugin's value, not the flag's default** (which is 0 = never). Said
  so, along with probes not counting as activity.
- Upgrade path documented (`CONTEXT_GURU_UPGRADE=1`, `CONTEXT_GURU_VERSION`).

The scripts are tested from Go (`context-guru-plugin/plugin_test.go`) so `go test ./...` and CI
cover them. Seven mutations, each proven to have landed before its result counted:

  backup() back to overwriting copy2       -> TestBackupsDoNotClobberEachOther FAIL
    "both operations reported the same backup path ..., so one overwrote the other"
  uninstall stops restoring                -> TestUninstallRestoresTheBaseURLItReplaced FAIL
    restored="" want "https://gateway.corp.example/anthropic"; env left {ANTHROPIC_AUTH_TOKEN:keep}
  mode no longer preserved                 -> TestSettingsPreservesFileMode FAIL
  realpath removed                         -> TestSettingsFollowsASymlink FAIL
  checksum fail-open again                 -> TestInstallRefusesAnUnverifiedDownload FAIL
  port back in the environment             -> TestHookMakesTheProxyIdentifiable FAIL
  pidfile no longer written                -> TestHookMakesTheProxyIdentifiable FAIL

One of those is worth recording as a process note: my first attempt at the backup mutation reverted
only the timestamp granularity and left the `O_EXCL` retry loop in place, so the name was still
unique and the test passed — proving nothing. Reverting half a fix is its own way to get a vacuous
result. The run above restores the original function whole.

Pre-existing coverage still passes: settings merge/conflict/removal/backup, and the hook's silence
in unrouted projects, idempotence, non-failure when the binary is missing, and its wait for
`/healthz`.

**Still not verified end to end in a real Claude Code session**, because `install.sh` resolves a
GitHub release and no tag has published assets yet. That is the first thing to do once #141 merges
and a tag exists.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: DAVID AMID <DAVIDA@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

3 participants