Compare commits
8
Commits
e3d7007417
...
v26.06.03
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837489e4f2 | ||
|
|
9a0d5f3109 | ||
|
|
5a930319ba | ||
|
|
266af7870d | ||
|
|
f446573c3d | ||
|
|
82d6812c7f | ||
|
|
8c9ca45479 | ||
|
|
e023c21aa1 |
+27
-20
@@ -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 }}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user