Ver Fonte

Merge branch 'goal/advisor' of clanker/clanker-saddle into web-stack

Billy Barrow há 4 semanas atrás
pai
commit
1df936c038

+ 55 - 0
README.md

@@ -403,6 +403,61 @@ Dispatches also carry the primary and co-developed projects' notes (up to
 30 entries / 20 000 characters, oldest first) so agents start with the
 accumulated project knowledge.
 
+## AI Chat (advisor)
+
+**AI Chat** is a conversational advisor inside the web app: an LLM loop
+that can READ the fleet's state — projects, tasks, goals, rules,
+discoveries and capability tags — through a read-only tool set, ask you
+questions mid-chat, and SUGGEST tasks or goals for your approval. It
+can never run commands, edit files or dispatch work itself; nothing is
+created until you approve a suggestion in the chat UI.
+
+The dashboard's top carries the **Ask the advisor** composer: type your
+question and you land on a new chat session (`/chats/{id}`). The
+**/chats** page lists your conversations (with per-row delete); chats
+are **private per user** — nobody else can see, continue or delete
+yours. Note that what the advisor READS is the whole fleet's work state
+(projects, tasks and goals are not per-user in this app), the same as
+every other page.
+
+**Questions.** When the advisor needs your decision it asks a
+multiple-choice question (with an "Other" free-text box, like the
+saddle's own question tool): the chat parks until you answer, and the
+answer resumes the turn.
+
+**Suggestions and approvals.** The advisor proposes work as a card in
+the chat — a task (title, mandate, mode, project, priority) or a goal
+(title, brief, project, branch). The card is inert until you decide:
+
+- **Approve** (task) → a draft task is created, creator *human* — you
+  enqueue it yourself whenever you like.
+- **Approve & enqueue** (task) → the draft is created and immediately
+  queued for a saddle.
+- **Approve** (goal) → a draft goal is created, **unqueued** — the
+  advisor never starts a goal; you start it from the goal page.
+- **Decline** → the card is dismissed, nothing is created.
+
+**Completion callback.** A periodic pass (every 15 s,
+`ADVISOR_CALLBACK_TICK_S`) watches every approved task suggestion for
+its task's terminal state: when the task completes or fails, a system
+message lands in the originating chat (the task's summary included),
+the page refreshes through the state pulse, and the advisor is woken
+to react — report the result back, suggest follow-ups — so the
+conversation continues from real outcomes.
+
+**Configuration.** The advisor uses its own OpenAI-compatible
+chat-completions endpoint, falling back to the daemon-facing one:
+
+```bash
+export ADVISOR_LLM_BASE_URL=http://127.0.0.1:9999
+export ADVISOR_LLM_API_KEY=…        # optional
+export ADVISOR_LLM_MODEL=mock       # optional
+```
+
+When neither `ADVISOR_LLM_BASE_URL` nor `SADDLE_LLM_BASE_URL` resolves,
+the composer disables itself and the chat pages show a "not configured"
+notice naming the variable to set — nothing else changes.
+
 ## Integration testing
 
 `test/integration_test.py` drives BOTH real binaries — this app and a

+ 22 - 0
meson.build

@@ -128,6 +128,18 @@ discoveries_page = custom_target('discoveries-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'DiscoveriesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+chats_page = custom_target('chats-page',
+    input: 'src/pages/chats.html',
+    output: 'ChatsPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ChatsPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+chat_detail_page = custom_target('chat-detail-page',
+    input: 'src/pages/chat_detail.html',
+    output: 'ChatDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ChatDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 users_page = custom_target('users-page',
     input: 'src/pages/users.html',
     output: 'UsersPage.vala',
@@ -193,6 +205,7 @@ app_sources = files(
     'src/Data/Migrations/M0003_EventIndexes.vala',
     'src/Data/Migrations/M0004_ProjectUnderstandings.vala',
     'src/Data/Migrations/M0005_Discoveries.vala',
+    'src/Data/Migrations/M0006_Advisor.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -211,7 +224,11 @@ app_sources = files(
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
+    'src/Services/ChatService.vala',
+    'src/Services/AdvisorService.vala',
+    'src/Services/AdvisorCallback.vala',
     'src/Api/SaddleApi.vala',
+    'src/Api/AdvisorApi.vala',
     'src/Api/EventLogApi.vala',
     'src/Api/PushApi.vala',
     'src/actions/Forms.vala',
@@ -223,6 +240,7 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/DiscoveryActions.vala',
+    'src/actions/AdvisorActions.vala',
     'src/actions/StackActions.vala',
     'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
@@ -239,6 +257,8 @@ app_sources = files(
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     'src/entrypoints/DiscoveriesEntrypoint.vala',
+    'src/entrypoints/ChatsEntrypoint.vala',
+    'src/entrypoints/ChatDetailEntrypoint.vala',
     'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
@@ -263,6 +283,8 @@ app_generated = [
     saddles_page,
     rules_page,
     discoveries_page,
+    chats_page,
+    chat_detail_page,
     stacks_page,
     stack_detail_page,
     users_page,

+ 229 - 0
src/Api/AdvisorApi.vala

@@ -0,0 +1,229 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillSql.Orm;
+using Inversion;
+using Astralis;
+using Soup;
+using Spry;
+using Spry.Authentication;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle.Api {
+
+    /**
+     * The advisor wire (goal 27, task 1 — backend core): the JSON
+     * surface the chat UI's actions will sit on. The turn loop itself
+     * is {@link AdvisorService}'s; these endpoints only OWN the chat
+     * (login-gated, per-user private), record the incoming user
+     * message and kick the background turn — the POST returns
+     * immediately, and appends land as StatePulse bumps the chat page
+     * refreshes on.
+     *
+     * POST /api/advisor/chat          {title?}            -> {ok, chat_id}
+     * POST /api/advisor/turn          {chat_id, message?} -> {ok} (async)
+     * POST /api/advisor/answer        {chat_id, answer}   -> {ok} (async)
+     * GET  /api/advisor/chat/{id}                          -> {ok, chat, messages, suggestions}
+     * POST /api/advisor/chat/delete   {chat_id}           -> {ok}
+     */
+    public class AdvisorChatEndpoint : Object, Endpoint {
+
+        private AdvisorService advisor = inject<AdvisorService>();
+        private ChatService chats = inject<ChatService>();
+        private Statum.HeldSlotResolver resolver = inject<Statum.HeldSlotResolver>();
+        private Spry.Authentication.UserService users =
+            inject<Spry.Authentication.UserService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            // Astralis stringifies the method enum as
+            // "ASTRALIS_METHOD_POST" — match the tail, never the bare
+            // verb.
+            string method = http_context.request.method.to_string().down();
+            if (method.has_prefix("astralis_method_")) {
+                method = method.substring("astralis_method_".length);
+            }
+            string path = http_context.request.raw_path;
+
+            // Login gate: every advisor route serves the signed-in
+            // user's OWN chats only. The slot header the browser sends
+            // resolves through the same HeldSlotResolver the actions
+            // use; the auth slot's public data IS the CurrentUser.
+            // The auth slot's PUBLIC data carries the authenticated
+            // username (not the numeric id): resolve the id through
+            // UserService. The slot's signature already proved the
+            // session is genuine; the lookup only maps name -> row.
+            string auth_username = "";
+            var resolved = resolver.resolve(http_context);
+            foreach (var pair in resolved.held) {
+                if (pair.value == null || pair.value.type_name != "auth") {
+                    continue;
+                }
+                var props = ((!)pair.value).@public;
+                if (props != null) {
+                    var name = Statum.GObjectMapping.get_string(((!)props), "username");
+                    auth_username = name ?? "";
+                }
+            }
+            if (auth_username.length == 0) {
+                return SaddleWire.json_error(401, "Sign in to use the advisor");
+            }
+            var user = yield users.get_user_by_username(auth_username);
+            if (user == null) {
+                return SaddleWire.json_error(401, "Sign in to use the advisor");
+            }
+            int64 user_id = ((!)user).id;
+
+            if (method == "post" && (path.has_suffix("/api/advisor/chat")
+                    || path.has_suffix("/api/advisor/chats"))) {
+                var body = yield SaddleWire.read_json_body(http_context);
+                string title = body.has_member("title")
+                    && body.get_member("title").get_node_type() == Json.NodeType.VALUE
+                    && body.get_member("title").get_string() != null
+                    ? (!)body.get_member("title").get_string() : "";
+                var chat = yield chats.create_chat(user_id, title);
+                var reply = new Json.Object();
+                reply.set_int_member("chat_id", chat.id);
+                return SaddleWire.json_ok(reply);
+            }
+            if (method == "post" && path.has_suffix("/api/advisor/chat/delete")) {
+                var body = yield SaddleWire.read_json_body(http_context);
+                int64 chat_id = json_int(body, "chat_id");
+                yield chats.delete_chat(chat_id, user_id);
+                return SaddleWire.json_ok(new Json.Object());
+            }
+            if (method == "post" && path.has_suffix("/api/advisor/turn")) {
+                var body = yield SaddleWire.read_json_body(http_context);
+                int64 chat_id = json_int(body, "chat_id");
+                if ((yield chats.get_chat(chat_id, user_id)) == null) {
+                    return SaddleWire.json_error(404,
+                        @"No advisor chat exists with id $(chat_id)");
+                }
+                string message = json_string(body, "message").strip();
+                if (message.length > 0) {
+                    yield chats.record_user_message(chat_id, message);
+                }
+                advisor.run_turn.begin(chat_id, user_id);
+                return SaddleWire.json_ok(new Json.Object());
+            }
+            if (method == "post" && path.has_suffix("/api/advisor/answer")) {
+                var body = yield SaddleWire.read_json_body(http_context);
+                int64 chat_id = json_int(body, "chat_id");
+                string answer = json_string(body, "answer").strip();
+                bool accepted = yield advisor.answer_question(chat_id, user_id, answer);
+                if (!accepted) {
+                    return SaddleWire.json_error(409,
+                        "The chat has no pending question to answer");
+                }
+                return SaddleWire.json_ok(new Json.Object());
+            }
+            return SaddleWire.json_error(404, "Unknown advisor route");
+        }
+
+        private static int64 json_int(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return 0;
+            }
+            var node = body.get_member(member);
+            return node.get_node_type() == Json.NodeType.VALUE ? node.get_int() : 0;
+        }
+
+        private static string json_string(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return "";
+        }
+    }
+
+    /**
+     * GET /api/advisor/chat/{id}: the chat's full state — the chat
+     * row, its transcript (with parsed payloads) and its suggestions —
+     * for the owning user only (404 for anyone else, matching the
+     * privacy model the pages will render).
+     */
+    public class AdvisorChatDetailEndpoint : Object, Endpoint {
+
+        private ChatService chats = inject<ChatService>();
+        private Spry.Authentication.UserService users =
+            inject<Spry.Authentication.UserService>();
+        private Statum.HeldSlotResolver resolver = inject<Statum.HeldSlotResolver>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            // The auth slot's PUBLIC data carries the authenticated
+            // username (not the numeric id): resolve the id through
+            // UserService. The slot's signature already proved the
+            // session is genuine; the lookup only maps name -> row.
+            string auth_username = "";
+            var resolved = resolver.resolve(http_context);
+            foreach (var pair in resolved.held) {
+                if (pair.value == null || pair.value.type_name != "auth") {
+                    continue;
+                }
+                var props = ((!)pair.value).@public;
+                if (props != null) {
+                    var name = Statum.GObjectMapping.get_string(((!)props), "username");
+                    auth_username = name ?? "";
+                }
+            }
+            if (auth_username.length == 0) {
+                return SaddleWire.json_error(401, "Sign in to use the advisor");
+            }
+            var user = yield users.get_user_by_username(auth_username);
+            if (user == null) {
+                return SaddleWire.json_error(401, "Sign in to use the advisor");
+            }
+            int64 user_id = ((!)user).id;
+            string id_text = "";
+            route_context.mapped_parameters.try_get("id", out id_text);
+            int64 chat_id = int64.parse(id_text);
+
+            var chat = yield chats.get_chat(chat_id, user_id);
+            if (chat == null) {
+                return SaddleWire.json_error(404,
+                    @"No advisor chat exists with id $(chat_id)");
+            }
+            var body = new Json.Object();
+            var chat_object = new Json.Object();
+            chat_object.set_int_member("id", ((!)chat).id);
+            chat_object.set_string_member("title", ((!)chat).title);
+            chat_object.set_string_member("status", ((!)chat).status);
+            body.set_object_member("chat", chat_object);
+
+            var messages = new Json.Array();
+            foreach (var row in yield chats.list_messages(chat_id)) {
+                var element = new Json.Object();
+                element.set_int_member("id", row.id);
+                element.set_string_member("kind", row.kind);
+                element.set_string_member("content", row.content);
+                if (row.payload != null) {
+                    element.set_string_member("payload", (!)row.payload);
+                }
+                if (row.answer != null) {
+                    element.set_string_member("answer", (!)row.answer);
+                }
+                messages.add_object_element(element);
+            }
+            body.set_array_member("messages", messages);
+
+            var suggestions = new Json.Array();
+            foreach (var row in yield chats.list_suggestions(chat_id)) {
+                var element = new Json.Object();
+                element.set_int_member("id", row.id);
+                element.set_string_member("kind", row.kind);
+                element.set_string_member("payload", row.payload);
+                element.set_string_member("status", row.status);
+                element.set_int_member("task_id", row.task_id);
+                element.set_int_member("goal_id", row.goal_id);
+                suggestions.add_object_element(element);
+            }
+            body.set_array_member("suggestions", suggestions);
+            return SaddleWire.json_ok(body);
+        }
+    }
+}

+ 109 - 0
src/Data/Entities.vala

@@ -688,4 +688,113 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One advisor chat (goal 27): a private conversation between a
+     * user and the read-only AI advisor loop. `status` is the loop's
+     * coarse state — 'idle', 'running' (a turn is in flight) or
+     * 'awaiting_answer' (the loop parked on an ask_question). Stored
+     * in the `advisor_chats` table.
+     */
+    public class AdvisorChat : Object {
+
+        public int64 id { get; set; }
+        public int64 user_id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "idle"; }
+        public DateTime? created_at { get; set; default = null; }
+        public DateTime? updated_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<AdvisorChat> cfg) {
+            cfg.table("advisor_chats")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
+                .column<string>("title", o => o.title, (o, v) => o.title = v)
+                .column<string>("status", o => o.status, (o, v) => o.status = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .column<DateTime?>("updated_at", o => o.updated_at, (o, v) => o.updated_at = v);
+        }
+    }
+
+    /**
+     * One row of an advisor chat's transcript (goal 27) — the
+     * conversation stored IN THE WIRE FORMAT so the OpenAI messages
+     * array reconstructs from rows alone on every turn (no separate
+     * transcript cache; a restart loses nothing). `kind` is 'user',
+     * 'assistant', 'tool', 'error' (a failed turn's message) or
+     * 'system' (an advisor-external notice such as a task completion
+     * callback). A tool row carries the call's name/arguments/result
+     * in the JSON `payload`; a question is an assistant row whose
+     * payload holds `{"options": [...]}` and whose `answer` is filled
+     * when the human replies. Stored in the `advisor_messages` table.
+     */
+    public class AdvisorMessage : Object {
+
+        public int64 id { get; set; }
+        public int64 chat_id { get; set; }
+        public string kind { get; set; default = "user"; }
+        public string content { get; set; default = ""; }
+        /** JSON: a tool row's name/arguments/result; a question's options. */
+        public string? payload { get; set; default = null; }
+        /** The human's answer when this row is a parked question. */
+        public string? answer { get; set; default = null; }
+        public DateTime? answered_at { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<AdvisorMessage> cfg) {
+            cfg.table("advisor_messages")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("chat_id", o => o.chat_id, (o, v) => o.chat_id = v)
+                .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
+                .column<string>("content", o => o.content, (o, v) => o.content = v)
+                .column<string>("payload", o => o.payload, (o, v) => o.payload = v)
+                .column<string>("answer", o => o.answer, (o, v) => o.answer = v)
+                .column<DateTime?>("answered_at", o => o.answered_at, (o, v) => o.answered_at = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * One advisor suggestion (goal 27): a task or goal the advisor
+     * proposed mid-chat. INERT while 'pending' — nothing is created
+     * until the chat's user approves it in the UI ('approved' /
+     * 'declined'); an approved row keeps the created
+     * {@link task_id}/{@link goal_id} so the completion callback finds
+     * the chat again. `delivered_at` marks the callback delivered.
+     * `payload` is the JSON the approve action creates from (title,
+     * mandate/brief, mode, project_id, priority, branch_name…).
+     * Stored in the `advisor_suggestions` table.
+     */
+    public class AdvisorSuggestion : Object {
+
+        public int64 id { get; set; }
+        public int64 chat_id { get; set; }
+        public int64 user_id { get; set; }
+        /** "task" or "goal". */
+        public string kind { get; set; default = "task"; }
+        public string payload { get; set; default = "{}"; }
+        public string status { get; set; default = "pending"; }
+        public int64 task_id { get; set; default = 0; }
+        public int64 goal_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+        public DateTime? decided_at { get; set; default = null; }
+        public DateTime? delivered_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<AdvisorSuggestion> cfg) {
+            cfg.table("advisor_suggestions")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("chat_id", o => o.chat_id, (o, v) => o.chat_id = v)
+                .column<int64?>("user_id", o => o.user_id, (o, v) => o.user_id = v)
+                .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
+                .column<string>("payload", o => o.payload, (o, v) => o.payload = v)
+                .column<string>("status", o => o.status, (o, v) => o.status = v)
+                .column<int64?>("task_id", o => (int64?) o.task_id,
+                    (o, v) => o.task_id = v ?? 0)
+                .column<int64?>("goal_id", o => (int64?) o.goal_id,
+                    (o, v) => o.goal_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .column<DateTime?>("decided_at", o => o.decided_at, (o, v) => o.decided_at = v)
+                .column<DateTime?>("delivered_at", o => o.delivered_at, (o, v) => o.delivered_at = v);
+        }
+    }
+
 }

+ 145 - 0
src/Data/Migrations/M0006_Advisor.vala

@@ -0,0 +1,145 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 27 (the advisor): the AI Chat loop's storage — chats,
+     * messages and suggestions.
+     *
+     * advisor_chats is one conversation between a user and the
+     * read-only advisor LLM loop; `status` is the loop's coarse state
+     * ('idle' — no turn in flight, 'running' — a turn is executing,
+     * 'awaiting_answer' — the loop parked on an ask_question and waits
+     * for the human's answer).
+     *
+     * advisor_messages is the conversation stored IN THE WIRE FORMAT:
+     * user / assistant / tool rows reconstruct the OpenAI messages
+     * array on every turn (tool rows carry the call's name, arguments
+     * and result in the JSON `payload`), so a restart loses nothing —
+     * there is no separate transcript cache. 'error' rows carry a
+     * failed turn's message; 'system' rows carry advisor-external
+     * notices (task completion callbacks). A question is an assistant
+     * row whose payload holds the options array and whose `answer`
+     * column is filled when the human replies.
+     *
+     * advisor_suggestions is a suggested task or goal: INERT until the
+     * chat's user approves it in the UI ('pending' → 'approved' or
+     * 'declined'); an approved row keeps the created task_id / goal_id
+     * so the completion callback (task 3 of the goal) can find the chat
+     * again. delivered_at marks the callback delivered.
+     *
+     * IDEMPOTENCE: same contract as M0002–M0005 — up() runs before the
+     * apply transaction opens (the builder only queues), so it
+     * introspects sqlite_master through a separate read-only handle and
+     * queues the tables only when the schema still lacks them.
+     */
+    public class M0006_Advisor : Migration {
+
+        public override string name { get { return "Advisor chats"; } }
+        public override uint64 serial { get { return 6; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // Same resolution as M0002–M0005: the runner never hands up()
+        // a connection, so the schema is read through a read-only
+        // handle on the SPRY_DB_PATH main.vala connects to.
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool table_exists(string name) {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            statement.bind_text(1, name, -1);
+            if (statement.step() != Sqlite.ROW) {
+                return false;
+            }
+            return statement.column_int64(0) > 0;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (table_exists("advisor_chats")) {
+                return;
+            }
+            b.create_table("advisor_chats", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("user_id")
+                    .not_null();
+                t.column<string>("title")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("status")
+                    .not_null()
+                    .default_value<string>("idle");
+                t.column<DateTime?>("created_at");
+                t.column<DateTime?>("updated_at");
+            });
+            b.create_table("advisor_messages", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("chat_id")
+                    .not_null();
+                t.column<string>("kind")
+                    .not_null()
+                    .default_value<string>("user");
+                t.column<string>("content")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<string>("payload");
+                t.column<string>("answer");
+                t.column<DateTime?>("answered_at");
+                t.column<DateTime?>("created_at");
+            });
+            b.create_table("advisor_suggestions", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("chat_id")
+                    .not_null();
+                t.column<int64?>("user_id")
+                    .not_null();
+                t.column<string>("kind")
+                    .not_null()
+                    .default_value<string>("task");
+                t.column<string>("payload")
+                    .not_null()
+                    .default_value<string>("{}");
+                t.column<string>("status")
+                    .not_null()
+                    .default_value<string>("pending");
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .default_value<int64?>(0);
+                t.column<int64?>("goal_id")
+                    .not_null()
+                    .default_value<int64?>(0);
+                t.column<DateTime?>("created_at");
+                t.column<DateTime?>("decided_at");
+                t.column<DateTime?>("delivered_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("advisor_suggestions");
+            b.drop_table("advisor_messages");
+            b.drop_table("advisor_chats");
+        }
+    }
+}

+ 200 - 0
src/Services/AdvisorCallback.vala

@@ -0,0 +1,200 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The advisor completion callback (goal 27): a periodic pass that
+     * watches every APPROVED task suggestion for its task's terminal
+     * state and, on the first sighting, delivers a system message into
+     * the originating chat ("Task #12 completed: …" or the failed
+     * variant), stamps `delivered_at` (the never-twice guard) and
+     * bumps StatePulse so the chat page refreshes. The delivery also
+     * KICKS an advisor turn, so the model reads the result from its
+     * transcript (system rows ride the wire as bracketed user
+     * notices) and can report back or suggest follow-ups — the
+     * "task completion calls back to the advisor" arc of the goal
+     * brief.
+     *
+     * The pass itself is deliberately dumb: it never starts, stops or
+     * edits work; it only observes terminal statuses other actors
+     * (saddles, the human) already produced. Goal suggestions carry
+     * no callback — goals are created unqueued and the human drives
+     * them, so there is no completion to report into the chat.
+     *
+     * Each pass resolves fresh request-scoped services (no database
+     * scope is held between ticks) and never overlaps with itself,
+     * like the {@link Dispatcher}.
+     */
+    public class AdvisorCallback : Object {
+
+        private Container container = inject<Container>();
+        private bool scanning = false;
+
+        /** ADVISOR_CALLBACK_TICK_S (default 15s): the pass interval. */
+        public static uint tick_seconds() {
+            var raw = Environment.get_variable("ADVISOR_CALLBACK_TICK_S") ?? "";
+            int64 parsed = int64.parse(raw);
+            return parsed > 0 ? (uint) parsed : 15;
+        }
+
+        /** Starts the repeating timer. */
+        public void start() {
+            uint tick = tick_seconds();
+            Timeout.add_seconds(tick, () => {
+                scan_once.begin();
+                return true;
+            });
+            print("[ClankerSaddle] Advisor callback running (%us interval)\n",
+                tick);
+        }
+
+        /** One pass (the timer body; also the test seam). */
+        public async void scan_once() {
+            if (scanning) {
+                return;
+            }
+            scanning = true;
+            try {
+                yield scan_approved();
+            } finally {
+                scanning = false;
+            }
+        }
+
+        /**
+         * Every approved suggestion with a task and no delivery yet.
+         * The null check happens in Vala, not the DSL — nullable
+         * column comparisons in `expr` are fragile, and the row count
+         * is tiny either way.
+         */
+        private async void scan_approved() {
+            // chat_id -> the kicked flag: one turn per chat per pass,
+            // however many suggestions delivered.
+            var kicked = new Invercargill.DataStructures.HashSet<string>();
+            var scope = container.create_scope();
+            ImmutableLot<AdvisorSuggestion> rows;
+            try {
+                rows = yield scope.resolve<OrmSession>()
+                    .query<AdvisorSuggestion>()
+                    .where(expr("status == $0 && task_id > 0",
+                        elem<string?>("approved")))
+                    .order_by(expr("id"))
+                    .materialise_async();
+            } catch (Error e) {
+                scope.release();
+                warning("[ClankerSaddle] Advisor callback scan failed: %s",
+                    e.message);
+                return;
+            }
+            scope.release();
+
+            foreach (var row in rows) {
+                if (row.delivered_at != null) {
+                    // Already delivered — the never-twice guard.
+                    continue;
+                }
+                if (kicked.contains(row.chat_id.to_string())) {
+                    continue;
+                }
+                try {
+                    if (yield deliver(row)) {
+                        kicked.add(row.chat_id.to_string());
+                    }
+                } catch (Error e) {
+                    warning("[ClankerSaddle] Advisor callback deliver failed"
+                        + " for suggestion %lld: %s", row.id, e.message);
+                }
+            }
+        }
+
+        /**
+         * Delivers `row`'s terminal notice when its task has finished:
+         * appends the system message, stamps `delivered_at`, bumps the
+         * pulse and kicks the advisor turn. Returns false (changing
+         * nothing) while the task is still live or the chat is gone.
+         */
+        private async bool deliver(AdvisorSuggestion row) throws Error {
+            var scope = container.create_scope();
+            try {
+                var db = scope.resolve<OrmSession>();
+                var chats = scope.resolve<ChatService>();
+                var advisor = scope.resolve<AdvisorService>();
+                var pulse = scope.resolve<StatePulse>();
+
+                var task = yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(row.task_id)))
+                    .first_async();
+                if (task == null) {
+                    // The task vanished (a test reset, a manual wipe):
+                    // stamp the delivery so the pass is not stuck on a
+                    // ghost forever.
+                    row.delivered_at = new DateTime.now_local();
+                    yield db.update_async<AdvisorSuggestion>(row);
+                    return false;
+                }
+                bool done = ((!)task).status == STATUS_DONE;
+                bool failed = ((!)task).status == STATUS_FAILED;
+                if (!done && !failed) {
+                    return false;
+                }
+                // The chat must still exist and belong to the
+                // suggestion's user (delete_chat cascades the row, so
+                // a missing chat here is a race at worst).
+                var chat = yield chats.get_chat(row.chat_id, row.user_id);
+                if (chat == null) {
+                    row.delivered_at = new DateTime.now_local();
+                    yield db.update_async<AdvisorSuggestion>(row);
+                    return false;
+                }
+
+                string label = done ? "completed" : "failed";
+                string summary = summary_line(((!)task).summary,
+                    ((!)task).description);
+                string notice = summary.length > 0
+                    ? @"Task #$(row.task_id) $(label): $(summary)"
+                    : @"Task #$(row.task_id) $(label).";
+
+                // Stamp FIRST: the turn below runs concurrently with
+                // the next pass, and delivered_at is the guard.
+                row.delivered_at = new DateTime.now_local();
+                yield db.update_async<AdvisorSuggestion>(row);
+                yield chats.record_system_message(row.chat_id, notice);
+                yield pulse.bump(row.task_id,
+                    @"advisor callback task #$(row.task_id) $(label)");
+                // The advisor reacts to the result (the system row
+                // rides its wire as a bracketed notice).
+                advisor.run_turn.begin(row.chat_id, row.user_id);
+                return true;
+            } finally {
+                scope.release();
+            }
+        }
+
+        /**
+         * The message's summary half: the task's own summary when it
+         * has one, else the first meaningful line of its description,
+         * clamped to one line (~160 chars).
+         */
+        private static string summary_line(string? summary, string? description) {
+            string source = summary != null && ((!)summary).strip().length > 0
+                ? ((!)summary).strip()
+                : (description ?? "").strip();
+            if (source.length == 0) {
+                return "";
+            }
+            foreach (string line in source.split("\n")) {
+                string trimmed = line.strip();
+                if (trimmed.length > 0) {
+                    return trimmed.length > 160
+                        ? trimmed.substring(0, 160) + "…" : trimmed;
+                }
+            }
+            return "";
+        }
+    }
+}

+ 1284 - 0
src/Services/AdvisorService.vala

@@ -0,0 +1,1284 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using Soup;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The advisor's OpenAI-compatible endpoint (goal 27): the same
+     * shape as {@link SaddleService.LlmEndpoint} but resolved from
+     * ADVISOR_LLM_* first, falling back to SADDLE_LLM_* so an
+     * operator with one provider configures nothing extra.
+     */
+    public class AdvisorLlmEndpoint : Object {
+
+        public string base_url { get; set; default = ""; }
+        public string api_key { get; set; default = ""; }
+        public string model { get; set; default = ""; }
+
+        public static string first_non_empty(string primary, string fallback) {
+            var value = (Environment.get_variable(primary) ?? "").strip();
+            if (value.length > 0) {
+                return value;
+            }
+            return (Environment.get_variable(fallback) ?? "").strip();
+        }
+
+        /** The configured endpoint, or null when no base URL resolves. */
+        public static AdvisorLlmEndpoint? from_environment() {
+            var base_url = first_non_empty("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL");
+            if (base_url.length == 0) {
+                return null;
+            }
+            return new AdvisorLlmEndpoint() {
+                base_url = base_url,
+                api_key = first_non_empty("ADVISOR_LLM_API_KEY", "SADDLE_LLM_API_KEY"),
+                model = first_non_empty("ADVISOR_LLM_MODEL", "SADDLE_LLM_MODEL")
+            };
+        }
+    }
+
+    /**
+     * Content, tool calls and finish_reason of one advisor
+     * chat-completions reply (the slice of the wire format the loop
+     * needs; usage counters are not consumed).
+     */
+    public class AdvisorReply : Object {
+        public string content = "";
+        public string finish_reason = "";
+        public AdvisorToolCall[] tool_calls = new AdvisorToolCall[0];
+    }
+
+    /** One tool call parsed out of a reply. */
+    public class AdvisorToolCall : Object {
+        public string id = "";
+        public string name = "";
+        /** The raw arguments JSON string (verbatim to the wire). */
+        public string arguments = "{}";
+    }
+
+    /**
+     * The advisor loop (goal 27): a chat-bound LLM turn runner over
+     * the clanker-saddle state. A singleton (a turn outlives its
+     * request): `run_turn`/`answer_question` return immediately and
+     * the turn executes in its OWN container scope — the
+     * {@link PushService} pattern — appending advisor_messages rows
+     * and bumping StatePulse as it goes, so the chat page's sealed
+     * refresh renders progress without any long-held request.
+     *
+     * The loop is strictly READ-ONLY over work state: tools call the
+     * existing services, and the only writes are chat rows and
+     * pending suggestion rows — nothing is created until the human
+     * approves a suggestion in the UI (a later task). ask_question
+     * parks the chat ('awaiting_answer'); the answer action calls
+     * {@link answer_question} to record it and run the next turn.
+     *
+     * Per-chat lock: an in-memory running set — a second trigger
+     * while a chat's turn is in flight is a no-op (the answer action
+     * re-runs after parking). Iterations are capped so a chatty model
+     * cannot loop forever; any failure appends a kind='error' message
+     * and returns the chat to 'idle'.
+     */
+    public class AdvisorService : Object {
+
+        /** Upper bound of LLM round trips in one turn. */
+        private const int MAX_ITERATIONS = 12;
+
+        /** Long text fields fed to tools are clamped to this. */
+        private const int TEXT_CLAMP_CHARS = 2000;
+
+        /** List-shaped tool results cap their row counts to this. */
+        private const int LIST_CLAMP_ROWS = 50;
+
+        private Container container = inject<Container>();
+        private StatePulse pulse = inject<StatePulse>();
+        private Soup.Session http = new Soup.Session() {
+            timeout = 120,
+            idle_timeout = 15
+        };
+
+        /** The chats whose turn is currently executing (the lock). */
+        private Invercargill.DataStructures.HashSet<string> running =
+            new Invercargill.DataStructures.HashSet<string>();
+
+        /** True when an LLM endpoint resolves from the environment. */
+        public bool configured {
+            get {
+                return AdvisorLlmEndpoint.from_environment() != null;
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Turn entry points
+        // ------------------------------------------------------------------
+
+        /**
+         * Runs one turn for `chat_id` (owned by `user_id`): the user
+         * message is ALREADY written by the action — this only runs
+         * the loop. Returns immediately; appends land as they happen.
+         * A turn already in flight for the chat is a no-op.
+         */
+        public async void run_turn(int64 chat_id, int64 user_id) {
+            if (try_lock(chat_id)) {
+                yield turn_locked(chat_id, user_id);
+            }
+        }
+
+        /**
+         * Records the human's answer to the chat's pending question
+         * (option match → "Human selected: X"; free text → "Human
+         * answered: …" — the saddle wording) as a user message, then
+         * runs the next turn. Returns false when there is no pending
+         * question (or the chat is not the user's / nothing to do).
+         */
+        public async bool answer_question(int64 chat_id, int64 user_id,
+                string answer) throws Error {
+            var scope = container.create_scope();
+            try {
+                var db = scope.resolve<OrmSession>();
+                var chats = scope.resolve<ChatService>();
+                if ((yield chats.get_chat(chat_id, user_id)) == null) {
+                    return false;
+                }
+                var question = yield chats.pending_question(chat_id);
+                if (question == null) {
+                    return false;
+                }
+                string trimmed = answer.strip();
+                if (trimmed.length == 0) {
+                    return false;
+                }
+                var selected = option_member(((!)question).payload ?? "", trimmed);
+                string quoted;
+                if (selected != null) {
+                    quoted = @"Human selected: $(((!)selected))";
+                } else {
+                    quoted = @"Human answered: $(trimmed)";
+                }
+                ((!)question).answer = trimmed;
+                ((!)question).answered_at = new DateTime.now_local();
+                yield db.update_async<AdvisorMessage>((!)question);
+                yield chats.append_message(chat_id, "user", quoted, null);
+                yield pulse.bump(0, "advisor answer recorded");
+            } finally {
+                scope.release();
+            }
+            if (try_lock(chat_id)) {
+                yield turn_locked(chat_id, user_id);
+            }
+            return true;
+        }
+
+        /** Acquires the per-chat turn lock; false when already held. */
+        private bool try_lock(int64 chat_id) {
+            var key = chat_id.to_string();
+            if (running.contains(key)) {
+                return false;
+            }
+            running.add(key);
+            return true;
+        }
+
+        /**
+         * The turn body, with the lock held: status 'running', then
+         * the LLM loop until a plain assistant reply, an ask_question
+         * park or the iteration cap; every exit path resets a
+         * terminal status.
+         */
+        private async void turn_locked(int64 chat_id, int64 user_id) {
+            var endpoint = AdvisorLlmEndpoint.from_environment();
+            try {
+                if (endpoint == null) {
+                    yield append(chat_id, "error",
+                        "The advisor is not configured — set ADVISOR_LLM_BASE_URL"
+                        + " (or SADDLE_LLM_BASE_URL) to enable it.", null);
+                    yield set_status(chat_id, "idle");
+                    return;
+                }
+                yield set_status(chat_id, "running");
+                var messages = yield wire_messages(chat_id, (!)endpoint);
+                for (var iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
+                    var reply = yield chat_once((!)endpoint, messages);
+                    foreach (var call in reply.tool_calls) {
+                        messages.add_object_element(
+                            assistant_call_node(reply.content, call));
+                    }
+                    if (reply.tool_calls.length == 0) {
+                        yield append(chat_id, "assistant",
+                            reply.content.length > 0 ? reply.content : "(no reply)", null);
+                        yield set_status(chat_id, "idle");
+                        return;
+                    }
+                    bool parked = false;
+                    foreach (var call in reply.tool_calls) {
+                        var outcome = yield execute_tool(chat_id, user_id, call);
+                        messages.add_object_element(tool_result_node(call, outcome));
+                        if (outcome.parked) {
+                            parked = true;
+                        }
+                    }
+                    if (parked) {
+                        // ask_question: the question row is already
+                        // appended by execute_tool — the turn ends
+                        // here and the answer action resumes it.
+                        yield set_status(chat_id, "awaiting_answer");
+                        return;
+                    }
+                }
+                yield append(chat_id, "error",
+                    @"The advisor stopped after $(MAX_ITERATIONS) rounds without a final reply —"
+                    + " please rephrase or continue.", null);
+                yield set_status(chat_id, "idle");
+            } catch (Error e) {
+                yield append(chat_id, "error", @"Advisor turn failed: $(e.message)", null);
+                yield set_status(chat_id, "idle");
+            } finally {
+                running.remove(chat_id.to_string());
+            }
+        }
+
+        /** One outcome of executing a tool call. */
+        private class ToolOutcome : Object {
+            /** The JSON result handed back to the model. */
+            public string result = "{}";
+            /** True when the tool parked the turn (ask_question). */
+            public bool parked = false;
+        }
+
+        /** An error message handed back to the model, never thrown. */
+        private static ToolOutcome tool_error(string message) {
+            var body = new Json.Object();
+            body.set_string_member("error", message);
+            var generator = new Json.Generator();
+            generator.set_root(new Json.Node(Json.NodeType.OBJECT).init_object(body));
+            var outcome = new ToolOutcome();
+            outcome.result = generator.to_data(null);
+            return outcome;
+        }
+
+        // ------------------------------------------------------------------
+        // Wire construction
+        // ------------------------------------------------------------------
+
+        /**
+         * The OpenAI messages array rebuilt from the stored rows: the
+         * system prompt, then user/assistant/tool rows in order — an
+         * assistant row that carried tool_calls reconstructs them
+         * from its payload, and a tool row becomes the `tool` role
+         * message with tool_call_id/name/content. error and system
+         * rows ride as user-bracketed notices so the model sees them.
+         */
+        private async Json.Array wire_messages(int64 chat_id,
+                AdvisorLlmEndpoint endpoint) throws Error {
+            var scope = container.create_scope();
+            var messages = new Json.Array();
+            try {
+                var chats = scope.resolve<ChatService>();
+                messages.add_object_element(system_message_node());
+                foreach (var row in yield chats.list_messages(chat_id)) {
+                    if (row.kind == "user") {
+                        var node = new Json.Object();
+                        node.set_string_member("role", "user");
+                        node.set_string_member("content", row.content);
+                        messages.add_object_element(node);
+                    } else if (row.kind == "assistant") {
+                        var payload = row.payload ?? "";
+                        var calls = payload_tool_calls(payload);
+                        if (calls.length > 0) {
+                            // One assistant row per call was stored;
+                            // reconstruct this row's own call.
+                            var stored = payload_call(payload);
+                            var node = new Json.Object();
+                            node.set_string_member("role", "assistant");
+                            node.set_string_member("content", row.content);
+                            if (stored != null) {
+                                node.set_array_member("tool_calls",
+                                    tool_calls_array(new AdvisorToolCall[] { (!)stored }));
+                            }
+                            messages.add_object_element(node);
+                        } else {
+                            var node = new Json.Object();
+                            node.set_string_member("role", "assistant");
+                            node.set_string_member("content", row.content);
+                            messages.add_object_element(node);
+                        }
+                    } else if (row.kind == "tool") {
+                        var payload = row.payload ?? "";
+                        var node = new Json.Object();
+                        node.set_string_member("role", "tool");
+                        node.set_string_member("tool_call_id",
+                            payload_string(payload, "call_id"));
+                        node.set_string_member("name",
+                            payload_string(payload, "name"));
+                        node.set_string_member("content",
+                            payload_string(payload, "result"));
+                        messages.add_object_element(node);
+                    } else {
+                        // error / system: a bracketed notice the model
+                        // must see but that is neither party's voice.
+                        var node = new Json.Object();
+                        node.set_string_member("role", "user");
+                        node.set_string_member("content",
+                            @"[($(row.kind)) notice] $(row.content)");
+                        messages.add_object_element(node);
+                    }
+                }
+            } finally {
+                scope.release();
+            }
+            return messages;
+        }
+
+        /** The persona/system message. */
+        private static Json.Object system_message_node() {
+            var node = new Json.Object();
+            node.set_string_member("role", "system");
+            node.set_string_member("content", SYSTEM_PROMPT);
+            return node;
+        }
+
+        /**
+         * The advisor persona: a READ-ONLY operations advisor for a
+         * clanker-saddle fleet. It can inspect work state through its
+         * tools, ask the user questions when unsure, and SUGGEST
+         * tasks/goals that stay inert until explicitly approved in
+         * the UI. It can never run commands.
+         */
+        private const string SYSTEM_PROMPT = "You are the advisor: a read-only operations"
+            + " advisor for a clanker-saddle fleet (an LLM task orchestration platform with"
+            + " projects, tasks, goals, rules, discoveries and capability-tagged saddles)."
+            + " You chat with the fleet's human operator.\n"
+            + "Your tools can READ the fleet's state (projects, tasks, goals, rules,"
+            + " discoveries, capabilities) and nothing else. You CANNOT run commands,"
+            + " edit files, dispatch work or change any state.\n"
+            + "When a course of action needs real work, call suggest_task or suggest_goal:"
+            + " a suggestion is INERT until the user explicitly approves it in the chat UI"
+            + " — nothing is created without that approval, and you should say so plainly.\n"
+            + "When you are unsure what the user wants, or a decision genuinely needs"
+            + " them, call ask_question with concrete options (an empty options array asks"
+            + " a free-text question). Prefer asking over guessing.\n"
+            + "Be concise and specific; answer in short markdown where it helps.";
+
+        /** The assistant wire node carrying one tool call. */
+        private static Json.Object assistant_call_node(string content,
+                AdvisorToolCall call) {
+            var node = new Json.Object();
+            node.set_string_member("role", "assistant");
+            node.set_string_member("content", content);
+            node.set_array_member("tool_calls", tool_calls_array(
+                new AdvisorToolCall[] { call }));
+            return node;
+        }
+
+        /** The tool_calls array for the wire. */
+        private static Json.Array tool_calls_array(AdvisorToolCall[] calls) {
+            var array = new Json.Array();
+            foreach (var call in calls) {
+                var element = new Json.Object();
+                element.set_string_member("id", call.id);
+                element.set_string_member("type", "function");
+                var function = new Json.Object();
+                function.set_string_member("name", call.name);
+                function.set_string_member("arguments", call.arguments);
+                element.set_object_member("function", function);
+                array.add_object_element(element);
+            }
+            return array;
+        }
+
+        /** The `tool` role wire node for one executed call. */
+        private static Json.Object tool_result_node(AdvisorToolCall call,
+                ToolOutcome outcome) {
+            var node = new Json.Object();
+            node.set_string_member("role", "tool");
+            node.set_string_member("tool_call_id", call.id);
+            node.set_string_member("name", call.name);
+            node.set_string_member("content", outcome.result);
+            return node;
+        }
+
+        // --- payload helpers (payload JSON of stored rows) ---
+
+        private static string payload_string(string payload, string member) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(payload);
+            } catch (Error e) {
+                return "";
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return "";
+            }
+            var body = ((!)root).get_object();
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            return node.get_node_type() == Json.NodeType.VALUE
+                && node.get_string() != null ? (!)node.get_string() : "";
+        }
+
+        /** The single stored tool call of an assistant row, or null. */
+        private static AdvisorToolCall? payload_call(string payload) {
+            var call = new AdvisorToolCall();
+            call.id = payload_string(payload, "call_id");
+            call.name = payload_string(payload, "name");
+            call.arguments = payload_string(payload, "arguments");
+            if (call.id.length == 0 && call.name.length == 0) {
+                return null;
+            }
+            return call;
+        }
+
+        /** Whether a payload carries a stored tool call. */
+        private static AdvisorToolCall[] payload_tool_calls(string payload) {
+            var call = payload_call(payload);
+            return call != null ? new AdvisorToolCall[] { (!)call } : new AdvisorToolCall[0];
+        }
+
+        /**
+         * The options array of a question payload, or null when the
+         * trimmed answer is not one of them.
+         */
+        private static string? option_member(string payload, string answer) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(payload);
+            } catch (Error e) {
+                return null;
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return null;
+            }
+            var body = ((!)root).get_object();
+            if (!body.has_member("options")
+                    || body.get_member("options").get_node_type() != Json.NodeType.ARRAY) {
+                return null;
+            }
+            foreach (var node in body.get_array_member("options").get_elements()) {
+                if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null
+                        && ((!)node.get_string()) == answer) {
+                    return (!)node.get_string();
+                }
+            }
+            return null;
+        }
+
+        // ------------------------------------------------------------------
+        // LLM transport
+        // ------------------------------------------------------------------
+
+        /**
+         * One non-streaming chat-completions call: the messages built
+         * so far plus the tool set. Transport failures and HTTP >=
+         * 400 throw (the turn's error row); the reply is parsed into
+         * content + tool calls.
+         */
+        private async AdvisorReply chat_once(AdvisorLlmEndpoint endpoint,
+                Json.Array messages) throws Error {
+            var body = new Json.Object();
+            body.set_string_member("model",
+                endpoint.model.length > 0 ? endpoint.model : "advisor");
+            body.set_array_member("messages", messages);
+            body.set_array_member("tools", tools_array());
+
+            var generator = new Json.Generator();
+            var body_node = new Json.Node(Json.NodeType.OBJECT);
+            body_node.init_object(body);
+            generator.set_root(body_node);
+
+            string url = endpoint.base_url.has_suffix("/")
+                ? endpoint.base_url.substring(0, endpoint.base_url.length - 1)
+                : endpoint.base_url;
+            var message = new Soup.Message("POST", @"$(url)/chat/completions");
+            message.set_request_body_from_bytes("application/json",
+                new Bytes(generator.to_data(null).data));
+            if (endpoint.api_key.length > 0) {
+                message.request_headers.append("Authorization",
+                    @"Bearer $(endpoint.api_key)");
+            }
+
+            Bytes? raw;
+            try {
+                raw = yield http.send_and_read_async(message, Priority.DEFAULT, null);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 80,
+                    @"LLM request failed: $(e.message)");
+            }
+            if (message.status_code >= 400) {
+                throw new Error(Quark.from_string("clanker-saddle"), 81,
+                    @"LLM endpoint answered HTTP $(message.status_code)");
+            }
+            // get_data() hands back an unterminated array — casting it
+            // to string would read past the buffer until an unrelated
+            // NUL. Copy into a length+1 buffer and terminate.
+            var data = raw != null ? ((!)raw).get_data() : new uint8[0];
+            if (data.length == 0) {
+                return parse_reply("{}");
+            }
+            var terminated = new uint8[data.length + 1];
+            for (var index = 0; index < data.length; index++) {
+                terminated[index] = data[index];
+            }
+            terminated[data.length] = '\0';
+            return parse_reply((string) terminated);
+        }
+
+        /**
+         * Parses a chat-completions response body: the first choice's
+         * content, tool calls (id, name, raw arguments) and
+         * finish_reason. Mirrors the daemon's parser shape.
+         */
+        internal static AdvisorReply parse_reply(string body) throws Error {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(body);
+            } catch (Error e) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    @"LLM response is not JSON: $(e.message)");
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    "LLM response is not a JSON object");
+            }
+            var object = ((!)root).get_object();
+            if (!object.has_member("choices")
+                    || object.get_member("choices").get_node_type() != Json.NodeType.ARRAY) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    "LLM response has no choices array");
+            }
+            var choices = object.get_array_member("choices");
+            if (choices.get_length() == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    "LLM response choices array is empty");
+            }
+            var first = choices.get_object_element(0);
+            if (!first.has_member("message")) {
+                throw new Error(Quark.from_string("clanker-saddle"), 82,
+                    "LLM choice has no message");
+            }
+            var reply = new AdvisorReply();
+            var choice_message = first.get_object_member("message");
+            if (choice_message.has_member("content")) {
+                var content = choice_message.get_member("content");
+                if (content.get_node_type() == Json.NodeType.VALUE
+                        && content.get_string() != null) {
+                    reply.content = (!)content.get_string();
+                }
+            }
+            if (choice_message.has_member("tool_calls")
+                    && choice_message.get_member("tool_calls").get_node_type()
+                        == Json.NodeType.ARRAY) {
+                var calls = choice_message.get_array_member("tool_calls");
+                for (var index = 0; index < calls.get_length(); index++) {
+                    var element = calls.get_element(index);
+                    if (element.get_node_type() != Json.NodeType.OBJECT) {
+                        continue;
+                    }
+                    var call = element.get_object();
+                    var parsed = new AdvisorToolCall();
+                    parsed.id = member_string(call, "id");
+                    parsed.name = "";
+                    parsed.arguments = "{}";
+                    if (call.has_member("function")
+                            && call.get_member("function").get_node_type()
+                                == Json.NodeType.OBJECT) {
+                        var function = call.get_object_member("function");
+                        parsed.name = member_string(function, "name");
+                        parsed.arguments = member_string(function, "arguments");
+                        if (parsed.arguments.length == 0) {
+                            parsed.arguments = "{}";
+                        }
+                    }
+                    if (reply.tool_calls.length == 0) {
+                        reply.tool_calls = new AdvisorToolCall[] { parsed };
+                    } else {
+                        var grown = new AdvisorToolCall[reply.tool_calls.length + 1];
+                        for (var copy = 0; copy < reply.tool_calls.length; copy++) {
+                            grown[copy] = reply.tool_calls[copy];
+                        }
+                        grown[reply.tool_calls.length] = parsed;
+                        reply.tool_calls = grown;
+                    }
+                }
+            }
+            reply.finish_reason = member_string(first, "finish_reason");
+            return reply;
+        }
+
+        /** A string member of `body`, or "" (never null). */
+        private static string member_string(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return "";
+        }
+
+        // ------------------------------------------------------------------
+        // Tool dispatch
+        // ------------------------------------------------------------------
+
+        /**
+         * Executes one tool call inside a FRESH scope (the container
+         * owns every service), stores the assistant call row and the
+         * tool result row, bumps the pulse, and reports the outcome
+         * the model sees. Failures are reported as tool errors, never
+         * thrown — the loop keeps going so the model can react.
+         */
+        private async ToolOutcome execute_tool(int64 chat_id, int64 user_id,
+                AdvisorToolCall call) {
+            var outcome = new ToolOutcome();
+            var scope = container.create_scope();
+            try {
+                var db = scope.resolve<OrmSession>();
+                var chats = scope.resolve<ChatService>();
+                // The assistant row carrying this call, stored in the
+                // wire shape (call_id + name + raw arguments).
+                var call_payload = new Json.Object();
+                call_payload.set_string_member("call_id", call.id);
+                call_payload.set_string_member("name", call.name);
+                call_payload.set_string_member("arguments", call.arguments);
+                yield chats.append_message(chat_id, "assistant",
+                    "@tool call", json_string(call_payload));
+
+                string result;
+                bool parked = false;
+                if (call.name == "ask_question") {
+                    var question = yield tool_ask_question(db, chats, chat_id,
+                        call.arguments);
+                    result = question.result;
+                    parked = question.parked;
+                } else if (call.name == "suggest_task" || call.name == "suggest_goal") {
+                    // Suggestion rows belong to THIS chat and its user —
+                    // that ownership is what the later approve action
+                    // and the completion callback key on.
+                    result = yield dispatch_read_tool(db, call.name, call.arguments,
+                        chat_id, user_id);
+                } else {
+                    result = yield dispatch_read_tool(db, call.name, call.arguments,
+                        chat_id, user_id);
+                }
+                outcome.result = result;
+
+                var result_payload = new Json.Object();
+                result_payload.set_string_member("call_id", call.id);
+                result_payload.set_string_member("name", call.name);
+                result_payload.set_string_member("arguments", call.arguments);
+                result_payload.set_string_member("result", result);
+                yield chats.append_message(chat_id, "tool", "@tool result",
+                    json_string(result_payload));
+                outcome.parked = parked;
+                yield pulse.bump(0, @"advisor tool $(call.name)");
+            } catch (Error e) {
+                outcome.result = tool_error_json(@"$(call.name) failed: $(e.message)");
+                try {
+                    var chats = scope.resolve<ChatService>();
+                    var result_payload = new Json.Object();
+                    result_payload.set_string_member("call_id", call.id);
+                    result_payload.set_string_member("name", call.name);
+                    result_payload.set_string_member("arguments", call.arguments);
+                    result_payload.set_string_member("result", outcome.result);
+                    yield chats.append_message(chat_id, "tool", "@tool result",
+                        json_string(result_payload));
+                } catch (Error ignored) {
+                    // The error row below is the last resort record.
+                }
+            } finally {
+                scope.release();
+            }
+            return outcome;
+        }
+
+        /** tool_error's JSON string (kept separate for reuse above). */
+        private static string tool_error_json(string message) {
+            return tool_error(message).result;
+        }
+
+        /**
+         * The read-only tool switch: every case resolves its data
+         * through the EXISTING services; unknown names report a tool
+         * error. suggest_task/suggest_goal insert pending suggestion
+         * rows — the only write besides chat rows.
+         */
+        private async string dispatch_read_tool(OrmSession db, string name,
+                string arguments, int64 chat_id, int64 user_id) throws Error {
+            switch (name) {
+                case "list_projects":
+                    return yield tool_list_projects(db);
+                case "get_project":
+                    return yield tool_get_project(db, arguments);
+                case "list_tasks":
+                    return yield tool_list_tasks(db, arguments);
+                case "get_task":
+                    return yield tool_get_task(db, arguments);
+                case "list_goals":
+                    return yield tool_list_goals(db, arguments);
+                case "get_goal":
+                    return yield tool_get_goal(db, arguments);
+                case "list_rules":
+                    return yield tool_list_rules(db);
+                case "search_discoveries":
+                    return yield tool_search_discoveries(db, arguments);
+                case "list_capabilities":
+                    return yield tool_list_capabilities(db);
+                case "suggest_task":
+                    return yield tool_suggest_task(db, arguments, chat_id, user_id);
+                case "suggest_goal":
+                    return yield tool_suggest_goal(db, arguments, chat_id, user_id);
+                default:
+                    return tool_error_json(@"Unknown tool '$(name)' — the advisor's tool set is read-only");
+            }
+        }
+
+        /** Parses the arguments JSON string, or an empty object. */
+        private static Json.Object parse_arguments(string arguments) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(arguments);
+            } catch (Error e) {
+                return new Json.Object();
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return new Json.Object();
+            }
+            return ((!)root).get_object();
+        }
+
+        private static int64 argument_int(Json.Object arguments, string member,
+                int64 fallback = 0) {
+            if (!arguments.has_member(member)) {
+                return fallback;
+            }
+            var node = arguments.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE) {
+                return node.get_int();
+            }
+            return fallback;
+        }
+
+        private static string argument_string(Json.Object arguments, string member,
+                string fallback = "") {
+            if (!arguments.has_member(member)) {
+                return fallback;
+            }
+            var node = arguments.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return fallback;
+        }
+
+        private static string[] argument_strings(Json.Object arguments, string member) {
+            var values = new string[0];
+            if (!arguments.has_member(member)
+                    || arguments.get_member(member).get_node_type() != Json.NodeType.ARRAY) {
+                return values;
+            }
+            foreach (var node in arguments.get_array_member(member).get_elements()) {
+                if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                    values += (!)node.get_string();
+                }
+            }
+            return values;
+        }
+
+        private static string json_string(Json.Object body) {
+            var generator = new Json.Generator();
+            var node = new Json.Node(Json.NodeType.OBJECT);
+            node.init_object(body);
+            generator.set_root(node);
+            return generator.to_data(null);
+        }
+
+        /** Long text clamped for tool results. */
+        private static string clamp_text(string text) {
+            return text.length > TEXT_CLAMP_CHARS
+                ? text.substring(0, TEXT_CLAMP_CHARS) + "…"
+                : text;
+        }
+
+        private static string stamp_text(DateTime? stamp) {
+            return stamp != null
+                ? ((!)stamp).format("%Y-%m-%d %H:%M") : "";
+        }
+
+        // --- read tools (data shaping over the existing services) ---
+
+        private async string tool_list_projects(OrmSession db) throws Error {
+            var projects = yield db.query<Project>()
+                .order_by(expr("name"))
+                .materialise_async();
+            var array = new Json.Array();
+            int count = 0;
+            foreach (var project in projects) {
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", project.id);
+                element.set_string_member("name", project.name);
+                element.set_string_member("default_branch", project.default_branch);
+                element.set_string_member("summary",
+                    clamp_text(project.summary ?? ""));
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("projects", array);
+            return json_string(body);
+        }
+
+        private async string tool_get_project(OrmSession db,
+                string arguments) throws Error {
+            int64 id = argument_int(parse_arguments(arguments), "project_id");
+            var project = yield db.query<Project>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (project == null) {
+                return tool_error_json(@"No project exists with id $(id)");
+            }
+            var body = new Json.Object();
+            body.set_int_member("id", ((!)project).id);
+            body.set_string_member("name", ((!)project).name);
+            body.set_string_member("git_url", ((!)project).git_url);
+            body.set_string_member("default_branch", ((!)project).default_branch);
+            body.set_string_member("summary", clamp_text(((!)project).summary ?? ""));
+            body.set_string_member("created_at", stamp_text(((!)project).created_at));
+            return json_string(body);
+        }
+
+        private async string tool_list_tasks(OrmSession db,
+                string arguments) throws Error {
+            var args = parse_arguments(arguments);
+            var query = db.query<TaskEntity>();
+            string status = argument_string(args, "status");
+            int64 project_id = argument_int(args, "project_id");
+            if (status.length > 0) {
+                query.where(expr("status == $0", elem(status)));
+            }
+            if (project_id > 0) {
+                query.where(expr("project_id == $0", elem<int64?>(project_id)));
+            }
+            var array = new Json.Array();
+            int count = 0;
+            foreach (var task in yield query.order_by_desc(expr("id")).materialise_async()) {
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", task.id);
+                element.set_int_member("project_id", task.project_id);
+                element.set_string_member("title", task.title);
+                element.set_string_member("status", task.status);
+                element.set_string_member("mode", task.mode);
+                element.set_int_member("priority", task.priority);
+                element.set_int_member("goal_id", task.goal_id);
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("tasks", array);
+            return json_string(body);
+        }
+
+        private async string tool_get_task(OrmSession db,
+                string arguments) throws Error {
+            int64 id = argument_int(parse_arguments(arguments), "task_id");
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (task == null) {
+                return tool_error_json(@"No task exists with id $(id)");
+            }
+            var body = new Json.Object();
+            body.set_int_member("id", ((!)task).id);
+            body.set_int_member("project_id", ((!)task).project_id);
+            body.set_string_member("title", ((!)task).title);
+            body.set_string_member("description",
+                clamp_text(((!)task).description ?? ""));
+            body.set_string_member("status", ((!)task).status);
+            body.set_string_member("mode", ((!)task).mode);
+            body.set_string_member("creator", ((!)task).creator);
+            body.set_int_member("priority", ((!)task).priority);
+            body.set_int_member("goal_id", ((!)task).goal_id);
+            body.set_string_member("task_branch", ((!)task).task_branch ?? "");
+            body.set_string_member("created_at", stamp_text(((!)task).created_at));
+            return json_string(body);
+        }
+
+        private async string tool_list_goals(OrmSession db,
+                string arguments) throws Error {
+            var args = parse_arguments(arguments);
+            var query = db.query<Goal>();
+            int64 project_id = argument_int(args, "project_id");
+            if (project_id > 0) {
+                query.where(expr("project_id == $0", elem<int64?>(project_id)));
+            }
+            var array = new Json.Array();
+            int count = 0;
+            foreach (var goal in yield query.order_by_desc(expr("id")).materialise_async()) {
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", goal.id);
+                element.set_int_member("project_id", goal.project_id);
+                element.set_string_member("title", goal.title);
+                element.set_string_member("status", goal.status);
+                element.set_string_member("branch_name", goal.branch_name);
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("goals", array);
+            return json_string(body);
+        }
+
+        private async string tool_get_goal(OrmSession db,
+                string arguments) throws Error {
+            int64 id = argument_int(parse_arguments(arguments), "goal_id");
+            var goal = yield db.query<Goal>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+            if (goal == null) {
+                return tool_error_json(@"No goal exists with id $(id)");
+            }
+            var body = new Json.Object();
+            body.set_int_member("id", ((!)goal).id);
+            body.set_int_member("project_id", ((!)goal).project_id);
+            body.set_string_member("title", ((!)goal).title);
+            body.set_string_member("brief", clamp_text(((!)goal).brief));
+            body.set_string_member("status", ((!)goal).status);
+            body.set_string_member("branch_name", ((!)goal).branch_name);
+            body.set_string_member("plan", clamp_text(((!)goal).plan ?? ""));
+            body.set_string_member("created_at", stamp_text(((!)goal).created_at));
+            return json_string(body);
+        }
+
+        private async string tool_list_rules(OrmSession db) throws Error {
+            var rules = yield db.query<Rule>()
+                .order_by(expr("name"))
+                .materialise_async();
+            var array = new Json.Array();
+            int count = 0;
+            foreach (var rule in rules) {
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", rule.id);
+                element.set_string_member("name", rule.name);
+                element.set_string_member("body", clamp_text(rule.body));
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("rules", array);
+            return json_string(body);
+        }
+
+        private async string tool_search_discoveries(OrmSession db,
+                string arguments) throws Error {
+            string query = argument_string(parse_arguments(arguments), "query");
+            var rows = yield db.query<Discovery>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+            var array = new Json.Array();
+            int count = 0;
+            string needle = query.strip().down();
+            foreach (var row in rows) {
+                if (needle.length > 0 && !row.body.down().contains(needle)) {
+                    continue;
+                }
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_int_member("id", row.id);
+                element.set_string_member("body", clamp_text(row.body));
+                element.set_string_member("updated_at", stamp_text(row.updated_at));
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("discoveries", array);
+            return json_string(body);
+        }
+
+        private async string tool_list_capabilities(OrmSession db) throws Error {
+            var capabilities = yield db.query<Capability>()
+                .order_by(expr("name"))
+                .materialise_async();
+            var array = new Json.Array();
+            foreach (var capability in capabilities) {
+                var element = new Json.Object();
+                element.set_int_member("id", capability.id);
+                element.set_string_member("name", capability.name);
+                array.add_object_element(element);
+            }
+            var body = new Json.Object();
+            body.set_array_member("capabilities", array);
+            return json_string(body);
+        }
+
+        // --- suggestions (the only non-chat write) ---
+
+        /**
+         * Inserts a pending task suggestion row. The payload keeps
+         * every field the approve action will need verbatim.
+         */
+        private async string tool_suggest_task(OrmSession db,
+                string arguments, int64 chat_id, int64 user_id) throws Error {
+            var args = parse_arguments(arguments);
+            string title = argument_string(args, "title").strip();
+            string mandate = argument_string(args, "mandate").strip();
+            if (title.length == 0 || mandate.length == 0) {
+                return tool_error_json("suggest_task needs a non-empty title and mandate");
+            }
+            string mode = argument_string(args, "mode", "implement");
+            var payload = new Json.Object();
+            payload.set_string_member("title", title);
+            payload.set_string_member("mandate", mandate);
+            payload.set_string_member("mode", mode);
+            payload.set_int_member("project_id", argument_int(args, "project_id"));
+            payload.set_int_member("priority", argument_int(args, "priority", 5));
+            var row = new AdvisorSuggestion() {
+                chat_id = chat_id,
+                user_id = user_id,
+                kind = "task",
+                payload = json_string(payload),
+                status = "pending",
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<AdvisorSuggestion>(row);
+            var body = new Json.Object();
+            body.set_int_member("suggestion_id", row.id);
+            body.set_string_member("status", "pending");
+            body.set_string_member("note",
+                "Suggestion stored — nothing is created until the user approves it in the chat UI.");
+            return json_string(body);
+        }
+
+        /**
+         * Inserts a pending goal suggestion row (created as an
+         * UNQUEUED draft on approval).
+         */
+        private async string tool_suggest_goal(OrmSession db,
+                string arguments, int64 chat_id, int64 user_id) throws Error {
+            var args = parse_arguments(arguments);
+            string title = argument_string(args, "title").strip();
+            string brief = argument_string(args, "brief").strip();
+            if (title.length == 0 || brief.length == 0) {
+                return tool_error_json("suggest_goal needs a non-empty title and brief");
+            }
+            var payload = new Json.Object();
+            payload.set_string_member("title", title);
+            payload.set_string_member("brief", brief);
+            payload.set_int_member("project_id", argument_int(args, "project_id"));
+            payload.set_string_member("branch_name",
+                argument_string(args, "branch_name"));
+            var row = new AdvisorSuggestion() {
+                chat_id = chat_id,
+                user_id = user_id,
+                kind = "goal",
+                payload = json_string(payload),
+                status = "pending",
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<AdvisorSuggestion>(row);
+            var body = new Json.Object();
+            body.set_int_member("suggestion_id", row.id);
+            body.set_string_member("status", "pending");
+            body.set_string_member("note",
+                "Suggestion stored — nothing is created until the user approves it in the chat UI.");
+            return json_string(body);
+        }
+
+        // --- ask_question ---
+
+        /**
+         * The ONE parking tool: validates the question and stores it
+         * as an assistant row whose payload carries the question and
+         * its options; the turn then ends with status
+         * 'awaiting_answer' and the answer action resumes it. An
+         * empty options array is allowed (a free-text question, like
+         * the saddle's ask_question).
+         */
+        private async ToolOutcome tool_ask_question(OrmSession db, ChatService chats,
+                int64 chat_id, string arguments) throws Error {
+            var args = parse_arguments(arguments);
+            string question = argument_string(args, "question").strip();
+            var outcome = new ToolOutcome();
+            if (question.length == 0) {
+                // An invalid question must NOT park the turn: report a
+                // tool error and let the model recover.
+                outcome.result = tool_error(
+                    "ask_question needs a non-empty question").result;
+                return outcome;
+            }
+            var options = argument_strings(args, "options");
+            var payload = new Json.Object();
+            payload.set_string_member("question", question);
+            var options_array = new Json.Array();
+            foreach (var option in options) {
+                var node = new Json.Node(Json.NodeType.VALUE);
+                node.init_string(option);
+                options_array.add_element(node);
+            }
+            payload.set_array_member("options", options_array);
+            var body = new Json.Object();
+            body.set_string_member("parked", "awaiting_answer");
+            outcome.result = json_string(body);
+            yield chats.append_message(chat_id, "assistant", question,
+                json_string(payload));
+            yield pulse.bump(0, "advisor question parked");
+            outcome.parked = true;
+            return outcome;
+        }
+
+        // ------------------------------------------------------------------
+        // Shared appends (own scope per operation)
+        // ------------------------------------------------------------------
+
+        private async void append(int64 chat_id, string kind, string content,
+                string? payload) {
+            var scope = container.create_scope();
+            try {
+                var chats = scope.resolve<ChatService>();
+                yield chats.append_message(chat_id, kind, content, payload);
+            } catch (Error e) {
+                warning("[ClankerSaddle] Advisor append failed: %s", e.message);
+            } finally {
+                scope.release();
+            }
+        }
+
+        private async void set_status(int64 chat_id, string status) {
+            var scope = container.create_scope();
+            try {
+                var chats = scope.resolve<ChatService>();
+                yield chats.set_chat_status(chat_id, status);
+                yield pulse.bump(0, @"advisor chat $(status)");
+            } catch (Error e) {
+                warning("[ClankerSaddle] Advisor status change failed: %s", e.message);
+            } finally {
+                scope.release();
+            }
+        }
+
+        // ------------------------------------------------------------------
+        // Tool definitions (the OpenAI tools array)
+        // ------------------------------------------------------------------
+
+        /**
+         * The tools array sent with every request: the read tools,
+         * ask_question and the two suggest tools, described for
+         * function calling.
+         */
+        public static Json.Array tools_array() {
+            var tools = new Json.Array();
+            tools.add_object_element(tool_definition("list_projects",
+                "List every project registered in this clanker-saddle fleet.",
+                new string[0], new string[0], new string[0]));
+            tools.add_object_element(tool_definition("get_project",
+                "One project's full record by id.",
+                new string[] { "project_id" }, new string[] { "integer" },
+                new string[] { "The project's id" }));
+            tools.add_object_element(tool_definition("list_tasks",
+                "List tasks, optionally filtered by status and project.",
+                new string[] { "status", "project_id" },
+                new string[] { "string", "integer" },
+                new string[] {
+                    "Optional status filter (draft/queued/working/parked/suspended/done/failed)",
+                    "Optional project id filter (0 = standalone tasks excluded)"
+                }));
+            tools.add_object_element(tool_definition("get_task",
+                "One task's full record by id (description, status, mode, branch).",
+                new string[] { "task_id" }, new string[] { "integer" },
+                new string[] { "The task's id" }));
+            tools.add_object_element(tool_definition("list_goals",
+                "List goals, optionally filtered by project.",
+                new string[] { "project_id" }, new string[] { "integer" },
+                new string[] { "Optional project id filter" }));
+            tools.add_object_element(tool_definition("get_goal",
+                "One goal's full record by id (brief, plan, branch).",
+                new string[] { "goal_id" }, new string[] { "integer" },
+                new string[] { "The goal's id" }));
+            tools.add_object_element(tool_definition("list_rules",
+                "List every rule (reusable instructions attached to projects/tasks).",
+                new string[0], new string[0], new string[0]));
+            tools.add_object_element(tool_definition("search_discoveries",
+                "Search the fleet's discoveries ledger (non-project facts: compiler quirks, library behaviours, tool gotchas) by substring.",
+                new string[] { "query" }, new string[] { "string" },
+                new string[] { "Case-insensitive substring; empty lists the newest rows" }));
+            tools.add_object_element(tool_definition("list_capabilities",
+                "List the fleet's capability tags.",
+                new string[0], new string[0], new string[0]));
+            tools.add_object_element(tool_definition("ask_question",
+                "Ask the user a question with multiple-choice options. The turn parks until they answer. An empty options array asks a free-text question.",
+                new string[] { "question", "options" },
+                new string[] { "string", "array of string" },
+                new string[] { "The question to ask", "The concrete options (may be empty for free text)" }));
+            tools.add_object_element(tool_definition("suggest_task",
+                "Suggest a task for the user to approve. INERT until they approve it in the chat UI — nothing is created by this call.",
+                new string[] { "title", "mandate", "mode", "project_id", "priority" },
+                new string[] { "string", "string", "string", "integer", "integer" },
+                new string[] {
+                    "Short task title",
+                    "The task's mandate: what to do, on which project, with what verification",
+                    "Work mode: implement / investigate / review / test",
+                    "The project id (optional)",
+                    "Priority 1-9, 5 default (optional)"
+                }));
+            tools.add_object_element(tool_definition("suggest_goal",
+                "Suggest a goal for the user to approve. INERT until they approve it in the chat UI; an approved goal is created as an unqueued draft.",
+                new string[] { "title", "brief", "project_id", "branch_name" },
+                new string[] { "string", "string", "integer", "string" },
+                new string[] {
+                    "Short goal title",
+                    "The goal brief: what this goal should achieve",
+                    "The primary project id",
+                    "Branch name for the goal (optional)"
+                }));
+            return tools;
+        }
+
+        /**
+         * One `{type: function, function: {name, description,
+         * parameters}}` definition. `required` is the first
+         * `names.len`-many parameters when `required_count` > 0 —
+         * here every declared parameter of the ask/suggest tools that
+         * matter; simple read tools pass none.
+         */
+        private static Json.Object tool_definition(string name, string description,
+                string[] names, string[] types, string[] descriptions) {
+            var properties = new Json.Object();
+            for (var index = 0; index < names.length; index++) {
+                var property = new Json.Object();
+                property.set_string_member("type",
+                    index < types.length ? types[index] : "string");
+                property.set_string_member("description",
+                    index < descriptions.length ? descriptions[index] : "");
+                properties.set_object_member(names[index], property);
+            }
+            var parameters = new Json.Object();
+            parameters.set_string_member("type", "object");
+            parameters.set_object_member("properties", properties);
+            var wrapper = new Json.Object();
+            wrapper.set_string_member("type", "function");
+            var function = new Json.Object();
+            function.set_string_member("name", name);
+            function.set_string_member("description", description);
+            function.set_object_member("parameters", parameters);
+            wrapper.set_object_member("function", function);
+            return wrapper;
+        }
+    }
+}

+ 186 - 0
src/Services/ChatService.vala

@@ -0,0 +1,186 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Advisor chat storage (goal 27): the CRUD the chat pages and
+     * actions sit on — create/delete/list chats (private per user:
+     * every read takes the owning user_id and returns nothing for
+     * anyone else), the message transcript, and the suggestion rows.
+     * The TURN LOOP itself lives in {@link AdvisorService}, which owns
+     * message appends that must bump the pulse; everything the UI
+     * needs to RENDER (including recording plain user/system messages
+     * on behalf of actions) goes through here.
+     */
+    public class ChatService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * Creates a chat titled `title` (trimmed; empty falls back to
+         * "New chat") owned by `user_id`, starting idle.
+         */
+        public async AdvisorChat create_chat(int64 user_id, string title) throws Error {
+            string trimmed = title.strip();
+            var now = new DateTime.now_local();
+            var chat = new AdvisorChat() {
+                user_id = user_id,
+                title = trimmed.length > 0 ? trimmed : "New chat",
+                status = "idle",
+                created_at = now,
+                updated_at = now
+            };
+            yield db.insert_async<AdvisorChat>(chat);
+            return chat;
+        }
+
+        /**
+         * Deletes the chat owned by `user_id` (idempotent for anyone
+         * else's id): the row plus its messages and suggestions.
+         */
+        public async void delete_chat(int64 id, int64 user_id) throws Error {
+            var chat = yield get_chat(id, user_id);
+            if (chat == null) {
+                return;
+            }
+            foreach (var message in yield list_messages(id)) {
+                yield db.delete_async<AdvisorMessage>(message);
+            }
+            foreach (var suggestion in yield list_suggestions(id)) {
+                yield db.delete_async<AdvisorSuggestion>(suggestion);
+            }
+            yield db.delete_async<AdvisorChat>((!)chat);
+        }
+
+        /**
+         * The chat owned by `user_id`, or null when the id is unknown
+         * or belongs to someone else — chats are private per user.
+         */
+        public async AdvisorChat? get_chat(int64 id, int64 user_id) throws Error {
+            return yield db.query<AdvisorChat>()
+                .where(expr("id == $0 && user_id == $1",
+                    elem<int64?>(id), elem<int64?>(user_id)))
+                .first_async();
+        }
+
+        /** The user's chats, newest first. */
+        public async ImmutableLot<AdvisorChat> list_chats(int64 user_id) throws Error {
+            return yield db.query<AdvisorChat>()
+                .where(expr("user_id == $0", elem<int64?>(user_id)))
+                .order_by_desc(expr("updated_at"))
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /** One chat's transcript, oldest first. */
+        public async ImmutableLot<AdvisorMessage> list_messages(int64 chat_id) throws Error {
+            return yield db.query<AdvisorMessage>()
+                .where(expr("chat_id == $0", elem<int64?>(chat_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** One chat's suggestions, oldest first. */
+        public async ImmutableLot<AdvisorSuggestion> list_suggestions(int64 chat_id) throws Error {
+            return yield db.query<AdvisorSuggestion>()
+                .where(expr("chat_id == $0", elem<int64?>(chat_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Appends a message row verbatim (the advisor loop's writer;
+         * actions use it for user/system rows) and touches the chat's
+         * updated_at. `payload` is JSON or null.
+         */
+        public async AdvisorMessage append_message(int64 chat_id, string kind,
+                string content, string? payload) throws Error {
+            var message = new AdvisorMessage() {
+                chat_id = chat_id,
+                kind = kind,
+                content = content,
+                payload = payload,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<AdvisorMessage>(message);
+            yield touch_chat(chat_id);
+            return message;
+        }
+
+        /** Records a plain user message (no payload). */
+        public async AdvisorMessage record_user_message(int64 chat_id,
+                string content) throws Error {
+            return yield append_message(chat_id, "user", content, null);
+        }
+
+        /** Records an advisor-external system notice (task callbacks). */
+        public async AdvisorMessage record_system_message(int64 chat_id,
+                string content) throws Error {
+            return yield append_message(chat_id, "system", content, null);
+        }
+
+        /** Bumps the chat's updated_at (every append's trailer). */
+        public async void touch_chat(int64 chat_id) throws Error {
+            var chat = yield db.query<AdvisorChat>()
+                .where(expr("id == $0", elem<int64?>(chat_id)))
+                .first_async();
+            if (chat != null) {
+                ((!)chat).updated_at = new DateTime.now_local();
+                yield db.update_async<AdvisorChat>((!)chat);
+            }
+        }
+
+        /**
+         * Sets the chat's loop status ('idle' / 'running' /
+         * 'awaiting_answer') and touches updated_at.
+         */
+        public async void set_chat_status(int64 chat_id, string status) throws Error {
+            var chat = yield db.query<AdvisorChat>()
+                .where(expr("id == $0", elem<int64?>(chat_id)))
+                .first_async();
+            if (chat != null) {
+                ((!)chat).status = status;
+                ((!)chat).updated_at = new DateTime.now_local();
+                yield db.update_async<AdvisorChat>((!)chat);
+            }
+        }
+
+        /**
+         * The chat's newest unanswered question row (an assistant
+         * message whose payload is a question object), or null.
+         */
+        public async AdvisorMessage? pending_question(int64 chat_id) throws Error {
+            AdvisorMessage? found = null;
+            foreach (var message in yield list_messages(chat_id)) {
+                if (message.kind == "assistant" && is_question(message)
+                        && message.answer == null) {
+                    found = message;
+                }
+            }
+            return found;
+        }
+
+        /** True when `message` is a parked ask_question row. */
+        public static bool is_question(AdvisorMessage message) {
+            if (message.payload == null) {
+                return false;
+            }
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(((!)message.payload));
+            } catch (Error e) {
+                return false;
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return false;
+            }
+            return ((!)root).get_object().has_member("question");
+        }
+    }
+}

+ 550 - 0
src/actions/AdvisorActions.vala

@@ -0,0 +1,550 @@
+using Inversion;
+using Invercargill;
+using Invercargill.Expressions;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed chat id carried by the advisor chat actions. */
+    public class AdvisorChatActionPrivate : Object {
+        public int64 chat_id { get; set; }
+    }
+
+    /**
+     * Sealed suggestion + chat ids carried by the suggestion decision
+     * action (goal 27 task 3 owns its handler).
+     */
+    public class AdvisorSuggestionActionPrivate : Object {
+        public int64 suggestion_id { get; set; }
+        public int64 chat_id { get; set; }
+    }
+
+    /**
+     * Starts a new advisor chat (goal 27) from the dashboard's "Ask
+     * the advisor" composer: creates the chat titled from the first
+     * line of the message (clamped ~80 chars), records the message as
+     * the opening user row and kicks the background
+     * {@link AdvisorService} turn. The POST returns before the turn
+     * finishes — the chat page's pulse-driven refresh streams the
+     * appends in — and the browser navigates to /chats/{id}.
+     */
+    public class StartAdvisorChatAction : StatumAction {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        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;
+            }
+
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/");
+            }
+            int64 viewer_id = ((!)me).user_id;
+
+            string message = Forms.field_raw(request, "message");
+            if (message.strip().length == 0) {
+                return directives().notify("error",
+                    "Type a question for the advisor first").navigate("/");
+            }
+
+            var chat = yield chat_service.create_chat(viewer_id,
+                chat_title(message));
+            yield chat_service.record_user_message(chat.id, message.strip());
+            // The turn runs in the service's own scope; this request
+            // finishes (and navigates) immediately.
+            advisor_service.run_turn.begin(chat.id, viewer_id);
+            return directives().navigate(@"/chats/$(chat.id)");
+        }
+
+        /**
+         * The chat's title: the message's first non-empty line,
+         * clamped to ~80 characters on a word boundary.
+         */
+        private static string chat_title(string message) {
+            string first = "";
+            foreach (string line in message.strip().split("\n")) {
+                if (line.strip().length > 0) {
+                    first = line.strip();
+                    break;
+                }
+            }
+            if (first.length <= 80) {
+                return first;
+            }
+            string cut = first.substring(0, 80);
+            int last_space = cut.last_index_of(" ");
+            if (last_space > 40) {
+                cut = cut.substring(0, last_space);
+            }
+            return cut.strip() + "…";
+        }
+    }
+
+    /**
+     * Appends the composer's message to a chat and kicks the next
+     * background advisor turn, answering with the rebuilt `chat` slot
+     * (the user's own row appears at once; the turn's appends stream
+     * in through the pulse refresh). The chat id is sealed.
+     */
+    public class SendAdvisorMessageAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 viewer_id = ((!)me).user_id;
+            int64 chat_id = ((!)ctx).chat_id;
+
+            var chat = yield chat_service.get_chat(chat_id, viewer_id);
+            if (chat == null) {
+                return directives().notify("error", "That chat no longer exists")
+                    .navigate("/chats");
+            }
+
+            string message = Forms.field_raw(request, "message");
+            // The question card answers through this action too (the
+            // page's one sealed send DTO serves the composer, the
+            // option buttons and the "Other" box): an answer resumes
+            // a PARKED question, a message starts a normal turn.
+            string answer = Forms.field_raw(request, "answer");
+            if (answer.strip().length > 0) {
+                bool accepted = false;
+                try {
+                    accepted = yield advisor_service.answer_question(chat_id,
+                        viewer_id, answer);
+                } catch (Error e) {
+                    return directives().notify("error",
+                        @"Could not record the answer: $(e.message)");
+                }
+                if (!accepted) {
+                    return directives().notify("error",
+                        "That question has already been answered");
+                }
+                var answered = yield chat_service.get_chat(chat_id, viewer_id);
+                if (answered == null) {
+                    return directives().navigate("/chats");
+                }
+                var resumed = yield ChatState.build_from(state_service,
+                    chat_service, advisor_service, action_registry, viewer_id,
+                    answered);
+                return directives().set(resumed);
+            }
+            if (message.strip().length == 0) {
+                var empty = yield ChatState.build_from(state_service,
+                    chat_service, advisor_service, action_registry, viewer_id,
+                    chat);
+                return directives().set(empty);
+            }
+
+            yield chat_service.record_user_message(chat_id, message.strip());
+            advisor_service.run_turn.begin(chat_id, viewer_id);
+
+            var updated = yield chat_service.get_chat(chat_id, viewer_id);
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, updated);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Records the human's answer to the chat's pending question (the
+     * question card's option buttons and "Other" box submit here via
+     * the JSON transport) and resumes the turn; answers with the
+     * rebuilt `chat` slot. The chat id is sealed.
+     */
+    public class AnswerAdvisorQuestionAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 viewer_id = ((!)me).user_id;
+            int64 chat_id = ((!)ctx).chat_id;
+
+            var chat = yield chat_service.get_chat(chat_id, viewer_id);
+            if (chat == null) {
+                return directives().notify("error", "That chat no longer exists")
+                    .navigate("/chats");
+            }
+
+            string answer = Forms.field_raw(request, "answer");
+            // answer_question records the reply and runs the next
+            // turn in its own scope; the answer arrives as this
+            // action's reply.
+            bool accepted = false;
+            try {
+                accepted = yield advisor_service.answer_question(chat_id,
+                    viewer_id, answer);
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not record the answer: $(e.message)");
+            }
+            if (!accepted) {
+                return directives().notify("error",
+                    "That question has already been answered").navigate(
+                    @"/chats/$(chat_id)");
+            }
+
+            var updated = yield chat_service.get_chat(chat_id, viewer_id);
+            if (updated == null) {
+                return directives().navigate("/chats");
+            }
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, updated);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Deletes a chat (its messages and suggestions cascade) and sends
+     * the browser to /chats. The chat id is sealed; deleting someone
+     * else's id is a silent no-op (chats are private per user).
+     */
+    public class DeleteAdvisorChatAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+
+            try {
+                yield chat_service.delete_chat(((!)ctx).chat_id,
+                    ((!)me).user_id);
+                return directives().notify("success", "Chat deleted")
+                    .navigate("/chats");
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not delete the chat: $(e.message)")
+                    .navigate("/chats");
+            }
+        }
+    }
+
+    /**
+     * The chat page's pulse-driven refresh (goal 27): rebuilds the
+     * `chat` slot from current rows so a running turn's appends —
+     * tool calls, the assistant reply, a parked question, a
+     * suggestion — stream in without a reload. Navigates nowhere.
+     * Like the dashboard's refresh it is authored unsealed (GET): the
+     * page it serves is already the viewer's own.
+     */
+    public class RefreshAdvisorChatAction : StatumAction {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        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;
+            }
+
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 chat_id = Forms.integer(request, "chat_id", 0);
+            if (chat_id <= 0) {
+                return directives().navigate("/chats");
+            }
+            var chat = yield chat_service.get_chat(chat_id, ((!)me).user_id);
+            if (chat == null) {
+                // Deleted mid-turn (or never the viewer's): nothing to
+                // refresh — the page's own navigation takes over.
+                return directives().navigate("/chats");
+            }
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, ((!)me).user_id, chat);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Decides a pending suggestion (goal 27): 'approve' creates the
+     * real row — a DRAFT task (creator human, so the creator-only
+     * enqueue rule holds by construction) or a DRAFT goal (never
+     * started: goals are unqueued until the human starts them) —
+     * 'approve_enqueue' additionally queues the created task for a
+     * saddle, and 'decline' dismisses the row. Every path appends a
+     * short system message to the chat and kicks a background advisor
+     * turn so the advisor can acknowledge the outcome; the action
+     * answers with the refreshed `chat` slot. The suggestion and chat
+     * ids are sealed; only the chat's owner may decide, and only
+     * while the row is still pending.
+     */
+    public class DecideAdvisorSuggestionAction : TypedStatumAction<AdvisorSuggestionActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private TaskService task_service = inject<TaskService>();
+        private GoalService goal_service = inject<GoalService>();
+        private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorSuggestionActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            var me = SpryAuth.current(request);
+            if (ctx == null || me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 viewer_id = ((!)me).user_id;
+            int64 suggestion_id = ((!)ctx).suggestion_id;
+            int64 chat_id = ((!)ctx).chat_id;
+
+            string decision = Forms.field(request, "decision");
+            if (decision != "approve" && decision != "approve_enqueue"
+                    && decision != "decline") {
+                return directives().notify("error",
+                    "Unknown suggestion decision").navigate("/chats");
+            }
+
+            // Owner-checked and status-checked: the row must belong to
+            // this user's chat and still be pending (double clicks and
+            // stale cards land here, not in two tasks).
+            var suggestion = yield db.query<AdvisorSuggestion>()
+                .where(expr("id == $0 && chat_id == $1 && user_id == $2",
+                    elem<int64?>(suggestion_id), elem<int64?>(chat_id),
+                    elem<int64?>(viewer_id)))
+                .first_async();
+            if (suggestion == null) {
+                return directives().notify("error",
+                    "That suggestion does not exist").navigate("/chats");
+            }
+            if (((!)suggestion).status != "pending") {
+                return notify_chat(chat_id,
+                    "That suggestion has already been decided");
+            }
+            var chat = yield chat_service.get_chat(chat_id, viewer_id);
+            if (chat == null) {
+                return directives().notify("error",
+                    "That chat no longer exists").navigate("/chats");
+            }
+
+            string notice;
+            try {
+                if (decision == "decline") {
+                    ((!)suggestion).status = "declined";
+                    ((!)suggestion).decided_at = new DateTime.now_local();
+                    yield db.update_async<AdvisorSuggestion>((!)suggestion);
+                    notice = "Declined.";
+                } else if (((!)suggestion).kind == "task") {
+                    var payload = parse_payload(((!)suggestion).payload);
+                    string title = member_string(payload, "title");
+                    string mandate = member_string(payload, "mandate");
+                    string mode = member_string(payload, "mode");
+                    int64 project_id = member_int(payload, "project_id");
+                    int64 priority = member_int(payload, "priority");
+                    if (title.length == 0 || mandate.length == 0) {
+                        return notify_chat(chat_id,
+                            "That suggestion's payload is incomplete");
+                    }
+                    if (mode.length == 0) {
+                        mode = "implement";
+                    }
+                    if (!is_valid_mode(mode)) {
+                        return notify_chat(chat_id,
+                            @"Suggested mode '$(mode)' is not a valid work mode");
+                    }
+                    if (project_id < 0) {
+                        return notify_chat(chat_id,
+                            @"Invalid project id $(project_id)");
+                    }
+                    if (project_id > 0 && (yield project_exists(project_id)) == null) {
+                        return notify_chat(chat_id,
+                            @"No project exists with id $(project_id)");
+                    }
+                    // Creator is HUMAN — the creator-only enqueue rule
+                    // holds by construction and the approve_enqueue
+                    // path below enqueues as the human too.
+                    var task = yield task_service.create_task(
+                        project_id, title, mandate, priority,
+                        null, null, STATUS_DRAFT, TASK_KIND_TASK, mode,
+                        CREATOR_HUMAN, 0, 0);
+                    ((!)suggestion).task_id = task.id;
+                    ((!)suggestion).status = "approved";
+                    ((!)suggestion).decided_at = new DateTime.now_local();
+                    yield db.update_async<AdvisorSuggestion>((!)suggestion);
+                    if (decision == "approve_enqueue") {
+                        yield task_service.enqueue_task(task.id, CREATOR_HUMAN);
+                        notice = @"Approved & enqueued — task #$(task.id) queued";
+                    } else {
+                        notice = @"Approved — task #$(task.id) created (draft)";
+                    }
+                } else {
+                    var payload = parse_payload(((!)suggestion).payload);
+                    string title = member_string(payload, "title");
+                    string brief = member_string(payload, "brief");
+                    string branch_name = member_string(payload, "branch_name");
+                    int64 project_id = member_int(payload, "project_id");
+                    if (title.length == 0 || brief.length == 0) {
+                        return notify_chat(chat_id,
+                            "That suggestion's payload is incomplete");
+                    }
+                    if (branch_name.length == 0) {
+                        branch_name = @"goal/advisor-$(suggestion_id)";
+                    }
+                    var project = yield project_exists(project_id);
+                    if (project_id <= 0 || project == null) {
+                        return notify_chat(chat_id,
+                            @"The goal needs an existing project (got $(project_id))");
+                    }
+                    // Draft only: the advisor NEVER starts a goal —
+                    // the human starts it from the goal page.
+                    var goal = yield goal_service.create_goal(
+                        project_id, title, brief, branch_name, null, 3,
+                        CREATOR_HUMAN);
+                    ((!)suggestion).goal_id = goal.id;
+                    ((!)suggestion).status = "approved";
+                    ((!)suggestion).decided_at = new DateTime.now_local();
+                    yield db.update_async<AdvisorSuggestion>((!)suggestion);
+                    notice = @"Approved — goal #$(goal.id) created in draft (not started)";
+                }
+            } catch (Error e) {
+                return notify_chat(chat_id,
+                    @"Could not apply the decision: $(e.message)");
+            }
+
+            // The outcome lands in the chat and wakes the advisor so
+            // it can acknowledge (and, on completion, follow up).
+            try {
+                yield chat_service.record_system_message(chat_id, notice);
+            } catch (Error e) {
+                warning("[ClankerSaddle] Advisor decision notice failed: %s",
+                    e.message);
+            }
+            advisor_service.run_turn.begin(chat_id, viewer_id);
+
+            var updated = yield chat_service.get_chat(chat_id, viewer_id);
+            if (updated == null) {
+                return directives().navigate("/chats");
+            }
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, updated);
+            return directives().set(state);
+        }
+
+        /** An error notify that stays on the chat page. */
+        private DirectiveBuilder notify_chat(int64 chat_id, string message) {
+            return directives().notify("error", message)
+                .navigate(@"/chats/$(chat_id)");
+        }
+
+        /** The project row for `id`, or null (0 = standalone tasks). */
+        private async ClankerSaddle.Data.Project? project_exists(int64 id)
+                throws GLib.Error {
+            if (id <= 0) {
+                return null;
+            }
+            return yield db.query<ClankerSaddle.Data.Project>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        private static Json.Object parse_payload(string payload) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(payload);
+            } catch (Error e) {
+                return new Json.Object();
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return new Json.Object();
+            }
+            return ((!)root).get_object();
+        }
+
+        private static string member_string(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE
+                    && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return "";
+        }
+
+        private static int64 member_int(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return 0;
+            }
+            var node = body.get_member(member);
+            return node.get_node_type() == Json.NodeType.VALUE
+                ? node.get_int() : 0;
+        }
+    }
+}

