A task's kind is correctable — the Kind select stops lying (#3129) #133
@@ -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 TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
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 type NoteType = "note" | "process" | "snippet";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
|||||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||||
<option value="work">Work</option>
|
<option value="work">Work</option>
|
||||||
<option value="issue">Issue</option>
|
<option value="issue">Issue</option>
|
||||||
|
<option value="spike">Spike</option>
|
||||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||||
offered only so legacy plan-tasks display their kind. -->
|
offered only so legacy plan-tasks display their kind. -->
|
||||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
<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.
|
slice, search(system_id=...) filters semantic search to this association.
|
||||||
|
|
||||||
Args:
|
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).
|
open_only: limit to tasks not done/cancelled (e.g. open issues only).
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
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
|
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
|
every project's. 0 = no filter (all projects — use only for a
|
||||||
deliberate cross-project view).
|
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.
|
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).
|
milestone_id: Place within a project milestone (0 = no milestone).
|
||||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||||
tags: List of plain-string tags without # prefix.
|
tags: List of plain-string tags without # prefix.
|
||||||
kind: 'work' (default) or 'issue'. An issue is corrective work — a
|
kind: 'work' (default), 'issue', or 'spike'.
|
||||||
problem you fixed or are fixing; record symptom → root cause → fix
|
An ISSUE is corrective work — a problem you fixed or are fixing;
|
||||||
in the body. (Plans are milestones now — call start_planning to begin
|
record symptom → root cause → fix in the body.
|
||||||
a plan; 'plan' is not a valid kind here.)
|
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
|
system_ids: Ids of the project's Systems (reusable subsystem/area
|
||||||
objects; see list_systems / create_system) to associate this task with.
|
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
|
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||||
(provenance). 0 = none.
|
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
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
meaning-similar task already exists in the same project, creation is
|
meaning-similar task already exists in the same project, creation is
|
||||||
BLOCKED and the existing task's id is returned so you update it
|
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
|
# 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.)
|
# 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")
|
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
|
# 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
|
# 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")
|
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||||
# Queryable structured fields for typed records — currently snippets, whose
|
# Queryable structured fields for typed records — currently snippets, whose
|
||||||
# name/language/signature/locations live here so they can be INDEXED. The
|
# 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
|
from scribe.mcp.tools.tasks import list_tasks
|
||||||
await list_tasks()
|
await list_tasks()
|
||||||
assert mock.call_args.kwargs["task_kind"] is None
|
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