ソースを参照

Merge branch 'goal/project-knowledge' of clanker/clanker-saddle into web-stack

Billy Barrow 4 週間 前
親
コミット
929ad9ba67

+ 13 - 0
meson.build

@@ -122,6 +122,12 @@ rules_page = custom_target('rules-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'RulesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+discoveries_page = custom_target('discoveries-page',
+    input: 'src/pages/discoveries.html',
+    output: 'DiscoveriesPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'DiscoveriesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 users_page = custom_target('users-page',
     input: 'src/pages/users.html',
     output: 'UsersPage.vala',
@@ -185,6 +191,8 @@ app_sources = files(
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0002_BatchImprovements.vala',
     'src/Data/Migrations/M0003_EventIndexes.vala',
+    'src/Data/Migrations/M0004_ProjectUnderstandings.vala',
+    'src/Data/Migrations/M0005_Discoveries.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -198,6 +206,8 @@ app_sources = files(
     'src/Services/RuleService.vala',
     'src/Services/StackService.vala',
     'src/Services/NoteService.vala',
+    'src/Services/UnderstandingService.vala',
+    'src/Services/DiscoveryService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
@@ -212,6 +222,7 @@ app_sources = files(
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
+    'src/actions/DiscoveryActions.vala',
     'src/actions/StackActions.vala',
     'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
@@ -227,6 +238,7 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/DiscoveriesEntrypoint.vala',
     'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
@@ -250,6 +262,7 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    discoveries_page,
     stacks_page,
     stack_detail_page,
     users_page,

+ 269 - 0
src/Api/SaddleApi.vala

@@ -414,6 +414,275 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * The understanding callback pair (goal 24):
+     *
+     * `POST /api/saddle/understanding` — what an understanding task's
+     * daemon POSTs after exploring a project (wire contract),
+     * authenticated by the per-saddle bearer token like
+     * {@link SaddleNoteEndpoint}:
+     *
+     * ```json
+     * {"task_id": 7, "project": "ZL4GQ", "body": "…",
+     *  "commit_hash": "abc1234"}
+     * ```
+     *
+     * `task_id` and `commit_hash` are optional (stored as the row's
+     * source task and stamp); the project is resolved by name and
+     * must exist, the body must be non-empty. The insert APPENDS a
+     * history row — the project's current understanding is its
+     * newest. Response `{"ok": true, "understanding_id": N}`.
+     *
+     * `GET /api/saddle/understanding?project=NAME` — reads the
+     * project's CURRENT understanding back:
+     * `{"ok": true, "understanding": {"body": "…", "commit_hash":
+     * "…", "created": "…"} }`, or `"understanding": null` when the
+     * project has none (the daemon's read_linked_understanding tool
+     * consumes this for linked projects).
+     */
+    public class SaddleUnderstandingEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
+
+        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");
+            }
+            if (http_context.request.method == Method.GET) {
+                return yield handle_read(http_context);
+            }
+            return yield handle_write(http_context);
+        }
+
+        /** The GET branch: the project's current understanding, or null. */
+        private async HttpResult handle_read(HttpContext http_context) throws Error {
+            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();
+            if (project_name.length == 0) {
+                return SaddleWire.json_error(400,
+                    "A project query parameter is required");
+            }
+            try {
+                var project = yield projects.get_project_by_name(project_name);
+                if (project == null) {
+                    return SaddleWire.json_error(400,
+                        @"No project exists named '$(project_name)'");
+                }
+                var current = yield understandings.current_understanding(
+                    ((!)project).id);
+                var body = new Json.Object();
+                if (current != null) {
+                    var element = new Json.Object();
+                    element.set_string_member("body", ((!)current).body);
+                    element.set_string_member("commit_hash", ((!)current).commit_hash);
+                    if (((!)current).created_at != null) {
+                        element.set_string_member("created",
+                            (((!)current).created_at).to_timezone(new TimeZone.utc())
+                                .format("%Y-%m-%dT%H:%M:%SZ"));
+                    }
+                    body.set_object_member("understanding", element);
+                } else {
+                    body.set_null_member("understanding");
+                }
+                return SaddleWire.json_ok(body);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+
+        /** The POST branch: append a history row. */
+        private async HttpResult handle_write(HttpContext http_context) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleWire.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+
+            string project_name = EntryJson.get_string_or(body, "project", "");
+            string understanding_body = EntryJson.get_string_or(body, "body", "");
+            string commit_hash = EntryJson.get_string_or(body, "commit_hash", "");
+            int64 task_id = body.has_member("task_id")
+                && body.get_member("task_id").get_value_type() == typeof(int64)
+                ? body.get_int_member("task_id") : 0;
+            if (understanding_body.strip().length == 0) {
+                return SaddleWire.json_error(400, "An understanding body is required");
+            }
+
+            try {
+                var project = yield projects.get_project_by_name(project_name);
+                if (project == null) {
+                    return SaddleWire.json_error(400,
+                        @"No project exists named '$(project_name)'");
+                }
+                var row = yield understandings.insert(((!)project).id,
+                    understanding_body, commit_hash, task_id);
+                var response = new Json.Object();
+                response.set_int_member("understanding_id", row.id);
+                return SaddleWire.json_ok(response);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+    }
+
+    /**
+     * The discoveries callback pair (goal 24) — the fleet's global,
+     * EDITABLE knowledge base of non-project facts (compiler quirks,
+     * library behaviours, tool flags, protocol gotchas), consumed by
+     * the daemon's search_discoveries / save_discovery tools:
+     *
+     * `POST /api/saddle/discovery` — inserts a new row:
+     *
+     * ```json
+     * {"task_id": 7, "body": "meson setup requires ..."}
+     * ```
+     *
+     * `task_id` is optional (stored as the row's source task). The
+     * body must be non-empty. Response `{"ok": true, "discovery_id":
+     * N}` — the id is what a later refine round POSTs to the update
+     * endpoint.
+     *
+     * `GET /api/saddle/discoveries?q=...` — lists discoveries newest
+     * first, filtered to a case-insensitive substring match on the
+     * body when `q` is non-empty; empty `q` lists the newest rows.
+     * Response `{"ok": true, "discoveries": [{"id": N, "body": "...",
+     * "created": "...", "updated": "..."}]}`, capped at 20 rows.
+     */
+    public class SaddleDiscoveryEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private DiscoveryService discoveries = inject<DiscoveryService>();
+
+        /** How many rows the search answers at most. */
+        public const int SEARCH_LIMIT = 20;
+
+        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");
+            }
+            if (http_context.request.method == Method.GET) {
+                return yield handle_search(http_context);
+            }
+            return yield handle_insert(http_context);
+        }
+
+        /** The GET branch: search (or the newest rows when q is empty). */
+        private async HttpResult handle_search(HttpContext http_context) throws Error {
+            string query = "";
+            if (!http_context.request.query_params.try_get_any("q", out query)
+                    || query == null) {
+                query = "";
+            }
+            try {
+                var rows = yield discoveries.search(query, SEARCH_LIMIT);
+                var array = new Json.Array();
+                foreach (var row in rows) {
+                    var element = new Json.Object();
+                    element.set_int_member("id", row.id);
+                    element.set_string_member("body", row.body);
+                    if (row.created_at != null) {
+                        element.set_string_member("created",
+                            (((!)row.created_at)).to_timezone(new TimeZone.utc())
+                                .format("%Y-%m-%dT%H:%M:%SZ"));
+                    }
+                    if (row.updated_at != null) {
+                        element.set_string_member("updated",
+                            (((!)row.updated_at)).to_timezone(new TimeZone.utc())
+                                .format("%Y-%m-%dT%H:%M:%SZ"));
+                    }
+                    array.add_object_element(element);
+                }
+                var body = new Json.Object();
+                body.set_array_member("discoveries", array);
+                return SaddleWire.json_ok(body);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+
+        /** The POST branch: insert a new discovery. */
+        private async HttpResult handle_insert(HttpContext http_context) throws Error {
+            Json.Object body;
+            try {
+                body = yield SaddleWire.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+
+            string discovery_body = EntryJson.get_string_or(body, "body", "");
+            int64 task_id = body.has_member("task_id")
+                && body.get_member("task_id").get_value_type() == typeof(int64)
+                ? body.get_int_member("task_id") : 0;
+            if (discovery_body.strip().length == 0) {
+                return SaddleWire.json_error(400, "A discovery body is required");
+            }
+
+            try {
+                var row = yield discoveries.insert(discovery_body, task_id);
+                var response = new Json.Object();
+                response.set_int_member("discovery_id", row.id);
+                return SaddleWire.json_ok(response);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+    }
+
+    /**
+     * `POST /api/saddle/discovery/update` — REFINES an existing
+     * discovery in place (goal 24): `{"id": N, "body": "..."}`.
+     * The body is replaced and updated_at bumped; created_at and the
+     * original source task stay frozen. An unknown id answers an
+     * error JSON. This is how nuance accumulates in one note instead
+     * of stacking near-duplicates.
+     */
+    public class SaddleDiscoveryUpdateEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private DiscoveryService discoveries = inject<DiscoveryService>();
+
+        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");
+            }
+
+            Json.Object body;
+            try {
+                body = yield SaddleWire.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+
+            int64 id = body.has_member("id")
+                && body.get_member("id").get_value_type() == typeof(int64)
+                ? body.get_int_member("id") : 0;
+            string discovery_body = EntryJson.get_string_or(body, "body", "");
+            if (id <= 0) {
+                return SaddleWire.json_error(400, "A discovery id is required");
+            }
+            if (discovery_body.strip().length == 0) {
+                return SaddleWire.json_error(400, "A discovery body is required");
+            }
+
+            try {
+                yield discoveries.update(id, discovery_body);
+                return SaddleWire.json_ok(new Json.Object());
+            } catch (Error e) {
+                return SaddleWire.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
      * The plan-12 task CLI callbacks (`task create/delete/enqueue`):
      * the goal agent's synchronous task operations against its own

+ 65 - 0
src/Data/Entities.vala

@@ -419,6 +419,71 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * One historical revision of a project's Understanding (goal 24):
+     * a markdown document summarising the project's structure and
+     * anything unusual worth knowing, stamped with the commit hash it
+     * was written against. Rows are immutable — a refresh INSERTS a
+     * new revision — and the project's "current" understanding is its
+     * newest row (see {@link ClankerSaddle.Services.UnderstandingService}).
+     * Stored in the `project_understandings` table.
+     */
+    public class ProjectUnderstanding : Object {
+
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public string body { get; set; default = ""; }
+        /** The commit the document was written against ("" when unknown). */
+        public string commit_hash { get; set; default = ""; }
+        /** The understanding task that wrote the row (0 = human/unknown). */
+        public int64 task_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<ProjectUnderstanding> cfg) {
+            cfg.table("project_understandings")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<string>("commit_hash", o => o.commit_hash, (o, v) => o.commit_hash = v)
+                .column<int64?>("task_id", o => (int64?) o.task_id,
+                    (o, v) => o.task_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * One row of the discoveries ledger (goal 24): the fleet's global,
+     * EDITABLE knowledge base of non-project facts — compiler quirks,
+     * library behaviours, tool flags, protocol gotchas. Unlike the
+     * immutable Understanding history, a discovery is LIVING: agents
+     * refine it in place through the update callback (the body is
+     * replaced, {@link updated_at} bumped) and humans edit or delete
+     * rows from the Discoveries page, so nuance accumulates in one
+     * note instead of stacking near-duplicates. Stored in the
+     * `discoveries` table.
+     */
+    public class Discovery : Object {
+
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        /** The task whose agent wrote the row (0 = human/unknown). */
+        public int64 source_task_id { get; set; default = 0; }
+        /** When the row was first written — frozen. */
+        public DateTime? created_at { get; set; default = null; }
+        /** When the body was last refined (equals created_at until then). */
+        public DateTime? updated_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Discovery> cfg) {
+            cfg.table("discoveries")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<int64?>("source_task_id", o => (int64?) o.source_task_id,
+                    (o, v) => o.source_task_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v)
+                .column<DateTime?>("updated_at", o => o.updated_at, (o, v) => o.updated_at = v);
+        }
+    }
+
     /**
      * A dashboard scratchpad note (goal 8 task 3): a personal
      * todo/idea jotted down before a batch of changes is ready to be

+ 103 - 0
src/Data/Migrations/M0004_ProjectUnderstandings.vala

@@ -0,0 +1,103 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 24 (project knowledge): the project Understanding ledger.
+     *
+     * A project's Understanding is a markdown document summarising the
+     * structure of the project and anything unusual worth knowing
+     * before working in it, stamped with the commit hash it was
+     * written against. The Refresh flow spins up a special
+     * understanding task; its agent POSTs the fresh document to
+     * `/api/saddle/understanding`, which INSERTS a row here — rows
+     * are immutable history, and a project's "current" understanding
+     * is simply its newest row (the refresh agent also receives the
+     * previous commit hash and document so it can diff the Git
+     * history between the two).
+     *
+     * The current understanding rides every assignment payload for
+     * the project (capped), and the daemon renders it into the system
+     * prompt next to the project notes.
+     *
+     * IDEMPOTENCE: same contract as M0002/M0003 — the runner invokes
+     * up() BEFORE opening the apply transaction (the builder only
+     * queues), so up() introspects sqlite_master through a separate
+     * read-only handle and queues the table only when the schema
+     * still lacks it. A fresh database queues it; a database that
+     * already has it (defensive: this build's DB handed back to an
+     * older serial, then forward again) queues nothing and records
+     * as a no-op.
+     */
+    public class M0004_ProjectUnderstandings : Migration {
+
+        public override string name { get { return "Project understanding history"; } }
+        public override uint64 serial { get { return 4; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The database this migration is upgrading — same resolution
+        // as M0002/M0003 (the runner never hands up() a connection,
+        // so the schema is read through a separate read-only handle
+        // on the same SPRY_DB_PATH main.vala connects).
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool table_exists(string name) {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                // Unopenable (locked, or missing — a database the
+                // runner is about to create): treat as absent and let
+                // the queued operation fail loudly rather than
+                // silently skipping an upgrade.
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            statement.bind_text(1, name, -1);
+            if (statement.step() != Sqlite.ROW) {
+                return false;
+            }
+            return statement.column_int64(0) > 0;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (table_exists("project_understandings")) {
+                return;
+            }
+            b.create_table("project_understandings", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("body")
+                    .not_null();
+                t.column<string>("commit_hash")
+                    .not_null()
+                    .default_value<string>("");
+                t.column<int64?>("task_id")
+                    .not_null()
+                    .default_value<int64?>(0);
+                t.column<DateTime?>("created_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("project_understandings");
+        }
+    }
+}

+ 96 - 0
src/Data/Migrations/M0005_Discoveries.vala

@@ -0,0 +1,96 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 24 (project knowledge): the discoveries ledger — the
+     * fleet's global, EDITABLE knowledge base of non-project facts
+     * (compiler quirks, library behaviours, tool flags, protocol
+     * gotchas).
+     *
+     * Unlike the immutable Understanding history, a discovery row is
+     * LIVING: agents refine it in place (POST /api/saddle/discovery
+     * /update replaces the body, bumping updated_at) so nuance
+     * accumulates in one note instead of stacking near-duplicates,
+     * and humans edit or delete rows from the web UI's Discoveries
+     * page. created_at and source_task_id stay frozen at the original
+     * insert; the recent rows ride every assignment payload (capped)
+     * and the daemon directs agents to SEARCH before working with a
+     * technology (see the Saddle daemon's system prompt).
+     *
+     * IDEMPOTENCE: same contract as M0002/M0003/M0004 — the runner
+     * invokes up() BEFORE opening the apply transaction (the builder
+     * only queues), so up() introspects sqlite_master through a
+     * separate read-only handle and queues the table only when the
+     * schema still lacks it. A fresh database queues it; a database
+     * that already has it (defensive: this build's DB handed back to
+     * an older serial, then forward again) queues nothing and records
+     * as a no-op.
+     */
+    public class M0005_Discoveries : Migration {
+
+        public override string name { get { return "Discovery knowledge base"; } }
+        public override uint64 serial { get { return 5; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The database this migration is upgrading — same resolution
+        // as M0002/M0003/M0004 (the runner never hands up() a
+        // connection, so the schema is read through a separate
+        // read-only handle on the same SPRY_DB_PATH main.vala
+        // connects).
+        private string db_path() {
+            var path = Environment.get_variable("SPRY_DB_PATH") ?? "";
+            if (path.strip().length == 0) {
+                return "./clanker-saddle.sqlite";
+            }
+            return path;
+        }
+
+        private bool table_exists(string name) {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                // Unopenable (locked, or missing — a database the
+                // runner is about to create): treat as absent and let
+                // the queued operation fail loudly rather than
+                // silently skipping an upgrade.
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            statement.bind_text(1, name, -1);
+            if (statement.step() != Sqlite.ROW) {
+                return false;
+            }
+            return statement.column_int64(0) > 0;
+        }
+
+        public override void up(MigrationBuilder b) {
+            if (table_exists("discoveries")) {
+                return;
+            }
+            b.create_table("discoveries", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("body")
+                    .not_null();
+                t.column<int64?>("source_task_id")
+                    .not_null()
+                    .default_value<int64?>(0);
+                t.column<DateTime?>("created_at");
+                t.column<DateTime?>("updated_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("discoveries");
+        }
+    }
+}

+ 13 - 0
src/Json.vala

@@ -150,6 +150,19 @@ namespace ClankerSaddle {
             return new JsonElement.from_elements(arr);
         }
 
+        /**
+         * A single row model as a slot public-data value (the object
+         * twin of {@link list}): `dict[\"current\"] = StateJson.element(row);`
+         * — null-safe, a null row maps to a JSON null.
+         */
+        public static JsonElement? element(Object? item) throws GLib.Error {
+            if (item == null) {
+                return null;
+            }
+            var props = GObjectMapping.to_properties((!)item);
+            return new JsonElement.from_properties(props);
+        }
+
         /**
          * A JSON array of plain strings (e.g. a block's multiple-choice
          * options).

+ 129 - 0
src/Services/DiscoveryService.vala

@@ -0,0 +1,129 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The discoveries ledger (goal 24): the fleet's global, EDITABLE
+     * knowledge base of non-project facts — compiler quirks, library
+     * behaviours, tool flags, protocol gotchas. Where an
+     * Understanding row is an immutable revision in a per-project
+     * history, a discovery is a LIVING note: {@link update} refines
+     * the body in place (bumping updated_at, keeping created_at and
+     * the original source task) so nuance accumulates in one row
+     * instead of stacking near-duplicates. Agents insert and update
+     * through the `/api/saddle/discovery*` callbacks; humans edit
+     * and delete from the web UI's Discoveries page. Deletion is
+     * human-side only.
+     */
+    public class DiscoveryService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * The newest rows first — the Discoveries page's listing and
+         * the assignment payload's recent slice both read this
+         * (capped by their callers).
+         */
+        public async ImmutableLot<Discovery> list_newest_first(int limit) throws Error {
+            var query = db.query<Discovery>()
+                .order_by_desc(expr("id"));
+            if (limit > 0) {
+                query.limit((int64) limit);
+            }
+            return yield query.materialise_async();
+        }
+
+        /**
+         * Case-insensitive substring search over the bodies,
+         * newest first: what the daemon's search_discoveries tool
+         * and the page's search box call. The query is trimmed;
+         * an empty query lists the newest rows like
+         * {@link list_newest_first}.
+         */
+        public async ImmutableLot<Discovery> search(string query, int limit) throws Error {
+            string needle = query.strip().down();
+            var rows = yield list_newest_first(-1);
+            var matched = new Vector<Discovery>();
+            foreach (var row in rows) {
+                if (needle.length == 0 || row.body.down().contains(needle)) {
+                    matched.add(row);
+                    if (limit > 0 && matched.length >= limit) {
+                        break;
+                    }
+                }
+            }
+            return matched.to_immutable_buffer();
+        }
+
+        /**
+         * Loads the row identified by `id`, or null.
+         */
+        public async Discovery? get_discovery(int64 id) throws Error {
+            return yield db.query<Discovery>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /**
+         * Inserts a new discovery. The body is trimmed and must be
+         * non-empty; `task_id` is 0 for human-authored rows.
+         * updated_at starts equal to created_at (the row has not
+         * been refined yet).
+         */
+        public async Discovery insert(string body, int64 task_id) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 99,
+                    "A discovery body is required");
+            }
+            var now = new DateTime.now_local();
+            var row = new Discovery() {
+                body = trimmed,
+                source_task_id = task_id,
+                created_at = now,
+                updated_at = now
+            };
+            yield db.insert_async<Discovery>(row);
+            return row;
+        }
+
+        /**
+         * Replaces the body of the discovery identified by `id`,
+         * bumping updated_at and leaving created_at and
+         * source_task_id frozen. Throws when the id is unknown or
+         * the body empty.
+         */
+        public async void update(int64 id, string body) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 99,
+                    "A discovery body is required");
+            }
+            var row = yield get_discovery(id);
+            if (row == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 99,
+                    @"No discovery exists with id $id");
+            }
+            ((!)row).body = trimmed;
+            ((!)row).updated_at = new DateTime.now_local();
+            yield db.update_async<Discovery>((!)row);
+        }
+
+        /**
+         * Deletes the row (idempotent: an absent row is fine). Only
+         * the web UI calls this — the agent-side API has no delete,
+         * so curation stays human.
+         */
+        public async void delete(int64 id) throws Error {
+            var row = yield get_discovery(id);
+            if (row != null) {
+                yield db.delete_async<Discovery>((!)row);
+            }
+        }
+    }
+}

+ 74 - 0
src/Services/SaddleService.vala

@@ -37,6 +37,25 @@ namespace ClankerSaddle.Services {
     public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
 
+    /**
+     * Cap of the `understanding` member in the /start payload: the
+     * primary project's current understanding document (goal 24).
+     * The whole document rides the payload and renders into the
+     * system prompt next to the project notes, so an over-grown
+     * revision is head-clamped rather than bloating every prompt.
+     */
+    public const int64 UNDERSTANDING_MAX_CHARS = 16000;
+
+    /**
+     * Caps of the `discoveries` array in the /start payload (goal 24):
+     * the fleet's newest discoveries — non-project facts earlier
+     * agents recorded — so a fresh agent inherits the knowledge base
+     * before it ever searches. Head-kept char cap across the whole
+     * array, like PROJECT_NOTES_MAX_CHARS.
+     */
+    public const int64 DISCOVERIES_MAX_ENTRIES = 10;
+    public const int64 DISCOVERIES_MAX_CHARS = 8000;
+
     /**
      * Upper bound of a saddle's prompt_note served to the daemon (goal 8
      * task 5): the note rides EVERY assignment payload, so a fat note
@@ -145,6 +164,8 @@ namespace ClankerSaddle.Services {
         private TaskService task_service = inject<TaskService>();
         private GoalService goal_service = inject<GoalService>();
         private EventService event_service = inject<EventService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
+        private DiscoveryService discoveries = inject<DiscoveryService>();
         private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
         /**
@@ -1118,6 +1139,59 @@ namespace ClankerSaddle.Services {
             }
             payload.end_array();
 
+            // The wire contract's `understanding` addition (goal 24):
+            // the PRIMARY project's current understanding document —
+            // a markdown summary of the project's structure and its
+            // unusual corners, stamped with the commit it was written
+            // against. Present but empty when the project has none;
+            // head-clamped at UNDERSTANDING_MAX_CHARS.
+            string understanding_body = "";
+            if (project != null) {
+                var current_understanding = yield understandings
+                    .current_understanding(((!)project).id);
+                if (current_understanding != null) {
+                    understanding_body = ((!)current_understanding).body;
+                    if (understanding_body.length > UNDERSTANDING_MAX_CHARS) {
+                        understanding_body = understanding_body.substring(0,
+                            (int) UNDERSTANDING_MAX_CHARS);
+                    }
+                }
+            }
+            payload.set_member_name("understanding");
+            payload.add_string_value(understanding_body);
+
+            // The wire contract's `discoveries` addition (goal 24):
+            // the fleet's NEWEST discoveries — non-project facts
+            // (compiler quirks, library behaviours, tool flags,
+            // protocol gotchas) earlier agents recorded — newest
+            // first, capped at 10 entries / 8 000 chars (head kept),
+            // so a fresh agent inherits the knowledge base before it
+            // ever searches. An empty array when there are none.
+            payload.set_member_name("discoveries");
+            payload.begin_array();
+            int64 discovery_entries = 0;
+            int64 discovery_chars = 0;
+            foreach (var discovery in yield discoveries.list_newest_first(
+                    (int) DISCOVERIES_MAX_ENTRIES)) {
+                string discovery_body = discovery.body;
+                if (discovery_chars + discovery_body.length
+                        > DISCOVERIES_MAX_CHARS) {
+                    discovery_body = discovery_body.substring(0,
+                        (int)(DISCOVERIES_MAX_CHARS - discovery_chars));
+                }
+                payload.begin_object();
+                payload.set_member_name("body");
+                payload.add_string_value(discovery_body);
+                payload.end_object();
+                discovery_entries++;
+                discovery_chars += discovery_body.length;
+                if (discovery_entries >= DISCOVERIES_MAX_ENTRIES
+                        || discovery_chars >= DISCOVERIES_MAX_CHARS) {
+                    break;
+                }
+            }
+            payload.end_array();
+
             // The wire contract's `callback` addition: this app's
             // externally reachable base URL plus the bearer token the
             // daemon presents when posting back — its own per-saddle

+ 93 - 0
src/Services/UnderstandingService.vala

@@ -0,0 +1,93 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The project Understanding ledger (goal 24): a project's
+     * Understanding is a markdown document summarising its structure
+     * and anything unusual worth knowing before working in it,
+     * stamped with the commit hash it was written against. The
+     * Refresh flow spins up a special understanding task whose agent
+     * explores the project and POSTs the fresh document; each POST
+     * INSERTS an immutable history row, so the "current"
+     * understanding is simply the newest revision. There is no
+     * agent-side delete — curation of the history stays human.
+     */
+    public class UnderstandingService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+
+        /**
+         * The project's CURRENT understanding — its newest revision —
+         * or null when the project has none yet. The current body is
+         * what rides the assignment payload and the page renders.
+         */
+        public async ProjectUnderstanding? current_understanding(int64 project_id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by_desc(expr("id"))
+                .first_async();
+        }
+
+        /**
+         * The project's full revision history, newest first — the
+         * page's history list (each row a past Refresh).
+         */
+        public async ImmutableLot<ProjectUnderstanding> history(int64 project_id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Loads the revision identified by `id`, or null.
+         */
+        public async ProjectUnderstanding? get_understanding(int64 id) throws Error {
+            return yield db.query<ProjectUnderstanding>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /**
+         * Appends a new revision. The body is trimmed and must be
+         * non-empty; `commit_hash` may be "" (unknown) and `task_id`
+         * 0 (no understanding task sourced it). Inserting makes the
+         * row the project's current understanding by recency.
+         */
+        public async ProjectUnderstanding insert(int64 project_id, string body,
+                string commit_hash, int64 task_id) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 99,
+                    "An understanding body is required");
+            }
+            var row = new ProjectUnderstanding() {
+                project_id = project_id,
+                body = trimmed,
+                commit_hash = commit_hash.strip(),
+                task_id = task_id,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<ProjectUnderstanding>(row);
+            return row;
+        }
+
+        /**
+         * Deletes the revision (idempotent: an absent row is fine).
+         * Only the web UI calls this — the agent-side API has no
+         * delete, so history curation stays human.
+         */
+        public async void delete(int64 id) throws Error {
+            var row = yield get_understanding(id);
+            if (row != null) {
+                yield db.delete_async<ProjectUnderstanding>((!)row);
+            }
+        }
+    }
+}

+ 91 - 0
src/actions/DiscoveryActions.vala

@@ -0,0 +1,91 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed discovery id carried by the discovery row actions. */
+    public class DiscoveryActionPrivate : Object {
+        public int64 discovery_id { get; set; }
+    }
+
+    /**
+     * Saves the refined body of a discovery (goal 24) — the HUMAN
+     * path of the same update-in-place semantics the agents use
+     * through `/api/saddle/discovery/update`: the body is replaced
+     * and updated_at bumped, created_at and the original source task
+     * stay frozen. The id comes from the sealed
+     * {@link DiscoveryActionPrivate} blob (the hidden form field is
+     * a fallback).
+     */
+    public class UpdateDiscoveryAction : TypedStatumAction<DiscoveryActionPrivate> {
+
+        private DiscoveryService discoveries = inject<DiscoveryService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(DiscoveryActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 discovery_id = ctx != null
+                ? ((!)ctx).discovery_id : Forms.integer(request, "discovery_id", 0);
+            if (discovery_id <= 0) {
+                return directives().navigate("/discoveries");
+            }
+            string body = Forms.field_raw(request, "body");
+            if (body.strip().length == 0) {
+                return directives().notify("error", "A discovery body is required")
+                    .navigate("/discoveries");
+            }
+
+            try {
+                yield discoveries.update(discovery_id, body);
+                return directives().notify("success", "Discovery saved")
+                    .navigate("/discoveries");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the discovery: $(e.message)")
+                    .navigate("/discoveries");
+            }
+        }
+    }
+
+    /**
+     * Deletes a discovery — humans only (the agent-side API has no
+     * delete); the id is sealed.
+     */
+    public class DeleteDiscoveryAction : TypedStatumAction<DiscoveryActionPrivate> {
+
+        private DiscoveryService discoveries = inject<DiscoveryService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(DiscoveryActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/discoveries");
+            }
+
+            try {
+                yield discoveries.delete(((!)ctx).discovery_id);
+                return directives().notify("success", "Discovery deleted")
+                    .navigate("/discoveries");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the discovery: $(e.message)")
+                    .navigate("/discoveries");
+            }
+        }
+    }
+}

+ 110 - 0
src/actions/ProjectActions.vala

@@ -23,6 +23,13 @@ namespace ClankerSaddle {
         public int64 note_id { get; set; }
     }
 
+    /**
+     * The title prefix of every understanding refresh task (goal 24):
+     * {@link RefreshUnderstandingAction} creates them and refuses a
+     * second refresh while one is still live.
+     */
+    public const string UNDERSTANDING_REFRESH_PREFIX = "Understanding refresh:";
+
     /**
      * Creates a project from the projects form (name and git URL
      * required); default_branch falls back to 'main'.
@@ -191,4 +198,107 @@ namespace ClankerSaddle {
             }
         }
     }
+
+    /**
+     * Spins up the project's understanding refresh task (goal 24): a
+     * QUEUED investigate task whose agent explores the project, walks
+     * the git history since the current document's commit when one
+     * exists, and saves an UPDATED markdown understanding via the
+     * `save_understanding` tool. Refuses while another refresh task
+     * for the project is still live — one explorer at a time.
+     */
+    public class RefreshUnderstandingAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UnderstandingService understandings = inject<UnderstandingService>();
+        private TaskService tasks = inject<TaskService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            if (project_id <= 0) {
+                return directives().navigate("/projects");
+            }
+            var project = yield projects.get_project(project_id);
+            if (project == null) {
+                return directives().notify("error", "That project no longer exists").navigate("/projects");
+            }
+
+            // One live explorer at a time: a non-terminal task whose
+            // title carries the refresh prefix blocks a new refresh.
+            foreach (var task in yield tasks.list_tasks(null, project_id)) {
+                if (!task.title.has_prefix(UNDERSTANDING_REFRESH_PREFIX)) {
+                    continue;
+                }
+                if (task.status == STATUS_DONE || task.status == STATUS_FAILED) {
+                    continue;
+                }
+                return directives().notify("error",
+                        @"An understanding refresh (#$(task.id)) is already running for this project")
+                    .navigate(@"/projects/$(project_id)");
+            }
+
+            var current = yield understandings.current_understanding(project_id);
+            string old_hash = current != null ? ((!)current).commit_hash : "";
+            string old_body = current != null ? ((!)current).body : "";
+
+            var brief = new StringBuilder();
+            brief.append("MANDATE — you are the project's understanding explorer.\n\n");
+            brief.append("Explore the project's structure and produce a markdown understanding ");
+            brief.append("document covering the project layout and anything unusual that would ");
+            brief.append("be useful to know for anyone working on it.\n");
+            if (old_body.length > 0) {
+                brief.append("\nA previous understanding document exists. Do NOT start from ");
+                brief.append("scratch: review the git history since the old commit hash ");
+                brief.append("(git log <old>..HEAD, plus diffs of the significant changes) and ");
+                brief.append("produce an UPDATED document that keeps what still holds and ");
+                brief.append("rewrites what changed.\n");
+            }
+            brief.append("\nThe finishing move: call the save_understanding tool exactly ONCE ");
+            brief.append("with the new document and the git HEAD hash of the explored checkout ");
+            brief.append("(run `git rev-parse HEAD`), then finish(status=done) with a short ");
+            brief.append("summary. You are in investigate mode — do not edit any files.\n");
+            brief.append("\nPrevious commit hash: ");
+            brief.append(old_hash.length > 0 ? old_hash : "none — first refresh");
+            if (old_body.length > 0) {
+                brief.append("\n\nPrevious document:\n\n```markdown\n");
+                brief.append(old_body);
+                brief.append("\n```\n");
+            }
+            var notes = yield projects.list_notes(project_id);
+            brief.append("\nProject notes from other agents:\n");
+            if (notes.length == 0) {
+                brief.append("(none)\n");
+            } else {
+                foreach (var note in notes) {
+                    brief.append("\n---\n");
+                    brief.append(note.body);
+                    brief.append("\n");
+                }
+            }
+
+            try {
+                var task = yield tasks.create_task(project_id,
+                    @"$(UNDERSTANDING_REFRESH_PREFIX) $(((!)project).name)",
+                    brief.str, 5, null, null, STATUS_QUEUED,
+                    TASK_KIND_TASK, "investigate", CREATOR_HUMAN);
+                yield tasks.add_system_comment(task.id,
+                    @"Understanding refresh queued against commit $(old_hash.length > 0 ? old_hash : "(none)")");
+                return directives().notify("success",
+                        @"Refresh queued — the explorer task is #$(task.id)")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not queue the refresh: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
 }

+ 96 - 0
src/entrypoints/DiscoveriesEntrypoint.vala

@@ -0,0 +1,96 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A discovery row on the Discoveries page, with its row actions. */
+    public class DiscoveryRowView : Object {
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        /** Shown only when the row has been refined since creation. */
+        public string updated_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `discoveries` slot. */
+    public class DiscoveriesPublic : Object {
+        /** The current search box contents (kept across re-renders). */
+        public string query { get; set; default = ""; }
+        /** How many rows the search matched beyond the shown cap. */
+        public int64 more { get; set; }
+    }
+
+    /**
+     * Entrypoint for /discoveries: the fleet's global, editable
+     * knowledge base of non-project facts (goal 24). A plain GET
+     * search form filters the rows (case-insensitive substring over
+     * the body — the same search the daemon's search_discoveries
+     * tool calls); every row carries an inline EDIT form and a
+     * delete action so humans can refine nuance and prune
+     * near-duplicates, the same update-in-place semantics the agents
+     * use through the callback API.
+     */
+    public class DiscoveriesEntrypoint : StatumEntrypoint {
+
+        private DiscoveryService discoveries = Inversion.inject<DiscoveryService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        /** Rows the page renders (matches SEARCH_LIMIT on the API). */
+        private const int PAGE_LIMIT = 20;
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string query = "";
+            request.query.try_get_any("q", out query);
+            if (query == null) {
+                query = "";
+            }
+
+            var rows = new Vector<DiscoveryRowView>();
+            int64 total = 0;
+            foreach (var discovery in yield discoveries.search(query, -1)) {
+                total++;
+                if (rows.length >= PAGE_LIMIT) {
+                    continue;
+                }
+                var sealed = new DiscoveryActionPrivate() {
+                    discovery_id = discovery.id
+                };
+                rows.add(new DiscoveryRowView() {
+                    id = discovery.id,
+                    body = discovery.body,
+                    created_display = format_stamp(discovery.created_at),
+                    updated_display = format_stamp(discovery.updated_at),
+                    update = action_registry.author_private<UpdateDiscoveryAction, DiscoveryActionPrivate>(sealed),
+                    delete = action_registry.author_private<DeleteDiscoveryAction, DiscoveryActionPrivate>(sealed)
+                });
+            }
+
+            var pub = new DiscoveriesPublic() {
+                query = query,
+                more = total - (int64) rows.length
+            };
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["discoveries"] = StateJson.list<DiscoveryRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "discoveries",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 20 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -139,6 +139,11 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? add_note { get; set; }
     }
 
+    /** One of the five newest discoveries on the dashboard (goal 24). */
+    public class DashboardDiscoveryView : Object {
+        public string body { get; set; default = ""; }
+    }
+
     /**
      * Builds the `dashboard` slot state shared by the home entrypoint and
      * the {@link RefreshDashboardAction} pulse refresh, so both hydrate
@@ -410,6 +415,21 @@ namespace ClankerSaddle {
             }
             props["notes"] = StateJson.list<DashboardNoteView>(note_rows.to_immutable_buffer());
 
+            // The five newest discoveries (goal 24): a one-glance slice
+            // of the fleet's knowledge base; the Discoveries page owns
+            // the full searchable listing.
+            var discovery_rows = new Vector<DashboardDiscoveryView>();
+            foreach (var discovery in yield db.query<Discovery>()
+                    .order_by_desc(expr("id"))
+                    .limit(5)
+                    .materialise_async()) {
+                discovery_rows.add(new DashboardDiscoveryView() {
+                    body = discovery.body
+                });
+            }
+            props["discoveries"] = StateJson.list<DashboardDiscoveryView>(
+                discovery_rows.to_immutable_buffer());
+
             // The promote form's project options (goal 8 task 3):
             // every project, oldest first — a stable select order.
             var project_options = new Vector<DashboardProjectOptionView>();

+ 56 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -139,6 +139,22 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? delete { get; set; }
     }
 
+    /**
+     * One understanding revision on the project page. The current row
+     * is rendered above; the history rows below it are the OLDER
+     * revisions (newest first). An absent current understanding is an
+     * all-empty row (bodies are never empty when stored), which the
+     * page's empty state keys off.
+     */
+    public class UnderstandingRowView : Object {
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public string commit_hash { get; set; default = ""; }
+        public bool has_commit { get; set; }
+        public int64 source_task_id { get; set; }
+        public string created_display { get; set; default = ""; }
+    }
+
     /** Public data of the `project` slot. */
     public class ProjectDetailPublic : Object {
         public bool exists { get; set; }
@@ -152,6 +168,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
         public Statum.Model.ActionDto? apply_stack { get; set; }
+        public Statum.Model.ActionDto? refresh_understanding { get; set; }
     }
 
     /**
@@ -164,6 +181,7 @@ namespace ClankerSaddle {
 
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
+        private UnderstandingService understanding_service = Inversion.inject<UnderstandingService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private StackService stack_service = Inversion.inject<StackService>();
         private UserService users = Inversion.inject<UserService>();
@@ -193,7 +211,8 @@ namespace ClankerSaddle {
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
                 attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
-                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed)
+                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed),
+                refresh_understanding = action_registry.author_private<RefreshUnderstandingAction, ProjectActionPrivate>(sealed)
             };
 
             var links = new Vector<ProjectLinkView>();
@@ -292,12 +311,48 @@ namespace ClankerSaddle {
                 });
             }
 
+            // The understanding ledger read: the current (newest)
+            // revision renders above; the history below it lists the
+            // OLDER revisions newest-first, so the current row is not
+            // shown twice. No understanding yet is an empty row — the
+            // page's empty state keys off it.
+            var understanding_rows = new Vector<UnderstandingRowView>();
+            bool first_understanding = true;
+            foreach (var revision in yield understanding_service.history(project_id)) {
+                if (first_understanding) {
+                    first_understanding = false;
+                    continue;
+                }
+                understanding_rows.add(new UnderstandingRowView() {
+                    id = revision.id,
+                    body = revision.body,
+                    commit_hash = revision.commit_hash,
+                    has_commit = revision.commit_hash.length > 0,
+                    source_task_id = revision.task_id,
+                    created_display = format_stamp(revision.created_at)
+                });
+            }
+            var current_understanding_row = new UnderstandingRowView();
+            var current_revision = yield understanding_service.current_understanding(project_id);
+            if (current_revision != null) {
+                current_understanding_row = new UnderstandingRowView() {
+                    id = ((!)current_revision).id,
+                    body = ((!)current_revision).body,
+                    commit_hash = ((!)current_revision).commit_hash,
+                    has_commit = ((!)current_revision).commit_hash.length > 0,
+                    source_task_id = ((!)current_revision).task_id,
+                    created_display = format_stamp(((!)current_revision).created_at)
+                };
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            props["understanding_current"] = StateJson.element(current_understanding_row);
+            props["understanding_history"] = StateJson.list<UnderstandingRowView>(understanding_rows.to_immutable_buffer());
             var stack_options = new Vector<StackOptionView>();
             foreach (var stack in yield stack_service.list_stacks()) {
                 stack_options.add(new StackOptionView() {

+ 20 - 0
src/main.vala

@@ -45,6 +45,8 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_BatchImprovements>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_EventIndexes>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectUnderstandings>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0005_Discoveries>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -57,6 +59,8 @@ int main(string[] args) {
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
+        db.add_entity<ProjectUnderstanding>(ProjectUnderstanding.entity_mapping);
+        db.add_entity<Discovery>(Discovery.entity_mapping);
         db.add_entity<Capability>(Capability.entity_mapping);
         db.add_entity<SaddleCapability>(SaddleCapability.entity_mapping);
         db.add_entity<TaskCapability>(TaskCapability.entity_mapping);
@@ -75,6 +79,8 @@ int main(string[] args) {
         application.add_scoped<GoalService>();
         application.add_scoped<StackService>();
         application.add_scoped<NoteService>();
+        application.add_scoped<UnderstandingService>();
+        application.add_scoped<DiscoveryService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
@@ -110,6 +116,7 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<DiscoveriesPage, DiscoveriesEntrypoint>();
         statum.add_page<StacksPage, StacksEntrypoint>();
         statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
@@ -167,6 +174,9 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
+        statum.action<RefreshUnderstandingAction>();
+        statum.action<UpdateDiscoveryAction>();
+        statum.action<DeleteDiscoveryAction>();
         statum.action<CreateStackAction>();
         statum.action<UpdateStackAction>();
         statum.action<DeleteStackAction>();
@@ -212,6 +222,16 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/results", Method.POST));
         application.add_endpoint<SaddleNoteEndpoint>(
             new EndpointRoute("/api/saddle/note", Method.POST));
+        application.add_endpoint<SaddleUnderstandingEndpoint>(
+            new EndpointRoute("/api/saddle/understanding", Method.POST));
+        application.add_endpoint<SaddleUnderstandingEndpoint>(
+            new EndpointRoute("/api/saddle/understanding", Method.GET));
+        application.add_endpoint<SaddleDiscoveryEndpoint>(
+            new EndpointRoute("/api/saddle/discovery", Method.POST));
+        application.add_endpoint<SaddleDiscoveryEndpoint>(
+            new EndpointRoute("/api/saddle/discoveries", Method.GET));
+        application.add_endpoint<SaddleDiscoveryUpdateEndpoint>(
+            new EndpointRoute("/api/saddle/discovery/update", Method.POST));
         application.add_endpoint<SaddleTaskCreateEndpoint>(
             new EndpointRoute("/api/saddle/task/create", Method.POST));
         application.add_endpoint<SaddleTaskDeleteEndpoint>(

+ 83 - 0
src/pages/discoveries.html

@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/discoveries</pstm-uri>
+    <pstm-template>main</pstm-template>
+<script>
+    // The search box filters CLIENT-SIDE, like the task page's
+    // selects: the Statum entrypoint fetch never carries the page's
+    // own query string to the server, so the box filters the
+    // rendered rows directly (case-insensitive substring over the
+    // body, the same search the daemon's search_discoveries tool
+    // performs server-side). A `?q=` on a direct entrypoint hit
+    // pre-seeds the box (DiscoveriesEntrypoint reads it).
+    if (window.statum) {
+        (function () {
+            'use strict';
+
+            window.discoveryRowVisible = function (row) {
+                var needle = (window.discoveryQuery || '').toLowerCase();
+                if (!needle) return true;
+                return (row.body || '').toLowerCase().indexOf(needle) !== -1;
+            };
+
+            window.setDiscoveryQuery = function (value) {
+                window.discoveryQuery = value || '';
+                var box = document.getElementById('discovery-q');
+                if (box && box.value !== window.discoveryQuery) {
+                    box.value = window.discoveryQuery;
+                }
+                document.body.setAttribute('data-discovery-bump',
+                    String(Date.now()));
+            };
+        })();
+    }
+</script>
+</head>
+<body>
+    <section>
+        <h2>Discoveries</h2>
+        <p class="muted">The fleet's editable knowledge base of non-project facts — compiler
+            quirks, library behaviours, tool flags, protocol gotchas. Agents search this
+            before working with a technology and refine notes in place; you can do the same.</p>
+
+        <p stm-if="discoveries == null">Loading…</p>
+
+        <div stm-if="discoveries != null">
+            <form class="inline" onsubmit="window.setDiscoveryQuery(document.getElementById('discovery-q').value); return false;">
+                <div class="form-row"><label for="discovery-q">Search</label>
+                    <input type="search" id="discovery-q"
+                           stm-attribute.value="discoveries.query"
+                           placeholder="meson, valac, sqlite…">
+                    <button type="submit">Search</button></div>
+            </form>
+
+            <div stm-for-discovery-in="discoveries.discoveries" stm-key="discovery.id"
+                 stm-if="window.discoveryRowVisible(discovery)" class="card">
+                <div class="md" stm-markdown="discovery.body">body</div>
+                <p class="muted">Created <span stm-text="discovery.created_display">when</span><span
+                        stm-if="discovery.updated_display != ''"> · Updated <span
+                        stm-text="discovery.updated_display">when</span></span></p>
+                <details>
+                    <summary>Edit</summary>
+                    <form stm-action="discovery.update">
+                        <input type="hidden" name="discovery_id" stm-attribute.value="discovery.id">
+                        <div class="form-row"><label>Body</label>
+                            <textarea name="body" rows="6" stm-attribute.value="discovery.body"></textarea></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </details>
+                <form class="inline">
+                    <button type="button" stm-action="discovery.delete"
+                            stm-confirm="Delete this discovery?">Delete</button>
+                </form>
+            </div>
+            <p stm-if="discoveries.discoveries.length == 0" class="muted">
+                No discoveries yet — the knowledge base starts empty and grows as agents
+                record what they learn.</p>
+            <p stm-if="discoveries.more > 0" class="muted">… and <span
+                    stm-text="discoveries.more">n</span> more (narrow the search).</p>
+        </div>
+    </section>
+</body>
+</html>

+ 12 - 0
src/pages/home.html

@@ -290,6 +290,18 @@
                     <button type="submit">Add note</button>
                 </form>
             </section>
+
+            <!-- The five newest discoveries (goal 24): the fleet's
+                 knowledge base at a glance; the Discoveries page owns
+                 the searchable listing. -->
+            <div stm-if="dashboard.discoveries.length > 0">
+                <h3>Latest discoveries</h3>
+                <ul>
+                    <li stm-for-discovery-in="dashboard.discoveries" class="md footnote"
+                        stm-markdown="discovery.body">body</li>
+                </ul>
+                <p class="muted"><a href="/discoveries">All discoveries →</a></p>
+            </div>
             <table stm-if="dashboard.notes.length > 0">
                 <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
                 <tbody>

+ 1 - 0
src/pages/main.html

@@ -31,6 +31,7 @@
                     <a href="/projects">Projects</a>
                     <a href="/rules">Rules</a>
                     <a href="/stacks">Stacks</a>
+                    <a href="/discoveries">Discoveries</a>
                 </div>
             </details>
             <details class="nav-menu" name="nav-menu">

+ 27 - 0
src/pages/project_detail.html

@@ -93,6 +93,33 @@
             </form>
             <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
 
+            <h3>Understanding</h3>
+            <p class="muted">A markdown map of the project's structure and anything
+                unusual, written by an agent against a commit and refreshed on demand.</p>
+            <div class="card" stm-if="project.understanding_current.body.length > 0">
+                <p class="muted"><span stm-if="project.understanding_current.has_commit">commit
+                        <code stm-text="project.understanding_current.commit_hash">hash</code> · </span>refreshed
+                    <span stm-text="project.understanding_current.created_display">when</span><span
+                        stm-if="project.understanding_current.source_task_id > 0"> · from task
+                        <a stm-attribute.href="'/tasks/' + project.understanding_current.source_task_id"
+                           stm-text="'#' + project.understanding_current.source_task_id">task</a></span></p>
+                <div class="md" stm-markdown="project.understanding_current.body">body</div>
+            </div>
+            <p class="muted" stm-if="project.understanding_current.body.length == 0">No understanding yet —
+                press Refresh to have an agent explore the project.</p>
+            <form class="inline" stm-action="project.refresh_understanding">
+                <button type="submit" stm-confirm="Spin up an agent task to refresh the understanding?">Refresh</button>
+            </form>
+            <div stm-for-revision-in="project.understanding_history" stm-key="revision.id" class="card">
+                <p class="muted"><span stm-if="revision.has_commit">commit
+                        <code stm-text="revision.commit_hash">hash</code> · </span>refreshed
+                    <span stm-text="revision.created_display">when</span><span
+                        stm-if="revision.source_task_id > 0"> · from task
+                        <a stm-attribute.href="'/tasks/' + revision.source_task_id"
+                           stm-text="'#' + revision.source_task_id">task</a></span></p>
+                <div class="md" stm-markdown="revision.body">body</div>
+            </div>
+
             <h3>Project notes</h3>
             <p class="muted">Written by agents via <code>saddle note</code>; curated here — delete what
                 stops being useful. There is no manual create.</p>

+ 207 - 0
test/bundles/discoveries.py

@@ -0,0 +1,207 @@
+"""The discoveries bundle (goal 24): the fleet's global, EDITABLE
+knowledge base of non-project facts — the /api/saddle/discovery
+insert + update callbacks, the /api/saddle/discoveries search, the
+human edit/delete actions on the Discoveries page, and the newest
+rows riding the assignment payload."""
+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
+
+    def db_rows(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchall()
+        finally:
+            conn.close()
+
+    print("== discoveries: the migration ==")
+    # A fresh database: M0001..M0005 ran at startup, so the table
+    # exists with the goal-24 shape.
+    tables = [r[0] for r in db_rows(
+        "select name from sqlite_master where type = 'table'")]
+    check("discoveries" in tables,
+          "M0005 creates the discoveries table on a fresh DB")
+    columns = [r[1] for r in db_rows("pragma table_info(discoveries)")]
+    check(columns == ["id", "body", "source_task_id", "created_at",
+                      "updated_at"],
+          "the table carries the goal-24 columns (%s)" % columns)
+
+    print("== discoveries: POST auth + validation ==")
+    status, _ = saddle_api("POST", "/api/saddle/discovery", None, {"body": "x"})
+    check(status == 401, "POST rejects a missing token")
+    status, _ = saddle_api("POST", "/api/saddle/discovery", "saddle-bogus",
+                           {"body": "x"})
+    check(status == 401, "POST rejects a wrong token")
+    status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
+                              {"body": "   "})
+    check(status == 400 and body.get("ok") is False,
+          "POST rejects an empty body")
+
+    print("== discoveries: inserts and ordering ==")
+    status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
+                              {"task_id": 21,
+                               "body": "valac chokes on `size` for Invercargill's Vector; use `length`."})
+    check(status == 200 and body.get("ok") is True
+          and int(body.get("discovery_id", 0)) > 0,
+          "POST inserts and returns the discovery id")
+    first_id = int(body["discovery_id"])
+    status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
+                              {"body": "meson setup needs a fresh build dir after editing meson.build custom_targets."})
+    check(status == 200 and int(body.get("discovery_id", 0)) > first_id,
+          "a second insert gets a LATER id")
+    second_id = int(body["discovery_id"])
+    rows = db_rows("select id, body, source_task_id, created_at, updated_at"
+                   " from discoveries order by id")
+    check([r[0] for r in rows] == [first_id, second_id],
+          "rows land in insert order")
+    check(rows[0][2] == 21 and rows[1][2] == 0,
+          "rows keep the optional source task (21 / 0)")
+    check(all(r[3] is not None and r[4] is not None for r in rows),
+          "created_at and updated_at are stamped")
+
+    print("== discoveries: GET search ==")
+    status, _ = saddle_api("GET", "/api/saddle/discoveries?q=valac", None)
+    check(status == 401, "GET rejects a missing token")
+    status, body = saddle_api("GET", "/api/saddle/discoveries?q=VALAC",
+                              fake_token)
+    check(status == 200 and body.get("ok") is True,
+          "GET answers a search")
+    hits = body.get("discoveries") or []
+    check(len(hits) == 1 and hits[0]["id"] == first_id,
+          "the search is a case-insensitive substring match over the body")
+    check((hits[0].get("created") or "").endswith("Z")
+          and (hits[0].get("updated") or "").endswith("Z"),
+          "rows carry ISO-8601 created/updated stamps")
+    status, body = saddle_api("GET", "/api/saddle/discoveries", fake_token)
+    check(status == 200 and len(body.get("discoveries") or []) == 2
+          and body["discoveries"][0]["id"] == second_id,
+          "an empty q lists the newest 20 (newest first)")
+
+    print("== discoveries: update refines in place ==")
+    status, _ = saddle_api("POST", "/api/saddle/discovery/update", None,
+                           {"id": first_id, "body": "x"})
+    check(status == 401, "update rejects a missing token")
+    status, body = saddle_api("POST", "/api/saddle/discovery/update",
+                              fake_token, {"id": 999999, "body": "x"})
+    check(status == 400 and body.get("ok") is False,
+          "update rejects an unknown id with an error JSON")
+    status, body = saddle_api("POST", "/api/saddle/discovery/update",
+                              fake_token,
+                              {"id": first_id,
+                               "body": "valac chokes on `size` for Invercargill's Vector; use `length` (also `count`, a uint)."})
+    check(status == 200 and body.get("ok") is True, "update answers ok")
+    rows = db_rows("select id, body, source_task_id, created_at, updated_at"
+                   " from discoveries order by id")
+    check(len(rows) == 2 and rows[0][0] == first_id,
+          "update INSERTS NOTHING — the row is refined in place")
+    check("also `count`" in rows[0][1], "the body was replaced")
+    check(rows[0][2] == 21, "update keeps the original source task")
+    check(rows[0][4] >= rows[0][3],
+          "updated_at bumped at or after created_at")
+
+    print("== discoveries: the page and the human actions ==")
+    state = slot_public(auth, "/discoveries", "discoveries")
+    listed = state.get("discoveries") or []
+    check([r["id"] for r in listed] == [str(second_id), str(first_id)],
+          "the page lists rows newest first")
+    check("also `count`" in listed[1]["body"],
+          "the page shows the refined body")
+    check(listed[1].get("updated_display", "") != ""
+          and listed[0].get("updated_display", "") != "",
+          "refined rows show an updated stamp")
+    check(all(r.get("update") is not None and r.get("delete") is not None
+              for r in listed),
+          "every row authors the edit and delete actions")
+    # The human edit path: the same update-in-place semantics.
+    result = act(auth, listed[1]["update"],
+                 {"body": "valac: Invercargill Vector exposes `length` (uint) and `count()`, never `size`."})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success"
+              for d in result), "the human edit notifies success")
+    rows = db_rows("select body from discoveries where id = %d" % first_id)
+    check("never `size`" in rows[0][0],
+          "the human edit replaced the body in place")
+    # The human delete path.
+    result = act(auth, listed[0]["delete"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success"
+              for d in result), "the human delete notifies success")
+    rows = db_rows("select id from discoveries order by id")
+    check([r[0] for r in rows] == [first_id],
+          "the human delete removed the row")
+
+    print("== discoveries: the page search filter ==")
+    # The browser filters client-side (the script above), but the
+    # entrypoint still honours a server-side q: a direct entrypoint
+    # hit passes the filter as a sibling query param next to uri=
+    # (the same shape the task page's status filter documents).
+    status, body = http(
+        "GET",
+        "/_statum/entrypoint?uri=" + urllib.parse.quote("/discoveries")
+        + "&q=" + urllib.parse.quote("never `size`"),
+        headers={"X-Statum-Slot": "key=%s; as_at=%s" % (auth["key"], auth["as_at"])})
+    check(status == 200, "a direct entrypoint hit with q= answers")
+    filtered = None
+    for directive in json.loads(body):
+        if directive.get("type") == "set":
+            content = json.loads(directive["snapshot_frame"]["content"])
+            if content["slot"]["type"] == "discoveries":
+                filtered = content["public"]
+    check(filtered is not None and filtered.get("query") == "never `size`",
+          "the entrypoint seeds the search box from q")
+    check(len(filtered.get("discoveries") or []) == 1
+          and "never `size`" in (filtered["discoveries"][0].get("body", "")),
+          "the q filter narrows the rows server-side")
+    state = slot_public(auth, "/discoveries", "discoveries")
+    check(state.get("query", "") == "" and len(state.get("discoveries") or []) == 1,
+          "a plain hit stays unfiltered")
+
+    print("== discoveries: ride the assignment payload ==")
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Discovery dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="discovery /start push") is not None,
+          "dispatcher pushes the discovery task")
+    payload = fake.start_payloads[-1]
+    check("discoveries" in payload,
+          "the assignment payload carries a discoveries member")
+    shipped = payload.get("discoveries")
+    check(isinstance(shipped, list) and len(shipped) == 1
+          and "never `size`" in shipped[0].get("body", ""),
+          "the payload ships the newest rows as {body} objects")
+    # The cap: 12 giant rows → at most 10 entries / 8000 chars.
+    conn = db()
+    for i in range(12):
+        conn.execute(
+            "insert into discoveries (body, source_task_id, created_at, updated_at)"
+            " values (?, 0, strftime('%s','now'), strftime('%s','now'))",
+            ("cap%02d " % i + "z" * 1500,))
+    conn.commit()
+    conn.close()
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Discovery cap dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="cap /start push") is not None,
+          "dispatcher pushes the cap task")
+    shipped = fake.start_payloads[-1].get("discoveries")
+    check(isinstance(shipped, list) and len(shipped) <= 10,
+          "the payload caps the discoveries at 10 entries (%d)" % len(shipped))
+    check(sum(len(r.get("body", "")) for r in shipped) <= 8000,
+          "the payload caps the discoveries at 8000 chars total (%d)"
+          % sum(len(r.get("body", "")) for r in shipped))
+    check(shipped[0].get("body", "").startswith("cap11"),
+          "the newest rows ship first")
+
+    print("== discoveries: the dashboard slice ==")
+    home = slot_public(auth, "/", "dashboard")
+    latest = home.get("discoveries") or []
+    check(len(latest) == 5 and latest[0].get("body", "").startswith("cap11"),
+          "the dashboard shows the five newest discoveries newest first")

+ 5 - 1
test/bundles/goalui.py

@@ -301,7 +301,11 @@ def run(ctx):
           and log_state["task_url"] == "/tasks/%d" % run_task,
           "the event log page hydrates the run-task header")
     log_page = http_get_page(auth, "/tasks/%d/log" % run_task)
-    check("EventSource" in log_page and "events/stream" in log_page
+    # The SSE client ships as the compiled task_log.js resource (plan 17
+    # b3+): the page references it and the resource carries the code.
+    status, log_js = http("GET", "/_statum/resource/task_log.js", timeout=15)
+    check(status == 200 and "task_log.js" in log_page
+          and "EventSource" in log_js and "events/stream" in log_js
           and "data-log-feed" in log_page,
           "the log page subscribes to the ledger's SSE stream")
     check(goal_state(goal)["runs"][0]["terminal_url"] == "/tasks/%d/log" % run_task,

+ 179 - 0
test/bundles/knowledge_cycle.py

@@ -0,0 +1,179 @@
+"""The knowledge-cycle bundle (goal 24 task 6): ONE end-to-end pass
+through the understanding and discoveries cycles, cross-cutting the
+surfaces the per-repo bundles could only check in isolation.
+
+Understanding: press Refresh on a project → the created task's OWN
+assignment payload (what the daemon actually receives) carries the
+brief's embedded old hash / old document / project notes AND the
+`understanding` member with the current revision → the saddle POSTs
+/api/saddle/understanding → the project page shows the new current
+document and a history row → a second POST makes the page show the
+newer one.
+
+Discoveries: POST via the API → the /discoveries page lists it and the
+dashboard shows it → update-by-id refines it → a fresh assignment
+payload carries it.
+"""
+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
+
+    def db_rows(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchall()
+        finally:
+            conn.close()
+
+    def payload_for(task_id):
+        for payload in reversed(fake.start_payloads):
+            if int((payload.get("task") or {}).get("id") or 0) == int(task_id):
+                return payload
+        return None
+
+    # A dedicated project so the cycle owns every row it asserts on.
+    state = slot_public(auth, "/projects", "projects")
+    act(auth, state["create"], {"name": "cycle",
+                                "git_url": "https://example.com/cycle.git"})
+    cycle_id = int([p["id"] for p in
+                    slot_public(auth, "/projects", "projects")["projects"]
+                    if p["name"] == "cycle"][0])
+
+    print("== cycle: the project page ships the section and Refresh ==")
+    cycle_state = slot_public(auth, "/projects/%d" % cycle_id, "project")
+    check(cycle_state.get("refresh_understanding") is not None,
+          "the cycle project's slot authors the Refresh action")
+    page_html = http_get_page(auth, "/projects/%d" % cycle_id)
+    check("Understanding" in page_html and "Refresh" in page_html,
+          "the project page renders the Understanding section with its button")
+
+    print("== cycle: seed a current document + notes, press Refresh ==")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "cycle",
+                               "body": "# Cycle v1\n\nThe old map.",
+                               "commit_hash": "aaa1111", "task_id": 0})
+    check(status == 200 and body.get("ok") is True,
+          "seeding the first understanding via the API works")
+    conn = db()
+    conn.execute("insert into project_notes (project_id, source_task_id, body,"
+                 " created_at) values (?, 0, ?, strftime('%s','now'))",
+                 (cycle_id, "Cycle note: the fixtures live in test/bundles/."))
+    conn.commit()
+    conn.close()
+
+    cycle_state = slot_public(auth, "/projects/%d" % cycle_id, "project")
+    result = act(auth, cycle_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "pressing Refresh queues the explorer task")
+    rows = db_rows("select id, title, status from tasks"
+                   " where title like 'Understanding refresh: cycle'")
+    check(len(rows) == 1, "Refresh created exactly one task")
+    refresh_id = rows[0][0]
+    check(rows[0][1].startswith("Understanding refresh:"),
+          "the refresh task's title carries the goal-24 prefix")
+    check(rows[0][2] in ("queued", "assigned", "working"),
+          "the refresh task is live (%s)" % rows[0][2])
+
+    print("== cycle: the refresh task's OWN assignment payload ==")
+    # The fake saddle asks when idle, so the dispatcher hands it the
+    # refresh task: the payload the daemon builds from the brief AND
+    # the understanding member must both be there.
+    check(wait_for(lambda: payload_for(refresh_id) is not None or False,
+                   timeout=30, what="refresh assignment push") is not None,
+          "the dispatcher assigns the refresh task to the saddle")
+    payload = payload_for(refresh_id)
+    brief = (payload.get("task") or {}).get("description") or ""
+    check("Previous commit hash: aaa1111" in brief,
+          "the assigned brief embeds the OLD commit hash")
+    check("```markdown" in brief and "The old map." in brief,
+          "the assigned brief embeds the OLD document")
+    check("Cycle note: the fixtures live in test/bundles/." in brief,
+          "the assigned brief embeds the project notes from other agents")
+    check(payload.get("understanding") == "# Cycle v1\n\nThe old map.",
+          "the assignment payload's understanding member is the current doc")
+    check("discoveries" in payload and isinstance(payload.get("discoveries"), list),
+          "the assignment payload carries the discoveries array alongside")
+
+    print("== cycle: the explorer saves; the page flips ==")
+    fake.push_results("done", summary="walked the history and updated the doc")
+    check(wait_task_done(auth, refresh_id, timeout=30) is not None,
+          "the refresh task completes")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "cycle",
+                               "body": "# Cycle v2\n\nThe updated map.",
+                               "commit_hash": "bbb2222",
+                               "task_id": refresh_id})
+    check(status == 200 and int(body.get("understanding_id", 0)) > 0,
+          "the explorer's save_understanding POST lands")
+    cycle_state = slot_public(auth, "/projects/%d" % cycle_id, "project")
+    current = cycle_state.get("understanding_current") or {}
+    check(current.get("body") == "# Cycle v2\n\nThe updated map."
+          and current.get("commit_hash") == "bbb2222",
+          "the page shows the NEW current document after the save")
+    history = cycle_state.get("understanding_history") or []
+    check([h.get("commit_hash") for h in history] == ["aaa1111"]
+          and (history[0].get("body") or "").endswith("The old map."),
+          "the page keeps the previous revision as a history row")
+    page_html = http_get_page(auth, "/projects/%d" % cycle_id)
+    check("stm-markdown=\"project.understanding_current.body\"" in page_html,
+          "the rendered page binds the current document as markdown")
+
+    print("== cycle: a second save makes the page show the newer ==")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "cycle",
+                               "body": "# Cycle v3\n\nThe newest map.",
+                               "commit_hash": "ccc3333"})
+    check(status == 200, "the second explorer POST lands")
+    cycle_state = slot_public(auth, "/projects/%d" % cycle_id, "project")
+    current = cycle_state.get("understanding_current") or {}
+    check(current.get("body") == "# Cycle v3\n\nThe newest map.",
+          "the page's current flips to the newest revision")
+    check([h.get("commit_hash") for h in
+           cycle_state.get("understanding_history") or []]
+          == ["bbb2222", "aaa1111"],
+          "the history grows newest-first with both older revisions")
+
+    print("== cycle: the discoveries leg ==")
+    status, body = saddle_api("POST", "/api/saddle/discovery", fake_token,
+                              {"body": "cycle fact: meson test -C builddir runs the suite",
+                               "task_id": refresh_id})
+    check(status == 200 and int(body.get("discovery_id", 0)) > 0,
+          "POST /api/saddle/discovery inserts and echoes the id")
+    discovery_id = int(body["discovery_id"])
+    discoveries_state = slot_public(auth, "/discoveries", "discoveries")
+    listed = [r for r in discoveries_state.get("discoveries") or []
+              if r.get("body") == "cycle fact: meson test -C builddir runs the suite"]
+    check(len(listed) == 1, "the /discoveries page lists the new row")
+    home_state = slot_public(auth, "/", "dashboard")
+    dashboard = [r for r in home_state.get("discoveries") or []
+                 if r.get("body") == "cycle fact: meson test -C builddir runs the suite"]
+    check(len(dashboard) == 1, "the dashboard shows the discovery")
+
+    status, body = saddle_api("POST", "/api/saddle/discovery/update", fake_token,
+                              {"id": discovery_id,
+                               "body": "cycle fact: meson test -C builddir runs the suite (also task-log-js)"})
+    check(status == 200 and body.get("ok") is True, "update-by-id answers ok")
+    discoveries_state = slot_public(auth, "/discoveries", "discoveries")
+    rows_now = [r for r in discoveries_state.get("discoveries") or []
+                if r.get("id") == str(discovery_id)]
+    check(len(rows_now) == 1
+          and "(also task-log-js)" in rows_now[0].get("body", ""),
+          "the page shows the refined body in place (no duplicate row)")
+
+    # A fresh assignment carries the refined discovery.
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {"project_id": str(cycle_id),
+                         "title": "Cycle dispatch",
+                         "priority": "2"})
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="cycle /start push") is not None,
+          "the dispatcher pushes the cycle task")
+    shipped = [r.get("body", "") for r in
+               fake.start_payloads[-1].get("discoveries") or []]
+    check(any("(also task-log-js)" in b for b in shipped),
+          "a fresh assignment payload carries the refined discovery")
+    check(fake.start_payloads[-1].get("understanding")
+          == "# Cycle v3\n\nThe newest map.",
+          "the fresh assignment also carries the newest understanding")

+ 12 - 6
test/bundles/pages.py

@@ -720,7 +720,12 @@ def run(ctx):
     check(log_state.get("exists") is True and int(log_state["task_id"]) == dash_run,
           "the event log page state names the run task")
     log_html = http_get_page(auth, "/tasks/%d/log" % dash_run)
-    check("EventSource" in log_html and "events/stream" in log_html,
+    # The log page's client JS ships as a compiled resource (plan 17
+    # b3+), so the SSE subscription is asserted on the served resource
+    # the page references, not the page HTML itself.
+    status, log_js = http("GET", "/_statum/resource/task_log.js", timeout=15)
+    check(status == 200 and "task_log.js" in log_html
+          and "EventSource" in log_js and "events/stream" in log_js,
           "the log page subscribes to the task's SSE stream")
     run_detail = task_state(auth, dash_run)
     check(run_detail.get("is_goal_run") is True and run_detail.get("mode") == "orchestrate",
@@ -757,7 +762,7 @@ def run(ctx):
     task_page_html = http_get_page(auth, "/tasks/%d" % keeper)
     check("saddle comment" not in task_page_html,
           "the task page never shows the command echo")
-    check(task_page_html.index('stm-text="comment.body"')
+    check(task_page_html.index('stm-markdown="comment.body"')
           < task_page_html.index('stm-text="comment.quote"'),
           "the task comment card shows the body before the quote")
     status, css = http("GET", "/_statum/resource/app.css", timeout=15)
@@ -905,10 +910,11 @@ def run(ctx):
         "select name from sqlite_master"
         " where type = 'index' and tbl_name = 'events'").fetchall()}
     conn.close()
-    check([s[0] for s in serials] == [1, 2, 3],
-          "the clanker-saddle namespace carries exactly serials 1, 2 and 3"
-          " (a fresh boot ran M0001 then the M0002 no-op then M0003)")
-    check(len(serials) == 3,
+    check([s[0] for s in serials] == [1, 2, 3, 4, 5],
+          "the clanker-saddle namespace carries exactly serials 1 through 5"
+          " (a fresh boot ran M0001 then the M0002 no-op then M0003,"
+          " M0004 understandings and M0005 discoveries)")
+    check(len(serials) == 5,
           "no duplicate migration rows (each guarded up() applied once)")
     check({"idx_events_scope", "idx_events_terminal"} <= event_indexes,
           "M0003's event reader indexes exist (fresh boot)")

+ 6 - 2
test/bundles/parity.py

@@ -109,8 +109,12 @@ def run(ctx):
     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)
+    # The badge classes and kind handling live in the compiled
+    # task_log.js resource (plan 17 b3+), not the page HTML.
+    status_js, log_js = http("GET", "/_statum/resource/task_log.js", 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,
+    check(status_js == 200 and "badge-yeehaw" in log_js,
+          "yeehaw: the page ships the festive badge class")
+    check("'yeehaw'" in log_js or '"yeehaw"' in log_js,
           "yeehaw: the page's client script knows the kind")

+ 298 - 0
test/bundles/understanding.py

@@ -0,0 +1,298 @@
+"""The understanding bundle (goal 24 task 2): the project Understanding
+ledger — the /api/saddle/understanding insert/read callback pair, the
+immutable history rows, and the current document riding the assignment
+payload."""
+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
+
+    def db_rows(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchall()
+        finally:
+            conn.close()
+
+    print("== understanding: the migration ==")
+    # A fresh database: M0001..M0004 ran at startup, so the table
+    # exists with the goal-24 shape.
+    tables = [r[0] for r in db_rows(
+        "select name from sqlite_master where type = 'table'")]
+    check("project_understandings" in tables,
+          "M0004 creates the project_understandings table on a fresh DB")
+    columns = [r[1] for r in db_rows("pragma table_info(project_understandings)")]
+    check(columns == ["id", "project_id", "body", "commit_hash", "task_id",
+                      "created_at"],
+          "the table carries the goal-24 columns (%s)" % columns)
+
+    print("== understanding: POST auth + validation ==")
+    status, _ = saddle_api("POST", "/api/saddle/understanding", None,
+                           {"project": "demo", "body": "x"})
+    check(status == 401, "POST rejects a missing token")
+    status, _ = saddle_api("POST", "/api/saddle/understanding", "saddle-bogus",
+                           {"project": "demo", "body": "x"})
+    check(status == 401, "POST rejects a wrong token")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "no-such-project", "body": "x"})
+    check(status == 400 and body.get("ok") is False,
+          "POST rejects an unknown project")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo", "body": "   "})
+    check(status == 400 and body.get("ok") is False,
+          "POST rejects an empty body")
+
+    print("== understanding: inserts append history ==")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo",
+                               "body": "# Demo understanding\n\nFirst revision.",
+                               "commit_hash": "abc1000", "task_id": 11})
+    check(status == 200 and body.get("ok") is True
+          and int(body.get("understanding_id", 0)) > 0,
+          "POST inserts and returns the understanding id")
+    first_id = int(body["understanding_id"])
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "demo",
+                               "body": "# Demo understanding\n\nSecond revision.",
+                               "commit_hash": "def2000"})
+    check(status == 200 and int(body["understanding_id"]) > first_id,
+          "a refresh appends a LATER history row")
+    second_id = int(body["understanding_id"])
+    rows = db_rows("select id, body, commit_hash, task_id"
+                   " from project_understandings order by id")
+    check([r[0] for r in rows] == [first_id, second_id],
+          "history rows are immutable appends in insert order")
+    check(rows[0][2] == "abc1000" and rows[0][3] == 11
+          and rows[1][2] == "def2000" and rows[1][3] == 0,
+          "rows keep the commit hash and the optional task id")
+
+    print("== understanding: GET reads the current revision ==")
+    status, _ = saddle_api("GET", "/api/saddle/understanding?project=demo", None)
+    check(status == 401, "GET rejects a missing token")
+    status, body = saddle_api(
+        "GET", "/api/saddle/understanding?project=no-such-project", fake_token)
+    check(status == 400, "GET rejects an unknown project")
+    status, body = saddle_api("GET", "/api/saddle/understanding", fake_token)
+    check(status == 400, "GET requires the project query parameter")
+    status, body = saddle_api("GET", "/api/saddle/understanding?project=demo",
+                              fake_token)
+    check(status == 200 and body.get("ok") is True,
+          "GET answers with the current understanding")
+    current = body.get("understanding") or {}
+    check(current.get("body") == "# Demo understanding\n\nSecond revision.",
+          "GET returns the NEWEST revision (%r)" % current.get("body", "")[:40])
+    check(current.get("commit_hash") == "def2000"
+          and (current.get("created") or "").endswith("Z"),
+          "GET carries the commit hash and an ISO-8601 stamp")
+    # A project without any understanding answers null, not an error.
+    state = slot_public(auth, "/projects", "projects")
+    act(auth, state["create"], {"name": "plain",
+                                "git_url": "https://example.com/plain.git"})
+    status, body = saddle_api("GET", "/api/saddle/understanding?project=plain",
+                              fake_token)
+    check(status == 200 and body.get("ok") is True
+          and body.get("understanding") is None,
+          "GET answers understanding null for a project with none")
+
+    print("== understanding: rides the assignment payload ==")
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Understanding dispatch",
+        "description": "The current understanding rides the /start payload.",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="understanding /start push") is not None,
+          "dispatcher pushes the understanding task")
+    payload = fake.start_payloads[-1]
+    check("understanding" in payload,
+          "the assignment payload carries an understanding member")
+    check(payload.get("understanding") == "# Demo understanding\n\nSecond revision.",
+          "the payload's understanding is the project's CURRENT revision")
+
+    # A giant revision is head-clamped at 16000 chars (the payload cap).
+    # created_at goes in as a raw epoch INTEGER like every other bundle
+    # fixture: the ORM materialises DateTime columns from int64 (a text
+    # value in a DATETIME column fails the element conversion, which
+    # the project page read surfaces as a 500).
+    conn = db()
+    conn.execute("insert into project_understandings"
+                 " (project_id, body, commit_hash, task_id, created_at)"
+                 " values (1, ?, 'big0000', 0, strftime('%s','now'))",
+                 ("giant!" + "y" * 25000,))
+    conn.commit()
+    conn.close()
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": "1",
+        "title": "Understanding cap dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="cap /start push") is not None,
+          "dispatcher pushes the cap task")
+    shipped = fake.start_payloads[-1].get("understanding", "")
+    check(len(shipped) == 16000 and shipped.startswith("giant!"),
+          "the payload caps the understanding at 16000 chars, head kept (%d)"
+          % len(shipped))
+
+    # No understanding yet: the member is present but empty.
+    plain_id = int([p["id"] for p in
+                    slot_public(auth, "/projects", "projects")["projects"]
+                    if p["name"] == "plain"][0])
+    payload_count = len(fake.start_payloads)
+    create_queued(auth, {
+        "project_id": str(plain_id),
+        "title": "No-understanding dispatch",
+        "priority": "2",
+    })
+    check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
+                   timeout=30, what="empty /start push") is not None,
+          "dispatcher pushes the no-understanding task")
+    check(fake.start_payloads[-1].get("understanding") == "",
+          "a project without an understanding ships an empty string")
+
+    print("== understanding: the project page section ==")
+    # 'plain' has no understanding: empty current row, empty history,
+    # but the refresh action is authored.
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    check(plain_state.get("refresh_understanding") is not None,
+          "the project slot authors the refresh action")
+    check((plain_state.get("understanding_current") or {}).get("body", "x") == "",
+          "a project without an understanding shows an empty current row")
+    check(plain_state.get("understanding_history") == [],
+          "a project without an understanding shows no history rows")
+    # 'demo' (project 1) has three revisions by now (two API inserts +
+    # the giant direct row): current is the giant, history is the two
+    # API revisions newest first.
+    demo_state = slot_public(auth, "/projects/1", "project")
+    current_row = demo_state.get("understanding_current") or {}
+    check(current_row.get("body", "").startswith("giant!"),
+          "the page's current understanding is the newest revision")
+    check(current_row.get("commit_hash") == "big0000"
+          and current_row.get("has_commit") is True,
+          "the current row carries the commit hash stamp flag")
+    history = demo_state.get("understanding_history") or []
+    check([h["commit_hash"] for h in history] == ["def2000", "abc1000"],
+          "the history lists the older revisions newest first (current not repeated)")
+    check(history[0]["body"].endswith("Second revision.")
+          and history[1]["body"].endswith("First revision."),
+          "history rows keep their markdown bodies")
+
+    print("== understanding: Refresh spins up the special task ==")
+    # The 'plain' project: no notes, no understanding — a first refresh.
+    result = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "the first refresh notifies success")
+    rows = db_rows("select id, title, status, mode, kind, description from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(len(rows) == 1, "refresh created exactly one task")
+    refresh_id = rows[0][0]
+    check(rows[0][2] == "queued" and rows[0][3] == "investigate"
+          and rows[0][4] == "task",
+          "the refresh task is queued, investigate mode, kind task (%s)" % (rows[0][2:5],))
+    brief = rows[0][5] or ""
+    check("none — first refresh" in brief,
+          "a first refresh embeds 'none — first refresh' as the previous hash")
+    check("Previous document:" not in brief,
+          "a first refresh carries no previous document")
+    check("save_understanding" in brief and "git rev-parse HEAD" in brief,
+          "the brief mandates the save_understanding finishing move")
+    check("do not edit any files" in brief,
+          "the brief states the investigate-mode no-edit constraint")
+    # A system comment marks the refresh on the task thread.
+    conn = db()
+    marker = conn.execute(
+        "select count(*) from task_comments where task_id = ? and author = 'system'"
+        " and body like 'Understanding refresh queued%'",
+        (refresh_id,)).fetchone()[0]
+    conn.close()
+    check(marker == 1, "the refresh task carries the system comment marker")
+
+    print("== understanding: a live refresh refuses a second ==")
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    refused = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already running" in (d.get("message") or "") for d in refused),
+          "a second refresh while the task is live refuses")
+    rows = db_rows("select count(*) from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(rows[0][0] == 1, "the refused refresh created no second task")
+
+    print("== understanding: refresh against an existing document ==")
+    # The demo project has a current understanding + project notes:
+    # the brief must embed the old hash, the old document and the notes.
+    conn = db()
+    conn.execute("insert into project_notes (project_id, source_task_id, body,"
+                 " created_at) values (1, 0, ?, strftime('%s','now'))",
+                 ("Demo note one: the test harness lives in test/.",))
+    conn.execute("insert into project_notes (project_id, source_task_id, body,"
+                 " created_at) values (1, 0, ?, strftime('%s','now') + 1)",
+                 ("Demo note two: meson builds everything.",))
+    conn.commit()
+    conn.close()
+    demo_state = slot_public(auth, "/projects/1", "project")
+    result = act(auth, demo_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "refreshing a project with an understanding succeeds")
+    row = db_rows("select id, description from tasks"
+                  " where title like 'Understanding refresh: demo'")[0]
+    demo_brief = row[1]
+    check("Previous commit hash: big0000" in demo_brief,
+          "the brief embeds the previous document's commit hash")
+    check("```markdown" in demo_brief and "giant!" in demo_brief,
+          "the brief embeds the previous document in a markdown fence")
+    check("git log" in demo_brief and "..HEAD" in demo_brief,
+          "the brief directs the git-history review since the old hash")
+    check("Project notes from other agents:" in demo_brief
+          and "Demo note one: the test harness lives in test/." in demo_brief
+          and "Demo note two: meson builds everything." in demo_brief,
+          "the brief embeds every project note")
+
+    print("== understanding: the refresh task runs, saves, refresh works again ==")
+    # The fake saddle picks the queued plain refresh up (it asks when
+    # idle), runs it to done, and the understanding lands via the API.
+    check(wait_for(lambda: any(
+        int(p.get("task", {}).get("id") or 0) == refresh_id
+        for p in fake.start_payloads) or False,
+        timeout=30, what="refresh task assignment") is not None,
+          "the dispatcher assigns the refresh task to the saddle")
+    fake.push_results("done", summary="explored and saved the understanding")
+    check(wait_task_done(auth, refresh_id, timeout=30) is not None,
+          "the refresh task completes")
+    status, body = saddle_api("POST", "/api/saddle/understanding", fake_token,
+                              {"project": "plain",
+                               "body": "# Plain understanding\n\nFirst map.",
+                               "commit_hash": "fff3000",
+                               "task_id": refresh_id})
+    check(status == 200 and body.get("ok") is True,
+          "the completed refresh saves its understanding via the API")
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    current_row = plain_state.get("understanding_current") or {}
+    check(current_row.get("body") == "# Plain understanding\n\nFirst map."
+          and current_row.get("commit_hash") == "fff3000",
+          "the page shows the new current understanding after the save")
+    check(plain_state.get("understanding_history") == [],
+          "the saved revision is current, not duplicated into history")
+    # Terminal refresh task: a new refresh is allowed again.
+    result = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "success" for d in result),
+          "after the refresh task completes a new refresh is allowed")
+    rows = db_rows("select count(*) from tasks"
+                   " where title like 'Understanding refresh: plain'")
+    check(rows[0][0] == 2, "the second refresh created its task")
+    # The new live task blocks again, and the new current doc is now
+    # the previous one in the fresh brief.
+    plain_state = slot_public(auth, "/projects/%d" % plain_id, "project")
+    refused = act(auth, plain_state["refresh_understanding"], {})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already running" in (d.get("message") or "") for d in refused),
+          "the new live refresh blocks a third")
+    row = db_rows("select description from tasks"
+                  " where title like 'Understanding refresh: plain'"
+                  " order by id desc")[0]
+    check("Previous commit hash: fff3000" in (row[0] or "")
+          and "First map." in (row[0] or ""),
+          "the refreshed brief embeds the new previous hash and document")

+ 4 - 1
test/bundles/users.py

@@ -125,7 +125,10 @@ def run(ctx):
         "current_password": "adminpass123", "new_password": "newpass9",
         "confirm_password": "newpass9"})
     check("changed" in (action_notify(result) or ""), "password changed")
-    login("admin", "newpass9")
+    # Spry's change-own-password revokes the user's auth slots: the
+    # seeded handle above dies with the old password, so re-login and
+    # use the fresh slot for everything after this point.
+    admin = login("admin", "newpass9")
     check(True, "admin logs in with the new password")
 
     carol_auth = login("carol", "carolpass")

+ 9 - 2
test/harness.py

@@ -887,7 +887,13 @@ class FakeSaddle:
                 response = {"state": self.daemon.state, "summary": self.daemon.summary,
                             "uptime_seconds": int(time.monotonic() - self.daemon.started_mono),
                             "last_activity_epoch": self.daemon.last_activity_epoch,
-                            "ssh_public_key": self.daemon.ssh_public_key}
+                            "ssh_public_key": self.daemon.ssh_public_key,
+                            # The wire contract's task confirmation (the
+                            # probe-recovery flip): a running daemon names
+                            # the task it runs; an idle one reports null.
+                            # Without it every answered probe looks like a
+                            # crash orphan and requeues the task.
+                            "task_id": self.daemon.running_task or None}
                 if getattr(self.daemon, "status_extra", None):
                     response.update(self.daemon.status_extra)
                 if self.daemon.resumed_pending:
@@ -1317,7 +1323,8 @@ def start_bundle(name):
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
-                 "resurrect", "origins", "parity")
+                 "resurrect", "origins", "parity", "understanding",
+                 "discoveries", "knowledge_cycle")
 
 
 def run_bundle(name):