From e2e64b94c07943cc8877d7a878916560619d4ab9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 27 Aug 2026 12:02:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20task=5Fkind=20gains=20'spike'=20?= =?UTF-8?q?=E2=80=94=20the=20investigation,=20not=20the=20change=20(#3099,?= =?UTF-8?q?=20milestone=20312=20step=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- alembic/versions/0091_task_kind_spike.py | 66 +++++++++++++++++++++ frontend/src/types/note.ts | 11 +++- frontend/src/views/TaskEditorView.vue | 1 + src/scribe/mcp/tools/systems.py | 3 +- src/scribe/mcp/tools/tasks.py | 25 +++++--- src/scribe/models/note.py | 10 +++- tests/test_integration_task_kind_spike.py | 71 +++++++++++++++++++++++ tests/test_mcp_tool_tasks_kind.py | 14 +++++ 8 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/0091_task_kind_spike.py create mode 100644 tests/test_integration_task_kind_spike.py diff --git a/alembic/versions/0091_task_kind_spike.py b/alembic/versions/0091_task_kind_spike.py new file mode 100644 index 0000000..5b03374 --- /dev/null +++ b/alembic/versions/0091_task_kind_spike.py @@ -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), + ) diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 64105e4..c11a9d3 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -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 { diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index f19115f..4212ca5 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -578,6 +578,7 @@ useEditorGuards(dirty, save);