فهرست منبع

lifecycle: draft state machine on a flattened M0001 — enqueue/delete guards, edit matrix, auto-enqueue subtasks, waiting-on-drafts display

- Migrations flattened into one M0001 (tear-down-and-recreate; nothing
  deployed): full today-schema plus tasks.status default 'draft',
  tasks.plan_auto_approve, tasks.auto_enqueue_subtasks, and the task-E
  notification tables (push_subscriptions, task_notify_prefs) so D+E
  needs no migration. is_design_task retired — every task's plan parks.
- Draft lifecycle: human-only Enqueue action (no agent path — the
  ingest API accepts only working/blocked state pushes); enqueue
  refused with a shown error while any dependency is still draft;
  Delete only in draft and only with zero dependents (error names
  them); Cancel only non-draft non-terminal; user Mark-complete
  removed (bot-only completion).
- Edit matrix (edit_task + set_status replacing update_task): draft
  edits every field except status (incl. the three task options);
  non-draft non-terminal edits priority only; terminal nothing.
  Capabilities editable while draft or queued, locked once a worker
  has the job. Task edit UI moved into an expander with the
  description pre-filled, fields gated per state.
- Auto-enqueue agent-created subtasks: per-task option; the
  /api/saddle/subtask callback enqueues immediately when the parent
  carries it (capabilities attach BEFORE the enqueue so the queue
  never sees a partial requirement set), else drafts; children
  inherit the parent's value at creation. Draft subtasks map to
  'queued' on the subtask-status wire.
- Computed display state: draft + 'waiting on subtasks to be
  enqueued' (suspended parent with draft children) wired into the
  queue rows, task page and dashboard data (draft_count +
  waiting_drafts for D+E's block).
- Plan acceptance rides the same wires (coupled here — the files are
  atomic): plan blocks (type 'plan') render Accept / Reject-with-
  feedback with the stable verdict wordings; per-task plan_auto_approve
  and the transitive subtask auto-approve answer plan blocks instantly
  web-side; answering = allocating — the retired POST /answer delivery
  pass is gone and every resume is an affinity allocation carrying the
  verdict as resume_notice on a FRESH session id (the parked row
  closes 'resumed').
clanker 1 ماه پیش
والد
کامیت
b397745530

+ 21 - 10
README.md

@@ -137,9 +137,13 @@ with ephemeral keys.
    clone). Link related projects as *co-developed* (agent may modify) or
    *reference* (read-only) from the project page.
 2. **Task** — `/tasks` → project, title, description, priority (1 is
-   highest), optionally a design task (explore/plan/question only). Chain
-   work with dependencies: a task waits until everything it depends on is
-   done.
+   highest) and the plan-11 options ("auto-approve plan",
+   "auto-enqueue agent-created subtasks", "auto-approve subtasks"). The
+   task is created in **draft**: edit every field, attach rules and
+   capabilities, then press **Enqueue** (dependencies still in draft
+   block the enqueue with an error naming them; nothing depends on a
+   draft before you may delete it). Chain work with dependencies: an
+   enqueued task waits until everything it depends on is done.
 3. **Saddle** — `/saddles` (admin) → name plus the daemon's host:port.
    Registration mints a per-saddle token and delivers it to the daemon's
    `POST /register` (with this app's base URL); a phase-1 daemon then
@@ -158,8 +162,8 @@ revokes it).
 
 ## Monitoring agent progress
 
-- **`/tasks`** — live queue: status chips (`queued → assigned → working →
-  blocked → done/failed`), dependency states and ready flags.
+- **`/tasks`** — live queue: status chips (`draft → queued → assigned →
+  working → blocked → done/failed`), dependency states and ready flags.
 - **`/tasks/{id}`** — the control room; it refreshes on every state
   pulse while a saddle works: comments thread (yours and the agent's),
   blocks, subtasks, session history, and the agent's final summary.
@@ -174,11 +178,18 @@ revokes it).
 When an agent needs a human decision it runs `saddle block "<question>"`;
 the daemon pushes the `blocked` state with the block payload, the task
 flips to **blocked** (the pulse bump lands the question on live pages
-without a reload) and the saddle parks. Type the answer, submit — the
-task becomes resume-offerable, the dispatcher wakes the affine saddle,
-and the daemon's ask returns the assignment with the answer in its
-conversation; the task returns to `working`. Answers ride the resume
-assignment exactly once per question.
+without a reload) and the saddle parks. Type the answer, submit —
+answering IS allocating: the answered task becomes resume-offerable,
+the dispatcher wakes the affine saddle, and the daemon's ask returns
+the assignment carrying the verdict as its `resume_notice` (the stable
+wordings — "Human selected: …", "Plan approved. Proceed with
+implementation.", "Review approved. Continue with the task." &c.) with
+a **fresh session id**; the parked session closes as `resumed`. `saddle
+plan` parks the same way with a type=`plan` block ("Plan submitted for
+review"): Accept applies the markdown to the task's plan, Reject
+returns "Plan feedback received:\n…" — and the per-task auto-approve
+option (or an ancestor's subtask auto-approve toggle) answers plan
+blocks instantly web-side.
 
 ## Rules and project notes
 

+ 0 - 9
meson.build

@@ -107,15 +107,6 @@ app_sources = files(
     'src/Json.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
-    'src/Data/Migrations/M0002_Part2.vala',
-    'src/Data/Migrations/M0003_Rules.vala',
-    'src/Data/Migrations/M0004_ProjectNotes.vala',
-    'src/Data/Migrations/M0005_RawLog.vala',
-    'src/Data/Migrations/M0006_Capabilities.vala',
-    'src/Data/Migrations/M0007_SuspendedAt.vala',
-    'src/Data/Migrations/M0008_AutoApprove.vala',
-    'src/Data/Migrations/M0009_GitIdentity.vala',
-    'src/Data/Migrations/M0010_SaddleToken.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',

+ 17 - 2
src/Api/SaddleApi.vala

@@ -96,6 +96,9 @@ namespace ClankerSaddle.Api {
                         get_string_or(data, "body", null));
                     yield tasks.log_session_entry(task_id, "block",
                         @"block: $(block.question)", block.body);
+                    // The instant-approve levers answer covered review
+                    // and plan blocks web-side the moment they arrive.
+                    yield tasks.auto_answer_if_covered(block);
                     var block_response = new Json.Object();
                     block_response.set_int_member("block_id", block.id);
                     return block_response;
@@ -150,7 +153,7 @@ namespace ClankerSaddle.Api {
                 case "status":
                     string state = get_string_or(data, "state", "");
                     if (state == "working" || state == "blocked") {
-                        yield tasks.update_task(task_id, null, null, null, state);
+                        yield tasks.set_status(task_id, state);
                     }
                     break;
 
@@ -647,9 +650,13 @@ namespace ClankerSaddle.Api {
                 if (existing != null) {
                     task = (!)existing;
                 } else {
+                    // enqueue=false keeps the row in draft until the
+                    // explicit capabilities are attached below — the
+                    // dispatcher must never see the subtask with a
+                    // partial requirement set.
                     task = yield tasks.create_subtask_with_dependencies(parent_task_id,
                         title, SaddleUpdateEndpoint.get_string_or(body, "description", null),
-                        depends_on);
+                        depends_on, false);
                     created = true;
                 }
                 if (created) {
@@ -661,6 +668,14 @@ namespace ClankerSaddle.Api {
                 foreach (var capability in resolved_capabilities) {
                     yield capabilities.attach_to_task(capability.id, task.id);
                 }
+                // The parent's auto-enqueue option (plan 11): a subtask
+                // created under a parent carrying it enqueues immediately
+                // — after its capabilities landed, so the queue only ever
+                // sees the full requirement set. Otherwise the subtask
+                // stays in draft for the human Enqueue action.
+                if (created && ((!)parent).task.auto_enqueue_subtasks) {
+                    yield tasks.enqueue_task(task.id);
+                }
                 var response = new Json.Object();
                 response.set_int_member("task_id", task.id);
                 return SaddleUpdateEndpoint.json_ok(response);

+ 60 - 9
src/Data/Entities.vala

@@ -47,11 +47,14 @@ namespace ClankerSaddle.Data {
     }
 
     /**
-     * A unit of work for a saddle agent: queued, dispatched to a saddle,
-     * worked, possibly blocked on questions, and eventually done or failed.
-     * Stored in the `tasks` table; child tasks reference their parent via
-     * {@link parent_task_id}. Named `TaskEntity` (not `Task`) to stay
-     * clear of `GLib.Task` in files that import Gio transitively.
+     * A unit of work for a saddle agent: created in {@link
+     * TaskService.STATUS_DRAFT} and queued only by the human Enqueue
+     * action, dispatched to a saddle, worked, possibly blocked on
+     * questions or plans, and eventually done or failed (completion is
+     * bot-only). Stored in the `tasks` table; child tasks reference
+     * their parent via {@link parent_task_id}. Named `TaskEntity`
+     * (not `Task`) to stay clear of `GLib.Task` in files that import
+     * Gio transitively.
      */
     public class TaskEntity : Object {
 
@@ -60,9 +63,8 @@ namespace ClankerSaddle.Data {
         public int64 project_id { get; set; }
         public string title { get; set; default = ""; }
         public string? description { get; set; default = null; }
-        public string status { get; set; default = "queued"; }
+        public string status { get; set; default = "draft"; }
         public int64 priority { get; set; default = 5; }
-        public bool is_design_task { get; set; default = false; }
         public string? working_branch { get; set; default = null; }
         public string? summary { get; set; default = null; }
         public string? plan { get; set; default = null; }
@@ -73,6 +75,8 @@ namespace ClankerSaddle.Data {
         public DateTime? completed_at { get; set; default = null; }
         public DateTime? suspended_at { get; set; default = null; }
         public int64 auto_approve { get; set; default = 0; }
+        public bool plan_auto_approve { get; set; default = false; }
+        public bool auto_enqueue_subtasks { get; set; default = false; }
 
         public static void entity_mapping(EntityMapperBuilder<TaskEntity> cfg) {
             cfg.table("tasks")
@@ -83,7 +87,6 @@ namespace ClankerSaddle.Data {
                 .column<string>("description", o => o.description, (o, v) => o.description = v)
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
                 .column<int64?>("priority", o => o.priority, (o, v) => o.priority = v)
-                .column<bool>("is_design_task", o => o.is_design_task, (o, v) => o.is_design_task = v)
                 .column<string>("working_branch", o => o.working_branch, (o, v) => o.working_branch = v)
                 .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
                 .column<string>("plan", o => o.plan, (o, v) => o.plan = v)
@@ -93,7 +96,9 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v)
-                .column<int64?>("auto_approve", o => o.auto_approve, (o, v) => o.auto_approve = v ?? 0);
+                .column<int64?>("auto_approve", o => o.auto_approve, (o, v) => o.auto_approve = v ?? 0)
+                .column<bool>("plan_auto_approve", o => o.plan_auto_approve, (o, v) => o.plan_auto_approve = v)
+                .column<bool>("auto_enqueue_subtasks", o => o.auto_enqueue_subtasks, (o, v) => o.auto_enqueue_subtasks = v);
         }
     }
 
@@ -444,4 +449,50 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
         }
     }
+
+    /**
+     * A user's Web Push subscription endpoint (plan 11 task E shape):
+     * `p256dh`/`auth` are the browser's application server keys; the
+     * endpoint URL is unique per browser. `user_id` references the
+     * Spry users table by value (no FK — the auth schema owns that
+     * table).
+     */
+    public class PushSubscription : Object {
+
+        public int64 id { get; set; }
+        public int64 user_id { get; set; }
+        public string endpoint { get; set; default = ""; }
+        public string p256dh { get; set; default = ""; }
+        public string auth { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<PushSubscription> cfg) {
+            cfg.table("push_subscriptions")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
+                .column<string>("endpoint", o => o.endpoint, (o, v) => o.endpoint = v)
+                .column<string>("p256dh", o => o.p256dh, (o, v) => o.p256dh = v)
+                .column<string>("auth", o => o.auth, (o, v) => o.auth = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * A user's per-task "Notify me" opt-in (plan 11 task E shape): the
+     * task's first start, failure and completion notify the opted-in
+     * user; block events notify all push-subscribed users globally.
+     */
+    public class TaskNotifyPref : Object {
+
+        public int64 id { get; set; }
+        public int64 task_id { get; set; }
+        public int64 user_id { get; set; }
+
+        public static void entity_mapping(EntityMapperBuilder<TaskNotifyPref> cfg) {
+            cfg.table("task_notify_prefs")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v)
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v);
+        }
+    }
 }

+ 228 - 7
src/Data/Migrations/M0001_Initial.vala

@@ -3,13 +3,22 @@ using InvercargillSql.Migrations;
 namespace ClankerSaddle.Data.Migrations {
 
     /**
-     * Creates the full ClankerSaddle schema: projects and their links,
-     * tasks with dependencies/comments/blocks, saddle registrations, and
-     * the session + command audit trail.
+     * The full ClankerSaddle schema in one migration (plan 11: every
+     * earlier migration flattened — the database is not deployed, so
+     * tear-down-and-recreate replaced incremental evolution). Covers:
+     * projects and their links; tasks with dependencies, comments,
+     * typed blocks, progress and the draft-lifecycle columns
+     * (`status` defaults to 'draft', `plan_auto_approve`,
+     * `auto_enqueue_subtasks`); saddle registrations with git identity
+     * and the per-saddle token; the session + command + raw-log audit
+     * trail; rules and their attachments; project notes; capability
+     * tags; and the plan-11 notification tables for task E
+     * (`push_subscriptions`, `task_notify_prefs` — included now so
+     * D+E needs no migration).
      */
     public class M0001_Initial : Migration {
 
-        public override string name { get { return "Create ClankerSaddle tables"; } }
+        public override string name { get { return "Create the ClankerSaddle schema"; } }
         public override uint64 serial { get { return 1; } }
         public override string migration_namespace { get { return "clanker-saddle"; } }
 
@@ -55,18 +64,27 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("title")
                     .not_null();
                 t.column<string>("description");
+                // Plan 11 draft lifecycle: new tasks start in draft and
+                // only the human Enqueue action queues them.
                 t.column<string>("status")
-                    .default_value<string>("queued")
+                    .default_value<string>("draft")
                     .indexed();
                 t.column<int64?>("priority")
                     .default_value<int64?>(5);
-                t.column<bool>("is_design_task")
-                    .default_value<bool>(false);
                 t.column<string>("working_branch");
                 t.column<string>("summary");
+                t.column<string>("plan");
+                t.column<string>("task_branch");
+                t.column<string>("branch_from");
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("completed_at");
+                t.column<DateTime?>("suspended_at");
+                t.column<int64?>("auto_approve");
+                t.column<bool>("plan_auto_approve")
+                    .default_value<bool>(false);
+                t.column<bool>("auto_enqueue_subtasks")
+                    .default_value<bool>(false);
             });
 
             b.create_table("task_dependencies", (t) => {
@@ -94,6 +112,7 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
                 t.column<string>("body")
                     .not_null();
+                t.column<string>("quote");
                 t.column<DateTime?>("created_at");
             });
 
@@ -108,7 +127,25 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("question")
                     .not_null();
                 t.column<string>("answer");
+                t.column<string>("type")
+                    .default_value<string>("question");
+                t.column<string>("options");
+                t.column<string>("body");
                 t.column<DateTime?>("answered_at");
+                t.column<string>("answered_by");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("task_progress", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<string>("summary")
+                    .not_null();
                 t.column<DateTime?>("created_at");
             });
 
@@ -126,6 +163,10 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<int64?>("current_task_id")
                     .references("tasks", "id");
                 t.column<DateTime?>("allocated_at");
+                t.column<string>("git_name");
+                t.column<string>("git_email");
+                t.column<string>("ssh_public_key");
+                t.column<string>("token");
             });
 
             b.create_table("sessions", (t) => {
@@ -156,16 +197,196 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
                 t.column<string>("output_text");
                 t.column<int64?>("exit_code");
+                t.column<string>("type")
+                    .default_value<string>("command");
+                t.column<string>("piped_input");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("completed_at");
                 t.column<int64?>("duration_ms");
             });
+
+            b.create_table("api_tokens", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("saddle_id")
+                    .not_null()
+                    .references("saddles", "id")
+                    .indexed();
+                t.column<string>("token")
+                    .not_null()
+                    .unique();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("rules", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<string>("body")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("rule_projects", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("rule_id")
+                    .not_null()
+                    .references("rules", "id")
+                    .indexed();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
+            });
+
+            b.create_table("task_rules", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("rule_id")
+                    .not_null()
+                    .references("rules", "id")
+                    .indexed();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.unique("uq_task_rules_pair", {"rule_id", "task_id"});
+            });
+
+            b.create_table("project_notes", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<int64?>("source_task_id")
+                    .references("tasks", "id");
+                t.column<string>("body")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("session_raw_log", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("session_id")
+                    .not_null()
+                    .references("sessions", "id")
+                    .indexed();
+                t.column<int64?>("seq")
+                    .not_null();
+                t.column<string>("chunk")
+                    .not_null();
+                t.unique("uq_session_raw_log_seq", {"session_id", "seq"});
+            });
+
+            b.create_table("capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("saddle_capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("capability_id")
+                    .not_null()
+                    .references("capabilities", "id")
+                    .indexed();
+                t.column<int64?>("saddle_id")
+                    .not_null()
+                    .references("saddles", "id")
+                    .indexed();
+                t.unique("uq_saddle_capabilities_pair", {"capability_id", "saddle_id"});
+            });
+
+            b.create_table("task_capabilities", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("capability_id")
+                    .not_null()
+                    .references("capabilities", "id")
+                    .indexed();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"});
+            });
+
+            // Plan 11 task E: Web Push subscriptions, one row per
+            // browser endpoint a user registered (the VAPID keypair is
+            // generated at startup and persisted in the config dir, not
+            // here). `user_id` references the Spry users table by value
+            // without an FK — the auth schema owns that table.
+            b.create_table("push_subscriptions", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("user_id")
+                    .not_null()
+                    .indexed();
+                t.column<string>("endpoint")
+                    .not_null()
+                    .unique();
+                t.column<string>("p256dh")
+                    .not_null();
+                t.column<string>("auth")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+
+            // Plan 11 task E: a user's per-task "Notify me" opt-in for
+            // the task's first start, failure and completion (block
+            // events notify all push-subscribed users globally).
+            b.create_table("task_notify_prefs", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .references("tasks", "id")
+                    .indexed();
+                t.column<int64?>("user_id")
+                    .not_null()
+                    .indexed();
+                t.unique("uq_task_notify_prefs_pair", {"task_id", "user_id"});
+            });
         }
 
         public override void down(MigrationBuilder b) {
+            b.drop_table("task_notify_prefs");
+            b.drop_table("push_subscriptions");
+            b.drop_table("task_capabilities");
+            b.drop_table("saddle_capabilities");
+            b.drop_table("capabilities");
+            b.drop_table("session_raw_log");
+            b.drop_table("project_notes");
+            b.drop_table("task_rules");
+            b.drop_table("rule_projects");
+            b.drop_table("rules");
+            b.drop_table("api_tokens");
             b.drop_table("session_commands");
             b.drop_table("sessions");
             b.drop_table("saddles");
+            b.drop_table("task_progress");
             b.drop_table("task_blocks");
             b.drop_table("task_comments");
             b.drop_table("task_dependencies");

+ 0 - 90
src/Data/Migrations/M0002_Part2.vala

@@ -1,90 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Part 2 schema: task plans and branch fields, progress history, typed
-     * blocks (question/rfc/review), quoted comments, typed terminal entries
-     * and per-saddle API tokens.
-     */
-    public class M0002_Part2 : Migration {
-
-        public override string name { get { return "Part 2: plans, progress, typed blocks, tokens"; } }
-        public override uint64 serial { get { return 2; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.alter_table("tasks", (t) => {
-                t.add_column<string>("plan");
-                t.add_column<string>("task_branch");
-                t.add_column<string>("branch_from");
-            });
-
-            b.create_table("task_progress", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("task_id")
-                    .not_null()
-                    .references("tasks", "id")
-                    .indexed();
-                t.column<string>("summary")
-                    .not_null();
-                t.column<DateTime?>("created_at");
-            });
-
-            b.alter_table("task_blocks", (t) => {
-                t.add_column<string>("type")
-                    .default_value<string>("question");
-                t.add_column<string>("options");
-                t.add_column<string>("body");
-            });
-
-            b.alter_table("task_comments", (t) => {
-                t.add_column<string>("quote");
-            });
-
-            b.alter_table("session_commands", (t) => {
-                t.add_column<string>("type")
-                    .default_value<string>("command");
-                t.add_column<string>("piped_input");
-            });
-
-            b.create_table("api_tokens", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("saddle_id")
-                    .not_null()
-                    .references("saddles", "id")
-                    .indexed();
-                t.column<string>("token")
-                    .not_null()
-                    .unique();
-                t.column<DateTime?>("created_at");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            b.drop_table("api_tokens");
-            b.alter_table("session_commands", (t) => {
-                t.drop_column("piped_input");
-                t.drop_column("type");
-            });
-            b.alter_table("task_comments", (t) => {
-                t.drop_column("quote");
-            });
-            b.alter_table("task_blocks", (t) => {
-                t.drop_column("body");
-                t.drop_column("options");
-                t.drop_column("type");
-            });
-            b.drop_table("task_progress");
-            b.alter_table("tasks", (t) => {
-                t.drop_column("branch_from");
-                t.drop_column("task_branch");
-                t.drop_column("plan");
-            });
-        }
-    }
-}

+ 0 - 67
src/Data/Migrations/M0003_Rules.vala

@@ -1,67 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack rules schema: reusable rule bodies, their project and
-     * task attachments. The effective rule set for a dispatch is task ∪
-     * primary project ∪ co-developed projects (see
-     * {@link ClankerSaddle.Services.RuleService}).
-     */
-    public class M0003_Rules : Migration {
-
-        public override string name { get { return "Rules: rules, rule_projects, task_rules"; } }
-        public override uint64 serial { get { return 3; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.create_table("rules", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<string>("name")
-                    .not_null()
-                    .unique();
-                t.column<string>("body")
-                    .not_null();
-                t.column<DateTime?>("created_at");
-            });
-
-            b.create_table("rule_projects", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("rule_id")
-                    .not_null()
-                    .references("rules", "id")
-                    .indexed();
-                t.column<int64?>("project_id")
-                    .not_null()
-                    .references("projects", "id")
-                    .indexed();
-                t.unique("uq_rule_projects_pair", {"rule_id", "project_id"});
-            });
-
-            b.create_table("task_rules", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("rule_id")
-                    .not_null()
-                    .references("rules", "id")
-                    .indexed();
-                t.column<int64?>("task_id")
-                    .not_null()
-                    .references("tasks", "id")
-                    .indexed();
-                t.unique("uq_task_rules_pair", {"rule_id", "task_id"});
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            b.drop_table("task_rules");
-            b.drop_table("rule_projects");
-            b.drop_table("rules");
-        }
-    }
-}

+ 0 - 39
src/Data/Migrations/M0004_ProjectNotes.vala

@@ -1,39 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack project notes: LLM-authored remarks about a project
-     * (written by `saddle note` through the callback endpoint, or any
-     * future author), curated by humans from the project page. The
-     * optional source task links a note back to the task whose agent
-     * wrote it.
-     */
-    public class M0004_ProjectNotes : Migration {
-
-        public override string name { get { return "Project notes: project_notes"; } }
-        public override uint64 serial { get { return 4; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.create_table("project_notes", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("project_id")
-                    .not_null()
-                    .references("projects", "id")
-                    .indexed();
-                t.column<int64?>("source_task_id")
-                    .references("tasks", "id");
-                t.column<string>("body")
-                    .not_null();
-                t.column<DateTime?>("created_at");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            b.drop_table("project_notes");
-        }
-    }
-}

+ 0 - 40
src/Data/Migrations/M0005_RawLog.vala

@@ -1,40 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack raw terminal log: the daemon's PTY output chunks (GET
-     * /log?since=), appended while a session is active. When a session
-     * has rows here, the terminal view renders the concatenated raw log
-     * instead of the typed entry stream (which older sessions keep
-     * using). The (session_id, seq) pair is unique so poll retries can
-     * never duplicate a chunk.
-     */
-    public class M0005_RawLog : Migration {
-
-        public override string name { get { return "Raw log: session_raw_log"; } }
-        public override uint64 serial { get { return 5; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.create_table("session_raw_log", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("session_id")
-                    .not_null()
-                    .references("sessions", "id")
-                    .indexed();
-                t.column<int64?>("seq")
-                    .not_null();
-                t.column<string>("chunk")
-                    .not_null();
-                t.unique("uq_session_raw_log_seq", {"session_id", "seq"});
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            b.drop_table("session_raw_log");
-        }
-    }
-}

+ 0 - 66
src/Data/Migrations/M0006_Capabilities.vala

@@ -1,66 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack capabilities: tag-based routing. A capability is a named
-     * tag (e.g. "gpu"); saddles declare which tags they carry and tasks
-     * declare which they require. The dispatcher only pairs a task with
-     * a saddle whose tags are a superset of the task's (an empty task
-     * set matches any saddle).
-     */
-    public class M0006_Capabilities : Migration {
-
-        public override string name { get { return "Capabilities: capabilities, saddle_capabilities, task_capabilities"; } }
-        public override uint64 serial { get { return 6; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.create_table("capabilities", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<string>("name")
-                    .not_null()
-                    .unique();
-                t.column<DateTime?>("created_at");
-            });
-
-            b.create_table("saddle_capabilities", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("capability_id")
-                    .not_null()
-                    .references("capabilities", "id")
-                    .indexed();
-                t.column<int64?>("saddle_id")
-                    .not_null()
-                    .references("saddles", "id")
-                    .indexed();
-                t.unique("uq_saddle_capabilities_pair", {"capability_id", "saddle_id"});
-            });
-
-            b.create_table("task_capabilities", (t) => {
-                t.column<int64?>("id")
-                    .auto_increment()
-                    .primary_key();
-                t.column<int64?>("capability_id")
-                    .not_null()
-                    .references("capabilities", "id")
-                    .indexed();
-                t.column<int64?>("task_id")
-                    .not_null()
-                    .references("tasks", "id")
-                    .indexed();
-                t.unique("uq_task_capabilities_pair", {"capability_id", "task_id"});
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            b.drop_table("task_capabilities");
-            b.drop_table("saddle_capabilities");
-            b.drop_table("capabilities");
-        }
-    }
-}

+ 0 - 28
src/Data/Migrations/M0007_SuspendedAt.vala

@@ -1,28 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack suspension: a task parked by `saddle suspend` records
-     * when it parked (`suspended_at`), so the dispatcher can resume the
-     * parent only for subtask events that landed after the suspension —
-     * one coalesced resume per parking window.
-     */
-    public class M0007_SuspendedAt : Migration {
-
-        public override string name { get { return "Suspension: tasks.suspended_at"; } }
-        public override uint64 serial { get { return 7; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.alter_table("tasks", (t) => {
-                t.add_column<DateTime?>("suspended_at");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            // SQLite drops the column with the table rebuild machinery;
-            // leaving the nullable column behind is harmless.
-        }
-    }
-}

+ 0 - 32
src/Data/Migrations/M0008_AutoApprove.vala

@@ -1,32 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Web-stack auto-approve: `tasks.auto_approve` marks a parent whose
-     * descendants' review blocks the web answers automatically (the
-     * toggle covers descendants only — the parent's own final review is
-     * always a human gate). `task_blocks.answered_by` records who
-     * answered a block ('auto' for machine approvals; null for humans)
-     * so the timeline can tell them apart.
-     */
-    public class M0008_AutoApprove : Migration {
-
-        public override string name { get { return "Auto-approve: tasks.auto_approve, task_blocks.answered_by"; } }
-        public override uint64 serial { get { return 8; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.alter_table("tasks", (t) => {
-                t.add_column<int64?>("auto_approve");
-            });
-            b.alter_table("task_blocks", (t) => {
-                t.add_column<string>("answered_by");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            // Nullable additive columns; leaving them behind is harmless.
-        }
-    }
-}

+ 0 - 31
src/Data/Migrations/M0009_GitIdentity.vala

@@ -1,31 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Plan 9 git identity + SSH key columns: the web stores the git
-     * identity the daemon commits as (`git_name`/`git_email`, sent in
-     * every /start as `git_identity`) and the daemon's public SSH key as
-     * reported by `GET /status` (`ssh_public_key`, empty until the
-     * daemon generates one) so it can be added as a deploy key on the
-     * git server. The private key never leaves the daemon host.
-     */
-    public class M0009_GitIdentity : Migration {
-
-        public override string name { get { return "Git identity: saddles.git_name, git_email, ssh_public_key"; } }
-        public override uint64 serial { get { return 9; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.alter_table("saddles", (t) => {
-                t.add_column<string>("git_name");
-                t.add_column<string>("git_email");
-                t.add_column<string>("ssh_public_key");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            // Nullable additive columns; leaving them behind is harmless.
-        }
-    }
-}

+ 0 - 30
src/Data/Migrations/M0010_SaddleToken.vala

@@ -1,30 +0,0 @@
-using InvercargillSql.Migrations;
-
-namespace ClankerSaddle.Data.Migrations {
-
-    /**
-     * Plan 10 phase 1: the per-saddle token column. Association mints one
-     * token per saddle (shown once at registration, copied from the
-     * saddles page, delivered to the daemon by `POST /register`); every
-     * daemon→web call carries it and removing the saddle revokes it (the
-     * row dies with the token). Legacy `api_tokens` rows keep validating
-     * during the transition; startup copies them onto the row when
-     * present so each saddle has exactly one canonical token.
-     */
-    public class M0010_SaddleToken : Migration {
-
-        public override string name { get { return "Saddle token: saddles.token"; } }
-        public override uint64 serial { get { return 10; } }
-        public override string migration_namespace { get { return "clanker-saddle"; } }
-
-        public override void up(MigrationBuilder b) {
-            b.alter_table("saddles", (t) => {
-                t.add_column<string>("token");
-            });
-        }
-
-        public override void down(MigrationBuilder b) {
-            // Nullable additive column; leaving it behind is harmless.
-        }
-    }
-}

+ 31 - 6
src/Services/AllocationService.vala

@@ -231,7 +231,21 @@ namespace ClankerSaddle.Services {
                         connection.execute("ROLLBACK");
                         return null;
                     }
-                    session_id = ((!)newest_open).id;
+                    // Plan 11: the resuming assignment carries a FRESH
+                    // session id — the parked row closes with outcome
+                    // 'resumed' (the park record) and the daemon keys
+                    // every telemetry push of the resumed round on the
+                    // new row.
+                    ((!)newest_open).ended_at = new DateTime.now_local();
+                    ((!)newest_open).outcome = "resumed";
+                    db.update<Session>((!)newest_open);
+                    var resumed = new Session() {
+                        task_id = candidate.task.id,
+                        saddle_id = saddle.id,
+                        started_at = new DateTime.now_local()
+                    };
+                    db.insert<Session>(resumed);
+                    session_id = resumed.id;
                 } else {
                     var session = new Session() {
                         task_id = candidate.task.id,
@@ -300,11 +314,14 @@ namespace ClankerSaddle.Services {
         /**
          * Assembles the claimed task's assignment payload (today's
          * /start shape): pending block answers ride the task object,
-         * a suspended parent's resume carries the coalesced subtask
-         * report as `resume_notice` plus the `branch_advanced` verdict.
-         * The hand-over itself starts the session's push-liveness clock
-         * (the daemon cannot push before it owns the assignment), so
-         * the staleness probe's window opens here.
+         * and every parked resume carries a `resume_notice` — a
+         * suspended parent's the coalesced subtask report, an answered
+         * block's the verdict text in the stable wording ({@link
+         * TaskService.verdict_notice_for}) — plus the
+         * `branch_advanced` verdict. The hand-over itself starts the
+         * session's push-liveness clock (the daemon cannot push before
+         * it owns the assignment), so the staleness probe's window
+         * opens here.
          */
         private async Json.Object assemble_assignment(SaddleService saddles,
                 ProjectService projects, Saddle saddle, AllocationClaim claim) throws Error {
@@ -324,6 +341,14 @@ namespace ClankerSaddle.Services {
                     // The parking window is over: a later suspension
                     // starts a fresh event window.
                     yield tasks.clear_suspension(task.id);
+                } else {
+                    // Answering = allocating (plan 11): the verdict rides
+                    // the resuming assignment's resume_notice, injected as
+                    // the restored conversation's opening turn.
+                    var newest = yield tasks.newest_block(task.id);
+                    if (newest != null && ((!)newest).answer != null) {
+                        resume_notice = TaskService.verdict_notice_for((!)newest);
+                    }
                 }
             }
 

+ 27 - 27
src/Services/Dispatcher.vala

@@ -93,18 +93,17 @@ namespace ClankerSaddle.Services {
 
         /**
           * One dispatch cycle: the lost-wake guard offlines saddles whose
-          * wake went unanswered, then answered verdicts are delivered to
-          * daemons still holding their parked block open (POST /answer —
-          * those daemons cannot ask mid-session), then the wake
-          * computation offers every remaining offerable task to a
-          * capable sleeping or idle-awake ask daemon (a parked task's
-          * answered block or terminal subtask event resumes it through
-          * the affinity allocation — no push paths remain). Pending
-          * subtask events and human comments are delivered to RUNNING
-          * daemons via `POST /task-update`, and silent runners get their
-          * one exceptional staleness probe. Wake computation pauses while
-          * {@link LlmEndpoint} is unconfigured (deliveries and probes
-          * continue).
+          * wake went unanswered, then the wake computation offers every
+          * offerable task to a capable sleeping or idle-awake ask daemon
+          * (a parked task's answered block or terminal subtask event
+          * resumes it through the affinity allocation — answering IS
+          * allocating since plan 11; the retired POST /answer delivery
+          * pass is gone, the verdict rides the resuming assignment's
+          * resume_notice). Pending subtask events and human comments are
+          * delivered to RUNNING daemons via `POST /task-update`, and
+          * silent runners get their one exceptional staleness probe.
+          * Wake computation pauses while {@link LlmEndpoint} is
+          * unconfigured (deliveries and probes continue).
           */
         public async void tick() throws Error {
             var scope = container.create_scope();
@@ -121,7 +120,6 @@ namespace ClankerSaddle.Services {
             } else {
                 warned_no_llm = false;
                 yield enforce_lost_wake_guard(saddles, tasks);
-                yield saddles.deliver_answered_verdicts(tasks);
                 yield wake_sleeping_saddles(saddles, capabilities, allocation);
             }
 
@@ -177,20 +175,22 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * The wake computation: for every offerable task (see
-         * {@link AllocationService.offerable_tasks}) find a capable
-         * ask-capable saddle and POST /wake — the daemon asks and the
-         * allocation transaction hands the task over. Parked tasks
-         * target their affine saddle first (only it can be allocated
-         * them): any idle saddle that has ever asked is a valid target —
-         * a SLEEPING one (its last ask returned 204) wakes to ask, and
-         * an idle-awake one re-asks; requiring the 204 signature here
-         * used to strand parked tasks whose saddle never asked again
-         * after claiming them (the answer-delivery pass above handles
-         * daemons still holding the block open, which cannot ask).
-         * Saddles with a pending wake inside the guard window are
-         * skipped — the guard decides their fate.
-         */
+          * The wake computation: for every offerable task (see
+          * {@link AllocationService.offerable_tasks}) find a capable
+          * ask-capable saddle and POST /wake — the daemon asks and the
+          * allocation transaction hands the task over. Parked tasks
+          * target their affine saddle first (only it can be allocated
+          * them; parks free the daemon, so it is idle and wakeable —
+          * the wake may land while the park still unwinds, in which
+          * case the daemon asks once idle and the lost-wake machinery
+          * covers a lost ask): any idle saddle that has ever asked is a
+          * valid target — a SLEEPING one (its last ask returned 204)
+          * wakes to ask, and an idle-awake one re-asks; requiring the
+          * 204 signature here used to strand parked tasks whose saddle
+          * never asked again after claiming them. Saddles with a
+          * pending wake inside the guard window are skipped — the
+          * guard decides their fate.
+          */
         private async void wake_sleeping_saddles(SaddleService saddles,
                 CapabilityService capabilities, AllocationService allocation) throws Error {
             foreach (var offerable in yield allocation.offerable_tasks()) {

+ 10 - 116
src/Services/SaddleService.vala

@@ -447,114 +447,6 @@ namespace ClankerSaddle.Services {
             }
         }
 
-        /**
-         * The dispatcher's answer-delivery pass: a parked task whose
-         * newest block carries an UNDELIVERED human verdict gets the
-         * verdict pushed straight into the daemon's live answer loop —
-         * `POST /answer {answer, branch_advanced}` — and the session
-         * simply continues (phase 1's blocked-daemon routing, restored).
-         *
-         * Why this exists alongside the wake: a daemon parked on a
-         * question or review holds its LLM round open in the block's
-         * answer wait; its idle loop cannot ask while the session is
-         * live, so the saddle's last ask is still the one that claimed
-         * the task — it never reads as SLEEPING and no wake can serve
-         * it. Two affine row shapes are delivered to: an idle row whose
-         * saddle is awake (the question park), and a busy row still
-         * running this very task (a review park — `saddle review`
-         * blocks the round without a parking state push). A genuinely
-         * sleeping saddle (its last ask returned 204, so nothing is
-         * parked mid-session) is skipped: the wake pass owns that
-         * resume, and the daemon's ask allocates it with the answers
-         * riding the assignment. A 409 ("no block is waiting") leaves
-         * the answer undelivered on purpose — a restarted daemon asks
-         * on its own, and the affinity allocation then carries the
-         * verdict; a transport failure is the normal unreachable
-         * bookkeeping (the task keeps its parked session for the
-         * resume).
-         */
-        public async void deliver_answered_verdicts(TaskService tasks) throws Error {
-            foreach (var status in new string[] { STATUS_BLOCKED, STATUS_REVIEW_REQUESTED }) {
-                foreach (var task in yield tasks.list_tasks(status, null)) {
-                    var newest = yield tasks.newest_block(task.id);
-                    if (newest == null || ((!)newest).answer == null
-                            || poll_state.is_delivered(((!)newest).id)) {
-                        continue;
-                    }
-                    var session = yield tasks.open_session_for_task(task.id);
-                    if (session == null) {
-                        continue;
-                    }
-                    var saddle = yield get_saddle(((!)session).saddle_id);
-                    if (saddle == null) {
-                        continue;
-                    }
-                    bool idle_awake = ((!)saddle).status == "idle"
-                        && !poll_state.is_sleeping(((!)saddle).id);
-                    bool busy_on_this_task = ((!)saddle).status == "busy"
-                        && ((!)saddle).current_task_id == task.id;
-                    if (!idle_awake && !busy_on_this_task) {
-                        continue;
-                    }
-
-                    int64 task_id = task.id;
-                    int64 block_id = ((!)newest).id;
-                    try {
-                        bool advanced = yield tasks.branch_advanced_since(task_id);
-                        var payload = new Json.Builder();
-                        payload.begin_object();
-                        payload.set_member_name("answer");
-                        payload.add_string_value(raw_answer_for_saddle(((!)newest).answer ?? ""));
-                        payload.set_member_name("branch_advanced");
-                        payload.add_boolean_value(advanced);
-                        payload.end_object();
-                        Json.Node? root = null;
-                        int status_code = yield request_saddle(((!)saddle).host, "POST",
-                            "/answer", (owned) payload, out root);
-                        if (status_code == 200) {
-                            poll_state.mark_delivered(block_id);
-                            // The daemon is working the task again. Both
-                            // writes are conditional so pushes that
-                            // already landed (a fast resumed round, a
-                            // completion) are never stomped, and a
-                            // concurrent ask claim on the idle row is
-                            // never overwritten.
-                            var flip = connection.create_command(
-                                "UPDATE tasks SET status = 'working' WHERE id = :task"
-                                + " AND status IN ('blocked', 'review_requested')");
-                            flip.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
-                            int flipped = yield flip.execute_non_query_async();
-                            var command = connection.create_command(
-                                "UPDATE saddles SET status = 'busy', current_task_id = :task,"
-                                + " allocated_at = :allocated WHERE id = :id AND current_task_id = 0"
-                                + " AND :task IN (SELECT id FROM tasks WHERE status IN"
-                                + " ('blocked', 'review_requested', 'working', 'assigned'))");
-                            command.with_parameter<Invercargill.Element>("task", elem<int64?>(task_id));
-                            command.with_parameter<Invercargill.Element>("allocated",
-                                elem<DateTime?>(new DateTime.now_local()));
-                            command.with_parameter<Invercargill.Element>("id",
-                                elem<int64?>(((!)saddle).id));
-                            yield command.execute_non_query_async();
-                            if (flipped > 0) {
-                                yield pulse.bump(task_id, "verdict delivered; task working");
-                                tasks.note_requeued_subtask(task);
-                            }
-                            print(@"[ClankerSaddle] Delivered verdict for task $(task_id) block $(block_id)"
-                                + @" to saddle '$(((!)saddle).name)' — the parked session continues\n");
-                        } else if (status_code != 409) {
-                            warning(@"[ClankerSaddle] Answer delivery to saddle $(((!)saddle).id)"
-                                + @" returned HTTP $(status_code); retrying next tick");
-                        }
-                    } catch (Error e) {
-                        warning(@"[ClankerSaddle] Answer delivery to saddle $(((!)saddle).id)"
-                            + @" failed: $(e.message); marking offline");
-                        yield mark_saddle_unreachable(((!)saddle).id, tasks,
-                            @"answer delivery failed ($(e.message))");
-                    }
-                }
-            }
-        }
-
         /**
          * Cancelling / requeueing / stopping a task with a live session
          * reaches its daemon (wire contract): `POST /task-update
@@ -823,8 +715,6 @@ namespace ClankerSaddle.Services {
             payload.add_string_value(task.title);
             payload.set_member_name("description");
             payload.add_string_value(task.description ?? "");
-            payload.set_member_name("is_design_task");
-            payload.add_boolean_value(task.is_design_task);
             payload.set_member_name("project_name");
             payload.add_string_value(((!)project).name);
             payload.set_member_name("project_branch");
@@ -1627,7 +1517,13 @@ namespace ClankerSaddle.Services {
                 case "blocked":
                     yield ensure_block(tasks, task_id, block != null ? (!)block : new Json.Object());
                     if (!(yield task_is_terminal(task_id))) {
-                        yield tasks.update_task(task_id, null, null, null, STATUS_BLOCKED);
+                        // A review-kind block parks as review_requested
+                        // (the banner state); every other kind as
+                        // blocked.
+                        bool review = block != null
+                            && block_string_or((!)block, "type", "block_type") == BLOCK_REVIEW;
+                        yield tasks.set_status(task_id,
+                            review ? STATUS_REVIEW_REQUESTED : STATUS_BLOCKED);
                     }
                     // The daemon parks the task's workspace while blocked,
                     // freeing the saddle for other work. The blocked task
@@ -1653,11 +1549,9 @@ namespace ClankerSaddle.Services {
                     if (yield task_is_terminal(task_id)) {
                         return;
                     }
-                    var current = yield tasks.update_task(task_id, null, null, null, null);
-                    if (current != null && ((!)current).status != STATUS_REVIEW_REQUESTED) {
-                        current = yield tasks.update_task(task_id, null, null, null, STATUS_WORKING);
-                    }
-                    if (current != null && ((!)current).started_at == null) {
+                    var current = yield tasks.set_status(task_id, STATUS_WORKING);
+                    if (current != null && ((!)current).status != STATUS_REVIEW_REQUESTED
+                            && ((!)current).started_at == null) {
                         ((!)current).started_at = new DateTime.now_local();
                         yield db.update_async<TaskEntity>((!)current);
                     }

+ 2 - 2
src/Services/SubtaskEventQueue.vala

@@ -81,9 +81,9 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Records a dispatched subtask (the dispatcher writes the
+         * Records a dispatched subtask (the allocation claim writes the
          * assigned transition directly, bypassing
-         * {@link TaskService.update_task} — this is its hook). The wire
+         * {@link TaskService.set_status} — this is its hook). The wire
          * value is `working`.
          */
         public void record_dispatch(TaskEntity subtask) {

+ 407 - 90
src/Services/TaskService.vala

@@ -9,6 +9,7 @@ using ClankerSaddle.Data;
 namespace ClankerSaddle.Services {
 
     /** Valid task statuses (the `tasks.status` wire values). */
+    public const string STATUS_DRAFT = "draft";
     public const string STATUS_QUEUED = "queued";
     public const string STATUS_ASSIGNED = "assigned";
     public const string STATUS_WORKING = "working";
@@ -22,14 +23,28 @@ namespace ClankerSaddle.Services {
     public const string BLOCK_QUESTION = "question";
     public const string BLOCK_RFC = "rfc";
     public const string BLOCK_REVIEW = "review";
+    public const string BLOCK_PLAN = "plan";
+
+    /**
+     * The stable verdict wordings (plan 11): they are stored as the
+     * block answers AND ride the resuming allocation's
+     * `resume_notice` verbatim, so agent and human always read the
+     * same text.
+     */
+    public const string NOTICE_PLAN_APPROVED = "Plan approved. Proceed with implementation.";
+    public const string NOTICE_PLAN_FEEDBACK = "Plan feedback received:";
+    public const string NOTICE_REVIEW_APPROVED = "Review approved. Continue with the task.";
+    public const string NOTICE_REVIEW_FEEDBACK = "Review feedback received:";
 
     /**
      * A task's computed presentation state (stored status + open
      * subtasks + unanswered blocks): `review requested` while a review
      * block is unanswered (even when the stored status is terminal —
-     * the post-mortem's `done`-with-open-review case), `delegating x/y`
+     * the post-mortem's `done`-with-open-review case), `waiting on
+     * subtasks to be enqueued` while a SUSPENDED parent still has
+     * draft children (the human must enqueue them), `delegating x/y`
      * while non-terminal subtasks exist, `waiting on subtasks` for the
-     * parked suspended state, otherwise the stored status. Pure
+     * parked suspended state otherwise, else the stored status. Pure
      * presentation — the stored statuses and the dispatcher are
      * untouched.
      */
@@ -39,17 +54,21 @@ namespace ClankerSaddle.Services {
         public bool failed { get; set; }
         public int64 subtask_total { get; set; }
         public int64 subtask_open { get; set; }
+        public int64 subtask_draft { get; set; }
 
         public static TaskDisplay compute(TaskEntity task, bool review_pending,
-                int64 subtask_total, int64 subtask_open) {
+                int64 subtask_total, int64 subtask_open, int64 subtask_draft = 0) {
             var view = new TaskDisplay() {
                 terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
                 failed = task.status == STATUS_FAILED,
                 subtask_total = subtask_total,
-                subtask_open = subtask_open
+                subtask_open = subtask_open,
+                subtask_draft = subtask_draft
             };
             if (review_pending) {
                 view.status = "review requested";
+            } else if (task.status == STATUS_SUSPENDED && subtask_draft > 0) {
+                view.status = "waiting on subtasks to be enqueued";
             } else if (subtask_open > 0) {
                 view.status = @"delegating $(subtask_total - subtask_open)/$(subtask_total)";
             } else if (task.status == STATUS_SUSPENDED) {
@@ -93,19 +112,21 @@ namespace ClankerSaddle.Services {
          * Records a subtask's current state for the daemon hosting its
          * parent's live or parked session (`POST /task-update
          * {kind: "subtask-status"}`, wire contract). The wire carries
-         * the four guard-relevant values;
-         * intermediate web states (assigned, blocked, review-requested,
-         * suspended) all mean "still in flight" and map to `working`.
-         * Only the latest state per subtask is kept (queue coalescing).
+         * the four guard-relevant values; intermediate web states
+         * (assigned, blocked, review-requested, suspended) all mean
+         * "still in flight" and map to `working`. A draft subtask maps
+         * to `queued` — not yet started, but still pending for the
+         * parent's done/suspend guard. Only the latest state per
+         * subtask is kept (queue coalescing).
          */
         private void note_subtask_event(TaskEntity task) {
             if (task.parent_task_id <= 0) {
                 return;
             }
             string wire_status = STATUS_WORKING;
-            if (task.status == STATUS_QUEUED || task.status == STATUS_DONE
-                    || task.status == STATUS_FAILED) {
-                wire_status = task.status;
+            if (task.status == STATUS_DRAFT || task.status == STATUS_QUEUED
+                    || task.status == STATUS_DONE || task.status == STATUS_FAILED) {
+                wire_status = task.status == STATUS_DRAFT ? STATUS_QUEUED : task.status;
             }
             subtask_events.enqueue(new SubtaskStatusEvent() {
                 parent_task_id = task.parent_task_id,
@@ -117,15 +138,21 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Inserts a task (status 'queued'). `parent_task_id`,
+         * Inserts a task (status 'draft' — only the human Enqueue
+         * action queues it, and no agent path exists). `parent_task_id`,
          * `description`, `task_branch` and `branch_from` may be null;
          * `branch_from` prefills from the project's default branch and
          * `task_branch` is inherited from the parent task when unset;
-         * priority defaults to 5 (1 is highest).
+         * priority defaults to 5 (1 is highest). `plan_auto_approve`
+         * answers this task's own plan blocks instantly web-side;
+         * `auto_enqueue_subtasks` enqueues subtasks created under it;
+         * `auto_approve` covers descendants' review and plan blocks.
          */
         public async TaskEntity create_task(int64 project_id, string title, string? description,
-                int64 priority, bool is_design_task, int64? parent_task_id,
-                string? task_branch = null, string? branch_from = null) throws Error {
+                int64 priority, int64? parent_task_id,
+                string? task_branch = null, string? branch_from = null,
+                bool plan_auto_approve = false, bool auto_enqueue_subtasks = false,
+                bool auto_approve = false, string status = STATUS_DRAFT) throws Error {
             string resolved_branch_from = branch_from ?? "";
             if (resolved_branch_from.length == 0) {
                 var project = yield db.query<Project>()
@@ -151,10 +178,12 @@ namespace ClankerSaddle.Services {
                 title = title,
                 description = description != null && description.length > 0 ? description : null,
                 priority = priority.clamp(1, 9),
-                is_design_task = is_design_task,
-                status = STATUS_QUEUED,
+                status = status,
                 task_branch = resolved_task_branch,
                 branch_from = resolved_branch_from.length > 0 ? resolved_branch_from : null,
+                plan_auto_approve = plan_auto_approve,
+                auto_enqueue_subtasks = auto_enqueue_subtasks,
+                auto_approve = auto_approve ? 1 : 0,
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<TaskEntity>(task);
@@ -229,9 +258,12 @@ namespace ClankerSaddle.Services {
             }
 
             // Subtask counters per parent (stringly keyed: the Invercargill
-            // collections cannot key on value types).
+            // collections cannot key on value types). Draft children count
+            // both as open (not terminal) and as drafts (the suspended
+            // parent's "waiting on subtasks to be enqueued" wording).
             var open_by_parent = new Dictionary<string, string>();
             var total_by_parent = new Dictionary<string, string>();
+            var draft_by_parent = new Dictionary<string, string>();
             foreach (var child in yield db.query<TaskEntity>()
                     .where(expr("parent_task_id > 0"))
                     .materialise_async()) {
@@ -245,6 +277,10 @@ namespace ClankerSaddle.Services {
                     open_by_parent.set(parent,
                         (int64.parse(open_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
                 }
+                if (child.status == STATUS_DRAFT) {
+                    draft_by_parent.set(parent,
+                        (int64.parse(draft_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
+                }
             }
 
             var map = new Dictionary<string, TaskDisplay>();
@@ -253,7 +289,8 @@ namespace ClankerSaddle.Services {
                 map.set(key, TaskDisplay.compute(task,
                     review_pending.has(key),
                     int64.parse(total_by_parent.get_or_default(key) ?? "0"),
-                    int64.parse(open_by_parent.get_or_default(key) ?? "0")));
+                    int64.parse(open_by_parent.get_or_default(key) ?? "0"),
+                    int64.parse(draft_by_parent.get_or_default(key) ?? "0")));
             }
             return map;
         }
@@ -266,6 +303,7 @@ namespace ClankerSaddle.Services {
             bool review_pending = false;
             int64 subtask_total = 0;
             int64 subtask_open = 0;
+            int64 subtask_draft = 0;
             foreach (var block in detail.blocks) {
                 if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
                     review_pending = true;
@@ -276,8 +314,12 @@ namespace ClankerSaddle.Services {
                 if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
                     subtask_open++;
                 }
+                if (child.status == STATUS_DRAFT) {
+                    subtask_draft++;
+                }
             }
-            return TaskDisplay.compute(detail.task, review_pending, subtask_total, subtask_open);
+            return TaskDisplay.compute(detail.task, review_pending, subtask_total,
+                subtask_open, subtask_draft);
         }
 
         public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
@@ -304,12 +346,22 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Saves the editable fields of the task identified by `id`; null
-         * arguments keep their current values. Returns null when the task
-         * does not exist. The pulse only fires when a value actually
-         * moved, so periodic status refreshes do not spam live pages.
-         */        public async TaskEntity? update_task(int64 id, string? title, string? description,
-                int64? priority, string? status) throws Error {
+         * Saves the human-editable fields of the task identified by `id`
+         * under the draft-lifecycle edit matrix: in DRAFT all fields
+         * except status are editable (title, description, priority,
+         * branch fields and the three task options); in non-draft
+         * non-terminal states only priority applies; in terminal
+         * states nothing does. Fields not editable in the current
+         * state are silently left unchanged (null arguments always
+         * mean "leave unchanged"; an empty branch field clears it).
+         * Returns null when the task does not exist. The pulse only
+         * fires when a value actually moved, so periodic status
+         * refreshes do not spam live pages.
+         */
+        public async TaskEntity? edit_task(int64 id, string? title, string? description,
+                int64? priority, string? task_branch, string? branch_from,
+                bool? plan_auto_approve, bool? auto_enqueue_subtasks,
+                bool? auto_approve) throws Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(id)))
                 .first_async();
@@ -317,39 +369,208 @@ namespace ClankerSaddle.Services {
                 return null;
             }
             bool changed = false;
-            if (title != null && title.length > 0 && ((!)task).title != title) {
-                ((!)task).title = title;
-                changed = true;
-            }
-            if (description != null) {
-                string? next_description = description.length > 0 ? description : null;
-                if (((!)task).description != next_description) {
-                    ((!)task).description = next_description;
+            bool draft = ((!)task).status == STATUS_DRAFT;
+            if (draft) {
+                if (title != null && title.length > 0 && ((!)task).title != title) {
+                    ((!)task).title = title;
+                    changed = true;
+                }
+                if (description != null) {
+                    string? next_description = description.length > 0 ? description : null;
+                    if (((!)task).description != next_description) {
+                        ((!)task).description = next_description;
+                        changed = true;
+                    }
+                }
+                if (task_branch != null) {
+                    string? next_branch = task_branch.length > 0 ? task_branch : null;
+                    if (((!)task).task_branch != next_branch) {
+                        ((!)task).task_branch = next_branch;
+                        changed = true;
+                    }
+                }
+                if (branch_from != null) {
+                    string? next_from = branch_from.length > 0 ? branch_from : null;
+                    if (((!)task).branch_from != next_from) {
+                        ((!)task).branch_from = next_from;
+                        changed = true;
+                    }
+                }
+                if (plan_auto_approve != null && ((!)task).plan_auto_approve != (!)plan_auto_approve) {
+                    ((!)task).plan_auto_approve = (!)plan_auto_approve;
+                    changed = true;
+                }
+                if (auto_enqueue_subtasks != null
+                        && ((!)task).auto_enqueue_subtasks != (!)auto_enqueue_subtasks) {
+                    ((!)task).auto_enqueue_subtasks = (!)auto_enqueue_subtasks;
+                    changed = true;
+                }
+                if (auto_approve != null
+                        && (((!)task).auto_approve != 0) != (!)auto_approve) {
+                    ((!)task).auto_approve = (!)auto_approve ? 1 : 0;
                     changed = true;
                 }
             }
-            if (priority != null) {
+            // Priority stays editable in every non-terminal state.
+            if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
+                    && priority != null) {
                 int64 clamped = ((!)priority).clamp(1, 9);
                 if (((!)task).priority != clamped) {
                     ((!)task).priority = clamped;
                     changed = true;
                 }
             }
-            if (status != null && status.length > 0 && ((!)task).status != status) {
+            yield db.update_async<TaskEntity>(task);
+            if (changed) {
+                yield pulse.bump(id, "task saved");
+            }
+            return task;
+        }
+
+        /**
+         * The internal status-transition path (daemon pushes, park and
+         * result machinery) — never reachable from a human form.
+         * Stamps `completed_at` on done and lets the subtask's parent
+         * daemon hear the move. Returns null when the task does not
+         * exist.
+         */
+        public async TaskEntity? set_status(int64 id, string status) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return null;
+            }
+            if (((!)task).status != status) {
                 ((!)task).status = status;
                 if (status == STATUS_DONE && ((!)task).completed_at == null) {
                     ((!)task).completed_at = new DateTime.now_local();
                 }
-                changed = true;
+                yield db.update_async<TaskEntity>(task);
+                yield pulse.bump(id, @"task $(status)");
+                note_subtask_event(((!)task));
             }
-            yield db.update_async<TaskEntity>(task);
-            if (changed) {
-                yield pulse.bump(id, @"task $(status ?? "saved")");
-                if (status != null && status.length > 0) {
-                    note_subtask_event(((!)task));
+            return task;
+        }
+
+        /**
+         * The human-only Enqueue action: moves a draft task to the
+         * queue. Refused with an error naming them while any
+         * dependency is still in draft (draft dependencies never
+         * finish, so enqueueing would strand the task); dependencies
+         * in any other non-done state simply hold dispatch back (the
+         * dispatcher's readiness check). There is no agent path to
+         * this — the saddle API only ever reports working/blocked.
+         */
+        public async void enqueue_task(int64 id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 50,
+                    @"No task exists with id $(id)");
+            }
+            if (((!)task).status != STATUS_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 51,
+                    "Only draft tasks can be enqueued");
+            }
+
+            var blockers = new Vector<string>();
+            foreach (var dep in yield db.query<TaskDependency>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                var other = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(dep.depends_on_task_id)))
+                    .first_async();
+                if (other != null && ((!)other).status == STATUS_DRAFT) {
+                    blockers.add(@"#$(dep.depends_on_task_id) $(((!)other).title)");
                 }
             }
-            return task;
+            if (blockers.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 52,
+                    "Cannot enqueue while dependencies are still in draft: "
+                    + string.joinv(", ", blockers.to_array()));
+            }
+
+            ((!)task).status = STATUS_QUEUED;
+            yield db.update_async<TaskEntity>((!)task);
+            yield pulse.bump(id, "task enqueued");
+            note_subtask_event(((!)task));
+        }
+
+        /**
+         * The human-only Delete action: removes a DRAFT task with no
+         * dependents. Dependents — tasks holding a dependency edge to
+         * this one, or child subtasks — block the delete with an error
+         * naming every one of them (they must be deleted first).
+         * Attached rows (comments, blocks, progress, rules,
+         * capabilities, the task's own dependency edges) die with the
+         * task.
+         */
+        public async void delete_task(int64 id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return;
+            }
+            if (((!)task).status != STATUS_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 53,
+                    "Only draft tasks can be deleted (cancel or retry handles the rest)");
+            }
+
+            var dependents = new Vector<string>();
+            foreach (var dep in yield db.query<TaskDependency>()
+                    .where(expr("depends_on_task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                var other = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(dep.task_id)))
+                    .first_async();
+                dependents.add(@"#$(dep.task_id) $(((!)other).title)");
+            }
+            foreach (var child in yield db.query<TaskEntity>()
+                    .where(expr("parent_task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                dependents.add(@"#$(child.id) $(child.title) (subtask)");
+            }
+            if (dependents.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 54,
+                    "Cannot delete: other tasks depend on this one — "
+                    + string.joinv(", ", dependents.to_array()));
+            }
+
+            foreach (var link in yield db.query<TaskRule>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskRule>(link);
+            }
+            foreach (var link in yield db.query<TaskCapability>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskCapability>(link);
+            }
+            foreach (var row in yield db.query<TaskProgress>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskProgress>(row);
+            }
+            foreach (var row in yield db.query<TaskComment>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskComment>(row);
+            }
+            foreach (var row in yield db.query<TaskBlock>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskBlock>(row);
+            }
+            foreach (var row in yield db.query<TaskDependency>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .materialise_async()) {
+                yield db.delete_async<TaskDependency>(row);
+            }
+            yield db.delete_async<TaskEntity>((!)task);
+            yield pulse.bump(id, "task deleted");
         }
 
         /**
@@ -393,31 +614,6 @@ namespace ClankerSaddle.Services {
                 .materialise_async();
         }
 
-        /**
-         * Saves the task's branch fields: `task_branch` is the branch the
-         * agent works on, `branch_from` the base it is cut from. Null
-         * arguments keep their current values. Returns null when the task
-         * does not exist.
-         */
-        public async TaskEntity? set_branch_fields(int64 id, string? task_branch,
-                string? branch_from) throws Error {
-            var task = yield db.query<TaskEntity>()
-                .where(expr("id == $0", elem<int64?>(id)))
-                .first_async();
-            if (task == null) {
-                return null;
-            }
-            if (task_branch != null && task_branch.length > 0) {
-                ((!)task).task_branch = task_branch;
-            }
-            if (branch_from != null && branch_from.length > 0) {
-                ((!)task).branch_from = branch_from;
-            }
-            yield db.update_async<TaskEntity>((!)task);
-            yield pulse.bump(id, "branch fields saved");
-            return task;
-        }
-
         /** Appends a comment; `author` is 'bot' or 'user:<name>'. */
         public async TaskComment add_comment(int64 task_id, string author, string body,
                 string? quote = null) throws Error {
@@ -713,29 +909,117 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Auto-answers a just-raised review block when its task is
-         * covered by an ancestor's auto-approve toggle: answer text
-         * `Auto-approved (subtask auto-approve is on)`, answered-by
-         * 'auto', a timeline entry on the session — and the verdict
-         * reaches the daemon through the same delivery path as a human
-         * answer. Questions and RFC blocks are never touched; returns
-         * true when the block was auto-answered.
+         * Auto-answers a just-raised block web-side so neither agent nor
+         * daemon knows a human was not there:
+         *
+         * - REVIEW blocks covered by a STRICT ancestor's subtask
+         *   auto-approve toggle — answer text `Auto-approved (subtask
+         *   auto-approve is on)`, answered-by 'auto'.
+         * - PLAN blocks covered by the task's own "auto-approve plan"
+         *   option OR a strict ancestor's subtask auto-approve toggle
+         *   (the transitive toggle also covers subtask plans) — answer
+         *   text {@link NOTICE_PLAN_APPROVED}, the plan markdown applied
+         *   to task.plan, answered-by 'auto'.
+         *
+         * A timeline entry lands on the session either way — and the
+         * verdict reaches the daemon through the same path as a human
+         * answer: the allocation resume's `resume_notice`. Questions
+         * and RFC blocks are never touched; returns true when the
+         * block was auto-answered.
          */
         public async bool auto_answer_if_covered(TaskBlock block) throws Error {
-            if (block.block_type != BLOCK_REVIEW || block.answered_at != null) {
+            if (block.answered_at != null) {
                 return false;
             }
-            if (!(yield is_auto_approve_covered(block.task_id))) {
-                return false;
+            if (block.block_type == BLOCK_REVIEW) {
+                if (!(yield is_auto_approve_covered(block.task_id))) {
+                    return false;
+                }
+                yield answer_block(block.id, "Auto-approved (subtask auto-approve is on)", "auto");
+                yield log_session_entry(block.task_id, "auto-approve",
+                    "review auto-approved", block.body ?? block.question);
+                return true;
+            }
+            if (block.block_type == BLOCK_PLAN) {
+                var task = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(block.task_id)))
+                    .first_async();
+                bool covered = task != null && ((!)task).plan_auto_approve;
+                if (!covered) {
+                    covered = yield is_auto_approve_covered(block.task_id);
+                }
+                if (!covered) {
+                    return false;
+                }
+                yield answer_block(block.id, NOTICE_PLAN_APPROVED, "auto");
+                if (block.body != null && ((!)block.body).length > 0) {
+                    yield update_plan(block.task_id, (!)block.body);
+                }
+                yield log_session_entry(block.task_id, "auto-approve",
+                    "plan auto-approved", block.body ?? block.question);
+                return true;
             }
-            yield answer_block(block.id, "Auto-approved (subtask auto-approve is on)", "auto");
-            yield log_session_entry(block.task_id, "auto-approve",
-                "review auto-approved", block.body ?? block.question);
-            return true;
+            return false;
         }
 
-        /** Creates a child task under `parent_task_id` (status 'queued'). */
-        public async TaskEntity create_subtask(int64 parent_task_id, string title,
+        /**
+         * The resume_notice wording for an answered block (the plan-11
+         * stable verdicts): review and plan answers are already stored
+         * in the stable wording and pass through verbatim; question
+         * replies are formatted the way the daemon used to format
+         * delivered answers ("Human selected: …" for option matches,
+         * "Human answered (other): …" otherwise); RFC/plain answers
+         * carry the "Human answer: …" prefix.
+         */
+        public static string verdict_notice_for(TaskBlock block) {
+            string answer = block.answer ?? "";
+            if (block.block_type == BLOCK_REVIEW || block.block_type == BLOCK_PLAN) {
+                return answer;
+            }
+            if (block.block_type == BLOCK_QUESTION) {
+                foreach (var option in parse_block_options(block.options)) {
+                    if (option == answer) {
+                        return "Human selected: " + answer;
+                    }
+                }
+                return "Human answered (other): " + answer;
+            }
+            return "Human answer: " + answer;
+        }
+
+        /** The stored options JSON (a string array) back into a Vala array. */
+        public static string[] parse_block_options(string? options_json) {
+            if (options_json == null || ((!)options_json).length == 0) {
+                return new string[0];
+            }
+            try {
+                var parser = new Json.Parser();
+                parser.load_from_data((!)options_json);
+                var root = parser.get_root();
+                if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
+                    return new string[0];
+                }
+                var values = new Vector<string>();
+                foreach (var element in root.get_array().get_elements()) {
+                    if (element.get_node_type() == Json.NodeType.VALUE
+                            && element.get_string() != null) {
+                        values.add(element.get_string());
+                    }
+                }
+                return values.to_array();
+            } catch (Error e) {
+                return new string[0];
+            }
+        }
+
+        /**
+         * Creates a child task under `parent_task_id` in draft (the
+         * row-level builder shared by the human form, the entry-log
+         * fallback and the synchronous callback). The child always
+         * inherits the parent's auto-enqueue value as its own setting
+         * for ITS children at creation time.
+         */
+        private async TaskEntity create_subtask_draft(int64 parent_task_id, string title,
                 string? description) throws Error {
             var parent = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(parent_task_id)))
@@ -745,8 +1029,26 @@ namespace ClankerSaddle.Services {
                     @"No task exists with id $(parent_task_id)");
             }
             return yield create_task(((!)parent).project_id, title, description,
-                ((!)parent).priority, false, parent_task_id,
-                ((!)parent).task_branch, ((!)parent).branch_from);
+                ((!)parent).priority, parent_task_id,
+                ((!)parent).task_branch, ((!)parent).branch_from,
+                false, ((!)parent).auto_enqueue_subtasks, false, STATUS_DRAFT);
+        }
+
+        /**
+         * Creates a subtask applying the auto-enqueue rule: a subtask
+         * created under T enqueues immediately when T carries the
+         * "Auto-enqueue agent-created subtasks" option, otherwise it
+         * lands in draft — and either way the child inherits T's value
+         * as its own setting for its children at creation time.
+         */
+        public async TaskEntity create_subtask(int64 parent_task_id, string title,
+                string? description) throws Error {
+            var child = yield create_subtask_draft(parent_task_id, title, description);
+            if (child.auto_enqueue_subtasks) {
+                yield enqueue_task(child.id);
+                child.status = STATUS_QUEUED;
+            }
+            return child;
         }
 
         /**
@@ -778,10 +1080,14 @@ namespace ClankerSaddle.Services {
          * be an ancestor of the new subtask — depending on your own
          * ancestor would deadlock the parent/child wait (the parent
          * waits for its subtasks while the subtask waits for the
-         * parent's chain).
+         * parent's chain). With `enqueue` false the row stays in draft
+         * so the caller can attach capabilities before queueing it (the
+         * callback's race-free ordering); with it true the parent's
+         * auto-enqueue setting decides.
          */
         public async TaskEntity create_subtask_with_dependencies(int64 parent_task_id,
-                string title, string? description, int64[] depends_on) throws Error {
+                string title, string? description, int64[] depends_on,
+                bool enqueue = true) throws Error {
             foreach (int64 dependency in depends_on) {
                 var dep = yield db.query<TaskEntity>()
                     .where(expr("id == $0", elem<int64?>(dependency)))
@@ -806,10 +1112,16 @@ namespace ClankerSaddle.Services {
                 }
             }
 
-            var task = yield create_subtask(parent_task_id, title, description);
+            var task = yield create_subtask_draft(parent_task_id, title, description);
             foreach (int64 dependency in depends_on) {
                 yield add_dependency(task.id, dependency);
             }
+            if (enqueue && task.auto_enqueue_subtasks) {
+                // The parent's setting was inherited onto the row at
+                // creation; applying it here enqueues immediately.
+                yield enqueue_task(task.id);
+                task.status = STATUS_QUEUED;
+            }
             return task;
         }
 
@@ -847,7 +1159,8 @@ namespace ClankerSaddle.Services {
         /**
          * Cancels a task: status 'failed' with a cancellation summary. Any
          * open session is closed with outcome 'stopped' and its saddle
-         * freed.
+         * freed. Draft tasks are refused — they are deleted, not
+         * cancelled (nothing ever ran).
          */
         public async void cancel_task(int64 task_id) throws Error {
             var task = yield db.query<TaskEntity>()
@@ -857,6 +1170,10 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 9,
                     @"No task exists with id $(task_id)");
             }
+            if (((!)task).status == STATUS_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 55,
+                    "Draft tasks are deleted, not cancelled");
+            }
 
             ((!)task).status = STATUS_FAILED;
             ((!)task).completed_at = new DateTime.now_local();

+ 32 - 1
src/actions/CapabilityActions.vala

@@ -149,13 +149,33 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * True when the task's required capabilities are still editable:
+     * in draft, or queued before any worker has the job. Once a
+     * saddle is assigned (or the task is parked on one) the pairing
+     * the dispatcher negotiated is locked, and terminal tasks lock
+     * everything.
+     */
+    private static async bool task_capabilities_editable(TaskService tasks, int64 task_id)
+            throws GLib.Error {
+        var detail = yield tasks.get_task(task_id);
+        if (detail == null) {
+            return false;
+        }
+        string status = ((!)detail).task.status;
+        return status == STATUS_DRAFT || status == STATUS_QUEUED;
+    }
+
     /**
      * Attaches the form-selected capability to the sealed task as a
-     * required capability; the task page's capabilities section.
+     * required capability; the task page's capabilities section. Only
+     * editable while the task is draft or queued (before a worker has
+     * the job).
      */
     public class AttachTaskCapabilityAction : SealedTaskAction {
 
         private CapabilityService capability_service = inject<CapabilityService>();
+        private TaskService task_service = inject<TaskService>();
         private UserService users = inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -170,6 +190,11 @@ namespace ClankerSaddle {
                 return directives().notify("error", "Pick a capability to require")
                     .navigate(@"/tasks/$(task_id)");
             }
+            if (!(yield task_capabilities_editable(task_service, task_id))) {
+                return directives().notify("error",
+                        "Capabilities are locked once a worker has the job")
+                    .navigate(@"/tasks/$(task_id)");
+            }
 
             try {
                 yield capability_service.attach_to_task(capability_id, task_id);
@@ -186,6 +211,7 @@ namespace ClankerSaddle {
     public class DetachTaskCapabilityAction : TypedStatumAction<TaskCapabilityActionPrivate> {
 
         private CapabilityService capabilities = inject<CapabilityService>();
+        private TaskService task_service = inject<TaskService>();
         private UserService users = inject<UserService>();
 
         protected override Type private_type { get { return typeof(TaskCapabilityActionPrivate); } }
@@ -200,6 +226,11 @@ namespace ClankerSaddle {
             if (ctx == null) {
                 return directives().navigate("/tasks");
             }
+            if (!(yield task_capabilities_editable(task_service, ((!)ctx).task_id))) {
+                return directives().notify("error",
+                        "Capabilities are locked once a worker has the job")
+                    .navigate(@"/tasks/$(((!)ctx).task_id)");
+            }
 
             try {
                 yield capabilities.detach_from_task(((!)ctx).link_id);

+ 118 - 34
src/actions/TaskActions.vala

@@ -35,11 +35,13 @@ namespace ClankerSaddle {
 
     /**
      * Creates a task from the queue form (project and title required).
-     * Priority clamps to 1–9; the design-task checkbox and the optional
-     * parent id support design tasks and manual subtasks. Branch fields
-     * are optional — `branch_from` prefills from the project's default
-     * branch and `task_branch` is inherited from the parent task when
-     * left blank.
+     * The task lands in DRAFT — the human Enqueue action queues it;
+     * there is no agent path. Priority clamps to 1–9; the optional
+     * parent id supports manual subtasks. Branch fields are optional —
+     * `branch_from` prefills from the project's default branch and
+     * `task_branch` is inherited from the parent task when left blank.
+     * The plan-auto-approve and auto-enqueue-subtasks options store
+     * the per-task plan-11 levers.
      */
     public class CreateTaskAction : StatumAction {
 
@@ -88,17 +90,19 @@ namespace ClankerSaddle {
             try {
                 var task = yield tasks.create_task(project_id, title,
                     Forms.field_raw(request, "description"),
-                    Forms.integer(request, "priority", 5),
-                    Forms.field(request, "is_design_task") == "on", parent_id,
+                    Forms.integer(request, "priority", 5), parent_id,
                     Forms.optional(request, "task_branch"),
-                    Forms.optional(request, "branch_from"));
+                    Forms.optional(request, "branch_from"),
+                    Forms.field(request, "plan_auto_approve") == "on",
+                    Forms.field(request, "auto_enqueue_subtasks") == "on",
+                    Forms.field(request, "auto_approve") == "on");
                 if (Forms.field(request, "plan").length > 0) {
                     yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
                 }
                 foreach (int64 capability_id in required) {
                     yield capabilities.attach_to_task(capability_id, task.id);
                 }
-                return directives().notify("success", @"Task '$(task.title)' queued")
+                return directives().notify("success", @"Task '$(task.title)' created in draft")
                     .navigate(@"/tasks/$(task.id)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not create the task: $(e.message)").navigate("/tasks");
@@ -107,9 +111,11 @@ namespace ClankerSaddle {
     }
 
     /**
-     * Saves the editable fields of a task; the id comes from the sealed
-     * {@link TaskActionPrivate} blob (the hidden form field is a
-     * fallback).
+     * Saves the human-editable fields of a task under the draft
+     * lifecycle's edit matrix (see {@link TaskService.edit_task}): in
+     * draft every field except status, in non-draft non-terminal
+     * states priority only, in terminal states nothing. Status is not
+     * part of the form — Enqueue/Cancel/bot transitions own it.
      */
     public class UpdateTaskAction : SealedTaskAction {
 
@@ -129,17 +135,21 @@ namespace ClankerSaddle {
 
             try {
                 int64? priority = null;
-                if (Forms.field(request, "priority").length > 0) {
+                string priority_field = Forms.field(request, "priority");
+                if (priority_field.length > 0) {
                     priority = Forms.integer(request, "priority", 5);
                 }
-                yield tasks.update_task(task_id,
+                yield tasks.edit_task(task_id,
                     Forms.optional(request, "title"),
                     request.form != null ? Forms.field(request, "description") : null,
                     priority,
-                    Forms.optional(request, "status"));
-                yield tasks.set_branch_fields(task_id,
-                    Forms.optional(request, "task_branch"),
-                    Forms.optional(request, "branch_from"));
+                    request.form != null && request.form.get_field("task_branch") != null
+                        ? Forms.field(request, "task_branch") : null,
+                    request.form != null && request.form.get_field("branch_from") != null
+                        ? Forms.field(request, "branch_from") : null,
+                    Forms.field(request, "plan_auto_approve") == "on",
+                    Forms.field(request, "auto_enqueue_subtasks") == "on",
+                    Forms.field(request, "auto_approve") == "on");
                 return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
@@ -224,9 +234,13 @@ namespace ClankerSaddle {
      * Records the human's answer to a block; the task and block ids come
      * from the sealed {@link BlockAnswerPrivate} blob. Question blocks
      * submit either a chosen `option` or the free-text `other` field.
-     * Answers are stored verbatim — the saddle daemon applies its own
-     * "Human selected: …" formatting, so a stored prefix would wrap the
-     * answer a second time.
+     * Review and plan blocks submit a `feedback` reply which is stored
+     * wrapped in the stable verdict wording ("Review feedback
+     * received:\n…" / "Plan feedback received:\n…") — the answer is the
+     * resume_notice the daemon reads. Accepting a plan (the stable
+     * approval wording) also applies the submitted plan markdown to
+     * task.plan. Question/RFC answers are stored verbatim — the
+     * resume-notice builder applies the "Human selected: …" formatting.
      */
     public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
 
@@ -250,16 +264,42 @@ namespace ClankerSaddle {
             string option = Forms.field(request, "option");
             string other = Forms.field_raw(request, "other");
             string answer = Forms.field_raw(request, "answer");
+            string feedback = Forms.field_raw(request, "feedback");
             if (option == "__other__") {
                 answer = other.strip().length > 0 ? other.strip() : answer;
             } else if (option.length > 0) {
                 answer = option;
+            } else if (feedback.strip().length > 0) {
+                var block = yield tasks.get_task(task_id);
+                string type = "";
+                foreach (var candidate in ((!)block).blocks) {
+                    if (candidate.id == ((!)ctx).block_id) {
+                        type = candidate.block_type;
+                        break;
+                    }
+                }
+                answer = type == BLOCK_PLAN
+                    ? @"$(NOTICE_PLAN_FEEDBACK)\n$(feedback.strip())"
+                    : @"$(NOTICE_REVIEW_FEEDBACK)\n$(feedback.strip())";
             }
             if (answer.length == 0) {
                 return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)");
             }
 
             try {
+                // Accepting a plan applies the submitted markdown to the
+                // task's plan field (the approved text IS the plan).
+                if (answer == NOTICE_PLAN_APPROVED) {
+                    foreach (var candidate in (yield tasks.get_task(task_id)).blocks) {
+                        if (candidate.id == ((!)ctx).block_id
+                                && candidate.block_type == BLOCK_PLAN
+                                && candidate.body != null
+                                && ((!)candidate.body).length > 0) {
+                            yield tasks.update_plan(task_id, (!)candidate.body);
+                            break;
+                        }
+                    }
+                }
                 yield tasks.answer_block(((!)ctx).block_id, answer);
                 return directives().notify("success", "Answer recorded; the saddle will collect it")
                     .navigate(@"/tasks/$(task_id)");
@@ -327,10 +367,42 @@ namespace ClankerSaddle {
     }
 
     /**
-     * Completes a task (summary and working branch from the form); closes
-     * the open session and frees the saddle running it.
+     * The human-only Enqueue action: moves a draft task into the
+     * queue. Refused with a shown error while any dependency is still
+     * in draft; there is no agent path to enqueueing whatsoever.
+     */
+    public class EnqueueTaskAction : SealedTaskAction {
+
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 task_id = sealed_task_id();
+            if (task_id <= 0) {
+                return directives().navigate("/tasks");
+            }
+
+            try {
+                yield tasks.enqueue_task(task_id);
+                return directives().notify("success", "Task enqueued").navigate(@"/tasks/$(task_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not enqueue the task: $(e.message)")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+        }
+    }
+
+    /**
+     * The human-only Delete action: removes a draft task that nothing
+     * depends on (an error names the dependents otherwise — they must
+     * be deleted first). Non-draft tasks are refused.
      */
-    public class CompleteTaskAction : SealedTaskAction {
+    public class DeleteTaskAction : SealedTaskAction {
 
         private TaskService tasks = inject<TaskService>();
         private UserService users = inject<UserService>();
@@ -347,11 +419,10 @@ namespace ClankerSaddle {
             }
 
             try {
-                yield tasks.complete_task(task_id, Forms.optional(request, "summary"),
-                    Forms.optional(request, "working_branch"), "done");
-                return directives().notify("success", "Task completed").navigate(@"/tasks/$(task_id)");
+                yield tasks.delete_task(task_id);
+                return directives().notify("success", "Draft task deleted").navigate("/tasks");
             } catch (Error e) {
-                return directives().notify("error", @"Could not complete the task: $(e.message)")
+                return directives().notify("error", @"Could not delete the task: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");
             }
         }
@@ -359,7 +430,9 @@ namespace ClankerSaddle {
 
     /**
      * Cancels a task (status 'failed'); the sealed id comes from the task
-     * page's cancel button.
+     * page's cancel button. Only non-draft non-terminal tasks offer the
+     * action — drafts are deleted, terminal tasks are already finished,
+     * and completion itself is bot-only.
      */
     public class CancelTaskAction : SealedTaskAction {
 
@@ -378,6 +451,17 @@ namespace ClankerSaddle {
                 return directives().navigate("/tasks");
             }
 
+            var detail = yield tasks.get_task(task_id);
+            if (detail == null) {
+                return directives().navigate("/tasks");
+            }
+            string status = ((!)detail).task.status;
+            if (status == STATUS_DRAFT || status == STATUS_DONE || status == STATUS_FAILED) {
+                return directives().notify("error",
+                        "Only non-draft, non-terminal tasks can be cancelled")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+
             try {
                 yield saddles.abort_live_session(tasks, task_id, "cancelled");
                 yield tasks.cancel_task(task_id);
@@ -495,13 +579,13 @@ namespace ClankerSaddle {
             }
 
             try {
-                var task = yield task_service.update_task(task_id, null, null, null, null);
-                if (task == null) {
+                var detail = yield task_service.get_task(task_id);
+                if (detail == null) {
                     return directives().navigate("/tasks");
                 }
-                ((!)task).auto_approve = ((!)task).auto_approve != 0 ? 0 : 1;
-                yield task_service.save_auto_approve(((!)task).id, ((!)task).auto_approve != 0);
-                string state = ((!)task).auto_approve != 0 ? "on" : "off";
+                bool enabled = ((!)detail).task.auto_approve == 0;
+                yield task_service.save_auto_approve(task_id, enabled);
+                string state = enabled ? "on" : "off";
                 return directives().notify("success",
                         @"Subtask auto-approve is now $(state)")
                     .navigate(@"/tasks/$(task_id)");

+ 43 - 1
src/entrypoints/HomeEntrypoint.vala

@@ -57,6 +57,7 @@ namespace ClankerSaddle {
 
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
+        public int64 draft_count { get; set; }
         public int64 queued_count { get; set; }
         public int64 ready_count { get; set; }
         public int64 active_count { get; set; }
@@ -67,6 +68,18 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? refresh { get; set; }
     }
 
+    /**
+     * A suspended parent with subtasks still in draft (plan 11): the
+     * dashboard's "waiting on subtasks to be enqueued" block — task
+     * D+E renders it with links to the task page where the enqueue
+     * action lives.
+     */
+    public class DashboardWaitingDraftView : Object {
+        public int64 task_id { get; set; }
+        public string title { get; set; default = ""; }
+        public int64 draft_subtasks { get; set; }
+    }
+
     /**
      * Builds the `dashboard` slot state shared by the home entrypoint and
      * the {@link RefreshDashboardAction} pulse refresh, so both hydrate
@@ -88,9 +101,13 @@ namespace ClankerSaddle {
                 ActionRegistry? actions = null) throws GLib.Error {
             var pub = new DashboardPublic();
             var titles = new Dictionary<string, string>();
-            foreach (var task in yield tasks.list_tasks(null, null)) {
+            var all_tasks = yield tasks.list_tasks(null, null);
+            foreach (var task in all_tasks) {
                 titles.set(task.id.to_string(), task.title);
                 switch (task.status) {
+                    case STATUS_DRAFT:
+                        pub.draft_count++;
+                        break;
                     case STATUS_QUEUED:
                         pub.queued_count++;
                         break;
@@ -114,6 +131,29 @@ namespace ClankerSaddle {
             }
             pub.ready_count = (yield tasks.get_ready_tasks()).length;
 
+            // The plan-11 "waiting on subtasks to be enqueued" block:
+            // every suspended parent with subtasks still in draft.
+            var draft_children = new Dictionary<string, string>();
+            foreach (var task in all_tasks) {
+                if (task.parent_task_id > 0 && task.status == STATUS_DRAFT) {
+                    string parent = task.parent_task_id.to_string();
+                    draft_children.set(parent,
+                        (int64.parse(draft_children.get_or_default(parent) ?? "0") + 1).to_string());
+                }
+            }
+            var waiting_drafts = new Vector<DashboardWaitingDraftView>();
+            foreach (var task in all_tasks) {
+                int64 drafts = int64.parse(
+                    draft_children.get_or_default(task.id.to_string()) ?? "0");
+                if (task.status == STATUS_SUSPENDED && drafts > 0) {
+                    waiting_drafts.add(new DashboardWaitingDraftView() {
+                        task_id = task.id,
+                        title = task.title,
+                        draft_subtasks = drafts
+                    });
+                }
+            }
+
             var saddle_rows = new Vector<DashboardSaddleView>();
             foreach (var saddle in yield saddles.list_saddles()) {
                 if (saddle.status == "idle") {
@@ -201,6 +241,8 @@ namespace ClankerSaddle {
             props["activity"] = StateJson.list<DashboardActivityView>(activity.to_immutable_buffer());
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
+            props["waiting_drafts"] = StateJson.list<DashboardWaitingDraftView>(
+                waiting_drafts.to_immutable_buffer());
 
             return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",

+ 0 - 2
src/entrypoints/ProjectsEntrypoint.vala

@@ -96,7 +96,6 @@ namespace ClankerSaddle {
         public string status { get; set; default = ""; }
         public string display { get; set; default = ""; }
         public int64 priority { get; set; }
-        public bool is_design_task { get; set; }
     }
 
     /** A rule attached to the project, with its detach action. */
@@ -228,7 +227,6 @@ namespace ClankerSaddle {
                     status = task.status,
                     display = display != null ? ((!)display).status : task.status,
                     priority = task.priority,
-                    is_design_task = task.is_design_task
                 });
             }
 

+ 36 - 41
src/entrypoints/TaskDetailEntrypoint.vala

@@ -25,15 +25,15 @@ namespace ClankerSaddle {
     }
 
     /**
-     * A block (question, RFC or review) with its answer state and answer
-     * action. {@link is_plan_review} marks the design-task plan review
-     * block (`saddle plan` on a design task auto-blocks as
-     * type=rfc/question "Plan submitted for review"), which renders the
-     * approve/feedback flow instead of the plain reply form. While that
-     * review section is showing it already renders the plan markdown
-     * (`task.plan`), so the block's own body preview — the same text —
-     * stays hidden ({@link show_body}); every other typed block (e.g. a
-     * question with a markdown body) keeps its preview.
+     * A block (question, RFC, review or plan) with its answer state and
+     * answer action. {@link is_plan_review} marks the plan-acceptance
+     * block (`saddle plan` parks every task with a type=plan block
+     * "Plan submitted for review"), which renders the Accept /
+     * Reject-with-feedback flow instead of the plain reply form. While
+     * that section is showing it already renders the plan markdown
+     * (the block's body), so the block's own body preview — the same
+     * text — stays hidden ({@link show_body}); every other typed
+     * block (e.g. a question with a markdown body) keeps its preview.
      */
     public class BlockView : Object {
         public int64 id { get; set; }
@@ -101,7 +101,6 @@ namespace ClankerSaddle {
         public string description { get; set; default = ""; }
         public string status { get; set; default = ""; }
         public int64 priority { get; set; default = 5; }
-        public bool is_design_task { get; set; }
         public string working_branch { get; set; default = ""; }
         public string summary { get; set; default = ""; }
         public string plan { get; set; default = ""; }
@@ -114,11 +113,21 @@ namespace ClankerSaddle {
         public string saddle_health { get; set; default = ""; }
         public bool auto_approve { get; set; }
         public Statum.Model.ActionDto? toggle_auto_approve { get; set; }
+        public bool plan_auto_approve { get; set; }
+        public bool auto_enqueue_subtasks { get; set; }
         public string display { get; set; default = ""; }
         public bool terminal { get; set; }
         public bool failed { get; set; }
+        public bool draft { get; set; }
+        /** Draft: every field except status is editable. */
+        public bool editable_all { get; set; }
+        /** Non-draft non-terminal: priority (only) is editable. */
+        public bool editable_priority { get; set; }
+        /** Capabilities editable while draft or queued (no worker yet). */
+        public bool editable_capabilities { get; set; }
         public int64 subtask_total { get; set; }
         public int64 subtask_open { get; set; }
+        public int64 subtask_draft { get; set; }
         public int64 project_id { get; set; }
         public string project_name { get; set; default = ""; }
         public string git_url { get; set; default = ""; }
@@ -131,7 +140,8 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? save_plan { get; set; }
         public Statum.Model.ActionDto? subtask { get; set; }
         public Statum.Model.ActionDto? dependency { get; set; }
-        public Statum.Model.ActionDto? complete { get; set; }
+        public Statum.Model.ActionDto? enqueue { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
         public Statum.Model.ActionDto? cancel { get; set; }
         public Statum.Model.ActionDto? retry { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
@@ -174,7 +184,6 @@ namespace ClankerSaddle {
                 description = task.description ?? "",
                 status = task.status,
                 priority = task.priority,
-                is_design_task = task.is_design_task,
                 working_branch = task.working_branch ?? "",
                 summary = task.summary ?? "",
                 plan = task.plan ?? "",
@@ -193,8 +202,17 @@ namespace ClankerSaddle {
             pub.display = display.status;
             pub.terminal = display.terminal;
             pub.failed = display.failed;
+            pub.draft = task.status == STATUS_DRAFT;
             pub.subtask_total = display.subtask_total;
             pub.subtask_open = display.subtask_open;
+            pub.subtask_draft = display.subtask_draft;
+            // The edit matrix (plan 11): draft edits everything but
+            // status; non-draft non-terminal edits priority; terminal
+            // edits nothing; capabilities stay editable until a worker
+            // has the job (draft or queued).
+            pub.editable_all = pub.draft;
+            pub.editable_priority = !pub.terminal;
+            pub.editable_capabilities = pub.draft || task.status == STATUS_QUEUED;
 
             var sealed = new TaskActionPrivate() { task_id = task.id };
             pub.update = actions.author_private<UpdateTaskAction, TaskActionPrivate>(sealed);
@@ -202,11 +220,14 @@ namespace ClankerSaddle {
             pub.save_plan = actions.author_private<UpdatePlanAction, TaskActionPrivate>(sealed);
             pub.subtask = actions.author_private<CreateSubtaskAction, TaskActionPrivate>(sealed);
             pub.dependency = actions.author_private<AddDependencyAction, TaskActionPrivate>(sealed);
-            pub.complete = actions.author_private<CompleteTaskAction, TaskActionPrivate>(sealed);
+            pub.enqueue = actions.author_private<EnqueueTaskAction, TaskActionPrivate>(sealed);
+            pub.delete = actions.author_private<DeleteTaskAction, TaskActionPrivate>(sealed);
             pub.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
             pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
             pub.auto_approve = task.auto_approve != 0;
+            pub.plan_auto_approve = task.plan_auto_approve;
+            pub.auto_enqueue_subtasks = task.auto_enqueue_subtasks;
             pub.toggle_auto_approve = actions.author_private<ToggleAutoApproveAction, TaskActionPrivate>(sealed);
             pub.refresh = actions.author_private<RefreshTaskDataAction, TaskActionPrivate>(sealed, "GET");
             pub.attach_rule = actions.author_private<AttachTaskRuleAction, TaskActionPrivate>(sealed);
@@ -246,11 +267,10 @@ namespace ClankerSaddle {
                     answer = block.answer ?? "",
                     block_type = block.block_type,
                     body = block.body ?? "",
-                    options = parse_options(block.options),
+                    options = TaskService.parse_block_options(block.options),
                     answered = block.answer != null,
                     answered_by = block.answered_by ?? "",
-                    is_plan_review = block.block_type == BLOCK_RFC
-                        && block.question == "Plan submitted for review",
+                    is_plan_review = block.block_type == BLOCK_PLAN,
                     created_display = format_time(block.created_at),
                     answered_display = format_time(block.answered_at),
                     answer_action = actions.author_private<AnswerBlockAction, BlockAnswerPrivate>(block_private)
@@ -404,31 +424,6 @@ namespace ClankerSaddle {
             return slot_for(state_service, pub, props);
         }
 
-        /** The stored options JSON (a string array) back into a Vala array. */
-        private static string[] parse_options(string? options_json) {
-            if (options_json == null || ((!)options_json).length == 0) {
-                return new string[0];
-            }
-            try {
-                var parser = new Json.Parser();
-                parser.load_from_data((!)options_json);
-                var root = parser.get_root();
-                if (root == null || root.get_node_type() != Json.NodeType.ARRAY) {
-                    return new string[0];
-                }
-                var values = new Vector<string>();
-                foreach (var element in root.get_array().get_elements()) {
-                    if (element.get_node_type() == Json.NodeType.VALUE
-                            && element.get_string() != null) {
-                        values.add(element.get_string());
-                    }
-                }
-                return values.to_array();
-            } catch (Error e) {
-                return new string[0];
-            }
-        }
-
         private static DependencyView task_brief(TaskDetail? detail, int64 id) {
             if (detail == null) {
                 return new DependencyView() { id = id, title = @"#$(id)", status = "missing" };

+ 0 - 2
src/entrypoints/TasksEntrypoint.vala

@@ -17,7 +17,6 @@ namespace ClankerSaddle {
         public bool terminal { get; set; }
         public bool failed { get; set; }
         public int64 priority { get; set; }
-        public bool is_design_task { get; set; }
         public int64 parent_task_id { get; set; }
         public string project_name { get; set; default = ""; }
         public string dependency_summary { get; set; default = ""; }
@@ -148,7 +147,6 @@ namespace ClankerSaddle {
                     terminal = display != null && ((!)display).terminal,
                     failed = display != null && ((!)display).failed,
                     priority = task.priority,
-                    is_design_task = task.is_design_task,
                     parent_task_id = task.parent_task_id,
                     ready = is_ready,
                     project_name = project_names.get_or_default(task.project_id.to_string()) ?? ""

+ 4 - 10
src/main.vala

@@ -27,15 +27,6 @@ int main(string[] args) {
         var db = application.container.configure_with<DatabaseConfigurator>();
         db.register_connection(@"sqlite://$(db_path)");
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectNotes>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0005_RawLog>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0006_Capabilities>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0007_SuspendedAt>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0008_AutoApprove>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0009_GitIdentity>();
-        db.add_migration<ClankerSaddle.Data.Migrations.M0010_SaddleToken>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -55,6 +46,8 @@ int main(string[] args) {
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
+        db.add_entity<PushSubscription>(PushSubscription.entity_mapping);
+        db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -99,7 +92,8 @@ int main(string[] args) {
         statum.action<AnswerBlockAction>();
         statum.action<CreateSubtaskAction>();
         statum.action<AddDependencyAction>();
-        statum.action<CompleteTaskAction>();
+        statum.action<EnqueueTaskAction>();
+        statum.action<DeleteTaskAction>();
         statum.action<CancelTaskAction>();
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();

+ 1 - 0
src/pages/app.css

@@ -205,6 +205,7 @@ summary { cursor: pointer; font-weight: 600; }
     padding: 0;
 }
 
+.badge-draft { background: var(--line-soft); color: var(--ink-muted); }
 .badge-queued { background: #88843333; color: #4d4d33; }
 .badge-assigned { background: #ddf4ff; color: #0969da; }
 .badge-working { background: #fff8c5; color: var(--warning); }

+ 1 - 1
src/pages/project_detail.html

@@ -105,7 +105,7 @@
                     <tr stm-for-task-in="project.tasks">
                         <td stm-text="task.id">id</td>
                         <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                            <span class="muted" stm-if="task.is_design_task"> · design</span></td>
+                            </td>
                         <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
                         <td stm-text="task.priority">priority</td>
                     </tr>

+ 84 - 49
src/pages/task_detail.html

@@ -180,9 +180,16 @@
                         ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n' + item.body
                         : item.body;
                 }).join('\n\n');
+                // Free-text feedback rides the `feedback` field so the
+                // action wraps it in the stable verdict wording (the
+                // answer IS the resume_notice the daemon reads).
+                var extra = review.querySelector('[data-plan-feedback] textarea[name="feedback"]');
+                if (extra && extra.value.trim().length > 0) {
+                    answer += '\n\n' + extra.value.trim();
+                }
                 feedbackByBlock[id] = [];
                 modeByBlock[id] = false;
-                statum.act(block.answer_action, { answer: answer }).catch(function () { });
+                statum.act(block.answer_action, { feedback: answer }).catch(function () { });
             }
 
             function syncPlanFeedback() {
@@ -236,29 +243,40 @@
                     <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span></p>
 
             <!-- Status block pinned to the very top (plan 10): the
-                 current state is the first thing on the page. -->
+                 current state is the first thing on the page. The
+                 lifecycle buttons ride the card (plan 11): Enqueue and
+                 Delete in draft (delete guarded by dependents), Cancel
+                 in non-draft non-terminal; completion is bot-only. -->
             <section class="card status-head">
                 <h3>Status</h3>
                 <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
-                    · priority <strong stm-text="task.priority">5</strong>
-                    <span stm-if="task.is_design_task"> · <strong>plan review required</strong></span></p>
+                    · priority <strong stm-text="task.priority">5</strong></p>
                 <p class="muted">Created <span stm-text="task.created_display">when</span></p>
                 <p class="muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
                 <p class="muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
                 <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
                     <span class="muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
                 <p stm-if="task.working_branch.length > 0" class="muted">Working branch: <span stm-text="task.working_branch">branch</span></p>
-                <p stm-if="task.status == 'suspended'" class="muted">Parked — waiting on
+                <p stm-if="task.status == 'suspended' && task.subtask_draft > 0" class="muted">
+                    Parked — <span stm-text="task.subtask_draft">0</span> subtask(s) still in draft:
+                    enqueue them so this task's work can finish.</p>
+                <p stm-if="task.status == 'suspended' && task.subtask_draft == 0" class="muted">Parked — waiting on
                     subtasks (<span stm-text="task.subtask_open">0</span> open). The task
                     resumes with a status report whenever one finishes.</p>
                 <form class="inline">
                     <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <button type="button" stm-if="task.draft" class="primary"
+                            stm-action="task.enqueue">Enqueue task</button>
+                    <button type="button" stm-if="task.draft"
+                            stm-action="task.delete"
+                            stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
                     <button type="button" stm-if="task.status == 'failed'" class="primary"
                             stm-action="task.retry">Retry task</button>
                     <button type="button" stm-if="task.resume_available"
                             stm-action="task.resume"
                             stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
-                    <button type="button" stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
+                    <button type="button" stm-if="!task.draft && !task.terminal"
+                            stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                 </form>
             </section>
 
@@ -289,36 +307,56 @@
                     <p class="muted" stm-if="task.description.length == 0">No description.</p>
                     <p stm-if="task.summary.length > 0"><strong>Summary:</strong> <span stm-text="task.summary">summary</span></p>
                 </section>
-
-                <section class="card">
-                    <h3>Edit</h3>
-                    <form stm-action="task.update">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <div class="form-row"><label>Title</label>
-                            <input type="text" name="title" stm-attribute.value="task.title"></div>
-                        <div class="form-row"><label>Description</label>
-                            <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></div>
-                        <div class="form-row"><label>Priority (1–9)</label>
-                            <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
-                        <div class="form-row"><label>Task branch</label>
-                            <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
-                        <div class="form-row"><label>Branch from</label>
-                            <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
-                        <div class="form-row"><label>Status</label>
-                            <select name="status" stm-attribute.value="task.status">
-                                <option value="queued">queued</option>
-                                <option value="assigned">assigned</option>
-                                <option value="working">working</option>
-                                <option value="blocked">blocked</option>
-                                <option value="review_requested">review_requested</option>
-                                <option value="done">done</option>
-                                <option value="failed">failed</option>
-                            </select></div>
-                        <button type="submit">Save</button>
-                    </form>
-                </section>
             </div>
 
+            <!-- The edit expander (plan 11): draft tasks edit every
+                 field except status; non-draft non-terminal tasks edit
+                 priority only; terminal tasks edit nothing. The
+                 description arrives pre-filled whenever it is
+                 editable. -->
+            <details stm-if="task.editable_all || task.editable_priority">
+                <summary><strong>Edit task</strong>
+                    <span class="muted" stm-if="task.editable_all">— every field except status while in draft</span>
+                    <span class="muted" stm-if="!task.editable_all && task.editable_priority">— priority only once work has begun</span></summary>
+                <form stm-action="task.update" stm-if="task.editable_all">
+                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <div class="form-row"><label>Title</label>
+                        <input type="text" name="title" stm-attribute.value="task.title"></div>
+                    <div class="form-row"><label>Description</label>
+                        <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></div>
+                    <div class="form-row"><label>Priority (1–9)</label>
+                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                    <div class="form-row"><label>Task branch</label>
+                        <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
+                    <div class="form-row"><label>Branch from</label>
+                        <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
+                    <div class="form-row"><label>Task options</label>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="plan_auto_approve" stm-attribute.checked="task.plan_auto_approve">
+                                <span>Auto-approve plan — this task's plan review is answered instantly</span>
+                            </label></p>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="auto_enqueue_subtasks" stm-attribute.checked="task.auto_enqueue_subtasks">
+                                <span>Auto-enqueue agent-created subtasks</span>
+                            </label></p>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="auto_approve" stm-attribute.checked="task.auto_approve">
+                                <span>Auto-approve subtasks — descendants' review and plan blocks are answered instantly</span>
+                            </label></p>
+                    </div>
+                    <button type="submit">Save</button>
+                </form>
+                <form stm-action="task.update" stm-if="!task.editable_all && task.editable_priority">
+                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                    <div class="form-row"><label>Priority (1–9)</label>
+                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                    <button type="submit">Save</button>
+                </form>
+            </details>
+
             <h3>Plan</h3>
             <div stm-if="task.plan.length > 0" class="card md" stm-markdown="task.plan">plan</div>
             <details stm-if="task.plan.length > 0">
@@ -351,11 +389,11 @@
                            target="_blank" rel="noopener">· compare changes</a></p>
                     <div class="actions">
                         <form stm-action="block.answer_action" class="inline">
-                            <input type="hidden" name="answer" value="Human approved the review">
+                            <input type="hidden" name="answer" value="Review approved. Continue with the task.">
                             <button type="submit" class="approve">Approve</button>
                         </form>
                         <form stm-action="block.answer_action" class="inline">
-                            <textarea name="answer" rows="2" placeholder="What should change?"></textarea>
+                            <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
                             <button type="submit" class="reject">Request changes</button>
                         </form>
                     </div>
@@ -363,18 +401,19 @@
 
                 <div stm-if="!block.answered && block.is_plan_review" class="plan-review"
                      data-plan-review stm-attribute.data-block-id="block.id">
-                    <div class="md rfc-body" stm-markdown="task.plan" data-plan-body>plan</div>
+                    <div class="md rfc-body" stm-markdown="block.body.length > 0 ? block.body : task.plan" data-plan-body>plan</div>
                     <div class="actions">
                         <form stm-action="block.answer_action" class="inline">
                             <input type="hidden" name="answer" value="Plan approved. Proceed with implementation.">
-                            <button type="submit" class="approve">Approve plan</button>
+                            <button type="submit" class="approve">Accept plan</button>
                         </form>
-                        <button type="button" data-plan-toggle>Give feedback</button>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
                     </div>
                     <div data-plan-feedback hidden>
                         <p class="muted">Highlight plan text to attach a comment to it, then submit everything
                             as feedback — the agent revises the plan and asks again.</p>
                         <ul class="plan-comments" data-plan-comments></ul>
+                        <textarea name="feedback" rows="2" placeholder="Additional feedback (optional)"></textarea>
                         <button type="button" class="primary" data-plan-submit>Submit feedback</button>
                     </div>
                 </div>
@@ -450,9 +489,13 @@
                 <strong stm-text="task.auto_approve ? 'on' : 'off'">off</strong>
                 <button type="button" stm-action="task.toggle_auto_approve"
                         stm-text="task.auto_approve ? 'Turn off' : 'Turn on'">Turn on</button>
-                <span class="muted">— covered descendants' review blocks are answered
+                <span class="muted">— covered descendants' review AND plan blocks are answered
                     automatically (questions and RFCs still block; this task's own review is
-                    never auto-approved).</span></p>
+                    never auto-approved). This task's own plan is governed by the
+                    auto-approve-plan option.</span></p>
+            <p class="inline" stm-if="task.auto_enqueue_subtasks"><span class="badge badge-done">auto-enqueue on</span>
+                <span class="muted">— subtasks created under this task enqueue immediately
+                    (and inherit the setting).</span></p>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th></tr></thead>
                 <tbody>
@@ -548,14 +591,6 @@
                     <tr stm-if="task.sessions.length == 0"><td colspan="6" class="muted">No sessions yet.</td></tr>
                 </tbody>
             </table>
-
-            <h3>Complete</h3>
-            <form stm-action="task.complete" class="inline">
-                <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                <input type="text" name="summary" placeholder="Completion summary">
-                <input type="text" name="working_branch" placeholder="Branch (optional)">
-                <button type="submit">Mark done</button>
-            </form>
         </div>
     </section>
 </body>

+ 21 - 7
src/pages/tasks.html

@@ -51,6 +51,7 @@
                 <label for="filter-status">Status</label>
                 <select id="filter-status" name="status">
                     <option value="all" stm-attribute.selected="tasks.filter_status == ''">all</option>
+                    <option value="draft" stm-attribute.selected="tasks.filter_status == 'draft'">draft</option>
                     <option value="queued" stm-attribute.selected="tasks.filter_status == 'queued'">queued</option>
                     <option value="assigned" stm-attribute.selected="tasks.filter_status == 'assigned'">assigned</option>
                     <option value="working" stm-attribute.selected="tasks.filter_status == 'working'">working</option>
@@ -75,7 +76,7 @@
                     <tr stm-for-task-in="tasks.tasks">
                         <td stm-text="task.id">id</td>
                         <td><a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                            <span class="muted" stm-if="task.is_design_task"> · plan review</span>
+                            
                             <span class="muted" stm-if="task.parent_task_id > 0"> · subtask of
                                 <a stm-attribute.href="'/tasks/' + task.parent_task_id" stm-text="'#' + task.parent_task_id">#parent</a></span>
                             <span class="muted" stm-if="task.ready"> · ready</span>
@@ -118,12 +119,25 @@
                     <div class="form-row"><label>Branch from (optional)</label>
                         <input type="text" name="branch_from" id="create-branch-from"
                                placeholder="project default branch"></div>
-                    <p class="muted">Branch fields are inherited by subtasks unless overridden.</p>
-                    <div class="form-row"><label>Plan review</label>
-                        <label class="option-card">
-                            <input type="checkbox" name="is_design_task">
-                            <span>Require plan approval — the agent explores, asks questions, writes a plan, and waits for your approval before writing code</span>
-                        </label></div>
+                    <p class="muted">Branch fields are inherited by subtasks unless overridden.
+                        The task is created in draft — enqueue it from its page when ready.</p>
+                    <div class="form-row"><label>Task options</label>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="plan_auto_approve">
+                                <span>Auto-approve plan — the agent's plan review is answered instantly</span>
+                            </label></p>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="auto_enqueue_subtasks">
+                                <span>Auto-enqueue agent-created subtasks</span>
+                            </label></p>
+                        <p class="inline">
+                            <label class="option-card">
+                                <input type="checkbox" name="auto_approve">
+                                <span>Auto-approve subtasks — descendants' review and plan blocks are answered instantly</span>
+                            </label></p>
+                    </div>
                     <div class="form-row" stm-if="tasks.capability_options.length > 0"><label>Required capabilities</label>
                         <p stm-for-option-in="tasks.capability_options" class="inline">
                             <label class="option-card">