CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 27s
The ranked rule arm became measurable in M333. The preload did not — and that is the surface whose value is actually in question. `list_always_on_rules`, the SessionStart block and every `rules_payload` caller handed rules over wholesale and emitted nothing, so the resident set's token cost was certain and its usefulness could not be tested even in principle. Bulk deliveries now record as AMBIENT, beside the ranked count and never inside pull-through. Folding them in would mean growing the always-on set depressed the arm's measured precision and trimming it flattered the arm, neither for any reason to do with the arm. `RANKED_SOURCES` inverts the note twin's `AMBIENT_SOURCES` deliberately: there is one ranked rule source and this change adds seven bulk ones, so naming the rare half makes a forgotten surface default to ambient — under-counting it — rather than padding the denominator with surfacings nobody chose. Two lookalike call sites are deliberately left silent, with a test to keep them that way: the write-path etag arm and `rules_etag_for` read the rules to build or compare a MARKER and show nobody anything. No migration — `event` and `source` are plain Text with no CHECK (rule 36 does not apply). Snippet #2858 updated to the new `rules_payload` contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
425 lines
18 KiB
Python
425 lines
18 KiB
Python
"""Project CRUD MCP tools — thin wrappers over services/projects.py.
|
|
|
|
Mirrors existing fable-mcp project tool contracts. Note: there is no
|
|
fable_delete_project here (matches existing fable-mcp surface). To stop
|
|
working on a project, update its status to 'archived'.
|
|
|
|
The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py
|
|
is intentionally NOT replicated here — Claude is the client, not a weak local
|
|
model that needs that guardrail. services.projects.create_project creates
|
|
directly with no similarity warning.
|
|
|
|
The auto-summary regeneration that services.projects.update_project triggers
|
|
async will be removed in Phase 7 (it's an LLM call). The wrapper makes no
|
|
assumption either way; once the service-layer side effect is gone, this code
|
|
keeps working.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.mcp.tools import systems as systems_tools
|
|
from scribe.services import coverage as coverage_svc
|
|
from scribe.services import design_systems as design_systems_svc
|
|
from scribe.services import inception as inception_svc
|
|
from scribe.services import milestones as milestones_svc
|
|
from scribe.services import notes as notes_svc
|
|
from scribe.services import projects as projects_svc
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from scribe.services import systems as systems_svc
|
|
from scribe.services import trash as trash_svc
|
|
from scribe.services.background import spawn
|
|
from scribe.services.note_usage import record_surfaced
|
|
|
|
|
|
async def list_projects() -> dict:
|
|
"""List all Scribe projects for the current user.
|
|
|
|
Returns id, title, description, goal, status (active/paused/completed/archived), color,
|
|
and a short auto-generated summary for each project.
|
|
"""
|
|
uid = current_user_id()
|
|
rows = await projects_svc.list_projects(uid)
|
|
return {"projects": [p.to_dict() for p in rows]}
|
|
|
|
|
|
async def enter_project(project_id: int) -> dict:
|
|
"""Session-start handshake: load full context for working on a project.
|
|
|
|
Call this FIRST whenever you're about to do project-scoped work
|
|
(start_planning, create_task, update_*, anything that takes a project_id).
|
|
One round-trip returns the project, its applicable rules (both rulebook-
|
|
subscribed and project-scoped), milestone progress, open tasks, and
|
|
recently-updated notes — everything you need to know the lay of the land
|
|
before mutating.
|
|
|
|
No persistent server state: this is a read snapshot. Re-call if the
|
|
session goes idle long enough that the data feels stale.
|
|
|
|
Args:
|
|
project_id: The project to enter.
|
|
|
|
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
|
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
|
open_tasks, recent_notes, design_system, systems, pattern_coverage —
|
|
plus systems_bootstrap, present only when it applies (see below).
|
|
|
|
`pattern_coverage` (usually null) is the shape-accounting line — how many
|
|
of the bound repo's extracted shapes carry a classification against canon
|
|
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
|
|
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
|
|
unclassified, largest: internal/api". Unclassified IS the todo: as you
|
|
touch code in those areas, classify the shapes you can (instances of
|
|
recorded canon, deliberate variants, one-off exemptions) and record the
|
|
canon that's missing with create_snippet. A null line on a forge-served
|
|
project usually means the ledger is seeding in the background (entering
|
|
triggers it); refresh_pattern_coverage computes it on the spot.
|
|
|
|
`systems` is the project's vocabulary of named subsystems/areas. It is
|
|
returned here so you can TAG as you write: when creating or meaningfully
|
|
updating a record, ask which of these areas it is about and pass their ids
|
|
as `system_ids`. If the area a record describes is missing from this list,
|
|
create it with create_system rather than leaving the area unmodelled. Read
|
|
a subsystem's accumulated records with list_system_records.
|
|
|
|
`inception` (milestone 297) appears ONLY when the project is yours and
|
|
nobody has decided what it inherits: it carries the current defaults
|
|
(which always-on rulebooks bind, design system, Systems), what to ask the
|
|
operator — once — and the decide_project_inception call that answers it;
|
|
it repeats on every enter until a decision is recorded.
|
|
|
|
`systems_bootstrap` appears ONLY when the project has many records and no
|
|
Systems at all — act on it before starting other work: create_system a
|
|
starter vocabulary from the areas the project's records name, directly
|
|
and without asking permission, preferring the standard names the ask
|
|
lists. It stops appearing the moment the first System exists.
|
|
|
|
`design_system` is null unless the project points at one. When present it
|
|
carries the chain-merged guidance (the house style AND this project's
|
|
departures from it) plus a summary of the token set — treat it as binding
|
|
for any UI you write, and pull the values with resolve_design_system or
|
|
get_design_system_stylesheet before reaching for a literal.
|
|
|
|
Entering a project also SCOPES the session: reference and offer work on
|
|
this project only, and pass its id to search / list_* so results stay
|
|
inside it. If something clearly belongs to a different project, say so and
|
|
ask before switching — never silently operate cross-project. The active
|
|
project does not stick on the server (each call is self-contained);
|
|
carrying its id forward is on you.
|
|
|
|
Don't wait to be told which project is in scope. When work clearly belongs
|
|
to a project but none is entered, look for a match yourself (list_projects
|
|
/ search on the repo or subject), propose it, and enter it once the
|
|
operator confirms; if nothing matches, offer to create one — confirming
|
|
name and goal first, never guessing a project into existence.
|
|
"""
|
|
uid = current_user_id()
|
|
project = await projects_svc.get_project(uid, project_id)
|
|
if project is None:
|
|
raise ValueError(f"project {project_id} not found")
|
|
|
|
applicable = await rulebooks_svc.get_applicable_rules(
|
|
project_id=project_id, user_id=uid,
|
|
)
|
|
milestone_summary = await milestones_svc.get_project_milestone_summary(
|
|
uid, project_id,
|
|
)
|
|
open_tasks, _ = await notes_svc.list_notes(
|
|
uid, is_task=True, project_id=project_id,
|
|
status=["todo", "in_progress"], sort="updated_at", limit=10,
|
|
)
|
|
recent_notes, _ = await notes_svc.list_notes(
|
|
uid, is_task=False, project_id=project_id,
|
|
sort="updated_at", limit=5,
|
|
)
|
|
# The tagging vocabulary. Surfaced HERE because an instruction to "tag
|
|
# records to Systems" is only executable if the list is in front of the
|
|
# agent when it writes — which it never was, and tagging stopped within
|
|
# three days of the feature landing (#2546's audit).
|
|
systems = await systems_svc.list_systems(uid, project_id)
|
|
|
|
# The arrival-moment half of the bootstrap ask (#2683): session start is
|
|
# when the agent has just read the project map and is not yet deep in a
|
|
# task — the one moment minting a starter vocabulary is cheap. The
|
|
# write-moment half rides untagged-record responses (attach_systems);
|
|
# both retire the instant the first System exists.
|
|
systems_bootstrap = None
|
|
if not systems:
|
|
systems_bootstrap = await systems_tools.bootstrap_systems_ask(
|
|
uid, project_id
|
|
)
|
|
|
|
# The inception ask (milestone 297): a project nobody has decided on
|
|
# inherits its defaults silently — always-on rulebooks, no design system,
|
|
# no Systems. Owner-only (deciding is the owner's), and only until a
|
|
# decision is recorded; the key is ABSENT otherwise (#2483).
|
|
inception_ask = None
|
|
if project.user_id == uid and not inception_svc.is_decided(project):
|
|
inception_ask = await inception_svc.inception_ask(uid, project_id)
|
|
|
|
# Probably the largest surfacing by volume, and it emitted nothing — so
|
|
# the pulls it caused floated unattributed and the surfaced:pulled ratio
|
|
# ran against a denominator missing its biggest contributor (#2477). An
|
|
# AMBIENT source: these are top-N-by-recency, not a ranked choice, and the
|
|
# readout counts them apart so dead-weight detection isn't poisoned by
|
|
# "recently updated in a project you opened".
|
|
record_surfaced(
|
|
user_id=uid,
|
|
note_ids=[int(t.id) for t in open_tasks] + [int(n.id) for n in recent_notes],
|
|
source="enter_project",
|
|
)
|
|
# A project need not have one, and most installs won't — null is ordinary
|
|
# here, not a missing prerequisite.
|
|
design_system = None
|
|
if project.design_system_id:
|
|
design_system = await design_systems_svc.design_context(
|
|
uid, project.design_system_id,
|
|
)
|
|
|
|
# Cache read ONLY — computing coverage moves a repo tarball and never
|
|
# belongs in this request path. Null is the ordinary state (no forge, or
|
|
# never computed); the line appears exactly when there is evidence. Read
|
|
# on the OWNER's id: bindings and the cache live with the project owner.
|
|
coverage = await coverage_svc.cached_coverage(
|
|
project.user_id or uid, project_id
|
|
)
|
|
# Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
|
|
# the BACKGROUND — entering is the moment the number is wanted, and the
|
|
# UI button must not be the only path. This enter stays fast; the next
|
|
# one carries the line. Forge-less projects exit the seed quietly.
|
|
spawn(
|
|
coverage_svc.refresh_if_stale(
|
|
project.user_id or uid, project_id, cached=coverage
|
|
),
|
|
site="enter_project.coverage_seed",
|
|
)
|
|
|
|
out = {
|
|
"project": project.to_dict(),
|
|
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
|
|
# Trimmed to what tagging needs. The full charter is get_system's job —
|
|
# this list rides along on every session start, so it stays lean.
|
|
"systems": [
|
|
{
|
|
"id": s.id, "name": s.name,
|
|
"description": (s.description or "").split("\n")[0][:200],
|
|
}
|
|
for s in systems
|
|
],
|
|
"design_system": design_system,
|
|
"milestone_summary": milestone_summary,
|
|
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
|
|
"open_tasks": [
|
|
{
|
|
"id": t.id, "title": t.title, "status": t.status,
|
|
"priority": t.priority, "task_kind": t.task_kind,
|
|
"milestone_id": t.milestone_id,
|
|
}
|
|
for t in open_tasks
|
|
],
|
|
"recent_notes": [
|
|
{
|
|
"id": n.id, "title": n.title,
|
|
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
|
}
|
|
for n in recent_notes
|
|
],
|
|
}
|
|
# Attached only when it applies — a key that usually says null trains
|
|
# readers to skip it (#2483), and this one exists to be acted on.
|
|
if systems_bootstrap:
|
|
out["systems_bootstrap"] = systems_bootstrap
|
|
if inception_ask:
|
|
out["inception"] = inception_ask
|
|
return out
|
|
|
|
|
|
async def get_project(project_id: int) -> dict:
|
|
"""Fetch a Scribe project by ID.
|
|
|
|
Returns full project fields, a milestone_summary list, and the
|
|
rulebook-applicable_rules / subscribed_rulebooks pair the assistant
|
|
should consult when working on this project.
|
|
"""
|
|
uid = current_user_id()
|
|
project = await projects_svc.get_project(uid, project_id)
|
|
if project is None:
|
|
raise ValueError(f"project {project_id} not found")
|
|
data = project.to_dict()
|
|
data["milestone_summary"] = await milestones_svc.get_project_milestone_summary(
|
|
uid, project_id,
|
|
)
|
|
applicable = await rulebooks_svc.get_applicable_rules(
|
|
project_id=project_id, user_id=uid,
|
|
)
|
|
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_project"))
|
|
return data
|
|
|
|
|
|
def _inception_choices(
|
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
|
) -> dict | None:
|
|
"""The tool args → an inception choices object, or None when no inception
|
|
arg was given at all (a bare create stays undecided and enter_project
|
|
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
|
system."""
|
|
if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
|
|
and not design_system_id and seed_systems is None):
|
|
return None
|
|
return {
|
|
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
|
|
"subscribe_rulebooks": list(subscribe_rulebooks or []),
|
|
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
|
"seed_systems": bool(seed_systems),
|
|
}
|
|
|
|
|
|
async def create_project(
|
|
title: str,
|
|
description: str = "",
|
|
goal: str = "",
|
|
status: str = "active",
|
|
color: str = "",
|
|
exclude_always_on_rulebooks: list[int] | None = None,
|
|
subscribe_rulebooks: list[int] | None = None,
|
|
design_system_id: int = 0,
|
|
seed_systems: bool | None = None,
|
|
) -> dict:
|
|
"""Create a new project in Scribe — and decide what it inherits.
|
|
|
|
A project's inheritance is a decision, not a default (milestone 297):
|
|
before calling, ask the operator the four inception questions and pass
|
|
the answers; a project created without any of them is UNDECIDED and
|
|
enter_project will ask until decide_project_inception records it.
|
|
Defaults if nobody decides: every always-on rulebook binds, nothing is
|
|
subscribed, no design system, no Systems.
|
|
|
|
Args:
|
|
title: Project name (required).
|
|
description: Short summary of what the project is.
|
|
goal: The desired outcome or definition of done for the project.
|
|
status: one of active (default), paused, completed, archived.
|
|
color: Optional hex colour for the project card (e.g. "#6366f1").
|
|
exclude_always_on_rulebooks: always-on rulebook ids this project does
|
|
NOT inherit ([] = inherit them all). list_rulebooks shows which are
|
|
always_on.
|
|
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
|
|
design_system_id: the design system this project's UI is built from
|
|
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
|
seed_systems: true mints the standard starter Systems (CI & Release,
|
|
Auth & Access, …) so records can be tagged from day one.
|
|
"""
|
|
uid = current_user_id()
|
|
project = await projects_svc.create_project(
|
|
uid,
|
|
title=title,
|
|
description=description,
|
|
goal=goal,
|
|
status=status,
|
|
color=color or None,
|
|
)
|
|
data = project.to_dict()
|
|
choices = _inception_choices(
|
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
|
)
|
|
if choices is not None:
|
|
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
|
data["inception"] = decided["inception"]
|
|
data["inception_effects"] = decided["effects"]
|
|
else:
|
|
data["inception_hint"] = (
|
|
"Undecided: this project inherits its defaults until "
|
|
"decide_project_inception records what it should inherit "
|
|
"(enter_project will ask)."
|
|
)
|
|
return data
|
|
|
|
|
|
async def decide_project_inception(
|
|
project_id: int,
|
|
exclude_always_on_rulebooks: list[int] | None = None,
|
|
subscribe_rulebooks: list[int] | None = None,
|
|
design_system_id: int = 0,
|
|
seed_systems: bool | None = None,
|
|
) -> dict:
|
|
"""Record what a project inherits — answer enter_project's `inception` ask,
|
|
or re-decide later (milestone 297).
|
|
|
|
Owner-only. Applies the effects through the ordinary tools' paths —
|
|
exclude_always_on_rulebook, subscribe_project_to_rulebook,
|
|
set_project_design_system, the standard Systems seed — and writes the
|
|
decision on the project last, so get_project/enter_project can say why
|
|
the project has the rules, design and Systems it has. Re-deciding is
|
|
additive for exclusions/subscriptions (use include_always_on_rulebook /
|
|
unsubscribe_project_from_rulebook to undo one), replaces the design
|
|
system, and never re-seeds Systems a project already has.
|
|
|
|
Args: as create_project's inception args. Passing nothing records an
|
|
inherit-all decision (every always-on rulebook binds, no subscriptions,
|
|
no design system, no seed) — a valid answer, stated.
|
|
"""
|
|
uid = current_user_id()
|
|
choices = _inception_choices(
|
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
|
) or {}
|
|
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
|
return {"project_id": project_id, **decided}
|
|
|
|
|
|
async def update_project(
|
|
project_id: int,
|
|
title: str = "",
|
|
description: str = "",
|
|
goal: str = "",
|
|
status: str = "",
|
|
color: str = "",
|
|
) -> dict:
|
|
"""Update an existing Scribe project. Only explicitly provided fields are changed.
|
|
|
|
Args:
|
|
project_id: ID of the project to update.
|
|
title: New title, or omit to leave unchanged.
|
|
description: New description, or omit to leave unchanged.
|
|
goal: New goal/definition-of-done, or omit to leave unchanged.
|
|
status: New status — one of active, paused, completed, archived.
|
|
color: New hex colour, or omit to leave unchanged.
|
|
"""
|
|
uid = current_user_id()
|
|
fields: dict = {}
|
|
if title:
|
|
fields["title"] = title
|
|
if description:
|
|
fields["description"] = description
|
|
if goal:
|
|
fields["goal"] = goal
|
|
if status:
|
|
fields["status"] = status
|
|
if color:
|
|
fields["color"] = color
|
|
project = await projects_svc.update_project(uid, project_id, **fields)
|
|
if project is None:
|
|
raise ValueError(f"project {project_id} not found")
|
|
return project.to_dict()
|
|
|
|
|
|
async def delete_project(project_id: int) -> dict:
|
|
"""Move a project to the trash (recoverable). Its milestones, tasks, and notes
|
|
go with it as one batch. Restore via restore(batch_id)."""
|
|
uid = current_user_id()
|
|
batch = await trash_svc.delete(uid, "project", project_id)
|
|
if batch is None:
|
|
raise ValueError(f"project {project_id} not found")
|
|
return {"deleted_batch_id": batch,
|
|
"message": f"Project {project_id} + its contents moved to trash. Restore with restore('{batch}')."}
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (
|
|
list_projects,
|
|
enter_project,
|
|
get_project,
|
|
create_project,
|
|
update_project,
|
|
delete_project, decide_project_inception,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|