Эх сурвалжийг харах

Task detail: Act|Consult layout, anchor sub-nav, sticky log toolbar

Goal 41 T5 (ux-notes task detail A-D + quick win 6 + structural bets 1-2 half, event log A):

- task_detail.html: two-column Act|Consult layout (.with-sidebar
  .task-columns; Act = progress, blocks, completion report,
  status/lifecycle, edit, comments; Consult = mandate, goal,
  folded Configuration & history). Stacks Act-then-Consult under
  the framework breakpoint.
- Anchor sub-nav strip (Status & progress / Blocks / Comments /
  Configuration): smooth jumps that clear the sticky topbar,
  IntersectionObserver in-view highlighting, is-stuck stickiness.
  New client idiom: idempotent bind from statum.afterEvaluate
  (data-subnav=bound flag) so pulse re-renders never stack
  listeners; publishes --topbar-bottom for CSS. T6 copies this.
- Edit task expander replaced by the in-place editor card
  (data-long-form pattern, ux-notes expander-fatigue rule 3).
- Rules + capabilities + sessions cluster into ONE collapsed
  Configuration & history region with a count summary.
- Breadcrumbs (T1 partial): TaskDetailEntrypoint publishes the
  crumbs vector (Tasks / project / #id title); the hand-drawn
  All-tasks context line is gone.
- Event log link promoted top-level beside Progress (per-task,
  not per-session; ux-notes task detail D).
- task_log.html: one context header (back chevron + task title +
  live badge), sticky toolbar inside the shell, jump-to-latest
  button riding the feed's existing at-bottom state (bind-once
  guards; filter/search behaviour unchanged).
- app.css: appended goal-41 T5 section (columns, subnav, sticky
  toolbar, log-jump pill) - all --spry-* tokens.

Tests: pages.py/resurrect.py assertions updated to the new
markup (Act|Consult order, Edit card, crumb vector); all owning
bundles green; test_task_log_js.py untouched and green.
saddle-ClankerSaddle 1 өдөр өмнө
parent
commit
59dc81e95e

+ 28 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -60,6 +60,17 @@ namespace ClankerSaddle {
         public string status { get; set; default = ""; }
     }
 
+    /**
+     * One breadcrumb crumb (goal 41 T5): the detail pages render the
+     * shared breadcrumb partial from a vector of these. {@link href}
+     * is null for the trailing (current page) crumb — the partial
+     * gives it aria-current and muted styling.
+     */
+    public class CrumbView : Object {
+        public string? href { get; set; }
+        public string label { get; set; default = ""; }
+    }
+
     /** A rule attached to the task, with its detach action. */
     public class TaskRuleRowView : Object {
         public int64 link_id { get; set; }
@@ -482,6 +493,23 @@ namespace ClankerSaddle {
             props["capabilities"] = StateJson.list<TaskCapabilityRowView>(task_capabilities.to_immutable_buffer());
             props["capability_options"] = StateJson.list<CapabilityOptionView>(capability_options.to_immutable_buffer());
             props["sessions"] = StateJson.list<SessionView>(sessions.to_immutable_buffer());
+            // Goal 41 T5: the breadcrumb trail (Tasks -> #id title),
+            // replacing the hand-drawn "All tasks" context line. The
+            // project and enqueue context ride the trail: a
+            // project-owned task nests under its project, a standalone
+            // one sits directly under Tasks.
+            var crumbs = new Vector<CrumbView>();
+            crumbs.add(new CrumbView() { href = "/tasks", label = "Tasks" });
+            if (task.project_id > 0 && project != null) {
+                crumbs.add(new CrumbView() {
+                    href = @"/projects/$(task.project_id)",
+                    label = ((!)project).name
+                });
+            }
+            crumbs.add(new CrumbView() {
+                label = @"#$(task.id) $(task.title)"
+            });
+            props["crumbs"] = StateJson.list<CrumbView>(crumbs.to_immutable_buffer());
             return slot_for(state_service, pub, props);
         }
 

+ 100 - 0
src/pages/app.css

@@ -1387,3 +1387,103 @@ td .truncate.repo-url {
     display: block;
     max-width: 22rem;
 }
+
+/* ==========================================================================
+   Goal 41 T5 — task detail Act|Consult layout, anchor sub-nav, sticky
+   log toolbar (append-only section per the spry-conversion contract;
+   every value consumes a --spry-* token so themes and auto dark mode
+   need zero extra rules).
+   ========================================================================== */
+
+/* The Act|Consult split: Spry's .with-sidebar gives the rail a
+   minmax(11rem, 16rem) FIRST column; the task page wants the acting
+   column WIDE (the blocks and comments are the page's work) and the
+   consult rail narrower. Same override shape as T3's .dash-board. */
+.task-columns {
+    grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem);
+}
+
+/* app.css loads after spry.css, so the re-proportioned columns above
+   would out-rank the framework's own 55rem stacking rule inside its
+   @media block (media queries add no specificity). Restore the stack
+   here so Consult drops under Act on narrow screens — Act leads. */
+@media (max-width: 55rem) {
+    .task-columns { grid-template-columns: minmax(0, 1fr); }
+}
+
+/* The anchor sub-nav strip: the page's map. Horizontally scrollable
+   on narrow screens (the links never wrap into a second row), sticky
+   under the topbar once the page scrolls past it — the .is-stuck
+   class (toggled by the page script) adds the opaque surface and
+   edge the plain strip must not have, so an unstuck strip stays
+   quiet page furniture. */
+.subnav {
+    display: flex;
+    gap: var(--spry-space-1);
+    margin: var(--spry-space-3) 0;
+    overflow-x: auto;
+    padding: var(--spry-space-1) 0;
+    scrollbar-width: none;
+}
+.subnav::-webkit-scrollbar { display: none; }
+.subnav a {
+    border: var(--spry-border-width) solid transparent;
+    border-radius: var(--spry-radius-pill);
+    color: var(--spry-fg-muted);
+    font-size: 0.88rem;
+    padding: 0.18rem 0.75rem;
+    text-decoration: none;
+    white-space: nowrap;
+}
+.subnav a:hover {
+    background: var(--spry-bg-alt);
+    color: var(--spry-fg);
+}
+.subnav a.active {
+    background: var(--spry-accent-soft);
+    border-color: var(--spry-accent);
+    color: var(--spry-accent);
+}
+.subnav.is-stuck {
+    background: var(--spry-surface);
+    border-bottom: var(--spry-border-width) solid var(--spry-border);
+    box-shadow: var(--spry-shadow);
+    margin: 0 0 var(--spry-space-3);
+    position: sticky;
+    top: var(--topbar-bottom, 3.1rem);
+    z-index: 10;
+}
+
+/* Anchor targets clear the sticky chrome (the strip + topbar) so a
+   sub-nav jump lands the heading below them, not behind. */
+[data-subnav-section] { scroll-margin-top: 6.5rem; }
+
+/* The config cluster's summary count line: the numbers read at a
+   glance while the region stays collapsed (ux-notes task detail C). */
+.config-history summary { cursor: pointer; }
+.config-history h4 {
+    font-size: 1rem;
+    margin-bottom: var(--spry-space-1);
+}
+
+/* The log page's sticky toolbar (ux-notes event log A): the shell is
+   a plain block; making it a column flex keeps the toolbar glued to
+   the shell's top edge while the FEED keeps its own internal scroll
+   (its max-height contract from the log engine is untouched). The
+   jump-to-latest button inherits the filter pill language. */
+.log-shell { display: flex; flex-direction: column; }
+.log-toolbar {
+    position: sticky;
+    top: var(--topbar-bottom, 3.1rem);
+    z-index: 5;
+}
+.log-jump {
+    background: var(--spry-accent-soft);
+    border: var(--spry-border-width) solid var(--spry-accent);
+    border-radius: var(--spry-radius-pill);
+    color: var(--spry-accent);
+    font-size: 0.8rem;
+    padding: 0.25rem 0.8rem;
+    white-space: nowrap;
+}
+.log-jump:hover { background: var(--spry-accent); color: var(--spry-accent-fg); }

