Pārlūkot izejas kodu

goals: goal page parity — markdown everywhere, live feed, run terminals

Full task-page parity, goal-flavoured: the agent's progress feed
(green latest-first, history expander) and current activity (running
command + saddle health) below the status head; branch/push-state
chips inline from the cached goal_branches; plan revision history
with approved/rejected verdict badges; the assigned agent saddle in
the status head; run table with host saddles, terminals opening in
new tabs; every long-form body (brief, plans, reports, blocks,
comments, narrative events) renders through stm-markdown.
clanker 1 mēnesi atpakaļ
vecāks
revīzija
219632b418

+ 4 - 1
src/actions/GoalActions.vala

@@ -282,6 +282,8 @@ namespace ClankerSaddle {
         private GoalService goal_service = inject<GoalService>();
         private TaskService task_service = inject<TaskService>();
         private ProjectService project_service = inject<ProjectService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private SaddlePollState poll_state = inject<SaddlePollState>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = inject<UserService>();
 
@@ -297,7 +299,8 @@ namespace ClankerSaddle {
             }
 
             var state = yield GoalState.build(goal_id, goal_service, task_service,
-                project_service, db, action_registry, state_service);
+                project_service, db, action_registry, state_service,
+                saddle_service, poll_state);
             return directives().set(state);
         }
     }

+ 97 - 5
src/entrypoints/GoalDetailEntrypoint.vala

@@ -38,6 +38,8 @@ namespace ClankerSaddle {
         public bool is_completion_review { get; set; default = false; }
         /** Whether the block body renders as its own preview (not duplicated by a section). */
         public bool show_body { get; set; default = true; }
+        /** Plan revisions: the answer was the stable approval verdict. */
+        public bool approved { get; set; default = false; }
     }
 
     /** One comment on the goal thread. */
@@ -49,6 +51,13 @@ namespace ClankerSaddle {
         public bool from_agent { get; set; default = false; }
     }
 
