diff --git a/docs/api-reference.md b/docs/api-reference.md index 0fe3b15..fbf7c0a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -173,7 +173,7 @@ endpoint at `/mcp`, not these REST routes. | GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates | | GET | `/api/plugin/processes` | Stored Processes for skill-stub sync | | GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` | -| GET / POST | `/api/projects//coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups`, `derive_new`, `divergence`, `recheck`) | +| GET / POST | `/api/projects//coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) | | GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL | ## Dashboard, Export, Trash, Users diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 577b44c..f8ac872 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.43", + "version": "0.1.44", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/shape-accounting/SKILL.md b/plugin/skills/shape-accounting/SKILL.md index 8de6d67..e9196a3 100644 --- a/plugin/skills/shape-accounting/SKILL.md +++ b/plugin/skills/shape-accounting/SKILL.md @@ -118,6 +118,14 @@ the last sweep left it. Three surfaces say so without anyone running an audit 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"`. + The datum that decides between the two is **what renders it**: every css + row carries `used_by` (the files whose markup names the class — the CSS + consumer map, milestone 302), a derive group carries the family's + `consumers`, and the write-path line says "used by N template(s)". Many + templates, one recipe → derive; one template each, different purposes → + dismiss. `list_shapes(flag="unused-css")` is the map's negative space — + css rules no template names, a deletion candidate to look at, never + auto-deleted (a class built at runtime is invisible to the map). 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/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index 9476cc7..3ba6e6d 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -116,10 +116,17 @@ async def list_shapes( classify it: instance if it should use the canon, variant with the why if deliberate); "recheck": judged instances/variants whose body changed since judged (the judgment stands; confirm - it again with classify_shapes, or re-judge). + it again with classify_shapes, or re-judge); "unused-css" + (milestone 302): live css rules no file's markup names — a + deletion candidate to look at, never auto-deleted (the map reads + templates only; a class built at runtime is invisible to it). Returns {"shapes": [...], "total": N} — total counts every match, not - just this page. Each row's `classified_by` says who judged: agent / + just this page. Every css row carries `used_by` {count, paths} — the + files whose markup names its class (milestone 302, the CSS consumer + map: a scoped rule is used by its own template; a shared recipe by + many; a count of 0 is "no template names it"). Each row's + `classified_by` says who judged: agent / audit / import are judgments; `mechanical` is the canonical stamp the sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled a snippet and then wrote code referencing/resembling it, so the shape @@ -145,10 +152,12 @@ async def list_shapes( include_vanished=include_vanished, limit=limit, offset=offset, proposal=proposal, flag=flag, uses=uses, ) - return { - "shapes": [r.to_compact() if compact else r.to_dict() for r in rows], - "total": total, - } + shapes = [r.to_compact() if compact else r.to_dict() for r in rows] + used_by = await shape_ledger_svc.used_by_map(rows) + for row, out in zip(rows, shapes): + if row.id in used_by: + out["used_by"] = used_by[row.id] + return {"shapes": shapes, "total": total} async def classify_shapes_by_rule( @@ -295,7 +304,10 @@ async def refresh_pattern_coverage(project_id: int) -> dict: Returns the accounting payload — total, accounted, counts by status, unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting confirmation), `derive_groups` (the biggest repeats-with-no-canon - families), `derive_new` (copies that joined a family since the previous + families, each css one with `consumers` — the files whose markup + render it, milestone 302), `unused_css` (css rules no template names; + None where the map has no evidence of templates), `derive_new` (copies + that joined a family since the previous refresh — the drift to act on now: derive the canon, don't queue an audit), `proposer` (what this refresh examined) — plus `pattern_coverage`, the same one-line summary enter_project carries. diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index f6da17f..dcb31ae 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -629,7 +629,23 @@ async def compute_coverage( agg["accounted"] += row.status != "unclassified" unclassified = counts.pop("unclassified") - proposals = shape_ledger.proposal_summary(rows) + # The CSS consumer map's readout (milestone 302): which files render each + # css row — on the derive groups (a shared recipe vs a scoped one is a + # count), and the negative space: css rules no template names. "Unused" + # is measured only where the map has evidence of templates at all (one + # edge somewhere); a repo of bare stylesheets is "not measured", not + # "all unused". + css_rows = [r for r in rows if r.kind == "css"] + consumer_paths: dict[int, list[str]] = {} + unused_css = None + try: + edges = await shape_ledger.consumers_of([r.id for r in css_rows]) + consumer_paths = {sid: [e.path for e in es] for sid, es in edges.items()} + if consumer_paths: + unused_css = sum(1 for r in css_rows if r.id not in consumer_paths) + except Exception: + logger.warning("consumer map read failed", exc_info=True) + proposals = shape_ledger.proposal_summary(rows, consumer_paths=consumer_paths) divergence = shape_ledger.divergence_summary(rows) derive_new = shape_ledger.derive_new_summary(rows, since=since) return { @@ -646,6 +662,9 @@ async def compute_coverage( # duplicate family — what the arrival line names so drift is noticed # on entering, not found by an audit. "derive_new": derive_new, + # The consumer map's negative space (milestone 302): live css rules + # no template names — None when the map has no evidence of templates. + "unused_css": unused_css, "proposer": proposer_stats, # The divergence readout (#2793): button B where button A is canon, # and judged shapes whose bodies moved since they were judged. @@ -823,7 +842,16 @@ def coverage_line(coverage: dict) -> str: standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}") first = (coverage.get("derive_groups") or [{}])[0] if first.get("label") and first.get("files"): - standing.append(f"top copy {first['label']} ×{first['files']} files") + top_copy = f"top copy {first['label']} ×{first['files']} files" + # A css family says what renders it (milestone 302): the count that + # tells a shared recipe from a scoped convention. + if "consumers" in first: + n_t = (first.get("consumers") or {}).get("count", 0) + top_copy += f" · used by {n_t} template{'s' if n_t != 1 else ''}" + standing.append(top_copy) + if coverage.get("unused_css"): + n_u = coverage["unused_css"] + standing.append(f"{n_u} unused class{'es' if n_u != 1 else ''}") if unclassified: line += f"; {unclassified} unclassified" if standing: diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index ee4c5c5..de4350f 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -1073,6 +1073,18 @@ def _derive_line(path: str, derive: list[dict]) -> str: # 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)" + # What renders a css family (milestone 302): the consumer count is + # the datum that separates a shared recipe from a scoped convention. + cons = f.get("consumers") + if cons is not None: + n_t = cons.get("count", 0) + used = f"; used by {n_t} template{'s' if n_t != 1 else ''}" + if cons.get("paths"): + used += ": " + ", ".join(f"`{x}`" for x in cons["paths"]) + extra = n_t - len(cons["paths"]) + if extra > 0: + used += f" +{extra} more" + files += used # 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. diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index b403a0c..3d155bc 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -354,6 +354,28 @@ async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]: return out +# How many consumer files a readout names before "+N more". +_CONSUMERS_SHOWN = 4 + + +def consumer_summary(paths: Iterable[str]) -> dict: + """{"count", "paths"} — distinct consumer files, sorted, the first few + named. The one shape every surface uses for "used by N template(s)".""" + files = sorted(set(paths)) + return {"count": len(files), "paths": files[:_CONSUMERS_SHOWN]} + + +async def used_by_map(rows: Iterable[CodeShape]) -> dict[int, dict]: + """{shape_id: consumer_summary} for every css row given — a row with no + consumer gets {"count": 0, "paths": []}: "no template names it" is a + finding, not an absence.""" + css = [r for r in rows if r.kind == "css"] + if not css: + return {} + edges = await consumers_of([r.id for r in css]) + return {r.id: consumer_summary(e.path for e in edges.get(r.id, [])) for r in css} + + async def mark_canonicals( project_id: int, recorded: list[tuple[int, str, str]] ) -> None: @@ -666,7 +688,8 @@ async def list_project_shapes( suggestion), "derive" (a repeats-with-no-canon group), or one basis name (symbol/reference/text/signature/semantic). ``flag`` narrows to the readout's asks (#2793): "divergence" (new where a canon dominates, - `diverges_from` names it) or "recheck" (a judged shape whose body moved). + `diverges_from` names it), "recheck" (a judged shape whose body moved), + or "unused-css" (milestone 302: a css rule no template names). """ from sqlalchemy import func, or_ @@ -701,6 +724,13 @@ async def list_project_shapes( conds.append(CodeShape.diverges_from.isnot(None)) elif flag == "recheck": conds.append(CodeShape.recheck_at.isnot(None)) + elif flag == "unused-css": + # The consumer map's negative space (milestone 302): a live css rule + # no file's markup names. A candidate for deletion, surfaced — never + # deleted — because the map reads templates only (a class built at + # runtime, or used from a script, is invisible to it). + conds.append(CodeShape.kind == "css") + conds.append(~CodeShape.id.in_(select(CodeShapeConsumer.shape_id))) if uses: # Consumers of a canon (#2870): rows with a uses edge to it, whatever # shape they themselves are. @@ -1461,13 +1491,21 @@ async def apply_derive_groups(project_id: int) -> int: return grouped -def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: +def proposal_summary( + rows: Iterable[CodeShape], *, top: int = 8, + consumer_paths: dict[int, list[str]] | None = None, +) -> dict: """The readout's view of the proposer's standing: how many canon - proposals await confirmation, and the largest derive-first groups.""" + proposals await confirmation, and the largest derive-first groups. + ``consumer_paths`` (shape_id → files whose markup names it, milestone + 302) puts `consumers` on each group — the family's distinct consumer + files across its members, the datum that separates a shared recipe + from a scoped convention.""" proposed = 0 by_canon: dict[int, int] = {} groups: dict[str, dict] = {} files: dict[str, set[str]] = {} + consumers: dict[str, set[str]] = {} for row in rows: if row.status not in _MECHANICAL_TODO: continue @@ -1488,8 +1526,14 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict: files.setdefault(row.proposal_group, set()).add(row.path) if len(g["paths"]) < 3: g["paths"].append(row.path) + if consumer_paths is not None and row.kind == "css": + consumers.setdefault(row.proposal_group, set()).update( + consumer_paths.get(row.id) or () + ) for key, g in groups.items(): g["files"] = len(files[key]) + if key in consumers: + g["consumers"] = consumer_summary(consumers[key]) # Body-identical groups first (#2872): the things an audit actually # consolidated were identical bodies under different names/files; a # name repeated across modules is usually convention. Within a tier, @@ -1753,15 +1797,21 @@ async def write_time_derive( group = grouped[0].proposal_group members = [r for r in grouped if r.proposal_group == group] files = sorted({r.path for r in members}) - out.append({ - "symbol": name, "kind": kind, "key": group, - "family": { - "group": group, "label": label, - "identical": not group.startswith("name:"), - "files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files), - "size": len(members) + (1 if here is not None else 0), - }, - }) + family = { + "group": group, "label": label, + "identical": not group.startswith("name:"), + "files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files), + "size": len(members) + (1 if here is not None else 0), + } + if kind == "css": + # What renders the family (milestone 302): the members' consumer + # files, the row at `path` included when it already exists. + ids = [r.id for r in members] + ([here.id] if here is not None else []) + edges = await consumers_of(ids) + family["consumers"] = consumer_summary( + e.path for es in edges.values() for e in es + ) + out.append({"symbol": name, "kind": kind, "key": group, "family": family}) return out diff --git a/tests/test_integration_shape_classify.py b/tests/test_integration_shape_classify.py index 798cbce..f2d69a1 100644 --- a/tests/test_integration_shape_classify.py +++ b/tests/test_integration_shape_classify.py @@ -703,11 +703,13 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded): "v/B.vue": {"error-msg": 1}, "v/C.vue": {"error-msg": 1, "btn-primary": 4}, } - assert await sync_repo_consumers(pid, REPO, refs) == 5 + # A and B consume their OWN error-msg; C defines none, so its use fans + # out to both rows; btn-primary resolves to the shared sheet from A and C. + assert await sync_repo_consumers(pid, REPO, refs) == 6 rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"} edges = await consumers_of(rows.values()) view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()} - assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2)] + assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)] assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)] assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)] assert view[("web/button.css", "btn")] == [] # the seeded row: no template names it @@ -719,6 +721,22 @@ async def test_consumer_map_syncs_edges_from_template_references(seeded): assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)] assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)] + # The readout side: used_by per css row, the unused-css flag, and the + # family's consumers on the write-path check. + from scribe.services.shape_ledger import ( + apply_derive_groups, used_by_map, write_time_derive, + ) + owner = seeded["owner"] + live = [r for r in await live_rows(pid) if r.kind == "css"] + used = await used_by_map(live) + assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]} + assert used[rows[("web/button.css", "btn")]] == {"count": 0, "paths": []} + unused, n = await list_project_shapes(owner, pid, flag="unused-css") + assert n == 1 and [(r.path, r.symbol) for r in unused] == [("web/button.css", "btn")] + await apply_derive_groups(pid) + out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")]) + assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]} + # B's rule vanishes from the tree → its edges go with the pass. await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2") await sync_repo_consumers(pid, REPO, refs2) diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index e3f462c..f94a15c 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -604,6 +604,17 @@ def test_coverage_line_names_the_proposers_standing(): assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]}) assert "(1 derive group)" in line + # Milestone 302: a css top copy says what renders it; unused classes + # join the standing block only when measured (None = no evidence). + line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [ + {"group": "name:css:error-msg", "label": ".error-msg", "files": 6, + "consumers": {"count": 6, "paths": ["a.vue"]}}], "unused_css": 3}) + assert "top copy .error-msg ×6 files · used by 6 templates" in line + assert "3 unused classes" in line + line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [ + {"group": "name:css:x", "label": ".x", "files": 2, + "consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None}) + assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line # #2874: the next action on the line — biggest canon queue, widest copy. line = coverage_line({ **base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78}, diff --git a/tests/test_shape_ledger.py b/tests/test_shape_ledger.py index 52c0967..6ab47a9 100644 --- a/tests/test_shape_ledger.py +++ b/tests/test_shape_ledger.py @@ -380,6 +380,17 @@ def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows row("v/J.vue", "closed-msg", "dup:abc", status="exempt"), ] out = proposal_summary(rows) + # Milestone 302: with consumer paths in hand, each css group says what + # renders it — distinct files across the members; absent otherwise. + assert "consumers" not in out["derive_groups"][0] + for i, r in enumerate(rows): # unsaved rows have no id; give them one + r.id = i + 1 + cpaths = {rows[0].id: ["v/0.vue", "v/Z.vue"], rows[1].id: ["v/1.vue"], rows[2].id: ["v/0.vue"]} + with_c = proposal_summary(rows, consumer_paths=cpaths) + badge = next(g for g in with_c["derive_groups"] if g["group"] == "name:css:status-badge") + assert badge["consumers"] == {"count": 3, "paths": ["v/0.vue", "v/1.vue", "v/Z.vue"]} + dup = next(g for g in with_c["derive_groups"] if g["group"] == "dup:abc") + assert dup["consumers"] == {"count": 0, "paths": []} 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)" @@ -455,7 +466,7 @@ def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names(): (1, "v/A.vue"): 2, # own row, not B's (3, "v/A.vue"): 1, # the shared sheet (2, "v/B.vue"): 1, # own row - (2, "v/C.vue"): 1, # C defines nothing → the other file's row + (1, "v/C.vue"): 1, (2, "v/C.vue"): 1, # C defines none → every other definition (4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess } # Unknown tokens and an unreferenced row leave no trace. diff --git a/tests/test_write_path_trigger.py b/tests/test_write_path_trigger.py index 8b0c7d6..a99d018 100644 --- a/tests/test_write_path_trigger.py +++ b/tests/test_write_path_trigger.py @@ -1306,7 +1306,8 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band(): {"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}}, + "file_count": 5, "size": 6, + "consumers": {"count": 6, "paths": ["a/TaskLogSection.vue", "a/V.vue"]}}}, {"symbol": "btn-primary", "kind": "css", "key": "canon:2855", "canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css", "label": ".btn-primary"}}, @@ -1344,8 +1345,10 @@ async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band(): assert "Shape ledger at `frontend/src/components/New.vue`" in ctx # 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. + # A css family says what renders it (milestone 302) before the ask. 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 + "`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; used by 6 templates: " \ + "`a/TaskLogSection.vue`, `a/V.vue` +4 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