Просмотр исходного кода

goals: schema, lifecycle service, wire contract and task callbacks

The plan-12 foundation: goals/goal_links/goal_comments/goal_blocks/
goal_events tables plus the goal columns on tasks (goal_id, kind,
mode, creator, enqueue_after_task_id); GoalService with the full
lifecycle (draft -> planning -> plan review -> active -> completion
review -> complete/failed/cancelled, paused freezing supervision),
plan revisions, completion review, goal comments and the event-driven
coalesced resume (SADDLE_GOAL_RESUME_S, default 10s). The assignment
payload carries the goal wire contract (kind/mode/creator/goal/
ephemeral_branch; the subtasks/parent/task-plan members are gone),
goal work scopes links/rules through the goal's own links, and
/api/saddle/task/{create,delete,enqueue} serve the goal agent's task
CLI (ownership + draft validated, goal-run session identity, errors
verbatim). Goal pages/actions land as the lifecycle surface; enqueue
is creator-only; human tasks gain the cycle-checked
auto-enqueue-after predecessor. The daemon's /status goal_branches
exposure caches on the goal for the completion review.
clanker 1 месяц назад
Родитель
Сommit
44f2e66a5a

+ 17 - 0
meson.build

@@ -77,6 +77,18 @@ task_terminal_page = custom_target('task-terminal-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskTerminalPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'TaskTerminalPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
     depend_files: template_deps
 )
 )
+goals_page = custom_target('goals-page',
+    input: 'src/pages/goals.html',
+    output: 'GoalsPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'GoalsPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+goal_detail_page = custom_target('goal-detail-page',
+    input: 'src/pages/goal_detail.html',
+    output: 'GoalDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'GoalDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 saddles_page = custom_target('saddles-page',
 saddles_page = custom_target('saddles-page',
     input: 'src/pages/saddles.html',
     input: 'src/pages/saddles.html',
     output: 'SaddlesPage.vala',
     output: 'SaddlesPage.vala',
@@ -113,6 +125,7 @@ app_sources = files(
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
+    'src/Services/GoalService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/AllocationService.vala',
     'src/Services/AllocationService.vala',
@@ -130,6 +143,7 @@ app_sources = files(
     'src/actions/LoginAction.vala',
     'src/actions/LoginAction.vala',
     'src/actions/ProjectActions.vala',
     'src/actions/ProjectActions.vala',
     'src/actions/TaskActions.vala',
     'src/actions/TaskActions.vala',
+    'src/actions/GoalActions.vala',
     'src/actions/DashboardActions.vala',
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/RuleActions.vala',
@@ -141,6 +155,7 @@ app_sources = files(
     'src/entrypoints/TasksEntrypoint.vala',
     'src/entrypoints/TasksEntrypoint.vala',
     'src/entrypoints/TaskDetailEntrypoint.vala',
     'src/entrypoints/TaskDetailEntrypoint.vala',
     'src/entrypoints/TaskTerminalEntrypoint.vala',
     'src/entrypoints/TaskTerminalEntrypoint.vala',
+    'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     # spry:sources-end
     # spry:sources-end
@@ -159,6 +174,8 @@ app_generated = [
     tasks_page,
     tasks_page,
     task_detail_page,
     task_detail_page,
     task_terminal_page,
     task_terminal_page,
+    goals_page,
+    goal_detail_page,
     saddles_page,
     saddles_page,
     rules_page,
     rules_page,
     # spry:generated-end
     # spry:generated-end

+ 325 - 0
src/Api/SaddleApi.vala

@@ -768,6 +768,331 @@ namespace ClankerSaddle.Api {
         }
         }
     }
     }
 
 
+    /**
+     * The plan-12 task CLI callbacks (`task create/delete/enqueue`):
+     * the goal agent's synchronous task operations against its own
+     * goal. The goal agent's identity is its goal-run session — the
+     * `session_id` in the body must resolve to a kind 'goal_run' task
+     * hosted on the calling saddle; ownership then means "under that
+     * goal". Errors surface to the agent verbatim (the CLI prints the
+     * `error` member).
+     */
+    public class SaddleTaskCallbacks : Object {
+
+        private SaddleService saddles;
+        private TaskService tasks;
+        private GoalService goals;
+        private CapabilityService capabilities;
+        private InvercargillSql.Orm.OrmSession db;
+
+        public SaddleTaskCallbacks(SaddleService saddles, TaskService tasks,
+                GoalService goals, CapabilityService capabilities,
+                InvercargillSql.Orm.OrmSession db) {
+            this.saddles = saddles;
+            this.tasks = tasks;
+            this.goals = goals;
+            this.capabilities = capabilities;
+            this.db = db;
+        }
+
+        /**
+         * Resolves the caller's goal scope: the session must exist,
+         * belong to the calling saddle, and be a goal-run session.
+         * Returns the goal id, or an error naming the problem
+         * verbatim.
+         */
+        private async int64 goal_scope_of(Saddle saddle, int64 session_id) throws Error {
+            if (session_id <= 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 80,
+                    "A session_id is required (the goal-run session identifies the goal agent)");
+            }
+            var session = yield SaddleChunksEndpoint.session_owned_by(db, session_id, saddle);
+            if (session == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 81,
+                    @"No session $(session_id) exists on this saddle");
+            }
+            var task = yield db.query<TaskEntity>()
+                .where(Invercargill.Expressions.expr("id == $0",
+                    Invercargill.Expressions.elem<int64?>(((!)session).task_id)))
+                .first_async();
+            if (task == null || ((!)task).kind != TASK_KIND_GOAL_RUN
+                    || ((!)task).goal_id <= 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    "task commands are only available to a goal agent (its goal-run session)");
+            }
+            var goal = yield goals.get_goal(((!)task).goal_id);
+            if (goal == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 83,
+                    @"The goal of session $(session_id) no longer exists");
+            }
+            return ((!)task).goal_id;
+        }
+
+        /**
+         * `POST /api/saddle/task/create` — `task create` (heredoc
+         * mandate + flags): creates a draft task under the goal with
+         * the given mode and explicit capabilities. The goal agent
+         * sequences its own tasks, so the row carries NO
+         * auto-enqueue predecessor and lands in draft until the agent
+         * enqueues it.
+         *
+         * ```json
+         * {"session_id": 12, "title": "…", "mandate": "…",
+         *  "mode": "implement", "capabilities": ["gpu"]}
+         * ```
+         *
+         * → `{"ok": true, "task_id": 7}`.
+         */
+        public async HttpResult create(HttpContext http_context, Saddle saddle) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleUpdateEndpoint.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            try {
+                int64 session_id = body.has_member("session_id")
+                    && body.get_member("session_id").get_value_type() == typeof(int64)
+                    ? body.get_int_member("session_id") : 0;
+                int64 goal_id = yield goal_scope_of(saddle, session_id);
+                string title = SaddleUpdateEndpoint.get_string_or(body, "title", "");
+                string mandate = SaddleUpdateEndpoint.get_string_or(body, "mandate", "");
+                if (title.length == 0) {
+                    return SaddleUpdateEndpoint.json_error(400, "A title is required");
+                }
+                if (mandate.length == 0) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        "A mandate is required (heredoc body of `task create`)");
+                }
+                string mode = SaddleUpdateEndpoint.get_string_or(body, "mode", "implement");
+                bool mode_valid = false;
+                foreach (string candidate in TASK_MODES) {
+                    if (mode == candidate) {
+                        mode_valid = true;
+                        break;
+                    }
+                }
+                if (!mode_valid) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"Unknown mode '$(mode)' — valid modes: "
+                        + string.joinv(", ", TASK_MODES));
+                }
+                var goal = yield goals.get_goal(goal_id);
+
+                string[] requested_tags = {};
+                if (body.has_member("capabilities")) {
+                    var member = body.get_member("capabilities");
+                    if (member.get_node_type() != Json.NodeType.ARRAY) {
+                        return SaddleUpdateEndpoint.json_error(400,
+                            "capabilities must be a JSON array of tag names");
+                    }
+                    foreach (var element in member.get_array().get_elements()) {
+                        if (element.get_node_type() == Json.NodeType.VALUE
+                                && element.get_string() != null) {
+                            requested_tags += element.get_string();
+                        }
+                    }
+                }
+                var resolved = new Vector<Capability>();
+                if (requested_tags.length > 0) {
+                    var unknown = new Vector<string>();
+                    foreach (string tag in requested_tags) {
+                        string trimmed = tag.strip();
+                        if (trimmed.length == 0) {
+                            continue;
+                        }
+                        var capability = yield capabilities.find_by_name(trimmed);
+                        if (capability == null) {
+                            unknown.add(trimmed);
+                        } else {
+                            resolved.add((!)capability);
+                        }
+                    }
+                    if (unknown.length > 0) {
+                        var valid = new Vector<string>();
+                        foreach (var capability in yield capabilities.list_capabilities()) {
+                            valid.add(capability.name);
+                        }
+                        return SaddleUpdateEndpoint.json_error(400,
+                            @"Unknown capability tag(s): $(string.joinv(", ", unknown.to_array()))"
+                            + @" — valid tags: $(string.joinv(", ", valid.to_array()))");
+                    }
+                }
+
+                var task = yield tasks.create_task(((!)goal).project_id, title, mandate,
+                    5, null, null, null, false, false, false, STATUS_DRAFT,
+                    TASK_KIND_TASK, mode, CREATOR_GOAL, goal_id, 0);
+                foreach (var capability in resolved) {
+                    yield capabilities.attach_to_task(capability.id, task.id);
+                }
+                yield goals.record_event(goal_id, "task-created",
+                    @"Task #$(task.id) $(task.title) created by the goal agent ($(mode))",
+                    task.id);
+                var response = new Json.Object();
+                response.set_int_member("task_id", task.id);
+                return SaddleUpdateEndpoint.json_ok(response);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+
+        /**
+         * `POST /api/saddle/task/delete` — `task delete <id>`: removes
+         * one of the goal's DRAFT tasks (ownership: the task must be
+         * under the caller's goal). → `{"ok": true}`.
+         */
+        public async HttpResult delete(HttpContext http_context, Saddle saddle) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleUpdateEndpoint.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            try {
+                int64 session_id = body.has_member("session_id")
+                    && body.get_member("session_id").get_value_type() == typeof(int64)
+                    ? body.get_int_member("session_id") : 0;
+                int64 goal_id = yield goal_scope_of(saddle, session_id);
+                int64 task_id = body.has_member("task_id")
+                    && body.get_member("task_id").get_value_type() == typeof(int64)
+                    ? body.get_int_member("task_id") : 0;
+                var detail = yield tasks.get_task(task_id);
+                if (detail == null) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"No task exists with id $(task_id)");
+                }
+                if (((!)detail).task.goal_id != goal_id) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"Task $(task_id) does not belong to your goal");
+                }
+                if (((!)detail).task.status != STATUS_DRAFT) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        "Only draft tasks can be deleted (cancel handles the rest)");
+                }
+                yield tasks.delete_task(task_id);
+                yield goals.record_event(goal_id, "task-deleted",
+                    @"Task #$(task_id) deleted by the goal agent", task_id);
+                return SaddleUpdateEndpoint.json_ok(new Json.Object());
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+
+        /**
+         * `POST /api/saddle/task/enqueue` — `task enqueue <id>`: moves
+         * one of the goal's DRAFT tasks into the queue. The
+         * creator-only rule is honoured by construction: goal tasks
+         * are created by 'goal', and only a goal agent may act as
+         * 'goal'. → `{"ok": true}`.
+         */
+        public async HttpResult enqueue(HttpContext http_context, Saddle saddle) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleUpdateEndpoint.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            try {
+                int64 session_id = body.has_member("session_id")
+                    && body.get_member("session_id").get_value_type() == typeof(int64)
+                    ? body.get_int_member("session_id") : 0;
+                int64 goal_id = yield goal_scope_of(saddle, session_id);
+                int64 task_id = body.has_member("task_id")
+                    && body.get_member("task_id").get_value_type() == typeof(int64)
+                    ? body.get_int_member("task_id") : 0;
+                var detail = yield tasks.get_task(task_id);
+                if (detail == null) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"No task exists with id $(task_id)");
+                }
+                if (((!)detail).task.goal_id != goal_id) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"Task $(task_id) does not belong to your goal");
+                }
+                yield tasks.enqueue_task(task_id, CREATOR_GOAL);
+                yield goals.record_event(goal_id, "task-enqueued",
+                    @"Task #$(task_id) $(((!)detail).task.title) enqueued by the goal agent",
+                    task_id);
+                return SaddleUpdateEndpoint.json_ok(new Json.Object());
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
+    /**
+     * `POST /api/saddle/task/create` — the endpoint wrapper around
+     * {@link SaddleTaskCallbacks.create} (per-saddle-token
+     * authenticated).
+     */
+    public class SaddleTaskCreateEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
+        private GoalService goals = inject<GoalService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
+            if (saddle == null) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+            var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
+            return yield callbacks.create(http_context, (!)saddle);
+        }
+    }
+
+    /**
+     * `POST /api/saddle/task/delete` — the endpoint wrapper around
+     * {@link SaddleTaskCallbacks.delete}.
+     */
+    public class SaddleTaskDeleteEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
+        private GoalService goals = inject<GoalService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
+            if (saddle == null) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+            var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
+            return yield callbacks.delete(http_context, (!)saddle);
+        }
+    }
+
+    /**
+     * `POST /api/saddle/task/enqueue` — the endpoint wrapper around
+     * {@link SaddleTaskCallbacks.enqueue}.
+     */
+    public class SaddleTaskEnqueueEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private TaskService tasks = inject<TaskService>();
+        private GoalService goals = inject<GoalService>();
+        private CapabilityService capabilities = inject<CapabilityService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            var saddle = yield saddles.saddle_for_token(bearer_token(http_context));
+            if (saddle == null) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid saddle token");
+            }
+            var callbacks = new SaddleTaskCallbacks(saddles, tasks, goals, capabilities, db);
+            return yield callbacks.enqueue(http_context, (!)saddle);
+        }
+    }
+
     /**
     /**
      * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
      * `POST /api/saddle/next-task` (plan 10) — a saddle daemon asks for
      * work, authorised by its per-saddle bearer token. The allocation
      * work, authorised by its per-saddle bearer token. The allocation

+ 167 - 2
src/Data/Entities.vala

@@ -77,11 +77,21 @@ namespace ClankerSaddle.Data {
         public int64 auto_approve { get; set; default = 0; }
         public int64 auto_approve { get; set; default = 0; }
         public bool plan_auto_approve { get; set; default = false; }
         public bool plan_auto_approve { get; set; default = false; }
         public bool auto_enqueue_subtasks { get; set; default = false; }
         public bool auto_enqueue_subtasks { get; set; default = false; }
+        /** The goal this task belongs to (0 = standalone). */
+        public int64 goal_id { get; set; default = 0; }
+        /** "task" (a worker) or "goal_run" (the goal agent's own run). */
+        public string kind { get; set; default = "task"; }
+        /** implement / investigate / review / test (orchestrate for goal runs). */
+        public string mode { get; set; default = "implement"; }
+        /** "human" or "goal" — enqueueing is creator-only. */
+        public string creator { get; set; default = "human"; }
+        /** Human-created tasks only: auto-enqueue when this predecessor is done. */
+        public int64 enqueue_after_task_id { get; set; default = 0; }
 
 
         public static void entity_mapping(EntityMapperBuilder<TaskEntity> cfg) {
         public static void entity_mapping(EntityMapperBuilder<TaskEntity> cfg) {
             cfg.table("tasks")
             cfg.table("tasks")
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
                 .column<int64?>("id", o => o.id, (o, v) => o.id = v)
-                .column<int64?>("parent_task_id", o => o.parent_task_id, (o, v) => o.parent_task_id = v)
+                .column<int64?>("parent_task_id", o => o.parent_task_id, (o, v) => o.parent_task_id = v ?? 0)
                 .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
                 .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
                 .column<string>("title", o => o.title, (o, v) => o.title = v)
                 .column<string>("title", o => o.title, (o, v) => o.title = v)
                 .column<string>("description", o => o.description, (o, v) => o.description = v)
                 .column<string>("description", o => o.description, (o, v) => o.description = v)
@@ -98,7 +108,12 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_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>("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);
+                .column<bool>("auto_enqueue_subtasks", o => o.auto_enqueue_subtasks, (o, v) => o.auto_enqueue_subtasks = v)
+                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v ?? 0)
+                .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
+                .column<string>("mode", o => o.mode, (o, v) => o.mode = v)
+                .column<string>("creator", o => o.creator, (o, v) => o.creator = v)
+                .column<int64?>("enqueue_after_task_id", o => o.enqueue_after_task_id, (o, v) => o.enqueue_after_task_id = v ?? 0);
         }
         }
     }
     }
 
 
@@ -495,4 +510,154 @@ namespace ClankerSaddle.Data {
                 .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v);
                 .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v);
         }
         }
     }
     }
+
+    /**
+     * A user-created goal (plan 12): an agent-orchestrated unit of
+     * work over one primary project plus optional links. The brief
+     * locks once started; the lifecycle runs draft → planning → plan
+     * review → active → completion review → complete / failed /
+     * cancelled, with paused freezing supervision in between. Stored
+     * in the `goals` table.
+     */
+    public class Goal : Object {
+
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public string title { get; set; default = ""; }
+        public string brief { get; set; default = ""; }
+        public string branch_name { get; set; default = ""; }
+        public string? base_branch { get; set; default = null; }
+        public string status { get; set; default = "draft"; }
+        public int64 priority { get; set; default = 3; }
+        public string? plan { get; set; default = null; }
+        public string? completion_report { get; set; default = null; }
+        public string creator { get; set; default = "human"; }
+        public int64 last_seen_event_id { get; set; default = 0; }
+        public string? branch_push_state { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+        public DateTime? started_at { get; set; default = null; }
+        public DateTime? completed_at { get; set; default = null; }
+        public DateTime? paused_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Goal> cfg) {
+            cfg.table("goals")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("title", o => o.title, (o, v) => o.title = v)
+                .column<string>("brief", o => o.brief, (o, v) => o.brief = v)
+                .column<string>("branch_name", o => o.branch_name, (o, v) => o.branch_name = v)
+                .column<string>("base_branch", o => o.base_branch, (o, v) => o.base_branch = v)
+                .column<string>("status", o => o.status, (o, v) => o.status = v)
+                .column<int64?>("priority", o => o.priority, (o, v) => o.priority = v)
+                .column<string>("plan", o => o.plan, (o, v) => o.plan = v)
+                .column<string>("completion_report", o => o.completion_report, (o, v) => o.completion_report = v)
+                .column<string>("creator", o => o.creator, (o, v) => o.creator = v)
+                .column<int64?>("last_seen_event_id", o => o.last_seen_event_id, (o, v) => o.last_seen_event_id = v ?? 0)
+                .column<string>("branch_push_state", o => o.branch_push_state, (o, v) => o.branch_push_state = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .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?>("paused_at", o => o.paused_at, (o, v) => o.paused_at = v);
+        }
+    }
+
+    /** A project link on a goal (`goal_links`): co-developed or reference. */
+    public class GoalLink : Object {
+
+        public int64 id { get; set; }
+        public int64 goal_id { get; set; }
+        public int64 project_id { get; set; }
+        public string relationship { get; set; default = "reference"; }
+
+        public static void entity_mapping(EntityMapperBuilder<GoalLink> cfg) {
+            cfg.table("goal_links")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("relationship", o => o.relationship, (o, v) => o.relationship = v);
+        }
+    }
+
+    /** A remark on a goal, authored by a human or by the goal agent ('bot'). */
+    public class GoalComment : Object {
+
+        public int64 id { get; set; }
+        public int64 goal_id { get; set; }
+        public string author { get; set; default = "bot"; }
+        public string body { get; set; default = ""; }
+        public string? quote { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<GoalComment> cfg) {
+            cfg.table("goal_comments")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v)
+                .column<string>("author", o => o.author, (o, v) => o.author = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<string>("quote", o => o.quote, (o, v) => o.quote = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * A goal-level blocking prompt (plan 12): the goal agent's
+     * question/RFC blocks, its plan submissions/revisions (type
+     * 'plan', the approved one mirrored into {@link Goal.plan}) and
+     * the completion report awaiting review (type 'completion').
+     * Answered by a human on the goal page.
+     */
+    public class GoalBlock : Object {
+
+        public int64 id { get; set; }
+        public int64 goal_id { get; set; }
+        public string question { get; set; default = ""; }
+        public string? answer { get; set; default = null; }
+        public string block_type { get; set; default = "question"; }
+        public string? options { get; set; default = null; }
+        public string? body { get; set; default = null; }
+        public DateTime? answered_at { get; set; default = null; }
+        public string? answered_by { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<GoalBlock> cfg) {
+            cfg.table("goal_blocks")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v)
+                .column<string>("question", o => o.question, (o, v) => o.question = v)
+                .column<string>("answer", o => o.answer, (o, v) => o.answer = v)
+                .column<string>("type", o => o.block_type, (o, v) => o.block_type = v)
+                .column<string>("options", o => o.options, (o, v) => o.options = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<DateTime?>("answered_at", o => o.answered_at, (o, v) => o.answered_at = v)
+                .column<string>("answered_by", o => o.answered_by, (o, v) => o.answered_by = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * One entry of a goal's supervision narrative (`goal_events`):
+     * lifecycle moves, plan submissions/reviews, the goal agent's task
+     * operations and its tasks' terminal events. Also the resume
+     * machinery's queue — {@link Goal.last_seen_event_id} marks the
+     * events the goal agent has already consumed.
+     */
+    public class GoalEvent : Object {
+
+        public int64 id { get; set; }
+        public int64 goal_id { get; set; }
+        public string kind { get; set; default = ""; }
+        public string text { get; set; default = ""; }
+        public int64 task_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<GoalEvent> cfg) {
+            cfg.table("goal_events")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v)
+                .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
+                .column<string>("text", o => o.text, (o, v) => o.text = v)
+                .column<int64?>("task_id", o => o.task_id, (o, v) => o.task_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
 }
 }

+ 146 - 2
src/Data/Migrations/M0001_Initial.vala

@@ -6,8 +6,11 @@ namespace ClankerSaddle.Data.Migrations {
      * The full ClankerSaddle schema in one migration (plan 11: every
      * The full ClankerSaddle schema in one migration (plan 11: every
      * earlier migration flattened — the database is not deployed, so
      * earlier migration flattened — the database is not deployed, so
      * tear-down-and-recreate replaced incremental evolution). Covers:
      * tear-down-and-recreate replaced incremental evolution). Covers:
-     * projects and their links; tasks with dependencies, comments,
-     * typed blocks, progress and the draft-lifecycle columns
+     * projects and their links; the plan-12 goals (with links,
+     * comments, typed blocks and the supervision-narrative event log)
+     * and the goal-linked task columns (`goal_id`, `kind`, `mode`,
+     * `creator`, `enqueue_after_task_id`); tasks with dependencies,
+     * comments, typed blocks, progress and the draft-lifecycle columns
      * (`status` defaults to 'draft', `plan_auto_approve`,
      * (`status` defaults to 'draft', `plan_auto_approve`,
      * `auto_enqueue_subtasks`); saddle registrations with git identity
      * `auto_enqueue_subtasks`); saddle registrations with git identity
      * and the per-saddle token; the session + command + raw-log audit
      * and the per-saddle token; the session + command + raw-log audit
@@ -51,6 +54,64 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
                     .not_null();
             });
             });
 
 
+            // Plan 12 goals: user-created units of agent-orchestrated
+            // work. The brief locks once started; `branch_name` is the
+            // user-named goal branch (uniqueness-validated against the
+            // other live goals sharing involved repos); `plan` holds
+            // the APPROVED plan markdown (rejected revisions live on
+            // as goal_blocks); `last_seen_event_id` is the goal
+            // agent's consumed-event watermark (resume coalescing);
+            // `branch_push_state` caches the daemon-reported push
+            // state of the goal branches.
+            b.create_table("goals", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("title")
+                    .not_null();
+                t.column<string>("brief")
+                    .not_null();
+                t.column<string>("branch_name")
+                    .not_null();
+                t.column<string>("base_branch");
+                t.column<string>("status")
+                    .default_value<string>("draft")
+                    .indexed();
+                t.column<int64?>("priority")
+                    .default_value<int64?>(3);
+                t.column<string>("plan");
+                t.column<string>("completion_report");
+                t.column<string>("creator")
+                    .default_value<string>("human");
+                t.column<int64?>("last_seen_event_id")
+                    .default_value<int64?>(0);
+                t.column<string>("branch_push_state");
+                t.column<DateTime?>("created_at");
+                t.column<DateTime?>("started_at");
+                t.column<DateTime?>("completed_at");
+                t.column<DateTime?>("paused_at");
+            });
+
+            b.create_table("goal_links", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("goal_id")
+                    .not_null()
+                    .references("goals", "id")
+                    .indexed();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("relationship")
+                    .not_null();
+            });
+
             b.create_table("tasks", (t) => {
             b.create_table("tasks", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -61,6 +122,23 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .not_null()
                     .references("projects", "id")
                     .references("projects", "id")
                     .indexed();
                     .indexed();
+                // Plan 12: goals own their task graph — a task links to
+                // its goal (null = standalone), runs as a plain worker
+                // or a goal agent (kind), carries a work mode, records
+                // its creator (enqueue is creator-only) and —
+                // human-created tasks only — an auto-enqueue
+                // predecessor.
+                t.column<int64?>("goal_id")
+                    .references("goals", "id")
+                    .indexed();
+                t.column<string>("kind")
+                    .default_value<string>("task");
+                t.column<string>("mode")
+                    .default_value<string>("implement");
+                t.column<string>("creator")
+                    .default_value<string>("human");
+                t.column<int64?>("enqueue_after_task_id")
+                    .references("tasks", "id");
                 t.column<string>("title")
                 t.column<string>("title")
                     .not_null();
                     .not_null();
                 t.column<string>("description");
                 t.column<string>("description");
@@ -149,6 +227,67 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("created_at");
             });
             });
 
 
