Files
FabledScribe/docs/superpowers/plans/2026-04-08-knowledge-task-consolidation.md
T

22 KiB

Knowledge View Task Consolidation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Consolidate tasks into the Knowledge view as a fifth card type, deprecate /notes and /tasks list routes, and simplify navigation down to a single Knowledge hub.

Architecture: The backend knowledge service (services/knowledge.py) stops excluding tasks from queries and adds type=task filtering via the is_task property (Note.status IS NOT NULL). The knowledge route validation gains "task" as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.

Tech Stack: Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.


File Map

Action Path
Modify src/fabledassistant/services/knowledge.py
Modify src/fabledassistant/routes/knowledge.py
Modify frontend/src/views/KnowledgeView.vue
Modify frontend/src/router/index.ts
Modify frontend/src/components/AppHeader.vue
Modify frontend/src/App.vue
Delete frontend/src/views/NotesListView.vue
Delete frontend/src/views/TasksListView.vue

Task 1: Backend — Include tasks in knowledge queries

Files:

  • Modify: src/fabledassistant/services/knowledge.py
  • Modify: src/fabledassistant/routes/knowledge.py

Context: The knowledge service currently excludes tasks by filtering Note.status.is_(None). Every query function (query_knowledge, query_knowledge_ids, _semantic_knowledge_search, get_knowledge_tags, get_knowledge_counts) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding type=task as a filter option that maps to Note.status.isnot(None), (3) enriching _note_to_item with task-specific fields, (4) updating counts to include tasks.

  • Step 1: Add "task" to _VALID_TYPES in the route file

In src/fabledassistant/routes/knowledge.py, change:

_VALID_TYPES = {"note", "person", "place", "list"}

to:

_VALID_TYPES = {"note", "person", "place", "list", "task"}
  • Step 2: Update _note_to_item to include task fields

In src/fabledassistant/services/knowledge.py, the _note_to_item function builds the item dict. After the existing elif note.entity_type == "list": block (which ends around line 48), add a task branch. Find:

    elif note.entity_type == "list":
        # Parse markdown task list syntax into structured items
        body = note.body or ""
        list_items = []
        for line in body.split("\n"):
            stripped = line.strip()
            if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
                checked_item = not stripped.startswith("- [ ] ")
                list_items.append({"text": stripped[6:], "checked": checked_item})
        item["list_items"] = list_items
        item["item_count"] = len(list_items)
        item["checked_count"] = sum(1 for i in list_items if i["checked"])
        item["body"] = body
    return item

Replace with:

    elif note.entity_type == "list":
        # Parse markdown task list syntax into structured items
        body = note.body or ""
        list_items = []
        for line in body.split("\n"):
            stripped = line.strip()
            if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
                checked_item = not stripped.startswith("- [ ] ")
                list_items.append({"text": stripped[6:], "checked": checked_item})
        item["list_items"] = list_items
        item["item_count"] = len(list_items)
        item["checked_count"] = sum(1 for i in list_items if i["checked"])
        item["body"] = body

    # Task fields — included for all items but only meaningful when is_task
    if note.is_task:
        item["note_type"] = "task"
        item["status"] = note.status
        item["priority"] = note.priority
        item["due_date"] = note.due_date.isoformat() if note.due_date else None

    return item

This overrides note_type to "task" for task items (since entity_type returns the note_type column which is "note" for tasks) and adds status/priority/due_date fields.

  • Step 3: Update query_knowledge to include tasks

In the query_knowledge function, the "all types" filter currently excludes tasks. Change the base query and the else branch.

Find:

        base = (
            select(Note)
            .where(Note.user_id == user_id)
            .where(Note.status.is_(None))  # exclude tasks
        )

        if note_type:
            base = base.where(Note.note_type == note_type)
        else:
            # Exclude tasks — already done above; also exclude any legacy nulls
            base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))

Replace with:

        base = select(Note).where(Note.user_id == user_id)

        if note_type == "task":
            base = base.where(Note.status.isnot(None))
        elif note_type:
            base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
        else:
            # All types including tasks
            pass
  • Step 4: Update _semantic_knowledge_search to include tasks

Find:

        candidates = await semantic_search_notes(
            user_id=user_id,
            query=q,
            limit=min(200, limit * 8),
            threshold=0.3,
            is_task=False,
        )

Replace with:

        is_task_filter = True if note_type == "task" else (False if note_type else None)
        candidates = await semantic_search_notes(
            user_id=user_id,
            query=q,
            limit=min(200, limit * 8),
            threshold=0.3,
            is_task=is_task_filter,
        )

Also update the type matching in the filter loop — find:

    for _score, note in candidates:
        if note_type and note.entity_type != note_type:
            continue

Replace with:

    for _score, note in candidates:
        if note_type == "task" and not note.is_task:
            continue
        elif note_type and note_type != "task" and note.entity_type != note_type:
            continue
  • Step 5: Update query_knowledge_ids to include tasks

Find:

        base = (
            select(Note.id)
            .where(Note.user_id == user_id)
            .where(Note.status.is_(None))
        )
        if note_type:
            base = base.where(Note.note_type == note_type)
        else:
            base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))

Replace with:

        base = select(Note.id).where(Note.user_id == user_id)

        if note_type == "task":
            base = base.where(Note.status.isnot(None))
        elif note_type:
            base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
        else:
            pass
  • Step 6: Update get_knowledge_tags to include task tags

Find:

        base = (
            select(func.unnest(Note.tags).label("tag"))
            .where(Note.user_id == user_id)
            .where(Note.status.is_(None))
        )
        if note_type:
            base = base.where(Note.note_type == note_type)
        else:
            base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))

Replace with:

        base = (
            select(func.unnest(Note.tags).label("tag"))
            .where(Note.user_id == user_id)
        )
        if note_type == "task":
            base = base.where(Note.status.isnot(None))
        elif note_type:
            base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
        else:
            pass
  • Step 7: Update get_knowledge_counts to include tasks

Find:

    async with async_session() as session:
        stmt = (
            select(Note.note_type, func.count(Note.id))
            .where(Note.user_id == user_id)
            .where(Note.status.is_(None))
            .where(Note.note_type.in_(["note", "person", "place", "list"]))
            .group_by(Note.note_type)
        )
        if tags:
            for tag in tags:
                stmt = stmt.where(Note.tags.contains([tag]))
        rows = list((await session.execute(stmt)).all())
    counts = {row[0]: row[1] for row in rows}
    # Ensure all types present even if zero
    for t in ("note", "person", "place", "list"):
        counts.setdefault(t, 0)
    counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
    return counts

Replace with:

    async with async_session() as session:
        # Count non-task types
        stmt = (
            select(Note.note_type, func.count(Note.id))
            .where(Note.user_id == user_id)
            .where(Note.status.is_(None))
            .where(Note.note_type.in_(["note", "person", "place", "list"]))
            .group_by(Note.note_type)
        )
        if tags:
            for tag in tags:
                stmt = stmt.where(Note.tags.contains([tag]))
        rows = list((await session.execute(stmt)).all())
        counts = {row[0]: row[1] for row in rows}

        # Count tasks separately (is_task = status IS NOT NULL)
        task_stmt = (
            select(func.count(Note.id))
            .where(Note.user_id == user_id)
            .where(Note.status.isnot(None))
        )
        if tags:
            for tag in tags:
                task_stmt = task_stmt.where(Note.tags.contains([tag]))
        task_count: int = (await session.execute(task_stmt)).scalar_one()
        counts["task"] = task_count

    for t in ("note", "person", "place", "list", "task"):
        counts.setdefault(t, 0)
    counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
    return counts
  • Step 8: Verify backend changes
cd /path/to/fabledassistant
make typecheck
make test

Expected: no errors.

  • Step 9: Commit
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"

Task 2: Frontend — Task card rendering in KnowledgeView

Files:

  • Modify: frontend/src/views/KnowledgeView.vue

Context: KnowledgeView.vue has a KnowledgeItem interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The activeType ref controls filtering; it needs "task" as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.

  • Step 1: Add "task" to the KnowledgeItem interface and filter type

