Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/trigger-deploy
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
2026-07-16T10:00:29Z
2026-07-17T16:15:00Z deploy openapi-fallback crawl
2026-07-17T17:20:00Z deploy manifest-merge + alias collapse
2026-07-17T18:00:00Z deploy price tiebreak
1 change: 1 addition & 0 deletions .github/trigger-test
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
2026-07-17T16:30:00Z openapi-fallback crawl for manifest-less sellers
2026-07-17T17:05:00Z manifest-path bazaar merge + router alias collapse
2026-07-17T17:45:00Z price tiebreak: known beats unknown
2026-07-18T01:40:00Z wish bridge: qualified-demand gate against single-source bursts
13 changes: 11 additions & 2 deletions .github/workflows/wish-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
# GitHub token, so THIS workflow closes the loop: it polls the public
# aggregate and opens one issue per qualifying cluster.
#
# The gate is the server-computed `qualified` flag, NOT raw count: a cluster
# qualifies only when it clears the count threshold AND shows independence
# (≥2 distinct sources, or demand sustained past the span window). This stops
# a single script minting issues by POSTing the same string 5 times — see
# clusterQualifies in src/wish.js.
#
# Dedup is GitHub-side (label `tool-wish` + exact title match), so the server
# needs no writable "issueOpened" state and re-runs are idempotent. At most
# MAX_NEW issues per run — a spam wave can never flood the tracker.
Expand Down Expand Up @@ -35,7 +41,10 @@ jobs:
# "handled or rejected"; never re-open the same demand automatically).
EXISTING=$(gh issue list --repo "$REPO" --label tool-wish --state all --limit 200 --json title --jq '.[].title')
CREATED=0
echo "$WISHES" | jq -c --argjson th "$THRESHOLD" '.clusters[] | select(.count >= $th)' | while read -r c; do
# Gate on the server-computed `qualified` flag (count + independence),
# not raw count. A cached response from before the flag shipped lacks
# the field, so `== true` fails closed and opens nothing — safe.
echo "$WISHES" | jq -c '.clusters[] | select(.qualified == true)' | while read -r c; do
[ "$CREATED" -ge "$MAX_NEW" ] && { echo "cap reached ($MAX_NEW) — remaining clusters wait for the next run"; break; }
TEXT=$(echo "$c" | jq -r '.text')
COUNT=$(echo "$c" | jq -r '.count')
Expand All @@ -47,7 +56,7 @@ jobs:
echo "already tracked: $TITLE"
continue
fi
BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge when a cluster crosses the signal threshold (%s).' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD")
BODY=$(printf 'Agents asked for this **%s** times (sources: %s; first seen %s).\n\nNormalized request:\n\n> %s\n\nLive demand board: https://agent402.tools/api/wishes\n\nOpened automatically by the wish-issues bridge: this cluster cleared the signal threshold (%s) and qualified as independent demand (multiple sources or sustained over time), not a single-source burst.' "$COUNT" "$SOURCES" "$FIRST" "$TEXT" "$THRESHOLD")
gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" --label tool-wish
CREATED=$((CREATED+1))
echo "opened: $TITLE"
Expand Down
54 changes: 53 additions & 1 deletion scripts/test-wish.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
recordWish, getWishesAggregate, WISH_THRESHOLD,
clusterQualifies, QUALIFY_MIN_SPAN_MS,
__testSetFilePath, __testSetLineCap, __testState,
} from "../src/wish.js";

Expand Down Expand Up @@ -107,6 +108,57 @@ function freshFile(tag) {
ok(row && row.issueOpened === true, `cluster carries issueOpened:true after crossing the threshold (got ${JSON.stringify(row)})`);
}

// --- qualification gate: raw count is necessary but not sufficient ---
// clusterQualifies is the exact predicate the wish-issues workflow selects on.
// Tested directly with synthetic clusters so timestamps are controllable.
{
const T = 1_700_000_000_000;
const src = (o) => ({ api: 0, mcp: 0, "find-miss": 0, ...o });
// Below threshold never qualifies, regardless of shape.
ok(!clusterQualifies({ count: WISH_THRESHOLD - 1, sources: src({ api: 2, mcp: 2 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }),
"below count threshold never qualifies");
// Single-source short burst (the synthora spam shape) does NOT qualify.
ok(!clusterQualifies({ count: 103, sources: src({ api: 103 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }),
"single-source burst (103 hits, 5h) does not qualify");
ok(!clusterQualifies({ count: 18, sources: src({ "find-miss": 18 }), firstSeen: T, lastSeen: T + 5 * 3_600_000 }),
"single-source find-miss burst does not qualify");
// Corroboration across two sources qualifies at threshold.
ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 3, mcp: 2 }), firstSeen: T, lastSeen: T + 60_000 }),
"two distinct sources qualifies even in a short window");
// Sustained single-source demand over the span window qualifies.
ok(clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS }),
"single source sustained past the span window qualifies");
ok(!clusterQualifies({ count: WISH_THRESHOLD, sources: src({ api: 5 }), firstSeen: T, lastSeen: T + QUALIFY_MIN_SPAN_MS - 1 }),
"single source just under the span window does not qualify");
}

