Merge pull request 'Release: Issues+Systems, milestone-as-plan, plugin reliability/skills/dedup, compaction hygiene' (#71) from dev into main
This commit was merged in pull request #71.
This commit is contained in:
@@ -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') }}
|
||||
|
||||
@@ -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')",
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
Executable
+82
@@ -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
|
||||
@@ -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 1–2 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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]})
|
||||
@@ -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"])
|
||||
|
||||
@@ -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), {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user