Răsfoiți Sursa

C4: goal page batch — head line, expanders, live push flags, accept gate (F9)

The goalui restole regression is repaired: every section is a card
again and .goal-page .card carries the between-cards rhythm (the
progress feed included). The Current-activity card is DELETED — one
muted line in the status head ('running: `cmd`' / 'parked awaiting
the plan review | the completion review | a question | its tasks'
terminal transitions') plus the saddle-health line carry it.

The plan section moves BETWEEN attention and tasks: an open review
renders expanded with the remarks queue, otherwise the approved plan
and the revision history sit behind a collapsed expander. The
attention blocks section renders only OPEN questions/RFCs with
answer forms; the historic answered ones fold behind an
answered-blocks expander (task-page pattern, open/answered vectors
in the slot). Comment cards render body-first with the author/time
as a muted footnote (.comment-meta), and the composer states the
timeless-note rule.

Push state (F9): goals.branch_push_state is GONE — the daemon's
per-repo goal_branches flags from /status are recorded as LIVE
per-saddle probe state (SaddlePollState), read at render time
through the run's open session's saddle, and never persisted. The
completion-accept is now the operator-confirmed HARD gate: the
accept refuses with a clear message while any reported branch is
unpushed (unknown flags leave the daemon-side rule in charge), and
goes through once a fresh probe reports everything pushed.
clanker 1 lună în urmă
părinte
comite
3aadf3b00c

+ 0 - 2
src/Data/Entities.vala

@@ -555,7 +555,6 @@ namespace ClankerSaddle.Data {
          * derived at read time.
          */
         public DateTime? last_terminal_watermark { get; set; default = null; }
-        public string? branch_push_state { get; set; default = null; }
         public DateTime? created_at { get; set; default = null; }
         public DateTime? started_at { get; set; default = null; }
         public DateTime? completed_at { get; set; default = null; }
@@ -575,7 +574,6 @@ namespace ClankerSaddle.Data {
                 .column<string>("completion_report", o => o.completion_report, (o, v) => o.completion_report = v)
                 .column<string>("creator", o => o.creator, (o, v) => o.creator = v)
                 .column<DateTime?>("last_terminal_watermark", o => o.last_terminal_watermark, (o, v) => o.last_terminal_watermark = v)
-                .column<string>("branch_push_state", o => o.branch_push_state, (o, v) => o.branch_push_state = v)
                 .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
                 .column<DateTime?>("started_at", o => o.started_at, (o, v) => o.started_at = v)
                 .column<DateTime?>("completed_at", o => o.completed_at, (o, v) => o.completed_at = v)

+ 3 - 3
src/Data/Migrations/M0001_Initial.vala

@@ -72,8 +72,9 @@ namespace ClankerSaddle.Data.Migrations {
             // the goal agent has consumed its tasks' TERMINAL
             // transitions (resume_due computes from transitions newer
             // than it; the narrative itself is a derived read-time
-            // view); `branch_push_state` caches the daemon-reported
-            // push state of the goal branches.
+            // view). Plan 15 C4: the daemon-reported goal-branch push
+            // flags are LIVE per-saddle probe state (never a stored
+            // snapshot — the F9 bug), so no column exists.
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -99,7 +100,6 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<string>("creator")
                     .default_value<string>("human");
                 t.column<DateTime?>("last_terminal_watermark");
-                t.column<string>("branch_push_state");
                 t.column<DateTime?>("created_at");
                 t.column<DateTime?>("started_at");
                 t.column<DateTime?>("completed_at");

+ 93 - 10
src/Services/GoalService.vala

@@ -68,6 +68,7 @@ namespace ClankerSaddle.Services {
         private StatePulse pulse = inject<StatePulse>();
         private PushService push = inject<PushService>();
         private TaskService tasks = inject<TaskService>();
+        private SaddlePollState polls = inject<SaddlePollState>();
 
         /**
          * SADDLE_GOAL_RESUME_S (default 10): the coalescing window —
@@ -549,6 +550,9 @@ namespace ClankerSaddle.Services {
                 }
             } else if (((!)block).block_type == GOAL_BLOCK_COMPLETION) {
                 if (answer == NOTICE_REVIEW_APPROVED) {
+                    // Plan 15 C4: the operator-confirmed hard gate —
+                    // refuse while origin is behind (live flags only).
+                    yield refuse_unpushed_origin(((!)goal), (!)task);
                     ((!)goal).status = GOAL_COMPLETE;
                     ((!)goal).completed_at = new DateTime.now_local();
                 } else {
@@ -985,22 +989,101 @@ namespace ClankerSaddle.Services {
         }
 
         // ------------------------------------------------------------------
-        // Branch push state (daemon /status exposure)
+        // Completion gate (plan 15 C4)
         // ------------------------------------------------------------------
 
         /**
-         * Stores a daemon-reported goal-branch push state on the goal
-         * (raw JSON as the daemon sent it — the web only renders it).
-         * Called from the /status observation path when the probed
-         * saddle is running a goal agent.
+         * The hard human gate on completion acceptance (plan 15 C4,
+         * F9): the accept REFUSES while any daemon-reported goal
+         * branch is unpushed — push-after-each-merge is enforced at
+         * the operator's confirmation, read from the LIVE per-saddle
+         * probe flags (never a stored snapshot). Unknown flags (an
+         * older daemon that never reported goal_branches) leave the
+         * daemon-side push rule as the enforcing layer.
          */
-        public async void store_branch_push_state(int64 goal_id, string state_json) throws Error {
-            var goal = yield get_goal(goal_id);
-            if (goal == null || ((!)goal).branch_push_state == state_json) {
+        private async void refuse_unpushed_origin(Goal goal, TaskEntity run) throws Error {
+            int64 saddle_id = 0;
+            foreach (var session in yield db.query<Session>()
+                    .where(expr("task_id == $0", elem<int64?>(run.id)))
+                    .order_by_desc(expr("id"))
+                    .materialise_async()) {
+                if (session.ended_at == null) {
+                    saddle_id = session.saddle_id;
+                    break;
+                }
+                if (saddle_id == 0) {
+                    saddle_id = session.saddle_id;
+                }
+            }
+            if (saddle_id == 0) {
                 return;
             }
-            ((!)goal).branch_push_state = state_json;
-            yield db.update_async<Goal>((!)goal);
+            string raw = polls.goal_branches_for(saddle_id);
+            if (raw.length == 0) {
+                return;
+            }
+            var unpushed = new Vector<string>();
+            try {
+                var node = Json.from_string(raw);
+                if (node.get_node_type() == Json.NodeType.ARRAY) {
+                    foreach (var element in node.get_array().get_elements()) {
+                        if (element.get_node_type() != Json.NodeType.OBJECT) {
+                            continue;
+                        }
+                        var obj = element.get_object();
+                        string branch = obj.has_member("branch")
+                            ? (obj.get_string_member("branch") ?? "") : "";
+                        string repo = obj.has_member("repo")
+                            ? (obj.get_string_member("repo") ?? "") : "";
+                        string label = repo.length > 0 && branch.length > 0
+                            ? @"$(repo): $(branch)"
+                            : (branch.length > 0 ? branch : repo);
+                        bool pushed = obj.has_member("pushed")
+                            ? goal_node_truthy(obj.get_member("pushed")) : false;
+                        if (label.length > 0 && !pushed) {
+                            unpushed.add(label);
+                        }
+                    }
+                } else if (node.get_node_type() == Json.NodeType.OBJECT) {
+                    foreach (var member in node.get_object().get_members()) {
+                        var value = node.get_object().get_member(member);
+                        if (value.get_node_type() == Json.NodeType.VALUE
+                                && !goal_node_truthy(value)) {
+                            unpushed.add(member);
+                        }
+                    }
+                }
+            } catch (Error parse_error) {
+                return;
+            }
+            if (unpushed.length > 0) {
+                var labels = new StringBuilder();
+                foreach (var label in unpushed.to_array()) {
+                    if (labels.len > 0) {
+                        labels.append(", ");
+                    }
+                    labels.append(label);
+                }
+                throw new Error(Quark.from_string("clanker-saddle"), 88,
+                    @"Origin is behind — push the goal branch(es) before accepting ($(labels.str)). The accept refuses until the daemon reports them pushed.");
+            }
+        }
+
+        /** A JSON value as a boolean truth (objects/arrays: present). */
+        private static bool goal_node_truthy(Json.Node node) {
+            if (node.get_node_type() == Json.NodeType.VALUE) {
+                var value = node.get_value();
+                if (value.type() == typeof(bool)) {
+                    return ((!)value).get_boolean();
+                }
+                if (value.type() == typeof(string)) {
+                    return ((!)value).dup_string() != "false";
+                }
+                if (value.type() == typeof(int64)) {
+                    return ((!)value).get_int64() != 0;
+                }
+            }
+            return true;
         }
 
         // ------------------------------------------------------------------

+ 25 - 0
src/Services/SaddlePollState.vala

@@ -40,6 +40,10 @@ namespace ClankerSaddle.Services {
         private Dictionary<string, string> push_at_ms = new Dictionary<string, string>();
         private HashSet<string> push_seen = new HashSet<string>();
         private HashSet<string> stale_probed = new HashSet<string>();
+        // Plan 15 C4: the daemon-reported per-repo push flags of the
+        // goal branches, as the LAST /status probe saw them — live
+        // probe state only, NEVER a stored snapshot on the goal row.
+        private Dictionary<string, string> probe_goal_branches = new Dictionary<string, string>();
         private Mutex state_lock = Mutex();
 
         /**
@@ -58,6 +62,27 @@ namespace ClankerSaddle.Services {
             state_lock.unlock();
         }
 
+        /**
+         * Records the daemon-reported goal-branch push flags (plan 15
+         * C4): raw JSON as the probed daemon sent it — the goal page
+         * and the completion-accept gate read it back live. A missing
+         * member clears the record (nothing to render or enforce).
+         */
+        public void record_goal_branches(int64 saddle_id, string raw_json) {
+            state_lock.lock();
+            probe_goal_branches.set(saddle_id.to_string(), raw_json);
+            state_lock.unlock();
+        }
+
+        /** The last probed goal-branch flags ("" when never reported). */
+        public string goal_branches_for(int64 saddle_id) {
+            state_lock.lock();
+            string stored = "";
+            bool found = probe_goal_branches.try_get(saddle_id.to_string(), out stored);
+            state_lock.unlock();
+            return found ? stored : "";
+        }
+
         /** The last probe snapshot for `saddle_id` ("" when never probed). */
         public void health_for(int64 saddle_id, out string state, out int64 uptime,
                 out int64 activity, out int64 contact_ms) {

+ 9 - 19
src/Services/SaddleService.vala

@@ -415,31 +415,21 @@ namespace ClankerSaddle.Services {
             poll_state.record_probe(saddle.id, state, uptime, activity);
             yield store_reported_ssh_key(saddle, status);
 
-            // Plan 12: a daemon running (or parking — the workspace
-            // stays while the saddle frees) a goal agent exposes the
-            // push state of the goal branches on /status; cache it on
-            // the goal row for the completion-review UI.
+            // Plan 15 C4: a daemon running (or parking — the
+            // workspace stays while the saddle frees) a goal agent
+            // exposes the per-repo push flags of the goal branches on
+            // /status; they are recorded as LIVE probe state on the
+            // saddle (never a stored snapshot on the goal row — a
+            // pre-push snapshot is exactly the F9 bug), and the goal
+            // page + the completion-accept gate read them back at
+            // render/answer time.
             if (status.has_member("goal_branches")) {
                 var goal_branches = status.get_member("goal_branches");
                 if (goal_branches.get_node_type() == Json.NodeType.ARRAY
                         || goal_branches.get_node_type() == Json.NodeType.OBJECT) {
                     var generator = new Json.Generator();
                     generator.set_root(goal_branches);
-                    string raw = generator.to_data(null);
-                    int64 push_goal_id = 0;
-                    if (saddle.current_task_id > 0) {
-                        push_goal_id = yield goal_id_of_run(saddle.current_task_id);
-                    } else {
-                        // Parked: the open session still names the
-                        // goal agent's workspace.
-                        var open_session = yield open_session_on_saddle(saddle.id);
-                        if (open_session != null) {
-                            push_goal_id = yield goal_id_of_run(((!)open_session).task_id);
-                        }
-                    }
-                    if (push_goal_id > 0) {
-                        yield goal_service.store_branch_push_state(push_goal_id, raw);
-                    }
+                    poll_state.record_goal_branches(saddle.id, generator.to_data(null));
                 }
             }
 

+ 87 - 6
src/entrypoints/GoalDetailEntrypoint.vala

@@ -177,6 +177,14 @@ namespace ClankerSaddle {
         public string current_command_display { get; set; default = ""; }
         /** The current activity's entry has not completed yet. */
         public bool current_command_running { get; set; }
+        /**
+         * Plan 15 C4: the ONE muted activity line folded into the
+         * status head (the Current-activity card is gone) —
+         * "running: `cmd`" for the open round, "parked awaiting <a
+         * review | a question | its tasks' terminal transitions>"
+         * for the parked ones ("" when there is nothing to say).
+         */
+        public string current_activity { get; set; default = ""; }
         public Statum.Model.ActionDto? start { get; set; }
         public Statum.Model.ActionDto? pause { get; set; }
         public Statum.Model.ActionDto? resume { get; set; }
@@ -218,6 +226,8 @@ namespace ClankerSaddle {
             var pub = new GoalPublic();
             var links = new Vector<GoalLinkView>();
             var blocks = new Vector<GoalBlockView>();
+            var blocks_open = new Vector<GoalBlockView>();
+            var blocks_answered = new Vector<GoalBlockView>();
             var goal_tasks = new Vector<GoalTaskView>();
             var events = new Vector<GoalEventView>();
             var comments = new Vector<GoalCommentView>();
@@ -235,7 +245,7 @@ namespace ClankerSaddle {
             if (goal != null) {
                 yield populate(((!)goal), goals, tasks, db, actions, true, pub,
                     links, blocks, goal_tasks, events, comments, runs, push_branches,
-                    progress, saddles, polls);
+                    progress, saddles, polls, blocks_open, blocks_answered);
                 foreach (var block in blocks) {
                     if (block.is_plan_review && plan_pending == null) {
                         plan_pending = block;
@@ -288,6 +298,18 @@ namespace ClankerSaddle {
                     dict["options"] = StateJson.strings(
                         ((GoalBlockView) item).options ?? new string[0]);
                 });
+            pub_root["open_blocks"] = StateJson.list_rich<GoalBlockView>(
+                blocks_open.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["options"] = StateJson.strings(
+                        ((GoalBlockView) item).options ?? new string[0]);
+                });
+            pub_root["answered_blocks"] = StateJson.list_rich<GoalBlockView>(
+                blocks_answered.to_immutable_buffer(),
+                (item, dict) => {
+                    dict["options"] = StateJson.strings(
+                        ((GoalBlockView) item).options ?? new string[0]);
+                });
             pub_root["goal_tasks"] = StateJson.list_rich<GoalTaskView>(
                 goal_tasks.to_immutable_buffer(),
                 (item, dict) => {
@@ -330,7 +352,9 @@ namespace ClankerSaddle {
                 Vector<GoalPushBranchView> push_branches,
                 Vector<GoalProgressView>? progress = null,
                 SaddleService? saddles = null,
-                SaddlePollState? polls = null) throws GLib.Error {
+                SaddlePollState? polls = null,
+                Vector<GoalBlockView>? blocks_open = null,
+                Vector<GoalBlockView>? blocks_answered = null) throws GLib.Error {
             pub.exists = true;
             pub.id = goal.id;
             pub.project_id = goal.project_id;
@@ -395,6 +419,7 @@ namespace ClankerSaddle {
             }
 
             bool completion_pending = false;
+            bool plan_pending_open = false;
             // Plan 15 C1: the goal's blocks ARE the goal-run task's
             // blocks — one row per question, answered in place.
             foreach (var block in yield goals.list_blocks(goal.id)) {
@@ -433,10 +458,26 @@ namespace ClankerSaddle {
                 if (pending_completion) {
                     completion_pending = true;
                 }
+                if (pending_plan) {
+                    plan_pending_open = true;
+                }
                 blocks.add(view);
             }
             pub.completion_pending = completion_pending;
-
+            // Plan 15 C4: the current (unanswered) question/RFC blocks
+            // render in full; the historic answered ones fold behind
+            // the answered-blocks expander (the task page's pattern).
+            var open_sink = blocks_open ?? new Vector<GoalBlockView>();
+            var answered_sink = blocks_answered ?? new Vector<GoalBlockView>();
+            var block_array = blocks.to_array();
+            for (var index = block_array.length - 1; index >= 0; index--) {
+                var view = (GoalBlockView) block_array[index];
+                if (view.answered) {
+                    answered_sink.add(view);
+                } else if (!view.is_plan_review && !view.is_completion_review) {
+                    open_sink.add(view);
+                }
+            }
             var display_map = yield task_display_map(tasks, goal.id);
             foreach (var task in yield tasks.list_tasks_for_goal(goal.id)) {
                 if (task.kind == TASK_KIND_GOAL_RUN) {
@@ -557,6 +598,7 @@ namespace ClankerSaddle {
                 });
             }
 
+            int64 open_session_saddle = 0;
             if (pub.run_task_id > 0) {
                 // The goal agent's live progress feed (green, task-page
                 // pattern) — the run task's own progress lines.
@@ -598,6 +640,7 @@ namespace ClankerSaddle {
                 // activity line.
                 var open = yield tasks.open_session_for_task(pub.run_task_id);
                 if (open != null) {
+                    open_session_saddle = ((!)open).saddle_id;
                     if (saddles != null) {
                         var saddle_row = yield ((!)saddles).get_saddle(((!)open).saddle_id);
                         if (saddle_row != null) {
@@ -643,9 +686,47 @@ namespace ClankerSaddle {
                 }
             }
 
-            if (goal.branch_push_state != null
-                    && ((!)goal.branch_push_state).length > 0) {
-                parse_push_state((!)goal.branch_push_state, push_branches);
+            // The one muted activity line (plan 15 C4): what the run is
+            // doing right now — running the open round, or parked
+            // awaiting a review / a question / its tasks.
+            var activity_run = pub.run_task_id > 0
+                ? yield db.query<TaskEntity>()
+                    .where(expr("id == $0", elem<int64?>(pub.run_task_id)))
+                    .first_async() : null;
+            if (activity_run != null) {
+                if (((!)activity_run).status == STATUS_WORKING
+                        || ((!)activity_run).status == STATUS_ASSIGNED) {
+                    if (pub.current_command.length > 0) {
+                        pub.current_activity = pub.current_command_running
+                            ? @"running: `$(pub.current_command)`"
+                            : @"last: `$(pub.current_command)`";
+                    }
+                } else if (((!)activity_run).status == STATUS_BLOCKED
+                        || ((!)activity_run).status == STATUS_REVIEW_REQUESTED) {
+                    if (completion_pending) {
+                        pub.current_activity = "parked awaiting the completion review";
+                    } else if (plan_pending_open) {
+                        pub.current_activity = "parked awaiting the plan review";
+                    } else if (open_sink.length > 0) {
+                        pub.current_activity = "parked awaiting a question";
+                    } else {
+                        pub.current_activity = "parked";
+                    }
+                } else if (((!)activity_run).status == STATUS_SUSPENDED) {
+                    pub.current_activity =
+                        "parked awaiting its tasks' terminal transitions";
+                }
+            }
+
+            // Plan 15 C4: the push chips derive from the LIVE
+            // daemon-reported per-repo flags of the run's hosting
+            // saddle (the last /status probe) — never a stored
+            // snapshot. A restart with no probe yet renders none.
+            if (open_session_saddle > 0 && polls != null) {
+                string flags = ((!)polls).goal_branches_for(open_session_saddle);
+                if (flags.length > 0) {
+                    parse_push_state(flags, push_branches);
+                }
             }
         }
 

+ 10 - 0
src/pages/app.css

@@ -582,6 +582,16 @@ nav .nav-goal { font-weight: 600; }
 
 /* ---- Progress ---- */
 
+/* Plan 15 C4: the goal page's card rhythm restored (the goalui
+   restyle regression) — every section is a card and the cards
+   breathe between themselves. */
+.goal-page .card { margin-bottom: var(--grid-2); }
+.goal-page .card:last-child { margin-bottom: 0; }
+
+/* Timeless-comment footnote styling (plan 15 C4): the body leads,
+   the author/time line reads as a muted footnote under it. */
+.comment .comment-meta { margin: var(--grid-1) 0 0; }
+
 .progress-latest {
     background: #dafbe1;
     border: 1px solid var(--success);

+ 128 - 111
src/pages/goal_detail.html

@@ -6,8 +6,8 @@
 <script>
     // The goal page: supervision surface of one goal — status head
     // with lifecycle actions (plus run-control parity: retry
-    // supervision, requeue-to-resume), branch/push-state chips, the
-    // agent's live progress feed and current activity, the ATTENTION
+    // supervision, requeue-to-resume), branch/push-state chips, one
+    // muted activity line, the agent's live progress feed, the ATTENTION
     // blocks (the goal agent's answerable questions/RFCs and the
     // completion review), the task status strip with per-row
     // capability badges, the locked brief, the goal's effective
@@ -338,51 +338,71 @@
                     hands the goal to its agent (an orchestrate-mode run task, priority 3).</p>
                 <p class="muted" stm-if="goal.status == 'paused'">Paused — supervision frozen;
                     task events queue and coalesce into the resume.</p>
+                <!-- Plan 15 C4: the ONE muted activity line (the
+                     Current-activity card is gone) — what the run is
+                     doing right now, folded into the status head. -->
+                <p class="muted" stm-if="goal.current_activity.length > 0"
+                   stm-text="goal.current_activity">activity</p>
+                <p class="muted" stm-if="goal.saddle_health.length > 0"
+                   stm-text="goal.saddle_health">health</p>
             </section>
 
-            <!-- The agent's live progress feed (task-page pattern),
-                 inside the status region: 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), inside the
-                 status region: 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>
+            <!-- The agent's live progress feed (task-page pattern):
+                 the newest line leads, its history behind the
+                 expander. Plan 15 C4: a CARD again (the goalui
+                 restyle regression — every section rides the same
+                 card rhythm). -->
+            <section class="card">
+                <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>
             </section>
 
-            <!-- ATTENTION (plan 14 B5 order): the goal agent's
-                 answerable blocks lead — questions and RFCs with full
-                 markdown bodies and inline answer forms, then the
-                 completion review when one awaits. -->
-            <section class="card" stm-if="goal.blocks.length > 0">
+            <!-- ATTENTION (plan 14 B5 order; plan 15 C4): the goal
+                 agent's OPEN blocks lead — questions and RFCs with
+                 full markdown bodies and inline answer forms — and
+                 the historic answered ones fold behind the
+                 answered-blocks expander (the task page's pattern);
+                 the completion review follows when one awaits. -->
+            <section class="card"
+                     stm-if="goal.open_blocks.length > 0 || goal.answered_blocks.length > 0">
                 <h3>Goal agent blocks</h3>
-                <div stm-for-block-in="goal.blocks" class="goal-block">
-                    <div stm-if="!block.is_plan_review && !block.is_completion_review">
+                <div stm-for-block-in="goal.open_blocks" class="goal-block">
+                    <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
+                             stm-text="block.block_type">kind</span>
+                        <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="md rfc-body goal-body" stm-markdown="block.body">body</div>
+                    <form stm-action="block.answer_action">
+                        <div stm-if="block.options.length > 0" class="form-row">
+                            <label>Options</label>
+                            <select name="option">
+                                <option stm-for-option-in="block.options" stm-attribute.value="option"
+                                         stm-text="option">opt</option>
+                            </select></div>
+                        <div class="form-row">
+                            <textarea name="answer" rows="2"
+                                      placeholder="Reply (or pick an option)"></textarea></div>
+                        <button type="submit" class="primary">Answer</button>
+                    </form>
+                </div>
+                <details class="history-expander" stm-if="goal.answered_blocks.length > 0">
+                    <summary>Answered blocks (<span stm-text="goal.answered_blocks.length">0</span>)</summary>
+                    <div stm-for-block-in="goal.answered_blocks" class="goal-block">
                         <p><span stm-attribute.class="'badge badge-block-' + block.block_type"
                                  stm-text="block.block_type">kind</span>
                             <strong stm-text="block.question">question</strong>
@@ -392,20 +412,8 @@
                         <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>
-                        <form stm-if="!block.answered" stm-action="block.answer_action">
-                            <div stm-if="block.options.length > 0" class="form-row">
-                                <label>Options</label>
-                                <select name="option">
-                                    <option stm-for-option-in="block.options" stm-attribute.value="option"
-                                             stm-text="option">opt</option>
-                                </select></div>
-                            <div class="form-row">
-                                <textarea name="answer" rows="2"
-                                          placeholder="Reply (or pick an option)"></textarea></div>
-                            <button type="submit" class="primary">Answer</button>
-                        </form>
                     </div>
-                </div>
+                </details>
             </section>
 
             <!-- Completion review: the report, compare link (an
@@ -437,6 +445,62 @@
                 </details>
             </section>
 
+            <!-- The plan (plan 15 C4): BETWEEN attention and tasks.
+                 While a plan review is open the pending submission
+                 renders EXPANDED with the remarks queue (plan 14 B2);
+                 otherwise the approved plan and the revision history
+                 (approved + rejected) sit behind a collapsed
+                 expander — the goal page leads with what needs a
+                 human, not with settled history. -->
+            <section class="card">
+                <h3>Plan</h3>
+
+                <div stm-if="goal.plan_pending_block != null && goal.plan_pending_block != false"
+                     class="plan-review" data-plan-review
+                     stm-attribute.data-block-id="goal.plan_pending_block.id">
+                    <h4>Plan submitted for review</h4>
+                    <div class="md rfc-body goal-body" data-plan-body
+                         stm-markdown="goal.plan_pending_block.body">revision</div>
+                    <div class="actions">
+                        <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="approve" data-plan-accept>Approve plan</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Reject with feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="muted">Highlight plan text to attach a remark to it — the queue
+                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
+                        <ul class="plan-comments" data-plan-remarks></ul>
+                        <textarea name="feedback" rows="2"
+                                  placeholder="Additional feedback (optional)"></textarea>
+                        <button type="button" class="primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+
+                <details class="history-expander"
+                         stm-if="goal.plan_pending_block == null || goal.plan_pending_block == false">
+                    <summary>Approved plan &amp; revisions (<span
+                            stm-text="goal.plan_revisions.length">0</span>)</summary>
+                    <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" class="muted">No approved plan yet.</p>
+                    <div stm-for-revision-in="goal.plan_revisions" class="plan-revision">
+                        <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="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>:
+                            <span stm-text="revision.answer">answer</span></blockquote>
+                    </div>
+                </details>
+            </section>
+
             <!-- Task status strip + the goal's task list (plan 14 B5:
                  tasks above the brief; capability badges per row —
                  no editing here; yeehaw on rows only). -->
@@ -531,59 +595,6 @@
                 <p stm-if="goal.rules.length == 0" class="muted">No rules apply to this goal.</p>
             </section>
 
-            <!-- The plan: the approved markdown; the pending review
-                 with the remarks queue (plan 14 B2); 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="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" data-plan-review
-                     stm-attribute.data-block-id="goal.plan_pending_block.id">
-                    <h4>Plan submitted for review</h4>
-                    <div class="md rfc-body goal-body" data-plan-body
-                         stm-markdown="goal.plan_pending_block.body">revision</div>
-                    <div class="actions">
-                        <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="approve" data-plan-accept>Approve plan</button>
-                        </form>
-                        <button type="button" data-plan-toggle>Reject with feedback</button>
-                    </div>
-                    <div data-plan-feedback hidden>
-                        <p class="muted">Highlight plan text to attach a remark to it — the queue
-                            rides the rejection numbered, then clears. Approving ignores the queue.</p>
-                        <ul class="plan-comments" data-plan-remarks></ul>
-                        <textarea name="feedback" rows="2"
-                                  placeholder="Additional feedback (optional)"></textarea>
-                        <button type="button" class="primary" data-plan-submit>Submit feedback</button>
-                    </div>
-                </div>
-
-                <details class="history-expander" stm-if="goal.plan_revisions.length > 0">
-                    <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><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="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>:
-                            <span stm-text="revision.answer">answer</span></blockquote>
-                    </div>
-                </details>
-            </section>
-
             <!-- The supervision narrative (plan 14 B6): state events
                  at full weight, comment events as .footnote — the
                  feed reads as events-with-occasional-footnotes. -->
@@ -613,13 +624,19 @@
                  the run (a draft goal has no thread to inject into). -->
             <section class="card">
                 <h3>Comments</h3>
+                <!-- Plan 15 C4: timeless-comment footnote styling —
+                     the BODY leads; the author and time read as a
+                     muted footnote (status belongs in progress
+                     updates). System rows keep their labelled style. -->
                 <div stm-for-comment-in="goal.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="muted" stm-text="comment.created_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>
+                    <p class="comment-meta">
+                        <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="muted" stm-text="comment.created_display">when</span>
+                    </p>
                 </div>
                 <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <p stm-if="goal.run_task_id == 0" class="muted">The goal agent has not started

+ 62 - 7
test/web_stack_test.py

@@ -5717,11 +5717,36 @@ def bundle_goals(ctx):
                    timeout=60, what="resubmitted completion") is not None,
           "the agent resubmits its completion after feedback")
     state = goal_state(goal1)
+    # Plan 15 C4: the completion-accept hard gate — the live probe
+    # flags still report repob's branch unpushed, so the operator's
+    # accept REFUSES with origin behind.
+    act(auth, state["completion_pending_block"]["answer_action"],
+        {"answer": "Review approved. Continue with the task."})
+    check(goal_state(goal1)["status"] == "completion_review",
+          "the accept REFUSES while origin is behind (hard gate, live flags)")
+    # Push the branch daemon-side and probe again — the flags flip
+    # live (no goal-row write), and the accept goes through.
+    fake.status_extra = {"goal_branches": [
+        {"repo": "repoa", "branch": "goal/flange", "pushed": True},
+        {"repo": "repob", "branch": "goal/flange", "pushed": True}]}
+    act(auth, fake_card["refresh"], {})
+    check(wait_for(lambda: (goal_state(goal1).get("push_branches") or [])
+                   and all(b["pushed"] for b in goal_state(goal1)["push_branches"])
+                   or False, timeout=30, what="pushed flags land") is not None,
+          "the push chips derive from the LIVE probe flags")
+    conn = db()
+    no_push_column = conn.execute(
+        "select count(*) from pragma_table_info('goals')"
+        " where name = 'branch_push_state'").fetchone()[0] == 0
+    conn.close()
+    check(no_push_column,
+          "no stored push snapshot exists (F9: the goal row never records it)")
+    state = goal_state(goal1)
     act(auth, state["completion_pending_block"]["answer_action"],
         {"answer": "Review approved. Continue with the task."})
     check(wait_for(lambda: goal_state(goal1)["status"] == "complete" or False,
                    timeout=30, what="accepted completion completes") is not None,
-          "accepting the completion completes the goal")
+          "accepting the completion completes the goal once origin is current")
     state = goal_state(goal1)
     check(state["terminal"] is True and state.get("cancel") is None
           and "# Completion v2" in state["completion_report"],
@@ -6340,7 +6365,9 @@ def bundle_goalui(ctx):
     narrative footnote weights. C1 — the single-row block world: the
     goal's blocks ARE the run task's rows (answered from every
     surface through one path; no dual rows, no stragglers, at most
-    one review-kind block per verdict path).
+    one review-kind block per verdict path). C4 — the goal page
+    batch: one muted head activity line, plan between attention and
+    tasks, answered blocks behind an expander, footnote comments.
     """
     auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
 
@@ -6430,6 +6457,15 @@ def bundle_goalui(ctx):
           and goal_page.count("badge-yeehaw") == 1,
           "yeehaw is rows-only (no goal-head corner badge; the row badge remains)")
 
+    check(goal_state(goal)["current_activity"] == "parked awaiting the plan review",
+          "the head line reads 'parked awaiting the plan review' while one is open")
+    goal_page = http_get_page(auth, "/goals/%d" % goal)
+    check(goal_page.index("Goal agent blocks") < goal_page.index("<h3>Plan</h3>")
+          < goal_page.index("<h3>Tasks</h3>"),
+          "the plan section sits between attention and tasks")
+    check("data-plan-review" in goal_page and "Approved plan &amp; revisions" in goal_page,
+          "an open review renders expanded beside the collapsed approved-plan expander")
+
     print("== B2: the plan-review remarks queue ==")
     check(goal_state(goal)["status"] == "plan_review"
           and goal_state(goal)["plan_pending_block"] is not None,
@@ -6652,18 +6688,17 @@ def bundle_goalui(ctx):
               for e in state["events"]),
           "the narrative records the supervision retry (a system row)")
 
-    print("== B5: order, effective rules, capability badges ==")
+    print("== B5 + C4: order, effective rules, capability badges ==")
     goal_page = http_get_page(auth, "/goals/%d" % goal)
     markers = [
         ('class="card goal-head"', "status head"),
         ("Agent progress", "progress"),
-        ("Current activity", "activity"),
         ("Goal agent blocks", "attention blocks"),
+        ("<h3>Plan</h3>", "plan"),
         ("<h3>Tasks</h3>", "tasks"),
         ("Initial brief", "brief"),
         ("Involved projects", "projects"),
         ("<h3>Rules</h3>", "rules"),
-        ("<h3>Plan</h3>", "plan"),
         ("Supervision narrative", "narrative"),
         ("<h3>Comments</h3>", "comments"),
         ("Goal agent runs", "runs"),
@@ -6673,8 +6708,12 @@ def bundle_goalui(ctx):
         check(marker in goal_page, "the goal page ships the %s section" % _)
         positions.append(goal_page.index(marker))
     check(positions == sorted(positions),
-          "the page reads status → attention → tasks → brief → rules → plan →"
-          " narrative → comments → runs (in order)")
+          "the page reads status → progress → attention → plan → tasks → brief →"
+          " rules → narrative → comments → runs (in order)")
+    check("goal.current_activity" in goal_page and "Current activity" not in goal_page,
+          "the activity lives as ONE muted head line (the card is gone)")
+    check(".goal-page .card" in open(os.path.join(REPO, "src/pages/app.css")).read(),
+          "the stylesheet restores the goal page's card rhythm (padding regression)")
 
     rules_state = slot_public(auth, "/rules", "rules")
     act(auth, rules_state["create"], {"name": "goalui-primary", "body": "Primary rule"})
@@ -6788,6 +6827,22 @@ def bundle_goalui(ctx):
     check("comment.is_system" in task_page and "system-label" in task_page,
           "the task page renders system rows with the same style")
 
+    print("== C4: head line, answered-blocks expander, footnote comments ==")
+    state = goal_state(goal)
+    check(state["current_activity"] == "parked awaiting its tasks' terminal transitions",
+          "a suspended run folds into the one muted head line")
+    check(len(state.get("answered_blocks") or []) >= 2
+          and len(state.get("open_blocks") or []) == 0,
+          "the historic answered blocks expose as their own vector (open ones empty)")
+    goal_page = http_get_page(auth, "/goals/%d" % goal)
+    check("Answered blocks (" in goal_page and "comment-meta" in goal_page,
+          "the answered blocks and the footnote comment meta ship in the markup")
+    check("timeless note for a future reader" in goal_page,
+          "the composer hint states the timeless-comment rule")
+    css = open(os.path.join(REPO, "src/pages/app.css")).read()
+    check(".goal-page .card { margin-bottom" in css and ".comment-meta" in css,
+          "the stylesheet ships the card rhythm and the comment footnote meta")
+
     print("== C1: one review block per verdict path (F6) ==")
     # The daemon's honest round pushes the ENTRY and the STATE
     # together — the done pair must raise exactly ONE completion