From 56b1952de5fd0a9a3ba47e3191b179526e08c82e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 9 Aug 2026 15:35:59 -0400 Subject: [PATCH] =?UTF-8?q?fix(systems):=20sweeps=20are=20the=20discovery?= =?UTF-8?q?=20moment=20=E2=80=94=20zero-Systems=20hint,=20create=5Fsystem?= =?UTF-8?q?=20dedup=20gate,=20prose=20inverted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real-world test of the #2562 fixes (Scribe issue #2569): a Forge session ran a whole-codebase audit and created zero Systems — endorsed by the shipped guidance, whose "no particular area takes none" clause read as an exemption for exactly the record type that enumerates the subsystem vocabulary. And the systems_hint was silent for a zero-Systems project, the one state nothing else nudges out of. - systems_hint gains a zero-Systems branch: prompt the FIRST create_system instead of going quiet. - create_system is duplicate-gated like the other creates (normalized name, archived included, fail-open) — liberal creation becomes safe by construction, so the guidance can stop preaching restraint. - Prose inverted on every surface (hint text, create_system docstring, floor bullet, using-scribe step 7): audits/sweeps take several tags and mint the Systems they name; the gate is the guardrail against sprawl, not holding back; only a record genuinely about no particular area goes untagged. - Plugin 0.1.26 -> 0.1.27. Co-Authored-By: Claude Fable 5 --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_static_context.md | 14 ++++--- plugin/skills/using-scribe/SKILL.md | 8 +++- src/scribe/mcp/tools/systems.py | 54 +++++++++++++++++++++++---- tests/test_mcp_tool_systems.py | 35 ++++++++++++++++- 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a76f342..8700a91 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.26", + "version": "0.1.27", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 1a3426e..c45d996 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -39,12 +39,16 @@ for the operator's work, and as your own working memory across sessions. unrelated open task. - **Tag to Systems as you write** — `enter_project` lists the project's Systems (its named subsystems/areas). When you create or meaningfully update - a record, ask which area it is about and pass `system_ids`; if the area has + a record, ask which areas it is about and pass `system_ids`; if an area has no System yet, create it with `create_system` (name + a one-paragraph - charter) rather than leaving it unmodelled. A record about no particular - area takes none — don't force it. Untagged writes in a project that has - Systems come back with a `systems_hint` naming them — treat it as the tagging - question asked at exactly the right moment, not as noise to skip past. + charter) rather than leaving it unmodelled. Cross-cutting records — audits, + sweeps, reviews — take SEVERAL tags, and are the best moment to DISCOVER + missing Systems: a pass that walks the subsystems has just enumerated the + vocabulary, so mint what it names. Create liberally; the duplicate gate on + `create_system` (and reviewing the existing list) is the guardrail against + sprawl, not restraint. Untagged writes come back with a `systems_hint` — + treat it as the tagging question asked at exactly the right moment, not as + noise to skip past. - **Reuse before rebuilding** — before writing a new helper/utility/component, search recorded **snippets** (reusable code recorded once for recall) and reuse the prior art instead of re-solving it; when you build something diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index fbad010..29f17a3 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -88,7 +88,13 @@ Two constraints on *how* that's achieved: `list_system_records` returns? If the area has no System yet, create one (`create_system`: name + a one-paragraph charter) — an area that plainly exists deserves naming the moment two records would share it; don't wait to - be asked. A record about no particular area takes none. + be asked. Cross-cutting records — audits, sweeps, reviews — take *several* + tags and are the prime discovery moment: a pass that walks the subsystems + has just enumerated the vocabulary, so mint the Systems it names as it + names them. Create liberally — `create_system` is duplicate-gated, and that + gate (plus reviewing the existing list) is the guardrail against sprawl, + not restraint. Only a record genuinely about no particular area goes + untagged. 8. **State updates in place; chronicles don't.** A dev-log records what *happened* — write it once, never rewrite it. A durable finding (how a diff --git a/src/scribe/mcp/tools/systems.py b/src/scribe/mcp/tools/systems.py index 7e82482..60ebb90 100644 --- a/src/scribe/mcp/tools/systems.py +++ b/src/scribe/mcp/tools/systems.py @@ -30,13 +30,24 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None: except Exception: return None if not systems: - return None + # Zero Systems is the one state nothing else nudges out of: the hint + # below needs a vocabulary to name, so without this branch the FIRST + # create_system depends entirely on prose that demonstrably doesn't + # fire at write time (#2562). + return ( + "Created untagged — this project has no Systems yet. If this " + "record is about a code subsystem/area, create_system it (name + " + "a one-paragraph charter) and tag the record. An audit or sweep " + "that names areas is exactly the moment to mint them; the " + "duplicate gate is what guards against sprawl, not holding back." + ) names = ", ".join(f"#{s.id} {s.name}" for s in systems) return ( f"Created untagged. This project's Systems: {names}. If this record is " - "about one of those areas, tag it (update it with system_ids=[...]); if " - "its area is missing, create_system it and tag; if it is about no " - "particular area, leave it untagged." + "about one or more of those areas, tag it (update it with " + "system_ids=[...]) — cross-cutting records like audits take several; " + "if an area it names is missing, create_system it and tag; only leave " + "it untagged if it is about no particular area." ) @@ -54,17 +65,46 @@ async def create_system( Create one the moment two records would share an area that has no System yet — the same two-or-more test snippets use. Don't wait to be asked to name an area that plainly exists in the code; an unmodelled area means - every record about it stays untaggable. Give it a one-paragraph charter, - not just a label: the description is what tells a later session whether a - record belongs here. + every record about it stays untaggable. An audit or sweep that walks the + codebase is a DISCOVERY moment: mint the Systems it names as it names + them — the duplicate gate below, plus reviewing the existing list, is what + guards against sprawl, not holding back. Give each one a one-paragraph + charter, not just a label: the description is what tells a later session + whether a record belongs here. Args: project_id: The project this system belongs to (required). name: Short label (required). description: What the system is and how it's used — a name is rarely enough. color: Optional UI accent (hex), or empty. + + Duplicate-gated like the other creates: if a System with the same + normalized name already exists in this project (archived included), the + call returns {"duplicate": true, "existing_id": ...} instead of creating — + tag records to that one, or update_system it if its charter needs work. """ uid = current_user_id() + norm = " ".join(name.split()).lower() + if norm: + try: + existing = await systems_svc.list_systems( + uid, project_id, include_archived=True + ) + except Exception: + existing = [] + for s in existing: + if " ".join(s.name.split()).lower() == norm: + return { + "duplicate": True, + "existing_id": s.id, + "message": ( + f"System '{s.name}' (#{s.id}) already covers this area " + "in this project. Tag records to it with system_ids, " + "or update_system it if the charter needs revising — " + "a second System with the same name would split the " + "area's records across two piles." + ), + } system = await systems_svc.create_system( uid, project_id=project_id, name=name, description=description or None, color=color or None, diff --git a/tests/test_mcp_tool_systems.py b/tests/test_mcp_tool_systems.py index 60e8436..3b338b9 100644 --- a/tests/test_mcp_tool_systems.py +++ b/tests/test_mcp_tool_systems.py @@ -89,17 +89,48 @@ async def test_untagged_hint_names_the_projects_systems(): @pytest.mark.asyncio -async def test_untagged_hint_absent_without_systems_and_fails_open(): +async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open(): from scribe.mcp.tools.systems import untagged_systems_hint with patch("scribe.mcp.tools.systems.systems_svc") as svc: + # Zero Systems is the state nothing else nudges out of — the hint must + # prompt the FIRST create_system, not go silent. svc.list_systems = AsyncMock(return_value=[]) - assert await untagged_systems_hint(1, 5) is None + hint = await untagged_systems_hint(1, 5) + assert "no Systems yet" in hint and "create_system" in hint with patch("scribe.mcp.tools.systems.systems_svc") as svc: # A hint must never break a create — DB failure degrades to no hint. svc.list_systems = AsyncMock(side_effect=RuntimeError("db down")) assert await untagged_systems_hint(1, 5) is None +@pytest.mark.asyncio +async def test_create_system_same_normalized_name_is_duplicate_gated(): + existing = _fake_system(sid=7, name="Scrape Pipeline") + existing.name = "Scrape Pipeline" + with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ + patch("scribe.mcp.tools.systems.systems_svc") as svc: + svc.list_systems = AsyncMock(return_value=[existing]) + svc.create_system = AsyncMock() + from scribe.mcp.tools.systems import create_system + result = await create_system(project_id=5, name=" scrape pipeline ") + assert result["duplicate"] is True + assert result["existing_id"] == 7 + svc.create_system.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_system_distinct_name_passes_the_gate(): + other = _fake_system(sid=7, name="Workers") + other.name = "Workers" + with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ + patch("scribe.mcp.tools.systems.systems_svc") as svc: + svc.list_systems = AsyncMock(return_value=[other]) + svc.create_system = AsyncMock(return_value=_fake_system(sid=8, name="Exporter")) + from scribe.mcp.tools.systems import create_system + result = await create_system(project_id=5, name="Exporter") + assert result["name"] == "Exporter" + + @pytest.mark.asyncio async def test_create_task_untagged_in_project_with_systems_carries_hint(): note = MagicMock(); note.id = 60; note.to_dict.return_value = {"id": 60}