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
+11 -1
View File
@@ -5,8 +5,18 @@ export interface TaskListResponse {
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 {
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: {
id: number;
title: string;
+104 -1
View File
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
@@ -22,6 +23,8 @@ import {
interface Milestone {
id: number;
title: string;
description: string | null;
body: string | null;
status: string;
order_index: number;
pct: number;
@@ -77,10 +80,15 @@ async function confirmStartPlanning() {
if (!title || !project.value) return;
planningBusy.value = true;
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);
planTitle.value = "";
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 {
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() {
const ms = deletingMilestone.value;
if (!ms) return;
@@ -545,6 +586,9 @@ async function confirmDelete() {
</div>
<span class="ms-pct">{{ group.milestone.pct }}%</span>
<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)">
<Pencil :size="16" />
</button>
@@ -555,6 +599,31 @@ async function confirmDelete() {
</template>
</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">
<!-- Todo column -->
<div class="kanban-col col-todo">
@@ -1082,6 +1151,40 @@ async function confirmDelete() {
.milestone-header.clickable { cursor: pointer; }
.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-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {