refactor(systems): one seam — the Systems question rides every read and write of a record
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
Scribe issue #2570, from the operator's challenge: the invariant is "always be asking whether what you're touching is a System's territory and whether the work is filed there" — not a nudge in one corner. The prior shape failed it twice: the hint fired only on creates (with a special-cased zero-Systems branch), and get_task/get_note returned records WITHOUT their Systems, so the read-side reflex had nothing to fire on (same per-kind asymmetry as #2481). - attach_systems(): single helper used by get/create/update for tasks, notes, and snippets, plus add_task_log. Tagged records always show `systems`; an untagged project record carries the `systems_hint` question instead. Neither field attaches empty (#2483). Hint is owner-only; everything fail-open (#2109). - untagged_systems_hint unified to ONE question — the vocabulary listing varies, the question doesn't; the zero-Systems branch stops being special text. - Docstrings state the uniform contract; floor prose now names the read-side reflex (systems visible -> list_system_records the pile). - Plugin 0.1.27 -> 0.1.28. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,9 @@ async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
|
||||
async def get_note(note_id: int) -> dict:
|
||||
"""Fetch the full content of a single Scribe note by its ID.
|
||||
|
||||
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
|
||||
Returns id, title, body (markdown), tags, project_id, created_at,
|
||||
updated_at — plus `systems` (the areas this note is filed under) or, for
|
||||
an untagged project note, the `systems_hint` question.
|
||||
A note another user shared with you also carries `shared`, `owner` and
|
||||
`permission` — read it as their suggestion, not as settled practice you set.
|
||||
|
||||
@@ -112,6 +114,9 @@ async def get_note(note_id: int) -> dict:
|
||||
# like dead weight next to snippets that merely had a counter (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
|
||||
await _attach_supersession(uid, note_id, out)
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -150,10 +155,10 @@ async def create_note(
|
||||
|
||||
Returns the created note object including its assigned id, OR — when a
|
||||
near-duplicate is found and force is false — {"duplicate": true,
|
||||
"existing_id": ..., "message": ...} and nothing is created. Created
|
||||
untagged in a project that has Systems, the response carries a
|
||||
`systems_hint` naming them — answer it: tag the record, create the missing
|
||||
System, or deliberately leave it untagged.
|
||||
"existing_id": ..., "message": ...} and nothing is created. A tagged
|
||||
record shows its `systems`; created untagged in a project, the response
|
||||
carries the `systems_hint` question instead — answer it: tag the record,
|
||||
create the missing System, or deliberately leave it untagged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if not force:
|
||||
@@ -180,14 +185,7 @@ async def create_note(
|
||||
# not-found, and leave the note rather than silently rolling it back.
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
await _attach_supersession(uid, note.id, data)
|
||||
return data
|
||||
|
||||
@@ -236,10 +234,9 @@ async def update_note(
|
||||
except PermissionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
data = note.to_dict()
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
|
||||
]
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
|
||||
)
|
||||
await _attach_supersession(uid, note_id, data)
|
||||
return data
|
||||
|
||||
|
||||
@@ -133,9 +133,10 @@ async def create_snippet(
|
||||
and it really is the same reusable thing found in another place, prefer
|
||||
merge_snippets(existing_id, [new...]) — or record then merge — to unify them
|
||||
into ONE canonical record (which then carries every call site as a location),
|
||||
rather than forcing a second copy with force=true. Created untagged in a
|
||||
project that has Systems, the response carries a `systems_hint` naming
|
||||
them — answer it: tag, create the missing System, or deliberately skip.
|
||||
rather than forcing a second copy with force=true. A tagged record shows
|
||||
its `systems`; created untagged in a project, the response carries the
|
||||
`systems_hint` question instead — answer it: tag, create the missing
|
||||
System, or deliberately skip.
|
||||
|
||||
WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the
|
||||
same repo · path · symbol, or holding byte-identical code. Those are certain,
|
||||
@@ -174,14 +175,7 @@ async def create_snippet(
|
||||
if system_ids:
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -205,6 +199,9 @@ async def get_snippet(snippet_id: int) -> dict:
|
||||
# paths, and counting those would inflate exactly the number that is
|
||||
# supposed to mean "someone chose to look at this" (#2085).
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_snippet")
|
||||
await systems_tools.attach_systems(
|
||||
uid, note.user_id, data, note.id, note.project_id
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -397,11 +394,9 @@ async def update_snippet(
|
||||
await systems_svc.set_record_systems(note.user_id, snippet_id, system_ids)
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict()
|
||||
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
|
||||
]
|
||||
await systems_tools.attach_systems(
|
||||
uid, note.user_id, data, snippet_id, note.project_id
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -17,40 +17,68 @@ from scribe.services import systems as systems_svc
|
||||
|
||||
|
||||
async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
|
||||
"""Nudge text for a record created untagged in a project that has Systems.
|
||||
"""The Systems question, for an untagged project record.
|
||||
|
||||
Not a tool. create_task / create_note / create_snippet attach this to
|
||||
their responses so the tagging question arrives in-band at the exact write
|
||||
it applies to — instruction prose alone demonstrably doesn't fire at write
|
||||
time, while in-band behavior (the duplicate gate) does (#2562).
|
||||
Not a tool — attach_systems() rides this on tool responses so the question
|
||||
arrives in-band at the exact moment a record is touched. It is ONE
|
||||
question regardless of vocabulary state ("which area is this about, and is
|
||||
it modelled?"); only the vocabulary listing varies, because an empty
|
||||
vocabulary is not an exemption — it is the question at its most urgent
|
||||
(#2562, #2569). Instruction prose alone demonstrably doesn't fire at write
|
||||
time; in-band behavior (the duplicate gate) does.
|
||||
"""
|
||||
# Fail-open like the dedup gate: a hint must never break a create.
|
||||
# Fail-open like the dedup gate: a hint must never break the call.
|
||||
try:
|
||||
systems = await systems_svc.list_systems(user_id, project_id)
|
||||
except Exception:
|
||||
return None
|
||||
if not systems:
|
||||
# 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)
|
||||
if systems:
|
||||
vocab = "Existing Systems: " + ", ".join(
|
||||
f"#{s.id} {s.name}" for s in systems
|
||||
) + "."
|
||||
else:
|
||||
vocab = "This project has no Systems yet."
|
||||
return (
|
||||
f"Created untagged. This project's Systems: {names}. If this record is "
|
||||
"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."
|
||||
"This record is untagged — which area(s) of the project is it about? "
|
||||
f"{vocab} Tag it (system_ids=[...]) — cross-cutting records like "
|
||||
"audits take several; create_system any area it concerns that isn't "
|
||||
"modelled yet (a sweep that names areas is the moment to mint them, "
|
||||
"and the duplicate gate guards against sprawl). Leave it untagged "
|
||||
"only if it is genuinely about no particular area."
|
||||
)
|
||||
|
||||
|
||||
async def attach_systems(
|
||||
caller_id: int,
|
||||
owner_id: int,
|
||||
data: dict,
|
||||
note_id: int,
|
||||
project_id: int | None,
|
||||
) -> None:
|
||||
"""Attach a record's Systems to its payload — or the Systems question.
|
||||
|
||||
ONE seam for every tool that returns a project record, read or write: a
|
||||
tagged record shows its areas (the touching-a-System reflex needs the
|
||||
affiliation visible on read, not just settable on write), an untagged
|
||||
project record carries the question instead. Neither field is ever
|
||||
attached empty (same reasoning as notes._attach_supersession / #2483 — a
|
||||
field that always says nothing trains readers to skip fields). The hint
|
||||
goes only to the record's owner: tagging someone else's record in someone
|
||||
else's project is not the caller's call to make. Fail-open — decoration
|
||||
must never break the call it rides on.
|
||||
"""
|
||||
try:
|
||||
systems = await systems_svc.list_record_systems(owner_id, note_id)
|
||||
if systems:
|
||||
data["systems"] = [s.to_dict() for s in systems]
|
||||
elif project_id and caller_id == owner_id:
|
||||
hint = await untagged_systems_hint(owner_id, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def create_system(
|
||||
project_id: int,
|
||||
name: str,
|
||||
|
||||
@@ -67,7 +67,10 @@ async def get_task(task_id: int) -> dict:
|
||||
"""Fetch a single Scribe task by ID.
|
||||
|
||||
Returns id, title, body, status, priority, tags, project_id, milestone_id,
|
||||
parent_id, parent_title, due_date, created_at, updated_at. For legacy
|
||||
parent_id, parent_title, due_date, created_at, updated_at — plus `systems`
|
||||
(the areas this task is filed under; read a subsystem's whole pile with
|
||||
list_system_records) or, for an untagged project task, the `systems_hint`
|
||||
question. For legacy
|
||||
kind=plan tasks, the response also includes applicable_rules +
|
||||
subscribed_rulebooks from the task's project's rulebook subscriptions (new
|
||||
plans are milestones — use get_milestone for those).
|
||||
@@ -108,6 +111,9 @@ async def get_task(task_id: int) -> dict:
|
||||
# surfaced task counted as never-pulled because the tool that opens one didn't
|
||||
# say so, driving auto-inject's measured pull-through toward zero for its own
|
||||
# dominant kind. #1038 and #2085 are explicitly gated on that number (#2245).
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, note.id, note.project_id
|
||||
)
|
||||
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_task")
|
||||
return data
|
||||
|
||||
@@ -152,9 +158,10 @@ async def create_task(
|
||||
|
||||
Returns the created task, OR — when a near-duplicate is found and force is
|
||||
false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
|
||||
created). Created untagged in a project that has Systems, the response
|
||||
carries a `systems_hint` naming them — answer it: tag the record, create
|
||||
the missing System, or deliberately leave it untagged.
|
||||
created). A tagged record shows its `systems`; created untagged in a
|
||||
project, the response carries the `systems_hint` question instead —
|
||||
answer it: tag the record, create the missing System, or deliberately
|
||||
leave it untagged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if kind == "plan":
|
||||
@@ -187,14 +194,7 @@ async def create_task(
|
||||
if system_ids:
|
||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||
data = note.to_dict()
|
||||
if system_ids:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
|
||||
]
|
||||
elif project_id:
|
||||
hint = await systems_tools.untagged_systems_hint(uid, project_id)
|
||||
if hint:
|
||||
data["systems_hint"] = hint
|
||||
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
|
||||
return data
|
||||
|
||||
|
||||
@@ -258,10 +258,9 @@ async def update_task(
|
||||
if system_ids is not None:
|
||||
await systems_svc.set_record_systems(uid, task_id, system_ids)
|
||||
data = note.to_dict()
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)
|
||||
]
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -271,13 +270,31 @@ async def add_task_log(task_id: int, content: str) -> dict:
|
||||
Use this to record work sessions, decisions, or status updates over time
|
||||
without overwriting the task's main body. Each entry is stored separately
|
||||
and shown chronologically in the task view.
|
||||
|
||||
The response shows the task's `systems` — or, if the task is an untagged
|
||||
project record, the `systems_hint` question: logging work IS working in
|
||||
some area, so answer it (update_task with system_ids, or create_system
|
||||
the missing area) rather than logging past it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
log = await task_logs_svc.create_log(uid, task_id, content)
|
||||
return log.to_dict() if hasattr(log, "to_dict") else {
|
||||
data = log.to_dict() if hasattr(log, "to_dict") else {
|
||||
"id": log.id, "task_id": log.task_id, "content": log.content,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
# A work-log is work happening on the task NOW — the strongest moment to
|
||||
# ask which System's territory that work is in. Fail-open decoration.
|
||||
try:
|
||||
loaded = await notes_svc.get_note_for_user(uid, task_id)
|
||||
if loaded:
|
||||
task = loaded[0]
|
||||
await systems_tools.attach_systems(
|
||||
uid, getattr(task, "user_id", uid) or uid,
|
||||
data, task_id, task.project_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
async def start_planning(project_id: int, title: str) -> dict:
|
||||
|
||||
Reference in New Issue
Block a user