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);