feat(planning): start_planning hands back the active plan that already covers the work (#4079)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 1m15s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 13:43:18 -04:00
co-authored by Claude Opus 5
parent 3a501c2cac
commit 59407728e6
11 changed files with 468 additions and 22 deletions
+45 -1
View File
@@ -1,4 +1,4 @@
"""Real-Postgres tests for finding a plan by meaning (milestone 415, step 3).
"""Real-Postgres tests for finding a plan by meaning (milestone 415, steps 3 and 4).
A project's roadmap written as milestones was invisible to recall: `search`
covered notes, tasks and rules, so "is there already a plan for this?" had no
@@ -16,6 +16,7 @@ from scribe.models import async_session
from scribe.models.embedding import EMBEDDING_DIM, MilestoneEmbedding
from scribe.models.milestone import Milestone
from scribe.models.project import Project
from scribe.services import dedup as dedup_svc
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_milestones
from tests.helpers import ensure_user
@@ -80,3 +81,46 @@ async def test_without_a_project_it_searches_the_callers_own(roadmap):
async def test_a_project_the_caller_cannot_read_returns_nothing(roadmap):
assert await _found(roadmap["stranger"], project_id=roadmap["mine"]) == []
assert await _found(roadmap["stranger"]) == []
# ── the plan gate (step 4): start_planning finds the plan before making another ──
LONG = "Resolve works and editions against the metadata providers. " * 5
async def _gate(roadmap, title, text="", project=None):
with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)):
return await dedup_svc.plan_gate(
roadmap["owner"], project or roadmap["mine"], title, text,
)
async def test_the_gate_returns_an_active_plan_with_the_same_title(roadmap):
out = await _gate(roadmap, " m3 — METADATA ")
assert out["duplicate"] is True and out["match"] == "title"
assert out["existing_milestone"]["id"] == roadmap["m3"]
assert f"create_records(milestone_id={roadmap['m3']}" in out["message"]
async def test_the_gate_finds_a_plan_by_meaning_and_never_a_done_one(roadmap):
"""NEAR matches m3 (active), `done` (done) and `foreign` (another project).
Only m3 is a plan this project is still working through."""
out = await _gate(roadmap, "Book metadata", LONG)
assert out["match"] == "semantic"
assert out["existing_id"] == roadmap["m3"]
async def test_a_done_plan_and_another_projects_plan_do_not_block(roadmap):
assert await _gate(roadmap, "Covers") is None
assert await _gate(roadmap, "Metadata elsewhere") is None
async with async_session() as s:
m3 = await s.get(Milestone, roadmap["m3"])
m3.status = "done"
await s.commit()
assert await _gate(roadmap, "Book metadata", LONG) is None
async def test_a_short_candidate_is_judged_by_title_alone(roadmap):
"""A title-only embedding sits in a tight neighbourhood and matches
anything nearby, so a bare title never takes the semantic arm."""
assert await _gate(roadmap, "Book metadata", "just a title") is None