Эх сурвалжийг харах

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 сар өмнө
parent
commit
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@'],
     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',
     input: 'src/pages/saddles.html',
     output: 'SaddlesPage.vala',
@@ -113,6 +125,7 @@ app_sources = files(
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
+    'src/Services/GoalService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddlePollState.vala',
     'src/Services/AllocationService.vala',
@@ -130,6 +143,7 @@ app_sources = files(
     'src/actions/LoginAction.vala',
     'src/actions/ProjectActions.vala',
     'src/actions/TaskActions.vala',
+    'src/actions/GoalActions.vala',
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
@@ -141,6 +155,7 @@ app_sources = files(
     'src/entrypoints/TasksEntrypoint.vala',
     'src/entrypoints/TaskDetailEntrypoint.vala',
     'src/entrypoints/TaskTerminalEntrypoint.vala',
+    'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     # spry:sources-end
@@ -159,6 +174,8 @@ app_generated = [
     tasks_page,
     task_detail_page,
     task_terminal_page,
+    goals_page,
+    goal_detail_page,
     saddles_page,
     rules_page,
     # 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
      * 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 bool plan_auto_approve { 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) {
             cfg.table("tasks")
                 .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<string>("title", o => o.title, (o, v) => o.title = 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<int64?>("auto_approve", o => o.auto_approve, (o, v) => o.auto_approve = v ?? 0)
                 .column<bool>("plan_auto_approve", o => o.plan_auto_approve, (o, v) => o.plan_auto_approve = v)
-                .column<bool>("auto_enqueue_subtasks", o => o.auto_enqueue_subtasks, (o, v) => o.auto_enqueue_subtasks = v);
+                .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);
         }
     }
+
+    /**
+     * 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
      * earlier migration flattened — the database is not deployed, so
      * tear-down-and-recreate replaced incremental evolution). Covers:
-     * projects and their links; tasks with dependencies, comments,
-     * typed blocks, progress and the draft-lifecycle columns
+     * 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`,
      * `auto_enqueue_subtasks`); saddle registrations with git identity
      * and the per-saddle token; the session + command + raw-log audit
@@ -51,6 +54,64 @@ namespace ClankerSaddle.Data.Migrations {
                     .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) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -61,6 +122,23 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null()
                     .references("projects", "id")
                     .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")
                     .not_null();
                 t.column<string>("description");
@@ -149,6 +227,67 @@ namespace ClankerSaddle.Data.Migrations {
                 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) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -372,6 +511,9 @@ namespace ClankerSaddle.Data.Migrations {
         }
 
         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("push_subscriptions");
             b.drop_table("task_capabilities");
@@ -391,6 +533,8 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_comments");
             b.drop_table("task_dependencies");
             b.drop_table("tasks");
+            b.drop_table("goal_links");
+            b.drop_table("goals");
             b.drop_table("project_links");
             b.drop_table("projects");
         }

+ 23 - 1
src/Services/AllocationService.vala

@@ -74,6 +74,7 @@ namespace ClankerSaddle.Services {
         private TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
+        private GoalService goals = inject<GoalService>();
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
@@ -128,6 +129,20 @@ namespace ClankerSaddle.Services {
                 }
             }
             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)) {
                     var parked = yield parked_offerable(parent, true);
                     if (parked != null) {
@@ -341,7 +356,14 @@ namespace ClankerSaddle.Services {
             bool? branch_advanced = null;
             if (claim.parked) {
                 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);
                     // The parking window is over: a later suspension
                     // 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,
                 int64 task_id, int64 draft_count) {
             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
         // ------------------------------------------------------------------

+ 33 - 6
src/Services/RuleService.vala

@@ -221,6 +221,17 @@ namespace ClankerSaddle.Services {
          */
         public async ImmutableLot<Rule> effective_rules(TaskEntity task,
                 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>();
             // Ids are stringified: the Invercargill collections cannot
             // key (or hash) on value types.
@@ -236,13 +247,29 @@ namespace ClankerSaddle.Services {
                     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 CapabilityService capability_service = inject<CapabilityService>();
         private TaskService task_service = inject<TaskService>();
+        private GoalService goal_service = inject<GoalService>();
         private SubtaskEventQueue subtask_events = inject<SubtaskEventQueue>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
@@ -415,6 +416,34 @@ namespace ClankerSaddle.Services {
             poll_state.record_probe(saddle.id, state, uptime, activity);
             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
             // daemons; marked once per session in SaddlePollState.
             if (status.has_member("resumed")
@@ -667,10 +696,11 @@ namespace ClankerSaddle.Services {
 
         /**
          * 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
          * its pending answers up from the assignment itself), the
          * effective rule set ({@link RuleService.effective_rules}) as
@@ -680,11 +710,12 @@ namespace ClankerSaddle.Services {
          * saddle's `git_identity`, a `capabilities` context (plan 10
          * phase 3: `saddle_tags` — the allocated saddle's own tags —
          * 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
          * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
          * environment.
@@ -706,6 +737,47 @@ namespace ClankerSaddle.Services {
                     @"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();
             payload.begin_object();
             payload.set_member_name("task");
@@ -724,24 +796,6 @@ namespace ClankerSaddle.Services {
             payload.add_string_value(task.task_branch ?? "");
             payload.set_member_name("branch_from");
             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.begin_array();
@@ -794,12 +848,47 @@ namespace ClankerSaddle.Services {
             payload.end_array();
             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
             // 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.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.set_member_name("name");
                 payload.add_string_value(rule.name);
@@ -809,55 +898,6 @@ namespace ClankerSaddle.Services {
             }
             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
             // name/email the daemon commits as, from the saddle row.
             // 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:
-         * `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,
                 TaskService tasks) throws Error {
+            var goal_id = yield goal_id_of_run(task_id);
             foreach (var entry in entries) {
                 string command = string_member(entry, "command");
                 string output = string_member(entry, "output");
@@ -1271,20 +1318,22 @@ namespace ClankerSaddle.Services {
                         }
                         if (body.length > 0) {
                             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;
                     case "plan":
                         string markdown = output.length > 0 ? output
                             : 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;
                     case "progress":
@@ -1303,12 +1352,24 @@ namespace ClankerSaddle.Services {
                         yield tasks.auto_answer_if_covered(review_block);
                         break;
                     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,
                             strip_quotes(strip_verb(command, "saddle done")), null, "done");
                         return true;
                     case "failed":
                         string explanation = output.length > 0 ? output
                             : 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);
                         return true;
                     case "yeehaw":
@@ -1323,6 +1384,25 @@ namespace ClankerSaddle.Services {
             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`
          * 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
          * deleted /status poll switch ran, driven by the daemon's push:
          * `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,
                 string state, Json.Object? block, string? summary, TaskService tasks) throws Error {
             int64 task_id = session.task_id;
+            int64 goal_id = yield goal_id_of_run(task_id);
 
             switch (state) {
                 case "done":
                     if (yield task_is_terminal(task_id)) {
                         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");
                     break;
                 case "failed":
@@ -1520,9 +1614,43 @@ namespace ClankerSaddle.Services {
                         return;
                     }
                     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);
                     break;
                 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());
                     if (!(yield task_is_terminal(task_id))) {
                         // 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
          * 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;
          * `auto_enqueue_subtasks` enqueues subtasks created under it;
          * `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,
                 int64 priority, int64? parent_task_id,
                 string? task_branch = null, string? branch_from = null,
                 bool plan_auto_approve = false, bool auto_enqueue_subtasks = false,
-                bool auto_approve = false, string status = STATUS_DRAFT) throws Error {
+                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 ?? "";
             if (resolved_branch_from.length == 0) {
                 var project = yield db.query<Project>()
@@ -202,6 +239,16 @@ namespace ClankerSaddle.Services {
                     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() {
                 parent_task_id = parent_task_id != null ? ((!)parent_task_id) : 0,
@@ -215,9 +262,27 @@ namespace ClankerSaddle.Services {
                 plan_auto_approve = plan_auto_approve,
                 auto_enqueue_subtasks = auto_enqueue_subtasks,
                 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()
             };
             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");
             note_subtask_event(task);
             return task;
@@ -380,11 +445,14 @@ namespace ClankerSaddle.Services {
          * Saves the human-editable fields of the task identified by `id`
          * under the draft-lifecycle edit matrix: in DRAFT all fields
          * except status are editable (title, description, priority,
-         * branch fields and the three task options); in non-draft
-         * non-terminal states only priority applies; in terminal
-         * states nothing does. Fields not editable in the current
-         * state are silently left unchanged (null arguments always
-         * mean "leave unchanged"; an empty branch field clears it).
+         * 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
          * fires when a value actually moved, so periodic status
          * refreshes do not spam live pages.
@@ -392,7 +460,8 @@ namespace ClankerSaddle.Services {
         public async TaskEntity? edit_task(int64 id, string? title, string? description,
                 int64? priority, string? task_branch, string? branch_from,
                 bool? plan_auto_approve, bool? auto_enqueue_subtasks,
-                bool? auto_approve) throws Error {
+                bool? auto_approve, string? mode = null,
+                int64? enqueue_after_task_id = null) throws Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(id)))
                 .first_async();
@@ -441,6 +510,38 @@ namespace ClankerSaddle.Services {
                     ((!)task).auto_approve = (!)auto_approve ? 1 : 0;
                     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.
             if ((((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED)
@@ -484,16 +585,27 @@ namespace ClankerSaddle.Services {
             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>()
                 .where(expr("id == $0", elem<int64?>(id)))
                 .first_async();
@@ -505,6 +617,10 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 51,
                     "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>();
             foreach (var dep in yield db.query<TaskDependency>()
@@ -529,6 +645,113 @@ namespace ClankerSaddle.Services {
             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
          * dependents. Dependents — tasks holding a dependency edge to
@@ -1322,6 +1545,11 @@ namespace ClankerSaddle.Services {
             push.notify_opted_in(task_id, PushService.lifecycle_notification(
                 ((!)task).title, task_id, "completed", summary));
             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 pulse.bump(task_id, "task cancelled");
             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(
                 ((!)task).title, task_id, "failed", summary));
             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):
-         * 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 {
             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) {
                 foreach (var sibling in yield db.query<TaskEntity>()
                         .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 {
             var queued = yield db.query<TaskEntity>()
@@ -1700,18 +1944,31 @@ namespace ClankerSaddle.Services {
                     if (dep.task_id != task.id) {
                         continue;
                     }
-                    bool dep_done = false;
                     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;
                         }
                     }
-                    if (!dep_done) {
-                        ready = false;
+                    if (!ready) {
                         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) {
                     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 {
+                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,
                     Forms.field_raw(request, "description"),
                     Forms.integer(request, "priority", 5), parent_id,
@@ -95,7 +114,9 @@ namespace ClankerSaddle {
                     Forms.optional(request, "branch_from"),
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
-                    Forms.field(request, "auto_approve") == "on");
+                    Forms.field(request, "auto_approve") == "on",
+                    STATUS_DRAFT, TASK_KIND_TASK, mode, CREATOR_HUMAN, 0,
+                    enqueue_after);
                 if (Forms.field(request, "plan").length > 0) {
                     yield tasks.update_plan(task.id, Forms.field_raw(request, "plan"));
                 }
@@ -139,6 +160,18 @@ namespace ClankerSaddle {
                 if (priority_field.length > 0) {
                     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,
                     Forms.optional(request, "title"),
                     request.form != null ? Forms.field(request, "description") : null,
@@ -149,7 +182,9 @@ namespace ClankerSaddle {
                         ? Forms.field(request, "branch_from") : null,
                     Forms.field(request, "plan_auto_approve") == "on",
                     Forms.field(request, "auto_enqueue_subtasks") == "on",
-                    Forms.field(request, "auto_approve") == "on");
+                    Forms.field(request, "auto_approve") == "on",
+                    mode.length > 0 ? mode : null,
+                    enqueue_after);
                 return directives().notify("success", "Task saved").navigate(@"/tasks/$(task_id)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not save the task: $(e.message)")
@@ -388,7 +423,7 @@ namespace ClankerSaddle {
             }
 
             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)");
             } catch (Error e) {
                 return directives().notify("error", @"Could not enqueue the task: $(e.message)")
@@ -418,6 +453,17 @@ namespace ClankerSaddle {
                 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 {
                 yield tasks.delete_task(task_id);
                 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.DataStructures;
+using Invercargill.Expressions;
 using Spry;
 using Spry.Authentication;
 using Statum;
@@ -141,6 +142,18 @@ namespace ClankerSaddle {
         public string project_name { get; set; default = ""; }
         public string git_url { get; set; default = ""; }
         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 started_display { get; set; default = ""; }
         public string completed_display { get; set; default = ""; }
@@ -223,6 +236,11 @@ namespace ClankerSaddle {
                 project_name = project != null ? ((!)project).name : "",
                 git_url = project != null ? ((!)project).git_url : "",
                 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),
                 started_display = format_time(task.started_at),
                 completed_display = format_time(task.completed_at)
@@ -258,8 +276,14 @@ namespace ClankerSaddle {
             pub.save_plan = actions.author_private<UpdatePlanAction, TaskActionPrivate>(sealed);
             pub.subtask = actions.author_private<CreateSubtaskAction, TaskActionPrivate>(sealed);
             pub.dependency = actions.author_private<AddDependencyAction, TaskActionPrivate>(sealed);
-            pub.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.retry = actions.author_private<RetryTaskAction, 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);
                 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)) {
                 if (row.id != task.id) {
                     candidates.add(new DependencyView() {
@@ -485,6 +543,8 @@ namespace ClankerSaddle {
             props["subtasks"] = StateJson.list<SubtaskView>(subtasks.to_immutable_buffer());
             props["dependencies"] = StateJson.list<DependencyView>(dependencies.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["rule_options"] = StateJson.list<RuleOptionView>(rule_options.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<PushSubscription>(PushSubscription.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();
 
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
+        application.add_scoped<GoalService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<CapabilityService>();
@@ -82,6 +88,8 @@ int main(string[] args) {
         statum.add_page<TasksPage, TasksEntrypoint>();
         statum.add_page<TaskDetailPage, TaskDetailEntrypoint>();
         statum.add_page<TaskTerminalPage, TaskTerminalEntrypoint>();
+        statum.add_page<GoalsPage, GoalsEntrypoint>();
+        statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         // spry:pages-end
@@ -106,6 +114,16 @@ int main(string[] args) {
         statum.action<RetryTaskAction>();
         statum.action<ResumeTaskAction>();
         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<PushUnsubscribeAction>();
         statum.action<ToggleTaskNotifyAction>();
@@ -152,6 +170,12 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/note", Method.POST));
         application.add_endpoint<SaddleSubtaskEndpoint>(
             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>(
             new EndpointRoute("/api/saddle/tasks", Method.GET));
         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-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) */
 .badge-created { background: var(--line-soft); color: var(--ink-muted); }
 .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 -->
             <a href="/projects">Projects</a>
             <a href="/tasks">Tasks</a>
+            <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
             <!-- 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.answer_log = []             # recorded POST /answer bodies
         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):
         """Advances the scripted lifecycle."""
@@ -665,6 +669,13 @@ class FakeSaddle:
             # workspace still exists restores it; the first
             # /status after the restore reports "resumed": 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.fail_next = False
         # 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),
                             "last_activity_epoch": self.daemon.last_activity_epoch,
                             "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:
                     response["resumed"] = True
                     self.daemon.resumed_pending = False
@@ -843,7 +856,45 @@ class FakeSaddle:
                         self.maybe_trigger_ask("terminal")
             elif self.state == "working":
                 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
                         # resumes the workspace (title-targeted one-shot,
                         # so an interleaved dispatch cannot eat the flag).
@@ -914,6 +965,9 @@ def start_app():
         "SADDLE_LOST_WAKE_S": "2",
         "SADDLE_STALE_S": "3",
         "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
         # resolves the ancient /lib64 copies): empty makes the loader use
         # 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"]),
           "suspension frees the saddle")
     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):
     # 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
           and "the harness refused to start" in 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
     # round still runs — the operator's manual refresh in the push
     # world.
@@ -3051,18 +3106,8 @@ def bundle_lineage(ctx):
           "lineage subtask dispatches")
     lineage_push = [p for p in fake.start_payloads
                     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
                    if int(p.get("task", {}).get("id") or 0) == auto_parent]
     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")
 
 
+# =============================================================================
+# 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",
-                 "allocation", "lifecycle", "answers", "push")
+                 "allocation", "lifecycle", "answers", "push", "goals")
 
 
 def run_bundle(name):