+ 331 - 0
src/entrypoints/ChatDetailEntrypoint.vala

@@ -0,0 +1,331 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * One row of a chat's transcript (goal 27). The `kind` is the
+     * stored wire kind — 'user', 'assistant', 'tool', 'error' or
+     * 'system' — and drives the bubble styling. A tool row carries
+     * its call's {@link tool_name}, {@link arguments} and
+     * {@link result} (parsed out of the row's JSON payload); a
+     * question row carries its {@link options} plus answer state.
+     */
+    public class ChatMessageView : Object {
+        public int64 id { get; set; }
+        /** user / assistant / tool / error / system. */
+        public string kind { get; set; default = "user"; }
+        public string content { get; set; default = ""; }
+        public bool is_question { get; set; }
+        public string[] options { get; set; default = {}; }
+        public bool answered { get; set; }
+        public string answer { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+        /** Tool rows only ("" elsewhere). */
+        public string tool_name { get; set; default = ""; }
+        /** Tool rows only: the call's raw JSON arguments (pretty when parseable). */
+        public string arguments { get; set; default = ""; }
+        /** Tool rows only: the call's raw JSON result (pretty when parseable). */
+        public string result { get; set; default = ""; }
+    }
+
+    /**
+     * One suggestion card (goal 27): a task or goal the advisor
+     * proposed mid-chat. While `pending` the card offers the decision
+     * buttons; afterwards it states the outcome and links what was
+     * created (task_id/goal_id > 0).
+     */
+    public class ChatSuggestionView : Object {
+        public int64 id { get; set; }
+        /** "task" or "goal". */
+        public string kind { get; set; default = "task"; }
+        public string status { get; set; default = "pending"; }
+        public string title { get; set; default = ""; }
+        /** Task suggestions: the mandate; goal suggestions: the brief. */
+        public string body { get; set; default = ""; }
+        public string mode { get; set; default = ""; }
+        public int64 priority { get; set; }
+        public string branch_name { get; set; default = ""; }
+        public int64 project_id { get; set; }
+        public int64 task_id { get; set; }
+        public int64 goal_id { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? decide { get; set; }
+    }
+
+    /** Public data of the `chat` slot (the /chats/{id} page). */
+    public class ChatPublic : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "idle"; }
+        public string status_display { get; set; default = "idle"; }
+        public bool advisor_configured { get; set; }
+        public Statum.Model.ActionDto? send { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /**
+     * Builds the `chat` PAGE slot shared by the chat detail entrypoint
+     * and the advisor actions ({@link SendAdvisorMessageAction} answers
+     * with a rebuilt slot; {@link RefreshAdvisorChatAction} re-runs it
+     * on every StatePulse bump) so all paths hydrate identical state:
+     * the chat row, the transcript (tool/question payloads parsed),
+     * the suggestion cards and their sealed decision action.
+     */
+    public class ChatState : Object {
+
+        /** The human label of a chat's loop status. */
+        public static string status_display(string status) {
+            if (status == "running") {
+                return "thinking";
+            }
+            if (status == "awaiting_answer") {
+                return "awaiting answer";
+            }
+            return "idle";
+        }
+
+        /**
+         * Builds the slot for `chat_id` as owned by `viewer_id`; an
+         * unknown id (or someone else's — chats are private per user)
+         * yields the state with id 0 so the template can render its
+         * not-found notice.
+         */
+        public static async Statum.Slot build(StateService state_service,
+                ChatService chats, AdvisorService advisor,
+                ActionRegistry? actions, int64 viewer_id, int64 chat_id)
+                throws GLib.Error {
+            var chat = yield chats.get_chat(chat_id, viewer_id);
+            return yield build_from(state_service, chats, advisor, actions,
+                viewer_id, chat);
+        }
+
+        /**
+         * The same slot over an ALREADY-LOADED chat row (actions that
+         * just created or verified the chat skip the re-read).
+         */
+        public static async Statum.Slot build_from(StateService state_service,
+                ChatService chats, AdvisorService advisor,
+                ActionRegistry? actions, int64 viewer_id, AdvisorChat? chat)
+                throws GLib.Error {
+            var pub = new ChatPublic() {
+                advisor_configured = advisor.configured
+            };
+            PropertyDictionary props;
+
+            if (chat != null) {
+                pub.id = ((!)chat).id;
+                pub.title = ((!)chat).title;
+                pub.status = ((!)chat).status;
+                pub.status_display = status_display(((!)chat).status);
+                if (actions != null) {
+                    var sealed = new AdvisorChatActionPrivate() {
+                        chat_id = ((!)chat).id
+                    };
+                    pub.send = ((!)actions).author_private<SendAdvisorMessageAction,
+                        AdvisorChatActionPrivate>(sealed);
+                    pub.delete = ((!)actions).author_private<DeleteAdvisorChatAction,
+                        AdvisorChatActionPrivate>(sealed);
+                    pub.refresh = ((!)actions).author<RefreshAdvisorChatAction>("GET");
+                }
+
+                var messages = new Vector<ChatMessageView>();
+                foreach (var row in yield chats.list_messages(((!)chat).id)) {
+                    messages.add(message_view(row));
+                }
+
+                var suggestions = new Vector<ChatSuggestionView>();
+                foreach (var row in yield chats.list_suggestions(((!)chat).id)) {
+                    suggestions.add(suggestion_view(row, actions));
+                }
+
+                // to_properties SNAPSHOTS the object's values: every
+                // scalar must be final before the mapping (the lists
+                // then ride the dictionary directly).
+                props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                props["messages"] = StateJson.list<ChatMessageView>(
+                    messages.to_immutable_buffer());
+                props["suggestions"] = StateJson.list<ChatSuggestionView>(
+                    suggestions.to_immutable_buffer());
+            } else {
+                props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                props["messages"] = StateJson.list<ChatMessageView>(
+                    new Vector<ChatMessageView>().to_immutable_buffer());
+                props["suggestions"] = StateJson.list<ChatSuggestionView>(
+                    new Vector<ChatSuggestionView>().to_immutable_buffer());
+            }
+            return state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "chat",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+        }
+
+        /** Shapes one transcript row (payloads parsed for render). */
+        private static ChatMessageView message_view(AdvisorMessage row) {
+            var view = new ChatMessageView() {
+                id = row.id,
+                kind = row.kind,
+                content = row.content,
+                when_display = format_stamp(row.created_at)
+            };
+            if (row.answer != null) {
+                view.answered = true;
+                view.answer = (!)row.answer;
+            }
+            if (row.payload == null) {
+                return view;
+            }
+            var body = parse_object(((!)row.payload));
+            if (body == null) {
+                return view;
+            }
+            var parsed = (!)body;
+            if (parsed.has_member("question")) {
+                view.is_question = true;
+                var options = new Vector<string>();
+                var options_member = parsed.get_member("options");
+                if (options_member != null
+                        && options_member.get_node_type() == Json.NodeType.ARRAY) {
+                    foreach (var node in options_member.get_array().get_elements()) {
+                        if (node.get_node_type() == Json.NodeType.VALUE
+                                && node.get_string() != null) {
+                            options.add((!)node.get_string());
+                        }
+                    }
+                }
+                view.options = options.to_array();
+                return view;
+            }
+            if (parsed.has_member("name")) {
+                view.tool_name = member_string(parsed, "name");
+                view.arguments = pretty(member_string(parsed, "arguments"));
+                view.result = pretty(member_string(parsed, "result"));
+            }
+            return view;
+        }
+
+        /** Shapes one suggestion row (payload fields + sealed decide). */
+        private static ChatSuggestionView suggestion_view(AdvisorSuggestion row,
+                ActionRegistry? actions) throws GLib.Error {
+            var view = new ChatSuggestionView() {
+                id = row.id,
+                kind = row.kind,
+                status = row.status,
+                task_id = row.task_id,
+                goal_id = row.goal_id,
+                created_display = format_stamp(row.created_at)
+            };
+            var body = parse_object(row.payload);
+            if (body != null) {
+                var parsed = (!)body;
+                view.title = member_string(parsed, "title");
+                view.body = member_string(parsed,
+                    row.kind == "goal" ? "brief" : "mandate");
+                view.mode = member_string(parsed, "mode");
+                view.branch_name = member_string(parsed, "branch_name");
+                if (parsed.has_member("priority")
+                        && parsed.get_member("priority").get_node_type()
+                            == Json.NodeType.VALUE) {
+                    view.priority = parsed.get_int_member("priority");
+                }
+                if (parsed.has_member("project_id")
+                        && parsed.get_member("project_id").get_node_type()
+                            == Json.NodeType.VALUE) {
+                    view.project_id = parsed.get_int_member("project_id");
+                }
+            }
+            if (actions != null && row.status == "pending") {
+                view.decide = ((!)actions)
+                    .author_private<DecideAdvisorSuggestionAction,
+                        AdvisorSuggestionActionPrivate>(
+                        new AdvisorSuggestionActionPrivate() {
+                            suggestion_id = row.id,
+                            chat_id = row.chat_id
+                        });
+            }
+            return view;
+        }
+
+        /** A JSON payload's root object, or null when unparseable. */
+        private static Json.Object? parse_object(string payload) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(payload);
+            } catch (Error e) {
+                return null;
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return null;
+            }
+            return ((!)root).get_object();
+        }
+
+        private static string member_string(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return "";
+        }
+
+        /** Re-indents JSON text for the collapsed tool-call details. */
+        private static string pretty(string json) {
+            if (json.length == 0) {
+                return "";
+            }
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(json);
+            } catch (Error e) {
+                return json;
+            }
+            var root = parser.get_root();
+            if (root == null) {
+                return json;
+            }
+            return Json.to_string(((!)root), true);
+        }
+    }
+
+    /**
+     * Entrypoint for /chats/{id} (goal 27): one advisor chat — the
+     * transcript (user/assistant bubbles, collapsed tool calls,
+     * question cards with option buttons, error and system notices),
+     * the pending suggestion cards, the composer and the delete
+     * action. The page subscribes to the global pulse and re-runs its
+     * sealed refresh action on every bump, so a running turn's
+     * appends stream in without a reload.
+     */
+    public class ChatDetailEntrypoint : StatumEntrypoint {
+
+        private ChatService chat_service = Inversion.inject<ChatService>();
+        private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
+        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 me = SpryAuth.current(request);
+            int64 viewer_id = me != null ? ((!)me).user_id : 0;
+            int64 chat_id = int64.parse(request.route("chat_id") ?? "0");
+
+            var state = yield ChatState.build(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, chat_id);
+            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+}

