← refacto-feature-scout
"""Ideas are ordered by how much users would get out of them (Ken, 2026-09-06: "order by
value"): a 1..5 score the researcher gives each idea, kept in the store, used by the page, the
page, and `scout status`. Older ideas get theirs from `scout score`."""
from __future__ import annotations
import json
from scout.claude_runner import ClaudeResult
from scout.scan import parse_ideas, score_ideas
from scout.store import Store, by_value, clamp_score
from scout.projects import Project
def _project(tmp_path) -> Project:
return Project(key="terminal", name="Refacto Terminal", folder=tmp_path, topic="", what_it_is="A terminal for prediction markets.",
audience="prediction-market traders")
def test_scores_are_whole_numbers_one_to_five_or_nothing():
assert clamp_score(4) == 4 and clamp_score("3") == 3 and clamp_score(4.6) == 5
assert clamp_score(0) is None and clamp_score(6) is None
assert clamp_score(None) is None and clamp_score("high") is None
def test_by_value_puts_high_scores_first_then_newest_then_researcher_order():
day = 86400 * 20000
ideas = [
{"id": 1, "created_at": day, "value_score": 3},
{"id": 2, "created_at": day + 86400, "value_score": 3},
{"id": 3, "created_at": day + 86400, "value_score": 3},
{"id": 4, "created_at": day, "value_score": 5},
{"id": 5, "created_at": day + 86400, "value_score": None},
{"id": 6, "created_at": day + 86400, "value_score": 9}, # out of range = unscored
]
assert [i["id"] for i in by_value(ideas)] == [4, 2, 3, 1, 5, 6]
def test_the_store_keeps_the_score_and_an_old_store_gains_the_columns():
store = Store(":memory:")
iid = store.add_idea("terminal", "T", "s", evidence=[{"url": "https://e", "note": ""}],
value_score=4, value_reason="why")
row = store.ideas()[0]
assert row["value_score"] == 4 and row["value_reason"] == "why"
store.set_value(iid, 2, "on reflection")
assert store.ideas()[0]["value_score"] == 2
store.set_value(iid, 7, "bad") # out of range clears it rather than storing nonsense
assert store.ideas()[0]["value_score"] is None
# A store made before the columns existed gets them on open.
old = Store(":memory:")
old.conn.execute("ALTER TABLE ideas DROP COLUMN value_score")
old.conn.execute("ALTER TABLE ideas DROP COLUMN value_reason")
old._migrate()
cols = {r[1] for r in old.conn.execute("PRAGMA table_info(ideas)")}
assert {"value_score", "value_reason"} <= cols
def test_the_researcher_answer_carries_the_score():
raw = [{"title": "A", "summary": "s", "user_value": "u", "value_score": "4",
"value_reason": "many", "evidence": [{"url": "https://e", "note": "n"}], "effort": "S"},
{"title": "B", "summary": "s", "value_score": 11,
"evidence": [{"url": "https://e2", "note": "n"}]}]
ideas = parse_ideas(json.dumps(raw))
assert ideas[0]["value_score"] == 4 and ideas[0]["value_reason"] == "many"
assert ideas[1]["value_score"] is None and ideas[1]["value_reason"] == ""
def test_the_page_lists_by_value_and_shows_it():
from scout.review import column_order
day = 86400 * 20000
ideas = [
{"id": 7, "platform": "terminal", "title": "Low", "summary": "s", "evidence": [], "status": "proposed",
"created_at": day + 86400, "value_score": 2, "value_reason": "few would use it"},
{"id": 8, "platform": "terminal", "title": "High", "summary": "s", "evidence": [], "status": "proposed",
"created_at": day, "value_score": 5, "value_reason": "everyone, daily"},
]
assert [i["id"] for i in column_order("backlog", ideas)] == [8, 7]
def test_scout_status_prints_value_first(capsys, monkeypatch):
from scout import cli
store = Store(":memory:")
a = store.add_idea("terminal", "Small one", "s", evidence=[{"url": "https://e", "note": ""}], value_score=1)
b = store.add_idea("terminal", "Big one", "s", evidence=[{"url": "https://e", "note": ""}], value_score=5)
c = store.add_idea("terminal", "Unscored", "s", evidence=[{"url": "https://e", "note": ""}])
monkeypatch.setattr(cli, "known_projects", lambda: {})
cli.cmd_status(store)
out = capsys.readouterr().out
lines = [ln for ln in out.splitlines() if ln.strip().startswith("#")]
assert [ln.split()[0] for ln in lines] == [f"#{b}", f"#{a}", f"#{c}"]
assert "5/5" in lines[0] and "-/5" in lines[2]
def test_scoring_old_ideas_fills_in_only_the_unscored_ones(tmp_path):
store = Store(":memory:")
p = _project(tmp_path)
old1 = store.add_idea("terminal", "Old one", "s", user_value="u", evidence=[{"url": "https://e", "note": "n"}])
old2 = store.add_idea("terminal", "Old two", "s", evidence=[{"url": "https://e", "note": "n"}])
done = store.add_idea("terminal", "Scored", "s", evidence=[{"url": "https://e", "note": "n"}], value_score=3)
store.set_status(old2, "shipped") # not open: never asked about
seen = {}
def fake_runner(prompt, **kw):
seen["prompt"] = prompt
return ClaudeResult(ok=True, text=json.dumps([
{"id": old1, "value_score": 4, "value_reason": "many traders"},
{"id": done, "value_score": 1, "value_reason": "should be ignored"},
{"id": 999, "value_score": 5, "value_reason": "not ours"},
]), cost_usd=0.02)
r = score_ideas(p, store, runner=fake_runner)
assert r == {"project": "terminal", "ok": True, "asked": 1, "scored": 1, "unscored_ids": [], "cost_usd": 0.02}
assert f"id {old1}: Old one" in seen["prompt"] and "Old two" not in seen["prompt"] and "Scored" not in seen["prompt"]
assert "prediction-market traders" in seen["prompt"] and "$" not in seen["prompt"]
rows = {i["id"]: i for i in store.ideas()}
assert rows[old1]["value_score"] == 4 and rows[old1]["value_reason"] == "many traders"
assert rows[done]["value_score"] == 3, "an already-scored idea is left alone"
# Nothing left to do: no call is made.
r2 = score_ideas(p, store, runner=lambda *a, **k: (_ for _ in ()).throw(AssertionError("no call")))
assert r2["asked"] == 0 and r2["ok"]
def test_scoring_reports_a_failure_and_the_ones_it_missed(tmp_path):
store = Store(":memory:")
p = _project(tmp_path)
a = store.add_idea("terminal", "A", "s", evidence=[{"url": "https://e", "note": "n"}])
b = store.add_idea("terminal", "B", "s", evidence=[{"url": "https://e", "note": "n"}])
r = score_ideas(p, store, runner=lambda *a_, **k: ClaudeResult(ok=False, error="boom"))
assert r["ok"] is False and "boom" in r["error"]
r = score_ideas(p, store, runner=lambda *a_, **k: ClaudeResult(ok=True, text=json.dumps([{"id": a, "value_score": 2}])))
assert r["scored"] == 1 and r["unscored_ids"] == [b]
# An answer with no JSON in it is reported as a failure, and nothing is stored.
r = score_ideas(p, store, runner=lambda *a_, **k: ClaudeResult(ok=True, text="no json here"))
assert r["ok"] is False and r["scored"] == 0
assert {i["id"]: i["value_score"] for i in store.ideas()} == {a: 2, b: None}
def test_the_plan_an_idea_belongs_in_travels_from_the_answer_to_the_page():
"""Ken (2026-09-07): the scout should know a paid tier is coming. With plans set, every
idea says which plan it belongs in and why, and that shows wherever ideas are listed."""
raw = [{"title": "Alerts", "summary": "s", "plan": "Pro", "plan_reason": "traders pay for alerts",
"evidence": [{"url": "https://e", "note": "n"}]},
{"title": "Basics", "summary": "s", "evidence": [{"url": "https://e", "note": "n"}]}]
ideas = parse_ideas(json.dumps(raw))
assert ideas[0]["plan"] == "pro" and ideas[0]["plan_reason"] == "traders pay for alerts"
assert ideas[1]["plan"] == "" and ideas[1]["plan_reason"] == ""
store = Store(":memory:")
iid = store.add_idea("terminal", "Alerts", "s", evidence=[{"url": "https://e", "note": "n"}],
plan="Pro", plan_reason="traders pay for alerts")
row = store.ideas()[0]
assert row["plan"] == "pro" and row["plan_reason"] == "traders pay for alerts"
from scout.review import idea_card
assert "Plan: Pro</b> — traders pay for alerts" in idea_card(row, "tok")