feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
A spike is a shape the other kinds 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. Filing one as `work` makes a finished investigation look like an abandoned change, which is why the distinction earns a value rather than a convention. It is also the record a failed check asks for. This milestone gave rules a verify_with; when one 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 constraint -> spike provenance needed no schema at all — only a docstring saying it is there. Rule 36: the value and the widened CHECK land in the same migration, DROP then ADD, exactly as 0065 did for 'issue'. The two whitelists live in one tuple each so upgrade and downgrade cannot disagree about what the list was on either side. The downgrade demotes existing spikes to 'work' first — lossy, deliberately, because the alternative is a downgrade that fails on real data, and one that says what it did beats one that cannot run. 'plan' stays whitelisted though retired: historical plan-tasks carry it, and a row that cannot be rewritten cannot be edited, restored or migrated. The integration test asserts both halves. A test that only proved 'spike' is accepted would pass just as happily against a table whose CHECK had been dropped and never re-added — which is the other way rule 36's failure happens — so an unknown kind is asserted to still raise. Not in scope, deliberately: any special lifecycle, time-box enforcement, or gating relationship. It is a kind, not a workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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