Bläddra i källkod

Advisor refinements: hidden tool bubbles, chattier prompt, reports in reach, parking guard

Four review-feedback items for the advisor (goal 27):

1. Tool-call bubbles: an assistant row that carries a tool call now
   keeps the model's own words alongside the call (execute_tool
   previously stored a bare '@tool call' marker for every call row,
   dropping the prose); the chat page hides the bubble only when the
   row said nothing (empty or the marker), so the paired tool row's
   details expander is the silent call's render and real prose keeps
   its bubble.

2. Chattier persona: the system prompt gains an explicit
   conversational contract - open with a reply (never a tool call),
   address the user after every tool call or short batch, no more
   than 2-3 chained calls without a user-facing message, and prefer
   ask_question whenever a decision or ambiguity exists. The ethos
   section stays a tail append so the canon work composes cleanly.

3. Reports in reach: get_goal carries the completion report and
   completed_at, get_task the final summary and completed_at, the
   list rows hint report_chars/summary_chars, and a new read-only
   get_task_log tool returns a task's comments plus progress,
   state-change and lifecycle entries (clamped).

4. Parking guard: the web refuses a goal agent's 'suspended' push
   when its goal owns no task whose terminal transition could ever
   fire (every owned task done, failed or a never-enqueued draft).
   The park mechanics still run, but a one-shot
   tasks.pending_resume_notice (M0007) makes the run immediately
   resume-offerable; the daemon's next ask hands it 'Parking refused:
   ... finish the run (submit your report) or cancel.' as its
   resume_notice, consumed by that resume. Plain tasks park freely
   (their park is a human-facing pause; force wake is the lever).

Test fixtures that scripted goal agents to park with nothing live
plant a blocked live worker first (wakeable, never dispatched);
advisor_refine.py drives all four deliverables. Clean rebuild plus
the full sequential suite: 1529 checks, 0 failures.
saddle-Clanker-Saddle 4 veckor sedan
förälder
incheckning
6e569e25df

+ 27 - 5
README.md

@@ -326,6 +326,23 @@ that typo'd an enqueue sees it immediately. The flag clears on the
 wake (one wake per press). Paused goals and non-suspended tasks hide
 the button and the API refuses them (400).
 
+## Parking guard (the refused suspend)
+
+The web refuses a `saddle suspend` it knows would sleep forever: a
+goal agent whose goal owns **no task that could ever fire a terminal
+transition** (every owned task is done, failed or a draft nobody
+enqueued) has no wake source, so the park is rejected at the results
+push. The park mechanics still run, but the run immediately carries a
+one-shot refusal instruction — its next assignment's `resume_notice`
+reads "Parking refused: the goal owns no live tasks to wait on —
+finish the run (submit your report) or cancel." — and the daemon is
+reclaimable the moment its round ends. The instruction is consumed by
+the resume that delivers it. A goal with any live worker (queued,
+assigned, working, blocked, review-requested or suspended) parks
+normally, and plain (non-goal) tasks park freely: their park is a
+human-facing pause by design, and the task page's force wake is the
+resume path.
+
 ## Browser notifications (Web Push)
 
 The app speaks the real Web Push protocol (VAPID + RFC 8291 payload
@@ -406,11 +423,16 @@ accumulated project knowledge.
 ## AI Chat (advisor)
 
 **AI Chat** is a conversational advisor inside the web app: an LLM loop
-that can READ the fleet's state — projects, tasks, goals, rules,
-discoveries and capability tags — through a read-only tool set, ask you
-questions mid-chat, and SUGGEST tasks or goals for your approval. It
-can never run commands, edit files or dispatch work itself; nothing is
-created until you approve a suggestion in the chat UI.
+that can READ the fleet's state — projects, tasks, goals (plans and
+completion reports), task logs (summaries, comments and progress),
+rules, discoveries and capability tags — through a read-only tool set,
+ask you questions mid-chat, and SUGGEST tasks or goals for your
+approval. It can never run commands, edit files or dispatch work
+itself; nothing is created until you approve a suggestion in the chat
+UI. It is a conversation partner, not a report generator: after every
+tool call it tells you what it found before deciding next steps, and
+it prefers asking you over guessing whenever a decision or preference
+is involved.
 
 The dashboard's top carries the **Ask the advisor** composer: type your
 question and you land on a new chat session (`/chats/{id}`). The

+ 1 - 0
meson.build

