feat(plans): milestone-as-plan-container; retire kind=plan (T3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 59s

The milestone becomes the plan container: a new nullable milestones.body
holds the design/intent (Goal/Approach/Verification) and individual steps
live as first-class child tasks (milestone_id) instead of checkboxes crammed
into one kind=plan task body. start_planning now creates a MILESTONE seeded
with the body template (not a kind=plan task) and returns it with applicable
rules; a new get_milestone MCP tool reads the plan back (body + steps + rules).

kind=plan is hard-retired going forward — start_planning never creates one.
The 'plan' task_kind enum value stays valid so the 11 historical plan-tasks
remain readable in place; no body-shredding backfill (corpus review showed
auto-splitting their checklists into tasks would be lossy: embedded code
blocks, a non-binary [~] state, tables, ID-encoded hierarchy).

- migration 0066: add milestones.body
- model/service/route/MCP: body passthrough on create+update; get_milestone
- server _INSTRUCTIONS: "plan" = milestone w/ body + child step-tasks
- UI: ProjectView shows/edits a milestone's plan body; start_planning expands
  the new milestone and opens its plan editor
- tests updated to the milestone contract + new body/get_milestone coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 12:22:22 -04:00
parent c972af2690
commit 1f6c592226
13 changed files with 336 additions and 50 deletions
+32
View File
@@ -0,0 +1,32 @@
"""milestone-as-plan-container: milestones.body holds the plan/design
Revision ID: 0066
Revises: 0065
Create Date: 2026-06-14
T3 of plan #819. The milestone becomes the plan container: its `body` holds
the design/intent/purpose (markdown), `description` stays the one-liner, and
individual steps live as first-class child tasks (milestone_id) instead of
checkboxes crammed into a kind=plan task body. start_planning is reworked to
create a milestone instead of a kind=plan task (hard retirement going forward;
the 'plan' task_kind enum value stays valid so the historical plan-tasks are
left readable in place — no body-shredding backfill).
Schema change is just one nullable column; no data migration.
"""
from alembic import op
import sqlalchemy as sa
revision = "0066"
down_revision = "0065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("milestones", "body")
+11 -1
View File
@@ -5,8 +5,18 @@ export interface TaskListResponse {
total: number; total: number;
} }
// start_planning now creates a MILESTONE (the plan container), not a kind=plan
// task. The milestone's `body` holds the design; steps live as child tasks.
export interface StartPlanningResult { export interface StartPlanningResult {
task: import("./note").Note; milestone: {
id: number;
project_id: number;
title: string;
description: string | null;
body: string | null;
status: string;
order_index: number;
};
applicable_rules: { applicable_rules: {
id: number; id: number;
title: string; title: string;
+104 -1
View File
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks"; import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime"; import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue"; import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue"; import SystemsSection from "@/components/SystemsSection.vue";
@@ -22,6 +23,8 @@ import {
interface Milestone { interface Milestone {
id: number; id: number;
title: string; title: string;
description: string | null;
body: string | null;
status: string; status: string;
order_index: number; order_index: number;
pct: number; pct: number;
@@ -77,10 +80,15 @@ async function confirmStartPlanning() {
if (!title || !project.value) return; if (!title || !project.value) return;
planningBusy.value = true; planningBusy.value = true;
try { try {
// start_planning creates a MILESTONE (the plan container). Reload milestones,
// make sure the new one is expanded, and open its plan editor.
const result = await tasksStore.startPlanning(project.value.id, title); const result = await tasksStore.startPlanning(project.value.id, title);
planTitle.value = ""; planTitle.value = "";
showStartPlanning.value = false; showStartPlanning.value = false;
router.push(`/tasks/${result.task.id}`); await loadMilestones();
collapsedMilestones.value.delete(result.milestone.id);
startEditPlan(result.milestone.id, result.milestone.body);
toast.show("Plan started");
} finally { } finally {
planningBusy.value = false; planningBusy.value = false;
} }
@@ -225,6 +233,39 @@ async function commitRenameMilestone(ms: Milestone) {
} }
} }
// Plan body editing — a milestone IS the plan; its `body` holds the design.
const editingPlanId = ref<number | null>(null);
const editPlanBody = ref("");
const savingPlan = ref(false);
function startEditPlan(id: number, body: string | null) {
editingPlanId.value = id;
editPlanBody.value = body ?? "";
}
function cancelEditPlan() {
editingPlanId.value = null;
editPlanBody.value = "";
}
async function commitEditPlan(ms: Milestone) {
if (savingPlan.value) return;
savingPlan.value = true;
try {
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, {
body: editPlanBody.value,
});
await loadMilestones();
editingPlanId.value = null;
editPlanBody.value = "";
toast.show("Plan saved");
} catch {
toast.show("Failed to save plan", "error");
} finally {
savingPlan.value = false;
}
}
async function confirmDeleteMilestone() { async function confirmDeleteMilestone() {
const ms = deletingMilestone.value; const ms = deletingMilestone.value;
if (!ms) return; if (!ms) return;
@@ -545,6 +586,9 @@ async function confirmDelete() {
</div> </div>
<span class="ms-pct">{{ group.milestone.pct }}%</span> <span class="ms-pct">{{ group.milestone.pct }}%</span>
<div class="ms-actions" @click.stop> <div class="ms-actions" @click.stop>
<button class="ms-action-btn" :title="group.milestone.body ? 'Edit plan' : 'Add plan'" @click="startEditPlan(group.milestone.id, group.milestone.body)">
<FileText :size="16" />
</button>
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)"> <button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
<Pencil :size="16" /> <Pencil :size="16" />
</button> </button>
@@ -555,6 +599,31 @@ async function confirmDelete() {
</template> </template>
</div> </div>
<!-- Plan body: the milestone IS the plan; design lives here, steps are the tasks below. -->
<div
v-if="group.milestone && !collapsedMilestones.has(group.milestone.id) && (editingPlanId === group.milestone.id || group.milestone.body)"
class="ms-plan"
>
<template v-if="editingPlanId === group.milestone.id">
<textarea
v-model="editPlanBody"
class="ms-plan-editor"
rows="10"
placeholder="The plan: Goal / Approach / Verification. Track each step as a task below."
></textarea>
<div class="ms-plan-actions">
<button class="btn-secondary" @click="cancelEditPlan">Cancel</button>
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
</div>
</template>
<div
v-else
class="ms-plan-rendered markdown-body"
@click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')"
></div>
</div>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban"> <div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
<!-- Todo column --> <!-- Todo column -->
<div class="kanban-col col-todo"> <div class="kanban-col col-todo">
@@ -1082,6 +1151,40 @@ async function confirmDelete() {
.milestone-header.clickable { cursor: pointer; } .milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); } .milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
/* Plan body: the milestone's design/intent, shown above its task columns. */
.ms-plan {
padding: 0.6rem 0.85rem;
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
border-bottom: 1px solid var(--color-border);
}
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
.ms-plan-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
line-height: 1.5;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
.ms-plan-actions .btn-primary,
.ms-plan-actions .btn-secondary {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--color-border);
}
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; } .ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count { .ms-count {
+23 -13
View File
@@ -17,14 +17,21 @@ Hierarchy: Project -> Milestone -> Task/Note.
What each part is for, and when to reach for it: What each part is for, and when to reach for it:
- Project: the top-level container for a body of work. - Project: the top-level container for a body of work.
- Milestone: groups related tasks within a project toward a goal (status - Milestone: groups related tasks within a project toward a goal (status
active/done). Use one when a chunk of work needs its own arc. active/done). A milestone is ALSO the home of a plan — its `body` holds the
design/intent (Goal/Approach/Verification) and its child tasks are the steps.
Use one when a chunk of work needs its own arc.
- Task: a unit of actionable work with a lifecycle (status - Task: a unit of actionable work with a lifecycle (status
todo/in_progress/done/cancelled, optional priority). A task is a note with a todo/in_progress/done/cancelled, optional priority). A task is a note with a
status — reach for one when there is something to DO. Record progress over status — reach for one when there is something to DO. Record progress over
time with work-logs (add_task_log) rather than rewriting the body. time with work-logs (add_task_log) rather than rewriting the body.
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body - Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
holds the design + step checklist; work-logs record progress. Start one with work. The design/intent lives in the milestone `body`; each step is its own
start_planning when beginning non-trivial work, before you dive in. child task (create_task(milestone_id=...)), tracked with status + work-logs —
NOT a checkbox buried in the body. Start one with start_planning when
beginning non-trivial work, before you dive in; read it back with
get_milestone (body + steps). (The old kind=plan task is retired — some
historical plan-tasks still exist and remain readable, but don't create new
ones.)
- Note: durable free-form knowledge — reference material, decisions, logs of - Note: durable free-form knowledge — reference material, decisions, logs of
what happened. what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
@@ -147,14 +154,17 @@ adopting or creating — never do either silently, and never guess a project int
existence. Once a project is in scope, the enter_project handshake and the existence. Once a project is in scope, the enter_project handshake and the
host-memory pointer step above both apply. host-memory pointer step above both apply.
Plans are tasks with kind=plan, and Scribe is the canonical home for them. A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
When you begin non-trivial work, call start_planning(project_id, title) FIRST — non-trivial work, call start_planning(project_id, title) FIRST — before any
before any brainstorming, design, or plan-writing skill runs. start_planning brainstorming, design, or plan-writing skill runs. start_planning creates the
seeds the plan body, returns the project's applicable_rules, and gives you the milestone, seeds its `body` with the design template, returns the project's
task id you'll write into. If a habit tells you to save a plan or spec to a local applicable_rules, and gives you the milestone id you'll write into. Put the
`.md` file, that's superseded here: put the spec/plan content in the kind=plan design/intent in the milestone body via update_milestone(milestone_id, body=...);
task's body via update_task, and record progress with add_task_log. Local .md create each step as a child task with create_task(milestone_id=...) and track it
files are not the record — the task is. with status + add_task_log — do NOT list steps as checkboxes in the body. Read
the plan back with get_milestone (body + steps). If a habit tells you to save a
plan or spec to a local `.md` file, that's superseded here: the milestone is the
record, not a local file.
Deletes are recoverable: every delete_* tool moves the entity (and its Deletes are recoverable: every delete_* tool moves the entity (and its
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
@@ -180,7 +190,7 @@ operator. "Works for one user" is not done.
# until explicitly classified here. # until explicitly classified here.
_READ_ONLY_TOOLS = frozenset({ _READ_ONLY_TOOLS = frozenset({
"get_event", "get_note", "get_project", "get_rule", "get_rulebook", "get_event", "get_note", "get_project", "get_rule", "get_rulebook",
"get_task", "get_recent", "enter_project", "get_task", "get_milestone", "get_recent", "enter_project",
"list_events", "list_lists", "list_milestones", "list_notes", "list_events", "list_lists", "list_milestones", "list_notes",
"list_persons", "list_places", "list_projects", "list_rulebooks", "list_persons", "list_places", "list_projects", "list_rulebooks",
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash", "list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
+53 -4
View File
@@ -13,32 +13,75 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id from scribe.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc from scribe.services import trash as trash_svc
async def list_milestones(project_id: int) -> dict: async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index. """List milestones for a Scribe project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, Returns id, title, description, body (the plan/design), status
and task counts. (active/done), order_index, and task counts.
""" """
uid = current_user_id() uid = current_user_id()
rows = await milestones_svc.get_project_milestone_summary(uid, project_id) rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
return {"milestones": rows} return {"milestones": rows}
async def get_milestone(milestone_id: int) -> dict:
"""Fetch a milestone (the plan container) with its step-tasks and rules.
A milestone IS a plan: its `body` holds the design/intent, and its steps
are the child tasks listed here. Use this to read a plan top-to-bottom —
the body for the design, `steps` for the trackable units of work. Mirrors
the planning context that start_planning returns (applicable rules), so the
rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / subscribed_rulebooks.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
progress = await milestones_svc.get_milestone_progress(milestone_id)
steps, _ = await notes_svc.list_notes(
uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200,
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=milestone.project_id, user_id=uid,
)
out = milestone.to_dict()
out.update(progress)
return {
"milestone": out,
"steps": [t.to_dict() for t in steps],
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
}
async def create_milestone( async def create_milestone(
project_id: int, project_id: int,
title: str, title: str,
description: str = "", description: str = "",
body: str = "",
status: str = "active", status: str = "active",
) -> dict: ) -> dict:
"""Create a milestone within a Scribe project. """Create a milestone within a Scribe project.
A milestone can serve as a plan container — put the design/intent in `body`
and track each step as a child task (create_task(milestone_id=...)). For a
fresh plan, prefer start_planning, which seeds the body template + surfaces
the project's rules.
Args: Args:
project_id: The project this milestone belongs to (required). project_id: The project this milestone belongs to (required).
title: Milestone name (required). title: Milestone name (required).
description: Optional description of what this milestone covers. description: Optional one-line summary of what this milestone covers.
body: Optional plan/design (markdown) — the milestone's full plan text.
status: active (default) or done. status: active (default) or done.
""" """
uid = current_user_id() uid = current_user_id()
@@ -47,6 +90,7 @@ async def create_milestone(
project_id=project_id, project_id=project_id,
title=title, title=title,
description=description or None, description=description or None,
body=body or None,
status=status, status=status,
) )
return milestone.to_dict() return milestone.to_dict()
@@ -57,6 +101,7 @@ async def update_milestone(
milestone_id: int, milestone_id: int,
title: str = "", title: str = "",
description: str = "", description: str = "",
body: str = "",
status: str = "", status: str = "",
order_index: int = -1, order_index: int = -1,
) -> dict: ) -> dict:
@@ -67,7 +112,8 @@ async def update_milestone(
ownership scoping is enforced by user_id at the service layer). ownership scoping is enforced by user_id at the service layer).
milestone_id: ID of the milestone to update. milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged. title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged. description: New one-line summary, or omit to leave unchanged.
body: New plan/design (markdown), or omit to leave unchanged.
status: New status — active or done. status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged. order_index: New display position (0-based). Use -1 to leave unchanged.
""" """
@@ -77,6 +123,8 @@ async def update_milestone(
fields["title"] = title fields["title"] = title
if description: if description:
fields["description"] = description fields["description"] = description
if body:
fields["body"] = body
if status: if status:
fields["status"] = status fields["status"] = status
if order_index >= 0: if order_index >= 0:
@@ -101,6 +149,7 @@ async def delete_milestone(milestone_id: int) -> dict:
def register(mcp) -> None: def register(mcp) -> None:
for fn in ( for fn in (
list_milestones, list_milestones,
get_milestone,
create_milestone, create_milestone,
update_milestone, update_milestone,
delete_milestone, delete_milestone,
+15 -5
View File
@@ -233,14 +233,24 @@ async def add_task_log(task_id: int, content: str) -> dict:
async def start_planning(project_id: int, title: str) -> dict: async def start_planning(project_id: int, title: str) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file). """Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Creates a plan-task (a task with kind=plan) seeded with a plan template under Creates a MILESTONE that IS the plan: its `body` is seeded with a design
the given project, and returns it together with the project's applicable template (Goal/Approach/Verification) under the given project, and the call
Rulebook rules and brief context. Maintain the plan afterwards with the normal returns it together with the project's applicable Rulebook rules and brief
task tools (update_task to edit the body, add_task_log to record progress). context. The milestone is the plan container — the individual steps live as
first-class child tasks under it, not as checkboxes in the body.
Afterwards:
- Edit the plan/design with update_milestone(milestone_id, body=...).
- Create each step as its own task with create_task(milestone_id=<this id>);
track it with status + add_task_log. Do NOT put steps as checkboxes in the
milestone body.
(kind=plan tasks are retired — use this instead. Existing historical
plan-tasks remain readable but new planning goes through milestones.)
Args: Args:
project_id: The project this plan is for. project_id: The project this plan is for.
title: A short title for the plan. title: A short title for the plan/milestone.
""" """
uid = current_user_id() uid = current_user_id()
return await planning_svc.start_planning( return await planning_svc.start_planning(
+5
View File
@@ -13,6 +13,10 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE")) project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The plan: design/intent/purpose (markdown). The milestone is the plan
# container; its steps live as first-class child tasks (milestone_id), not
# as checkboxes in this body. `description` stays the one-line summary.
body: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active") status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0) order_index: Mapped[int] = mapped_column(Integer, default=0)
@@ -23,6 +27,7 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"project_id": self.project_id, "project_id": self.project_id,
"title": self.title, "title": self.title,
"description": self.description, "description": self.description,
"body": self.body,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
+2 -1
View File
@@ -62,6 +62,7 @@ async def create_milestone_route(project_id: int):
project_id, project_id,
title=data["title"], title=data["title"],
description=data.get("description"), description=data.get("description"),
body=data.get("body"),
order_index=data.get("order_index", 0), order_index=data.get("order_index", 0),
status=status, status=status,
) )
@@ -92,7 +93,7 @@ async def update_milestone_route(project_id: int, milestone_id: int):
if milestone is None: if milestone is None:
return not_found("Milestone") return not_found("Milestone")
data = await request.get_json() data = await request.get_json()
allowed = {"title", "description", "status", "order_index"} allowed = {"title", "description", "body", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed} fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "done"): if "status" in fields and fields["status"] not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400 return jsonify({"error": "status must be 'active' or 'done'"}), 400
+2
View File
@@ -16,6 +16,7 @@ async def create_milestone(
project_id: int, project_id: int,
title: str, title: str,
description: str | None = None, description: str | None = None,
body: str | None = None,
order_index: int = 0, order_index: int = 0,
status: str = "active", status: str = "active",
) -> Milestone: ) -> Milestone:
@@ -25,6 +26,7 @@ async def create_milestone(
project_id=project_id, project_id=project_id,
title=title, title=title,
description=description, description=description,
body=body,
status=status, status=status,
order_index=order_index, order_index=order_index,
) )
+18 -13
View File
@@ -1,31 +1,37 @@
"""Planning service — start_planning aggregates plan-task creation with the """Planning service — start_planning creates a MILESTONE seeded as the plan
project's applicable Rulebook rules and a little context, so planning happens container, surfacing the project's applicable Rulebook rules at the planning
in Scribe and rules surface at the planning moment. moment so rules land before any work.
The milestone IS the plan: its `body` holds the design/intent (Goal/Approach/
Verification), and the individual steps live as first-class child tasks
(milestone_id) rather than checkboxes crammed into one body. The legacy
kind=plan task is retired going forward — start_planning never creates one.
""" """
from __future__ import annotations from __future__ import annotations
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc from scribe.services import rulebooks as rulebooks_svc
# The plan body template — design only. Steps are NOT checkboxes here; each
# step becomes its own child task under this milestone (status, work-logs,
# priority of its own).
PLAN_TEMPLATE = """## Goal PLAN_TEMPLATE = """## Goal
## Approach ## Approach
## Steps
- [ ]
## Verification ## Verification
""" """
async def start_planning(user_id: int, project_id: int, title: str) -> dict: async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"""Create a plan-task seeded with the plan template and return it with the """Create a milestone seeded as a plan container and return it with the
project's applicable rules + brief context. project's applicable rules + brief context.
Returns: Returns:
{ {
"task": <task dict>, "milestone": <milestone dict>,
"applicable_rules": [...], "applicable_rules": [...],
"subscribed_rulebooks": [...], "subscribed_rulebooks": [...],
"applicable_rules_truncated": bool, "applicable_rules_truncated": bool,
@@ -37,13 +43,12 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
if project is None: if project is None:
raise ValueError(f"project {project_id} not found") raise ValueError(f"project {project_id} not found")
note = await notes_svc.create_note( milestone = await milestones_svc.create_milestone(
user_id, user_id,
project_id=project_id,
title=title, title=title,
body=PLAN_TEMPLATE, body=PLAN_TEMPLATE,
status="todo", status="active",
task_kind="plan",
project_id=project_id,
) )
applicable = await rulebooks_svc.get_applicable_rules( applicable = await rulebooks_svc.get_applicable_rules(
@@ -54,7 +59,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
) )
return { return {
"task": note.to_dict(), "milestone": milestone.to_dict(),
"applicable_rules": applicable["rules"], "applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"], "subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"], "applicable_rules_truncated": applicable["truncated"],
+59 -1
View File
@@ -5,7 +5,7 @@ import pytest
from scribe.mcp._context import _user_id_ctx from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.milestones import ( from scribe.mcp.tools.milestones import (
list_milestones, create_milestone, update_milestone, list_milestones, get_milestone, create_milestone, update_milestone,
) )
@@ -57,6 +57,64 @@ async def test_create_milestone_empty_description_becomes_none():
assert mock.call_args.kwargs["description"] is None assert mock.call_args.kwargs["description"] is None
@pytest.mark.asyncio
async def test_create_milestone_passes_body_through():
"""The milestone-as-plan body is forwarded to the service."""
m = _fake_ms(id=5)
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="## Goal\n\nship")
assert mock.call_args.kwargs["body"] == "## Goal\n\nship"
@pytest.mark.asyncio
async def test_create_milestone_empty_body_becomes_none():
m = _fake_ms()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="")
assert mock.call_args.kwargs["body"] is None
@pytest.mark.asyncio
async def test_update_milestone_sends_body():
m = _fake_ms()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, body="new plan")
assert mock.call_args.kwargs == {"body": "new plan"}
@pytest.mark.asyncio
async def test_get_milestone_returns_body_steps_and_rules():
m = _fake_ms(id=5, project_id=3, body="## Goal")
step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
AsyncMock(return_value={"total": 1, "completed": 0, "pct": 0.0})), \
patch("scribe.mcp.tools.milestones.notes_svc.list_notes",
AsyncMock(return_value=([step], 1))), \
patch("scribe.mcp.tools.milestones.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)):
out = await get_milestone(milestone_id=5)
assert out["milestone"]["body"] == "## Goal"
assert out["milestone"]["total"] == 1
assert out["steps"] == [{"id": 9, "title": "step 1", "status": "todo"}]
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
@pytest.mark.asyncio
async def test_get_milestone_raises_when_not_found():
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=None)):
with pytest.raises(ValueError, match="milestone 999 not found"):
await get_milestone(milestone_id=999)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_milestone_only_sends_non_default_fields(): async def test_update_milestone_only_sends_non_default_fields():
m = _fake_ms() m = _fake_ms()
+2 -2
View File
@@ -14,13 +14,13 @@ def _bind_user():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service(): async def test_start_planning_tool_delegates_to_service():
payload = {"task": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [], payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0} "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning", with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock: AsyncMock(return_value=payload)) as mock:
from scribe.mcp.tools.tasks import start_planning from scribe.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Plan it") out = await start_planning(project_id=3, title="Plan it")
assert out["task"]["id"] == 5 assert out["milestone"]["id"] == 5
assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"} assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"}
+10 -9
View File
@@ -4,17 +4,19 @@ import pytest
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_planning_creates_plan_task_and_returns_rules(): async def test_start_planning_creates_milestone_and_returns_rules():
fake_note = MagicMock() # start_planning now creates a MILESTONE (the plan container), not a
fake_note.to_dict.return_value = {"id": 5, "title": "Plan it", "task_kind": "plan"} # kind=plan task; its body holds the seeded design template.
fake_milestone = MagicMock()
fake_milestone.to_dict.return_value = {"id": 5, "title": "Plan it", "status": "active"}
applicable = { applicable = {
"rules": [{"id": 1, "title": "dev is home", "statement": "...", "rules": [{"id": 1, "title": "dev is home", "statement": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}], "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"truncated": False, "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}], "subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}],
} }
with patch("scribe.services.planning.notes_svc.create_note", with patch("scribe.services.planning.milestones_svc.create_milestone",
AsyncMock(return_value=fake_note)) as mock_create, \ AsyncMock(return_value=fake_milestone)) as mock_create, \
patch("scribe.services.planning.rulebooks_svc.get_applicable_rules", patch("scribe.services.planning.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)), \ AsyncMock(return_value=applicable)), \
patch("scribe.services.planning.notes_svc.list_notes", patch("scribe.services.planning.notes_svc.list_notes",
@@ -24,14 +26,13 @@ async def test_start_planning_creates_plan_task_and_returns_rules():
from scribe.services.planning import start_planning from scribe.services.planning import start_planning
out = await start_planning(user_id=7, project_id=3, title="Plan it") out = await start_planning(user_id=7, project_id=3, title="Plan it")
# Created a plan-task (status set => task, kind=plan) # Created a milestone with the seeded plan-body template.
kwargs = mock_create.call_args.kwargs kwargs = mock_create.call_args.kwargs
assert kwargs["task_kind"] == "plan"
assert kwargs["status"] == "todo"
assert kwargs["project_id"] == 3 assert kwargs["project_id"] == 3
assert kwargs["status"] == "active"
assert "## Goal" in kwargs["body"] # seeded template assert "## Goal" in kwargs["body"] # seeded template
# Returned shape # Returned shape
assert out["task"]["id"] == 5 assert out["milestone"]["id"] == 5
assert out["applicable_rules"][0]["title"] == "dev is home" assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}] assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}]
assert out["open_task_count"] == 3 assert out["open_task_count"] == 3