Przeglądaj źródła

feat: project notes — migration, POST /api/saddle/note callback with dedicated saddle-callback token, project page curation, capped project_notes + callback in the /start payload (base URL from web-config.json saddle_callback_base_url, env fallback)

clanker 1 miesiąc temu
rodzic
commit
f5813112c6

+ 39 - 0
README.md

@@ -149,6 +149,37 @@ question appears on its page with an answer form. Type the answer, submit
 resumes with the answer in its conversation, and the task returns to
 resumes with the answer in its conversation, and the task returns to
 `working`. Answers are delivered exactly once per question.
 `working`. Answers are delivered exactly once per question.
 
 
+## Rules and project notes
+
+**Rules** (`/rules`) are reusable instructions dispatched with every task
+that carries them: create them on the rules page, then attach/detach them
+on the project page (they lead every task of the project) and the task
+page (they lead the task's own set). A dispatch ships the effective set —
+task rules, then the primary project's, then the co-developed projects'
+in link order, deduplicated; reference projects contribute nothing.
+
+**Project notes** are written by agents mid-task via `saddle note` and
+curated by humans on the project page (delete only — there is no manual
+create). Daemons POST notes to this app's
+`POST /api/saddle/note` callback, authenticated by a dedicated
+`saddle-callback-…` bearer token minted at startup. The callback base
+URL daemons must dial is **this app's own externally reachable address**
+(it differs from the browser's when the daemon runs in a container) and
+is configured with the top-level `saddle_callback_base_url` key in
+`web-config.json`, falling back to the `SADDLE_CALLBACK_BASE_URL`
+environment variable and then `http://localhost:8080`:
+
+```json
+{
+  "statum": { "…": "…" },
+  "saddle_callback_base_url": "http://clanker-web:8080"
+}
+```
+
+Dispatches also carry the primary and co-developed projects' notes (up to
+30 entries / 20 000 characters, oldest first) so agents start with the
+accumulated project knowledge.
+
 ## Integration testing
 ## Integration testing
 
 
 `test/integration_test.py` drives the whole loop against a running
 `test/integration_test.py` drives the whole loop against a running
@@ -179,6 +210,14 @@ clanker-saddle python3 /usr/libexec/saddle/mock_llm.py --port 9999'`
 `MOCK_CMD` prefixes). The suite releases a daemon left blocked by an
 `MOCK_CMD` prefixes). The suite releases a daemon left blocked by an
 earlier run via `/stop` before it starts.
 earlier run via `/stop` before it starts.
 
 
+`meson test` additionally runs `test/web_stack_test.py` — a
+self-contained suite (no daemon needed) that spawns the app in a scratch
+directory plus an in-process fake saddle daemon speaking the wire
+contract, and asserts the web-stack features: plan-review body hiding,
+the effective `rules` array of `/start`, project notes with the
+`/api/saddle/note` callback and the payload's `project_notes`/`callback`
+caps, and raw-log polling into the terminal view.
+
 ## Deployment (USM)
 ## Deployment (USM)
 
 
 `MANIFEST.usm` + `usm-scripts/` package the app as `bin:clanker-saddle`
 `MANIFEST.usm` + `usm-scripts/` package the app as `bin:clanker-saddle`

+ 1 - 0
meson.build

