Forráskód Böngészése

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 hónapja
szülő
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
 `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
 
 `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
 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)
 
 `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/M0002_Part2.vala',
     'src/Data/Migrations/M0003_Rules.vala',
+    'src/Data/Migrations/M0004_ProjectNotes.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.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
          * 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();
             // to_raw_string on an empty body is not safe; guard the count.
             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
      * `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
      * `/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";
 
     /**
-     * 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 {
 
@@ -129,5 +130,51 @@ namespace ClankerSaddle.Services {
                 .where(expr("parent_project_id == $0", elem<int64?>(project_id)))
                 .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 {
 
+    /** 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. */
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
@@ -68,6 +75,7 @@ namespace ClankerSaddle.Services {
         private SaddlePollState poll_state = inject<SaddlePollState>();
         private StatePulse pulse = inject<StatePulse>();
         private RuleService rules = inject<RuleService>();
+        private Astralis.WebConfig config = inject<Astralis.WebConfig>();
 
         /**
          * Registers a saddle container and generates its API token.
@@ -135,6 +143,73 @@ namespace ClankerSaddle.Services {
             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. */
         public async void unregister_saddle(int64 id) throws Error {
             var saddle = yield get_saddle(id);
@@ -248,13 +323,16 @@ namespace ClankerSaddle.Services {
 
         /**
          * 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`)
          * and cloned from each project's `git_url`; the LLM endpoint by
          * the {@link LlmEndpoint} environment. Returns the parsed JSON
@@ -382,6 +460,58 @@ namespace ClankerSaddle.Services {
             }
             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.begin_object();
             payload.set_member_name("base_url");

+ 40 - 0
src/actions/ProjectActions.vala

@@ -17,6 +17,12 @@ namespace ClankerSaddle {
         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
      * 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 = ""; }
     }
 
+    /**
+     * 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 class ProjectDetailPublic : Object {
         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);
             props["links"] = StateJson.list<ProjectLinkView>(links.to_immutable_buffer());
             props["linkable"] = StateJson.list<ProjectRowView>(linkable.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
                 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.M0002_Part2>();
         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<ProjectLink>(ProjectLink.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<RuleProject>(RuleProject.entity_mapping);
         db.add_entity<TaskRule>(TaskRule.entity_mapping);
+        db.add_entity<ProjectNote>(ProjectNote.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
@@ -97,6 +99,7 @@ int main(string[] args) {
         statum.action<DetachProjectRuleAction>();
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
+        statum.action<DeleteProjectNoteAction>();
         // spry:actions-end
 
         // spry:resources-begin
@@ -110,11 +113,14 @@ int main(string[] args) {
             new EndpointRoute("/api/saddle/{id}/update", Method.POST));
         application.add_endpoint<SaddlePollEndpoint>(
             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>(
             new EndpointRoute("/terminal-stream/{session_id}", Method.GET));
 
         GLib.Idle.add(() => {
             seed_admin.begin(application.container);
+            mint_callback_token.begin(application.container);
             start_dispatcher.begin(application.container);
             return false;
         });
@@ -157,3 +163,18 @@ private async void start_dispatcher(Container container) {
         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>
             <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>
             <table>
                 <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,
               "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:
         app.terminate()
         try: