diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 57b65d0..6799e70 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.40", + "version": "0.1.41", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/reusing-code/SKILL.md b/plugin/skills/reusing-code/SKILL.md index 08db67d..9560c39 100644 --- a/plugin/skills/reusing-code/SKILL.md +++ b/plugin/skills/reusing-code/SKILL.md @@ -45,8 +45,9 @@ through recall/auto-inject; this skill is the active reflex around that. duplicate — reuse it and drop yours — or it isn't, and the record needs the new location adding. Both are cheaper now than after the duplicate settles in. - **A `Shape ledger at …` line is the ledger speaking, not the record.** It - names a duplicate family ("identical body in N other files, no canon") or a - canon elsewhere for a name you just wrote — for edits made through Bash + names a duplicate family ("identical body in N other files, no canon"), a + repeated name ("defined in N other files") or a canon elsewhere for a name + you just wrote — for edits made through Bash (sed, heredocs, scripts) as much as through Write/Edit. Derive the family or reuse the canon *now*; a family that is convention rather than copies is dismissed with `classify_shapes(..., status="exempt", diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index c62025b..8de6d67 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -94,7 +94,8 @@ the last sweep left it. Three surfaces say so without anyone running an audit - **At the write** — the prior-art hint (the Write/Edit hook, and since 0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too) carries a `Shape ledger at ` line when a name just written is a known - **duplicate family** ("identical body in N other files, no canon") or a + **duplicate family** ("identical body in N other files, no canon"), a + **repeated name** ("defined in N other files, no canon") or a **canon elsewhere** ("snippet #N at — reuse, don't redefine"). Act on it *then*: pull the canon and build from it, or derive the family now — `create_snippet` the dominant form, repoint the copies, `classify_shapes` @@ -109,6 +110,14 @@ the last sweep left it. Three surfaces say so without anyone running an audit reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule` for a whole family) removes it from the queue. Dismissal is a judgment and it is recorded; silence is not. +- **CSS is watched by name, never by body** (note 2917). Classes serving + different purposes share declarations because the style system makes them + alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are + two meanings, not two copies — so a CSS family is the *same class defined + in ≥2 files* (a recipe living in several places), and identical bodies + under different names are never a family. Derive a CSS family by moving + the recipe to the shared sheet and recording it; a class name reused for + genuinely different things is dismissed with `reason_code="scoped-css"`. After the one-time pay-down the derive queue reads empty; anything in it afterwards is drift of the moment, and the hint already said so at the write. diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 0695d29..a1b8bc3 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -198,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]: break block = lines[i:end] # A CSS rule's fingerprint is its DECLARATIONS, not its selector - # (#2872): the row's identity already carries the selector, and the - # question the fingerprint answers for derive grouping is "is this the - # same rule under another name?" — .closed-msg / .error-block / - # .success-msg with identical bodies are one dup group, not three - # lonely rows. Sym blocks keep their signature line in the hash. + # (#2872): the row's identity already carries the selector. Since + # note 2917 the derive grouping no longer reads CSS bodies at all (a + # class is grouped by name only), so for CSS the fingerprint is the + # recheck identity — "did this rule's body change since it was + # judged?" — and nothing more. The shape of the hash is kept as-is on + # purpose: changing it would flip every judged CSS row to recheck on + # the next sync. Sym blocks keep their signature line in the hash. if kind == "css": # One-line rules (`.x { color: red; }`) carry their declarations on # the selector line itself; a block that is only the selector plus @@ -217,10 +219,9 @@ def extract_definitions(text: str) -> list[Definition]: # A SINGLE declaration is not a shape (#2903): `color: var(--fs- # text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto # is three meanings sharing one line, not three copies of one - # rule — the first pay-down found 5-file "families" of exactly - # this and nobody would consolidate them. Keep the selector in the - # hash for one-liners, so they group only with same-name copies; - # two declarations and up stay selector-agnostic. + # rule. Keep the selector in the hash for one-liners; two + # declarations and up stay selector-agnostic. (Moot for grouping + # since note 2917, kept for fingerprint stability — see above.) elif _declaration_count(hashed) < 2: hashed = block else: diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 5b7eefb..ee4c5c5 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -1061,17 +1061,27 @@ def _derive_line(path: str, derive: list[dict]) -> str: ) continue f = d["family"] - how = "identical body" if f.get("identical") else "same name defined" files = ", ".join(f"`{x}`" for x in f.get("files") or []) more = f.get("file_count", 0) - len(f.get("files") or []) if more > 0: files += f" +{more} more" + n = f.get("file_count", 0) + if f.get("identical"): + what = f"is a duplicate family with no canon — identical body in {n} other file(s)" + else: + # A name family: the same definition name living in several + # files. CSS is only ever grouped this way (note 2917) — a class + # is a recipe, and the recipe is what gets derived or dismissed. + what = f"is a repeated name with no canon — defined in {n} other file(s)" + # The dismissal reason the family most likely earns: a class name + # reused for different purposes is scoped styling; a code name reused + # across modules is convention plumbing. + dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing" parts.append( - f"`{f['label']}` is a duplicate family with no canon — {how} in " - f"{f.get('file_count', 0)} other file(s): {files}; derive it now: " + f"`{f['label']}` {what}: {files}; derive it now: " "record the canon (create_snippet) and make the copies instances " "(classify_shapes) — or, if these are convention not copies, " - "`classify_shapes(..., status=\"exempt\", reason_code=\"convention-plumbing\")` " + f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` " "dismisses the family — rather than adding another copy" ) return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "." diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index e7520d1..b48d986 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -923,6 +923,13 @@ async def stamp_write_path_instances( # recur by convention, not by duplication). _DERIVE_MIN_DUP = 2 _DERIVE_MIN_NAME = 3 +# CSS is never grouped by body (note 2917): classes for different purposes +# share declarations because the style system makes them alike — `.text-muted` +# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are +# two meanings, not two copies. A CSS family is a NAME defined in more than +# one file: that is a recipe living in several places, and two is already +# the signal (a class name is deliberate in a way `setup`/`load` are not). +_DERIVE_MIN_NAME_CSS = 2 # Semantic checks per repo per refresh — an embedding each (local fastembed), # bounded so a 4,000-row ledger is worked through over refreshes, not in one. _SEMANTIC_CAP = 150 @@ -1298,15 +1305,16 @@ def derive_groups( rows: Iterable[tuple[str, str, str, str]] ) -> dict[tuple[str, str, str], str]: """The derive-first grouping over (path, kind, symbol, body_sha) rows - that matched no canon: {(path, kind, symbol): group_key}. Identical - bodies in ≥2 places group as `dup:`; the same name defined in ≥3 - files groups as `name::`; a row joins at most one group, - the copy before the name.""" + that matched no canon: {(path, kind, symbol): group_key}. For code + (kind `sym`) identical bodies in ≥2 places group as `dup:` and the + same name defined in ≥3 files groups as `name:sym:`, the copy + before the name. CSS groups by name only — the same class defined in + ≥2 files is `name:css:`; its body never groups it (note 2917).""" by_sha: dict[str, list[tuple[str, str, str]]] = {} by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {} for path, kind, symbol, sha in rows: key = (path, kind, symbol) - if sha: + if sha and kind != "css": by_sha.setdefault(sha, []).append(key) by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key) out: dict[tuple[str, str, str], str] = {} @@ -1315,7 +1323,8 @@ def derive_groups( for key in keys: out.setdefault(key, f"dup:{sha}") for (kind, symbol), keys in by_name.items(): - if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME: + floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME + if len({k[0] for k in keys}) >= floor: for key in keys: out.setdefault(key, f"name:{kind}:{symbol}") return out @@ -1606,9 +1615,10 @@ async def write_time_derive( named at ``path``, what the ledger already knows about that name elsewhere in the project — - family the name sits in a derive-first group (identical body in N - files, or the same name in ≥3): "this is a known duplicate - family with no canon — derive it now, don't add a copy"; + family the name sits in a derive-first group (code: identical body + in N files or the same name in ≥3; CSS: the same class in + ≥2 files, note 2917): "this is a known family with no canon + — derive it now, don't add a copy"; canon a `canonical` row of that name at another path: "this is canon #N at — reuse, don't redefine". diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index ad9b231..4d24c43 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -588,21 +588,30 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded): assert summary["derive_groups"][1]["label"] == ".card" assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3 - # One of the css copies gets judged → the group shrinks on the next pass. + # One of the css copies gets judged → the group shrinks on the next pass + # but stays a family: a class in two files is already a recipe living in + # two places (css name floor 2, note 2917). Judge the second and it's gone. await classify_shapes(owner, pid, [ {"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, ]) await apply_derive_groups(pid) rows, _ = await list_project_shapes(owner, pid, proposal="derive") - assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor + assert {r.symbol for r in rows} == {"slug", "card"} + assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"} + await classify_shapes(owner, pid, [ + {"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"}, + ]) + await apply_derive_groups(pid) + rows, _ = await list_project_shapes(owner, pid, proposal="derive") + assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor @pytest.mark.integration async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded): - """#2900: against real rows — a name in a dup family → the family (other - files, count); a name whose canonical row lives elsewhere → that canon; - a judged row at the path, the canon's own file, or an unknown name → - silence.""" + """#2900: against real rows — a name in a family → the family (other + files, count; for CSS a NAME family, never a body one — note 2917); a + name whose canonical row lives elsewhere → that canon; a judged row at + the path, the canon's own file, or an unknown name → silence.""" from scribe.services.shape_ledger import apply_derive_groups, write_time_derive owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"] @@ -622,9 +631,10 @@ async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")]) assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css" fam = out[0]["family"] - assert fam["identical"] is True and fam["label"] == ".log-empty" + # Three identical bodies, and still a NAME family: CSS never groups by body. + assert fam["identical"] is False and fam["label"] == ".log-empty" assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3 - assert out[0]["key"] == fam["group"] and fam["group"].startswith("dup:") + assert out[0]["key"] == fam["group"] == "name:css:log-empty" # Editing one existing member still names the OTHER members. out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")]) assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3 diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index faffe11..c56ac33 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -308,6 +308,12 @@ def test_derive_groups_copy_before_name_with_floors(): ("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"), ("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group ("i.py", "sym", "one", "s6"), + # CSS (note 2917): identical bodies under different names are NOT a + # copy — two meanings sharing the style system's look; the same + # class in two files IS a family (the name floor is 2 for css). + ("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"), + ("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"), + ("n.css", "css", "alone", "c3"), ] g = derive_groups(rows) assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")] @@ -315,6 +321,10 @@ def test_derive_groups_copy_before_name_with_floors(): assert g[("d.css", "css", "btn")] == "name:css:btn" assert ("g.py", "sym", "main") not in g assert ("i.py", "sym", "one") not in g + assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g + assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")] + assert ("n.css", "css", "alone") not in g + assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css") def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh(): diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index dbbd923..626663f 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -1250,13 +1250,16 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band(): channel silences a family already named.""" from scribe.services import plugin_context as pc found = [ - {"symbol": "log-empty", "kind": "css", "key": "dup:483a", - "family": {"group": "dup:483a", "label": ".log-empty", "identical": True, + {"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty", + "family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False, "files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"], "file_count": 5, "size": 6}}, {"symbol": "btn-primary", "kind": "css", "key": "canon:2855", "canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css", "label": ".btn-primary"}}, + {"symbol": "slugify", "kind": "sym", "key": "dup:483a", + "family": {"group": "dup:483a", "label": "slugify", "identical": True, + "files": ["a/x.py", "a/y.py"], "file_count": 2, "size": 3}}, {"symbol": "load", "kind": "sym", "key": "name:sym:load", "family": {"group": "name:sym:load", "label": "load", "identical": False, "files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}}, @@ -1274,20 +1277,27 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band(): patch.object(pc.shape_ledger_svc, "write_time_derive", check): out = await pc.build_write_path_hint( 1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24, - stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")], + stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "slugify"), + ("sym", "load")], exclude_derive=["name:sym:load"], ) check.assert_awaited_once_with(24, "frontend/src/components/New.vue", - [("css", "log-empty"), ("css", "btn-primary"), ("sym", "load")]) - # The excluded family is gone; the other two render and are keyed. - assert [d["key"] for d in out["derive"]] == ["dup:483a", "canon:2855"] - assert out["derive_keys"] == ["dup:483a", "canon:2855"] + [("css", "log-empty"), ("css", "btn-primary"), + ("sym", "slugify"), ("sym", "load")]) + # The excluded family is gone; the other three render and are keyed. + assert [d["key"] for d in out["derive"]] == ["name:css:log-empty", "canon:2855", "dup:483a"] + assert out["derive_keys"] == ["name:css:log-empty", "canon:2855", "dup:483a"] ctx = out["context"] assert "Shape ledger at `frontend/src/components/New.vue`" in ctx - assert "`.log-empty` is a duplicate family with no canon — identical body in 5 other file(s): " \ + # A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css; + # a code dup family is an identical body and dismisses as convention-plumbing. + assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \ "`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; derive it now" in ctx + assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \ + "`a/x.py`, `a/y.py`; derive it now" in ctx assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx - assert "convention-plumbing" in ctx + assert 'reason_code=\"scoped-css\")` dismisses' in ctx + assert 'reason_code=\"convention-plumbing\")` dismisses' in ctx assert "`load`" not in ctx # No project → no check; a failing check never sinks the hint. check.reset_mock()