fix(systems): sweeps are the discovery moment — zero-Systems hint, create_system dedup gate, prose inverted
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 15:35:59 -04:00
co-authored by Claude Fable 5
parent 3ff8803593
commit 56b1952de5
5 changed files with 97 additions and 16 deletions
+1 -1
View File
@@ -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": {
+9 -5
View File
@@ -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
+7 -1
View File
@@ -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
+47 -7
View File
@@ -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,
+33 -2
View File
@@ -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}