// --- qualification, end-to-end through recordWish + getWishesAggregate ---
{
// A single-source burst to the threshold in one sitting: recorded and
// counted, but the aggregate marks it unqualified so the workflow skips it.
freshFile("qualify-burst");
for (let i = 0; i < WISH_THRESHOLD; i++) {
recordWish({ need: "synthora mesh 962 m2m services", source: "api", ip: `10.0.9.${i}` });
}
let agg = getWishesAggregate();
let row = agg.clusters.find((c) => c.text.includes("synthora"));
ok(row && row.count >= WISH_THRESHOLD && row.qualified === false,
`single-source burst is recorded but unqualified (got ${JSON.stringify(row)})`);
ok(agg.qualifyMinSpanHours === QUALIFY_MIN_SPAN_MS / 3_600_000, "aggregate advertises the span window in hours");

// The same count spread across two surfaces qualifies.
freshFile("qualify-multisource");
recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.1" });
recordWish({ need: "real gap tool", source: "api", ip: "10.0.10.2" });
recordWish({ need: "real gap tool", source: "mcp", ip: "10.0.10.3" });
recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.4" });
recordWish({ need: "real gap tool", source: "find-miss", ip: "10.0.10.5" });
agg = getWishesAggregate();
row = agg.clusters.find((c) => c.text.includes("real gap tool"));
ok(row && row.count === WISH_THRESHOLD && row.qualified === true,
`multi-source demand at threshold qualifies (got ${JSON.stringify(row)})`);
}

// --- file-line cap: accept + count clusters, stop appending raw lines, never crash ---
{
freshFile("filecap");
Expand All @@ -132,7 +184,7 @@ function freshFile(tag) {
const agg = getWishesAggregate();
const row = agg.clusters[0];
const keys = Object.keys(row).sort();
ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`);
ok(JSON.stringify(keys) === JSON.stringify(["count", "firstSeen", "issueOpened", "lastSeen", "qualified", "sources", "text"]), `aggregate row has exactly the documented keys, no raw context (got ${keys.join(",")})`);
ok(!("context" in row), "aggregate row never carries a raw context field");
ok(row.text.includes("&lt;script&gt;") && !row.text.includes("<script>"), `aggregate text is esc()'d (got ${row.text})`);
ok(typeof row.firstSeen === "string" && typeof row.lastSeen === "string" && !Number.isNaN(Date.parse(row.firstSeen)), "firstSeen/lastSeen are ISO timestamps");
Expand Down
28 changes: 28 additions & 0 deletions src/wish.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ let capReached = false;
// the aggregate so a scheduled workflow can poll it and open the issue.
export const WISH_THRESHOLD = 5;

// A raw count is not enough to auto-open a public GitHub issue: one script can
// POST the same string 5 times in a minute (observed 2026-07-17: a single
// source drove a cluster to 100+ identical hits in a few hours, minting three
// junk issues). A cluster QUALIFIES only when it also shows independence —
// either corroboration across ≥2 distinct sources (api / mcp / find-miss), or
// demand sustained past QUALIFY_MIN_SPAN_MS. A genuine gap is hit by different
// agents across different surfaces, or recurs over days; a scripted burst is
// one source in one sitting and clears neither bar. Honest limit (same framing
// as the router's per-seller Sybil cap): a patient spammer can still drip over
// 24h or add a decoy hit on a second surface — this raises the cost from "5
// curls" to "sustained or multi-surface", it doesn't make gaming impossible.
// The wish is always recorded and visible on /api/wishes regardless; this gate
// only governs which clusters auto-open an issue.
export const QUALIFY_MIN_SPAN_MS = 24 * 3_600_000; // 24h

// Does a cluster's shape clear the anti-spam bar described above? Exported so
// the wish-issues workflow's gate and the unit tests share one definition.
export function clusterQualifies(c) {
if (!c || c.count < WISH_THRESHOLD) return false;
const distinctSources = ["api", "mcp", "find-miss"].filter((s) => (c.sources?.[s] || 0) > 0).length;
const spanMs = (c.lastSeen || 0) - (c.firstSeen || 0);
return distinctSources >= 2 || spanMs >= QUALIFY_MIN_SPAN_MS;
}

const esc = (s) => String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
Expand Down Expand Up @@ -233,11 +257,15 @@ export function getWishesAggregate({ limit = 200 } = {}) {
firstSeen: new Date(c.firstSeen).toISOString(),
lastSeen: new Date(c.lastSeen).toISOString(),
issueOpened: !!c.issueOpened,
// The gate the wish-issues workflow selects on. count >= threshold is
// necessary but not sufficient — see clusterQualifies / QUALIFY_MIN_SPAN_MS.
qualified: clusterQualifies(c),
}));
return {
distinctClusters: clusters.size,
totalWishes: [...clusters.values()].reduce((s, c) => s + c.count, 0),
threshold: WISH_THRESHOLD,
qualifyMinSpanHours: QUALIFY_MIN_SPAN_MS / 3_600_000,
clusters: rows,
};
}
Expand Down