Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:24:26 -04:00
4 changed files with 122 additions and 28 deletions
+28 -21
View File
@@ -1,12 +1,19 @@
# CI runs first; build only proceeds if all checks pass. # CI runs first; build only proceeds if all checks pass.
# #
# Push to dev: typecheck + lint + test + build :dev + :<sha> # Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version> # Push to main: typecheck + lint + test + build :<sha> (no moving tag)
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# #
# main pushes are NOT gated here: a merge to main only happens after # Both dev and main are gated AND built. dev pushes move the :dev tag; main
# dev has already passed CI, and the release tag is the sole trigger # pushes publish only the immutable :<sha> image — no :main tag, because
# for a production image. Re-running CI on the merge commit just burns # :latest (release-only) is the single production pointer and a :main alias
# runner time without changing the outcome. # would just duplicate it. Running CI on the main merge commit is intentional:
# main is validated and its :<sha> image is the rollback point. The v* release
# tag is the ONLY trigger that publishes :latest plus the immutable :<version>.
#
# Successive pushes to the SAME ref supersede each other (see concurrency
# below), so rapid pushes don't stack identical work; dev and main runs are
# independent refs and never cancel one another.
# #
# To cut a release: # To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name. # Create a release via the Forgejo UI on main with a v* tag name.
@@ -16,11 +23,8 @@
# gating on branch push is already enough. # gating on branch push is already enough.
# #
# NOTE on the `if:` guards below: Forgejo Actions does not consistently # NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main # honor `on.push.branches` as a filter, so every job repeats the ref check
# still trigger the workflow, producing redundant runs on the same SHA # explicitly — permitting dev, main, and v* tags, rejecting anything else.
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
# #
# Required secrets (repo → Settings → Secrets → Actions): # Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username # REGISTRY_USER — your Forgejo username
@@ -29,7 +33,7 @@ name: CI & Build
on: on:
push: push:
branches: [dev] branches: [dev, main]
tags: ["v*"] tags: ["v*"]
paths: paths:
- "src/**" - "src/**"
@@ -67,8 +71,8 @@ env:
jobs: jobs:
typecheck: typecheck:
name: TypeScript typecheck name: TypeScript typecheck
# Skip on main merge-commit pushes — see workflow header comment. # Gate dev, main, and v* tags; reject any other ref (see header note).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -92,7 +96,7 @@ jobs:
lint: lint:
name: Python lint name: Python lint
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -106,7 +110,7 @@ jobs:
test: test:
name: Python tests name: Python tests
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -138,11 +142,9 @@ jobs:
build: build:
name: Build & push image name: Build & push image
needs: [typecheck, lint, test] needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only. # Build on dev, main, and v* tag pushes. dev → :dev, main → (sha only),
# Mirrors the ref guard on the gate jobs above — main merge-commit # tag → :latest + :<version>; every build also gets an immutable :<sha>.
# pushes skip here too, so no production image is ever built from a if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -168,6 +170,11 @@ jobs:
refs/heads/dev) refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev" TAGS="$TAGS,${{ env.IMAGE }}:dev"
;; ;;
refs/heads/main)
# main publishes only the immutable :<sha> image (set above) —
# no :main tag; :latest (release-only) is the production pointer.
BUILD_VERSION="main"
;;
refs/tags/*) refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}" BUILD_VERSION="${{ github.ref_name }}"
+38 -1
View File
@@ -36,7 +36,20 @@ Mechanics:
- Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave - Tags are plain strings (no `#` prefix). Empty list clears tags; omit to leave
unchanged on updates. unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean - For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
"not set". "not set". On update_task, -1 clears an existing FK (e.g. milestone_id=-1
removes the task from its milestone); 0 leaves it unchanged.
Keep task state honest — this is what makes the project a trustworthy record:
- When you begin working a task, set it to in_progress (update_task
status=in_progress).
- Log progress as you go with add_task_log — at meaningful steps, not saved up
for the end.
- The moment a task's work is complete, set it done. Never leave finished work
at todo/in_progress — an out-of-date status makes Scribe misrepresent what's
left to do.
- At a significant landing (a merge, a shipped feature, a finished plan), write
a short dated dev-log note on the project (create_note) summarizing what
landed, and mark the plan/task done.
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
an actionable statement plus optional Why and How-to-apply context. At the an actionable statement plus optional Why and How-to-apply context. At the
@@ -56,6 +69,19 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to
avoid duplicates. Coordinate with the operator on whether a new rule belongs avoid duplicates. Coordinate with the operator on whether a new rule belongs
in a project, an existing rulebook+topic, or a new rulebook. in a project, an existing rulebook+topic, or a new rulebook.
That boundary cuts the other way too. Because rules are pull-only, a fresh
session won't reach for them unless its always-loaded context says to. So
when the current project subscribes to a rulebook (enter_project /
get_project reports subscribed_rulebooks), make sure the host's persistent
memory — the CLAUDE.md / AGENTS.md / ~/.claude memory the client loads at
startup — carries a short pointer: that the engineering and workflow rules
for this work live in Scribe and must be loaded via list_always_on_rules (or
enter_project when a project is in scope), plus a one-line note of what the
current project is and what is in flight. Add or refresh that pointer when
it's missing or stale; never copy the rules' content into memory — the
pointer plus project context is the whole job. This is what lets the next
session reach for Scribe instead of trusting a stale local copy.
When you are working on a specific project, call enter_project(project_id) When you are working on a specific project, call enter_project(project_id)
ONCE at session start (or whenever the active project changes). It returns the ONCE at session start (or whenever the active project changes). It returns the
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
@@ -63,6 +89,17 @@ summary, open tasks, and recent notes — everything you need to know the lay of
the land before mutating. Don't call get_project + get_applicable_rules + a the land before mutating. Don't call get_project + get_applicable_rules + a
search separately when enter_project already composes them. search separately when enter_project already composes them.
Don't wait to be told which project you're in. At the start of a session that
touches Scribe — or the moment work clearly belongs to a project but none is in
scope — bootstrap project context proactively: search for a related existing
project (search / list_projects, matching on the work's subject, the repo or
directory name, and recent activity). If you find a confident match, propose it
and call enter_project once the operator confirms. If nothing matches, offer to
create a project, confirming its name and goal first. Always confirm before
adopting or creating — never do either silently, and never guess a project into
existence. Once a project is in scope, the enter_project handshake and the
host-memory pointer step above both apply.
Plans are tasks with kind=plan, and Scribe is the canonical home for them. 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 — When you begin non-trivial work, call start_planning(project_id, title) FIRST —
before any brainstorming, design, or plan-writing skill runs. start_planning before any brainstorming, design, or plan-writing skill runs. start_planning
+16 -6
View File
@@ -14,7 +14,7 @@ Sentinels (preserved from existing fable-mcp):
what makes a Note a Task) what makes a Note a Task)
- priority="none" sets explicit no-priority; priority="" is "leave unchanged" - priority="none" sets explicit no-priority; priority="" is "leave unchanged"
- project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create, - project_id=0 / milestone_id=0 / parent_id=0 → "no association" on create,
"leave unchanged" on update "leave unchanged" on update; on update, -1 clears the FK (sets it NULL)
""" """
from __future__ import annotations from __future__ import annotations
@@ -143,10 +143,14 @@ async def update_task(
task_id: ID of the task to update. task_id: ID of the task to update.
title: New title, or omit to leave unchanged. title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged. body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled. status: New status — one of: todo, in_progress, done, cancelled. Drive
the lifecycle: set in_progress when you start, done when complete —
don't leave finished work at todo.
priority: New priority — one of: none, low, medium, high. priority: New priority — one of: none, low, medium, high.
project_id: New project. Omit (0) to leave unchanged. project_id: New project. 0 = leave unchanged, -1 = clear (remove from
milestone_id: New milestone. Omit (0) to leave unchanged. its project; also clears the milestone), positive = set.
milestone_id: New milestone. 0 = leave unchanged, -1 = clear (remove
from its milestone), positive = set.
""" """
uid = current_user_id() uid = current_user_id()
fields: dict = {} fields: dict = {}
@@ -158,9 +162,15 @@ async def update_task(
fields["status"] = status fields["status"] = status
if priority: if priority:
fields["priority"] = priority fields["priority"] = priority
if project_id: # Optional FKs: 0 = leave unchanged, -1 = clear (set NULL), positive = set.
if project_id == -1:
fields["project_id"] = None
fields["milestone_id"] = None # a milestone can't outlive its project
elif project_id:
fields["project_id"] = project_id fields["project_id"] = project_id
if milestone_id: if milestone_id == -1:
fields["milestone_id"] = None
elif milestone_id:
fields["milestone_id"] = milestone_id fields["milestone_id"] = milestone_id
note = await notes_svc.update_note(uid, task_id, **fields) note = await notes_svc.update_note(uid, task_id, **fields)
if note is None: if note is None:
+40
View File
@@ -164,6 +164,46 @@ async def test_update_task_raises_when_not_found():
await update_task(task_id=999, status="done") await update_task(task_id=999, status="done")
@pytest.mark.asyncio
async def test_update_task_milestone_zero_is_omitted():
"""milestone_id=0 is 'leave unchanged' — must not reach the service."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=0)
assert "milestone_id" not in mock.call_args.kwargs
@pytest.mark.asyncio
async def test_update_task_milestone_positive_is_set():
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=42)
assert mock.call_args.kwargs["milestone_id"] == 42
@pytest.mark.asyncio
async def test_update_task_milestone_negative_one_clears():
"""milestone_id=-1 clears the milestone (sets the column NULL)."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, milestone_id=-1)
assert mock.call_args.kwargs["milestone_id"] is None
@pytest.mark.asyncio
async def test_update_task_clearing_project_also_clears_milestone():
"""project_id=-1 clears the project and, with it, the milestone."""
fake = _fake_task()
mock = AsyncMock(return_value=fake)
with patch("fabledassistant.mcp.tools.tasks.notes_svc.update_note", mock):
await update_task(task_id=1, project_id=-1)
assert mock.call_args.kwargs["project_id"] is None
assert mock.call_args.kwargs["milestone_id"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_task_log_returns_log_dict(): async def test_add_task_log_returns_log_dict():
log = MagicMock() log = MagicMock()