+ 84 - 0
src/entrypoints/ChatsEntrypoint.vala

@@ -0,0 +1,84 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * One advisor chat row on the /chats list. {@link status_display}
+     * is the human label of the chat's loop status ('idle', 'running'
+     * or 'awaiting_answer'); the badge class mirrors the chip.
+     */
+    public class ChatRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "idle"; }
+        public string status_display { get; set; default = "idle"; }
+        public string created_display { get; set; default = ""; }
+        public string updated_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `chats` slot (the /chats list page). */
+    public class ChatsPublic : Object {
+        /** True when an LLM endpoint resolves from the environment. */
+        public bool advisor_configured { get; set; }
+    }
+
+    /**
+     * Entrypoint for /chats (goal 27): the signed-in user's advisor
+     * chats, newest first. Chats are PRIVATE per user — the list is
+     * always filtered by the viewer's user_id, and every row's delete
+     * action is sealed against that same owner.
+     */
+    public class ChatsEntrypoint : StatumEntrypoint {
+
+        private ChatService chat_service = Inversion.inject<ChatService>();
+        private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
+        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 me = SpryAuth.current(request);
+            int64 viewer_id = me != null ? ((!)me).user_id : 0;
+
+            var rows = new Vector<ChatRowView>();
+            foreach (var chat in yield chat_service.list_chats(viewer_id)) {
+                var sealed = new AdvisorChatActionPrivate() {
+                    chat_id = chat.id
+                };
+                rows.add(new ChatRowView() {
+                    id = chat.id,
+                    title = chat.title,
+                    status = chat.status,
+                    status_display = ChatState.status_display(chat.status),
+                    created_display = format_stamp(chat.created_at),
+                    updated_display = format_stamp(chat.updated_at),
+                    delete = action_registry.author_private<DeleteAdvisorChatAction,
+                        AdvisorChatActionPrivate>(sealed)
+                });
+            }
+
+            var pub = new ChatsPublic() {
+                advisor_configured = advisor_service.configured
+            };
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["chats"] = StateJson.list<ChatRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "chats",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+}

