瀏覽代碼

Add the advisor backend core: storage, LLM client, tool loop (goal 27)

The advisor is a web-side LLM chat loop that READS clanker-saddle state,
asks the user multiple-choice questions and suggests tasks/goals; it can
never run commands. This lands the backend core:

- M0006_Advisor migration (serial 6, idempotent like M0005): the
  advisor_chats, advisor_messages and advisor_suggestions tables. The
  transcript is stored IN THE WIRE FORMAT so the OpenAI messages array
  reconstructs from rows alone; suggestions are INERT rows until the
  owning user approves them in the UI (a later task).
- AdvisorChat/AdvisorMessage/AdvisorSuggestion entities, registered in
  main.vala alongside the migration.
- ChatService (scoped): chat/message/suggestion CRUD, per-user private
  (every read takes the owning user_id), delete cascades messages and
  suggestions.
- AdvisorService (singleton, the PushService scope-per-operation
  pattern): resolves ADVISOR_LLM_* falling back to SADDLE_LLM_*; runs a
  background TURN per chat with a per-chat in-memory lock and a 12-LLM-
  iteration cap; rebuilds the messages array from stored rows (tool
  rows reconstruct the tool_calls/tool roles exactly as the wire needs);
  appends bump StatePulse so live pages refresh. Tools: list_projects,
  get_project, list_tasks, get_task, list_goals, get_goal, list_rules,
  search_discoveries, list_capabilities (read-only over the existing
  services) plus ask_question (parks the chat in awaiting_answer;
  answer_question records the option or free-text reply with the
  saddle's wording and resumes) and suggest_task/suggest_goal (insert
  pending rows owned by the chat's user). Any failure appends a
  kind=error message and returns the chat to idle.
- Api/AdvisorApi: login-gated JSON endpoints (create/delete chat, turn,
  answer, chat detail) the chat UI actions will sit on. The login gate
  resolves the auth slot through HeldSlotResolver and maps the
  authenticated username to its user row.
- test/bundles/advisor.py: a scripted OpenAI-compatible endpoint drives
  a full turn (tool call, wire reconstruction, question park, both
  answer wordings), suggestion inertness and ownership, the 500 error
  path, the iteration cap, per-user privacy and delete cascade.

Also extends the pages bundle's migration-chain assertion to serial 6.

Fixed along the way: Soup send_and_read_async's get_data() returns an
unterminated array - casting to string read past the buffer, so the
reply parse intermittently failed; the body is now copied into a
NUL-terminated buffer (the same trap SaddleService documents).
saddle-agent 4 周之前
父節點
當前提交
95f0109433

+ 4 - 0
meson.build

@@ -193,6 +193,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 +212,10 @@ app_sources = files(
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
+    'src/Services/ChatService.vala',
+    'src/Services/AdvisorService.vala',
     'src/Api/SaddleApi.vala',
+    'src/Api/AdvisorApi.vala',
     'src/Api/EventLogApi.vala',
     'src/Api/PushApi.vala',
     'src/actions/Forms.vala',

+ 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");
+        }
+    }
+}

+ 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");
+        }
+    }
+}

+ 16 - 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,12 @@ 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>();
 
         // Web Push (plan 11 task E): the VAPID keypair loads or
         // generates + persists BEFORE the server accepts requests, so
@@ -252,6 +258,16 @@ 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);

+ 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)

+ 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)")

+ 1 - 1
test/harness.py

@@ -1324,7 +1324,7 @@ 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")
 
 
 def run_bundle(name):