@@ -109,6 +109,7 @@ app_sources = files(
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0002_Part2.vala',
     'src/Data/Migrations/M0002_Part2.vala',
     'src/Data/Migrations/M0003_Rules.vala',
     'src/Data/Migrations/M0003_Rules.vala',
+    'src/Data/Migrations/M0004_ProjectNotes.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
     'src/Services/SaddleService.vala',
     'src/Services/SaddleService.vala',

+ 56 - 1
src/Api/SaddleApi.vala

@@ -231,7 +231,7 @@ namespace ClankerSaddle.Api {
          * Reads and parses the request body as a JSON object; an empty body
          * Reads and parses the request body as a JSON object; an empty body
          * parses as an empty object.
          * parses as an empty object.
          */
          */
-        private static async Json.Object read_json_body(HttpContext http_context) throws Error {
+        internal static async Json.Object read_json_body(HttpContext http_context) throws Error {
             var bytes = yield http_context.request.request_body.read_all();
             var bytes = yield http_context.request.request_body.read_all();
             // to_raw_string on an empty body is not safe; guard the count.
             // to_raw_string on an empty body is not safe; guard the count.
             string text = "";
             string text = "";
@@ -351,6 +351,61 @@ namespace ClankerSaddle.Api {
         }
         }
     }
     }
 
 
+    /**
+     * `POST /api/saddle/note` — the callback endpoint a daemon's
+     * `saddle note` CLI posts to (wire contract): authenticated by the
+     * dedicated saddle-callback bearer token (see
+     * {@link SaddleService.ensure_callback_token}), inserting
+     *
+     * ```json
+     * {"task_id": 1, "project": "ZL4GQ", "body": "…"}
+     * ```
+     *
+     * `task_id` is optional (stored as the note's source task); the
+     * project is resolved by name and must exist. Response
+     * `{"ok": true}`.
+     */
+    public class SaddleNoteEndpoint : Object, Endpoint {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private ProjectService projects = inject<ProjectService>();
+
+        public virtual async HttpResult handle_request(HttpContext http_context,
+                RouteContext route_context) throws Error {
+            if (!(yield saddles.validate_callback_token(bearer_token(http_context)))) {
+                return SaddleUpdateEndpoint.json_error(401, "Missing or invalid callback token");
+            }
+
+            Json.Object body;
+            try {
+                body = yield SaddleUpdateEndpoint.read_json_body(http_context);
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+
+            string project_name = SaddleUpdateEndpoint.get_string_or(body, "project", "");
+            string note_body = SaddleUpdateEndpoint.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 (note_body.strip().length == 0) {
+                return SaddleUpdateEndpoint.json_error(400, "A note body is required");
+            }
+
+            try {
+                var project = yield projects.get_project_by_name(project_name);
+                if (project == null) {
+                    return SaddleUpdateEndpoint.json_error(400,
+                        @"No project exists named '$(project_name)'");
+                }
+                yield projects.add_note(((!)project).id, task_id, note_body);
+                return SaddleUpdateEndpoint.json_ok(new Json.Object());
+            } catch (Error e) {
+                return SaddleUpdateEndpoint.json_error(400, e.message);
+            }
+        }
+    }
+
     /**
     /**
      * The request's bearer token: the `Authorization` header minus its
      * The request's bearer token: the `Authorization` header minus its
      * `Bearer ` prefix, or "" when the header is missing or shaped
      * `Bearer ` prefix, or "" when the header is missing or shaped

+ 23 - 0
src/Data/Entities.vala

@@ -316,6 +316,29 @@ namespace ClankerSaddle.Data {
         }
         }
     }
     }
 
 
+    /**
+     * An LLM-authored remark about a project (`saddle note` via the
+     * callback endpoint), curated from the project page. The source task
+     * is null for notes without one.
+     */
+    public class ProjectNote : Object {
+
+        public int64 id { get; set; }
+        public int64 project_id { get; set; }
+        public int64 source_task_id { get; set; default = 0; }
+        public string body { get; set; default = ""; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<ProjectNote> cfg) {
+            cfg.table("project_notes")
+                .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<int64?>("source_task_id", o => o.source_task_id, (o, v) => o.source_task_id = v ?? 0)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
     /**
      * A bearer token authorising one saddle daemon's calls against the
      * A bearer token authorising one saddle daemon's calls against the
      * `/api/saddle/*` endpoints, generated at registration.
      * `/api/saddle/*` endpoints, generated at registration.

+ 39 - 0
src/Data/Migrations/M0004_ProjectNotes.vala

@@ -0,0 +1,39 @@
+using InvercargillSql.Migrations;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Web-stack project notes: LLM-authored remarks about a project
+     * (written by `saddle note` through the callback endpoint, or any
+     * future author), curated by humans from the project page. The
+     * optional source task links a note back to the task whose agent
+     * wrote it.
+     */
+    public class M0004_ProjectNotes : Migration {
+
+        public override string name { get { return "Project notes: project_notes"; } }
+        public override uint64 serial { get { return 4; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        public override void up(MigrationBuilder b) {
+            b.create_table("project_notes", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<int64?>("source_task_id")
+                    .references("tasks", "id");
+                t.column<string>("body")
+                    .not_null();
+                t.column<DateTime?>("created_at");
+            });
+        }
+
+        public override void down(MigrationBuilder b) {
+            b.drop_table("project_notes");
+        }
+    }
+}

+ 49 - 2
src/Services/ProjectService.vala

@@ -12,8 +12,9 @@ namespace ClankerSaddle.Services {
     public const string LINK_REFERENCE = "reference";
     public const string LINK_REFERENCE = "reference";
 
 
     /**
     /**
-     * Project storage: CRUD over `projects` plus the directed
-     * co-developed/reference link graph in `project_links`.
+     * Project storage: CRUD over `projects`, the directed
+     * co-developed/reference link graph in `project_links`, and the
+     * project's LLM-authored notes.
      */
      */
     public class ProjectService : Object {
     public class ProjectService : Object {
 
 
@@ -129,5 +130,51 @@ namespace ClankerSaddle.Services {
                 .where(expr("parent_project_id == $0", elem<int64?>(project_id)))
                 .where(expr("parent_project_id == $0", elem<int64?>(project_id)))
                 .materialise_async();
                 .materialise_async();
         }
         }
+
+        /** Loads the project identified by `name`, or null. */
+        public async Project? get_project_by_name(string name) throws Error {
+            return yield db.query<Project>()
+                .where(expr("name == $0", elem(name)))
+                .first_async();
+        }
+
+        /**
+         * Inserts a project note (LLM-authored via the callback endpoint).
+         * `source_task_id` may be 0 (unknown source task).
+         */
+        public async ProjectNote add_note(int64 project_id, int64 source_task_id,
+                string body) throws Error {
+            var note = new ProjectNote() {
+                project_id = project_id,
+                source_task_id = source_task_id,
+                body = body,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<ProjectNote>(note);
+            return note;
+        }
+
+        /** Loads the note identified by `id`, or null. */
+        public async ProjectNote? get_note(int64 id) throws Error {
+            return yield db.query<ProjectNote>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** A project's notes, oldest first. */
+        public async ImmutableLot<ProjectNote> list_notes(int64 project_id) throws Error {
+            return yield db.query<ProjectNote>()
+                .where(expr("project_id == $0", elem<int64?>(project_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /** Deletes the note identified by `id`. */
+        public async void delete_note(int64 id) throws Error {
+            var note = yield get_note(id);
+            if (note != null) {
+                yield db.delete_async<ProjectNote>((!)note);
+            }
+        }
     }
     }
 }
 }

+ 137 - 7
src/Services/SaddleService.vala

@@ -8,6 +8,13 @@ using ClankerSaddle.Data;
 
 
 namespace ClankerSaddle.Services {
 namespace ClankerSaddle.Services {
 
 
+    /** Prefix of the dedicated saddle-callback API token (feature 6). */
+    public const string CALLBACK_TOKEN_PREFIX = "saddle-callback-";
+
+    /** Caps of the `project_notes` array in the /start payload. */
+    public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
+    public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
+
     /** A block answer awaiting collection by one saddle's next poll. */
     /** A block answer awaiting collection by one saddle's next poll. */
     public class PendingAnswer : Object {
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
         public int64 block_id { get; set; }
@@ -68,6 +75,7 @@ namespace ClankerSaddle.Services {
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
         private StatePulse pulse = inject<StatePulse>();
         private RuleService rules = inject<RuleService>();
         private RuleService rules = inject<RuleService>();
+        private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
 
         /**
         /**
          * Registers a saddle container and generates its API token.
          * Registers a saddle container and generates its API token.
@@ -135,6 +143,73 @@ namespace ClankerSaddle.Services {
             return latest != null ? ((!)latest).token : null;
             return latest != null ? ((!)latest).token : null;
         }
         }
 
 
+        /**
+         * The dedicated callback token authorising daemon calls to
+         * `POST /api/saddle/note` (`saddle note`): minted on first use
+         * and at startup, kept stable afterwards. It lives in
+         * `api_tokens` with `saddle_id` 0, so it matches no registered
+         * saddle and cannot be used on the per-saddle endpoints.
+         */
+        public async string ensure_callback_token() throws Error {
+            var existing = yield db.query<ApiToken>()
+                .where(expr("saddle_id == $0", elem<int64?>(0)))
+                .order_by(expr("id"))
+                .materialise_async();
+            foreach (var row in existing) {
+                if (row.token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
+                    return row.token;
+                }
+            }
+            var token = new ApiToken() {
+                saddle_id = 0,
+                token = @"$(CALLBACK_TOKEN_PREFIX)$(Uuid.string_random().replace("-", ""))",
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<ApiToken>(token);
+            return token.token;
+        }
+
+        /**
+         * True when `token` is the dedicated callback token — the
+         * credential check for `POST /api/saddle/note`.
+         */
+        public async bool validate_callback_token(string token) throws Error {
+            if (token.length == 0 || !token.has_prefix(CALLBACK_TOKEN_PREFIX)) {
+                return false;
+            }
+            var rows = yield db.query<ApiToken>()
+                .where(expr("saddle_id == $0", elem<int64?>(0)))
+                .materialise_async();
+            foreach (var row in rows) {
+                if (row.token == token) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /**
+         * This app's externally reachable base URL — what daemons use to
+         * POST notes back — read from the `saddle_callback_base_url` key
+         * of web-config.json, falling back to the
+         * `SADDLE_CALLBACK_BASE_URL` environment variable and then to
+         * `http://localhost:8080`. The daemon typically runs in a
+         * container or on another host, so its view of this app differs
+         * from the browser's; set the key to whatever reaches this
+         * process from there.
+         */
+        public string callback_base_url() {
+            string configured = config.get_string("saddle_callback_base_url", "").strip();
+            if (configured.length > 0) {
+                return configured;
+            }
+            var from_env = (Environment.get_variable("SADDLE_CALLBACK_BASE_URL") ?? "").strip();
+            if (from_env.length > 0) {
+                return from_env;
+            }
+            return "http://localhost:8080";
+        }
+
         /** Removes a saddle registration; tasks it was running are re-queued. */
         /** Removes a saddle registration; tasks it was running are re-queued. */
         public async void unregister_saddle(int64 id) throws Error {
         public async void unregister_saddle(int64 id) throws Error {
             var saddle = yield get_saddle(id);
             var saddle = yield get_saddle(id);
@@ -248,13 +323,16 @@ namespace ClankerSaddle.Services {
 
 
         /**
         /**
          * Pushes a task assignment to the saddle daemon's `POST /start`
          * Pushes a task assignment to the saddle daemon's `POST /start`
-         * endpoint in its wire shape — `{task, rules, llm_config}` — with
-         * the project, co-developed/reference links, branch fields, plan
-         * and recent comments inside the task object, plus any block
-         * answers not yet handed over (a resumed workspace picks its
-         * pending answers up from the assignment itself) and the
-         * effective rule set ({@link RuleService.effective_rules}) as the
-         * top-level `rules` array. Project checkouts are addressed by the
+         * endpoint in its wire shape — `{task, rules, project_notes,
+         * callback, llm_config}` — with the project, co-developed/
+         * reference links, branch fields, plan and recent comments inside
+         * the task object, plus any block answers not yet handed over (a
+         * resumed workspace picks its pending answers up from the
+         * assignment itself), the effective rule set ({@link
+         * RuleService.effective_rules}) as the top-level `rules` array,
+         * the capped `project_notes` of the primary and co-developed
+         * projects, and the note-creation `callback` (base URL + bearer
+         * token). Project checkouts are addressed by the
          * `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
          * `SADDLE_PROJECTS_ROOT` convention (`root/task-<task id>/name`)
          * and cloned from each project's `git_url`; the LLM endpoint by
          * and cloned from each project's `git_url`; the LLM endpoint by
          * the {@link LlmEndpoint} environment. Returns the parsed JSON
          * the {@link LlmEndpoint} environment. Returns the parsed JSON
@@ -382,6 +460,58 @@ namespace ClankerSaddle.Services {
             }
             }
             payload.end_array();
             payload.end_array();
 
 
+            // The wire contract's `project_notes` addition: notes of the
+            // primary and co-developed projects, oldest first, capped at
+            // 30 entries / 20 000 chars (head kept).
+            var note_project_names = new Dictionary<string, string>();
+            var project_notes_rows = new Vector<ClankerSaddle.Data.ProjectNote>();
+            note_project_names.set(((!)project).id.to_string(), ((!)project).name);
+            foreach (var note in yield projects.list_notes(((!)project).id)) {
+                project_notes_rows.add(note);
+            }
+            foreach (var linked in co_developed) {
+                note_project_names.set(linked.id.to_string(), linked.name);
+                foreach (var note in yield projects.list_notes(linked.id)) {
+                    project_notes_rows.add(note);
+                }
+            }
+            var project_notes = project_notes_rows.sort((a, b) => (int)(a.id - b.id));
+            payload.set_member_name("project_notes");
+            payload.begin_array();
+            int64 note_entries = 0;
+            int64 note_chars = 0;
+            foreach (var note in project_notes) {
+                if (note_entries >= PROJECT_NOTES_MAX_ENTRIES
+                        || note_chars >= PROJECT_NOTES_MAX_CHARS) {
+                    break;
+                }
+                string note_body = note.body;
+                if (note_chars + note_body.length > PROJECT_NOTES_MAX_CHARS) {
+                    note_body = note_body.substring(0,
+                        (int)(PROJECT_NOTES_MAX_CHARS - note_chars));
+                }
+                payload.begin_object();
+                payload.set_member_name("project");
+                payload.add_string_value(note_project_names.get_or_default(note.project_id.to_string()) ?? "");
+                payload.set_member_name("body");
+                payload.add_string_value(note_body);
+                payload.end_object();
+                note_entries++;
+                note_chars += note_body.length;
+            }
+            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 notes back.
+            payload.set_member_name("callback");
+            payload.begin_object();
+            payload.set_member_name("base_url");
+            payload.add_string_value(callback_base_url());
+            payload.set_member_name("token");
+            payload.add_string_value(yield ensure_callback_token());
+            payload.end_object();
+
             payload.set_member_name("llm_config");
             payload.set_member_name("llm_config");
             payload.begin_object();
             payload.begin_object();
             payload.set_member_name("base_url");
             payload.set_member_name("base_url");

+ 40 - 0
src/actions/ProjectActions.vala

@@ -17,6 +17,12 @@ namespace ClankerSaddle {
         public int64 link_id { get; set; }
         public int64 link_id { get; set; }
     }
     }
 
 
+    /** Sealed note identity carried by {@link DeleteProjectNoteAction}. */
+    public class ProjectNoteActionPrivate : Object {
+        public int64 project_id { get; set; }
+        public int64 note_id { get; set; }
+    }
+
     /**
     /**
      * Creates a project from the projects form (name and git URL
      * Creates a project from the projects form (name and git URL
      * required); default_branch falls back to 'main'.
      * required); default_branch falls back to 'main'.
@@ -151,4 +157,38 @@ namespace ClankerSaddle {
             }
             }
         }
         }
     }
     }
+
+    /**
+     * Deletes a project note; both ids come from the sealed private blob.
+     * Notes are LLM-authored (`saddle note` via the callback endpoint) —
+     * the web UI only curates them.
+     */
+    public class DeleteProjectNoteAction : TypedStatumAction<ProjectNoteActionPrivate> {
+
+        private ProjectService projects = inject<ProjectService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectNoteActionPrivate); } }
+
+        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("/projects");
+            }
+
+            try {
+                yield projects.delete_note(((!)ctx).note_id);
+                return directives().notify("success", "Note deleted")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the note: $(e.message)")
+                    .navigate(@"/projects/$(((!)ctx).project_id)");
+            }
+        }
+    }
 }
 }

+ 32 - 0
src/entrypoints/ProjectsEntrypoint.vala

@@ -113,6 +113,20 @@ namespace ClankerSaddle {
         public string name { get; set; default = ""; }
         public string name { get; set; default = ""; }
     }
     }
 
 
+    /**
+     * A project note on the project page: LLM-authored via `saddle
+     * note`, with its date, source task link and a delete action (no
+     * manual create — notes are user-curated, not user-written).
+     */
+    public class ProjectNoteRowView : Object {
+        public int64 id { get; set; }
+        public string project_name { get; set; default = ""; }
+        public int64 source_task_id { get; set; }
+        public string body { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
     /** Public data of the `project` slot. */
     /** Public data of the `project` slot. */
     public class ProjectDetailPublic : Object {
     public class ProjectDetailPublic : Object {
         public bool exists { get; set; }
         public bool exists { get; set; }
@@ -240,12 +254,30 @@ namespace ClankerSaddle {
                 });
                 });
             }
             }
 
 
+            var note_rows = new Vector<ProjectNoteRowView>();
+            foreach (var note in yield project_service.list_notes(project_id)) {
+                var note_private = new ProjectNoteActionPrivate() {
+                    project_id = project_id,
+                    note_id = note.id
+                };
+                note_rows.add(new ProjectNoteRowView() {
+                    id = note.id,
+                    project_name = ((!)project).name,
+                    source_task_id = note.source_task_id,
+                    body = note.body,
+                    created_display = note.created_at != null
+                        ? ((!)note.created_at).format("%Y-%m-%d %H:%M") : "",
+                    delete = action_registry.author_private<DeleteProjectNoteAction, ProjectNoteActionPrivate>(note_private)
+                });
+            }
+
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_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["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
 
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 type_name = "project",
                 type_name = "project",

+ 21 - 0
src/main.vala

@@ -29,6 +29,7 @@ int main(string[] args) {
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0002_Part2>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
         db.add_migration<ClankerSaddle.Data.Migrations.M0003_Rules>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0004_ProjectNotes>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -43,6 +44,7 @@ int main(string[] args) {
         db.add_entity<Rule>(Rule.entity_mapping);
         db.add_entity<Rule>(Rule.entity_mapping);
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
+        db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
         db.migrate_on_startup();
         db.migrate_on_startup();
 
 
         application.add_scoped<ProjectService>();
         application.add_scoped<ProjectService>();
@@ -97,6 +99,7 @@ int main(string[] args) {
         statum.action<DetachProjectRuleAction>();
         statum.action<DetachProjectRuleAction>();
         statum.action<AttachTaskRuleAction>();
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
+        statum.action<DeleteProjectNoteAction>();
         // spry:actions-end
         // spry:actions-end
 
 
         // spry:resources-begin
         // spry:resources-begin
@@ -110,11 +113,14 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/{id}/update", Method.POST));
             new EndpointRoute("/api/saddle/{id}/update", Method.POST));
         application.add_endpoint<SaddlePollEndpoint>(
         application.add_endpoint<SaddlePollEndpoint>(
             new EndpointRoute("/api/saddle/{id}/poll", Method.GET));
             new EndpointRoute("/api/saddle/{id}/poll", Method.GET));
+        application.add_endpoint<SaddleNoteEndpoint>(
+            new EndpointRoute("/api/saddle/note", Method.POST));
         application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
         application.add_endpoint<ClankerSaddle.Api.TerminalSseEndpoint>(
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
 
 
         GLib.Idle.add(() => {
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
             seed_admin.begin(application.container);
+            mint_callback_token.begin(application.container);
             start_dispatcher.begin(application.container);
             start_dispatcher.begin(application.container);
             return false;
             return false;
         });
         });
@@ -157,3 +163,18 @@ private async void start_dispatcher(Container container) {
         printerr("[ClankerSaddle] Dispatcher startup failed: %s\n", e.message);
         printerr("[ClankerSaddle] Dispatcher startup failed: %s\n", e.message);
     }
     }
 }
 }
+
+/**
+ * Mints the dedicated 'saddle-callback' token at startup when absent —
+ * the credential daemons use to POST `saddle note` remarks back (see
+ * {@link ClankerSaddle.Api.SaddleNoteEndpoint}).
+ */
+private async void mint_callback_token(Container container) {
+    try {
+        var scope = container.create_scope();
+        string token = yield scope.resolve<SaddleService>().ensure_callback_token();
+        print("[ClankerSaddle] Saddle callback token ready (%s…)\n", token.substring(0, 16));
+    } catch (Error e) {
+        printerr("[ClankerSaddle] Callback token minting failed: %s\n", e.message);
+    }
+}

+ 16 - 0
src/pages/project_detail.html

@@ -82,6 +82,22 @@
             </form>
             </form>
             <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
             <p class="muted" stm-if="project.rule_options.length == 0">Create rules on the <a href="/rules">rules page</a>.</p>
 
 
+            <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>
+            <div stm-for-note-in="project.notes" stm-key="note.id" class="card">
+                <p><span class="badge" stm-text="note.project_name">project</span>
+                    <span class="muted" stm-text="note.created_display">when</span>
+                    <span class="muted" stm-if="note.source_task_id > 0"> · from task
+                        <a stm-attribute.href="'/tasks/' + note.source_task_id" stm-text="'#' + note.source_task_id">task</a></span></p>
+                <div class="md" stm-markdown="note.body">body</div>
+                <form class="inline">
+                    <button type="button" stm-action="note.delete" stm-confirm="Delete this note?">Delete</button>
+                </form>
+            </div>
+            <p stm-if="project.notes.length == 0" class="muted">No notes yet — they appear as agents
+                record them with <code>saddle note</code>.</p>
+
             <h3>Tasks in this project</h3>
             <h3>Tasks in this project</h3>
             <table>
             <table>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>
                 <thead><tr><th>#</th><th>Title</th><th>Status</th><th>Priority</th></tr></thead>

+ 112 - 0
test/web_stack_test.py

@@ -450,6 +450,118 @@ def main():
 
 
         check(wait_task_done(auth, rule_task, timeout=60) is not None,
         check(wait_task_done(auth, rule_task, timeout=60) is not None,
               "dispatched task completes against the fake daemon")
               "dispatched task completes against the fake daemon")
+
+        print("== feature 6: project notes + callback ==")
+        conn = db()
+        callback_rows = conn.execute(
+            "select token from api_tokens where saddle_id = 0").fetchall()
+        conn.close()
+        check(len(callback_rows) == 1 and callback_rows[0][0].startswith("saddle-callback-"),
+              "dedicated saddle-callback token minted at startup")
+        callback_token = callback_rows[0][0]
+
+        status, _ = saddle_api("POST", "/api/saddle/note", None,
+                               {"project": "demo", "body": "x"})
+        check(status == 401, "note endpoint rejects a missing token")
+        status, _ = saddle_api("POST", "/api/saddle/note", "saddle-callback-not-real",
+                               {"project": "demo", "body": "x"})
+        check(status == 401, "note endpoint rejects a wrong token")
+
+        status, body = saddle_api("POST", "/api/saddle/note", callback_token,
+                                  {"task_id": rule_task, "project": "demo",
+                                   "body": "style.css tokens live in :root"})
+        check(status == 200 and body.get("ok") is True, "note accepted with the callback token")
+        status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
+                               {"project": "no-such-project", "body": "x"})
+        check(status == 400, "unknown project rejected")
+        status, _ = saddle_api("POST", "/api/saddle/note", callback_token,
+                               {"project": "demo", "body": "   "})
+        check(status == 400, "empty note body rejected")
+
+        project = slot_public(auth, "/projects/1", "project")
+        check(any("style.css tokens" in n["body"] and int(n["source_task_id"]) == rule_task
+                  for n in project["notes"]),
+              "project page lists the note with its source task")
+        act(auth, [n for n in project["notes"] if "style.css tokens" in n["body"]][0]["delete"], {})
+        check(all("style.css tokens" not in n["body"]
+                  for n in slot_public(auth, "/projects/1", "project")["notes"]),
+              "note deleted from the project page")
+
+        # /start payload: notes of primary + co-developed (not reference),
+        # oldest first; callback carries the configured base URL + token.
+        saddle_api("POST", "/api/saddle/note", callback_token,
+                   {"project": "demo", "body": "note-one"})
+        saddle_api("POST", "/api/saddle/note", callback_token,
+                   {"project": "demo", "body": "note-two"})
+        saddle_api("POST", "/api/saddle/note", callback_token,
+                   {"project": "co", "body": "co-note"})
+        conn = db()
+        conn.execute("insert into project_notes (project_id, body, created_at)"
+                     " values (?, 'ref-note-must-not-ship', strftime('%s','now'))", (ref_id,))
+        conn.commit()
+        conn.close()
+
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Notes dispatch",
+            "description": "Project notes ride the /start payload.",
+            "priority": "2",
+        })
+        check(wait_for(lambda: len(fake.start_payloads) >= 2 or False,
+                       timeout=30, what="second /start push") is not None,
+              "dispatcher pushes the notes task")
+        payload = fake.start_payloads[-1]
+        notes = payload.get("project_notes", [])
+        check([n["body"] for n in notes] == ["note-one", "note-two", "co-note"],
+              "project_notes: primary + co-developed, oldest first (%s)" % [n["body"] for n in notes])
+        check(all(n.get("project") for n in notes)
+              and notes[2]["project"] == "co",
+              "project_notes carry the project name")
+        check("ref-note-must-not-ship" not in [n["body"] for n in notes],
+              "reference project notes excluded")
+        callback = payload.get("callback", {})
+        check(callback.get("base_url") == WEB and callback.get("token") == callback_token,
+              "callback carries the configured base URL and the minted token")
+
+        # entry cap: 30 of 33 candidates
+        conn = db()
+        for i in range(33):
+            conn.execute("insert into project_notes (project_id, body, created_at)"
+                         " values (?, ?, strftime('%s','now'))", (1, "bulk-%02d" % i))
+        conn.commit()
+        conn.close()
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Entry cap dispatch",
+            "priority": "2",
+        })
+        check(wait_for(lambda: len(fake.start_payloads) >= 3 or False,
+                       timeout=30, what="third /start push") is not None,
+              "dispatcher pushes the entry-cap task")
+        notes = fake.start_payloads[-1].get("project_notes", [])
+        check(len(notes) == 30 and notes[0]["body"] == "note-one" and notes[1]["body"] == "note-two",
+              "project_notes capped at 30 entries, oldest kept (%d)" % len(notes))
+
+        # char cap: the giant note is truncated so the total stays <= 20000
+        conn = db()
+        conn.execute("delete from project_notes where body like 'bulk-%' or body = 'co-note'")
+        conn.execute("insert into project_notes (project_id, body, created_at)"
+                     " values (?, ?, strftime('%s','now'))", (1, "giant!" + "x" * 25000,))
+        conn.commit()
+        conn.close()
+        act(auth, slot_public(auth, "/tasks", "tasks")["create"], {
+            "project_id": "1",
+            "title": "Char cap dispatch",
+            "priority": "2",
+        })
+        check(wait_for(lambda: len(fake.start_payloads) >= 4 or False,
+                       timeout=30, what="fourth /start push") is not None,
+              "dispatcher pushes the char-cap task")
+        notes = fake.start_payloads[-1].get("project_notes", [])
+        total = sum(len(n["body"]) for n in notes)
+        check(len(notes) == 3 and total == 20000 and notes[2]["body"].startswith("giant!"),
+              "project_notes char cap: head kept, giant truncated (entries=%d, chars=%d)"
+              % (len(notes), total))
     finally:
     finally:
         app.terminate()
         app.terminate()
         try:
         try: