Compare commits
21
Commits
7ef7d10b24
...
v26.06.03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837489e4f2 | ||
|
|
9a0d5f3109 | ||
|
|
5a930319ba | ||
|
|
266af7870d | ||
|
|
f446573c3d | ||
|
|
82d6812c7f | ||
|
|
8c9ca45479 | ||
|
|
e023c21aa1 | ||
|
|
e3d7007417 | ||
|
|
65c85bab15 | ||
|
|
0e980ee4b0 | ||
|
|
b5870d4694 | ||
|
|
c810d63bee | ||
|
|
a3a056d6fd | ||
|
|
2414437061 | ||
|
|
76dc75a03b | ||
|
|
c8765959ea | ||
|
|
f85b92a885 | ||
|
|
b81c4aa600 | ||
|
|
404698521f | ||
|
|
88b351a96e |
+28
-21
@@ -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 }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user