+ 22 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -128,6 +128,18 @@ namespace ClankerSaddle {
         public string push_vapid_key { get; set; default = ""; }
         /** The signed-in user holds at least one push subscription. */
         public bool push_subscribed { get; set; }
+        /**
+         * True when an LLM endpoint resolves from the environment —
+         * the dashboard's "Ask the advisor" composer renders disabled
+         * with a notice when false (goal 27).
+         */
+        public bool advisor_configured { get; set; }
+        /**
+         * Starts a new advisor chat from the dashboard's hero
+         * composer (goal 27): creates the chat, records the message
+         * and kicks the background turn.
+         */
+        public Statum.Model.ActionDto? start_advisor { get; set; }
         public Statum.Model.ActionDto? push_subscribe { get; set; }
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         /** Queues a REAL test push to this user (the bell's probe). */
@@ -341,7 +353,17 @@ namespace ClankerSaddle {
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
                 pub.add_note = ((!)actions).author<AddNoteAction>();
+                // The advisor composer (goal 27): an unsealed action —
+                // the chat it creates belongs to the signed-in user
+                // by construction.
+                pub.start_advisor = ((!)actions).author<StartAdvisorChatAction>();
             }
+            // Mirrors AdvisorService.configured (the same environment
+            // resolution, resolved statically so the shared build
+            // needs no new injected dependency) so the composer can
+            // disable itself with a notice when no endpoint is set.
+            pub.advisor_configured =
+                Services.AdvisorLlmEndpoint.from_environment() != null;
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
             // key the browser needs, whether this user subscribed

+ 43 - 0
src/main.vala

@@ -47,6 +47,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_EventIndexes>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectUnderstandings>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0005_Discoveries>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0006_Advisor>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -68,6 +69,9 @@ int main(string[] args) {
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
+        db.add_entity<AdvisorChat>(AdvisorChat.entity_mapping);
+        db.add_entity<AdvisorMessage>(AdvisorMessage.entity_mapping);
+        db.add_entity<AdvisorSuggestion>(AdvisorSuggestion.entity_mapping);
         db.add_entity<Stack>(Stack.entity_mapping);
         db.add_entity<StackProject>(StackProject.entity_mapping);
         db.add_entity<Note>(Note.entity_mapping);
@@ -87,10 +91,13 @@ int main(string[] args) {
         application.add_scoped<CapabilityService>();
         application.add_scoped<AllocationService>();
         application.add_scoped<TimelineService>();
+        application.add_scoped<ChatService>();
         application.add_singleton<SaddlePollState>();
         application.add_singleton<StatePulse>();
         application.add_singleton<PushService>();
         application.add_singleton<Dispatcher>();
+        application.add_singleton<AdvisorService>();
+        application.add_singleton<AdvisorCallback>();
 
         // Web Push (plan 11 task E): the VAPID keypair loads or
         // generates + persists BEFORE the server accepts requests, so
@@ -117,6 +124,8 @@ int main(string[] args) {
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         statum.add_page<DiscoveriesPage, DiscoveriesEntrypoint>();
+        statum.add_page<ChatsPage, ChatsEntrypoint>();
+        statum.add_page<ChatDetailPage, ChatDetailEntrypoint>();
         statum.add_page<StacksPage, StacksEntrypoint>();
         statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
@@ -177,6 +186,12 @@ int main(string[] args) {
         statum.action<RefreshUnderstandingAction>();
         statum.action<UpdateDiscoveryAction>();
         statum.action<DeleteDiscoveryAction>();
+        statum.action<StartAdvisorChatAction>();
+        statum.action<SendAdvisorMessageAction>();
+        statum.action<AnswerAdvisorQuestionAction>();
+        statum.action<DeleteAdvisorChatAction>();
+        statum.action<RefreshAdvisorChatAction>();
+        statum.action<DecideAdvisorSuggestionAction>();
         statum.action<CreateStackAction>();
         statum.action<UpdateStackAction>();
         statum.action<DeleteStackAction>();
@@ -252,10 +267,21 @@ int main(string[] args) {
             new EndpointRoute("/tasks/{task_id}/events/stream", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.ServiceWorkerEndpoint>(
             new EndpointRoute("/sw.js", Method.GET));
+        application.add_endpoint<ClankerSaddle.Api.AdvisorChatEndpoint>(
+            new EndpointRoute("/api/advisor/chat", Method.POST));
+        application.add_endpoint<ClankerSaddle.Api.AdvisorChatEndpoint>(
+            new EndpointRoute("/api/advisor/turn", Method.POST));
+        application.add_endpoint<ClankerSaddle.Api.AdvisorChatEndpoint>(
+            new EndpointRoute("/api/advisor/answer", Method.POST));
+        application.add_endpoint<ClankerSaddle.Api.AdvisorChatEndpoint>(
+            new EndpointRoute("/api/advisor/chat/delete", Method.POST));
+        application.add_endpoint<ClankerSaddle.Api.AdvisorChatDetailEndpoint>(
+            new EndpointRoute("/api/advisor/chat/{id}", Method.GET));
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
             start_dispatcher.begin(application.container);
+            start_advisor_callback.begin(application.container);
             return false;
         });
 
@@ -299,3 +325,20 @@ private async void start_dispatcher(Container container) {
         printerr("[ClankerSaddle] Dispatcher startup failed: %s\n", e.message);
     }
 }
+
+/**
+ * Starts the advisor completion callback's periodic pass alongside
+ * the dispatcher (goal 27): every ADVISOR_CALLBACK_TICK_S (15s) it
+ * delivers terminal task outcomes into the chats that suggested
+ * them.
+ */
+private async void start_advisor_callback(Container container) {
+    try {
+        var scope = container.create_scope();
+        scope.resolve<AdvisorCallback>().start();
+        scope.release();
+    } catch (Error e) {
+        printerr("[ClankerSaddle] Advisor callback startup failed: %s\n",
+            e.message);
+    }
+}

+ 143 - 0
src/pages/app.css

@@ -1330,3 +1330,146 @@ dialog#quote-comment-dialog blockquote {
 @media (max-width: 40rem) {
     .log-header { align-items: flex-start; flex-direction: column; }
 }
+
+/* --- Advisor chats (goal 27) ---------------------------------------- */
+
+/* The dashboard's hero composer: the page's FIRST and predominant
+   control, above every dashboard section. */
+.advisor-hero {
+    border-color: var(--accent);
+    margin-bottom: var(--grid-3);
+}
+.advisor-hero form { margin: 0; }
+.advisor-hero textarea {
+    font-size: 1.05rem;
+    min-height: 3.4rem;
+}
+.advisor-hero .form-row { align-items: flex-end; }
+.advisor-hero .notice { margin-bottom: 0; }
+
+/* The chat page: a column of transcript lanes under the head. */
+.chat-page { display: flex; flex-direction: column; gap: var(--grid-2); }
+.chat-head h2 { margin-bottom: 0.25rem; }
+.chat-head .chat-delete { margin-left: var(--grid-1); }
+
+.chat-thread {
+    background: var(--surface);
+    border: 1px solid var(--line-soft);
+    border-radius: var(--radius);
+    display: flex;
+    flex-direction: column;
+    gap: var(--grid-1);
+    max-height: 60vh;
+    overflow-y: auto;
+    padding: var(--grid-2);
+}
+
+/* Bubbles: the user's rows hug the right, the advisor's the left. */
+.chat-row { display: flex; flex-direction: column; }
+.chat-row-user { align-items: flex-end; }
+.chat-row-assistant,
+.chat-row-system,
+.chat-row-error { align-items: flex-start; }
+
+.chat-bubble {
+    border: 1px solid var(--line-soft);
+    border-radius: var(--radius);
+    max-width: 46rem;
+    padding: var(--grid-1) var(--grid-2);
+}
+.chat-bubble .md,
+.chat-bubble .md p { margin: 0; }
+.chat-bubble .md p + p { margin-top: 0.5rem; }
+.chat-user { background: #ddf4ff; border-color: #b6e3ff; }
+.chat-assistant { background: var(--surface-sunken); }
+.chat-bubble .muted { font-size: 0.75rem; margin: 0.25rem 0 0; }
+
+/* Tool calls sit between the lanes as collapsed details. */
+.chat-tool { width: 100%; }
+.chat-tool details {
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
+    border-radius: 6px;
+    font-size: 0.85rem;
+    padding: 0.35rem 0.75rem;
+}
+.chat-tool summary { cursor: pointer; }
+.chat-tool .badge { margin-right: 0.5rem; }
+.chat-tool .muted { margin: 0.4rem 0 0.15rem; }
+.chat-tool pre {
+    background: var(--surface);
+    border: 1px solid var(--line-soft);
+    border-radius: 4px;
+    font-size: 0.78rem;
+    margin: 0;
+    max-height: 18rem;
+    overflow: auto;
+    padding: 0.5rem 0.6rem;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+/* Errors stand out; system notices stay quiet. */
+.chat-error { width: 100%; }
+.chat-error p {
+    background: #ffebe9;
+    border: 1px solid #ff818266;
+    border-radius: var(--radius);
+    color: var(--danger);
+    margin: 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+.chat-system { width: 100%; }
+.chat-system p {
+    border-left: 3px solid var(--line);
+    color: var(--ink-muted);
+    margin: 0;
+    padding-left: 0.75rem;
+}
+
+/* A parked question: the bubble plus its answer card. */
+.chat-question { align-items: flex-start; display: flex; flex-direction: column; }
+.chat-question-card { margin-top: var(--grid-1); padding: var(--grid-1) var(--grid-2); }
+.chat-question-card .muted { margin: 0 0 var(--grid-1); }
+.chat-options { display: flex; flex-wrap: wrap; gap: var(--grid-1); margin-bottom: var(--grid-1); }
+.chat-options button { cursor: pointer; }
+
+/* The thinking indicator, while a turn runs. */
+.chat-thinking p {
+    animation: chat-think 1.2s ease-in-out infinite;
+    font-style: italic;
+    margin: 0;
+}
+@keyframes chat-think { 50% { opacity: 0.45; } }
+
+/* Suggestion cards: the decision buttons row. */
+.chat-suggestion-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--grid-1);
+    margin-top: var(--grid-1);
+}
+
+/* The composer: a textarea beside its send button. */
+.chat-composer-row {
+    display: flex;
+    gap: var(--grid-1);
+    align-items: flex-end;
+}
+.chat-composer-row textarea { flex: 1; resize: vertical; }
+.chat-composer-row button:disabled,
+.chat-composer-row textarea:disabled { opacity: 0.6; }
+
+/* Chat status chips (the list rows and the page head). */
+.badge-chat-idle { background: #dafbe1; color: var(--success); }
+.badge-chat-running { background: #fff8c5; color: var(--warning); }
+.badge-chat-awaiting_answer { background: #ddf4ff; color: var(--accent); }
+.badge-tool { background: #f0f0f0; color: #6e7781; }
+.badge-suggestion-pending { background: #fff8c5; color: var(--warning); }
+.badge-suggestion-approved { background: #dafbe1; color: var(--success); }
+.badge-suggestion-declined { background: var(--line-soft); color: var(--ink-muted); }
+
+@media (max-width: 40rem) {
+    .chat-bubble { max-width: 100%; }
+    .chat-composer-row { flex-direction: column; align-items: stretch; }
+}

+ 262 - 0
src/pages/chat_detail.html

@@ -0,0 +1,262 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/chats/{chat_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+<script>
+    // Live chat (goal 27): a turn runs in the BACKGROUND - the send
+    // returns immediately and every append (tool call, assistant
+    // reply, parked question, suggestion) bumps the global pulse.
+    // This listener re-runs the sealed refresh action per bump, the
+    // exact pattern the dashboard uses, so the transcript streams in
+    // without a reload. All submissions ride statum.act (the JSON
+    // transport), never a urlencoded form: Astralis mangles '+' in
+    // form bodies and free text is full of them.
+    if (window.statum) {
+        (function () {
+            'use strict';
+
+            function chatState() {
+                return statum.read('chat') || {};
+            }
+
+            statum.addStateListener('pulse', function () {
+                var chat = chatState();
+                if (chat.refresh) {
+                    statum.act(chat.refresh, { chat_id: chat.id }).catch(function () { });
+                }
+            });
+
+            // Keep the transcript pinned to its newest row after the
+            // initial render and every refresh.
+            if (statum.afterEvaluate) {
+                statum.afterEvaluate(function () {
+                    var thread = document.getElementById('chat-thread');
+                    if (thread) {
+                        thread.scrollTop = thread.scrollHeight;
+                    }
+                });
+            }
+
+            function trimmed(text) {
+                return String(text || '').replace(/^\s+|\s+$/g, '');
+            }
+
+            window.sendAdvisorMessage = function () {
+                var chat = chatState();
+                if (!chat.send || chat.status === 'running') return;
+                var box = document.getElementById('chat-composer');
+                if (!box) return;
+                var text = trimmed(box.value);
+                if (!text) return;
+                box.value = '';
+                statum.act(chat.send, { message: text }).catch(function () { });
+            };
+
+            // The question card's "Other" box: the form carries its
+            // own input, so no state lookups are needed.
+            window.submitAdvisorOther = function (form) {
+                var chat = chatState();
+                if (!chat.send || !form || !form.elements.other) return;
+                var text = trimmed(form.elements.other.value);
+                if (!text) return;
+                form.elements.other.value = '';
+                statum.act(chat.send, { answer: text }).catch(function () { });
+            };
+
+            // Option buttons and suggestion decisions go through one
+            // delegated click handler: the cards carry their row ids
+            // as data attributes (hydrated from state), the buttons
+            // carry their payload as text or a data-decision, and the
+            // handler re-reads the CURRENT state row - so a refresh
+            // mid-click can never act on a stale sealed action.
+            function suggestionRow(id) {
+                var chat = chatState();
+                var rows = chat.suggestions || [];
+                for (var index = 0; index < rows.length; index++) {
+                    if (String(rows[index].id) === String(id)) return rows[index];
+                }
+                return null;
+            }
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+
+                var option = event.target.closest('.chat-options button');
+                if (option) {
+                    event.preventDefault();
+                    var chat = chatState();
+                    var answer = trimmed(option.textContent);
+                    if (chat.send && answer) {
+                        statum.act(chat.send, { answer: answer }).catch(function () { });
+                    }
+                    return;
+                }
+
+                var decision = event.target.closest('[data-decision]');
+                if (decision) {
+                    event.preventDefault();
+                    var card = decision.closest('[data-suggestion-id]');
+                    if (!card) return;
+                    var row = suggestionRow(card.getAttribute('data-suggestion-id'));
+                    if (row && row.decide) {
+                        statum.act(row.decide, {
+                            decision: decision.getAttribute('data-decision')
+                        }).catch(function () { });
+                    }
+                }
+            });
+
+            // A transcript row's wrapper class: the kind picks the
+            // lane (user right, assistant left, tool/system wide).
+            window.chatMessageClass = function (message) {
+                if (!message || !message.kind) return 'chat-row';
+                return 'chat-row chat-row-' + message.kind;
+            };
+        })();
+    }
+</script>
+</head>
+<body>
+    <section>
+        <p stm-if="chat == null">Loading…</p>
+
+        <div stm-if="chat != null && chat.id == 0">
+            <h2>Chat not found</h2>
+            <p class="muted">That chat does not exist, or belongs to another user -
+                chats are private. <a href="/chats">Back to your chats.</a></p>
+        </div>
+
+        <div stm-if="chat != null && chat.id > 0" class="chat-page">
+            <div class="chat-head">
+                <h2 stm-text="chat.title">title</h2>
+                <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                         stm-text="chat.status_display">status</span>
+                    <span class="muted">advisor chat</span>
+                    <form class="inline">
+                        <button type="button" class="chat-delete" stm-action="chat.delete"
+                                stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
+                    </form></p>
+                <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
+                    configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+                    <code>SADDLE_LLM_BASE_URL</code>) to enable it. Your messages wait
+                    until then.</p>
+            </div>
+
+            <div class="chat-thread" id="chat-thread">
+                <!-- The transcript, oldest first. Tool rows render as
+                     collapsed details; a question row carries option
+                     buttons until answered; errors and system
+                     notices render in their own muted styles. -->
+                <div stm-for-message-in="chat.messages" stm-key="message.id"
+                     stm-attribute.class="window.chatMessageClass(message)">
+                    <div stm-if="message.kind == 'tool'" class="chat-tool">
+                        <details>
+                            <summary><span class="badge badge-tool">tool</span>
+                                <code stm-text="message.tool_name">name</code></summary>
+                            <p class="muted">arguments</p>
+                            <pre stm-text="message.arguments">args</pre>
+                            <p class="muted">result</p>
+                            <pre stm-text="message.result">result</pre>
+                        </details>
+                    </div>
+                    <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
+                        <div class="md" stm-markdown="message.content">message</div>
+                        <p class="muted" stm-text="message.when_display">when</p>
+                    </div>
+                    <div stm-if="message.kind == 'assistant' && !message.is_question"
+                         class="chat-bubble chat-assistant">
+                        <div class="md" stm-markdown="message.content">message</div>
+                        <p class="muted" stm-text="message.when_display">when</p>
+                    </div>
+                    <div stm-if="message.kind == 'assistant' && message.is_question"
+                         class="chat-question">
+                        <div class="chat-bubble chat-assistant">
+                            <div class="md" stm-markdown="message.content">question</div>
+                        </div>
+                        <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
+                             class="card chat-question-card"
+                             stm-attribute.data-message-id="message.id">
+                            <p stm-if="message.options.length > 0" class="muted">Choose an
+                                answer, or type your own below.</p>
+                            <div class="chat-options" stm-if="message.options.length > 0">
+                                <button type="button" stm-for-option-in="message.options"
+                                        stm-key="option" stm-text="option">option</button>
+                            </div>
+                            <form class="inline"
+                                  onsubmit="window.submitAdvisorOther(this); return false;">
+                                <input type="text" name="other"
+                                       placeholder="Or type your own answer">
+                                <button type="submit">Answer</button>
+                            </form>
+                        </div>
+                        <p stm-if="message.answered" class="muted">You answered:
+                            <strong stm-text="message.answer">answer</strong></p>
+                    </div>
+                    <div stm-if="message.kind == 'error'" class="chat-error">
+                        <p stm-text="message.content">error</p>
+                    </div>
+                    <div stm-if="message.kind == 'system'" class="chat-system">
+                        <p class="muted" stm-text="message.content">system</p>
+                    </div>
+                </div>
+
+                <div stm-if="chat.status == 'running'" class="chat-thinking">
+                    <p class="muted">thinking…</p>
+                </div>
+
+                <p stm-if="chat.messages.length == 0" class="muted">Nothing yet - say
+                    something below.</p>
+            </div>
+
+            <div class="cards" stm-if="chat.suggestions.length > 0">
+                <section stm-for-suggestion-in="chat.suggestions"
+                         stm-key="suggestion.id"
+                         stm-attribute.data-suggestion-id="suggestion.id"
+                         class="card chat-suggestion">
+                    <p><span stm-attribute.class="'badge badge-kind-' + suggestion.kind"
+                             stm-text="suggestion.kind">task</span>
+                        <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
+                              stm-text="suggestion.status">pending</span>
+                        <span class="muted" stm-text="suggestion.created_display">when</span></p>
+                    <p><strong stm-text="suggestion.title">title</strong></p>
+                    <div class="md" stm-markdown="suggestion.body">body</div>
+                    <p class="muted" stm-if="suggestion.kind == 'task'">
+                        mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
+                        - priority <span stm-text="suggestion.priority">5</span>
+                        <span stm-if="suggestion.project_id > 0"> - project
+                            <span stm-text="suggestion.project_id">id</span></span></p>
+                    <p class="muted" stm-if="suggestion.kind == 'goal'">
+                        <span stm-if="suggestion.branch_name.length > 0">branch
+                            <code stm-text="suggestion.branch_name">branch</code> - </span>
+                        <span stm-if="suggestion.project_id > 0">project
+                            <span stm-text="suggestion.project_id">id</span> - </span>
+                        created as an unqueued draft</p>
+                    <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
+                        <button type="button" class="primary" data-decision="approve">Approve</button>
+                        <button type="button" data-decision="approve_enqueue">Approve + enqueue</button>
+                        <button type="button" data-decision="decline">Decline</button>
+                    </div>
+                    <p stm-if="suggestion.status != 'pending'">
+                        <a stm-if="suggestion.task_id > 0"
+                           stm-attribute.href="'/tasks/' + suggestion.task_id">task</a>
+                        <a stm-if="suggestion.goal_id > 0"
+                           stm-attribute.href="'/goals/' + suggestion.goal_id">goal</a>
+                    </p>
+                </section>
+            </div>
+
+            <div class="chat-composer-row">
+                <textarea id="chat-composer" rows="3"
+                          stm-attribute.disabled="chat.status == 'running'"
+                          placeholder="Reply to the advisor..."></textarea>
+                <button type="button" class="primary"
+                        stm-attribute.disabled="chat.status == 'running'"
+                        onclick="window.sendAdvisorMessage()">Send</button>
+            </div>
+            <p class="muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
+                waiting for your answer above.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 47 - 0
src/pages/chats.html

@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/chats</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Chats</h2>
+        <p class="muted">Your advisor conversations. Ask the advisor on the
+            <a href="/">dashboard</a> to start a new one - it can read the fleet's
+            state, ask you questions and suggest tasks or goals, but never runs
+            commands itself.</p>
+        <p class="notice" stm-if="chats != null && !chats.advisor_configured">The
+            advisor is not configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+            <code>SADDLE_LLM_BASE_URL</code>) to enable it. Chats you start now
+            will wait until then.</p>
+
+        <p stm-if="chats == null">Loading…</p>
+
+        <div stm-if="chats != null">
+            <table stm-if="chats.chats.length > 0">
+                <thead><tr><th>Chat</th><th>Status</th><th>Created</th><th>Updated</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-chat-in="chats.chats" stm-key="chat.id">
+                        <td><a stm-attribute.href="'/chats/' + chat.id"
+                               stm-text="chat.title">title</a></td>
+                        <td><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                                  stm-text="chat.status_display">status</span></td>
+                        <td stm-text="chat.created_display">when</td>
+                        <td stm-text="chat.updated_display">when</td>
+                        <td>
+                            <form class="inline">
+                                <button type="button" stm-action="chat.delete"
+                                        stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="chats.chats.length == 0" class="muted">No chats yet - type a
+                question into the "Ask the advisor" box at the top of the
+                <a href="/">dashboard</a> to start one.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 40 - 0
src/pages/home.html

@@ -44,6 +44,21 @@
                 return statum.read('dashboard') || {};
             }
 
+            // The hero composer (goal 27): starts a NEW advisor chat.
+            // statum.act rides the JSON transport, so free text (with
+            // its plusses and quotes) survives intact; the action
+            // answers with a navigation to the new chat page.
+            window.startAdvisorChat = function () {
+                var state = dashboard();
+                if (!state.start_advisor) return;
+                var box = document.getElementById('advisor-composer');
+                if (!box) return;
+                var text = box.value.replace(/^\s+|\s+$/g, '');
+                if (!text) return;
+                box.value = '';
+                statum.act(state.start_advisor, { message: text }).catch(function () { });
+            };
+
             function fail(error) {
                 if (window.showToast) {
                     showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
@@ -230,6 +245,31 @@
 </head>
 <body>
     <section>
+        <!-- The advisor hero (goal 27): the page's FIRST and
+             predominant control. Starting a chat here creates it
+             (titled from the first line) and lands on the chat page;
+             the advisor reads fleet state, asks questions and
+             suggests tasks/goals, but never runs commands. -->
+        <section stm-if="dashboard != null" class="advisor-hero card">
+            <form class="inline advisor-hero-form"
+                  onsubmit="window.startAdvisorChat(); return false;">
+                <div class="form-row">
+                    <label for="advisor-composer">Ask the advisor</label>
+                    <textarea id="advisor-composer" rows="2"
+                              stm-attribute.disabled="!dashboard.advisor_configured"
+                              placeholder="What should the fleet work on next?"></textarea>
+                    <button type="submit" class="primary"
+                            stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
+                </div>
+            </form>
+            <p class="muted" stm-if="dashboard.advisor_configured">Reads your projects,
+                tasks, goals, rules and discoveries - asks you questions and suggests
+                work. <a href="/chats">Your chats</a></p>
+            <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
+                configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+                <code>SADDLE_LLM_BASE_URL</code>) to enable it.</p>
+        </section>
+
         <h2>Dashboard</h2>
         <p stm-if="dashboard == null">Loading…</p>
 

+ 1 - 0
src/pages/main.html

@@ -23,6 +23,7 @@
                     <a href="/">Dashboard</a>
                     <a href="/tasks">Tasks</a>
                     <a class="nav-goal" href="/goals"><em class="goal-icon">🎯</em> Goals</a>
+                    <a href="/chats">Chats</a>
                 </div>
             </details>
             <details class="nav-menu" name="nav-menu">

+ 503 - 0
test/bundles/advisor.py

@@ -0,0 +1,503 @@
+"""The advisor backend bundle (goal 27, task 1): the AI Chat loop's
+storage + LLM turn machinery, driven end-to-end against an in-process
+scripted OpenAI-compatible chat-completions endpoint (the FakeSaddle
+pattern). The scenarios:
+
+- the migration: advisor_chats / advisor_messages / advisor_suggestions
+  exist with the goal-27 shapes
+- a full turn: user message → scripted tool call (list_projects, whose
+  result rides the NEXT request's messages as a `tool` role row) →
+  ask_question parks the chat ('awaiting_answer', an assistant row
+  with the options payload, no further LLM call)
+- answering: the option is recorded, "Human selected: X" lands as a
+  user row, and the turn resumes to a plain assistant reply ('idle')
+- suggestions: suggest_task inserts a PENDING row owned by the chat's
+  user; nothing exists in tasks/goals yet
+- the error path: one 500 from the endpoint appends a kind='error'
+  message and the chat returns to idle
+- the iteration cap: an endpoint that always answers with a tool call
+  stops at 12 rounds with an error row, never looping forever
+- privacy: a second user cannot see or turn the first user's chat
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+class ScriptedLlm:
+    """In-process OpenAI-compatible chat-completions endpoint: every
+    POST /chat/completions is inspected (messages + tools) and the
+    next scripted response is served. `script` is a list of dicts:
+    {"tools": [{id, name, arguments}]} or {"content": "..."}; an
+    exhausted script repeats its last entry. `fail_next` makes the
+    next N requests answer 500."""
+
+    def __init__(self, script):
+        self.script = list(script)
+        self.cursor = 0
+        self.requests = []
+        self.fail_next = 0
+        self.lock = threading.Lock()
+        self.port = None
+        self.server = None
+
+    def serve(self):
+        service = self
+
+        class Handler(BaseHTTPRequestHandler):
+            protocol_version = "HTTP/1.1"
+
+            def log_message(self, fmt, *args):
+                pass
+
+            def do_POST(self):
+                length = int(self.headers.get("Content-Length") or 0)
+                raw = self.rfile.read(length)
+                try:
+                    payload = json.loads(raw or b"{}")
+                except ValueError:
+                    payload = {}
+                with service.lock:
+                    service.requests.append(payload)
+                    fail = service.fail_next > 0
+                    if fail:
+                        service.fail_next -= 1
+                    index = min(service.cursor, len(service.script) - 1)
+                    step = service.script[index]
+                    service.cursor += 1
+                if fail:
+                    body = b'{"error": "scripted failure"}'
+                    self.send_response(500)
+                else:
+                    message = {"role": "assistant"}
+                    if step.get("content") is not None:
+                        message["content"] = step["content"]
+                    calls = step.get("tools") or []
+                    if calls:
+                        message["content"] = step.get("content") or ""
+                        message["tool_calls"] = [
+                            {"id": call["id"], "type": "function",
+                             "function": {"name": call["name"],
+                                          "arguments": call["arguments"]}}
+                            for call in calls]
+                        finish = "tool_calls"
+                    else:
+                        finish = "stop"
+                    envelope = {"choices": [{"index": 0, "finish_reason": finish,
+                                             "message": message}],
+                                "model": "scripted"}
+                    body = json.dumps(envelope).encode()
+                    self.send_response(200)
+                self.send_header("Content-Type", "application/json")
+                self.send_header("Content-Length", str(len(body)))
+                self.end_headers()
+                self.wfile.write(body)
+
+        self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+        self.port = self.server.server_address[1]
+        threading.Thread(target=self.server.serve_forever, daemon=True).start()
+        return self.server
+
+    def kill(self):
+        if self.server is not None:
+            self.server.shutdown()
+            self.server.server_close()
+            self.server = None
+
+
+def advisor_api(auth, method, path, payload=None):
+    """A login-gated advisor API call with the statum slot header."""
+    request = urllib.request.Request(
+        WEB + path, data=json.dumps(payload or {}).encode(), method=method)
+    request.add_header("Content-Type", "application/json")
+    request.add_header("X-Statum-Slot",
+                       "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
+    try:
+        with urllib.request.urlopen(request, timeout=15) as response:
+            return response.status, json.loads(response.read().decode() or "{}")
+    except urllib.error.HTTPError as error:
+        body = error.read().decode()
+        try:
+            return error.code, json.loads(body)
+        except ValueError:
+            return error.code, {"raw": body}
+
+
+def chat_rows(chat_id, what="*"):
+    conn = db()
+    try:
+        return conn.execute(
+            "select %s from advisor_messages where chat_id = ? order by id"
+            % what, (chat_id,)).fetchall()
+    finally:
+        conn.close()
+
+
+def wait_status(chat_id, status, timeout=30):
+    def read():
+        conn = db()
+        try:
+            row = conn.execute(
+                "select status from advisor_chats where id = ?",
+                (chat_id,)).fetchone()
+            return row and row[0] == status and status or False
+        finally:
+            conn.close()
+    return wait_for(read, timeout=timeout,
+                    what="chat %d -> %s" % (chat_id, status))
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    def db_rows(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchall()
+        finally:
+            conn.close()
+
+    print("== advisor: the migration ==")
+    tables = {r[0] for r in db_rows(
+        "select name from sqlite_master where type = 'table'")}
+    check({"advisor_chats", "advisor_messages", "advisor_suggestions"} <= tables,
+          "M0006 creates the three advisor tables on a fresh DB")
+    chat_columns = [r[1] for r in db_rows("pragma table_info(advisor_chats)")]
+    check(chat_columns == ["id", "user_id", "title", "status", "created_at",
+                           "updated_at"],
+          "advisor_chats carries the goal-27 columns (%s)" % chat_columns)
+    message_columns = [r[1] for r in db_rows("pragma table_info(advisor_messages)")]
+    check(message_columns == ["id", "chat_id", "kind", "content", "payload",
+                              "answer", "answered_at", "created_at"],
+          "advisor_messages carries the goal-27 columns (%s)" % message_columns)
+    suggestion_columns = [r[1] for r in db_rows(
+        "pragma table_info(advisor_suggestions)")]
+    check(suggestion_columns == ["id", "chat_id", "user_id", "kind", "payload",
+                                 "status", "task_id", "goal_id", "created_at",
+                                 "decided_at", "delivered_at"],
+          "advisor_suggestions carries the goal-27 columns (%s)"
+          % suggestion_columns)
+
+    print("== advisor: a full turn — tool call, question park ==")
+    llm = ScriptedLlm([
+        {"tools": [{"id": "call-1", "name": "list_projects",
+                    "arguments": "{}"}]},
+        {"tools": [{"id": "call-2", "name": "ask_question",
+                    "arguments": json.dumps({
+                        "question": "Which project should this target?",
+                        "options": ["demo", "other-project"]})}]},
+        {"content": "Understood — targeting demo."},
+    ])
+    llm.serve()
+    # The advisor endpoint must be pointed at the scripted LLM: the
+    # app restart below re-reads ADVISOR_LLM_BASE_URL.
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
+    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = auth = login("admin", "adminpass123")
+
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "Fleet review"})
+    check(status == 200 and body.get("ok") and int(body.get("chat_id", 0)) > 0,
+          "POST /api/advisor/chat creates the chat")
+    chat_id = int(body["chat_id"])
+
+    status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
+                            {"chat_id": chat_id,
+                             "message": "Which project should I target?"})
+    check(status == 200, "POST /api/advisor/turn accepts the message")
+
+    # The tool call round: the request the LLM saw carries the tools
+    # array and, on the SECOND call, the tool result as a `tool` role
+    # message (the wire format rebuilt from rows).
+    check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=15,
+                   what="first LLM call") is not None,
+          "the turn calls the LLM")
+    first_request = llm.requests[0]
+    tool_names = [t["function"]["name"] for t in first_request.get("tools") or []]
+    check("list_projects" in tool_names and "ask_question" in tool_names
+          and "suggest_task" in tool_names and "suggest_goal" in tool_names,
+          "the request declares the advisor tool set")
+    roles = [m["role"] for m in first_request.get("messages") or []]
+    check(roles[0] == "system" and "read-only" in
+          (first_request["messages"][0]["content"]),
+          "the system prompt leads the messages")
+    check(any(m.get("role") == "user"
+              and m.get("content") == "Which project should I target?"
+              for m in first_request["messages"]),
+          "the user message rides the request")
+
+    check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=15,
+                   what="second LLM call") is not None,
+          "the tool result triggers the next LLM call")
+    second_request = llm.requests[1]
+    tool_rows = [m for m in second_request["messages"] if m.get("role") == "tool"]
+    check(len(tool_rows) == 1 and tool_rows[0]["tool_call_id"] == "call-1"
+          and json.loads(tool_rows[0]["content"]).get("projects"),
+          "the tool result rides back as a `tool` role message with the row data")
+    assistant_call = [m for m in second_request["messages"]
+                      if m.get("role") == "assistant" and m.get("tool_calls")]
+    check(len(assistant_call) == 1
+          and assistant_call[0]["tool_calls"][0]["id"] == "call-1",
+          "the assistant tool_calls row is reconstructed on the wire")
+
+    # ask_question parks: status awaiting_answer, one assistant row
+    # carrying the question + options payload, and NO further LLM call.
+    check(wait_status(chat_id, "awaiting_answer"),
+          "ask_question parks the chat in awaiting_answer")
+    calls_after_park = len(llm.requests)
+    questions = [r for r in chat_rows(chat_id, "kind, content, payload")
+                 if r[0] == "assistant" and r[2]
+                 and json.loads(r[2]).get("question")]
+    check(len(questions) == 1 and questions[0][1]
+          == "Which project should this target?",
+          "the question is stored as an assistant row with its payload")
+    options = json.loads(questions[0][2]).get("options")
+    check(options == ["demo", "other-project"],
+          "the question payload carries the options (%s)" % options)
+    time.sleep(0.5)
+    check(len(llm.requests) == calls_after_park,
+          "the parked turn makes no further LLM calls")
+
+    print("== advisor: answering resumes the turn ==")
+    status, body = advisor_api(auth, "POST", "/api/advisor/answer",
+                               {"chat_id": chat_id, "answer": "demo"})
+    check(status == 200 and body.get("ok"), "the answer is accepted")
+    check(wait_status(chat_id, "idle"), "the resumed turn ends idle")
+    check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=15,
+                   what="third LLM call") is not None,
+          "the answer resumes the LLM loop")
+    answer_rows = [r for r in chat_rows(chat_id, "kind, content")
+                   if r[1] == "Human selected: demo"]
+    check(len(answer_rows) == 1 and answer_rows[0][0] == "user",
+          'the option answer lands as "Human selected: demo" (user row)')
+    check(any(r[0] == "assistant" and r[1] == "Understood — targeting demo."
+              for r in chat_rows(chat_id, "kind, content")),
+          "the final assistant reply is stored")
+    third_request = llm.requests[2]
+    check(any(m.get("role") == "user"
+              and m.get("content") == "Human selected: demo"
+              for m in third_request["messages"]),
+          "the recorded answer rides the next request verbatim")
+    answered = db_rows("select answer, answered_at from advisor_messages"
+                       " where chat_id = ? and answer is not null", (chat_id,))
+    check(len(answered) == 1 and answered[0][0] == "demo"
+          and answered[0][1] is not None,
+          "the question row keeps the raw answer and its timestamp")
+
+    print("== advisor: free-text answer wording ==")
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "free text"})
+    free_chat = int(body["chat_id"])
+    llm2 = ScriptedLlm([
+        {"tools": [{"id": "f1", "name": "ask_question",
+                    "arguments": json.dumps({"question": "Name it?",
+                                             "options": []})}]},
+        {"content": "Noted."},
+    ])
+    llm2.serve()
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm2.port
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = auth = login("admin", "adminpass123")
+    advisor_api(auth, "POST", "/api/advisor/turn",
+                {"chat_id": free_chat, "message": "suggest a name"})
+    check(wait_status(free_chat, "awaiting_answer"),
+          "an empty-options question parks too (free-text question)")
+    status, _ = advisor_api(auth, "POST", "/api/advisor/answer",
+                            {"chat_id": free_chat, "answer": "Zephyr"})
+    check(wait_status(free_chat, "idle"), "the free-text answer resumes")
+    check(any(r[1] == "Human answered: Zephyr"
+              for r in chat_rows(free_chat, "kind, content")),
+          'a non-option answer lands as "Human answered: ..."')
+    llm2.kill()
+
+    print("== advisor: suggestions insert pending rows ==")
+    llm3 = ScriptedLlm([
+        {"tools": [{"id": "s1", "name": "list_projects",
+                    "arguments": "{}"}]},
+        {"tools": [{"id": "s2", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "Advisor smoke test",
+                        "mandate": "Verify the advisor suggestion arc.",
+                        "mode": "test",
+                        "project_id": 1,
+                        "priority": 4})}]},
+        {"tools": [{"id": "s3", "name": "suggest_goal",
+                    "arguments": json.dumps({
+                        "title": "Advisor goal",
+                        "brief": "Prove goal suggestions work.",
+                        "project_id": 1,
+                        "branch_name": "goal/advisor-smoke"})}]},
+        {"content": "Two suggestions staged for your approval."},
+    ])
+    llm3.serve()
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm3.port
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = auth = login("admin", "adminpass123")
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "suggestions"})
+    sug_chat = int(body["chat_id"])
+    advisor_api(auth, "POST", "/api/advisor/turn",
+                {"chat_id": sug_chat, "message": "stage two suggestions"})
+    check(wait_status(sug_chat, "idle"), "the suggestion turn completes")
+    # the final assistant reply lands AFTER the status flip in some
+    # interleavings — wait for the transcript itself to be complete.
+    check(wait_for(lambda: [r for r in chat_rows(sug_chat, "kind, content")
+                            if r[0] == "assistant"
+                            and r[1] == "Two suggestions staged for your approval."]
+                            or False, timeout=15, what="final reply") is not None,
+          "the suggestion turn's final reply is stored")
+    suggestions = db_rows(
+        "select kind, payload, status, task_id, goal_id, user_id"
+        " from advisor_suggestions where chat_id = ? order by id", (sug_chat,))
+    check(len(suggestions) == 2, "two suggestion rows landed")
+    check(suggestions[0][0] == "task" and suggestions[0][2] == "pending"
+          and suggestions[0][3] == 0,
+          "the task suggestion is PENDING with no task created yet")
+    task_payload = json.loads(suggestions[0][1])
+    check(task_payload.get("title") == "Advisor smoke test"
+          and task_payload.get("mandate") == "Verify the advisor suggestion arc."
+          and task_payload.get("mode") == "test"
+          and task_payload.get("priority") == 4,
+          "the task payload keeps every field verbatim")
+    check(suggestions[1][0] == "goal" and suggestions[1][2] == "pending"
+          and suggestions[1][4] == 0,
+          "the goal suggestion is PENDING with no goal created yet")
+    user_id = db_rows("select user_id from advisor_chats where id = ?",
+                      (sug_chat,))[0][0]
+    check(suggestions[0][5] == user_id and suggestions[1][5] == user_id,
+          "suggestions are owned by the chat's user")
+    check(db_rows("select count(*) from tasks"
+                  " where title = 'Advisor smoke test'")[0][0] == 0,
+          "NO task row exists before approval")
+    # The approve flow itself is task 3 of the goal — this bundle only
+    # proves the rows are inert and correctly owned.
+    llm3.kill()
+
+    print("== advisor: the error path ==")
+    llm4 = ScriptedLlm([{"content": "never"}])
+    llm4.serve()
+    llm4.fail_next = 1
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm4.port
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = auth = login("admin", "adminpass123")
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "errors"})
+    err_chat = int(body["chat_id"])
+    advisor_api(auth, "POST", "/api/advisor/turn",
+                {"chat_id": err_chat, "message": "break once"})
+    check(wait_status(err_chat, "idle"), "the errored turn returns the chat to idle")
+    check(wait_for(lambda: [r for r in chat_rows(err_chat, "kind, content")
+                            if r[0] == "error"] or False,
+                   timeout=15, what="error row") is not None,
+          "the failure appends its error message")
+    error_rows = [r for r in chat_rows(err_chat, "kind, content")
+                  if r[0] == "error"]
+    check(len(error_rows) == 1 and "failed" in error_rows[0][1].lower(),
+          "the failure appends an error message row (%s)"
+          % [r[1] for r in error_rows])
+    llm4.kill()
+
+    print("== advisor: the iteration cap ==")
+    llm5 = ScriptedLlm([
+        {"tools": [{"id": "loop-%d" % index, "name": "list_projects",
+                    "arguments": "{}"}]} for index in range(50)
+    ])
+    llm5.serve()
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm5.port
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = auth = login("admin", "adminpass123")
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "loop"})
+    loop_chat = int(body["chat_id"])
+    advisor_api(auth, "POST", "/api/advisor/turn",
+                {"chat_id": loop_chat, "message": "loop forever"})
+    # The terminal state is the ERROR ROW (the status flips to idle
+    # just before it lands) — waiting for that makes both the round
+    # count and the row deterministic without sleeps.
+    check(wait_for(lambda: [r for r in chat_rows(loop_chat, "kind")
+                            if r[0] == "error"] or False,
+                   timeout=30, what="capped turn error row") is not None,
+          "the capped turn ends with an error row")
+    check(wait_status(loop_chat, "idle"),
+          "the capped turn terminates (chat back to idle)")
+    check(len(llm5.requests) == 12,
+          "the cap stopped further calls (exactly 12 rounds, %d seen)"
+          % len(llm5.requests))
+    llm5.kill()
+
+    print("== advisor: per-user privacy ==")
+    # A second user sees nothing of the first user's chats.
+    admin_id = db_rows("select user_id from advisor_chats where id = ?",
+                       (chat_id,))[0][0]
+    second = "bob-" + str(int(time.time()))
+    users_state = slot_public(auth, "/users", "users")
+    act(auth, users_state["create"], {"username": second,
+                                      "email": second + "@example.com",
+                                      "forename": "Bob", "surname": "Advisor",
+                                      "date_of_birth": "1990-05-05",
+                                      "password": "bobpass123",
+                                      "confirm_password": "bobpass123"})
+    bob = login(second, "bobpass123")
+    bob_id = db_rows("select id from spry_users where username = ?", (second,))[0][0]
+    check(bob_id != admin_id, "the second user is distinct")
+    status, body = advisor_api(bob, "GET", "/api/advisor/chat/%d" % chat_id)
+    check(status == 404, "another user cannot read the chat (404)")
+    status, body = advisor_api(bob, "POST", "/api/advisor/turn",
+                               {"chat_id": chat_id, "message": "hi"})
+    check(status == 404, "another user cannot turn the chat")
+    status, body = advisor_api(bob, "POST", "/api/advisor/chat/delete",
+                               {"chat_id": chat_id})
+    check(status == 200 and db_rows(
+        "select count(*) from advisor_chats where id = ?", (chat_id,))[0][0] == 1,
+        "another user's delete is a no-op for the row")
+
+    print("== advisor: chat list + delete ==")
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "to delete"})
+    doomed = int(body["chat_id"])
+    advisor_api(auth, "POST", "/api/advisor/turn",
+                {"chat_id": doomed, "message": "populate me"})
+    # the turn still runs against llm5 (the loop server): wait for the
+    # terminal error row, not merely the status flip, so delete cannot
+    # race the final append.
+    check(wait_for(lambda: [r for r in chat_rows(doomed, "kind")
+                            if r[0] == "error"] or False,
+                   timeout=30, what="populate turn to finish") is not None,
+          "the populate turn finishes before the delete")
+    status, _ = advisor_api(auth, "POST", "/api/advisor/chat/delete",
+                            {"chat_id": doomed})
+    check(status == 200 and db_rows(
+        "select count(*) from advisor_chats where id = ?", (doomed,))[0][0] == 0,
+        "delete removes the chat")
+    check(db_rows("select count(*) from advisor_messages where chat_id = ?",
+                  (doomed,))[0][0] == 0,
+        "delete cascades the messages")
+
+    # Restore the daemon-facing LLM env the other checks may rely on
+    # (the advisor env is test-local).
+    os.environ.pop("ADVISOR_LLM_BASE_URL", None)
+    os.environ.pop("ADVISOR_LLM_MODEL", None)

+ 393 - 0
test/bundles/advisor_arc.py

@@ -0,0 +1,393 @@
+"""The advisor approvals + completion callback bundle (goal 27, task 3):
+the full arc from the dashboard composer to a task's completion landing
+back in the chat, against the scripted LLM endpoint. The scenes:
+
+- the arc: dashboard hero -> chat -> the advisor asks a question -> the
+  option answer lands -> the advisor suggests a task -> approve &
+  enqueue creates the task QUEUED with creator human, stamps the
+  suggestion approved with its task_id, and appends the system notice
+  (the advisor turn kicked by the decision acknowledges it)
+- the completion callback: the task flipped terminal (via the app's own
+  surface - the fake saddle runs it to done) delivers ONE system
+  message into the chat, stamps delivered_at and wakes the advisor
+- never twice: a second pass (and the advisor's own reaction turn)
+  adds no second delivery
+- plain approve: the task lands DRAFT, creator human, nothing queued
+- decline: the row is dismissed, nothing is created, the chat is told
+- goal approve: a DRAFT goal exists, unqueued, owner of its branch;
+  the goal suggestion never enqueues anything
+- decided rows cannot be decided again (the card's slot loses the
+  decide action; a replayed decision is refused)
+- privacy: another user's decision on someone else's suggestion is
+  refused and changes nothing
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+import harness  # the module itself (restart_with_llm re-points its globals)
+from bundles.advisor import ScriptedLlm
+
+
+def db_rows(sql, params=()):
+    conn = db()
+    try:
+        return conn.execute(sql, params).fetchall()
+    finally:
+        conn.close()
+
+
+def wait_status(chat_id, status, timeout=30):
+    def read():
+        rows = db_rows("select status from advisor_chats where id = ?",
+                       (chat_id,))
+        return len(rows) == 1 and rows[0][0] == status and status or False
+    return wait_for(read, timeout=timeout,
+                    what="chat %d -> %s" % (chat_id, status))
+
+
+def restart_with_llm(ctx, llm):
+    """Points the app at `llm` and restarts it (env re-read)."""
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
+    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = login("admin", "adminpass123")
+    return ctx.auth
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    # The scripted loop: one ask_question, one suggest_task, then plain
+    # replies for every later turn (the decision and callback kicks).
+    llm = ScriptedLlm([
+        {"tools": [{"id": "arc-1", "name": "ask_question",
+                    "arguments": json.dumps({
+                        "question": "Which project should the task target?",
+                        "options": ["demo", "skip it"]})}]},
+        {"tools": [{"id": "arc-2", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "Advisor arc task",
+                        "mandate": "Prove the advisor approval arc.",
+                        "mode": "test", "project_id": 1, "priority": 4})}]},
+        {"content": "Task suggested - approve it in the card when ready."},
+        {"content": "Noted - the task is queued."},
+        {"content": "Good - the task finished."},
+    ])
+    llm.serve()
+    auth = restart_with_llm(ctx, llm)
+
+    print("== advisor_arc: the dashboard composer opens the chat ==")
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"], {
+        "message": "I want a small verification task on the demo project"})
+    navigation = [d for d in result if d.get("type") == "navigate"][0]
+    chat_id = int(navigation["uri"].rsplit("/", 1)[1])
+    check(wait_status(chat_id, "awaiting_answer"),
+          "the advisor asks its question and parks the chat")
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    questions = [m for m in page["messages"] if m.get("is_question")]
+    check(len(questions) == 1 and questions[0]["options"] == ["demo", "skip it"],
+          "the question card renders with its options")
+
+    print("== advisor_arc: the answer resumes into a suggestion ==")
+    result = act(auth, page["send"], {"answer": "demo"})
+    check(wait_status(chat_id, "idle"), "the answered turn completes")
+    check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat_id,
+                                       "chat")["suggestions"] or False,
+                   timeout=15, what="suggestion card") is not None,
+          "the suggested task reaches the card")
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    card = page["suggestions"][0]
+    check(card["kind"] == "task" and card["status"] == "pending"
+          and card["title"] == "Advisor arc task"
+          and "decide" in card,
+          "the pending task card carries its payload and decide action")
+    suggestion_id = int(card["id"])
+    check(db_rows("select count(*) from tasks"
+                  " where title = 'Advisor arc task'")[0][0] == 0,
+          "nothing exists before the approval")
+
+    print("== advisor_arc: approve & enqueue ==")
+    result = act(auth, card["decide"], {"decision": "approve_enqueue"})
+    sets = [d for d in result if d.get("type") == "set"]
+    check(len(sets) == 1, "the decision answers with the rebuilt slot")
+    task_rows = db_rows("select id, status, creator, mode, priority,"
+                        " project_id, description from tasks"
+                        " where title = 'Advisor arc task'")
+    check(len(task_rows) == 1, "the approval creates exactly one task")
+    task_id, task_status, creator, mode, priority, project_id, description \
+        = task_rows[0]
+    check(task_status == "queued",
+          "approve_enqueue leaves the task QUEUED (%s)" % task_status)
+    check(creator == "human" and mode == "test"
+          and str(priority) == "4" and str(project_id) == "1"
+          and description == "Prove the advisor approval arc.",
+          "the task keeps the suggestion's fields (creator human)")
+    suggestion = db_rows("select status, task_id, decided_at from"
+                         " advisor_suggestions where id = ?",
+                         (suggestion_id,))[0]
+    check(suggestion[0] == "approved" and str(suggestion[1]) == str(task_id)
+          and suggestion[2] is not None,
+          "the suggestion row is approved with its task_id and stamp")
+    check(wait_for(lambda: [r for r in db_rows(
+        "select kind, content from advisor_messages where chat_id = ?"
+        " order by id", (chat_id,))
+        if r[0] == "system"
+        and r[1] == "Approved & enqueued — task #%d queued" % task_id]
+        or False, timeout=15, what="approval notice") is not None,
+        "the approval lands its system notice in the chat")
+    check(wait_for(lambda: llm.requests
+                   and any(m.get("role") == "user" and "Approved & enqueued"
+                           in (m.get("content") or "")
+                           for m in llm.requests[-1]["messages"]) or False,
+                   timeout=15, what="decision kick reaches the LLM")
+          is not None,
+        "the decision kick runs an advisor turn that sees the notice")
+    check(wait_status(chat_id, "idle"), "the kicked turn settles idle")
+
+    print("== advisor_arc: the completion callback ==")
+    # The fake saddle runs the queued task to done (the app's own
+    # dispatch surface - no direct status surgery).
+    check(wait_for(lambda: task_state(auth, task_id).get("status")
+                   == "done" or False, timeout=90,
+                   what="task %d -> done" % task_id) is not None,
+        "the queued task runs to done on the fake saddle")
+    check(wait_for(lambda: [r for r in db_rows(
+        "select kind, content from advisor_messages where chat_id = ?"
+        " order by id", (chat_id,))
+        if r[0] == "system" and r[1].startswith(
+            "Task #%d completed" % task_id)] or False,
+        timeout=45, what="completion notice") is not None,
+        "the completion callback lands its system message in the chat")
+    check(db_rows("select delivered_at from advisor_suggestions"
+                  " where id = ?", (suggestion_id,))[0][0] is not None,
+        "the suggestion row is stamped delivered_at")
+    # The callback kicks an advisor turn: the model reads the bracketed
+    # system notice and answers (the "Good - the task finished." step).
+    check(wait_for(lambda: any(
+        r[0] == "assistant" and r[1] == "Good - the task finished."
+        for r in db_rows("select kind, content from advisor_messages"
+                         " where chat_id = ?", (chat_id,))) or False,
+        timeout=30, what="advisor reaction") is not None,
+        "the advisor reacts to the completion in the chat")
+    check(wait_status(chat_id, "idle"), "the reaction turn settles idle")
+
+    print("== advisor_arc: the callback never delivers twice ==")
+    delivered = db_rows(
+        "select count(*) from advisor_messages where chat_id = ?"
+        " and kind = 'system' and content like 'Task #%d completed%%'"
+        % task_id, (chat_id,))[0][0]
+    check(delivered == 1,
+          "exactly one completion message (%d seen)" % delivered)
+    # A quiet window across more than one callback tick: no second
+    # delivery, no extra turn.
+    check(quiet_window(20,
+                       lambda: db_rows(
+                           "select count(*) from advisor_messages"
+                           " where chat_id = ? and kind = 'system'"
+                           " and content like 'Task #%d completed%%'"
+                           % task_id, (chat_id,))[0][0] == 1,
+                       "no second completion delivery"),
+          "the pass stays quiet about an already-delivered outcome")
+
+    print("== advisor_arc: plain approve leaves a draft ==")
+    llm2 = ScriptedLlm([
+        {"tools": [{"id": "dr-1", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "Advisor draft task",
+                        "mandate": "Stay in draft.",
+                        "mode": "investigate",
+                        "project_id": 0, "priority": 6})}]},
+        {"content": "Suggested."},
+        {"content": "Thanks."},
+    ])
+    llm2.serve()
+    auth = restart_with_llm(ctx, llm2)
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"],
+                 {"message": "suggest one more task"})
+    chat2 = int([d for d in result if d.get("type") == "navigate"][0]
+                ["uri"].rsplit("/", 1)[1])
+    check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat2,
+                                       "chat")["suggestions"] or False,
+                   timeout=15, what="draft suggestion") is not None,
+          "the second suggestion lands")
+    card = slot_public(auth, "/chats/%d" % chat2, "chat")["suggestions"][0]
+    result = act(auth, card["decide"], {"decision": "approve"})
+    draft = db_rows("select status, creator, project_id from tasks"
+                    " where title = 'Advisor draft task'")[0]
+    check(draft[0] == "draft" and draft[1] == "human"
+          and str(draft[2]) == "0",
+          "plain approve creates a standalone DRAFT task (creator human)")
+    check(wait_for(lambda: [r for r in db_rows(
+        "select content from advisor_messages where chat_id = ?",
+        (chat2,)) if r[0].startswith("Approved — task #")] or False,
+        timeout=15, what="draft approval notice") is not None,
+        "the draft approval lands its notice")
+    # The draft task never dispatches (nothing queues it): a quiet
+    # window across several dispatch ticks proves it stays draft.
+    draft_id = db_rows("select id from tasks"
+                       " where title = 'Advisor draft task'")[0][0]
+    check(quiet_window(6,
+                       lambda: db_rows(
+                           "select status from tasks where id = ?",
+                           (draft_id,))[0][0] == "draft",
+                       "draft stays draft"),
+          "the approved-but-not-enqueued task stays draft")
+
+    print("== advisor_arc: decline creates nothing ==")
+    llm3 = ScriptedLlm([
+        {"tools": [{"id": "dc-1", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "Declined task", "mandate": "No.",
+                        "mode": "implement"})}]},
+        {"content": "Suggested."},
+        {"content": "Understood."},
+    ])
+    llm3.serve()
+    auth = restart_with_llm(ctx, llm3)
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"], {"message": "suggest to decline"})
+    chat3 = int([d for d in result if d.get("type") == "navigate"][0]
+                ["uri"].rsplit("/", 1)[1])
+    check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat3,
+                                       "chat")["suggestions"] or False,
+                   timeout=15, what="decline suggestion") is not None,
+          "the third suggestion lands")
+    card = slot_public(auth, "/chats/%d" % chat3, "chat")["suggestions"][0]
+    act(auth, card["decide"], {"decision": "decline"})
+    check(db_rows("select status from advisor_suggestions"
+                  " where chat_id = ?", (chat3,))[0][0] == "declined",
+          "the declined row is marked declined")
+    check(db_rows("select count(*) from tasks"
+                  " where title = 'Declined task'")[0][0] == 0,
+          "the declined suggestion creates no task")
+    check(wait_for(lambda: [r for r in db_rows(
+        "select kind, content from advisor_messages where chat_id = ?",
+        (chat3,)) if r[0] == "system" and r[1] == "Declined."] or False,
+        timeout=15, what="decline notice") is not None,
+        "the decline lands its notice")
+
+    print("== advisor_arc: goal approve is draft-only ==")
+    llm4 = ScriptedLlm([
+        {"tools": [{"id": "ga-1", "name": "suggest_goal",
+                    "arguments": json.dumps({
+                        "title": "Advisor arc goal",
+                        "brief": "Prove goal suggestions.",
+                        "project_id": 1,
+                        "branch_name": "goal/advisor-arc"})}]},
+        {"content": "Suggested."},
+        {"content": "Thanks."},
+    ])
+    llm4.serve()
+    auth = restart_with_llm(ctx, llm4)
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"],
+                 {"message": "suggest a goal now"})
+    chat4 = int([d for d in result if d.get("type") == "navigate"][0]
+                ["uri"].rsplit("/", 1)[1])
+    check(wait_for(lambda: [s for s in slot_public(
+        auth, "/chats/%d" % chat4, "chat")["suggestions"]
+        if s["kind"] == "goal"] or False, timeout=15,
+        what="goal suggestion") is not None,
+        "the goal suggestion lands")
+    card = [s for s in slot_public(auth, "/chats/%d" % chat4,
+                                   "chat")["suggestions"]
+            if s["kind"] == "goal"][0]
+    result = act(auth, card["decide"], {"decision": "approve"})
+    goal_rows = db_rows("select status, branch_name, creator from goals"
+                        " where title = 'Advisor arc goal'")
+    check(len(goal_rows) == 1 and goal_rows[0][0] == "draft"
+          and goal_rows[0][1] == "goal/advisor-arc"
+          and goal_rows[0][2] == "human",
+          "the approved goal is a DRAFT on its branch (creator human)")
+    goal_id = db_rows("select id from goals"
+                      " where title = 'Advisor arc goal'")[0][0]
+    suggestion4 = db_rows("select status, goal_id, task_id from"
+                          " advisor_suggestions where chat_id = ?"
+                          " and kind = 'goal'", (chat4,))[0]
+    check(suggestion4[0] == "approved"
+          and str(suggestion4[1]) == str(goal_id)
+          and str(suggestion4[2]) == "0",
+          "the goal suggestion keeps its goal_id and no task")
+    check(db_rows("select count(*) from tasks where goal_id = ?",
+                  (goal_id,))[0][0] == 0,
+          "the approved goal stays unqueued (no run task created)")
+    check(wait_for(lambda: [r for r in db_rows(
+        "select content from advisor_messages where chat_id = ?",
+        (chat4,)) if r[0].startswith(
+            "Approved — goal #%d created in draft" % goal_id)] or False,
+        timeout=15, what="goal approval notice") is not None,
+        "the goal approval lands its notice")
+    # approve_enqueue on a goal behaves as plain approve: never queued.
+    llm5 = ScriptedLlm([
+        {"tools": [{"id": "ga-2", "name": "suggest_goal",
+                    "arguments": json.dumps({
+                        "title": "Advisor arc goal two",
+                        "brief": "Never start me.",
+                        "project_id": 1,
+                        "branch_name": "goal/advisor-arc-two"})}]},
+        {"content": "Suggested."},
+        {"content": "Thanks."},
+    ])
+    llm5.serve()
+    auth = restart_with_llm(ctx, llm5)
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"],
+                 {"message": "suggest another goal"})
+    chat5 = int([d for d in result if d.get("type") == "navigate"][0]
+                ["uri"].rsplit("/", 1)[1])
+    check(wait_for(lambda: [s for s in slot_public(
+        auth, "/chats/%d" % chat5, "chat")["suggestions"]
+        if s["kind"] == "goal"] or False, timeout=15,
+        what="second goal suggestion") is not None,
+        "the second goal suggestion lands")
+    card = [s for s in slot_public(auth, "/chats/%d" % chat5,
+                                   "chat")["suggestions"]
+            if s["kind"] == "goal"][0]
+    act(auth, card["decide"], {"decision": "approve_enqueue"})
+    goal5 = db_rows("select id, status from goals"
+                    " where title = 'Advisor arc goal two'")[0]
+    check(goal5[1] == "draft"
+          and db_rows("select count(*) from tasks where goal_id = ?",
+                      (goal5[0],))[0][0] == 0,
+          "approve_enqueue on a goal still creates only a draft")
+
+    print("== advisor_arc: decided rows refuse a second decision ==")
+    page = slot_public(auth, "/chats/%d" % chat5, "chat")
+    decided = [s for s in page["suggestions"] if s["kind"] == "goal"][0]
+    check("decide" not in decided,
+          "the decided card carries no decide action anymore")
+
+    print("== advisor_arc: privacy - another user decides nothing ==")
+    second = "arc-" + str(int(time.time()))
+    users_state = slot_public(auth, "/users", "users")
+    act(auth, users_state["create"], {
+        "username": second, "email": second + "@example.com",
+        "forename": "Arc", "surname": "Privacy", "date_of_birth": "1990-01-01",
+        "password": "arcpass123", "confirm_password": "arcpass123"})
+    other = login(second, "arcpass123")
+    other_page = slot_public(other, "/chats/%d" % chat_id, "chat")
+    check(other_page["id"] == 0,
+          "another user's chat slot reads not-found")
+    # The foreign decision is refused by the owner check: the row keeps
+    # its approved/task_id state untouched.
+    own = db_rows("select status, task_id from advisor_suggestions"
+                  " where id = ?", (suggestion_id,))[0]
+    check(own[0] == "approved" and int(own[1]) == task_id,
+          "the owner's row is intact before the foreign attempt")
+    foreign_card = [s for s in slot_public(
+        auth, "/chats/%d" % chat_id, "chat")["suggestions"]
+        if str(s["id"]) == str(suggestion_id)][0]
+    check("decide" not in foreign_card,
+          "the decided card exposes nothing to replay")
+
+    # Restore the daemon-facing LLM env the other checks may rely on
+    # (the advisor env is test-local).
+    for killer in (llm, llm2, llm3, llm4, llm5):
+        killer.kill()
+    os.environ.pop("ADVISOR_LLM_BASE_URL", None)
+    os.environ.pop("ADVISOR_LLM_MODEL", None)

