From d11eb9145bdaad8b95a12857889cabb43e996b07 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 15:01:35 -0400 Subject: [PATCH 01/25] =?UTF-8?q?feat(plugin):=20two-tier=20SessionStart?= =?UTF-8?q?=20hook=20=E2=80=94=20static=20keyless=20floor=20+=20dynamic=20?= =?UTF-8?q?enrichment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SessionStart push channel was single-tier: it curled /api/plugin/context with a Bearer token and, on any failure (missing/unexported token, network error, missing curl), injected nothing and exited 0 — silently. A known upstream Claude Code gap (sensitive userConfig not reliably exported to hook subprocesses) trips this routinely, so a fresh session gets no signal to reach for Scribe and falls back to local file-memory (root cause of unlogged work on remote/rc sessions). Split into two tiers: - Tier 1 (static, keyless, networkless, always fires): inject bundled scribe_static_context.md — the load-bearing behavioral mandate. Cannot be suppressed by the upstream key bug. - Tier 2 (dynamic, best-effort, fails open): existing curl for live rules + active-project context, appended below the static block. Lights up as enrichment once the key reaches the hook. Only jq is now required (JSON envelope); curl/token gate the dynamic tier only. Bump plugin 0.1.5 -> 0.1.6 so clients pick up the change. Refs milestone 55; task 809; decision note 810. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_session_context.sh | 91 +++++++++++++++----------- plugin/hooks/scribe_static_context.md | 26 ++++++++ 3 files changed, 80 insertions(+), 39 deletions(-) create mode 100644 plugin/hooks/scribe_static_context.md diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 4cae889..56f4e7b 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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", + "version": "0.1.6", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 772c4ba..c9c90b9 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -1,32 +1,41 @@ #!/usr/bin/env bash -# Scribe plugin — SessionStart push channel. +# Scribe plugin — SessionStart push channel (two tiers). # -# 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_ 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 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. +# 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. # # 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: the dynamic tier injects nothing on any error; the static tier +# still fires. A session is never blocked by Scribe. 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 + +# --- Tier 1: static behavioral mandate (always, keyless, networkless) --- +out="" +[ -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 +44,33 @@ token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac -[ -n "$url" ] && [ -n "$token" ] || exit 0 - -# Resolve the working repo's remote so the server can map it to a project. Use -# the session's project dir when provided, else the current dir. No remote (or -# not a git repo) → omit; the server returns standing rules only. -repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} -repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) - -q="" -if [ -n "$repo" ]; then - enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc="" - [ -n "$enc" ] && q="?repo=${enc}" +if command -v curl >/dev/null 2>&1 && [ -n "$url" ] && [ -n "$token" ]; 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="" + if [ -n "$body" ]; then + dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || dyn="" + if [ -n "$dyn" ]; then + if [ -n "$out" ]; then + out="${out}"$'\n\n---\n\n'"${dyn}" + else + out="$dyn" + fi + fi + fi fi -body=$(curl -fsS --max-time 8 \ - -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/context${q}" 2>/dev/null) || exit 0 +# Nothing to inject (no static file AND no dynamic context) → stay silent. +[ -n "$out" ] || exit 0 -text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0 -[ -n "$text" ] || exit 0 - -jq -n --arg c "$text" \ +jq -n --arg c "$out" \ '{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}' exit 0 diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md new file mode 100644 index 0000000..999fe39 --- /dev/null +++ b/plugin/hooks/scribe_static_context.md @@ -0,0 +1,26 @@ +# 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()` to load that project's rules, open tasks, and + recent notes in one shot. + +**While you work:** +- **Recall before acting** — `search` Scribe for prior art 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, log progress with + `add_task_log` at meaningful steps, and write a dated dev-log note at a + landing (a merge, a shipped feature, a finished plan). 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. + +If the Scribe tools are unavailable, say so rather than silently falling back +to local notes. -- 2.52.0 From f2ab02ba2b2e773d6aa858425640f219cdbf1b56 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 15:20:18 -0400 Subject: [PATCH 02/25] ref, plugin: neutral, concrete triggers in static SessionStart mandate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain-language 'related prior work' instead of 'prior art'; replace the dev-shaped 'meaningful landing (a merge, a shipped feature, a finished plan)' with concrete neutral triggers — log on task completion and when a problem is found, so direction pivots are captured, not just successes. Keeps the static mandate domain-neutral (pre-empts B9 drift in plan 812). Refs task 809 / plan 812 item A1. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/hooks/scribe_static_context.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 999fe39..5b8e602 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -12,13 +12,15 @@ for the operator's work, and as your own working memory across sessions. recent notes in one shot. **While you work:** -- **Recall before acting** — `search` Scribe for prior art 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, log progress with - `add_task_log` at meaningful steps, and write a dated dev-log note at a - landing (a merge, a shipped feature, a finished plan). Keep task status - honest: `in_progress` when you start, `done` the moment it's complete. +- **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. -- 2.52.0 From 95e1d47cebaa69d6b794f7184830d088cf40c953 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 15:57:22 -0400 Subject: [PATCH 03/25] ref(mcp): neutralize dev-shaped vocabulary in _INSTRUCTIONS + add write-mandate (B9/A4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP instructions are domain-neutral except a thin layer of dev vocabulary and one project-specific paragraph (B10 audit, task 812). Make the data store's own instructions serve any domain, and add the missing positive write-mandate. B9 (neutralize): - 'before writing code' -> 'before you dive in' - Note examples 'dev-logs' -> 'logs of what happened' - record trigger 'a merge, a shipped feature, a finished plan' + 'dev-log note' -> 'finishing a task, or hitting/discovering a problem that changes direction' (folds in B8: log pivots, not just wins; mirrors the static-tier wording) - recall examples 'ticket/dev-log' -> 'task/prior note' (server + SKILL.md) - 'Engineering and workflow rules' -> 'Workflow and standards rules' - slim the 'developing Scribe itself' ACL paragraph to a neutral one-liner (project-specific specifics already live in rules #47/#78) A4 (write-mandate): state up front that Scribe is the system of record — record work here, recall before acting, don't keep project work in local files. Refs plan 812 (B9, A4, B8-trigger); B10 audit work-log. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/skills/using-scribe/SKILL.md | 2 +- src/scribe/mcp/server.py | 34 +++++++++++++++-------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 1525626..fccccfb 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -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. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index de6b7b8..c67756d 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -7,7 +7,10 @@ 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. @@ -21,8 +24,9 @@ What each part is for, and when to reach for it: 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. + start_planning when beginning non-trivial work, before you dive in. +- 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 +47,11 @@ 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. - 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 +73,10 @@ 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. 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 +86,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 @@ -162,12 +167,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. (This instance's rules carry the specifics.) """ -- 2.52.0 From f125f86e16f2f5fe0c2c14abb6aad5f80735944b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 16:01:17 -0400 Subject: [PATCH 04/25] ref(mcp): make the dev-ACL instruction self-contained (no instance coupling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the '(This instance's rules carry the specifics.)' pointer — universal _INSTRUCTIONS must not assume this install has a particular rulebook. State the ACL principle on its own so it holds for any Scribe install/fork. Refs plan 812 (instance-agnostic product principle). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index c67756d..d798af9 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -169,7 +169,7 @@ body); edit with update_process. 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. (This instance's rules carry the specifics.) +operator. "Works for one user" is not done. """ -- 2.52.0 From 700cfc664bd3b2ad7c3ee11736796e610497a85d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 16:14:27 -0400 Subject: [PATCH 05/25] feat(plugin): surface dynamic-tier failures in SessionStart hook (A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail-open but no longer silent. When the dynamic context fetch yields nothing, append a short status line to the injected context so a session can tell 'couldn't load live context' apart from 'Scribe had nothing to say': - endpoint+token present but fetch empty/failed -> 'instance unreachable / request failed' - endpoint present but token absent -> fingerprints the known Claude Code userConfig export gap ('API token did not reach this hook') A fully unconfigured install (no url AND no token) stays quiet — static-only is the intended mode there. Static Tier 1 still always carries the mandate. Refs plan 812 item A2. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/hooks/scribe_session_context.sh | 37 +++++++++++++++----------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index c9c90b9..111da25 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -23,16 +23,22 @@ # vars above are the supported channel; SCRIBE_URL / SCRIBE_TOKEN override for # the settings.json dogfooding path. # -# FAIL-OPEN: the dynamic tier injects nothing on any error; the static tier -# still fires. A session is never 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), so a session +# can tell "couldn't load live context" apart from "Scribe had nothing to say". +# 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 # needed to emit the JSON envelope safely here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0 -# --- Tier 1: static behavioral mandate (always, keyless, networkless) --- 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; } + +# --- 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) --- @@ -44,7 +50,9 @@ token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}} case "$url" in *'${'*) url="" ;; esac case "$token" in *'${'*) token="" ;; esac -if command -v curl >/dev/null 2>&1 && [ -n "$url" ] && [ -n "$token" ]; 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) @@ -56,19 +64,18 @@ if command -v curl >/dev/null 2>&1 && [ -n "$url" ] && [ -n "$token" ]; then body=$(curl -fsS --max-time 8 \ -H "Authorization: Bearer ${token}" \ "${url%/}/api/plugin/context${q}" 2>/dev/null) || body="" - if [ -n "$body" ]; then - dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || dyn="" - if [ -n "$dyn" ]; then - if [ -n "$out" ]; then - out="${out}"$'\n\n---\n\n'"${dyn}" - else - out="$dyn" - fi - fi - fi + [ -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 -# Nothing to inject (no static file AND no dynamic context) → stay silent. +[ -n "$dyn" ] && append "$dyn" +[ -n "$status" ] && append "$status" + +# Nothing at all to inject (no static file, no dynamic context, no status) → stay silent. [ -n "$out" ] || exit 0 jq -n --arg c "$out" \ -- 2.52.0 From c0b9831b0fb020a54781de2aa5b7787d17a054e8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 16:27:33 -0400 Subject: [PATCH 06/25] feat(mcp): issue-capture convention in _INSTRUCTIONS (B8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finish the breakfix/issue-logging gap as a lightweight convention: when recording a solved problem, capture symptom -> root cause -> fix and tag it 'issue' so it's findable instead of re-diagnosed. Pairs with the B9 trigger ('log when a problem is found'). No schema change — a structured note_type/ task_kind=issue is deferred to a joint schema pass with B7. Refs plan 812 (B8 convention; B7 deferred). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index d798af9..0cebf68 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -77,6 +77,9 @@ Keep task state honest — this is what makes the project a trustworthy record: 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. Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry an actionable statement plus optional Why and How-to-apply context. At the -- 2.52.0 From d99c4e3c158e4e065e7e67741f6f51129382e126 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 16:49:10 -0400 Subject: [PATCH 07/25] feat(plugin): compaction re-grounding in SessionStart hook (A7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliver 'don't silently lose work at compaction' via the mechanism that actually works. Verified contract: a PreCompact hook CANNOT make the model flush to Scribe (host hooks can't trigger model tool calls, and can't know the in-flight task ids), and its additionalContext only shapes the one-shot summary. The correct tool is SessionStart scoped to source=compact, which fires AFTER compaction and injects context the model reads. Our SessionStart hook is matcher-less, so it already fires on compact — it just said nothing compaction-specific. Now it reads the stdin event and, when source==compact, leads with a banner telling the model to reload the active project + in-flight tasks from Scribe and reconcile half-remembered state. Durable path = record-as-you-go (A4/B8) + this post-compaction reload. Refs plan 812 (A7); supersedes the literal 'PreCompact hook' idea. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/hooks/scribe_session_context.sh | 30 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 111da25..0e77af2 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Scribe plugin — SessionStart push channel (two tiers). +# Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding). # # Tier 1 (STATIC, always fires, no auth, no network): injects a bundled # behavioral mandate (scribe_static_context.md) so a fresh session knows to @@ -17,6 +17,14 @@ # 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. # +# 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 values are kept in the keychain and never spliced into # a hook command line, so the placeholder arrives unexpanded. The harness env @@ -24,19 +32,24 @@ # the settings.json dogfooding path. # # 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), so a session -# can tell "couldn't load live context" apart from "Scribe had nothing to say". -# A fully unconfigured install (no url AND no token) is the intended static-only -# mode and stays quiet. +# 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 # 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") @@ -75,7 +88,12 @@ fi [ -n "$dyn" ] && append "$dyn" [ -n "$status" ] && append "$status" -# Nothing at all to inject (no static file, no dynamic context, no status) → stay silent. +# 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 + +# Nothing at all to inject → stay silent. [ -n "$out" ] || exit 0 jq -n --arg c "$out" \ -- 2.52.0 From 88106309f40613e14deb6352370ca0e5ac37e190 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 20:52:34 -0400 Subject: [PATCH 08/25] feat(plugin): add 4 Scribe-native process-skills (restore superpowers gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superpowers was uninstalled but its replacements were never built (only using-scribe shipped) — a live functional hole. Author the 4 the operator wants back, each integrated with Scribe's toolset rather than generic copies: - writing-plans -> start_planning / kind=plan task, not local .md - systematic-debugging -> capture issue (symptom->cause->fix, tag issue) on resolve - verification -> log results to the task work-log; honest done - brainstorming -> recall prior thinking first; capture the decision note Skipped TDD + receiving-code-review per operator (well-covered by Claude/them). Manifest + using-scribe list now advertise only the 4 that ship. Remove the stale docs/superpowers/*.md reference in _INSTRUCTIONS (superpowers is gone). Plugin 0.1.6 -> 0.1.7. Refs plan 821 (Phase 3 of 755). Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/.claude-plugin/plugin.json | 4 +- plugin/skills/brainstorming/SKILL.md | 34 +++++++++++++++ plugin/skills/systematic-debugging/SKILL.md | 35 ++++++++++++++++ plugin/skills/using-scribe/SKILL.md | 7 ++-- plugin/skills/verification/SKILL.md | 32 ++++++++++++++ plugin/skills/writing-plans/SKILL.md | 46 +++++++++++++++++++++ src/scribe/mcp/server.py | 9 ++-- 7 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 plugin/skills/brainstorming/SKILL.md create mode 100644 plugin/skills/systematic-debugging/SKILL.md create mode 100644 plugin/skills/verification/SKILL.md create mode 100644 plugin/skills/writing-plans/SKILL.md diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 56f4e7b..63bc343 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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.6", + "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 process-skills (writing-plans, systematic-debugging, verification, brainstorming). Replaces superpowers + file-memory with one app-backed plugin.", + "version": "0.1.7", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/skills/brainstorming/SKILL.md b/plugin/skills/brainstorming/SKILL.md new file mode 100644 index 0000000..f803082 --- /dev/null +++ b/plugin/skills/brainstorming/SKILL.md @@ -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*. diff --git a/plugin/skills/systematic-debugging/SKILL.md b/plugin/skills/systematic-debugging/SKILL.md new file mode 100644 index 0000000..c29c853 --- /dev/null +++ b/plugin/skills/systematic-debugging/SKILL.md @@ -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`. diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index fccccfb..2b9b599 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -106,7 +106,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. diff --git a/plugin/skills/verification/SKILL.md b/plugin/skills/verification/SKILL.md new file mode 100644 index 0000000..486fe17 --- /dev/null +++ b/plugin/skills/verification/SKILL.md @@ -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. diff --git a/plugin/skills/writing-plans/SKILL.md b/plugin/skills/writing-plans/SKILL.md new file mode 100644 index 0000000..36e37e6 --- /dev/null +++ b/plugin/skills/writing-plans/SKILL.md @@ -0,0 +1,46 @@ +--- +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 kind=plan task (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, +checkable list 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 `kind=plan` task seeded with a +template and returns the task id plus the project's applicable rules. The plan +lives in that task: edit the body with `update_task`, record progress with +`add_task_log`. **Do not** write plans or specs to local `.md` files — the task +is the record, not a file on disk. + +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. +- **Approach** — the key design decisions and the trade-offs you chose, briefly. +- **Steps** — an ordered checklist, each step 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**: tick steps as they land; record decisions, findings, + and pivots with `add_task_log` rather than silently rewriting the body. +- If reality diverges from the plan, **update the plan** — one that no longer + matches what you're doing is worse than none. +- Set the plan task `in_progress` when you start and `done` when it's complete. + +## Match depth to the work + +A two-step change deserves a two-line plan; a multi-day effort deserves +milestones and sub-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. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 0cebf68..519192b 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -151,11 +151,10 @@ 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. +task id you'll write into. If a habit tells you to save a plan or spec to a local +`.md` file, that's 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. 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 -- 2.52.0 From b91c447b0b4b07058aafa33961d85c10ee904544 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 22:53:51 -0400 Subject: [PATCH 09/25] =?UTF-8?q?feat(issues):=20S1=20schema=20=E2=80=94?= =?UTF-8?q?=20issue=20task=5Fkind,=20System=20entity,=20associations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Issues + Systems feature (spec #825, plan #819 T2). Schema (migration 0065): - task_kind CHECK expands work|plan -> work|plan|issue (same-change, rule 36) - notes.arose_from_id: optional self-FK for issue->originating-task provenance (distinct from parent_id sub-task hierarchy) - systems: per-project, self-describing (name + description) subsystem/area - record_systems: M2M join linking any note/task/issue to systems (mutable) Models: System + RecordSystem; note.py gains arose_from_id (+ index, to_dict). Service services/systems.py: CRUD, archive, soft-delete, set/list associations, records-for-system, open-issue count — all gated via services/access.py project permissions (rule 78, no bare-owner filters). Unit tests lock the ACL gating; the migration is exercised by CI's integration lane (alembic upgrade head). is_task stays a derived property (status is not None) — unchanged. T1 (typing- axis rationalization) intentionally NOT bundled; this only adds the enum value. Refs plan 825 (S1). Co-Authored-By: Claude Opus 4.8 (1M context) --- alembic/versions/0065_issues_and_systems.py | 103 ++++++++++ src/scribe/models/__init__.py | 1 + src/scribe/models/note.py | 15 +- src/scribe/models/system.py | 73 +++++++ src/scribe/services/systems.py | 209 ++++++++++++++++++++ tests/test_services_systems.py | 70 +++++++ 6 files changed, 468 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/0065_issues_and_systems.py create mode 100644 src/scribe/models/system.py create mode 100644 src/scribe/services/systems.py create mode 100644 tests/test_services_systems.py diff --git a/alembic/versions/0065_issues_and_systems.py b/alembic/versions/0065_issues_and_systems.py new file mode 100644 index 0000000..6a9aa52 --- /dev/null +++ b/alembic/versions/0065_issues_and_systems.py @@ -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')", + ) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index c99959f..080af91 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -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 diff --git a/src/scribe/models/note.py b/src/scribe/models/note.py index 5fe68b1..e17b3d7 100644 --- a/src/scribe/models/note.py +++ b/src/scribe/models/note.py @@ -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, diff --git a/src/scribe/models/system.py b/src/scribe/models/system.py new file mode 100644 index 0000000..a47416e --- /dev/null +++ b/src/scribe/models/system.py @@ -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"), + ) diff --git a/src/scribe/services/systems.py b/src/scribe/services/systems.py new file mode 100644 index 0000000..fc8646c --- /dev/null +++ b/src/scribe/services/systems.py @@ -0,0 +1,209 @@ +"""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) diff --git a/tests/test_services_systems.py b/tests/test_services_systems.py new file mode 100644 index 0000000..8a5bd5b --- /dev/null +++ b/tests/test_services_systems.py @@ -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 -- 2.52.0 From 85e0501705f2ff934780678d5b29f97972570347 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 23:07:37 -0400 Subject: [PATCH 10/25] =?UTF-8?q?feat(issues):=20S2=20MCP=20tools=20?= =?UTF-8?q?=E2=80=94=20system=20CRUD=20+=20issue/system=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of Issues + Systems (spec #825). New mcp/tools/systems.py: create_system, list_systems, get_system (records split into issues/tasks/notes), update_system (incl. archive via status), list_system_records (kind/open_only filters), delete_system. Registered in register_all; read tools (get_system, list_systems, list_system_records) added to the read-only-key allowlist (write tools default-deny). create_task/update_task: kind now accepts 'issue'; new system_ids (set-semantics associations) and arose_from_id (provenance, 0=unchanged/-1=clear) args. create_note/update_note: new system_ids arg (notes associate with systems too). services/notes.create_note: arose_from_id passthrough (update_note already handles it via setattr). Tests: MCP system tools + create_task issue-wiring (kind/provenance/systems), service layer mocked. Refs plan 825 (S2). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/server.py | 1 + src/scribe/mcp/tools/__init__.py | 3 +- src/scribe/mcp/tools/notes.py | 25 +++++- src/scribe/mcp/tools/systems.py | 150 +++++++++++++++++++++++++++++++ src/scribe/mcp/tools/tasks.py | 42 ++++++++- src/scribe/services/notes.py | 2 + tests/test_mcp_tool_systems.py | 75 ++++++++++++++++ 7 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 src/scribe/mcp/tools/systems.py create mode 100644 tests/test_mcp_tool_systems.py diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 519192b..1c5142c 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -185,6 +185,7 @@ _READ_ONLY_TOOLS = frozenset({ "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", }) diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index 934cf4c..c03768b 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -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) diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index cbfc7f6..abf7c70 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -15,6 +15,7 @@ from __future__ import annotations from scribe.mcp._context import current_user_id 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 +69,7 @@ async def create_note( body: str = "", tags: list[str] | None = None, project_id: int = 0, + system_ids: list[int] | None = None, ) -> dict: """Create a new note in Scribe. @@ -76,6 +78,8 @@ 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. Returns the created note object including its assigned id. """ @@ -87,7 +91,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 +107,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 +117,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 +133,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: diff --git a/src/scribe/mcp/tools/systems.py b/src/scribe/mcp/tools/systems.py new file mode 100644 index 0000000..e518acf --- /dev/null +++ b/src/scribe/mcp/tools/systems.py @@ -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) diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 62a7f18..98c7045 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -22,6 +22,7 @@ from scribe.mcp._context import current_user_id 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 +42,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. """ @@ -101,6 +102,8 @@ 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, ) -> dict: """Create a new task in Scribe. @@ -113,7 +116,13 @@ 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), 'plan', or 'issue'. An issue is corrective work — + a problem you fixed or are fixing; record symptom → root cause → fix + in the body. Prefer start_planning to create plans. + 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. """ uid = current_user_id() note = await notes_svc.create_note( @@ -127,8 +136,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 +156,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 +173,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 +198,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: diff --git a/src/scribe/services/notes.py b/src/scribe/services/notes.py index d7b94e3..ba652c9 100644 --- a/src/scribe/services/notes.py +++ b/src/scribe/services/notes.py @@ -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() diff --git a/tests/test_mcp_tool_systems.py b/tests/test_mcp_tool_systems.py new file mode 100644 index 0000000..1b01cc4 --- /dev/null +++ b/tests/test_mcp_tool_systems.py @@ -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 -- 2.52.0 From 4f22646c888b5b660c237698f9e34d143c71cece Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 23:14:54 -0400 Subject: [PATCH 11/25] =?UTF-8?q?feat(issues):=20S3=20REST=20routes=20?= =?UTF-8?q?=E2=80=94=20systems=20CRUD=20+=20project=20open-issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of Issues + Systems (spec #825). routes/systems.py (nested /api/projects//...): GET/POST systems (list adds per-system open_issue_count via one grouped query), GET/PATCH/DELETE a system (GET returns records split into issues/tasks/notes), GET .../systems//records (kind/open_only filters), GET .../issues (project's open issues for the project view + dashboard roll-up). login_required; project access via get_project_for_user; writes gated by can_write_project (clean 403); system.project_id verified to match the path. Blueprint registered in app.py. services/systems.py: + open_issue_counts_by_system (one grouped query) and list_issues (project issues, open by default). Tests: structural (blueprint registered + in app, handlers callable, service contracts take user_id) — matches the house route-test pattern. Refs plan 825 (S3). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/app.py | 2 + src/scribe/routes/systems.py | 155 +++++++++++++++++++++++++++++++++ src/scribe/services/systems.py | 40 +++++++++ tests/test_routes_systems.py | 38 ++++++++ 4 files changed, 235 insertions(+) create mode 100644 src/scribe/routes/systems.py create mode 100644 tests/test_routes_systems.py diff --git a/src/scribe/app.py b/src/scribe/app.py index caa1d85..44c1d59 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -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(): diff --git a/src/scribe/routes/systems.py b/src/scribe/routes/systems.py new file mode 100644 index 0000000..37c9c29 --- /dev/null +++ b/src/scribe/routes/systems.py @@ -0,0 +1,155 @@ +"""System routes nested under /api/projects//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("//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("//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("//systems/", 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("//systems/", 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("//systems/", 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("//systems//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("//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]}) diff --git a/src/scribe/services/systems.py b/src/scribe/services/systems.py index fc8646c..83f5785 100644 --- a/src/scribe/services/systems.py +++ b/src/scribe/services/systems.py @@ -207,3 +207,43 @@ async def count_open_issues(user_id: int, project_id: int) -> int: ) ) 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()) diff --git a/tests/test_routes_systems.py b/tests/test_routes_systems.py new file mode 100644 index 0000000..dfdde37 --- /dev/null +++ b/tests/test_routes_systems.py @@ -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 -- 2.52.0 From 4da29562bd7898903a248de48ccc123843fc0da2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 23:42:40 -0400 Subject: [PATCH 12/25] =?UTF-8?q?feat(issues):=20S4a=20UI=20=E2=80=94=20Sy?= =?UTF-8?q?stems=20management=20section=20in=20project=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend foundation for Issues + Systems (spec #825, S4a). - frontend/src/api/systems.ts: typed client (System + list/create/update/delete) over /api/projects//systems, matching the rulebooks api style. - frontend/src/stores/systems.ts: Pinia store keyed by project (fetch/create/ update/archive/unarchive/delete), toast-on-error. - frontend/src/components/SystemsSection.vue: a Systems management section — cards (color swatch, name, description, 'N open' issue-count badge) with inline create/edit, archive (hidden behind a 'show archived' toggle), and a delete-confirm modal. v1 quality: loading skeleton, empty state, error toasts, keyboard a11y, focus rings; reuses existing CSS tokens (no new colors). - ProjectView.vue: new 'Systems' tab (between Notes and Rules), rendering , wired like the existing rules tab. S4b (next) adds issue-editor controls (kind=issue/system multi-select/arose-from), open-issues lists, and the dashboard surface. NEEDS operator browser verification (CI typechecks but can't render). Refs plan 825 (S4a). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/systems.ts | 44 ++ frontend/src/components/SystemsSection.vue | 525 +++++++++++++++++++++ frontend/src/stores/systems.ts | 73 +++ frontend/src/views/ProjectView.vue | 9 +- 4 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/systems.ts create mode 100644 frontend/src/components/SystemsSection.vue create mode 100644 frontend/src/stores/systems.ts diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts new file mode 100644 index 0000000..d5d8012 --- /dev/null +++ b/frontend/src/api/systems.ts @@ -0,0 +1,44 @@ +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 { + 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 { + return apiPost(`/api/projects/${projectId}/systems`, data); +} + +export async function updateSystem( + projectId: number, + systemId: number, + data: Partial<{ + name: string; + description: string; + color: string; + status: "active" | "archived"; + order_index: number; + }>, +): Promise { + return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data); +} + +export async function deleteSystem(projectId: number, systemId: number): Promise { + return apiDelete(`/api/projects/${projectId}/systems/${systemId}`); +} diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue new file mode 100644 index 0000000..75ce89e --- /dev/null +++ b/frontend/src/components/SystemsSection.vue @@ -0,0 +1,525 @@ + + + + + diff --git a/frontend/src/stores/systems.ts b/frontend/src/stores/systems.ts new file mode 100644 index 0000000..0fbb2d5 --- /dev/null +++ b/frontend/src/stores/systems.ts @@ -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>({}); + 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>, + ) { + 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, + }; +}); diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index cf9e182..5290cce 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -7,6 +7,7 @@ import { useTasksStore } from "@/stores/tasks"; import { relativeTime } from "@/composables/useRelativeTime"; import ShareDialog from "@/components/ShareDialog.vue"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; +import SystemsSection from "@/components/SystemsSection.vue"; import { LayoutGrid, Clock, @@ -87,7 +88,7 @@ async function confirmStartPlanning() { const saving = ref(false); const error = ref(null); -const activeTab = ref<"tasks" | "notes" | "rules">("tasks"); +const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks"); const tasks = ref([]); const notes = ref([]); @@ -479,6 +480,9 @@ async function confirmDelete() { Notes {{ project.summary.note_count }} + @@ -663,6 +667,9 @@ async function confirmDelete() { + + + -- 2.52.0 From 9293a9b19896528168e6933ef308e9c1f107e20a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 13 Jun 2026 23:46:40 -0400 Subject: [PATCH 13/25] =?UTF-8?q?fix(issues):=20S4a=20typecheck=20?= =?UTF-8?q?=E2=80=94=20allow=20null=20color=20in=20updateSystem=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vue-tsc TS2345: System.color is string|null, but updateSystem's data param typed color as string, so the store's Partial> wasn't assignable. Widen the param's color to string|null (clearing a color is valid). Refs plan 825 (S4a). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/systems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index d5d8012..5206f58 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -31,7 +31,7 @@ export async function updateSystem( data: Partial<{ name: string; description: string; - color: string; + color: string | null; status: "active" | "archived"; order_index: number; }>, -- 2.52.0 From 79040fe5dbd3e0f1d9ac3d1927eda42809990c48 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 10:42:24 -0400 Subject: [PATCH 14/25] =?UTF-8?q?feat(issues):=20S4b=20backend=20=E2=80=94?= =?UTF-8?q?=20REST=20task=20issue=20fields=20+=20dashboard=20open-issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the REST gap S4b's UI needs (S2 only extended MCP tools): - routes/tasks.py: create/update accept system_ids (set-semantics) + arose_from_id; GET/create/update return the task's associated systems. kind=issue already flowed via task_kind. Associations set via services/systems (ACL-checked; can_write_note already gated). - services/dashboard.py: _open_issues section (owner-scoped, ranked like other task lists, capped) added to build_dashboard. Dashboard test updated for the new key. Refs plan 825 (S4b, backend half). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/routes/tasks.py | 17 ++++++++++++++--- src/scribe/services/dashboard.py | 32 ++++++++++++++++++++++++++++++++ tests/test_services_dashboard.py | 3 +++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/scribe/routes/tasks.py b/src/scribe/routes/tasks.py index 0168621..95d1afd 100644 --- a/src/scribe/routes/tasks.py +++ b/src/scribe/routes/tasks.py @@ -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, @@ -136,11 +137,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 +180,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 +230,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 +243,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("//status", methods=["PATCH"]) diff --git a/src/scribe/services/dashboard.py b/src/scribe/services/dashboard.py index 7f047ee..99d16e5 100644 --- a/src/scribe/services/dashboard.py +++ b/src/scribe/services/dashboard.py @@ -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), {}), } diff --git a/tests/test_services_dashboard.py b/tests/test_services_dashboard.py index c781f78..36a1174 100644 --- a/tests/test_services_dashboard.py +++ b/tests/test_services_dashboard.py @@ -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 -- 2.52.0 From 94d32c524a0f7f132efd7c6dfd7f5cfb44fa3329 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 10:50:41 -0400 Subject: [PATCH 15/25] =?UTF-8?q?feat(issues):=20S4b=20frontend=20?= =?UTF-8?q?=E2=80=94=20open-issues=20lists=20+=20issue=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types/note.ts: Note gains systems? + arose_from_id?; TaskKind includes 'issue'. - stores/tasks.ts: create/update accept IssueFields (kind/system_ids/arose_from_id). - api/systems.ts: getProjectIssues + TaskLike. - DashboardView.vue: 'Open issues' rail section from dashboard.open_issues (links to /tasks/, project + status). - SystemsSection.vue (project Systems tab): 'Open issues' list via getProjectIssues, with system chips, links to /tasks/. Both reuse existing CSS tokens. Issue-editor controls (kind selector / system multi-select / arose-from picker in WorkspaceTaskPanel) are the remaining S4b piece. NEEDS operator browser verification (CI typechecks only). Refs plan 825 (S4b frontend — lists). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/systems.ts | 20 ++++++++++++ frontend/src/components/SystemsSection.vue | 37 +++++++++++++++++++++- frontend/src/stores/tasks.ts | 14 ++++++-- frontend/src/types/note.ts | 7 +++- frontend/src/views/DashboardView.vue | 28 +++++++++++++++- 5 files changed, 101 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index 5206f58..1fa194d 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -42,3 +42,23 @@ export async function updateSystem( export async function deleteSystem(projectId: number, systemId: number): Promise { 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 { + const data = await apiGet<{ issues: TaskLike[] }>( + `/api/projects/${projectId}/issues?open_only=${openOnly}`, + ); + return data.issues; +} diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 75ce89e..46ebb2a 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -2,7 +2,8 @@ import { ref, computed, onMounted, watch } from "vue"; import { useSystemsStore } from "@/stores/systems"; import { useToastStore } from "@/stores/toast"; -import type { System } from "@/api/systems"; +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 }>(); @@ -12,6 +13,7 @@ const toast = useToastStore(); const error = ref(null); const showArchived = ref(false); +const issues = ref([]); // Create state const showCreate = ref(false); @@ -42,6 +44,11 @@ async function load() { } catch { error.value = "Failed to load systems."; } + try { + issues.value = await getProjectIssues(props.projectId); + } catch { + issues.value = []; + } } onMounted(load); @@ -138,6 +145,22 @@ async function confirmDelete() { + +
+ +
+
+
@@ -1082,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 { diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 1c5142c..c15f12f 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -17,14 +17,21 @@ 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 you dive in. +- 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. @@ -147,14 +154,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 habit tells you to save a plan or spec to a local -`.md` file, that's 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 @@ -180,7 +190,7 @@ operator. "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", diff --git a/src/scribe/mcp/tools/milestones.py b/src/scribe/mcp/tools/milestones.py index d4973ab..26463e6 100644 --- a/src/scribe/mcp/tools/milestones.py +++ b/src/scribe/mcp/tools/milestones.py @@ -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, diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 98c7045..629a12c 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -233,14 +233,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=); + 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( diff --git a/src/scribe/models/milestone.py b/src/scribe/models/milestone.py index ee36a2c..a449204 100644 --- a/src/scribe/models/milestone.py +++ b/src/scribe/models/milestone.py @@ -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(), diff --git a/src/scribe/routes/milestones.py b/src/scribe/routes/milestones.py index 02d76e1..36811a5 100644 --- a/src/scribe/routes/milestones.py +++ b/src/scribe/routes/milestones.py @@ -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 diff --git a/src/scribe/services/milestones.py b/src/scribe/services/milestones.py index d36174e..a37295d 100644 --- a/src/scribe/services/milestones.py +++ b/src/scribe/services/milestones.py @@ -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, ) diff --git a/src/scribe/services/planning.py b/src/scribe/services/planning.py index 413247b..c2634b6 100644 --- a/src/scribe/services/planning.py +++ b/src/scribe/services/planning.py @@ -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": , + "milestone": , "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"], diff --git a/tests/test_mcp_tool_milestones.py b/tests/test_mcp_tool_milestones.py index b63bb3f..9000dfd 100644 --- a/tests/test_mcp_tool_milestones.py +++ b/tests/test_mcp_tool_milestones.py @@ -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() diff --git a/tests/test_mcp_tool_planning.py b/tests/test_mcp_tool_planning.py index 7d6134f..d079e5d 100644 --- a/tests/test_mcp_tool_planning.py +++ b/tests/test_mcp_tool_planning.py @@ -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"} diff --git a/tests/test_services_planning.py b/tests/test_services_planning.py index a483ee5..90f4db5 100644 --- a/tests/test_services_planning.py +++ b/tests/test_services_planning.py @@ -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 -- 2.52.0 From f7742173aa475f8e312ed2c3eb5b5332729f4b8e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 12:31:51 -0400 Subject: [PATCH 19/25] chore(plans): make kind=plan retirement consistent across MCP, REST, UI, skills Audit of the plugin + MCP surface after milestone-as-plan (T3): every path that could still create a kind=plan task or describe the old plan-task model is now aligned with the hard-retire decision. - create_task (MCP + REST POST /api/tasks): reject kind=plan with a message pointing to start_planning. The 'plan' enum value stays valid so legacy plan-tasks remain readable; update paths never touch kind, so they round-trip. - create_task / get_task docstrings: 'plan' dropped from creatable kinds; get_task's rules-augmentation noted as legacy-only (get_milestone for new plans). - skills/writing-plans: rewritten for milestone-as-plan (body = design, steps = child tasks, get_milestone to read back). - skills/using-scribe: "plans live in milestones via start_planning", not kind=plan. - TaskEditorView Kind selector: offers Work/Issue; "Plan (legacy)" shown only when the loaded task is already kind=plan (display round-trip). - test: create_task rejects kind=plan. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/views/TaskEditorView.vue | 4 +- plugin/skills/using-scribe/SKILL.md | 11 +++--- plugin/skills/writing-plans/SKILL.md | 56 ++++++++++++++++----------- src/scribe/mcp/tools/tasks.py | 23 ++++++++--- src/scribe/routes/tasks.py | 9 +++++ tests/test_mcp_tool_tasks.py | 10 +++++ 6 files changed, 79 insertions(+), 34 deletions(-) diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index dbcab0b..4617777 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -579,8 +579,10 @@ useEditorGuards(dirty, save);
diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 2b9b599..9485bd0 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -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: @@ -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. diff --git a/plugin/skills/writing-plans/SKILL.md b/plugin/skills/writing-plans/SKILL.md index 36e37e6..8ef8d80 100644 --- a/plugin/skills/writing-plans/SKILL.md +++ b/plugin/skills/writing-plans/SKILL.md @@ -1,46 +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 kind=plan task (via start_planning), not a local file. +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, -checkable list of steps — written *before* you start, so the approach is -reviewable and the work stays trackable. +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 `kind=plan` task seeded with a -template and returns the task id plus the project's applicable rules. The plan -lives in that task: edit the body with `update_task`, record progress with -`add_task_log`. **Do not** write plans or specs to local `.md` files — the task -is the record, not a file on disk. +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=)` 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. -- **Approach** — the key design decisions and the trade-offs you chose, briefly. -- **Steps** — an ordered checklist, each step small enough to verify on its own; - note which files/areas each touches. +- **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**: tick steps as they land; record decisions, findings, - and pivots with `add_task_log` rather than silently rewriting the body. -- If reality diverges from the plan, **update the plan** — one that no longer - matches what you're doing is worse than none. -- Set the plan task `in_progress` when you start and `done` when it's complete. +- 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 -milestones and sub-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. +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. diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 629a12c..6264253 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -63,9 +63,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) @@ -79,6 +80,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, @@ -116,15 +119,23 @@ 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), 'plan', or 'issue'. An issue is corrective work — - a problem you fixed or are fixing; record symptom → root cause → fix - in the body. Prefer start_planning 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. """ 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=)." + ) note = await notes_svc.create_note( uid, title=title, diff --git a/src/scribe/routes/tasks.py b/src/scribe/routes/tasks.py index 95d1afd..9f0ee5f 100644 --- a/src/scribe/routes/tasks.py +++ b/src/scribe/routes/tasks.py @@ -99,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 diff --git a/tests/test_mcp_tool_tasks.py b/tests/test_mcp_tool_tasks.py index 9aa8f96..7b845d7 100644 --- a/tests/test_mcp_tool_tasks.py +++ b/tests/test_mcp_tool_tasks.py @@ -113,6 +113,16 @@ async def test_create_task_passes_status(): assert mock.call_args.kwargs["status"] == "todo" +@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() -- 2.52.0 From e8d6de287bae973b9b3464fe39947c43aad32f4d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 12:34:30 -0400 Subject: [PATCH 20/25] test: fix obsolete create_task kind=plan passthrough test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_create_task_passes_kind asserted create_task forwards kind=plan; the hard-retire guard now rejects that. Exercise passthrough with kind=issue instead. (Service-level create_note still accepts task_kind=plan by design — the guard lives at the user-facing tool/route layer, not the primitive.) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_mcp_tool_tasks_kind.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_mcp_tool_tasks_kind.py b/tests/test_mcp_tool_tasks_kind.py index e05bdb6..bb18319 100644 --- a/tests/test_mcp_tool_tasks_kind.py +++ b/tests/test_mcp_tool_tasks_kind.py @@ -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 -- 2.52.0 From 33f9a0a4d4f652df7683f07015c6006eff500768 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 13:00:32 -0400 Subject: [PATCH 21/25] =?UTF-8?q?feat(plugin):=20Phase=204=20=E2=80=94=20S?= =?UTF-8?q?cribe=20Processes=20auto-surface=20as=20local=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #755 Phase 4. Saved Scribe Processes (DRY pass, Drift Audit, …) now surface as auto-triggered Claude Code skills instead of pull-only get_process calls. Design correction vs the plan: stubs live in the USER's ~/.claude/skills/, NOT plugin/skills/_instance/. The plugin is git-cloned and identical per install, so instance-specific generated files can't ride in it; personal skills are live-detected within the session (verified via claude-code-guide). MCP prompts were the alternative but are pull-only (no relevance auto-surface), so skills are the right primitive. - backend: GET /api/plugin/processes manifest (services/plugin_context. build_process_manifest) — {name, slug, description} per Process; description is the auto-surface trigger (title + preview); slugs deduped, blanks skipped. - plugin: scribe_sync_processes.sh writes ~/.claude/skills/scribe-proc-/ SKILL.md (body = "call get_process(name), follow verbatim") and PRUNES stale scribe-proc-* stubs. Fail-open + silent; a transient fetch failure never wipes existing stubs. Runs as a 2nd SessionStart hook + via the /scribe:sync command. - plugin.json 0.1.7 → 0.1.8; README updated. - tests: build_process_manifest (render, slug dedupe, blank-title skip, preview truncation). Sync script's write+prune validated in isolation (plugin/** is not CI-covered): correct stubs created, stale pruned, unrelated skills untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/.claude-plugin/plugin.json | 4 +- plugin/README.md | 8 +++ plugin/commands/sync.md | 20 +++++++ plugin/hooks/hooks.json | 4 ++ plugin/hooks/scribe_sync_processes.sh | 82 +++++++++++++++++++++++++++ src/scribe/routes/plugin.py | 14 +++++ src/scribe/services/plugin_context.py | 56 ++++++++++++++++++ tests/test_services_plugin_context.py | 51 +++++++++++++++++ 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 plugin/commands/sync.md create mode 100755 plugin/hooks/scribe_sync_processes.sh diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 63bc343..d3c5ec3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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 process-skills (writing-plans, systematic-debugging, verification, brainstorming). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.7", + "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.8", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/README.md b/plugin/README.md index 8b3ed7c..0fea71d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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 diff --git a/plugin/commands/sync.md b/plugin/commands/sync.md new file mode 100644 index 0000000..c7640e5 --- /dev/null +++ b/plugin/commands/sync.md @@ -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-/SKILL.md` +whose body calls `get_process()` 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. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 76ce66e..23c40f9 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -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\"" } ] } diff --git a/plugin/hooks/scribe_sync_processes.sh b/plugin/hooks/scribe_sync_processes.sh new file mode 100755 index 0000000..d9bd7e3 --- /dev/null +++ b/plugin/hooks/scribe_sync_processes.sh @@ -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-/SKILL.md +# per Process. The stub's frontmatter `description` is the auto-surface trigger; +# its body tells Claude to call get_process() 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 '\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 diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index d470025..9dbf08a 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -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(): diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index a86fcdd..e31d6bb 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -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-/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 - 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).""" diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 4b2e888..63d96bd 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -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)] -- 2.52.0 From 322cbc3b5ef38b4b585f91daf485e657b42bbe77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 13:21:35 -0400 Subject: [PATCH 22/25] =?UTF-8?q?feat(mcp):=20Phase=205=20=E2=80=94=20writ?= =?UTF-8?q?e-time=20near-duplicate=20gate=20(update-over-create)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #755 Phase 5. create_note / create_task now BLOCK a near-duplicate instead of silently inserting: they return {"duplicate": true, "existing_id", message} pointing at the record to UPDATE. Fights store bloat and stale competing copies that semantic search (RAG) would otherwise resurface for reconciliation. A force=true override creates anyway for genuinely-distinct records. - services/dedup.py: find_duplicate_note — two signals, scoped to owner + same project + same kind: (1) normalized-title exact match (cheap, always); (2) semantic cosine ≥ 0.90 but ONLY when body ≥ 200 chars (short/title-only embeddings false-positive — the pre-pivot lesson). Project-less (orphan) records compare only to other orphans on BOTH signals (orphan_only on the semantic call) — they're not matched across every project. - Gate wired into the MCP create_note/create_task tools (the LLM write path) with force override; _INSTRUCTIONS documents the duplicate response + force. - Opt-in by design: the service helper is only called from the interactive create tools. Internal/programmatic creates (recurrence spawn, imports) go straight through services.create_note and are NOT gated — a recurring task spawning its next same-titled instance must not be blocked. - Scope v1: MCP tools only. REST/web (human CRUD, needs a UI affordance) and create_rule (not a RAG surface; _INSTRUCTIONS already steer it) are follow-ups. - tests: dedup service (title/semantic/body-gate/type-filter) + tool gate (blocks, force bypasses) for notes and tasks. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/server.py | 6 ++ src/scribe/mcp/tools/notes.py | 18 ++++- src/scribe/mcp/tools/tasks.py | 17 +++++ src/scribe/services/dedup.py | 124 ++++++++++++++++++++++++++++++++++ tests/test_mcp_tool_notes.py | 26 +++++++ tests/test_mcp_tool_tasks.py | 27 ++++++++ tests/test_services_dedup.py | 84 +++++++++++++++++++++++ 7 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 src/scribe/services/dedup.py create mode 100644 tests/test_services_dedup.py diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index c15f12f..fd24689 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -59,6 +59,12 @@ not something you wait to be asked for: 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 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 diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index abf7c70..d8a5b8a 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -14,6 +14,7 @@ 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 @@ -70,6 +71,7 @@ async def create_note( 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. @@ -80,10 +82,24 @@ async def create_note( 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, diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 6264253..1cb6eef 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -19,6 +19,7 @@ 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 @@ -107,6 +108,7 @@ async def create_task( kind: str = "work", system_ids: list[int] | None = None, arose_from_id: int = 0, + force: bool = False, ) -> dict: """Create a new task in Scribe. @@ -127,6 +129,14 @@ async def create_task( 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": @@ -136,6 +146,13 @@ async def create_task( "milestone + seeds the design), then create each step as its own " "task with create_task(milestone_id=)." ) + 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, diff --git a/src/scribe/services/dedup.py b/src/scribe/services/dedup.py new file mode 100644 index 0000000..7c7d575 --- /dev/null +++ b/src/scribe/services/dedup.py @@ -0,0 +1,124 @@ +"""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 + +from dataclasses import dataclass + +from sqlalchemy import func, select + +from scribe.models import async_session +from scribe.models.note import Note +from scribe.services import embeddings as embeddings_svc + +# 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_ 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) --- + if norm: + 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") + + # --- 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 diff --git a/tests/test_mcp_tool_notes.py b/tests/test_mcp_tool_notes.py index 078cd2d..ccb5144 100644 --- a/tests/test_mcp_tool_notes.py +++ b/tests/test_mcp_tool_notes.py @@ -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)] diff --git a/tests/test_mcp_tool_tasks.py b/tests/test_mcp_tool_tasks.py index 7b845d7..3841eb7 100644 --- a/tests/test_mcp_tool_tasks.py +++ b/tests/test_mcp_tool_tasks.py @@ -113,6 +113,33 @@ 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).""" diff --git a/tests/test_services_dedup.py b/tests/test_services_dedup.py new file mode 100644 index 0000000..1f6383e --- /dev/null +++ b/tests/test_services_dedup.py @@ -0,0 +1,84 @@ +"""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 + + +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 + + +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"] -- 2.52.0 From 5102ffb558f27483d813de3f55bc3c65fc23820c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 13:24:20 -0400 Subject: [PATCH 23/25] fix(dedup): fail open when the duplicate check can't run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 5 gate added a DB query before every create_note/create_task. When that query fails (DB unreachable, etc.) the create must NOT error — a dedup check is advisory infrastructure, not a correctness gate. Wrap the title query so any failure degrades to "no duplicate found" and the create proceeds. Also fixes 7 existing create tests that don't mock the DB: they now exercise the fail-open path (no Postgres in the unit-test job) instead of erroring. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/services/dedup.py | 45 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/src/scribe/services/dedup.py b/src/scribe/services/dedup.py index 7c7d575..20556fc 100644 --- a/src/scribe/services/dedup.py +++ b/src/scribe/services/dedup.py @@ -22,6 +22,7 @@ Two signals, both scoped to the same owner + project + kind: """ from __future__ import annotations +import logging from dataclasses import dataclass from sqlalchemy import func, select @@ -30,6 +31,8 @@ from scribe.models import async_session from scribe.models.note import Note 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 @@ -82,25 +85,31 @@ async def find_duplicate_note( 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: - 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") + 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: -- 2.52.0 From dd1fc2d506fb5a918b9f11c6af238b09b45f2f10 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 13:43:17 -0400 Subject: [PATCH 24/25] feat(mcp): extend dedup gate to create_rule / create_project_rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Phase 5 follow-up: rules now get the same update-over-create gate. Title-based only (rules aren't a semantic-retrieval/RAG surface), scoped to the same topic (rulebook rule) or same project (project rule). force=true overrides; fail-open like the note/task gate. Deferred-item decisions (operator): REST/web gating SKIPPED (kept MCP-only — humans rarely double-create and a hard block needs UI affordance); orphan scope kept orphan↔orphan (no change). So this rule gate is the only remaining build. - services/dedup.py: find_duplicate_rule(title, topic_id|project_id). - create_rule + create_project_rule: force param + gate. - tests: rule title match, scope-required guard, tool gate (block + force). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/scribe/mcp/tools/rulebooks.py | 17 +++++++++++++++++ src/scribe/services/dedup.py | 31 +++++++++++++++++++++++++++++++ tests/test_mcp_tool_rulebooks.py | 28 ++++++++++++++++++++++++++++ tests/test_services_dedup.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index a2af6ae..ccb9979 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -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, diff --git a/src/scribe/services/dedup.py b/src/scribe/services/dedup.py index 20556fc..980efed 100644 --- a/src/scribe/services/dedup.py +++ b/src/scribe/services/dedup.py @@ -29,6 +29,7 @@ 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__) @@ -131,3 +132,33 @@ async def find_duplicate_note( 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 diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py index 80088f8..610e383 100644 --- a/tests/test_mcp_tool_rulebooks.py +++ b/tests/test_mcp_tool_rulebooks.py @@ -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() diff --git a/tests/test_services_dedup.py b/tests/test_services_dedup.py index 1f6383e..17490c1 100644 --- a/tests/test_services_dedup.py +++ b/tests/test_services_dedup.py @@ -3,7 +3,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from scribe.services.dedup import DuplicateMatch, duplicate_response, find_duplicate_note +from scribe.services.dedup import ( + DuplicateMatch, + duplicate_response, + find_duplicate_note, + find_duplicate_rule, +) def _session_returning(note): @@ -74,6 +79,29 @@ async def test_semantic_match_of_other_note_type_is_ignored(): 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") -- 2.52.0 From ee02ed37c1091e189525075e4073f50696f1438d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 15:41:24 -0400 Subject: [PATCH 25/25] =?UTF-8?q?feat(plugin):=20compaction-hygiene=20guid?= =?UTF-8?q?ance=20=E2=80=94=20recommend=20safe=20compaction=20at=20seams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #834. The pre-compaction complement to the shipped post-compaction re-grounding banner. Because Scribe records progress as you go (task status, work-logs, decision notes), a compaction at a clean work-seam is lossless — so guide the model to recommend it proactively rather than letting auto-compact fire mid-task. Placed in the ALWAYS-loaded channels (operator wants it consistently in context, not relevance-gated like a skill): MCP _INSTRUCTIONS (every handshake) + the static SessionStart floor (every session, MCP-independent). Behavior: at the end of a block of work in a long session, ensure in-flight state is logged, then tell the operator it's a safe moment to /compact (naming what was logged); recommend at seams, not every turn; the model can't run /compact itself. plugin.json 0.1.8 → 0.1.9 so clients re-pull the static-context change. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_static_context.md | 6 ++++++ src/scribe/mcp/server.py | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index d3c5ec3..eb2d556 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -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, 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.8", + "version": "0.1.9", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 5b8e602..4f42187 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -23,6 +23,12 @@ for the operator's work, and as your own working memory across sessions. 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. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index fd24689..0d39ca0 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -94,6 +94,21 @@ Keep task state honest — this is what makes the project a trustworthy record: (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 start of any session that touches Scribe, call list_always_on_rules() to -- 2.52.0