@@ -206,6 +206,7 @@ app_sources = files(
     'src/Data/Migrations/M0004_ProjectUnderstandings.vala',
     'src/Data/Migrations/M0005_Discoveries.vala',
     'src/Data/Migrations/M0006_Advisor.vala',
+    'src/Data/Migrations/M0007_ParkingGuard.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',

+ 11 - 0
src/Data/Entities.vala

@@ -81,6 +81,16 @@ namespace ClankerSaddle.Data {
          * Consumed (cleared) by the allocation that resumes it.
          */
         public bool force_wake { get; set; default = false; }
+        /**
+         * Goal 27 refinements (the parking guard): the ONE-SHOT
+         * resume instruction a refused `saddle suspend` leaves on a
+         * goal run that owned no live task (nothing could ever wake
+         * the park). While set, the suspended run is immediately
+         * resume-offerable; the allocation that hands the assignment
+         * out consumes it (it becomes the assignment's
+         * resume_notice, the force-wake discipline).
+         */
+        public string? pending_resume_notice { get; set; default = null; }
         /** The goal this task belongs to (0 = standalone). */
         public int64 goal_id { get; set; default = 0; }
         /** "task" (a worker) or "goal_run" (the goal agent's own run). */
@@ -108,6 +118,7 @@ namespace ClankerSaddle.Data {
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)
                 .column<DateTime?>("suspended_at", o => o.suspended_at, (o, v) => o.suspended_at = v)
                 .column<bool>("force_wake", o => o.force_wake, (o, v) => o.force_wake = v)
+                .column<string>("pending_resume_notice", o => o.pending_resume_notice, (o, v) => o.pending_resume_notice = v)
                 .column<int64?>("goal_id", o => o.goal_id, (o, v) => o.goal_id = v ?? 0)
                 .column<string>("kind", o => o.kind, (o, v) => o.kind = v)
                 .column<string>("mode", o => o.mode, (o, v) => o.mode = v)

+ 75 - 0
src/Data/Migrations/M0007_ParkingGuard.vala

@@ -0,0 +1,75 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 27 refinements — the parking guard. `saddle suspend` on a
+     * goal run that owns NO live task is refused by the web (there is
+     * nothing whose terminal transition could ever wake the parked
+     * agent): the park mechanics still run, but the run carries a
+     * one-shot `pending_resume_notice`, so the daemon's next ask hands
+     * it the refusal instruction instead of a sleep nothing ends.
+     *
+     * tasks.pending_resume_notice is that one-shot channel. It is set
+     * by the refused park and consumed by the allocation that hands
+     * the assignment out (the resume_notice discipline): while set,
+     * a suspended task is immediately offerable — the exact lift the
+     * task-level force_wake flag gives a plain parked task, but
+     * carrying the guard's own instruction text. Nullable, NULL when
+     * no refusal is pending.
+     *
+     * IDEMPOTENCE: same contract as M0002–M0006 — up() runs before the
+     * apply transaction opens (the builder only queues), so it
+     * introspects sqlite_master through a separate read-only handle
+     * and adds the column only when the schema still lacks it.
+     */
+    public class M0007_ParkingGuard : Migration {
+
+        public override string name { get { return "Parking guard"; } }
+        public override uint64 serial { get { return 7; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // Same resolution as M0006: 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 column_exists() {
+            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 1 from pragma_table_info('tasks') where name = 'pending_resume_notice'",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            return statement.step() == Sqlite.ROW;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (column_exists()) {
+                return;
+            }
+            b.alter_table("tasks", (t) => {
+                t.add_column<string>("pending_resume_notice");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            // SQLite cannot drop a column through the builder's
+            // alter_table path; the raw statement is the honest form.
+            b.execute_sql("alter table tasks drop column pending_resume_notice");
+        }
+    }
+}

+ 140 - 7
src/Services/AdvisorService.vala

@@ -215,7 +215,8 @@ namespace ClankerSaddle.Services {
                     }
                     bool parked = false;
                     foreach (var call in reply.tool_calls) {
-                        var outcome = yield execute_tool(chat_id, user_id, call);
+                        var outcome = yield execute_tool(chat_id, user_id, call,
+                            reply.content);
                         messages.add_object_element(tool_result_node(call, outcome));
                         if (outcome.parked) {
                             parked = true;
@@ -346,7 +347,9 @@ namespace ClankerSaddle.Services {
          * 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.
+         * the UI. It can never run commands. (The ethos section —
+         * appended by the canon work on its own branch — lands after
+         * the last line; keep new paragraphs ABOVE it.)
          */
         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"
@@ -361,6 +364,24 @@ namespace ClankerSaddle.Services {
             + "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"
+            + "This is a CONVERSATION, not a script. The conversational contract:\n"
+            + "- OPEN every exchange by replying to the user in plain language — greet,"
+            + " restate what they want, say what you will look at. Never open with a"
+            + " tool call.\n"
+            + "- AFTER every tool call (or short batch of them), ADDRESS THE USER before"
+            + " going further: say in your own words what you found and what you make of"
+            + " it. Never chain tool calls silently.\n"
+            + "- Do not run more than 2-3 tool calls in a row without a user-facing"
+            + " message in between; if you catch yourself mid-scan, stop and tell the"
+            + " user what you have so far.\n"
+            + "- You may combine a short spoken sentence with a tool call in ONE reply"
+            + " (\"Let me check the queue — …\"): the sentence renders to the user while"
+            + " the call runs.\n"
+            + "- PREFER ask_question whenever a decision, a preference or an ambiguity"
+            + " exists (which project, how deep, what to suggest, how to proceed). An"
+            + " asked question is a better outcome than a guessed answer.\n"
+            + "- Close every exchange with a direct answer or a question — never with a"
+            + " bare tool call.\n"
             + "Be concise and specific; answer in short markdown where it helps.";
 
         /** The assistant wire node carrying one tool call. */
@@ -700,20 +721,25 @@ namespace ClankerSaddle.Services {
          * thrown — the loop keeps going so the model can react.
          */
         private async ToolOutcome execute_tool(int64 chat_id, int64 user_id,
-                AdvisorToolCall call) {
+                AdvisorToolCall call, string spoken) {
             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).
+                // wire shape (call_id + name + raw arguments). The
+                // model's own words alongside the call ride the
+                // content — the bare marker only when it said
+                // nothing (the chat page hides that row's bubble and
+                // lets the tool row's expander speak).
                 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));
+                    spoken.strip().length > 0 ? spoken : "@tool call",
+                    json_string(call_payload));
 
                 string result;
                 bool parked = false;
@@ -785,6 +811,8 @@ namespace ClankerSaddle.Services {
                     return yield tool_list_tasks(db, arguments);
                 case "get_task":
                     return yield tool_get_task(db, arguments);
+                case "get_task_log":
+                    return yield tool_get_task_log(db, arguments);
                 case "list_goals":
                     return yield tool_list_goals(db, arguments);
                 case "get_goal":
@@ -947,6 +975,8 @@ namespace ClankerSaddle.Services {
                 element.set_string_member("mode", task.mode);
                 element.set_int_member("priority", task.priority);
                 element.set_int_member("goal_id", task.goal_id);
+                element.set_int_member("summary_chars",
+                    ((!)task.summary ?? "").length);
                 array.add_object_element(element);
             }
             var body = new Json.Object();
@@ -976,9 +1006,97 @@ namespace ClankerSaddle.Services {
             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));
+            // The completion story (goal 27 refinements): the agent's
+            // final summary and when it finished — clamped like every
+            // long text. get_task_log carries the full running log.
+            body.set_string_member("summary", clamp_text(((!)task).summary ?? ""));
+            body.set_string_member("completed_at", stamp_text(((!)task).completed_at));
             return json_string(body);
         }
 
+        /**
+         * get_task_log: the task's running log — its comments (human
+         * and agent) plus the ledger's progress, comment, state-change
+         * and lifecycle entries, oldest first, clamped to the LAST
+         * {@link LIST_CLAMP_ROWS} rows (the completion story — what
+         * the agent said as it finished — lives here).
+         */
+        private async string tool_get_task_log(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 entries = new Json.Array();
+            int count = 0;
+            // The timeline feed kinds (everything but the noisy tool
+            // traffic): progress updates, comments, state changes and
+            // lifecycle milestones, plus the task_comments rows the
+            // human/agent conversation keeps.
+            foreach (var record in yield db.query<EventRecord>()
+                    .where(expr("scope_type == $0 && scope_id == $1",
+                        elem(EVENT_SCOPE_TASK), elem<int64?>(id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (record.kind != EVENT_PROGRESS && record.kind != EVENT_COMMENT
+                        && record.kind != EVENT_STATE_CHANGE
+                        && record.kind != EVENT_LIFECYCLE) {
+                    continue;
+                }
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_string_member("kind", record.kind);
+                element.set_string_member("at", stamp_text(record.created_at));
+                element.set_string_member("text",
+                    clamp_text(event_summary(record)));
+                entries.add_object_element(element);
+            }
+            foreach (var comment in yield db.query<TaskComment>()
+                    .where(expr("task_id == $0", elem<int64?>(id)))
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                if (count++ >= LIST_CLAMP_ROWS) {
+                    break;
+                }
+                var element = new Json.Object();
+                element.set_string_member("kind", "comment");
+                element.set_string_member("author", comment.author);
+                element.set_string_member("at", stamp_text(comment.created_at));
+                element.set_string_member("text", clamp_text(comment.body));
+                entries.add_object_element(element);
+            }
+
+            var body = new Json.Object();
+            body.set_int_member("task_id", ((!)task).id);
+            body.set_string_member("title", ((!)task).title);
+            body.set_string_member("status", ((!)task).status);
+            body.set_int_member("entries", entries.get_length());
+            body.set_array_member("log", entries);
+            return json_string(body);
+        }
+
+        /**
+         * One ledger event's one-line summary for get_task_log: the
+         * payload's `text`, falling back to its `summary` (terminal
+         * transitions), else the kind alone.
+         */
+        private static string event_summary(EventRecord record) {
+            string text = payload_string(record.payload, "text");
+            if (text.length == 0) {
+                text = payload_string(record.payload, "summary");
+            }
+            if (text.length == 0) {
+                text = record.kind;
+            }
+            return text;
+        }
+
         private async string tool_list_goals(OrmSession db,
                 string arguments) throws Error {
             var args = parse_arguments(arguments);
@@ -999,6 +1117,8 @@ namespace ClankerSaddle.Services {
                 element.set_string_member("title", goal.title);
                 element.set_string_member("status", goal.status);
                 element.set_string_member("branch_name", goal.branch_name);
+                element.set_int_member("report_chars",
+                    ((!)goal.completion_report ?? "").length);
                 array.add_object_element(element);
             }
             var body = new Json.Object();
@@ -1024,6 +1144,12 @@ namespace ClankerSaddle.Services {
             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));
+            // The completion story (goal 27 refinements): the goal's
+            // completion report and when it finished — clamped like
+            // the plan above.
+            body.set_string_member("completion_report",
+                clamp_text(((!)goal).completion_report ?? ""));
+            body.set_string_member("completed_at", stamp_text(((!)goal).completed_at));
             return json_string(body);
         }
 
@@ -1277,9 +1403,16 @@ namespace ClankerSaddle.Services {
             task_properties.set_object_member("task_id", integer_property(
                 "The task's id"));
             tools.add_object_element(tool_definition("get_task",
-                "One task's full record by id (description, status, mode, branch).",
+                "One task's full record by id (description, status, mode, branch, final summary, completed_at).",
                 new string[] { "task_id" }, task_properties));
 
+            var task_log_properties = new Json.Object();
+            task_log_properties.set_object_member("task_id", integer_property(
+                "The task's id"));
+            tools.add_object_element(tool_definition("get_task_log",
+                "The task's running log: progress updates, comments and state changes (clamped) — where the completion story lives.",
+                new string[] { "task_id" }, task_log_properties));
+
             var goals_properties = new Json.Object();
             goals_properties.set_object_member("project_id", integer_property(
                 "Optional project id filter"));
@@ -1291,7 +1424,7 @@ namespace ClankerSaddle.Services {
             goal_properties.set_object_member("goal_id", integer_property(
                 "The goal's id"));
             tools.add_object_element(tool_definition("get_goal",
-                "One goal's full record by id (brief, plan, branch).",
+                "One goal's full record by id (brief, plan, branch, completion report, completed_at).",
                 new string[] { "goal_id" }, goal_properties));
 
             var rules_properties = new Json.Object();

+ 25 - 1
src/Services/AllocationService.vala

@@ -144,6 +144,19 @@ namespace ClankerSaddle.Services {
                         rows.add((!)parked);
                     }
                 }
+                // Goal 27 refinements (the parking guard): a run whose
+                // park was REFUSED (nothing left to wait on) is
+                // suspended with a pending_resume_notice — immediately
+                // offerable, no event or coalescing window needed.
+                // Works for a plain task too (the flag is the one-shot
+                // channel; the plain-task force_wake below is the
+                // human's variant of the same lift).
+                if (((!)run).pending_resume_notice != null) {
+                    var parked = yield parked_offerable(run, true);
+                    if (parked != null) {
+                        rows.add((!)parked);
+                    }
+                }
                 // Goal 4 task 1 (Force Wake): a human pressed Force
                 // wake on a suspended PLAIN task — it becomes offerable
                 // on its affine saddle immediately, with the explicit
@@ -395,7 +408,18 @@ namespace ClankerSaddle.Services {
                 // round (plan approval, rules, notes) rides the
                 // resume as a turn — null when nothing changed.
                 context_update = yield context_update_for(task, claim.session_id, projects);
-                if (claim.suspended_resume && task.kind == TASK_KIND_GOAL_RUN) {
+                if (claim.suspended_resume && task.pending_resume_notice != null) {
+                    // Goal 27 refinements (the parking guard): the run
+                    // parked nothing to wait on and the web refused —
+                    // the refusal notice IS the resume instruction,
+                    // delivered once (the flag is the one-shot
+                    // channel; consuming it here). The batched goal
+                    // report would only say "nothing changed".
+                    resume_notice = ((!)task.pending_resume_notice).strip();
+                    task.pending_resume_notice = null;
+                    yield db.update_async<TaskEntity>(task);
+                    yield tasks.clear_suspension(task.id);
+                } else if (claim.suspended_resume && task.kind == TASK_KIND_GOAL_RUN) {
                     // Plan 12: the goal agent's batched report — every
                     // plan task's status plus the completion reports
                     // that landed while it was parked. Consumes the

+ 82 - 0
src/Services/SaddleService.vala

@@ -1348,6 +1348,63 @@ namespace ClankerSaddle.Services {
          * 'goal_run'), else 0 — the discriminator for the goal-context
          * verb handling above.
          */
+        /**
+         * True when the goal owns at least one WORKER task that could
+         * still fire a terminal transition (the goal-run park's wake
+         * source): every status but done/failed counts — a queued,
+         * assigned, working, blocked, review-requested or suspended
+         * row will end its life in a transition that wakes the run.
+         * DRAFT rows do NOT count: a draft nobody ever enqueued
+         * produces no transition, ever (the goal-2 stall class).
+         */
+        private async bool goal_has_wakeable_task(int64 goal_id) throws Error {
+            foreach (var task in yield db.query<TaskEntity>()
+                    .where(expr("goal_id == $0 && kind == $1",
+                        elem<int64?>(goal_id), elem(TASK_KIND_TASK)))
+                    .materialise_async()) {
+                if (task.status != STATUS_DONE && task.status != STATUS_FAILED
+                        && task.status != STATUS_DRAFT) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * The parking guard's refusal: the park mechanics run (status
+         * 'suspended', the round's session closed 'parked',
+         * suspended_at stamped) and the ONE-SHOT
+         * {@link TaskEntity.pending_resume_notice} is set — which
+         * makes the run resume-offerable IMMEDIATELY (no terminal
+         * event or coalescing window). The daemon asks again the
+         * moment its round ends; that ask claims the run back and the
+         * refusal text rides the assignment as its resume_notice, so
+         * the agent is told to finish (submit its report) or cancel
+         * instead of sleeping forever. Keeping the session open
+         * instead would dead-end: an open session on an assigned task
+         * marks the saddle busy, so the next ask would 204 and the
+         * daemon would sleep with the workspace wedged.
+         */
+        private async void refuse_park(int64 task_id, string notice) throws Error {
+            yield task_service.set_parked(task_id);
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null || ((!)task).pending_resume_notice != null) {
+                // The first refusal stands (an already-pending notice
+                // keeps its original wording).
+                return;
+            }
+            ((!)task).pending_resume_notice = notice;
+            yield db.update_async<TaskEntity>((!)task);
+            var refused = new Json.Object();
+            refused.set_string_member("text", notice);
+            yield event_service.append(EVENT_STATE_CHANGE, EVENT_ACTOR_SYSTEM,
+                EVENT_SCOPE_TASK, task_id, refused);
+            yield task_service.add_system_comment(task_id, notice);
+            yield pulse.bump(task_id, "park refused (nothing to wait on)");
+        }
+
         private async int64 goal_id_of_run(int64 task_id) throws Error {
             var task = yield db.query<TaskEntity>()
                 .where(expr("id == $0", elem<int64?>(task_id)))
@@ -1552,6 +1609,31 @@ namespace ClankerSaddle.Services {
                     // assignment carrying the coalesced report.
                     // (set_parked closed the round's session 'parked';
                     // the freed saddle is the derived view's reading.)
+                    //
+                    // Goal 27 refinements — the PARKING GUARD: a goal
+                    // run parks so its OWNED tasks' terminal
+                    // transitions can wake it. When the goal owns no
+                    // task whose terminal transition could ever fire
+                    // (no non-terminal worker — only done/failed rows
+                    // and drafts nobody enqueued), the park would be a
+                    // dead sleep: nobody but a human force-wake could
+                    // end it. Refuse it instead — the park mechanics
+                    // run but the one-shot refusal notice makes the
+                    // run resume-offerable IMMEDIATELY, so the
+                    // daemon's next ask hands it "Parking refused — …"
+                    // instead of a sleep nothing will ever wake. A
+                    // live task anywhere under the goal keeps the
+                    // normal park.
+                    if (goal_id > 0 && !(yield goal_has_wakeable_task(goal_id))) {
+                        yield refuse_park(task_id,
+                            "Parking refused: the goal owns no live tasks to wait on —"
+                            + " finish the run (submit your report) or cancel.");
+                        break;
+                    }
+                    // A PLAIN task parking is ALLOWED (no guard): it
+                    // has no wake source either way, but its park is a
+                    // human-facing pause by design — the task page's
+                    // force-wake is the resume path a goal run lacks.
                     yield tasks.set_parked(task_id);
                     break;
                 case "working":

+ 21 - 1
src/entrypoints/ChatDetailEntrypoint.vala

@@ -22,6 +22,13 @@ namespace ClankerSaddle {
         public string kind { get; set; default = "user"; }
         public string content { get; set; default = ""; }
         public bool is_question { get; set; }
+        /**
+         * True when this assistant row carries a tool call AND said
+         * nothing worth a bubble (empty content or the bare "@tool
+         * call" marker): the paired tool row's details expander is
+         * its render, so the transcript shows no redundant bubble.
+         */
+        public bool bubble_hidden { get; set; }
         public string[] options { get; set; default = {}; }
         public bool answered { get; set; }
         public string answer { get; set; default = ""; }
@@ -208,7 +215,20 @@ namespace ClankerSaddle {
                 view.options = options.to_array();
                 return view;
             }
-            if (parsed.has_member("name")) {
+            if (parsed.has_member("name") && parsed.has_member("call_id")) {
+                // An assistant row that carries a tool call: it shows
+                // no bubble of its own unless the model wrote real
+                // prose alongside the call (the paired tool row's
+                // details expander is the call's render).
+                view.tool_name = member_string(parsed, "name");
+                view.arguments = pretty(member_string(parsed, "arguments"));
+                view.result = member_string(parsed, "result");
+                string spoken = view.content.strip();
+                if (spoken.length == 0 || spoken == "@tool call") {
+                    view.bubble_hidden = true;
+                }
+            } else if (parsed.has_member("name")) {
+                // The tool RESULT row (call_id + name + result).
                 view.tool_name = member_string(parsed, "name");
                 view.arguments = pretty(member_string(parsed, "arguments"));
                 view.result = pretty(member_string(parsed, "result"));

+ 1 - 0
src/main.vala

@@ -48,6 +48,7 @@ int main(string[] args) {
         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_migration<ClankerSaddle.Data.Migrations.M0007_ParkingGuard>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);

+ 7 - 1
src/pages/chat_detail.html

@@ -167,7 +167,13 @@
                         <div class="md" stm-markdown="message.content">message</div>
                         <p class="muted" stm-text="message.when_display">when</p>
                     </div>
-                    <div stm-if="message.kind == 'assistant' && !message.is_question"
+                    <!-- A tool-calling assistant row renders NO bubble
+                         of its own when it said nothing worth reading
+                         (empty or the bare "@tool call" marker) — the
+                         tool row's details expander right below IS its
+                         render. Meaningful prose alongside a call keeps
+                         its bubble. -->
+                    <div stm-if="message.kind == 'assistant' && !message.is_question && !message.bubble_hidden"
                          class="chat-bubble chat-assistant">
                         <div class="md" stm-markdown="message.content">message</div>
                         <p class="muted" stm-text="message.when_display">when</p>

+ 383 - 0
test/bundles/advisor_refine.py

@@ -0,0 +1,383 @@
+"""The goal-27 review-refinement bundle (task 181): four deliverables
+driven end-to-end.
+
+1. Hidden tool-call bubbles: an assistant row that carries a tool call
+   and said nothing worth a bubble (empty content or the bare
+   "@tool call" marker) renders bubble_hidden — the chat page's slot
+   hides its bubble while the paired tool row keeps its details
+   expander. A row with real prose alongside the call keeps its bubble.
+
+2. The chattier persona: the system prompt of every LLM request
+   carries the conversational contract (address the user after tool
+   calls, prefer ask_question, never open with a tool call).
+
+3. Reports in reach: get_goal carries the completion report and
+   completed_at, get_task the summary and completed_at, the list rows
+   hint report_chars/summary_chars, and the new get_task_log returns
+   the task's comments and progress/state-change entries (clamped).
+
+4. The parking guard: a goal run pushing `suspended` while its goal
+   owns NO wakeable task is refused — the park mechanics still run,
+   but the run carries a one-shot pending_resume_notice, is
+   immediately resume-offerable again, and the daemon's next ask
+   hands it the refusal text as its resume_notice. With a live task
+   under the goal the park lands normally (suspended, no notice).
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+import harness  # the module itself (the re-pointing below reads it)
+import bundles.advisor  # the imported helpers' module (see run())
+from bundles.advisor import (ScriptedLlm, advisor_api, chat_rows,
+                             wait_status)
+
+
+def restart_with_llm(ctx, llm):
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
+    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = login("admin", "adminpass123")
+    return ctx.auth
+
+
+def db_rows(sql, params=()):
+    conn = db()
+    try:
+        return conn.execute(sql, params).fetchall()
+    finally:
+        conn.close()
+
+
+def run(ctx):
+    # Sequential-suite trap (the shared wire helpers' known shape): the
+    # port-dependent helpers imported from bundles.advisor read THAT
+    # module's star-imported globals (WEB &c.), which the harness never
+    # re-points for it — a solo run passes but the sequential full
+    # suite dies with ConnectionRefused on the first call. Re-point.
+    for name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT", "WEB"):
+        setattr(bundles.advisor, name, getattr(harness, name))
+
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    # ------------------------------------------------------------------
+    print("== refine 1+2: the prompt contract + the hidden call bubbles ==")
+    llm = ScriptedLlm([
+        # A silent tool call (no prose), then one WITH prose, then a
+        # plain reply — three shapes of assistant row.
+        {"tools": [{"id": "c1", "name": "list_projects",
+                    "arguments": "{}"}]},
+        {"content": "Let me look at the goals too.",
+         "tools": [{"id": "c2", "name": "list_goals",
+                    "arguments": "{}"}]},
+        {"content": "All read."},
+    ])
+    llm.serve()
+    auth = restart_with_llm(ctx, llm)
+
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "Refinements"})
+    chat_id = int(body["chat_id"])
+    status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
+                            {"chat_id": chat_id,
+                             "message": "Take a look around."})
+    check(status == 200, "the turn is accepted")
+    check(wait_status(chat_id, "idle"), "the turn completes idle")
+    check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=20,
+                   what="three LLM calls") is not None,
+          "the scripted rounds ran")
+
+    # (2) the conversational contract rides every request's system prompt
+    system = llm.requests[0]["messages"][0]
+    check(system["role"] == "system", "the system prompt leads the wire")
+    prompt = system["content"]
+    for needle in ("CONVERSATION",
+                   "ADDRESS THE USER",
+                   "Never open with a",
+                   "2-3 tool calls",
+                   "PREFER ask_question"):
+        check(needle in prompt,
+              "the system prompt carries the contract language (%r)" % needle)
+    for later in llm.requests[1:]:
+        check(later["messages"][0]["content"] == prompt,
+              "later rounds keep the same system prompt")
+
+    # (1) the bubble gating: the slot's message rows flag the silent
+    # call row hidden and the prose-alongside-call row visible.
+    slot = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    rows = {(int(m["id"])): m for m in slot["messages"]}
+    call_rows = [m for m in slot["messages"]
+                 if m["kind"] == "assistant" and m.get("tool_name")]
+    check(len(call_rows) == 2, "two tool-carrying assistant rows exist")
+    silent = [m for m in call_rows if m.get("bubble_hidden")]
+    spoken = [m for m in call_rows if not m.get("bubble_hidden")]
+    check(len(silent) == 1 and len(spoken) == 1,
+          "the silent call row hides its bubble, the spoken one keeps it")
+    check(spoken[0]["content"] == "Let me look at the goals too.",
+          "the kept bubble is the prose row")
+    tool_details = [m for m in slot["messages"] if m["kind"] == "tool"]
+    check(len(tool_details) == 2 and all(m.get("tool_name") for m in tool_details),
+          "the tool rows (the details expanders) still render")
+
+    # The page template itself gates on the flag.
+    page = http_get_page(auth, "/chats/%d" % chat_id)
+    check("message.bubble_hidden" in page,
+          "the chat page's assistant bubble is gated on bubble_hidden")
+
+    # ------------------------------------------------------------------
+    print("== refine 3: completion reports within the advisor's reach ==")
+    # A goal with a completion report + a task with a summary and log
+    # rows, straight into the DB (the wire's own suites cover the
+    # transitions; here the TOOLS are under test).
+    conn = db()
+    conn.execute(
+        "insert into goals (project_id, title, brief, branch_name, status,"
+        " plan, completion_report, completed_at, created_at)"
+        " values (1, 'Reported goal', 'b', 'goal/reported', 'complete',"
+        " 'plan text', 'The goal shipped.', strftime('%s','now'),"
+        " strftime('%s','now'))")
+    goal_id = conn.execute("select max(id) from goals").fetchone()[0]
+    conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status,"
+        " priority, mode, creator, summary, completed_at, created_at)"
+        " values (1, ?, 'task', 'Reported task', 'done', 5, 'implement',"
+        " 'human', 'Fixed the flange.', strftime('%s','now'),"
+        " strftime('%s','now'))", (goal_id,))
+    task_id = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute(
+        "insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'agent', 'The flange tests pass now.',"
+        " strftime('%s','now'))", (task_id,))
+    conn.execute(
+        "insert into events (kind, actor, scope_type, scope_id, payload,"
+        " created_at) values ('progress', 'agent', 'task', ?,"
+        " '{\"text\": \"wrote the flange tests\"}', strftime('%s','now'))",
+        (task_id,))
+    conn.commit()
+    conn.close()
+
+    llm2 = ScriptedLlm([
+        {"tools": [{"id": "g1", "name": "get_goal",
+                    "arguments": json.dumps({"goal_id": goal_id})}]},
+        {"tools": [{"id": "g2", "name": "get_task",
+                    "arguments": json.dumps({"task_id": task_id})}]},
+        {"tools": [{"id": "g3", "name": "get_task_log",
+                    "arguments": json.dumps({"task_id": task_id})}]},
+        {"content": "Read them all."},
+    ])
+    llm2.serve()
+    auth = restart_with_llm(ctx, llm2)
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "Reports"})
+    chat2 = int(body["chat_id"])
+    status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
+                            {"chat_id": chat2,
+                             "message": "Pull the completion story."})
+    check(status == 200, "the report-reading turn is accepted")
+    # A fresh chat is already 'idle' — wait on the LLM traffic itself.
+    check(wait_for(lambda: len(llm2.requests) >= 4 or False, timeout=25,
+                   what="the report-reading rounds") is not None,
+          "the report-reading turn completes")
+
+    def tool_result(call_id):
+        for request in llm2.requests:
+            for message in request.get("messages") or []:
+                if message.get("role") == "tool" and \
+                        message.get("tool_call_id") == call_id:
+                    return json.loads(message["content"])
+        return None
+
+    goal_view = tool_result("g1")
+    check(goal_view is not None and goal_view.get("completion_report")
+          == "The goal shipped." and goal_view.get("completed_at"),
+          "get_goal returns the completion report + completed_at")
+    task_view = tool_result("g2")
+    check(task_view is not None and task_view.get("summary")
+          == "Fixed the flange." and task_view.get("completed_at"),
+          "get_task returns the final summary + completed_at")
+    log_view = tool_result("g3")
+    check(log_view is not None, "get_task_log answers")
+    texts = [e.get("text", "") for e in (log_view.get("log") or [])]
+    check(any("flange tests pass" in t for t in texts)
+          and any("wrote the flange tests" in t for t in texts),
+          "get_task_log carries the comment and the progress entry")
+
+    # The list hints: a second turn lists and sees report_chars > 0.
+    llm3 = ScriptedLlm([
+        {"tools": [{"id": "l1", "name": "list_goals",
+                    "arguments": "{}"}]},
+        {"tools": [{"id": "l2", "name": "list_tasks",
+                    "arguments": "{}"}]},
+        {"content": "Listed."},
+    ])
+    llm3.serve()
+    auth = restart_with_llm(ctx, llm3)
+    status, body = advisor_api(auth, "POST", "/api/advisor/chat",
+                               {"title": "Lists"})
+    chat3 = int(body["chat_id"])
+    status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
+                            {"chat_id": chat3, "message": "List things."})
+    check(status == 200, "the listing turn is accepted")
+    check(wait_for(lambda: len(llm3.requests) >= 3 or False, timeout=25,
+                   what="the listing rounds") is not None,
+          "the listing turn completes")
+
+    def tool_result3(call_id):
+        for request in llm3.requests:
+            for message in request.get("messages") or []:
+                if message.get("role") == "tool" and \
+                        message.get("tool_call_id") == call_id:
+                    return json.loads(message["content"])
+        return None
+
+    goals_list = tool_result3("l1") or {}
+    row = next((g for g in goals_list.get("goals", [])
+                if g.get("id") == goal_id), None)
+    check(row is not None and row.get("report_chars") == len("The goal shipped."),
+          "list_goals hints report_chars (%s)" % row)
+    tasks_list = tool_result3("l2") or {}
+    trow = next((t for t in tasks_list.get("tasks", [])
+                 if t.get("id") == task_id), None)
+    check(trow is not None and trow.get("summary_chars") == len("Fixed the flange."),
+          "list_tasks hints summary_chars (%s)" % trow)
+
+    # The new tool is declared on the wire.
+    names = [t["function"]["name"] for t in llm.requests[0].get("tools") or []]
+    check("get_task_log" in names, "get_task_log is declared to the model")
+
+    # ------------------------------------------------------------------
+    print("== refine 4: the parking guard ==")
+    # Goal A: reaches active with NO owned task, then parks — refused.
+    # After the refusal resume the script ENDS the run (the notice's
+    # instruction: finish or cancel) so the churn cannot loop.
+    fake.goal_script = [
+        ("plan", "# Plan\n\n1. Park with nothing to wait on"),
+        ("suspend",),
+        ("done", "# Completion\n\nNothing to wait on; finished."),
+    ]
+    act(auth, slot_public(auth, "/goals", "goals")["create"],
+        {"project_id": "1", "title": "Guard goal",
+         "brief": "Parks with zero live tasks.",
+         "branch_name": "goal/guard"})
+    guard_goal = int([g for g in slot_public(auth, "/goals", "goals")["goals"]
+                      if g["title"] == "Guard goal"][0]["id"])
+
+    def run_task_of(goal_id):
+        return int(db_rows(
+            "select id from tasks where goal_id = ? and kind = 'goal_run'",
+            (goal_id,))[0][0])
+
+    def run_status(goal_id):
+        return task_state(auth, run_task_of(goal_id))["status"]
+
+    act(auth, goal_state(guard_goal)["start"], {})
+    # The app restarts above orphan the fake's scheduled asks — the
+    # goal run dispatches on an explicit ask (the daemon's own idle
+    # ask after register).
+    status, _ = fake.ask_now()
+    check(wait_for(lambda: goal_state(guard_goal)["status"] == "plan_review"
+                   or False, timeout=90, what="guard goal plans") is not None,
+          "the guard goal reaches plan review")
+    fake.goal_script = [("suspend",), ("done",
+                        "# Completion\n\nNothing to wait on; finished.")]
+    pending = goal_state(guard_goal)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(guard_goal)["status"] == "active"
+                   or False, timeout=60, what="plan approved") is not None,
+          "approving the plan activates the goal")
+    check(wait_for(lambda: run_status(guard_goal) == "suspended" or False,
+                   timeout=60, what="the run parks") is not None,
+          "the run parks (the mechanics still run)")
+
+    guard_run = run_task_of(guard_goal)
+
+    # The refusal is recorded durably: a system comment on the run.
+    check(wait_for(lambda: db_rows(
+        "select count(*) from task_comments where task_id = ?"
+        " and body like 'Parking refused%'", (guard_run,))[0][0] > 0
+        or False, timeout=15, what="the refusal comment") is not None,
+        "the refused park leaves its instruction as a comment")
+
+    # The refusal makes the run resume-offerable immediately — the
+    # dispatcher's wake (or any ask) hands the daemon the notice as
+    # its resume_notice.
+    def refusal_resume():
+        for assignment in fake.goal_assignments:
+            notice = assignment.get("resume_notice") or ""
+            if "Parking refused" in notice and "no live tasks" in notice:
+                return notice
+        return False
+    notice = wait_for(refusal_resume, timeout=60, what="the refusal resume")
+    check(notice is not None,
+          "the resuming assignment carries the refusal notice")
+
+    # One-shot: consumed by the resume that delivered it.
+    check(wait_for(lambda: not db_rows(
+        "select pending_resume_notice from tasks where id = ?",
+        (guard_run,))[0][0] or False, timeout=15,
+        what="the notice is consumed") is not None,
+        "the notice is consumed by the resume (one-shot)")
+
+    # The instructed ending: the agent finishes its run, the goal
+    # enters completion review and completes.
+    check(wait_for(lambda: goal_state(guard_goal)["status"]
+                   == "completion_review" or False, timeout=60,
+                   what="the refused run finishes") is not None,
+          "the refused run finishes (submit its report)")
+    pending = goal_state(guard_goal).get("completion_pending_block")
+    if pending:
+        act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(guard_goal)["status"] == "complete"
+                   or False, timeout=60, what="the guard goal completes")
+          is not None,
+          "accepting the completion completes the guard goal")
+
+    # Goal B: the SAME flow but with a live task under the goal — the
+    # park must land normally (no notice). A BLOCKED live task: still
+    # wakeable (not terminal), but never dispatched, so nothing fires
+    # a terminal transition behind the test's back.
+    fake.goal_script = [("plan", "# Plan\n\n1. Wait on a live task")]
+    act(auth, slot_public(auth, "/goals", "goals")["create"],
+        {"project_id": "1", "title": "Unguard goal",
+         "brief": "Parks with a live task.",
+         "branch_name": "goal/unguard"})
+    unguard_goal = int([g for g
+                        in slot_public(auth, "/goals", "goals")["goals"]
+                        if g["title"] == "Unguard goal"][0]["id"])
+    conn = db()
+    conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status,"
+        " priority, mode, creator, created_at)"
+        " values (1, ?, 'task', 'Live worker', 'blocked', 5, 'implement',"
+        " 'goal', strftime('%s','now'))", (unguard_goal,))
+    conn.commit()
+    conn.close()
+    act(auth, goal_state(unguard_goal)["start"], {})
+    status, _ = fake.ask_now()
+    check(wait_for(lambda: goal_state(unguard_goal)["status"] == "plan_review"
+                   or False, timeout=90, what="unguard goal plans") is not None,
+          "the unguard goal reaches plan review")
+    fake.goal_script = [("suspend",)]
+    pending = goal_state(unguard_goal)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(unguard_goal)["status"] == "active"
+                   or False, timeout=60, what="unguard plan approved")
+          is not None,
+          "approving the unguard plan activates the goal")
+    check(wait_for(lambda: run_status(unguard_goal) == "suspended" or False,
+                   timeout=60, what="unguard run parks") is not None,
+          "the run with a live task parks normally")
+    conn = db()
+    unguard_run = run_task_of(unguard_goal)
+    plain_row = conn.execute(
+        "select pending_resume_notice from tasks where id = ?",
+        (unguard_run,)).fetchone()
+    conn.close()
+    check(plain_row is not None and not plain_row[0],
+          "a park with something to wait on carries no refusal notice")
+
+    llm.kill()
+    llm2.kill()
+    llm3.kill()

+ 15 - 0
test/bundles/allocation.py

@@ -208,6 +208,14 @@ def run(ctx):
     # the run resume-offerable the moment its goal row carries the
     # verdict, so a wake-triggered background ask would race this
     # manual ask_now for the claim.
+    # (Parking guard: the scripted park needs a live wake source.)
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Affinity live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (affinity_goal,))
+    conn.commit()
+    conn.close()
     fake2.ask_on_wake = False
     fake2.ask_on_idle_status = False
     fake2.settle_asks()
@@ -419,6 +427,13 @@ def run(ctx):
         "branch_name": "goal/tags"})
     conn = db()
     tag_goal = conn.execute("select id from goals where title = 'Tag goal'").fetchone()[0]
+    # (Parking guard: the parked agent hosts the CLI calls below — the
+    # park needs a live wake source under the goal.)
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Tag live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (tag_goal,))
+    conn.commit()
     conn.close()
     fake.goal_script = [("suspend",)]
     act(auth, slot_public(auth, "/goals/%d" % tag_goal, "goal")["start"], {})

+ 10 - 0
test/bundles/forcewake.py

@@ -32,10 +32,20 @@ def run(ctx):
 
     print("== goal 4 t1: force wake — the goal-2 stall class ==")
     # Create → plan → approve → parked: the goalui-proven short flow.
+    # (Goal 27 refinements — the parking guard: the park needs a live
+    # wake source; the blocked worker is it. The never-enqueued DRAFT
+    # below still produces no transition — the stall the wake recovers.)
     fake.goal_script = [("plan", "# Wake plan\n\n1. Park safely")]
     goal = create_goal({"project_id": "1", "title": "Force-wake goal",
                         "brief": "Parks with a never-enqueued draft.",
                         "branch_name": "goal/forcewake"})
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Wake live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (goal,))
+    conn.commit()
+    conn.close()
     act(auth, goal_state(goal)["start"], {})
     check(wait_for(lambda: goal_state(goal)["status"] == "plan_review" or False,
                    timeout=90, what="goal plans") is not None,

+ 36 - 0
test/bundles/goals.py

@@ -23,6 +23,25 @@ def run(ctx):
                 if g["title"] == fields["title"]]
         return int(rows[0]["id"])
 
+    def plant_live_worker(goal_id, title="Live supervision worker",
+                          status="blocked"):
+        """Goal 27 refinements — the parking guard: a goal agent may
+        park only while its goal owns a task whose terminal transition
+        could still fire (the park's wake source). Scenes that script
+        the agent to park with every owned task already terminal plant
+        one BLOCKED live worker first — wakeable (not terminal), never
+        dispatched (nothing runs behind the scene's back), and swept
+        by the goal's own cancel/fail cascade at teardown."""
+        conn = db()
+        conn.execute("insert into tasks (project_id, goal_id, kind, title,"
+                     " status, priority, mode, creator, created_at)"
+                     " values (1, ?, 'task', ?, ?, 5, 'implement', 'goal',"
+                     " strftime('%s','now'))", (goal_id, title, status))
+        planted = conn.execute("select max(id) from tasks").fetchone()[0]
+        conn.commit()
+        conn.close()
+        return int(planted)
+
     print("== plan 12: goal creation, validation, lifecycle actions ==")
     goal1 = create_goal({"project_id": "1", "title": "Ship the flange",
                          "brief": "Make the flange module production-grade.",
@@ -194,6 +213,9 @@ def run(ctx):
     # Approve: goal.plan = v2, status active, agent resumes and parks.
     # Plan 16 S2: the accept posts the verdict MARKER — the server
     # resolves the goal-plan kind and owns the constant.
+    # (The parking guard: the agent parks only with something live to
+    # wait on — the blocked worker below is this park's wake source.)
+    plant_live_worker(goal1)
     fake.goal_script = [("suspend",)]
     pending = state["plan_pending_block"]
     act(auth, pending["answer_action"], {"verdict": "accept"})
@@ -334,6 +356,7 @@ def run(ctx):
           "the resumed goal agent carries its identity triplet")
     # The resumed agent parked again (script): two events at once must
     # coalesce into ONE resume within the window.
+    plant_live_worker(goal1, "Live supervision worker two")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="goal agent re-parks") is not None,
           "the resumed goal agent parks again")
@@ -382,6 +405,7 @@ def run(ctx):
     check(s3_notice.count("the full report with all the detail lines.") == 1
           and "### #" in s3_notice,
           "each new completion's full body embeds EXACTLY ONCE (no double embedding)")
+    plant_live_worker(goal1, "Live supervision worker three")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="S3 agent re-parks") is not None,
           "the S3 agent re-parks")
@@ -407,6 +431,7 @@ def run(ctx):
     check("the full report with all the detail lines." not in s3_next
           and "### #" in s3_next and "fresh one-liner" in s3_next,
           "the consumed completion's body never re-embeds (notice size constant per event)")
+    plant_live_worker(goal1, "Live supervision worker four")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="S3 agent parks a third time") is not None,
           "the S3 agent parks for the pause section")
@@ -503,6 +528,7 @@ def run(ctx):
           "the queued events coalesce into the post-resume wake")
     check("finished while paused" in (fake.goal_assignments[-1].get("resume_notice") or ""),
           "the flushed notice reports the task that finished while paused")
+    plant_live_worker(goal1, "Live supervision worker five")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="goal agent parks again") is not None,
           "the goal agent parks again after the flush")
@@ -553,6 +579,7 @@ def run(ctx):
           "the repeated-failure section names both cluster members")
     check("Invalid UTF-8 middle byte 0x5c" in s6_notice,
           "the section names the shared signature's first line")
+    plant_live_worker(goal1, "Live supervision worker six")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="S6 agent re-parks") is not None,
           "the S6 agent re-parks")
@@ -593,6 +620,7 @@ def run(ctx):
           " newer than the park")
     check(("#%d" % distinct_id) in s6_next and ("#%d" % stale_id) in s6_next,
           "the negative-round notice still reports both failures as statuses")
+    plant_live_worker(goal1, "Live supervision worker seven")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="S6 negative agent re-parks")
           is not None,
@@ -712,6 +740,7 @@ def run(ctx):
     # the goal page's reject posts raw `feedback` — the SERVER wraps
     # it with the completion-review prefix (byte-identical to the
     # historical client-built answer).
+    plant_live_worker(goal1, "Live supervision worker eight")
     fake.goal_script = [("suspend",)]
     act(auth, state["completion_pending_block"]["answer_action"],
         {"feedback": "Not done — the docs are missing"})
@@ -881,6 +910,7 @@ def run(ctx):
     goal2 = create_goal({"project_id": "1", "title": "Doomed goal",
                          "brief": "This goal will be cancelled.",
                          "branch_name": "goal/doomed"})
+    plant_live_worker(goal2, "Doomed live worker")
     fake.goal_script = [("suspend",)]
     act(auth, goal_state(goal2)["start"], {})
     conn = db()
@@ -928,6 +958,7 @@ def run(ctx):
     collapse = create_goal({"project_id": "1", "title": "Collapse goal",
                             "brief": "Fails with tasks in flight.",
                             "branch_name": "goal/collapse"})
+    plant_live_worker(collapse, "Collapse live worker")
     fake.goal_script = [("suspend",)]
     act(auth, goal_state(collapse)["start"], {})
     conn = db()
@@ -1140,9 +1171,14 @@ def run(ctx):
     # draft task never enqueued → no terminal transition → parked
     # forever). Force wake is the human recovery lever. A fresh goal
     # owns the scenario (goal1's run completed with its goal).
+    # (Goal 27 refinements — the parking guard: the park needs a live
+    # wake source now, so the blocked worker rides along; the never-
+    # enqueued DRAFT below still produces no transition, which is the
+    # stall the wake recovers.)
     stall_goal = create_goal({"project_id": "1", "title": "Stall goal",
                               "brief": "Parks with a never-enqueued draft.",
                               "branch_name": "goal/stall"})
+    plant_live_worker(stall_goal, "Stall live worker")
     fake.goal_script = [("plan", "# Stall plan\n\n1. Wait for the stall")]
     act(auth, goal_state(stall_goal)["start"], {})
     check(wait_for(lambda: goal_state(stall_goal)["status"] == "plan_review" or False,

+ 23 - 1
test/bundles/goalui.py

@@ -42,6 +42,19 @@ def run(ctx):
                 slot_public(auth, "/", "dashboard")["pinned"]
                 if b.get("item_type") == "goal"]
 
+    def plant_live_worker(goal_id, title="Goalui live worker"):
+        """Goal 27 refinements — the parking guard: the goal agent parks
+        only while its goal owns a task that could still fire a
+        terminal transition. The scenes that script it to park plant a
+        BLOCKED live worker first (wakeable, never dispatched)."""
+        conn = db()
+        conn.execute("insert into tasks (project_id, goal_id, kind, title,"
+                     " status, priority, mode, creator, created_at)"
+                     " values (1, ?, 'task', ?, 'blocked', 5, 'implement',"
+                     " 'goal', strftime('%s','now'))", (goal_id, title))
+        conn.commit()
+        conn.close()
+
     def open_session(task_id):
         """The task's newest session row (plan 16 D4: a parked round's
         row is closed 'parked' — still the record the CLI callbacks,
@@ -276,6 +289,7 @@ def run(ctx):
           "no goal-side store exists — historical stragglers are impossible")
 
     print("== B4: resume notices on the session rows + the event log page ==")
+    plant_live_worker(goal)
     fake.goal_script = [("suspend",)]
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="agent parks suspended") is not None,
@@ -363,6 +377,7 @@ def run(ctx):
     conn.commit()
     conn.close()
     fake.goal_script = [("suspend",)]
+    plant_live_worker(goal, "Goalui live worker two")
     check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended"
                    or False, timeout=60, what="requeued run re-dispatches") is not None,
           "the requeued run re-dispatches and re-parks")
@@ -384,6 +399,7 @@ def run(ctx):
                    or False, timeout=30, what="retry offered") is not None,
           "a failed goal offers Retry supervision")
     fake.goal_script = [("suspend",)]
+    plant_live_worker(goal, "Goalui live worker three")
     act(auth, goal_state(goal)["retry_run"], {})
     state = goal_state(goal)
     check(state["status"] == "active",
@@ -573,7 +589,13 @@ def run(ctx):
 
     print("== C4: head line, answered-blocks expander, footnote comments ==")
     state = goal_state(goal)
-    check(state["current_activity"] == "parked awaiting its tasks' terminal transitions",
+    # (Goal 27 refinements: the parking guard can hand the run a
+    # refusal resume between rounds — the parked head line is the
+    # settled state, not a transient working blip.)
+    check(wait_for(lambda: goal_state(goal)["current_activity"]
+                   == "parked awaiting its tasks' terminal transitions"
+                   or False, timeout=60, what="run settles parked")
+          is not None,
           "a suspended run folds into the one muted head line")
     check(len(state.get("answered_blocks") or []) >= 2
           and len(state.get("open_blocks") or []) == 0,

+ 12 - 5
test/bundles/origins.py

@@ -124,15 +124,22 @@ def run(ctx):
                       if c["name"] == "legacy-only"][0])
     legacy_card = saddle_card(auth, "legacy-daemon")
     act(auth, legacy_card["attach_capability"], {"capability_id": str(legacy_cap)})
-    third = create_queued(auth, {"project_id": "1", "title": "Path probe C",
-                                 "description": "Dispatch to the legacy daemon.",
-                                 "priority": "4"})
-    detail = task_state(auth, third)
-    act(auth, detail["attach_capability"], {"capability_id": str(legacy_cap)})
+    # Create as a DRAFT, attach the capability, THEN enqueue: a queued
+    # task races the idle-awake primary fake's ask (the dispatch can
+    # win before the attach lands, and the routed probe then completes
+    # unrouted — a flake the parking-guard timing shifts exposed).
+    act(auth, slot_public(auth, "/tasks", "tasks")["create"],
+        {"project_id": "1", "title": "Path probe C",
+         "description": "Dispatch to the legacy daemon.", "priority": "4"})
+    third = int([t["id"] for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+                 if t["title"] == "Path probe C"][0])
+    act(auth, task_state(auth, third)["attach_capability"],
+        {"capability_id": str(legacy_cap)})
     check(wait_for(lambda: [c["name"] for c in
                             task_state(auth, third)["capabilities"]] == ["legacy-only"]
                    or False, timeout=30, what="capability attached") is not None,
           "the probe task requires the legacy-only capability")
+    enqueue_task(auth, third)
     check(wait_for(lambda: path_of(legacy, "Path probe C") is not None,
                    timeout=60, what="legacy-saddle assignment") is not None,
           "the legacy saddle receives the task")

+ 15 - 4
test/bundles/pages.py

@@ -660,6 +660,16 @@ def run(ctx):
     check(goal_pile["block_type"] == "plan" and "Dashboard goal" in goal_pile["goal_title"],
           "the goal pile card names the goal and block kind")
     # Answer it: the pile drains.
+    # (Goal 27 refinements — the parking guard: the later scripted park
+    # needs a live wake source under the goal; plant one before the
+    # approval resumes the agent.)
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Dash live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (goal3,))
+    conn.commit()
+    conn.close()
     act(auth, goal_state(goal3)["plan_pending_block"]["answer_action"],
         {"answer": "Plan approved. Proceed with implementation."})
     check(wait_for(lambda: not any(b.get("item_type") == "goal"
@@ -910,11 +920,12 @@ 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, 6],
-          "the clanker-saddle namespace carries exactly serials 1 through 6"
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5, 6, 7],
+          "the clanker-saddle namespace carries exactly serials 1 through 7"
           " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
-          " M0004 understandings, M0005 discoveries and M0006 advisor)")
-    check(len(serials) == 6,
+          " M0004 understandings, M0005 discoveries, M0006 advisor and"
+          " M0007 the parking guard)")
+    check(len(serials) == 7,
           "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)")

+ 9 - 0
test/bundles/parity.py

@@ -89,6 +89,15 @@ def run(ctx):
     check((body.get("goal") or {}).get("has_plan") is False,
           "goals: a PENDING plan review leaves has_plan false (accept-gated)")
     fake.goal_script = [("suspend",)]
+    # (Goal 27 refinements — the parking guard: the scripted park
+    # needs a live wake source under the goal.)
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Parity live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (goal_a,))
+    conn.commit()
+    conn.close()
     pending = goal_state(goal_a)["plan_pending_block"]
     act(auth, pending["answer_action"], {"verdict": "accept"})
     check(wait_for(lambda: goal_state(goal_a)["status"] == "active" or False,

+ 12 - 0
test/bundles/replay.py

@@ -179,6 +179,12 @@ def run(ctx):
     conn = db()
     burst_goal = conn.execute(
         "select id from goals where title = 'Burst goal'").fetchone()[0]
+    # (Parking guard: the scripted park needs a live wake source.)
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Burst live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (burst_goal,))
+    conn.commit()
     conn.close()
     fake.goal_script = [("suspend",)]
     act(auth, goal_state(burst_goal)["start"], {})
@@ -237,6 +243,12 @@ def run(ctx):
     conn = db()
     comment_goal = conn.execute(
         "select id from goals where title = 'Comment goal'").fetchone()[0]
+    # (Parking guard: the scripted park needs a live wake source.)
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Comment live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (comment_goal,))
+    conn.commit()
     conn.close()
     fake.goal_script = [("suspend",)]
     act(auth, goal_state(comment_goal)["start"], {})

+ 9 - 0
test/bundles/resurrect.py

@@ -117,6 +117,15 @@ def run(ctx):
     goal = create_goal({"project_id": "1", "title": "Resurrect goal",
                         "brief": "Completes, then re-opens on a comment.",
                         "branch_name": "goal/resurrect"})
+    # (Goal 27 refinements — the parking guard: the round-2 suspend
+    # needs a live wake source under the goal.)
+    conn = db()
+    conn.execute("insert into tasks (project_id, goal_id, kind, title, status,"
+                 " priority, mode, creator, created_at) values (1, ?, 'task',"
+                 " 'Resurrect live worker', 'blocked', 5, 'implement', 'goal',"
+                 " strftime('%s','now'))", (goal,))
+    conn.commit()
+    conn.close()
     act(auth, goal_state(goal)["start"], {})
     check(wait_for(lambda: run_task_of(goal) is not None or False,
                    timeout=30, what="goal run task appears") is not None,

+ 1 - 1
test/harness.py

@@ -1325,7 +1325,7 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "resurrect", "origins", "parity", "understanding",
                  "discoveries", "knowledge_cycle", "advisor", "chatui",
-                 "advisor_arc", "advisor_fix")
+                 "advisor_arc", "advisor_fix", "advisor_refine")
 
 
 def run_bundle(name):