浏览代码

Merge origin/goal/advisor (advisor refinements) into task/27-180

saddle-Clanker-Saddle 4 周之前
父节点
当前提交
5e03078f1c

+ 27 - 6
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,12 +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, capability tags, the canonical texts and the ethos —
-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, capability tags, the canonical texts and the
+ethos — 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");
+        }
+    }
+}

+ 139 - 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;
@@ -348,7 +349,8 @@ namespace ClankerSaddle.Services {
          * tasks/goals that stay inert until explicitly approved in
          * the UI. It can never run commands. The ethos section — the
          * same orientation every saddle agent carries — is appended
-         * from the ONE web-side constant (never a second hand-copy).
+         * after the last line from the ONE web-side constant (never a
+         * second hand-copy); 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"
@@ -363,6 +365,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.\n\n"
             + ClankerSaddle.Services.Ethos.TEXT;
 
@@ -703,20 +723,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;
@@ -788,6 +813,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":
@@ -956,6 +983,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();
@@ -985,9 +1014,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);
@@ -1008,6 +1125,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();
@@ -1033,6 +1152,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);
         }
 
@@ -1517,9 +1642,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"));
@@ -1531,7 +1663,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

@@ -1356,6 +1356,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)))
@@ -1560,6 +1617,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", "canon")
+                 "advisor_arc", "advisor_fix", "advisor_refine", "canon")
 
 
 def run_bundle(name):