From 59407728e65588ba3f885338fe350b091236717b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:43:18 -0400 Subject: [PATCH] feat(planning): start_planning hands back the active plan that already covers the work (#4079) Step 4 of milestone 415 "An existing plan is found before a new one is made". A session that could not see an existing plan made a second one beside it. start_planning and create_milestone now ask first: an ACTIVE milestone in the project with the same title, or one that reads as the same plan (title, design and steps against milestone embeddings), is returned with its progress and a pointer to create_records(milestone_id=...). Nothing is created; force=true bypasses. - dedup.find_matching_plan / plan_gate / plan_match_response; access-checked before either arm (rule 78), fail-open like the other gates. - Done milestones never block; the semantic arm needs 200+ chars of candidate. - kb_plan_match_threshold (default 0.90) is a setting, in the Settings view, and pinned against the Python default by test_settings_defaults_agree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- docs/features.md | 4 + frontend/src/views/SettingsView.vue | 31 ++++ src/scribe/mcp/tools/milestones.py | 14 ++ src/scribe/mcp/tools/tasks.py | 24 ++- src/scribe/services/dedup.py | 173 +++++++++++++++++++++ tests/test_integration_milestone_search.py | 46 +++++- tests/test_mcp_tool_create_records.py | 1 + tests/test_mcp_tool_milestones.py | 36 +++++ tests/test_mcp_tool_planning.py | 43 +++++ tests/test_services_dedup.py | 80 ++++++++++ tests/test_settings_defaults_agree.py | 38 +++-- 11 files changed, 468 insertions(+), 22 deletions(-) diff --git a/docs/features.md b/docs/features.md index 24a2238..892ede3 100644 --- a/docs/features.md +++ b/docs/features.md @@ -45,6 +45,10 @@ Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority - **Milestones** — Ordered stages within a project. A milestone is also the home of a **plan** — its body holds the design (Goal/Approach/Verification) and its child tasks are the steps. Completion percentage is shown on the project page. + Milestones are searchable by meaning (`search(content_type="milestone")`), and an + agent starting a plan (`start_planning`, `create_milestone`) is handed the active + milestone that already has its title or reads as the same plan, so steps are added + there rather than to a parallel plan. The match threshold is in Settings. - **Kanban view** — `/projects/:id` groups tasks by milestone in a column layout with status-advance buttons on the cards. diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index b77b88c..2647c35 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -104,6 +104,8 @@ const kbPromptRuleThreshold = ref("0.72"); const kbDupThresholdSnippet = ref("0.82"); const kbDupThresholdNote = ref("0.93"); const kbDupThresholdTask = ref("0.93"); +// PLAN_MATCH_DEFAULT_THRESHOLD in services/dedup.py. +const kbPlanMatchThreshold = ref("0.90"); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -155,6 +157,9 @@ async function saveKbInject() { const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82)); const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93)); const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93)); + // Same `|| default` guard: a floor of 0 would hand back an existing plan + // for every new one, and no plan could be started without force. + const planT = Math.min(1, Math.max(0, Number(kbPlanMatchThreshold.value) || 0.9)); // Same `|| default` reasoning: falling back to 0 would surface every // snippet in the corpus on every edit, which is the failure this knob fixes. const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); @@ -171,6 +176,7 @@ async function saveKbInject() { kbDupThresholdSnippet.value = String(dupSnip); kbDupThresholdNote.value = String(dupNote); kbDupThresholdTask.value = String(dupTask); + kbPlanMatchThreshold.value = String(planT); kbWritePathThreshold.value = String(wpT); kbRuleHintThreshold.value = String(rhT); kbToolRuleThreshold.value = String(trT); @@ -199,6 +205,7 @@ async function saveKbInject() { kb_duplicate_threshold_snippet: String(dupSnip), kb_duplicate_threshold_note: String(dupNote), kb_duplicate_threshold_task: String(dupTask), + kb_plan_match_threshold: String(planT), }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -662,6 +669,9 @@ onMounted(async () => { if (allSettings.kb_duplicate_threshold_task !== undefined) { kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task; } + if (allSettings.kb_plan_match_threshold !== undefined) { + kbPlanMatchThreshold.value = allSettings.kb_plan_match_threshold; + } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1623,6 +1633,27 @@ async function deleteUser(userId: number) { stays strict to keep the report pointed at work opened twice.

+ +
+ + +

+ How alike a new plan must be to an active milestone in the same project + before an agent is handed that milestone instead of creating a second + one. A plan with the same title always matches. Lower it if sessions + still open parallel plans for work that already has one; raise it if + they are sent to plans that are only related. +

+