diff --git a/alembic/versions/0082_repo_binding_ref.py b/alembic/versions/0082_repo_binding_ref.py
new file mode 100644
index 0000000..30f4916
--- /dev/null
+++ b/alembic/versions/0082_repo_binding_ref.py
@@ -0,0 +1,26 @@
+"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
+
+Revision ID: 0082
+Revises: 0081
+Create Date: 2026-08-21
+
+A repo binding used to imply the repo's default branch; the shape ledger
+therefore only saw work after a merge to main, while the operator's work
+lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
+NULL keeps today's behaviour (the forge's default branch).
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0082"
+down_revision = "0081"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("repo_bindings", "ref")
diff --git a/alembic/versions/0083_code_shape_reason_code.py b/alembic/versions/0083_code_shape_reason_code.py
new file mode 100644
index 0000000..6798ae7
--- /dev/null
+++ b/alembic/versions/0083_code_shape_reason_code.py
@@ -0,0 +1,26 @@
+"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
+
+Revision ID: 0083
+Revises: 0082
+Create Date: 2026-08-21
+
+The 2026-08 audit wrote the same free-text reason thousands of times
+("scoped rule — styles one element of this view"); a judgment's WHY stays
+prose, but an optional code from a fixed catalogue makes the ledger
+filterable and aggregable ("how many pure helpers, how many test helpers").
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0083"
+down_revision = "0082"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("code_shapes", "reason_code")
diff --git a/alembic/versions/0084_code_shape_uses.py b/alembic/versions/0084_code_shape_uses.py
new file mode 100644
index 0000000..1da91e6
--- /dev/null
+++ b/alembic/versions/0084_code_shape_uses.py
@@ -0,0 +1,40 @@
+"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
+
+Revision ID: 0084
+Revises: 0083
+Create Date: 2026-08-21
+
+A ledger row carries ONE snippet_id: what shape this is (instance/variant of
+a canon). But a shape can also CALL several canonical helpers — a service
+function that is an instance of the service-function convention and a
+consumer of hash_token. The 2026-08 audit had to pick one; hook evidence
+("pulled #N then wrote code referencing it") was stamped as instance when it
+is a uses fact. This table holds the many-valued relation: shape → snippet,
+with the basis and the evidence. Cascades with the shape and the snippet.
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0084"
+down_revision = "0083"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "code_shape_uses",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("basis", sa.Text(), nullable=False),
+ sa.Column("evidence", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
+ sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
+ )
+ op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
+ op.drop_table("code_shape_uses")
diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index dcd222e..5a0261c 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -754,7 +754,7 @@ async function confirmDelete() {
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index b2ea759..1c468de 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
- "version": "0.1.36",
+ "version": "0.1.37",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md
index 2fb419e..6105faf 100644
--- a/plugin/skills/shape-accounting/SKILL.md
+++ b/plugin/skills/shape-accounting/SKILL.md
@@ -17,6 +17,11 @@ row carries a status:
— the why IS the record.
- `exempt` — judged genuinely one-off. **Reason required.** A recorded
judgment, not silence — it stops the next pass re-litigating it.
+- `scoped` — one-off **by construction**, stamped by the coverage sync
+ (a Vue component's scoped `", re.IGNORECASE)
+
+
+def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tuple[str, str]]:
+ """The (kind, name) pairs among ``defs`` that are one-offs by
+ construction in this file: every sym in a .vue, and every css rule
+ that starts inside a `\n"
+ "\n"
+ )
+ defs = extract_definitions(vue)
+ names = {(d.kind, d.name) for d in defs}
+ assert {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title"), ("css", "global-toast")} <= names
+ scoped = scoped_definitions("frontend/src/views/A.vue", vue, defs)
+ assert scoped == {("sym", "load"), ("sym", "save"), ("css", "card"), ("css", "title")}
+ # Definitions know their line, which is what the scoped-style range uses.
+ assert next(d for d in defs if d.name == "card").line > next(d for d in defs if d.name == "save").line
+ # Not a .vue: nothing is scoped, whatever it contains.
+ assert scoped_definitions("frontend/src/assets/components.css", ".card {\n x: 1;\n}\n",
+ extract_definitions(".card {\n x: 1;\n}\n")) == set()
+ assert scoped_definitions("src/a.py", "def load():\n pass\n", extract_definitions("def load():\n pass\n")) == set()
+
+
+@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
+ default branch); "" clears it; None on a re-bind leaves it standing."""
+ from scribe.services.coverage import compute_coverage
+ from scribe.services.repo_bindings import bindings_for_project, set_binding
+ uid, pid = seeded["uid"], seeded["pid"]
+ b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "dev")
+ assert b.ref == "dev"
+ coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
+ assert coverage["repos"][0]["ref"] == "dev"
+ # A re-bind without a ref keeps it; "" clears it back to the default branch.
+ b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid)
+ assert b.ref == "dev"
+ b = await set_binding(uid, "https://git.example.com/alice/widget.git", pid, "")
+ assert b.ref is None
+ assert [x.ref for x in await bindings_for_project(uid, pid)] == [None]
+ coverage = await compute_coverage(uid, pid, selector=_selector(_tarball(TREE)))
+ assert coverage["repos"][0]["ref"] == "main"
+
diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py
index a1e19e7..05b062e 100644
--- a/tests/test_services_backup.py
+++ b/tests/test_services_backup.py
@@ -18,7 +18,7 @@ def test_backup_version_is_v8():
point of the test — a payload section added without moving the version
produces backups that are structurally different and indistinguishable
by inspection."""
- assert backup.BACKUP_VERSION == 8
+ assert backup.BACKUP_VERSION == 9
def test_not_included_lists_the_known_gaps():
@@ -115,7 +115,8 @@ async def test_export_full_backup_contains_every_declared_section():
"topic_suppressions",
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
- "note_supersessions", "code_shapes", "code_shape_events"):
+ "note_supersessions", "code_shapes", "code_shape_events",
+ "code_shape_uses"):
assert key in out, f"missing export section: {key}"
assert out[key] == []
diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py
index 6e062d9..cb07e65 100644
--- a/tests/test_shape_ledger.py
+++ b/tests/test_shape_ledger.py
@@ -30,7 +30,7 @@ def test_the_todo_state_is_the_default():
assert CodeShape.__table__.c.status.default.arg == "unclassified"
assert "unclassified" in SHAPE_STATUSES
assert set(SHAPE_STATUSES) == {
- "canonical", "instance", "variant", "exempt", "unclassified",
+ "canonical", "instance", "variant", "exempt", "scoped", "unclassified",
}
assert set(SHAPE_CLASSIFIERS) == {
"agent", "audit", "hook", "mechanical", "import",
@@ -248,6 +248,49 @@ def test_match_canon_orders_bases_strongest_first_and_respects_kind():
assert match_canon("sym", "x.py", "unrelated", "def unrelated(a, b, c, d, e):", "return 1", canons) is None
+def test_match_canon_gates_sym_bases_by_language_family():
+ """A Python canon says nothing about a Vue body (and vice versa): the
+ 2026-08 audit's worst proposals were `register` (MCP tool module, python)
+ offered for every auth view's handleSubmit that calls authStore.register()
+ and for a TS store's own `register`. Unknown language on either side →
+ no gate (the canons recorded without a language keep proposing)."""
+ from scribe.services.shape_ledger import Canon, _norm_text, match_canon, same_family
+ py_register = Canon(46, "sym", "register", (("src/scribe/mcp/tools/notes.py", "register"),),
+ "def register(mcp) -> None:", _norm_text("def register(mcp) -> None: ..."), 2, "python")
+ ts_helper = Canon(53, "sym", "apiErrorMessage", (("frontend/src/api/client.ts", "apiErrorMessage"),),
+ "export function apiErrorMessage(e: unknown, fallback: string): string {",
+ _norm_text("export function apiErrorMessage(e, fallback) { return fallback }"), 2, "typescript")
+ canons = [py_register, ts_helper]
+ vue_body = "async function handleSubmit() {\n await authStore.register(username.value);\n error.value = apiErrorMessage(e, 'x');\n}"
+ # The Vue handler references the TS helper, never the Python canon.
+ assert match_canon("sym", "frontend/src/views/RegisterView.vue", "handleSubmit",
+ "async function handleSubmit() {", vue_body, canons) == (53, "reference", 0.9)
+ # A TS store's own `register` is not a second definition of the Python one.
+ assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
+ "async function register(u: string) {", "return apiPost('/api/auth/register', {u})",
+ [py_register]) is None
+ # Same family still proposes by symbol; unknown language still proposes.
+ assert match_canon("sym", "src/scribe/mcp/tools/other.py", "register",
+ "def register(mcp) -> None:", "pass", [py_register]) == (46, "symbol", 1.0)
+ unknown = py_register._replace(language="")
+ assert match_canon("sym", "frontend/src/stores/auth.ts", "register",
+ "async function register(u: string) {", "", [unknown]) == (46, "symbol", 1.0)
+ assert same_family("a.py", "python") and same_family("a.vue", "typescript")
+ assert same_family("a.py", "") and same_family("", "python")
+ assert not same_family("a.py", "vue")
+
+
+def test_match_canon_reference_skips_generic_verbs():
+ """A bare mention of `load`/`save`/`register` is not a call site of THIS
+ canon; the symbol basis still catches a second definition of the name."""
+ from scribe.services.shape_ledger import Canon, _norm_text, match_canon
+ loader = Canon(70, "sym", "load", (("frontend/src/components/A.vue", "load"),),
+ "async function load() {", _norm_text("async function load() { await fetch() }"), 2, "vue")
+ body = "async function refresh() {\n await load();\n}"
+ assert match_canon("sym", "frontend/src/components/B.vue", "refresh", "async function refresh() {", body, [loader]) is None
+ assert match_canon("sym", "frontend/src/components/B.vue", "load", "async function load() {", "", [loader]) == (70, "symbol", 1.0)
+
+
def test_match_canon_symbol_beats_everything_including_css_copies():
"""The previous test's css `btn-primary`-elsewhere case, stated plainly:
a second definition of the canon's own name is the symbol basis."""
@@ -274,6 +317,32 @@ def test_derive_groups_copy_before_name_with_floors():
assert ("i.py", "sym", "one") not in g
+def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows():
+ """#2872: dup groups (the real copies) outrank name groups (usually
+ convention), wider spread first; #2869: scoped rows are in the readout."""
+ from scribe.models.code_shape import CodeShape
+ from scribe.services.shape_ledger import proposal_summary
+
+ def row(path, symbol, group, kind="css", status="scoped"):
+ return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind,
+ status=status, proposal_basis="derive", proposal_group=group)
+ rows = [
+ # a name group of 6 across 6 files
+ *[row(f"v/{i}.vue", "status-badge", "name:css:status-badge") for i in range(6)],
+ # a dup group of 3 across 3 files (different selector names, one body)
+ row("v/Login.vue", "closed-msg", "dup:abc"), row("v/Reset.vue", "error-block", "dup:abc"),
+ row("v/Forgot.vue", "success-msg", "dup:abc"),
+ # a dup group of 2 in ONE file — a copy, but not across files
+ row("v/A.vue", "x", "dup:def"), row("v/A.vue", "y", "dup:def"),
+ # judged rows never count
+ row("v/J.vue", "closed-msg", "dup:abc", status="exempt"),
+ ]
+ out = proposal_summary(rows)
+ assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"]
+ assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3
+ assert out["derive_groups"][0]["label"] == "closed-msg (identical body)"
+
+
def test_confirm_requires_a_named_scope():
import asyncio
@@ -291,6 +360,99 @@ def test_proposer_tools_are_mounted():
assert mcp._tool_manager.get_tool("confirm_shape_proposals") is not None
tool = mcp._tool_manager.get_tool("list_shapes")
assert "proposal" in tool.parameters.get("properties", {})
+ # #2868: the audit surfaces — compact pages and the sweep form.
+ assert "compact" in tool.parameters.get("properties", {})
+ rule = mcp._tool_manager.get_tool("classify_shapes_by_rule")
+ assert rule is not None
+ for name in ("path", "status", "pattern", "kind", "snippet_id", "reason", "include_judged"):
+ assert name in rule.parameters.get("properties", {}), name
+
+
+# --- #2868: the bulk surfaces (pure) -----------------------------------------
+
+
+def test_compact_row_carries_identity_standing_and_the_proposers_word_only():
+ """A 500-row compact page must fit the tool budget: no commits, shas or
+ timestamps; optional fields only when set."""
+ from scribe.models.code_shape import CodeShape
+ row = CodeShape(project_id=2, repo_key="r", path="src/a.py", symbol="f", kind="sym",
+ status="unclassified", signature="def f(x):", body_sha="abc",
+ first_seen_commit="c1", last_seen_commit="c2")
+ assert row.to_compact() == {
+ "path": "src/a.py", "symbol": "f", "kind": "sym",
+ "status": "unclassified", "signature": "def f(x):",
+ }
+ row.status, row.snippet_id, row.classified_by = "instance", 9, "audit"
+ row.proposed_snippet_id, row.proposal_basis, row.proposal_score = 9, "symbol", 1.0
+ compact = row.to_compact()
+ assert compact["snippet_id"] == 9 and compact["by"] == "audit"
+ assert compact["proposal"]["basis"] == "symbol"
+ for noisy in ("first_seen_commit", "last_seen_commit", "body_sha", "created_at", "classified_at"):
+ assert noisy not in compact
+
+
+def test_uses_edges_table_and_validation():
+ """#2870: consumption is its own relation — a table that cascades with
+ both ends, and `uses` on a classification must be a list of ids."""
+ from scribe.models import Base
+ from scribe.models.code_shape import USE_BASES, CodeShapeUse
+ from scribe.services.shape_ledger import validate_classifications
+ assert "code_shape_uses" in Base.metadata.tables
+ cols = CodeShapeUse.__table__.c
+ assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE"
+ assert next(iter(cols.snippet_id.foreign_keys)).ondelete == "CASCADE"
+ assert set(USE_BASES) == {"reference", "hook", "agent", "audit", "import"}
+ ok = [{"path": "a.py", "symbol": "f", "status": "instance", "snippet_id": 9, "uses": [3, 4]}]
+ assert validate_classifications(ok) is None
+ bad = [{"path": "a.py", "symbol": "f", "status": "instance", "snippet_id": 9, "uses": "3"}]
+ assert "uses must be a list" in validate_classifications(bad)
+
+
+def test_reference_canons_names_every_used_canon_not_just_the_best():
+ from scribe.services.shape_ledger import Canon, _norm_text, reference_canons
+ a = Canon(1, "sym", "hash_token", (("src/x.py", "hash_token"),), "def hash_token(raw):", _norm_text("x"), 2, "python")
+ b = Canon(2, "sym", "rules_payload", (("src/y.py", "rules_payload"),), "def rules_payload(r):", _norm_text("y"), 2, "python")
+ ts = Canon(3, "sym", "fmtDate", (("f/d.ts", "fmtDate"),), "export function fmtDate(iso: string): string {", _norm_text("z"), 2, "typescript")
+ body = "def create_invitation(email):\n h = hash_token(raw)\n return rules_payload(h)\n"
+ assert reference_canons("sym", "src/scribe/services/auth.py", "create_invitation", body, [a, b, ts]) == [1, 2]
+ # the shape's own name and the other language family are never "uses"
+ assert reference_canons("sym", "src/x.py", "hash_token", body, [a]) == []
+ assert reference_canons("sym", "f/v.vue", "show", "fmtDate(x); hash_token(y)", [a, ts]) == [3]
+
+
+def test_reason_codes_are_a_fixed_catalogue_and_validated():
+ """#2874: an optional index beside the prose reason; unknown codes are a
+ structural error (the batch applies nothing)."""
+ from scribe.models.code_shape import REASON_CODES
+ from scribe.services.shape_ledger import validate_classifications
+ assert set(REASON_CODES) == {
+ "scoped-css", "one-off-handler", "test-helper", "convention-plumbing",
+ "pure-helper", "generated", "script", "typed-record",
+ }
+ assert "reason_code" in CodeShape.__table__.c
+ ok = [{"path": "a.py", "symbol": "f", "status": "exempt", "reason": "x", "reason_code": "pure-helper"}]
+ assert validate_classifications(ok) is None
+ bad = [{"path": "a.py", "symbol": "f", "status": "exempt", "reason": "x", "reason_code": "nope"}]
+ assert "unknown reason_code" in validate_classifications(bad)
+
+
+def test_rule_matches_is_directory_glob_and_kind_aware():
+ from scribe.models.code_shape import CodeShape
+ from scribe.services.shape_ledger import rule_matches
+
+ def row(path, symbol, kind="sym"):
+ return CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind, status="unclassified")
+
+ r = row("frontend/src/views/LoginView.vue", "auth-card", "css")
+ assert rule_matches(r, path="frontend/src/views", pattern="", kind="")
+ assert rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="css")
+ assert not rule_matches(r, path="frontend/src/views", pattern="auth-*", kind="sym")
+ assert not rule_matches(r, path="frontend/src/view", pattern="", kind="") # directory, not prefix
+ assert rule_matches(r, path="frontend/src/views/LoginView.vue", pattern="", kind="")
+ # CSS symbols compare without the leading dot, like everywhere else.
+ assert rule_matches(row("w/a.css", ".btn-primary", "css"), path="w", pattern="btn-*", kind="css")
+ assert rule_matches(row("src/scribe/services/backup.py", "_note_rows"), path="src/scribe/services", pattern="_*_rows", kind="")
+ assert not rule_matches(row("src/scribe/services/backup.py", "export_full_backup"), path="src/scribe/services", pattern="_*_rows", kind="")
# --- step 7: the divergence readout (pure) ----------------------------------
diff --git a/tests/test_snippet_location_filter.py b/tests/test_snippet_location_filter.py
index 882c4dc..29617a6 100644
--- a/tests/test_snippet_location_filter.py
+++ b/tests/test_snippet_location_filter.py
@@ -56,6 +56,7 @@ def test_no_parts_matches_everything():
def test_matches_exact_repo_path_and_symbol():
data = _data({"repo": "Scribe", "path": "src/scribe/x.py", "symbol": "helper"})
assert location_matches(data, {"repo": "Scribe"})
+ assert location_matches(data, {"repo": "scribe"}) # repo names: case never distinguishes (#2874)
assert location_matches(data, {"path": "src/scribe/x.py"})
assert location_matches(data, {"symbol": "helper"})
assert location_matches(data, {"repo": "Scribe", "symbol": "helper"})
@@ -101,7 +102,8 @@ def test_blank_recorded_part_does_not_match_a_requested_one():
def test_jsonpath_filters_within_one_locations_entry():
expr = location_jsonpath({"repo": "Scribe", "symbol": "helper"})
assert expr.startswith("$.locations[*] ? (")
- assert '@.repo == "Scribe"' in expr
+ # repo: anchored, case-insensitive (#2874) — mirrors location_matches.
+ assert '@.repo like_regex "^Scribe$" flag "i"' in expr
assert '@.symbol == "helper"' in expr
assert " && " in expr
@@ -121,8 +123,9 @@ def test_jsonpath_quotes_values_as_json_literals():
"""A quote in a repo name must stay inside the literal, not end it."""
nasty = 'we"ird'
expr = location_jsonpath({"repo": nasty})
- assert json.dumps(nasty) in expr
+ assert json.dumps("^" + nasty + "$") in expr # the regex is a JSON literal too
assert '\\"' in expr
+ assert json.dumps(nasty) in location_jsonpath({"symbol": nasty})
def test_jsonpath_emits_parts_in_a_fixed_key_order():