Bladeren bron

goals: web UI — pile, timeline, queue filters, task page, carried fixes

The plan-12 supervision surfaces: the dashboard's pile unifies task
and goal blocks (interleaved, newest-first by stamp, task/goal type
badges linking the right page; the goal-run task's mirror block stays
out — the goal entry owns the card), and the timeline carries goal
events linked to the goal page. The task queue gains the goal filter
(all / standalone / by goal) plus mode badges, goal links and creator
hints on rows; because the Statum entrypoint fetch never carries a
page's own query string, the status/project/goal filters now apply
client-side (window.taskFilters) over a sealed queue refresh action —
this also un-breaks the status/project filters in the browser. The
create form offers the mode select with descriptions, the
auto-enqueue-after dropdown (global human tasks only) and drops the
auto-* checkboxes. The task page frames the description as the
Mandate, links the goal reference section (goal task vs the goal
agent's own run), badges the mode and shows the enqueue-after
predecessor; goal-run mirror blocks push quiet so only the
goal-level notification fires. Carried fixes: the terminal view's
session picker now works — the client owns the ?session_id=
selection (per-session raw flags, reconnect on change, latest
session default; the server-side query never reached the
entrypoint), and a priority-only edit no longer blanks the mandate
(absent description field = leave unchanged). Goal notifications
verified protocol-level: plan/completion reviews and goal
failure/success push to every subscribed user.
clanker 1 maand geleden
bovenliggende
commit
4bc3e61edb

+ 4 - 1
src/Services/GoalService.vala

@@ -581,6 +581,9 @@ namespace ClankerSaddle.Services {
          * allocation) works untouched, and the goal-run task parks as
          * 'blocked' waiting for the human verdict. A no-op when the
          * run task does not exist or already carries the question.
+         * The mirror is QUIET — the goal-level block already pushed
+         * its notification (goal title, /goals/<id> URL); the mirror
+         * must not double-notify with the run task's title.
          */
         private async void mirror_block_on_run_task(Goal goal, GoalBlock block) throws Error {
             var run = yield goal_run_task(goal.id);
@@ -594,7 +597,7 @@ namespace ClankerSaddle.Services {
                 }
             }
             yield tasks.add_block(((!)run).id, block.question, block.block_type,
-                TaskService.parse_block_options(block.options), block.body);
+                TaskService.parse_block_options(block.options), block.body, true);
         }
 
         // ------------------------------------------------------------------

+ 4 - 2
src/Services/TaskService.vala

