feat(plans): milestone-as-plan-container; retire kind=plan (T3)
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:
@@ -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")
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user