diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 102af45..25c590e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -275,41 +275,44 @@ function actorLabel(actor: string): string { } async function saveKbInject() { - const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); + // Every similarity bar, clamped the way the server's `bounded_float` clamps + // it: an unparseable value falls back to its default, never to 0, and `lo` + // is the floor a bar that BLOCKS must not go under (0.80 block, 0.70 overlap). + const asBar = (v: string, d: number, lo = 0) => Math.min(1, Math.max(lo, Number(v) || d)); + const t = asBar(kbInjectThreshold.value, 0); const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); // `|| default` not `|| 0`: an unparseable value here should fall back to the // per-kind default, not to 0 — a 0 floor would report every record as a // duplicate of every other one. - const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82)); - const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93)); - const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93)); + const dupSnip = asBar(kbDupThresholdSnippet.value, 0.82); + const dupNote = asBar(kbDupThresholdNote.value, 0.93); + const dupTask = asBar(kbDupThresholdTask.value, 0.93); // The gate BLOCKS a write, so its bars have a floor the server enforces too: // 0.80 for a block, 0.70 for the overlap list. - const gateAt = (v: string, d: number, lo: number) => Math.min(1, Math.max(lo, Number(v) || d)); - const gate = gateAt(kbGateThreshold.value, 0.9, 0.8); - const gateSnip = gateAt(kbGateThresholdSnippet.value, 0.96, 0.8); - const gateLesson = gateAt(kbGateThresholdLesson.value, 0.96, 0.8); - const gateCopy = gateAt(kbGateThresholdNoteCopy.value, 0.98, 0.8); - const gateOverlap = gateAt(kbGateNoteOverlapFloor.value, 0.87, 0.7); + const gate = asBar(kbGateThreshold.value, 0.9, 0.8); + const gateSnip = asBar(kbGateThresholdSnippet.value, 0.96, 0.8); + const gateLesson = asBar(kbGateThresholdLesson.value, 0.96, 0.8); + const gateCopy = asBar(kbGateThresholdNoteCopy.value, 0.98, 0.8); + const gateOverlap = asBar(kbGateNoteOverlapFloor.value, 0.87, 0.7); // Same `|| default` guard: a floor of 0 would hand back an existing plan // for every new one, and no plan could be started without force. - const planT = Math.min(1, Math.max(0, Number(kbPlanMatchThreshold.value) || 0.8)); + const planT = asBar(kbPlanMatchThreshold.value, 0.8); // Same `|| default` reasoning: falling back to 0 would surface every // snippet in the corpus on every edit, which is the failure this knob fixes. - const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); + const wpT = asBar(kbWritePathThreshold.value, 0.68); // Same `|| default` reasoning again, and it bites harder here: a rule hint // fires on every write, so a fallback of 0 would attach a standing rule to // every edit in the session. - const rhT = Math.min(1, Math.max(0, Number(kbRuleHintThreshold.value) || 0.72)); + const rhT = asBar(kbRuleHintThreshold.value, 0.72); // Same `|| default` guard, and the same reason: this arm fires before every // Bash call, so a fallback of 0 would put a rule in front of every command. - const trT = Math.min(1, Math.max(0, Number(kbToolRuleThreshold.value) || 0.68)); - const prT = Math.min(1, Math.max(0, Number(kbPromptRuleThreshold.value) || 0.72)); + const trT = asBar(kbToolRuleThreshold.value, 0.68); + const prT = asBar(kbPromptRuleThreshold.value, 0.72); // The checkpoint bar, and the `|| default` guard matters most here of all: // this is the only number that can STOP a call, so a fallback of 0 would // hold the first command of every session behind whatever ranked first. - const cpT = Math.min(1, Math.max(0, Number(kbCheckpointThreshold.value) || 0.8)); - const rpT = Math.min(1, Math.max(0, Number(kbReportPrefThreshold.value) || 0.72)); + const cpT = asBar(kbCheckpointThreshold.value, 0.8); + const rpT = asBar(kbReportPrefThreshold.value, 0.72); // The budgets, clamped the way the server clamps them: a whole number in // [1, 10]. Never 0 — an arm turned off is turned off by its switch, and a // budget of zero would run the search, log the retrieval and render nothing, diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 1d30bdb..45eb2b3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.09.24.1358", + "version": "2026.10.01.1246", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/PACKAGING.md b/plugin/PACKAGING.md index 780825e..72376a8 100644 --- a/plugin/PACKAGING.md +++ b/plugin/PACKAGING.md @@ -40,6 +40,7 @@ another one means adding files, not moving or rewriting any. | `hooks/scribe_after_write.sh` | PostToolUse on shell commands: the same check for code written through the shell. | | `hooks/scribe_tool_rules.sh` | PreToolUse on shell commands: `GET /api/plugin/tool-rules`. | | `hooks/scribe_report_check.sh` | Stop: when the turn closed a task, checks the reply for the completion sections and reports to `GET /api/plugin/report-check`; blocks once, with the reason the server returns. | +| `hooks/scribe_shape_check.sh` | Stop: sends the definitions the turn wrote (the write hooks' `.written.ids` ledger) to `GET /api/plugin/shape-check`; blocks once, with the reason the server returns, so the agent judges what it built. | | `hooks/scribe_sync_processes.sh` + `commands/sync.md` | `GET /api/plugin/processes` → `~/.claude/skills/scribe-proc-*` stubs; `/scribe:sync` on demand. | | `hooks/scribe_defs.sh` | Shared shell helpers: config, dedup ledgers, outage line. | | `hooks/scribe_static_context.md` | The adapter static text. | diff --git a/plugin/README.md b/plugin/README.md index cfd79e9..859e151 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -91,6 +91,15 @@ On install you'll be asked for: never blocks twice, and never blocks when the instance did not record the check (unconfigured or unreachable). Outcomes land in the admin logs under category `plugin`, action `report_check`. +- `hooks/hooks.json` → a second Stop hook (`hooks/scribe_shape_check.sh`): the + write hooks note every definition a write names (and every new file) in a + session ledger; at the end of the turn this sends them to + `GET /api/plugin/shape-check`, which answers with the ones nobody has judged. + If any are, it blocks once with the server's reason, asking the agent that + wrote them to say what each is — reusable (record a snippet), an instance or + variant of one, or a one-off — in one `classify_shapes` call. Same discipline + as the report check: never twice, never without a recorded check. Outcomes + land under category `plugin`, action `shape_check`. - `skills/` → the universal process-skills, surfaced by description match. - `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync` command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 5c6b7ef..1bdf248 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -98,6 +98,10 @@ { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_report_check.sh\"" + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_shape_check.sh\"" } ] } diff --git a/plugin/hooks/scribe_after_write.sh b/plugin/hooks/scribe_after_write.sh index 689bead..4c647bd 100644 --- a/plugin/hooks/scribe_after_write.sh +++ b/plugin/hooks/scribe_after_write.sh @@ -129,14 +129,19 @@ while IFS= read -r rel_path; do # The code just written: the ADDED lines of the uncommitted diff for a # tracked file (sed, not cut: this strips one marker char per line, it is # not a payload cap), the whole file when untracked. + fresh="" if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code="" else code=$(cat "$file_path" 2>/dev/null) || code="" + fresh="new" fi [ -n "$code" ] || continue # `|| true` inside: an early-exiting `head` must not void its own output (#4042). names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12 || true) + # What this write defined, for the end-of-turn question (milestone 439) — + # recorded before the arms below, which skip a write that defines nothing. + printf '%s\n' "$names" | scribe_written_append "$safe_sid" "$rel_path" "$fresh" "$code" # Nothing DEFINED in what was written (prose, data, a call-site edit) → # nothing to say; the arms are about shapes. [ -n "$names" ] || continue diff --git a/plugin/hooks/scribe_defs.sh b/plugin/hooks/scribe_defs.sh index 675cfab..6652be9 100644 --- a/plugin/hooks/scribe_defs.sh +++ b/plugin/hooks/scribe_defs.sh @@ -1158,6 +1158,41 @@ scribe_held_query() { # the tests read THIS string rather than a copy of it. SCRIBE_LEDGER_DIRS="scribe-priorart scribe-autoinject" +# THE WRITTEN-SHAPES LEDGER (milestone 439). Every definition a write names, +# appended as `pathkindname`, for the Stop hook to ask about at the +# end of the turn (scribe_shape_check.sh). Its own file — one file, one +# question (lesson #4226) — under the prior-art directory, so the session +# clear above already sweeps it. Read `kindname` lines on stdin (the +# scribe_defs format). +# +# A NEW file that is a unit in its own right adds one `file` line named by its +# stem — the same test the server's sync applies (coverage.is_file_unit): it +# RENDERS (markup names classes, or it opens a \n\n" + "\n\n" + "
\n\n" + "\n" + ) + defs = extract_definitions(svelte) + names = {(d.kind, d.name) for d in defs} + assert {("sym", "stageOf"), ("sym", "toggle"), ("css", "card")} <= names + scoped = scoped_definitions("web/src/lib/A.svelte", svelte, defs) + assert ("sym", "toggle") in scoped and ("css", "card") in scoped + assert ("sym", "stageOf") not in scoped, "a module script's export is importable" + assert not any(n == "toast" for _, n in scoped), ":global(...) opts a selector out" + # The legacy spelling of the module block, and a whole global style block. + legacy = ( + "\n" + "\n" + ) + assert scoped_definitions("web/src/lib/B.svelte", legacy, extract_definitions(legacy)) == set() + + +def test_the_line_names_the_review_queue_and_the_tool_that_holds_it(): + """#4608: `stamps_to_review` existed and two projects never called it, + because nothing on arrival said there was anything to review.""" + from scribe.services.coverage import coverage_line + base = {"accounted": 10, "total": 10, "counts": {"exempt": 10}, + "unclassified": 0, "computed_at": "2026-09-30T00:00:00"} + assert "stamps_to_review" not in coverage_line(base) + line = coverage_line({**base, "weak_stamps": 132, "incoherent_canons": 3}) + assert line.endswith("; 132 weak stamps · 3 incoherent canons to judge — stamps_to_review") + assert coverage_line({**base, "weak_stamps": 1}).endswith("; 1 weak stamp to judge — stamps_to_review") + + @pytest.mark.integration async def test_binding_ref_is_the_branch_the_ledger_follows(seeded): """#2873: a binding that names a ref is read at that ref (not the forge's @@ -843,3 +887,36 @@ async def test_binding_ref_is_the_branch_the_ledger_follows(seeded): coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE))) assert coverage["repos"][0]["ref"] == "main" + + +@pytest.mark.parametrize("path,text,unit", [ + # A single-file component: nothing inside carries its name. + ("web/lib/StatusChip.svelte", "\n\n", True), + ("web/views/Card.vue", "\n", True), + # A TSX component already has its row: the function named after the file. + ("web/Card.tsx", "export function Card() {\n return
;\n}\n", False), + # A JSX file whose markup names classes but whose component is named otherwise. + ("web/index.jsx", "function App() {\n return
;\n}\n", True), + # Modules render nothing: their definitions account for them. + ("internal/api/upnext.go", "func stageOf(x int) int {\n\treturn x\n}\n", False), + ("src/app/util.py", "def helper():\n pass\n", False), + ("web/button.css", ".btn {\n color: red;\n}\n", False), +]) +def test_a_file_is_a_unit_when_it_renders_and_nothing_inside_carries_its_name(path, text, unit): + """Milestone 439: one structural test, not a framework list.""" + from scribe.services.coverage import class_references, extract_definitions, is_file_unit + + assert is_file_unit(path, text, extract_definitions(text), class_references(path, text)) is unit + + +def test_the_line_says_whether_writers_judged_what_they_wrote(): + """Milestone 439: the write-time figure — silent until the question has + been put, then turns checked, asked, and asks walked past.""" + from scribe.services.coverage import coverage_line + base = {"accounted": 10, "total": 10, "counts": {"exempt": 10}, + "unclassified": 0, "computed_at": "2026-10-01T00:00:00"} + assert "written-shape check" not in coverage_line(base) + assert "written-shape check" not in coverage_line( + {**base, "write_time": {"checked": 0, "asked": 0, "left": 0, "days": 7}}) + line = coverage_line({**base, "write_time": {"checked": 12, "asked": 4, "left": 1, "days": 7}}) + assert line.endswith("; written-shape check (7d): 12 turns checked, 4 asked, 1 left unjudged") diff --git a/tests/test_services_shape_check.py b/tests/test_services_shape_check.py new file mode 100644 index 0000000..30b6fc8 --- /dev/null +++ b/tests/test_services_shape_check.py @@ -0,0 +1,89 @@ +"""The server half of the end-of-turn shape check (milestone 439): what the +agent is asked, what the hook's ledger may say, and the outcome record.""" +import json +from unittest.mock import patch + +import pytest + +from tests.helpers import make_mock_session + + +def test_the_ledger_lines_parse_and_garbage_is_dropped(): + from scribe.services.shape_check import parse_written + + raw = ("a.py\tsym\thelper\n" + "a.py\tsym\thelper\n" # duplicate + "web/A.svelte\tfile\tA\n" + "x.py\tbogus\tthing\n" # unknown kind + "only-two\tsym\n" # malformed + "\tsym\tnameless-path\n") + assert parse_written(raw) == [("a.py", "sym", "helper"), ("web/A.svelte", "file", "A")] + assert len(parse_written("".join(f"p\tsym\tn{i}\n" for i in range(500)), cap=7)) == 7 + + +def test_a_first_stop_blocks_or_passes_and_the_next_never_blocks(): + from scribe.services.shape_check import outcome_for + + one = [{"path": "a", "kind": "sym", "symbol": "b"}] + assert outcome_for("check", one) == "blocked" + assert outcome_for("check", []) == "passed" + assert outcome_for("after", one) == "left_after_block" + assert outcome_for("after", []) == "judged_after_block" + + +def test_the_reason_names_each_shape_its_evidence_and_the_one_call(): + from scribe.services.shape_check import block_reason + + unjudged = [ + {"path": "web/A.svelte", "kind": "file", "symbol": "A", "status": "new", "evidence": {}}, + {"path": "src/x.py", "kind": "sym", "symbol": "load", "status": "unclassified", + "evidence": {"looks_like": {"snippet_id": 12, "basis": "semantic", "score": 0.84}, + "diverges_from": 9}}, + ] + reason = block_reason(unjudged, project_id=3, repo="git.example.com/a/w") + assert "2 definitions" in reason + assert "web/A.svelte · A (file) — new" in reason + assert "looks like #12 0.84" in reason and "#9 is canon in this directory" in reason + assert 'classify_shapes(project_id=3, repo="git.example.com/a/w"' in reason + for verdict in ("create_snippet", "`instance`", "`variant`", "`exempt`"): + assert verdict in reason, verdict + + +def test_a_long_turn_is_listed_with_a_tail_not_dropped(): + from scribe.services.shape_check import _LISTED, block_reason + + many = [{"path": "p.py", "kind": "sym", "symbol": f"f{i}", "status": "new", "evidence": {}} + for i in range(_LISTED + 3)] + reason = block_reason(many, project_id=1, repo="") + assert "… and 3 more" in reason + assert "repo=" not in reason + + +async def test_the_outcome_is_recorded_as_a_plugin_event(): + from scribe.services.shape_check import record_shape_check + + session = make_mock_session() + with patch("scribe.services.shape_check.async_session", return_value=session): + await record_shape_check(7, "blocked", written=5, unjudged=2, project_id=2) + row = session.add.call_args.args[0] + assert (row.category, row.action, row.user_id) == ("plugin", "shape_check", 7) + assert json.loads(row.details) == {"outcome": "blocked", "written": 5, + "unjudged": 2, "project_id": 2} + + +async def test_an_unknown_outcome_is_refused_before_anything_is_written(): + from scribe.services.shape_check import record_shape_check + + session = make_mock_session() + with patch("scribe.services.shape_check.async_session", return_value=session), \ + pytest.raises(ValueError): + await record_shape_check(7, "skipped", written=1, unjudged=1) + session.add.assert_not_called() + + +def test_the_window_counts_turns_asks_and_asks_walked_past(): + from scribe.services.shape_check import summarise + + assert summarise([]) == {"checked": 0, "asked": 0, "left": 0} + got = summarise(["passed", "blocked", "judged_after_block", "blocked", "left_after_block"]) + assert got == {"checked": 3, "asked": 2, "left": 1} diff --git a/tests/test_shape_check_hook.py b/tests/test_shape_check_hook.py new file mode 100644 index 0000000..249fb8f --- /dev/null +++ b/tests/test_shape_check_hook.py @@ -0,0 +1,171 @@ +"""The end-of-turn shape check (milestone 439): the write hooks note what each +write defined, and the Stop hook asks the instance which of those nobody has +judged, blocking once in the server's words. + +Runs the real shell against the shared HTTP sink. What it pins: the write +hooks' ledger (a Write of a new file adds a `file` line; a skipped path adds +nothing); silence with nothing written; a block only on a reason the instance +returned; the post-block stop recorded and let through; another hook's block +loop left alone; the ledger kept when the instance could not be reached. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.helpers import http_sink + +HOOKS = Path(__file__).resolve().parents[1] / "plugin" / "hooks" +CHECK = HOOKS / "scribe_shape_check.sh" +PRIOR_ART = HOOKS / "scribe_prior_art.sh" +REASON = "SERVER REASON: judge what you wrote" + + +def _env(tmp_path, url="http://127.0.0.1:9"): + for tool in ("curl", "bash", "git"): + if shutil.which(tool) is None: + pytest.skip(f"hook runtime tool {tool!r} not installed") + return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t", + "TMPDIR": str(tmp_path), "HOME": str(tmp_path)} + + +def _ledger(tmp_path, session="s1"): + return tmp_path / "scribe-priorart" / f"{session}.written.ids" + + +def _write_ledger(tmp_path, lines, session="s1"): + path = _ledger(tmp_path, session) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(f"{line}\n" for line in lines)) + return path + + +def _stop(env, cwd, active=False, session="s1"): + out = subprocess.run( + ["bash", str(CHECK)], + input=json.dumps({"session_id": session, "transcript_path": "/nonexistent", + "cwd": str(cwd), "hook_event_name": "Stop", + "stop_hook_active": active}), + capture_output=True, text=True, env=env, timeout=30, + ) + assert out.returncode == 0, out.stderr + return out.stdout.strip() + + +def _repo(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", + "https://git.example.com/alice/widget.git"], check=True) + return repo + + +# ── the write hooks keep the ledger ─────────────────────────────────────── + +def _pre_write(env, repo, rel, content, tool="Write"): + field = "content" if tool == "Write" else "new_string" + out = subprocess.run( + ["bash", str(PRIOR_ART)], + input=json.dumps({"session_id": "s1", "cwd": str(repo), "tool_name": tool, + "tool_input": {"file_path": str(repo / rel), field: content}}), + capture_output=True, text=True, env=env, timeout=30, + ) + assert out.returncode == 0, out.stderr + + +def test_a_write_notes_what_it_defined_and_a_new_file_is_a_candidate(tmp_path): + with http_sink(b'{"note_ids":[]}') as (port, _seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + repo = _repo(tmp_path) + (repo / "web").mkdir() + _pre_write(env, repo, "web/StatusChip.svelte", + "\n" + "\n") + lines = _ledger(tmp_path).read_text().splitlines() + assert "web/StatusChip.svelte\tfile\tStatusChip" in lines + assert "web/StatusChip.svelte\tsym\ttone" in lines + assert "web/StatusChip.svelte\tcss\tchip" in lines + + +def test_an_edit_to_an_existing_file_adds_no_file_line(tmp_path): + with http_sink(b'{"note_ids":[]}') as (port, _seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + repo = _repo(tmp_path) + (repo / "lib.py").write_text("x = 1\n") + _pre_write(env, repo, "lib.py", "def helper():\n return 1\n", tool="Edit") + lines = _ledger(tmp_path).read_text().splitlines() + assert lines == ["lib.py\tsym\thelper"] + + +def test_a_skipped_path_notes_nothing(tmp_path): + with http_sink(b'{"note_ids":[]}') as (port, _seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + repo = _repo(tmp_path) + _pre_write(env, repo, "README.md", "# def helper():\n") + assert not _ledger(tmp_path).exists() + + +# ── the Stop hook asks ──────────────────────────────────────────────────── + +def test_nothing_written_is_silent_and_asks_nothing(tmp_path): + with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + assert _stop(env, _repo(tmp_path)) == "" + assert seen == [] + + +def test_all_judged_passes_silently_and_empties_the_ledger(tmp_path): + with http_sink(b'{"status":"ok","outcome":"passed","unjudged":[]}') as (port, seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper", "a.py\tsym\thelper"]) + assert _stop(env, _repo(tmp_path)) == "" + assert seen[0]["phase"] == ["check"] + assert seen[0]["written"] == ["a.py\tsym\thelper"], "duplicates collapse" + assert seen[0]["repo"] == ["https://git.example.com/alice/widget.git"] + assert not ledger.exists() + + +def test_unjudged_blocks_once_in_the_servers_words_then_records_the_answer(tmp_path): + reply = json.dumps({"status": "ok", "outcome": "blocked", "reason": REASON}).encode() + with http_sink(reply) as (port, seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + repo = _repo(tmp_path) + ledger = _write_ledger(tmp_path, ["web/A.svelte\tfile\tA"]) + assert json.loads(_stop(env, repo)) == {"decision": "block", "reason": REASON} + assert ledger.exists(), "kept, so the stop after the block asks about the same set" + + # The stop after the block: recorded, never blocked, ledger consumed. + assert _stop(env, repo, active=True) == "" + assert [q["phase"] for q in seen] == [["check"], ["after"]] + assert not ledger.exists() + assert _stop(env, repo, active=True) == "" + assert len(seen) == 2 + + +def test_no_block_without_a_reason_from_the_instance(tmp_path): + with http_sink(b'{"status":"ok"}') as (port, _seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + _write_ledger(tmp_path, ["a.py\tsym\thelper"]) + assert _stop(env, _repo(tmp_path)) == "" + + +def test_another_hooks_block_loop_is_left_alone(tmp_path): + with http_sink(b'{"status":"ok","reason":"x"}') as (port, seen): + env = _env(tmp_path, f"http://127.0.0.1:{port}") + ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper"]) + assert _stop(env, _repo(tmp_path), active=True) == "" + assert seen == [] + assert ledger.exists(), "this hook's own question is still owed" + + +def test_an_unreachable_instance_keeps_the_question_for_later(tmp_path): + env = _env(tmp_path) # port 9: nothing listens + ledger = _write_ledger(tmp_path, ["a.py\tsym\thelper"]) + assert _stop(env, _repo(tmp_path)) == "" + assert ledger.exists() diff --git a/tests/test_stamps_to_review.py b/tests/test_stamps_to_review.py index ac839b6..b4d7066 100644 --- a/tests/test_stamps_to_review.py +++ b/tests/test_stamps_to_review.py @@ -350,3 +350,53 @@ def test_the_coherence_verdict_reads_rows_and_writes_nothing() -> None: canon_coherence(rows) assert [(r.path, r.symbol, r.status, r.snippet_id, r.signature) for r in rows] == before + + +# ── a weak stamp is listed for judgment and counted by nobody (#4608) ───── +# +# The floor guarded new writes; the rows written before it went on electing +# canons. On two projects a YAML CI snippet "dominated" a Svelte directory on +# the strength of 0.68-0.72 stamps, and every write there was told it +# diverged from it. The readers below must agree with the review surface +# about what "weak" means — one predicate, so they cannot drift apart. + +def test_one_predicate_says_what_weak_is() -> None: + from scribe.services.shape_ledger import is_weak_stamp + + assert is_weak_stamp(_stamped(0.72)) + assert not is_weak_stamp(_stamped(_RESEMBLE_MIN)) + # A by-name stamp carries no score: it is not resemblance evidence at all. + assert not is_weak_stamp(_Row(reason="hook: pulled #1; payload references `f`")) + for by in ("agent", "audit", "mechanical"): + assert not is_weak_stamp(_stamped(0.5, classified_by=by)), by + + +def test_weak_stamps_do_not_elect_a_dominant_canon() -> None: + from scribe.services.shape_ledger import dominant_canon + + weak = [_stamped(0.72, path=f"web/src/lib/{i}.ts", symbol=f"f{i}") for i in range(5)] + assert dominant_canon(weak) is None + # The same rows judged by an agent do — the kind of evidence is the point. + judged = [_stamped(0.72, path=f"web/src/lib/{i}.ts", symbol=f"f{i}", classified_by="agent") + for i in range(5)] + assert dominant_canon(judged) == (1, 5, 5) + # And weak rows do not dilute a real majority either: they are not counted + # as judged at all. + mixed = judged[:3] + [_stamped(0.7, snippet_id=2, symbol=f"g{i}") for i in range(4)] + assert dominant_canon(mixed) == (1, 3, 3) + + +def test_weak_stamps_do_not_say_what_form_a_canon_is() -> None: + from scribe.services.shape_ledger import FORM_UNKNOWN, canon_form + + weak = [_stamped(0.7, symbol=f"T{i}", signature=f"class T{i}:") for i in range(5)] + assert canon_form(weak, 1) == FORM_UNKNOWN + judged = [_stamped(0.7, symbol=f"T{i}", signature=f"class T{i}:", classified_by="audit") + for i in range(5)] + assert canon_form(judged + weak, 1) == canon_form(judged, 1) != FORM_UNKNOWN + + +def test_counting_them_out_leaves_them_listed() -> None: + """Not counted is not withdrawn: the review still lists every one.""" + rows = [_stamped(0.7, symbol=f"f{i}") for i in range(3)] + assert stamps_to_review(rows)["weak_count"] == 3 diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 02c2f9b..8800f5f 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -1291,9 +1291,9 @@ async def test_stamping_needs_named_shapes_and_a_recent_pull(): patch.object(pc, "semantic_search_notes", AsyncMock(return_value=[])), \ patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc.shape_ledger_svc, "recent_pulls", pulls), \ - patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp): + patch.object(pc.shape_ledger_svc, "suggest_write_path_instances", stamp): out = await pc.build_write_path_hint(1, "src/x.py", code=REAL_CODE, project_id=4) - assert out["stamped"] == [] + assert out["suggested"] == [] pulls.assert_not_awaited() # no shapes → no read out = await pc.build_write_path_hint( 1, "src/x.py", code=REAL_CODE, project_id=4, @@ -1301,7 +1301,7 @@ async def test_stamping_needs_named_shapes_and_a_recent_pull(): ) pulls.assert_awaited_once() stamp.assert_not_awaited() # shapes, but no pull - assert out["stamped"] == [] + assert out["suggested"] == [] @pytest.mark.asyncio @@ -1330,7 +1330,7 @@ async def test_a_pulled_snippet_already_seen_is_evidence_not_menu(): patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={7: _ts()})), \ - patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", stamp): + patch.object(pc.shape_ledger_svc, "suggest_write_path_instances", stamp): out = await pc.build_write_path_hint( 1, "src/x.py", code=REAL_CODE, project_id=4, exclude_ids=[7], stamp_shapes=[("sym", "debounce")], repo_key="git.example.com/a/b", @@ -1355,11 +1355,11 @@ async def test_a_pulled_snippet_already_seen_is_evidence_not_menu(): assert skw["resembles"] == {7: 0.91} assert skw["shapes"] == [("sym", "debounce")] assert skw["repo_key"] == "git.example.com/a/b" - assert out["stamped"][0]["snippet_id"] == 7 - # And the session is told what landed, with the way to correct it. + assert out["suggested"][0]["snippet_id"] == 7 + # And the session is shown it as evidence for its own end-of-turn judgment. assert "Shape accounting" in out["context"] - assert "`debounce` → instance of #7" in out["context"] - assert "classify_shapes" in out["context"] + assert "`debounce` → looks like #7" in out["context"] + assert "When you judge this turn's shapes" in out["context"] @pytest.mark.asyncio @@ -1376,15 +1376,15 @@ async def test_a_stamp_renders_even_when_the_hint_is_otherwise_silent(): patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={5: _ts()})), \ - patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", + patch.object(pc.shape_ledger_svc, "suggest_write_path_instances", AsyncMock(return_value=stamped)): out = await pc.build_write_path_hint( 1, "web/b.css", code=".btn-primary { color: red; }" * 4, project_id=4, stamp_shapes=[("css", "btn-primary")], ) assert out["note_ids"] == [] - assert out["stamped"] == stamped - assert "`.btn-primary` → instance of #5" in out["context"] + assert out["suggested"] == stamped + assert "`.btn-primary` → looks like #5" in out["context"] @pytest.mark.asyncio @@ -1397,13 +1397,13 @@ async def test_a_failing_stamp_does_not_sink_the_hint(): patch.object(pc, "record_retrieval", MagicMock()), \ patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={12: _ts()})), \ - patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", + patch.object(pc.shape_ledger_svc, "suggest_write_path_instances", AsyncMock(side_effect=RuntimeError("ledger down"))): out = await pc.build_write_path_hint( 1, "src/x.py", code=REAL_CODE, project_id=4, stamp_shapes=[("sym", "f")], ) assert out["sync_note_ids"] == [12] - assert out["stamped"] == [] + assert out["suggested"] == [] def test_route_stamps_only_for_a_caller_allowed_to_write(): @@ -1760,7 +1760,7 @@ async def test_a_record_this_arm_withheld_itself_is_not_a_near_miss(): patch.object(pc, "owner_names_for", AsyncMock(return_value={})), \ patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={7: _ts()})), \ - patch.object(pc.shape_ledger_svc, "stamp_write_path_instances", + patch.object(pc.shape_ledger_svc, "suggest_write_path_instances", AsyncMock(return_value=[])), \ patch.object(pc, "semantic_search_notes", _search_reporting(0.9, fake_note(