Skip to content

feat(hooks): gate junk queries out of the WebSearch MISS ledger - #179

Closed
vraspar wants to merge 1 commit into
mainfrom
vraspar/websearch-junk-gate
Closed

feat(hooks): gate junk queries out of the WebSearch MISS ledger#179
vraspar wants to merge 1 commit into
mainfrom
vraspar/websearch-junk-gate

Conversation

@vraspar

@vraspar vraspar commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

The CLI-side v0 of the junk gate in BackTrackCo/tenjin#704. The server-side eligibility column stays that issue's scope; this is only the half that decides what lands in the local ledger.

The problem

The PreToolUse/WebSearch hook records every query it sees into ~/.tenjin/searches.json. A recorded MISS is not a note, it is an obligation: the Stop hook nags the agent to publish it back or close it, and the server row feeds /trending's questions tier. Most web searches are not marketplace questions at all (Cluely launch video who made it, land-book.com website inspiration 2026), so the ledger fills with fake demand and the nag loses its meaning.

The change

In the generated WebSearch hook only:

  • Four narrow patterns test the query text: social/news artifacts, design inspiration, curated-list shopping, stock media.
  • They apply only when the response decision is MISS. Then recordSearch is skipped entirely: no local entry, therefore no obligation and no Stop nag.
  • The POST to /api/agent/search is unchanged, so the server row still exists for demand telemetry and for tenjin#704's server-side classifier.
  • A CANDIDATES response is always recorded and always hinted, whatever the patterns say. HIT overrides: if the marketplace surfaced answers, the question was marketplace-shaped by definition.
  • HOOK_SCRIPT_VERSION 17 to 18.
  • No new config key. tenjin config set hooks.searchMode off already disarms the whole hook.

The measurement

Against 1094 unique real WebSearch queries extracted from this machine's transcripts (1617 .jsonl files under ~/.claude/projects), the four shipped patterns flag 68 queries, 6.2%.

Deliberately rejected: the "operator soup" pattern from tenjin#704's handoff comment (bare OR, quoted fragments, site:). Reconstructed as 1+ bare OR | 2+ quoted fragments | site: (the narrowest variant that still flags all three false positives cited in the handoff), it flags 124 of the same 1094, 11.3% — and the flagged set is full of genuine durable questions:

  • iCloud Private Relay Sec-Fetch headers stripped OR service worker Sec-Fetch-Dest empty prefetch Sec-Purpose
  • "dvh" caniuse baseline "widely available" 2023 date OR "2024" OR "2025"
  • execCommand copy "not allowed" OR "SecurityError" async setTimeout requires user gesture

Every variant of that rule sits in the same band: bare OR alone flags 6.4%, 2+ quoted 7.9%, 2+OR | 2+quoted | site: 9.6%. The handoff's original run measured 10%; my reconstruction lands at 11.3% on a corpus that has grown since. The conclusion is not sensitive to which variant you pick — the rule is syntactic, and technical questions are the ones that use search operators.

Also rejected: a bare best ... 20XX rule, because maintained comparisons are in-scope demand per the tenjin-search skill. best article extraction API 2026 Firecrawl Jina Reader Exa and llms.txt best practices 2026 examples are real questions, and both survive the shipped patterns.

The asymmetry is deliberate and conservative-first: when unsure, the query is real. Missing junk costs one stale line in a batch the agent already skims; suppressing a real question costs the finding it would have become.

Tests

14 new cases in src/lib/hook-scripts.test.ts, all executing the real generated script as a child process in the existing style. Both directions are pinned with real queries out of the transcripts.

  • 6 must-flag: no local entry, hits() === 1 so the POST still happened, exit 0 and silent.
  • 7 must-not-flag: the entry is written with source: 'websearch-hook'.
  • 1 owner-locked: a flagged query with a CANDIDATES response is still recorded and still hinted.

Each gate was reverted separately to prove the tests bite: dropping the junk gate fails exactly the 6 must-flag cases; extending it to CANDIDATES fails exactly the override case.

Ordering

Buyers need to re-run tenjin install to pick up hook script v18. tenjin update replaces the binary and nothing else, and the self-heal does not reach hook scripts, so an existing install keeps the v17 record-everything behavior until someone re-runs install.

Checks

pnpm typecheck, pnpm lint, pnpm format:check, pnpm build all clean.