@@ -1016,7 +1016,8 @@ namespace ClankerSaddle.Services {
          * while it waits for a human answer. Returns the new block.
          */
         public async TaskBlock add_block(int64 task_id, string question, string block_type = BLOCK_QUESTION,
-                string[]? options = null, string? body = null) throws Error {
+                string[]? options = null, string? body = null,
+                bool quiet = false) throws Error {
             string options_json = null;
             if (options != null && options.length > 0) {
                 var array = new Json.Array();
@@ -1050,7 +1051,8 @@ namespace ClankerSaddle.Services {
             // push-subscribed users globally — except the ones a lever
             // answers instantly web-side (their "block" never becomes
             // visible to a human, so a notification would be noise).
-            if (task != null && !(yield block_would_auto_answer(((!)task), block))) {
+            if (!quiet && task != null
+                    && !(yield block_would_auto_answer(((!)task), block))) {
                 push.notify_global(PushService.block_notification(
                     ((!)task).title, task_id, block.block_type, block.question));
             }

+ 35 - 6
src/Services/TimelineService.vala

@@ -11,12 +11,15 @@ namespace ClankerSaddle.Services {
     public class TimelineEvent : Object {
         /** created | started | suspended | completed | failed |
          *  block-raised | block-answered | comment | progress | dispatch |
-         *  yeehaw */
+         *  yeehaw | goal-<kind> (plan 12) */
         public string kind { get; set; default = ""; }
         /** The event's epoch second — the newest-first sort key. */
         public int64 at_epoch { get; set; }
         public int64 task_id { get; set; }
         public string task_title { get; set; default = ""; }
+        /** Plan 12: set on goal events (else 0) — the timeline links the goal. */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
         public string text { get; set; default = ""; }
         /** Build-order tiebreak for same-second events. */
         public int64 sequence { get; set; }
@@ -27,11 +30,13 @@ namespace ClankerSaddle.Services {
      * phase 3): the last {@link WINDOW} events across every task,
      * newest first — status changes (started, suspended), blocks
      * raised and answered, comments (author + snippet), progress
-     * updates, completions/failures and session dispatches —
-     * materialised per request from the existing tables (tasks,
-     * task_blocks, task_comments, task_progress, sessions). Nothing is
-     * archived or aggregated: at this fleet size a bounded newest-cut
-     * per source, merged and re-sorted, is the whole job.
+     * updates, completions/failures and session dispatches — plus the
+     * plan-12 goal events (plan submissions/reviews, task
+     * fan-out, lifecycle) — materialised per request from the
+     * existing tables (tasks, task_blocks, task_comments,
+     * task_progress, sessions, goal_events). Nothing is archived or
+     * aggregated: at this fleet size a bounded newest-cut per source,
+     * merged and re-sorted, is the whole job.
      */
     public class TimelineService : Object {
 
@@ -58,6 +63,10 @@ namespace ClankerSaddle.Services {
             foreach (var saddle in yield db.query<Saddle>().materialise_async()) {
                 saddle_names.set(saddle.id.to_string(), saddle.name);
             }
+            var goal_titles = new Dictionary<string, string>();
+            foreach (var goal in yield db.query<Goal>().materialise_async()) {
+                goal_titles.set(goal.id.to_string(), goal.title);
+            }
 
             var events = new Vector<TimelineEvent>();
             int64 sequence = 0;
@@ -199,6 +208,26 @@ namespace ClankerSaddle.Services {
                 });
             }
 
+            // Plan 12: goal events — plan submissions/reviews, the
+            // goal agent's task operations, task terminal events and
+            // lifecycle moves — keyed on the goal (the timeline links
+            // the goal page, not the task).
+            foreach (var goal_event in yield db.query<GoalEvent>()
+                    .order_by_desc(expr("id")).limit(WINDOW).materialise_async()) {
+                if (goal_event.created_at == null) {
+                    continue;
+                }
+                events.add(new TimelineEvent() {
+                    kind = @"goal-$(goal_event.kind)",
+                    at_epoch = ((!)goal_event.created_at).to_unix(),
+                    task_id = goal_event.task_id,
+                    task_title = titles.get_or_default(goal_event.task_id.to_string()) ?? "",
+                    goal_id = goal_event.goal_id,
+                    goal_title = goal_titles.get_or_default(goal_event.goal_id.to_string()) ?? "",
+                    text = snippet(goal_event.text), sequence = sequence++
+                });
+            }
+
             var sorted = events.sort((a, b) => {
                 if (a.at_epoch != b.at_epoch) {
                     return a.at_epoch > b.at_epoch ? -1 : 1;

+ 3 - 2
src/actions/DashboardActions.vala

@@ -16,6 +16,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private GoalService goal_service = inject<GoalService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -28,8 +29,8 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry, push_service,
-                me != null ? ((!)me).user_id : 0);
+                saddle_service, timeline_service, goal_service, db, action_registry,
+                push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state);
         }
     }

+ 6 - 1
src/actions/TaskActions.vala

@@ -172,9 +172,14 @@ namespace ClankerSaddle {
                     int64 parsed = int64.parse(enqueue_field);
                     enqueue_after = parsed > 0 ? parsed : -1;
                 }
+                // The carried fix: the mandate only updates when the
+                // form actually carried the description field (the
+                // priority-only edit must not blank it — an absent
+                // field is "leave unchanged", same as title).
                 yield tasks.edit_task(task_id,
                     Forms.optional(request, "title"),
-                    request.form != null ? Forms.field(request, "description") : null,
+                    request.form != null && request.form.get_field("description") != null
+                        ? Forms.field(request, "description") : null,
                     priority,
                     request.form != null && request.form.get_field("task_branch") != null
                         ? Forms.field(request, "task_branch") : null,

+ 65 - 13
src/entrypoints/HomeEntrypoint.vala

@@ -26,6 +26,9 @@ namespace ClankerSaddle {
         public string when_display { get; set; default = ""; }
         public int64 task_id { get; set; }
         public string task_title { get; set; default = ""; }
+        /** Plan 12: set on goal events — the row links the goal page. */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
         public string text { get; set; default = ""; }
         /**
          * The referenced task earned its 🤠 badge — any of its sessions
@@ -45,19 +48,28 @@ namespace ClankerSaddle {
     }
 
     /**
-     * A pinned OPEN blocking item (plan 10 phase 3): an unanswered
-     * question, RFC or review across any task, rendered as a
-     * link-through card — task title, block kind badge, question
-     * title, and a link to the task page where the answer form lives.
+     * A pinned OPEN blocking item (plan 10 phase 3; plan 12 unified):
+     * an unanswered question, RFC or review across any task, or a goal
+     * block (the goal agent's question/RFC, a plan submission or the
+     * completion report) — rendered as a link-through card. Goal items
+     * carry `item_type` "goal" and link the goal page; task items
+     * carry "task" and link the task page where the answer form lives.
      * No inline answer forms on the dashboard (settled).
      */
     public class DashboardBlockView : Object {
+        /** "task" or "goal" — the pile interleaves both. */
+        public string item_type { get; set; default = "task"; }
         public int64 id { get; set; }
         public int64 task_id { get; set; }
         public string task_title { get; set; default = ""; }
+        /** Goal items only: the goal the block belongs to. */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
         public string block_type { get; set; default = "question"; }
         public string question { get; set; default = ""; }
         public string created_display { get; set; default = ""; }
+        /** The created stamp's epoch — the unified newest-first key. */
+        public int64 at_epoch { get; set; }
     }
 
     /** Public data of the dashboard's `dashboard` slot. */
@@ -114,7 +126,7 @@ namespace ClankerSaddle {
          */
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
-                InvercargillSql.Orm.OrmSession db,
+                GoalService goal_service, InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, PushService? push = null,
                 int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
@@ -229,23 +241,60 @@ namespace ClankerSaddle {
                     if (block.answer != null) {
                         continue;
                     }
-                    pinned.add(new DashboardBlockView() {
+                    var view = new DashboardBlockView() {
+                        item_type = "task",
                         id = block.id,
                         task_id = task.id,
                         task_title = task.title,
                         block_type = block.block_type,
                         question = block.question,
                         created_display = format_time(block.created_at)
-                    });
+                    };
+                    if (block.created_at != null) {
+                        view.at_epoch = ((!)block.created_at).to_unix();
+                    }
+                    // The goal agent's own parked run mirrors its
+                    // blocks onto the goal — the goal entry owns the
+                    // card (no duplicate).
+                    if (task.kind == TASK_KIND_GOAL_RUN) {
+                        continue;
+                    }
+                    pinned.add(view);
+                }
+            }
+
+            // Plan 12: the goal blocks join the pile — the goal agent's
+            // questions/RFCs, plan submissions and completion reports,
+            // interleaved with the task blocks, newest first. The
+            // GoalService arrives as a parameter (an inject past an
+            // await resumes with an empty injection context stack).
+            foreach (var goal in yield goal_service.list_goals()) {
+                foreach (var block in yield goal_service.list_blocks(goal.id)) {
+                    if (block.answer != null) {
+                        continue;
+                    }
+                    var view = new DashboardBlockView() {
+                        item_type = "goal",
+                        id = block.id,
+                        goal_id = goal.id,
+                        goal_title = goal.title,
+                        block_type = block.block_type,
+                        question = block.question,
+                        created_display = format_time(block.created_at)
+                    };
+                    if (block.created_at != null) {
+                        view.at_epoch = ((!)block.created_at).to_unix();
+                    }
+                    pinned.add(view);
                 }
             }
             // Newest first (plan 10 phase 3): the most recent question,
-            // RFC or review leads the pinned list.
+            // RFC, review or goal block leads the unified pile.
             var pinned_sorted = pinned.sort((a, b) => {
-                if (a.id != b.id) {
-                    return a.id > b.id ? -1 : 1;
+                if (a.at_epoch != b.at_epoch) {
+                    return a.at_epoch > b.at_epoch ? -1 : 1;
                 }
-                return 0;
+                return a.id > b.id ? -1 : 1;
             });
 
             if (actions != null) {
@@ -272,6 +321,8 @@ namespace ClankerSaddle {
                         .format("%Y-%m-%d %H:%M"),
                     task_id = item.task_id,
                     task_title = item.task_title,
+                    goal_id = item.goal_id,
+                    goal_title = item.goal_title,
                     text = item.text,
                     task_yeehaw = yeehaw_ids.has(item.task_id.to_string())
                 });
@@ -316,6 +367,7 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
+        private GoalService goal_service = Inversion.inject<GoalService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
@@ -328,8 +380,8 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry, push_service,
-                me != null ? ((!)me).user_id : 0);
+                saddle_service, timeline_service, goal_service, db, action_registry,
+                push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 33 - 11
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -8,11 +8,21 @@ using ClankerSaddle.Services;
 
 namespace ClankerSaddle {
 
-    /** A session selector row above the terminal log. */
+    /**
+     * One session selector row above the terminal log. The plan-12
+     * carried fix: each row carries its own `raw` flag so the CLIENT
+     * can switch streams (and rendering mode) without a server
+     * round-trip — the page reads `?session_id=` from its own URL
+     * (the Statum entrypoint fetch never carries the page's query
+     * string to the server, which is why only the first session used
+     * to render) and connects the chosen session's SSE stream itself.
+     */
     public class TerminalSessionView : Object {
         public int64 id { get; set; }
         public string label { get; set; default = ""; }
         public bool selected { get; set; }
+        /** This session's raw PTY log exists (renders raw, not typed). */
+        public bool raw { get; set; }
     }
 
     /** Public data of the `terminal` slot. */
@@ -28,14 +38,20 @@ namespace ClankerSaddle {
 
     /**
      * Entrypoint for /tasks/{task_id}/terminal: the live bash session
-     * log. The `session_id` query parameter selects which of the task's
-     * sessions to show (the latest when absent). The log itself renders
-     * client-side from the `/terminal-stream/{session_id}` SSE stream —
-     * one unstyled `<pre>` of concatenated raw chunks when the session
-     * carries raw PTY rows ({@link TerminalPublic.has_raw_log}), the
-     * typed entry cards with expandable output and text-highlight
-     * commenting otherwise — so this state carries only the session
-     * table and the sealed comment action.
+     * log — the goal agent's run history included (the goal page's
+     * "Terminal" tab lands here). The `session_id` query parameter
+     * selects which of the task's sessions to show; because the
+     * Statum entrypoint fetch never carries the page's own query
+     * string server-side, the SELECTION is client-side (see
+     * task_terminal.html): the server default is the LATEST session,
+     * the client honours `?session_id=` and reconnects. The log
+     * itself renders client-side from the
+     * `/terminal-stream/{session_id}` SSE stream — one unstyled
+     * `<pre>` of concatenated raw chunks when the session carries raw
+     * PTY rows, the typed entry cards with expandable output and
+     * text-highlight commenting otherwise — so this state carries the
+     * session table (with per-session raw flags) and the sealed
+     * comment action.
      */
     public class TaskTerminalEntrypoint : StatumEntrypoint {
 
@@ -65,6 +81,9 @@ namespace ClankerSaddle {
                 return directives().set_typed<TerminalPublic>("terminal", Scope.PAGE, pub);
             }
 
+            // The server-side default: the request's session_id when
+            // the entrypoint actually saw one (direct links), else the
+            // LATEST session — the client refines from its own URL.
             int64 selected = 0;
             string? requested = null;
             request.query.try_get_any("session_id", out requested);
@@ -72,13 +91,15 @@ namespace ClankerSaddle {
                 selected = int64.parse(requested);
             }
             bool found = false;
+            int64 latest = 0;
             foreach (var session in sessions) {
+                latest = session.id;
                 if (session.id == selected) {
                     found = true;
                 }
             }
             if (!found) {
-                selected = sessions.first().id;
+                selected = latest;
             }
 
             var pub_with_session = new TerminalPublic() {
@@ -96,7 +117,8 @@ namespace ClankerSaddle {
                 session_rows.add(new TerminalSessionView() {
                     id = session.id,
                     label = @"#$(session.id) — $(format_time(session.started_at)) ($(session.outcome ?? "open"))",
-                    selected = session.id == selected
+                    selected = session.id == selected,
+                    raw = yield task_service.has_raw_log(session.id)
                 });
             }
 

+ 173 - 22
src/entrypoints/TasksEntrypoint.vala

@@ -30,6 +30,16 @@ namespace ClankerSaddle {
          * `saddle yeehaw` entry (derived, nothing stored for it).
          */
         public bool has_yeehaw { get; set; }
+        /** Plan 12: the task's work mode (badge). */
+        public string mode { get; set; default = "implement"; }
+        /** Plan 12: the task's creator ("human"/"goal"). */
+        public string creator { get; set; default = "human"; }
+        /** Plan 12: the goal the task runs under (0 = standalone). */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
+        /** Plan 12: the auto-enqueue predecessor (0 = none). */
+        public int64 enqueue_after_task_id { get; set; }
+        public int64 project_id { get; set; }
     }
 
     /**
@@ -47,7 +57,21 @@ namespace ClankerSaddle {
     public class TasksPublic : Object {
         public string filter_status { get; set; default = ""; }
         public int64 filter_project_id { get; set; }
-        public Statum.Model.ActionDto create { get; set; }
+        /** Plan 12: the goal filter ("all" | "standalone" | goal id). */
+        public string filter_goal { get; set; default = "all"; }
+        public Statum.Model.ActionDto? create { get; set; }
+        /** The sealed GET refresh (live pulses + filter re-renders). */
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /**
+     * One goal option in the queue page's filter and create forms:
+     * the goal picker for the plan-12 task fan-out view.
+     */
+    public class GoalOptionView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = ""; }
     }
 
     /**
@@ -56,28 +80,41 @@ namespace ClankerSaddle {
      * dependency summary (each dependency's id and status) and whether the
      * task is dispatch-ready.
      */
-    public class TasksEntrypoint : StatumEntrypoint {
-
-        private TaskService task_service = Inversion.inject<TaskService>();
-        private ProjectService project_service = Inversion.inject<ProjectService>();
-        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
-        private SaddleService saddle_service = Inversion.inject<SaddleService>();
-        private UserService users = Inversion.inject<UserService>();
-
-        public override async DirectiveBuilder handle() throws GLib.Error {
-            var guard = yield SpryAuth.require_login(directives(), request, users);
-            if (guard != null) {
-                return (!)guard;
-            }
+    /**
+     * The queue page's state builder, shared by the entrypoint and the
+     * sealed refresh action (live pulse rehydration + the client-side
+     * filter clicks re-render through it).
+     */
+    public class TasksState : Object {
 
+        public static async Statum.Slot build(StateService state_service,
+                TaskService task_service, ProjectService project_service,
+                CapabilityService capability_service, SaddleService saddle_service,
+                GoalService goal_service, ActionRegistry? actions,
+                StatumRequest? request = null) throws GLib.Error {
+            // Query filters arrive only on direct entrypoint hits (the
+            // browser client's entrypoint fetch never carries the
+            // page's own query string — the selects filter
+            // client-side).
             string? status = null;
-            request.query.try_get_any("status", out status);
+            if (request != null) {
+                request.query.try_get_any("status", out status);
+            }
             if (status == null || status.length == 0 || status == "all") {
                 status = null;
             }
+            string? goal_filter = null;
+            if (request != null) {
+                request.query.try_get_any("goal", out goal_filter);
+            }
+            if (goal_filter == null || goal_filter.length == 0) {
+                goal_filter = "all";
+            }
             int64? project_id = null;
             string? project_text = null;
-            request.query.try_get_any("project_id", out project_text);
+            if (request != null) {
+                request.query.try_get_any("project_id", out project_text);
+            }
             if (project_text != null && project_text.length > 0) {
                 int64 parsed = int64.parse(project_text);
                 if (parsed > 0) {
@@ -100,9 +137,29 @@ namespace ClankerSaddle {
             var ready = yield task_service.get_ready_tasks();
 
             // Computed display state for every listed task (review
-            // requested / delegating / suspended surface here).
+            // requested / delegating / suspended surface here). The
+            // plan-12 goal filter narrows the list AFTER the display
+            // map (the strip needs every task's display state). The
+            // GoalService arrives as a parameter — an inject inside
+            // this async builder would resume past an await where the
+            // injection context stack is empty.
+            var goal_titles = new Dictionary<string, string>();
+            foreach (var goal in yield goal_service.list_goals()) {
+                goal_titles.set(goal.id.to_string(), goal.title);
+            }
+            int64 goal_filter_id = 0;
+            bool standalone_only = goal_filter == "standalone";
+            if (goal_filter != "all" && !standalone_only) {
+                goal_filter_id = int64.parse(goal_filter);
+            }
             var listed = new Vector<ClankerSaddle.Data.TaskEntity>();
-            foreach (var task in yield task_service.list_tasks(status, project_id)) {
+            foreach (var task in yield task_service.list_tasks(null, null)) {
+                if (goal_filter_id > 0 && task.goal_id != goal_filter_id) {
+                    continue;
+                }
+                if (standalone_only && task.goal_id > 0) {
+                    continue;
+                }
                 listed.add(task);
             }
             var display_map = yield task_service.display_map_for(listed.to_immutable_buffer());
@@ -156,8 +213,14 @@ namespace ClankerSaddle {
                     failed = display != null && ((!)display).failed,
                     priority = task.priority,
                     parent_task_id = task.parent_task_id,
-                    ready = is_ready,
-                    project_name = project_names.get_or_default(task.project_id.to_string()) ?? ""
+                    project_id = task.project_id,
+                    project_name = project_names.get_or_default(task.project_id.to_string()) ?? "",
+                    mode = task.mode,
+                    creator = task.creator,
+                    goal_id = task.goal_id,
+                    goal_title = goal_titles.get_or_default(task.goal_id.to_string()) ?? "",
+                    enqueue_after_task_id = task.enqueue_after_task_id,
+                    ready = is_ready
                 };
                 var parts = new StringBuilder();
                 foreach (var dep in all_dependencies) {
@@ -208,7 +271,11 @@ namespace ClankerSaddle {
             var pub = new TasksPublic() {
                 filter_status = status ?? "",
                 filter_project_id = project_id != null ? ((!)project_id) : 0,
-                create = action_registry.author<CreateTaskAction>()
+                filter_goal = goal_filter,
+                create = actions != null
+                    ? actions.author<CreateTaskAction>() : null,
+                refresh = actions != null
+                    ? actions.author<RefreshTasksDataAction>("GET") : null
             };
 
             var capability_options = new Vector<CapabilityOptionView>();
@@ -219,6 +286,31 @@ namespace ClankerSaddle {
                 });
             }
 
+            // Plan 12: the goal filter's options (live goals, newest
+            // status badge) and the create form's auto-enqueue-after
+            // dropdown — every human-created worker task of the global
+            // list (goal tasks are excluded: the goal agent sequences
+            // its own).
+            var goal_options = new Vector<GoalOptionView>();
+            foreach (var goal in yield goal_service.list_goals()) {
+                goal_options.add(new GoalOptionView() {
+                    id = goal.id,
+                    title = goal.title,
+                    status = goal.status
+                });
+            }
+            var enqueue_options = new Vector<TaskProjectOptionView>();
+            foreach (var task in yield task_service.list_tasks(null, null)) {
+                if (task.kind != TASK_KIND_TASK || task.goal_id > 0
+                        || task.creator != CREATOR_HUMAN) {
+                    continue;
+                }
+                enqueue_options.add(new TaskProjectOptionView() {
+                    id = task.id,
+                    name = @"#$(task.id) $(task.title) ($(task.status))"
+                });
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["tasks"] = StateJson.list_rich<TaskRowView>(rows.to_immutable_buffer(),
                 (item, dict) => {
@@ -227,12 +319,71 @@ namespace ClankerSaddle {
                 });
             props["projects"] = StateJson.list<TaskProjectOptionView>(options.to_immutable_buffer());
             props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
+            props["goal_options"] = StateJson.list<GoalOptionView>(goal_options.to_immutable_buffer());
+            props["enqueue_after_options"] = StateJson.list<TaskProjectOptionView>(
+                enqueue_options.to_immutable_buffer());
 
-            var slot = state_service.new_slot(Scope.PAGE, new State() {
+            return state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "tasks",
                 public_data = props,
                 private_data = new PropertyDictionary()
             });
+        }
+    }
+
+    /**
+     * Entrypoint for /tasks: the filterable, sortable queue. Plan 12:
+     * the status/project/goal filters apply client-side (the Statum
+     * entrypoint fetch carries no page query string), rows carry the
+     * mode badge and goal link, and the create form offers the mode
+     * select and the auto-enqueue-after dropdown. The page subscribes
+     * to the global pulse and re-runs its sealed refresh action.
+     */
+    public class TasksEntrypoint : StatumEntrypoint {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var slot = yield TasksState.build(state_service, task_service,
+                project_service, capability_service, saddle_service,
+                goal_service, action_registry, request);
+            return directives().set(slot).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+
+    /**
+     * The sealed GET refresh action the queue page re-runs on global
+     * pulses and after a filter click (the client-side filter survives
+     * the re-render in window.taskFilters).
+     */
+    public class RefreshTasksDataAction : StatumAction {
+
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private CapabilityService capability_service = Inversion.inject<CapabilityService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var slot = yield TasksState.build(state_service, task_service,
+                project_service, capability_service, saddle_service,
+                goal_service, action_registry);
             return directives().set(slot);
         }
     }

+ 1 - 0
src/main.vala

@@ -114,6 +114,7 @@ int main(string[] args) {
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
         statum.action<ToggleAutoApproveAction>();
+        statum.action<RefreshTasksDataAction>();
         statum.action<CreateGoalAction>();
         statum.action<StartGoalAction>();
         statum.action<PauseGoalAction>();

+ 2 - 0
src/pages/app.css

@@ -280,6 +280,8 @@ summary { cursor: pointer; font-weight: 600; }
 .badge-block-completion { background: #fff1e0; color: #9a4700; }
 
 .goal-strip { display: flex; gap: var(--grid-1); flex-wrap: wrap; align-items: center; }
+.badge-kind-task { background: #ddf4ff; color: #0969da; }
+.badge-kind-goal { background: #f3e8ff; color: var(--plan); }
 .goal-filter {
     background: var(--line-soft);
     border: 1px solid var(--line);

+ 17 - 5
src/pages/home.html

@@ -164,14 +164,20 @@
             <h3 stm-if="dashboard.pinned.length > 0">Needs a human
                 (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
             <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
-                reviews across all tasks, newest first — follow the link to answer on the
-                task page.</p>
+                reviews across all tasks and goals, newest first — follow the link to
+                answer on the task or goal page.</p>
             <div class="cards" stm-if="dashboard.pinned.length > 0">
                 <section class="card" stm-for-blockview-in="dashboard.pinned">
-                    <p><span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
+                    <p>
+                        <span stm-attribute.class="'badge badge-kind-' + blockview.item_type"
+                              stm-text="blockview.item_type">task</span>
+                        <span stm-attribute.class="'badge badge-' + blockview.block_type" stm-text="blockview.block_type">type</span>
                         <span class="muted" stm-text="blockview.created_display">when</span></p>
                     <p><strong stm-text="blockview.question">question</strong></p>
-                    <p><a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
+                    <p stm-if="blockview.item_type == 'task'">
+                        <a stm-attribute.href="'/tasks/' + blockview.task_id" stm-text="blockview.task_title">task</a></p>
+                    <p stm-if="blockview.item_type == 'goal'">
+                        <a stm-attribute.href="'/goals/' + blockview.goal_id" stm-text="blockview.goal_title">goal</a></p>
                 </section>
             </div>
             <p stm-if="dashboard.pinned.length == 0" class="muted">Nothing needs a human —
@@ -239,7 +245,13 @@
                 <tbody>
                     <tr stm-for-item-in="dashboard.activity">
                         <td stm-text="item.when_display">when</td>
-                        <td><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
+                        <td stm-if="item.goal_id > 0">
+                            <span class="badge badge-kind-goal">goal</span>
+                            <a stm-attribute.href="'/goals/' + item.goal_id" stm-text="item.goal_title">goal</a>
+                            <a stm-if="item.task_id > 0" class="muted"
+                               stm-attribute.href="'/tasks/' + item.task_id"
+                               stm-text="'→ task #' + item.task_id">task</a></td>
+                        <td stm-if="item.goal_id == 0"><a stm-attribute.href="'/tasks/' + item.task_id" stm-text="item.task_title">task</a>
                             <span class="badge badge-yeehaw" stm-if="item.task_yeehaw"
                                   title="The agent celebrated this task (saddle yeehaw)">🤠</span></td>
                         <td><span stm-attribute.class="'badge badge-' + item.kind" stm-text="item.kind">kind</span>

+ 37 - 3
src/pages/task_detail.html

@@ -291,7 +291,11 @@
                     <h3>Status <span class="badge badge-yeehaw status-corner" stm-if="task.yeehaw"
                           title="The agent celebrated this task (saddle yeehaw)">🤠</span></h3>
                     <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
+                        <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
                         · priority <strong stm-text="task.priority">5</strong></p>
+                    <p class="muted" stm-if="task.enqueue_after_task_id > 0">Auto-enqueues after
+                        <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
+                           stm-text="'#' + task.enqueue_after_task_id + ' ' + task.enqueue_after_title">after</a></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>
@@ -328,10 +332,26 @@
                     </form>
                 </section>
 
+                <!-- Plan 12: the goal reference — a goal task or the
+                     goal agent's own run links back to its goal. -->
+                <section class="card" stm-if="task.goal_id > 0">
+                    <h3>Goal</h3>
+                    <p><span stm-attribute.class="'badge badge-' + task.goal_status"
+                             stm-text="task.goal_status.replace('_', ' ')">status</span>
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a></p>
+                    <p class="muted">Goal branch <code stm-text="task.goal_branch">branch</code>
+                        <span stm-if="task.is_goal_run">— this task IS the goal agent's run
+                            (orchestrate mode; the goal page owns its lifecycle)</span>
+                        <span stm-if="!task.is_goal_run">— this task runs under the goal agent's
+                            supervision on an ephemeral branch</span></p>
+                </section>
+
+                <!-- Plan 12: the mandate — the plan-11 "Description"
+                     card, framed as the binding work order. -->
                 <section class="card">
-                    <h3>Description</h3>
+                    <h3>Mandate</h3>
                     <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
-                    <p class="muted" stm-if="task.description.length == 0">No description.</p>
+                    <p class="muted" stm-if="task.description.length == 0">No mandate.</p>
                 </section>
             </div>
 
@@ -361,8 +381,22 @@
                     <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>
+                    <div class="form-row"><label>Mandate</label>
                         <textarea name="description" rows="4" stm-attribute.value="task.description"></textarea></div>
+                    <div class="form-row" stm-if="!task.is_goal_run"><label>Mode</label>
+                        <select name="mode" stm-attribute.value="task.mode">
+                            <option value="implement">implement — build the change on a task branch</option>
+                            <option value="investigate">investigate — explore and report, no code changes</option>
+                            <option value="review">review — read and critique existing work</option>
+                            <option value="test">test — write and run tests</option>
+                        </select></div>
+                    <div class="form-row" stm-if="task.creator == 'human' && !task.is_goal_run"><label>Auto-enqueue after
+                            <span class="muted">(enqueues itself when the predecessor is done; none clears it)</span></label>
+                        <select name="enqueue_after_task_id">
+                            <option value="">none</option>
+                            <option stm-for-option-in="task.enqueue_after_options" stm-attribute.value="option.id"
+                                     stm-text="'#' + option.id + ' ' + option.title">task</option>
+                        </select></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>

+ 91 - 24
src/pages/task_terminal.html

@@ -25,25 +25,69 @@
                     }
                 });
 
+                // The plan-12 session-picker fix: the Statum entrypoint
+                // fetch never carries this page's own ?session_id= to
+                // the server, so the server-side selection was stuck on
+                // its default and only the first-rendered session showed.
+                // The client therefore owns the selection: it reads
+                // ?session_id= from its own URL (falling back to the
+                // server default — the latest session), picks the
+                // matching session row's raw flag, and (re)connects
+                // whenever the choice changes.
+                function requestedSessionId(state) {
+                    var match = /[?&]session_id=(\d+)/.exec(window.location.search || '');
+                    if (match) {
+                        var requested = parseInt(match[1], 10);
+                        var rows = state.sessions || [];
+                        for (var i = 0; i < rows.length; i++) {
+                            if (parseInt(rows[i].id, 10) === requested) return requested;
+                        }
+                    }
+                    return state.session_id;
+                }
+
+                function sessionRaw(state, sessionId) {
+                    var rows = state.sessions || [];
+                    for (var i = 0; i < rows.length; i++) {
+                        if (parseInt(rows[i].id, 10) === parseInt(sessionId, 10)) {
+                            return !!rows[i].raw;
+                        }
+                    }
+                    return !!state.has_raw_log;
+                }
+
+                function highlightPicker(sessionId) {
+                    var links = document.querySelectorAll('a[data-session-id]');
+                    for (var i = 0; i < links.length; i++) {
+                        var link = links[i];
+                        var mine = parseInt(link.getAttribute('data-session-id'), 10)
+                            === parseInt(sessionId, 10);
+                        link.className = mine ? '' : 'muted';
+                    }
+                }
+
                 statum.addStateListener('terminal', function () {
                     var state = statum.read('terminal');
-                    if (!connected && state && state.exists && state.session_id > 0) {
-                        // Floating "comment on selection" covers both
-                        // terminal flavours: the raw PTY log (#term-raw) and
-                        // the legacy entry log (#term-log).
-                        if (window.initQuoteComments) {
-                            window.initQuoteComments({
-                                matches: function (node) {
-                                    if (!node.closest) {
-                                        return false;
-                                    }
-                                    return node.closest('#term-log') != null
-                                        || node.closest('#term-raw') != null;
-                                },
-                                action: function () { return state.comment; }
-                            });
-                        }
-                        connect(state.session_id, !!state.has_raw_log);
+                    if (!state || !state.exists || !(state.session_id > 0)) return;
+                    // Floating "comment on selection" covers both
+                    // terminal flavours: the raw PTY log (#term-raw) and
+                    // the legacy entry log (#term-log).
+                    if (window.initQuoteComments) {
+                        window.initQuoteComments({
+                            matches: function (node) {
+                                if (!node.closest) {
+                                    return false;
+                                }
+                                return node.closest('#term-log') != null
+                                    || node.closest('#term-raw') != null;
+                            },
+                            action: function () { return state.comment; }
+                        });
+                    }
+                    var desired = requestedSessionId(state);
+                    if (!connected || desired !== activeSession) {
+                        connect(desired, sessionRaw(state, desired));
+                        highlightPicker(desired);
                     }
                 });
 
@@ -190,9 +234,29 @@
 
                 function connect(sessionId, rawMode) {
                     connected = true;
+                    if (activeSource) {
+                        try { activeSource.close(); } catch (e) { }
+                        activeSource = null;
+                    }
                     activeSession = sessionId;
                     activeRaw = rawMode;
-                    var log = document.getElementById(rawMode ? 'term-raw' : 'term-log');
+                    // Both log containers live in the DOM (the raw flag
+                    // is per-session and the client owns the selection):
+                    // the active mode shows, the other hides.
+                    var rawLog = document.getElementById('term-raw');
+                    var typedLog = document.getElementById('term-log');
+                    var log = rawMode ? rawLog : typedLog;
+                    if (rawLog) rawLog.style.display = rawMode ? '' : 'none';
+                    if (typedLog) typedLog.style.display = rawMode ? 'none' : '';
+                    if (!log) return;
+                    var placeholder = log.querySelector('.term-placeholder');
+                    if (!placeholder) {
+                        log.textContent = '';
+                        var fresh = document.createElement('span');
+                        fresh.className = 'muted term-placeholder';
+                        fresh.textContent = 'Connecting…';
+                        log.appendChild(fresh);
+                    }
                     var source = new EventSource('/terminal-stream/' + sessionId);
                     activeSource = source;
                     var ansi = rawMode ? makeAnsiRenderer() : null;
@@ -217,8 +281,8 @@
                         }
                         if (entry.id <= lastId) return;
                         lastId = entry.id;
-                        var placeholder = log.querySelector('.term-placeholder');
-                        if (placeholder) placeholder.remove();
+                        var placeholder2 = log.querySelector('.term-placeholder');
+                        if (placeholder2) placeholder2.remove();
                         var nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
                         appendEntry(log, entry);
                         if (nearBottom) log.scrollTop = log.scrollHeight;
@@ -303,17 +367,20 @@
 
             <p stm-if="terminal.sessions.length > 1">Sessions:
                 <span stm-for-session-in="terminal.sessions">
-                    <a stm-if="session.selected"
+                    <a stm-if="session.selected" data-session-id="session.id"
                        stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
                        stm-text="session.label">session</a>
-                    <a stm-if="!session.selected" class="muted"
+                    <a stm-if="!session.selected" class="muted" data-session-id="session.id"
                        stm-attribute.href="'/tasks/' + terminal.task_id + '/terminal?session_id=' + session.id"
                        stm-text="session.label">session</a>
                 </span>
             </p>
 
-            <pre class="terminal-plain terminal-raw" stm-if="terminal.has_raw_log" id="term-raw"><span class="muted term-placeholder">Connecting…</span></pre>
-            <div class="terminal-plain" stm-if="terminal.sessions.length > 0 && !terminal.has_raw_log" id="term-log">
+            <!-- Both containers stay in the DOM: the per-session raw
+                 flag drives which one the client shows (see the
+                 picker script above). -->
+            <pre class="terminal-plain terminal-raw" stm-if="terminal.sessions.length > 0" id="term-raw"><span class="muted term-placeholder">Connecting…</span></pre>
+            <div class="terminal-plain" stm-if="terminal.sessions.length > 0" id="term-log" style="display:none">
                 <span class="muted term-placeholder">Connecting…</span>
             </div>
             <p stm-if="terminal.sessions.length == 0" class="muted">No sessions recorded for this task yet.</p>

+ 158 - 146
src/pages/tasks.html

@@ -3,160 +3,172 @@
 <head>
     <pstm-uri>/tasks</pstm-uri>
     <pstm-template>main</pstm-template>
-    <script>
-        // Create-form branch prefill: `branch_from` follows the chosen
-        // project's default branch until the user types their own value
-        // (the touched flag keeps hands-off edits authoritative). Events
-        // are delegated and the sync re-runs after every statum render,
-        // because re-renders replace the form's nodes.
-        if (window.statum) {
-            (function () {
-                'use strict';
+<script>
+    // The task queue. Plan 12: the filters (status / project / goal)
+    // run CLIENT-SIDE — the Statum entrypoint fetch never carries the
+    // page's own query string to the server, so selects filter the
+    // rendered rows directly (window.taskFilters + taskRowVisible)
+    // and survive re-renders; the create form gains the mode select
+    // (with descriptions) and the auto-enqueue-after dropdown (the
+    // global human-task list; goal tasks are the goal agent's to
+    // sequence).
+    if (window.statum) {
+        (function () {
+            'use strict';
 
-                function chosenDefaultBranch() {
-                    var select = document.getElementById('create-project');
-                    if (!select) return null;
-                    var option = select.options[select.selectedIndex];
-                    var branch = option && option.getAttribute('data-default-branch');
-                    return branch && branch.length > 0 ? branch : null;
-                }
+            window.taskFilters = window.taskFilters || {};
 
-                function syncBranchFrom() {
-                    var input = document.getElementById('create-branch-from');
-                    if (!input || input.dataset.touched) return;
-                    var branch = chosenDefaultBranch();
-                    if (branch) input.value = branch;
+            window.taskRowVisible = function (row) {
+                var filters = window.taskFilters || {};
+                if (filters.status && filters.status !== 'all'
+                        && row.status !== filters.status) {
+                    return false;
                 }
-
-                document.addEventListener('change', function (event) {
-                    if (event.target.id === 'create-project') syncBranchFrom();
-                });
-                document.addEventListener('input', function (event) {
-                    if (event.target.id === 'create-branch-from') {
-                        event.target.dataset.touched = '1';
+                if (filters.project && filters.project > 0
+                        && String(row.project_id) !== String(filters.project)) {
+                    return false;
+                }
+                if (filters.goal) {
+                    if (filters.goal === 'standalone' && row.goal_id > 0) {
+                        return false;
                     }
-                });
-                statum.addStateListener('tasks', syncBranchFrom);
-            })();
-        }
-    </script>
+                    if (filters.goal !== 'standalone'
+                            && String(row.goal_id) !== String(filters.goal)) {
+                        return false;
+                    }
+                }
+                return true;
+            };
+
+            window.setTaskFilter = function (name, value) {
+                window.taskFilters[name] = value;
+                var tasks = statum.read('tasks');
+                if (tasks && tasks.refresh) {
+                    statum.act(tasks.refresh).catch(function () { });
+                } else {
+                    // No sealed refresh on this build: force a render
+                    // cycle through the pulse listener if present.
+                    document.body.setAttribute('data-filter-bump', String(Date.now()));
+                }
+            };
+        })();
+    }
+</script>
 </head>
 <body>
-    <section>
-        <h2>Task queue</h2>
-        <p stm-if="tasks == null">Loading…</p>
-
-        <div stm-if="tasks != null">
-            <!-- "New task" expander ABOVE the table and the filter
-                 controls (plan 11 task D): creation is the primary
-                 action, not an afterthought under the queue. -->
-            <details class="new-task-expander">
-                <summary><strong>New task</strong></summary>
-                <form stm-action="tasks.create">
-                    <div class="form-row"><label>Project</label>
-                        <select name="project_id" id="create-project" required>
-                            <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
-                                    stm-attribute.data-default-branch="project.default_branch"
-                                    stm-text="project.name">project</option>
-                        </select></div>
-                    <div class="form-row"><label>Title</label>
-                        <input type="text" name="title" required placeholder="Implement the flange module"></div>
-                    <div class="form-row"><label>Description</label>
-                        <textarea name="description" rows="4" placeholder="What the agent should achieve"></textarea></div>
-                    <div class="form-row"><label>Plan (markdown, optional)</label>
-                        <textarea name="plan" rows="6" placeholder="The agent fills this in if left empty"></textarea></div>
-                    <div class="form-row"><label>Priority (1–9)</label>
-                        <input type="number" name="priority" min="1" max="9" value="5"></div>
-                    <div class="form-row"><label>Task branch (optional)</label>
-                        <input type="text" name="task_branch" placeholder="auto from title"></div>
-                    <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.
-                        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">
-                                <input type="checkbox" name="capability_id" stm-attribute.value="option.id">
-                                <span stm-text="option.name">tag</span>
-                            </label>
-                        </p>
-                        <p class="muted">Only saddles carrying every ticked tag will run this task.</p>
-                    </div>
-                    <button type="submit" class="primary">Create task</button>
-                </form>
-            </details>
+    <div class="page">
+        <h1>Tasks</h1>
 
-            <form method="get" action="/tasks" class="inline">
-                <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>
-                    <option value="blocked" stm-attribute.selected="tasks.filter_status == 'blocked'">blocked</option>
-                    <option value="done" stm-attribute.selected="tasks.filter_status == 'done'">done</option>
-                    <option value="failed" stm-attribute.selected="tasks.filter_status == 'failed'">failed</option>
-                </select>
-                <label for="filter-project">Project</label>
-                <select id="filter-project" name="project_id">
-                    <option value="0" stm-attribute.selected="tasks.filter_project_id == 0">all</option>
-                    <option stm-for-project-in="tasks.projects"
-                             stm-attribute.value="project.id"
-                             stm-attribute.selected="tasks.filter_project_id == project.id"
-                             stm-text="project.name">project</option>
-                </select>
-                <button type="submit">Filter</button>
-            </form>
+        <form class="inline" onsubmit="return false">
+            <label for="filter-status">Status</label>
+            <select id="filter-status" onchange="window.setTaskFilter('status', this.value)">
+                <option value="all">all</option>
+                <option value="draft">draft</option>
+                <option value="queued">queued</option>
+                <option value="assigned">assigned</option>
+                <option value="working">working</option>
+                <option value="blocked">blocked</option>
+                <option value="suspended">suspended</option>
+                <option value="done">done</option>
+                <option value="failed">failed</option>
+            </select>
+            <label for="filter-project">Project</label>
+            <select id="filter-project" onchange="window.setTaskFilter('project', this.value)">
+                <option value="0">all</option>
+                <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
+                         stm-text="project.name">project</option>
+            </select>
+            <label for="filter-goal">Goal</label>
+            <select id="filter-goal" onchange="window.setTaskFilter('goal', this.value)">
+                <option value="all">all</option>
+                <option value="standalone">standalone only</option>
+                <option stm-for-goal-in="tasks.goal_options" stm-attribute.value="goal.id"
+                         stm-text="goal.title">goal</option>
+            </select>
+            <span class="muted">— filters apply instantly</span>
+        </form>
 
-            <table>
-                <thead><tr><th>#</th><th>Title</th><th>Project</th><th>Status</th><th>Prio</th><th>Depends on</th></tr></thead>
-                <tbody>
-                    <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="badge badge-yeehaw" stm-if="task.has_yeehaw"
-                                  title="The agent celebrated this task (saddle yeehaw)">🤠</span>
+        <table>
+            <thead>
+                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
+                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
+            </thead>
+            <tbody>
+                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
+                    <td stm-text="task.id">id</td>
+                    <td>
+                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
+                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
+                    </td>
+                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
+                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
+                    <td stm-if="task.goal_id > 0">
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
+                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
+                    </td>
+                    <td stm-if="task.goal_id == 0" class="muted">—</td>
+                    <td stm-text="task.priority">5</td>
+                    <td stm-text="task.project_name">project</td>
+                    <td><span stm-if="task.capabilities.length > 0"
+                              stm-text="task.capabilities.join(', ')">caps</span>
+                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
+                </tr>
+                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
+            </tbody>
+        </table>
 
-                            <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>
-                            <span stm-if="task.capabilities.length > 0">
-                                <span stm-for-capability-in="task.capabilities" stm-key="capability">
-                                    <span class="badge" stm-text="capability">tag</span>
-                                </span>
-                                <span class="muted" stm-if="task.waiting_for_saddle"> · waiting for a capable saddle</span>
-                            <span class="muted" stm-if="task.unreachable"> · saddle unreachable — requeueing</span>
-                            </span></td>
-                        <td stm-text="task.project_name">project</td>
-                        <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
-                        <td stm-text="task.priority">priority</td>
-                        <td stm-if="task.has_dependencies" stm-text="task.dependency_summary">deps</td>
-                        <td stm-if="!task.has_dependencies" class="muted">—</td>
-                    </tr>
-                    <tr stm-if="tasks.tasks.length == 0"><td colspan="6" class="muted">No tasks match.</td></tr>
-                </tbody>
-            </table>
-
-        </div>
-    </section>
+        <details class="history-expander">
+            <summary><strong>New task</strong></summary>
+            <form stm-action="tasks.create">
+                <div class="form-row"><label for="task-project">Project</label>
+                    <select id="task-project" name="project_id" required>
+                        <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
+                                 stm-text="project.name">project</option>
+                    </select></div>
+                <div class="form-row"><label for="task-title">Title</label>
+                    <input type="text" id="task-title" name="title" required></div>
+                <div class="form-row"><label for="task-mode">Mode</label>
+                    <select id="task-mode" name="mode">
+                        <option value="implement" selected>implement — build the change on a task branch</option>
+                        <option value="investigate">investigate — explore and report, no code changes</option>
+                        <option value="review">review — read and critique existing work</option>
+                        <option value="test">test — write and run tests</option>
+                    </select></div>
+                <div class="form-row"><label for="task-description">Mandate</label>
+                    <textarea id="task-description" name="description" rows="4"
+                              placeholder="What the agent must accomplish (the mandate)"></textarea></div>
+                <div class="form-row"><label for="task-priority">Priority</label>
+                    <select id="task-priority" name="priority">
+                        <option value="1">1 — highest</option>
+                        <option value="2">2</option>
+                        <option value="3">3</option>
+                        <option value="4">4</option>
+                        <option value="5" selected>5 — default</option>
+                        <option value="6">6</option>
+                        <option value="7">7</option>
+                        <option value="8">8</option>
+                        <option value="9">9 — lowest</option>
+                    </select></div>
+                <div class="form-row"><label for="task-enqueue-after">Auto-enqueue after
+                        <span class="muted">(optional — enqueues itself when the predecessor is done)</span></label>
+                    <select id="task-enqueue-after" name="enqueue_after_task_id">
+                        <option value="">none</option>
+                        <option stm-for-option-in="tasks.enqueue_after_options"
+                                 stm-attribute.value="option.id" stm-text="option.name">task</option>
+                    </select></div>
+                <div class="form-row" stm-if="tasks.capability_options.length > 0">
+                    <label for="task-capabilities">Capabilities
+                        <span class="muted">(the saddle fleet must carry them until assigned)</span></label>
+                    <select id="task-capabilities" name="required_capabilities" multiple
+                            size="3">
+                        <option stm-for-capability-in="tasks.capability_options"
+                                 stm-attribute.value="capability.id"
+                                 stm-text="capability.name">cap</option>
+                    </select></div>
+                <button type="submit" class="primary">Create task</button>
+            </form>
+        </details>
+    </div>
 </body>
 </html>

+ 215 - 6
test/web_stack_test.py

@@ -395,6 +395,17 @@ def db():
     return sqlite3.connect(DB_PATH)
 
 
+def http_get_page(auth, uri):
+    """The compiled page HTML for a statum route (auth via the slot
+    header), for asserting page-level embeds the slot state cannot
+    see."""
+    status, body = http("GET", uri, headers={
+        "X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
+    if status != 200:
+        raise SystemExit("GET %s -> HTTP %d" % (uri, status))
+    return body
+
+
 def saddle_card(auth, name):
     rows = [s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
             if s["name"] == name]
@@ -1587,10 +1598,10 @@ def bundle_pages(ctx):
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
     check(page.index("grid-2") < page.index("status-head")
-          and page.index("status-head") < page.index("<h3>Description</h3>"),
+          and page.index("status-head") < page.index("<h3>Mandate</h3>"),
           "status and description sit side by side inside the grid (status first)")
-    check("<h3>Description</h3>" in page
-          and page.index("<h3>Description</h3>") < page.index("plan-expander"),
+    check("<h3>Mandate</h3>" in page
+          and page.index("<h3>Mandate</h3>") < page.index("plan-expander"),
           "the plan detail hides behind its expander below the grid")
     # Answered blocks fold behind the history expander (this fixture's
     # three blocks are all answered by now).
@@ -1779,9 +1790,14 @@ def bundle_pages(ctx):
           "the lifecycle buttons ride the status card inside the grid")
     status, queue_page = http("GET", "/tasks", timeout=15)
     check(status == 200
-          and queue_page.index("new-task-expander") < queue_page.index("filter-status")
-          < queue_page.index("<table"),
-          "the New Task expander sits above the filter controls and the table")
+          and queue_page.index("filter-status") < queue_page.index("<table")
+          and "filter-goal" in queue_page,
+          "the filter controls (status + goal) sit above the queue table")
+    check("New task" in queue_page and "task-mode" in queue_page
+          and "task-enqueue-after" in queue_page
+          and 'name="plan_auto_approve"' not in queue_page,
+          "the create expander offers the mode + auto-enqueue-after selects"
+          " (no subtask checkboxes)")
 
     print("== yeehaw: ingestion, festive card, badge, timeline ==")
     # `saddle yeehaw <message>` arrives as a round-results entry with the
@@ -5091,6 +5107,108 @@ def bundle_push(ctx):
     check(task_state(auth, task_id_of("Parked-late child"))["status"] == "draft",
           "the parked parent's child lands in draft (no auto-enqueue)")
 
+    print("== plan 12: goal notifications — blocks/success/failure always ==")
+    # Goal blocks, completion approval and failure push to EVERY
+    # subscribed user (the mirror on the goal-run task stays quiet).
+    def goal_state(goal_id):
+        return slot_public(auth, "/goals/%d" % goal_id, "goal")
+
+    push_goal = None
+    act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Push goal", "brief": "Notifies everyone.",
+        "branch_name": "goal/push"})
+    conn = db()
+    row = conn.execute("select id from goals where title = 'Push goal'").fetchone()
+    conn.close()
+    push_goal = int(row[0])
+    fake.goal_script = [("plan", "# Push plan\n\n1. Ring the bells")]
+    main_before = len(push.hits("/main"))
+    other_before = len(push.hits("/other"))
+    act(auth, goal_state(push_goal)["start"], {})
+    check(wait_for(lambda: goal_state(push_goal)["status"] == "plan_review" or False,
+                   timeout=60, what="push goal enters plan review") is not None,
+          "the push goal reaches plan review")
+    check(wait_for(lambda: len(push.hits("/main")) > main_before
+                   and len(push.hits("/other")) > other_before, timeout=15,
+                   what="goal plan-review push") is not None,
+          "a goal plan review notifies every subscribed user globally")
+    _, plan_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
+                                      other_public, other_auth)
+    check("Push goal" in plan_payload["title"] and "plan" in plan_payload["title"]
+          and plan_payload["url"] == "/goals/%d" % push_goal,
+          "the goal block payload carries the goal title + click URL (%s)"
+          % plan_payload)
+    main_before = len(push.hits("/main"))
+    fake.goal_script = [("suspend",)]
+    act(auth, goal_state(push_goal)["plan_pending_block"]["answer_action"],
+        {"answer": "Plan approved. Proceed with implementation."})
+    check(wait_for(lambda: goal_state(push_goal)["status"] == "active" or False,
+                   timeout=30, what="push goal active") is not None,
+          "the push goal activates")
+    check(quiet_window(3, lambda: len(push.hits("/main")) == main_before,
+                       "no push for an internal resume"),
+          "the goal agent's own park/resume does not notify (internal)")
+
+    # `saddle failure` in goal context fails the goal — global push.
+    fake.goal_script = [("fail", "the harness collapsed")]
+    other_before2 = len(push.hits("/other"))
+    main_before2 = len(push.hits("/main"))
+    conn = db()
+    conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
+                 " values (?, 'task-done', 'Task #999 Warm — done',"
+                 " strftime('%s','now','-5 seconds'))", (push_goal,))
+    conn.commit()
+    conn.close()
+    check(wait_for(lambda: goal_state(push_goal)["status"] == "failed" or False,
+                   timeout=60, what="goal failure lands") is not None,
+          "the goal agent's failure fails the goal")
+    check(wait_for(lambda: len(push.hits("/other")) > other_before2
+                   and len(push.hits("/main")) > main_before2, timeout=15,
+                   what="goal failure push") is not None,
+          "a goal failure notifies every subscribed user globally")
+    _, fail_payload = decrypt_webpush(push.hits("/other")[-1], other_browser,
+                                      other_public, other_auth)
+    check("Push goal" in fail_payload["title"] and "failed" in fail_payload["title"]
+          and fail_payload["url"] == "/goals/%d" % push_goal,
+          "the goal failure payload carries the goal title + click URL (%s)"
+          % fail_payload)
+
+    # Completion approval pushes "complete" globally (success always).
+    act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Push done goal", "brief": "Completes.",
+        "branch_name": "goal/push-done"})
+    conn = db()
+    row = conn.execute("select id from goals where title = 'Push done goal'").fetchone()
+    conn.close()
+    done_goal = int(row[0])
+    fake.goal_script = [("plan", "# Done plan")]
+    act(auth, goal_state(done_goal)["start"], {})
+    check(wait_for(lambda: goal_state(done_goal)["status"] == "plan_review" or False,
+                   timeout=60, what="done goal plan review") is not None,
+          "the done goal reaches plan review")
+    fake.goal_script = [("done", "# Completion\n\nShipped.")]
+    act(auth, goal_state(done_goal)["plan_pending_block"]["answer_action"],
+        {"answer": "Plan approved. Proceed with implementation."})
+    check(wait_for(lambda: goal_state(done_goal)["status"] == "completion_review"
+                   or False, timeout=60, what="done goal completion review") is not None,
+          "the approved done goal reaches completion review")
+    main_before = len(push.hits("/main"))
+    act(auth, goal_state(done_goal)["completion_pending_block"]["answer_action"],
+        {"answer": "Review approved. Continue with the task."})
+    check(wait_for(lambda: goal_state(done_goal)["status"] == "complete" or False,
+                   timeout=30, what="done goal completes") is not None,
+          "the done goal completes")
+    check(wait_for(lambda: len(push.hits("/main")) > main_before, timeout=15,
+                   what="goal success push") is not None,
+          "a goal completion notifies every subscribed user globally")
+    _, done_goal_payload = decrypt_webpush(push.hits("/main")[-1], browser,
+                                           browser_public, rotated_auth)
+    check("Push done goal" in done_goal_payload["title"]
+          and "complete" in done_goal_payload["title"]
+          and done_goal_payload["url"] == "/goals/%d" % done_goal,
+          "the goal success payload carries the goal title + click URL (%s)"
+          % done_goal_payload)
+
     print("== task E: unsubscribe removes the subscription ==")
     dash = slot_public(auth, "/", "dashboard")
     check(dash.get("push_subscribed") is True,
@@ -5547,6 +5665,97 @@ def bundle_goals(ctx):
     check(wait_task_done(auth, successor, timeout=90) is not None,
           "the auto-enqueued successor completes")
 
+    print("== plan 12: the web surfaces — pile, timeline, queue, task page ==")
+    # A goal parked in plan review feeds the unified dashboard pile;
+    # goal events feed the timeline.
+    goal3 = create_goal({"project_id": "1", "title": "Dashboard goal",
+                         "brief": "Feeds the pile.", "branch_name": "goal/dash"})
+    fake.goal_script = [("plan", "# Dash plan\n\n1. Probe the pile")]
+    act(auth, goal_state(goal3)["start"], {})
+    check(wait_for(lambda: goal_state(goal3)["status"] == "plan_review" or False,
+                   timeout=60, what="dashboard goal enters plan review") is not None,
+          "the dashboard goal reaches plan review")
+    check(wait_for(lambda: any(b.get("item_type") == "goal"
+                               and int(b.get("goal_id") or 0) == goal3
+                               for b in slot_public(auth, "/", "dashboard")["pinned"])
+                   or False, timeout=30, what="goal block pins on the dashboard") is not None,
+          "the unified dashboard pile carries the goal's plan review")
+    pile = slot_public(auth, "/", "dashboard")["pinned"]
+    check(all(b.get("item_type") in ("task", "goal") for b in pile),
+          "pile items carry type badges (task/goal interleaved)")
+    goal_pile = [b for b in pile if b.get("item_type") == "goal"][0]
+    check(goal_pile["block_type"] == "plan" and "Dashboard goal" in goal_pile["goal_title"],
+          "the goal pile card names the goal and block kind")
+    # Answer it: the pile drains.
+    act(auth, goal_state(goal3)["plan_pending_block"]["answer_action"],
+        {"answer": "Plan approved. Proceed with implementation."})
+    check(wait_for(lambda: not any(b.get("item_type") == "goal"
+                                   and int(b.get("goal_id") or 0) == goal3
+                                   for b in slot_public(auth, "/", "dashboard")["pinned"])
+                   or False, timeout=30, what="goal pile card drains") is not None,
+          "answering the review drains the goal's pile card")
+    activity = slot_public(auth, "/", "dashboard")["activity"]
+    check(any(a["kind"] == "goal-plan-submitted" and int(a.get("goal_id") or 0) == goal3
+              and a["goal_title"] == "Dashboard goal" for a in activity),
+          "the dashboard timeline carries goal events linked to the goal")
+
+    # The task queue: goal filter options, mode badges, goal links,
+    # enqueue-after dropdown.
+    queue = slot_public(auth, "/tasks", "tasks")
+    check(any(int(g["id"]) == goal3 and g["title"] == "Dashboard goal"
+              for g in queue["goal_options"]),
+          "the queue's goal filter lists the goals")
+    check(queue["tasks"][0].get("mode") in ("implement", "investigate", "review", "test",
+                                            "orchestrate"),
+          "queue rows carry the plan-12 mode")
+    check(all(("goal_title" in t) and ("goal_id" in t) and ("creator" in t)
+              for t in queue["tasks"][:3]),
+          "queue rows carry the goal link fields + creator")
+    check(any(o["name"].startswith("#") for o in queue["enqueue_after_options"]),
+          "the create form's enqueue-after dropdown lists the global human tasks")
+    check(not any(int(o["id"]) == fan_task for o in queue["enqueue_after_options"]),
+          "the enqueue-after dropdown excludes the goal agent's tasks")
+
+    # The task page: mandate framing, goal reference section, the
+    # terminal session picker fix, and the description prefill.
+    task_page_html = http_get_page(auth, "/tasks/%d" % fan_task)
+    check("Mandate" in task_page_html and 'stm-markdown="task.description"' in task_page_html,
+          "the task page frames the description as the mandate")
+    conn = db()
+    agent_session_row = conn.execute(
+        "select id from sessions where task_id = ?", (run_task,)).fetchone()
+    conn.close()
+    terminal_state = slot_public(auth, "/tasks/%d/terminal" % run_task, "terminal")
+    session_rows = terminal_state["sessions"]
+    check(len(session_rows) >= 4
+          and all(("raw" in s) for s in session_rows),
+          "the terminal state lists every goal-run session with per-session raw flags")
+    check(int(terminal_state["session_id"]) == int(session_rows[-1]["id"]),
+          "the terminal default selects the LATEST session (was: the first)")
+    terminal_html = http_get_page(auth, "/tasks/%d/terminal" % run_task)
+    check("requestedSessionId" in terminal_html and "data-session-id" in terminal_html,
+          "the terminal client owns the ?session_id= selection (query params never reach entrypoints)")
+    detail = task_state(auth, fan_task)
+    check(detail.get("goal_id") == goal1 and detail.get("goal_title") == "Ship the flange"
+          and detail.get("creator") == "goal",
+          "a goal task's page carries the goal reference section data")
+    run_detail = task_state(auth, run_task)
+    check(run_detail.get("is_goal_run") is True and run_detail.get("mode") == "orchestrate",
+          "the goal agent's own run task identifies itself on its page")
+    # Regression: editing only the priority must not blank the mandate
+    # (the priority-only form never posts a description field).
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Mandate keeper", "priority": "4",
+        "description": "Keep this mandate intact."})
+    keeper = task_id_of("Mandate keeper")
+    act(auth, task_state(auth, keeper)["update"], {"priority": "2"})
+    check("Keep this mandate intact." == task_state(auth, keeper)["description"],
+          "a priority-only edit leaves the mandate untouched")
+    # And the full edit form prefills the mandate (carried fix).
+    task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
+    check('stm-attribute.value="task.description"' in task_page_html,
+          "the edit expander pre-populates the mandate textarea")
+
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals")