Compare commits

...
23 Commits
Author SHA1 Message Date
bvandeusen 837489e4f2 Merge pull request 'CI: build on main (and drop the :main tag)' (#57) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 14s
2026-06-03 12:44:44 -04:00
bvandeusenandClaude Opus 4.8 9a0d5f3109 ci: drop the :main tag — main builds publish only the immutable :<sha>
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 14s
:latest (release-only) is the single production pointer; a :main moving
tag just duplicated it. main pushes still gate + build (the :<sha> image
is the rollback point), but no longer publish a :main alias. The tag was
new and unreferenced, so nothing depends on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:52:21 -04:00
bvandeusenandClaude Opus 4.8 5a930319ba ci: gate and build main too (:main image); :latest stays release-only
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 15s
Previously main pushes were deliberately skipped — CI only ran on dev
and v* tags. This conflicted with the intended policy (CI on dev AND
main). Now main is a first-class gated, built line: dev->:dev, main->:main,
v* tag->:latest + :<version>, every build also tagged with the commit sha.
Per-ref concurrency already supersedes rapid pushes, so dev and main run
independently without stacking identical work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:27:09 -04:00
bvandeusen 266af7870d Merge pull request 'MCP instruction hardening + milestone-unset' (#56) from dev into main 2026-06-03 11:19:09 -04:00
bvandeusenandClaude Opus 4.8 f446573c3d feat(mcp): proactive project bootstrapping at session start
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m6s
Adds an always-on _INSTRUCTIONS directive: when work touches Scribe and
no project is in scope, search for a related project and propose
enter_project (confirm first), or offer to create one (confirm name/goal
first) — never silently adopt or create. Pairs with the enter_project
handshake and the host-memory pointer directive. Closes scribe task #585.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:01:55 -04:00
bvandeusenandClaude Opus 4.8 82d6812c7f feat(mcp): milestone_id=-1 clears a task's milestone (update_task)
Optional FKs on update_task previously had no way to express 'remove' —
0 meant leave-unchanged and any positive int meant set, so a milestone
(or project) could only be cleared via the web UI. Now -1 clears the FK
(NULL); clearing project_id also clears milestone_id since a milestone
can't outlive its project. update_note already NULLs on None, so the
change is confined to the tool wrapper. Closes scribe task #586.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:59:47 -04:00
bvandeusenandClaude Opus 4.8 8c9ca45479 feat(mcp): instruct agents to keep a Scribe-rules pointer in host memory
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 1m6s
When a project subscribes to a rulebook, the agent should ensure the
host's persistent memory carries a pointer that engineering/workflow
rules live in Scribe (loaded via list_always_on_rules / enter_project),
plus a one-line note of the current project's work. Pairs with the
existing 'don't duplicate rules into memory' directive: memory holds the
pointer + project context, Scribe holds the rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:42:23 -04:00
bvandeusenandClaude Opus 4.8 e023c21aa1 docs(mcp): instruct agents to drive task lifecycle + log work
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m48s
Adds a 'keep task state honest' directive to the MCP _INSTRUCTIONS: set
in_progress on start, log progress with add_task_log as you go, set done the
moment work completes (never leave finished work at todo), and write a dated
dev-log note on the project at significant landings. Reinforced in the
update_task status docstring. App-layer + always-loaded, no rule/config needed
— closes the gap where finished work (e.g. a shipped plan) sat open because the
lifecycle was available but never prescribed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:24:26 -04:00
bvandeusen e3d7007417 Merge pull request 'Drift-audit remediation + Stored Processes + Dashboard' (#55) from dev into main 2026-06-03 08:11:14 -04:00
bvandeusenandClaude Opus 4.8 65c85bab15 feat(dashboard): DashboardView landing + route + nav
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Task 3 of #583. New DashboardView at /dashboard composes the approved layout:
done-recently strip, Active-now project panels (project -> active milestones ->
open tasks, in-progress flagged, + no-milestone group), and a rail with
upcoming events / week stats / quick-create (Task/Note/Process). '/' now
redirects to /dashboard; AppHeader gains a Dashboard link and relabels
Knowledge -> Browse (route unchanged). Empty + loading states included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:24:32 -04:00
bvandeusenandClaude Opus 4.8 7ef7d10b24 feat(dashboard): GET /api/dashboard endpoint
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m17s
Task 2 of #583. Minimal login-gated blueprint returning build_dashboard(uid);
registered in the app factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:22:08 -04:00
bvandeusenandClaude Opus 4.8 6a3619555d feat(dashboard): aggregation service build_dashboard
Task 1 of #583. build_dashboard(user_id) assembles the /dashboard payload:
most-recently-active projects (ranked by max child updated_at) each broken
into active milestones -> open tasks (in_progress->priority->recency, capped 5),
recently-completed (7d/8), upcoming events (7d), week stats. Owner-scoped,
trashed excluded; each section isolated via _safe so one failure doesn't blank
the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:21:23 -04:00
bvandeusen 0e980ee4b0 Merge pull request 'Project rule + topic suppressions' (#54) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 22s
2026-06-01 08:01:59 -04:00
bvandeusen b5870d4694 Merge pull request 'Rules consolidation: Scribe-first check, project-scoped rules, enter_project handshake' (#53) from dev into main 2026-06-01 01:16:55 -04:00
bvandeusen c810d63bee Merge pull request 'MCP plan-prompt tune + Flutter docs removal' (#52) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 24s
2026-06-01 00:13:31 -04:00
bvandeusen a3a056d6fd v26.05.30.1 — MCP-first pivot + Rulebook + Plans + Soft-delete 2026-05-29 22:50:34 -04:00
bvandeusen 2414437061 Merge pull request 'feat: closeout + tool-use fixes + task-as-record + version pinning' (#50) from dev into main 2026-05-13 23:27:33 +00:00
bvandeusen 76dc75a03b Merge pull request 'feat: journal closeout for profile observations + LLM tool-use fixes' (#49) from dev into main 2026-05-13 01:31:19 +00:00
bvandeusen c8765959ea Merge pull request 'Release v26.04.29.4 — recurrence UI + journal weather wiring fixes' (#48) from dev into main 2026-04-30 02:08:19 +00:00
bvandeusen f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen b81c4aa600 Release v26.04.29.2 — Anti-placeholder rule on create_event 2026-04-29 16:10:53 +00:00
bvandeusen 404698521f Release v26.04.29.1 — Date durability + journal voice tuning 2026-04-29 14:37:45 +00:00
bvandeusen 88b351a96e Merge pull request 'Release v26.04.28.1 — Design system polish, journal config in Profile, doc cleanup' (#44) from dev into main 2026-04-28 13:12:54 +00:00
12 changed files with 612 additions and 33 deletions
+28 -21
View File
@@ -1,12 +1,19 @@
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :<sha> (no moving tag)
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a production image. Re-running CI on the merge commit just burns
# runner time without changing the outcome.
# Both dev and main are gated AND built. dev pushes move the :dev tag; main
# pushes publish only the immutable :<sha> image — no :main tag, because
# :latest (release-only) is the single production pointer and a :main alias
# would just duplicate it. Running CI on the main merge commit is intentional:
# main is validated and its :<sha> image is the rollback point. The v* release
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
#
# Successive pushes to the SAME ref supersede each other (see concurrency
# below), so rapid pushes don't stack identical work; dev and main runs are
# independent refs and never cancel one another.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -16,11 +23,8 @@
# gating on branch push is already enough.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main
# still trigger the workflow, producing redundant runs on the same SHA
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
# honor `on.push.branches` as a filter, so every job repeats the ref check
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
@@ -29,7 +33,7 @@ name: CI & Build
on:
push:
branches: [dev]
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
@@ -67,8 +71,8 @@ env:
jobs:
typecheck:
name: TypeScript typecheck
# Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
# Gate dev, main, and v* tags; reject any other ref (see header note).
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -92,7 +96,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -106,7 +110,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -138,11 +142,9 @@ jobs:
build:
name: Build & push image
needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only.
# Mirrors the ref guard on the gate jobs above — main merge-commit
# pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
# Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -168,6 +170,11 @@ jobs:
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# main publishes only the immutable :<sha> image (set above) —
# no :main tag; :latest (release-only) is the production pointer.
BUILD_VERSION="main"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
+4 -2
View File
@@ -45,7 +45,8 @@ router.afterEach(() => {
<!-- Center: primary navigation (desktop) -->
<div class="nav-center">
<div class="nav-pill-bar">
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
@@ -90,7 +91,8 @@ router.afterEach(() => {
<!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
+8 -3
View File
@@ -5,10 +5,15 @@ const router = createRouter({
history: createWebHistory(),
routes: [
{
// Knowledge is the landing page in the MCP-first architecture
// (chat / journal / workspace surfaces have been removed).
// The dashboard ("what to work on") is the landing page; Knowledge
// remains as the exhaustive "Browse" surface.
path: "/",
redirect: "/knowledge",
redirect: "/dashboard",
},
{
path: "/dashboard",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
},
{
path: "/knowledge",
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
import { relativeTime } from "@/composables/useRelativeTime";
interface TaskRow { id: number; title: string; status: string; priority: string }
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
interface ActiveProject {
id: number; title: string; color: string | null; last_activity: string;
open_count: number; progress_pct: number;
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
}
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
interface DashboardData {
active_projects: ActiveProject[];
recently_completed: DoneItem[];
upcoming_events: UpcomingEvent[];
week_stats: WeekStats;
}
const data = ref<DashboardData | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
data.value = await apiGet<DashboardData>("/api/dashboard");
} catch {
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
} finally {
loading.value = false;
}
});
function fmtEvent(e: UpcomingEvent): string {
if (!e.start_dt) return "";
const d = new Date(e.start_dt);
const day = d.toLocaleDateString(undefined, { weekday: "short" });
if (e.all_day) return day;
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
}
</script>
<template>
<div class="dash-root">
<header class="dash-head">
<h1>Dashboard</h1>
<p class="dash-sub">What to work on, across your most-active projects.</p>
</header>
<div v-if="loading" class="dash-empty">Loading</div>
<template v-else-if="data">
<!-- Done recently -->
<section v-if="data.recently_completed.length" class="done-strip">
<span class="dash-label"> Done recently</span>
<router-link
v-for="d in data.recently_completed"
:key="d.id"
:to="`/tasks/${d.id}`"
class="done-chip"
>
{{ d.title }}
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
</router-link>
</section>
<div class="dash-cols">
<!-- Active now -->
<main class="dash-main">
<div class="dash-label">Active now</div>
<div v-if="!data.active_projects.length" class="dash-empty card">
No active projects yet <router-link to="/projects">create one</router-link>.
</div>
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
<header class="proj-head">
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
</header>
<div class="bar"><div class="bar-fill" :style="{ width: p.progress_pct + '%' }" /></div>
<div v-for="m in p.milestones" :key="m.id" class="ms-block">
<div class="ms-head">
<span class="ms-title">{{ m.title }}</span>
<span class="ms-pct">{{ m.progress_pct }}%</span>
</div>
<router-link
v-for="t in m.open_tasks"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<div v-if="p.no_milestone.length" class="ms-block">
<div class="ms-head"><span class="ms-title ms-none">No milestone</span></div>
<router-link
v-for="t in p.no_milestone"
:key="t.id"
:to="`/tasks/${t.id}`"
class="task-row"
:class="{ 'task-inprogress': t.status === 'in_progress' }"
>
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="task-title">{{ t.title }}</span>
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
</router-link>
</div>
<router-link :to="`/projects/${p.id}`" class="proj-more">+ more in {{ p.title }} </router-link>
</article>
</main>
<!-- Right rail -->
<aside class="dash-rail">
<div class="dash-label">Upcoming · 7 days</div>
<div class="rail-card">
<template v-if="data.upcoming_events.length">
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
<span class="evt-when">{{ fmtEvent(e) }}</span>
<span class="evt-title">{{ e.title }}</span>
</div>
</template>
<p v-else class="rail-empty">Nothing scheduled.</p>
</div>
<div class="dash-label">This week</div>
<div class="rail-card stats">
<span> {{ data.week_stats.completed_this_week }} done</span>
<span> {{ data.week_stats.open_total }} open</span>
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
</div>
<div class="quick-add">
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
<router-link to="/notes/new?type=process" class="qa-btn">+ Process</router-link>
</div>
</aside>
</div>
</template>
</div>
</template>
<style scoped>
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.done-chip:hover { border-color: var(--color-primary); }
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
.dash-main { flex: 1.7; min-width: 0; }
.dash-rail { flex: 1; min-width: 240px; }
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
.proj-title:hover { color: var(--color-primary); }
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
.ms-block { margin-top: 0.7rem; }
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
.task-row:hover { background: var(--color-hover); }
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
.task-mark { color: var(--color-muted); }
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
.pri-high { color: #c0556b; border-color: #c0556b66; }
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
.evt-row:last-child { border-bottom: none; }
.evt-when { color: var(--color-muted); white-space: nowrap; }
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+2
View File
@@ -27,6 +27,7 @@ from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
from fabledassistant.routes.rulebooks import rulebooks_bp
from fabledassistant.routes.trash import trash_bp
from fabledassistant.routes.dashboard import dashboard_bp
from fabledassistant.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
@@ -87,6 +88,7 @@ def create_app() -> Quart:
app.register_blueprint(knowledge_bp)
app.register_blueprint(rulebooks_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
@app.before_request
async def before_request():
+38 -1
View File
@@ -36,7 +36,20 @@ Mechanics:
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
"not set".
"not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
removes the task from its milestone); 0 leaves it unchanged.
Keep task state honest — this is what makes the project a trustworthy record:
- When you begin working a task, set it to in_progress (update_task
status=in_progress).
- Log progress as you go with add_task_log — at meaningful steps, not saved up
for the end.
- The moment a task's work is complete, set it done. Never leave finished work
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
left to do.
- At a significant landing (a merge, a shipped feature, a finished plan), write
a short dated dev-log note on the project (create_note) summarizing what
landed, and mark the plan/task done.
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
an actionable statement plus optional Why and How-to-apply context. At the
@@ -56,6 +69,19 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to
avoid duplicates. Coordinate with the operator on whether a new rule belongs
in a project, an existing rulebook+topic, or a new rulebook.
That boundary cuts the other way too. Because rules are pull-only, a fresh
session won't reach for them unless its always-loaded context says to. So
when the current project subscribes to a rulebook (enter_project /
get_project reports subscribed_rulebooks), make sure the host's persistent
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
startup — carries a short pointer: that the engineering and workflow rules
for this work live in Scribe and must be loaded via list_always_on_rules (or
enter_project when a project is in scope), plus a one-line note of what the
current project is and what is in flight. Add or refresh that pointer when
it's missing or stale; never copy the rules' content into memory — the
pointer plus project context is the whole job. This is what lets the next
session reach for Scribe instead of trusting a stale local copy.
When you are working on a specific project, call enter_project(project_id)
ONCE at session start (or whenever the active project changes). It returns the
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
@@ -63,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of
the land before mutating. Don't call get_project + get_applicable_rules + a
search separately when enter_project already composes them.
Don't wait to be told which project you're in. At the start of a session that
touches Scribe — or the moment work clearly belongs to a project but none is in
scope — bootstrap project context proactively: search for a related existing
project (search / list_projects, matching on the work's subject, the repo or
directory name, and recent activity). If you find a confident match, propose it
and call enter_project once the operator confirms. If nothing matches, offer to
create a project, confirming its name and goal first. Always confirm before
adopting or creating — never do either silently, and never guess a project into
existence. Once a project is in scope, the enter_project handshake and the
host-memory pointer step above both apply.
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
before any brainstorming, design, or plan-writing skill runs. start_planning
+16 -6
View File
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
"leave unchanged" on update
"leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
"""
from __future__ import annotations
@@ -143,10 +143,14 @@ async def update_task(
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
status: New status — one of: todo, in_progress, done, cancelled. Drive
the lifecycle: set in_progress when you start, done when complete —
don't leave finished work at todo.
priority: New priority — one of: none, low, medium, high.
project_id: New project. Omit (0) to leave unchanged.
milestone_id: New milestone. Omit (0) to leave unchanged.
project_id: New project. 0 = leave unchanged, -1 = clear (remove from
its project; also clears the milestone), positive = set.
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
from its milestone), positive = set.
"""
uid = current_user_id()
fields: dict = {}
@@ -158,9 +162,15 @@ async def update_task(
fields["status"] = status
if priority:
fields["priority"] = priority
if project_id:
# Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
if project_id == -1:
fields["project_id"] = None
fields["milestone_id"] = None # a milestone can't outlive its project
elif project_id:
fields["project_id"] = project_id
if milestone_id:
if milestone_id == -1:
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
+13
View File
@@ -0,0 +1,13 @@
"""Dashboard REST endpoint — the aggregated landing payload."""
from quart import Blueprint, g, jsonify
from fabledassistant.auth import login_required
from fabledassistant.services.dashboard import build_dashboard
dashboard_bp = Blueprint("dashboard", __name__, url_prefix="/api/dashboard")
@dashboard_bp.get("")
@login_required
async def get_dashboard():
return jsonify(await build_dashboard(g.user.id))
+171
View File
@@ -0,0 +1,171 @@
"""Dashboard aggregation — assembles the /dashboard landing payload.
One call: most-recently-active projects (each -> active milestones -> open
tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped,
trashed rows excluded. Each section is independent — a failure returns its
empty value rather than blanking the page.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.services import milestones as milestones_svc
logger = logging.getLogger(__name__)
N_PROJECTS = 3 # most-recently-active projects shown
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
_OPEN = ["todo", "in_progress"]
def _open_order():
"""in-progress first -> priority high..none -> most-recently-updated."""
status_rank = case((Note.status == "in_progress", 0), else_=1)
priority_rank = case(
(Note.priority == "high", 0), (Note.priority == "medium", 1),
(Note.priority == "low", 2), else_=3,
)
return status_rank, priority_rank, Note.updated_at.desc()
def _task_row(n: Note) -> dict:
return {"id": n.id, "title": n.title, "status": n.status,
"priority": n.priority or "none"}
async def _safe(coro, empty):
try:
return await coro
except Exception:
logger.warning("dashboard section failed", exc_info=True)
return empty
async def build_dashboard(user_id: int) -> dict:
return {
"active_projects": await _safe(_active_projects(user_id), []),
"recently_completed": await _safe(_recently_completed(user_id), []),
"upcoming_events": await _safe(_upcoming_events(user_id), []),
"week_stats": await _safe(_week_stats(user_id), {}),
}
async def _active_projects(user_id: int) -> list[dict]:
so, po, ro = _open_order()
async with async_session() as session:
recency = (
select(Note.project_id, func.max(Note.updated_at).label("last"))
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
Note.project_id.isnot(None))
.group_by(Note.project_id).subquery()
)
prows = (await session.execute(
select(Project, recency.c.last)
.outerjoin(recency, Project.id == recency.c.project_id)
.where(Project.user_id == user_id, Project.status == "active",
Project.deleted_at.is_(None))
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
.limit(N_PROJECTS)
)).all()
out = []
for project, last in prows:
counts = (await session.execute(
select(
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "done"),
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
).where(Note.user_id == user_id, Note.project_id == project.id,
Note.deleted_at.is_(None))
)).one()
open_count, done_count, resolved_total = counts
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
mrows = (await session.execute(
select(Milestone).where(
Milestone.user_id == user_id, Milestone.project_id == project.id,
Milestone.status == "active", Milestone.deleted_at.is_(None))
.order_by(Milestone.order_index.asc())
)).scalars().all()
milestones = []
for m in mrows:
tasks = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.milestone_id == m.id,
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
if not tasks:
continue
prog = await milestones_svc.get_milestone_progress(m.id)
milestones.append({
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
"open_tasks": [_task_row(t) for t in tasks],
})
no_ms = (await session.execute(
select(Note).where(
Note.user_id == user_id, Note.project_id == project.id,
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
Note.deleted_at.is_(None))
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
)).scalars().all()
out.append({
"id": project.id, "title": project.title, "color": project.color,
"last_activity": (last or project.updated_at).isoformat(),
"open_count": open_count, "progress_pct": progress_pct,
"milestones": milestones,
"no_milestone": [_task_row(t) for t in no_ms],
})
return out
async def _recently_completed(user_id: int) -> list[dict]:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
rows = (await session.execute(
select(Note, Project.title)
.outerjoin(Project, Note.project_id == Project.id)
.where(Note.user_id == user_id, Note.status == "done",
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
Note.completed_at >= cutoff)
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
async def _upcoming_events(user_id: int) -> list[dict]:
from fabledassistant.services import events as events_svc
now = datetime.now(timezone.utc)
rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS))
out = []
for e in rows:
d = e if isinstance(e, dict) else e.to_dict()
out.append({"id": d["id"], "title": d["title"],
"start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)})
return out
async def _week_stats(user_id: int) -> dict:
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
async with async_session() as session:
row = (await session.execute(
select(
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
func.count(Note.id).filter(Note.status.in_(_OPEN)),
func.count(Note.id).filter(Note.status == "in_progress"),
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
)).one()
return {"completed_this_week": row[0], "open_total": row[1],
"in_progress": row[2], "active_plans": row[3]}
+40
View File
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
await update_task(task_id=999, status="done")
@pytest.mark.asyncio
async def test_update_task_milestone_zero_is_omitted():
"""milestone_id=0 is 'leave unchanged' — must not reach the service."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=0)
assert "milestone_id" not in mock.call_args.kwargs
@pytest.mark.asyncio
async def test_update_task_milestone_positive_is_set():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=42)
assert mock.call_args.kwargs["milestone_id"] == 42
@pytest.mark.asyncio
async def test_update_task_milestone_negative_one_clears():
"""milestone_id=-1 clears the milestone (sets the column NULL)."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=-1)
assert mock.call_args.kwargs["milestone_id"] is None
@pytest.mark.asyncio
async def test_update_task_clearing_project_also_clears_milestone():
"""project_id=-1 clears the project and, with it, the milestone."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, project_id=-1)
assert mock.call_args.kwargs["project_id"] is None
assert mock.call_args.kwargs["milestone_id"] is None
@pytest.mark.asyncio
async def test_add_task_log_returns_log_dict():
log = MagicMock()
+18
View File
@@ -0,0 +1,18 @@
"""Structural tests for the dashboard blueprint."""
def test_dashboard_blueprint_registered():
from fabledassistant.routes.dashboard import dashboard_bp
assert dashboard_bp.name == "dashboard"
assert dashboard_bp.url_prefix == "/api/dashboard"
def test_dashboard_blueprint_registered_in_app():
from fabledassistant.app import create_app
app = create_app()
assert "dashboard" in app.blueprints
def test_dashboard_handler_callable():
from fabledassistant.routes import dashboard as dashboard_routes
assert callable(dashboard_routes.get_dashboard)
+66
View File
@@ -0,0 +1,66 @@
"""build_dashboard composition + helpers (services/dashboard.py).
Query semantics (ranking/caps/owner-scope) are exercised by manual smoke —
the repo's unit tests mock the DB, so SQL isn't executed here. These cover the
pure helpers and the section-isolation contract.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def test_task_row_maps_fields():
from fabledassistant.services.dashboard import _task_row
n = MagicMock()
n.id = 5
n.title = "Wire reminders"
n.status = "in_progress"
n.priority = None
assert _task_row(n) == {
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
}
@pytest.mark.asyncio
async def test_safe_returns_value_then_empty_on_error():
from fabledassistant.services.dashboard import _safe
async def ok():
return [1, 2, 3]
async def boom():
raise RuntimeError("section blew up")
assert await _safe(ok(), []) == [1, 2, 3]
# a failing section returns the supplied empty default, not an exception
assert await _safe(boom(), []) == []
assert await _safe(boom(), {}) == {}
@pytest.mark.asyncio
async def test_build_dashboard_composes_sections():
import fabledassistant.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})):
out = await dash.build_dashboard(user_id=1)
assert out == {
"active_projects": ["P"],
"recently_completed": ["done"],
"upcoming_events": ["evt"],
"week_stats": {"open_total": 4},
}
@pytest.mark.asyncio
async def test_build_dashboard_isolates_failing_section():
import fabledassistant.services.dashboard as dash
with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \
patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \
patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \
patch.object(dash, "_week_stats", AsyncMock(return_value={})):
out = await dash.build_dashboard(user_id=1)
# failing section degrades to its empty default; others still populate
assert out["active_projects"] == []
assert out["recently_completed"] == ["done"]