From ac01eee04054255dc27248bf41658fa672f57ee1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:26:48 -0400 Subject: [PATCH 1/8] fix(rules): planning reads list rules by id and title instead of restating them (#4081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start_planning on project 2 replied with 92,645 characters, 65k of them applicable_rules. Milestone 414 made a project's listing every global rule tagged to an area it works in (before, the rules of subscribed rulebooks, and project 2 subscribed to none), and the non-brief rules_payload sent each as a full rule_brief. get_milestone, get_project and get_task carried the same. Every rules_payload form now lists: id, title, the topic a global rule sits in, and `via` for a co_surfaces partner. get_rule reads one in full, and retrieval delivers them in full when work matches — the reasoning #4045 applied to the handshake. A test pins 81 full-length rules under 6k characters. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- src/scribe/services/rulebooks.py | 38 +++++++++++++++++---------- tests/test_milestone_summary_brief.py | 25 ++++++++++++++++++ 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py index 48eed7d..57b0849 100644 --- a/src/scribe/services/rulebooks.py +++ b/src/scribe/services/rulebooks.py @@ -1112,36 +1112,46 @@ def rules_payload( (`plugin_context`) — computes a marker and shows nobody anything, and counting it would put rules in the denominator that no agent ever saw. + EVERY FORM LISTS, NONE RESTATES. Rules reach a session in full by + retrieval, so these payloads say which constraints exist — id and title, + the topic a global rule sits in, `via` for a co_surfaces partner — and + get_rule reads one. Planning reads carried the full rule_brief until a + project's listing grew to every global rule tagged to its areas + (milestone 414) and start_planning replied with 92k characters (#4081), + the shape #4045 had just removed from the handshake. + `brief` is the session handshake's form (#4045): the project's own rules - as id and title, nothing else. Rules reach a session in full by - retrieval, so the handshake lists which of the project's constraints exist - rather than restating them; get_rule reads one. Only what is shown is - recorded as surfaced. + only. Only what is shown is recorded as surfaced. """ + project_rules = [_rule_line(r) for r in applicable.get("project_rules", [])] if brief: - project_rules = [ - {"id": r["id"], "title": r["title"]} - for r in applicable.get("project_rules", []) - ] record_rule_surfaced( user_id=user_id, rule_ids=[r["id"] for r in project_rules], source=source, ) return {"project_rules": project_rules} + rules = [_rule_line(r) for r in applicable.get("rules", [])] record_rule_surfaced( user_id=user_id, - rule_ids=( - [r["id"] for r in applicable.get("rules", [])] - + [r["id"] for r in applicable.get("project_rules", [])] - ), + rule_ids=[r["id"] for r in rules] + [r["id"] for r in project_rules], source=source, ) return { - "applicable_rules": applicable["rules"], + "applicable_rules": rules, "applicable_rules_truncated": applicable["truncated"], - "project_rules": applicable.get("project_rules", []), + "project_rules": project_rules, } +def _rule_line(brief: dict) -> dict: + """One rule as a listing names it: enough to recognise it and fetch it. + Keys a row does not carry are left out rather than sent empty (#2483).""" + line = {"id": brief["id"], "title": brief["title"]} + for key in ("topic_title", "via"): + if brief.get(key): + line[key] = brief[key] + return line + + # ── The staleness marker (milestone 323 step 5) ──────────────────────── # # WHAT THIS CAN AND CANNOT SEE. An etag catches a rule that MOVED after a diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index 3c5fcf9..63309bf 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -193,3 +193,28 @@ async def test_list_milestones_lists_every_milestone_without_plans(): out = await list_milestones(project_id=5) assert len(out["milestones"]) == 30 assert all("body" not in m for m in out["milestones"]) + + +def test_a_planning_read_lists_rules_without_restating_them(): + """#4081: a project's listing is every global rule tagged to its areas, so + the full rule_brief of each put start_planning at 92k characters. Planning + reads name the rules; get_rule reads one.""" + from scribe.services.rulebooks import rules_payload + + applicable = { + "rules": [{"id": i, "title": f"r{i}", "statement": PLAN, "when_to_apply": PLAN, + "topic_title": "git", "relations": [{"note": PLAN}]} for i in range(50)] + + [{"id": 900, "title": "partner", "statement": PLAN, "via": "co_surfaces"}], + "project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN} + for i in range(30)], + "truncated": True, + } + with patch("scribe.services.rulebooks.record_rule_surfaced") as surfaced: + out = rules_payload(applicable, user_id=7, source="start_planning") + + assert out["applicable_rules"][0] == {"id": 0, "title": "r0", "topic_title": "git"} + assert out["applicable_rules"][-1] == {"id": 900, "title": "partner", "via": "co_surfaces"} + assert out["project_rules"][0] == {"id": 100, "title": "pr0"} + assert out["applicable_rules_truncated"] is True + assert len(surfaced.call_args.kwargs["rule_ids"]) == 81 + assert len(json.dumps(out)) < 6_000, len(json.dumps(out)) From 184a3e026d3e1c085eaa95c8585c5f3289989237 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:28:05 -0400 Subject: [PATCH 2/8] feat(mcp): enter_project names active milestones with no steps as open work (#4076) A plan written as a milestone with a description and no steps was invisible to the session handshake: it lists the 5 most recently touched milestones (#4045), and touching is a step changing, so a step-less milestone can never qualify. FabledLibrarian's roadmap (nine such milestones) sat unseen while later plans were opened as new milestones beside the ones that already described them. enter_project adds `unplanned_milestones`: active milestones with no steps, in roadmap order, id/title/description, up to 10 with an omitted count, none repeated from the recent list, and absent when there are none. The docstring says what they are for: check them before starting a new milestone, and add steps to a match with create_records(milestone_id=...). Milestone 415 step 1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- src/scribe/mcp/tools/projects.py | 25 +++++++++++- src/scribe/services/milestones.py | 26 +++++++++++++ tests/test_milestone_summary_brief.py | 55 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 02d4384..5477670 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -48,6 +48,7 @@ async def list_projects() -> dict: # characters, past what a client accepts as a tool result (#4045). _HANDSHAKE_MILESTONES = 5 _HANDSHAKE_OPEN_TASKS = 10 +_HANDSHAKE_UNPLANNED = 10 async def enter_project(project_id: int) -> dict: @@ -67,7 +68,8 @@ async def enter_project(project_id: int) -> dict: Returns a dict with keys: project, milestone_summary, open_tasks, systems, design_system, project_rules, pattern_coverage — - plus milestone_summary_omitted, inception and systems_bootstrap, each + plus unplanned_milestones, milestone_summary_omitted, + unplanned_milestones_omitted, inception and systems_bootstrap, each present only when it applies (see below). `project` is id, title, status and the full goal. get_project has the @@ -79,6 +81,15 @@ async def enter_project(project_id: int) -> dict: get_milestone(id) reads a plan and its steps. `milestone_summary_omitted` says how many others exist; list_milestones lists them all. + `unplanned_milestones` is the active milestones that have NO steps yet, + in roadmap order (up to 10; `unplanned_milestones_omitted` counts the + rest) — id, title and description. They are open work: a plan somebody + wrote down and nobody has broken into steps. A milestone with no steps is + never touched, so the recent list above can never show one. Before + starting a new milestone for work, check whether one of these already + describes it; if so, add steps to it with create_records(milestone_id=…) + rather than opening a second plan for the same thing. + `open_tasks` is the 10 most recently touched todo / in-progress tasks, with or without a milestone. A work-log counts as touching its task. Each names its milestone. list_tasks has the rest. @@ -156,6 +167,11 @@ async def enter_project(project_id: int) -> dict: milestone_rows, limit=_HANDSHAKE_MILESTONES, ) milestone_titles = {m["id"]: m.get("title") for m in milestone_rows} + unplanned, unplanned_omitted = milestones_svc.unplanned_milestones( + milestone_rows, + exclude_ids={m["id"] for m in milestone_summary}, + limit=_HANDSHAKE_UNPLANNED, + ) open_tasks, _ = await notes_svc.list_notes( uid, is_task=True, project_id=project_id, status=["todo", "in_progress"], sort="touched", limit=_HANDSHAKE_OPEN_TASKS, @@ -256,6 +272,13 @@ async def enter_project(project_id: int) -> dict: f"{omitted} other milestone(s) not listed. " f"list_milestones({project_id}) lists every milestone." ) + if unplanned: + out["unplanned_milestones"] = unplanned + if unplanned_omitted: + out["unplanned_milestones_omitted"] = ( + f"{unplanned_omitted} more active milestone(s) with no steps. " + f"list_milestones({project_id}) lists every milestone." + ) if systems_bootstrap: out["systems_bootstrap"] = systems_bootstrap if inception_ask: diff --git a/src/scribe/services/milestones.py b/src/scribe/services/milestones.py index c9229b2..06ba93d 100644 --- a/src/scribe/services/milestones.py +++ b/src/scribe/services/milestones.py @@ -283,3 +283,29 @@ def brief_milestone_summary( )[:limit] brief = [{k: r[k] for k in _BRIEF_FIELDS if k in r} for r in kept] return brief, len(rows) - len(kept) + + +def unplanned_milestones( + rows: list[dict], *, exclude_ids: set[int] = frozenset(), limit: int | None = None, +) -> tuple[list[dict], int]: + """Active milestones with no steps yet, as (rows, omitted). + + A plan written as a milestone with a description and no steps is open work + that nothing else names. It is never "touched" — touching is a step + changing — so the recency list that brief_milestone_summary(limit=) builds + can never reach it, and progress reads 0% either way. A project whose + roadmap was written that way ended up with every later plan opened as a + new milestone beside the one that already described it (milestone 415). + + `exclude_ids` drops milestones a caller already listed. Kept in roadmap + order (order_index, then creation), the order they were written in. + Rows are id, title and description: what a reader needs to recognise the + plan, and not its body, which get_milestone reads. + """ + found = [ + {"id": r["id"], "title": r.get("title"), "description": r.get("description")} + for r in rows + if r.get("status") == "active" and not r.get("total") and r["id"] not in exclude_ids + ] + kept = found if limit is None else found[:limit] + return kept, len(found) - len(kept) diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index 63309bf..50fc698 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -14,6 +14,7 @@ import pytest from scribe.mcp.tools.milestones import list_milestones from scribe.mcp.tools.projects import enter_project, get_project from scribe.services.milestones import brief_milestone_summary +from scribe.services.milestones import unplanned_milestones as brief_unplanned from tests.helpers import fake_project @@ -218,3 +219,57 @@ def test_a_planning_read_lists_rules_without_restating_them(): assert out["applicable_rules_truncated"] is True assert len(surfaced.call_args.kwargs["rule_ids"]) == 81 assert len(json.dumps(out)) < 6_000, len(json.dumps(out)) + + +# ── Milestones with no steps are open work (milestone 415) ───────────────── + + +def _planless(mid: int, status: str = "active") -> dict: + """A roadmap milestone: a description and no steps, never touched since.""" + row = _milestone(mid, status, touched_day=1) + row.update(total=0, completed=0, pct=0.0, + status_counts={"todo": 0, "in_progress": 0, "done": 0, "cancelled": 0}) + return row + + +def test_unplanned_lists_active_milestones_with_no_steps_in_roadmap_order(): + rows = [_planless(3), _milestone(4, "active", 9), _planless(5, "done"), _planless(6)] + kept, omitted = brief_unplanned(rows) + assert [r["id"] for r in kept] == [3, 6] # not the one with steps, not the done one + assert kept[0] == {"id": 3, "title": "M3", "description": "what M3 is for"} + assert omitted == 0 + + +def test_unplanned_respects_exclusions_and_the_cap(): + rows = [_planless(i) for i in range(15)] + kept, omitted = brief_unplanned(rows, exclude_ids={0, 1}, limit=10) + assert [r["id"] for r in kept] == list(range(2, 12)) + assert omitted == 3 + + +@pytest.mark.asyncio +async def test_enter_project_names_a_roadmap_the_recent_list_cannot_reach(): + """The FabledLibrarian shape: plans written as step-less milestones sat + beside newer milestones that did their work, and the handshake — five most + recently touched — could never show them.""" + rows = _history(8) + [_planless(100), _planless(101), _planless(102, "done")] + out, _ = await _enter(*_enter_stubs(fake_project(id=5), rows, [])) + + assert [m["id"] for m in out["unplanned_milestones"]] == [100, 101] + listed = {m["id"] for m in out["milestone_summary"]} + assert not listed & {m["id"] for m in out["unplanned_milestones"]} + assert "unplanned_milestones_omitted" not in out + + +@pytest.mark.asyncio +async def test_no_unplanned_key_when_every_milestone_has_steps(): + out, _ = await _enter(*_enter_stubs(fake_project(id=5), _history(4), [])) + assert "unplanned_milestones" not in out + + +@pytest.mark.asyncio +async def test_a_long_roadmap_does_not_rebuild_the_payload(): + rows = _history(5) + [_planless(1000 + i) for i in range(40)] + out, _ = await _enter(*_enter_stubs(fake_project(id=5), rows, [])) + assert len(out["unplanned_milestones"]) == 10 + assert out["unplanned_milestones_omitted"].startswith("30 more") From e886bd2a87ffc0486c7a3d452812c1e4855ea5c9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:28:45 -0400 Subject: [PATCH 3/8] test(rules): rules_payload fixtures carry a title, as every rule_brief does (#4081) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- tests/test_rule_usage_wiring.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_rule_usage_wiring.py b/tests/test_rule_usage_wiring.py index 4233a0d..952b29b 100644 --- a/tests/test_rule_usage_wiring.py +++ b/tests/test_rule_usage_wiring.py @@ -327,8 +327,9 @@ def test_rules_payload_records_both_the_family_and_project_halves(): with patch.object(svc, "record_rule_surfaced", rec): svc.rules_payload( { - "rules": [{"id": 10}, {"id": 11}], - "project_rules": [{"id": 12}], + # rule_brief always carries a title; a listing names each rule. + "rules": [{"id": 10, "title": "a"}, {"id": 11, "title": "b"}], + "project_rules": [{"id": 12, "title": "c"}], "truncated": False, }, user_id=1, From 6d0dee48fa4b9a1cec35e7c0b606bdfad1692828 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:30:11 -0400 Subject: [PATCH 4/8] feat(board): the No Milestone group collapses, and every group's Done column starts folded (#4077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator, 2026-09-15: the unmilestoned group should collapse, "especially the done column as it currently consume a lot of vertical space for [work] that's already done." - The "No Milestone" group gets the milestones' chevron and collapse, keyed as 0 in the same Set (no milestone id is 0), and starts collapsed on first load when everything in it is finished — the rule finished milestones already follow. - Each group's Done column header becomes a button that folds its cards, with the count still showing and aria-expanded set. Folded by default: done work is the part of a board nobody is reading. - Not persisted, matching the milestone collapse beside it. Milestone 415 step 2. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- frontend/src/views/ProjectView.vue | 65 ++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index b98dd36..ae9bdaf 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -179,6 +179,25 @@ const milestoneGroups = computed((): MilestoneGroup[] => { return groups; }); +// The "No Milestone" group has no id, and collapsing it uses the same Set as +// the milestones: 0 stands for it, since no milestone id is ever 0. +const UNASSIGNED_KEY = 0; +function groupKey(group: MilestoneGroup): number { + return group.milestone?.id ?? UNASSIGNED_KEY; +} + +// A group's Done column starts COLLAPSED, to its header and count. Done work is +// the part of a board nobody is reading, and listed in full it pushed the open +// columns of every group below it off the screen (operator, 2026-09-15). Not +// persisted, matching the milestone collapse beside it: each load starts from +// the same defaults. +const expandedDone = ref>(new Set()); +function toggleDone(group: MilestoneGroup) { + const key = groupKey(group); + if (expandedDone.value.has(key)) expandedDone.value.delete(key); + else expandedDone.value.add(key); +} + function toggleMilestoneCollapse(id: number) { if (collapsedMilestones.value.has(id)) { collapsedMilestones.value.delete(id); @@ -386,6 +405,16 @@ async function loadTasks() { all.push(...next.notes); } tasks.value = all; + // The No Milestone group follows the milestone rule: once, on first load, + // it starts collapsed when everything in it is finished. + if (!autoCollapsedOnce.value.has(UNASSIGNED_KEY)) { + autoCollapsedOnce.value.add(UNASSIGNED_KEY); + const assigned = new Set(milestones.value.map((m) => m.id)); + const loose = all.filter((t) => !t.milestone_id || !assigned.has(t.milestone_id)); + if (loose.length && loose.every((t) => t.status === "done" || t.status === "cancelled")) { + collapsedMilestones.value.add(UNASSIGNED_KEY); + } + } } catch { // Say so. This used to swallow the error and leave an empty board, which is // indistinguishable from a project with no tasks — the same "hidden with no @@ -950,11 +979,12 @@ async function confirmDelete() {
- - + +
-
+
@@ -1094,12 +1124,19 @@ async function confirmDelete() {
-
+
-
+ + + +
brings with it. */ +.col-toggle { + width: 100%; + background: none; + border: none; + padding: 0; + font-family: inherit; + cursor: pointer; + text-align: left; +} +.col-toggle:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); border-radius: var(--fs-radius-sm); } .col-count { background: var(--fs-surface-raised); border: 1px solid var(--fs-border-color); From 3a501c2cac17f5f50597a788d1e1dd55d128a67d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:34:03 -0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(search):=20milestones=20are=20searchab?= =?UTF-8?q?le=20by=20meaning=20=E2=80=94=20"is=20there=20already=20a=20pla?= =?UTF-8?q?n=20for=20this=3F"=20(#4078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `search` covered notes, tasks and rules, and a milestone — the record a plan lives in — could not be found. A project whose roadmap was written as milestones had every later plan opened beside the one that already described it, because nothing could have told the session it existed. - milestone_embeddings (migration 0102): the third sibling of note_ and rule_embeddings, for note 3163's reason — the search is milestone-specific. The document is title — description, then description and the plan body, so a roadmap milestone with no description is still found by its design. - Written on create, on a title/description/body update, and for a plan made through start_planning / create_records, fire-and-forget with the parent-row claim (#3262); a startup backfill covers every existing milestone. Derived, so it joins _NOT_INCLUDED beside the other embeddings. - semantic_search_milestones: a project's milestones when the caller can read it (access.can_read_project), otherwise the caller's own; optional status. - search(content_type="milestone"): id, title, description, status, project and progress. Its own shape, and not part of "all", whose results are note-shaped. The docstring says what it is for: ask before start_planning. - Integration test on real Postgres: found in its project and not another, status narrows, an unreadable project returns nothing. Milestone 415 step 3. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy --- alembic/versions/0102_milestone_embeddings.py | 59 ++++++ src/scribe/app.py | 10 +- src/scribe/mcp/tools/search.py | 46 ++++- src/scribe/models/__init__.py | 2 +- src/scribe/models/embedding.py | 33 ++++ src/scribe/services/backup.py | 3 +- src/scribe/services/embeddings.py | 169 +++++++++++++++++- src/scribe/services/milestones.py | 27 +++ src/scribe/services/record_batch.py | 3 + tests/conftest.py | 5 +- tests/test_integration_milestone_search.py | 82 +++++++++ tests/test_mcp_tool_search.py | 31 ++++ 12 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/0102_milestone_embeddings.py create mode 100644 tests/test_integration_milestone_search.py diff --git a/alembic/versions/0102_milestone_embeddings.py b/alembic/versions/0102_milestone_embeddings.py new file mode 100644 index 0000000..61281aa --- /dev/null +++ b/alembic/versions/0102_milestone_embeddings.py @@ -0,0 +1,59 @@ +"""milestone_embeddings — a plan becomes findable by meaning (milestone 415) + +Revision ID: 0102 +Revises: 0101 +Create Date: 2026-09-15 + +`search` covered notes, tasks and rules, and a milestone — the record a plan +lives in — could not be found at all. So "is there already a plan for this?" +had no tool, and a project whose roadmap was written as milestones had every +later plan opened as a new milestone beside the one that already described it. + +The sibling of rule_embeddings (0089), for the reasons its model docstring and +note 3163 give. The vectors are DERIVED: nothing is backfilled here, the startup +backfill writes them. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0102" +down_revision = "0101" +branch_labels = None +depends_on = None + +# Matches note_embeddings and rule_embeddings — bge-small-en-v1.5, 384-dim. +_EMBEDDING_DIM = 384 + + +def upgrade() -> None: + op.create_table( + "milestone_embeddings", + sa.Column( + "milestone_id", sa.Integer(), + sa.ForeignKey("milestones.id", ondelete="CASCADE"), primary_key=True, + ), + sa.Column("chunk_index", sa.Integer(), primary_key=True), + sa.Column("chunk_text", sa.Text(), nullable=False), + sa.Column("chunker_version", sa.Integer(), nullable=False), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("now()"), + ), + ) + # Raw DDL for the vector column, as 0067 and 0089 do: the type comes from + # the pgvector extension, not SQLAlchemy's type system. + op.execute( + f"ALTER TABLE milestone_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL" + ) + op.execute( + """ + CREATE INDEX ix_milestone_embeddings_embedding_hnsw + ON milestone_embeddings + USING hnsw (embedding vector_cosine_ops) + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_milestone_embeddings_embedding_hnsw") + op.drop_table("milestone_embeddings") diff --git a/src/scribe/app.py b/src/scribe/app.py index e158003..668fcfe 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -161,7 +161,9 @@ def create_app() -> Quart: import asyncio from scribe.services.auth import start_auth_token_retention_loop - from scribe.services.embeddings import backfill_note_embeddings, backfill_rule_embeddings + from scribe.services.embeddings import ( + backfill_milestone_embeddings, backfill_note_embeddings, backfill_rule_embeddings, + ) from scribe.services.logging import start_log_retention_loop from scribe.services.notifications import start_notification_loop @@ -182,6 +184,12 @@ def create_app() -> Quart: await backfill_rule_embeddings() except Exception: logger.warning("Rule embedding backfill failed", exc_info=True) + # Milestones got vectors in milestone 415, so a plan written before + # it is findable only after this pass. + try: + await backfill_milestone_embeddings() + except Exception: + logger.warning("Milestone embedding backfill failed", exc_info=True) # Snippets written before migration 0070 have no `notes.data` mirror, # and the location reverse lookup queries that column — an unfilled # row would read as "no snippet here" rather than as a gap. Separate diff --git a/src/scribe/mcp/tools/search.py b/src/scribe/mcp/tools/search.py index 8a5b6d9..57c2a22 100644 --- a/src/scribe/mcp/tools/search.py +++ b/src/scribe/mcp/tools/search.py @@ -12,7 +12,8 @@ import time from scribe.mcp._context import current_user_id from scribe.services.access import owner_names_for from scribe.services.embeddings import ( - DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules, + DEFAULT_SIMILARITY_THRESHOLD, semantic_search_milestones, semantic_search_notes, + semantic_search_rules, ) from scribe.services import rulebooks as rulebooks_svc from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary @@ -67,6 +68,40 @@ async def _search_rules(uid: int, q: str, limit: int, project_id: int) -> dict: } +async def _search_milestones(uid: int, q: str, limit: int, project_id: int) -> dict: + """Milestones by meaning — "is there already a plan for this?" (milestone 415). + + Its own result shape, like rules: a milestone is a plan with progress, not + a note with a body. The plan itself is left out — get_milestone reads it — + because a search hit is for recognising a plan, and bodies run long. + Not part of content_type="all", whose results are note-shaped. + """ + raw = await semantic_search_milestones(uid, q, project_id=project_id or None, limit=limit) + progress: dict[int, dict] = {} + if raw: + from scribe.services import milestones as milestones_svc + + for pid in {m.project_id for _s, m in raw}: + for row in await milestones_svc.get_project_milestone_summary(uid, pid): + progress[row["id"]] = row + return { + "results": [ + { + "id": m.id, + "title": m.title, + "description": m.description or "", + "status": m.status, + "project_id": m.project_id, + "total": progress.get(m.id, {}).get("total", 0), + "completed": progress.get(m.id, {}).get("completed", 0), + "similarity": float(score), + } + for score, m in raw + ], + "total": len(raw), + } + + async def search( q: str, content_type: str = "all", @@ -93,7 +128,12 @@ async def search( tagging?". A hit carries the rule's `why` and `how_to_apply`, which the session-start payload does not. With a project_id, rules come back as the global rules plus that project's own; - with 0, every rule in the rulebook. + with 0, every rule in the rulebook. Or 'milestone' (PLANS): + reach for it before start_planning to ask whether a plan for + this work already exists — a match is where new steps go + (create_records(milestone_id=…)), not a reason to open a second + milestone. Hits carry title, description, status and progress; + get_milestone reads the plan. Not included in 'all'. limit: maximum number of results (1-50). project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID whenever a project is in scope (the one you entered with @@ -118,6 +158,8 @@ async def search( limit = max(1, min(limit, 50)) if content_type == "rule": return await _search_rules(uid, q, limit, project_id) + if content_type == "milestone": + return await _search_milestones(uid, q, limit, project_id) is_task = {"note": False, "task": True}.get(content_type) # None => any t0 = time.perf_counter() report: dict = {} diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index 8914670..f522011 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -25,7 +25,7 @@ from scribe.models.user import User # noqa: E402, F401 from scribe.models.app_log import AppLog # noqa: E402, F401 from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401 from scribe.models.invitation import InvitationToken # noqa: E402, F401 -from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401 +from scribe.models.embedding import MilestoneEmbedding, NoteEmbedding, RuleEmbedding # noqa: E402, F401 from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401 from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401 from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401 diff --git a/src/scribe/models/embedding.py b/src/scribe/models/embedding.py index 2cd510c..3ab02a2 100644 --- a/src/scribe/models/embedding.py +++ b/src/scribe/models/embedding.py @@ -95,3 +95,36 @@ class RuleEmbedding(Base): DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), ) + + +class MilestoneEmbedding(Base): + """One embedding vector per CHUNK of a milestone (milestone 415). + + The third sibling, for note 3163's reason: the row could be shared, the + search cannot. A milestone is scoped by its project, has no share of its + own, and is searched to answer one question — "is there already a plan for + this?" — which no note or rule search can answer, because a plan is not a + note. Before this, a roadmap written as milestones was invisible to recall, + and every later plan was opened as a new milestone beside the one that + already described it. + + The document is the title, the one-line description and the plan body, the + parts a reader uses to recognise a plan. Derived data: the startup backfill + regenerates it, which is also how a chunker-version bump is handled. + """ + + __tablename__ = "milestone_embeddings" + + milestone_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("milestones.id", ondelete="CASCADE"), + primary_key=True, + ) + chunk_index: Mapped[int] = mapped_column(Integer, primary_key=True) + embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False) + chunk_text: Mapped[str] = mapped_column(Text, nullable=False) + chunker_version: Mapped[int] = mapped_column(Integer, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + ) diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 92484f9..1ad21d5 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -115,7 +115,8 @@ _BACKED_UP = [ # like coverage while naming nothing the schema could confirm. _NOT_INCLUDED = [ "groups", "group_memberships", "project_shares", "note_shares", - "api_keys", "note_embeddings", "rule_embeddings", "app_logs", "notifications", + "api_keys", "note_embeddings", "rule_embeddings", "milestone_embeddings", + "app_logs", "notifications", "invitation_tokens", "password_reset_tokens", "user_profiles", "retrieval_logs", # Sensitive credentials, same reasoning as api_keys: a backup that carries diff --git a/src/scribe/services/embeddings.py b/src/scribe/services/embeddings.py index 6365dd6..000dfed 100644 --- a/src/scribe/services/embeddings.py +++ b/src/scribe/services/embeddings.py @@ -25,7 +25,8 @@ from scribe.models.embedding import NoteEmbedding, RuleEmbedding from scribe.models.note import Note from scribe.services.access import can_read_project, notes_visibility_clause -if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime +if TYPE_CHECKING: # resolves forward refs without importing at runtime + from scribe.models.milestone import Milestone from scribe.models.rulebook import Rule logger = logging.getLogger(__name__) @@ -975,3 +976,169 @@ async def backfill_rule_embeddings() -> None: logger.info("Rule embedding backfill: embedding %d rule(s)", len(stale)) for rule_id, title, statement, when_to_apply in stale: await upsert_rule_embedding(rule_id, title, statement, when_to_apply) + + +# ── Milestones (milestone 415) ────────────────────────────────────────── + +def milestone_document( + title: str | None, description: str | None, body: str | None, +) -> tuple[str | None, str | None]: + """The (title, body) a milestone is EMBEDDED as. + + Title and one-line description lead, the way a snippet's name and purpose + lead its document (note 2485): the question this search answers is "does + a plan for this already exist?", and a plan is recognised by what it is + FOR. The plan body follows, chunked, so a milestone whose description is + empty — most roadmap milestones written by hand — is still findable by the + words of its design. + """ + name = (title or "").strip() + purpose = (description or "").strip() + plan = (body or "").strip() + doc_title = f"{name} — {purpose}" if name and purpose else (name or purpose or None) + parts = [p for p in (purpose, plan) if p] + return doc_title, "\n\n".join(parts) or None + + +async def upsert_milestone_embedding( + milestone_id: int, title: str | None, description: str | None, body: str | None, +) -> None: + """Chunk, embed and persist a milestone's vectors. Safe to fire-and-forget. + + The rule twin's contract: one document definition shared by the write path + and the backfill, and an atomic per-milestone replacement guarded by the + parent-row claim (#3262), so a milestone deleted mid-refresh wins. + """ + from scribe.models.embedding import MilestoneEmbedding + from scribe.models.milestone import Milestone + + doc_title, doc_body = milestone_document(title, description, body) + chunks = chunk_document(doc_title, doc_body) + try: + if not chunks: + async with async_session() as session: + await session.execute( + delete(MilestoneEmbedding).where(MilestoneEmbedding.milestone_id == milestone_id) + ) + await session.commit() + return + except Exception: + logger.warning("Failed to clear embedding for milestone %d", milestone_id, exc_info=True) + return + + try: + vectors = await get_embeddings(chunks) + except Exception: + logger.debug("Skipping embedding for milestone %d — embedder unavailable", milestone_id) + return + + try: + async with async_session() as session: + if not await _claim_parent_row(session, Milestone.id, milestone_id, "milestone"): + return + await session.execute( + delete(MilestoneEmbedding).where(MilestoneEmbedding.milestone_id == milestone_id) + ) + for index, (chunk, vector) in enumerate(zip(chunks, vectors)): + session.add(MilestoneEmbedding( + milestone_id=milestone_id, chunk_index=index, embedding=vector, + chunk_text=chunk, chunker_version=CHUNKER_VERSION, + )) + await session.commit() + except Exception: + logger.warning("Failed to persist embedding for milestone %d", milestone_id, exc_info=True) + + +async def semantic_search_milestones( + user_id: int, + query: str, + *, + project_id: int | None = None, + status: str | None = None, + limit: int = 5, + threshold: float = _SIMILARITY_THRESHOLD, +) -> list[tuple[float, "Milestone"]]: + """Return up to *limit* (score, milestone) pairs most like *query*. + + Answers "is there already a plan for this?" — the question a session asks + before start_planning, and the one the planning gate asks for it. + + SCOPE. With `project_id`, that project's milestones, provided the caller + can read the project (access.can_read_project, rule 78) — a collaborator on + a shared project sees its plans. Without one, the milestones the caller + owns across their projects. `status` narrows to "active" or "done". + + Collapses to best-chunk-per-milestone, like the sibling searches. Returns + an empty list if the embedder is unavailable, the project is not readable, + or on any error: a recall aid must never break the call it serves. + """ + from scribe.models.embedding import MilestoneEmbedding + from scribe.models.milestone import Milestone + + if not query or not query.strip(): + return [] + try: + query_vec = await get_embedding(query) + except Exception: + logger.debug("Milestone search skipped — embedder unavailable") + return [] + + distance = MilestoneEmbedding.embedding.cosine_distance(query_vec) + try: + if project_id: + if not await can_read_project(user_id, project_id): + return [] + scope = Milestone.project_id == project_id + else: + scope = Milestone.user_id == user_id + async with async_session() as session: + rows = (await session.execute( + select(Milestone, distance.label("distance")) + .select_from(MilestoneEmbedding) + .join(Milestone, MilestoneEmbedding.milestone_id == Milestone.id) + .where( + scope, + Milestone.deleted_at.is_(None), + *([Milestone.status == status] if status else []), + ) + .order_by(distance) + .limit(limit * _CHUNK_OVERFETCH) + )).all() + except Exception: + logger.warning("Milestone semantic search failed", exc_info=True) + return [] + + best: dict[int, tuple[float, object]] = {} + for milestone, dist in rows: + score = 1.0 - float(dist) + if milestone.id not in best or score > best[milestone.id][0]: + best[milestone.id] = (score, milestone) + ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True) + return [pair for pair in ranked if pair[0] >= threshold][:limit] + + +async def backfill_milestone_embeddings() -> None: + """Embed milestones that have no current vectors. Runs at startup beside + the note and rule backfills; a CHUNKER_VERSION bump re-embeds.""" + from scribe.models.embedding import MilestoneEmbedding + from scribe.models.milestone import Milestone + + try: + async with async_session() as session: + current = select(MilestoneEmbedding.milestone_id).where( + MilestoneEmbedding.chunker_version == CHUNKER_VERSION + ) + stale = (await session.execute( + select(Milestone.id, Milestone.title, Milestone.description, Milestone.body) + .where(Milestone.deleted_at.is_(None), Milestone.id.notin_(current)) + )).all() + except Exception: + logger.warning("Milestone embedding backfill: failed to query milestones", exc_info=True) + return + + if not stale: + logger.info("Milestone embedding backfill: all milestones current at chunker v%d", CHUNKER_VERSION) + return + logger.info("Milestone embedding backfill: embedding %d milestone(s)", len(stale)) + for milestone_id, title, description, body in stale: + await upsert_milestone_embedding(milestone_id, title, description, body) diff --git a/src/scribe/services/milestones.py b/src/scribe/services/milestones.py index 06ba93d..0e99a8e 100644 --- a/src/scribe/services/milestones.py +++ b/src/scribe/services/milestones.py @@ -11,6 +11,30 @@ from scribe.models.note import Note logger = logging.getLogger(__name__) +def embed_milestone(milestone: Milestone) -> None: + """Refresh a milestone's vectors, fire-and-forget (milestone 415). + + At the service, so every path that writes a milestone gets it — the lesson + embed_note records (#2056): a record written through a door that forgot the + call stays out of search until a restart. Exceptions are swallowed because + a milestone that saved must not fail on its index refresh; no running loop + (a script, a unit test) is ordinary. A delete racing the refresh wins: the + upsert claims the milestone's row first (#3262). + """ + try: + import asyncio + + from scribe.services.embeddings import upsert_milestone_embedding + + asyncio.create_task(upsert_milestone_embedding( + milestone.id, milestone.title, milestone.description, milestone.body, + )) + except RuntimeError: + pass + except Exception: # noqa: BLE001 - never let indexing break a write + logger.exception("embedding refresh failed for milestone %s", milestone.id) + + async def create_milestone( user_id: int, project_id: int, @@ -33,6 +57,7 @@ async def create_milestone( session.add(milestone) await session.commit() await session.refresh(milestone) + embed_milestone(milestone) return milestone @@ -125,6 +150,8 @@ async def update_milestone(user_id: int, milestone_id: int, **fields: object) -> milestone.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(milestone) + if {"title", "description", "body"} & set(fields): + embed_milestone(milestone) return milestone diff --git a/src/scribe/services/record_batch.py b/src/scribe/services/record_batch.py index 1600836..0d650f8 100644 --- a/src/scribe/services/record_batch.py +++ b/src/scribe/services/record_batch.py @@ -28,6 +28,7 @@ from scribe.models import async_session from scribe.models.milestone import Milestone from scribe.models.note import Note from scribe.services import access as access_svc +from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import systems as systems_svc from scribe.services.record_refs import placeholder_keys, resolve_placeholders @@ -183,6 +184,8 @@ async def create_batch( # After the commit, as a single create does: embedding and System tags are # enrichment on records that now exist, and a failure in either must not # un-create them. + if new_ms is not None: + milestones_svc.embed_milestone(new_ms) for note, item in zip(notes, items): notes_svc.embed_note(note) if item.system_ids: diff --git a/tests/conftest.py b/tests/conftest.py index 66ade64..d562c8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -80,7 +80,10 @@ def _no_embedding(): """ from unittest.mock import MagicMock - with patch("scribe.services.notes.embed_note", MagicMock()): + # Milestones embed too since milestone 415; a plan created in a test would + # otherwise detach the same model-loading task. + with patch("scribe.services.notes.embed_note", MagicMock()), \ + patch("scribe.services.milestones.embed_milestone", MagicMock()): yield diff --git a/tests/test_integration_milestone_search.py b/tests/test_integration_milestone_search.py new file mode 100644 index 0000000..c03eb34 --- /dev/null +++ b/tests/test_integration_milestone_search.py @@ -0,0 +1,82 @@ +"""Real-Postgres tests for finding a plan by meaning (milestone 415, step 3). + +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 +tool. What a mock cannot show is the join scoping the vectors to a project and +to what the caller may read, so these seed real milestones with hand-made +vectors and stub only the embedder. +""" +import uuid +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio + +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.embeddings import CHUNKER_VERSION, semantic_search_milestones +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")] + +NEAR = [1.0] + [0.0] * (EMBEDDING_DIM - 1) +FAR = [0.0, 1.0] + [0.0] * (EMBEDDING_DIM - 2) + + +@pytest_asyncio.fixture +async def roadmap(): + tag = uuid.uuid4().hex[:8] + async with async_session() as s: + owner = await ensure_user(s, f"ms_search_owner_{tag}") + stranger = await ensure_user(s, f"ms_search_stranger_{tag}") + mine = Project(user_id=owner.id, title="Librarian") + other = Project(user_id=owner.id, title="Elsewhere") + s.add_all([mine, other]) + await s.flush() + m3 = Milestone(user_id=owner.id, project_id=mine.id, title="M3 — Metadata", + description="works, editions, providers, provenance", status="active") + done = Milestone(user_id=owner.id, project_id=mine.id, title="Covers", + description="cover art", status="done") + unrelated = Milestone(user_id=owner.id, project_id=mine.id, title="Android client", + description="native app", status="active") + foreign = Milestone(user_id=owner.id, project_id=other.id, title="Metadata elsewhere", + description="same words, other project", status="active") + s.add_all([m3, done, unrelated, foreign]) + await s.flush() + for ms, vec in ((m3, NEAR), (done, NEAR), (unrelated, FAR), (foreign, NEAR)): + s.add(MilestoneEmbedding(milestone_id=ms.id, chunk_index=0, embedding=vec, + chunk_text=ms.title, chunker_version=CHUNKER_VERSION)) + ids = {"owner": owner.id, "stranger": stranger.id, "mine": mine.id, + "m3": m3.id, "done": done.id, "unrelated": unrelated.id, "foreign": foreign.id} + await s.commit() + return ids + + +async def _found(user_id, **kw) -> list[int]: + with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=NEAR)): + hits = await semantic_search_milestones(user_id, "book metadata and providers", + threshold=0.5, limit=10, **kw) + return [m.id for _s, m in hits] + + +async def test_a_plan_is_found_in_its_project_and_not_in_another(roadmap): + found = await _found(roadmap["owner"], project_id=roadmap["mine"]) + assert set(found) == {roadmap["m3"], roadmap["done"]} + assert roadmap["foreign"] not in found and roadmap["unrelated"] not in found + + +async def test_status_narrows_to_open_plans(roadmap): + found = await _found(roadmap["owner"], project_id=roadmap["mine"], status="active") + assert found == [roadmap["m3"]] + + +async def test_without_a_project_it_searches_the_callers_own(roadmap): + found = await _found(roadmap["owner"]) + assert {roadmap["m3"], roadmap["done"], roadmap["foreign"]} <= set(found) + + +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"]) == [] diff --git a/tests/test_mcp_tool_search.py b/tests/test_mcp_tool_search.py index 39f023b..002db62 100644 --- a/tests/test_mcp_tool_search.py +++ b/tests/test_mcp_tool_search.py @@ -106,3 +106,34 @@ async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope): kwargs = found.await_args.kwargs assert {k: kwargs[k] for k in scope} == scope assert set(kwargs) & {"project_id", "everywhere"} == set(scope) + + +def test_a_milestone_is_embedded_by_what_it_is_for_then_its_plan(): + from scribe.services.embeddings import milestone_document + + assert milestone_document("M3", "metadata providers", "## Goal\nx") == ( + "M3 — metadata providers", "metadata providers\n\n## Goal\nx") + # A roadmap milestone written with no description is still findable by its plan. + assert milestone_document("M3", None, "the plan") == ("M3", "the plan") + assert milestone_document(None, None, None) == (None, None) + + +@pytest.mark.asyncio +async def test_milestone_search_is_its_own_shape_and_scopes_to_the_project(): + """milestone 415: 'is there already a plan for this?' has a tool.""" + from unittest.mock import MagicMock + + _user_id_ctx.set(7) + ms = MagicMock(id=339, title="M3 — Metadata", description="works, editions", + status="active", project_id=30) + found = AsyncMock(return_value=[(0.81, ms)]) + summary = AsyncMock(return_value=[{"id": 339, "total": 0, "completed": 0}]) + with patch("scribe.mcp.tools.search.semantic_search_milestones", found), \ + patch("scribe.services.milestones.get_project_milestone_summary", summary): + out = await search(q="book metadata", content_type="milestone", project_id=30) + assert found.await_args.kwargs["project_id"] == 30 + assert out["results"] == [{ + "id": 339, "title": "M3 — Metadata", "description": "works, editions", + "status": "active", "project_id": 30, "total": 0, "completed": 0, + "similarity": 0.81, + }] From 59407728e65588ba3f885338fe350b091236717b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 15 Sep 2026 13:43:18 -0400 Subject: [PATCH 6/8] 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. +

+