+ 542 - 333
src/pages/task_detail.html

@@ -9,10 +9,141 @@
     // the app, the sealed refresh action rehydrates the task slot -
     // no fixed-interval polling. RFC bodies get the shared
     // text-highlight -> comment popover (see main.html).
+    //
+    // Goal 41 T5: the anchor sub-nav. A small client-side idiom for
+    // server-rendered statum pages: the strip's links are plain
+    // anchors into the page's [data-subnav-section] regions, and this
+    // script owns (a) smooth in-page jumps that clear the sticky
+    // topbar's height, (b) an IntersectionObserver that highlights
+    // whichever region the reader is actually in, and (c) the strip's
+    // own stuck class once the page scrolls it under the topbar. The
+    // statum render cycle never rebuilds static markup (only stm-for
+    // clones), but the strip rebinds from statum.afterEvaluate anyway
+    // - the idiom must survive a pulse refresh for free, so binding
+    // is idempotent: a second bind finds the strip already flagged
+    // and does nothing (no stacked listeners). T6 (goal detail) and
+    // later detail pages copy this whole block.
     if (window.statum) {
         (function () {
             'use strict';
 
+            function subnavStrip() {
+                return document.querySelector('[data-subnav]');
+            }
+
+            function subnavSections() {
+                return Array.prototype.slice.call(
+                    document.querySelectorAll('[data-subnav-section]'));
+            }
+
+            // The strip parks under the sticky topbar; publish the
+            // measured topbar height as a custom property so app.css's
+            // `top:` tracks any wrap/viewport (the log page's sticky
+            // toolbar consumes the same property from task_log.js).
+            function syncTopbarOffset() {
+                var bar = document.querySelector('.topbar');
+                if (bar && bar.offsetHeight > 0) {
+                    document.documentElement.style.setProperty(
+                        '--topbar-bottom', bar.offsetHeight + 'px');
+                }
+            }
+
+            // How much sticky chrome sits above a scrolled-to heading:
+            // the topbar plus the strip itself (so the heading the
+            // reader jumped to is not swallowed by either).
+            function subnavOffset() {
+                var height = 0;
+                var bar = document.querySelector('.topbar');
+                if (bar) height += bar.offsetHeight;
+                var strip = subnavStrip();
+                if (strip) height += strip.offsetHeight + 8;
+                return height;
+            }
+
+            function highlightSection(id) {
+                var strip = subnavStrip();
+                if (!strip) return;
+                var links = strip.querySelectorAll('a[href^="#"]');
+                for (var i = 0; i < links.length; i++) {
+                    var active = links[i].getAttribute('href') === '#' + id;
+                    links[i].classList.toggle('active', active);
+                    if (active) {
+                        links[i].setAttribute('aria-current', 'true');
+                    } else {
+                        links[i].removeAttribute('aria-current');
+                    }
+                }
+            }
+
+            function bindSubnav() {
+                var strip = subnavStrip();
+                if (!strip) return;
+                if (strip.getAttribute('data-subnav') === 'bound') {
+                    return; // afterEvaluate rebind: already live
+                }
+                strip.setAttribute('data-subnav', 'bound');
+
+                // Smooth jumps that stop below the sticky chrome.
+                strip.addEventListener('click', function (event) {
+                    var anchor = event.target.closest
+                        ? event.target.closest('a[href^="#"]') : null;
+                    if (!anchor) return;
+                    var region = document.querySelector(
+                        anchor.getAttribute('href'));
+                    if (!region) return;
+                    event.preventDefault();
+                    window.scrollTo({
+                        top: region.getBoundingClientRect().top
+                            + window.pageYOffset - subnavOffset(),
+                        behavior: 'smooth'
+                    });
+                    if (history.replaceState) {
+                        history.replaceState(null, '',
+                            anchor.getAttribute('href'));
+                    }
+                });
+
+                // Highlight the region the reader is in. The observer
+                // keeps every section under watch; a region wins the
+                // highlight when it is intersecting AND its top sits
+                // at or just below the sticky chrome - i.e. the LAST
+                // heading that crossed the line, which a long region
+                // keeps until the next heading approaches it.
+                var observer = new IntersectionObserver(
+                    function (entries) {
+                        entries.forEach(function (entry) {
+                            var region = entry.target;
+                            if (entry.isIntersecting
+                                    && region.getBoundingClientRect().top
+                                        <= subnavOffset() + 32) {
+                                highlightSection(
+                                    region.getAttribute('data-subnav-section'));
+                            }
+                        });
+                    },
+                    { rootMargin: '0px 0px -70% 0px', threshold: 0 });
+                subnavSections().forEach(function (region) {
+                    observer.observe(region);
+                });
+
+                // The strip sticks once the page scrolls it under the
+                // topbar: the class carries the opaque background and
+                // edge the un-stuck strip must not have.
+                var stuck = false;
+                var onScroll = function () {
+                    var bar = document.querySelector('.topbar');
+                    syncTopbarOffset();
+                    var barBottom = bar ? bar.getBoundingClientRect().bottom : 0;
+                    var now = strip.getBoundingClientRect().top <= barBottom;
+                    if (now !== stuck) {
+                        stuck = now;
+                        strip.classList.toggle('is-stuck', stuck);
+                    }
+                };
+                window.addEventListener('scroll', onScroll, { passive: true });
+                onScroll();
+            }
+
             function refreshTask(source) {
                 var task = statum.read('task');
                 if (!task || !task.refresh) return;
@@ -52,6 +183,11 @@
             if (window.initLongFormEditors) {
                 window.initLongFormEditors();
             }
+
+            statum.afterEvaluate(function () {
+                bindSubnav();
+            });
+            bindSubnav();
         })();
     }
 </script>
@@ -63,350 +199,423 @@
         <p stm-if="task != null && !task.exists" class="text-muted">Task not found.</p>
 
         <div stm-if="task != null && task.exists">
-            <!-- Goal 8 task 4: a standalone task (no project) shows
-                 "(standalone)" instead of a project link — its
-                 workspace holds no clones. -->
-            <p stm-if="task.project_id > 0"><a href="/tasks">All tasks</a> · project
-                <a stm-attribute.href="'/projects/' + task.project_id" stm-text="task.project_name">project</a>
-                <span stm-if="task.enqueue_after_task_id > 0"> · auto-enqueues after
-                    <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
-                       stm-text="'#' + task.enqueue_after_task_id">after</a></span></p>
-            <p stm-if="task.project_id == 0"><a href="/tasks">All tasks</a> ·
-                <strong>(standalone)</strong>
-                <span class="text-muted">— no project: the agent works in its own ephemeral
-                    workspace and delivers its completion report</span></p>
-
-            <!-- Progress feed ABOVE the status card (plan 11 task D):
-                 the agent's newest line leads the page, its history
-                 behind the "Show history" expander. -->
-            <h3>Progress</h3>
-            <pstm-fragment name="progress-feed" entries="task.progress"/>
-            <p stm-if="task.progress.length == 0" class="text-muted">No progress reports yet — the agent reports via
-                <code>saddle progress-update</code>.</p>
-
-            <!-- Status + description side by side in a 50/50 grid (plan
-                 11 task D), collapsing to stacked
-                 status-then-description on small screens. The
-                 lifecycle buttons ride the status card (plan 11): new
-                 tasks are drafts — Enqueue and Delete in draft (delete
-                 guarded by dependents), Cancel in non-draft
-                 non-terminal; completion is agent-only. -->
-            <div class="grid-2">
-                <section class="card status-head">
-                    <h3>Status</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
-                        <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
-                        · priority <strong stm-text="task.priority">5</strong></p>
-                    <p class="text-muted" stm-if="task.enqueue_after_task_id > 0">Auto-enqueues after
-                        <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
-                           stm-text="'#' + task.enqueue_after_task_id + ' ' + task.enqueue_after_title">after</a></p>
-                    <p class="text-muted">Created <span stm-text="task.created_display">when</span></p>
-                    <p class="text-muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
-                    <p class="text-muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
-                    <!-- S5: the aggregate LLM usage stat line (empty for
-                         zero-usage tasks renders nothing). -->
-                    <p class="text-muted" stm-if="task.usage_line.length > 0"
-                       stm-text="task.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
-                    <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
-                        <span class="text-muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
-                    <p stm-if="task.status == 'suspended'" class="text-muted">Parked (`saddle suspend`) —
-                        the goal agent resumes on its goal's task events, with the
-                        batched status report riding the resume.</p>
-                    <form class="cluster">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <button type="button" stm-if="task.draft" class="btn-primary"
-                                stm-action="task.enqueue">Enqueue task</button>
-                        <button type="button" stm-if="task.draft"
-                                stm-action="task.delete"
-                                stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
-                        <button type="button" stm-if="task.status == 'failed'" class="btn-primary"
-                                stm-action="task.retry">Retry task</button>
-                        <button type="button" stm-if="task.resume_available"
-                                stm-action="task.resume"
-                                stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
-                        <!-- Goal 4 task 1: Force wake — the parked-task
-                             recovery (a goal run routes to the goal's
-                             wake). -->
-                        <button type="button" stm-if="task.force_wake != null"
-                                stm-action="task.force_wake"
-                                stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
-                        <button type="button" stm-if="!task.draft && !task.terminal"
-                                stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
-                    </form>
-                    <form class="cluster">
-                        <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                        <button type="button" stm-action="task.toggle_notify"
-                                stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
-                        <span class="text-muted">— this task&#39;s first start, failure and completion
-                            (questions and reviews always notify subscribed users)</span>
-                    </form>
-                </section>
-
-                <!-- Plan 12: the goal reference — a goal task or the
-                     goal agent's own run links back to its goal. -->
-                <section class="card" stm-if="task.goal_id > 0">
-                    <h3>Goal</h3>
-                    <p><span stm-attribute.class="'badge badge-' + task.goal_status"
-                             stm-text="task.goal_status.replace('_', ' ')">status</span>
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a></p>
-                    <p class="text-muted">Goal branch <code stm-text="task.goal_branch">branch</code>
-                        <span stm-if="task.is_goal_run">— this task IS the goal agent's run
-                            (orchestrate mode; the goal page owns its lifecycle)</span>
-                        <span stm-if="!task.is_goal_run">— this task runs under the goal agent's
-                            supervision on an ephemeral branch</span></p>
-                </section>
-
-                <!-- Plan 12: the mandate — the plan-11 "Description"
-                     card, framed as the binding work order. Task 12:
-                     drafts swap the rendered markdown for an in-place
-                     editor (initLongFormEditors in main.html) — the
-                     expander no longer carries the long-form field,
-                     and non-draft/terminal tasks keep the plain read
-                     view. -->
-                <section class="card" data-long-form data-field="task"
-                         data-lf-open="0">
-                    <h3>Mandate
-                        <button type="button" class="cluster" stm-if="task.editable_all"
-                                data-lf-toggle>Edit</button></h3>
-                    <div data-lf-view>
-                        <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
-                        <p class="text-muted" stm-if="task.description.length == 0">No mandate.</p>
-                    </div>
-                    <!-- The stm-if wrapper (not the form itself) owns
-                         draft-gating: statum toggles `hidden` on
-                         stm-if branches, so the form inside keeps its
-                         visibility JS-owned (initLongFormEditors). -->
-                    <div stm-if="task.editable_all">
-                        <form data-lf-edit hidden stm-action="task.update">
+            <!-- Goal 41 T5: breadcrumbs replace the hand-drawn "All
+                 tasks" context line (the partial is T1's; the crumbs
+                 vector is the entrypoint's). The project / enqueue
+                 context now rides the crumb chain itself. -->
+            <pstm-fragment name="breadcrumb" crumbs="task.crumbs"/>
+
+            <!-- Goal 41 T5: the anchor sub-nav - the page's map. Pure
+                 anchors into the [data-subnav-section] regions below;
+                 the page script owns jumps, in-view highlighting and
+                 the strip's stickiness. The open-block count rides
+                 the Blocks link so the page's verb announces itself
+                 in the map. -->
+            <nav class="subnav" aria-label="Sections" data-subnav>
+                <a href="#task-progress">Status &amp; progress</a>
+                <a href="#task-blocks">Blocks</a>
+                <a href="#task-comments">Comments</a>
+                <a href="#task-config">Configuration</a>
+            </nav>
+
+            <!-- Goal 41 T5: the Act|Consult split (ux-notes task
+                 detail A + structural bet 1). Left column: everything
+                 a human ACTS on - progress, blocks (the page's verb),
+                 the completion report, the status/lifecycle surface,
+                 the comment composer. Right column: everything a
+                 human CONSULTS - mandate, goal reference, the folded
+                 configuration + session history. Stacks
+                 Act-then-Consult under the framework breakpoint
+                 (app.css .task-columns mirrors the dashboard's
+                 .dash-board precedent). -->
+            <div class="with-sidebar task-columns">
+                <!-- ================= ACT ================= -->
+                <div class="stack task-col-act">
+                    <section data-subnav-section="task-progress">
+                        <!-- Progress feed leads the page (plan 11 task
+                             D): the agent's newest line first, its
+                             history behind the "Show history"
+                             expander. ux-notes D: the event log link
+                             is per-task, not per-session - it rides
+                             here at top level instead of a Sessions
+                             table row. -->
+                        <div class="cluster cluster-between">
+                            <h3>Progress</h3>
+                            <a stm-attribute.href="'/tasks/' + task.id + '/log'"
+                               target="_blank" rel="noopener">Event log</a>
+                        </div>
+                        <pstm-fragment name="progress-feed" entries="task.progress"/>
+                        <p stm-if="task.progress.length == 0" class="text-muted">No progress reports yet — the agent reports via
+                            <code>saddle progress-update</code>.</p>
+                    </section>
+
+                    <!-- Current blocks render in full (plan 11 task D);
+                         answered ones fold behind the history expander.
+                         This is the page's VERB: it leads the acting
+                         column, above every reference surface. -->
+                    <section data-subnav-section="task-blocks">
+                        <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
+                        <div stm-for-block-in="task.open_blocks" stm-key="block.id"
+                             stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
+                            <div stm-if="!block.answered && block.block_type == 'review'" class="review-banner">
+                                <h3>Review requested</h3>
+                                <div class="md" stm-markdown="block.body.length > 0 ? block.body : block.question">summary</div>
+                                <p>Branch <strong stm-text="task.task_branch">branch</strong>
+                                    <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
+                                       target="_blank" rel="noopener">· compare changes</a></p>
+                                <div class="actions">
+                                    <form stm-action="block.answer_action" class="cluster">
+                                        <input type="hidden" name="verdict" value="accept">
+                                        <button type="submit" class="btn-primary">Approve</button>
+                                    </form>
+                                    <form stm-action="block.answer_action" class="cluster">
+                                        <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
+                                        <button type="submit" class="btn-danger">Request changes</button>
+                                    </form>
+                                </div>
+                            </div>
+
+                            <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
+                                <span class="text-muted" stm-text="block.created_display">when</span></p>
+
+                            <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
+                                 stm-markdown="block.body">body</div>
+
+                            <div stm-if="block.answered">
+                                <p>Answer: <strong stm-text="block.answer">answer</strong>
+                                    <span class="text-muted" stm-text="block.answered_display">when</span>
+                                    <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                            </div>
+
+                            <pstm-fragment name="block-answer"/>
+                            <pstm-fragment name="block-reply"/>
+                        </div>
+                        <p stm-if="task.open_blocks.length == 0 && task.answered_blocks.length == 0" class="text-muted">No blocks — the agent has no open questions.</p>
+
+                        <!-- Historical (answered) blocks behind an expander, the
+                             current ones in full above (plan 11 task D). -->
+                        <details class="blocks-history" stm-if="task.answered_blocks.length > 0">
+                            <summary>Answered blocks (<span stm-text="task.answered_blocks.length">0</span>)</summary>
+                            <div stm-for-block-in="task.answered_blocks" stm-key="block.id"
+                                 stm-attribute.class="'block-card answered ' + block.block_type">
+                                <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
+                                    <span stm-text="block.question">question</span>
+                                    <span class="text-muted" stm-text="block.created_display">when</span></p>
+                                <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
+                                     stm-markdown="block.body">body</div>
+                                <p>Answer: <strong stm-text="block.answer">answer</strong>
+                                    <span class="text-muted" stm-text="block.answered_display">when</span>
+                                    <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                            </div>
+                        </details>
+                    </section>
+
+                    <!-- The heredoc completion report (`cat << 'EOF' | saddle
+                         done`): terminal tasks render tasks.summary as
+                         markdown (XSS-safe via stm-markdown) in this
+                         card - "Completion report" when done, "Failure
+                         summary" when failed; an empty summary renders
+                         no card. -->
+                    <section class="card completion-report"
+                             stm-if="task.terminal && task.summary.length > 0">
+                        <h3 stm-text="task.report_title">Completion report</h3>
+                        <div class="md" stm-markdown="task.summary">summary</div>
+                    </section>
+
+                    <!-- Status + lifecycle: the acting column's anchor.
+                         The lifecycle buttons ride the status card (plan
+                         11): new tasks are drafts - Enqueue and Delete in
+                         draft (delete guarded by dependents), Cancel in
+                         non-draft non-terminal; completion is agent-only.
+                         The old 50/50 grid-2 paired status with the
+                         mandate; the Act|Consult split replaces it - the
+                         mandate reads in the consult column. -->
+                    <section class="card status-head">
+                        <h3>Status</h3>
+                        <p><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span>
+                            <span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span>
+                            · priority <strong stm-text="task.priority">5</strong></p>
+                        <p class="text-muted" stm-if="task.enqueue_after_task_id > 0">Auto-enqueues after
+                            <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
+                               stm-text="'#' + task.enqueue_after_task_id + ' ' + task.enqueue_after_title">after</a></p>
+                        <p class="text-muted">Created <span stm-text="task.created_display">when</span></p>
+                        <p class="text-muted" stm-if="task.started_display.length > 0">Started <span stm-text="task.started_display">when</span></p>
+                        <p class="text-muted" stm-if="task.completed_display.length > 0">Completed <span stm-text="task.completed_display">when</span></p>
+                        <!-- S5: the aggregate LLM usage stat line (empty for
+                             zero-usage tasks renders nothing). -->
+                        <p class="text-muted" stm-if="task.usage_line.length > 0"
+                           stm-text="task.usage_line">LLM: 1,234 calls · 45.2M in / 1.1M out tok · 3h 12m</p>
+                        <p stm-if="task.task_branch.length > 0">Branch: <strong stm-text="task.task_branch">branch</strong>
+                            <span class="text-muted" stm-if="task.branch_from.length > 0"> from <span stm-text="task.branch_from">base</span></span></p>
+                        <p stm-if="task.status == 'suspended'" class="text-muted">Parked (`saddle suspend`) —
+                            the goal agent resumes on its goal's task events, with the
+                            batched status report riding the resume.</p>
+                        <form class="cluster">
                             <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                            <div class="field"><label>Mandate</label>
-                                <textarea name="description" rows="8"
-                                          data-lf-src="task.description">description</textarea></div>
-                            <button type="submit">Save</button>
-                            <button type="button" data-lf-toggle>Cancel</button>
+                            <button type="button" stm-if="task.draft" class="btn-primary"
+                                    stm-action="task.enqueue">Enqueue task</button>
+                            <button type="button" stm-if="task.draft"
+                                    stm-action="task.delete"
+                                    stm-confirm="Delete this draft task? This cannot be undone.">Delete</button>
+                            <button type="button" stm-if="task.status == 'failed'" class="btn-primary"
+                                    stm-action="task.retry">Retry task</button>
+                            <button type="button" stm-if="task.resume_available"
+                                    stm-action="task.resume"
+                                    stm-confirm="Requeue this task? The daemon restores its workspace context on the next dispatch.">Requeue to resume</button>
+                            <!-- Goal 4 task 1: Force wake — the parked-task
+                                 recovery (a goal run routes to the goal's
+                                 wake). -->
+                            <button type="button" stm-if="task.force_wake != null"
+                                    stm-action="task.force_wake"
+                                    stm-confirm="Force-woke this parked task? Its agent resumes immediately on its saddle - one wake per press.">Force wake</button>
+                            <button type="button" stm-if="!task.draft && !task.terminal"
+                                    stm-action="task.cancel" stm-confirm="Cancel this task?">Cancel task</button>
                         </form>
-                    </div>
-                </section>
-            </div>
+                        <form class="cluster">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <button type="button" stm-action="task.toggle_notify"
+                                    stm-text="task.notify_me ? 'Don&#39;t notify me' : 'Notify me'">Notify me</button>
+                            <span class="text-muted">— this task&#39;s first start, failure and completion
+                                (questions and reviews always notify subscribed users)</span>
+                        </form>
+                    </section>
 
-            <!-- The heredoc completion report (`cat << 'EOF' | saddle
-                 done`): terminal tasks render tasks.summary as
-                 markdown (XSS-safe via stm-markdown) in this
-                 full-width card directly below the status/description
-                 grid — "Completion report" when done, "Failure
-                 summary" when failed; an empty summary renders no
-                 card. -->
-            <section class="card completion-report"
-                     stm-if="task.terminal && task.summary.length > 0">
-                <h3 stm-text="task.report_title">Completion report</h3>
-                <div class="md" stm-markdown="task.summary">summary</div>
-            </section>
-
-            <!-- The edit expander sits UNDER the "Show history" expander
-                 (plan 11 task D): draft tasks edit every field except
-                 status; non-draft non-terminal tasks edit priority
-                 only; terminal tasks edit nothing. Task 12: the
-                 long-form mandate moved to its own in-place editor on
-                 the mandate card — the expander keeps the short
-                 properties only. -->
-            <details class="edit-expander" stm-if="task.editable_all || task.editable_priority">
-                <summary><strong>Edit task</strong>
-                    <span class="text-muted" stm-if="task.editable_all">— every field except status while in draft</span>
-                    <span class="text-muted" stm-if="!task.editable_all && task.editable_priority">— priority only once work has begun</span></summary>
-                <form stm-action="task.update" stm-if="task.editable_all">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <div class="field"><label>Title</label>
-                        <input type="text" name="title" stm-attribute.value="task.title"></div>
-                    <div class="field" stm-if="!task.is_goal_run"><label>Mode</label>
-                        <select name="mode" stm-attribute.value="task.mode">
-                            <option value="implement">implement — build the change on a task branch</option>
-                            <option value="investigate">investigate — explore and report, no code changes</option>
-                            <option value="review">review — read and critique existing work</option>
-                            <option value="test">test — write and run tests</option>
-                        </select></div>
-                    <div class="field" stm-if="task.creator == 'human' && !task.is_goal_run"><label>Auto-enqueue after
-                            <span class="text-muted">(enqueues itself when the predecessor is done; none clears it)</span></label>
-                        <select name="enqueue_after_task_id">
-                            <option value="">none</option>
-                            <option stm-for-option-in="task.enqueue_after_options" stm-attribute.value="option.id"
-                                     stm-text="'#' + option.id + ' ' + option.title">task</option>
-                        </select></div>
-                    <div class="field"><label>Priority (1–9)</label>
-                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
-                    <div class="field"><label>Task branch</label>
-                        <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
-                    <div class="field"><label>Branch from</label>
-                        <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
-                    <button type="submit">Save</button>
-                </form>
-                <form stm-action="task.update" stm-if="!task.editable_all && task.editable_priority">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <div class="field"><label>Priority (1–9)</label>
-                        <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
-                    <button type="submit">Save</button>
-                </form>
-            </details>
-
-<!-- Current blocks render in full (plan 11 task D);
-                 answered ones fold behind the history expander. -->
-            <h3>Blocks <span class="text-muted" stm-if="task.open_blocks.length > 0">(<span stm-text="task.open_blocks.length">0</span> open)</span></h3>
-            <div stm-for-block-in="task.open_blocks" stm-key="block.id"
-                 stm-attribute.class="'block-card ' + block.block_type + (block.answered ? ' answered' : '')">
-                <div stm-if="!block.answered && block.block_type == 'review'" class="review-banner">
-                    <h3>Review requested</h3>
-                    <div class="md" stm-markdown="block.body.length > 0 ? block.body : block.question">summary</div>
-                    <p>Branch <strong stm-text="task.task_branch">branch</strong>
-                        <a stm-if="task.review_diff_url.length > 0" stm-attribute.href="task.review_diff_url"
-                           target="_blank" rel="noopener">· compare changes</a></p>
-                     <div class="actions">
-                         <form stm-action="block.answer_action" class="cluster">
-                             <input type="hidden" name="verdict" value="accept">
-                             <button type="submit" class="btn-primary">Approve</button>
-                         </form>
-                        <form stm-action="block.answer_action" class="cluster">
-                            <textarea name="feedback" rows="2" placeholder="What should change?"></textarea>
-                            <button type="submit" class="btn-danger">Request changes</button>
+                    <!-- The short-property in-place editor (goal 41 T5):
+                         the edit surface adopts the data-long-form
+                         in-place pattern (ux-notes expander-fatigue
+                         rule 3: short forms prefer in-place - a read
+                         view + Edit toggle shows the current values
+                         instead of a cold form behind an expander).
+                         Drafts edit every field except status; active
+                         tasks edit priority only; terminal tasks edit
+                         nothing. The mandate keeps its own in-place
+                         editor on the mandate card (task 12). -->
+                    <section class="card" data-long-form data-field="task"
+                             data-lf-open="0"
+                             stm-if="task.editable_all || task.editable_priority">
+                        <h3>Edit task
+                            <button type="button" class="btn-ghost btn-sm" data-lf-toggle>Edit</button></h3>
+                        <div data-lf-view>
+                            <p class="text-muted" stm-if="task.editable_all">Draft — every field except status is editable.</p>
+                            <p class="text-muted" stm-if="!task.editable_all && task.editable_priority">Work has begun — priority only.</p>
+                        </div>
+                        <div stm-if="task.editable_all">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Title</label>
+                                    <input type="text" name="title" stm-attribute.value="task.title"></div>
+                                <div class="field" stm-if="!task.is_goal_run"><label>Mode</label>
+                                    <select name="mode" stm-attribute.value="task.mode">
+                                        <option value="implement">implement — build the change on a task branch</option>
+                                        <option value="investigate">investigate — explore and report, no code changes</option>
+                                        <option value="review">review — read and critique existing work</option>
+                                        <option value="test">test — write and run tests</option>
+                                    </select></div>
+                                <div class="field" stm-if="task.creator == 'human' && !task.is_goal_run"><label>Auto-enqueue after
+                                        <span class="text-muted">(enqueues itself when the predecessor is done; none clears it)</span></label>
+                                    <select name="enqueue_after_task_id">
+                                        <option value="">none</option>
+                                        <option stm-for-option-in="task.enqueue_after_options" stm-attribute.value="option.id"
+                                                 stm-text="'#' + option.id + ' ' + option.title">task</option>
+                                    </select></div>
+                                <div class="field"><label>Priority (1–9)</label>
+                                    <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                                <div class="field"><label>Task branch</label>
+                                    <input type="text" name="task_branch" stm-attribute.value="task.task_branch" placeholder="task/<id>-<slug>"></div>
+                                <div class="field"><label>Branch from</label>
+                                    <input type="text" name="branch_from" stm-attribute.value="task.branch_from"></div>
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                        <div stm-if="!task.editable_all && task.editable_priority">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Priority (1–9)</label>
+                                    <input type="number" name="priority" min="1" max="9" stm-attribute.value="task.priority"></div>
+                                <button type="submit" class="btn-primary">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                    </section>
+
+                    <section data-subnav-section="task-comments">
+                        <h3>Comments</h3>
+                        <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
+                             requeues, retries, session restorations) renders as a
+                             distinct muted SYSTEM row — never an agent comment. -->
+                        <div stm-for-comment-in="task.comments" stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
+                            <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
+                            <span stm-if="comment.is_system" class="system-label">system</span>
+                            <span class="text-muted" stm-text="comment.when_display">when</span>
+                            <div class="md" stm-markdown="comment.body">body</div>
+                            <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
+                        </div>
+                        <p stm-if="task.comments.length == 0" class="text-muted">No comments yet.</p>
+                        <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
+                             A terminal NON-GOAL task resurrects on comment (it
+                             re-queues with the comment injected); a terminal
+                             goal-owned worker keeps its goal agent as the only
+                             voice — the hint says so and the service refuses. -->
+                        <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
+                            <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                            <div class="field"><label for="comment-body">Add comment</label>
+                                <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
+                            <p stm-if="task.terminal && task.goal_id == 0" class="text-muted">
+                                This task is terminal — commenting re-queues it for another round
+                                with your comment injected.</p>
+                            <button type="submit" class="btn-primary">Comment</button>
                         </form>
-                    </div>
+                        <p stm-if="task.terminal && task.goal_id > 0" class="text-muted">
+                            Comments are closed — this task belongs to its goal; the goal agent
+                            (its goal's page) owns the conversation.</p>
+
+                        <!-- The highlight-to-comment popover (main.html) fills and opens
+                             this dialog; it went missing in a template rework, which broke
+                             quoting on RFC bodies. -->
+                        <dialog id="quote-comment-dialog">
+                            <h3>Comment on selected text</h3>
+                            <blockquote id="quote-text" class="term-comment-quote"></blockquote>
+                            <form stm-action="task.comment">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <input type="hidden" name="quote">
+                                <input type="hidden" name="return_to">
+                                <textarea name="body" rows="4" placeholder="Your comment…" style="width:100%; margin:0.5em 0;"></textarea>
+                                <button type="submit" class="btn-primary">Send comment</button>
+                                <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
+                            </form>
+                        </dialog>
+                    </section>
                 </div>
 
-                <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
-                    <span class="text-muted" stm-text="block.created_display">when</span></p>
+                <!-- ================ CONSULT ================ -->
+                <div class="stack task-col-consult">
+                    <!-- Plan 12: the mandate — the plan-11 "Description"
+                         card, framed as the binding work order. Task 12:
+                         drafts swap the rendered markdown for an in-place
+                         editor (initLongFormEditors in main.html).
+                         Consult column lead: the single most useful read. -->
+                    <section class="card" data-long-form data-field="task"
+                             data-lf-open="0">
+                        <h3>Mandate
+                            <button type="button" class="cluster" stm-if="task.editable_all"
+                                    data-lf-toggle>Edit</button></h3>
+                        <div data-lf-view>
+                            <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
+                            <p class="text-muted" stm-if="task.description.length == 0">No mandate.</p>
+                        </div>
+                        <!-- The stm-if wrapper (not the form itself) owns
+                             draft-gating: statum toggles `hidden` on
+                             stm-if branches, so the form inside keeps its
+                             visibility JS-owned (initLongFormEditors). -->
+                        <div stm-if="task.editable_all">
+                            <form data-lf-edit hidden stm-action="task.update">
+                                <input type="hidden" name="task_id" stm-attribute.value="task.id">
+                                <div class="field"><label>Mandate</label>
+                                    <textarea name="description" rows="8"
+                                              data-lf-src="task.description">description</textarea></div>
+                                <button type="submit">Save</button>
+                                <button type="button" data-lf-toggle>Cancel</button>
+                            </form>
+                        </div>
+                    </section>
 
-                <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
-                     stm-markdown="block.body">body</div>
+                    <!-- Plan 12: the goal reference — a goal task or the
+                         goal agent's own run links back to its goal. -->
+                    <section class="card" stm-if="task.goal_id > 0">
+                        <h3>Goal</h3>
+                        <p><span stm-attribute.class="'badge badge-' + task.goal_status"
+                                 stm-text="task.goal_status.replace('_', ' ')">status</span>
+                            <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a></p>
+                        <p class="text-muted">Goal branch <code stm-text="task.goal_branch">branch</code>
+                            <span stm-if="task.is_goal_run">— this task IS the goal agent's run
+                                (orchestrate mode; the goal page owns its lifecycle)</span>
+                            <span stm-if="!task.is_goal_run">— this task runs under the goal agent's
+                                supervision on an ephemeral branch</span></p>
+                    </section>
 
-                <div stm-if="block.answered">
-                    <p>Answer: <strong stm-text="block.answer">answer</strong>
-                        <span class="text-muted" stm-text="block.answered_display">when</span>
-                        <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
-                </div>
+                    <!-- Goal 41 T5 (ux-notes task detail C + quick win 6):
+                         the low-traffic reference sections - Rules,
+                         Required capabilities, Sessions - cluster into
+                         ONE collapsed "Configuration & history" region
+                         with a summary count line, so reference
+                         material no longer competes with the action
+                         surfaces. Spry styles the bare details. -->
+                    <section data-subnav-section="task-config">
+                        <details class="config-history">
+                            <summary><strong>Configuration &amp; history</strong>
+                                <span class="text-muted">— <span stm-text="task.rules.length">0</span> rules ·
+                                    <span stm-text="task.capabilities.length">0</span> capabilities ·
+                                    <span stm-text="task.sessions.length">0</span> sessions</span></summary>
+                            <div class="stack mt-2">
+                                <div>
+                                    <h4>Rules</h4>
+                                    <p class="text-muted">Task rules lead the effective set dispatched with this task.</p>
+                                    <div stm-for-rule-in="task.rules" stm-key="rule.link_id" class="card">
+                                        <h3 stm-text="rule.name">name</h3>
+                                        <div class="md" stm-markdown="rule.body">body</div>
+                                        <form class="cluster">
+                                            <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
+                                        </form>
+                                    </div>
+                                    <p stm-if="task.rules.length == 0" class="text-muted">No task-specific rules.</p>
+                                    <form class="cluster" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
+                                        <label for="task-rule-target">Attach rule</label>
+                                        <select id="task-rule-target" name="rule_id" required>
+                                            <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
+                                                    stm-text="option.name">rule</option>
+                                        </select>
+                                        <button type="submit">Attach</button>
+                                    </form>
+                                </div>
 
-                <pstm-fragment name="block-answer"/>
-                <pstm-fragment name="block-reply"/>
-            </div>
-            <p stm-if="task.open_blocks.length == 0 && task.answered_blocks.length == 0" class="text-muted">No blocks — the agent has no open questions.</p>
-
-            <!-- Historical (answered) blocks behind an expander, the
-                 current ones in full above (plan 11 task D). -->
-            <details class="blocks-history" stm-if="task.answered_blocks.length > 0">
-                <summary>Answered blocks (<span stm-text="task.answered_blocks.length">0</span>)</summary>
-                <div stm-for-block-in="task.answered_blocks" stm-key="block.id"
-                     stm-attribute.class="'block-card answered ' + block.block_type">
-                    <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
-                        <span stm-text="block.question">question</span>
-                        <span class="text-muted" stm-text="block.created_display">when</span></p>
-                    <div stm-if="block.show_body && block.body.length > 0" class="md rfc-body"
-                         stm-markdown="block.body">body</div>
-                    <p>Answer: <strong stm-text="block.answer">answer</strong>
-                        <span class="text-muted" stm-text="block.answered_display">when</span>
-                        <span class="badge" stm-if="block.answered_by == 'auto'">auto</span></p>
+                                <div>
+                                    <h4>Required capabilities</h4>
+                                    <p class="text-muted">The dispatcher only assigns this task to a saddle carrying all of these tags.</p>
+                                    <p stm-if="task.capabilities.length > 0">
+                                        <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
+                                            <span class="badge"><span stm-text="capability.name">tag</span>
+                                                <button type="button" class="btn-ghost chip-remove"
+                                                        stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
+                                        </span>
+                                    </p>
+                                    <p stm-if="task.capabilities.length == 0" class="text-muted">No requirements — any idle saddle may run it.</p>
+                                    <form class="cluster" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
+                                        <label for="task-capability-target">Require capability</label>
+                                        <select id="task-capability-target" name="capability_id" required>
+                                            <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"
+                                                    stm-text="option.name">capability</option>
+                                        </select>
+                                        <button type="submit">Require</button>
+                                    </form>
+                                </div>
+
+                                <div>
+                                    <h4>Sessions</h4>
+                                    <p stm-if="task.saddle_health.length > 0" class="text-muted"
+                                       stm-text="task.saddle_health">saddle health</p>
+                                    <div class="table-wrap">
+                                        <table>
+                                            <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th></tr></thead>
+                                            <tbody>
+                                                <tr stm-for-session-in="task.sessions">
+                                                    <td stm-text="session.id">id</td>
+                                                    <td stm-text="session.saddle_name">saddle</td>
+                                                    <td stm-text="session.started_display">started</td>
+                                                    <td stm-text="session.ended_display">ended</td>
+                                                    <td stm-if="session.open"><span class="badge badge-working">open</span></td>
+                                                    <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
+                                                </tr>
+                                                <tr stm-if="task.sessions.length == 0"><td colspan="5" class="text-muted">No sessions yet.</td></tr>
+                                            </tbody>
+                                        </table>
+                                    </div>
+                                </div>
+                            </div>
+                        </details>
+                    </section>
                 </div>
-            </details>
-
-            <h3>Comments</h3>
-            <!-- Plan 15 C2: harness-authored bookkeeping (aborts,
-                 requeues, retries, session restorations) renders as a
-                 distinct muted SYSTEM row — never an agent comment. -->
-            <div stm-for-comment-in="task.comments" stm-attribute.class="'comment' + (comment.from_agent ? ' agent' : '') + (comment.is_system ? ' system' : '')">
-                <strong stm-if="!comment.is_system" stm-text="comment.author">author</strong>
-                <span stm-if="comment.is_system" class="system-label">system</span>
-                <span class="text-muted" stm-text="comment.when_display">when</span>
-                <div class="md" stm-markdown="comment.body">body</div>
-                <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
-            </div>
-            <p stm-if="task.comments.length == 0" class="text-muted">No comments yet.</p>
-            <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
-                 A terminal NON-GOAL task resurrects on comment (it
-                 re-queues with the comment injected); a terminal
-                 goal-owned worker keeps its goal agent as the only
-                 voice — the hint says so and the service refuses. -->
-            <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
-                <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                <div class="field"><label for="comment-body">Add comment</label>
-                    <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
-                <p stm-if="task.terminal && task.goal_id == 0" class="text-muted">
-                    This task is terminal — commenting re-queues it for another round
-                    with your comment injected.</p>
-                <button type="submit" class="btn-primary">Comment</button>
-            </form>
-            <p stm-if="task.terminal && task.goal_id > 0" class="text-muted">
-                Comments are closed — this task belongs to its goal; the goal agent
-                (its goal's page) owns the conversation.</p>
-
-            <!-- The highlight-to-comment popover (main.html) fills and opens
-                 this dialog; it went missing in a template rework, which broke
-                 quoting on RFC bodies. -->
-            <dialog id="quote-comment-dialog">
-                <h3>Comment on selected text</h3>
-                <blockquote id="quote-text" class="term-comment-quote"></blockquote>
-                <form stm-action="task.comment">
-                    <input type="hidden" name="task_id" stm-attribute.value="task.id">
-                    <input type="hidden" name="quote">
-                    <input type="hidden" name="return_to">
-                    <textarea name="body" rows="4" placeholder="Your comment…" style="width:100%; margin:0.5em 0;"></textarea>
-                    <button type="submit" class="btn-primary">Send comment</button>
-                    <button type="button" onclick="document.getElementById('quote-comment-dialog').close()">Cancel</button>
-                </form>
-            </dialog>
-
-            <h3>Rules</h3>
-            <p class="text-muted">Task rules lead the effective set dispatched with this task.</p>
-            <div stm-for-rule-in="task.rules" stm-key="rule.link_id" class="card">
-                <h3 stm-text="rule.name">name</h3>
-                <div class="md" stm-markdown="rule.body">body</div>
-                <form class="cluster">
-                    <button type="button" stm-action="rule.detach" stm-confirm="Detach this rule?">Detach</button>
-                </form>
             </div>
-            <p stm-if="task.rules.length == 0" class="text-muted">No task-specific rules.</p>
-            <form class="cluster" stm-action="task.attach_rule" stm-if="task.rule_options.length > 0">
-                <label for="task-rule-target">Attach rule</label>
-                <select id="task-rule-target" name="rule_id" required>
-                    <option stm-for-option-in="task.rule_options" stm-attribute.value="option.id"
-                             stm-text="option.name">rule</option>
-                </select>
-                <button type="submit">Attach</button>
-            </form>
-
-            <h3>Required capabilities</h3>
-            <p class="text-muted">The dispatcher only assigns this task to a saddle carrying all of these tags.</p>
-            <p stm-if="task.capabilities.length > 0">
-                <span stm-for-capability-in="task.capabilities" stm-key="capability.link_id">
-                    <span class="badge"><span stm-text="capability.name">tag</span>
-                        <button type="button" class="btn-ghost chip-remove"
-                                stm-action="capability.detach" stm-confirm="Remove this requirement?">×</button></span>
-                </span>
-            </p>
-            <p stm-if="task.capabilities.length == 0" class="text-muted">No requirements — any idle saddle may run it.</p>
-            <form class="cluster" stm-action="task.attach_capability" stm-if="task.capability_options.length > 0">
-                <label for="task-capability-target">Require capability</label>
-                <select id="task-capability-target" name="capability_id" required>
-                    <option stm-for-option-in="task.capability_options" stm-attribute.value="option.id"
-                             stm-text="option.name">capability</option>
-                </select>
-                <button type="submit">Require</button>
-            </form>
-
-            <h3>Sessions</h3>
-            <p stm-if="task.saddle_health.length > 0" class="text-muted"
-               stm-text="task.saddle_health">saddle health</p>
-            <table>
-                <thead><tr><th>#</th><th>Saddle</th><th>Started</th><th>Ended</th><th>Outcome</th><th></th></tr></thead>
-                <tbody>
-                    <tr stm-for-session-in="task.sessions">
-                        <td stm-text="session.id">id</td>
-                        <td stm-text="session.saddle_name">saddle</td>
-                        <td stm-text="session.started_display">started</td>
-                        <td stm-text="session.ended_display">ended</td>
-                        <td stm-if="session.open"><span class="badge badge-working">open</span></td>
-                        <td stm-if="!session.open" stm-text="session.outcome">outcome</td>
-                        <td><a stm-attribute.href="'/tasks/' + task.id + '/log'"
-                               target="_blank" rel="noopener">Event log</a></td>
-                    </tr>
-                    <tr stm-if="task.sessions.length == 0"><td colspan="6" class="text-muted">No sessions yet.</td></tr>
-                </tbody>
-            </table>
         </div>
     </section>
 </body>

+ 27 - 13
src/pages/task_log.html

@@ -7,22 +7,35 @@
 </head>
 <body>
     <div class="page log-page">
-        <p class="log-back"><a href="/tasks">&larr; Tasks</a></p>
+        <!-- Goal 41 T5 (ux-notes event log B): one context header
+             instead of a link farm. The back-chevron, the task id +
+             title (the script fills data-log-title from the slot) and
+             the live badge sit in ONE row - the reader always knows
+             whose transcript this is and how to get back to its
+             action surface. -->
+        <header class="log-header">
+            <div class="log-title">
+                <p class="log-back"><a stm-attribute.href="task_log.task_url">&larr; Task page</a></p>
+                <h2 data-log-title>Event log</h2>
+                <p class="text-muted">the unified event ledger · newest at the bottom ·
+                    loads older history on demand</p>
+            </div>
+            <div class="log-actions">
+                <span class="log-live" data-log-live><span class="live-dot"></span><span
+                        data-live-label>live</span></span>
+            </div>
+        </header>
         <div stm-if="!task_log.exists" class="text-muted">No task exists with this id.</div>
         <div stm-if="task_log.exists">
-            <header class="log-header">
-                <div class="log-title">
-                    <h2 data-log-title>Event log</h2>
-                    <p class="text-muted">the unified event ledger · newest at the bottom ·
-                        loads older history on demand</p>
-                </div>
-                <div class="log-actions">
-                    <span class="log-live" data-log-live><span class="live-dot"></span><span
-                            data-live-label>live</span></span>
-                    <a class="log-task-link" stm-attribute.href="task_log.task_url">Task page</a>
-                </div>
-            </header>
             <section class="log-shell">
+                <!-- Goal 41 T5 (ux-notes event log A): the toolbar
+                     (filters + search) is STICKY inside the shell, so
+                     the controls survive deep scrolling through the
+                     feed - the position work lives in app.css's
+                     goal-41 log section; the DOM (and every filter
+                     behaviour) is unchanged. The jump-to-latest
+                     button rides the toolbar: the feed's own
+                     at-bottom/pinned state drives it from task_log.js. -->
                 <div class="log-toolbar">
                     <span class="log-toolbar-label">Show</span>
                     <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
@@ -31,6 +44,7 @@
                     <button type="button" class="log-filter active" data-log-filter="transitions">transitions</button>
                     <button type="button" class="log-filter active" data-log-filter="usage">usage</button>
                     <input type="search" data-log-search placeholder="Search events…">
+                    <button type="button" class="log-jump" data-log-jump hidden>Jump to latest</button>
                 </div>
                 <div class="log-feed" data-log-feed>
                     <button type="button" class="log-more" data-log-more hidden>Load older events</button>

+ 43 - 0
src/pages/task_log.js

@@ -88,6 +88,7 @@
             };
             var feed = null;
             var moreButton = null;
+            var jumpButton = null;
             var stream = null;
             var liveBadge = null;
             var liveLabel = null;
@@ -182,9 +183,23 @@
             }
 
             // Pin the feed to its bottom edge (the newest row).
+            // Reaching the tail retires the jump button (goal 41 T5).
             function scrollToBottom() {
                 if (!feed) return;
                 feed.scrollTop = feed.scrollHeight;
+                updateJump();
+            }
+
+            // Goal 41 T5 (ux-notes event log A): the jump-to-latest
+            // affordance. The feed already owns the pinned/tail
+            // distinction for its stick rule; this surfaces it - the
+            // button appears once the reader scrolls away from the
+            // newest row and disappears at the tail.
+            function updateJump() {
+                if (!jumpButton) return;
+                var away = feed && feed.scrollHeight - feed.scrollTop
+                    - feed.clientHeight > STICK_THRESHOLD;
+                jumpButton.hidden = !away;
             }
 
             // The daemon JSON-encodes tool arguments and tool
@@ -1402,11 +1417,24 @@
                 }
             });
 
+            // Goal 41 T5: the sticky toolbar parks under the sticky
+            // topbar; publish the measured topbar height as a custom
+            // property so app.css's `top:` tracks any wrap/viewport.
+            function syncTopbarOffset() {
+                var bar = document.querySelector('.topbar');
+                if (bar && bar.offsetHeight > 0) {
+                    document.documentElement.style.setProperty(
+                        '--topbar-bottom', bar.offsetHeight + 'px');
+                }
+            }
+
             statum.afterEvaluate(function () {
                 var page = statum.read('task_log');
                 if (!page || !page.exists) return;
+                syncTopbarOffset();
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
+                jumpButton = document.querySelector('[data-log-jump]');
                 liveBadge = document.querySelector('[data-log-live]');
                 liveLabel = document.querySelector('[data-live-label]');
                 taskStatus = strText(page.task_status);
@@ -1416,6 +1444,21 @@
                     header.textContent = '#' + page.task_id + ' \u2014 '
                         + page.task_title;
                 }
+                // afterEvaluate fires every render cycle; the
+                // static toolbar elements are never rebuilt, so bind
+                // once and let later cycles only refresh state.
+                if (jumpButton && jumpButton.getAttribute('data-bound') !== '1') {
+                    jumpButton.setAttribute('data-bound', '1');
+                    jumpButton.addEventListener('click', function () {
+                        scrollToBottom();
+                    });
+                }
+                if (feed && feed.getAttribute('data-scroll-bound') !== '1') {
+                    feed.setAttribute('data-scroll-bound', '1');
+                    feed.addEventListener('scroll', function () {
+                        updateJump();
+                    }, { passive: true });
+                }
                 if (!stream && feed) connect();
                 render();
             });

