Rules that can go stale say so — verify_with, expires_when, the sweep, and task_kind='spike' (milestone 312, steps 1–5) #132
@@ -0,0 +1,66 @@
|
||||
"""task_kind gains 'spike' — the investigation, not the change
|
||||
(milestone 312 step 5)
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A spike is a task shape the others cannot hold. `work` ships a change;
|
||||
`issue` fixes something broken. A spike is time-boxed and its output is
|
||||
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
|
||||
end of it. "Find out whether the runner can be given a bash shell" is not
|
||||
work, and filing it as work makes a finished investigation look like an
|
||||
abandoned change.
|
||||
|
||||
It is the record a failed check asks for. Milestone 312 gave rules a
|
||||
`verify_with`; when one of those fails, the rule is wrong and the next move
|
||||
is often to go and find out what replaced it. `notes.arose_from_id` already
|
||||
exists (0065), so that constraint -> spike link needs no further schema.
|
||||
|
||||
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
|
||||
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
||||
0065 did when it introduced 'issue'. Adding the value and constraining it
|
||||
later leaves a window where the database accepts anything.
|
||||
|
||||
'plan' stays in the list though it is retired (plans are milestones since
|
||||
0066): historical plan-tasks still carry it, and dropping it from the
|
||||
whitelist would make old rows unwritable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0091"
|
||||
down_revision = "0090"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# One tuple so the upgrade and the downgrade cannot disagree about what the
|
||||
# list was on either side of this migration.
|
||||
_KINDS_AFTER = ("work", "plan", "issue", "spike")
|
||||
_KINDS_BEFORE = ("work", "plan", "issue")
|
||||
|
||||
|
||||
# Restated rather than imported from 0088, which has the same helper. A
|
||||
# migration is a snapshot: it must keep working when the code around it has
|
||||
# moved on, so it never imports from live modules or from its siblings. Six
|
||||
# duplicated lines are the price of that, and the cheap half of the bargain.
|
||||
def _in_list(values: tuple[str, ...]) -> str:
|
||||
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Any row already filed as a spike would violate the narrowed constraint,
|
||||
# so they are demoted to 'work' first. Lossy and deliberately so: the
|
||||
# alternative is a downgrade that fails on real data, which is worse than
|
||||
# a downgrade that says what it did.
|
||||
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
|
||||
)
|
||||
@@ -2,7 +2,16 @@ import type { System } from "@/api/systems";
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
/**
|
||||
* What KIND of work a task is, not how it is going.
|
||||
* work — ships a change (default)
|
||||
* issue — corrective; something was broken
|
||||
* spike — time-boxed, output is knowledge; it succeeds by producing an
|
||||
* answer and nothing ships at the end of it
|
||||
* plan — retired (plans are milestones); kept so historical plan-tasks
|
||||
* still render their kind
|
||||
*/
|
||||
export type TaskKind = "work" | "plan" | "issue" | "spike";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
|
||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||
<option value="work">Work</option>
|
||||
<option value="issue">Issue</option>
|
||||
<option value="spike">Spike</option>
|
||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||
offered only so legacy plan-tasks display their kind. -->
|
||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||
|
||||
@@ -336,7 +336,8 @@ async def list_system_records(
|
||||
slice, search(system_id=...) filters semantic search to this association.
|
||||
|
||||
Args:
|
||||
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
||||
kind: filter by task_kind — 'issue', 'work', 'spike' (or the retired
|
||||
'plan'). Omit for all.
|
||||
open_only: limit to tasks not done/cancelled (e.g. open issues only).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -46,7 +46,8 @@ 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', 'plan', or 'issue'. Omit (empty) for all kinds.
|
||||
kind: Filter by task kind — 'work', 'issue', 'spike' (or the retired
|
||||
'plan'). Omit (empty) for all kinds.
|
||||
|
||||
Results are ordered by last-updated descending.
|
||||
"""
|
||||
@@ -138,14 +139,24 @@ 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 '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.)
|
||||
kind: 'work' (default), 'issue', or 'spike'.
|
||||
An ISSUE is corrective work — a problem you fixed or are fixing;
|
||||
record symptom → root cause → fix in the body.
|
||||
A SPIKE is time-boxed and its output is KNOWLEDGE rather than a
|
||||
change: "find out whether the runner can be given a bash shell",
|
||||
"work out why the index is not used". It succeeds by producing an
|
||||
answer, so nothing ships at the end of it — which is why filing
|
||||
one as `work` makes a finished investigation look like an
|
||||
abandoned change. Reach for it when the honest deliverable is a
|
||||
finding, and say in the body what would close the box: a time, or
|
||||
the question being answered well enough to act on.
|
||||
(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.
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||
for a spike, the record that raised the question — including a
|
||||
standing rule whose check just failed. 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
|
||||
|
||||
@@ -61,10 +61,16 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
|
||||
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
|
||||
# Task sub-kind — what KIND of work this is, not how it is going:
|
||||
# work (default) — ships a change
|
||||
# issue — corrective; something was broken (0065)
|
||||
# spike — time-boxed, and its output is KNOWLEDGE rather than a change;
|
||||
# it succeeds by producing an answer, and nothing ships (0091)
|
||||
# plan — retired since 0066 (plans are milestones), kept in the CHECK
|
||||
# so historical plan-tasks stay writable
|
||||
# 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).
|
||||
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
|
||||
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||
# Queryable structured fields for typed records — currently snippets, whose
|
||||
# name/language/signature/locations live here so they can be INDEXED. The
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Real-Postgres test that the CHECK actually accepts 'spike' (0091).
|
||||
|
||||
Rule 36 exists because the value and the constraint can drift apart: the
|
||||
code starts writing a new kind while the database still refuses it, and
|
||||
nothing catches it until a write fails in front of someone. A mock cannot
|
||||
show that — it has no CHECK — so the constraint gets its own real-DB test,
|
||||
the same way migration 0090's nullability did.
|
||||
|
||||
The negative half matters as much as the positive one. A test that only
|
||||
proves 'spike' is accepted would also pass against a table with NO
|
||||
constraint at all, which is the other way this goes wrong.
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.note import Note
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def owner_id():
|
||||
async with async_session() as s:
|
||||
owner = await ensure_user(s, "spike_owner")
|
||||
uid = owner.id
|
||||
await s.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _write(uid: int, kind: str) -> int:
|
||||
async with async_session() as s:
|
||||
note = Note(
|
||||
user_id=uid, title=f"kind {kind}", body="", is_task=True,
|
||||
status="todo", task_kind=kind,
|
||||
)
|
||||
s.add(note)
|
||||
await s.commit()
|
||||
return note.id
|
||||
|
||||
|
||||
async def test_a_spike_can_be_written(owner_id):
|
||||
note_id = await _write(owner_id, "spike")
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == "spike"
|
||||
|
||||
|
||||
async def test_the_older_kinds_still_write(owner_id):
|
||||
"""0091 widens the whitelist; it must not narrow it by accident.
|
||||
|
||||
'plan' is retired — plans are milestones since 0066 — but historical
|
||||
plan-tasks still carry it, and a row that cannot be rewritten is a row
|
||||
that cannot be edited, restored, or migrated.
|
||||
"""
|
||||
for kind in ("work", "issue", "plan"):
|
||||
note_id = await _write(owner_id, kind)
|
||||
async with async_session() as s:
|
||||
assert (await s.get(Note, note_id)).task_kind == kind
|
||||
|
||||
|
||||
async def test_an_unknown_kind_is_still_refused(owner_id):
|
||||
"""The half that proves a constraint is there at all.
|
||||
|
||||
Without this, every assertion above would pass just as happily against a
|
||||
table whose CHECK had been dropped and never re-added — which is exactly
|
||||
the failure rule 36 is written against.
|
||||
"""
|
||||
with pytest.raises(IntegrityError):
|
||||
await _write(owner_id, "investigation")
|
||||
@@ -33,3 +33,17 @@ async def test_list_tasks_kind_empty_means_no_filter():
|
||||
from scribe.mcp.tools.tasks import list_tasks
|
||||
await list_tasks()
|
||||
assert mock.call_args.kwargs["task_kind"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_passes_spike():
|
||||
"""The kind a failed rule-check asks for (milestone 312).
|
||||
|
||||
Time-boxed, and its output is knowledge rather than a change — filing one
|
||||
as `work` makes a finished investigation look like an abandoned change.
|
||||
"""
|
||||
mock = AsyncMock(return_value=fake_note(task_kind="spike"))
|
||||
with patch("scribe.mcp.tools.tasks.notes_svc.create_note", mock):
|
||||
from scribe.mcp.tools.tasks import create_task
|
||||
await create_task(title="Can the runner be given a bash shell?", kind="spike")
|
||||
assert mock.call_args.kwargs["task_kind"] == "spike"
|
||||
|
||||
Reference in New Issue
Block a user