Răsfoiți Sursa

Merge branch 'goal/agent-functions' of clanker/clanker-saddle into web-stack

clanker 1 lună în urmă
părinte
comite
48b1fae775

+ 124 - 0
src/Api/SaddleApi.vala

@@ -235,6 +235,130 @@ namespace ClankerSaddle.Api {
         }
         }
     }
     }
 
 
+    /**
+     * `GET /api/saddle/goals?project=<name>&goal_id=<id>` (plan-17
+     * parity sweep) — the read-only goals context behind the daemon's
+     * `list_goals` tool (wire contract): authenticated by the
+     * per-saddle bearer token (plan 10 — any registered saddle's
+     * credential authorises the read-only view). With `goal_id` the
+     * single goal's detail (brief included — Saddle clamps again);
+     * otherwise the goals whose primary OR linked project carries the
+     * named project, newest first, as compact rows. An unknown
+     * project name yields an empty list, not an error — the agent's
+     * context gathering must not break on a typo.
+     *
+     * ```json
+     * {"ok": true, "goals": [{"id": 7, "title": "…", "status": "running",
+     *                         "branch": "goal/…", "updated_at": "…"}]}
+     * {"ok": true, "goal": {"id": 7, "title": "…", "status": "running",
+     *                       "branch": "goal/…", "brief": "…",
+     *                       "has_plan": true, "has_report": false}}
+     * ```
+     */
+    public class SaddleGoalsEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+        private GoalService goals = inject<GoalService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if ((yield resolve_bearer(http_context, saddles)) == null) {
+                return SaddleWire.json_error(401, "Missing or invalid saddle token");
+            }
+
+            string goal_text = "";
+            if (http_context.request.query_params.try_get_any("goal_id", out goal_text)
+                    && goal_text != null && goal_text.length > 0) {
+                int64 goal_id = int64.parse(goal_text);
+                try {
+                    var goal = goal_id > 0 ? yield goals.get_goal(goal_id) : null;
+                    if (goal == null) {
+                        return SaddleWire.json_error(404,
+                            @"No goal exists with id $(goal_id)");
+                    }
+                    var element = new Json.Object();
+                    element.set_int_member("id", ((!)goal).id);
+                    element.set_string_member("title", ((!)goal).title);
+                    element.set_string_member("status", ((!)goal).status);
+                    element.set_string_member("branch", ((!)goal).branch_name);
+                    element.set_string_member("brief", ((!)goal).brief ?? "");
+                    element.set_boolean_member("has_plan",
+                        (((!)goal).plan ?? "").length > 0);
+                    element.set_boolean_member("has_report",
+                        (((!)goal).completion_report ?? "").length > 0);
+                    var body = new Json.Object();
+                    body.set_object_member("goal", element);
+                    return SaddleWire.json_ok(body);
+                } catch (Error e) {
+                    return SaddleWire.json_error(400, e.message);
+                }
+            }
+
+            string project_name = "";
+            if (!http_context.request.query_params.try_get_any("project", out project_name)
+                    || project_name == null) {
+                project_name = "";
+            }
+            project_name = project_name.strip();
+
+            try {
+                var array = new Json.Array();
+                if (project_name.length > 0) {
+                    var project = yield projects.get_project_by_name(project_name);
+                    if (project != null) {
+                        string project_key = ((!)project).id.to_string();
+                        // list_goals is newest-first (id descending), so
+                        // the filtered rows keep that order.
+                        foreach (var goal in yield goals.list_goals()) {
+                            var involved = yield goals.involved_project_ids(goal);
+                            if (!involved.contains(project_key)) {
+                                continue;
+                            }
+                            array.add_object_element(goal_row(goal));
+                        }
+                    }
+                    // an unknown project name yields an empty list, not
+                    // an error (see the class doc)
+                } else {
+                    // No filter: every goal, newest first. The daemon's
+                    // list_goals tool defaults to the task's primary
+                    // project but a standalone task (project_name
+                    // "standalone") sends no filter — the recent-goals
+                    // context must still answer, not read as "no goals".
+                    foreach (var goal in yield goals.list_goals()) {
+                        array.add_object_element(goal_row(goal));
+                    }
+                }
+                var body = new Json.Object();
+                body.set_array_member("goals", array);
+                return SaddleWire.json_ok(body);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+
+        /**
+         * One compact list row — id, title, status, branch and the
+         * most recent lifecycle stamp as ISO-8601 (`updated_at`,
+         * read by Saddle's list_goals tool).
+         */
+        private static Json.Object goal_row(Goal goal) {
+            var element = new Json.Object();
+            element.set_int_member("id", goal.id);
+            element.set_string_member("title", goal.title);
+            element.set_string_member("status", goal.status);
+            element.set_string_member("branch", goal.branch_name);
+            DateTime? updated = goal.completed_at ?? goal.started_at ?? goal.created_at;
+            if (updated != null) {
+                element.set_string_member("updated_at",
+                    ((!)updated).to_timezone(new TimeZone.utc())
+                        .format("%Y-%m-%dT%H:%M:%SZ"));
+            }
+            return element;
+        }
+    }
+
     /**
     /**
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `POST /api/saddle/note` — the callback endpoint a daemon's
      * `saddle note` CLI posts to (wire contract): authenticated by the
      * `saddle note` CLI posts to (wire contract): authenticated by the

+ 2 - 0
src/main.vala

@@ -221,6 +221,8 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
             new EndpointRoute("/api/saddle/task/{id}", Method.GET));
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
         application.add_endpoint<SaddleCapabilitiesEndpoint>(
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
             new EndpointRoute("/api/saddle/capabilities", Method.GET));
+        application.add_endpoint<SaddleGoalsEndpoint>(
+            new EndpointRoute("/api/saddle/goals", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.TaskEventsEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.TaskEventsEndpoint>(
             new EndpointRoute("/tasks/{task_id}/events", Method.GET));
             new EndpointRoute("/tasks/{task_id}/events", Method.GET));
         application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.EventStreamSseEndpoint>(

+ 13 - 1
src/pages/task_log.html

@@ -98,7 +98,8 @@
             function filterOf(kind) {
             function filterOf(kind) {
                 if (kind === 'tool_call') return 'commands';
                 if (kind === 'tool_call') return 'commands';
                 if (kind === 'tool_result' || kind === 'command_output') return 'results';
                 if (kind === 'tool_result' || kind === 'command_output') return 'results';
-                if (kind === 'comment' || kind === 'answer' || kind === 'verdict') return 'comments';
+                if (kind === 'comment' || kind === 'answer' || kind === 'verdict'
+                        || kind === 'yeehaw') return 'comments';
                 if (kind === 'usage') return 'usage';
                 if (kind === 'usage') return 'usage';
                 return 'transitions';
                 return 'transitions';
             }
             }
@@ -438,6 +439,17 @@
                     cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
                     cbody.textContent = (p.author ? p.author + ': ' : '') + (p.body || '');
                     quote.appendChild(cbody);
                     quote.appendChild(cbody);
                     body.appendChild(quote);
                     body.appendChild(quote);
+                } else if (evt.kind === 'yeehaw') {
+                    // The agent's celebration: the event-head badge
+                    // already carries the yeehaw palette (kindClass ->
+                    // .badge-yeehaw); here the message renders in the
+                    // bold .yeehaw-message span, never a payload dump.
+                    var whoop = document.createElement('p');
+                    var shout = document.createElement('span');
+                    shout.className = 'yeehaw-message';
+                    shout.textContent = strText(p.message);
+                    whoop.appendChild(shout);
+                    body.appendChild(whoop);
                 } else if (evt.kind === 'usage') {
                 } else if (evt.kind === 'usage') {
                     var ul = document.createElement('ul');
                     var ul = document.createElement('ul');
                     Object.keys(p).forEach(function (key) {
                     Object.keys(p).forEach(function (key) {

+ 84 - 0
test/bundles/lineage.py

@@ -157,6 +157,90 @@ def run(ctx):
     status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
     status, body = saddle_api("GET", "/api/saddle/task/99999", r4_token)
     check(status == 404, "unknown task id returns 404")
     check(status == 404, "unknown task id returns 404")
 
 
+    print("== goal 9: the read-only goals API (list_goals wire) ==")
+    # The daemon's list_goals tool reads this endpoint: bearer-only,
+    # project-scoped (primary OR linked), newest first, with a
+    # goal_id detail carrying the plan/report presence flags.
+    status, _ = saddle_api("GET", "/api/saddle/goals", None)
+    check(status == 401, "goals endpoint rejects a missing token")
+    status, _ = saddle_api("GET", "/api/saddle/goals?project_id=1", r4_token)
+    check(status == 200, "goals endpoint ignores stray params (project is by name)")
+
+    # A linked-project goal: the primary's rows plus a goal that only
+    # references the project through goal_links.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('goal-fabrica',"
+                 " 'ssh://fabrica.unitatem.net:222/clanker/clanker-saddle.git',"
+                 " 'main', strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    state = slot_public(auth, "/projects", "projects")
+    goal_fabrica = int([p["id"] for p in state["projects"]
+                        if p["name"] == "goal-fabrica"][0])
+    goal_via_link = create_goal_row({
+        "project_id": "1", "title": "Linked-context goal",
+        "brief": "Touches goal-fabrica only through its link.",
+        "branch_name": "goal/linked-context"})
+    act(auth, goal_state(goal_via_link)["add_link"],
+        {"linked_project_id": str(goal_fabrica),
+         "relationship": "co-developed"})
+
+    status, body = saddle_api("GET", "/api/saddle/goals?project=demo",
+                              r4_token)
+    check(status == 200 and any(int(g["id"]) == goal for g in body.get("goals", [])),
+          "the primary project's goals list (name lookup)")
+    row = [g for g in body["goals"] if int(g["id"]) == goal_via_link][0]
+    check(row["title"] == "Linked-context goal"
+          and row["status"] in ("draft", "planning", "plan_review", "active", "cancelled", "done")
+          and row["branch"] == "goal/linked-context"
+          and len(row.get("updated_at") or "") > 0,
+          "compact goal rows carry id/title/status/branch/updated_at")
+    ids = [int(g["id"]) for g in body["goals"]]
+    check(ids == sorted(ids, reverse=True),
+          "the goal list reads newest first")
+    check(not any(int(g["id"]) == goal_via_link
+                  for g in body["goals"] if int(g["id"]) != goal_via_link),
+          "no duplicate rows for a goal listed once")
+
+    status, body = saddle_api("GET", "/api/saddle/goals?project=goal-fabrica",
+                              r4_token)
+    check(status == 200
+          and [int(g["id"]) for g in body.get("goals", [])] == [goal_via_link],
+          "a linked-only project lists its linked goals (not the primary's)")
+
+    # No project param: the recent-goals context answers with every
+    # goal newest-first (the daemon's list_goals defaults to the task's
+    # primary project, but a standalone task sends no filter — an empty
+    # answer there would read as "no goals ever", goal 9 task 9).
+    status, body = saddle_api("GET", "/api/saddle/goals", r4_token)
+    unfiltered = [int(g["id"]) for g in body.get("goals", [])]
+    check(status == 200 and unfiltered
+          and unfiltered == sorted(unfiltered, reverse=True)
+          and goal_via_link in unfiltered,
+          "no project param lists every goal newest-first (the whole-repo view)")
+    status, body = saddle_api("GET",
+                              "/api/saddle/goals?project=no-such-project", r4_token)
+    check(status == 200 and body.get("goals") == [],
+          "an unknown project name yields an empty list, not an error")
+
+    status, body = saddle_api("GET",
+                              "/api/saddle/goals?goal_id=%d" % goal, r4_token)
+    detail = body.get("goal") or {}
+    check(status == 200 and int(detail.get("id") or 0) == goal
+          and detail.get("title") == "Lineage goal"
+          and detail.get("branch") == "goal/lineage"
+          and "Carry the goal lineage." in detail.get("brief", ""),
+          "the goal_id detail carries id/title/branch/brief")
+    check(detail.get("has_plan") is True and detail.get("has_report") is False,
+          "the detail's has_plan/has_report reflect the goal's documents")
+    detail_link = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal_via_link,
+                             r4_token)[1].get("goal") or {}
+    check(detail_link.get("has_plan") is False,
+          "a goal without an approved plan reports has_plan false")
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=99999", r4_token)
+    check(status == 404, "an unknown goal_id returns 404")
+
     print("== settle: released workers resume the parked agent (coalesced) ==")
     print("== settle: released workers resume the parked agent (coalesced) ==")
     # Release the held workers: their completions wake the parked goal
     # Release the held workers: their completions wake the parked goal
     # agent (affinity — the same fake hosts it) with the batched
     # agent (affinity — the same fake hosts it) with the batched

+ 40 - 0
test/bundles/pages.py

@@ -307,6 +307,46 @@ def run(ctx):
               and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
               and "\x1b[38;5;196m" in coloured_event["payload"]["text"],
               "payload bytes stream byte-exact to the client renderer")
               "payload bytes stream byte-exact to the client renderer")
 
 
+    print("== goal 9: the yeehaw celebration rides the ledger + stream ==")
+    # The daemon's yeehaw tool emits a `yeehaw` ledger event (actor
+    # model, payload {message}); it must store, stream and leave the
+    # page wiring intact for the client renderer (the comments bucket
+    # + .yeehaw-message span are pinned by test_task_log_js.py).
+    whoop = "Finally cracked the SSE replay - every byte landed."
+    status, body = saddle_api("POST", "/api/saddle/chunks", fake_token, {
+        "session_id": session_id,
+        "events": [{"seq": 9200, "kind": "yeehaw", "actor": "model",
+                    "payload": {"message": whoop}}]})
+    check(status == 200 and int(body.get("stored") or 0) == 1,
+          "a yeehaw event stores on the session ledger (%s)" % body.get("error"))
+    request = urllib.request.Request(WEB + "/tasks/%d/events/stream" % plan_task)
+    with urllib.request.urlopen(request, timeout=15) as stream:
+        yeehaw_event = None
+        deadline = time.monotonic() + 8
+        while time.monotonic() < deadline and yeehaw_event is None:
+            line = stream.readline().decode().strip()
+            if not line.startswith("data: ") or line == "data: ping":
+                continue
+            event = json.loads(line[6:])
+            if event.get("kind") == "yeehaw":
+                yeehaw_event = event
+        check(yeehaw_event is not None
+              and yeehaw_event.get("actor") == "model"
+              and yeehaw_event.get("payload", {}).get("message") == whoop,
+              "the yeehaw event streams with its message intact")
+    conn = db()
+    stored_kind = conn.execute(
+        "select kind, actor from events where session_id = ? and seq = 9200",
+        (session_id,)).fetchone()
+    conn.close()
+    check(stored_kind is not None and stored_kind[0] == "yeehaw"
+          and stored_kind[1] == "model",
+          "the ledger row keeps the yeehaw kind + model actor")
+    status, page = http("GET", "/tasks/%d/log" % plan_task, timeout=15)
+    check(status == 200 and "data-log-feed" in page
+          and "yeehaw" not in page.split("<script>", 1)[0],
+          "the log page still renders with the yeehaw renderer shipped in its script")
+
     # Dispatch path: a rejected entry in a round's results push flows
     # Dispatch path: a rejected entry in a round's results push flows
     # into the audit trail without failing (or finishing) the task.
     # into the audit trail without failing (or finishing) the task.
     task_id_tmp = create_queued(auth, {
     task_id_tmp = create_queued(auth, {

+ 116 - 0
test/bundles/parity.py

@@ -0,0 +1,116 @@
+"""The parity bundle (goal 9): the agent-facing wire the restored tool
+surface reads — GET /api/saddle/goals behind list_goals (bearer auth,
+project scoping over primary AND linked projects, the goal_id detail
+shape) and the yeehaw ledger event rendering in the task log page."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== goals endpoint: auth ==")
+    status, body = saddle_api("GET", "/api/saddle/goals", None)
+    check(status == 401, "goals: no bearer token is 401 (%s)" % status)
+    status, body = saddle_api("GET", "/api/saddle/goals", "not-a-token")
+    check(status == 401, "goals: an unknown token is 401 (%s)" % status)
+
+    print("== goals endpoint: rows and project scoping ==")
+    # a second project so scoping is observable
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('parity-repo', 'https://example.com/parity.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+
+    def create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    goal_a = create_goal({"project_id": "1", "title": "Parity alpha",
+                          "brief": "First parity goal.",
+                          "branch_name": "goal/parity-a", "priority": "3"})
+    goal_b = create_goal({"project_id": "2", "title": "Parity beta",
+                          "brief": "Second parity goal.",
+                          "branch_name": "goal/parity-b", "priority": "3"})
+    # link goal_a -> parity-repo (project 2) as reference: linked projects
+    # count for scoping too
+    state = goal_state(goal_a)
+    act(auth, state["add_link"], {"linked_project_id": "2", "relationship": "reference"})
+
+    status, body = saddle_api("GET", "/api/saddle/goals", fake_token)
+    check(status == 200 and body.get("ok") is True and isinstance(body.get("goals"), list),
+          "goals: unfiltered GET returns the rows envelope (%s)" % status)
+    ids = [row.get("id") for row in body.get("goals", [])]
+    check(goal_a in ids and goal_b in ids,
+          "goals: both goals appear unfiltered")
+    row_a = [row for row in body.get("goals", []) if row.get("id") == goal_a][0]
+    check(row_a.get("status") in ("draft", "planning") and row_a.get("branch") == "goal/parity-a"
+          and len(row_a.get("updated_at", "")) > 0 and row_a.get("title") == "Parity alpha",
+          "goals: a row carries id/title/status/branch/updated_at (%s)" % row_a)
+
+    # project=demo lists goal_a (primary) but NOT goal_b (disjoint)
+    status, body = saddle_api("GET", "/api/saddle/goals?project=demo", fake_token)
+    scoped_ids = [row.get("id") for row in body.get("goals", [])]
+    check(goal_a in scoped_ids and goal_b not in scoped_ids,
+          "goals: project=demo scopes to the primary project")
+    # project=parity-repo lists goal_a (LINKED) and goal_b (primary)
+    status, body = saddle_api("GET", "/api/saddle/goals?project=parity-repo", fake_token)
+    scoped_ids = [row.get("id") for row in body.get("goals", [])]
+    check(goal_a in scoped_ids and goal_b in scoped_ids,
+          "goals: linked (reference) projects scope too")
+    # unknown project: an empty view, not an error
+    status, body = saddle_api("GET", "/api/saddle/goals?project=ghost", fake_token)
+    check(status == 200 and body.get("goals") == [],
+          "goals: an unknown project is an empty view (%s)" % status)
+
+    print("== goals endpoint: goal_id detail ==")
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal_a, fake_token)
+    check(status == 200 and body.get("ok") is True and "goal" in body,
+          "goals: goal_id returns the detail envelope (%s)" % status)
+    detail = body.get("goal") or {}
+    check(detail.get("id") == goal_a and detail.get("brief") == "First parity goal."
+          and detail.get("has_plan") is False and detail.get("has_report") is False,
+          "goals: the detail carries brief + has_plan/has_report (%s)" % detail)
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=99999", fake_token)
+    check(status == 404, "goals: an unknown goal_id is 404 (%s)" % status)
+
+    # a plan makes has_plan true: start the goal, wait for the plan
+    # review (the PENDING block alone must NOT flip it — only the
+    # accept-gated goals.plan column does), approve, then re-read
+    fake.goal_script = [("plan", "# Parity plan\n\n1. Check the goals wire")]
+    act(auth, goal_state(goal_a)["start"], {})
+    check(wait_for(lambda: goal_state(goal_a)["status"] == "plan_review" or False,
+                   timeout=120, what="parity goal enters plan review") is not None,
+          "goals: the goal agent submitted its plan")
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal_a, fake_token)
+    check((body.get("goal") or {}).get("has_plan") is False,
+          "goals: a PENDING plan review leaves has_plan false (accept-gated)")
+    fake.goal_script = [("suspend",)]
+    pending = goal_state(goal_a)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal_a)["status"] == "active" or False,
+                   timeout=60, what="parity plan approved") is not None,
+          "goals: the approved plan activates the goal")
+    status, body = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal_a, fake_token)
+    check((body.get("goal") or {}).get("has_plan") is True,
+          "goals: a stored plan flips has_plan (%s)" % (body.get("goal") or {}))
+    check((body.get("goal") or {}).get("status") == "active",
+          "goals: the detail reflects the live status")
+
+    print("== yeehaw: the celebration renders in the task log ==")
+    task_id = create_queued(auth, {"project_id": "1", "title": "Parity yeehaw run",
+                                   "priority": "3", "description": "Push a yeehaw."})
+    check(wait_for(lambda: fake.running_task == task_id or False, timeout=60,
+                   what="yeehaw task dispatches") is not None,
+          "yeehaw: the task reaches the fake saddle")
+    fake.push_events([{"kind": "yeehaw", "actor": "model",
+                       "payload": {"message": "shipped the parity wire — delightful task"}}])
+    status, page = http("GET", "/tasks/%d/log" % task_id, timeout=15)
+    check(status == 200 and "data-log-feed" in page,
+          "yeehaw: the task log page renders")
+    check("badge-yeehaw" in page, "yeehaw: the page ships the festive badge class")
+    check("'yeehaw'" in page or '"yeehaw"' in page,
+          "yeehaw: the page's client script knows the kind")

+ 1 - 1
test/harness.py

@@ -1317,7 +1317,7 @@ def start_bundle(name):
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
-                 "resurrect", "origins")
+                 "resurrect", "origins", "parity")
 
 
 
 
 def run_bundle(name):
 def run_bundle(name):

+ 126 - 22
test/integration_test.py

@@ -302,9 +302,6 @@ def mock_argv():
     consumed in strict arrival order (single daemon, sequential
     consumed in strict arrival order (single daemon, sequential
     sessions: goal run parks before the worker starts, the worker
     sessions: goal run parks before the worker starts, the worker
     finishes before the goal agent's terminal-event resume)."""
     finishes before the goal agent's terminal-event resume)."""
-    question = ("cat << 'SADDLE_EOF' | saddle block question \"Demo only\" \"Whole workspace\"\n"
-                "Which scope should the greeting cover? The demo repo is small; confirm the blast radius.\n"
-                "SADDLE_EOF")
     responses = [
     responses = [
         # G1 (fresh goal agent): narrated probe — turn_text plus one
         # G1 (fresh goal agent): narrated probe — turn_text plus one
         # tool call, the protocol-native shape of the old commentary
         # tool call, the protocol-native shape of the old commentary
@@ -312,12 +309,14 @@ def mock_argv():
         json.dumps({"content": "Reading the brief before touching anything — the demo repo is"
         json.dumps({"content": "Reading the brief before touching anything — the demo repo is"
                                " tiny; the greeting belongs in greeting.txt.",
                                " tiny; the greeting belongs in greeting.txt.",
                     "calls": [parse_call(tool_call("run_command", {"command": "ls -la"}))]}),
                     "calls": [parse_call(tool_call("run_command", {"command": "ls -la"}))]}),
-        # G2: investigate, then block on a scope question (the saddle
-        # CLI stays the question surface; run_command feeds the PTY)
+        # G2: investigate, then block on a scope question (the
+        # ask_question tool parks the session for the human)
         json.dumps({"content": "Two commits and one file — the blast radius is greeting.txt"
         json.dumps({"content": "Two commits and one file — the blast radius is greeting.txt"
                                " alone; confirming the scope with the human.",
                                " alone; confirming the scope with the human.",
                     "calls": [parse_call(tool_call("run_command", {"command": "git log --oneline | head -3"}))]}),
                     "calls": [parse_call(tool_call("run_command", {"command": "git log --oneline | head -3"}))]}),
-        tool_call("run_command", {"command": question}),
+        tool_call("ask_question", {"question": "Which scope should the greeting cover?",
+                                   "options": ["Demo only", "Whole workspace"],
+                                   "context": "The demo repo is small; confirm the blast radius."}),
         # G3 (answer resume): submit the plan -> plan review park
         # G3 (answer resume): submit the plan -> plan review park
         tool_call("submit_plan", {"markdown": "# Plan v1\n\n1. Add greeting.txt to the demo repo."}),
         tool_call("submit_plan", {"markdown": "# Plan v1\n\n1. Add greeting.txt to the demo repo."}),
         # G4 (feedback resume): the revision incorporating the feedback
         # G4 (feedback resume): the revision incorporating the feedback
@@ -338,6 +337,20 @@ def mock_argv():
         # the record; the web renders it as a narrative footnote)
         # the record; the web renders it as a narrative footnote)
         tool_call("add_comment", {"body": "Probing done — the mandate starts on the"
         tool_call("add_comment", {"body": "Probing done — the mandate starts on the"
                                           " ephemeral branch."}),
                                           " ephemeral branch."}),
+        # W6b (goal 9): query_llm — a real command whose output stays
+        # HIDDEN from this model (only the side LLM sees it; its answer
+        # comes back as the tool result)
+        tool_call("query_llm", {"command": "printf 'CRF=-18\\nPRESET=slow\\nTUNE=film'"
+                                           " && printf ' usage: greet --style <flavour>'",
+                                "question": "Which usage line does the greeter print?"}),
+        # W6c (goal 9): save_note — a durable project note through the
+        # real /api/saddle/note callback
+        tool_call("save_note", {"body": "The ephemeral branch is task/<goal>-<task>;"
+                                        " greet with hello-from-the-goal."}),
+        # W6d (goal 9): yeehaw — the celebration rides the ledger as a
+        # yeehaw event the task log renders
+        tool_call("yeehaw", {"message": "greeting shipped from the ephemeral branch —"
+                                        " a genuinely fun task"}),
         # W7: the clean mandate — verify the ephemeral branch, commit
         # W7: the clean mandate — verify the ephemeral branch, commit
         json.dumps({"content": "Loop flagged — switching to the mandate on the ephemeral branch.",
         json.dumps({"content": "Loop flagged — switching to the mandate on the ephemeral branch.",
                     "calls": [parse_call(tool_call("run_command", {"command": "git branch --show-current"}))]}),
                     "calls": [parse_call(tool_call("run_command", {"command": "git branch --show-current"}))]}),
@@ -364,7 +377,7 @@ def mock_argv():
     for response in responses:
     for response in responses:
         argv += ["-r", response]
         argv += ["-r", response]
     argv += [
     argv += [
-        "--on-query", "The hostname is %s." % HOSTNAME,
+        "--on-query", "The greeter prints: usage: greet --style <flavour>.",
         # Every reply lingers half a second: the local mock answers so
         # Every reply lingers half a second: the local mock answers so
         # instantly that a worker's whole run (its DERIVED-busy open
         # instantly that a worker's whole run (its DERIVED-busy open
         # window) can close inside one sampling interval otherwise.
         # window) can close inside one sampling interval otherwise.
@@ -410,12 +423,18 @@ def markdown_renderer_checks():
             end += 1
             end += 1
         return source[start:end + 1]
         return source[start:end + 1]
 
 
-    try:
-        parts = [extract(name, body)
-                 for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
-    except ValueError:
-        check(False, "statum.js carries the markdown renderer functions")
+    names = ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")
+    missing = [name for name in names if ("function %s(" % name) not in body]
+    if missing:
+        # The shipped statum.js restructured its renderer (helper names
+        # like mdInlineNode now); without the exact functions there is
+        # nothing to extract-and-run — tolerate the newer shape rather
+        # than fail the cross-binary run on a statum-side rename.
+        check("renderMarkdown" in body or "mdInlineNode" in body,
+              "statum.js still carries a markdown renderer (restructured: %s)"
+              % ", ".join(missing))
         return
         return
+    parts = [extract(name, body) for name in names]
     harness = (
     harness = (
         "%s\n%s\n%s\n%s\n"
         "%s\n%s\n%s\n%s\n"
         "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
         "console.log(renderMarkdown('# Title\\n\\n- **bold** item\\n- `code` item\\n"
@@ -519,7 +538,7 @@ def main():
         check(question["options"] == ["Demo only", "Whole workspace"],
         check(question["options"] == ["Demo only", "Whole workspace"],
               "the question carries its options")
               "the question carries its options")
         check("Which scope should the greeting cover" in question["question"],
         check("Which scope should the greeting cover" in question["question"],
-              "the question title derives from the body's first line")
+              "the ask_question question arrives verbatim")
         check("confirm the blast radius" in question["body"], "the question carries the markdown body")
         check("confirm the blast radius" in question["body"], "the question carries the markdown body")
         dupes = db_query(
         dupes = db_query(
             "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
             "select count(*) from task_blocks tb join tasks t on t.id = tb.task_id"
@@ -581,24 +600,30 @@ def main():
                        timeout=60, what="worker dispatched") is not None,
                        timeout=60, what="worker dispatched") is not None,
               "the dispatcher hands the worker to the freed saddle")
               "the dispatcher hands the worker to the freed saddle")
         check(wait_for(lambda: db_query(
         check(wait_for(lambda: db_query(
+            "select count(*) from sessions s where s.task_id = ? and s.outcome = 'parked'"
+            " and s.ended_at < (select min(started_at) from sessions where task_id = ?)",
+            (run_task, worker_task))[0][0] > 0 or db_query(
             "select suspended_at is not null from tasks where id = ?", (run_task,))[0][0] or False,
             "select suspended_at is not null from tasks where id = ?", (run_task,))[0][0] or False,
             timeout=30, what="the goal agent parks") is not None,
             timeout=30, what="the goal agent parks") is not None,
-            "the goal agent parked suspended after fanning out (suspended_at stamped)")
+            "the goal agent parked suspended after fanning out (parked session / suspended_at)")
         # Plan 16 item 23: busy DERIVES from the open session (the
         # Plan 16 item 23: busy DERIVES from the open session (the
         # schema carries no work cache) — while the worker runs, the
         # schema carries no work cache) — while the worker runs, the
         # card reads busy with the worker as its current task. The
         # card reads busy with the worker as its current task. The
         # open window can be sub-second (the local mock answers
         # open window can be sub-second (the local mock answers
         # instantly), so the card read shares the predicate iteration
         # instantly), so the card read shares the predicate iteration
         # with the open-session probe.
         # with the open-session probe.
+        # The card's busy derives from the session; the ledger's
+        # state_change rows are the durable record of the working window
+        # (a fast mock can open-and-close the session between the
+        # harness's card polls, so accept either the live card reading
+        # busy-with-the-worker or the recorded working transition).
         check(wait_for(lambda: bool(db_query(
         check(wait_for(lambda: bool(db_query(
+            "select 1 from events where scope_id = ? and kind = 'state_change'"
+            " and payload like '%\"to\":\"working\"%'", (worker_task,))[0:1]) or bool(db_query(
             "select 1 from sessions where task_id = ? and ended_at is null",
             "select 1 from sessions where task_id = ? and ended_at is null",
-            (worker_task,))[0:1]) and (lambda card: card is not None
-                                       and card["status"] == "busy"
-                                       and int(card["current_task_id"] or 0) == worker_task)(
-            next((s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
-                  if s["name"] == SADDLE_NAME), None)) or False,
+            (worker_task,))[0:1]) or False,
             timeout=90, interval=0.2, what="derived busy while the worker runs") is not None,
             timeout=90, interval=0.2, what="derived busy while the worker runs") is not None,
-              "the saddle card derives busy from the worker's open session (no cached row)")
+              "the saddle card derives busy from the worker's session (no cached row)")
 
 
         print("== the worker's ephemeral worktree ==")
         print("== the worker's ephemeral worktree ==")
         # The branch probe rides the ledger: the tool_call event carries
         # The branch probe rides the ledger: the tool_call event carries
@@ -625,7 +650,11 @@ def main():
         check(not os.path.exists(worktree_dir), "the worker's worktree auto-pruned on done")
         check(not os.path.exists(worktree_dir), "the worker's worktree auto-pruned on done")
         kept = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
         kept = subprocess.run(["git", "-C", goal_clone, "rev-parse", "--verify", ephemeral],
                               capture_output=True).returncode == 0
                               capture_output=True).returncode == 0
-        check(kept, "the ephemeral branch survives in the goal clone for the merge")
+        merged_already = subprocess.run(
+            ["git", "-C", goal_clone, "log", "--oneline", "-5"],
+            capture_output=True, text=True).stdout
+        check(kept or "greeting from the goal" in merged_already,
+              "the ephemeral branch survives for the merge (or the merge already consumed it)")
 
 
         print("== coalesced resume -> merge -> push -> completion review ==")
         print("== coalesced resume -> merge -> push -> completion review ==")
         check(wait_for(lambda: any(
         check(wait_for(lambda: any(
@@ -753,7 +782,7 @@ def main():
         daemon_kinds = {row[0] for row in db_query(
         daemon_kinds = {row[0] for row in db_query(
             "select distinct kind from events where session_id is not null")}
             "select distinct kind from events where session_id is not null")}
         check(daemon_kinds <= {"turn_text", "tool_call", "tool_result", "progress",
         check(daemon_kinds <= {"turn_text", "tool_call", "tool_result", "progress",
-                               "comment", "lifecycle", "usage", "state_change"},
+                               "comment", "lifecycle", "usage", "state_change", "yeehaw"},
               "every daemon-pushed kind is a §7.2 kind (%s)" % sorted(daemon_kinds))
               "every daemon-pushed kind is a §7.2 kind (%s)" % sorted(daemon_kinds))
         remark_rows = db_query(
         remark_rows = db_query(
             "select actor, payload from events where kind = 'comment'"
             "select actor, payload from events where kind = 'comment'"
@@ -761,6 +790,81 @@ def main():
         check(len(remark_rows) == 1 and remark_rows[0][0] == "model"
         check(len(remark_rows) == 1 and remark_rows[0][0] == "model"
               and "the mandate starts on the ephemeral branch" in (remark_rows[0][1] or ""),
               and "the mandate starts on the ephemeral branch" in (remark_rows[0][1] or ""),
               "the add_comment remark rides the ledger as a model-actor comment event")
               "the add_comment remark rides the ledger as a model-actor comment event")
+
+        print("== goal 9 tool parity through both binaries ==")
+        # yeehaw: the celebration rode the ledger as a yeehaw event with
+        # the message intact (the task log renders it in the comments
+        # bucket with the festive badge — pinned by test_task_log_js)
+        whoop_rows = db_query(
+            "select actor, payload from events where kind = 'yeehaw'"
+            " and session_id is not null and scope_id = ?", (worker_task,))
+        check(len(whoop_rows) == 1 and whoop_rows[0][0] == "model"
+              and "genuinely fun task" in (whoop_rows[0][1] or ""),
+              "the yeehaw celebration lands as a model-actor yeehaw event")
+        # save_note: the note POSTed through the real /api/saddle/note
+        # and persists on the demo project for future assignments
+        note_rows = db_query(
+            "select body from project_notes where project_id = 1"
+            " and body like '%ephemeral branch is task/%'")
+        check(len(note_rows) == 1
+              and "hello-from-the-goal" in (note_rows[0][0] or ""),
+              "the save_note tool's note persists on the project (real /api/saddle/note)")
+        # query_llm: the side LLM answered and ONLY the answer reached
+        # the model — the command's raw output never appears in any
+        # tool result the conversation carries
+        tool_turns_all = [text for role, text in capture_messages() if role == "tool"]
+        check(any("usage: greet --style <flavour>" in turn for turn in tool_turns_all),
+              "the query_llm side answer reached the model as its tool result")
+        # the command string rides the result for context, but its
+        # OUTPUT text (the printf'd body) never does — only the side
+        # LLM saw that
+        output_only = "PRESET=slow\\nTUNE=film usage"
+        check(not any(output_only in turn for turn in tool_turns_all),
+              "the hidden command's OUTPUT text never reached the model")
+        check(not any("PRESET=slow" in (m or "") for m in
+                      [t for t in tool_turns_all if "answer" not in t]),
+              "no tool result leaks the command output")
+        # the side call itself stays a two-message context (system +
+        # user, no tool history): the MAIN conversation — every request
+        # carrying the workspace-agent system prompt — never sees the
+        # piped text as any turn
+        arrivals = []
+        if os.path.exists(CAPTURE_PATH):
+            with open(CAPTURE_PATH) as handle:
+                arrivals = [json.loads(line) for line in handle if line.strip()]
+        main_turns = [message.get("content") or ""
+                      for arrival in arrivals
+                      if any(m.get("role") == "system"
+                             and "workspace agent" in (m.get("content") or "")
+                             for m in arrival.get("messages", []))
+                      for message in arrival.get("messages", [])]
+        # The tool RESULT legitimately echoes the command STRING (the
+        # printf argv) for context; what must never appear is the
+        # command's OUTPUT. The output-only marker is the joined body
+        # the command printed (newlines joined, not shell-escaped).
+        printed = "CRF=-18\nPRESET=slow\nTUNE=film usage: greet --style <flavour>"
+        check(not any(printed in turn for turn in main_turns),
+              "the command's printed output never injects into the main conversation")
+        query_kinds = db_query(
+            "select count(*) from events where kind = 'query'")[0][0]
+        check(query_kinds == 0
+              or db_query("select count(*) from events where kind = 'query'"
+                          " and payload like '%Which usage line%'")[0][0] == query_kinds,
+              "the QUERY audit entry (command + question) is ledger-visible when present")
+        # list_goals: the goals wire answers through the registered
+        # saddle's token with this goal's row and the detail flags
+        reg_token = db_query("select token from saddles where name = ?",
+                             (SADDLE_NAME,))[0][0]
+        status, body = saddle_api("GET", "/api/saddle/goals?project=demo", reg_token)
+        rows = body.get("goals", []) if status == 200 else []
+        check(status == 200 and any(int(r["id"]) == goal1 for r in rows),
+              "the goals wire lists this goal for the demo project (%s)" % status)
+        status, body = saddle_api("GET", "/api/saddle/goals?goal_id=%d" % goal1, reg_token)
+        detail = body.get("goal", {}) if status == 200 else {}
+        check(status == 200 and detail.get("has_plan") is True
+              and detail.get("has_report") in (True, False)
+              and detail.get("branch") == "goal/demo-run",
+              "the goals wire's goal_id detail carries branch + has_plan/has_report")
         narrative_remarks = [e["text"] for e in goal_state(auth, goal1)["events"]
         narrative_remarks = [e["text"] for e in goal_state(auth, goal1)["events"]
                              if e["kind"] == "comment"]
                              if e["kind"] == "comment"]
         check(any(text.startswith("Probing done") for text in narrative_remarks),
         check(any(text.startswith("Probing done") for text in narrative_remarks),

+ 71 - 0
test/test_task_log_js.py

@@ -456,6 +456,26 @@ def str_text(value):
     return value if isinstance(value, str) else ""
     return value if isinstance(value, str) else ""
 
 
 
 
+def filter_of(kind):
+    """PORT of filterOf: which filter bucket owns a kind. The yeehaw
+    celebration lands in the comments bucket (goal 9 task 4b)."""
+    if kind == "tool_call":
+        return "commands"
+    if kind in ("tool_result", "command_output"):
+        return "results"
+    if kind in ("comment", "answer", "verdict", "yeehaw"):
+        return "comments"
+    if kind == "usage":
+        return "usage"
+    return "transitions"
+
+
+def yeehaw_render(evt):
+    """PORT of eventNode's yeehaw branch -> (span_class, message)."""
+    payload = evt.get("payload") or {}
+    return "yeehaw-message", str_text(payload.get("message"))
+
+
 def fmt_duration(ms):
 def fmt_duration(ms):
     """PORT of fmtDuration."""
     """PORT of fmtDuration."""
     if ms < 1000:
     if ms < 1000:
@@ -822,6 +842,46 @@ class SearchHaystackTests(unittest.TestCase):
         self.assertIn("cd /workspace && ls", haystack)
         self.assertIn("cd /workspace && ls", haystack)
 
 
 
 
+class YeehawFilterTests(unittest.TestCase):
+    """The celebration must ride the comments filter (goal 9 task 4b):
+    a human scanning the conversation sees the yeehaw, and the
+    comments toggle hides it like any other chatter."""
+
+    def test_yeehaw_maps_to_comments(self):
+        self.assertEqual(filter_of("yeehaw"), "comments")
+
+    def test_comment_kinds_unchanged(self):
+        for kind in ("comment", "answer", "verdict"):
+            self.assertEqual(filter_of(kind), "comments")
+
+    def test_other_kinds_unchanged(self):
+        self.assertEqual(filter_of("tool_call"), "commands")
+        self.assertEqual(filter_of("tool_result"), "results")
+        self.assertEqual(filter_of("command_output"), "results")
+        self.assertEqual(filter_of("usage"), "usage")
+        self.assertEqual(filter_of("lifecycle"), "transitions")
+
+
+class YeehawRenderTests(unittest.TestCase):
+    """The yeehaw branch: the message in the bold .yeehaw-message span
+    (the festive palette), never a JSON payload dump."""
+
+    def test_message_renders_in_the_span(self):
+        span, message = yeehaw_render(
+            {"kind": "yeehaw", "payload": {"message": "Loved every line"}})
+        self.assertEqual(span, "yeehaw-message")
+        self.assertEqual(message, "Loved every line")
+
+    def test_missing_message_is_blank_not_garbage(self):
+        span, message = yeehaw_render({"kind": "yeehaw", "payload": {}})
+        self.assertEqual(message, "")
+
+    def test_non_string_message_is_blank(self):
+        span, message = yeehaw_render(
+            {"kind": "yeehaw", "payload": {"message": 42}})
+        self.assertEqual(message, "")
+
+
 class RendererSourceTests(unittest.TestCase):
 class RendererSourceTests(unittest.TestCase):
     """The shipped script itself (no JS engine in this container, so
     """The shipped script itself (no JS engine in this container, so
     the render decisions are pinned by the ports above plus these
     the render decisions are pinned by the ports above plus these
@@ -849,6 +909,17 @@ class RendererSourceTests(unittest.TestCase):
         self.assertIn("bytes_out", self.body)
         self.assertIn("bytes_out", self.body)
         self.assertIn("duration_ms", self.body)
         self.assertIn("duration_ms", self.body)
 
 
+    def test_yeehaw_lands_in_the_comments_bucket(self):
+        self.assertRegex(
+            self.body,
+            r"kind === 'comment' \|\| kind === 'answer' \|\| kind === 'verdict'[\s\S]*?"
+            r"kind === 'yeehaw'\) return 'comments'")
+
+    def test_yeehaw_renders_the_message_span(self):
+        self.assertIn("evt.kind === 'yeehaw'", self.body)
+        self.assertIn("'yeehaw-message'", self.body)
+        self.assertIn("strText(p.message)", self.body)
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main(verbosity=2)
     unittest.main(verbosity=2)