Release: Issues+Systems, milestone-as-plan, plugin reliability/skills/dedup, compaction hygiene #71

Merged
bvandeusen merged 25 commits from dev into main 2026-06-14 15:51:44 -04:00
60 changed files with 3195 additions and 140 deletions
+5
View File
@@ -81,6 +81,11 @@ jobs:
- name: Cache npm download cache
uses: actions/cache@v4
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
# typecheck job (it was skipping install + type check and reporting red
# on backend-only pushes — see issue task #828). On cache miss/error the
# job just installs without the cache.
continue-on-error: true
with:
path: ~/.npm
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
+103
View File
@@ -0,0 +1,103 @@
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
Revision ID: 0065
Revises: 0064
Create Date: 2026-06-14
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
'new CHECK-enum values need a same-change migration' rule), a per-project
self-describing System entity, a many-to-many record<->system join (any
note/task/issue), and an issue->originating-task provenance FK.
"""
from alembic import op
import sqlalchemy as sa
revision = "0065"
down_revision = "0064"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
# same change that introduces the value.
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
)
# 2. Provenance: an issue can point back at the task/feature it arose from.
# Distinct from parent_id (sub-task hierarchy).
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_notes_arose_from_id", "notes", "notes",
["arose_from_id"], ["id"], ondelete="SET NULL",
)
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
# 3. systems: per-project, reusable, self-describing subsystem/area.
op.create_table(
"systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False, server_default=""),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("color", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index("ix_systems_project_id", "systems", ["project_id"])
op.create_check_constraint(
"systems_status_check", "systems", "status IN ('active','archived')",
)
# 4. record_systems: M2M join — any note/task/issue <-> system.
op.create_table(
"record_systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"note_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"system_id", sa.Integer(),
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
)
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
def downgrade() -> None:
op.drop_table("record_systems")
op.drop_table("systems")
op.drop_index("ix_notes_arose_from_id", table_name="notes")
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
op.drop_column("notes", "arose_from_id")
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
)
+32
View File
@@ -0,0 +1,32 @@
"""milestone-as-plan-container: milestones.body holds the plan/design
Revision ID: 0066
Revises: 0065
Create Date: 2026-06-14
T3 of plan #819. The milestone becomes the plan container: its `body` holds
the design/intent/purpose (markdown), `description` stays the one-liner, and
individual steps live as first-class child tasks (milestone_id) instead of
checkboxes crammed into a kind=plan task body. start_planning is reworked to
create a milestone instead of a kind=plan task (hard retirement going forward;
the 'plan' task_kind enum value stays valid so the historical plan-tasks are
left readable in place — no body-shredding backfill).
Schema change is just one nullable column; no data migration.
"""
from alembic import op
import sqlalchemy as sa
revision = "0066"
down_revision = "0065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("milestones", "body")
+64
View File
@@ -0,0 +1,64 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
export interface System {
id: number;
project_id: number;
name: string;
description: string;
color: string | null;
status: "active" | "archived";
order_index: number;
open_issue_count: number;
created_at: string | null;
updated_at: string | null;
}
export async function listSystems(projectId: number): Promise<System[]> {
const data = await apiGet<{ systems: System[] }>(`/api/projects/${projectId}/systems`);
return data.systems;
}
export async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
): Promise<System> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
export async function updateSystem(
projectId: number,
systemId: number,
data: Partial<{
name: string;
description: string;
color: string | null;
status: "active" | "archived";
order_index: number;
}>,
): Promise<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
}
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
}
// Lightweight issue shape returned by the project-issues list endpoint.
export interface TaskLike {
id: number;
title: string;
status: string;
priority: string;
systems?: System[];
updated_at?: string | null;
}
export async function getProjectIssues(
projectId: number,
openOnly = true,
): Promise<TaskLike[]> {
const data = await apiGet<{ issues: TaskLike[] }>(
`/api/projects/${projectId}/issues?open_only=${openOnly}`,
);
return data.issues;
}
+560
View File
@@ -0,0 +1,560 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useSystemsStore } from "@/stores/systems";
import { useToastStore } from "@/stores/toast";
import { getProjectIssues } from "@/api/systems";
import type { System, TaskLike } from "@/api/systems";
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
const store = useSystemsStore();
const toast = useToastStore();
const error = ref<string | null>(null);
const showArchived = ref(false);
const issues = ref<TaskLike[]>([]);
// Create state
const showCreate = ref(false);
const newName = ref("");
const newDescription = ref("");
const creating = ref(false);
// Edit state
const editingId = ref<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const savingEdit = ref(false);
// Delete confirmation
const deletingSystem = ref<System | null>(null);
const systems = computed<System[]>(() => store.systemsByProject[props.projectId] ?? []);
const activeSystems = computed(() => systems.value.filter((s) => s.status === "active"));
const archivedSystems = computed(() => systems.value.filter((s) => s.status === "archived"));
const visibleSystems = computed(() =>
showArchived.value ? systems.value : activeSystems.value,
);
async function load() {
error.value = null;
try {
await store.fetchSystems(props.projectId);
} catch {
error.value = "Failed to load systems.";
}
try {
issues.value = await getProjectIssues(props.projectId);
} catch {
issues.value = [];
}
}
onMounted(load);
watch(() => props.projectId, load);
function openCreate() {
showCreate.value = true;
newName.value = "";
newDescription.value = "";
}
function cancelCreate() {
showCreate.value = false;
newName.value = "";
newDescription.value = "";
}
async function submitCreate() {
const name = newName.value.trim();
if (!name || creating.value) return;
creating.value = true;
try {
await store.createSystem(props.projectId, {
name,
description: newDescription.value.trim() || undefined,
});
cancelCreate();
toast.show("System created");
} catch {
toast.show("Failed to create system", "error");
} finally {
creating.value = false;
}
}
function startEdit(system: System) {
editingId.value = system.id;
editName.value = system.name;
editDescription.value = system.description;
}
function cancelEdit() {
editingId.value = null;
}
async function submitEdit(system: System) {
const name = editName.value.trim();
if (!name || savingEdit.value) return;
savingEdit.value = true;
try {
await store.updateSystem(props.projectId, system.id, {
name,
description: editDescription.value.trim(),
});
editingId.value = null;
toast.show("System updated");
} catch {
toast.show("Failed to update system", "error");
} finally {
savingEdit.value = false;
}
}
async function archive(system: System) {
try {
await store.archiveSystem(props.projectId, system.id);
toast.show("System archived");
} catch {
toast.show("Failed to archive system", "error");
}
}
async function unarchive(system: System) {
try {
await store.unarchiveSystem(props.projectId, system.id);
toast.show("System restored");
} catch {
toast.show("Failed to restore system", "error");
}
}
async function confirmDelete() {
const system = deletingSystem.value;
if (!system) return;
deletingSystem.value = null;
try {
await store.deleteSystem(props.projectId, system.id);
toast.show("System deleted");
} catch {
toast.show("Failed to delete system", "error");
}
}
</script>
<template>
<div class="systems-section">
<!-- Open issues -->
<div v-if="issues.length" class="open-issues">
<div class="open-issues-label"> Open issues ({{ issues.length }})</div>
<ul class="issue-list">
<li v-for="issue in issues" :key="issue.id" class="issue-item">
<router-link :to="`/tasks/${issue.id}`" class="issue-link">
<span class="issue-mark" :class="`imk-${issue.status}`">{{ issue.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="issue-name">{{ issue.title }}</span>
<span v-if="issue.systems && issue.systems.length" class="issue-systems">
<span v-for="s in issue.systems" :key="s.id" class="issue-sys-chip">{{ s.name }}</span>
</span>
</router-link>
</li>
</ul>
</div>
<!-- Toolbar -->
<div class="systems-toolbar">
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
+ System
</button>
<label v-if="archivedSystems.length" class="archived-toggle">
<input v-model="showArchived" type="checkbox" class="archived-checkbox" />
Show archived ({{ archivedSystems.length }})
</label>
</div>
<!-- Create form -->
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
<input
v-model="newName"
class="system-input"
placeholder="System name"
aria-label="System name"
autofocus
@keydown.escape="cancelCreate"
/>
<textarea
v-model="newDescription"
class="system-textarea"
rows="2"
placeholder="What is this subsystem responsible for? (optional)"
aria-label="System description"
></textarea>
<div class="system-form-actions">
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
</button>
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
</div>
</form>
<!-- Loading -->
<div v-if="store.loading && !systems.length" class="systems-skeleton" aria-label="Loading systems">
<div class="skel-row"></div>
<div class="skel-row skel-row--short"></div>
<div class="skel-row"></div>
</div>
<!-- Error -->
<p v-else-if="error" class="error-msg">{{ error }}</p>
<!-- Empty -->
<div v-else-if="!visibleSystems.length" class="systems-empty">
<p class="empty-title">No systems yet</p>
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
</div>
<!-- List -->
<ul v-else class="systems-list">
<li
v-for="system in visibleSystems"
:key="system.id"
class="system-card"
:class="{ 'system-card--archived': system.status === 'archived' }"
>
<!-- Inline edit -->
<template v-if="editingId === system.id">
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
<input
v-model="editName"
class="system-input"
placeholder="System name"
aria-label="System name"
autofocus
@keydown.escape="cancelEdit"
/>
<textarea
v-model="editDescription"
class="system-textarea"
rows="2"
placeholder="Description (optional)"
aria-label="System description"
></textarea>
<div class="system-form-actions">
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
</div>
</form>
</template>
<!-- Display -->
<template v-else>
<span
class="system-swatch"
:style="{ background: system.color || 'var(--color-text-muted)' }"
aria-hidden="true"
></span>
<div class="system-body">
<div class="system-name-row">
<span class="system-name">{{ system.name }}</span>
<span
class="issue-badge"
:title="`${system.open_issue_count} open issue(s)`"
>{{ system.open_issue_count }} open</span>
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
</div>
<div class="system-actions">
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
<Pencil :size="16" />
</button>
<button
v-if="system.status === 'active'"
class="action-btn"
title="Archive"
aria-label="Archive system"
@click="archive(system)"
>
<Archive :size="16" />
</button>
<button
v-else
class="action-btn"
title="Restore"
aria-label="Restore system"
@click="unarchive(system)"
>
<ArchiveRestore :size="16" />
</button>
<button
class="action-btn action-delete"
title="Delete"
aria-label="Delete system"
@click="deletingSystem = system"
>
<Trash2 :size="16" />
</button>
</div>
</template>
</li>
</ul>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="deletingSystem" class="modal-overlay" @click.self="deletingSystem = null">
<div class="modal-card">
<h3 class="modal-title">Delete System</h3>
<p class="modal-message">
Delete <strong>{{ deletingSystem.name }}</strong>? This cannot be undone.
</p>
<div class="modal-actions">
<button class="modal-btn" @click="deletingSystem = null">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
</div>
</template>
<style scoped>
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Open issues ──────────────────────────────────────────────── */
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.issue-link:hover { background: var(--color-bg-secondary); }
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--color-primary); }
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
background: none;
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
padding: 0.28rem 0.65rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
.archived-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
/* ── Create / edit form ───────────────────────────────────────── */
.system-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
.system-input, .system-textarea {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
.btn-confirm {
padding: 0.35rem 0.8rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-cancel {
padding: 0.35rem 0.8rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
/* ── List ─────────────────────────────────────────────────────── */
.systems-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.system-card {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.85rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
transition: border-color 0.12s, box-shadow 0.15s;
}
.system-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
}
.system-card--archived { opacity: 0.6; }
.system-swatch {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.3rem;
}
.system-body { flex: 1; min-width: 0; }
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
.issue-badge {
font-size: 0.7rem;
font-weight: 500;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--color-primary);
border-radius: 999px;
padding: 0.05rem 0.45rem;
flex-shrink: 0;
}
.archived-badge {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.system-description {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
line-height: 1.4;
word-break: break-word;
}
.system-actions { display: flex; gap: 0.15rem; flex-shrink: 0; opacity: 0; transition: opacity 0.15s; }
.system-card:hover .system-actions,
.system-card:focus-within .system-actions { opacity: 1; }
.action-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
transition: background 0.12s, color 0.12s;
}
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
.action-delete:hover { color: var(--color-danger, #e74c3c); }
/* ── Empty ────────────────────────────────────────────────────── */
.systems-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 2rem 1rem;
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
}
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
/* ── Skeleton ─────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
.skel-row {
height: 3rem;
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-row--short { width: 65%; }
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
</style>
+73
View File
@@ -0,0 +1,73 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/systems";
import type { System } from "@/api/systems";
import { useToastStore } from "@/stores/toast";
export const useSystemsStore = defineStore("systems", () => {
const systemsByProject = ref<Record<number, System[]>>({});
const loading = ref(false);
async function fetchSystems(projectId: number) {
loading.value = true;
try {
systemsByProject.value[projectId] = await api.listSystems(projectId);
} catch (e) {
useToastStore().show("Failed to load systems", "error");
throw e;
} finally {
loading.value = false;
}
}
async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
) {
const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
systemsByProject.value[projectId].push(system);
return system;
}
async function updateSystem(
projectId: number,
systemId: number,
data: Partial<Pick<System, "name" | "description" | "color" | "status" | "order_index">>,
) {
const system = await api.updateSystem(projectId, systemId, data);
const list = systemsByProject.value[projectId];
if (list) {
const idx = list.findIndex((s) => s.id === systemId);
if (idx >= 0) list[idx] = system;
}
return system;
}
async function archiveSystem(projectId: number, systemId: number) {
return updateSystem(projectId, systemId, { status: "archived" });
}
async function unarchiveSystem(projectId: number, systemId: number) {
return updateSystem(projectId, systemId, { status: "active" });
}
async function deleteSystem(projectId: number, systemId: number) {
await api.deleteSystem(projectId, systemId);
const list = systemsByProject.value[projectId];
if (list) {
systemsByProject.value[projectId] = list.filter((s) => s.id !== systemId);
}
}
return {
systemsByProject,
loading,
fetchSystems,
createSystem,
updateSystem,
archiveSystem,
unarchiveSystem,
deleteSystem,
};
});
+12 -2
View File
@@ -3,6 +3,16 @@ import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
import type { TaskKind } from "@/types/note";
// Issues + Systems write-only fields accepted by the task create/update API.
// `kind` selects work/plan/issue; system_ids / arose_from_id are not mirrored
// 1:1 on the Task model (the read side exposes `systems` + `arose_from_id`).
interface IssueFields {
kind?: TaskKind;
system_ids?: number[];
arose_from_id?: number | null;
}
// Single-task + mutation surface. The list/filter/sort/pagination surface that
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
@@ -34,7 +44,7 @@ export const useTasksStore = defineStore("tasks", () => {
milestone_id?: number | null;
parent_id?: number | null;
recurrence_rule?: Record<string, unknown> | null;
}): Promise<Task> {
} & IssueFields): Promise<Task> {
try {
return await apiPost<Task>("/api/tasks", data);
} catch (e) {
@@ -47,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => {
id: number,
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
>
> & IssueFields
): Promise<Task> {
try {
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
+6 -1
View File
@@ -1,5 +1,8 @@
import type { System } from "@/api/systems";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type TaskKind = "work" | "plan" | "issue";
export type NoteType = "note" | "person" | "place" | "list" | "process";
export interface Note {
@@ -22,7 +25,9 @@ export interface Note {
recurrence_next_spawn_at: string | null;
is_task: boolean;
note_type: NoteType;
task_kind?: "work" | "plan";
task_kind?: TaskKind;
systems?: System[];
arose_from_id?: number | null;
metadata: Record<string, string>;
created_at: string;
updated_at: string;
+11 -1
View File
@@ -5,8 +5,18 @@ export interface TaskListResponse {
total: number;
}
// start_planning now creates a MILESTONE (the plan container), not a kind=plan
// task. The milestone's `body` holds the design; steps live as child tasks.
export interface StartPlanningResult {
task: import("./note").Note;
milestone: {
id: number;
project_id: number;
title: string;
description: string | null;
body: string | null;
status: string;
order_index: number;
};
applicable_rules: {
id: number;
title: string;
+27 -1
View File
@@ -13,10 +13,12 @@ interface ActiveProject {
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 IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
interface DashboardData {
active_projects: ActiveProject[];
recently_completed: DoneItem[];
upcoming_events: UpcomingEvent[];
open_issues: IssueRow[];
week_stats: WeekStats;
}
@@ -29,7 +31,7 @@ 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 } };
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
} finally {
loading.value = false;
}
@@ -132,6 +134,22 @@ function fmtEvent(e: UpcomingEvent): string {
<!-- Right rail -->
<aside class="dash-rail">
<template v-if="data.open_issues.length">
<div class="dash-label"> Open issues</div>
<div class="rail-card issues-card">
<router-link
v-for="i in data.open_issues"
:key="i.id"
:to="`/tasks/${i.id}`"
class="issue-row"
>
<span class="issue-mark" :class="`imk-${i.status}`">{{ i.status === 'in_progress' ? '▸' : '○' }}</span>
<span class="issue-title">{{ i.title }}</span>
<span class="issue-meta">{{ i.project_title || '—' }}</span>
</router-link>
</div>
</template>
<div class="dash-label">Upcoming · 7 days</div>
<div class="rail-card">
<template v-if="data.upcoming_events.length">
@@ -240,5 +258,13 @@ function fmtEvent(e: UpcomingEvent): string {
.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); }
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
.issue-row:hover { background: var(--color-hover); }
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
.issue-mark.imk-in_progress { color: var(--color-primary); }
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
</style>
+112 -2
View File
@@ -5,8 +5,10 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
LayoutGrid,
Clock,
@@ -21,6 +23,8 @@ import {
interface Milestone {
id: number;
title: string;
description: string | null;
body: string | null;
status: string;
order_index: number;
pct: number;
@@ -76,10 +80,15 @@ async function confirmStartPlanning() {
if (!title || !project.value) return;
planningBusy.value = true;
try {
// start_planning creates a MILESTONE (the plan container). Reload milestones,
// make sure the new one is expanded, and open its plan editor.
const result = await tasksStore.startPlanning(project.value.id, title);
planTitle.value = "";
showStartPlanning.value = false;
router.push(`/tasks/${result.task.id}`);
await loadMilestones();
collapsedMilestones.value.delete(result.milestone.id);
startEditPlan(result.milestone.id, result.milestone.body);
toast.show("Plan started");
} finally {
planningBusy.value = false;
}
@@ -87,7 +96,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "rules">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -224,6 +233,39 @@ async function commitRenameMilestone(ms: Milestone) {
}
}
// Plan body editing — a milestone IS the plan; its `body` holds the design.
const editingPlanId = ref<number | null>(null);
const editPlanBody = ref("");
const savingPlan = ref(false);
function startEditPlan(id: number, body: string | null) {
editingPlanId.value = id;
editPlanBody.value = body ?? "";
}
function cancelEditPlan() {
editingPlanId.value = null;
editPlanBody.value = "";
}
async function commitEditPlan(ms: Milestone) {
if (savingPlan.value) return;
savingPlan.value = true;
try {
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, {
body: editPlanBody.value,
});
await loadMilestones();
editingPlanId.value = null;
editPlanBody.value = "";
toast.show("Plan saved");
} catch {
toast.show("Failed to save plan", "error");
} finally {
savingPlan.value = false;
}
}
async function confirmDeleteMilestone() {
const ms = deletingMilestone.value;
if (!ms) return;
@@ -479,6 +521,9 @@ async function confirmDelete() {
Notes
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'systems' }]" @click="activeTab = 'systems'">
Systems
</button>
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
Rules
</button>
@@ -541,6 +586,9 @@ async function confirmDelete() {
</div>
<span class="ms-pct">{{ group.milestone.pct }}%</span>
<div class="ms-actions" @click.stop>
<button class="ms-action-btn" :title="group.milestone.body ? 'Edit plan' : 'Add plan'" @click="startEditPlan(group.milestone.id, group.milestone.body)">
<FileText :size="16" />
</button>
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
<Pencil :size="16" />
</button>
@@ -551,6 +599,31 @@ async function confirmDelete() {
</template>
</div>
<!-- Plan body: the milestone IS the plan; design lives here, steps are the tasks below. -->
<div
v-if="group.milestone && !collapsedMilestones.has(group.milestone.id) && (editingPlanId === group.milestone.id || group.milestone.body)"
class="ms-plan"
>
<template v-if="editingPlanId === group.milestone.id">
<textarea
v-model="editPlanBody"
class="ms-plan-editor"
rows="10"
placeholder="The plan: Goal / Approach / Verification. Track each step as a task below."
></textarea>
<div class="ms-plan-actions">
<button class="btn-secondary" @click="cancelEditPlan">Cancel</button>
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
</div>
</template>
<div
v-else
class="ms-plan-rendered markdown-body"
@click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')"
></div>
</div>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
<!-- Todo column -->
<div class="kanban-col col-todo">
@@ -663,6 +736,9 @@ async function confirmDelete() {
</template>
</div>
<!-- Systems tab -->
<SystemsSection v-if="activeTab === 'systems'" :project-id="projectId" />
<!-- Rules tab -->
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
</div>
@@ -1075,6 +1151,40 @@ async function confirmDelete() {
.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
/* Plan body: the milestone's design/intent, shown above its task columns. */
.ms-plan {
padding: 0.6rem 0.85rem;
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
border-bottom: 1px solid var(--color-border);
}
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
.ms-plan-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
line-height: 1.5;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
.ms-plan-actions .btn-primary,
.ms-plan-actions .btn-secondary {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--color-border);
}
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {
+59 -1
View File
@@ -12,6 +12,9 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { TaskKind } from "@/types/note";
import { useSystemsStore } from "@/stores/systems";
import type { System } from "@/api/systems";
import type { Note } from "@/types/note";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
@@ -31,6 +34,7 @@ import { Trash2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const systemsStore = useSystemsStore();
const notesStore = useNotesStore();
const toast = useToastStore();
@@ -41,6 +45,8 @@ const consolidatedAt = ref<string | null>(null);
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const kind = ref<TaskKind>("work");
const systemIds = ref<number[]>([]);
const dueDate = ref("");
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
@@ -201,6 +207,8 @@ let savedDueDate = "";
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedParentId: number | null = null;
let savedKind: TaskKind = "work";
let savedSystemIds: number[] = [];
function markDirty() {
dirty.value =
@@ -213,7 +221,22 @@ function markDirty() {
dueDate.value !== savedDueDate ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
parentId.value !== savedParentId;
parentId.value !== savedParentId ||
kind.value !== savedKind ||
JSON.stringify(systemIds.value) !== JSON.stringify(savedSystemIds);
}
const projectSystems = computed<System[]>(() =>
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
);
async function loadSystems() {
if (!projectId.value) return;
try {
await systemsStore.fetchSystems(projectId.value);
} catch {
/* non-fatal — the systems picker just won't populate */
}
}
function onBodyUpdate(newVal: string) {
@@ -288,6 +311,8 @@ onMounted(async () => {
const taskRec = store.currentTask as Record<string, unknown>;
projectId.value = (taskRec.project_id as number | null) ?? null;
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
kind.value = (taskRec.task_kind as TaskKind) ?? "work";
systemIds.value = ((taskRec.systems as Array<{ id: number }> | undefined) ?? []).map((s) => s.id);
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
@@ -305,6 +330,8 @@ onMounted(async () => {
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
savedKind = kind.value;
savedSystemIds = [...systemIds.value];
// Start in preview mode only if the task already has body content
showPreview.value = body.value.trim().length > 0;
}
@@ -315,6 +342,7 @@ onMounted(async () => {
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
if (route.query.parentId) parentId.value = Number(route.query.parentId);
}
loadSystems();
});
async function save() {
@@ -333,6 +361,8 @@ async function save() {
milestone_id: milestoneId.value,
parent_id: parentId.value,
recurrence_rule: recurrenceRule.value,
kind: kind.value,
system_ids: systemIds.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
@@ -346,6 +376,8 @@ async function save() {
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
savedKind = kind.value;
savedSystemIds = [...systemIds.value];
dirty.value = false;
toast.show("Task saved");
router.push(`/tasks/${taskId.value}`);
@@ -543,6 +575,16 @@ useEditorGuards(dirty, save);
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="sb-field">
<label class="sb-label">Kind</label>
<select v-model="kind" @change="markDirty" class="sb-select">
<option value="work">Work</option>
<option value="issue">Issue</option>
<!-- 'plan' is retired (plans are milestones via start_planning);
offered only so legacy plan-tasks display their kind. -->
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
</select>
</div>
<div v-if="startedAt || completedAt" class="sb-timestamps">
<div v-if="startedAt" class="sb-timestamp">
<span class="sb-ts-label">Started</span>
@@ -578,6 +620,16 @@ useEditorGuards(dirty, save);
<label class="sb-label">Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
<div v-if="projectId" class="sb-field">
<label class="sb-label">Systems</label>
<div v-if="projectSystems.length" class="sb-systems">
<label v-for="s in projectSystems" :key="s.id" class="sb-system-opt">
<input type="checkbox" :value="s.id" v-model="systemIds" @change="markDirty" />
<span>{{ s.name }}</span>
</label>
</div>
<p v-else class="sb-systems-empty">No systems in this project yet.</p>
</div>
<!-- Parent task -->
<div class="sb-field">
@@ -955,6 +1007,12 @@ useEditorGuards(dirty, save);
min-height: 0;
}
/* Systems multi-select (in sidebar) */
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
/* Writing Assistant section (in sidebar) */
.assist-section {
display: flex;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, and a set of universal process-skills (brainstorm, debug, TDD, plan, verify). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.5",
"description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.9",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+8
View File
@@ -9,6 +9,10 @@ instance into a first-class Claude Code extension:
rules + active-project context so Scribe surfaces *without being asked*.
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
writing-plans, verification, receiving-code-review (replaces superpowers).
- **Your Scribe Processes as skills** — saved Processes are synced into local
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
stub fetches the live procedure via `get_process`. Refreshed each session and
on demand with `/scribe:sync`.
It is designed so you can uninstall `superpowers` and disable auto-memory and
depend on neither.
@@ -38,6 +42,10 @@ On install you'll be asked for:
**fail-open**: if Scribe is unreachable it injects nothing and never blocks
the session.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
match what exists in Scribe.
## Notes
+20
View File
@@ -0,0 +1,20 @@
---
description: Sync your Scribe stored Processes into auto-surfacing local skills
allowed-tools: Bash(bash:*), Bash(ls:*)
---
Regenerate the local skill stubs for your Scribe **Processes** so they auto-surface
this session. Each Process becomes a `~/.claude/skills/scribe-proc-<slug>/SKILL.md`
whose body calls `get_process(<name>)` for the live procedure.
This normally runs automatically at session start; use it after adding or editing
a Process when you want it available immediately (Claude Code live-detects the new
skill files within this session — no restart needed).
Run the bundled sync script, then list what was synced:
```
bash "${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^scribe-proc-' || echo "no Scribe process skills (no Processes, or Scribe unreachable)"
```
Report which `scribe-proc-*` skills are now present.
+4
View File
@@ -6,6 +6,10 @@
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\""
}
]
}
+76 -36
View File
@@ -1,32 +1,60 @@
#!/usr/bin/env bash
# Scribe plugin — SessionStart push channel.
# Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding).
#
# Curls the operator's Scribe instance for always-on rules + active-project
# context and emits it as SessionStart `additionalContext`. Config comes from
# the plugin's userConfig, which Claude Code exports to hook subprocesses as
# CLAUDE_PLUGIN_OPTION_<key> env vars:
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
# reach for Scribe — record work, recall before acting — even when the instance
# is unreachable OR the API token never reached this hook. The latter is a known
# Claude Code gap: sensitive userConfig values aren't always exported to the
# hook subprocess, so the dynamic tier can silently get nothing. The static tier
# is the load-bearing floor that does not depend on the key or the network.
#
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
# for always-on rules + active-project context and appends it. Config comes from
# the plugin's userConfig, exported to hooks as:
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
# The active project is resolved server-side from the working repo's git remote
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
#
# The active project is NOT configured here — it's resolved server-side from
# the working repo's git remote (see services/repo_bindings). This keeps a
# single install working across many repos/projects: bind each repo once with
# the `bind_repo` MCP tool. An unbound repo just yields standing rules + a hint.
# COMPACTION RE-GROUNDING: this hook is registered matcher-less, so it ALSO
# fires after a compaction (SessionStart input `source` == "compact"), when
# earlier turns have just been summarized and in-flight state is most at risk.
# On that source we lead with a banner telling the model to reload project +
# in-flight tasks from Scribe. (PreCompact is the wrong tool here — a host hook
# can't make the model flush, and can't know the in-flight task ids; the durable
# path is record-as-you-go + this post-compaction reload.)
#
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in
# hooks.json — sensitive userConfig values (api_token) are kept in the keychain
# and are never spliced into a hook command line, so the placeholder arrives
# unexpanded. The harness env vars above are the supported channel. SCRIBE_URL
# / SCRIBE_TOKEN still override, for the settings.json dogfooding path where the
# hook is wired up by hand.
# hooks.json — sensitive values are kept in the keychain and never spliced into
# a hook command line, so the placeholder arrives unexpanded. The harness env
# vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for
# the settings.json dogfooding path.
#
# FAIL-OPEN: any missing tool/config, network error, or unreachable instance
# injects nothing and exits 0. A session must never be blocked by Scribe.
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session. A *failed*
# dynamic fetch is surfaced as a short status line (not swallowed). A fully
# unconfigured install (no url AND no token) is the intended static-only mode
# and stays quiet.
set -uo pipefail
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
event=$(cat 2>/dev/null || true)
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
out=""
# Append $1 to $out, separated by a horizontal rule when $out already has content.
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
# Prepend $1 above $out (used for the compaction banner so it's seen first).
prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"; fi; }
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
@@ -35,27 +63,39 @@ token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ] || exit 0
# Resolve the working repo's remote so the server can map it to a project. Use
# the session's project dir when provided, else the current dir. No remote (or
# not a git repo) → omit; the server returns standing rules only.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
q=""
if [ -n "$repo" ]; then
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && q="?repo=${enc}"
dyn=""
status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
# Resolve the working repo's remote so the server can map it to a project.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
q=""
if [ -n "$repo" ]; then
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && q="?repo=${enc}"
fi
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
elif [ -n "$url" ] && [ -z "$token" ]; then
# Endpoint configured but token absent: the signature of the known Claude Code
# userConfig export gap (sensitive values not always reaching the hook).
status="> ⚠️ Scribe: live context disabled this session — the API token did not reach this hook (a known Claude Code plugin-config gap). Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
fi
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || exit 0
[ -n "$dyn" ] && append "$dyn"
[ -n "$status" ] && append "$status"
text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
[ -n "$text" ] || exit 0
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
if [ "$source" = "compact" ]; then
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
fi
jq -n --arg c "$text" \
# Nothing at all to inject → stay silent.
[ -n "$out" ] || exit 0
jq -n --arg c "$out" \
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
exit 0
+34
View File
@@ -0,0 +1,34 @@
# Scribe — your second brain and system of record
This environment has the **Scribe** plugin: the operator's self-hosted second
brain (notes, tasks, projects, milestones, rules) reachable through the
`scribe` MCP tools. Treat Scribe — **not local files** — as the source of truth
for the operator's work, and as your own working memory across sessions.
**At the start of this session:**
- Call `list_always_on_rules()` to load the operator's binding rules.
- If the working repo maps to a Scribe project (check `list_repo_bindings`),
call `enter_project(<id>)` to load that project's rules, open tasks, and
recent notes in one shot.
**While you work:**
- **Recall before acting** — `search` Scribe for related prior work before
answering a question about the operator's work, starting a task, or
re-deriving a decision. Assume a related note, task, or decision already
exists.
- **Record as you go** — track work as Scribe tasks and log progress with
`add_task_log`. Always log when you **complete a task** and when you **hit or
discover a problem** — so changes of direction are captured, not just
successes. Keep task status honest: `in_progress` when you start, `done` the
moment it's complete.
- Do **not** keep the operator's rules, plans, or project notes in local
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
- **Compact at clean seams** — because you record as you go, a context
compaction is safe: the durable record lives in Scribe, not the transcript.
After finishing a block of work in a long session, make sure in-flight state
is logged to Scribe, then tell the operator it's a good, safe moment to
`/compact` (name what you logged). You can't run it yourself — surface the
recommendation and let them decide. Suggest it at seams, not every turn.
If the Scribe tools are unavailable, say so rather than silently falling back
to local notes.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Scribe plugin — sync stored Processes into auto-surfacing local skills.
#
# Fetches `GET /api/plugin/processes` and writes one
# ~/.claude/skills/scribe-proc-<slug>/SKILL.md
# per Process. The stub's frontmatter `description` is the auto-surface trigger;
# its body tells Claude to call get_process(<name>) and follow the LIVE procedure
# from Scribe (the DB stays the single source of truth — the stub is a pointer).
#
# WHY LOCAL ~/.claude/skills (not the plugin dir): the plugin is git-cloned and
# identical on every install, so instance-specific stubs can't live inside it.
# Personal skills in ~/.claude/skills are live-detected by Claude Code within the
# session, so a freshly written stub auto-surfaces without a restart.
#
# TRIGGERS: this runs at SessionStart (alongside the context hook) so stubs stay
# fresh each session, and on demand via the `/scribe:sync` command.
#
# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's
# safe as a second SessionStart hook). On any fetch failure it exits without
# touching existing stubs — a transient outage must not wipe the user's skills.
# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override).
set -uo pipefail
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ] || exit 0
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/processes" 2>/dev/null) || exit 0
[ -n "$body" ] || exit 0
count=$(printf '%s' "$body" | jq -r '.processes | length' 2>/dev/null) || exit 0
[ -n "$count" ] && [ "$count" != "null" ] || exit 0
skills_dir="${HOME}/.claude/skills"
mkdir -p "$skills_dir" 2>/dev/null || exit 0
# Slugs written this run — anything else under scribe-proc-* is pruned below.
managed=" "
i=0
while [ "$i" -lt "$count" ]; do
name=$(printf '%s' "$body" | jq -r ".processes[$i].name // empty" 2>/dev/null)
slug=$(printf '%s' "$body" | jq -r ".processes[$i].slug // empty" 2>/dev/null)
desc=$(printf '%s' "$body" | jq -r ".processes[$i].description // empty" 2>/dev/null)
i=$((i + 1))
[ -n "$slug" ] && [ -n "$name" ] || continue
dir="${skills_dir}/scribe-proc-${slug}"
mkdir -p "$dir" 2>/dev/null || continue
# description folded to one line — YAML scalar must not contain a newline.
desc=$(printf '%s' "$desc" | tr '\n' ' ')
{
printf -- '---\n'
printf 'name: scribe-proc-%s\n' "$slug"
printf 'description: %s\n' "$desc"
printf -- '---\n\n'
printf '<!-- GENERATED by the Scribe plugin (scribe_sync_processes.sh). Do not edit here; edit the Process in Scribe and re-sync with /scribe:sync. -->\n\n'
printf 'This is a saved **Scribe Process**: `%s`.\n\n' "$name"
printf 'Do not improvise it. Call `get_process("%s")` via the Scribe MCP server to fetch the live procedure, then follow the returned body verbatim — including any "clarify first" steps it contains.\n' "$name"
} > "${dir}/SKILL.md" 2>/dev/null || continue
managed="${managed}${slug} "
done
# Prune stubs whose Process no longer exists (scoped to our scribe-proc-* prefix).
for d in "${skills_dir}"/scribe-proc-*; do
[ -d "$d" ] || continue
slug=$(basename "$d"); slug=${slug#scribe-proc-}
case "$managed" in
*" $slug "*) : ;;
*) rm -rf "$d" 2>/dev/null ;;
esac
done
exit 0
+34
View File
@@ -0,0 +1,34 @@
---
name: brainstorming
description: Use when exploring options or shaping a direction before committing — open up the solution space instead of jumping to the first idea. Triggers on "how should we approach X", "what are the options", weighing trade-offs, or any open-ended design question. Recall prior thinking first; capture the decision after.
---
# Brainstorming
Widen before you narrow. The first idea is rarely the best; the goal is a few
real options and a reasoned choice — not a single path defended after the fact.
## Recall first
`search` Scribe before generating from scratch — a prior decision, note, or
brainstorm on this often already exists. Build on it instead of repeating it.
## Open up
- Generate a few genuinely *different* options, not variations of one. Include at
least one you don't initially favor.
- For each: the core idea, what it's good at, and its main cost or risk — briefly.
- Resist converging until the space is actually explored.
## Then choose
- Recommend one, and say *why* — the trade-off that decided it, not just the pick.
- Surface the 12 places you made an interpretive call, so the operator can
redirect before it's baked in.
## Capture the decision
When a direction is chosen, record it in Scribe (`create_note`, e.g. tag
`decision`): the choice, the alternatives weighed, and the reason. That's what
keeps the same question from being re-litigated later — and what a future
session reads to understand *why*, not just *what*.
@@ -0,0 +1,35 @@
---
name: systematic-debugging
description: Use when diagnosing a bug, failure, or unexpected behavior — investigate methodically instead of guessing. Triggers on "why is this failing/breaking", a stack trace, a flaky test, or any "it should work but doesn't". On resolution, capture the issue in Scribe so it isn't re-debugged from scratch.
---
# Systematic debugging
Find the *root cause*, don't patch the symptom. Move one step at a time — a
guessed fix that "seems to work" often just moves the bug somewhere else.
## Recall first
Before digging in, `search` Scribe for the symptom — a prior `issue` note may
already hold the cause and the fix. Don't re-debug what's already solved.
## The loop
1. **Reproduce** — get a reliable, minimal repro. If you can't reproduce it, you
can't confirm you fixed it.
2. **Observe** — read the actual error / log / state. Don't theorize past the
data you have.
3. **Isolate** — narrow to the smallest failing case; change one variable at a
time so each result actually means something.
4. **Hypothesize → test** — state the single most likely cause, then test *that
one thing*. Confirm or rule it out before moving on; don't stack guesses.
5. **Root cause** — keep going until you can explain *why* it failed, not just
what made it stop. "It works now" without "because X" is unfinished.
6. **Fix + verify** — fix the cause, then re-run the repro to confirm it's gone.
## Capture the issue (so it's findable)
When resolved, record it in Scribe (`create_note`, tag `issue`): **symptom →
root cause → fix → how it was verified**. Even a problem fixed in passing is
worth two lines — that's how the next person (or you) avoids re-deriving it. If
the fix was tracked as a task, log the resolution there and set it `done`.
+10 -10
View File
@@ -30,8 +30,8 @@ This plugin makes Scribe the home for the operator's **rules, recall, and
planning** — the jobs Claude's native auto-memory would otherwise do. When the
plugin is present, route those jobs to Scribe and **do not also write them to
native memory**: codify rules with `create_rule` / `create_project_rule`,
capture durable knowledge as Scribe notes, and keep plans in `kind=plan` tasks —
not in `MEMORY.md` or `CLAUDE.md`. One copy, in Scribe; let any existing local
capture durable knowledge as Scribe notes, and keep plans in Scribe milestones
(via `start_planning`) — not in `MEMORY.md` or `CLAUDE.md`. One copy, in Scribe; let any existing local
memory shrink as Scribe takes over. Don't maintain both stores in parallel.
Two constraints on *how* that's achieved:
@@ -51,7 +51,7 @@ Two constraints on *how* that's achieved:
1. **Recall before acting.** Before answering a question about the operator's
work, or starting a task, `search` Scribe (and `list_tasks` / `list_notes`)
for prior art — an existing ticket, decision, or dev-log — instead of
for related prior work — an existing task, decision, or note — instead of
re-deriving it or opening a duplicate. When a project is in scope, pass its
`project_id` so results stay scoped.
@@ -64,9 +64,10 @@ Two constraints on *how* that's achieved:
note/rule/task over creating a new one. Search first; revise what's there.
4. **Plans live in Scribe.** For non-trivial work call `start_planning(project_id,
title)` FIRST — the plan body + step checklist live in the `kind=plan` task,
progress goes in work-logs (`add_task_log`). Do not write plans/specs to local
`.md` files.
title)` FIRST — it creates a milestone whose `body` holds the design; each
step is its own task under that milestone (`create_task(milestone_id=...)`),
progress goes in work-logs (`add_task_log`). Read it back with `get_milestone`.
Do not write plans/specs to local `.md` files.
5. **Keep state honest.** Set a task `in_progress` when you start it, `done` the
moment it's complete; log progress as you go.
@@ -106,7 +107,6 @@ rulebook — it leaks to every other project that gets it.
## Other Scribe process-skills
This plugin also ships focused process-skills — brainstorming, systematic
debugging, test-driven development, writing-plans, verification, receiving code
review. Reach for the matching one when its situation arises, the same way you
reach for this skill.
This plugin also ships focused process-skills — writing-plans, systematic
debugging, verification, and brainstorming. Reach for the matching one when its
situation arises, the same way you reach for this skill.
+32
View File
@@ -0,0 +1,32 @@
---
name: verification
description: Use before claiming a task is done or a change works — confirm it actually does, then record that you did. Triggers when you're about to report completion, mark a task done, or say "it works" / "fixed". Guards against declaring success on unverified work.
---
# Verification before completion
"Done" means verified, not "written." Before you claim a change works or set a
task `done`, confirm it against reality and record what you checked.
## Verify against reality
- Exercise the actual behavior — run it, test it, observe the output. Prefer the
real path over "it should work by inspection."
- Check the thing the user actually asked for, not a proxy for it.
- If you *can't* verify something (no environment, needs hardware, needs the
operator), say so explicitly — name what's unverified rather than letting it
read as passed.
## Record the result, then close
- Log what you verified, and how, to the task with `add_task_log` — the check is
part of the record, not a private step.
- Only then set the task `done`. Never mark finished work you haven't confirmed,
and never leave confirmed work sitting at `in_progress`.
- If verification surfaced a problem, capture it (tag `issue`) and keep the task
open — a found problem is a pivot to record, not something to quietly skip.
## Honesty over optimism
A truthful "verified X; could not verify Y" is worth more than a confident
"done." The record is only useful if `done` reliably means done.
+58
View File
@@ -0,0 +1,58 @@
---
name: writing-plans
description: Use before starting any non-trivial or multi-step piece of work — produce a clear plan BEFORE diving in. Triggers when the user asks you to plan, design an approach, scope an effort, or tackle work big enough to need ordered steps. The plan lives in a Scribe milestone (via start_planning), not a local file.
---
# Writing plans
A plan is **how** you'll execute a chunk of work — the design plus an ordered
set of steps — written *before* you start, so the approach is reviewable and the
work stays trackable.
## Start the plan in Scribe, not a file
For non-trivial work, call **`start_planning(project_id, title)` FIRST** —
before any design or implementation. It creates a **milestone** (the plan
container) seeded with a design template and returns the milestone id plus the
project's applicable rules. The plan lives in that milestone:
- The **design/intent** goes in the milestone `body` — edit it with
`update_milestone(milestone_id, body=...)`.
- Each **step** is its own task under the milestone — create it with
`create_task(milestone_id=<that milestone>)` and track it with status +
`add_task_log`. Steps are first-class tasks, **not** checkboxes in the body.
- Read the whole plan back with `get_milestone` (body + its step-tasks).
**Do not** write plans or specs to local `.md` files — the milestone is the
record, not a file on disk. (The old `kind=plan` task is retired; `start_planning`
no longer creates one.)
Before designing from scratch, **recall**: `search` Scribe for a related prior
plan or decision. Often the thinking (or half of it) already exists.
## What a good plan contains
- **Goal** — what "done" looks like, and why, in a sentence or two (milestone body).
- **Approach** — the key design decisions and the trade-offs you chose, briefly
(milestone body).
- **Steps** — an ordered set of step-tasks under the milestone, each small enough
to verify on its own; note which files/areas each touches.
- **Verification** — how you'll know it actually works (a test, CI, an
observable behavior), not just "it's written."
## While executing
- Keep the plan **honest**: drive each step-task's status (todo →
in_progress → done) as it lands; record decisions, findings, and pivots with
`add_task_log` on the relevant step rather than silently rewriting the body.
- If reality diverges from the plan, **update the milestone body** — a design
that no longer matches what you're doing is worse than none. Add or re-scope
step-tasks as the work changes.
- Mark the milestone `done` when its steps are complete.
## Match depth to the work
A two-step change deserves a two-line plan; a multi-day effort deserves a
fleshed-out milestone body and several step-tasks. Don't over-plan the trivial,
and don't under-plan something that will sprawl. The point is a shared,
reviewable intent — not ceremony.
+2
View File
@@ -29,6 +29,7 @@ from scribe.routes.rulebooks import rulebooks_bp
from scribe.routes.plugin import plugin_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
@@ -91,6 +92,7 @@ def create_app() -> Quart:
app.register_blueprint(plugin_bp)
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
@app.before_request
async def before_request():
+65 -29
View File
@@ -7,22 +7,33 @@ from quart import Quart
_INSTRUCTIONS = """
Scribe is the user's self-hosted second-brain and project-management data
store. You (Claude) are the assistant.
store, and your own system of record for their work. You (Claude) are the
assistant: record what you do here — tasks, work-logs, decisions, notes — and
recall from here before acting. Do not keep the user's project work in local
files (CLAUDE.md, scratch/auto memory) in parallel; Scribe holds the single copy.
Hierarchy: Project -> Milestone -> Task/Note.
What each part is for, and when to reach for it:
- Project: the top-level container for a body of work.
- Milestone: groups related tasks within a project toward a goal (status
active/done). Use one when a chunk of work needs its own arc.
active/done). A milestone is ALSO the home of a plan — its `body` holds the
design/intent (Goal/Approach/Verification) and its child tasks are the steps.
Use one when a chunk of work needs its own arc.
- Task: a unit of actionable work with a lifecycle (status
todo/in_progress/done/cancelled, optional priority). A task is a note with a
status — reach for one when there is something to DO. Record progress over
time with work-logs (add_task_log) rather than rewriting the body.
- Plan: a task with kind=plan — HOW you'll execute a chunk of work. The body
holds the design + step checklist; work-logs record progress. Start one with
start_planning when beginning non-trivial work, before writing code.
- Note: durable free-form knowledge — reference material, decisions, dev-logs.
- Plan: a MILESTONE acting as a plan container — HOW you'll execute a chunk of
work. The design/intent lives in the milestone `body`; each step is its own
child task (create_task(milestone_id=...)), tracked with status + work-logs —
NOT a checkbox buried in the body. Start one with start_planning when
beginning non-trivial work, before you dive in; read it back with
get_milestone (body + steps). (The old kind=plan task is retired — some
historical plan-tasks still exist and remain readable, but don't create new
ones.)
- Note: durable free-form knowledge — reference material, decisions, logs of
what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping.
- Typed entities (person/place/list): structured records about people, places,
and checklists.
@@ -43,11 +54,17 @@ Reach for Scribe to RECALL, not just to record. Scribe is a second brain —
its value is mostly in what it already holds, so make searching it a reflex,
not something you wait to be asked for:
- Before you answer a question about the user's work, or start a task, search
Scribe first (search / list_tasks / list_notes). Assume relevant prior art
already exists — a related ticket, an earlier decision, a dev-log — and look
Scribe first (search / list_tasks / list_notes). Assume relevant prior work
already exists — a related task, an earlier decision, a prior note — and look
before you re-derive it or open a duplicate.
- Before creating a task, search for an existing one (search content_type=
'task') — don't open a second ticket for work already tracked.
'task') — don't open a second task for work already tracked.
- create_note / create_task enforce this: if a title- or meaning-similar record
already exists in the same project, the call is BLOCKED and returns
{"duplicate": true, "existing_id": ...} instead of creating. UPDATE that
record (update_note / update_task / add_task_log) rather than duplicating.
Only pass force=true when it's genuinely a distinct record — a duplicate both
bloats the store and surfaces as a stale competing copy in later searches.
- Scope to the project in scope. When a project is active (you called
enter_project), pass its project_id to search / list_tasks / list_notes so
results stay inside that project. Querying with no project_id pulls in every
@@ -69,9 +86,28 @@ Keep task state honest — this is what makes the project a trustworthy record:
- 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.
- At a meaningful point — finishing a task, or hitting or discovering a problem
that changes direction — write a short dated note on the project (create_note)
capturing what happened (the pivots, not just the wins), and set the finished
task to done.
- When you record a problem you solved, capture symptom → root cause → fix
(tag it `issue`) so it's findable later — even one solved in passing is worth
two lines, so it isn't diagnosed from scratch next time.
Compaction hygiene — recommend compacting at clean seams. Because you record
progress as you go, a context compaction is SAFE: the durable state lives in
Scribe (task status, work-logs, decision notes), not the transcript, so it
survives the summary. Use this rather than letting auto-compaction fire mid-task:
- At the end of a coherent block of work (a task closed, a plan phase finished)
in a long session, first make sure in-flight state is actually in Scribe —
update task status, add a work-log, capture any decision as a note. Surface
the few things worth logging before suggesting the compact.
- Then tell the operator it's a good, safe moment to /compact, naming what you
logged ("logged to #X/#Y — safe to /compact, nothing will be lost"). You
cannot run /compact yourself; surface the recommendation and let them decide.
- Recommend it at genuine seams, not every turn. The next session's start will
prompt you to reload your bearings from Scribe — so a clean-seam compact plus
that reload loses nothing.
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
an actionable statement plus optional Why and How-to-apply context. At the
@@ -81,7 +117,7 @@ in scope, get_project(id) returns applicable_rules (rules from rulebooks the
project subscribes to) and subscribed_rulebooks; consult those too. Full text
(Why / How-to-apply) is available via get_rule(id).
Engineering and workflow rules live in Scribe. When you notice a pattern
Workflow and standards rules live in Scribe. When you notice a pattern
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
@@ -139,15 +175,17 @@ adopting or creating — never do either silently, and never guess a project int
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
seeds the plan body, returns the project's applicable_rules, and gives you the
task id you'll write into. If a skill or habit tells you to save a plan or spec
to `docs/superpowers/plans/*.md` or `docs/superpowers/specs/*.md`, that path is
superseded here: put the spec/plan content in the kind=plan task's body via
update_task, and record progress with add_task_log. Local .md files are not
the record — the task is.
A plan is a MILESTONE, and Scribe is the canonical home for it. When you begin
non-trivial work, call start_planning(project_id, title) FIRST — before any
brainstorming, design, or plan-writing skill runs. start_planning creates the
milestone, seeds its `body` with the design template, returns the project's
applicable_rules, and gives you the milestone id you'll write into. Put the
design/intent in the milestone body via update_milestone(milestone_id, body=...);
create each step as a child task with create_task(milestone_id=...) and track it
with status + add_task_log — do NOT list steps as checkboxes in the body. Read
the plan back with get_milestone (body + steps). If a habit tells you to save a
plan or spec to a local `.md` file, that's superseded here: the milestone is the
record, not a local file.
Deletes are recoverable: every delete_* tool moves the entity (and its
descendants) to the trash and returns a deleted_batch_id. Use list_trash() to
@@ -162,12 +200,9 @@ get_process(name) and follow the returned prompt verbatim, including any
"clarify first" steps it contains. Author a new one with create_process(title,
body); edit with update_process.
When you are developing Scribe itself (not just using it as a data store),
honor the multi-user sharing ACL: every read or mutation of user data must
scope by owner + direct shares + group shares through services/access.py
(can_read_* / can_write_* / can_admin_*) — never assume a single operator. An
unscoped query (a fetch-by-id with no ownership check) is a cross-user data
leak; "works for one user" is not done.
When developing Scribe itself, honor its multi-user sharing ACL: scope every
read and mutation of user data by owner + shares — never assume a single
operator. "Works for one user" is not done.
"""
@@ -176,11 +211,12 @@ leak; "works for one user" is not done.
# until explicitly classified here.
_READ_ONLY_TOOLS = frozenset({
"get_event", "get_note", "get_project", "get_rule", "get_rulebook",
"get_task", "get_recent", "enter_project",
"get_task", "get_milestone", "get_recent", "enter_project",
"list_events", "list_lists", "list_milestones", "list_notes",
"list_persons", "list_places", "list_projects", "list_rulebooks",
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"list_always_on_rules", "search",
"get_system", "list_systems", "list_system_records",
})
+2 -1
View File
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, tags, tasks, trash,
entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, systems, tags, tasks, trash,
)
@@ -16,6 +16,7 @@ def register_all(mcp) -> None:
tasks.register(mcp)
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
events.register(mcp)
tags.register(mcp)
recent.register(mcp)
+53 -4
View File
@@ -13,32 +13,75 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
async def list_milestones(project_id: int) -> dict:
"""List milestones for a Scribe project, ordered by order_index.
Returns id, title, description, status (active/done), order_index,
and task counts.
Returns id, title, description, body (the plan/design), status
(active/done), order_index, and task counts.
"""
uid = current_user_id()
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
return {"milestones": rows}
async def get_milestone(milestone_id: int) -> dict:
"""Fetch a milestone (the plan container) with its step-tasks and rules.
A milestone IS a plan: its `body` holds the design/intent, and its steps
are the child tasks listed here. Use this to read a plan top-to-bottom —
the body for the design, `steps` for the trackable units of work. Mirrors
the planning context that start_planning returns (applicable rules), so the
rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / subscribed_rulebooks.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
progress = await milestones_svc.get_milestone_progress(milestone_id)
steps, _ = await notes_svc.list_notes(
uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200,
)
applicable = await rulebooks_svc.get_applicable_rules(
project_id=milestone.project_id, user_id=uid,
)
out = milestone.to_dict()
out.update(progress)
return {
"milestone": out,
"steps": [t.to_dict() for t in steps],
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
}
async def create_milestone(
project_id: int,
title: str,
description: str = "",
body: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Scribe project.
A milestone can serve as a plan container — put the design/intent in `body`
and track each step as a child task (create_task(milestone_id=...)). For a
fresh plan, prefer start_planning, which seeds the body template + surfaces
the project's rules.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
description: Optional one-line summary of what this milestone covers.
body: Optional plan/design (markdown) — the milestone's full plan text.
status: active (default) or done.
"""
uid = current_user_id()
@@ -47,6 +90,7 @@ async def create_milestone(
project_id=project_id,
title=title,
description=description or None,
body=body or None,
status=status,
)
return milestone.to_dict()
@@ -57,6 +101,7 @@ async def update_milestone(
milestone_id: int,
title: str = "",
description: str = "",
body: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
@@ -67,7 +112,8 @@ async def update_milestone(
ownership scoping is enforced by user_id at the service layer).
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
description: New one-line summary, or omit to leave unchanged.
body: New plan/design (markdown), or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
@@ -77,6 +123,8 @@ async def update_milestone(
fields["title"] = title
if description:
fields["description"] = description
if body:
fields["body"] = body
if status:
fields["status"] = status
if order_index >= 0:
@@ -101,6 +149,7 @@ async def delete_milestone(milestone_id: int) -> dict:
def register(mcp) -> None:
for fn in (
list_milestones,
get_milestone,
create_milestone,
update_milestone,
delete_milestone,
+40 -3
View File
@@ -14,7 +14,9 @@ Sentinel conventions (inherited from existing fable-mcp tools):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
@@ -68,6 +70,8 @@ async def create_note(
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
force: bool = False,
) -> dict:
"""Create a new note in Scribe.
@@ -76,10 +80,26 @@ async def create_note(
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
system_ids: Ids of the project's Systems to associate this note with
(e.g. research about a subsystem). See list_systems / create_system.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar note already exists in the same project, creation is
BLOCKED and the existing note's id is returned so you update it
instead (no duplicate bloat / no stale RAG copies). Set true only
when you're sure this is a genuinely distinct note.
Returns the created note object including its assigned id.
Returns the created note object including its assigned id, OR — when a
near-duplicate is found and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created.
"""
uid = current_user_id()
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type="note",
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "note")
note = await notes_svc.create_note(
uid,
title=title,
@@ -87,7 +107,14 @@ async def create_note(
tags=tags,
project_id=project_id or None,
)
return note.to_dict()
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
data = note.to_dict()
if system_ids:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return data
async def update_note(
@@ -96,6 +123,7 @@ async def update_note(
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
system_ids: list[int] | None = None,
) -> dict:
"""Update an existing Scribe note. Only explicitly provided fields are changed.
@@ -105,6 +133,8 @@ async def update_note(
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association. Omit (or pass 0) to leave unchanged.
system_ids: Replace this note's System associations with these ids
(set-semantics). None = leave unchanged; [] = clear all.
"""
uid = current_user_id()
fields: dict = {}
@@ -119,7 +149,14 @@ async def update_note(
note = await notes_svc.update_note(uid, note_id, **fields)
if note is None:
raise ValueError(f"note {note_id} not found")
return note.to_dict()
if system_ids is not None:
await systems_svc.set_record_systems(uid, note_id, system_ids)
data = note.to_dict()
if system_ids is not None:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note_id)
]
return data
async def delete_note(note_id: int) -> dict:
+17
View File
@@ -10,6 +10,7 @@ spec.
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
@@ -257,6 +258,7 @@ async def get_rule(rule_id: int) -> dict:
async def create_rule(
topic_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
force: bool = False,
) -> dict:
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
@@ -275,8 +277,15 @@ async def create_rule(
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the topic (default 0).
force: Bypass the near-duplicate gate. By default, a title-identical rule
already in this topic BLOCKS creation and returns its id so you update
it instead. Set true only for a genuinely distinct rule.
"""
uid = current_user_id()
if not force:
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
if dup is not None:
return dedup_svc.duplicate_response(dup, "rule")
rule = await rulebooks_svc.create_rule(
topic_id=topic_id, user_id=uid,
title=title, statement=statement,
@@ -288,6 +297,7 @@ async def create_rule(
async def create_project_rule(
project_id: int, statement: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
force: bool = False,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
@@ -307,9 +317,16 @@ async def create_project_rule(
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the project's rule list (default 0).
force: Bypass the near-duplicate gate. By default, a title-identical rule
already on this project BLOCKS creation and returns its id so you
update it instead. Set true only for a genuinely distinct rule.
"""
uid = current_user_id()
derived_title = title.strip() or statement.strip().split(".")[0][:50]
if not force:
dup = await dedup_svc.find_duplicate_rule(derived_title, project_id=project_id)
if dup is not None:
return dedup_svc.duplicate_response(dup, "rule")
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
+150
View File
@@ -0,0 +1,150 @@
"""System CRUD + record-association MCP tools — wrappers over services/systems.py.
A System is a per-project, reusable, self-describing subsystem/area that any
record (note, task, or issue) can be associated with — so research, build-work,
and corrective work line up under the same area and recurring problem-spots are
visible. The service enforces the multi-user ACL (project permission); these
tools are thin wrappers.
Sentinels (match the milestone/task tool conventions):
- name="" / description="" / color="" / status="""leave unchanged" on update
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import systems as systems_svc
async def create_system(
project_id: int,
name: str,
description: str = "",
color: str = "",
) -> dict:
"""Create a System (a reusable, self-describing subsystem/area) in a project.
Associate records with it via the `system_ids` arg on create/update_task and
create/update_note.
Args:
project_id: The project this system belongs to (required).
name: Short label (required).
description: What the system is and how it's used — a name is rarely enough.
color: Optional UI accent (hex), or empty.
"""
uid = current_user_id()
system = await systems_svc.create_system(
uid, project_id=project_id, name=name,
description=description or None, color=color or None,
)
if system is None:
raise ValueError(f"cannot create system in project {project_id} (no write access)")
return system.to_dict()
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
"""List a project's systems (active by default), ordered by order_index.
Pass include_archived=True to include archived systems.
"""
uid = current_user_id()
rows = await systems_svc.list_systems(uid, project_id, include_archived=include_archived)
return {"systems": [s.to_dict() for s in rows]}
async def get_system(system_id: int) -> dict:
"""Fetch a System plus the records associated with it.
Returns the system, plus its associated records split into `issues`,
`tasks` (work/plan), and `notes`.
"""
uid = current_user_id()
system = await systems_svc.get_system(uid, system_id)
if system is None:
raise ValueError(f"system {system_id} not found")
records = await systems_svc.list_records_for_system(uid, system_id)
issues, tasks, notes = [], [], []
for r in records:
d = r.to_dict()
if r.status is None:
notes.append(d)
elif r.task_kind == "issue":
issues.append(d)
else:
tasks.append(d)
data = system.to_dict()
data["issues"] = issues
data["tasks"] = tasks
data["notes"] = notes
return data
async def update_system(
system_id: int,
name: str = "",
description: str = "",
color: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a System. Only explicitly provided fields change.
Args:
status: 'active' or 'archived'. Archive a system to retire it without
losing history; archived systems hide from default lists.
order_index: display position (0-based); -1 = leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
if name:
fields["name"] = name
if description:
fields["description"] = description
if color:
fields["color"] = color
if status:
fields["status"] = status
if order_index >= 0:
fields["order_index"] = order_index
system = await systems_svc.update_system(uid, system_id, **fields)
if system is None:
raise ValueError(f"system {system_id} not found or no write access")
return system.to_dict()
async def list_system_records(
system_id: int, kind: str = "", open_only: bool = False
) -> dict:
"""List records associated with a System.
Args:
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
open_only: limit to tasks not done/cancelled (e.g. open issues only).
"""
uid = current_user_id()
rows = await systems_svc.list_records_for_system(
uid, system_id, kind=kind or None, open_only=open_only,
)
return {"records": [r.to_dict() for r in rows]}
async def delete_system(system_id: int) -> dict:
"""Soft-delete a System (recoverable). Its record associations are removed."""
uid = current_user_id()
ok = await systems_svc.delete_system(uid, system_id)
if not ok:
raise ValueError(f"system {system_id} not found or no write access")
return {"message": f"System {system_id} deleted."}
def register(mcp) -> None:
for fn in (
create_system,
list_systems,
get_system,
update_system,
list_system_records,
delete_system,
):
mcp.tool(name=fn.__name__)(fn)
+84 -12
View File
@@ -19,9 +19,11 @@ Sentinels (preserved from existing fable-mcp):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
from scribe.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import task_logs as task_logs_svc
from scribe.services import trash as trash_svc
@@ -41,7 +43,7 @@ async def list_tasks(
whenever a project is in scope so you list that project's tasks, not
every project's. 0 = no filter (all projects — use only for a
deliberate cross-project view).
kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
kind: Filter by task kind — 'work', 'plan', or 'issue'. Omit (empty) for all kinds.
Results are ordered by last-updated descending.
"""
@@ -62,9 +64,10 @@ async def get_task(task_id: int) -> dict:
"""Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at. For kind=plan
tasks, the response also includes applicable_rules + subscribed_rulebooks
from the task's project's rulebook subscriptions.
parent_id, parent_title, due_date, created_at, updated_at. For legacy
kind=plan tasks, the response also includes applicable_rules +
subscribed_rulebooks from the task's project's rulebook subscriptions (new
plans are milestones — use get_milestone for those).
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, task_id)
@@ -78,6 +81,8 @@ async def get_task(task_id: int) -> dict:
parent_title = parent.title
data["parent_title"] = parent_title
# Legacy kind=plan tasks predate milestone-as-plan; still surface their
# project's rules on read so the historical plans stay useful.
if data.get("task_kind") == "plan" and note.project_id:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid,
@@ -101,6 +106,9 @@ async def create_task(
parent_id: int = 0,
tags: list[str] | None = None,
kind: str = "work",
system_ids: list[int] | None = None,
arose_from_id: int = 0,
force: bool = False,
) -> dict:
"""Create a new task in Scribe.
@@ -113,9 +121,38 @@ async def create_task(
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
kind: 'work' (default) or 'plan'. Prefer the start_planning tool to create plans.
kind: 'work' (default) or 'issue'. An issue is corrective work — a
problem you fixed or are fixing; record symptom → root cause → fix
in the body. (Plans are milestones now — call start_planning to begin
a plan; 'plan' is not a valid kind here.)
system_ids: Ids of the project's Systems (reusable subsystem/area
objects; see list_systems / create_system) to associate this task with.
arose_from_id: For an issue, the id of the task/feature it arose from
(provenance). 0 = none.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar task already exists in the same project, creation is
BLOCKED and the existing task's id is returned so you update it
instead. Set true only for a genuinely distinct task.
Returns the created task, OR — when a near-duplicate is found and force is
false — {"duplicate": true, "existing_id": ..., "message": ...} (nothing
created).
"""
uid = current_user_id()
if kind == "plan":
raise ValueError(
"kind=plan is retired — a plan is now a milestone. Call "
"start_planning(project_id, title) to begin a plan (it creates the "
"milestone + seeds the design), then create each step as its own "
"task with create_task(milestone_id=<that milestone>)."
)
if not force:
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=True, note_type="note",
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "task")
note = await notes_svc.create_note(
uid,
title=title,
@@ -127,8 +164,16 @@ async def create_task(
parent_id=parent_id or None,
tags=tags,
task_kind=kind,
arose_from_id=arose_from_id or None,
)
return note.to_dict()
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
data = note.to_dict()
if system_ids:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
return data
async def update_task(
@@ -139,6 +184,8 @@ async def update_task(
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
system_ids: list[int] | None = None,
arose_from_id: int = 0,
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
@@ -154,6 +201,10 @@ async def update_task(
its project; also clears the milestone), positive = set.
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
from its milestone), positive = set.
system_ids: Replace this task's System associations with these ids
(set-semantics). None = leave unchanged; [] = clear all.
arose_from_id: Provenance (issue → originating task). 0 = leave unchanged,
-1 = clear, positive = set.
"""
uid = current_user_id()
fields: dict = {}
@@ -175,10 +226,21 @@ async def update_task(
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id
if arose_from_id == -1:
fields["arose_from_id"] = None
elif arose_from_id:
fields["arose_from_id"] = arose_from_id
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
return note.to_dict()
if system_ids is not None:
await systems_svc.set_record_systems(uid, task_id, system_ids)
data = note.to_dict()
if system_ids is not None:
data["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)
]
return data
async def add_task_log(task_id: int, content: str) -> dict:
@@ -199,14 +261,24 @@ async def add_task_log(task_id: int, content: str) -> dict:
async def start_planning(project_id: int, title: str) -> dict:
"""Begin a plan in Scribe (the preferred home for plans — not a local .md file).
Creates a plan-task (a task with kind=plan) seeded with a plan template under
the given project, and returns it together with the project's applicable
Rulebook rules and brief context. Maintain the plan afterwards with the normal
task tools (update_task to edit the body, add_task_log to record progress).
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
template (Goal/Approach/Verification) under the given project, and the call
returns it together with the project's applicable Rulebook rules and brief
context. The milestone is the plan container — the individual steps live as
first-class child tasks under it, not as checkboxes in the body.
Afterwards:
- Edit the plan/design with update_milestone(milestone_id, body=...).
- Create each step as its own task with create_task(milestone_id=<this id>);
track it with status + add_task_log. Do NOT put steps as checkboxes in the
milestone body.
(kind=plan tasks are retired — use this instead. Existing historical
plan-tasks remain readable but new planning goes through milestones.)
Args:
project_id: The project this plan is for.
title: A short title for the plan.
title: A short title for the plan/milestone.
"""
uid = current_user_id()
return await planning_svc.start_planning(
+1
View File
@@ -41,3 +41,4 @@ from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
+5
View File
@@ -13,6 +13,10 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"))
title: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# The plan: design/intent/purpose (markdown). The milestone is the plan
# container; its steps live as first-class child tasks (milestone_id), not
# as checkboxes in this body. `description` stays the one-line summary.
body: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0)
@@ -23,6 +27,7 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"project_id": self.project_id,
"title": self.title,
"description": self.description,
"body": self.body,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
+12 -3
View File
@@ -40,6 +40,12 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
# Provenance: the task/feature an issue arose from. Distinct from parent_id
# (sub-task hierarchy) — this is "what spawned this". Only meaningful for
# issues; nullable for every record.
arose_from_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
@@ -60,9 +66,10 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# Structured metadata for entity types (person/place/list)
# Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute
entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
# Task sub-kind — 'work' (default) or 'plan'. Only meaningful when the note
# is a task (status is not None); ordinary notes keep the 'work' default and
# ignore it. Orthogonal to note_type (which is the note/entity axis).
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
# Only meaningful when the note is a task (status is not None); ordinary
# notes keep the 'work' default and ignore it. Orthogonal to note_type
# (which is the note/entity axis).
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
__table_args__ = (
@@ -73,6 +80,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
Index("ix_notes_project_id", "project_id"),
Index("ix_notes_milestone_id", "milestone_id"),
Index("ix_notes_note_type", "note_type"),
Index("ix_notes_arose_from_id", "arose_from_id"),
)
@property
@@ -95,6 +103,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
),
"tags": self.tags or [],
"parent_id": self.parent_id,
"arose_from_id": self.arose_from_id,
"project_id": self.project_id,
"milestone_id": self.milestone_id,
"status": self.status,
+73
View File
@@ -0,0 +1,73 @@
from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin, SoftDeleteMixin
class System(Base, TimestampMixin, SoftDeleteMixin):
"""A per-project, reusable, self-describing subsystem/area.
Any record (note, task, or issue) can be associated with one or more
systems via record_systems, so research notes, build-tasks, and corrective
work line up under the same area — and recurring problem-areas become
filterable. Self-describing (name + description) because a bare name is
never enough to convey what a system is or how it's used.
"""
__tablename__ = "systems"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE")
)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(Text, default="", server_default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(Text, nullable=True)
# active | archived — systems accumulate; archive rather than delete.
status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
__table_args__ = (
Index("ix_systems_project_id", "project_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"project_id": self.project_id,
"name": self.name,
"description": self.description,
"color": self.color,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class RecordSystem(Base, CreatedAtMixin):
"""M2M association: a record (notes.id — any note/task/issue) <-> a System.
Mutable over time; uniqueness keeps a record from linking the same system
twice. Cascades on both sides, so deleting a record or a system clears its
associations.
"""
__tablename__ = "record_systems"
id: Mapped[int] = mapped_column(primary_key=True)
note_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE")
)
system_id: Mapped[int] = mapped_column(
Integer, ForeignKey("systems.id", ondelete="CASCADE")
)
__table_args__ = (
UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
Index("ix_record_systems_note_id", "note_id"),
Index("ix_record_systems_system_id", "system_id"),
)
+2 -1
View File
@@ -62,6 +62,7 @@ async def create_milestone_route(project_id: int):
project_id,
title=data["title"],
description=data.get("description"),
body=data.get("body"),
order_index=data.get("order_index", 0),
status=status,
)
@@ -92,7 +93,7 @@ async def update_milestone_route(project_id: int, milestone_id: int):
if milestone is None:
return not_found("Milestone")
data = await request.get_json()
allowed = {"title", "description", "status", "order_index"}
allowed = {"title", "description", "body", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "done"):
return jsonify({"error": "status must be 'active' or 'done'"}), 400
+14
View File
@@ -57,6 +57,20 @@ async def session_context():
return jsonify(result)
@plugin_bp.get("/processes")
@login_required
async def process_manifest():
"""Stored Processes as skill-stub specs for the plugin's sync script.
The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the
`/scribe:sync` command) curls this and writes one auto-surfacing local skill
per Process into ~/.claude/skills/. See services/plugin_context.
build_process_manifest. A read-scoped API key suffices.
"""
result = await plugin_ctx_svc.build_process_manifest(g.user.id)
return jsonify(result)
@plugin_bp.get("/marketplace-url")
@login_required
async def get_marketplace_url():
+155
View File
@@ -0,0 +1,155 @@
"""System routes nested under /api/projects/<project_id>/systems, plus the
project's open-issues list. A System is a per-project, reusable subsystem/area
that records (notes/tasks/issues) associate with."""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found
from scribe.services import systems as systems_svc
from scribe.services.access import can_write_project
from scribe.services.projects import get_project_for_user
logger = logging.getLogger(__name__)
systems_bp = Blueprint("systems", __name__, url_prefix="/api/projects")
def _truthy(v: str | None) -> bool:
return (v or "").lower() in ("1", "true", "yes")
def _split_records(records: list) -> tuple[list, list, list]:
issues, tasks, notes = [], [], []
for r in records:
d = r.to_dict()
if r.status is None:
notes.append(d)
elif r.task_kind == "issue":
issues.append(d)
else:
tasks.append(d)
return issues, tasks, notes
@systems_bp.route("/<int:project_id>/systems", methods=["GET"])
@login_required
async def list_systems_route(project_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
systems = await systems_svc.list_systems(
uid, project_id, include_archived=_truthy(request.args.get("include_archived")),
)
counts = await systems_svc.open_issue_counts_by_system(uid, project_id)
out = []
for s in systems:
d = s.to_dict()
d["open_issue_count"] = counts.get(s.id, 0)
out.append(d)
return jsonify({"systems": out})
@systems_bp.route("/<int:project_id>/systems", methods=["POST"])
@login_required
async def create_system_route(project_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0),
)
if system is None:
return jsonify({"error": "Permission denied"}), 403
return jsonify(system.to_dict()), 201
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
@login_required
async def get_system_route(project_id: int, system_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
system = await systems_svc.get_system(uid, system_id)
if system is None or system.project_id != project_id:
return not_found("System")
issues, tasks, notes = _split_records(
await systems_svc.list_records_for_system(uid, system_id)
)
data = system.to_dict()
data["issues"], data["tasks"], data["notes"] = issues, tasks, notes
return jsonify(data)
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["PATCH"])
@login_required
async def update_system_route(project_id: int, system_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
system = await systems_svc.get_system(uid, system_id)
if system is None or system.project_id != project_id:
return not_found("System")
data = await request.get_json() or {}
allowed = {"name", "description", "color", "status", "order_index"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "archived"):
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
updated = await systems_svc.update_system(uid, system_id, **fields)
if updated is None:
return not_found("System")
return jsonify(updated.to_dict())
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["DELETE"])
@login_required
async def delete_system_route(project_id: int, system_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
if not await can_write_project(uid, project_id):
return jsonify({"error": "Permission denied"}), 403
system = await systems_svc.get_system(uid, system_id)
if system is None or system.project_id != project_id:
return not_found("System")
await systems_svc.delete_system(uid, system_id)
return "", 204
@systems_bp.route("/<int:project_id>/systems/<int:system_id>/records", methods=["GET"])
@login_required
async def system_records_route(project_id: int, system_id: int):
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
system = await systems_svc.get_system(uid, system_id)
if system is None or system.project_id != project_id:
return not_found("System")
records = await systems_svc.list_records_for_system(
uid, system_id,
kind=request.args.get("kind") or None,
open_only=_truthy(request.args.get("open_only")),
)
return jsonify({"records": [r.to_dict() for r in records]})
@systems_bp.route("/<int:project_id>/issues", methods=["GET"])
@login_required
async def project_issues_route(project_id: int):
"""A project's issues (open by default — pass open_only=false for all)."""
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
open_only = request.args.get("open_only", "true").lower() in ("1", "true", "yes")
issues = await systems_svc.list_issues(uid, project_id, open_only=open_only)
return jsonify({"issues": [n.to_dict() for n in issues]})
+23 -3
View File
@@ -7,6 +7,7 @@ from scribe.auth import login_required, get_current_user_id
from scribe.models.note import TaskPriority, TaskStatus
from scribe.routes.utils import not_found, parse_iso_date, parse_pagination
from scribe.services.access import can_write_note
from scribe.services import systems as systems_svc
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.notes import (
create_note,
@@ -98,6 +99,15 @@ async def create_task_route():
description = data.get("description")
tags = data.get("tags", [])
# kind=plan is retired — plans are milestones now (see start_planning).
# The 'plan' enum value stays valid for legacy tasks, but new ones can't
# be created with it through any path.
if data.get("kind") == "plan":
return jsonify({
"error": "kind=plan is retired — plans are milestones. "
"Use POST /api/tasks/planning to start a plan."
}), 400
due_date = parse_iso_date(data.get("due_date"), "due_date")
if isinstance(due_date, tuple):
return due_date
@@ -136,11 +146,16 @@ async def create_task_route():
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
task_kind=data.get("kind", "work"),
arose_from_id=data.get("arose_from_id"),
)
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task.id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict()), 201
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task.id)]
return jsonify(out), 201
@tasks_bp.route("/planning", methods=["POST"])
@@ -174,6 +189,7 @@ async def get_task_route(task_id: int):
if task.parent_id:
parent = await get_note(uid, task.parent_id)
data["parent_title"] = parent.title if parent else None
data["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
return jsonify(data)
@@ -223,7 +239,7 @@ async def update_task_route(task_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
for key in ("project_id", "milestone_id", "parent_id"):
for key in ("project_id", "milestone_id", "parent_id", "arose_from_id"):
if key in data:
fields[key] = data[key]
@@ -236,10 +252,14 @@ async def update_task_route(task_id: int):
task = await update_note(task_note.user_id, task_id, **fields)
if task is None:
return not_found("Task")
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, task_id, data["system_ids"])
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, task_note.user_id, text))
return jsonify(task.to_dict())
out = task.to_dict()
out["systems"] = [s.to_dict() for s in await systems_svc.list_record_systems(uid, task_id)]
return jsonify(out)
@tasks_bp.route("/<int:task_id>/status", methods=["PATCH"])
+32
View File
@@ -22,6 +22,7 @@ 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
OPEN_ISSUES_LIMIT = 10 # open issues shown on the dashboard
WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window
_OPEN = ["todo", "in_progress"]
@@ -49,11 +50,42 @@ async def _safe(coro, empty):
return empty
async def _open_issues(user_id: int) -> list[dict]:
"""Open issues (task_kind='issue', not done/cancelled) across the owner's
projects, ranked like the other task lists. Owner-scoped, matching the rest
of the dashboard."""
async with async_session() as session:
rows = await session.execute(
select(
Note.id, Note.title, Note.status, Note.priority,
Note.project_id, Project.title,
)
.join(Project, Project.id == Note.project_id, isouter=True)
.where(
Note.user_id == user_id,
Note.task_kind == "issue",
Note.status.in_(_OPEN),
Note.deleted_at.is_(None),
)
.order_by(*_open_order())
.limit(OPEN_ISSUES_LIMIT)
)
return [
{
"id": iid, "title": title, "status": status,
"priority": priority or "none",
"project_id": pid, "project_title": pname,
}
for iid, title, status, priority, pid, pname in rows.fetchall()
]
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), []),
"open_issues": await _safe(_open_issues(user_id), []),
"week_stats": await _safe(_week_stats(user_id), {}),
}
+164
View File
@@ -0,0 +1,164 @@
"""Write-time near-duplicate detection — the update-over-create gate.
Goal: stop a second near-identical row from being created when an existing one
should be UPDATED instead. Duplicates bloat the store and, worse, get surfaced
by semantic search (RAG) later as competing/stale copies that then have to be
reconciled. This is the enforcement half of the instruction-level "prefer
updating over creating" reflex.
OPT-IN by design: the interactive create paths (MCP create tools + REST create
routes) run this gate; internal/programmatic creates do NOT (e.g. a recurring
task spawning its next instance, or a bulk import — those legitimately repeat a
title and must not be blocked). Callers that want the gate call find_duplicate_*
themselves and act on a hit; nothing here mutates.
Two signals, both scoped to the same owner + project + kind:
1. Normalized-title exact match — cheap, always checked.
2. Semantic similarity (cosine ≥ _SEMANTIC_THRESHOLD) — only when the incoming
body is substantial. Short/title-only embeddings sit in a tight neighborhood
and false-positive (the pre-pivot lesson: "Lore: Shell 0" vs
"Lore: Reinitialization 0" matched at 0.91 with no body), so we gate it on
a minimum body length.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.rulebook import Rule
from scribe.services import embeddings as embeddings_svc
logger = logging.getLogger(__name__)
# Run the semantic check only when the incoming body has at least this many
# characters — below it, embeddings are dominated by the title and false-positive.
_MIN_BODY_FOR_SEMANTIC = 200
# Cosine threshold for "this is the same thing, reworded." Deliberately high to
# keep false positives rare (a hard block with a force-override is unforgiving of
# noise). Matches the 0.90 the pre-pivot dedup settled on.
_SEMANTIC_THRESHOLD = 0.90
@dataclass
class DuplicateMatch:
"""An existing record judged a near-duplicate of an incoming create."""
id: int
title: str
similarity: float # 1.0 for an exact normalized-title match
reason: str # "title" | "semantic"
def duplicate_response(dup: "DuplicateMatch", kind: str) -> dict:
"""Standard 'blocked — update instead' payload returned by a create tool
when the gate finds a near-duplicate. `kind` is 'note' or 'task' (drives the
update_<kind> hint)."""
return {
"duplicate": True,
"existing_id": dup.id,
"existing_title": dup.title,
"similarity": dup.similarity,
"match": dup.reason,
"message": (
f'A {dup.reason}-similar {kind} already exists (id {dup.id}: '
f'"{dup.title}"). Prefer UPDATING it (update_{kind}) over creating a '
f"near-duplicate. If this really is a distinct {kind}, retry with "
f"force=true."
),
}
async def find_duplicate_note(
user_id: int,
title: str,
body: str = "",
project_id: int | None = None,
is_task: bool | None = None,
note_type: str = "note",
) -> DuplicateMatch | None:
"""Best near-duplicate of (title, body) within the same owner + project +
kind, or None. Title match first (cheap, exact), then semantic when the body
is long enough to be meaningful. Never raises — embedder failure degrades to
title-only (callers should still be able to create)."""
norm = " ".join((title or "").split()).lower()
# --- Signal 1: normalized-title exact match (same scope) ---
# Fail-open: a dedup-check failure (DB down, etc.) must never block a
# legitimate create — degrade to "no duplicate found" and let it through.
if norm:
try:
async with async_session() as session:
stmt = select(Note).where(
Note.user_id == user_id,
Note.deleted_at.is_(None),
Note.note_type == note_type,
func.lower(func.trim(Note.title)) == norm,
)
if project_id is not None:
stmt = stmt.where(Note.project_id == project_id)
else:
stmt = stmt.where(Note.project_id.is_(None))
if is_task is True:
stmt = stmt.where(Note.status.isnot(None))
elif is_task is False:
stmt = stmt.where(Note.status.is_(None))
existing = (await session.execute(stmt.limit(1))).scalars().first()
if existing is not None:
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
except Exception:
logger.debug("dedup title check skipped — query failed", exc_info=True)
return None
# --- Signal 2: semantic similarity (only with a substantial body) ---
if body and len(body.strip()) >= _MIN_BODY_FOR_SEMANTIC:
query = f"{title}\n{body}".strip()
# Scope the semantic check the same way as the title check: a record in
# project P compares only to P; a project-less (orphan) record compares
# only to other orphans (orphan_only), NOT across every project — without
# this, semantic_search_notes applies no project filter when project_id
# is None and would match an orphan note against any project's notes.
hits = await embeddings_svc.semantic_search_notes(
user_id, query, project_id=project_id, is_task=is_task,
orphan_only=(project_id is None),
limit=3, threshold=_SEMANTIC_THRESHOLD,
)
for score, note in hits:
# semantic_search_notes doesn't filter note_type — enforce it here so
# a note doesn't shadow a task of the same wording, etc.
if note.note_type == note_type:
return DuplicateMatch(note.id, note.title, round(score, 3), "semantic")
return None
async def find_duplicate_rule(
title: str,
topic_id: int | None = None,
project_id: int | None = None,
) -> DuplicateMatch | None:
"""Title-based near-duplicate of a rule, scoped to the same topic (a rulebook
rule) or the same project (a project rule). Rules aren't a semantic-retrieval
surface, so a normalized-title match is the right (and only) signal. Fail-open
like find_duplicate_note."""
norm = " ".join((title or "").split()).lower()
if not norm or (topic_id is None and project_id is None):
return None
try:
async with async_session() as session:
stmt = select(Rule).where(
Rule.deleted_at.is_(None),
func.lower(func.trim(Rule.title)) == norm,
)
if topic_id is not None:
stmt = stmt.where(Rule.topic_id == topic_id)
else:
stmt = stmt.where(Rule.project_id == project_id)
existing = (await session.execute(stmt.limit(1))).scalars().first()
if existing is not None:
return DuplicateMatch(existing.id, existing.title, 1.0, "title")
except Exception:
logger.debug("dedup rule title check skipped — query failed", exc_info=True)
return None
+2
View File
@@ -16,6 +16,7 @@ async def create_milestone(
project_id: int,
title: str,
description: str | None = None,
body: str | None = None,
order_index: int = 0,
status: str = "active",
) -> Milestone:
@@ -25,6 +26,7 @@ async def create_milestone(
project_id=project_id,
title=title,
description=description,
body=body,
status=status,
order_index=order_index,
)
+2
View File
@@ -65,6 +65,7 @@ async def create_note(
note_type: str = "note",
entity_meta: dict | None = None,
task_kind: str = "work",
arose_from_id: int | None = None,
) -> Note:
# Validate status/priority here so the MCP create_task path (which passes
# them straight through) can't persist an out-of-enum value that the REST
@@ -108,6 +109,7 @@ async def create_note(
note_type=note_type,
entity_meta=entity_meta,
task_kind=task_kind,
arose_from_id=arose_from_id,
)
session.add(note)
await session.commit()
+18 -13
View File
@@ -1,31 +1,37 @@
"""Planning service — start_planning aggregates plan-task creation with the
project's applicable Rulebook rules and a little context, so planning happens
in Scribe and rules surface at the planning moment.
"""Planning service — start_planning creates a MILESTONE seeded as the plan
container, surfacing the project's applicable Rulebook rules at the planning
moment so rules land before any work.
The milestone IS the plan: its `body` holds the design/intent (Goal/Approach/
Verification), and the individual steps live as first-class child tasks
(milestone_id) rather than checkboxes crammed into one body. The legacy
kind=plan task is retired going forward — start_planning never creates one.
"""
from __future__ import annotations
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
# The plan body template — design only. Steps are NOT checkboxes here; each
# step becomes its own child task under this milestone (status, work-logs,
# priority of its own).
PLAN_TEMPLATE = """## Goal
## Approach
## Steps
- [ ]
## Verification
"""
async def start_planning(user_id: int, project_id: int, title: str) -> dict:
"""Create a plan-task seeded with the plan template and return it with the
"""Create a milestone seeded as a plan container and return it with the
project's applicable rules + brief context.
Returns:
{
"task": <task dict>,
"milestone": <milestone dict>,
"applicable_rules": [...],
"subscribed_rulebooks": [...],
"applicable_rules_truncated": bool,
@@ -37,13 +43,12 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
if project is None:
raise ValueError(f"project {project_id} not found")
note = await notes_svc.create_note(
milestone = await milestones_svc.create_milestone(
user_id,
project_id=project_id,
title=title,
body=PLAN_TEMPLATE,
status="todo",
task_kind="plan",
project_id=project_id,
status="active",
)
applicable = await rulebooks_svc.get_applicable_rules(
@@ -54,7 +59,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
)
return {
"task": note.to_dict(),
"milestone": milestone.to_dict(),
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
+56
View File
@@ -14,10 +14,13 @@ index alone already steers behavior.
"""
from __future__ import annotations
import re
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.rulebook import RulebookTopic
from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
@@ -25,6 +28,59 @@ from scribe.services import rulebooks as rulebooks_svc
# Defensive cap below Claude Code's 10k additionalContext limit.
_MAX_CHARS = 9000
# Max chars of a Process body to fold into the auto-surface description.
_PROC_PREVIEW_CHARS = 200
def _slugify(text: str) -> str:
"""kebab-case slug for a skill directory name (a-z0-9 + single hyphens)."""
s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
return s or "process"
async def build_process_manifest(user_id: int) -> dict:
"""List the user's stored Processes as auto-surfacing skill-stub specs.
The plugin's sync script (scribe_sync_processes.sh) writes one
~/.claude/skills/scribe-proc-<slug>/SKILL.md per entry — `description` is the
auto-surface trigger, and the stub body calls get_process(name) for the live
procedure (single source of truth in the DB). Reuses the list_processes query
(note_type='process'). Instance-agnostic: derived from whatever Processes the
calling install owns, no operator-specific coupling.
Returns {"processes": [{id, name, slug, description}], "total": int}.
Slugs are unique within the result (a collision gets an -<id> suffix).
"""
items, _ = await knowledge_svc.query_knowledge(
user_id=user_id, note_type="process", tags=[], sort="modified",
q=None, limit=100, offset=0,
)
procs: list[dict] = []
seen: set[str] = set()
for it in items:
title = (it.get("title") or "").strip()
if not title:
continue
slug = _slugify(title)
if slug in seen:
slug = f"{slug}-{it['id']}"
seen.add(slug)
preview = " ".join((it.get("snippet") or "").split())
if len(preview) > _PROC_PREVIEW_CHARS:
preview = preview[:_PROC_PREVIEW_CHARS].rstrip() + ""
description = (
f'Run the operator\'s saved Scribe process "{title}".'
+ (f" {preview}" if preview else "")
+ f' Use when {title}-type work is requested, or when asked to run'
f' the "{title}" process.'
)
procs.append({
"id": it["id"], "name": title, "slug": slug,
"description": description,
})
return {"processes": procs, "total": len(procs)}
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
"""Map topic_id -> title for the given ids (live topics only)."""
+249
View File
@@ -0,0 +1,249 @@
"""System management + record<->system associations.
A System is a per-project, reusable, self-describing subsystem/area. Access is
governed by the project's permission via services/access.py (multi-user ACL
rule) — never a bare owner filter. Records (notes/tasks/issues) link to systems
many-to-many through record_systems, mutable over time.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import delete, func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.system import RecordSystem, System
from scribe.services import access
logger = logging.getLogger(__name__)
async def create_system(
user_id: int,
project_id: int,
name: str,
description: str | None = None,
color: str | None = None,
order_index: int = 0,
) -> System | None:
"""Create a System. None if the user can't write the project."""
if not await access.can_write_project(user_id, project_id):
return None
async with async_session() as session:
system = System(
user_id=user_id,
project_id=project_id,
name=name.strip(),
description=description,
color=color,
order_index=order_index,
)
session.add(system)
await session.commit()
await session.refresh(system)
return system
async def get_system(user_id: int, system_id: int) -> System | None:
"""Fetch a System if the user can read its project."""
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_read_project(user_id, system.project_id):
return None
return system
async def list_systems(
user_id: int, project_id: int, include_archived: bool = False
) -> list[System]:
"""A project's systems (active by default). [] if no read access."""
if not await access.can_read_project(user_id, project_id):
return []
async with async_session() as session:
query = select(System).where(
System.project_id == project_id,
System.deleted_at.is_(None),
)
if not include_archived:
query = query.where(System.status == "active")
query = query.order_by(System.order_index.asc(), System.created_at.asc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
"""Update a System if the user can write its project."""
allowed = {"name", "description", "color", "status", "order_index"}
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_write_project(user_id, system.project_id):
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(system, key, value)
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def archive_system(user_id: int, system_id: int) -> System | None:
return await update_system(user_id, system_id, status="archived")
async def delete_system(user_id: int, system_id: int) -> bool:
"""Soft-delete a System (recoverable). Requires project write access."""
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return False
if not await access.can_write_project(user_id, system.project_id):
return False
system.deleted_at = datetime.now(timezone.utc)
await session.commit()
return True
# --- record <-> system associations (many-to-many, mutable) ---
async def set_record_systems(
user_id: int, note_id: int, system_ids: list[int]
) -> list[int] | None:
"""Replace a record's system associations with `system_ids` (set semantics).
Returns the resulting associated system ids, or None if the user can't write
the record. Silently drops ids that don't exist or the user can't read —
associations only link accessible systems.
"""
if not await access.can_write_note(user_id, note_id):
return None
async with async_session() as session:
wanted: list[int] = []
for sid in dict.fromkeys(system_ids): # de-dup, preserve order
system = await session.get(System, sid)
if system is None or system.deleted_at is not None:
continue
if not await access.can_read_project(user_id, system.project_id):
continue
wanted.append(sid)
existing = set(
(
await session.execute(
select(RecordSystem.system_id).where(RecordSystem.note_id == note_id)
)
).scalars().all()
)
wanted_set = set(wanted)
to_remove = existing - wanted_set
if to_remove:
await session.execute(
delete(RecordSystem).where(
RecordSystem.note_id == note_id,
RecordSystem.system_id.in_(to_remove),
)
)
for sid in wanted:
if sid not in existing:
session.add(RecordSystem(note_id=note_id, system_id=sid))
await session.commit()
return wanted
async def list_record_systems(user_id: int, note_id: int) -> list[System]:
"""Systems associated with a record (if the user can read it)."""
if not await access.can_read_note(user_id, note_id):
return []
async with async_session() as session:
result = await session.execute(
select(System)
.join(RecordSystem, RecordSystem.system_id == System.id)
.where(RecordSystem.note_id == note_id, System.deleted_at.is_(None))
.order_by(System.order_index.asc(), System.name.asc())
)
return list(result.scalars().all())
async def list_records_for_system(
user_id: int, system_id: int, kind: str | None = None, open_only: bool = False
) -> list[Note]:
"""Records associated with a System. `kind` filters task_kind (e.g. 'issue');
`open_only` limits to tasks not done/cancelled. [] if no read access."""
system = await get_system(user_id, system_id)
if system is None:
return []
async with async_session() as session:
query = (
select(Note)
.join(RecordSystem, RecordSystem.note_id == Note.id)
.where(RecordSystem.system_id == system_id, Note.deleted_at.is_(None))
)
if kind:
query = query.where(Note.task_kind == kind)
if open_only:
query = query.where(Note.status.not_in(["done", "cancelled"]))
query = query.order_by(Note.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def count_open_issues(user_id: int, project_id: int) -> int:
"""Count open (not done/cancelled) issues in a project. 0 if no read access."""
if not await access.can_read_project(user_id, project_id):
return 0
async with async_session() as session:
result = await session.execute(
select(func.count(Note.id)).where(
Note.project_id == project_id,
Note.task_kind == "issue",
Note.status.isnot(None),
Note.status.not_in(["done", "cancelled"]),
Note.deleted_at.is_(None),
)
)
return int(result.scalar() or 0)
async def open_issue_counts_by_system(user_id: int, project_id: int) -> dict[int, int]:
"""{system_id: open-issue count} for all systems in a project, in one query.
{} if no read access."""
if not await access.can_read_project(user_id, project_id):
return {}
async with async_session() as session:
rows = await session.execute(
select(RecordSystem.system_id, func.count(Note.id))
.join(Note, Note.id == RecordSystem.note_id)
.join(System, System.id == RecordSystem.system_id)
.where(
System.project_id == project_id,
Note.task_kind == "issue",
Note.status.isnot(None),
Note.status.not_in(["done", "cancelled"]),
Note.deleted_at.is_(None),
)
.group_by(RecordSystem.system_id)
)
return {sid: cnt for sid, cnt in rows.fetchall()}
async def list_issues(user_id: int, project_id: int, open_only: bool = True) -> list[Note]:
"""Issues in a project (open by default), newest first. [] if no read access."""
if not await access.can_read_project(user_id, project_id):
return []
async with async_session() as session:
query = select(Note).where(
Note.project_id == project_id,
Note.task_kind == "issue",
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
if open_only:
query = query.where(Note.status.not_in(["done", "cancelled"]))
query = query.order_by(Note.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
+59 -1
View File
@@ -5,7 +5,7 @@ import pytest
from scribe.mcp._context import _user_id_ctx
from scribe.mcp.tools.milestones import (
list_milestones, create_milestone, update_milestone,
list_milestones, get_milestone, create_milestone, update_milestone,
)
@@ -57,6 +57,64 @@ async def test_create_milestone_empty_description_becomes_none():
assert mock.call_args.kwargs["description"] is None
@pytest.mark.asyncio
async def test_create_milestone_passes_body_through():
"""The milestone-as-plan body is forwarded to the service."""
m = _fake_ms(id=5)
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="## Goal\n\nship")
assert mock.call_args.kwargs["body"] == "## Goal\n\nship"
@pytest.mark.asyncio
async def test_create_milestone_empty_body_becomes_none():
m = _fake_ms()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.create_milestone", mock):
await create_milestone(project_id=1, title="t", body="")
assert mock.call_args.kwargs["body"] is None
@pytest.mark.asyncio
async def test_update_milestone_sends_body():
m = _fake_ms()
mock = AsyncMock(return_value=m)
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", mock):
await update_milestone(project_id=1, milestone_id=5, body="new plan")
assert mock.call_args.kwargs == {"body": "new plan"}
@pytest.mark.asyncio
async def test_get_milestone_returns_body_steps_and_rules():
m = _fake_ms(id=5, project_id=3, body="## Goal")
step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
AsyncMock(return_value={"total": 1, "completed": 0, "pct": 0.0})), \
patch("scribe.mcp.tools.milestones.notes_svc.list_notes",
AsyncMock(return_value=([step], 1))), \
patch("scribe.mcp.tools.milestones.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)):
out = await get_milestone(milestone_id=5)
assert out["milestone"]["body"] == "## Goal"
assert out["milestone"]["total"] == 1
assert out["steps"] == [{"id": 9, "title": "step 1", "status": "todo"}]
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
@pytest.mark.asyncio
async def test_get_milestone_raises_when_not_found():
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=None)):
with pytest.raises(ValueError, match="milestone 999 not found"):
await get_milestone(milestone_id=999)
@pytest.mark.asyncio
async def test_update_milestone_only_sends_non_default_fields():
m = _fake_ms()
+26
View File
@@ -25,6 +25,32 @@ def _fake_note(**overrides) -> MagicMock:
return note
@pytest.mark.asyncio
async def test_create_note_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=88, title="Embeddings notes", similarity=0.94, reason="semantic")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.notes.notes_svc.create_note", create_mock):
out = await create_note(title="embeddings", body="notes about embeddings")
assert out["duplicate"] is True
assert out["existing_id"] == 88
assert out["match"] == "semantic"
create_mock.assert_not_called()
@pytest.mark.asyncio
async def test_create_note_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.notes.dedup_svc.find_duplicate_note", find_mock), \
patch("scribe.mcp.tools.notes.notes_svc.create_note",
AsyncMock(return_value=_fake_note(id=3))):
out = await create_note(title="dup", force=True)
assert out["id"] == 3
find_mock.assert_not_called()
@pytest.mark.asyncio
async def test_list_notes_repackages_tuple_into_dict():
rows = [_fake_note(id=1), _fake_note(id=2)]
+2 -2
View File
@@ -14,13 +14,13 @@ def _bind_user():
@pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service():
payload = {"task": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock:
from scribe.mcp.tools.tasks import start_planning
out = await start_planning(project_id=3, title="Plan it")
assert out["task"]["id"] == 5
assert out["milestone"]["id"] == 5
assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"}
+28
View File
@@ -94,6 +94,34 @@ async def test_create_rule_passes_required_fields():
assert kwargs["statement"] == "Work directly on dev"
@pytest.mark.asyncio
async def test_create_rule_blocked_by_duplicate_gate():
from scribe.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x")
assert out["duplicate"] is True
assert out["existing_id"] == 47
assert "update_rule" in out["message"]
create_mock.assert_not_called()
@pytest.mark.asyncio
async def test_create_rule_force_bypasses_duplicate_gate():
find_mock = AsyncMock()
with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
AsyncMock(return_value=_fake_rule(id=5))):
from scribe.mcp.tools.rulebooks import create_rule
out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
assert out["id"] == 5
find_mock.assert_not_called()
@pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields():
rule = _fake_rule()
+75
View File
@@ -0,0 +1,75 @@
"""MCP system tools + task/note issue wiring (service layer mocked)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _fake_system(sid=1, name="Reader", project_id=5):
s = MagicMock()
s.to_dict.return_value = {"id": sid, "name": name, "project_id": project_id}
s.project_id = project_id
return s
@pytest.mark.asyncio
async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.create_system = AsyncMock(return_value=_fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader"
@pytest.mark.asyncio
async def test_create_system_no_access_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.create_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import create_system
with pytest.raises(ValueError):
await create_system(project_id=5, name="Reader")
@pytest.mark.asyncio
async def test_get_system_splits_records_by_kind():
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"
work = MagicMock(); work.to_dict.return_value = {"id": 11}; work.task_kind = "work"; work.status = "todo"
note = MagicMock(); note.to_dict.return_value = {"id": 12}; note.task_kind = "work"; note.status = None
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.get_system = AsyncMock(return_value=_fake_system(sid=3))
svc.list_records_for_system = AsyncMock(return_value=[issue, work, note])
from scribe.mcp.tools.systems import get_system
result = await get_system(system_id=3)
assert [r["id"] for r in result["issues"]] == [10]
assert [r["id"] for r in result["tasks"]] == [11]
assert [r["id"] for r in result["notes"]] == [12]
@pytest.mark.asyncio
async def test_update_system_not_found_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.update_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import update_system
with pytest.raises(ValueError):
await update_system(system_id=99, name="x")
@pytest.mark.asyncio
async def test_create_task_issue_sets_kind_provenance_and_systems():
note = MagicMock(); note.id = 50; note.to_dict.return_value = {"id": 50, "task_kind": "issue"}
with patch("scribe.mcp.tools.tasks.current_user_id", return_value=1), \
patch("scribe.mcp.tools.tasks.notes_svc") as notes_svc, \
patch("scribe.mcp.tools.tasks.systems_svc") as systems_svc:
notes_svc.create_note = AsyncMock(return_value=note)
systems_svc.set_record_systems = AsyncMock(return_value=[2, 3])
systems_svc.list_record_systems = AsyncMock(return_value=[])
from scribe.mcp.tools.tasks import create_task
result = await create_task(title="bug", kind="issue", system_ids=[2, 3], arose_from_id=9)
_, kwargs = notes_svc.create_note.call_args
assert kwargs["task_kind"] == "issue"
assert kwargs["arose_from_id"] == 9
systems_svc.set_record_systems.assert_awaited_once_with(1, 50, [2, 3])
assert result["id"] == 50
+37
View File
@@ -113,6 +113,43 @@ async def test_create_task_passes_status():
assert mock.call_args.kwargs["status"] == "todo"
@pytest.mark.asyncio
async def test_create_task_blocked_by_duplicate_gate():
"""A near-duplicate blocks creation and returns the existing id (no insert)."""
from scribe.services.dedup import DuplicateMatch
dup = DuplicateMatch(id=42, title="Set up CI", similarity=1.0, reason="title")
create_mock = AsyncMock()
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note",
AsyncMock(return_value=dup)), \
patch("scribe.mcp.tools.tasks.notes_svc.create_note", create_mock):
out = await create_task(title="set up ci")
assert out["duplicate"] is True
assert out["existing_id"] == 42
create_mock.assert_not_called() # nothing was created
@pytest.mark.asyncio
async def test_create_task_force_bypasses_duplicate_gate():
"""force=true skips the gate entirely and creates."""
find_mock = AsyncMock()
with patch("scribe.mcp.tools.tasks.dedup_svc.find_duplicate_note", find_mock), \
patch("scribe.mcp.tools.tasks.notes_svc.create_note",
AsyncMock(return_value=_fake_task(id=9))):
out = await create_task(title="dup", force=True)
assert out["id"] == 9
find_mock.assert_not_called() # gate not even consulted
@pytest.mark.asyncio
async def test_create_task_rejects_retired_plan_kind():
"""kind=plan is hard-retired — plans are milestones (start_planning)."""
mock = AsyncMock()
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
with pytest.raises(ValueError, match="kind=plan is retired"):
await create_task(title="x", kind="plan")
assert not mock.called # never reached the create
@pytest.mark.asyncio
async def test_create_task_priority_empty_becomes_none():
fake = _fake_task()
+4 -3
View File
@@ -20,11 +20,12 @@ def _fake_note(task_kind="work"):
@pytest.mark.asyncio
async def test_create_task_passes_kind():
mock = AsyncMock(return_value=_fake_note(task_kind="plan"))
# kind=plan is retired (plans are milestones); 'issue' exercises passthrough.
mock = AsyncMock(return_value=_fake_note(task_kind="issue"))
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
from scribe.mcp.tools.tasks import create_task
await create_task(title="P", kind="plan")
assert mock.call_args.kwargs["task_kind"] == "plan"
await create_task(title="P", kind="issue")
assert mock.call_args.kwargs["task_kind"] == "issue"
@pytest.mark.asyncio
+38
View File
@@ -0,0 +1,38 @@
"""Structural tests for the systems blueprint — registration + handler/service
contracts. Full HTTP integration needs a live DB + auth the unit env lacks."""
import inspect
def test_systems_blueprint_registered():
from scribe.routes.systems import systems_bp
assert systems_bp.name == "systems"
assert systems_bp.url_prefix == "/api/projects"
def test_systems_blueprint_registered_in_app():
from scribe.app import create_app
app = create_app()
assert "systems" in app.blueprints
def test_system_handlers_callable():
from scribe.routes import systems as routes
for name in (
"list_systems_route", "create_system_route", "get_system_route",
"update_system_route", "delete_system_route", "system_records_route",
"project_issues_route",
):
assert callable(getattr(routes, name))
def test_service_functions_take_user_id():
"""Routes must call systems services with user_id — verify the contract."""
from scribe.services import systems as svc
for fn_name in (
"create_system", "list_systems", "get_system", "update_system",
"delete_system", "list_records_for_system", "list_issues",
"open_issue_counts_by_system",
):
fn = getattr(svc, fn_name)
assert callable(fn)
assert "user_id" in inspect.signature(fn).parameters
+3
View File
@@ -43,12 +43,14 @@ async def test_build_dashboard_composes_sections():
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, "_open_issues", AsyncMock(return_value=["iss"])), \
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"],
"open_issues": ["iss"],
"week_stats": {"open_total": 4},
}
@@ -59,6 +61,7 @@ async def test_build_dashboard_isolates_failing_section():
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, "_open_issues", 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
+112
View File
@@ -0,0 +1,112 @@
"""Unit tests for the write-time near-duplicate gate (services/dedup.py)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from scribe.services.dedup import (
DuplicateMatch,
duplicate_response,
find_duplicate_note,
find_duplicate_rule,
)
def _session_returning(note):
"""A mocked async_session() whose single execute() yields `note` (or None)."""
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
result = MagicMock()
result.scalars.return_value.first.return_value = note
s.execute = AsyncMock(return_value=result)
return s
def _fake_note(id=1, title="T", note_type="note"):
n = MagicMock()
n.id, n.title, n.note_type = id, title, note_type
return n
@pytest.mark.asyncio
async def test_title_exact_match_returns_title_duplicate():
note = _fake_note(id=10, title="Setup CI")
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(note)):
# whitespace/case differences are normalized away
dup = await find_duplicate_note(7, " setup ci ", project_id=2, is_task=True)
assert dup is not None
assert dup.id == 10
assert dup.reason == "title"
assert dup.similarity == 1.0
@pytest.mark.asyncio
async def test_short_body_skips_semantic_check():
sem = AsyncMock()
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(7, "Unique", body="too short", project_id=2)
assert dup is None
sem.assert_not_called() # body under _MIN_BODY_FOR_SEMANTIC
@pytest.mark.asyncio
async def test_semantic_match_when_body_substantial():
hit = _fake_note(id=20, title="Existing", note_type="note")
sem = AsyncMock(return_value=[(0.93, hit)])
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(
7, "Title", body="x" * 250, project_id=2, is_task=False, note_type="note",
)
assert dup is not None
assert dup.id == 20
assert dup.reason == "semantic"
assert dup.similarity == 0.93
@pytest.mark.asyncio
async def test_semantic_match_of_other_note_type_is_ignored():
other = _fake_note(id=21, title="X", note_type="process")
sem = AsyncMock(return_value=[(0.97, other)])
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(None)), \
patch("scribe.services.dedup.embeddings_svc.semantic_search_notes", sem):
dup = await find_duplicate_note(7, "Title", body="x" * 250, note_type="note")
assert dup is None # type mismatch must not block
@pytest.mark.asyncio
async def test_rule_title_match_in_topic():
rule = _fake_note(id=47, title="Honor the multi-user sharing ACL")
with patch("scribe.services.dedup.async_session",
return_value=_session_returning(rule)):
dup = await find_duplicate_rule(
"honor the multi-user sharing acl", topic_id=7,
)
assert dup is not None
assert dup.id == 47
assert dup.reason == "title"
@pytest.mark.asyncio
async def test_rule_requires_a_scope():
# No topic_id and no project_id → nothing to scope to → no match, no query.
sess = AsyncMock()
with patch("scribe.services.dedup.async_session", return_value=sess):
dup = await find_duplicate_rule("anything")
assert dup is None
sess.__aenter__.assert_not_called()
def test_duplicate_response_shape():
dm = DuplicateMatch(id=5, title="Foo", similarity=1.0, reason="title")
r = duplicate_response(dm, "task")
assert r["duplicate"] is True
assert r["existing_id"] == 5
assert r["match"] == "title"
assert "force=true" in r["message"]
assert "update_task" in r["message"]
+10 -9
View File
@@ -4,17 +4,19 @@ import pytest
@pytest.mark.asyncio
async def test_start_planning_creates_plan_task_and_returns_rules():
fake_note = MagicMock()
fake_note.to_dict.return_value = {"id": 5, "title": "Plan it", "task_kind": "plan"}
async def test_start_planning_creates_milestone_and_returns_rules():
# start_planning now creates a MILESTONE (the plan container), not a
# kind=plan task; its body holds the seeded design template.
fake_milestone = MagicMock()
fake_milestone.to_dict.return_value = {"id": 5, "title": "Plan it", "status": "active"}
applicable = {
"rules": [{"id": 1, "title": "dev is home", "statement": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}],
}
with patch("scribe.services.planning.notes_svc.create_note",
AsyncMock(return_value=fake_note)) as mock_create, \
with patch("scribe.services.planning.milestones_svc.create_milestone",
AsyncMock(return_value=fake_milestone)) as mock_create, \
patch("scribe.services.planning.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value=applicable)), \
patch("scribe.services.planning.notes_svc.list_notes",
@@ -24,14 +26,13 @@ async def test_start_planning_creates_plan_task_and_returns_rules():
from scribe.services.planning import start_planning
out = await start_planning(user_id=7, project_id=3, title="Plan it")
# Created a plan-task (status set => task, kind=plan)
# Created a milestone with the seeded plan-body template.
kwargs = mock_create.call_args.kwargs
assert kwargs["task_kind"] == "plan"
assert kwargs["status"] == "todo"
assert kwargs["project_id"] == 3
assert kwargs["status"] == "active"
assert "## Goal" in kwargs["body"] # seeded template
# Returned shape
assert out["task"]["id"] == 5
assert out["milestone"]["id"] == 5
assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}]
assert out["open_task_count"] == 3
+51
View File
@@ -74,6 +74,57 @@ async def test_build_session_context_unbound_repo_emits_bind_hint():
assert "## Active project" not in ctx
@pytest.mark.asyncio
async def test_build_process_manifest_renders_stub_specs():
items = [
{"id": 5, "title": "Drift Audit", "tags": [], "snippet": "Find drifted docs."},
{"id": 9, "title": "DRY Pass", "tags": [], "snippet": ""},
]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 2))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
assert out["total"] == 2
drift = out["processes"][0]
assert drift["id"] == 5
assert drift["name"] == "Drift Audit"
assert drift["slug"] == "drift-audit" # kebab-cased
assert "Drift Audit" in drift["description"] # auto-surface trigger
assert "Find drifted docs." in drift["description"] # preview folded in
# No-snippet process still gets a usable description.
assert "DRY Pass" in out["processes"][1]["description"]
@pytest.mark.asyncio
async def test_build_process_manifest_dedupes_slugs_and_skips_blank_titles():
items = [
{"id": 1, "title": "My Process", "tags": [], "snippet": "a"},
{"id": 2, "title": "my process", "tags": [], "snippet": "b"}, # same slug
{"id": 3, "title": " ", "tags": [], "snippet": "skip me"}, # blank title
]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 3))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
slugs = [p["slug"] for p in out["processes"]]
assert slugs == ["my-process", "my-process-2"] # collision suffixed with id
assert out["total"] == 2 # blank-title entry dropped
@pytest.mark.asyncio
async def test_build_process_manifest_truncates_long_preview():
items = [{"id": 1, "title": "Big", "tags": [], "snippet": "x" * 500}]
with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge",
AsyncMock(return_value=(items, 1))):
from scribe.services.plugin_context import build_process_manifest
out = await build_process_manifest(user_id=7)
assert "" in out["processes"][0]["description"]
assert "x" * 500 not in out["processes"][0]["description"]
@pytest.mark.asyncio
async def test_build_session_context_caps_length():
many = [_rule(i, "x" * 200, 1) for i in range(200)]
+70
View File
@@ -0,0 +1,70 @@
"""ACL gating + field handling for services/systems.py (unit, mocked session)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_mock_session():
s = AsyncMock()
s.__aenter__ = AsyncMock(return_value=s)
s.__aexit__ = AsyncMock(return_value=False)
s.add = MagicMock()
s.commit = AsyncMock()
s.refresh = AsyncMock()
return s
@pytest.mark.asyncio
async def test_create_system_denied_without_project_write():
with patch("scribe.services.systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=False)
from scribe.services.systems import create_system
result = await create_system(user_id=1, project_id=5, name="Reader")
assert result is None
@pytest.mark.asyncio
async def test_create_system_sets_fields_when_authorized():
mock_session = _make_mock_session()
captured = {}
def _capture_add(obj):
captured["name"] = getattr(obj, "name", "MISSING")
captured["project_id"] = getattr(obj, "project_id", "MISSING")
mock_session.add = MagicMock(side_effect=_capture_add)
with patch("scribe.services.systems.async_session") as mock_cls, \
patch("scribe.services.systems.access") as acc:
acc.can_write_project = AsyncMock(return_value=True)
mock_cls.return_value = mock_session
from scribe.services.systems import create_system
await create_system(user_id=1, project_id=5, name=" Reader ")
assert captured["name"] == "Reader" # stripped
assert captured["project_id"] == 5
@pytest.mark.asyncio
async def test_set_record_systems_denied_without_note_write():
with patch("scribe.services.systems.access") as acc:
acc.can_write_note = AsyncMock(return_value=False)
from scribe.services.systems import set_record_systems
result = await set_record_systems(user_id=1, note_id=9, system_ids=[1, 2])
assert result is None
@pytest.mark.asyncio
async def test_list_systems_denied_returns_empty():
with patch("scribe.services.systems.access") as acc:
acc.can_read_project = AsyncMock(return_value=False)
from scribe.services.systems import list_systems
result = await list_systems(user_id=1, project_id=5)
assert result == []
@pytest.mark.asyncio
async def test_count_open_issues_denied_returns_zero():
with patch("scribe.services.systems.access") as acc:
acc.can_read_project = AsyncMock(return_value=False)
from scribe.services.systems import count_open_issues
result = await count_open_issues(user_id=1, project_id=5)
assert result == 0