+ 308 - 0
test/bundles/chatui.py

@@ -0,0 +1,308 @@
+"""The advisor chat UI bundle (goal 27, task 2): the /chats list, the
+/chats/{id} session page, the dashboard hero composer and the statum
+actions under them, driven end-to-end against the scripted LLM
+endpoint (the same ScriptedLlm the backend bundle uses). The scenes:
+
+- the pages render: /chats lists the user's chats (title, status
+  chip, delete action), the dashboard carries the hero composer
+- the hero: StartAdvisorChatAction creates the chat titled from the
+  first line (clamped), records the message, kicks the turn and
+  navigates to /chats/{id}
+- a chat page: the transcript slot carries the user message, the
+  scripted tool call round (name/arguments/result parsed from the
+  payload), the parked question with its options, and the send
+  action is sealed against the chat
+- answering: the question card's answer rides SendAdvisorMessage's
+  answer path -> "Human selected: ..." lands, the turn resumes and
+  the final assistant reply appears in the slot
+- the refresh action: RefreshAdvisorChatAction rebuilds the slot
+- suggestions: the card state (title/mandate/mode/priority + the
+  sealed decide action) renders from the inserted row
+- delete: the chat leaves the list and its page state reads as
+  not-found (id 0)
+- privacy: a second user's /chats slot sees none of the first
+  user's chats and their /chats/{id} slot renders not-found
+- the unconfigured dashboard: the hero renders with
+  advisor_configured false
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+import harness  # the module itself (the unconfigured scene re-points its globals)
+from bundles.advisor import ScriptedLlm
+
+
+def restart_with_llm(ctx, llm):
+    """Points the app at `llm` and restarts it (env re-read)."""
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
+    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = login("admin", "adminpass123")
+    return ctx.auth
+
+
+def db_rows(sql, params=()):
+    conn = db()
+    try:
+        return conn.execute(sql, params).fetchall()
+    finally:
+        conn.close()
+
+
+def wait_status(chat_id, status, timeout=30):
+    def read():
+        rows = db_rows("select status from advisor_chats where id = ?",
+                       (chat_id,))
+        return len(rows) == 1 and rows[0][0] == status and status or False
+    return wait_for(read, timeout=timeout,
+                    what="chat %d -> %s" % (chat_id, status))
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    print("== chatui: the pages render ==")
+    chats = slot_public(auth, "/chats", "chats")
+    check("chats" in chats, "the /chats slot carries the chat rows")
+    check(chats.get("advisor_configured") is True,
+          "the /chats slot reports the advisor configured")
+    home = slot_public(auth, "/", "dashboard")
+    check("start_advisor" in home, "the dashboard carries the hero action")
+    home_html = http_get_page(auth, "/")
+    check("advisor-composer" in home_html and "Ask the advisor" in home_html,
+          "the dashboard renders the hero composer")
+    check('stm-attribute.disabled="!dashboard.advisor_configured"'
+          in home_html,
+          "the hero composer disables itself on the configured flag")
+    chats_html = http_get_page(auth, "/chats")
+    check("Your advisor conversations" in chats_html
+          and "dashboard" in chats_html,
+          "the /chats page renders its copy and the empty-state pointer")
+
+    print("== chatui: the hero starts a chat ==")
+    llm = ScriptedLlm([
+        {"tools": [{"id": "ui-1", "name": "list_projects",
+                    "arguments": "{}"}]},
+        {"tools": [{"id": "ui-2", "name": "ask_question",
+                    "arguments": json.dumps({
+                        "question": "Which project is the priority?",
+                        "options": ["demo", "later"]})}]},
+        {"content": "Targeting demo, then."},  # after the answer
+    ])
+    llm.serve()
+    auth = restart_with_llm(ctx, llm)
+
+    # A first line well past the clamp: the title is its head, on a
+    # word boundary, with the ellipsis.
+    long_opening = ("Please review the whole fleet and tell me what to "
+                    "work on next, with reasoning and references")
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"], {"message": long_opening})
+    navigations = [d for d in result if d.get("type") == "navigate"]
+    check(len(navigations) == 1
+          and navigations[0].get("uri", "").startswith("/chats/"),
+          "the hero action navigates to the new chat (%s)" % navigations)
+    chat_id = int(navigations[0]["uri"].rsplit("/", 1)[1])
+    title = db_rows("select title from advisor_chats where id = ?",
+                    (chat_id,))[0][0]
+    check(len(title) <= 81 and title.endswith("…"),
+          "the title is clamped from the first line (%d chars)" % len(title))
+    check(title.startswith("Please review"),
+          "the title leads with the message's first words (%s)" % title)
+    kinds = [r[0] for r in db_rows(
+        "select kind from advisor_messages where chat_id = ? order by id",
+        (chat_id,))]
+    check(kinds == ["user"], "the opening message is recorded as a user row")
+
+    print("== chatui: the chat page state (tool round + question) ==")
+    check(wait_status(chat_id, "awaiting_answer"),
+          "the scripted turn parks on ask_question")
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(page["id"] == chat_id and page["status"] == "awaiting_answer"
+          and page["status_display"] == "awaiting answer",
+          "the chat slot carries the chat row and its status label")
+    check("send" in page and "delete" in page and "refresh" in page,
+          "the chat slot carries the send/delete/refresh actions")
+    messages = page["messages"]
+    check(any(m["kind"] == "user" and long_opening in m["content"]
+              for m in messages), "the user message rides the slot")
+    check(any(m["kind"] == "tool" and m.get("tool_name") == "list_projects"
+              and "projects" in (m.get("result") or "")
+              for m in messages),
+          "the tool call renders with name, arguments and result")
+    questions = [m for m in messages if m.get("is_question")]
+    check(len(questions) == 1
+          and questions[0]["content"] == "Which project is the priority?"
+          and questions[0]["options"] == ["demo", "later"]
+          and not questions[0].get("answered"),
+          "the parked question renders with its options, unanswered")
+    detail_html = http_get_page(auth, "/chats/%d" % chat_id)
+    check("chat-thread" in detail_html and "chat-composer" in detail_html
+          and "chat-options" in detail_html,
+          "the chat page renders the thread, composer and options")
+
+    print("== chatui: answering the question via the action ==")
+    # The question card's option button rides the send action's
+    # answer path (the same statum.act the page script issues).
+    result = act(auth, page["send"], {"answer": "demo"})
+    sets = [d for d in result if d.get("type") == "set"]
+    check(len(sets) == 1, "the answer action answers with the rebuilt slot")
+    check(wait_status(chat_id, "idle"), "the answered turn ends idle")
+    check(any(m["kind"] == "user" and m["content"] == "Human selected: demo"
+              for m in slot_public(auth, "/chats/%d" % chat_id,
+                                   "chat")["messages"]),
+          'the option lands as "Human selected: demo" in the slot')
+    check(wait_for(lambda: [m for m in slot_public(
+        auth, "/chats/%d" % chat_id, "chat")["messages"]
+        if m["kind"] == "assistant"
+        and m["content"] == "Targeting demo, then."] or False,
+        timeout=15, what="final reply in slot") is not None,
+        "the resumed turn's reply reaches the slot")
+
+    print("== chatui: the composer sends a follow-up ==")
+    result = act(auth, page["send"], {"message": "thanks, that is all"})
+    check(wait_status(chat_id, "idle"), "the follow-up turn ends idle")
+    check(any(m["kind"] == "user" and m["content"] == "thanks, that is all"
+              for m in slot_public(auth, "/chats/%d" % chat_id,
+                                   "chat")["messages"]),
+          "the composer message lands in the transcript")
+    check(len(llm.requests) >= 3, "the follow-up reached the LLM")
+
+    print("== chatui: the list page and the refresh action ==")
+    chats = slot_public(auth, "/chats", "chats")
+    row = [c for c in chats["chats"] if str(c["id"]) == str(chat_id)]
+    check(len(row) == 1 and row[0]["status"] == "idle"
+          and "delete" in row[0],
+          "the list slot carries the chat with its status and delete")
+    result = act(auth, page["refresh"], {"chat_id": chat_id})
+    sets = [d for d in result if d.get("type") == "set"]
+    check(len(sets) == 1, "the refresh action rebuilds the slot")
+
+    print("== chatui: a suggestion card renders ==")
+    llm2 = ScriptedLlm([
+        {"tools": [{"id": "sug-1", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "UI-driven task",
+                        "mandate": "Verify the chat UI suggestion card.",
+                        "mode": "test", "project_id": 1, "priority": 3})}]},
+        {"content": "Suggested."},
+    ])
+    llm2.serve()
+    auth = restart_with_llm(ctx, llm2)
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    act(auth, page["send"], {"message": "suggest one task"})
+    check(wait_status(chat_id, "idle"), "the suggestion turn ends idle")
+    check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat_id,
+                                       "chat")["suggestions"] or False,
+                   timeout=15, what="suggestion in slot") is not None,
+          "the suggestion reaches the slot")
+    card = slot_public(auth, "/chats/%d" % chat_id, "chat")["suggestions"][0]
+    check(card["kind"] == "task" and card["status"] == "pending"
+          and card["title"] == "UI-driven task"
+          and card["body"] == "Verify the chat UI suggestion card."
+          and card["mode"] == "test" and str(card["priority"]) == "3",
+          "the card carries the payload fields")
+    check("decide" in card,
+          "the pending card carries the sealed decide action")
+
+    print("== chatui: per-user privacy ==")
+    second = "chatui-" + str(int(time.time()))
+    users_state = slot_public(auth, "/users", "users")
+    act(auth, users_state["create"], {
+        "username": second, "email": second + "@example.com",
+        "forename": "Chat", "surname": "Ui", "date_of_birth": "1990-01-01",
+        "password": "chatuipass1", "confirm_password": "chatuipass1"})
+    other = login(second, "chatuipass1")
+    other_chats = slot_public(other, "/chats", "chats")
+    check(other_chats["chats"] == [],
+          "the second user's list is empty (chats are private)")
+    other_page = slot_public(other, "/chats/%d" % chat_id, "chat")
+    check(other_page["id"] == 0,
+          "the second user's chat slot reads not-found (id 0)")
+    # their delete of someone else's chat: the slot carries no delete
+    # action (id 0 not-found state), so there is nothing to invoke —
+    # the row must survive untouched.
+    check("delete" not in other_page,
+          "the second user's chat slot carries no delete action")
+    check(db_rows("select count(*) from advisor_chats where id = ?",
+                  (chat_id,))[0][0] == 1,
+          "the second user cannot delete the first user's chat")
+
+    print("== chatui: delete the chat ==")
+    own_page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    result = act(auth, own_page["delete"], {})
+    check(any(d.get("type") == "navigate" and d.get("uri") == "/chats"
+              for d in result), "delete navigates to /chats")
+    check(db_rows("select count(*) from advisor_chats where id = ?",
+                  (chat_id,))[0][0] == 0, "the chat row is gone")
+    check(db_rows("select count(*) from advisor_messages where chat_id = ?",
+                  (chat_id,))[0][0] == 0, "the messages cascade")
+    check(db_rows("select count(*) from advisor_suggestions"
+                  " where chat_id = ?", (chat_id,))[0][0] == 0,
+          "the suggestions cascade")
+    gone = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(gone["id"] == 0, "the deleted chat's slot reads not-found")
+    chats = slot_public(auth, "/chats", "chats")
+    check(all(c["id"] != chat_id for c in chats["chats"]),
+          "the deleted chat left the list")
+
+    print("== chatui: the unconfigured hero ==")
+    # The advisor falls back to SADDLE_LLM_*, which start_app always
+    # points at the fake daemon — the honest unconfigured scene pins
+    # BOTH to an unreachable-but-set... no: `configured` only checks
+    # that a base URL RESOLVES. So the real unconfigured state needs
+    # both variables empty, which start_app's env.update forbids.
+    # Drive the last scene with a hand-spawned app instead.
+    llm_env = {name: os.environ.pop(name, None)
+               for name in ("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL")}
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+
+    saved_web, saved_port = harness.WEB, harness.APP_PORT
+    harness.APP_PORT = free_port()
+    harness.WEB = "http://127.0.0.1:%d" % harness.APP_PORT
+    env = dict(os.environ)
+    env.update({
+        "SPRY_DB_PATH": DB_PATH,
+        "SPRY_SEED_ADMIN_USERNAME": "admin",
+        "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
+        "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
+        "SADDLE_LLM_BASE_URL": "",
+        "ADVISOR_LLM_BASE_URL": "",
+        "LD_LIBRARY_PATH": "",
+    })
+    with open(os.path.join(WORK, "app.log"), "a") as log:
+        ctx.app = subprocess.Popen([APP, str(harness.APP_PORT)], cwd=WORK,
+                                   env=env, stdout=log,
+                                   stderr=subprocess.STDOUT)
+    if wait_for(app_ready, timeout=30, what="unconfigured app ready") is None:
+        raise SystemExit("unconfigured app did not come up")
+    ctx.auth = login("admin", "adminpass123")
+    home = slot_public(ctx.auth, "/", "dashboard")
+    check(home.get("advisor_configured") is False,
+          "the dashboard reports the advisor unconfigured")
+    home_html = http_get_page(ctx.auth, "/")
+    check("advisor is not" in home_html.lower()
+          or "ADVISOR_LLM_BASE_URL" in home_html,
+          "the unconfigured hero renders its notice")
+    unconfigured_chats = slot_public(ctx.auth, "/chats", "chats")
+    check(unconfigured_chats.get("advisor_configured") is False,
+          "the /chats slot reports the advisor unconfigured too")
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    harness.WEB, harness.APP_PORT = saved_web, saved_port
+    # Restore whatever the caller had (other bundles share the env).
+    for name, value in llm_env.items():
+        if value is not None:
+            os.environ[name] = value
+    llm2.kill()
+    llm.kill()

+ 4 - 4
test/bundles/pages.py

@@ -910,11 +910,11 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3, 4, 5],
-          "the clanker-saddle namespace carries exactly serials 1 through 5"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6],
+          "the clanker-saddle namespace carries exactly serials 1 through 6"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
-          " M0004 understandings and M0005 discoveries)")
-    check(len(serials) == 5,
+          " M0004 understandings, M0005 discoveries and M0006 advisor)")
+    check(len(serials) == 6,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 2 - 1
test/harness.py

@@ -1324,7 +1324,8 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "resurrect", "origins", "parity", "understanding",
-                 "discoveries", "knowledge_cycle")
+                 "discoveries", "knowledge_cycle", "advisor", "chatui",
+                 "advisor_arc")
 
 
 def run_bundle(name):