In the <script setup> section, find the KnowledgeItem interface and add task fields:

interface KnowledgeItem {
  id: number;
  note_type: "note" | "person" | "place" | "list";
  // ... existing fields

Change to:

interface KnowledgeItem {
  id: number;
  note_type: "note" | "person" | "place" | "list" | "task";
  // ... existing fields

Also add the task-specific fields at the end of the interface (before the closing }):

  // Task-specific
  status?: string;
  priority?: string;
  due_date?: string;

Update the activeType ref type:

const activeType = ref<"" | "note" | "person" | "place" | "list">(""); 

Change to:

const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
  • Step 2: Add "Tasks" to the type filter sidebar

Find the type filter v-for in the template:

          <button
            v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"

Replace with:

          <button
            v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"

Update the type cast on the click handler. Find:

            @click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"

Replace with:

            @click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
  • Step 3: Add task card content in the template

In the card grid, find the note snippet section:

              <!-- Note snippet -->
              <p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>

Add a task-specific section above it:

              <!-- Task specifics -->
              <div v-else-if="item.note_type === 'task'" class="k-card-task">
                <div class="task-badges">
                  <span class="status-badge" :class="`status--${item.status}`">
                    {{ item.status === 'in_progress' ? 'in progress' : item.status }}
                  </span>
                  <span
                    v-if="item.priority && item.priority !== 'none'"
                    class="priority-badge"
                    :class="`priority--${item.priority}`"
                  >{{ item.priority }}</span>
                </div>
                <span
                  v-if="item.due_date"
                  class="task-due"
                  :class="{ 'task-overdue': isOverdue(item) }"
                >{{ formatDate(item.due_date) }}</span>
                <p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
              </div>

              <!-- Note snippet -->
              <p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
  • Step 4: Add isOverdue helper and update openItem for tasks

In the <script setup>, add after the formatDate function:

function isOverdue(item: KnowledgeItem): boolean {
  if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
  return new Date(item.due_date) < new Date(new Date().toDateString());
}

Update openItem to route tasks to their editor:

function openItem(item: KnowledgeItem) {
  if (item.note_type === 'task') {
    router.push(`/tasks/${item.id}`);
  } else {
    router.push(`/notes/${item.id}`);
  }
}
  • Step 5: Update the "New note" button to toggle interaction

Find the current new-note button markup:

        <div class="new-note-wrap">
          <button class="btn-new-note" @click="createNew('note')">+ New note</button>
          <button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
          <div v-if="newNoteMenuOpen" class="new-note-menu">
            <button @click="createNew('note')">Note</button>
            <button @click="createNew('person')">Person</button>
            <button @click="createNew('place')">Place</button>
            <button @click="createNew('list')">List</button>
          </div>
        </div>

Replace with:

