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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user