pnpm test: 2152 passed, 10 skipped, 2 failed (2164). The 2 failures are pre-existing on origin/maindoctor.test.ts "names the rule on auto" / "on full-auto" fail identically at 0c92f5b with none of this branch's changes. Baseline at origin/main is 2138 passed / 10 skipped / 2 failed (2150), so this branch is exactly +14 passed with skips and file counts unchanged.

pack-smoke runs outside vitest and was not run locally (it rewrites ~/.claude/skills on the host), so a green local suite is not a green CI here — CI is the check that matters for it.

Comment density on added lines: 12/81, 14.8%.

🤖 Generated with Claude Code

A recorded MISS is a local obligation: the Stop hook nags the agent to
publish it back or close it. Most web searches are not marketplace
questions, so the ledger filled with demand nobody has.

A MISS whose query matches one of four narrow patterns is no longer
written to searches.json. It is still POSTed to /api/agent/search, so the
server row survives for demand telemetry. A CANDIDATES response is always
recorded and always hinted, whatever the patterns say.

Hook script version 18, so `tenjin install` rewrites the scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@A1igator A1igator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: the gate's shape is right, pattern 1 is not, and the base is behind a refactor of the line it patches

Reviewed against main at 014932a, verified at fe93a05. Findings below were run against the head checkout and the regexes were executed rather than read.

What's solid:

  • The asymmetry is the correct one and it is stated where it belongs: an unsure query is a real query, because a suppressed question costs the finding it would have become and a missed junk line costs one row in a batch the agent already skims.
  • The CANDIDATES override, and pinning it as owner-locked. If the marketplace surfaced answers, the question was marketplace-shaped, and that outranks any syntactic guess.
  • Rejecting the operator-soup rule, with the flagged examples shown rather than described. Three durable questions out of that rule's own output is the kind of evidence that settles a design argument, and the variant sweep showing the whole family sits in the same band is what makes the rejection stick rather than read as taste.
  • Leaving the POST alone, so the server row still reaches tenjin#704's classifier and only the local obligation is dropped.

Major

  1. [correctness] pattern 1 matches platform names on their own, which is where durable integration questions live: the first alternation flags reddit, discord, youtube, linkedin, instagram, podcast and hacker news as bare tokens (hook-scripts.ts:98-104). Those are not junk signals, they are the most-searched API surfaces on the web. I ran the four shipped regexes over eleven realistic technical queries and all eleven were suppressed, including discord.js v14 gateway intents GUILD_MEMBERS privileged 4014 disallowed intent, YouTube Data API v3 search.list quota cost 100 units per call, LinkedIn OAuth r_liteprofile deprecated replacement openid profile scope migration and Reddit API pricing 2023 rate limit 100 QPM per OAuth client id. Every one is a version-specific integration gotcha, which is in-scope demand by the tenjin-search skill's own definition, and each would be silently unrecorded: no entry, no nag, no --search-id to report against, and nothing on the machine showing it happened. The must-not-flag set is seven queries and not one of them names a platform, so this class is unpinned in both directions. What makes the fix cheap: of your six must-flag cases only two depend on a bare platform token, so requiring the platform to co-occur with an artifact or discussion noun (thread, post, comments, AMA, video) keeps four of them untouched. The junk in your examples is the artifact, not the platform.

  2. [testing] awesome-[a-z-]+\b never matches a list name containing a digit, and no test would notice: [a-z-]+ cannot consume the digit and the trailing \b then cannot be satisfied, so awesome-x402, awesome-web3 and awesome-vue2 all fail while awesome-selfhosted passes (hook-scripts.ts:101). Your own must-flag case x402 protocol Discord Telegram community awesome-x402 list builders is therefore caught by the bare discord token, not by the curated-list rule it looks written for, which is why deleting that rule entirely would leave all fourteen tests green. The revert check described in the PR body drops the gate as a whole, so it establishes that the gate bites but not that any individual pattern does. Fix: awesome-[a-z0-9-]+, and revert each pattern separately so every one of the four is known to be load-bearing for at least one case.

  3. [hygiene] the base predates the refactor of the exact line this patches, and re-applying it is a decision rather than a merge: the merge base is 0c92f5b and GitHub reports the PR CONFLICTING. Since then #180 landed and moved recordSearch out of the WebSearch hook's main() into the shared marketplaceSource, split the body into askTenjin/hintLines, added a dispatch hook that records through the same function, and already took HOOK_SCRIPT_VERSION to 18, so this PR's 18 has to become 19 and the ordering paragraph's "keeps the v17 record-everything behavior" is stale. The part that is not mechanical: once recordSearch is shared, "in the generated WebSearch hook only" stops being a location and becomes a choice. The obligation rationale genuinely does not apply to the dispatch arm, since the Stop hook never raises those, but that arm is also the one nothing ever drains, so junk there sits in the demand budget until evicted. Whichever way you take it, say so at the gate, because the next reader will find one call site and two callers.