        <div class="new-note-wrap">
          <button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
          <div v-if="newNoteMenuOpen" class="new-note-menu">
            <button @click="createNew('task')">Task</button>
            <button @click="createNew('person')">Person</button>
            <button @click="createNew('place')">Place</button>
            <button @click="createNew('list')">List</button>
          </div>
        </div>

Add a click-outside handler. In the <script setup>, add after the createNew function:

function onClickOutsideNewNote(e: MouseEvent) {
  const wrap = document.querySelector('.new-note-wrap');
  if (wrap && !wrap.contains(e.target as Node)) {
    newNoteMenuOpen.value = false;
  }
}

In onMounted, add:

document.addEventListener('click', onClickOutsideNewNote);

In onUnmounted, add:

document.removeEventListener('click', onClickOutsideNewNote);
  • Step 6: Add task card CSS

Append to the <style scoped> block:

/* ── Task card ──────────────────────────────────────────── */
.k-card--task { border-left: 3px solid #a78bfa; }

.k-card-task {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.task-badges {
  display: flex;
  gap: 5px;
  flex-wrap: wrap;
}
.status-badge {
  font-size: 0.7rem;
  padding: 1px 7px;
  border-radius: 8px;
  font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }

.priority-badge {
  font-size: 0.7rem;
  padding: 1px 7px;
  border-radius: 8px;
  font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }

.task-due {
  font-size: 0.78rem;
  color: var(--color-text-muted);
}
.task-overdue {
  color: var(--color-overdue);
  font-weight: 500;
}

Also add the task type badge color. Find:

.badge--list   { background: rgba(56,189,248,0.15); color: #7dd3fc; }

Add after it:

.badge--task   { background: rgba(167,139,250,0.15); color: #a78bfa; }
  • Step 7: Remove the chevron button CSS

Find and delete:

.btn-new-chevron {
  padding: 7px 9px;
  border-radius: 0 8px 8px 0;
  border: 1px solid rgba(99, 102, 241, 0.4);
  background: rgba(99, 102, 241, 0.12);
  color: var(--color-primary, #818cf8);
  cursor: pointer;
  font-size: 0.78rem;
  line-height: 1;
  transition: background 0.15s, transform 0.15s;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }

Update .btn-new-note to have full border-radius now that the chevron is gone:

.btn-new-note {
  flex: 1;
  padding: 7px 10px;
  border-radius: 8px;
  border: 1px solid rgba(99, 102, 241, 0.4);
  background: rgba(99, 102, 241, 0.12);
  color: var(--color-primary, #818cf8);
  cursor: pointer;
  font-size: 0.85rem;
  font-weight: 500;
  text-align: left;
  transition: background 0.15s;
}
  • Step 8: Verify TypeScript compiles
cd /path/to/fabledassistant/frontend
npx tsc --noEmit

Expected: no new errors (pre-existing TipTap errors are fine).

  • Step 9: Commit
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"

Task 3: Route redirects, navigation cleanup, dead code removal

Files:

  • Modify: frontend/src/router/index.ts
  • Modify: frontend/src/components/AppHeader.vue
  • Modify: frontend/src/App.vue
  • Delete: frontend/src/views/NotesListView.vue
  • Delete: frontend/src/views/TasksListView.vue

Context: The router currently has /notes and /tasks pointing to list view components. These become redirects to /. The AppHeader has "Tasks" in both desktop and mobile nav. The g+t keyboard shortcut navigates to /tasks which should change to /. The stores (notes.ts, tasks.ts) are used by other views so they stay.

  • Step 1: Replace list view routes with redirects

In frontend/src/router/index.ts, find:

    {
      path: "/notes",
      name: "notes",
      component: () => import("@/views/NotesListView.vue"),
    },

Replace with:

    {
      path: "/notes",
      redirect: "/",
    },

Find:

    {
      path: "/tasks",
      name: "tasks",
      component: () => import("@/views/TasksListView.vue"),
    },

Replace with:

    {
      path: "/tasks",
      redirect: "/",
    },
  • Step 2: Remove "Tasks" from AppHeader navigation

In frontend/src/components/AppHeader.vue, find in the desktop nav-center:

        <router-link to="/tasks" class="nav-link">Tasks</router-link>

Delete this line.

Find in the mobile dropdown menu:

      <router-link to="/tasks" class="nav-link">Tasks</router-link>

Delete this line.

  • Step 3: Update g+t keyboard shortcut in App.vue

In frontend/src/App.vue, find in the onGlobalKeydown function, inside the if (pendingPrefix === "g") block:

      case "t": router.push("/tasks"); break;

Replace with:

      case "t": router.push("/"); break;
  • Step 4: Update shortcuts overlay text

In frontend/src/App.vue, find in the shortcuts overlay template:

                <kbd class="shortcut-key">t</kbd>
                <span class="shortcut-desc">Tasks</span>

Replace the description:

                <kbd class="shortcut-key">t</kbd>
                <span class="shortcut-desc">Knowledge (tasks)</span>
  • Step 5: Delete the deprecated list view files
rm frontend/src/views/NotesListView.vue
rm frontend/src/views/TasksListView.vue
  • Step 6: Verify build
cd /path/to/fabledassistant/frontend
npx tsc --noEmit

Expected: no new errors. The deleted files were only imported via lazy () => import(...) in the router, which we already replaced with redirects.

  • Step 7: Commit
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
  frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"