+            // Plan 12: goal-level conversation and review machinery,
+            // mirroring the task-side shapes (author 'bot' or
+            // 'user:<name>'; blocks carry question/rfc bodies, plan
+            // submissions/revisions and the completion report).
+            b.create_table("goal_comments", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("goal_id")
+                    .not_null()
+                    .references("goals", "id")
+                    .indexed();
+                t.column<string>("author")
+                    .not_null();
+                t.column<string>("body")
+                    .not_null();
+                t.column<string>("quote");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("goal_blocks", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("goal_id")
+                    .not_null()
+                    .references("goals", "id")
+                    .indexed();
+                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");
+            });
+
+            // The supervision narrative's event log: one row per
+            // notable goal occurrence (plan reviews, agent task
+            // operations, task terminal events, lifecycle moves) —
+            // the resume machinery's watermark (`goals.
+            // last_seen_event_id`) also lives on these ids.
+            b.create_table("goal_events", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("goal_id")
+                    .not_null()
+                    .references("goals", "id")
+                    .indexed();
+                t.column<string>("kind")
+                    .not_null();
+                t.column<string>("text")
+                    .not_null();
+                t.column<int64?>("task_id");
+                t.column<DateTime?>("created_at");
+            });
+
             b.create_table("saddles", (t) => {
             b.create_table("saddles", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -372,6 +511,9 @@ namespace ClankerSaddle.Data.Migrations {
         }
         }
 
 
         public override void down(MigrationBuilder b) {
         public override void down(MigrationBuilder b) {
+            b.drop_table("goal_events");
+            b.drop_table("goal_blocks");
+            b.drop_table("goal_comments");
             b.drop_table("task_notify_prefs");
             b.drop_table("task_notify_prefs");
             b.drop_table("push_subscriptions");
             b.drop_table("push_subscriptions");
             b.drop_table("task_capabilities");
             b.drop_table("task_capabilities");
@@ -391,6 +533,8 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_comments");
             b.drop_table("task_comments");
             b.drop_table("task_dependencies");
             b.drop_table("task_dependencies");
             b.drop_table("tasks");
             b.drop_table("tasks");
+            b.drop_table("goal_links");
+            b.drop_table("goals");
             b.drop_table("project_links");
             b.drop_table("project_links");
             b.drop_table("projects");
             b.drop_table("projects");
         }
         }

+ 23 - 1
src/Services/AllocationService.vala

@@ -74,6 +74,7 @@ namespace ClankerSaddle.Services {
         private TaskService tasks = inject<TaskService>();
         private TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
+        private GoalService goals = inject<GoalService>();
         private StatePulse pulse = inject<StatePulse>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
@@ -128,6 +129,20 @@ namespace ClankerSaddle.Services {
                 }
                 }
             }
             }
             foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
             foreach (var parent in yield tasks.list_tasks(STATUS_SUSPENDED, null)) {
+                // Plan 12: a parked goal agent resumes on its own
+                // machinery — task-terminal events under the goal,
+                // coalesced ~10 s (GoalService.resume_due); the
+                // subtask-parent resume below serves the legacy
+                // parent/child rows.
+                if (parent.kind == TASK_KIND_GOAL_RUN) {
+                    if (yield goals.resume_due(parent)) {
+                        var parked = yield parked_offerable(parent, true);
+                        if (parked != null) {
+                            rows.add((!)parked);
+                        }
+                    }
+                    continue;
+                }
                 if (yield tasks.has_terminal_subtask_since(parent)) {
                 if (yield tasks.has_terminal_subtask_since(parent)) {
                     var parked = yield parked_offerable(parent, true);
                     var parked = yield parked_offerable(parent, true);
                     if (parked != null) {
                     if (parked != null) {
@@ -341,7 +356,14 @@ namespace ClankerSaddle.Services {
             bool? branch_advanced = null;
             bool? branch_advanced = null;
             if (claim.parked) {
             if (claim.parked) {
                 branch_advanced = yield tasks.branch_advanced_since(task.id);
                 branch_advanced = yield tasks.branch_advanced_since(task.id);
-                if (claim.suspended_resume) {
+                if (claim.suspended_resume && task.kind == TASK_KIND_GOAL_RUN) {
+                    // Plan 12: the goal agent's batched report — every
+                    // plan task's status plus the completion reports
+                    // that landed while it was parked. Consumes the
+                    // pending goal events (the coalescing watermark).
+                    resume_notice = yield goals.take_resume_notice(task.goal_id);
+                    yield tasks.clear_suspension(task.id);
+                } else if (claim.suspended_resume) {
                     resume_notice = yield tasks.subtask_status_report(task.id);
                     resume_notice = yield tasks.subtask_status_report(task.id);
                     // The parking window is over: a later suspension
                     // The parking window is over: a later suspension
                     // starts a fresh event window.
                     // starts a fresh event window.

+ 798 - 0
src/Services/GoalService.vala

@@ -0,0 +1,798 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /** Valid goal statuses (the `goals.status` values). */
+    public const string GOAL_DRAFT = "draft";
+    public const string GOAL_PLANNING = "planning";
+    public const string GOAL_PLAN_REVIEW = "plan_review";
+    public const string GOAL_ACTIVE = "active";
+    public const string GOAL_PAUSED = "paused";
+    public const string GOAL_COMPLETION_REVIEW = "completion_review";
+    public const string GOAL_COMPLETE = "complete";
+    public const string GOAL_FAILED = "failed";
+    public const string GOAL_CANCELLED = "cancelled";
+
+    /** Valid {@link GoalBlock.block_type} values. */
+    public const string GOAL_BLOCK_QUESTION = "question";
+    public const string GOAL_BLOCK_RFC = "rfc";
+    public const string GOAL_BLOCK_PLAN = "plan";
+    public const string GOAL_BLOCK_COMPLETION = "completion";
+
+    /** Task kinds (the `tasks.kind` wire values). */
+    public const string TASK_KIND_TASK = "task";
+    public const string TASK_KIND_GOAL_RUN = "goal_run";
+
+    /** Task creators: enqueueing is creator-only. */
+    public const string CREATOR_HUMAN = "human";
+    public const string CREATOR_GOAL = "goal";
+
+    /**
+     * The goal-run internal task's mode: the goal agent orchestrates
+     * (prompt-level mode shaping is the daemon's job).
+     */
+    public const string MODE_ORCHESTRATE = "orchestrate";
+
+    /** Valid work modes for human/goal-created tasks. */
+    public const string[] TASK_MODES = { "implement", "investigate", "review", "test" };
+
+    /** The priority every goal-run internal task dispatches at. */
+    public const int64 GOAL_RUN_PRIORITY = 3;
+
+    /**
+     * Goal storage and lifecycle (plan 12): creation with branch-name
+     * uniqueness validation, the draft → planning → plan review →
+     * active → completion review → complete/failed/cancelled flow
+     * (with paused freezing supervision in between), the goal agent's
+     * run task, plan/completion review flows with revisions, goal
+     * comments and blocks, the cancel cascade and the event-driven
+     * resume machinery (task-terminal events coalesce ~10 s into one
+     * goal-agent resume carrying the batched status report).
+     */
+    public class GoalService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private StatePulse pulse = inject<StatePulse>();
+        private PushService push = inject<PushService>();
+        private TaskService tasks = inject<TaskService>();
+
+        /**
+         * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
+         * a parked goal agent resumes once the OLDEST unconsumed task
+         * event is at least this old, so bursts collapse into one
+         * resume.
+         */
+        public static uint goal_resume_coalesce_seconds() {
+            var raw = Environment.get_variable("SADDLE_GOAL_RESUME_S") ?? "";
+            int64 parsed = int64.parse(raw);
+            return parsed > 0 ? (uint) parsed : 10;
+        }
+
+        // ------------------------------------------------------------------
+        // Creation and links
+        // ------------------------------------------------------------------
+
+        /**
+         * Creates a goal in draft. The branch name is required and
+         * uniqueness-validated: no other live (non-cancelled,
+         * non-complete, non-failed) goal may use the same branch name
+         * while sharing any involved project (primary or link) — one
+         * branch of that name per involved repo. `base_branch` defaults
+         * to the project's default branch.
+         */
+        public async Goal create_goal(int64 project_id, string title, string brief,
+                string branch_name, string? base_branch, int64 priority,
+                string creator = CREATOR_HUMAN) throws Error {
+            var project = yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(project_id)))
+                .first_async();
+            if (project == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 60,
+                    @"No project exists with id $(project_id)");
+            }
+            string trimmed_branch = branch_name.strip();
+            if (title.strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 61,
+                    "A goal title is required");
+            }
+            if (brief.strip().length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 62,
+                    "An initial brief is required");
+            }
+            if (trimmed_branch.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 63,
+                    "A goal branch name is required");
+            }
+
+            foreach (var other in yield db.query<Goal>().materialise_async()) {
+                if (other.branch_name != trimmed_branch
+                        || other.status == GOAL_CANCELLED
+                        || other.status == GOAL_COMPLETE
+                        || other.status == GOAL_FAILED) {
+                    continue;
+                }
+                if ((yield involved_project_ids(other)).contains(project_id.to_string())) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 64,
+                        @"Branch name '$(trimmed_branch)' is already used by a live goal of an involved project (#$(other.id) $(other.title)) — pick another name");
+                }
+            }
+
+            var goal = new Goal() {
+                project_id = project_id,
+                title = title.strip(),
+                brief = brief,
+                branch_name = trimmed_branch,
+                base_branch = base_branch != null && base_branch.strip().length > 0
+                    ? base_branch.strip() : ((!)project).default_branch,
+                priority = priority.clamp(1, 9),
+                creator = creator,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Goal>(goal);
+            yield record_event(goal.id, "created", @"Goal created (branch $(trimmed_branch))");
+            yield pulse.bump(0, "goal created");
+            return goal;
+        }
+
+        /** Loads the goal identified by `id`, or null. */
+        public async Goal? get_goal(int64 id) throws Error {
+            return yield db.query<Goal>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Every goal, newest first. */
+        public async ImmutableLot<Goal> list_goals() throws Error {
+            return yield db.query<Goal>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * The primary + linked project ids of `goal` (stringified —
+         * the Invercargill collections cannot hash value types).
+         */
+        public async Invercargill.DataStructures.HashSet<string> involved_project_ids(Goal goal) throws Error {
+            var ids = new Invercargill.DataStructures.HashSet<string>();
+            ids.add(goal.project_id.to_string());
+            foreach (var link in yield list_links(goal.id)) {
+                ids.add(link.project_id.to_string());
+            }
+            return ids;
+        }
+
+        /** Adds a project link to the goal; duplicates are refused. */
+        public async void add_link(int64 goal_id, int64 project_id,
+                string relationship) throws Error {
+            if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 65,
+                    @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
+            }
+            var goal = yield get_goal(goal_id);
+            if (goal == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 66,
+                    @"No goal exists with id $(goal_id)");
+            }
+            if (project_id == ((!)goal).project_id) {
+                throw new Error(Quark.from_string("clanker-saddle"), 67,
+                    "The primary project is implicitly linked");
+            }
+            var existing = yield db.query<GoalLink>()
+                .where(expr("goal_id == $0 && project_id == $1",
+                    elem<int64?>(goal_id), elem<int64?>(project_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 68,
+                    "That project is already linked to the goal");
+            }
+            yield db.insert_async<GoalLink>(new GoalLink() {
+                goal_id = goal_id,
+                project_id = project_id,
+                relationship = relationship
+            });
+            yield pulse.bump(0, "goal link added");
+        }
+
+        /** Removes the goal link identified by `id`. */
+        public async void remove_link(int64 link_id) throws Error {
+            var link = yield db.query<GoalLink>()
+                .where(expr("id == $0", elem<int64?>(link_id)))
+                .first_async();
+            if (link != null) {
+                yield db.delete_async<GoalLink>((!)link);
+                yield pulse.bump(0, "goal link removed");
+            }
+        }
+
+        /** The goal's links, oldest first. */
+        public async ImmutableLot<GoalLink> list_links(int64 goal_id) throws Error {
+            return yield db.query<GoalLink>()
+                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        // ------------------------------------------------------------------
+        // Lifecycle
+        // ------------------------------------------------------------------
+
+        /**
+         * The goal's goal-run internal task (kind 'goal_run'), or null
+         * before the goal starts.
+         */
+        public async TaskEntity? goal_run_task(int64 goal_id) throws Error {
+            var rows = yield db.query<TaskEntity>()
+                .where(expr("goal_id == $0 && kind == $1",
+                    elem<int64?>(goal_id), elem(TASK_KIND_GOAL_RUN)))
+                .order_by(expr("id"))
+                .materialise_async();
+            TaskEntity? first = null;
+            foreach (var row in rows) {
+                first = row;
+                break;
+            }
+            return first;
+        }
+
+        /**
+         * Starts a draft goal: the brief locks, the goal moves to
+         * 'planning' and the goal agent's internal run task is created
+         * (kind 'goal_run', mode 'orchestrate', priority 3, creator
+         * 'goal') and queued — the dispatcher hands it to a saddle and
+         * the agent begins investigating.
+         */
+        public async Goal start_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_DRAFT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 69,
+                    "Only draft goals can be started");
+            }
+            ((!)goal).status = GOAL_PLANNING;
+            ((!)goal).started_at = new DateTime.now_local();
+            yield db.update_async<Goal>((!)goal);
+
+            var run = yield tasks.create_task(((!)goal).project_id,
+                @"Goal agent — $(((!)goal).title)",
+                ((!)goal).brief, GOAL_RUN_PRIORITY, null,
+                null, ((!)goal).base_branch,
+                false, false, false, STATUS_QUEUED,
+                TASK_KIND_GOAL_RUN, MODE_ORCHESTRATE, CREATOR_GOAL,
+                ((!)goal).id, 0);
+            yield record_event(goal_id, "started",
+                @"Goal started — the goal agent is planning (run task #$(run.id))", run.id);
+            yield pulse.bump(run.id, "goal started");
+            return goal;
+        }
+
+        /**
+         * Pauses an active goal: supervision freezes — no goal-agent
+         * resumes are scheduled while paused; task events keep
+         * queueing and coalesce into the flush on resume.
+         */
+        public async Goal pause_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_ACTIVE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 70,
+                    "Only active goals can be paused");
+            }
+            ((!)goal).status = GOAL_PAUSED;
+            ((!)goal).paused_at = new DateTime.now_local();
+            yield db.update_async<Goal>((!)goal);
+            yield record_event(goal_id, "paused", "Goal paused — supervision frozen (events queue)");
+            yield pulse.bump(0, "goal paused");
+            return goal;
+        }
+
+        /**
+         * Resumes a paused goal: supervision restarts and any events
+         * that queued while paused coalesce into the next resume (the
+         * oldest pending event usually already satisfies the window).
+         */
+        public async Goal resume_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_PAUSED) {
+                throw new Error(Quark.from_string("clanker-saddle"), 71,
+                    "Only paused goals can be resumed");
+            }
+            ((!)goal).status = GOAL_ACTIVE;
+            ((!)goal).paused_at = null;
+            yield db.update_async<Goal>((!)goal);
+            yield record_event(goal_id, "resumed", "Goal resumed — queued events coalesce into the next agent wake");
+            yield pulse.bump(0, "goal resumed");
+            return goal;
+        }
+
+        /**
+         * Cancels a goal: the cascade closes every one of its tasks —
+         * non-terminal ones are cancelled (the running goal agent
+         * included), draft ones deleted (they never ran). Terminal
+         * goals refuse the action. Live daemons are NOT aborted here —
+         * the cancel action (which can reach SaddleService without a
+         * dependency cycle) aborts them first.
+         */
+        public async void cancel_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            string status = ((!)goal).status;
+            if (status == GOAL_COMPLETE || status == GOAL_FAILED
+                    || status == GOAL_CANCELLED) {
+                throw new Error(Quark.from_string("clanker-saddle"), 72,
+                    "The goal is already finished");
+            }
+            ((!)goal).status = GOAL_CANCELLED;
+            ((!)goal).completed_at = new DateTime.now_local();
+            yield db.update_async<Goal>((!)goal);
+
+            foreach (var task in yield db.query<TaskEntity>()
+                    .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
+                    continue;
+                }
+                if (task.status == STATUS_DRAFT) {
+                    yield tasks.delete_task_quietly(task.id);
+                } else {
+                    yield tasks.cancel_task(task.id);
+                }
+            }
+            yield record_event(goal_id, "cancelled", "Goal cancelled — its tasks were cancelled with it");
+            yield pulse.bump(0, "goal cancelled");
+        }
+
+        // ------------------------------------------------------------------
+        // The goal agent's pushes: plan, completion, failure
+        // ------------------------------------------------------------------
+
+        /**
+         * The goal agent submitted (or revised) its plan: a new
+         * goal_blocks row of type 'plan' carrying the markdown, the
+         * goal moves to 'plan_review', and the goal-run task parks on
+         * a mirrored task block so the human verdict resumes it. The
+         * caller has already parked the task via the results state.
+         */
+        public async GoalBlock submit_plan(int64 goal_id, string markdown) throws Error {
+            var goal = yield require_goal(goal_id);
+            // Idempotent on the pending submission: a redelivered push
+            // (outbox retry, the entry + state pair of one `saddle
+            // plan`) must not stack duplicate revisions — a NEW row
+            // only appears once the previous one was answered
+            // (rejected → the agent revises).
+            foreach (var existing in yield list_blocks(goal_id)) {
+                if (existing.block_type == GOAL_BLOCK_PLAN && existing.answer == null) {
+                    return existing;
+                }
+            }
+            var block = new GoalBlock() {
+                goal_id = goal_id,
+                question = "Plan submitted for review",
+                block_type = GOAL_BLOCK_PLAN,
+                body = markdown,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<GoalBlock>(block);
+            ((!)goal).status = GOAL_PLAN_REVIEW;
+            yield db.update_async<Goal>((!)goal);
+            yield record_event(goal_id, "plan-submitted",
+                @"Plan revision #$(block.id) submitted for review");
+            yield mirror_block_on_run_task(((!)goal), block);
+            yield pulse.bump(0, "goal plan submitted");
+            push.notify_global(PushService.goal_block_notification(
+                ((!)goal).title, goal_id, GOAL_BLOCK_PLAN, block.question));
+            return block;
+        }
+
+        /**
+         * Records a goal-agent question/RFC block and parks the
+         * goal-run task on its mirrored task block.
+         */
+        public async GoalBlock raise_agent_block(int64 goal_id, string question,
+                string block_type, string[]? options = null, string? body = null) throws Error {
+            var goal = yield require_goal(goal_id);
+            string options_json = null;
+            if (options != null && options.length > 0) {
+                var array = new Json.Array();
+                foreach (var option in options) {
+                    array.add_string_element(option);
+                }
+                options_json = Json.to_string(
+                    new Json.Node(Json.NodeType.ARRAY).init_array(array), false);
+            }
+            var block = new GoalBlock() {
+                goal_id = goal_id,
+                question = question,
+                block_type = block_type.length > 0 ? block_type : GOAL_BLOCK_QUESTION,
+                options = options_json,
+                body = body != null && body.length > 0 ? body : null,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<GoalBlock>(block);
+            yield record_event(goal_id, "block-raised",
+                @"$(block.block_type) raised: $(question)");
+            yield mirror_block_on_run_task(((!)goal), block);
+            yield pulse.bump(0, "goal block raised");
+            push.notify_global(PushService.goal_block_notification(
+                ((!)goal).title, goal_id, block.block_type, question));
+            return block;
+        }
+
+        /**
+         * The goal agent submitted its completion report (`saddle
+         * done` in goal context): the goal moves to
+         * 'completion_review', the report is stored (goal row +
+         * goal_blocks type 'completion') and the goal-run task parks
+         * on the mirrored block. The daemon enforces the
+         * branch-push rule before letting `done` through.
+         */
+        public async GoalBlock submit_completion(int64 goal_id, string report) throws Error {
+            var goal = yield require_goal(goal_id);
+            // Idempotent on the pending report (see submit_plan).
+            foreach (var existing in yield list_blocks(goal_id)) {
+                if (existing.block_type == GOAL_BLOCK_COMPLETION
+                        && existing.answer == null) {
+                    return existing;
+                }
+            }
+            ((!)goal).status = GOAL_COMPLETION_REVIEW;
+            ((!)goal).completion_report = report;
+            yield db.update_async<Goal>((!)goal);
+            var block = new GoalBlock() {
+                goal_id = goal_id,
+                question = "Goal completion report",
+                block_type = GOAL_BLOCK_COMPLETION,
+                body = report,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<GoalBlock>(block);
+            yield record_event(goal_id, "completion-submitted",
+                "Completion report submitted for review");
+            yield mirror_block_on_run_task(((!)goal), block);
+            yield pulse.bump(0, "goal completion submitted");
+            push.notify_global(PushService.goal_block_notification(
+                ((!)goal).title, goal_id, GOAL_BLOCK_COMPLETION, block.question));
+            return block;
+        }
+
+        /**
+         * The goal agent failed the goal (`saddle failure` in goal
+         * context): status 'failed', the report stored, the goal-run
+         * task failed (its session closes and saddle frees) and every
+         * user notified.
+         */
+        public async void fail_goal(int64 goal_id, string? report) throws Error {
+            var goal = yield require_goal(goal_id);
+            ((!)goal).status = GOAL_FAILED;
+            ((!)goal).completed_at = new DateTime.now_local();
+            ((!)goal).completion_report = report ?? ((!)goal).completion_report;
+            yield db.update_async<Goal>((!)goal);
+
+            var run = yield goal_run_task(goal_id);
+            if (run != null && ((!)run).status != STATUS_DONE
+                    && ((!)run).status != STATUS_FAILED) {
+                yield tasks.fail_task(((!)run).id,
+                    report != null && report.length > 0 ? report : "The goal agent failed the goal");
+            }
+            yield record_event(goal_id, "goal-failed",
+                report != null && report.length > 0 ? report : "The goal agent failed the goal");
+            push.notify_global(PushService.goal_terminal_notification(
+                ((!)goal).title, goal_id, false));
+            yield pulse.bump(0, "goal failed");
+        }
+
+        /**
+         * Answers a goal block (the goal page's single verdict path):
+         * the answer lands on the goal row AND on the goal-run task's
+         * mirrored task block — the parked-resume machinery then wakes
+         * the agent with the verdict as its resume_notice. Plan and
+         * completion blocks apply their goal-state transitions:
+         *
+         * - plan + {@link NOTICE_PLAN_APPROVED} → the submission
+         *   becomes goal.plan, status 'active';
+         * - plan + feedback → status 'planning' (the agent revises);
+         * - completion + {@link NOTICE_REVIEW_APPROVED} → the goal
+         *   completes and the goal-run task closes done;
+         * - completion + feedback → status 'active' (the agent keeps
+         *   working).
+         */
+        public async void answer_goal_block(int64 block_id, string answer,
+                string? answered_by = null) throws Error {
+            var block = yield db.query<GoalBlock>()
+                .where(expr("id == $0", elem<int64?>(block_id)))
+                .first_async();
+            if (block == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 73,
+                    @"No goal block exists with id $(block_id)");
+            }
+            if (((!)block).answer != null) {
+                return;
+            }
+            var goal = yield get_goal(((!)block).goal_id);
+            if (goal == null) {
+                return;
+            }
+
+            ((!)block).answer = answer;
+            ((!)block).answered_at = new DateTime.now_local();
+            ((!)block).answered_by = answered_by;
+            yield db.update_async<GoalBlock>((!)block);
+
+            if (((!)block).block_type == GOAL_BLOCK_PLAN) {
+                if (answer == NOTICE_PLAN_APPROVED) {
+                    if (((!)block).body != null && ((!)block).body.length > 0) {
+                        ((!)goal).plan = ((!)block).body;
+                    }
+                    ((!)goal).status = GOAL_ACTIVE;
+                    yield record_event(((!)goal).id, "plan-approved",
+                        @"Plan revision #$(((!)block).id) approved — the goal is active");
+                } else {
+                    ((!)goal).status = GOAL_PLANNING;
+                    yield record_event(((!)goal).id, "plan-rejected",
+                        @"Plan revision #$(((!)block).id) rejected with feedback — the agent revises");
+                }
+            } else if (((!)block).block_type == GOAL_BLOCK_COMPLETION) {
+                if (answer == NOTICE_REVIEW_APPROVED) {
+                    ((!)goal).status = GOAL_COMPLETE;
+                    ((!)goal).completed_at = new DateTime.now_local();
+                    yield record_event(((!)goal).id, "goal-approved",
+                        "Completion report approved — the goal is complete");
+                } else {
+                    ((!)goal).status = GOAL_ACTIVE;
+                    yield record_event(((!)goal).id, "completion-rejected",
+                        "Completion report rejected with feedback — the agent continues");
+                }
+            }
+            yield db.update_async<Goal>((!)goal);
+
+            // The mirrored task block carries the same verdict to the
+            // parked goal-run workspace (resume_notice material).
+            var run = yield goal_run_task(((!)goal).id);
+            if (run != null) {
+                foreach (var task_block in yield tasks.list_blocks(((!)run).id)) {
+                    if (task_block.question == ((!)block).question
+                            && task_block.answer == null) {
+                        yield tasks.answer_block(task_block.id, answer, answered_by);
+                        break;
+                    }
+                }
+                if (((!)block).block_type == GOAL_BLOCK_COMPLETION
+                        && answer == NOTICE_REVIEW_APPROVED) {
+                    // Approving the completion closes the goal agent's
+                    // own task: the run is finished, its saddle frees.
+                    yield tasks.complete_task(((!)run).id,
+                        ((!)goal).completion_report, null, "done");
+                }
+            }
+            if (((!)block).block_type == GOAL_BLOCK_COMPLETION
+                    && answer == NOTICE_REVIEW_APPROVED) {
+                push.notify_global(PushService.goal_terminal_notification(
+                    ((!)goal).title, ((!)goal).id, true));
+            }
+            yield pulse.bump(0, "goal block answered");
+        }
+
+        /**
+         * Raises the mirror of a goal block on the goal-run task: the
+         * parked-resume machinery (answered blocks ride the resuming
+         * 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.
+         */
+        private async void mirror_block_on_run_task(Goal goal, GoalBlock block) throws Error {
+            var run = yield goal_run_task(goal.id);
+            if (run == null || ((!)run).status == STATUS_DONE
+                    || ((!)run).status == STATUS_FAILED) {
+                return;
+            }
+            foreach (var existing in yield tasks.list_blocks(((!)run).id)) {
+                if (existing.question == block.question && existing.answer == null) {
+                    return;
+                }
+            }
+            yield tasks.add_block(((!)run).id, block.question, block.block_type,
+                TaskService.parse_block_options(block.options), block.body);
+        }
+
+        // ------------------------------------------------------------------
+        // Comments and events
+        // ------------------------------------------------------------------
+
+        /** Appends a goal comment; `author` is 'bot' or 'user:<name>'. */
+        public async GoalComment add_comment(int64 goal_id, string author, string body,
+                string? quote = null) throws Error {
+            var comment = new GoalComment() {
+                goal_id = goal_id,
+                author = author,
+                body = body,
+                quote = quote != null && quote.strip().length > 0 ? quote.strip() : null,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<GoalComment>(comment);
+            yield pulse.bump(0, "goal comment");
+            return comment;
+        }
+
+        /** A goal's comments, oldest first. */
+        public async ImmutableLot<GoalComment> list_comments(int64 goal_id) throws Error {
+            return yield db.query<GoalComment>()
+                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** A goal's blocks, oldest first. */
+        public async ImmutableLot<GoalBlock> list_blocks(int64 goal_id) throws Error {
+            return yield db.query<GoalBlock>()
+                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** A goal's narrative events, oldest first. */
+        public async ImmutableLot<GoalEvent> list_events(int64 goal_id) throws Error {
+            return yield db.query<GoalEvent>()
+                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Records one supervision-narrative event. Called by the
+         * lifecycle flows, the goal agent's task operations and the
+         * task-terminal hook — every goal_events row is also a
+         * potential resume signal (see {@link resume_due}).
+         */
+        public async GoalEvent record_event(int64 goal_id, string kind, string text,
+                int64 task_id = 0, int64 ref_task_id = 0) throws Error {
+            var event = new GoalEvent() {
+                goal_id = goal_id,
+                kind = kind,
+                text = text,
+                task_id = task_id,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<GoalEvent>(event);
+            return event;
+        }
+
+        // ------------------------------------------------------------------
+        // Event-driven resume (the parked goal agent)
+        // ------------------------------------------------------------------
+
+        /**
+         * True when the goal agent should be resumed: its run task is
+         * parked (suspended), the goal is active (paused goals freeze
+         * supervision; review states park on blocks instead), and at
+         * least one task event has waited out the coalescing window
+         * (the OLDEST unconsumed event is old enough — bursts collapse
+         * into one resume).
+         */
+        public async bool resume_due(TaskEntity run_task) throws Error {
+            if (run_task.kind != TASK_KIND_GOAL_RUN || run_task.status != STATUS_SUSPENDED) {
+                return false;
+            }
+            var goal = yield get_goal(run_task.goal_id);
+            if (goal == null || ((!)goal).status != GOAL_ACTIVE) {
+                return false;
+            }
+            DateTime? oldest = null;
+            foreach (var event in yield db.query<GoalEvent>()
+                    .where(expr("goal_id == $0 && id > $1",
+                        elem<int64?>(run_task.goal_id),
+                        elem<int64?>(((!)goal).last_seen_event_id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (event.created_at != null) {
+                    oldest = ((!)event).created_at;
+                    break;
+                }
+            }
+            if (oldest == null) {
+                return false;
+            }
+            double age = new DateTime.now_local().difference((!)oldest) / 1000000.0;
+            return age >= goal_resume_coalesce_seconds();
+        }
+
+        /**
+         * The batched markdown report riding a goal-agent resume (the
+         * assignment's `resume_notice`): the current status of ALL of
+         * the goal's tasks plus the full completion reports of the
+         * tasks that finished since the agent parked. Consumes the
+         * pending events (the watermark advances), so the window
+         * starts fresh for the next park.
+         */
+        public async string take_resume_notice(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            DateTime? since = null;
+            var run = yield goal_run_task(goal_id);
+            if (run != null) {
+                since = ((!)run).suspended_at;
+            }
+
+            var report = new StringBuilder();
+            report.append("Goal update — resuming your parked session:\n\n");
+            report.append("## Task statuses\n");
+            int64 newest_event = ((!)goal).last_seen_event_id;
+            var finished = new Vector<TaskEntity>();
+            foreach (var task in yield db.query<TaskEntity>()
+                    .where(expr("goal_id == $0 && kind == $1",
+                        elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (task.status == STATUS_DONE) {
+                    report.append_printf("- #%s %s — done: %s\n", task.id.to_string(), task.title,
+                        task.summary ?? "no summary reported");
+                    if (since == null || task.completed_at == null
+                            || ((!)task.completed_at).compare((!)since) > 0) {
+                        finished.add(task);
+                    }
+                } else if (task.status == STATUS_FAILED) {
+                    report.append_printf("- #%s %s — failed: %s\n", task.id.to_string(), task.title,
+                        task.summary ?? "no summary reported");
+                } else {
+                    report.append_printf("- #%s %s — %s\n", task.id.to_string(), task.title, task.status);
+                }
+            }
+            if (finished.length > 0) {
+                report.append("\n## New completion reports\n");
+                foreach (var task in finished) {
+                    report.append_printf("\n### #%s %s\n%s\n", task.id.to_string(), task.title,
+                        task.summary ?? "no summary reported");
+                }
+            }
+            report.append("\nSupervise your tasks: merge completed work into the goal branch,")
+                .append(" prune worktrees, create or enqueue follow-ups as needed, then")
+                .append(" `saddle suspend` again when you are parked.\n");
+
+            foreach (var event in yield db.query<GoalEvent>()
+                    .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                    .order_by_desc(expr("id"))
+                    .limit(1)
+                    .materialise_async()) {
+                newest_event = event.id;
+            }
+            if (newest_event != ((!)goal).last_seen_event_id) {
+                ((!)goal).last_seen_event_id = newest_event;
+                yield db.update_async<Goal>((!)goal);
+            }
+            return report.str;
+        }
+
+        // ------------------------------------------------------------------
+        // Branch push state (daemon /status exposure)
+        // ------------------------------------------------------------------
+
+        /**
+         * Stores a daemon-reported goal-branch push state on the goal
+         * (raw JSON as the daemon sent it — the web only renders it).
+         * Called from the /status observation path when the probed
+         * saddle is running a goal agent.
+         */
+        public async void store_branch_push_state(int64 goal_id, string state_json) throws Error {
+            var goal = yield get_goal(goal_id);
+            if (goal == null || ((!)goal).branch_push_state == state_json) {
+                return;
+            }
+            ((!)goal).branch_push_state = state_json;
+            yield db.update_async<Goal>((!)goal);
+        }
+
+        // ------------------------------------------------------------------
+        // Helpers
+        // ------------------------------------------------------------------
+
+        private async Goal require_goal(int64 goal_id) throws Error {
+            var goal = yield get_goal(goal_id);
+            if (goal == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 74,
+                    @"No goal exists with id $(goal_id)");
+            }
+            return (!)goal;
+        }
+    }
+}

+ 38 - 4
src/Services/PushService.vala

@@ -268,10 +268,10 @@ namespace ClankerSaddle.Services {
         }
         }
 
 
         /**
         /**
-         * The waiting-on-drafts payload (global audience, fired like a
-         * block event): the parent is parked and cannot finish until
-         * the human enqueues its draft subtasks.
-         */
+          * The waiting-on-drafts payload (global audience, fired like a
+          * block event): the parent is parked and cannot finish until
+          * the human enqueues its draft subtasks.
+          */
         public static PushNotification waiting_drafts_notification(string task_title,
         public static PushNotification waiting_drafts_notification(string task_title,
                 int64 task_id, int64 draft_count) {
                 int64 task_id, int64 draft_count) {
             return new PushNotification() {
             return new PushNotification() {
@@ -282,6 +282,40 @@ namespace ClankerSaddle.Services {
             };
             };
         }
         }
 
 
+        /**
+         * The goal block-event payload (plan 12, global audience —
+         * goal blocks always notify): title = goal title + kind, body
+         * = the question, click-through = the goal page.
+         */
+        public static PushNotification goal_block_notification(string goal_title,
+                int64 goal_id, string block_type, string question) {
+            string label = block_kind_label(block_type);
+            if (block_type == GOAL_BLOCK_COMPLETION) {
+                label = "completion review";
+            }
+            return new PushNotification() {
+                title = @"$(goal_title) — $(label)",
+                body = question,
+                url = @"/goals/$(goal_id)"
+            };
+        }
+
+        /**
+         * The goal terminal payload (plan 12, global audience — goal
+         * success/failure always notify): `succeeded` picks the
+         * wording; the report body is passed by the caller.
+         */
+        public static PushNotification goal_terminal_notification(string goal_title,
+                int64 goal_id, bool succeeded) {
+            return new PushNotification() {
+                title = @"$(goal_title) — $(succeeded ? "complete" : "failed")",
+                body = succeeded
+                    ? @"Goal $(goal_id) reached its completion review approval"
+                    : @"Goal $(goal_id) was failed by its agent",
+                url = @"/goals/$(goal_id)"
+            };
+        }
+
         // ------------------------------------------------------------------
         // ------------------------------------------------------------------
         // Enqueue + delivery
         // Enqueue + delivery
         // ------------------------------------------------------------------
         // ------------------------------------------------------------------

+ 33 - 6
src/Services/RuleService.vala

@@ -221,6 +221,17 @@ namespace ClankerSaddle.Services {
          */
          */
         public async ImmutableLot<Rule> effective_rules(TaskEntity task,
         public async ImmutableLot<Rule> effective_rules(TaskEntity task,
                 ProjectService projects) throws Error {
                 ProjectService projects) throws Error {
+            return yield effective_rules_for(task, projects, 0);
+        }
+
+        /**
+         * The goal-aware variant (plan 12): with `goal_id` > 0 the
+         * co-developed expansion walks the GOAL's links instead of the
+         * project's — goal work (the agent's run and its worker
+         * tasks) scopes its involved repos through the goal.
+         */
+        public async ImmutableLot<Rule> effective_rules_for(TaskEntity task,
+                ProjectService projects, int64 goal_id) throws Error {
             var ordered = new Vector<Rule>();
             var ordered = new Vector<Rule>();
             // Ids are stringified: the Invercargill collections cannot
             // Ids are stringified: the Invercargill collections cannot
             // key (or hash) on value types.
             // key (or hash) on value types.
@@ -236,13 +247,29 @@ namespace ClankerSaddle.Services {
                     ordered.add(rule);
                     ordered.add(rule);
                 }
                 }
             }
             }
-            foreach (var link in yield projects.list_links(task.project_id)) {
-                if (link.relationship != LINK_CO_DEVELOPED) {
-                    continue;
+            if (goal_id > 0) {
+                foreach (var link in yield db.query<GoalLink>()
+                        .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                        .order_by(expr("id"))
+                        .materialise_async()) {
+                    if (link.relationship != LINK_CO_DEVELOPED) {
+                        continue;
+                    }
+                    foreach (var rule in yield list_rules_for_project(link.project_id)) {
+                        if (seen.add(rule.id.to_string())) {
+                            ordered.add(rule);
+                        }
+                    }
                 }
                 }
-                foreach (var rule in yield list_rules_for_project(link.linked_project_id)) {
-                    if (seen.add(rule.id.to_string())) {
-                        ordered.add(rule);
+            } else {
+                foreach (var link in yield projects.list_links(task.project_id)) {
+                    if (link.relationship != LINK_CO_DEVELOPED) {
+                        continue;
+                    }
+                    foreach (var rule in yield list_rules_for_project(link.linked_project_id)) {
+                        if (seen.add(rule.id.to_string())) {
+                            ordered.add(rule);
+                        }
                     }
                     }
                 }
                 }
             }
             }

+ 235 - 107
src/Services/SaddleService.vala

@@ -100,6 +100,7 @@ namespace ClankerSaddle.Services {
         private RuleService rules = inject<RuleService>();
         private RuleService rules = inject<RuleService>();
         private CapabilityService capability_service = inject<CapabilityService>();
         private CapabilityService capability_service = inject<CapabilityService>();
         private TaskService task_service = inject<TaskService>();
         private TaskService task_service = inject<TaskService>();
+        private GoalService goal_service = inject<GoalService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
 
@@ -415,6 +416,34 @@ namespace ClankerSaddle.Services {
             poll_state.record_probe(saddle.id, state, uptime, activity);
             poll_state.record_probe(saddle.id, state, uptime, activity);
             yield store_reported_ssh_key(saddle, status);
             yield store_reported_ssh_key(saddle, status);
 
 
+            // Plan 12: a daemon running (or parking — the workspace
+            // stays while the saddle frees) a goal agent exposes the
+            // push state of the goal branches on /status; cache it on
+            // the goal row for the completion-review UI.
+            if (status.has_member("goal_branches")) {
+                var goal_branches = status.get_member("goal_branches");
+                if (goal_branches.get_node_type() == Json.NodeType.ARRAY
+                        || goal_branches.get_node_type() == Json.NodeType.OBJECT) {
+                    var generator = new Json.Generator();
+                    generator.set_root(goal_branches);
+                    string raw = generator.to_data(null);
+                    int64 push_goal_id = 0;
+                    if (saddle.current_task_id > 0) {
+                        push_goal_id = yield goal_id_of_run(saddle.current_task_id);
+                    } else {
+                        // Parked: the open session still names the
+                        // goal agent's workspace.
+                        var open_session = yield open_session_on_saddle(saddle.id);
+                        if (open_session != null) {
+                            push_goal_id = yield goal_id_of_run(((!)open_session).task_id);
+                        }
+                    }
+                    if (push_goal_id > 0) {
+                        yield goal_service.store_branch_push_state(push_goal_id, raw);
+                    }
+                }
+            }
+
             // The "interrupted -> resumed" timeline arc. Absent on older
             // The "interrupted -> resumed" timeline arc. Absent on older
             // daemons; marked once per session in SaddlePollState.
             // daemons; marked once per session in SaddlePollState.
             if (status.has_member("resumed")
             if (status.has_member("resumed")
@@ -667,10 +696,11 @@ namespace ClankerSaddle.Services {
 
 
         /**
         /**
          * Assembles the assignment payload shared by the /start push and
          * Assembles the assignment payload shared by the /start push and
-         * the `POST /api/saddle/next-task` response — `{task, rules,
-         * subtasks, project_notes, callback, llm_config, session_id}` —
-         * with the project, co-developed/reference links, branch fields,
-         * plan and recent comments inside the task object, plus any
+         * the `POST /api/saddle/next-task` response — `{task, kind,
+         * mode, creator, goal?, rules, project_notes, callback,
+         * llm_config, session_id}` — with the project, links (the
+         * goal's own links for goal work, else the project's), branch
+         * fields and recent comments inside the task object, plus any
          * block answers not yet handed over (a resumed workspace picks
          * block answers not yet handed over (a resumed workspace picks
          * its pending answers up from the assignment itself), the
          * its pending answers up from the assignment itself), the
          * effective rule set ({@link RuleService.effective_rules}) as
          * effective rule set ({@link RuleService.effective_rules}) as
@@ -680,11 +710,12 @@ namespace ClankerSaddle.Services {
          * saddle's `git_identity`, a `capabilities` context (plan 10
          * saddle's `git_identity`, a `capabilities` context (plan 10
          * phase 3: `saddle_tags` — the allocated saddle's own tags —
          * phase 3: `saddle_tags` — the allocated saddle's own tags —
          * and `fleet_tags` — every tag name known across the fleet),
          * and `fleet_tags` — every tag name known across the fleet),
-         * and — for resumed workspaces — the
-         * `resume_notice`/`branch_advanced` context. `session_id` (the
-         * web-side run row the daemon reports against) rides along so
-         * pushed telemetry can key on it. Project checkouts are
-         * addressed by the `SADDLE_PROJECTS_ROOT` convention
+         * the plan-12 goal context (`goal` for goal work; the
+         * worker's `ephemeral_branch`) and — for resumed workspaces —
+         * the `resume_notice`/`branch_advanced` context. `session_id`
+         * (the web-side run row the daemon reports against) rides
+         * along so pushed telemetry can key on it. Project checkouts
+         * are addressed by the `SADDLE_PROJECTS_ROOT` convention
          * (`root/task-<task id>/name`) and cloned from each project's
          * (`root/task-<task id>/name`) and cloned from each project's
          * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
          * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
          * environment.
          * environment.
@@ -706,6 +737,47 @@ namespace ClankerSaddle.Services {
                     @"Task $(task.id) has no project $(task.project_id)");
                     @"Task $(task.id) has no project $(task.project_id)");
             }
             }
 
 
+            // Plan 12: goal work (the agent's own run and its worker
+            // tasks) scopes its co-developed/reference set through the
+            // GOAL's links; standalone tasks through the project's.
+            Goal? goal = null;
+            if (task.goal_id > 0) {
+                goal = yield db.query<Goal>()
+                    .where(expr("id == $0", elem<int64?>(task.goal_id)))
+                    .first_async();
+            }
+            var co_developed = new Vector<Project>();
+            var references = new Vector<Project>();
+            if (goal != null) {
+                foreach (var link in yield goal_service.list_links(task.goal_id)) {
+                    var linked = yield db.query<Project>()
+                        .where(expr("id == $0", elem<int64?>(link.project_id)))
+                        .first_async();
+                    if (linked == null) {
+                        continue;
+                    }
+                    if (link.relationship == LINK_CO_DEVELOPED) {
+                        co_developed.add((!)linked);
+                    } else if (link.relationship == LINK_REFERENCE) {
+                        references.add((!)linked);
+                    }
+                }
+            } else {
+                foreach (var link in yield projects.list_links(task.project_id)) {
+                    var linked = yield db.query<Project>()
+                        .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
+                        .first_async();
+                    if (linked == null) {
+                        continue;
+                    }
+                    if (link.relationship == LINK_CO_DEVELOPED) {
+                        co_developed.add((!)linked);
+                    } else if (link.relationship == LINK_REFERENCE) {
+                        references.add((!)linked);
+                    }
+                }
+            }
+
             var payload = new Json.Builder();
             var payload = new Json.Builder();
             payload.begin_object();
             payload.begin_object();
             payload.set_member_name("task");
             payload.set_member_name("task");
@@ -724,24 +796,6 @@ namespace ClankerSaddle.Services {
             payload.add_string_value(task.task_branch ?? "");
             payload.add_string_value(task.task_branch ?? "");
             payload.set_member_name("branch_from");
             payload.set_member_name("branch_from");
             payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
             payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
-            payload.set_member_name("plan");
-            payload.add_string_value(task.plan ?? "");
-
-            var co_developed = new Vector<Project>();
-            var references = new Vector<Project>();
-            foreach (var link in yield projects.list_links(task.project_id)) {
-                var linked = yield db.query<Project>()
-                    .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
-                    .first_async();
-                if (linked == null) {
-                    continue;
-                }
-                if (link.relationship == LINK_CO_DEVELOPED) {
-                    co_developed.add((!)linked);
-                } else if (link.relationship == LINK_REFERENCE) {
-                    references.add((!)linked);
-                }
-            }
 
 
             payload.set_member_name("co_developed");
             payload.set_member_name("co_developed");
             payload.begin_array();
             payload.begin_array();
@@ -794,12 +848,47 @@ namespace ClankerSaddle.Services {
             payload.end_array();
             payload.end_array();
             payload.end_object();
             payload.end_object();
 
 
+            // The plan-12 wire additions: kind ("task"|"goal_run"),
+            // mode and creator ride the top level beside the task.
+            payload.set_member_name("kind");
+            payload.add_string_value(task.kind);
+            payload.set_member_name("mode");
+            payload.add_string_value(task.mode);
+            payload.set_member_name("creator");
+            payload.add_string_value(task.creator);
+            // The goal context (goal work only): the goal agent gets
+            // the full brief + approved plan; a goal worker gets the
+            // same object as read-only reference (the daemon adds the
+            // stick-to-mandate shaping) plus its ephemeral branch.
+            if (goal != null) {
+                payload.set_member_name("goal");
+                payload.begin_object();
+                payload.set_member_name("id");
+                payload.add_int_value(((!)goal).id);
+                payload.set_member_name("branch");
+                payload.add_string_value(((!)goal).branch_name);
+                payload.set_member_name("base_branch");
+                payload.add_string_value(((!)goal).base_branch
+                    ?? ((!)project).default_branch);
+                payload.set_member_name("brief");
+                payload.add_string_value(((!)goal).brief);
+                payload.set_member_name("plan");
+                payload.add_string_value(((!)goal).plan ?? "");
+                payload.end_object();
+                if (task.kind == TASK_KIND_TASK) {
+                    payload.set_member_name("ephemeral_branch");
+                    payload.add_string_value(@"task/$(task.goal_id)-$(task.id)");
+                }
+            }
+
             // The wire contract's top-level `rules` addition: the
             // The wire contract's top-level `rules` addition: the
             // effective rule set (task ∪ primary ∪ co-developed, deduped,
             // effective rule set (task ∪ primary ∪ co-developed, deduped,
-            // stable order — see RuleService.effective_rules).
+            // stable order — see RuleService.effective_rules). Goal work
+            // expands co-developed through the goal's links.
             payload.set_member_name("rules");
             payload.set_member_name("rules");
             payload.begin_array();
             payload.begin_array();
-            foreach (var rule in yield rules.effective_rules(task, projects)) {
+            foreach (var rule in yield rules.effective_rules_for(task, projects,
+                    goal != null ? task.goal_id : 0)) {
                 payload.begin_object();
                 payload.begin_object();
                 payload.set_member_name("name");
                 payload.set_member_name("name");
                 payload.add_string_value(rule.name);
                 payload.add_string_value(rule.name);
@@ -809,55 +898,6 @@ namespace ClankerSaddle.Services {
             }
             }
             payload.end_array();
             payload.end_array();
 
 
-            // The wire contract's `subtasks` addition: the task's children
-            // at dispatch time — the daemon's source of truth for its
-            // `saddle done` guard and `saddle suspend` validation.
-            payload.set_member_name("subtasks");
-            payload.begin_array();
-            foreach (var child in yield db.query<TaskEntity>()
-                    .where(expr("parent_task_id == $0", elem<int64?>(task.id)))
-                    .order_by(expr("id"))
-                    .materialise_async()) {
-                payload.begin_object();
-                payload.set_member_name("id");
-                payload.add_int_value(child.id);
-                payload.set_member_name("title");
-                payload.add_string_value(child.title);
-                payload.set_member_name("status");
-                payload.add_string_value(child.status);
-                payload.end_object();
-            }
-            payload.end_array();
-
-            // The wire contract's `parent` addition (subtask dispatches
-            // only): the daemon's system prompt renders the lineage —
-            // the subtask agent sees its parent's title, description,
-            // status and plan (clamped, plans run long).
-            if (task.parent_task_id > 0) {
-                var parent_row = yield db.query<TaskEntity>()
-                    .where(expr("id == $0", elem<int64?>(task.parent_task_id)))
-                    .first_async();
-                if (parent_row != null) {
-                    string parent_plan = ((!)parent_row).plan ?? "";
-                    if (parent_plan.length > 4000) {
-                        parent_plan = parent_plan.substring(0, 4000);
-                    }
-                    payload.set_member_name("parent");
-                    payload.begin_object();
-                    payload.set_member_name("id");
-                    payload.add_int_value(((!)parent_row).id);
-                    payload.set_member_name("title");
-                    payload.add_string_value(((!)parent_row).title);
-                    payload.set_member_name("description");
-                    payload.add_string_value(((!)parent_row).description ?? "");
-                    payload.set_member_name("plan");
-                    payload.add_string_value(parent_plan);
-                    payload.set_member_name("status");
-                    payload.add_string_value(((!)parent_row).status);
-                    payload.end_object();
-                }
-            }
-
             // The wire contract's `git_identity` addition (plan 9): the
             // The wire contract's `git_identity` addition (plan 9): the
             // name/email the daemon commits as, from the saddle row.
             // name/email the daemon commits as, from the saddle row.
             // Omitted entirely when unset so the daemon's fallback
             // Omitted entirely when unset so the daemon's fallback
@@ -1247,19 +1287,26 @@ namespace ClankerSaddle.Services {
 
 
         /**
         /**
          * Projects the agent's conversation-level events onto the task:
          * Projects the agent's conversation-level events onto the task:
-         * `saddle comment` lines become bot comments, `saddle subtask`
-         * lines create child tasks, `saddle plan`/`saddle
-         * progress-update`/`saddle review` update the plan, append
-         * progress and raise the review banner, the `saddle done` entry
-         * completes the task with the summary the agent gave, and the
-         * `saddle failure` entry fails it with the explanation the agent
-         * piped in. `saddle yeehaw` entries are handled explicitly but
-         * project nothing — the stored `session_commands` row IS the
-         * celebration the card, badge and timeline read back. Returns
-         * true once a done or failed event finished the task.
+         * `saddle comment` lines become bot comments, `saddle plan`
+         * lines update the plan, `saddle progress-update` lines append
+         * progress, `saddle review` raises the review banner, the
+         * `saddle done` entry completes the task with the summary the
+         * agent gave, and the `saddle failure` entry fails it with the
+         * explanation the agent piped in. `saddle yeehaw` entries are
+         * handled explicitly but project nothing — the stored
+         * `session_commands` row IS the celebration the card, badge
+         * and timeline read back. Returns true once a done or failed
+         * event finished the task.
+         *
+         * Plan 12: on a GOAL-RUN session the plan/done/failure verbs
+         * are goal-context — a plan submission enters plan review, a
+         * done submits the completion report (the goal-run task parks
+         * instead of completing), a failure fails the goal — and
+         * comments also land on the goal's thread.
          */
          */
         public async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
         public async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
                 TaskService tasks) throws Error {
                 TaskService tasks) throws Error {
+            var goal_id = yield goal_id_of_run(task_id);
             foreach (var entry in entries) {
             foreach (var entry in entries) {
                 string command = string_member(entry, "command");
                 string command = string_member(entry, "command");
                 string output = string_member(entry, "output");
                 string output = string_member(entry, "output");
@@ -1271,20 +1318,22 @@ namespace ClankerSaddle.Services {
                         }
                         }
                         if (body.length > 0) {
                         if (body.length > 0) {
                             yield tasks.add_comment(task_id, "bot", body);
                             yield tasks.add_comment(task_id, "bot", body);
-                        }
-                        break;
-                    case "subtask":
-                        string[] operands = split_operands(strip_verb(command, "saddle subtask"));
-                        if (operands.length >= 1 && operands[0].length > 0) {
-                            yield tasks.create_subtask(task_id, operands[0],
-                                operands.length >= 2 ? operands[1] : null);
+                            if (goal_id > 0) {
+                                yield goal_service.add_comment(goal_id, "bot", body);
+                                yield goal_service.record_event(goal_id, "comment",
+                                    snippet_text(body));
+                            }
                         }
                         }
                         break;
                         break;
                     case "plan":
                     case "plan":
                         string markdown = output.length > 0 ? output
                         string markdown = output.length > 0 ? output
                             : strip_quotes(strip_verb(command, "saddle plan"));
                             : strip_quotes(strip_verb(command, "saddle plan"));
-                        if (markdown.length > 0) {
-                            yield tasks.update_plan(task_id, markdown);
+                        if (markdown.length > 0 && goal_id > 0) {
+                            // Goal context: a plan submission enters
+                            // plan review (the state push parks the
+                            // run; the mirror block carries the
+                            // verdict back).
+                            yield goal_service.submit_plan(goal_id, markdown);
                         }
                         }
                         break;
                         break;
                     case "progress":
                     case "progress":
@@ -1303,12 +1352,24 @@ namespace ClankerSaddle.Services {
                         yield tasks.auto_answer_if_covered(review_block);
                         yield tasks.auto_answer_if_covered(review_block);
                         break;
                         break;
                     case "done":
                     case "done":
+                        if (goal_id > 0) {
+                            string report = output.length > 0 ? output
+                                : strip_quotes(strip_verb(command, "saddle done"));
+                            yield goal_service.submit_completion(goal_id,
+                                report.length > 0 ? report : "The goal agent submitted its completion report");
+                            return true;
+                        }
                         yield tasks.complete_task(task_id,
                         yield tasks.complete_task(task_id,
                             strip_quotes(strip_verb(command, "saddle done")), null, "done");
                             strip_quotes(strip_verb(command, "saddle done")), null, "done");
                         return true;
                         return true;
                     case "failed":
                     case "failed":
                         string explanation = output.length > 0 ? output
                         string explanation = output.length > 0 ? output
                             : strip_quotes(strip_verb(command, "saddle failure"));
                             : strip_quotes(strip_verb(command, "saddle failure"));
+                        if (goal_id > 0) {
+                            yield goal_service.fail_goal(goal_id,
+                                explanation.length > 0 ? explanation : null);
+                            return true;
+                        }
                         yield tasks.fail_task(task_id, explanation);
                         yield tasks.fail_task(task_id, explanation);
                         return true;
                         return true;
                     case "yeehaw":
                     case "yeehaw":
@@ -1323,6 +1384,25 @@ namespace ClankerSaddle.Services {
             return false;
             return false;
         }
         }
 
 
+        /**
+         * The goal id when `task_id` is a goal agent's run task (kind
+         * 'goal_run'), else 0 — the discriminator for the goal-context
+         * verb handling above.
+         */
+        private async int64 goal_id_of_run(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            return task != null && ((!)task).kind == TASK_KIND_GOAL_RUN
+                ? ((!)task).goal_id : 0;
+        }
+
+        /** A one-line snippet of a body (narrative event text). */
+        private static string snippet_text(string body) {
+            string flat = body.replace("\n", " ").strip();
+            return flat.length > 160 ? flat.substring(0, 160) + "…" : flat;
+        }
+
         /**
         /**
          * Raises the daemon's pushed blocking prompt as a `task_blocks`
          * Raises the daemon's pushed blocking prompt as a `task_blocks`
          * row exactly once: a not-yet-answered row, or an answered row
          * row exactly once: a not-yet-answered row, or an answered row
@@ -1493,26 +1573,40 @@ namespace ClankerSaddle.Services {
          * plan 10 phase 2) — the same task/saddle bookkeeping the
          * plan 10 phase 2) — the same task/saddle bookkeeping the
          * deleted /status poll switch ran, driven by the daemon's push:
          * deleted /status poll switch ran, driven by the daemon's push:
          * `done`/`failed` complete or fail the task with the pushed
          * `done`/`failed` complete or fail the task with the pushed
-         * summary (freeing the saddle and closing the session),
-         * `blocked` raises the block payload as an answerable
-         * `task_blocks` row (the add bumps the pulse, so the question
-         * appears on live pages without a reload) and parks the saddle,
-         * `suspended` parks the same way for the dispatcher's
-         * subtask-event resume, and `working` flips the task to working
-         * (never downgrading a pending review). Terminal states are
-         * ignored once the task is already terminal — an outbox
-         * redelivery after the entries finished the task must not
-         * re-run completion bookkeeping.
+         * summary (freeing the saddle and closing the session) —
+         * except on a goal-run session, where they submit the
+         * completion report or fail the goal (plan 12); `blocked`
+         * raises the block payload as an answerable block (a goal
+         * run's blocks attach to the goal, mirrored onto the run task
+         * for the verdict resume) and parks the saddle; `suspended`
+         * parks the same way — for a goal agent the resume is driven
+         * by the goal's task-terminal events; and `working` flips the
+         * task to working (never downgrading a pending review).
+         * Terminal states are ignored once the task is already
+         * terminal — an outbox redelivery after the entries finished
+         * the task must not re-run completion bookkeeping.
          */
          */
         public async void apply_result_state(Saddle saddle, ClankerSaddle.Data.Session session,
         public async void apply_result_state(Saddle saddle, ClankerSaddle.Data.Session session,
                 string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
                 string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
             int64 task_id = session.task_id;
             int64 task_id = session.task_id;
+            int64 goal_id = yield goal_id_of_run(task_id);
 
 
             switch (state) {
             switch (state) {
                 case "done":
                 case "done":
                     if (yield task_is_terminal(task_id)) {
                     if (yield task_is_terminal(task_id)) {
                         return;
                         return;
                     }
                     }
+                    if (goal_id > 0) {
+                        // Goal context: `saddle done` submits the
+                        // completion report and enters completion
+                        // review — the run parks on the mirrored
+                        // block, it does not complete.
+                        yield goal_service.submit_completion(goal_id,
+                            summary != null && summary.length > 0 ? summary
+                                : "The goal agent submitted its completion report");
+                        yield park_if_current(saddle.id, task_id);
+                        break;
+                    }
                     yield tasks.complete_task(task_id, summary, null, "done");
                     yield tasks.complete_task(task_id, summary, null, "done");
                     break;
                     break;
                 case "failed":
                 case "failed":
@@ -1520,9 +1614,43 @@ namespace ClankerSaddle.Services {
                         return;
                         return;
                     }
                     }
                     string explanation = summary ?? "";
                     string explanation = summary ?? "";
+                    if (goal_id > 0) {
+                        yield goal_service.fail_goal(goal_id,
+                            explanation.length > 0 ? explanation : null);
+                        break;
+                    }
                     yield tasks.fail_task(task_id, explanation.length > 0 ? explanation : null);
                     yield tasks.fail_task(task_id, explanation.length > 0 ? explanation : null);
                     break;
                     break;
                 case "blocked":
                 case "blocked":
+                    if (goal_id > 0) {
+                        // Goal context: blocks attach to the goal — a
+                        // plan payload enters plan review, anything
+                        // else raises a goal question/RFC. The mirror
+                        // block on the run task parks it for the
+                        // verdict resume.
+                        string question = block_question_from_payload(
+                            block != null ? (!)block : new Json.Object());
+                        if (question.length == 0) {
+                            question = "The goal agent raised a question";
+                        }
+                        if (block != null
+                                && block_string_or((!)block, "type", "block_type") == BLOCK_PLAN) {
+                            yield goal_service.submit_plan(goal_id,
+                                optional_string_member((!)block, "body") ?? question);
+                        } else {
+                            yield goal_service.raise_agent_block(goal_id, question,
+                                block_string_or(block != null ? (!)block : new Json.Object(),
+                                    "type", "block_type"),
+                                string_array_member(block != null ? (!)block : new Json.Object(),
+                                    block != null && block.has_member("options") ? "options" : "block_options"),
+                                optional_string_member(block != null ? (!)block : new Json.Object(), "body"));
+                        }
+                        if (!(yield task_is_terminal(task_id))) {
+                            yield tasks.set_status(task_id, STATUS_BLOCKED);
+                        }
+                        yield park_if_current(saddle.id, task_id);
+                        break;
+                    }
                     yield ensure_block(tasks, task_id, block != null ? (!)block : new Json.Object());
                     yield ensure_block(tasks, task_id, block != null ? (!)block : new Json.Object());
                     if (!(yield task_is_terminal(task_id))) {
                     if (!(yield task_is_terminal(task_id))) {
                         // A review-kind block parks as review_requested
                         // A review-kind block parks as review_requested

+ 281 - 24
src/Services/TaskService.vala

@@ -168,6 +168,34 @@ namespace ClankerSaddle.Services {
             });
             });
         }
         }
 
 
+        /**
+         * The goal's task-terminal hook (plan 12): a worker task under
+         * a goal records a supervision-narrative event — the parked
+         * goal agent's resume machinery coalesces on these rows (see
+         * {@link GoalService.resume_due}). Inline (not via
+         * GoalService) because GoalService depends on this service.
+         */
+        private async void note_goal_task_event(TaskEntity task, string outcome_word) throws Error {
+            if (task.goal_id <= 0 || task.kind != TASK_KIND_TASK) {
+                return;
+            }
+            string lead = "";
+            if (task.summary != null && ((!)task.summary).length > 0) {
+                int cut = ((!)task.summary).index_of("\n");
+                lead = cut >= 0 ? ((!)task.summary).substring(0, cut) : (!)task.summary;
+            }
+            string text = lead.length > 0
+                ? @"Task #$(task.id) $(task.title) — $(outcome_word): $(lead)"
+                : @"Task #$(task.id) $(task.title) — $(outcome_word)";
+            yield db.insert_async<GoalEvent>(new GoalEvent() {
+                goal_id = task.goal_id,
+                kind = task.status == STATUS_DONE ? "task-done" : "task-failed",
+                text = text,
+                task_id = task.id,
+                created_at = new DateTime.now_local()
+            });
+        }
+
         /**
         /**
          * Inserts a task (status 'draft' — only the human Enqueue
          * Inserts a task (status 'draft' — only the human Enqueue
          * action queues it, and no agent path exists). `parent_task_id`,
          * action queues it, and no agent path exists). `parent_task_id`,
@@ -178,12 +206,21 @@ namespace ClankerSaddle.Services {
          * answers this task's own plan blocks instantly web-side;
          * answers this task's own plan blocks instantly web-side;
          * `auto_enqueue_subtasks` enqueues subtasks created under it;
          * `auto_enqueue_subtasks` enqueues subtasks created under it;
          * `auto_approve` covers descendants' review and plan blocks.
          * `auto_approve` covers descendants' review and plan blocks.
+         * The plan-12 goal columns: `kind` ("task"/"goal_run"), `mode`
+         * (implement/investigate/review/test, orchestrate for goal
+         * runs), `creator` ("human"/"goal" — enqueueing is
+         * creator-only), `goal_id` (the owning goal) and
+         * `enqueue_after_task_id` (human-created tasks only: the
+         * auto-enqueue predecessor, cycle-checked by the caller).
          */
          */
         public async TaskEntity create_task(int64 project_id, string title, string? description,
         public async TaskEntity create_task(int64 project_id, string title, string? description,
                 int64 priority, int64? parent_task_id,
                 int64 priority, int64? parent_task_id,
                 string? task_branch = null, string? branch_from = null,
                 string? task_branch = null, string? branch_from = null,
                 bool plan_auto_approve = false, bool auto_enqueue_subtasks = false,
                 bool plan_auto_approve = false, bool auto_enqueue_subtasks = false,
-                bool auto_approve = false, string status = STATUS_DRAFT) throws Error {
+                bool auto_approve = false, string status = STATUS_DRAFT,
+                string kind = TASK_KIND_TASK, string mode = "implement",
+                string creator = CREATOR_HUMAN, int64 goal_id = 0,
+                int64 enqueue_after_task_id = 0) throws Error {
             string resolved_branch_from = branch_from ?? "";
             string resolved_branch_from = branch_from ?? "";
             if (resolved_branch_from.length == 0) {
             if (resolved_branch_from.length == 0) {
                 var project = yield db.query<Project>()
                 var project = yield db.query<Project>()
@@ -202,6 +239,16 @@ namespace ClankerSaddle.Services {
                     resolved_task_branch = ((!)parent).task_branch;
                     resolved_task_branch = ((!)parent).task_branch;
                 }
                 }
             }
             }
+            if (goal_id > 0 && kind == TASK_KIND_TASK) {
+                // Goal worker tasks exist only under a live goal.
+                var goal = yield db.query<Goal>()
+                    .where(expr("id == $0", elem<int64?>(goal_id)))
+                    .first_async();
+                if (goal == null) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 75,
+                        @"No goal exists with id $(goal_id)");
+                }
+            }
 
 
             var task = new TaskEntity() {
             var task = new TaskEntity() {
                 parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
                 parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
@@ -215,9 +262,27 @@ namespace ClankerSaddle.Services {
                 plan_auto_approve = plan_auto_approve,
                 plan_auto_approve = plan_auto_approve,
                 auto_enqueue_subtasks = auto_enqueue_subtasks,
                 auto_enqueue_subtasks = auto_enqueue_subtasks,
                 auto_approve = auto_approve ? 1 : 0,
                 auto_approve = auto_approve ? 1 : 0,
+                kind = kind,
+                mode = mode,
+                creator = creator,
+                goal_id = goal_id,
+                enqueue_after_task_id = enqueue_after_task_id,
                 created_at = new DateTime.now_local()
                 created_at = new DateTime.now_local()
             };
             };
             yield db.insert_async<TaskEntity>(task);
             yield db.insert_async<TaskEntity>(task);
+            if (goal_id > 0 && kind == TASK_KIND_TASK) {
+                // Goal worker tasks run on ephemeral auto-named
+                // branches cut from the goal branch (the daemon builds
+                // the worktrees; the stored name feeds compare links).
+                var goal = yield db.query<Goal>()
+                    .where(expr("id == $0", elem<int64?>(goal_id)))
+                    .first_async();
+                if (goal != null) {
+                    task.task_branch = @"task/$(goal_id)-$(task.id)";
+                    task.branch_from = ((!)goal).branch_name;
+                    yield db.update_async<TaskEntity>(task);
+                }
+            }
             yield pulse.bump(task.id, "task created");
             yield pulse.bump(task.id, "task created");
             note_subtask_event(task);
             note_subtask_event(task);
             return task;
             return task;
@@ -380,11 +445,14 @@ namespace ClankerSaddle.Services {
          * Saves the human-editable fields of the task identified by `id`
          * Saves the human-editable fields of the task identified by `id`
          * under the draft-lifecycle edit matrix: in DRAFT all fields
          * under the draft-lifecycle edit matrix: in DRAFT all fields
          * except status are editable (title, description, priority,
          * 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).
+         * branch fields, the three task options, the plan-12 work mode
+         * and — human-created tasks only — the auto-enqueue
+         * predecessor, cycle-validated); 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; an
+         * `enqueue_after_task_id` of -1 CLEARS the predecessor).
          * Returns null when the task does not exist. The pulse only
          * Returns null when the task does not exist. The pulse only
          * fires when a value actually moved, so periodic status
          * fires when a value actually moved, so periodic status
          * refreshes do not spam live pages.
          * refreshes do not spam live pages.
@@ -392,7 +460,8 @@ namespace ClankerSaddle.Services {
         public async TaskEntity? edit_task(int64 id, string? title, string? description,
         public async TaskEntity? edit_task(int64 id, string? title, string? description,
                 int64? priority, string? task_branch, string? branch_from,
                 int64? priority, string? task_branch, string? branch_from,
                 bool? plan_auto_approve, bool? auto_enqueue_subtasks,
                 bool? plan_auto_approve, bool? auto_enqueue_subtasks,
-                bool? auto_approve) throws Error {
+                bool? auto_approve, string? mode = null,
+                int64? enqueue_after_task_id = null) throws Error {
             var task = yield db.query<TaskEntity>()
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(id)))
                 .where(expr("id == $0", elem<int64?>(id)))
                 .first_async();
                 .first_async();
@@ -441,6 +510,38 @@ namespace ClankerSaddle.Services {
                     ((!)task).auto_approve = (!)auto_approve ? 1 : 0;
                     ((!)task).auto_approve = (!)auto_approve ? 1 : 0;
                     changed = true;
                     changed = true;
                 }
                 }
+                if (mode != null && mode.length > 0) {
+                    bool mode_valid = false;
+                    foreach (string candidate in TASK_MODES) {
+                        if (mode == candidate) {
+                            mode_valid = true;
+                            break;
+                        }
+                    }
+                    if (!mode_valid) {
+                        throw new Error(Quark.from_string("clanker-saddle"), 79,
+                            @"Unknown mode '$(mode)' — valid modes: " + string.joinv(", ", TASK_MODES));
+                    }
+                    if (((!)task).mode != mode) {
+                        ((!)task).mode = mode;
+                        changed = true;
+                    }
+                }
+                // Human-created tasks only: the auto-enqueue
+                // predecessor is settable/clearable while draft, and
+                // setting it validates the chain (no cycles).
+                if (enqueue_after_task_id != null
+                        && ((!)task).creator == CREATOR_HUMAN
+                        && ((!)task).kind == TASK_KIND_TASK) {
+                    int64 next = (!)enqueue_after_task_id;
+                    if (next > 0) {
+                        yield validate_enqueue_after_cycle(id, next);
+                    }
+                    if (((!)task).enqueue_after_task_id != next) {
+                        ((!)task).enqueue_after_task_id = next;
+                        changed = true;
+                    }
+                }
             }
             }
             // Priority stays editable in every non-terminal state.
             // Priority stays editable in every non-terminal state.
             if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
             if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
@@ -484,16 +585,27 @@ namespace ClankerSaddle.Services {
             return task;
             return task;
         }
         }
 
 
+        /** Every task under `goal_id` (both kinds), oldest first. */
+        public async ImmutableLot<TaskEntity> list_tasks_for_goal(int64 goal_id) throws Error {
+            return yield db.query<TaskEntity>()
+                .where(expr("goal_id == $0", elem<int64?>(goal_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
         /**
         /**
-         * 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.
+         * The Enqueue action (creator-only, plan 12): moves a draft
+         * task to the queue. Humans enqueue theirs, goal agents theirs
+         * — an actor that is not the task's creator is refused (the
+         * goal agent sequences its own tasks). 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). The
+         * internal auto-enqueue-after pass calls this as the
+         * dependent's creator (the creator's pre-authorisation).
          */
          */
-        public async void enqueue_task(int64 id) throws Error {
+        public async void enqueue_task(int64 id, string actor = CREATOR_HUMAN) throws Error {
             var task = yield db.query<TaskEntity>()
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(id)))
                 .where(expr("id == $0", elem<int64?>(id)))
                 .first_async();
                 .first_async();
@@ -505,6 +617,10 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 51,
                 throw new Error(Quark.from_string("clanker-saddle"), 51,
                     "Only draft tasks can be enqueued");
                     "Only draft tasks can be enqueued");
             }
             }
+            if (((!)task).creator != actor) {
+                throw new Error(Quark.from_string("clanker-saddle"), 76,
+                    @"Only the task's creator may enqueue it (task created by '$(((!)task).creator)', actor '$(actor)')");
+            }
 
 
             var blockers = new Vector<string>();
             var blockers = new Vector<string>();
             foreach (var dep in yield db.query<TaskDependency>()
             foreach (var dep in yield db.query<TaskDependency>()
@@ -529,6 +645,113 @@ namespace ClankerSaddle.Services {
             note_subtask_event(((!)task));
             note_subtask_event(((!)task));
         }
         }
 
 
+        /**
+         * The cycle check for `enqueue_after_task_id` (human-created
+         * tasks only, settable/clearable while the dependent is
+         * draft): following the predecessor chain from
+         * `predecessor_id` must never reach `task_id` — a cycle would
+         * strand every task on it in draft forever.
+         */
+        public async void validate_enqueue_after_cycle(int64 task_id,
+                int64 predecessor_id) throws Error {
+            if (predecessor_id <= 0) {
+                return;
+            }
+            if (predecessor_id == task_id) {
+                throw new Error(Quark.from_string("clanker-saddle"), 77,
+                    "A task cannot enqueue after itself");
+            }
+            var seen = new Invercargill.DataStructures.HashSet<string>();
+            int64 current = predecessor_id;
+            while (current > 0 && seen.add(current.to_string())) {
+                if (current == task_id) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 78,
+                        @"enqueue-after would form a cycle through task $(predecessor_id)");
+                }
+                var row = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(current)))
+                    .first_async();
+                if (row == null) {
+                    return;
+                }
+                current = ((!)row).enqueue_after_task_id;
+            }
+            if (!seen.has(task_id.to_string())) {
+                return;
+            }
+            throw new Error(Quark.from_string("clanker-saddle"), 78,
+                @"enqueue-after would form a cycle through task $(predecessor_id)");
+        }
+
+        /**
+         * The auto-enqueue-after pass (plan 12): when a predecessor
+         * reaches `done`, its draft dependents enqueue as their
+         * creator (the pre-authorised enqueue). A failed or cancelled
+         * predecessor leaves them in draft — the creator decides.
+         */
+        private async void enqueue_dependents_of(TaskEntity predecessor) throws Error {
+            if (predecessor.status != STATUS_DONE) {
+                return;
+            }
+            foreach (var dependent in yield db.query<TaskEntity>()
+                    .where(expr("enqueue_after_task_id == $0 && status == $1",
+                        elem<int64?>(predecessor.id), elem(STATUS_DRAFT)))
+                    .materialise_async()) {
+                try {
+                    yield enqueue_task(dependent.id, dependent.creator);
+                } catch (Error e) {
+                    warning("[ClankerSaddle] Auto-enqueue of task %lld after %lld failed: %s",
+                        dependent.id, predecessor.id, e.message);
+                }
+            }
+        }
+
+        /**
+         * The delete the goal cascade uses on its own draft tasks:
+         * every attached row dies with the task and no dependents
+         * guard applies (the whole goal is going away together).
+         */
+        public async void delete_task_quietly(int64 id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return;
+            }
+            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");
+        }
+
         /**
         /**
          * The human-only Delete action: removes a DRAFT task with no
          * The human-only Delete action: removes a DRAFT task with no
          * dependents. Dependents — tasks holding a dependency edge to
          * dependents. Dependents — tasks holding a dependency edge to
@@ -1322,6 +1545,11 @@ namespace ClankerSaddle.Services {
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
                 ((!)task).title, task_id, "completed", summary));
                 ((!)task).title, task_id, "completed", summary));
             note_subtask_event(((!)task));
             note_subtask_event(((!)task));
+            // Plan 12: the goal's narrative hears the terminal event
+            // (a parked goal agent resumes on it), and a done
+            // predecessor auto-enqueues its dependents.
+            yield note_goal_task_event(((!)task), "completed");
+            yield enqueue_dependents_of(((!)task));
         }
         }
 
 
         /**
         /**
@@ -1354,6 +1582,7 @@ namespace ClankerSaddle.Services {
             yield free_saddles_running(task_id);
             yield free_saddles_running(task_id);
             yield pulse.bump(task_id, "task cancelled");
             yield pulse.bump(task_id, "task cancelled");
             note_subtask_event(((!)task));
             note_subtask_event(((!)task));
+            yield note_goal_task_event(((!)task), "cancelled");
         }
         }
 
 
         /**
         /**
@@ -1390,6 +1619,7 @@ namespace ClankerSaddle.Services {
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
                 ((!)task).title, task_id, "failed", summary));
                 ((!)task).title, task_id, "failed", summary));
             note_subtask_event(((!)task));
             note_subtask_event(((!)task));
+            yield note_goal_task_event(((!)task), "failed");
         }
         }
 
 
         /**
         /**
@@ -1533,8 +1763,11 @@ namespace ClankerSaddle.Services {
 
 
         /**
         /**
          * The tasks sharing `task`'s working context (excluding it):
          * The tasks sharing `task`'s working context (excluding it):
-         * siblings under the same parent, plus same-project tasks on the
-         * same non-empty working branch.
+         * siblings under the same parent, same-project tasks on the
+         * same non-empty working branch, and — for a goal agent —
+         * every other task of its goal (their completions merge into
+         * the goal branch, so the goal agent's post-park pull must
+         * hear them).
          */
          */
         private async ImmutableLot<TaskEntity> sibling_tasks(TaskEntity task) throws Error {
         private async ImmutableLot<TaskEntity> sibling_tasks(TaskEntity task) throws Error {
             var rows = new Vector<TaskEntity>();
             var rows = new Vector<TaskEntity>();
@@ -1548,6 +1781,15 @@ namespace ClankerSaddle.Services {
                     }
                     }
                 }
                 }
             }
             }
+            if (task.goal_id > 0) {
+                foreach (var sibling in yield db.query<TaskEntity>()
+                        .where(expr("goal_id == $0", elem<int64?>(task.goal_id)))
+                        .materialise_async()) {
+                    if (sibling.id != task.id && seen.add(sibling.id.to_string())) {
+                        rows.add(sibling);
+                    }
+                }
+            }
             if (task.task_branch != null && ((!)task.task_branch).length > 0) {
             if (task.task_branch != null && ((!)task.task_branch).length > 0) {
                 foreach (var sibling in yield db.query<TaskEntity>()
                 foreach (var sibling in yield db.query<TaskEntity>()
                         .where(expr("project_id == $0 && task_branch == $1",
                         .where(expr("project_id == $0 && task_branch == $1",
@@ -1683,8 +1925,10 @@ namespace ClankerSaddle.Services {
         }
         }
 
 
         /**
         /**
-         * Queued tasks whose dependencies are all done, ordered by priority
-         * (1 first) then creation time — the dispatcher's candidate list.
+         * Queued tasks whose dependencies are all done and whose
+         * auto-enqueue predecessor (if any) is done, ordered by
+         * priority (1 first) then creation time — the dispatcher's
+         * candidate list.
          */
          */
         public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
         public async ImmutableLot<TaskEntity> get_ready_tasks() throws Error {
             var queued = yield db.query<TaskEntity>()
             var queued = yield db.query<TaskEntity>()
@@ -1700,18 +1944,31 @@ namespace ClankerSaddle.Services {
                     if (dep.task_id != task.id) {
                     if (dep.task_id != task.id) {
                         continue;
                         continue;
                     }
                     }
-                    bool dep_done = false;
                     foreach (var other in all_tasks) {
                     foreach (var other in all_tasks) {
-                        if (other.id == dep.depends_on_task_id && other.status == STATUS_DONE) {
-                            dep_done = true;
+                        if (other.id == dep.depends_on_task_id) {
+                            if (other.status != STATUS_DONE) {
+                                ready = false;
+                            }
                             break;
                             break;
                         }
                         }
                     }
                     }
-                    if (!dep_done) {
-                        ready = false;
+                    if (!ready) {
                         break;
                         break;
                     }
                     }
                 }
                 }
+                if (ready && task.enqueue_after_task_id > 0) {
+                    // The auto-enqueue predecessor also gates dispatch:
+                    // a dependent that reached the queue some other way
+                    // (a predecessor set after a manual enqueue) still
+                    // waits for it to finish.
+                    ready = false;
+                    foreach (var other in all_tasks) {
+                        if (other.id == task.enqueue_after_task_id) {
+                            ready = other.status == STATUS_DONE;
+                            break;
+                        }
+                    }
+                }
                 if (ready) {
                 if (ready) {
                     rows.add(task);
                     rows.add(task);
                 }
                 }

+ 351 - 0
src/actions/GoalActions.vala

@@ -0,0 +1,351 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed goal id carried by the goal row actions. */
+    public class GoalActionPrivate : Object {
+        public int64 goal_id { get; set; }
+    }
+
+    /** Sealed goal-block identity carried by {@link AnswerGoalBlockAction}. */
+    public class GoalBlockAnswerPrivate : Object {
+        public int64 goal_id { get; set; }
+        public int64 block_id { get; set; }
+    }
+
+    /**
+     * Common base for actions sealed against a goal id: materialises
+     * the typed private blob and exposes the resolved id (falling back
+     * to the hidden `goal_id` form field).
+     */
+    public abstract class SealedGoalAction : TypedStatumAction<GoalActionPrivate> {
+
+        protected override Type private_type { get { return typeof(GoalActionPrivate); } }
+
+        /** The sealed goal id, or 0 when neither blob nor form field carried one. */
+        protected int64 sealed_goal_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).goal_id : Forms.integer(request, "goal_id", 0);
+        }
+    }
+
+    /**
+     * Creates a goal from the goals form (project, title, brief and a
+     * required branch name; base branch optional — the project's
+     * default when blank; priority clamps to 1–9 and defaults to 3).
+     * The branch name is uniqueness-validated against the other live
+     * goals sharing involved projects. The goal lands in DRAFT until
+     * started.
+     */
+    public class CreateGoalAction : StatumAction {
+
+        private GoalService goals = inject<GoalService>();
+        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 project_id = Forms.integer(request, "project_id", 0);
+            string title = Forms.field(request, "title");
+            if (project_id <= 0 || title.length == 0) {
+                return directives().notify("error", "Project and title are required").navigate("/goals");
+            }
+
+            try {
+                var goal = yield goals.create_goal(project_id, title,
+                    Forms.field_raw(request, "brief"),
+                    Forms.field(request, "branch_name"),
+                    Forms.optional(request, "base_branch"),
+                    Forms.integer(request, "priority", 3));
+                return directives().notify("success", @"Goal '$(goal.title)' created in draft")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
+    /**
+     * Starts a draft goal: the brief locks, the goal moves to
+     * 'planning' and the goal agent's internal run task (kind
+     * 'goal_run', mode 'orchestrate', priority 3) is created queued —
+     * the dispatcher hands it to a saddle and the agent begins
+     * investigating.
+     */
+    public class StartGoalAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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;
+            }
+            try {
+                var goal = yield goals.start_goal(sealed_goal_id());
+                return directives().notify("success", @"Goal '$(goal.title)' started — the agent is planning")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not start the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
+    /** Pauses an active goal: supervision freezes, events queue. */
+    public class PauseGoalAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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;
+            }
+            try {
+                var goal = yield goals.pause_goal(sealed_goal_id());
+                return directives().notify("success", @"Goal '$(goal.title)' paused")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not pause the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
+    /** Resumes a paused goal: queued events coalesce into the next wake. */
+    public class ResumeGoalAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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;
+            }
+            try {
+                var goal = yield goals.resume_goal(sealed_goal_id());
+                return directives().notify("success", @"Goal '$(goal.title)' resumed")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not resume the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
+    /**
+     * Cancels a goal: the cascade cancels every non-terminal task under
+     * it (drafts are deleted); live daemons hosting its running tasks
+     * are aborted first (the cancel action can reach SaddleService
+     * without the service dependency cycle).
+     */
+    public class CancelGoalAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        private SaddleService saddles = inject<SaddleService>();
+        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 goal_id = sealed_goal_id();
+            try {
+                var goal = yield goals.get_goal(goal_id);
+                if (goal == null) {
+                    return directives().notify("error", @"No goal exists with id $(goal_id)").navigate("/goals");
+                }
+                foreach (var task in yield tasks.list_tasks_for_goal(goal_id)) {
+                    if (task.status == STATUS_ASSIGNED || task.status == STATUS_WORKING) {
+                        try {
+                            yield saddles.abort_live_session(tasks, task.id, "goal cancelled");
+                        } catch (Error e) {
+                            warning("[ClankerSaddle] Goal-cancel abort of task %s failed: %s",
+                                task.id.to_string(), e.message);
+                        }
+                    }
+                }
+                yield goals.cancel_goal(goal_id);
+                return directives().notify("success", @"Goal '$(((!)goal).title)' cancelled — its tasks went with it")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not cancel the goal: $(e.message)").navigate("/goals");
+            }
+        }
+    }
+
+    /**
+     * Posts a comment on a goal (the goal page's thread; a selection
+     * quote rides along when the popover opened one).
+     */
+    public class GoalCommentAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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;
+            }
+            string body = Forms.field_raw(request, "body");
+            if (body.strip().length == 0) {
+                return directives().notify("error", "A comment body is required")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            }
+            var me = SpryAuth.current(request);
+            string author = me != null ? @"user:$(me.username)" : "user:anonymous";
+            try {
+                yield goals.add_comment(sealed_goal_id(), author, body,
+                    Forms.optional(request, "quote"));
+                return directives().notify("success", "Comment added")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the comment: $(e.message)")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            }
+        }
+    }
+
+    /**
+     * Answers a goal block — the goal page's single verdict path for
+     * plan submissions, completion reports and the agent's
+     * questions/RFCs (see {@link GoalService.answer_goal_block}). An
+     * empty reply with a pending option list selects the first option
+     * (the plain reply form's affordance).
+     */
+    public class AnswerGoalBlockAction : TypedStatumAction<GoalBlockAnswerPrivate> {
+
+        private GoalService goals = inject<GoalService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(GoalBlockAnswerPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 block_id = 0;
+            int64 goal_id = 0;
+            var ctx = request_private;
+            if (ctx != null) {
+                block_id = ((!)ctx).block_id;
+                goal_id = ((!)ctx).goal_id;
+            } else {
+                block_id = Forms.integer(request, "block_id", 0);
+                goal_id = Forms.integer(request, "goal_id", 0);
+            }
+
+            string answer = Forms.field(request, "answer");
+            string option = Forms.field(request, "option");
+            if (answer.length == 0 && option.length > 0) {
+                answer = option;
+            }
+            if (answer.length == 0) {
+                return directives().notify("error", "An answer is required")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+
+            var me = SpryAuth.current(request);
+            string answered_by = me != null ? @"user:$(me.username)" : "user:anonymous";
+            try {
+                yield goals.answer_goal_block(block_id, answer, answered_by);
+                return directives().notify("success", "Answer recorded")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not answer: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+
+    /**
+     * The sealed GET refresh action the goal page re-runs on every
+     * global pulse (and after client-side task-strip filter changes):
+     * re-hydrates the goal slot with fresh narrative/task/review
+     * state, exactly like the task page's refresh.
+     */
+    public class RefreshGoalDataAction : SealedGoalAction {
+
+        private GoalService goal_service = inject<GoalService>();
+        private TaskService task_service = inject<TaskService>();
+        private ProjectService project_service = inject<ProjectService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        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 goal_id = sealed_goal_id();
+            if (goal_id <= 0) {
+                return directives();
+            }
+
+            var state = yield GoalState.build(goal_id, goal_service, task_service,
+                project_service, db, action_registry, state_service);
+            return directives().set(state);
+        }
+    }
+
+    /** Adds a project link (co-developed/reference) to a goal. */
+    public class AddGoalLinkAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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;
+            }
+            try {
+                yield goals.add_link(sealed_goal_id(),
+                    Forms.integer(request, "linked_project_id", 0),
+                    Forms.field(request, "relationship"));
+                return directives().notify("success", "Project linked")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not link the project: $(e.message)")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            }
+        }
+    }
+
+    /** Removes the goal link identified by `link_id`. */
+    public class RemoveGoalLinkAction : SealedGoalAction {
+
+        private GoalService goals = inject<GoalService>();
+        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 link_id = Forms.integer(request, "link_id", 0);
+            try {
+                yield goals.remove_link(link_id);
+                return directives().notify("success", "Link removed")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the link: $(e.message)")
+                    .navigate(@"/goals/$(sealed_goal_id())");
+            }
+        }
+    }
+}

+ 49 - 3
src/actions/TaskActions.vala

@@ -88,6 +88,25 @@ namespace ClankerSaddle {
             }
             }
 
 
             try {
             try {
+                string mode = Forms.field(request, "mode");
+                if (mode.length == 0) {
+                    mode = "implement";
+                }
+                bool mode_valid = false;
+                foreach (string candidate in TASK_MODES) {
+                    if (mode == candidate) {
+                        mode_valid = true;
+                        break;
+                    }
+                }
+                if (!mode_valid) {
+                    return directives().notify("error",
+                        @"Unknown mode '$(mode)'").navigate("/tasks");
+                }
+                int64 enqueue_after = Forms.integer(request, "enqueue_after_task_id", 0);
+                if (enqueue_after > 0) {
+                    yield tasks.validate_enqueue_after_cycle(-1, enqueue_after);
+                }
                 var task = yield tasks.create_task(project_id, title,
                 var task = yield tasks.create_task(project_id, title,
                     Forms.field_raw(request, "description"),
                     Forms.field_raw(request, "description"),
                     Forms.integer(request, "priority", 5), parent_id,
                     Forms.integer(request, "priority", 5), parent_id,
@@ -95,7 +114,9 @@ namespace ClankerSaddle {
                     Forms.optional(request, "branch_from"),
                     Forms.optional(request, "branch_from"),
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
-                    Forms.field(request, "auto_approve") == "on");
+                    Forms.field(request, "auto_approve") == "on",
+                    STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0,
+                    enqueue_after);
                 if (Forms.field(request, "plan").length > 0) {
                 if (Forms.field(request, "plan").length > 0) {
                     yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
                     yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
                 }
                 }
@@ -139,6 +160,18 @@ namespace ClankerSaddle {
                 if (priority_field.length > 0) {
                 if (priority_field.length > 0) {
                     priority = Forms.integer(request, "priority", 5);
                     priority = Forms.integer(request, "priority", 5);
                 }
                 }
+                // The plan-12 mode select and the auto-enqueue
+                // predecessor (draft-only fields; -1 clears).
+                string mode = "";
+                if (request.form != null && request.form.get_field("mode") != null) {
+                    mode = Forms.field(request, "mode");
+                }
+                int64? enqueue_after = null;
+                string enqueue_field = Forms.field(request, "enqueue_after_task_id");
+                if (enqueue_field.length > 0) {
+                    int64 parsed = int64.parse(enqueue_field);
+                    enqueue_after = parsed > 0 ? parsed : -1;
+                }
                 yield tasks.edit_task(task_id,
                 yield tasks.edit_task(task_id,
                     Forms.optional(request, "title"),
                     Forms.optional(request, "title"),
                     request.form != null ? Forms.field(request, "description") : null,
                     request.form != null ? Forms.field(request, "description") : null,
@@ -149,7 +182,9 @@ namespace ClankerSaddle {
                         ? Forms.field(request, "branch_from") : null,
                         ? Forms.field(request, "branch_from") : null,
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
-                    Forms.field(request, "auto_approve") == "on");
+                    Forms.field(request, "auto_approve") == "on",
+                    mode.length > 0 ? mode : null,
+                    enqueue_after);
                 return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
                 return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
                 return directives().notify("error", @"Could not save the task: $(e.message)")
@@ -388,7 +423,7 @@ namespace ClankerSaddle {
             }
             }
 
 
             try {
             try {
-                yield tasks.enqueue_task(task_id);
+                yield tasks.enqueue_task(task_id, CREATOR_HUMAN);
                 return directives().notify("success", "Task enqueued").navigate(@"/tasks/$(task_id)");
                 return directives().notify("success", "Task enqueued").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
             } catch (Error e) {
                 return directives().notify("error", @"Could not enqueue the task: $(e.message)")
                 return directives().notify("error", @"Could not enqueue the task: $(e.message)")
@@ -418,6 +453,17 @@ namespace ClankerSaddle {
                 return directives().navigate("/tasks");
                 return directives().navigate("/tasks");
             }
             }
 
 
+            // Plan 12: goal tasks are the goal agent's to delete (the
+            // task CLI callback under its goal) — humans cancel goals
+            // instead; the cascade closes their tasks.
+            var detail = yield tasks.get_task(task_id);
+            if (detail != null && (((!)detail).task.creator != CREATOR_HUMAN
+                    || ((!)detail).task.kind == TASK_KIND_GOAL_RUN)) {
+                return directives().notify("error",
+                        "Goal tasks are managed by their goal agent — cancel the goal instead")
+                    .navigate(@"/tasks/$(task_id)");
+            }
+
             try {
             try {
                 yield tasks.delete_task(task_id);
                 yield tasks.delete_task(task_id);
                 return directives().notify("success", "Draft task deleted").navigate("/tasks");
                 return directives().notify("success", "Draft task deleted").navigate("/tasks");

+ 621 - 0
src/entrypoints/GoalDetailEntrypoint.vala

@@ -0,0 +1,621 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillJson;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A project link row on the goal page. */
+    public class GoalLinkView : Object {
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string relationship { get; set; default = ""; }
+        public Statum.Model.ActionDto? remove { get; set; }
+    }
+
+    /** One goal block (question/RFC/plan revision/completion) as the page renders it. */
+    public class GoalBlockView : Object {
+        public int64 id { get; set; }
+        public string question { get; set; default = ""; }
+        public string block_type { get; set; default = "question"; }
+        public string[] options { get; set; default = {}; }
+        public string body { get; set; default = ""; }
+        public bool answered { get; set; default = false; }
+        public string answer { get; set; default = ""; }
+        public string answered_at_display { get; set; default = ""; }
+        public string answered_by { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? answer_action { get; set; }
+        /** True while this is THE pending plan review (the plan section renders its body). */
+        public bool is_plan_review { get; set; default = false; }
+        /** True while this is THE pending completion review. */
+        public bool is_completion_review { get; set; default = false; }
+        /** Whether the block body renders as its own preview (not duplicated by a section). */
+        public bool show_body { get; set; default = true; }
+    }
+
+    /** One comment on the goal thread. */
+    public class GoalCommentView : Object {
+        public string author { get; set; default = ""; }
+        public string body { get; set; default = ""; }
+        public string quote { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public bool from_bot { get; set; default = false; }
+    }
+
+    /** One supervision-narrative event row. */
+    public class GoalEventView : Object {
+        public int64 id { get; set; }
+        public string kind { get; set; default = ""; }
+        public string text { get; set; default = ""; }
+        public int64 task_id { get; set; }
+        public string at_display { get; set; default = ""; }
+    }
+
+    /** One task row under the goal (the status strip's list). */
+    public class GoalTaskView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "draft"; }
+        public string display { get; set; default = "draft"; }
+        public string mode { get; set; default = "implement"; }
+        public string creator { get; set; default = "human"; }
+        public bool yeehaw { get; set; default = false; }
+        public string updated_display { get; set; default = ""; }
+    }
+
+    /** One goal-agent run (a session of the goal-run task). */
+    public class GoalRunView : Object {
+        public int64 id { get; set; }
+        public string started_display { get; set; default = ""; }
+        public string ended_display { get; set; default = ""; }
+        public bool open { get; set; default = false; }
+        public string outcome { get; set; default = ""; }
+        public string terminal_url { get; set; default = ""; }
+    }
+
+    /** One branch of the daemon-reported goal push state. */
+    public class GoalPushBranchView : Object {
+        public string branch { get; set; default = ""; }
+        public bool pushed { get; set; default = false; }
+    }
+
+    /** A project option chip (the goal link form's select). */
+    public class GoalProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /** Public root of the `/goals` slot. */
+    public class GoalsListPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * The scalar half of the goal page's public state (the lists ride
+     * alongside as separate properties; see {@link GoalState.build}).
+     */
+    public class GoalPublic : Object {
+        public bool exists { get; set; }
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public string project_name { get; set; default = ""; }
+        public string title { get; set; default = ""; }
+        public string brief { get; set; default = ""; }
+        public string branch_name { get; set; default = ""; }
+        public string base_branch { get; set; default = ""; }
+        public string status { get; set; default = "draft"; }
+        public string display { get; set; default = "draft"; }
+        public int64 priority { get; set; default = 3; }
+        public string plan { get; set; default = ""; }
+        public string completion_report { get; set; default = ""; }
+        public string compare_url { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public string started_display { get; set; default = ""; }
+        public string completed_display { get; set; default = ""; }
+        public bool terminal { get; set; }
+        public int64 draft_tasks { get; set; }
+        public int64 queued_tasks { get; set; }
+        public int64 running_tasks { get; set; }
+        public int64 done_tasks { get; set; }
+        public int64 failed_tasks { get; set; }
+        public int64 run_task_id { get; set; }
+        /** True while a completion report awaits review. */
+        public bool completion_pending { get; set; }
+        public Statum.Model.ActionDto? start { get; set; }
+        public Statum.Model.ActionDto? pause { get; set; }
+        public Statum.Model.ActionDto? resume { get; set; }
+        public Statum.Model.ActionDto? cancel { get; set; }
+        public Statum.Model.ActionDto? comment { get; set; }
+        public Statum.Model.ActionDto? add_link { get; set; }
+        /** The sealed GET refresh action (live pulse rehydration). */
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /**
+     * State builder shared by the goals list row and the goal page:
+     * fills a {@link GoalPublic} plus the section lists from a goal
+     * row. `full` builds the narrative/runs/sections the list skips.
+     */
+    public class GoalState : Object {
+
+        /**
+         * Builds the goal page's whole slot from `goal_id` — the
+         * shape the entrypoint sets and the sealed refresh action
+         * re-hydrates. `exists` is false when the goal is missing.
+         */
+        public static async Statum.Slot build(int64 goal_id, GoalService goals,
+                TaskService tasks, ProjectService projects,
+                InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
+                StateService state_service) throws GLib.Error {
+            var pub = new GoalPublic();
+            var links = new Vector<GoalLinkView>();
+            var blocks = new Vector<GoalBlockView>();
+            var goal_tasks = new Vector<GoalTaskView>();
+            var events = new Vector<GoalEventView>();
+            var comments = new Vector<GoalCommentView>();
+            var runs = new Vector<GoalRunView>();
+            var push_branches = new Vector<GoalPushBranchView>();
+
+            GoalBlockView? plan_pending = null;
+            GoalBlockView? completion_pending = null;
+            var plan_revisions = new Vector<GoalBlockView>();
+            var project_options = new Vector<GoalProjectOptionView>();
+
+            var goal = yield goals.get_goal(goal_id);
+            if (goal != null) {
+                yield populate(((!)goal), goals, tasks, db, actions, true, pub,
+                    links, blocks, goal_tasks, events, comments, runs, push_branches);
+                foreach (var block in blocks) {
+                    if (block.is_plan_review && plan_pending == null) {
+                        plan_pending = block;
+                    }
+                    if (block.is_completion_review && completion_pending == null) {
+                        completion_pending = block;
+                    }
+                    if (block.block_type == GOAL_BLOCK_PLAN) {
+                        plan_revisions.add(block);
+                    }
+                }
+                foreach (var project in yield projects.list_projects()) {
+                    if (project.id != ((!)goal).project_id) {
+                        project_options.add(new GoalProjectOptionView() {
+                            id = project.id,
+                            name = project.name
+                        });
+                    }
+                }
+            }
+
+            if (actions != null && goal != null) {
+                pub.refresh = ((!)actions)
+                    .author_private<RefreshGoalDataAction, GoalActionPrivate>(
+                        new GoalActionPrivate() { goal_id = goal_id }, "GET");
+            }
+
+            var pub_root = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            pub_root["links"] = StateJson.list<GoalLinkView>(links.to_immutable_buffer());
+            pub_root["blocks"] = StateJson.list_rich<GoalBlockView>(blocks.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["options"] = StateJson.strings(
+                        ((GoalBlockView) item).options ?? new string[0]);
+                });
+            pub_root["goal_tasks"] = StateJson.list<GoalTaskView>(goal_tasks.to_immutable_buffer());
+            pub_root["events"] = StateJson.list<GoalEventView>(events.to_immutable_buffer());
+            pub_root["comments"] = StateJson.list<GoalCommentView>(comments.to_immutable_buffer());
+            pub_root["runs"] = StateJson.list<GoalRunView>(runs.to_immutable_buffer());
+            pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
+                push_branches.to_immutable_buffer());
+            pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
+                plan_revisions.to_immutable_buffer());
+            pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
+                project_options.to_immutable_buffer());
+            pub_root["plan_pending_block"] = plan_pending != null
+                ? new JsonElement.from_properties(
+                    (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
+                : new JsonElement.from_element(new NativeElement<bool>(false));
+            pub_root["completion_pending_block"] = completion_pending != null
+                ? new JsonElement.from_properties(
+                    (PropertyDictionary) GObjectMapping.to_properties((!)completion_pending))
+                : new JsonElement.from_element(new NativeElement<bool>(false));
+            return state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "goal",
+                public_data = pub_root,
+                private_data = new PropertyDictionary()
+            });
+        }
+        public static async void populate(Goal goal, GoalService goals, TaskService tasks,
+                InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
+                bool full, GoalPublic pub,
+                Vector<GoalLinkView> links, Vector<GoalBlockView> blocks,
+                Vector<GoalTaskView> goal_tasks, Vector<GoalEventView> events,
+                Vector<GoalCommentView> comments, Vector<GoalRunView> runs,
+                Vector<GoalPushBranchView> push_branches) throws GLib.Error {
+            pub.exists = true;
+            pub.id = goal.id;
+            pub.project_id = goal.project_id;
+            pub.title = goal.title;
+            pub.brief = goal.brief;
+            pub.branch_name = goal.branch_name;
+            pub.base_branch = goal.base_branch ?? "";
+            pub.status = goal.status;
+            pub.display = goal.status.replace("_", " ");
+            pub.priority = goal.priority;
+            pub.plan = goal.plan ?? "";
+            pub.completion_report = goal.completion_report ?? "";
+            pub.created_display = format_stamp(goal.created_at);
+            pub.started_display = format_stamp(goal.started_at);
+            pub.completed_display = format_stamp(goal.completed_at);
+            pub.terminal = goal.status == GOAL_COMPLETE || goal.status == GOAL_FAILED
+                || goal.status == GOAL_CANCELLED;
+
+            var project = yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(goal.project_id)))
+                .first_async();
+            if (project != null) {
+                pub.project_name = ((!)project).name;
+                string base_name = goal.base_branch != null && ((!)goal.base_branch).length > 0
+                    ? ((!)goal.base_branch) : ((!)project).default_branch;
+                pub.compare_url = @"$(TaskService.https_git_url(((!)project).git_url))/compare/$(base_name)...$(goal.branch_name)";
+            }
+
+            if (actions != null) {
+                var sealed = new GoalActionPrivate() { goal_id = goal.id };
+                pub.comment = ((!)actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
+                pub.add_link = ((!)actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
+                if (goal.status == GOAL_DRAFT) {
+                    pub.start = ((!)actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (goal.status == GOAL_ACTIVE) {
+                    pub.pause = ((!)actions).author_private<PauseGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (goal.status == GOAL_PAUSED) {
+                    pub.resume = ((!)actions).author_private<ResumeGoalAction, GoalActionPrivate>(sealed);
+                }
+                if (!pub.terminal) {
+                    pub.cancel = ((!)actions).author_private<CancelGoalAction, GoalActionPrivate>(sealed);
+                }
+            }
+
+            foreach (var link in yield goals.list_links(goal.id)) {
+                var view = new GoalLinkView() {
+                    id = link.id,
+                    project_id = link.project_id,
+                    relationship = link.relationship,
+                    remove = ((!)actions).author_private<RemoveGoalLinkAction, GoalActionPrivate>(
+                        new GoalActionPrivate() { goal_id = goal.id })
+                };
+                var linked = yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(link.project_id)))
+                    .first_async();
+                if (linked != null) {
+                    view.name = ((!)linked).name;
+                }
+                links.add(view);
+            }
+
+            bool completion_pending = false;
+            foreach (var block in yield goals.list_blocks(goal.id)) {
+                bool pending_plan = block.block_type == GOAL_BLOCK_PLAN
+                    && block.answer == null;
+                bool pending_completion = block.block_type == GOAL_BLOCK_COMPLETION
+                    && block.answer == null;
+                var view = new GoalBlockView() {
+                    id = block.id,
+                    question = block.question,
+                    block_type = block.block_type,
+                    options = TaskService.parse_block_options(block.options),
+                    body = block.body ?? "",
+                    answered = block.answer != null,
+                    answer = block.answer ?? "",
+                    answered_at_display = format_stamp(block.answered_at),
+                    answered_by = block.answered_by ?? "",
+                    created_display = format_stamp(block.created_at),
+                    is_plan_review = pending_plan,
+                    is_completion_review = pending_completion,
+                    // A pending plan/completion block hides its body
+                    // preview: the plan/completion section already
+                    // renders it (the task page's duplicate rule).
+                    show_body = !(pending_plan || pending_completion)
+                };
+                if (block.answer == null && actions != null) {
+                    view.answer_action = ((!)actions)
+                        .author_private<AnswerGoalBlockAction, GoalBlockAnswerPrivate>(
+                            new GoalBlockAnswerPrivate() {
+                                goal_id = goal.id,
+                                block_id = block.id
+                            });
+                }
+                if (pending_completion) {
+                    completion_pending = true;
+                }
+                blocks.add(view);
+            }
+            pub.completion_pending = completion_pending;
+
+            var display_map = yield task_display_map(tasks, goal.id);
+            foreach (var task in yield tasks.list_tasks_for_goal(goal.id)) {
+                if (task.kind == TASK_KIND_GOAL_RUN) {
+                    pub.run_task_id = task.id;
+                    continue;
+                }
+                var view = new GoalTaskView() {
+                    id = task.id,
+                    title = task.title,
+                    status = task.status,
+                    mode = task.mode,
+                    creator = task.creator,
+                    updated_display = format_stamp(task.completed_at ?? task.created_at)
+                };
+                var display = display_map.get_or_default(task.id.to_string());
+                view.display = display != null ? ((!)display).display : task.status;
+                view.yeehaw = display != null && ((!)display).yeehaw;
+                goal_tasks.add(view);
+                switch (task.status) {
+                    case STATUS_DRAFT:
+                        pub.draft_tasks++; break;
+                    case STATUS_QUEUED:
+                    case STATUS_ASSIGNED:
+                        pub.queued_tasks++; break;
+                    case STATUS_WORKING:
+                    case STATUS_BLOCKED:
+                    case STATUS_REVIEW_REQUESTED:
+                    case STATUS_SUSPENDED:
+                        pub.running_tasks++; break;
+                    case STATUS_DONE:
+                        pub.done_tasks++; break;
+                    case STATUS_FAILED:
+                        pub.failed_tasks++; break;
+                    default:
+                        break;
+                }
+            }
+
+            if (!full) {
+                return;
+            }
+
+            var event_buffer = new Vector<GoalEventView>();
+            foreach (var row in yield goals.list_events(goal.id)) {
+                event_buffer.add(new GoalEventView() {
+                    id = row.id,
+                    kind = row.kind,
+                    text = row.text,
+                    task_id = row.task_id,
+                    at_display = format_stamp(row.created_at)
+                });
+            }
+            // Newest first: the narrative reads like a feed.
+            var event_array = event_buffer.to_array();
+            for (int i = event_array.length - 1; i >= 0; i--) {
+                events.add(event_array[i]);
+            }
+
+            foreach (var row in yield goals.list_comments(goal.id)) {
+                comments.add(new GoalCommentView() {
+                    author = row.author,
+                    body = row.body,
+                    quote = row.quote ?? "",
+                    created_display = format_stamp(row.created_at),
+                    from_bot = row.author == "bot"
+                });
+            }
+
+            if (pub.run_task_id > 0) {
+                var session_rows = yield db.query<ClankerSaddle.Data.Session>()
+                    .where(expr("task_id == $0", elem<int64?>(pub.run_task_id)))
+                    .order_by_desc(expr("id"))
+                    .materialise_async();
+                foreach (var session in session_rows) {
+                    runs.add(new GoalRunView() {
+                        id = session.id,
+                        started_display = format_stamp(session.started_at),
+                        ended_display = format_stamp(session.ended_at),
+                        open = session.ended_at == null,
+                        outcome = session.outcome ?? "",
+                        terminal_url = @"/tasks/$(pub.run_task_id)/terminal"
+                    });
+                }
+            }
+
+            if (goal.branch_push_state != null
+                    && ((!)goal.branch_push_state).length > 0) {
+                parse_push_state((!)goal.branch_push_state, push_branches);
+            }
+        }
+
+        /**
+         * The goal tasks' display states + yeehaw badges in one map —
+         * the task page's computed display (`display_of_detail`) with
+         * the queue batch lookups.
+         */
+        private static async Dictionary<string, GoalTaskDisplay> task_display_map(
+                TaskService tasks, int64 goal_id) throws GLib.Error {
+            var map = new Dictionary<string, GoalTaskDisplay>();
+            var listed = new Vector<TaskEntity>();
+            foreach (var task in yield tasks.list_tasks_for_goal(goal_id)) {
+                if (task.kind != TASK_KIND_GOAL_RUN) {
+                    listed.add(task);
+                }
+            }
+            var display_map = yield tasks.display_map_for(listed.to_immutable_buffer());
+            var yeehaw_ids = yield tasks.yeehaw_task_ids();
+            foreach (var task in listed) {
+                var display = display_map.get_or_default(task.id.to_string());
+                map.set(task.id.to_string(), new GoalTaskDisplay() {
+                    display = display != null ? ((!)display).status : task.status,
+                    yeehaw = yeehaw_ids.has(task.id.to_string())
+                });
+            }
+            return map;
+        }
+
+        /**
+         * Parses the daemon's goal_branches report into display rows:
+         * both the array form ([{"branch": "g/x", "pushed": true}])
+         * and the object form ({"g/x": true}) are accepted.
+         */
+        public static void parse_push_state(string raw, Vector<GoalPushBranchView> rows) {
+            try {
+                var node = Json.from_string(raw);
+                if (node.get_node_type() == Json.NodeType.ARRAY) {
+                    foreach (var element in node.get_array().get_elements()) {
+                        if (element.get_node_type() != Json.NodeType.OBJECT) {
+                            continue;
+                        }
+                        var obj = element.get_object();
+                        var row = new GoalPushBranchView();
+                        if (obj.has_member("branch")) {
+                            row.branch = obj.get_string_member("branch") ?? "";
+                        } else if (obj.has_member("repo")) {
+                            row.branch = obj.get_string_member("repo") ?? "";
+                        }
+                        if (obj.has_member("pushed")) {
+                            var pushed = obj.get_member("pushed");
+                            row.pushed = node_truthy(pushed);
+                        }
+                        if (row.branch.length > 0) {
+                            rows.add(row);
+                        }
+                    }
+                } else if (node.get_node_type() == Json.NodeType.OBJECT) {
+                    foreach (var member in node.get_object().get_members()) {
+                        var value = node.get_object().get_member(member);
+                        if (value.get_node_type() != Json.NodeType.VALUE) {
+                            continue;
+                        }
+                        rows.add(new GoalPushBranchView() {
+                            branch = member,
+                            pushed = node_truthy(value)
+                        });
+                    }
+                }
+            } catch (Error e) {
+                // Unparseable push state renders as no rows.
+            }
+        }
+
+        private static bool node_truthy(Json.Node node) {
+            if (node.get_node_type() != Json.NodeType.VALUE) {
+                return false;
+            }
+            var value = node.get_value();
+            if (value.holds(typeof(bool))) {
+                return value.get_boolean();
+            }
+            if (value.holds(typeof(int64)) || value.holds(typeof(long))) {
+                return value.get_int64() != 0;
+            }
+            return false;
+        }
+
+        private static string format_stamp(DateTime? stamp) {
+            return stamp != null ? ((!)stamp).format("%Y-%m-%d %H:%M") : "";
+        }
+    }
+
+    /** Display + badge pair for a goal task row. */
+    public class GoalTaskDisplay : Object {
+        public string display { get; set; default = ""; }
+        public bool yeehaw { get; set; }
+    }
+
+    /**
+     * Entrypoint for /goals: the goals dashboard — every goal (newest
+     * first) with lifecycle badges, branch, priority and task counts,
+     * plus the create form. The page subscribes to the global pulse
+     * and re-renders whenever goal or task state changes.
+     */
+    public class GoalsEntrypoint : StatumEntrypoint {
+
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        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 rows = new JsonArray();
+            foreach (var goal in yield goal_service.list_goals()) {
+                var pub = new GoalPublic();
+                var links = new Vector<GoalLinkView>();
+                var blocks = new Vector<GoalBlockView>();
+                var goal_tasks = new Vector<GoalTaskView>();
+                var events = new Vector<GoalEventView>();
+                var comments = new Vector<GoalCommentView>();
+                var runs = new Vector<GoalRunView>();
+                var push_branches = new Vector<GoalPushBranchView>();
+                yield GoalState.populate(goal, goal_service, task_service, db,
+                    action_registry, false, pub, links, blocks, goal_tasks,
+                    events, comments, runs, push_branches);
+                var row = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                row["plan_pending"] = new JsonElement.from_element(
+                    new NativeElement<bool>(goal.status == GOAL_PLAN_REVIEW));
+                rows.add(new JsonElement.from_properties(row));
+            }
+
+            var project_options = new Vector<GoalProjectOptionView>();
+            foreach (var project in yield project_service.list_projects()) {
+                project_options.add(new GoalProjectOptionView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+
+            var pub_root = (PropertyDictionary) GObjectMapping.to_properties(
+                new GoalsListPublic() {
+                    create = action_registry.author<CreateGoalAction>()
+                });
+            pub_root["goals"] = new JsonElement.from_elements(rows);
+            pub_root["projects"] = StateJson.list<GoalProjectOptionView>(
+                project_options.to_immutable_buffer());
+
+            return directives().set(state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "goals",
+                public_data = pub_root,
+                private_data = new PropertyDictionary()
+            })).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+
+    /**
+     * Entrypoint for /goals/{goal_id}: the goal page — status head
+     * with the lifecycle actions, the locked brief, the approved plan
+     * (rejected revisions under the historical-blocks expander), the
+     * task strip + list, the supervision narrative, the comment
+     * thread, the completion review (report + push state + compare
+     * link) and the goal agent's runs with their terminal views.
+     * Subscribes to the global pulse for live re-renders.
+     */
+    public class GoalDetailEntrypoint : StatumEntrypoint {
+
+        private GoalService goal_service = Inversion.inject<GoalService>();
+        private TaskService task_service = Inversion.inject<TaskService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        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;
+            }
+
+            int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
+            var state = yield GoalState.build(goal_id, goal_service, task_service,
+                project_service, db, action_registry, state_service);
+            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+}

+ 67 - 0
src/entrypoints/GoalsEntrypoint.vala

@@ -0,0 +1,67 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql.Orm;
+using Inversion;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * The `/goals` slot: every goal (newest first) with lifecycle
+     * badges, branch, task counts and the create form's action — the
+     * goals dashboard.
+     */
+    public class GoalsEntrypoint : StatumEntrypoint {
+
+        private GoalService goals = inject<GoalService>();
+        private TaskService tasks = inject<TaskService>();
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        public override async StatumPublic entrypoint_public(StatumRequest request) throws GLib.Error {
+            var goal_public = new GoalListPublic();
+
+            var auth = yield SpryAuth.current_slot(request, users);
+            if (auth == null) {
+                return goal_public;
+            }
+
+            goal_public.create = StatumActionDescriptor.from_action(
+                typeof(CreateGoalAction), request);
+            foreach (var project in yield projects.list_projects()) {
+                goal_public.projects.add(new ProjectChipView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+
+            foreach (var goal in yield goals.list_goals()) {
+                goal_public.goals.add(yield GoalPublic.from(goal, goals, tasks));
+            }
+            return goal_public;
+        }
+    }
+
+    /** The public state of {@link GoalsEntrypoint}'s slot. */
+    public class GoalListPublic : StatumPublic {
+
+        /** The create-form action (null when logged out). */
+        public StatumActionDescriptor? create { get; set; }
+
+        /** The project chips for the create form's select. */
+        public Lot<ProjectChipView> projects { get; set; }
+
+        /** Every goal, newest first. */
+        public Lot<GoalPublic> goals { get; set; }
+
+        construct {
+            projects = new Vector<ProjectChipView>().to_immutable_buffer();
+            goals = new Vector<GoalPublic>().to_immutable_buffer();
+        }
+
+        public override string slot_type { get { return "goals"; } }
+    }
+}

+ 62 - 2
src/entrypoints/TaskDetailEntrypoint.vala

@@ -1,5 +1,6 @@
 using Invercargill;
 using Invercargill;
 using Invercargill.DataStructures;
 using Invercargill.DataStructures;
+using Invercargill.Expressions;
 using Spry;
 using Spry;
 using Spry.Authentication;
 using Spry.Authentication;
 using Statum;
 using Statum;
@@ -141,6 +142,18 @@ namespace ClankerSaddle {
         public string project_name { get; set; default = ""; }
         public string project_name { get; set; default = ""; }
         public string git_url { get; set; default = ""; }
         public string git_url { get; set; default = ""; }
         public int64 parent_task_id { get; set; }
         public int64 parent_task_id { get; set; }
+        /** Plan 12: the goal this task belongs to (0 = standalone). */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
+        public string goal_status { get; set; default = ""; }
+        public string goal_branch { get; set; default = ""; }
+        public string mode { get; set; default = "implement"; }
+        public string creator { get; set; default = "human"; }
+        /** True when the task is the goal agent's own run (goal page owns it). */
+        public bool is_goal_run { get; set; }
+        /** The auto-enqueue predecessor id (0 = none), human tasks only. */
+        public int64 enqueue_after_task_id { get; set; }
+        public string enqueue_after_title { get; set; default = ""; }
         public string created_display { get; set; default = ""; }
         public string created_display { get; set; default = ""; }
         public string started_display { get; set; default = ""; }
         public string started_display { get; set; default = ""; }
         public string completed_display { get; set; default = ""; }
         public string completed_display { get; set; default = ""; }
@@ -223,6 +236,11 @@ namespace ClankerSaddle {
                 project_name = project != null ? ((!)project).name : "",
                 project_name = project != null ? ((!)project).name : "",
                 git_url = project != null ? ((!)project).git_url : "",
                 git_url = project != null ? ((!)project).git_url : "",
                 parent_task_id = task.parent_task_id,
                 parent_task_id = task.parent_task_id,
+                goal_id = task.goal_id,
+                mode = task.mode,
+                creator = task.creator,
+                is_goal_run = task.kind == TASK_KIND_GOAL_RUN,
+                enqueue_after_task_id = task.enqueue_after_task_id,
                 created_display = format_time(task.created_at),
                 created_display = format_time(task.created_at),
                 started_display = format_time(task.started_at),
                 started_display = format_time(task.started_at),
                 completed_display = format_time(task.completed_at)
                 completed_display = format_time(task.completed_at)
@@ -258,8 +276,14 @@ namespace ClankerSaddle {
             pub.save_plan = actions.author_private<UpdatePlanAction, TaskActionPrivate>(sealed);
             pub.save_plan = actions.author_private<UpdatePlanAction, TaskActionPrivate>(sealed);
             pub.subtask = actions.author_private<CreateSubtaskAction, TaskActionPrivate>(sealed);
             pub.subtask = actions.author_private<CreateSubtaskAction, TaskActionPrivate>(sealed);
             pub.dependency = actions.author_private<AddDependencyAction, TaskActionPrivate>(sealed);
             pub.dependency = actions.author_private<AddDependencyAction, TaskActionPrivate>(sealed);
-            pub.enqueue = actions.author_private<EnqueueTaskAction, TaskActionPrivate>(sealed);
-            pub.delete = actions.author_private<DeleteTaskAction, TaskActionPrivate>(sealed);
+            // Plan 12: enqueue/delete are creator-only — the human's
+            // actions exist just for human-created worker tasks; goal
+            // tasks (and the goal agent's own run) are sequenced by
+            // their goal agent.
+            if (task.creator == CREATOR_HUMAN && task.kind == TASK_KIND_TASK) {
+                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.cancel = actions.author_private<CancelTaskAction, TaskActionPrivate>(sealed);
             pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
             pub.retry = actions.author_private<RetryTaskAction, TaskActionPrivate>(sealed);
             pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
             pub.resume = actions.author_private<ResumeTaskAction, TaskActionPrivate>(sealed);
@@ -381,6 +405,40 @@ namespace ClankerSaddle {
                 var other = yield tasks.get_task(dep.depends_on_task_id);
                 var other = yield tasks.get_task(dep.depends_on_task_id);
                 dependencies.add(task_brief(other, dep.depends_on_task_id));
                 dependencies.add(task_brief(other, dep.depends_on_task_id));
             }
             }
+            // Plan 12: the auto-enqueue-after dropdown lists every
+            // OTHER human-created task of the global list (any status
+            // eligible as predecessor); goal tasks are excluded — the
+            // goal agent sequences its own.
+            var enqueue_options = new Vector<DependencyView>();
+            string enqueue_after_title = "";
+            foreach (var row in yield tasks.list_tasks(null, null)) {
+                if (row.id == task.id || row.kind == TASK_KIND_GOAL_RUN) {
+                    continue;
+                }
+                if (row.goal_id > 0) {
+                    continue;
+                }
+                enqueue_options.add(new DependencyView() {
+                    id = row.id,
+                    title = row.title,
+                    status = row.status
+                });
+                if (row.id == task.enqueue_after_task_id) {
+                    enqueue_after_title = row.title;
+                }
+            }
+            pub.enqueue_after_title = enqueue_after_title;
+            // The goal reference section (goal work only).
+            if (task.goal_id > 0) {
+                var goal_row = yield db.query<Goal>()
+                    .where(expr("id == $0", elem<int64?>(task.goal_id)))
+                    .first_async();
+                if (goal_row != null) {
+                    pub.goal_title = ((!)goal_row).title;
+                    pub.goal_status = ((!)goal_row).status;
+                    pub.goal_branch = ((!)goal_row).branch_name;
+                }
+            }
             foreach (var row in yield tasks.list_tasks(null, task.project_id)) {
             foreach (var row in yield tasks.list_tasks(null, task.project_id)) {
                 if (row.id != task.id) {
                 if (row.id != task.id) {
                     candidates.add(new DependencyView() {
                     candidates.add(new DependencyView() {
@@ -485,6 +543,8 @@ namespace ClankerSaddle {
             props["subtasks"] = StateJson.list<SubtaskView>(subtasks.to_immutable_buffer());
             props["subtasks"] = StateJson.list<SubtaskView>(subtasks.to_immutable_buffer());
             props["dependencies"] = StateJson.list<DependencyView>(dependencies.to_immutable_buffer());
             props["dependencies"] = StateJson.list<DependencyView>(dependencies.to_immutable_buffer());
             props["candidates"] = StateJson.list<DependencyView>(candidates.to_immutable_buffer());
             props["candidates"] = StateJson.list<DependencyView>(candidates.to_immutable_buffer());
+            props["enqueue_after_options"] = StateJson.list<DependencyView>(
+                enqueue_options.to_immutable_buffer());
             props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
             props["rules"] = StateJson.list<TaskRuleRowView>(task_rules.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
             props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());

+ 24 - 0
src/main.vala

@@ -48,10 +48,16 @@ int main(string[] args) {
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
         db.add_entity<PushSubscription>(PushSubscription.entity_mapping);
         db.add_entity<PushSubscription>(PushSubscription.entity_mapping);
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
+        db.add_entity<Goal>(Goal.entity_mapping);
+        db.add_entity<GoalLink>(GoalLink.entity_mapping);
+        db.add_entity<GoalComment>(GoalComment.entity_mapping);
+        db.add_entity<GoalBlock>(GoalBlock.entity_mapping);
+        db.add_entity<GoalEvent>(GoalEvent.entity_mapping);
         db.migrate_on_startup();
         db.migrate_on_startup();
 
 
         application.add_scoped<ProjectService>();
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<TaskService>();
+        application.add_scoped<GoalService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<CapabilityService>();
         application.add_scoped<CapabilityService>();
@@ -82,6 +88,8 @@ int main(string[] args) {
         statum.add_page<TasksPage, TasksEntrypoint>();
         statum.add_page<TasksPage, TasksEntrypoint>();
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
         statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
         statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
+        statum.add_page<GoalsPage, GoalsEntrypoint>();
+        statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         // spry:pages-end
         // spry:pages-end
@@ -106,6 +114,16 @@ int main(string[] args) {
         statum.action<RetryTaskAction>();
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
         statum.action<ResumeTaskAction>();
         statum.action<ToggleAutoApproveAction>();
         statum.action<ToggleAutoApproveAction>();
+        statum.action<CreateGoalAction>();
+        statum.action<StartGoalAction>();
+        statum.action<PauseGoalAction>();
+        statum.action<ResumeGoalAction>();
+        statum.action<CancelGoalAction>();
+        statum.action<GoalCommentAction>();
+        statum.action<AnswerGoalBlockAction>();
+        statum.action<AddGoalLinkAction>();
+        statum.action<RemoveGoalLinkAction>();
+        statum.action<RefreshGoalDataAction>();
         statum.action<PushSubscribeAction>();
         statum.action<PushSubscribeAction>();
         statum.action<PushUnsubscribeAction>();
         statum.action<PushUnsubscribeAction>();
         statum.action<ToggleTaskNotifyAction>();
         statum.action<ToggleTaskNotifyAction>();
@@ -152,6 +170,12 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/note", Method.POST));
             new EndpointRoute("/api/saddle/note", Method.POST));
         application.add_endpoint<SaddleSubtaskEndpoint>(
         application.add_endpoint<SaddleSubtaskEndpoint>(
             new EndpointRoute("/api/saddle/subtask", Method.POST));
             new EndpointRoute("/api/saddle/subtask", Method.POST));
+        application.add_endpoint<SaddleTaskCreateEndpoint>(
+            new EndpointRoute("/api/saddle/task/create", Method.POST));
+        application.add_endpoint<SaddleTaskDeleteEndpoint>(
+            new EndpointRoute("/api/saddle/task/delete", Method.POST));
+        application.add_endpoint<SaddleTaskEnqueueEndpoint>(
+            new EndpointRoute("/api/saddle/task/enqueue", Method.POST));
         application.add_endpoint<SaddleTaskListEndpoint>(
         application.add_endpoint<SaddleTaskListEndpoint>(
             new EndpointRoute("/api/saddle/tasks", Method.GET));
             new EndpointRoute("/api/saddle/tasks", Method.GET));
         application.add_endpoint<SaddleTaskDetailEndpoint>(
         application.add_endpoint<SaddleTaskDetailEndpoint>(

+ 61 - 0
src/pages/app.css

@@ -243,6 +243,67 @@ summary { cursor: pointer; font-weight: 600; }
 .badge-question { background: #ddf4ff; color: #0969da; }
 .badge-question { background: #ddf4ff; color: #0969da; }
 .badge-rfc { background: #f3e8ff; color: var(--plan); }
 .badge-rfc { background: #f3e8ff; color: var(--plan); }
 
 
+/* Plan 12 goals: lifecycle badges (task-like palette), mode badges
+   and the narrative/strip chrome. */
+.badge-planning { background: #ddf4ff; color: #0969da; }
+.badge-plan_review { background: #f3e8ff; color: var(--plan); }
+.badge-active { background: #dafbe1; color: var(--success); }
+.badge-paused { background: var(--line-soft); color: var(--ink-muted); }
+.badge-completion_review { background: #fff1e0; color: #9a4700; }
+.badge-complete { background: #dafbe1; color: var(--success); }
+.badge-cancelled { background: var(--line-soft); color: var(--ink-muted); }
+.badge-mode-implement { background: var(--line-soft); color: var(--ink-muted); }
+.badge-mode-investigate { background: #ddf4ff; color: #0969da; }
+.badge-mode-review { background: #fff1e0; color: #9a4700; }
+.badge-mode-test { background: #dafbe1; color: var(--success); }
+.badge-mode-orchestrate { background: #f3e8ff; color: var(--plan); }
+.badge-goal-plan-submitted { background: #f3e8ff; color: var(--plan); }
+.badge-goal-plan-approved { background: #dafbe1; color: var(--success); }
+.badge-goal-plan-rejected { background: #ffebe9; color: var(--danger); }
+.badge-goal-completion-submitted { background: #fff1e0; color: #9a4700; }
+.badge-goal-goal-approved { background: #dafbe1; color: var(--success); }
+.badge-goal-completion-rejected { background: #ffebe9; color: var(--danger); }
+.badge-goal-goal-failed { background: #ffebe9; color: var(--danger); }
+.badge-goal-started { background: #ddf4ff; color: #0969da; }
+.badge-goal-paused { background: var(--line-soft); color: var(--ink-muted); }
+.badge-goal-resumed { background: #ddf4ff; color: #0969da; }
+.badge-goal-cancelled { background: var(--line-soft); color: var(--ink-muted); }
+.badge-goal-task-created { background: #ddf4ff; color: #0969da; }
+.badge-goal-task-enqueued { background: #88843333; color: #4d4d33; }
+.badge-goal-task-deleted { background: var(--line-soft); color: var(--ink-muted); }
+.badge-goal-task-done { background: #dafbe1; color: var(--success); }
+.badge-goal-task-failed { background: #ffebe9; color: var(--danger); }
+.badge-goal-block-raised { background: #fff8c5; color: var(--warning); }
+.badge-goal-comment { background: #f0f0f0; color: #6e7781; }
+.badge-goal-created { background: var(--line-soft); color: var(--ink-muted); }
+.badge-block-plan { background: #f3e8ff; color: var(--plan); }
+.badge-block-completion { background: #fff1e0; color: #9a4700; }
+
+.goal-strip { display: flex; gap: var(--grid-1); flex-wrap: wrap; align-items: center; }
+.goal-filter {
+    background: var(--line-soft);
+    border: 1px solid var(--line);
+    border-radius: 999px;
+    color: var(--ink-muted);
+    cursor: pointer;
+    font-size: 0.8rem;
+    padding: 0.15rem 0.7rem;
+}
+.goal-filter.active {
+    background: #ddf4ff;
+    border-color: #0969da;
+    color: #0969da;
+}
+.goal-event {
+    border-left: 2px solid var(--line-soft);
+    display: flex;
+    gap: 0.5rem;
+    flex-wrap: wrap;
+    margin: 0.35rem 0;
+    padding-left: 0.6rem;
+}
+.plan-revision { margin: 0.5rem 0; }
+
 /* Dashboard timeline event kinds (plan 10 phase 3) */
 /* Dashboard timeline event kinds (plan 10 phase 3) */
 .badge-created { background: var(--line-soft); color: var(--ink-muted); }
 .badge-created { background: var(--line-soft); color: var(--ink-muted); }
 .badge-started { background: #ddf4ff; color: #0969da; }
 .badge-started { background: #ddf4ff; color: #0969da; }

+ 363 - 0
src/pages/goal_detail.html

@@ -0,0 +1,363 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/goals/{goal_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+<script>
+    // The goal page: supervision surface of one goal — status head
+    // with lifecycle actions, the locked brief, the approved plan
+    // (rejected revisions under the historical-blocks expander), the
+    // task status strip (its buttons filter the task list), the
+    // supervision narrative, the comment thread with
+    // selection-quoting, the completion review and the goal agent's
+    // runs. The status strip's filter lives client-side in
+    // window.goalTaskFilter; clicking a strip button re-runs the
+    // sealed refresh action, and the filter rides the re-render.
+    if (window.statum) {
+        (function () {
+            'use strict';
+
+            window.goalTaskFilter = window.goalTaskFilter || null;
+
+            window.goalFilterClass = function (status) {
+                return window.goalTaskFilter === status ? 'goal-filter active'
+                    : 'goal-filter';
+            };
+
+            window.goalTaskVisible = function (task) {
+                return window.goalTaskFilter == null
+                    || window.goalTaskFilter === false
+                    || window.goalTaskFilter === ''
+                    || window.goalTaskFilter === task.status;
+            };
+
+            window.goalSetFilter = function (status) {
+                window.goalTaskFilter = window.goalTaskFilter === status ? null : status;
+                refreshGoal('filter');
+            };
+
+            function refreshGoal(source) {
+                var goal = statum.read('goal');
+                if (!goal || !goal.refresh) return;
+                statum.act(goal.refresh).catch(function (error) {
+                    console.warn('[goal] live refresh (' + source + ') failed:', error);
+                });
+            }
+
+            statum.addStateListener('pulse', function () {
+                refreshGoal('pulse');
+            });
+            document.addEventListener('visibilitychange', function () {
+                if (document.visibilityState === 'visible') refreshGoal('focus');
+            });
+
+            if (window.initQuoteComments) {
+                window.initQuoteComments({
+                    matches: function (node) {
+                        return node.closest && node.closest('.goal-body') != null;
+                    },
+                    action: function () {
+                        var goal = statum.read('goal');
+                        return goal && goal.comment;
+                    }
+                });
+            }
+        })();
+    }
+</script>
+</head>
+<body>
+    <div class="page">
+        <p><a href="/goals">&larr; Goals</a></p>
+        <h2 stm-if="goal.exists">#<span stm-text="goal.id">id</span> —
+            <span stm-text="goal.title">title</span></h2>
+        <p stm-if="!goal.exists" class="muted">Goal not found.</p>
+
+        <div stm-if="goal.exists">
+            <!-- Status head: lifecycle badge, branch pair, priority and
+                 the lifecycle actions. -->
+            <section class="card">
+                <div class="card-head">
+                    <h3>Status</h3>
+                    <span class="badge badge-yeehaw status-corner"
+                          stm-if="goal.terminal && goal.status == 'complete'">🤠 goal complete</span>
+                </div>
+                <p><span stm-attribute.class="'badge badge-' + goal.status" stm-text="goal.display">status</span>
+                    · goal branch <code stm-text="goal.branch_name">branch</code>
+                    <span class="muted">from</span> <code stm-text="goal.base_branch">base</code>
+                    · priority <strong stm-text="goal.priority">3</strong></p>
+                <p class="muted">Project:
+                    <a stm-attribute.href="'/projects/' + goal.project_id" stm-text="goal.project_name">project</a>
+                    · Created <span stm-text="goal.created_display">when</span>
+                    <span stm-if="goal.started_display.length > 0"> · Started
+                        <span stm-text="goal.started_display">when</span></span>
+                    <span stm-if="goal.completed_display.length > 0"> · Finished
+                        <span stm-text="goal.completed_display">when</span></span></p>
+                <p>
+                    <button type="button" class="primary" stm-if="goal.start != null"
+                            stm-action="goal.start">Start goal</button>
+                    <button type="button" stm-if="goal.pause != null"
+                            stm-action="goal.pause">Pause</button>
+                    <button type="button" stm-if="goal.resume != null"
+                            stm-action="goal.resume">Resume</button>
+                    <button type="button" class="danger" stm-if="goal.cancel != null"
+                            stm-action="goal.cancel"
+                            stm-confirm="Cancel this goal? Its tasks are cancelled with it.">Cancel goal</button>
+                </p>
+                <p class="muted" stm-if="goal.status == 'draft'">Starting locks the brief and
+                    hands the goal to its agent (an orchestrate-mode run task, priority 3).</p>
+                <p class="muted" stm-if="goal.status == 'paused'">Paused — supervision frozen;
+                    task events queue and coalesce into the resume.</p>
+            </section>
+
+            <!-- The locked brief. -->
+            <section class="card">
+                <h3>Initial brief
+                    <span class="muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
+                <div class="rfc-body goal-body" data-brief-body stm-text="goal.brief">brief</div>
+            </section>
+
+            <!-- Involved projects. -->
+            <section class="card">
+                <h3>Involved projects</h3>
+                <p stm-if="goal.links.length > 0"><span stm-for-link-in="goal.links">
+                    <span stm-attribute.class="'badge badge-' + link.relationship"
+                          stm-text="link.name + ' (' + link.relationship + ')'">link</span>
+                    <button type="button" class="inline" stm-action="link.remove"
+                            stm-confirm="Remove this link?">&times;</button>
+                </span></p>
+                <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
+                <details stm-if="!goal.terminal">
+                    <summary>Link a project</summary>
+                    <form class="inline" stm-action="goal.add_link">
+                        <select name="linked_project_id" required>
+                            <option stm-for-project-in="goal.project_options" stm-attribute.value="project.id"
+                                     stm-text="project.name">project</option>
+                        </select>
+                        <select name="relationship">
+                            <option value="co-developed">co-developed</option>
+                            <option value="reference">reference</option>
+                        </select>
+                        <button type="submit">Link</button>
+                    </form>
+                </details>
+            </section>
+
+            <!-- The plan: the approved markdown; the pending review;
+                 rejected revisions behind the historical expander. -->
+            <section class="card">
+                <h3>Plan</h3>
+                <div stm-if="goal.plan.length > 0" class="rfc-body goal-body" data-plan-body
+                     stm-text="goal.plan">plan</div>
+                <p stm-if="goal.plan.length == 0 && goal.plan_pending_block == null"
+                   class="muted">No approved plan yet.</p>
+
+                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" class="plan-review">
+                    <h4>Plan submitted for review</h4>
+                    <div class="rfc-body goal-body" stm-text="goal.plan_pending_block.body">revision</div>
+                    <form stm-action="goal.plan_pending_block.answer_action" class="inline">
+                        <input type="hidden" name="answer"
+                               value="Plan approved. Proceed with implementation.">
+                        <button type="submit" class="primary">Approve plan</button>
+                    </form>
+                    <details>
+                        <summary>Reject with feedback</summary>
+                        <form stm-action="goal.plan_pending_block.answer_action">
+                            <div class="form-row">
+                                <textarea name="answer" rows="4" required
+                                          placeholder="What must change before approval?"></textarea></div>
+                            <button type="submit">Send feedback</button>
+                        </form>
+                    </details>
+                </div>
+
+                <details class="history-expander" stm-if="goal.plan_revisions.length > 0">
+                    <summary>Plan revisions &amp; feedback (<span
+                            stm-text="goal.plan_revisions.length">0</span>)</summary>
+                    <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
+                        <p class="muted" stm-text="'Revision #' + revision.id + ' — ' + revision.created_display">when</p>
+                        <details>
+                            <summary>Submitted markdown</summary>
+                            <div class="rfc-body" stm-text="revision.body">body</div>
+                        </details>
+                        <blockquote stm-if="revision.answered" class="term-comment-quote">
+                            <span stm-text="revision.answered_by">who</span>:
+                            <span stm-text="revision.answer">answer</span></blockquote>
+                    </div>
+                </details>
+            </section>
+
+            <!-- Completion review: the report, push state, compare link. -->
+            <section class="card" stm-if="goal.completion_pending">
+                <h3>Completion review</h3>
+                <p stm-if="goal.push_branches.length > 0">Goal branches:
+                    <span stm-for-branch-in="goal.push_branches">
+                        <code stm-text="branch.branch">b</code>
+                        <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
+                              stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
+                    </span>
+                    <span class="muted">(reported by the daemon's /status)</span></p>
+                <p><a stm-if="goal.compare_url.length > 0" stm-attribute.href="goal.compare_url"
+                      target="_blank" rel="noopener">Compare changes</a></p>
+                <div class="rfc-body goal-body" stm-text="goal.completion_report">report</div>
+                <form stm-action="goal.completion_pending_block.answer_action" class="inline">
+                    <input type="hidden" name="answer"
+                           value="Review approved. Continue with the task.">
+                    <button type="submit" class="primary">Accept goal</button>
+                </form>
+                <details>
+                    <summary>Request changes</summary>
+                    <form stm-action="goal.completion_pending_block.answer_action">
+                        <div class="form-row">
+                            <textarea name="answer" rows="4" required
+                                      placeholder="What remains to be done?"></textarea></div>
+                        <button type="submit">Send feedback</button>
+                    </form>
+                </details>
+            </section>
+
+            <!-- Task status strip + the goal's task list. -->
+            <section class="card">
+                <h3>Tasks</h3>
+                <p class="goal-strip">
+                    <button type="button" stm-attribute.class="window.goalFilterClass('draft')"
+                            onclick="window.goalSetFilter('draft')"
+                            stm-text="'draft ' + goal.draft_tasks">draft</button>
+                    <button type="button" stm-attribute.class="window.goalFilterClass('queued')"
+                            onclick="window.goalSetFilter('queued')"
+                            stm-text="'queued ' + goal.queued_tasks">queued</button>
+                    <button type="button" stm-attribute.class="window.goalFilterClass('working')"
+                            onclick="window.goalSetFilter('working')"
+                            stm-text="'running ' + goal.running_tasks">running</button>
+                    <button type="button" stm-attribute.class="window.goalFilterClass('done')"
+                            onclick="window.goalSetFilter('done')"
+                            stm-text="'done ' + goal.done_tasks">done</button>
+                    <button type="button" stm-attribute.class="window.goalFilterClass('failed')"
+                            onclick="window.goalSetFilter('failed')"
+                            stm-text="'failed ' + goal.failed_tasks">failed</button>
+                    <span class="muted">— click a status to filter</span>
+                </p>
+                <table>
+                    <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Created by</th></tr></thead>
+                    <tbody>
+                        <tr stm-for-goal_task-in="goal.goal_tasks"
+                            stm-if="window.goalTaskVisible(goal_task)">
+                            <td stm-text="goal_task.id">id</td>
+                            <td><a stm-attribute.href="'/tasks/' + goal_task.id"
+                                   stm-text="goal_task.title">title</a></td>
+                            <td><span stm-attribute.class="'badge badge-' + goal_task.status"
+                                      stm-text="goal_task.display">status</span>
+                                <span class="badge badge-yeehaw" stm-if="goal_task.yeehaw">🤠</span></td>
+                            <td><span stm-attribute.class="'badge badge-mode-' + goal_task.mode"
+                                      stm-text="goal_task.mode">mode</span></td>
+                            <td class="muted" stm-text="goal_task.creator">who</td>
+                        </tr>
+                        <tr stm-if="goal.goal_tasks.length == 0">
+                            <td colspan="5" class="muted">No tasks yet — the goal agent fans
+                                them out once the plan is approved.</td></tr>
+                    </tbody>
+                </table>
+            </section>
+
+            <!-- The supervision narrative: one chronological feed of
+                 the goal agent's life. -->
+            <section class="card">
+                <h3>Supervision narrative</h3>
+                <p stm-if="goal.events.length == 0" class="muted">Nothing yet — start the goal.</p>
+                <div stm-for-event-in="goal.events" class="goal-event">
+                    <span class="muted" stm-text="event.at_display">when</span>
+                    <span stm-attribute.class="'badge badge-goal-' + event.kind"
+                          stm-text="event.kind.replace('-', ' ')">kind</span>
+                    <span stm-text="event.text">text</span>
+                    <a stm-if="event.task_id > 0"
+                       stm-attribute.href="'/tasks/' + event.task_id">task</a>
+                </div>
+            </section>
+
+            <!-- Comments with selection-quoting. -->
+            <section class="card">
+                <h3>Comments</h3>
+                <div stm-for-comment-in="goal.comments"
+                     stm-attribute.class="'comment' + (comment.from_bot ? ' bot' : '')">
+                    <strong stm-text="comment.author">author</strong>
+                    <span class="muted" stm-text="comment.created_display">when</span>
+                    <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                    <div stm-text="comment.body">body</div>
+                </div>
+                <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
+                <form stm-action="goal.comment">
+                    <div class="form-row"><label for="goal-comment-body">Add comment</label>
+                        <textarea id="goal-comment-body" name="body" rows="3"
+                                  placeholder="A comment for the goal agent"></textarea></div>
+                    <button type="submit" class="primary">Comment</button>
+                </form>
+                <dialog id="quote-comment-dialog">
+                    <h3>Comment on selected text</h3>
+                    <blockquote id="quote-text" class="term-comment-quote"></blockquote>
+                    <form stm-action="goal.comment">
+                        <input type="hidden" name="quote">
+                        <input type="hidden" name="return_to">
+                        <textarea name="body" rows="4" placeholder="Your comment…"
+                                  style="width:100%; margin:0.5em 0;"></textarea>
+                        <button type="submit" class="primary">Send comment</button>
+                        <button type="button"
+                                onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
+                    </form>
+                </dialog>
+            </section>
+
+            <!-- The goal agent's blocks (questions/RFCs) with their
+                 answer forms. -->
+            <section class="card" stm-if="goal.blocks.length > 0">
+                <h3>Goal agent blocks</h3>
+                <div stm-for-block-in="goal.blocks" class="goal-block">
+                    <div stm-if="!block.is_plan_review && !block.is_completion_review">
+                        <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
+                                 stm-text="block.block_type">kind</span>
+                            <strong stm-text="block.question">question</strong>
+                            <span class="muted" stm-text="block.created_display">when</span></p>
+                        <div stm-if="block.show_body && block.body.length > 0"
+                             class="rfc-body goal-body" stm-text="block.body">body</div>
+                        <blockquote stm-if="block.answered" class="term-comment-quote">
+                            <span stm-text="block.answered_by">who</span>:
+                            <span stm-text="block.answer">answer</span></blockquote>
+                        <form stm-if="!block.answered" stm-action="block.answer_action">
+                            <div stm-if="block.options.length > 0" class="form-row">
+                                <label>Options</label>
+                                <select name="option">
+                                    <option stm-for-option-in="block.options" stm-attribute.value="option"
+                                             stm-text="option">opt</option>
+                                </select></div>
+                            <div class="form-row">
+                                <textarea name="answer" rows="2"
+                                          placeholder="Reply (or pick an option)"></textarea></div>
+                            <button type="submit" class="primary">Answer</button>
+                        </form>
+                    </div>
+                </div>
+            </section>
+
+            <!-- Goal agent runs with terminal links (the session
+                 picker lives on the terminal page). -->
+            <section class="card" stm-if="goal.runs.length > 0">
+                <h3>Goal agent runs</h3>
+                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/terminal'">Terminal
+                        (session picker)</a> <span class="muted">— run task
+                        #<span stm-text="goal.run_task_id">id</span></span></p>
+                <table>
+                    <thead><tr><th>Run</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
+                    <tbody>
+                        <tr stm-for-run-in="goal.runs">
+                            <td><a stm-attribute.href="run.terminal_url" stm-text="'session ' + run.id">run</a></td>
+                            <td class="muted" stm-text="run.started_display">when</td>
+                            <td class="muted" stm-text="run.ended_display">when</td>
+                            <td><span stm-if="run.open" class="badge badge-working">open</span>
+                                <span stm-if="!run.open" stm-text="run.outcome">outcome</span></td>
+                        </tr>
+                    </tbody>
+                </table>
+            </section>
+        </div>
+    </div>
+</body>
+</html>

+ 80 - 0
src/pages/goals.html

@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/goals</pstm-uri>
+    <pstm-template>main</pstm-template>
+<script>
+    // The goals dashboard: every goal newest-first with lifecycle
+    // badges, branch, priority and task counts; the create form's
+    // brief is a heredoc-style textarea (markdown, rendered on the
+    // goal page). Branch name is required and uniqueness-validated
+    // server-side.
+    if (window.statum) {
+        (function () {
+            'use strict';
+        })();
+    }
+</script>
+</head>
+<body>
+    <div class="page">
+        <h1>Goals</h1>
+        <p class="muted">User-created units of agent-orchestrated work — brief, plan,
+            task fan-out, one shared goal branch per involved repo.</p>
+
+        <details class="history-expander">
+            <summary><strong>New goal</strong></summary>
+            <form stm-action="goals.create">
+                <div class="form-row"><label for="goal-project">Project</label>
+                    <select id="goal-project" name="project_id" required>
+                        <option stm-for-project-in="goals.projects" stm-attribute.value="project.id"
+                                 stm-text="project.name">project</option>
+                    </select></div>
+                <div class="form-row"><label for="goal-title">Title</label>
+                    <input type="text" id="goal-title" name="title" required
+                           placeholder="What should the goal achieve?"></div>
+                <div class="form-row"><label for="goal-brief">Initial brief</label>
+                    <textarea id="goal-brief" name="brief" rows="6" required
+                              placeholder="Markdown brief — locks once the goal starts"></textarea></div>
+                <div class="form-row"><label for="goal-branch">Goal branch name</label>
+                    <input type="text" id="goal-branch" name="branch_name" required
+                           placeholder="goal/my-feature"></div>
+                <div class="form-row"><label for="goal-base">Base branch <span class="muted">(default: the project's default branch)</span></label>
+                    <input type="text" id="goal-base" name="base_branch"
+                           placeholder="main"></div>
+                <div class="form-row"><label for="goal-priority">Priority</label>
+                    <select id="goal-priority" name="priority">
+                        <option value="1">1 — highest</option>
+                        <option value="2">2</option>
+                        <option value="3" selected>3 — default</option>
+                        <option value="4">4</option>
+                        <option value="5">5</option>
+                        <option value="6">6</option>
+                        <option value="7">7</option>
+                        <option value="8">8</option>
+                        <option value="9">9 — lowest</option>
+                    </select></div>
+                <button type="submit" class="primary">Create goal</button>
+            </form>
+        </details>
+
+        <table>
+            <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Branch</th><th>Priority</th>
+                <th>Tasks</th><th>Created</th></tr></thead>
+            <tbody>
+                <tr stm-for-goal-in="goals.goals">
+                    <td stm-text="goal.id">id</td>
+                    <td><a stm-attribute.href="'/goals/' + goal.id" stm-text="goal.title">title</a></td>
+                    <td><span stm-attribute.class="'badge badge-' + goal.status"
+                              stm-text="goal.display">status</span></td>
+                    <td><code stm-text="goal.branch_name">branch</code></td>
+                    <td stm-text="goal.priority">3</td>
+                    <td><span class="muted" stm-text="goal.draft_tasks + ' draft / ' + goal.queued_tasks + ' queued / ' + goal.running_tasks + ' running / ' + goal.done_tasks + ' done'">counts</span></td>
+                    <td class="muted" stm-text="goal.created_display">when</td>
+                </tr>
+                <tr stm-if="goals.goals.length == 0"><td colspan="7" class="muted">No goals yet.</td></tr>
+            </tbody>
+        </table>
+    </div>
+</body>
+</html>

+ 1 - 0
src/pages/main.html

@@ -130,6 +130,7 @@
             <!-- spry:nav-begin -->
             <!-- spry:nav-begin -->
             <a href="/projects">Projects</a>
             <a href="/projects">Projects</a>
             <a href="/tasks">Tasks</a>
             <a href="/tasks">Tasks</a>
+            <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
             <a href="/rules">Rules</a>
             <!-- spry:nav-end -->
             <!-- spry:nav-end -->

+ 513 - 20
test/web_stack_test.py

@@ -490,6 +490,10 @@ class FakeSaddle:
         self.pending_block = None        # the block payload this daemon waits on (state blocked)
         self.pending_block = None        # the block payload this daemon waits on (state blocked)
         self.answer_log = []             # recorded POST /answer bodies
         self.answer_log = []             # recorded POST /answer bodies
         self.answer_push_delay = 0.2     # the resumed round's first push, after the response
         self.answer_push_delay = 0.2     # the resumed round's first push, after the response
+        # --- plan 12: goal-agent scripting ---
+        self.goal_script = []            # behaviours consumed per goal-run assignment
+        self.goal_behaviour = None       # the current round's popped behaviour
+        self.goal_assignments = []       # captured goal-run assignment payloads
 
 
     def set_state(self, state, summary=None):
     def set_state(self, state, summary=None):
         """Advances the scripted lifecycle."""
         """Advances the scripted lifecycle."""
@@ -665,6 +669,13 @@ class FakeSaddle:
             # workspace still exists restores it; the first
             # workspace still exists restores it; the first
             # /status after the restore reports "resumed": true.
             # /status after the restore reports "resumed": true.
             self.resumed_pending = True
             self.resumed_pending = True
+        # Plan 12: a goal-run assignment plays its scripted behaviour
+        # instead of the plain done push (plan submission, parking,
+        # question, completion, failure — consumed one per round).
+        self.goal_behaviour = None
+        if payload.get("kind") == "goal_run":
+            self.goal_assignments.append(payload)
+            self.goal_behaviour = self.goal_script.pop(0) if self.goal_script else ("suspend",)
         self.set_state("failing" if self.fail_next else "working")
         self.set_state("failing" if self.fail_next else "working")
         self.fail_next = False
         self.fail_next = False
         # The round's opening telemetry: the flushed-so-far raw PTY
         # The round's opening telemetry: the flushed-so-far raw PTY
@@ -806,6 +817,8 @@ class FakeSaddle:
                             "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
                             "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
                             "last_activity_epoch": self.daemon.last_activity_epoch,
                             "last_activity_epoch": self.daemon.last_activity_epoch,
                             "ssh_public_key": self.daemon.ssh_public_key}
                             "ssh_public_key": self.daemon.ssh_public_key}
+                if getattr(self.daemon, "status_extra", None):
+                    response.update(self.daemon.status_extra)
                 if self.daemon.resumed_pending:
                 if self.daemon.resumed_pending:
                     response["resumed"] = True
                     response["resumed"] = True
                     self.daemon.resumed_pending = False
                     self.daemon.resumed_pending = False
@@ -843,7 +856,45 @@ class FakeSaddle:
                         self.maybe_trigger_ask("terminal")
                         self.maybe_trigger_ask("terminal")
             elif self.state == "working":
             elif self.state == "working":
                 if time.monotonic() - self.state_entered >= FAKE_WORK_S:
                 if time.monotonic() - self.state_entered >= FAKE_WORK_S:
-                    if self.suspend_on_title and self.suspend_on_title == self.running_title:
+                    if self.goal_behaviour is not None:
+                        # A goal-agent round: play the scripted
+                        # behaviour (the results channel carries the
+                        # goal-context verbs).
+                        behaviour, self.goal_behaviour = self.goal_behaviour, None
+                        kind = behaviour[0]
+                        if kind == "plan":
+                            self.set_state("blocked")
+                            self.push_results("blocked", block={
+                                "type": "plan",
+                                "question": "Plan submitted for review",
+                                "body": behaviour[1]})
+                            if self.ask_on_idle_status:
+                                self.maybe_trigger_ask("parked")
+                        elif kind == "question":
+                            self.set_state("blocked")
+                            self.push_results("blocked", block={
+                                "type": "question",
+                                "question": behaviour[1],
+                                "options": list(behaviour[2])
+                                if len(behaviour) > 2 else None})
+                            if self.ask_on_idle_status:
+                                self.maybe_trigger_ask("parked")
+                        elif kind == "suspend":
+                            self.set_state("suspended")
+                            self.push_results("suspended")
+                            if self.ask_on_idle_status:
+                                self.maybe_trigger_ask("parked")
+                        elif kind == "fail":
+                            self.set_state("failed", behaviour[1])
+                            self.push_results("failed", summary=behaviour[1])
+                            if self.ask_on_terminal:
+                                self.maybe_trigger_ask("terminal")
+                        else:  # "done": the completion report
+                            self.set_state("done", behaviour[1])
+                            self.push_results("done", summary=behaviour[1])
+                            if self.ask_on_terminal:
+                                self.maybe_trigger_ask("terminal")
+                    elif self.suspend_on_title and self.suspend_on_title == self.running_title:
                         # `saddle suspend`: park until a fresh assignment
                         # `saddle suspend`: park until a fresh assignment
                         # resumes the workspace (title-targeted one-shot,
                         # resumes the workspace (title-targeted one-shot,
                         # so an interleaved dispatch cannot eat the flag).
                         # so an interleaved dispatch cannot eat the flag).
@@ -914,6 +965,9 @@ def start_app():
         "SADDLE_LOST_WAKE_S": "2",
         "SADDLE_LOST_WAKE_S": "2",
         "SADDLE_STALE_S": "3",
         "SADDLE_STALE_S": "3",
         "SADDLE_DISPATCH_TICK_S": "1",
         "SADDLE_DISPATCH_TICK_S": "1",
+        # Plan 12: the goal-agent resume coalescing window shrinks to
+        # one second so the event-driven resume tests settle quickly.
+        "SADDLE_GOAL_RESUME_S": os.environ.get("SADDLE_GOAL_RESUME_S", "1"),
         # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
         # Never inherit a caller-poisoned LD_LIBRARY_PATH (e.g. one that
         # resolves the ancient /lib64 copies): empty makes the loader use
         # resolves the ancient /lib64 copies): empty makes the loader use
         # the binary's RUNPATH, which is the dev stack it was built
         # the binary's RUNPATH, which is the dev stack it was built
@@ -2571,8 +2625,11 @@ def bundle_subtasks(ctx):
               for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
               for s in slot_public(auth, "/saddles", "saddles")["saddles"]),
           "suspension frees the saddle")
           "suspension frees the saddle")
     first_push = fake.start_payloads[pushes_before]
     first_push = fake.start_payloads[pushes_before]
-    check(first_push.get("subtasks") == [],
-          "the /start payload carries the subtasks array (empty here)")
+    check(first_push.get("kind") == "task" and first_push.get("creator") == "human"
+          and first_push.get("mode") == "implement",
+          "the /start payload carries the plan-12 identity triplet (kind/creator/mode)")
+    check("subtasks" not in first_push and "parent" not in first_push,
+          "the plan-12 payload drops the subtasks/parent members")
 
 
     # Two subtask events land while parked (staged, not dispatched):
     # Two subtask events land while parked (staged, not dispatched):
     # both must coalesce into one resume with a full report.
     # both must coalesce into one resume with a full report.
@@ -2607,10 +2664,8 @@ def bundle_subtasks(ctx):
     check("Child two" in notice and "failed" in notice
     check("Child two" in notice and "failed" in notice
           and "the harness refused to start" in notice,
           and "the harness refused to start" in notice,
           "coalesced: the second event rides the same resume notice")
           "coalesced: the second event rides the same resume notice")
-    subtask_entries = {(s["title"], s["status"]) for s in resume_push.get("subtasks", [])}
-    check(("Child one", "done") in subtask_entries
-          and ("Child two", "failed") in subtask_entries,
-          "subtasks array carries the children's states at dispatch time")
+    check("subtasks" not in resume_push,
+          "the plan-12 resume payload drops the subtasks member")
     # The resumed arc surfaces on a /status read while the resumed
     # The resumed arc surfaces on a /status read while the resumed
     # round still runs — the operator's manual refresh in the push
     # round still runs — the operator's manual refresh in the push
     # world.
     # world.
@@ -3051,18 +3106,8 @@ def bundle_lineage(ctx):
           "lineage subtask dispatches")
           "lineage subtask dispatches")
     lineage_push = [p for p in fake.start_payloads
     lineage_push = [p for p in fake.start_payloads
                     if int(p.get("task", {}).get("id") or 0) == lineage_task][-1]
                     if int(p.get("task", {}).get("id") or 0) == lineage_task][-1]
-    parent_obj = lineage_push.get("parent")
-    check(parent_obj is not None
-          and int(parent_obj.get("id") or 0) == auto_parent
-          and parent_obj.get("title") == "Auto parent"
-          and parent_obj.get("status") in ("suspended", "working", "done"),
-          "subtask /start carries the parent object")
-    check(parent_obj.get("description") == "torque to spec"
-          or "Parent plan" in (parent_obj.get("plan") or ""),
-          "parent description/plan content flows through")
-    check(len(parent_obj.get("plan") or "") <= 4000,
-          "parent plan clamped to ~4000 chars (%d)"
-          % len(parent_obj.get("plan") or ""))
+    check("parent" not in lineage_push,
+          "the plan-12 payload drops the parent member (subtask lineage is goal machinery now)")
     root_pushes = [p for p in fake.start_payloads
     root_pushes = [p for p in fake.start_payloads
                    if int(p.get("task", {}).get("id") or 0) == auto_parent]
                    if int(p.get("task", {}).get("id") or 0) == auto_parent]
     check(all("parent" not in p for p in root_pushes),
     check(all("parent" not in p for p in root_pushes),
@@ -5055,8 +5100,456 @@ def bundle_push(ctx):
           "unsubscribing removes the endpoint's row")
           "unsubscribing removes the endpoint's row")
 
 
 
 
+# =============================================================================
+# Bundle: goals — the plan-12 goal lifecycle: creation with branch-name
+# uniqueness, the start → planning → plan review → active flow with
+# revisions, the goal agent's task fan-out through the task CLI
+# callbacks (ownership + creator-only enqueue), the event-driven
+# coalesced resume, pause/resume queueing, the completion review with
+# branch push state, the cancel cascade, and the human tasks'
+# auto-enqueue-after predecessor.
+# =============================================================================
+
+def bundle_goals(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+    app = ctx.app
+
+    def goal_state(goal_id):
+        return slot_public(auth, "/goals/%d" % goal_id, "goal")
+
+    def create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    print("== plan 12: goal creation, validation, lifecycle actions ==")
+    goal1 = create_goal({"project_id": "1", "title": "Ship the flange",
+                         "brief": "Make the flange module production-grade.",
+                         "branch_name": "goal/flange", "priority": "3"})
+    state = goal_state(goal1)
+    check(state["status"] == "draft" and state["display"] == "draft"
+          and state["branch_name"] == "goal/flange"
+          and state["base_branch"] == "main",
+          "a goal lands in draft with its branch pair")
+    check(state.get("start") is not None and state.get("cancel") is not None
+          and state.get("pause") is None,
+          "draft offers start + cancel only")
+    # Branch-name uniqueness: same name, same involved project → refuse.
+    duplicate_directives = act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "Clash", "brief": "b", "branch_name": "goal/flange"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "goal/flange" in (d.get("message") or "")
+              for d in duplicate_directives),
+          "a live goal's branch name cannot be reused in an involved project (%s)"
+          % [d.get("message") for d in duplicate_directives if d.get("type") == "notify"])
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('other-repo', 'https://example.com/other.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    goal_elsewhere = create_goal({"project_id": "2", "title": "Elsewhere",
+                                  "brief": "b", "branch_name": "goal/flange"})
+    check(goal_elsewhere > 0,
+          "the same branch name in a disjoint project is fine (one per repo)")
+    # Brief validation.
+    brief_directives = act(auth, slot_public(auth, "/goals", "goals")["create"], {
+        "project_id": "1", "title": "No brief", "brief": "  ", "branch_name": "goal/x1"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "brief" in (d.get("message") or "").lower()
+              for d in brief_directives),
+          "an empty brief rejects")
+
+    # Start: planning + the goal-run internal task.
+    act(auth, state["start"], {})
+    state = goal_state(goal1)
+    check(state["status"] == "planning" and state.get("start") is None,
+          "start moves the goal to planning")
+    conn = db()
+    run_row = conn.execute(
+        "select id, kind, mode, creator, goal_id, priority, status from tasks"
+        " where goal_id = ? and kind = 'goal_run'", (goal1,)).fetchone()
+    conn.close()
+    check(run_row is not None and run_row[1] == "goal_run"
+          and run_row[2] == "orchestrate" and run_row[3] == "goal"
+          and run_row[4] == goal1 and run_row[5] == 3
+          and run_row[6] in ("queued", "assigned", "working"),
+          "start creates the goal-run task (orchestrate, priority 3, creator goal,"
+          " queued)")
+    run_task = int(run_row[0])
+    check(goal_state(goal1)["run_task_id"] == run_task,
+          "the goal state carries the run task id")
+    check(any(e["kind"] == "started" for e in goal_state(goal1)["events"]),
+          "the narrative records the start")
+
+    print("== plan 12: the goal agent plans, plan review, revisions ==")
+    fake.goal_script = [("plan", "# Plan v1\n\n1. Add tests\n2. Ship")]
+    check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
+                   timeout=60, what="goal agent submits its plan") is not None,
+          "the goal agent's plan push enters plan review")
+    state = goal_state(goal1)
+    check(task_state(auth, run_task)["status"] == "blocked",
+          "the goal-run task parks on the plan review")
+    pending = state.get("plan_pending_block")
+    check(pending and "# Plan v1" in (pending.get("body") or ""),
+          "the pending plan review carries the submitted markdown")
+    check(any(b["block_type"] == "plan" and b["is_plan_review"]
+              for b in state["blocks"]),
+          "the goal blocks list carries the pending plan block")
+    # Reject with feedback: the agent revises.
+    fake.goal_script = [("plan", "# Plan v2\n\n1. Add tests\n2. Add docs\n3. Ship")]
+    act(auth, pending["answer_action"], {"answer": "Add documentation please"})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "plan_review" or False,
+                   timeout=60, what="revised plan enters review") is not None,
+          "feedback sends the agent back to planning; the revision re-enters review")
+    state = goal_state(goal1)
+    revisions = state["plan_revisions"]
+    check(len(revisions) == 2
+          and revisions[0]["answered"] and "Add documentation please" in revisions[0]["answer"]
+          and not revisions[1]["answered"],
+          "rejected revisions stay as answered history (historical-blocks data)")
+    # Approve: goal.plan = v2, status active, agent resumes and parks.
+    fake.goal_script = [("suspend",)]
+    pending = state["plan_pending_block"]
+    act(auth, pending["answer_action"],
+        {"answer": "Plan approved. Proceed with implementation."})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
+                   timeout=60, what="approved plan activates the goal") is not None,
+          "approving the plan activates the goal")
+    state = goal_state(goal1)
+    check("# Plan v2" in state["plan"], "the approved plan markdown is stored on the goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="goal agent parks after approval") is not None,
+          "the resumed goal agent parks (saddle suspend)")
+
+    print("== plan 12: task fan-out via the task CLI callbacks ==")
+    # The goal agent (parked, open session on its saddle) creates,
+    # enqueues and deletes its tasks through /api/saddle/task/*.
+    conn = db()
+    agent_session = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (run_task,)).fetchone()[0]
+    conn.close()
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "Flange tests",
+        "mandate": "Write the test suite for the flange module.",
+        "mode": "test"})
+    check(status == 200 and body.get("ok") is True,
+          "task create under the goal succeeds (%s)" % body.get("error"))
+    fan_task = int(body["task_id"])
+    detail = task_state(auth, fan_task)
+    check(detail["status"] == "draft" and detail["mode"] == "test"
+          and detail["creator"] == "goal" and detail.get("goal_id") == goal1,
+          "the created task is a draft goal task with its mode and creator")
+    check(detail["task_branch"] == "task/%d-%d" % (goal1, fan_task)
+          and detail["branch_from"] == "goal/flange",
+          "goal worker tasks get ephemeral branches cut from the goal branch")
+    check(any(e["kind"] == "task-created" and int(e["task_id"]) == fan_task
+              for e in goal_state(goal1)["events"]),
+          "the narrative records the agent's task creation")
+
+    # Draft until the agent enqueues; humans may not (creator-only).
+    check(detail.get("enqueue") is None,
+          "the human Enqueue action is not offered for goal tasks (creator-only)")
+    status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
+        "session_id": agent_session, "task_id": fan_task})
+    check(status == 200 and task_state(auth, fan_task)["status"] == "queued",
+          "task enqueue by the goal agent queues the draft")
+    # Ownership + shape errors, verbatim for the CLI.
+    status, body = saddle_api("POST", "/api/saddle/task/enqueue", fake_token, {
+        "session_id": agent_session, "task_id": 999999})
+    check(status == 400 and "No task exists" in (body.get("error") or ""),
+          "enqueue of a missing task errors verbatim")
+    create_queued(auth, {"project_id": "1", "title": "Innocent bystander", "priority": "4"})
+    bystander = task_id_of("Innocent bystander")
+    status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
+        "session_id": agent_session, "task_id": bystander})
+    check(status == 400 and "does not belong to your goal" in (body.get("error") or ""),
+          "the goal agent cannot touch tasks outside its goal")
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "Bad mode", "mandate": "m", "mode": "party"})
+    check(status == 400 and "valid modes" in (body.get("error") or ""),
+          "an unknown mode rejects with the valid list")
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "Bad tag", "mandate": "m",
+        "capabilities": ["warp-drive"]})
+    check(status == 400 and "warp-drive" in (body.get("error") or "")
+          and "valid tags" in (body.get("error") or ""),
+          "an unknown capability tag rejects naming the valid set")
+    # A plain task's session is not a goal-agent identity.
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token,
+                              {"session_id": 999999, "title": "x", "mandate": "m"})
+    check(status == 400 and "session" in (body.get("error") or ""),
+          "an unknown session_id errors verbatim")
+    # Delete: draft + owned.
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session, "title": "Scratch task", "mandate": "m"})
+    scratch = int(body["task_id"])
+    status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
+        "session_id": agent_session, "task_id": scratch})
+    check(status == 200 and not any(
+        t["id"] == scratch for t in slot_public(auth, "/tasks", "tasks")["tasks"]),
+        "task delete removes the owned draft")
+    status, body = saddle_api("POST", "/api/saddle/task/delete", fake_token, {
+        "session_id": agent_session, "task_id": fan_task})
+    check(status == 400 and "draft" in (body.get("error") or ""),
+          "deleting a queued task refuses (draft only)")
+
+    print("== plan 12: goal context in the worker assignment + coalesced resume ==")
+    # The queued fan-out task dispatches to the daemon with the goal
+    # reference + ephemeral branch; its completion schedules the parked
+    # goal agent's resume (>= SADDLE_GOAL_RESUME_S after the event).
+    check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == fan_task
+                               for p in fake.start_payloads) or False,
+                   timeout=60, what="fan-out task dispatches") is not None,
+          "the goal worker task dispatches")
+    fan_payload = [p for p in fake.start_payloads
+                   if int(p.get("task", {}).get("id") or 0) == fan_task][-1]
+    goal_ctx = fan_payload.get("goal") or {}
+    check(goal_ctx.get("id") == goal1 and goal_ctx.get("branch") == "goal/flange"
+          and goal_ctx.get("base_branch") == "main"
+          and "flange module" in goal_ctx.get("brief", "")
+          and "# Plan v2" in goal_ctx.get("plan", ""),
+          "the worker assignment carries the goal context (read-only brief + plan)")
+    check(fan_payload.get("ephemeral_branch") == "task/%d-%d" % (goal1, fan_task)
+          and fan_payload.get("kind") == "task" and fan_payload.get("mode") == "test"
+          and fan_payload.get("creator") == "goal",
+          "the worker assignment carries the ephemeral branch + identity triplet")
+    check(wait_task_done(auth, fan_task, timeout=60) is not None,
+          "the goal worker task completes")
+    check(any(e["kind"] == "task-done" and int(e["task_id"]) == fan_task
+              for e in goal_state(goal1)["events"]),
+          "the narrative records the task's completion")
+    goal_runs_before = len(fake.goal_assignments)
+    check(wait_for(lambda: len(fake.goal_assignments) > goal_runs_before or False,
+                   timeout=30, what="parked goal agent resumes") is not None,
+          "the task-terminal event resumes the parked goal agent")
+    resume_payload = fake.goal_assignments[-1]
+    notice = resume_payload.get("resume_notice") or ""
+    check("Task statuses" in notice and "Flange tests" in notice
+          and "done" in notice,
+          "the resume notice batches ALL plan task statuses")
+    check("New completion reports" in notice and "fake daemon finished" in notice,
+          "the resume notice carries the new completion report")
+    check(resume_payload.get("kind") == "goal_run"
+          and resume_payload.get("mode") == "orchestrate",
+          "the resumed goal agent carries its identity triplet")
+    # The resumed agent parked again (script): two events at once must
+    # coalesce into ONE resume within the window.
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="goal agent re-parks") is not None,
+          "the resumed goal agent parks again")
+
+    print("== plan 12: pause freezes supervision; events coalesce on resume ==")
+    state = goal_state(goal1)
+    act(auth, state["pause"], {})
+    check(goal_state(goal1)["status"] == "paused"
+          and goal_state(goal1).get("resume") is not None,
+          "an active goal pauses and offers resume")
+    # Terminal events under a paused goal queue without waking anybody.
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status, priority,"
+                 " summary, completed_at, created_at) values (1, ?, 'task',"
+                 " 'Quiet task', 'done', 5, 'finished while paused',"
+                 " strftime('%s','now','+1 second'), strftime('%s','now'))", (goal1,))
+    conn.commit()
+    conn.close()
+    paused_runs = len(fake.goal_assignments)
+    check(quiet_window(quiet_seconds(),
+                       lambda: len(fake.goal_assignments) == paused_runs,
+                       "paused goal does not resume"),
+          "a paused goal's events queue without scheduling a resume")
+    state = goal_state(goal1)
+    act(auth, state["resume"], {})
+    check(goal_state(goal1)["status"] == "active",
+          "resume returns the goal to active")
+    check(wait_for(lambda: len(fake.goal_assignments) > paused_runs or False,
+                   timeout=30, what="queued events flush on resume") is not None,
+          "the queued events coalesce into the post-resume wake")
+    check("finished while paused" in (fake.goal_assignments[-1].get("resume_notice") or ""),
+          "the flushed notice reports the task that finished while paused")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
+                   or False, timeout=60, what="goal agent parks again") is not None,
+          "the goal agent parks again after the flush")
+
+    print("== plan 12: completion review, push state, accept ==")
+    # The daemon exposes goal-branch push state on /status; the web
+    # caches it on the goal when it probes the goal agent's saddle.
+    def status_extra():
+        return None
+    fake.status_extra = {"goal_branches": [
+        {"branch": "goal/flange", "pushed": True},
+        {"branch": "goal/other-repo", "pushed": False}]}
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["refresh"], {})
+    check(wait_for(lambda: goal_state(goal1).get("push_branches") or False,
+                   timeout=30, what="push state lands on the goal") is not None,
+          "the daemon's /status goal_branches exposure caches on the goal")
+    branches = goal_state(goal1)["push_branches"]
+    check({(b["branch"], b["pushed"]) for b in branches}
+          == {("goal/flange", True), ("goal/other-repo", False)},
+          "the push state renders per branch")
+    fake.status_extra = None
+
+    # `saddle done` in goal context: the completion review.
+    fake.goal_script = [("done", "# Completion\n\nThe flange ships.")]
+    # Wake the parked agent: a pending event has waited out the window
+    # (the pause-flush above consumed the queue, so arm one directly —
+    # the queued 'Quiet task' row was consumed; use a fresh event).
+    conn = db()
+    conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
+                 " values (?, 'task-done', 'Task #999 Warmup — done',"
+                 " strftime('%s','now','-5 seconds'))", (goal1,))
+    conn.commit()
+    conn.close()
+    check(wait_for(lambda: goal_state(goal1)["status"] == "completion_review" or False,
+                   timeout=60, what="goal agent submits its completion") is not None,
+          "`saddle done` in goal context enters completion review")
+    state = goal_state(goal1)
+    check("# Completion" in state["completion_report"]
+          and state["completion_pending"] is True
+          and state["completion_pending_block"] is not None
+          and state["completion_pending_block"] is not False,
+          "the completion review state carries the report + pending block")
+    check(task_state(auth, run_task)["status"] == "blocked",
+          "the goal-run task parks on the completion review")
+    check(state["compare_url"].endswith("/compare/main...goal/flange"),
+          "the completion review links the compare view")
+    # Reject first: feedback returns the goal to active.
+    fake.goal_script = [("suspend",)]
+    act(auth, state["completion_pending_block"]["answer_action"],
+        {"answer": "Not done — the docs are missing"})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "active" or False,
+                   timeout=30, what="rejected completion reactivates") is not None,
+          "rejecting the completion returns the goal to active")
+    # Round 2: submit again and accept.
+    fake.goal_script = [("done", "# Completion v2\n\nThe flange ships, with docs.")]
+    conn = db()
+    conn.execute("insert into goal_events (goal_id, kind, text, created_at)"
+                 " values (?, 'task-done', 'Task #998 Warmup2 — done',"
+                 " strftime('%s','now','-5 seconds'))", (goal1,))
+    conn.commit()
+    conn.close()
+    check(wait_for(lambda: goal_state(goal1)["status"] == "completion_review" or False,
+                   timeout=60, what="resubmitted completion") is not None,
+          "the agent resubmits its completion after feedback")
+    state = goal_state(goal1)
+    act(auth, state["completion_pending_block"]["answer_action"],
+        {"answer": "Review approved. Continue with the task."})
+    check(wait_for(lambda: goal_state(goal1)["status"] == "complete" or False,
+                   timeout=30, what="accepted completion completes") is not None,
+          "accepting the completion completes the goal")
+    state = goal_state(goal1)
+    check(state["terminal"] is True and state.get("cancel") is None
+          and "# Completion v2" in state["completion_report"],
+          "a complete goal is terminal with its report stored")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "done" or False,
+                   timeout=30, what="goal run task closes") is not None,
+          "approving the completion closes the goal-run task done")
+
+    print("== plan 12: cancel cascades to the goal's tasks ==")
+    goal2 = create_goal({"project_id": "1", "title": "Doomed goal",
+                         "brief": "This goal will be cancelled.",
+                         "branch_name": "goal/doomed"})
+    fake.goal_script = [("suspend",)]
+    act(auth, goal_state(goal2)["start"], {})
+    conn = db()
+    run2 = conn.execute("select id from tasks where goal_id = ? and kind = 'goal_run'",
+                        (goal2,)).fetchone()[0]
+    conn.close()
+    check(wait_for(lambda: task_state(auth, run2)["status"] == "suspended" or False,
+                   timeout=60, what="doomed goal agent parks") is not None,
+          "the doomed goal's agent parks")
+    conn = db()
+    agent_session2 = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (run2,)).fetchone()[0]
+    conn.close()
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session2, "title": "Doomed draft", "mandate": "m"})
+    doomed_draft = int(body["task_id"])
+    doomed_queued_task = None
+    status, body = saddle_api("POST", "/api/saddle/task/create", fake_token, {
+        "session_id": agent_session2, "title": "Doomed runner", "mandate": "m"})
+    doomed_runner = int(body["task_id"])
+    saddle_api("POST", "/api/saddle/task/enqueue", fake_token,
+               {"session_id": agent_session2, "task_id": doomed_runner})
+    act(auth, goal_state(goal2)["cancel"], {})
+    check(goal_state(goal2)["status"] == "cancelled",
+          "the goal cancels from active (parked-agent) state")
+    check(task_state(auth, run2)["status"] == "failed",
+          "the cascade cancels the goal agent's run task")
+    check(wait_for(lambda: task_state(auth, doomed_runner)["status"] == "failed"
+                   or False, timeout=30, what="cascade cancels the queued task") is not None,
+          "the cascade cancels the goal's queued task")
+    check(not any(t["id"] == doomed_draft
+                  for t in slot_public(auth, "/tasks", "tasks")["tasks"]),
+          "the cascade deletes the goal's draft tasks")
+    # A cancelled goal frees its branch name.
+    reuse = create_goal({"project_id": "1", "title": "Reused name",
+                         "brief": "b", "branch_name": "goal/doomed"})
+    check(reuse > goal2, "a cancelled goal's branch name is reusable")
+
+    print("== plan 12: auto-enqueue-after for human tasks ==")
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Predecessor task", "priority": "4"})
+    predecessor = task_id_of("Predecessor task")
+    enqueue_task(auth, predecessor)
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Successor task", "priority": "4",
+        "enqueue_after_task_id": str(predecessor)})
+    successor = task_id_of("Successor task")
+    check(task_state(auth, successor)["status"] == "draft"
+          and task_state(auth, successor).get("enqueue_after_task_id") == predecessor,
+          "a human task can carry an auto-enqueue predecessor")
+    check(wait_task_done(auth, predecessor, timeout=60) is not None,
+          "the predecessor completes")
+    check(wait_for(lambda: task_state(auth, successor)["status"] in
+                   ("queued", "assigned", "working") or False,
+                   timeout=30, what="successor auto-enqueues") is not None,
+          "the done predecessor auto-enqueues its dependent")
+    # Cycle validation refuses a predecessor chain that loops.
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Cycle A", "priority": "4"})
+    cycle_a = task_id_of("Cycle A")
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Cycle B", "priority": "4",
+        "enqueue_after_task_id": str(cycle_a)})
+    cycle_b = task_id_of("Cycle B")
+    cycle_directives = act(auth, task_state(auth, cycle_a)["update"], {
+        "title": "Cycle A", "description": "x", "priority": "4",
+        "enqueue_after_task_id": str(cycle_b)})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "cycle" in (d.get("message") or "").lower()
+              for d in cycle_directives),
+          "a predecessor cycle rejects with a naming error")
+    # A failed predecessor leaves the dependent in draft.
+    fake.fail_next = True
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Failing predecessor", "priority": "1"})
+    failing_pred = task_id_of("Failing predecessor")
+    enqueue_task(auth, failing_pred)
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+        "project_id": "1", "title": "Stranded successor", "priority": "4",
+        "enqueue_after_task_id": str(failing_pred)})
+    stranded = task_id_of("Stranded successor")
+    check(wait_for(lambda: task_state(auth, failing_pred)["status"] == "failed"
+                   or False, timeout=60, what="failing predecessor fails") is not None,
+          "the scripted-failure predecessor fails")
+    check(quiet_window(quiet_seconds(),
+                       lambda: task_state(auth, stranded)["status"] == "draft",
+                       "failed predecessor leaves the draft"),
+          "a failed predecessor leaves its dependent in draft")
+
+    # Successor cleanup: let the queued successor finish so the
+    # shard's app shuts down with an empty queue.
+    check(wait_task_done(auth, successor, timeout=90) is not None,
+          "the auto-enqueued successor completes")
+
+
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
-                 "allocation", "lifecycle", "answers", "push")
+                 "allocation", "lifecycle", "answers", "push", "goals")
 
 
 
 
 def run_bundle(name):
 def run_bundle(name):