+    /** One progress line from the goal agent's runs (`saddle progress-update`). */
+    public class GoalProgressView : Object {
+        public int64 id { get; set; }
+        public string summary { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+    }
+
     /** One supervision-narrative event row. */
     public class GoalEventView : Object {
         public int64 id { get; set; }
@@ -78,6 +87,8 @@ namespace ClankerSaddle {
         public bool open { get; set; default = false; }
         public string outcome { get; set; default = ""; }
         public string terminal_url { get; set; default = ""; }
+        /** The saddle that hosted the run ("" when unknown). */
+        public string saddle_name { get; set; default = ""; }
     }
 
     /** One branch of the daemon-reported goal push state. */
@@ -128,6 +139,15 @@ namespace ClankerSaddle {
         public int64 run_task_id { get; set; }
         /** True while a completion report awaits review. */
         public bool completion_pending { get; set; }
+        /** The saddle currently hosting the goal agent ("" while unassigned). */
+        public string agent_saddle { get; set; default = ""; }
+        /** Health line for the agent's saddle ("" when none). */
+        public string saddle_health { get; set; default = ""; }
+        /** The agent's newest recorded activity line ("" when none). */
+        public string current_command { get; set; default = ""; }
+        public string current_command_display { get; set; default = ""; }
+        /** The current activity's entry has not completed yet. */
+        public bool current_command_running { get; set; }
         public Statum.Model.ActionDto? start { get; set; }
         public Statum.Model.ActionDto? pause { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
@@ -153,7 +173,8 @@ namespace ClankerSaddle {
         public static async Statum.Slot build(int64 goal_id, GoalService goals,
                 TaskService tasks, ProjectService projects,
                 InvercargillSql.Orm.OrmSession db, ActionRegistry? actions,
-                StateService state_service) throws GLib.Error {
+                StateService state_service,
+                SaddleService? saddles = null, SaddlePollState? polls = null) throws GLib.Error {
             var pub = new GoalPublic();
             var links = new Vector<GoalLinkView>();
             var blocks = new Vector<GoalBlockView>();
@@ -162,6 +183,7 @@ namespace ClankerSaddle {
             var comments = new Vector<GoalCommentView>();
             var runs = new Vector<GoalRunView>();
             var push_branches = new Vector<GoalPushBranchView>();
+            var progress = new Vector<GoalProgressView>();
 
             GoalBlockView? plan_pending = null;
             GoalBlockView? completion_pending = null;
@@ -171,7 +193,8 @@ namespace ClankerSaddle {
             var goal = yield goals.get_goal(goal_id);
             if (goal != null) {
                 yield populate(((!)goal), goals, tasks, db, actions, true, pub,
-                    links, blocks, goal_tasks, events, comments, runs, push_branches);
+                    links, blocks, goal_tasks, events, comments, runs, push_branches,
+                    progress, saddles, polls);
                 foreach (var block in blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
@@ -210,6 +233,8 @@ namespace ClankerSaddle {
             pub_root["events"] = StateJson.list<GoalEventView>(events.to_immutable_buffer());
             pub_root["comments"] = StateJson.list<GoalCommentView>(comments.to_immutable_buffer());
             pub_root["runs"] = StateJson.list<GoalRunView>(runs.to_immutable_buffer());
+            pub_root["progress"] = StateJson.list<GoalProgressView>(
+                progress.to_immutable_buffer());
             pub_root["push_branches"] = StateJson.list<GoalPushBranchView>(
                 push_branches.to_immutable_buffer());
             pub_root["plan_revisions"] = StateJson.list<GoalBlockView>(
@@ -236,7 +261,10 @@ namespace ClankerSaddle {
                 Vector<GoalLinkView> links, Vector<GoalBlockView> blocks,
                 Vector<GoalTaskView> goal_tasks, Vector<GoalEventView> events,
                 Vector<GoalCommentView> comments, Vector<GoalRunView> runs,
-                Vector<GoalPushBranchView> push_branches) throws GLib.Error {
+                Vector<GoalPushBranchView> push_branches,
+                Vector<GoalProgressView>? progress = null,
+                SaddleService? saddles = null,
+                SaddlePollState? polls = null) throws GLib.Error {
             pub.exists = true;
             pub.id = goal.id;
             pub.project_id = goal.project_id;
@@ -322,7 +350,9 @@ namespace ClankerSaddle {
                     // A pending plan/completion block hides its body
                     // preview: the plan/completion section already
                     // renders it (the task page's duplicate rule).
-                    show_body = !(pending_plan || pending_completion)
+                    show_body = !(pending_plan || pending_completion),
+                    approved = block.answer != null
+                        && block.answer == NOTICE_PLAN_APPROVED
                 };
                 if (block.answer == null && actions != null) {
                     view.answer_action = ((!)actions)
@@ -408,23 +438,76 @@ namespace ClankerSaddle {
             }
 
             if (pub.run_task_id > 0) {
+                // The goal agent's live progress feed (green, task-page
+                // pattern) — the run task's own progress lines.
+                if (progress != null) {
+                    foreach (var row in yield tasks.list_progress(pub.run_task_id)) {
+                        ((!)progress).add(new GoalProgressView() {
+                            id = row.id,
+                            summary = row.summary,
+                            when_display = format_stamp(row.created_at)
+                        });
+                    }
+                }
                 var session_rows = yield db.query<ClankerSaddle.Data.Session>()
                     .where(expr("task_id == $0", elem<int64?>(pub.run_task_id)))
                     .order_by_desc(expr("id"))
                     .materialise_async();
                     foreach (var session in session_rows) {
+                        string run_saddle = "";
+                        if (saddles != null) {
+                            var saddle_row = yield ((!)saddles).get_saddle(session.saddle_id);
+                            run_saddle = saddle_row != null
+                                ? ((!)saddle_row).name : @"saddle #$(session.saddle_id)";
+                        }
                         runs.add(new GoalRunView() {
                             id = session.id,
                             started_display = format_stamp(session.started_at),
                             ended_display = format_stamp(session.ended_at),
                             open = session.ended_at == null,
                             outcome = session.outcome ?? "",
+                            saddle_name = run_saddle,
                             // Deep-links this run's terminal view: the
                             // picker's client-side ?session_id=
                             // selection lands on exactly this session.
                             terminal_url = @"/tasks/$(pub.run_task_id)/terminal?session_id=$(session.id)"
                         });
                     }
+                // Current activity (task-page parity): the open round's
+                // hosting saddle, its health and the newest recorded
+                // activity line.
+                var open = yield tasks.open_session_for_task(pub.run_task_id);
+                if (open != null) {
+                    if (saddles != null) {
+                        var saddle_row = yield ((!)saddles).get_saddle(((!)open).saddle_id);
+                        if (saddle_row != null) {
+                            pub.agent_saddle = ((!)saddle_row).name;
+                            if (polls != null) {
+                                string daemon_state = "";
+                                int64 uptime = 0;
+                                int64 activity = 0;
+                                int64 contact_ms = 0;
+                                ((!)polls).health_for(((!)saddle_row).id, out daemon_state,
+                                    out uptime, out activity, out contact_ms);
+                                if (daemon_state.length > 0) {
+                                    pub.saddle_health = @"saddle '$(((!)saddle_row).name)' — daemon $(daemon_state)";
+                                }
+                            }
+                        }
+                    }
+                    SessionCommand? newest = null;
+                    foreach (var command in yield db.query<SessionCommand>()
+                            .where(expr("session_id == $0", elem<int64?>(((!)open).id)))
+                            .order_by(expr("id"))
+                            .materialise_async()) {
+                        newest = command;
+                    }
+                    if (newest != null && ((!)newest).command_text.length > 0) {
+                        pub.current_command = clip_line(((!)newest).command_text, 200);
+                        pub.current_command_display = format_stamp(((!)newest).started_at);
+                        pub.current_command_running = ((!)newest).completed_at == null;
+                    }
+                }
             }
 
             if (goal.branch_push_state != null
@@ -532,6 +615,12 @@ namespace ClankerSaddle {
         private static string format_stamp(DateTime? stamp) {
             return stamp != null ? ((!)stamp).format("%Y-%m-%d %H:%M") : "";
         }
+
+        /** A flattened, clipped activity line (the current-activity display). */
+        private static string clip_line(string text, int limit) {
+            string flat = text.replace("\n", " ⏎ ").strip();
+            return flat.length > limit ? flat.substring(0, limit) + "…" : flat;
+        }
     }
 
     /** Display + badge pair for a goal task row. */
@@ -617,6 +706,8 @@ namespace ClankerSaddle {
         private GoalService goal_service = Inversion.inject<GoalService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private ProjectService project_service = Inversion.inject<ProjectService>();
+        private SaddleService saddle_service = Inversion.inject<SaddleService>();
+        private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -628,7 +719,8 @@ namespace ClankerSaddle {
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             var state = yield GoalState.build(goal_id, goal_service, task_service,
-                project_service, db, action_registry, state_service);
+                project_service, db, action_registry, state_service,
+                saddle_service, poll_state);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }
     }

+ 92 - 37
src/pages/goal_detail.html

@@ -5,12 +5,15 @@
     <pstm-template>main</pstm-template>
 <script>
     // The goal page: supervision surface of one goal — status head
-    // with lifecycle actions, the locked brief, the approved plan
-    // (rejected revisions under the historical-blocks expander), the
+    // with lifecycle actions, branch/push-state chips, the agent's
+    // live progress feed and current activity, the locked brief, the
+    // approved plan (revision history behind the expander), the
     // task status strip (its buttons filter the task list), the
     // supervision narrative, the comment thread with
     // selection-quoting, the completion review and the goal agent's
-    // runs. The status strip's filter lives client-side in
+    // runs. Every long-form body (brief, plans, reports, blocks,
+    // comments, events) renders as markdown via stm-markdown. The
+    // status strip's filter lives client-side in
     // window.goalTaskFilter; clicking a strip button re-runs the
     // sealed refresh action, and the filter rides the re-render.
     if (window.statum) {
@@ -67,16 +70,17 @@
 </script>
 </head>
 <body>
-    <div class="page">
+    <div class="page goal-page">
         <p><a href="/goals">&larr; Goals</a></p>
-        <h2 stm-if="goal.exists">#<span stm-text="goal.id">id</span> —
+        <h2 stm-if="goal.exists"><span class="goal-icon">🎯</span> #<span stm-text="goal.id">id</span> —
             <span stm-text="goal.title">title</span></h2>
         <p stm-if="!goal.exists" class="muted">Goal not found.</p>
 
         <div stm-if="goal.exists">
-            <!-- Status head: lifecycle badge, branch pair, priority and
-                 the lifecycle actions. -->
-            <section class="card">
+            <!-- Status head: lifecycle badge, branch pair with the
+                 daemon-reported push-state chips, priority, the
+                 assigned agent saddle and the lifecycle actions. -->
+            <section class="card goal-head">
                 <div class="card-head">
                     <h3>Status</h3>
                     <span class="badge badge-yeehaw status-corner"
@@ -85,7 +89,16 @@
                 <p><span stm-attribute.class="'badge badge-' + goal.status" stm-text="goal.display">status</span>
                     · goal branch <code stm-text="goal.branch_name">branch</code>
                     <span class="muted">from</span> <code stm-text="goal.base_branch">base</code>
-                    · priority <strong stm-text="goal.priority">3</strong></p>
+                    · priority <strong stm-text="goal.priority">3</strong>
+                    <span stm-if="goal.agent_saddle.length > 0"> · agent saddle
+                        <strong stm-text="goal.agent_saddle">saddle</strong></span></p>
+                <p stm-if="goal.push_branches.length > 0">Goal branches:
+                    <span stm-for-branch-in="goal.push_branches">
+                        <code stm-text="branch.branch">b</code>
+                        <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
+                              stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
+                    </span>
+                    <span class="muted">(the daemon's /status report)</span></p>
                 <p class="muted">Project:
                     <a stm-attribute.href="'/projects/' + goal.project_id" stm-text="goal.project_name">project</a>
                     · Created <span stm-text="goal.created_display">when</span>
@@ -110,11 +123,46 @@
                     task events queue and coalesce into the resume.</p>
             </section>
 
+            <!-- The agent's live progress feed (task-page pattern):
+                 the newest line leads, its history behind the
+                 expander. -->
+            <h3>Agent progress</h3>
+            <div stm-if="goal.progress.length > 0">
+                <p class="progress-latest" stm-text="goal.progress[0].summary">latest</p>
+                <p class="muted" stm-text="'reported ' + goal.progress[0].when_display">when</p>
+                <details class="history-expander" stm-if="goal.progress.length > 1">
+                    <summary>Show history (<span stm-text="goal.progress.length">0</span> updates)</summary>
+                    <ul class="progress-history">
+                        <li stm-for-item-in="goal.progress">
+                            <span class="muted" stm-text="item.when_display">when</span> —
+                            <span stm-text="item.summary">summary</span>
+                        </li>
+                    </ul>
+                </details>
+            </div>
+            <p stm-if="goal.progress.length == 0" class="muted">No progress reports yet — the goal
+                agent reports via <code>saddle progress-update</code>.</p>
+
+            <!-- Current activity (task-page parity): what the agent's
+                 open round is doing right now, plus its saddle's
+                 health. -->
+            <section class="card goal-activity" stm-if="goal.run_task_id > 0">
+                <h3>Current activity</h3>
+                <p stm-if="goal.current_command.length > 0">
+                    <span class="badge badge-working" stm-if="goal.current_command_running">running</span>
+                    <code stm-text="goal.current_command">command</code>
+                    <span class="muted" stm-text="goal.current_command_display">when</span></p>
+                <p stm-if="goal.current_command.length == 0" class="muted">No recorded activity in
+                    the current round — the raw terminal carries the live stream.</p>
+                <p stm-if="goal.saddle_health.length > 0" class="muted"
+                   stm-text="goal.saddle_health">health</p>
+            </section>
+
             <!-- The locked brief. -->
             <section class="card">
                 <h3>Initial brief
                     <span class="muted" stm-if="goal.started_display.length > 0">(locked)</span></h3>
-                <div class="rfc-body goal-body" data-brief-body stm-text="goal.brief">brief</div>
+                <div class="md rfc-body goal-body" data-brief-body stm-markdown="goal.brief">brief</div>
             </section>
 
             <!-- Involved projects. -->
@@ -144,21 +192,22 @@
             </section>
 
             <!-- The plan: the approved markdown; the pending review;
-                 rejected revisions behind the historical expander. -->
+                 the revision history (approved + rejected) behind the
+                 expander, task-page pattern. -->
             <section class="card">
                 <h3>Plan</h3>
-                <div stm-if="goal.plan.length > 0" class="rfc-body goal-body" data-plan-body
-                     stm-text="goal.plan">plan</div>
+                <div stm-if="goal.plan.length > 0" class="md rfc-body goal-body" data-plan-body
+                     stm-markdown="goal.plan">plan</div>
                 <p stm-if="goal.plan.length == 0 && goal.plan_pending_block == null"
                    class="muted">No approved plan yet.</p>
 
                 <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false" class="plan-review">
                     <h4>Plan submitted for review</h4>
-                    <div class="rfc-body goal-body" stm-text="goal.plan_pending_block.body">revision</div>
+                    <div class="md rfc-body goal-body" stm-markdown="goal.plan_pending_block.body">revision</div>
                     <form stm-action="goal.plan_pending_block.answer_action" class="inline">
                         <input type="hidden" name="answer"
                                value="Plan approved. Proceed with implementation.">
-                        <button type="submit" class="primary">Approve plan</button>
+                        <button type="submit" class="approve">Approve plan</button>
                     </form>
                     <details>
                         <summary>Reject with feedback</summary>
@@ -166,7 +215,7 @@
                             <div class="form-row">
                                 <textarea name="answer" rows="4" required
                                           placeholder="What must change before approval?"></textarea></div>
-                            <button type="submit">Send feedback</button>
+                            <button type="submit" class="reject">Send feedback</button>
                         </form>
                     </details>
                 </div>
@@ -175,10 +224,12 @@
                     <summary>Plan revisions &amp; feedback (<span
                             stm-text="goal.plan_revisions.length">0</span>)</summary>
                     <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
-                        <p class="muted" stm-text="'Revision #' + revision.id + ' — ' + revision.created_display">when</p>
+                        <p><span stm-attribute.class="'badge ' + (revision.approved ? 'badge-done' : (revision.answered ? 'badge-failed' : 'badge-plan'))"
+                                 stm-text="revision.approved ? 'approved' : (revision.answered ? 'rejected' : 'pending')">verdict</span>
+                            <span class="muted" stm-text="'Revision #' + revision.id + ' — ' + revision.created_display">when</span></p>
                         <details>
                             <summary>Submitted markdown</summary>
-                            <div class="rfc-body" stm-text="revision.body">body</div>
+                            <div class="md rfc-body" stm-markdown="revision.body">body</div>
                         </details>
                         <blockquote stm-if="revision.answered" class="term-comment-quote">
                             <span stm-text="revision.answered_by">who</span>:
@@ -187,7 +238,7 @@
                 </details>
             </section>
 
-            <!-- Completion review: the report, push state, compare link. -->
+            <!-- Completion review: the report, compare link. -->
             <section class="card" stm-if="goal.completion_pending">
                 <h3>Completion review</h3>
                 <p stm-if="goal.push_branches.length > 0">Goal branches:
@@ -195,15 +246,14 @@
                         <code stm-text="branch.branch">b</code>
                         <span stm-attribute.class="branch.pushed ? 'badge badge-done' : 'badge badge-failed'"
                               stm-text="branch.pushed ? 'pushed' : 'unpushed'">state</span>
-                    </span>
-                    <span class="muted">(reported by the daemon's /status)</span></p>
+                    </span></p>
                 <p><a stm-if="goal.compare_url.length > 0" stm-attribute.href="goal.compare_url"
                       target="_blank" rel="noopener">Compare changes</a></p>
-                <div class="rfc-body goal-body" stm-text="goal.completion_report">report</div>
+                <div class="md rfc-body goal-body" stm-markdown="goal.completion_report">report</div>
                 <form stm-action="goal.completion_pending_block.answer_action" class="inline">
                     <input type="hidden" name="answer"
                            value="Review approved. Continue with the task.">
-                    <button type="submit" class="primary">Accept goal</button>
+                    <button type="submit" class="approve">Accept goal</button>
                 </form>
                 <details>
                     <summary>Request changes</summary>
@@ -211,7 +261,7 @@
                         <div class="form-row">
                             <textarea name="answer" rows="4" required
                                       placeholder="What remains to be done?"></textarea></div>
-                        <button type="submit">Send feedback</button>
+                        <button type="submit" class="reject">Send feedback</button>
                     </form>
                 </details>
             </section>
@@ -260,7 +310,8 @@
             </section>
 
             <!-- The supervision narrative: one chronological feed of
-                 the goal agent's life. -->
+                 the goal agent's life (markdown bodies — reports land
+                 here whole). -->
             <section class="card">
                 <h3>Supervision narrative</h3>
                 <p stm-if="goal.events.length == 0" class="muted">Nothing yet — start the goal.</p>
@@ -268,13 +319,13 @@
                     <span class="muted" stm-text="event.at_display">when</span>
                     <span stm-attribute.class="'badge badge-goal-' + event.kind"
                           stm-text="event.kind.replace('-', ' ')">kind</span>
-                    <span stm-text="event.text">text</span>
+                    <div class="md goal-event-text" stm-markdown="event.text">text</div>
                     <a stm-if="event.task_id > 0"
                        stm-attribute.href="'/tasks/' + event.task_id">task</a>
                 </div>
             </section>
 
-            <!-- Comments with selection-quoting. -->
+            <!-- Comments with selection-quoting (markdown bodies). -->
             <section class="card">
                 <h3>Comments</h3>
                 <div stm-for-comment-in="goal.comments"
@@ -282,13 +333,13 @@
                     <strong stm-text="comment.author">author</strong>
                     <span class="muted" stm-text="comment.created_display">when</span>
                     <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
-                    <div stm-text="comment.body">body</div>
+                    <div class="md" stm-markdown="comment.body">body</div>
                 </div>
                 <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <form stm-action="goal.comment">
                     <div class="form-row"><label for="goal-comment-body">Add comment</label>
                         <textarea id="goal-comment-body" name="body" rows="3"
-                                  placeholder="A comment for the goal agent"></textarea></div>
+                                  placeholder="A comment for the goal agent (markdown)"></textarea></div>
                     <button type="submit" class="primary">Comment</button>
                 </form>
                 <dialog id="quote-comment-dialog">
@@ -306,8 +357,8 @@
                 </dialog>
             </section>
 
-            <!-- The goal agent's blocks (questions/RFCs) with their
-                 answer forms. -->
+            <!-- The goal agent's blocks (questions/RFCs) with full
+                 markdown bodies and their inline answer forms. -->
             <section class="card" stm-if="goal.blocks.length > 0">
                 <h3>Goal agent blocks</h3>
                 <div stm-for-block-in="goal.blocks" class="goal-block">
@@ -317,7 +368,7 @@
                             <strong stm-text="block.question">question</strong>
                             <span class="muted" stm-text="block.created_display">when</span></p>
                         <div stm-if="block.show_body && block.body.length > 0"
-                             class="rfc-body goal-body" stm-text="block.body">body</div>
+                             class="md rfc-body goal-body" stm-markdown="block.body">body</div>
                         <blockquote stm-if="block.answered" class="term-comment-quote">
                             <span stm-text="block.answered_by">who</span>:
                             <span stm-text="block.answer">answer</span></blockquote>
@@ -337,18 +388,22 @@
                 </div>
             </section>
 
-            <!-- Goal agent runs with terminal links (the session
-                 picker lives on the terminal page). -->
+            <!-- Goal agent runs: every round with its host saddle; the
+                 terminal links open in a new tab (the session picker
+                 lives on the terminal page). -->
             <section class="card" stm-if="goal.runs.length > 0">
                 <h3>Goal agent runs</h3>
-                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/terminal'">Terminal
+                <p><a stm-attribute.href="'/tasks/' + goal.run_task_id + '/terminal'"
+                      target="_blank" rel="noopener">Terminal
                         (session picker)</a> <span class="muted">— run task
                         #<span stm-text="goal.run_task_id">id</span></span></p>
                 <table>
-                    <thead><tr><th>Run</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
+                    <thead><tr><th>Run</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
                     <tbody>
                         <tr stm-for-run-in="goal.runs">
-                            <td><a stm-attribute.href="run.terminal_url" stm-text="'session ' + run.id">run</a></td>
+                            <td><a stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"
+                                   stm-text="'session ' + run.id">run</a></td>
+                            <td class="muted" stm-text="run.saddle_name">saddle</td>
                             <td class="muted" stm-text="run.started_display">when</td>
                             <td class="muted" stm-text="run.ended_display">when</td>
                             <td><span stm-if="run.open" class="badge badge-working">open</span>

+ 56 - 2
test/web_stack_test.py

@@ -2593,8 +2593,9 @@ def bundle_dispatch(ctx):
     detail = task_state(auth, flaky)
     check(detail["status"] == "queued" and (detail.get("summary") or "") == "",
           "retry requeues the task and clears the stale summary")
-    check(any("bash -n rejected" in c["body"] for c in detail["comments"]),
-          "failure explanation kept as an agent comment")
+    check(any("bash -n rejected" in c["body"] and c["author"] == "agent"
+              for c in detail["comments"]),
+          "failure explanation kept as an agent comment (author 'agent')")
     check(any(c["body"] == "Task retried by user" for c in detail["comments"]),
           "retry lands its audit comment")
     check(len(detail["comments"]) >= comments_before + 1,
@@ -5439,6 +5440,59 @@ def bundle_goals(ctx):
                    or False, timeout=60, what="goal agent re-parks") is not None,
           "the resumed goal agent parks again")
 
+    print("== feedback: goal page parity — progress feed, current activity, runs ==")
+    # The goal agent's runs feed the page's live surfaces: progress
+    # lines (green latest-first feed), the open round's newest
+    # activity line and its hosting saddle. Parked runs keep their
+    # open session, so fixtures land as rows without disturbing the
+    # scripted daemon.
+    conn = db()
+    open_run_session = conn.execute(
+        "select id from sessions where task_id = ? and ended_at is null",
+        (run_task,)).fetchone()[0]
+    conn.execute(
+        "insert into task_progress (task_id, summary, created_at)"
+        " values (?, 'Overseeing the flange fan-out', strftime('%s','now'))",
+        (run_task,))
+    conn.execute(
+        "insert into session_commands (session_id, command_text, type, started_at)"
+        " values (?, 'git -C demo log --oneline -5', 'command',"
+        " strftime('%s','now'))", (open_run_session,))
+    conn.commit()
+    conn.close()
+    # A manual refresh records the daemon's health snapshot (the
+    # parked daemon answers /status with its suspended state).
+    act(auth, saddle_card(auth, "fake-saddle")["refresh"], {})
+    state = goal_state(goal1)
+    check(state["progress"] and state["progress"][0]["summary"]
+          == "Overseeing the flange fan-out",
+          "the goal page carries the agent's live progress feed")
+    check(state["current_command"] == "git -C demo log --oneline -5"
+          and state["current_command_running"] is True,
+          "the current activity shows the open round's running command")
+    check(state["agent_saddle"] == "fake-saddle"
+          and "fake-saddle" in state["saddle_health"],
+          "the assigned agent saddle and its health display")
+    check(state["runs"] and state["runs"][0]["saddle_name"] == "fake-saddle"
+          and "session_id=" in state["runs"][0]["terminal_url"],
+          "the run table carries host saddles and deep-linked terminals")
+    goal_page = http_get_page(auth, "/goals/%d" % goal1)
+    check('stm-markdown="goal.brief"' in goal_page
+          and 'stm-markdown="goal.plan"' in goal_page
+          and 'stm-markdown="comment.body"' in goal_page
+          and 'stm-markdown="event.text"' in goal_page
+          and 'stm-markdown="block.body"' in goal_page,
+          "every long-form goal body renders through stm-markdown")
+    check('stm-attribute.href="run.terminal_url" target="_blank" rel="noopener"' in goal_page,
+          "the run table's terminal links open in a new tab")
+    check("progress-latest" in goal_page and "Agent progress" in goal_page,
+          "the green progress feed renders on the goal page")
+    # Plan revision history keeps its verdicts (approved + rejected).
+    revisions = goal_state(goal1)["plan_revisions"]
+    check(revisions[0]["answered"] is True and revisions[0]["approved"] is False
+          and revisions[1]["approved"] is True,
+          "plan revision history badges approved and rejected verdicts")
+
     print("== plan 12: pause freezes supervision; events coalesce on resume ==")
     state = goal_state(goal1)
     act(auth, state["pause"], {})