diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 149d12c..fee91c4 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -37,7 +37,12 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy. Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: - ORIENT: enter_project(id) at session start — rules, open tasks, recent - notes, Systems and design system in one call. + notes, Systems and design system in one call. If it returns `inception`, + the project's inheritance was never decided: raise that ask once, then + decide_project_inception. +- START a project: create_project takes what it inherits — always-on + rulebooks to exclude, rulebooks to subscribe, design system, seed + Systems. Ask the operator first; never create a project bare by default. - DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause -> fix), never a work-log line on an unrelated task. Log with add_task_log; keep status honest — in_progress on start, done on finish. diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index f74b3ed..b2620d6 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -20,6 +20,7 @@ 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 @@ -80,6 +81,12 @@ async def enter_project(project_id: int) -> dict: 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 @@ -141,6 +148,14 @@ async def enter_project(project_id: int) -> dict: 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 @@ -213,6 +228,8 @@ async def enter_project(project_id: int) -> dict: # 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 @@ -238,14 +255,43 @@ async def get_project(project_id: int) -> dict: 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. + """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). @@ -253,6 +299,14 @@ async def create_project( 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( @@ -263,7 +317,52 @@ async def create_project( status=status, color=color or None, ) - return project.to_dict() + 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( @@ -320,6 +419,6 @@ def register(mcp) -> None: get_project, create_project, update_project, - delete_project, + delete_project, decide_project_inception, ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/routes/projects.py b/src/scribe/routes/projects.py index 2a45fe4..fd9b954 100644 --- a/src/scribe/routes/projects.py +++ b/src/scribe/routes/projects.py @@ -5,6 +5,7 @@ from quart import Blueprint, g, jsonify, request from scribe.auth import login_required, get_current_user_id from scribe.routes.utils import not_found, parse_pagination +from scribe.services import inception as inception_svc from scribe.services.milestones import list_milestones from scribe.services.notes import list_notes from scribe.services.projects import ( @@ -66,6 +67,15 @@ async def create_project_route(): status = data.get("status", "active") if status not in ("active", "paused", "completed", "archived"): return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400 + # The inception decision rides the create (milestone 297): the UI's + # second step sends `inception: {choices}`; absent = undecided, and the + # project page shows the card until it is. Validated before the create + # so a bad decision never leaves a half-made project behind. + inception = data.get("inception") + if inception is not None: + error = inception_svc.validate_inception(inception) + if error: + return jsonify({"error": error}), 400 project = await create_project( uid, title=data["title"], @@ -74,7 +84,44 @@ async def create_project_route(): color=data.get("color"), status=status, ) - return jsonify(project.to_dict()), 201 + out = project.to_dict() + if inception is not None: + try: + decided = await inception_svc.decide(uid, project.id, choices=inception, via="ui") + except ValueError as exc: + return jsonify({"error": str(exc), "project": out}), 400 + out["inception"] = decided["inception"] + out["inception_effects"] = decided["effects"] + return jsonify(out), 201 + + +@projects_bp.route("//inception", methods=["POST"]) +@login_required +async def decide_inception_route(project_id: int): + """Record (or re-record) what a project inherits — milestone 297. + Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks, + design_system_id, seed_systems}; owner-only.""" + uid = get_current_user_id() + data = await request.get_json() or {} + choices = data.get("choices", data) + try: + decided = await inception_svc.decide(uid, project_id, choices=choices, via="ui") + except ValueError as exc: + msg = str(exc) + status = 404 if "not found" in msg else 400 + return jsonify({"error": msg}), status + return jsonify({"project_id": project_id, **decided}) + + +@projects_bp.route("//inception/defaults", methods=["GET"]) +@login_required +async def inception_defaults_route(project_id: int): + """What the project inherits if nobody decides — the card's payload.""" + uid = get_current_user_id() + try: + return jsonify(await inception_svc.current_defaults(uid, project_id)) + except ValueError: + return not_found("Project") @projects_bp.route("/", methods=["GET"]) diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py index 3a5e0af..65d18ee 100644 --- a/src/scribe/services/inception.py +++ b/src/scribe/services/inception.py @@ -237,3 +237,36 @@ async def decide( }, } + +async def inception_ask(user_id: int, project_id: int) -> dict: + """The enter_project ask for an undecided project (milestone 297) — the + sibling of the systems-bootstrap ask (#2683): the project's OWN current + defaults, what to ask the operator, and the exact call that answers it. + Fail-open: a hint must never break the call it rides on.""" + try: + defaults = await current_defaults(user_id, project_id) + except Exception: + return {} + always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none" + others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none" + designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none" + return { + "defaults": defaults, + "ask": ( + "This project has no inception decision: nobody has said what it " + f"inherits. Today, by default: always-on rulebooks binding it — {always}; " + f"rulebooks it could subscribe to — {others}; design system — " + f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} " + f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, " + "once: which always-on rulebooks to EXCLUDE here (default: none), which " + "rulebooks to subscribe, which design system (or none), and whether to seed " + "the standard starter Systems — then record the answers. This ask repeats on " + "every enter_project until a decision is recorded." + ), + "call": ( + f"decide_project_inception(project_id={project_id}, " + "exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], " + "design_system_id=, seed_systems=)" + ), + } + diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index a434ee4..5f7ef98 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -391,3 +391,81 @@ def test_enter_project_registered_in_register(): register(mcp) assert "enter_project" in mcp.names + + +# --- milestone 297: the inception doors --------------------------------------- + + +@pytest.mark.asyncio +async def test_create_project_without_inception_args_stays_undecided(): + p = fake_project(id=5, title="P", inception=None) + with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide: + out = await create_project(title="P") + decide.assert_not_awaited() + assert "inception_hint" in out and "inception_effects" not in out + + +@pytest.mark.asyncio +async def test_create_project_with_inception_args_decides_via_mcp(): + p = fake_project(id=5, title="P", inception=None) + decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}} + with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True) + kw = decide.await_args.kwargs + assert decide.await_args.args[1] == 5 and kw["via"] == "mcp" + assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [], + "design_system_id": None, "seed_systems": True} + assert out["inception"]["via"] == "mcp" and "inception_effects" in out + + +@pytest.mark.asyncio +async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing(): + from scribe.mcp.tools.projects import decide_project_inception + decided = {"inception": {"via": "mcp"}, "effects": {}} + with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + out = await decide_project_inception(project_id=5) + assert decide.await_args.kwargs["choices"] == {} + assert out["project_id"] == 5 and out["inception"]["via"] == "mcp" + + +@pytest.mark.asyncio +async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project(): + applicable = {"rules": [], "project_rules": [], "truncated": False, + "subscribed_rulebooks": [], "excluded_always_on": []} + ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"} + + async def run(project): + with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \ + patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \ + patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \ + patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \ + patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \ + patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \ + patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked: + return await enter_project(project_id=5), asked + + # Own + undecided → the ask rides along. + out, asked = await run(fake_project(id=5, title="P", user_id=7, inception=None)) + assert out["inception"] == ask and asked.await_count == 1 + # Decided → absent, and the ask is not even built. + out, asked = await run(fake_project(id=5, title="P", user_id=7, inception={"via": "legacy"})) + assert "inception" not in out and asked.await_count == 0 + # Someone else's (shared) project, undecided → not this caller's to decide. + out, asked = await run(fake_project(id=5, title="P", user_id=8, inception=None)) + assert "inception" not in out and asked.await_count == 0 + + +def test_inception_routes_and_tool_are_registered(): + from scribe.app import create_app + from scribe.mcp.server import build_mcp_server + rules = {r.rule for r in create_app().url_map.iter_rules()} + assert "/api/projects//inception" in rules + assert "/api/projects//inception/defaults" in rules + mcp = build_mcp_server() + assert mcp._tool_manager.get_tool("decide_project_inception") is not None + tool = mcp._tool_manager.get_tool("create_project") + for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"): + assert name in tool.parameters.get("properties", {}), name +