+ 33 - 19
test/bundles/pages.py

@@ -416,12 +416,14 @@ def run(ctx):
     # Plan 11 task D: the progress feed sits ABOVE the status card;
     # status + description share a 50/50 grid; the plan detail hides
     # behind its expander below it.
-    check(status == 200 and "grid-2" in page
+    check(status == 200 and "with-sidebar task-columns" in page
           and page.index("<h3>Progress</h3>") < page.index("status-head"),
           "the progress feed renders above the status card")
-    check(page.index("grid-2") < page.index("status-head")
-          and page.index("status-head") < page.index('data-long-form data-field="task"'),
-          "status and description sit side by side inside the grid (status first)")
+    check(page.index('class="stack task-col-act"')
+          < page.index('class="stack task-col-consult"'),
+          "the Act column precedes the Consult column (goal 41 T5)")
+    check(page.index('data-subnav-section="task-blocks"') < page.index("status-head"),
+          "the blocks section leads the acting column above the status card")
     check("Mandate" in page and "plan-expander" not in page,
           "the task page carries no plan expander (plans live on the goal)")
     # Answered blocks fold behind the history expander (this fixture's
@@ -448,9 +450,15 @@ def run(ctx):
     check(status == 200
           and history_page.index("progress-latest") < history_page.index("progress-history"),
           "the progress timeline renders newest-first (latest above the history)")
-    # The edit expander sits UNDER the "Show history" expander.
-    check(history_page.index("Show history") < history_page.index("edit-expander"),
-          "the edit expander sits under the Show history expander")
+    # Goal 41 T5: the edit surface is the in-place Edit task card
+    # (no expander) in the acting column, below the progress feed.
+    check(history_page.index("Show history") < history_page.index(">Edit task"),
+          "the Edit task card sits under the progress history expander")
+    check("edit-expander" not in history_page,
+          "the old edit expander is gone (in-place editor instead)")
+    check(history_page.index('data-subnav-section="task-blocks"')
+          < history_page.index('data-subnav-section="task-comments"'),
+          "the blocks section precedes comments in the acting column")
 
     print("== heredoc completion reports: markdown card + timeline snippet ==")
     # The daemon's `cat << 'EOF' | saddle done` stores a markdown
@@ -516,10 +524,10 @@ def run(ctx):
 
     status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
     check(status == 200 and "completion-report" in page
-          and page.index("grid-2") < page.index("completion-report")
-          < page.index("edit-expander"),
-          "the completion-report card sits below the status/description grid, full width")
-    card = page[page.index("completion-report"):page.index("edit-expander")]
+          and page.index("data-subnav-section=\"task-blocks\"") < page.index("completion-report")
+          < page.index("status-head"),
+          "the completion-report card sits below the blocks, above the status card")
+    card = page[page.index("completion-report"):page.index("status-head")]
     check('stm-markdown="task.summary"' in card
           and 'stm-text="task.report_title"' in card,
           "the card renders the summary as markdown under its state-driven heading")
@@ -848,13 +856,17 @@ def run(ctx):
           "the mandate card carries the in-place Edit button and editor")
     check('name="description" rows="8"' in task_page,
           "the editor's textarea is the wide in-place one")
-    # The expander no longer carries the mandate field (short
-    # properties only) — and no other description textarea exists.
-    expander_at = task_page.index('class="edit-expander"')
-    expander_end = task_page.index('</details>', expander_at)
-    expander_html = task_page[expander_at:expander_end]
-    check('name="description"' not in expander_html,
-          "the edit expander no longer carries the mandate field")
+    # Goal 41 T5: the edit surface is the in-place Edit task card —
+    # short properties only (no mandate field).
+    edit_card_at = task_page.index('>Edit task')
+    edit_card_end = task_page.index('</section>', edit_card_at)
+    edit_html = task_page[edit_card_at:edit_card_end]
+    check('name="description"' not in edit_html,
+          "the Edit task card carries no mandate field")
+    check('data-lf-toggle' in edit_html and 'data-lf-edit' in edit_html,
+          "the Edit task card is the in-place editor (view + toggle + form)")
+    check(task_page.count('data-long-form data-field="task"') == 2,
+          "two in-place cards exist: the mandate and the Edit task card")
 
     # The PREFILL contract: the editor reads task.description from
     # state at open (JS-applied — stm-attribute.value is a no-op on
