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/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/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); 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. +

+