feat(inception): the doors — create_project/decide_project_inception take the decision, enter_project asks until decided, REST inception endpoints, _INSTRUCTIONS (#2882, milestone 297 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
- MCP create_project(..., exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id (0 unstated / -1 none / n), seed_systems): any inception arg → inception.decide(via="mcp") after the create; none → undecided with an inception_hint. New decide_project_inception(project_id, …) records or re-records; nothing given = an inherit-all decision, stated. - enter_project carries `inception` ONLY for the caller's own, undecided project: inception_ask() = the project's current defaults + what to ask the operator once + the exact call (the #2683 ask shape). Absent otherwise. - REST: POST /api/projects accepts `inception` (validated before the create); POST /api/projects/<id>/inception decides/re-decides; GET …/inception/defaults is the card's payload; GET project already carries inception via to_dict. - _INSTRUCTIONS: ORIENT names the ask; START a project names the questions — never create a project bare by default (product behaviour, P#119). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("/<int:project_id>/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("/<int:project_id>/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("/<int:project_id>", methods=["GET"])
|
||||
|
||||
@@ -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=<id | -1 for none>, seed_systems=<true|false>)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user