Minor

  1. [correctness] color palette and icon set are the same class one size down: accessible color palette WCAG 2.2 contrast ratio 4.5:1 and matplotlib colorblind safe color palette tab10 vs viridis both flag on pattern 2, and lucide icon set tree shaking bundle size vite flags on pattern 4. Design vocabulary and front-end engineering vocabulary overlap heavily, and these two tokens sit in the overlap.

  2. [agent-usability] a suppressed query leaves no trace anywhere on the machine: no entry, no counter, no line, and the searchId the server minted is unreachable locally, so a false positive is undiscoverable after the fact and unreportable even once noticed. Given the whole design rests on the claim that false positives are rare, the cheapest way to keep that claim honest is to make them countable: either record the entry with a marker that the Stop hook and outcome --all-open both skip, or keep a counter beside hook-nags.json.

Nits (2), none blocking
  1. [hygiene] the 6.2% is one machine's corpus: 1094 queries from one developer's transcripts bounds what the rate can tell you, and the population most at risk from pattern 1 is whoever integrates against those platforms. Worth stating as a limit on the number rather than leaving it to be read as a general rate.
  2. [hygiene] no lever for someone the gate is wrong about: hooks.searchMode off disarms the whole hook, and the gate only changes on a re-install because it is baked into the script. A user who hits a false positive has no smaller move available.
Verified, not issues
  • The regexes serialize into the generated script cleanly: toString() preserves the i flag and none of the four sources contains a backtick or ${, so nothing escapes the template.
  • The gate runs after the response boundary and only on MISS, so a validation failure still exits quiet and a HIT is untouched, which is what the override test pins.
  • The local ledger and the server row are deliberately allowed to diverge, and the PR says so rather than implying the query is dropped.

Heads up on merge order, not a review finding: #177 and #113 both still touch hook-scripts.ts, and #177 rewrites the Stop hook's source branch that decides what a recorded MISS turns into.

Verdict: comments-only. The gate's architecture is the right one and the rejected-rule analysis is the strongest thing in the PR, but pattern 1 as written suppresses the exact question class the marketplace exists to collect, and the test set cannot see it. Ran locally: head checkout, the four regexes executed against eleven constructed technical queries and against your six must-flag cases with the platform tokens removed, plus the awesome- digit case.

@vraspar

vraspar commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Closing unmerged. @A1igator's review is why, and both majors were verified against head rather than taken on faith.

The eleven-of-eleven result is the finding that ends this PR, not just pattern 1. Once bare platform tokens go, and awesome-[a-z-]+ is fixed rather than being carried by a bug in another rule, and color palette / icon set come out for the same reason, what remains reaches about 1.5% of the 1094-query corpus. In the CLI that costs a HOOK_SCRIPT_VERSION bump and a forced tenjin install for every buyer, against a nag #164 already reduced to one batched line per session. The trade stopped making sense.

The deeper point the review surfaced: a regex over query text is a syntactic guess at a semantic property. Both rule families we tried failed in the same direction, toward suppressing precise technical questions, which is the one class the ledger exists to collect.

Two corrections to the record on the way out:

  • The dispatch arm was never at risk. Reviewing Major 3 against main: dispatch entries carry source: 'dispatch-hook', the Stop hook's three-way branch defaults to silence for them, and budgeted() already caps them at STORE_DEMAND_MAX, evicting oldest-demand-first while protecting cli and websearch-hook entries. "Nothing ever drains that arm" is answered by code in feat(hooks): SessionStart instinct primer and research-dispatch hook #180. The gate-placement question was real; the harm behind it was not.
  • The 6.2% was one machine's corpus, as the nit says. Every number in this PR and in the evidence comment carries that bound.

All of it now lives on BackTrackCo/tenjin#704: the operator-soup rejection with its examples, the variant sweep showing the family sits in one band, the eleven suppressed integration queries, and the argument that query-shape rules belong in a scored classifier as an input, never as a boolean that drops a row. The server side is where that 1.5% costs a column instead of a release.

Branch left in place for the sweep data. Thanks for running the regexes instead of reading them; that is what made this decidable.

@vraspar vraspar closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: high High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants