فهرست منبع

Add the discoveries knowledge base: storage, callback API, page and payload injection

Goal 24: a global, searchable, EDITABLE knowledge base of non-project facts (compiler quirks, library behaviours, tool flags, protocol gotchas).

- M0005 migration: discoveries(id, body, source_task_id, created_at, updated_at), idempotent like M0004.
- Discovery entity + DiscoveryService: newest-first listing, case-insensitive substring search, insert, update (replaces the body in place, bumps updated_at, keeps created_at and the original source task) and delete.
- Callback API (bearer auth like the understanding endpoints): POST /api/saddle/discovery inserts and answers {ok, discovery_id}; POST /api/saddle/discovery/update refines an existing row by id (error JSON when unknown); GET /api/saddle/discoveries?q= lists/searches newest-first capped at 20 rows.
- Discoveries page (/discoveries, nav link in the Library menu): search box (client-side filter like the task page's selects, with the entrypoint honouring a server-side q), rows newest-first with markdown bodies, created/updated stamps (updated shown only after a refinement), an inline EDIT form per row and delete - the human path to refine nuance. Dashboard shows the five newest.
- build_assignment_payload: discoveries member after understanding - newest 10 rows as {body} objects capped at 8000 chars total, empty array when none.
- test/bundles/discoveries.py (43 checks) registered in BUNDLES_ORDER: migration shape, API auth/validation/ordering/search/update semantics, human edit + delete actions, payload carriage and caps, dashboard slice.
saddle-agent 1 ماه پیش
والد
کامیت
6895c0802f

+ 11 - 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',
@@ -186,6 +192,7 @@ app_sources = files(
     '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',
@@ -200,6 +207,7 @@ app_sources = files(
     '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',
@@ -214,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',
@@ -229,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',
@@ -252,6 +262,7 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    discoveries_page,
     stacks_page,
     stack_detail_page,
     users_page,

+ 150 - 0
src/Api/SaddleApi.vala

@@ -533,6 +533,156 @@ namespace ClankerSaddle.Api {
         }
     }
 
+    /**
+     * 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

+ 33 - 0
src/Data/Entities.vala

@@ -451,6 +451,39 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * 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

+ 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");
+        }
+    }
+}

+ 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);
+            }
+        }
+    }
+}

+ 43 - 0
src/Services/SaddleService.vala

@@ -46,6 +46,16 @@ namespace ClankerSaddle.Services {
      */
     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
@@ -155,6 +165,7 @@ namespace ClankerSaddle.Services {
         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>();
 
         /**
@@ -1149,6 +1160,38 @@ namespace ClankerSaddle.Services {
             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

+ 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");
+            }
+        }
+    }
+}

+ 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>();

+ 12 - 0
src/main.vala

@@ -46,6 +46,7 @@ int main(string[] args) {
         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);
@@ -59,6 +60,7 @@ int main(string[] args) {
         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);
@@ -78,6 +80,7 @@ int main(string[] args) {
         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>();
@@ -113,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>();
@@ -171,6 +175,8 @@ int main(string[] args) {
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
         statum.action<RefreshUnderstandingAction>();
+        statum.action<UpdateDiscoveryAction>();
+        statum.action<DeleteDiscoveryAction>();
         statum.action<CreateStackAction>();
         statum.action<UpdateStackAction>();
         statum.action<DeleteStackAction>();
@@ -220,6 +226,12 @@ int main(string[] args) {
             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">

+ 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")

+ 2 - 1
test/harness.py

@@ -1317,7 +1317,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", "understanding")
+                 "resurrect", "origins", "parity", "understanding",
+                 "discoveries")
 
 
 def run_bundle(name):