If your scorer can't return zero, it will rank a dead thread
My growth lane ranked a 195-day-old HN item and four competitor landing pages as top leads. The fix was a hard zero, not more decay. Check your own queue.
Most agent systems eventually grow a ranked worklist. Leads, alerts, retries, documents to review, tickets to triage. You write a scoring function, sort descending, show the top ten, and hand the list to a human or to a model that acts on it.
The bug that shows up in every one of these is the same: the score measures how interesting a row is, and nobody ever checks whether the row can be acted on at all. So the list fills with the most interesting things you cannot do anything about, and because the list is sorted, they go on top.
I built one of these last month and it did exactly that. Here is what it ranked, and what it took to make it stop.
17 searches, a JSON ledger, and no send path
The lane is small and it is all open source. scripts/growth/signal-hunt.sh runs three sources: exa (a hosted MCP server, keyless), the Hacker News Algolia API, and Discourse /search.json on public forums. The queries live in three text files, one per source, because the sources want different things: exa wants a description of the ideal page, HN wants keywords, Discourse wants its own syntax (after:YYYY-MM-DD order:latest). Then scripts/growth/parse-signals.py parses, classifies, dedups against a persistent ledger, scores, and writes a markdown digest.
It finds people publicly describing the problem I work on, so I can answer them by hand. It drafts and ranks. There is no send path in either script and there never will be; outreach is a human act. Cost is $0, because paid search on X starts at $200/month, which is twice the marketing budget. The rule I settled on for every social surface: read by free API, write by human browser session.
This runs on Vodou’s own scheduler, which is the same local-first system I build everything else on and which you can set up on your own machine. The hunt keeps a 0 11 * * * cron. The triage step used to be a second cron at 11:30, hoping the first had finished; it is now an automation that watches the ledger hourly and fires once per new lead. First run after the migration: four triages, cursor advanced, no duplicates.
Four of my top ten were competitors announcing themselves
First run, the actionable list was topped by ContextVault, Mem0, Core and MemoryPlugin.
Those are not prospects. That was the competitive set announcing itself, and the author of a memory tool is the one person on earth who will never install another one. The queries were not wrong, either: “someone asking whether a tool exists to share one memory across ChatGPT, Claude and Gemini” returns launch posts, because a launch post is someone saying that tool should exist.
The fix was two regexes in parse-signals.py: a launch-verb pattern (show hn:, launching, introducing, we built, i built) matched against title plus URL, and a product pattern matched against title, URL and snippet. Either a vendor brand in the title suffix or both patterns firing lanes the row as rival, scores it zero, and files it under “competitive set, never pitched.” Free competitor tracking fell out of the same run, which was the only pleasant surprise in the whole build.
A 195-day-old HN item outranked the only live thread
This is the one that made me throw away the scoring function.
My original score had an age decay term. Older is worth less, standard stuff. The decay bottomed out at 90 days, so past three months every row kept a small residual value. Combine that with a high lane weight and good engagement numbers, and a 195-day-old Hacker News item outscored a forum thread posted that week.
Hacker News disables replies roughly two weeks in. That item was not a low-value target. It was not a target. There is no text box on the page. Neither were the four vendor landing pages sitting next to it, or the help-center docs, or the SEO listicles: no comment box, no human on the other end.
So repliability stopped being a score input and became a precondition. A URL is a reply target only if it matches a venue table (HN item, Reddit comments permalink, Discourse /t/, Stack Exchange question, GitHub issue or discussion), and each venue carries its own window: 14 days for HN, 45 for Reddit, 60 for forums, 90 for GitHub. Anything with no venue is laned article and scores exactly 0. It is still useful, it just feeds the writing side instead of pretending to be a person.
The forum had already published the flag I was estimating
Age is a proxy for “is this thread open,” and proxies lose. One Discourse topic was 19 days old against a 60-day window, cleared every gate, and ranked as the top new ask of 2026-09-15. Support had closed the thread the day before. Discourse says so outright in its search payload: closed and archived are fields. I was modelling a state the source hands you for free.
Two smaller ones from the same file, both worth the thirty seconds it takes to check your own:
The recurrence counter was wrong in a way that read as signal. seen_count was meant to mean “runs this lead resurfaced in,” but 17 searches share one ledger, so a lead answering five of them incremented five times per run. One row read 16 after three runs, and the triage prompt had been told to treat a high count as a person raising the issue repeatedly. It now increments once per run, per lead.
And the digest header counted the actionable list after slicing it to ten, so it printed “10 repliable” forever, no matter what the real number was. Count, then cap.
A scorer without a hard zero has only a discount
The transferable property, stated so you can check it rather than agree with it:
In any ranked worklist, the precondition that makes the action possible must be a gate that returns exactly zero, evaluated before the score, and read from the source rather than inferred from a proxy.
A scoring function whose minimum over your real data is greater than zero cannot express “never.” It can only express “less.” Decay, penalties and multipliers are all “less.” The moment a row with no comment box can be lifted by engagement numbers, your ranking is measuring interest and reporting it as actionability.
Three checks on your own queue
Nothing here needs my code. Point these at whatever table your agent sorts.
First, ask whether your scorer can say no:
SELECT MIN(score) AS floor,
SUM(CASE WHEN score = 0 THEN 1 ELSE 0 END) AS hard_zeros,
COUNT(*) AS rows
FROM work_queue
WHERE created_at > date('now','-30 day');
Passing looks like floor = 0 with a real share of hard_zeros, typically most of the table. Failing looks like floor = 0.41, hard_zeros = 0: every row is still a candidate, and your top ten is whatever floated.
Second, audit the top twenty against their own precondition, independently of the score. Fetch each one and assert the thing that has to be true for the action to work: HTTP 200, not closed, not archived, permission present, inventory above zero, whatever your domain’s version is. If more than one or two of twenty fail, the ranking is decorative.
Third, prove that empty and broken do not look the same:
cp queue.db /tmp/before.db
./producer --input ./fixtures/frozen # identical input, twice
./producer --input ./fixtures/frozen
sqlite3 queue.db "SELECT id, seen_count FROM work_queue ORDER BY id LIMIT 5;"
HTTPS_PROXY=http://127.0.0.1:1 ./producer; echo "exit=$?"
Counters should move by exactly 1 per run; if one jumps by five, you are counting matches and calling it recurrence. And the sabotaged run must exit nonzero and say which fetches died. If it exits 0 with an empty result, a dead upstream and a genuine quiet day are indistinguishable in your system, forever. That is why the hunt exits 3 when zero of N searches returned data, and prints searches: N/M ok as the first line of every digest. Absence-shaped metrics are satisfied by total failure.
Every signal engine ships decay, and decay cannot say never
The public prior art on this problem is good and it is all built on the same shape. RZ-Logic’s signal-driven outbound engine lists “Score Decay: stale signals automatically lose weight,” which is precisely the design I removed: weight-losing is not gating. SignalPipe runs a three-judge LLM panel to separate real buyers from sellers and competitors, which is a much more expensive version of my two regexes and solves one of my four failures. Talocode’s replylane scores reply opportunities on timing, author size and competition, and is explicit that it does not auto-post. GideonOS/signal watches for buying signals and drafts outreach on your own keys.
Every one of them scores intent well. None of the READMEs describes a hard test for whether the target accepts a reply at this moment. That gap is not laziness, it is the natural consequence of treating relevance as the whole problem.
Anthropic’s Building Effective Agents says to find the simplest solution and add complexity only when needed. My read after this build: the simple component was the right call, and the complexity I skipped was not where the risk lived. A regex plus a URL table beat a judge panel here. What I actually needed was a cheap, boring precondition check, and a judge would have been graded on the same ranking the gate was supposed to protect.
dev.to has a comment box and my parser files it as an article
Live limitation, unfixed today: exa labels results by source, and my venue table works off URL shape. A dev.to post with an open comment thread has a real box and a real human, and it lands in the article pile. I am losing genuine reply targets on that one, and the fix is per-source venue rules rather than a single URL table. The article lane is honest about what it is, so nothing is being ranked wrong. It is just being ranked out.
Why this lane runs on my laptop and not somebody’s dashboard
Every failure above came from one place: the machine did not know what I already knew. It did not know MemoryPlugin was a competitor. It did not know a 195-day HN thread is closed. It did not know that a lead I already answered should never surface again. I had to teach it four times, and each lesson is now a line in a file I own, in a ledger on my disk, with a status column only a human writes.
That is the same problem as re-explaining yourself to a fresh chat window, and it is why I built Vodou. Your memory lives in a database on your machine. Nothing leaves it until you send it. Facts get extracted from your conversations on their own, so you are not maintaining notes, and retrieval is hybrid vector plus keyword with a cross-encoder reranker and a precision floor that injects nothing rather than noise. A correction supersedes the stale fact instead of arguing with it, which is the memory equivalent of a sticky human status.
Three things from this build that matter in an ordinary day. The memory crosses the browser boundary: the same facts ride into ChatGPT, Claude and Gemini in the browser through the Vodou Bridge extension, not just into Cursor, VS Code and Claude Code over MCP, so the context you built while coding is there when you are arguing with a web chat. The scheduler runs work while you are gone: this very post was mined from memory, drafted, graded against a rubric, scanned by a redaction gate and deployed on that scheduler, and the growth hunt above is one more cron row next to it. And every piece is yours to change, because skills, MCP servers, scripts and schedules are files: scripts/growth/ is six files and two of them are plain text phrase lists, which is why adding Discourse took an afternoon.
Vodou is for people who run their own stack and want the assistant to accumulate instead of resetting. Engineers, founders, anyone whose work is a long-running context that no chat window survives. The client side is open source (MCP servers, skills, scripts, the browser extension); the engine is not.
If you are tired of telling an AI who you are every morning, the fix is a memory you own and that follows you into every tool you already use. Start at vodou.ai.