@@ -892,7 +904,9 @@ def run(ctx):
     # hides them at render), while the rendered mandate view carries
     # no gate at all.
     active_page = http_get_page(auth, "/tasks/%d" % active_task)
-    card_at = active_page.find('data-long-form data-field="task"')
+    # Goal 41 T5: two in-place cards exist (Edit task, Mandate); the
+    # draft-gate assertions address the MANDATE card.
+    card_at = active_page.find('>Mandate')
     card_end = active_page.find('</section>', card_at)
     card_html = active_page[card_at:card_end]
     toggle_at = card_html.find('data-lf-toggle')

+ 11 - 2
test/bundles/resurrect.py

@@ -47,8 +47,17 @@ def run(ctx):
     check(detail["status"] == "draft" and int(detail["project_id"]) == 0,
           "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
     page = http_get_page(auth, "/tasks/%d" % standalone)
-    check("(standalone)" in page and "/projects/0" not in page,
-          "the task page renders the (standalone) marker, not a project link")
+    # Goal 41 T5: the context line is the breadcrumb trail now; a
+    # standalone task carries NO project crumb (and no project link).
+    crumbs = detail.get("crumbs", [])
+    check(len(crumbs) == 2 and crumbs[0]["label"] == "Tasks"
+          and crumbs[1]["href"] is None
+          and "#%d " % standalone in crumbs[1]["label"],
+          "a standalone task's crumbs are Tasks / #id (no project crumb): %r" % crumbs)
+    check('class="breadcrumb"' in page and "/projects/" not in page,
+          "the task page renders the breadcrumb trail, not a project link")
+    check("(standalone)" in http_get_page(auth, "/tasks"),
+          "the tasks list still marks the standalone task")
     check("No project (standalone)" in http_get_page(auth, "/tasks"),
           "the create form offers the No project (standalone) option")
     enqueue_task(auth, standalone)