Browse Source

Merge branch 'goal/toto-batch-1' of clanker/clanker-saddle into web-stack

Billy Barrow 1 month ago
parent
commit
ef672f7aa7
44 changed files with 3269 additions and 76 deletions
  1. 73 27
      README.md
  2. 20 0
      meson.build
  3. 92 1
      src/Data/Entities.vala
  4. 71 3
      src/Data/Migrations/M0001_Initial.vala
  5. 196 0
      src/Data/Migrations/M0002_BatchImprovements.vala
  6. 6 2
      src/Services/AllocationService.vala
  7. 105 1
      src/Services/GoalService.vala
  8. 152 0
      src/Services/NoteService.vala
  9. 3 0
      src/Services/PushService.vala
  10. 8 4
      src/Services/RuleService.vala
  11. 104 19
      src/Services/SaddleService.vala
  12. 266 0
      src/Services/StackService.vala
  13. 68 1
      src/Services/TaskService.vala
  14. 2 1
      src/actions/DashboardActions.vala
  15. 3 1
      src/actions/GoalActions.vala
  16. 204 0
      src/actions/NoteActions.vala
  17. 2 1
      src/actions/PushActions.vala
  18. 37 0
      src/actions/SaddleActions.vala
  19. 274 0
      src/actions/StackActions.vala
  20. 42 3
      src/actions/TaskActions.vala
  21. 32 2
      src/entrypoints/GoalDetailEntrypoint.vala
  22. 83 2
      src/entrypoints/HomeEntrypoint.vala
  23. 20 1
      src/entrypoints/ProjectsEntrypoint.vala
  24. 8 0
      src/entrypoints/SaddlesEntrypoint.vala
  25. 170 0
      src/entrypoints/StacksEntrypoint.vala
  26. 20 0
      src/main.vala
  27. 18 0
      src/pages/goal_detail.html
  28. 52 0
      src/pages/home.html
  29. 1 0
      src/pages/main.html
  30. 11 0
      src/pages/project_detail.html
  31. 11 0
      src/pages/saddles.html
  32. 67 0
      src/pages/stack_detail.html
  33. 42 0
      src/pages/stacks.html
  34. 20 2
      src/pages/task_detail.html
  35. 6 1
      src/pages/tasks.html
  36. 6 1
      test/bundles/__init__.py
  37. 56 0
      test/bundles/lifecycle.py
  38. 183 0
      test/bundles/notes.py
  39. 142 0
      test/bundles/origins.py
  40. 30 0
      test/bundles/pages.py
  41. 15 1
      test/bundles/push.py
  42. 227 0
      test/bundles/resurrect.py
  43. 316 0
      test/bundles/stacks.py
  44. 5 2
      test/harness.py

+ 73 - 27
README.md

@@ -63,7 +63,7 @@ Orchestration behaviour to know:
 | `SADDLE_LLM_API_KEY` / `SADDLE_LLM_MODEL` | Optional credentials/model for the above |
 | `SADDLE_LOST_WAKE_S` | Lost-wake guard window (default 15s): no ask after a wake → the saddle goes offline and the next capable one is woken |
 | `SADDLE_STALE_S` | Running-liveness window (default 90s): no chunks or results for this long → one exceptional `GET /status` probe; failure requeues the task and marks the saddle offline |
-| `SADDLE_PROJECTS_ROOT` | Checkouts are addressed as `root/<project-name>` inside the saddle container (default `/workspace`) |
+| `SADDLE_PROJECTS_ROOT` | Fallback for advisory checkout paths when a saddle's daemon did not report a `workspace_root` at registration (itself falling back to `/workspace`); see *Deployment assumptions* |
 
 ## Quick start (local)
 
@@ -101,6 +101,10 @@ restart on save); `web-config.json` (from `spry keys`) keeps sessions
 alive across restarts and is never packaged or baked into images.
 
 The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
+It is UPGRADEABLE: migrations are incremental from `M0002` on and apply
+automatically at startup, so a deployed database keeps its data across
+upgrades. An old single-migration database (serial 1 only) upgrades in
+place on first boot of a newer build.
 
 ## Container deployment (spry deploy / usm)
 
@@ -131,7 +135,6 @@ podman run -d --name clanker-web --userns=keep-id --network clanker-net \
     -p 8080:8080 -v "$PWD/data:/var/lib/clanker-saddle:Z" \
     -e SPRY_SEED_ADMIN_USERNAME=admin -e SPRY_SEED_ADMIN_PASSWORD=change-me \
     -e SADDLE_LLM_BASE_URL=http://127.0.0.1:9999 \
-    -e SADDLE_PROJECTS_ROOT=/workspace \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     localhost/clanker-saddle:0.1
 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
@@ -147,8 +150,11 @@ saddle container via `podman exec -d clanker-saddle …`, reachable at
 `web-config.json` `saddle_callback_base_url` key) must be the app's
 address **as the daemon dials it** — every registered daemon pushes
 telemetry, task callbacks and notes there. Put the SQLite store on a volume
-(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`)
-and mount git checkouts under `/workspace`. On SELinux hosts the bind
+(`SPRY_DB_PATH` defaults to `/var/lib/clanker-saddle/clanker-saddle.sqlite`).
+The saddle daemon clones into its own `/workspace` volume and reports
+that root at registration, so the web container needs NO workspace mount;
+`SADDLE_PROJECTS_ROOT` on the web side is only the fallback for daemons
+that do not report one. On SELinux hosts the bind
 mounts need `:Z`, and rootless podman needs `--userns=keep-id` for the
 mounts to be writable. For durable sessions mount `web-config.json`
 read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
@@ -163,15 +169,17 @@ for the full guide): a two-line Containerfile `FROM` the loaded base
 plus `dnf install` of your tools (C, Vala, GTK, Node, .NET, Android,
 QEMU — anything), then tag the capability names you gave those tools in
 the web UI's saddle page so only tasks requiring them route there.
-Goal task worktrees need every container that may run a goal's tasks to
-share the `/workspace` volume.
+Each saddle mounts its OWN workspace volume: goal task worktrees are cut
+from `origin/<goal branch>` and merges travel through origin, so no two
+saddles ever need to share a filesystem (see *Deployment assumptions*).
 
 ## Creating your first project and task
 
 1. **Project** — `/projects` → name, git URL, default branch. The
-   checkout must exist at `$SADDLE_PROJECTS_ROOT/<name>` on the saddle's
-   filesystem (clone it into the workspace volume; the agent does not
-   clone). Link related projects as *co-developed* (agent may modify) or
+   saddle daemon clones the repo itself into its own workspace; the
+   paths the web app puts in assignments are ADVISORY and are rewritten
+   onto the asking saddle's filesystem (the daemon owns its volumes —
+   the web needs no access to them). Link related projects as *co-developed* (agent may modify) or
    *reference* (read-only) from the project page.
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
    mode (implement / investigate / review / test) and priority (1 is
@@ -206,6 +214,13 @@ transitions) and `POST /api/saddle/chunks` (raw PTY batches) — every
 `/api/saddle/*` call is authenticated by the per-saddle bearer token
 (removing the saddle revokes it).
 
+A goal's **involved projects** start as a one-time snapshot of the
+primary project's linked projects: creating a goal copies the project's
+co-developed/reference links into the goal's own link set, where you
+prune or extend them before starting. Links added to the project later
+never propagate to existing goals — the goal's link set is deliberately
+frozen at creation (the project page remains the long-term graph).
+
 ## Users, roles and your profile
 
 Two roles decide what a logged-in user can do (the account model is
@@ -345,6 +360,17 @@ loudly, never silently claimed as delivered), endpoints answering
 rejection) keep the subscription row and retry, since the endpoint
 may recover.
 
+## Stacks
+
+**Stacks** (`/stacks`) are named collections of reference projects:
+build the collection once (projects with their relationships —
+co-developed or reference), then **Apply stack** on a project page or a
+goal page attaches the whole collection to that project's or goal's
+linked projects in one click. Applying skips exact duplicates (same
+project, same relationship), so re-applying after the collection grows
+is safe; a different relationship on the same project coexists.
+Deleting a stack never unlinks what it already applied.
+
 ## Rules and project notes
 
 **Rules** (`/rules`) are reusable instructions dispatched with every task
@@ -422,9 +448,9 @@ truth. The harness-prepared `demo` repo and its bare origin both live
 on branch `main` (the project registration's default branch matches).
 
 The daemon's `SADDLE_WORKSPACE_ROOT` and the app's
-`SADDLE_PROJECTS_ROOT` point at the SAME directory (the goal clone the
-daemon cuts must anchor the worker worktrees — the shared-volume
-deployment assumption). For a container run point `WEB`/`SADDLE` at the
+`SADDLE_PROJECTS_ROOT` may point at the same directory for a local run
+(the daemon reports its root at registration, and the app's value is
+only the fallback for daemons that do not). For a container run point `WEB`/`SADDLE` at the
 published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's
 SQLite file as `DB_PATH`, and start the in-container mock via
 `MOCK_CMD='podman exec -d clanker-saddle python3
@@ -473,22 +499,42 @@ HTTP/2 removes the cap entirely.
 
 ## Deployment assumptions (goal era)
 
-- **Shared workspace volume across saddles.** A goal's clone workspace
-  (`<root>/goal-<id>`, one clone per involved repo on the goal branch)
-  anchors every task worktree (`<root>/task-<task-id>`, ephemeral
-  branch `task/<goal-id>-<task-id>`). Whichever saddle runs a goal task
-  must see the same `SADDLE_WORKSPACE_ROOT` volume as the saddle that
-  runs the goal agent — today the fleet is single-saddle per goal by
-  construction (one goal agent, one worker at a time on the one
-  registered saddle); multi-saddle fleets must mount the same volume
-  everywhere before fanning goal work out.
-- **Database tear-down-and-recreate.** The schema is a single flattened
-  `M0001_Initial` (goals with links/comments, the flattened tasks, and
-  the plan-16 deletions already applied: no `api_tokens`, no
+- **Per-saddle workspace volumes; git is the coordination medium.** A
+  goal's clone workspace (`<root>/goal-<id>`, one clone per involved
+  repo on the goal branch) lives on whichever saddle runs the goal
+  agent; goal task worktrees (`<root>/task-<task-id>`, ephemeral branch
+  `task/<goal-id>-<task-id>`) are cut from `origin/<goal branch>` — not
+  the local clone — so a worker on ANOTHER saddle (its own volume)
+  starts from the fleet's pushed tip. At every workspace adoption the
+  daemon fetches origin and surfaces a loud *unpulled changes* notice
+  per repo left behind, instructing `git pull --rebase`. The goal agent
+  merges finished task branches via origin (`git fetch origin
+  task/<id>` + `git merge origin/task/<id>`), so a branch that only
+  exists on another saddle merges exactly like a local one. Consequence:
+  every project involved in a goal needs a pushable origin (deploy
+  keys), and saddles mount their own `SADDLE_WORKSPACE_ROOT` — no
+  shared `/workspace` anywhere.
+- **The web app has NO workspace access.** Assignment project paths are
+  advisory: they are built from the ASKING saddle's registered
+  `workspace_root` (its daemon reports `SADDLE_WORKSPACE_ROOT` at
+  `POST /register`; the saddles page shows it), falling back to
+  `SADDLE_PROJECTS_ROOT`, then the historical `/workspace`. The daemon
+  owns its filesystem and rewrites paths onto its own root.
+- **Database upgrades IN PLACE.** `M0001_Initial` remains the full
+  fresh-install schema (goals with links/comments, the flattened tasks,
+  and the plan-16 deletions already applied: no `api_tokens`, no
   `working_branch`, no saddle-row work cache — blocks, comments and
-  narrative ride the run task's own structures). There is NO upgrade
-  path from an earlier era's database — delete the SQLite file (or
-  point `SPRY_DB_PATH` at a fresh path) and let the app recreate it.
+  narrative ride the run task's own structures). From goal 8 on,
+  schema changes ship as INCREMENTAL migrations (`M0002_BatchImprovements`
+  added `notes`, `stacks`, `stack_projects` and the per-saddle
+  `prompt_note`/`workspace_root` columns) and run automatically at
+  startup: a deployed database is upgraded in place, no data loss, no
+  deleting the SQLite file. **Never edit an applied migration again** —
+  a database that already recorded it will not re-run it, and the pages
+  touching the new tables/columns 500 (exactly the live-instance break
+  this fixed). Add a new `M0003_...` with the next serial instead; the
+  pages bundle pins the recorded serials so a flattened edit fails the
+  suite.
 - **Restart both sides.** The daemon (`saddle`) and this app must both
   restart onto the goal-era builds together — the wire contract
   (assignment `kind`/`mode`/`creator`/`goal`/`ephemeral_branch`, the

+ 20 - 0
meson.build

@@ -104,6 +104,18 @@ saddles_page = custom_target('saddles-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'SaddlesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+stacks_page = custom_target('stacks-page',
+    input: 'src/pages/stacks.html',
+    output: 'StacksPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'StacksPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+stack_detail_page = custom_target('stack-detail-page',
+    input: 'src/pages/stack_detail.html',
+    output: 'StackDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'StackDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 rules_page = custom_target('rules-page',
     input: 'src/pages/rules.html',
     output: 'RulesPage.vala',
@@ -159,6 +171,7 @@ app_sources = files(
     'src/Format.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
+    'src/Data/Migrations/M0002_BatchImprovements.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
@@ -170,6 +183,8 @@ app_sources = files(
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',
+    'src/Services/StackService.vala',
+    'src/Services/NoteService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
@@ -184,6 +199,8 @@ app_sources = files(
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
+    'src/actions/StackActions.vala',
+    'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/ProfileActions.vala',
@@ -197,6 +214,7 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
@@ -219,6 +237,8 @@ app_generated = [
     goal_detail_page,
     saddles_page,
     rules_page,
+    stacks_page,
+    stack_detail_page,
     users_page,
     profile_page,
     # spry:generated-end

+ 92 - 1
src/Data/Entities.vala

@@ -116,6 +116,51 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A named bundle of reference/co-developed projects (goal 8 task
+     * 2): applied one-click to a project's or a goal's link set so a
+     * fleet's common reference collection needs one click, not N.
+     * Stored in the `stacks` table.
+     */
+    public class Stack : Object {
+
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string? summary { get; set; default = null; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Stack> cfg) {
+            cfg.table("stacks")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("name", o => o.name, (o, v) => o.name = v)
+                .column<string>("summary", o => o.summary, (o, v) => o.summary = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
+    /**
+     * One project of a {@link Stack} with the relationship it is
+     * applied as ('co-developed' or 'reference'). Stored in the
+     * `stack_projects` table.
+     */
+    public class StackProject : Object {
+
+        public int64 id { get; set; }
+        public int64 stack_id { get; set; }
+        public int64 project_id { get; set; }
+        public string relationship { get; set; default = "reference"; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<StackProject> cfg) {
+            cfg.table("stack_projects")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<int64?>("stack_id", o => o.stack_id, (o, v) => o.stack_id = v)
+                .column<int64?>("project_id", o => o.project_id, (o, v) => o.project_id = v)
+                .column<string>("relationship", o => o.relationship, (o, v) => o.relationship = v)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
      * One row of the plan-17 unified event ledger (`events`): daemon
      * events carry their session and its per-session monotonic `seq`
@@ -230,8 +275,22 @@ namespace ClankerSaddle.Data {
         public string status { get; set; default = "idle"; }
         public string? git_name { get; set; default = null; }
         public string? git_email { get; set; default = null; }
+        /**
+         * The saddle admin's standing prompt note (goal 8 task 5):
+         * a message about THIS machine (e.g. "You have root and may
+         * install development tooling via DNF") served verbatim inside
+         * every assignment's system prompt; null when unset.
+         */
+        public string? prompt_note { get; set; default = null; }
         public string? ssh_public_key { get; set; default = null; }
         public string? token { get; set; default = null; }
+        /**
+         * The workspace root this saddle's daemon reported at
+         * /register (its SADDLE_WORKSPACE_ROOT), or null for daemons
+         * that predate the field — assignment paths fall back to
+         * SADDLE_PROJECTS_ROOT, then "/workspace".
+         */
+        public string? workspace_root { get; set; default = null; }
 
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
@@ -241,8 +300,10 @@ namespace ClankerSaddle.Data {
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
                 .column<string>("git_name", o => o.git_name, (o, v) => o.git_name = v)
                 .column<string>("git_email", o => o.git_email, (o, v) => o.git_email = v)
+                .column<string>("prompt_note", o => o.prompt_note, (o, v) => o.prompt_note = v)
                 .column<string>("ssh_public_key", o => o.ssh_public_key, (o, v) => o.ssh_public_key = v)
-                .column<string>("token", o => o.token, (o, v) => o.token = v);
+                .column<string>("token", o => o.token, (o, v) => o.token = v)
+                .column<string>("workspace_root", o => o.workspace_root, (o, v) => o.workspace_root = v);
         }
     }
 
@@ -358,6 +419,36 @@ namespace ClankerSaddle.Data {
         }
     }
 
+    /**
+     * A dashboard scratchpad note (goal 8 task 3): a personal
+     * todo/idea jotted down before a batch of changes is ready to be
+     * a full goal. Promoting ({@link ClankerSaddle.Services.NoteService.promote_to_goal})
+     * creates a DRAFT goal from the body and sets {@link goal_id},
+     * which links the created goal and freezes the note. Stored in
+     * the `notes` table.
+     */
+    public class Note : Object {
+
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public bool done { get; set; default = false; }
+        public DateTime? done_at { get; set; default = null; }
+        /** The goal this note promoted into (0 = not promoted). */
+        public int64 goal_id { get; set; default = 0; }
+        public DateTime? created_at { get; set; default = null; }
+
+        public static void entity_mapping(EntityMapperBuilder<Note> cfg) {
+            cfg.table("notes")
+                .column<int64?>("id", o => o.id, (o, v) => o.id = v)
+                .column<string>("body", o => o.body, (o, v) => o.body = v)
+                .column<bool>("done", o => o.done, (o, v) => o.done = v)
+                .column<DateTime?>("done_at", o => o.done_at, (o, v) => o.done_at = v)
+                .column<int64?>("goal_id", o => o.goal_id > 0 ? (int64?) o.goal_id : (int64?) null,
+                    (o, v) => o.goal_id = v ?? 0)
+                .column<DateTime?>("created_at", o => o.created_at, (o, v) => o.created_at = v);
+        }
+    }
+
     /**
      * A named tag used for dispatch routing (`capabilities`): saddles
      * declare the tags they carry, tasks the tags they require.

+ 71 - 3
src/Data/Migrations/M0001_Initial.vala

@@ -20,7 +20,8 @@ namespace ClankerSaddle.Data.Migrations {
      * narrative, resume-notice and log-view surface derives from it,
      * and daemon chunk pushes are idempotent on its
      * UNIQUE(session_id, seq); rules and their attachments; project
-     * notes; capability tags; the plan-11 notification tables.
+     * notes; the dashboard scratchpad notes (goal 8 task 3);
+     * capability tags; the plan-11 notification tables.
      */
     public class M0001_Initial : Migration {
 
@@ -57,6 +58,40 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
             });
 
+            // Stacks (goal 8 task 2): a named bundle of reference
+            // projects applied one-click to a project's or a goal's
+            // link set — the common reference collections a fleet of
+            // repos always wants attached.
+            b.create_table("stacks", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("name")
+                    .not_null()
+                    .unique();
+                t.column<string>("summary");
+                t.column<DateTime?>("created_at");
+            });
+
+            b.create_table("stack_projects", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<int64?>("stack_id")
+                    .not_null()
+                    .references("stacks", "id")
+                    .indexed();
+                t.column<int64?>("project_id")
+                    .not_null()
+                    .references("projects", "id")
+                    .indexed();
+                t.column<string>("relationship")
+                    .not_null()
+                    .default_value<string>("reference");
+                t.column<DateTime?>("created_at");
+                t.unique("uq_stack_projects_pair", {"stack_id", "project_id"});
+            });
+
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -110,10 +145,14 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<int64?>("id")
                     .auto_increment()
                     .primary_key();
+                // Goal 8 task 4: 0 = a STANDALONE task — no project
+                // row exists, the workspace holds no clones and the
+                // deliverable is the completion report. No FK (0 is
+                // not a project id) and no index (every browse path
+                // filters by goal or lists all).
                 t.column<int64?>("project_id")
                     .not_null()
-                    .references("projects", "id")
-                    .indexed();
+                    .default_value<int64?>(0);
                 t.column<int64?>("goal_id")
                     .references("goals", "id")
                     .indexed();
@@ -192,8 +231,13 @@ namespace ClankerSaddle.Data.Migrations {
                     .default_value<string>("idle");
                 t.column<string>("git_name");
                 t.column<string>("git_email");
+                t.column<string>("prompt_note");
                 t.column<string>("ssh_public_key");
                 t.column<string>("token");
+                // goal 8 task 6: the workspace root the daemon reported at
+                // /register — assignment paths are addressed on the ASKING
+                // saddle's filesystem, not a shared /workspace default
+                t.column<string>("workspace_root");
             });
 
             b.create_table("sessions", (t) => {
@@ -303,6 +347,27 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
             });
 
+            // Personal scratchpad notes (goal 8 task 3): a lightweight
+            // todo/idea list on the dashboard — jotted down before a
+            // batch of changes is ready to become a full goal. A note
+            // promotes into a DRAFT goal (title = first line, brief =
+            // the whole body); goal_id then links the created goal and
+            // freezes the note (no further promote).
+            b.create_table("notes", (t) => {
+                t.column<int64?>("id")
+                    .auto_increment()
+                    .primary_key();
+                t.column<string>("body")
+                    .not_null();
+                t.column<bool>("done")
+                    .not_null()
+                    .default_value<bool>(false);
+                t.column<DateTime?>("done_at");
+                t.column<int64?>("goal_id")
+                    .references("goals", "id");
+                t.column<DateTime?>("created_at");
+            });
+
             b.create_table("capabilities", (t) => {
                 t.column<int64?>("id")
                     .auto_increment()
@@ -381,6 +446,7 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("capabilities");
+            b.drop_table("notes");
             b.drop_table("project_notes");
             b.drop_table("task_rules");
             b.drop_table("rule_projects");
@@ -394,6 +460,8 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("goal_links");
             b.drop_table("goals");
             b.drop_table("project_links");
+            b.drop_table("stack_projects");
+            b.drop_table("stacks");
             b.drop_table("projects");
         }
     }

+ 196 - 0
src/Data/Migrations/M0002_BatchImprovements.vala

@@ -0,0 +1,196 @@
+using InvercargillSql.Migrations;
+using Sqlite;
+
+namespace ClankerSaddle.Data.Migrations {
+
+    /**
+     * Goal 8: the incremental upgrade that un-flattens the schema.
+     *
+     * M0001 was historically treated as tear-down-and-recreate — every
+     * later change edited it in place. A deployed database (the live
+     * instance) carries `__migrations` row (1, "clanker-saddle", 1),
+     * so an in-place edit of M0001 never re-runs there: every page
+     * touching the new tables/columns 500s. This migration brings any
+     * serial-1 database up to the current M0001 shape IN PLACE.
+     *
+     * Adds (exactly what the goal-8 tasks folded into M0001):
+     *   - `stacks`, `stack_projects` (task 2 — reference-project bundles)
+     *   - `notes` (task 3 — the dashboard scratchpad)
+     *   - `saddles.prompt_note` (task 5 — per-saddle prompt preamble)
+     *   - `saddles.workspace_root` (task 6 — per-saddle workspace paths)
+     *
+     * NOT altered: `tasks.project_id`. Goal 8 task 4 changed its M0001
+     * definition (dropped the projects FK + index, added DEFAULT 0) for
+     * standalone tasks. A live serial-1 database already has the column
+     * — NOT NULL, no default, with the FK — and SQLite cannot ALTER a
+     * column in place. The difference is semantically inert at runtime:
+     * foreign keys are not enforced (no `PRAGMA foreign_keys=ON` is
+     * ever set — see the connection setup in main.vala), and the ORM
+     * always writes project_id explicitly (0 for standalone tasks), so
+     * the column default is never exercised. Both shapes work; the
+     * constraint text in sqlite_master simply differs.
+     *
+     * IDEMPOTENCE: the runner applies each migration exactly once (the
+     * `__migrations` insert is the guard), so up() only ever runs on a
+     * database this migration has not touched. But it must succeed BOTH
+     * on a serial-1 database (live) and, defensively, on a fresh one
+     * where M0001 already created all of this (e.g. a database created
+     * by this build then handed to an older build, or a partially
+     * upgraded copy). The framework offers no guarded create/add, and
+     * up() cannot catch a failure itself: the builder merely QUEUES
+     * operations, which the runner executes later inside a single
+     * transaction (any duplicate would abort the whole migration). So
+     * up() introspects first — the runner invokes up() BEFORE opening
+     * that transaction (verified against the InvercargillSql runner
+     * source: migration.up(builder) runs, then begin_transaction, then
+     * builder.execute) — and only queues the operations the schema
+     * still needs. A fresh database queues nothing and the migration
+     * records as a no-op.
+     */
+    public class M0002_BatchImprovements : Migration {
+
+        public override string name { get { return "Goal 8 batch improvements: stacks, notes, per-saddle columns"; } }
+        public override uint64 serial { get { return 2; } }
+        public override string migration_namespace { get { return "clanker-saddle"; } }
+
+        // The database this migration is upgrading. The runner never
+        // hands up() a connection (the builder only queues SQL), so the
+        // schema is read through a separate read-only handle. SPRY_DB_PATH
+        // is the same variable main.vala resolves the connection string
+        // from, so this always targets the database actually being
+        // migrated.
+        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 everything as absent and
+                // let the queued operations 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;
+        }
+
+        private bool column_exists(string table, string column) {
+            Database db;
+            int rc = Database.open_v2(db_path(), out db, Sqlite.OPEN_READONLY);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            Statement statement;
+            rc = db.prepare_v2(
+                "SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
+                -1, out statement);
+            if (rc != Sqlite.OK) {
+                return false;
+            }
+            statement.bind_text(1, table, -1);
+            statement.bind_text(2, column, -1);
+            if (statement.step() != Sqlite.ROW) {
+                return false;
+            }
+            return statement.column_int64(0) > 0;
+        }
+
+        public override void up(MigrationBuilder b) {
+            // Stacks (goal 8 task 2) — same shapes M0001 now defines.
+            if (!table_exists("stacks")) {
+                b.create_table("stacks", (t) => {
+                    t.column<int64?>("id")
+                        .auto_increment()
+                        .primary_key();
+                    t.column<string>("name")
+                        .not_null()
+                        .unique();
+                    t.column<string>("summary");
+                    t.column<DateTime?>("created_at");
+                });
+            }
+            if (!table_exists("stack_projects")) {
+                b.create_table("stack_projects", (t) => {
+                    t.column<int64?>("id")
+                        .auto_increment()
+                        .primary_key();
+                    t.column<int64?>("stack_id")
+                        .not_null()
+                        .references("stacks", "id")
+                        .indexed();
+                    t.column<int64?>("project_id")
+                        .not_null()
+                        .references("projects", "id")
+                        .indexed();
+                    t.column<string>("relationship")
+                        .not_null()
+                        .default_value<string>("reference");
+                    t.column<DateTime?>("created_at");
+                    t.unique("uq_stack_projects_pair", {"stack_id", "project_id"});
+                });
+            }
+
+            // Dashboard scratchpad notes (goal 8 task 3).
+            if (!table_exists("notes")) {
+                b.create_table("notes", (t) => {
+                    t.column<int64?>("id")
+                        .auto_increment()
+                        .primary_key();
+                    t.column<string>("body")
+                        .not_null();
+                    t.column<bool>("done")
+                        .not_null()
+                        .default_value<bool>(false);
+                    t.column<DateTime?>("done_at");
+                    t.column<int64?>("goal_id")
+                        .references("goals", "id");
+                    t.column<DateTime?>("created_at");
+                });
+            }
+
+            // Per-saddle columns (tasks 5 and 6).
+            if (!column_exists("saddles", "prompt_note")) {
+                b.alter_table("saddles", (t) => {
+                    t.add_column<string>("prompt_note");
+                });
+            }
+            if (!column_exists("saddles", "workspace_root")) {
+                b.alter_table("saddles", (t) => {
+                    t.add_column<string>("workspace_root");
+                });
+            }
+        }
+
+        public override void down(MigrationBuilder b) {
+            // Column drops first (the two saddles additions), then the
+            // three tables. drop_column is not universal across SQLite
+            // builds older than 3.35, but the runner's rollback path is
+            // unused by the app (nothing calls rollback); down() exists
+            // to satisfy the abstract class.
+            b.alter_table("saddles", (t) => {
+                t.drop_column("workspace_root");
+                t.drop_column("prompt_note");
+            });
+            b.drop_table("notes");
+            b.drop_table("stack_projects");
+            b.drop_table("stacks");
+        }
+    }
+}

+ 6 - 2
src/Services/AllocationService.vala

@@ -656,14 +656,18 @@ namespace ClankerSaddle.Services {
         private async Vector<string> co_developed_project_ids(TaskEntity task,
                 ProjectService projects) throws Error {
             var ids = new Vector<string>();
-            ids.add(task.project_id.to_string());
+            // Goal 8 task 4: a standalone task (project 0) involves no
+            // projects — its context update covers rules only.
+            if (task.project_id > 0) {
+                ids.add(task.project_id.to_string());
+            }
             if (task.goal_id > 0) {
                 foreach (var link in yield goals.list_links(task.goal_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.project_id.to_string());
                     }
                 }
-            } else {
+            } else if (task.project_id > 0) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.linked_project_id.to_string());

+ 105 - 1
src/Services/GoalService.vala

@@ -169,12 +169,41 @@ namespace ClankerSaddle.Services {
                 created_at = new DateTime.now_local()
             };
             yield db.insert_async<Goal>(goal);
+            yield prefill_links_from_project(goal, (!)project);
             yield append_goal_event(goal.id, EVENT_STATE_CHANGE,
                 @"Goal created (branch $(trimmed_branch))", GOAL_DRAFT);
             yield pulse.bump(0, "goal created");
             return goal;
         }
 
+        /**
+         * Goal 8 task 2 A: pre-fills the new goal's links from the
+         * primary project's `project_links` — a ONE-TIME snapshot at
+         * creation, not a runtime union. `goal_links` stays the
+         * single, user-editable source of truth (the goal page's
+         * add/remove UI prunes what the snapshot carried), and links
+         * added to the project later never propagate — a goal may
+         * deliberately exclude a co-developed repo by removing the
+         * pre-filled row. The goal-create form carries no link fields,
+         * so the pre-fill is unconditional. Same linked project, same
+         * relationship.
+         */
+        private async void prefill_links_from_project(Goal goal, Project project) throws Error {
+            foreach (var link in yield db.query<ProjectLink>()
+                    .where(expr("parent_project_id == $0",
+                        elem<int64?>(project.id)))
+                    .materialise_async()) {
+                if (link.linked_project_id == goal.project_id) {
+                    continue;
+                }
+                yield db.insert_async<GoalLink>(new GoalLink() {
+                    goal_id = goal.id,
+                    project_id = link.linked_project_id,
+                    relationship = link.relationship
+                });
+            }
+        }
+
         /** Loads the goal identified by `id`, or null. */
         public async Goal? get_goal(int64 id) throws Error {
             return yield db.query<Goal>()
@@ -411,6 +440,64 @@ namespace ClankerSaddle.Services {
             return goal;
         }
 
+        /**
+         * Goal 8 task 4 (comment-resurrect): a HUMAN comment on a
+         * terminal goal (complete/failed — cancelled goals stay dead,
+         * a cancellation is a decision, not a stopping point) re-opens
+         * the goal and re-offers its run for another round. The goal
+         * flips back to 'active' (supervision restarts — the same
+         * state a rejected completion review lands in), the run task
+         * returns to 'suspended' so the parked-resume machinery picks
+         * it up, and force_wake is set so resume_due fires without
+         * waiting on the coalescing window. The completion report
+         * STAYS on the goal row: the resuming agent reads it, amends
+         * its work and submits a fresh one. A run task that closed
+         * terminal (the approved-completion case) gets a seeded
+         * parked session row — the parked-resume allocation keys on
+         * the newest session's affine saddle, and a DONE run has none
+         * open. Returns true when the goal was terminal and has been
+         * resurrected; false when it was not terminal (the comment is
+         * just a comment). Throws when the goal has no run task at
+         * all (the comment already failed upstream — nothing to
+         * resurrect onto).
+         */
+        public async bool resurrect_goal(int64 goal_id) throws Error {
+            var goal = yield require_goal(goal_id);
+            if (((!)goal).status != GOAL_COMPLETE && ((!)goal).status != GOAL_FAILED) {
+                return false;
+            }
+            var run = yield goal_run_task(goal_id);
+            if (run == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 88,
+                    "The goal agent has no run task — nothing to resurrect");
+            }
+
+            string from = ((!)goal).status;
+            ((!)goal).status = GOAL_ACTIVE;
+            ((!)goal).completed_at = null;
+            ((!)goal).force_wake = true;
+            yield db.update_async<Goal>((!)goal);
+            yield append_goal_event(goal_id, EVENT_STATE_CHANGE,
+                @"Goal re-opened by comment (was $(from)) — supervision restarts", GOAL_ACTIVE);
+
+            // The run task returns to the parked shape. A terminal run
+            // (the approved completion) additionally seeds the parked
+            // session row the parked-resume claim needs; the summary
+            // stays on the row for the agent to read on resume.
+            string run_from = ((!)run).status;
+            if (((!)run).status == STATUS_DONE || ((!)run).status == STATUS_FAILED) {
+                yield tasks.seed_parked_session(((!)run).id);
+            }
+            ((!)run).status = STATUS_SUSPENDED;
+            ((!)run).suspended_at = new DateTime.now_local();
+            ((!)run).completed_at = null;
+            yield db.update_async<TaskEntity>((!)run);
+            yield tasks.add_system_comment(((!)run).id,
+                @"Goal re-opened by comment — supervision resumes (was $(run_from))");
+            yield pulse.bump(0, "goal re-opened by comment");
+            return true;
+        }
+
 
         /**
          * Cancels a goal: the cascade closes every one of its tasks —
@@ -794,7 +881,16 @@ namespace ClankerSaddle.Services {
                 throw new Error(Quark.from_string("clanker-saddle"), 87,
                     "The goal agent has not started yet — comments open with its run");
             }
-            return yield tasks.add_comment(((!)run).id, author, body, quote);
+            var comment = yield tasks.add_comment(((!)run).id, author, body, quote);
+            // Goal 8 task 4: a comment on a TERMINAL goal resurrects
+            // it — the goal re-opens, the parked run re-offers with the
+            // comment riding the resume (force_wake skips the
+            // coalescing window; the comment itself rides the thread
+            // both as the resuming assignment's comments array and the
+            // wake-noticed resume). Non-terminal goals just keep the
+            // comment.
+            yield resurrect_goal(goal_id);
+            return comment;
         }
 
         /**
@@ -1187,6 +1283,14 @@ namespace ClankerSaddle.Services {
             if (woken) {
                 report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n");
             }
+            // Goal 8 task 4: a goal resurrected by comment names why —
+            // the fresh human comment (below) is the resumption
+            // instruction; the resubmitted completion report stays on
+            // the goal row for the agent to amend.
+            if (((!)goal).completion_report != null
+                    && ((!)((!)goal).completion_report).length > 0) {
+                report.append("This goal was re-opened by a human comment after its completion report — address the comment, then submit_report again.\n\n");
+            }
             report.append("Goal update — resuming your parked session:\n\n");
             report.append("## Task statuses\n");
             var finished = new Vector<TaskEntity>();

+ 152 - 0
src/Services/NoteService.vala

@@ -0,0 +1,152 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * The dashboard scratchpad (goal 8 task 3): personal notes/todo
+     * rows for jotting down batches of changes BEFORE they are ready
+     * to be full-on goals or tasks. `notes` carries no project (or
+     * any other) semantics — it is the human's own list — and a note
+     * promotes into a DRAFT goal with the body as the brief, after
+     * which it is frozen and linked to that goal.
+     */
+    public class NoteService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private GoalService goals = inject<GoalService>();
+        private StatePulse pulse = inject<StatePulse>();
+
+        /** Loads the note identified by `id`, or null. */
+        public async Note? get_note(int64 id) throws Error {
+            return yield db.query<Note>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /**
+         * Inserts a note. The body is trimmed and must be non-empty;
+         * a note starts undone and unpromoted.
+         */
+        public async Note add_note(string body) throws Error {
+            string trimmed = body.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 95,
+                    "A note body is required");
+            }
+            var note = new Note() {
+                body = trimmed,
+                done = false,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Note>(note);
+            yield pulse.bump(0, "note added");
+            return note;
+        }
+
+        /**
+         * Every note, undone first, then newest first (the sort runs
+         * in memory — the ORM's order_by carries a single key).
+         * Promotion freezes a note but does not finish it, so a
+         * promoted note keeps its place by its own done flag.
+         */
+        public async Enumerable<Note> list_notes() throws Error {
+            var rows = yield db.query<Note>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+            return rows.sort((a, b) => {
+                if (a.done != b.done) {
+                    return a.done ? 1 : -1;
+                }
+                return a.id > b.id ? -1 : 1;
+            });
+        }
+
+        /**
+         * Sets (or clears) a note's done flag, stamping done_at on
+         * the transition. Returns the saved note, or null when no
+         * such note exists. A promoted note stays toggleable — done
+         * still means "I dealt with this", promotion only locks the
+         * create-goal path.
+         */
+        public async Note? set_done(int64 note_id, bool done) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                return null;
+            }
+            if (((!)note).done != done) {
+                ((!)note).done = done;
+                ((!)note).done_at = done ? new DateTime.now_local() : null;
+                yield db.update_async<Note>((!)note);
+                yield pulse.bump(0, "note toggled");
+            }
+            return note;
+        }
+
+        /** Deletes the note (idempotent: an absent note is fine). */
+        public async void delete_note(int64 note_id) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                return;
+            }
+            yield db.delete_async<Note>((!)note);
+            yield pulse.bump(0, "note deleted");
+        }
+
+        /**
+         * Derives the promote-to-goal title from the note body: the
+         * first non-empty line, clamped to ~80 characters (the clamp
+         * appends an ellipsis when it actually cut anything).
+         */
+        public static string title_from_body(string body) {
+            string first_line = "";
+            foreach (string line in body.split("\n")) {
+                if (line.strip().length > 0) {
+                    first_line = line.strip();
+                    break;
+                }
+            }
+            if (first_line.char_count() > 80) {
+                first_line = first_line.substring(0,
+                    (long) first_line.index_of_nth_char(80)) + "…";
+            }
+            return first_line.length > 0 ? first_line : "Note goal";
+        }
+
+        /**
+         * Promotes an UNDONE, unpromoted note into a DRAFT goal on
+         * `project_id` (title = {@link title_from_body}, brief = the
+         * full note body, branch = `branch_name`), stamps the note's
+         * goal_id and returns the created goal. The human lands on
+         * the goal's page to set links/branch details and press
+         * start. A promoted (or done) note refuses — the row is
+         * frozen once its goal exists.
+         */
+        public async Goal promote_to_goal(int64 note_id, int64 project_id,
+                string branch_name) throws Error {
+            var note = yield get_note(note_id);
+            if (note == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 96,
+                    @"No note exists with id $(note_id)");
+            }
+            if (((!)note).goal_id > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 97,
+                    @"Note #$(note_id) already promoted to goal #$(((!)note).goal_id)");
+            }
+            if (((!)note).done) {
+                throw new Error(Quark.from_string("clanker-saddle"), 98,
+                    @"Note #$(note_id) is done — only open notes promote to goals");
+            }
+            var goal = yield goals.create_goal(project_id,
+                title_from_body(((!)note).body), ((!)note).body, branch_name, null, 3);
+            ((!)note).goal_id = goal.id;
+            yield db.update_async<Note>((!)note);
+            yield pulse.bump(0, "note promoted");
+            return goal;
+        }
+    }
+}

+ 3 - 0
src/Services/PushService.vala

@@ -656,6 +656,9 @@ namespace ClankerSaddle.Services {
                 new Bytes(body));
             message.request_headers.append("TTL", "60");
             message.request_headers.append("Urgency", "high");
+            // RFC 8291 section 4: every aes128gcm body MUST carry
+            // Content-Encoding or the push service rejects with 400.
+            message.request_headers.append("Content-Encoding", "aes128gcm");
             message.request_headers.append("Authorization",
                 @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
             try {

+ 8 - 4
src/Services/RuleService.vala

@@ -257,9 +257,13 @@ namespace ClankerSaddle.Services {
                     ordered.add(rule);
                 }
             }
-            foreach (var rule in yield list_rules_for_project(task.project_id)) {
-                if (seen.add(rule.id.to_string())) {
-                    ordered.add(rule);
+            // Goal 8 task 4: standalone tasks (project 0) carry only
+            // their own task rules — no project exists to expand.
+            if (task.project_id > 0) {
+                foreach (var rule in yield list_rules_for_project(task.project_id)) {
+                    if (seen.add(rule.id.to_string())) {
+                        ordered.add(rule);
+                    }
                 }
             }
             if (goal_id > 0) {
@@ -276,7 +280,7 @@ namespace ClankerSaddle.Services {
                         }
                     }
                 }
-            } else {
+            } else if (task.project_id > 0) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship != LINK_CO_DEVELOPED) {
                         continue;

+ 104 - 19
src/Services/SaddleService.vala

@@ -37,6 +37,13 @@ namespace ClankerSaddle.Services {
     public const int64 PROJECT_NOTES_MAX_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
 
+    /**
+     * 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
+     * would bloat every system prompt and cache.
+     */
+    public const int64 PROMPT_NOTE_MAX_CHARS = 2000;
+
     /** A block answer awaiting collection by one saddle's next poll. */
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
@@ -191,8 +198,17 @@ namespace ClankerSaddle.Services {
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                     (owned) payload);
                 string reported_key = EntryJson.string_member(response, "ssh_public_key");
-                if (reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? "")) {
-                    saddle.ssh_public_key = reported_key;
+                // goal 8 task 6: the daemon reports the workspace root it
+                // mounts so assignment paths address the ASKING saddle's
+                // filesystem; absent on older daemons (kept null → the
+                // SADDLE_PROJECTS_ROOT fallback chain)
+                string reported_root = EntryJson.string_member(response, "workspace_root");
+                if ((reported_key.length > 0 && reported_key != (saddle.ssh_public_key ?? ""))
+                        || reported_root != (saddle.workspace_root ?? "")) {
+                    if (reported_key.length > 0) {
+                        saddle.ssh_public_key = reported_key;
+                    }
+                    saddle.workspace_root = reported_root.length > 0 ? reported_root : null;
                     yield db.update_async<Saddle>(saddle);
                 }
                 print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
@@ -435,6 +451,29 @@ namespace ClankerSaddle.Services {
             yield pulse.bump(0, "saddle identity saved");
         }
 
+        /**
+         * Saves the saddle admin's standing prompt note (goal 8 task 5):
+         * the message about THIS machine rendered into every
+         * assignment's system prompt. Empty clears it (the daemon then
+         * omits the section entirely); the value is clamped to
+         * {@link PROMPT_NOTE_MAX_CHARS} so a fat note can never bloat
+         * every prompt.
+         */
+        public async void update_prompt_note(int64 id, string? prompt_note) throws Error {
+            var saddle = yield get_saddle(id);
+            if (saddle == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 17,
+                    @"No saddle exists with id $(id)");
+            }
+            var trimmed = prompt_note != null ? prompt_note.strip() : "";
+            if (trimmed.length > PROMPT_NOTE_MAX_CHARS) {
+                trimmed = trimmed.substring(0, (int) PROMPT_NOTE_MAX_CHARS);
+            }
+            ((!)saddle).prompt_note = trimmed.length > 0 ? trimmed : null;
+            yield db.update_async<Saddle>((!)saddle);
+            yield pulse.bump(0, "saddle prompt note saved");
+        }
+
         /**
          * Persists the daemon's reported `ssh_public_key` onto the saddle
          * row (the plan-9 contract: the key travels on /status, empty
@@ -775,10 +814,18 @@ namespace ClankerSaddle.Services {
                     "No LLM endpoint configured (set SADDLE_LLM_BASE_URL)");
             }
 
-            var project = yield db.query<Project>()
-                .where(expr("id == $0", elem<int64?>(task.project_id)))
-                .first_async();
-            if (project == null) {
+            var project = task.project_id > 0
+                ? yield db.query<Project>()
+                    .where(expr("id == $0", elem<int64?>(task.project_id)))
+                    .first_async()
+                : null;
+            // Goal 8 task 4: project_id 0 is a STANDALONE task — no
+            // project row exists (and none is looked up), the
+            // co-developed/reference arrays ship EMPTY and the daemon
+            // renders the standalone prompt section. A task with a
+            // non-zero project_id whose row is missing is still the
+            // data-integrity error it always was.
+            if (project == null && task.project_id > 0) {
                 throw new Error(Quark.from_string("clanker-saddle"), 12,
                     @"Task $(task.id) has no project $(task.project_id)");
             }
@@ -786,6 +833,8 @@ namespace ClankerSaddle.Services {
             // Plan 12: goal work (the agent's own run and its worker
             // tasks) scopes its co-developed/reference set through the
             // GOAL's links; standalone tasks through the project's.
+            // Standalone tasks (project 0) own no links at all — the
+            // simplest consistent shape: both arrays stay empty.
             Goal? goal = null;
             if (task.goal_id > 0) {
                 goal = yield db.query<Goal>()
@@ -808,7 +857,7 @@ namespace ClankerSaddle.Services {
                         references.add((!)linked);
                     }
                 }
-            } else {
+            } else if (project != null) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     var linked = yield db.query<Project>()
                         .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
@@ -835,25 +884,28 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("description");
             payload.add_string_value(task.description ?? "");
             payload.set_member_name("project_name");
-            payload.add_string_value(((!)project).name);
+            payload.add_string_value(project != null ? ((!)project).name : "standalone");
             payload.set_member_name("project_branch");
-            payload.add_string_value(((!)project).default_branch);
+            payload.add_string_value(project != null ? ((!)project).default_branch : "");
             payload.set_member_name("task_branch");
             payload.add_string_value(task.task_branch ?? "");
             payload.set_member_name("branch_from");
-            payload.add_string_value(task.branch_from ?? ((!)project).default_branch);
+            payload.add_string_value(task.branch_from
+                ?? (project != null ? ((!)project).default_branch : ""));
 
             payload.set_member_name("co_developed");
             payload.begin_array();
-            payload.add_value(project_node(((!)project), task.id));
+            if (project != null) {
+                payload.add_value(project_node(((!)project), task.id, false, saddle));
+            }
             foreach (var linked in co_developed) {
-                payload.add_value(project_node(linked, task.id));
+                payload.add_value(project_node(linked, task.id, false, saddle));
             }
             payload.end_array();
             payload.set_member_name("reference");
             payload.begin_array();
             foreach (var linked in references) {
-                payload.add_value(project_node(linked, task.id, true));
+                payload.add_value(project_node(linked, task.id, true, saddle));
             }
             payload.end_array();
 
@@ -915,7 +967,7 @@ namespace ClankerSaddle.Services {
                 payload.add_string_value(((!)goal).branch_name);
                 payload.set_member_name("base_branch");
                 payload.add_string_value(((!)goal).base_branch
-                    ?? ((!)project).default_branch);
+                    ?? (project != null ? ((!)project).default_branch : ""));
                 payload.set_member_name("brief");
                 payload.add_string_value(((!)goal).brief);
                 payload.set_member_name("plan");
@@ -960,6 +1012,18 @@ namespace ClankerSaddle.Services {
                 payload.end_object();
             }
 
+            // The wire contract's `prompt_note` addition (goal 8 task
+            // 5): the saddle admin's standing message about THIS
+            // machine, served on EVERY assignment (the daemon renders
+            // it verbatim under an admin heading). Empty string when
+            // unset, clamped so a fat note can never bloat prompts.
+            var prompt_note_text = saddle.prompt_note ?? "";
+            if (prompt_note_text.length > PROMPT_NOTE_MAX_CHARS) {
+                prompt_note_text = prompt_note_text.substring(0, (int) PROMPT_NOTE_MAX_CHARS);
+            }
+            payload.set_member_name("prompt_note");
+            payload.add_string_value(prompt_note_text);
+
             // The wire contract's `capabilities` context (plan 10
             // phase 3): the allocated saddle's own tags plus every tag
             // name known across the fleet (the operator-managed
@@ -1016,9 +1080,11 @@ namespace ClankerSaddle.Services {
             // 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);
+            if (project != null) {
+                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);
@@ -1627,9 +1693,28 @@ namespace ClankerSaddle.Services {
          * saddle's container — one workspace per task, so concurrent
          * tasks never share a checkout.
          */
-        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false) {
+        /**
+         * The projects root assignment paths are addressed against: the
+         * ASKING saddle's registered `workspace_root` (the daemon's own
+         * SADDLE_WORKSPACE_ROOT, reported at /register — goal 8 task 6,
+         * so a fleet no longer assumes one shared /workspace mount),
+         * else the `SADDLE_PROJECTS_ROOT` environment override, else
+         * the historical "/workspace" default. Paths are ADVISORY: the
+         * daemon owns its filesystem and rewrites them onto its own
+         * workspace root.
+         */
+        private static string projects_root_for_saddle(Saddle? saddle) {
+            var registered = saddle != null ? ((!)saddle).workspace_root : null;
+            if (registered != null && ((!)registered).strip().length > 0) {
+                return ((!)registered).strip();
+            }
             var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
-            string projects_root = root_var.length > 0 ? root_var : "/workspace";
+            return root_var.length > 0 ? root_var : "/workspace";
+        }
+
+        private static Json.Node project_node(Project project, int64 task_id, bool is_reference = false,
+                Saddle? saddle = null) {
+            string projects_root = projects_root_for_saddle(saddle);
             var builder = new Json.Builder();
             builder.begin_object();
             builder.set_member_name("name");

+ 266 - 0
src/Services/StackService.vala

@@ -0,0 +1,266 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Invercargill.Expressions;
+using InvercargillSql.Orm;
+using Inversion;
+using ClankerSaddle.Data;
+
+namespace ClankerSaddle.Services {
+
+    /**
+     * Stack storage (goal 8 task 2 B): CRUD over `stacks`, the stack's
+     * project members in `stack_projects` (relationship 'co-developed'
+     * or 'reference', validated like the project link graph), and the
+     * two apply paths — a stack's members land as `project_links` rows
+     * on a project or as `goal_links` rows on a goal, skipping exact
+     * duplicates (same target, same relationship) so re-applying is
+     * idempotent.
+     */
+    public class StackService : Object {
+
+        private OrmSession db = inject<OrmSession>();
+        private ProjectService projects = inject<ProjectService>();
+        private GoalService goals = inject<GoalService>();
+        private StatePulse pulse = inject<StatePulse>();
+
+        /** Whether `relationship` is a valid stack-member relationship. */
+        private void require_valid_relationship(string relationship) throws Error {
+            if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
+                throw new Error(Quark.from_string("clanker-saddle"), 90,
+                    @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
+            }
+        }
+
+        /** Inserts a stack; `summary` may be empty. */
+        public async Stack create_stack(string name, string? summary) throws Error {
+            string trimmed = name.strip();
+            if (trimmed.length == 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 91,
+                    "A stack name is required");
+            }
+            var existing = yield get_stack_by_name(trimmed);
+            if (existing != null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 92,
+                    @"A stack named '$(trimmed)' already exists");
+            }
+            var stack = new Stack() {
+                name = trimmed,
+                summary = summary != null && summary.strip().length > 0
+                    ? summary.strip() : null,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<Stack>(stack);
+            yield pulse.bump(0, "stack created");
+            return stack;
+        }
+
+        /** Loads the stack identified by `id`, or null. */
+        public async Stack? get_stack(int64 id) throws Error {
+            return yield db.query<Stack>()
+                .where(expr("id == $0", elem<int64?>(id)))
+                .first_async();
+        }
+
+        /** Loads the stack identified by `name`, or null. */
+        public async Stack? get_stack_by_name(string name) throws Error {
+            return yield db.query<Stack>()
+                .where(expr("name == $0", elem(name)))
+                .first_async();
+        }
+
+        /** Every stack, newest first. */
+        public async ImmutableLot<Stack> list_stacks() throws Error {
+            return yield db.query<Stack>()
+                .order_by_desc(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Saves the editable fields of the stack identified by `id`;
+         * null/empty arguments keep their current values. Returns the
+         * saved stack, or null when no such stack exists.
+         */
+        public async Stack? update_stack(int64 id, string? name, string? summary) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                return null;
+            }
+            if (name != null && name.strip().length > 0) {
+                string trimmed = name.strip();
+                var clash = yield get_stack_by_name(trimmed);
+                if (clash != null && ((!)clash).id != id) {
+                    throw new Error(Quark.from_string("clanker-saddle"), 93,
+                        @"A stack named '$(trimmed)' already exists");
+                }
+                ((!)stack).name = trimmed;
+            }
+            if (summary != null) {
+                ((!)stack).summary = summary.strip().length > 0 ? summary.strip() : null;
+            }
+            yield db.update_async<Stack>((!)stack);
+            yield pulse.bump(0, "stack saved");
+            return stack;
+        }
+
+        /** Deletes the stack and its members. */
+        public async void delete_stack(int64 id) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                return;
+            }
+            foreach (var member in yield list_members(id)) {
+                yield db.delete_async<StackProject>(member);
+            }
+            yield db.delete_async<Stack>((!)stack);
+            yield pulse.bump(0, "stack deleted");
+        }
+
+        /** The stack's member rows, oldest first. */
+        public async ImmutableLot<StackProject> list_members(int64 stack_id) throws Error {
+            return yield db.query<StackProject>()
+                .where(expr("stack_id == $0", elem<int64?>(stack_id)))
+                .order_by(expr("id"))
+                .materialise_async();
+        }
+
+        /**
+         * Adds a project to the stack with `relationship`;
+         * duplicates (same stack, same project) are rejected.
+         */
+        public async StackProject add_member(int64 stack_id, int64 project_id,
+                string relationship) throws Error {
+            require_valid_relationship(relationship);
+            var stack = yield get_stack(stack_id);
+            if (stack == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 94,
+                    @"No stack exists with id $(stack_id)");
+            }
+            var project = yield projects.get_project(project_id);
+            if (project == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 95,
+                    @"No project exists with id $(project_id)");
+            }
+            var existing = yield db.query<StackProject>()
+                .where(expr("stack_id == $0 && project_id == $1",
+                    elem<int64?>(stack_id), elem<int64?>(project_id)))
+                .materialise_async();
+            if (existing.length > 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 96,
+                    "That project is already in the stack");
+            }
+            var member = new StackProject() {
+                stack_id = stack_id,
+                project_id = project_id,
+                relationship = relationship,
+                created_at = new DateTime.now_local()
+            };
+            yield db.insert_async<StackProject>(member);
+            yield pulse.bump(0, "stack member added");
+            return member;
+        }
+
+        /** Removes the stack member identified by `id`. */
+        public async void remove_member(int64 member_id) throws Error {
+            var member = yield db.query<StackProject>()
+                .where(expr("id == $0", elem<int64?>(member_id)))
+                .first_async();
+            if (member != null) {
+                yield db.delete_async<StackProject>((!)member);
+                yield pulse.bump(0, "stack member removed");
+            }
+        }
+
+        /**
+         * Applies the stack to a project: every member becomes a
+         * `project_links` row on `parent_id`, same relationship, as
+         * long as the parent does not already carry that exact pair
+         * (same target AND same relationship — a different
+         * relationship on the same target stays). Returns how many
+         * links landed. Never throws on duplicates: re-applying is
+         * idempotent by design.
+         */
+        public async int64 apply_to_project(int64 stack_id, int64 parent_id) throws Error {
+            var stack = yield require_stack(stack_id);
+            var parent = yield projects.get_project(parent_id);
+            if (parent == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 97,
+                    @"No project exists with id $(parent_id)");
+            }
+            int64 added = 0;
+            foreach (var member in yield list_members(stack_id)) {
+                if (member.project_id == parent_id) {
+                    continue;  // a project never links to itself
+                }
+                bool duplicate = false;
+                foreach (var link in yield projects.list_links(parent_id)) {
+                    if (link.linked_project_id == member.project_id
+                            && link.relationship == member.relationship) {
+                        duplicate = true;
+                        break;
+                    }
+                }
+                if (duplicate) {
+                    continue;
+                }
+                yield db.insert_async<ProjectLink>(new ProjectLink() {
+                    parent_project_id = parent_id,
+                    linked_project_id = member.project_id,
+                    relationship = member.relationship
+                });
+                added++;
+            }
+            yield pulse.bump(0, @"stack $(((!)stack).name) applied to project");
+            return added;
+        }
+
+        /**
+         * Applies the stack to a goal: every member becomes a
+         * `goal_links` row on `goal_id`, same relationship, skipping
+         * exact duplicates. The primary project is implicitly linked
+         * and never duplicated. Returns how many links landed.
+         */
+        public async int64 apply_to_goal(int64 stack_id, int64 goal_id) throws Error {
+            var stack = yield require_stack(stack_id);
+            var goal = yield goals.get_goal(goal_id);
+            if (goal == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 98,
+                    @"No goal exists with id $(goal_id)");
+            }
+            int64 added = 0;
+            foreach (var member in yield list_members(stack_id)) {
+                if (member.project_id == ((!)goal).project_id) {
+                    continue;  // the primary project is implicitly linked
+                }
+                bool duplicate = false;
+                foreach (var link in yield goals.list_links(goal_id)) {
+                    if (link.project_id == member.project_id
+                            && link.relationship == member.relationship) {
+                        duplicate = true;
+                        break;
+                    }
+                }
+                if (duplicate) {
+                    continue;
+                }
+                yield db.insert_async<GoalLink>(new GoalLink() {
+                    goal_id = goal_id,
+                    project_id = member.project_id,
+                    relationship = member.relationship
+                });
+                added++;
+            }
+            yield pulse.bump(0, @"stack $(((!)stack).name) applied to goal");
+            return added;
+        }
+
+        /** Loads the stack or throws (the apply paths' guard). */
+        private async Stack require_stack(int64 id) throws Error {
+            var stack = yield get_stack(id);
+            if (stack == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 94,
+                    @"No stack exists with id $(id)");
+            }
+            return (!)stack;
+        }
+    }
+}

+ 68 - 1
src/Services/TaskService.vala

@@ -163,8 +163,14 @@ namespace ClankerSaddle.Services {
                 string kind = TASK_KIND_TASK, string mode = "implement",
                 string creator = CREATOR_HUMAN, int64 goal_id = 0,
                 int64 enqueue_after_task_id = 0) throws Error {
+            // Goal 8 task 4: project_id 0 is a STANDALONE task — its
+            // own ephemeral workspace, no project row anywhere.
+            if (project_id < 0) {
+                throw new Error(Quark.from_string("clanker-saddle"), 76,
+                    @"Invalid project id $(project_id)");
+            }
             string resolved_branch_from = branch_from ?? "";
-            if (resolved_branch_from.length == 0) {
+            if (resolved_branch_from.length == 0 && project_id > 0) {
                 var project = yield db.query<Project>()
                     .where(expr("id == $0", elem<int64?>(project_id)))
                     .first_async();
@@ -959,6 +965,10 @@ namespace ClankerSaddle.Services {
                     || ((!)task).task_branch.length == 0) {
                 return "";
             }
+            if (((!)task).project_id <= 0) {
+                // Standalone tasks own no repository — no compare URL.
+                return "";
+            }
             var project = yield db.query<Project>()
                 .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
                 .first_async();
@@ -1642,6 +1652,63 @@ namespace ClankerSaddle.Services {
          * stamp; its keep-parked gate (a task that parked mid-flight)
          * skips the release entirely.
          */
+        /**
+         * Goal 8 task 4 (comment-resurrect): re-queues a TERMINAL
+         * non-goal task (goal_id == 0, status done/failed) for another
+         * round after a human comment. The completion summary and
+         * `completed_at` STAY (the report of the earlier round remains
+         * readable — the daemon's next finish overwrites the summary),
+         * and a fresh parked session row is seeded on the task's last
+         * saddle so the plain-task requeue path (which keys on the
+         * newest session's affine saddle) has a workspace to resume;
+         * no session row at all (a fixture-shaped task) requeues onto
+         * the open queue instead. The resurrection is idempotent from
+         * the caller's side: only a terminal task flips.
+         */
+        public async void requeue_terminal_task(int64 task_id) throws Error {
+            var task = yield db.query<TaskEntity>()
+                .where(expr("id == $0", elem<int64?>(task_id)))
+                .first_async();
+            if (task == null) {
+                throw new Error(Quark.from_string("clanker-saddle"), 8,
+                    @"No task exists with id $(task_id)");
+            }
+            if (((!)task).status != STATUS_DONE && ((!)task).status != STATUS_FAILED) {
+                return;
+            }
+            string from = ((!)task).status;
+            ((!)task).status = STATUS_QUEUED;
+            ((!)task).completed_at = null;
+            yield db.update_async<TaskEntity>((!)task);
+            yield append_state_change(task_id, from, STATUS_QUEUED,
+                @"Task re-opened by comment ($(from))", EVENT_ACTOR_USER);
+            yield pulse.bump(task_id, "task re-opened by comment");
+        }
+
+        /**
+         * Seeds a CLOSED 'parked' session row for `task_id` on the
+         * saddle of its newest prior session (the resurrect paths'
+         * resume anchor — the allocation's parked-resume arm needs a
+         * session row to key the affine saddle and the successor row
+         * on). Returns true when a row was seeded; false when the task
+         * has no session history at all.
+         */
+        public async bool seed_parked_session(int64 task_id) throws Error {
+            var newest = yield newest_session_for_task(task_id);
+            if (newest == null) {
+                return false;
+            }
+            var seeded = new Session() {
+                task_id = task_id,
+                saddle_id = ((!)newest).saddle_id,
+                started_at = ((!)newest).started_at ?? new DateTime.now_local(),
+                ended_at = new DateTime.now_local(),
+                outcome = OUTCOME_PARKED
+            };
+            yield db.insert_async<Session>(seeded);
+            return true;
+        }
+
         public async void release_task(int64 task_id, string outcome, bool requeue) throws Error {
             if (requeue) {
                 var task = yield db.query<TaskEntity>()

+ 2 - 1
src/actions/DashboardActions.vala

@@ -16,6 +16,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -28,7 +29,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry,
+                saddle_service, timeline_service, db, note_service, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state);
         }

+ 3 - 1
src/actions/GoalActions.vala

@@ -52,6 +52,7 @@ namespace ClankerSaddle {
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected RuleService rule_service = inject<RuleService>();
+        protected StackService stack_service = inject<StackService>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected UserService users = inject<UserService>();
 
@@ -62,7 +63,8 @@ namespace ClankerSaddle {
          */
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                event_service, db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service,
+                stack_service);
             return yield GoalState.build(goal_id, ctx, state_service);
         }
     }

+ 204 - 0
src/actions/NoteActions.vala

@@ -0,0 +1,204 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed note identity carried by the note row actions. */
+    public class NoteActionPrivate : Object {
+        public int64 note_id { get; set; }
+    }
+
+    /**
+     * Common base for actions sealed against a note id: materialises
+     * the typed private blob and exposes the resolved id (falling
+     * back to the hidden `note_id` form field).
+     */
+    public abstract class SealedNoteAction : TypedStatumAction<NoteActionPrivate> {
+
+        protected override Type private_type { get { return typeof(NoteActionPrivate); } }
+
+        /** The sealed note id, or 0 when neither blob nor form field carried one. */
+        protected int64 sealed_note_id() {
+            var ctx = request_private;
+            return ctx != null ? ((!)ctx).note_id : Forms.integer(request, "note_id", 0);
+        }
+    }
+
+    /**
+     * Common base for the note-row actions that answer with the
+     * refreshed dashboard slot (toggle/delete — add and promote carry
+     * no sealed id of their own, and add reuses the helper below):
+     * owns the service set {@link DashboardState.build} needs, so a
+     * mutation answers with refreshed state in ONE call.
+     */
+    public abstract class DashboardRefreshingNoteAction : SealedNoteAction {
+
+        protected TaskService task_service = inject<TaskService>();
+        protected SaddleService saddle_service = inject<SaddleService>();
+        protected TimelineService timeline_service = inject<TimelineService>();
+        protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        protected PushService push_service = inject<PushService>();
+        protected NoteService note_service = inject<NoteService>();
+        protected UserService users = inject<UserService>();
+
+        /**
+         * Rebuilds the dashboard slot from current data — the same
+         * build the page and its pulse refresh run.
+         */
+        protected async Statum.Slot dashboard_slot() throws GLib.Error {
+            var me = SpryAuth.current(request);
+            return yield DashboardState.build(state_service, task_service,
+                saddle_service, timeline_service, db, note_service, action_registry,
+                push_service, me != null ? ((!)me).user_id : 0);
+        }
+    }
+
+    /**
+     * Adds a dashboard scratchpad note (the body is the whole note —
+     * no project or assignment semantics). Answers with the
+     * refreshed dashboard slot, like the page's own refresh. A plain
+     * action (no sealed id — the form is not row-scoped).
+     */
+    public class AddNoteAction : StatumAction {
+
+        private NoteService notes = inject<NoteService>();
+        private TaskService task_service = inject<TaskService>();
+        private SaddleService saddle_service = inject<SaddleService>();
+        private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
+        private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
+        private PushService push_service = inject<PushService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string body = Forms.field_raw(request, "body");
+            if (body.strip().length == 0) {
+                return directives().notify("error", "A note body is required");
+            }
+
+            try {
+                yield notes.add_note(body);
+                var me = SpryAuth.current(request);
+                var state = yield DashboardState.build(state_service, task_service,
+                    saddle_service, timeline_service, db, note_service, action_registry,
+                    push_service, me != null ? ((!)me).user_id : 0);
+                return directives().set(state);
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Toggles a note's done flag (the checkbox on the row). The id
+     * comes from the sealed {@link NoteActionPrivate} blob; answers
+     * with the refreshed dashboard slot.
+     */
+    public class ToggleNoteAction : DashboardRefreshingNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                // Flip (the row button carries no direction field): an
+                // explicit "0"/"1" form value still forces a direction.
+                var current = yield notes.get_note(note_id);
+                if (current == null) {
+                    return directives().notify("error", "No such note").navigate("/");
+                }
+                string direction = Forms.field(request, "done");
+                bool target = direction.length > 0
+                    ? direction == "1" : !((!)current).done;
+                yield notes.set_done(note_id, target);
+                return directives().set(yield dashboard_slot());
+            } catch (Error e) {
+                return directives().notify("error", @"Could not update the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Deletes a note. The id comes from the sealed private blob;
+     * answers with the refreshed dashboard slot.
+     */
+    public class DeleteNoteAction : DashboardRefreshingNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                yield notes.delete_note(note_id);
+                return directives().set(yield dashboard_slot());
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the note: $(e.message)");
+            }
+        }
+    }
+
+    /**
+     * Promotes a note into a DRAFT goal (goal 8 task 3): title = the
+     * note's first line, brief = the full note body, on the chosen
+     * project with the chosen branch. The note is stamped with the
+     * goal id (frozen — no further promote) and the human lands on
+     * the goal's draft page to finish it (links, base branch, start).
+     */
+    public class PromoteNoteAction : SealedNoteAction {
+
+        private NoteService notes = inject<NoteService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 note_id = sealed_note_id();
+            if (note_id <= 0) {
+                return directives().navigate("/");
+            }
+
+            try {
+                var goal = yield notes.promote_to_goal(note_id,
+                    Forms.integer(request, "project_id", 0),
+                    Forms.field(request, "branch_name"));
+                return directives()
+                    .notify("success", @"Goal '$(goal.title)' created in draft from note #$(note_id) — set it up and press start")
+                    .navigate(@"/goals/$(goal.id)");
+            } catch (Error e) {
+                return directives()
+                    .notify("error", @"Could not create the goal from note #$(note_id): $(e.message)")
+                    .navigate("/");
+            }
+        }
+    }
+}

+ 2 - 1
src/actions/PushActions.vala

@@ -100,6 +100,7 @@ namespace ClankerSaddle {
         private TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private PushService push = inject<PushService>();
         private UserService users = inject<UserService>();
@@ -138,7 +139,7 @@ namespace ClankerSaddle {
                     }
                 }
                 var state = yield DashboardState.build(state_service, task_service,
-                    saddle_service, timeline_service, db, action_registry,
+                    saddle_service, timeline_service, db, note_service, action_registry,
                     push, me.user_id);
                 return directives().notify(kind, message).set(state);
             } catch (Error e) {

+ 37 - 0
src/actions/SaddleActions.vala

@@ -120,6 +120,43 @@ namespace ClankerSaddle {
         }
     }
 
+    /**
+     * Saves a saddle's admin prompt note (goal 8 task 5): the standing
+     * message about THIS machine served inside every assignment's
+     * system prompt (e.g. "You have root and may install development
+     * tooling via DNF"). Empty clears it — the daemon then omits the
+     * section entirely.
+     */
+    public class UpdateSaddlePromptNoteAction : TypedStatumAction<SaddleActionPrivate> {
+
+        private SaddleService saddles = inject<SaddleService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(SaddleActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_permission(directives(), request, users,
+                SpryAuth.ADMIN_PERMISSION, "/login");
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/saddles");
+            }
+
+            try {
+                yield saddles.update_prompt_note(((!)ctx).saddle_id,
+                    Forms.optional(request, "prompt_note"));
+                return directives().notify("success", "Prompt note saved").navigate("/saddles");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the prompt note: $(e.message)")
+                    .navigate("/saddles");
+            }
+        }
+    }
+
     public class RefreshSaddleStatusAction : TypedStatumAction<SaddleActionPrivate> {
 
         private SaddleService saddles = inject<SaddleService>();

+ 274 - 0
src/actions/StackActions.vala

@@ -0,0 +1,274 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed stack id carried by the stack actions. */
+    public class StackActionPrivate : Object {
+        public int64 stack_id { get; set; }
+    }
+
+    /** Sealed stack-member identity carried by {@link RemoveStackMemberAction}. */
+    public class StackMemberActionPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 member_id { get; set; }
+    }
+
+    /** Sealed stack + project identity for {@link ApplyStackToProjectAction}. */
+    public class StackProjectApplyPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 project_id { get; set; }
+    }
+
+    /** Sealed stack + goal identity for {@link ApplyStackToGoalAction}. */
+    public class StackGoalApplyPrivate : Object {
+        public int64 stack_id { get; set; }
+        public int64 goal_id { get; set; }
+    }
+
+    /**
+     * Creates a stack from the stacks form (name required, unique;
+     * summary optional).
+     */
+    public class CreateStackAction : StatumAction {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            string name = Forms.field(request, "name");
+            if (name.length == 0) {
+                return directives().notify("error", "A stack name is required").navigate("/stacks");
+            }
+
+            try {
+                var stack = yield stacks.create_stack(name,
+                    Forms.field(request, "summary"));
+                return directives().notify("success", @"Stack '$(stack.name)' created")
+                    .navigate(@"/stacks/$(stack.id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not create the stack: $(e.message)").navigate("/stacks");
+            }
+        }
+    }
+
+    /**
+     * Saves a stack's editable fields (name, summary); the id comes
+     * from the sealed {@link StackActionPrivate} blob (the hidden form
+     * field is a fallback). An empty summary clears the stored one.
+     */
+    public class UpdateStackAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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 stack_id = ctx != null ? ((!)ctx).stack_id : Forms.integer(request, "stack_id", 0);
+            if (stack_id <= 0) {
+                return directives().navigate("/stacks");
+            }
+
+            try {
+                yield stacks.update_stack(stack_id,
+                    Forms.optional(request, "name"),
+                    request.form != null ? Forms.field(request, "summary") : null);
+                return directives().notify("success", "Stack saved")
+                    .navigate(@"/stacks/$(stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not save the stack: $(e.message)")
+                    .navigate(@"/stacks/$(stack_id)");
+            }
+        }
+    }
+
+    /** Deletes a stack; the id comes from the sealed private blob. */
+    public class DeleteStackAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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("/stacks");
+            }
+
+            try {
+                yield stacks.delete_stack(((!)ctx).stack_id);
+                return directives().notify("success", "Stack deleted").navigate("/stacks");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not delete the stack: $(e.message)")
+                    .navigate("/stacks/$(((!)ctx).stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Adds a project to a stack; the stack id is sealed, the target
+     * and relationship come from the form selects.
+     */
+    public class AddStackMemberAction : TypedStatumAction<StackActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackActionPrivate); } }
+
+        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 stack_id = ctx != null ? ((!)ctx).stack_id : Forms.integer(request, "stack_id", 0);
+            int64 project_id = Forms.integer(request, "project_id", 0);
+            if (stack_id <= 0 || project_id <= 0) {
+                return directives().notify("error", "Pick a project to add").navigate(@"/stacks/$(stack_id)");
+            }
+
+            try {
+                yield stacks.add_member(stack_id, project_id,
+                    Forms.field(request, "relationship"));
+                return directives().notify("success", "Project added to the stack")
+                    .navigate(@"/stacks/$(stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not add the project: $(e.message)")
+                    .navigate(@"/stacks/$(stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Removes a stack member; both ids come from the sealed private
+     * blob.
+     */
+    public class RemoveStackMemberAction : TypedStatumAction<StackMemberActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(StackMemberActionPrivate); } }
+
+        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("/stacks");
+            }
+
+            try {
+                yield stacks.remove_member(((!)ctx).member_id);
+                return directives().notify("success", "Project removed from the stack")
+                    .navigate(@"/stacks/$(((!)ctx).stack_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not remove the project: $(e.message)")
+                    .navigate(@"/stacks/$(((!)ctx).stack_id)");
+            }
+        }
+    }
+
+    /**
+     * Applies a stack to a project (the project page's "Apply stack"
+     * action): the stack's members land as project_links rows,
+     * skipping exact duplicates. The stack comes from the form select;
+     * the project id is sealed.
+     */
+    public class ApplyStackToProjectAction : TypedStatumAction<ProjectActionPrivate> {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type { get { return typeof(ProjectActionPrivate); } }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            int64 project_id = ctx != null ? ((!)ctx).project_id : Forms.integer(request, "project_id", 0);
+            int64 stack_id = Forms.integer(request, "stack_id", 0);
+            if (project_id <= 0 || stack_id <= 0) {
+                return directives().notify("error", "Pick a stack to apply").navigate(@"/projects/$(project_id)");
+            }
+
+            try {
+                int64 added = yield stacks.apply_to_project(stack_id, project_id);
+                return directives().notify("success",
+                        added > 0
+                            ? @"Stack applied — $(added) link(s) added"
+                            : "Stack applied — nothing new to add")
+                    .navigate(@"/projects/$(project_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not apply the stack: $(e.message)")
+                    .navigate(@"/projects/$(project_id)");
+            }
+        }
+    }
+
+    /**
+     * Applies a stack to a goal (the goal page's "Apply stack"
+     * action): the stack's members land as goal_links rows, skipping
+     * exact duplicates. The stack comes from the form select; the
+     * goal id is sealed.
+     */
+    public class ApplyStackToGoalAction : SealedGoalAction {
+
+        private StackService stacks = inject<StackService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 goal_id = sealed_goal_id();
+            int64 stack_id = Forms.integer(request, "stack_id", 0);
+            if (goal_id <= 0 || stack_id <= 0) {
+                return directives().notify("error", "Pick a stack to apply").navigate(@"/goals/$(goal_id)");
+            }
+
+            try {
+                int64 added = yield stacks.apply_to_goal(stack_id, goal_id);
+                return directives().notify("success",
+                        added > 0
+                            ? @"Stack applied — $(added) link(s) added"
+                            : "Stack applied — nothing new to add")
+                    .navigate(@"/goals/$(goal_id)");
+            } catch (Error e) {
+                return directives().notify("error", @"Could not apply the stack: $(e.message)")
+                    .navigate(@"/goals/$(goal_id)");
+            }
+        }
+    }
+}

+ 42 - 3
src/actions/TaskActions.vala

@@ -86,6 +86,12 @@ namespace ClankerSaddle {
         private TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private UserService users = inject<UserService>();
+        private ProjectService projects = inject<ProjectService>();
+
+        /** The project of `id`, or null (standalone validation). */
+        private async ClankerSaddle.Data.Project? tasks_project(int64 id) throws GLib.Error {
+            return yield projects.get_project(id);
+        }
 
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -95,8 +101,21 @@ namespace ClankerSaddle {
 
             int64 project_id = Forms.integer(request, "project_id", 0);
             string title = Forms.field(request, "title");
-            if (project_id <= 0 || title.length == 0) {
-                return directives().notify("error", "Project and title are required").navigate("/tasks");
+            // Goal 8 task 4: project_id 0 posts a STANDALONE task (the
+            // form's "No project" option) — no repository, its own
+            // ephemeral workspace; negative ids are form corruption.
+            if (project_id < 0) {
+                return directives().notify("error", "Unknown project").navigate("/tasks");
+            }
+            if (title.length == 0) {
+                return directives().notify("error", "Title is required").navigate("/tasks");
+            }
+            if (project_id > 0) {
+                var project = yield tasks_project(project_id);
+                if (project == null) {
+                    return directives().notify("error",
+                            @"No project exists with id $(project_id)").navigate("/tasks");
+                }
             }
 
             // Required capabilities: the create form's multi-select posts
@@ -237,13 +256,33 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             try {
+                var task = yield tasks.get_task(task_id);
+                if (task == null) {
+                    return directives().notify("error", @"No task exists with id $(task_id)")
+                        .navigate("/tasks");
+                }
+                // Goal 8 task 4: comments stay OPEN on terminal tasks,
+                // and a comment on a TERMINAL NON-GOAL task (goal_id
+                // == 0) resurrects it — the task re-queues with the
+                // comment injected (the daemon receives the thread's
+                // comments on start). Goal-owned worker tasks keep
+                // their goal agent as the only voice: the service
+                // refuses below.
+                bool terminal_plain = (((!)task).task.goal_id == 0)
+                    && ((((!)task).task.status == STATUS_DONE)
+                        || (((!)task).task.status == STATUS_FAILED));
                 yield tasks.add_comment(task_id, @"user:$(me.username)", body, quote);
+                if (terminal_plain) {
+                    yield tasks.requeue_terminal_task(task_id);
+                }
                 // Terminals post with a return_to so the live view survives
                 // the comment; every other caller lands on the task page.
                 string return_to = Forms.field(request, "return_to");
                 string destination = return_to.length > 0 && return_to.has_prefix("/")
                     ? return_to : @"/tasks/$(task_id)";
-                return directives().notify("success", "Comment added").navigate(destination);
+                string message = terminal_plain
+                    ? "Comment added — task re-queued for another round" : "Comment added";
+                return directives().notify("success", message).navigate(destination);
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");

+ 32 - 2
src/entrypoints/GoalDetailEntrypoint.vala

@@ -213,6 +213,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? force_wake { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
+        /**
+         * Goal 8 task 2 B: applies a stack's members as goal links.
+         * Authored while the goal is not terminal (the involved set
+         * is frozen once complete/failed/cancelled).
+         */
+        public Statum.Model.ActionDto? apply_stack { get; set; }
+        public Vector<StackOptionView> stack_options { get; set; default = new Vector<StackOptionView>(); }
         /** The sealed GET refresh action (live pulse rehydration). */
         public Statum.Model.ActionDto? refresh { get; set; }
     }
@@ -234,12 +241,14 @@ namespace ClankerSaddle {
         public SaddleService? saddles { get; set; default = null; }
         public SaddlePollState? polls { get; set; default = null; }
         public RuleService? rules { get; set; default = null; }
+        public StackService? stacks { get; set; default = null; }
 
         public GoalViewContext(GoalService goals, TaskService tasks,
                 ProjectService projects, EventService events,
                 InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, SaddleService? saddles = null,
-                SaddlePollState? polls = null, RuleService? rules = null) {
+                SaddlePollState? polls = null, RuleService? rules = null,
+                StackService? stacks = null) {
             this.goals = goals;
             this.tasks = tasks;
             this.projects = projects;
@@ -249,6 +258,7 @@ namespace ClankerSaddle {
             this.saddles = saddles;
             this.polls = polls;
             this.rules = rules;
+            this.stacks = stacks;
         }
     }
 
@@ -316,6 +326,16 @@ namespace ClankerSaddle {
                         });
                     }
                 }
+                // Goal 8 task 2 B: the stacks appliable to this goal.
+                if (ctx.stacks != null) {
+                    foreach (var stack in yield ((!)ctx.stacks).list_stacks()) {
+                        view.pub.stack_options.add(new StackOptionView() {
+                            id = stack.id,
+                            name = stack.name,
+                            member_count = (yield ((!)ctx.stacks).list_members(stack.id)).length
+                        });
+                    }
+                }
                 // Plan 14 B5: the goal's effective rules — the set a
                 // dispatch ships for the run (and every worker):
                 // run-task rules, the primary project's, then the
@@ -380,6 +400,8 @@ namespace ClankerSaddle {
                 effective_rules.to_immutable_buffer());
             pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
                 project_options.to_immutable_buffer());
+            pub_root["stack_options"] = StateJson.list<StackOptionView>(
+                view.pub.stack_options.to_immutable_buffer());
             pub_root["plan_pending_block"] = plan_pending != null
                 ? new JsonElement.from_properties(
                     (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
@@ -444,6 +466,9 @@ namespace ClankerSaddle {
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
+                if (!pub.terminal) {
+                    pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
+                }
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
@@ -543,6 +568,9 @@ namespace ClankerSaddle {
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, GoalActionPrivate>(sealed);
+                if (!pub.terminal) {
+                    pub.apply_stack = ((!)ctx.actions).author_private<ApplyStackToGoalAction, GoalActionPrivate>(sealed);
+                }
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, GoalActionPrivate>(sealed);
@@ -1031,6 +1059,7 @@ namespace ClankerSaddle {
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private StackService stack_service = Inversion.inject<StackService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
 
@@ -1042,7 +1071,8 @@ namespace ClankerSaddle {
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
-                event_service, db, action_registry, saddle_service, poll_state, rule_service);
+                event_service, db, action_registry, saddle_service, poll_state, rule_service,
+                stack_service);
             var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 83 - 2
src/entrypoints/HomeEntrypoint.vala

@@ -75,6 +75,32 @@ namespace ClankerSaddle {
         public int64 at_epoch { get; set; }
     }
 
+    /** A project option in the note promote form's select. */
+    public class DashboardProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /**
+     * A scratchpad note row on the dashboard (goal 8 task 3): the
+     * body, its done state, and the per-row actions. An unpromoted,
+     * undone note carries the promote action; a promoted note carries
+     * its goal link instead (and never a second promote).
+     */
+    public class DashboardNoteView : Object {
+        public int64 id { get; set; }
+        public string body { get; set; default = ""; }
+        public bool done { get; set; }
+        public string done_display { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        /** The goal this note promoted into (0 = not promoted). */
+        public int64 goal_id { get; set; }
+        public string goal_title { get; set; default = ""; }
+        public Statum.Model.ActionDto? toggle { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+        public Statum.Model.ActionDto? promote { get; set; }
+    }
+
     /** Public data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
         public int64 draft_count { get; set; }
@@ -106,6 +132,11 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         /** Queues a REAL test push to this user (the bell's probe). */
         public Statum.Model.ActionDto? push_test { get; set; }
+        /**
+         * Adds a scratchpad note (goal 8 task 3) — the Notes card's
+         * add form.
+         */
+        public Statum.Model.ActionDto? add_note { get; set; }
     }
 
     /**
@@ -126,7 +157,7 @@ namespace ClankerSaddle {
          */
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
-                InvercargillSql.Orm.OrmSession db,
+                InvercargillSql.Orm.OrmSession db, NoteService notes,
                 ActionRegistry? actions = null, PushService? push = null,
                 int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
@@ -304,6 +335,7 @@ namespace ClankerSaddle {
                 pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
+                pub.add_note = ((!)actions).author<AddNoteAction>();
             }
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
@@ -344,6 +376,54 @@ namespace ClankerSaddle {
             props["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.to_immutable_buffer());
 
+            // The scratchpad (goal 8 task 3): every note with its
+            // per-row sealed actions; promoted notes link their goal
+            // and never carry a promote action again.
+            var note_rows = new Vector<DashboardNoteView>();
+            foreach (var note in yield notes.list_notes()) {
+                var view = new DashboardNoteView() {
+                    id = note.id,
+                    body = note.body,
+                    done = note.done,
+                    created_display = format_stamp(note.created_at),
+                    toggle = ((!)actions).author_private<ToggleNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id }),
+                    delete = ((!)actions).author_private<DeleteNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id })
+                };
+                if (note.done_at != null) {
+                    view.done_display = format_stamp(note.done_at);
+                }
+                if (note.goal_id > 0) {
+                    view.goal_id = note.goal_id;
+                    var goal_row = yield db.query<Goal>()
+                        .where(expr("id == $0", elem<int64?>(note.goal_id)))
+                        .first_async();
+                    if (goal_row != null) {
+                        view.goal_title = ((!)goal_row).title;
+                    }
+                } else if (!note.done) {
+                    view.promote = ((!)actions).author_private<PromoteNoteAction, NoteActionPrivate>(
+                        new NoteActionPrivate() { note_id = note.id });
+                }
+                note_rows.add(view);
+            }
+            props["notes"] = StateJson.list<DashboardNoteView>(note_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>();
+            foreach (var project in yield db.query<Project>()
+                    .order_by(expr("id"))
+                    .materialise_async()) {
+                project_options.add(new DashboardProjectOptionView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+            props["projects"] = StateJson.list<DashboardProjectOptionView>(
+                project_options.to_immutable_buffer());
+
             return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",
                 public_data = props,
@@ -383,6 +463,7 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
+        private NoteService note_service = Inversion.inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
@@ -395,7 +476,7 @@ namespace ClankerSaddle {
 
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
-                saddle_service, timeline_service, db, action_registry,
+                saddle_service, timeline_service, db, note_service, action_registry,
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
         }

+ 20 - 1
src/entrypoints/ProjectsEntrypoint.vala

@@ -112,6 +112,13 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? detach { get; set; }
     }
 
+    /** A stack option in the apply select (project and goal pages). */
+    public class StackOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public int64 member_count { get; set; }
+    }
+
     /** A rule option in the attach selects (project and task pages). */
     public class RuleOptionView : Object {
         public int64 id { get; set; }
@@ -144,6 +151,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? attach_rule { get; set; }
+        public Statum.Model.ActionDto? apply_stack { get; set; }
     }
 
     /**
@@ -157,6 +165,7 @@ namespace ClankerSaddle {
         private ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private StackService stack_service = Inversion.inject<StackService>();
         private UserService users = Inversion.inject<UserService>();
 
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -183,7 +192,8 @@ namespace ClankerSaddle {
                 created_display = format_stamp(((!)project).created_at),
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 add_link = action_registry.author_private<AddProjectLinkAction, ProjectActionPrivate>(sealed),
-                attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed)
+                attach_rule = action_registry.author_private<AttachProjectRuleAction, ProjectActionPrivate>(sealed),
+                apply_stack = action_registry.author_private<ApplyStackToProjectAction, ProjectActionPrivate>(sealed)
             };
 
             var links = new Vector<ProjectLinkView>();
@@ -288,6 +298,15 @@ namespace ClankerSaddle {
             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());
+            var stack_options = new Vector<StackOptionView>();
+            foreach (var stack in yield stack_service.list_stacks()) {
+                stack_options.add(new StackOptionView() {
+                    id = stack.id,
+                    name = stack.name,
+                    member_count = (yield stack_service.list_members(stack.id)).length
+                });
+            }
+            props["stack_options"] = StateJson.list<StackOptionView>(stack_options.to_immutable_buffer());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {

+ 8 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -41,7 +41,12 @@ namespace ClankerSaddle {
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { get; set; default = ""; }
+        /** The workspace root the daemon reported at /register (advisory). */
+        public string workspace_root { get; set; default = ""; }
         public Statum.Model.ActionDto? update_identity { get; set; }
+        /** The admin's standing prompt note about THIS machine (goal 8 task 5). */
+        public string prompt_note { get; set; default = ""; }
+        public Statum.Model.ActionDto? update_prompt_note { get; set; }
         public int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
         /** Set when the current task is a goal agent's run: link the goal instead. */
@@ -181,7 +186,10 @@ namespace ClankerSaddle {
                     git_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
+                    workspace_root = saddle.workspace_root ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
+                    prompt_note = saddle.prompt_note ?? "",
+                    update_prompt_note = action_registry.author_private<UpdateSaddlePromptNoteAction, SaddleActionPrivate>(sealed),
                     current_task_id = work != null ? ((!)work).task_id : 0,
                     allocated_display = work != null
                         ? format_stamp(((!)work).started_at) : "",

+ 170 - 0
src/entrypoints/StacksEntrypoint.vala

@@ -0,0 +1,170 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** A stack row on the stacks page, with its member names. */
+    public class StackRowView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public int64 member_count { get; set; }
+    }
+
+    /** Public data of the `stacks` slot. */
+    public class StacksPublic : Object {
+        public Statum.Model.ActionDto create { get; set; }
+    }
+
+    /**
+     * Entrypoint for /stacks (goal 8 task 2 B): every stack with its
+     * member count and a link to its page, plus the create form. A
+     * stack is a named bundle of reference projects applied one-click
+     * to a project's or a goal's link set.
+     */
+    public class StacksEntrypoint : StatumEntrypoint {
+
+        private StackService stack_service = Inversion.inject<StackService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var rows = new Vector<StackRowView>();
+            foreach (var stack in yield stack_service.list_stacks()) {
+                rows.add(new StackRowView() {
+                    id = stack.id,
+                    name = stack.name,
+                    summary = stack.summary ?? "",
+                    created_display = format_stamp(stack.created_at),
+                    member_count = (yield stack_service.list_members(stack.id)).length
+                });
+            }
+
+            var pub = new StacksPublic();
+            pub.create = action_registry.author<CreateStackAction>();
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["stacks"] = StateJson.list<StackRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "stacks",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+
+    /** A member project of a stack, with its remove action. */
+    public class StackMemberView : Object {
+        public int64 member_id { get; set; }
+        public int64 project_id { get; set; }
+        public string name { get; set; default = ""; }
+        public string git_url { get; set; default = ""; }
+        public string relationship { get; set; default = ""; }
+        public Statum.Model.ActionDto? remove { get; set; }
+    }
+
+    /** A project option in the member-add select. */
+    public class StackProjectOptionView : Object {
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+    }
+
+    /** Public data of the `stack` slot. */
+    public class StackDetailPublic : Object {
+        public bool exists { get; set; }
+        public int64 id { get; set; }
+        public string name { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? update { get; set; }
+        public Statum.Model.ActionDto? add_member { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /**
+     * Entrypoint for /stacks/{stack_id}: the stack's editable card,
+     * its member projects with relationship badges and remove actions,
+     * and the add-member form (every project, relationship select).
+     */
+    public class StackDetailEntrypoint : StatumEntrypoint {
+
+        private StackService stack_service = Inversion.inject<StackService>();
+        private ProjectService project_service = Inversion.inject<ProjectService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            int64 stack_id = int64.parse(request.route("stack_id") ?? "0");
+            var stack = yield stack_service.get_stack(stack_id);
+            if (stack == null) {
+                return directives().set_typed<StackDetailPublic>("stack", Scope.PAGE,
+                    new StackDetailPublic() { exists = false });
+            }
+
+            var sealed = new StackActionPrivate() { stack_id = stack_id };
+            var pub = new StackDetailPublic() {
+                exists = true,
+                id = ((!)stack).id,
+                name = ((!)stack).name,
+                summary = ((!)stack).summary ?? "",
+                created_display = format_stamp(((!)stack).created_at),
+                update = action_registry.author_private<UpdateStackAction, StackActionPrivate>(sealed),
+                add_member = action_registry.author_private<AddStackMemberAction, StackActionPrivate>(sealed),
+                delete = action_registry.author_private<DeleteStackAction, StackActionPrivate>(sealed)
+            };
+
+            var members = new Vector<StackMemberView>();
+            foreach (var member in yield stack_service.list_members(stack_id)) {
+                var project = yield project_service.get_project(member.project_id);
+                if (project == null) {
+                    continue;
+                }
+                members.add(new StackMemberView() {
+                    member_id = member.id,
+                    project_id = member.project_id,
+                    name = ((!)project).name,
+                    git_url = ((!)project).git_url,
+                    relationship = member.relationship,
+                    remove = action_registry.author_private<RemoveStackMemberAction, StackMemberActionPrivate>(
+                        new StackMemberActionPrivate() { stack_id = stack_id, member_id = member.id })
+                });
+            }
+
+            var options = new Vector<StackProjectOptionView>();
+            foreach (var project in yield project_service.list_projects()) {
+                options.add(new StackProjectOptionView() {
+                    id = project.id,
+                    name = project.name
+                });
+            }
+
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["members"] = StateJson.list<StackMemberView>(members.to_immutable_buffer());
+            props["options"] = StateJson.list<StackProjectOptionView>(options.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new State() {
+                type_name = "stack",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot);
+        }
+    }
+}

+ 20 - 0
src/main.vala

@@ -43,6 +43,7 @@ int main(string[] args) {
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
         }
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
+        db.add_migration<ClankerSaddle.Data.Migrations.M0002_BatchImprovements>();
         db.add_entity<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -62,12 +63,17 @@ int main(string[] args) {
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.entity_mapping);
+        db.add_entity<Stack>(Stack.entity_mapping);
+        db.add_entity<StackProject>(StackProject.entity_mapping);
+        db.add_entity<Note>(Note.entity_mapping);
         db.migrate_on_startup();
 
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<EventService>();
         application.add_scoped<GoalService>();
+        application.add_scoped<StackService>();
+        application.add_scoped<NoteService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
@@ -103,6 +109,8 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<StacksPage, StacksEntrypoint>();
+        statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
         statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
@@ -149,6 +157,7 @@ int main(string[] args) {
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
         statum.action<UpdateSaddleIdentityAction>();
+        statum.action<UpdateSaddlePromptNoteAction>();
         statum.action<CreateRuleAction>();
         statum.action<UpdateRuleAction>();
         statum.action<DeleteRuleAction>();
@@ -157,6 +166,17 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
+        statum.action<CreateStackAction>();
+        statum.action<UpdateStackAction>();
+        statum.action<DeleteStackAction>();
+        statum.action<AddStackMemberAction>();
+        statum.action<RemoveStackMemberAction>();
+        statum.action<ApplyStackToProjectAction>();
+        statum.action<ApplyStackToGoalAction>();
+        statum.action<AddNoteAction>();
+        statum.action<ToggleNoteAction>();
+        statum.action<DeleteNoteAction>();
+        statum.action<PromoteNoteAction>();
         statum.action<CreateCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();

+ 18 - 0
src/pages/goal_detail.html

@@ -422,6 +422,7 @@
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
                     <button type="button" class="inline" stm-action="link.remove"
                             stm-confirm="Remove this link?">&times;</button>
+                    <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
                 <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
                 <details stm-if="!goal.terminal">
@@ -437,6 +438,15 @@
                         </select>
                         <button type="submit">Link</button>
                     </form>
+                    <form class="inline" stm-action="goal.apply_stack"
+                          stm-if="goal.stack_options.length > 0" style="margin-top: 0.5em;">
+                        <label for="goal-stack">Apply stack</label>
+                        <select id="goal-stack" name="stack_id" required>
+                            <option stm-for-stack-in="goal.stack_options" stm-attribute.value="stack.id"
+                                     stm-text="stack.name + ' (' + stack.member_count + ')'">stack</option>
+                        </select>
+                        <button type="submit">Apply</button>
+                    </form>
                 </details>
             </section>
 
@@ -499,10 +509,18 @@
                 <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <p stm-if="goal.run_task_id == 0" class="muted">The goal agent has not started
                     yet — comments open with its run.</p>
+                <!-- Goal 8 task 4: the composer stays OPEN on terminal
+                     goals — a comment there re-opens the goal and
+                     re-offers its agent's run with the comment riding
+                     the resume (cancelled goals stay closed: their
+                     composer keeps the run gate alone). -->
                 <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
                     <div class="form-row"><label for="goal-comment-body">Add comment</label>
                         <textarea id="goal-comment-body" name="body" rows="3"
                                   placeholder="A timeless note for a future reader — status belongs in progress updates (markdown)"></textarea></div>
+                    <p stm-if="goal.terminal && goal.status != 'cancelled'" class="muted">
+                        This goal is terminal — commenting re-opens it: the agent resumes
+                        with your comment and submits a fresh completion report.</p>
                     <button type="submit" class="primary">Comment</button>
                 </form>
                 <dialog id="quote-comment-dialog">

+ 52 - 0
src/pages/home.html

@@ -276,6 +276,58 @@
                 </section>
             </div>
 
+            <!-- The scratchpad (goal 8 task 3): jot down batches of
+                 changes before they are ready to be full-on goals.
+                 Check a note off, delete it, or promote it — promote
+                 creates a DRAFT goal (title = the first line, brief =
+                 the whole body) and jumps to its page to finish. -->
+            <h3>Notes</h3>
+            <section class="card">
+                <form stm-action="dashboard.add_note">
+                    <div class="form-row">
+                        <textarea name="body" rows="2" required
+                                  placeholder="Note down a batch of changes before it is ready to be a goal…"></textarea></div>
+                    <button type="submit">Add note</button>
+                </form>
+            </section>
+            <table stm-if="dashboard.notes.length > 0">
+                <thead><tr><th>Done</th><th>Note</th><th>Created</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-note-in="dashboard.notes">
+                        <td><button type="button" stm-action="note.toggle"
+                                    stm-attribute.class="note.done ? 'approve' : ''"
+                                    stm-text="note.done ? 'Done' : 'Open'">toggle</button></td>
+                        <td><span stm-attribute.class="note.done ? 'footnote' : ''"
+                                  stm-text="note.body">body</span></td>
+                        <td class="muted" stm-text="note.created_display">when</td>
+                        <td>
+                            <span stm-if="note.goal_id > 0">
+                                <span class="goal-icon">🎯</span>
+                                <a stm-attribute.href="'/goals/' + note.goal_id"
+                                   stm-text="note.goal_title">goal</a>
+                            </span>
+                            <form class="inline" stm-if="note.goal_id == 0 && !note.done"
+                                  stm-action="note.promote">
+                                <select name="project_id" required>
+                                    <option stm-for-project-in="dashboard.projects"
+                                            stm-attribute.value="project.id"
+                                            stm-text="project.name">project</option>
+                                </select>
+                                <input type="text" name="branch_name" required
+                                       placeholder="goal/branch" size="14">
+                                <button type="submit" class="primary">Create goal</button>
+                            </form>
+                            <span class="muted" stm-if="note.goal_id == 0 && note.done">—</span>
+                            <form class="inline" stm-action="note.delete">
+                                <button type="submit">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="dashboard.notes.length == 0" class="muted">No notes yet — jot down the
+                next batch of changes above.</p>
+
             <h3 stm-if="dashboard.pinned.length > 0">Needs a human
                 (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
             <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and

+ 1 - 0
src/pages/main.html

@@ -452,6 +452,7 @@
             <a href="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
+            <a href="/stacks">Stacks</a>
             <!-- The users link is display-guarded (the page's entrypoint
                  guard is authoritative); auth.permissions rides the
                  signed auth slot, so an admin sees it everywhere. -->

+ 11 - 0
src/pages/project_detail.html

@@ -62,6 +62,17 @@
                 <button type="submit">Add link</button>
             </form>
 
+            <form class="inline" stm-action="project.apply_stack" stm-if="project.stack_options.length > 0">
+                <label for="stack-target">Apply stack</label>
+                <select id="stack-target" name="stack_id" required>
+                    <option stm-for-stack-in="project.stack_options" stm-attribute.value="stack.id"
+                             stm-text="stack.name + ' (' + stack.member_count + ')'">stack</option>
+                </select>
+                <button type="submit">Apply</button>
+            </form>
+            <p class="muted" stm-if="project.stack_options.length == 0">Manage reference
+                collections on the <a href="/stacks">stacks page</a> and apply them here.</p>
+
             <h3>Rules</h3>
             <p class="muted">Attached rules ride along with every task dispatched for this project.</p>
             <div stm-for-rule-in="project.rules" stm-key="rule.link_id" class="card">

+ 11 - 0
src/pages/saddles.html

@@ -66,6 +66,17 @@
                             <span class="muted">Empty fields fall back to the daemon's default identity.</span>
                         </form>
                     </details>
+                    <details stm-if="saddles.is_admin">
+                        <summary>Prompt note</summary>
+                        <form stm-action="saddle.update_prompt_note" class="inline">
+                            <label for="prompt-note">Served inside every assignment's system prompt</label>
+                            <textarea id="prompt-note" name="prompt_note" rows="3"
+                                      stm-attribute.placeholder="'e.g. You have root and may install development tooling via DNF'"
+                                      stm-text="saddle.prompt_note"></textarea>
+                            <button type="submit">Save</button>
+                            <span class="muted">A standing message about THIS machine for its agents; empty removes it (capped at 2000 chars).</span>
+                        </form>
+                    </details>
                     <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>

+ 67 - 0
src/pages/stack_detail.html

@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/stacks/{stack_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2 stm-if="stack != null && stack.exists" stm-text="stack.name">Stack</h2>
+        <p stm-if="stack != null && !stack.exists" class="muted">Stack not found.</p>
+
+        <div stm-if="stack != null && stack.exists">
+            <p><a href="/stacks">All stacks</a></p>
+            <div class="cards">
+                <section class="card">
+                    <h3>Details</h3>
+                    <p class="muted" stm-if="stack.summary.length > 0" stm-text="stack.summary">summary</p>
+                    <p class="muted">Created <span stm-text="stack.created_display">when</span></p>
+                </section>
+
+                <section class="card">
+                    <h3>Edit</h3>
+                    <form stm-action="stack.update">
+                        <input type="hidden" name="stack_id" stm-attribute.value="stack.id">
+                        <div class="form-row"><label>Name</label>
+                            <input type="text" name="name" stm-attribute.value="stack.name"></div>
+                        <div class="form-row"><label>Summary</label>
+                            <input type="text" name="summary" stm-attribute.value="stack.summary"></div>
+                        <button type="submit">Save</button>
+                    </form>
+                </section>
+            </div>
+
+            <h3>Projects in this stack</h3>
+            <p class="muted">Applying the stack adds each of these to a project's or a goal's
+                linked projects with the relationship shown here.</p>
+            <table>
+                <thead><tr><th>Project</th><th>Git URL</th><th>Relationship</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-member-in="stack.members">
+                        <td><a stm-attribute.href="'/projects/' + member.project_id" stm-text="member.name">name</a></td>
+                        <td stm-text="member.git_url">git_url</td>
+                        <td><span stm-attribute.class="'badge badge-' + member.relationship" stm-text="member.relationship">relationship</span></td>
+                        <td><button type="button" stm-action="member.remove" stm-confirm="Remove this project from the stack?">Remove</button></td>
+                    </tr>
+                    <tr stm-if="stack.members.length == 0"><td colspan="4" class="muted">No projects in this stack yet.</td></tr>
+                </tbody>
+            </table>
+            <form class="inline" stm-action="stack.add_member" stm-if="stack.options.length > 0">
+                <label for="member-target">Add project</label>
+                <select id="member-target" name="project_id" required>
+                    <option stm-for-option-in="stack.options" stm-attribute.value="option.id" stm-text="option.name">project</option>
+                </select>
+                <select name="relationship">
+                    <option value="reference" selected>reference</option>
+                    <option value="co-developed">co-developed</option>
+                </select>
+                <button type="submit">Add</button>
+            </form>
+
+            <form class="inline">
+                <button type="button" stm-action="stack.delete" stm-confirm="Delete this stack? Its projects keep any links already applied.">Delete stack</button>
+            </form>
+        </div>
+    </section>
+</body>
+</html>

+ 42 - 0
src/pages/stacks.html

@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/stacks</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Stacks</h2>
+        <p class="muted">Named bundles of reference projects — apply one to a project or a
+            goal to attach its whole collection of links in a single click.</p>
+        <p stm-if="stacks == null">Loading…</p>
+
+        <div stm-if="stacks != null">
+            <details>
+                <summary><strong>New stack</strong></summary>
+                <form stm-action="stacks.create">
+                    <div class="form-row"><label>Name</label>
+                        <input type="text" name="name" required placeholder="Web stack references"></div>
+                    <div class="form-row"><label>Summary <span class="muted">(optional)</span></label>
+                        <input type="text" name="summary" placeholder="What this collection is for"></div>
+                    <button type="submit">Create stack</button>
+                </form>
+            </details>
+
+            <table stm-if="stacks.stacks.length > 0">
+                <thead><tr><th>#</th><th>Name</th><th>Projects</th><th>Summary</th><th>Created</th></tr></thead>
+                <tbody>
+                    <tr stm-for-stack-in="stacks.stacks">
+                        <td stm-text="stack.id">id</td>
+                        <td><a stm-attribute.href="'/stacks/' + stack.id" stm-text="stack.name">name</a></td>
+                        <td stm-text="stack.member_count">0</td>
+                        <td class="muted" stm-text="stack.summary">summary</td>
+                        <td class="muted" stm-text="stack.created_display">when</td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="stacks.stacks.length == 0" class="muted">No stacks yet.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 20 - 2
src/pages/task_detail.html

@@ -63,11 +63,18 @@
         <p stm-if="task != null && !task.exists" class="muted">Task not found.</p>
 
         <div stm-if="task != null && task.exists">
-            <p><a href="/tasks">All tasks</a> · project
+            <!-- Goal 8 task 4: a standalone task (no project) shows
+                 "(standalone)" instead of a project link — its
+                 workspace holds no clones. -->
+            <p stm-if="task.project_id > 0"><a href="/tasks">All tasks</a> · project
                 <a stm-attribute.href="'/projects/' + task.project_id" stm-text="task.project_name">project</a>
                 <span stm-if="task.enqueue_after_task_id > 0"> · auto-enqueues after
                     <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
                        stm-text="'#' + task.enqueue_after_task_id">after</a></span></p>
+            <p stm-if="task.project_id == 0"><a href="/tasks">All tasks</a> ·
+                <strong>(standalone)</strong>
+                <span class="muted">— no project: the agent works in its own ephemeral
+                    workspace and delivers its completion report</span></p>
 
             <!-- Progress feed ABOVE the status card (plan 11 task D):
                  the agent's newest line leads the page, its history
@@ -305,12 +312,23 @@
                 <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
             </div>
             <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
-            <form stm-action="task.comment">
+            <!-- Goal 8 task 4: comments stay OPEN on terminal tasks.
+                 A terminal NON-GOAL task resurrects on comment (it
+                 re-queues with the comment injected); a terminal
+                 goal-owned worker keeps its goal agent as the only
+                 voice — the hint says so and the service refuses. -->
+            <form stm-if="!(task.terminal && task.goal_id > 0)" stm-action="task.comment">
                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                 <div class="form-row"><label for="comment-body">Add comment</label>
                     <textarea id="comment-body" name="body" rows="3" placeholder="A comment for the agent"></textarea></div>
+                <p stm-if="task.terminal && task.goal_id == 0" class="muted">
+                    This task is terminal — commenting re-queues it for another round
+                    with your comment injected.</p>
                 <button type="submit" class="primary">Comment</button>
             </form>
+            <p stm-if="task.terminal && task.goal_id > 0" class="muted">
+                Comments are closed — this task belongs to its goal; the goal agent
+                (its goal's page) owns the conversation.</p>
 
             <!-- The highlight-to-comment popover (main.html) fills and opens
                  this dialog; it went missing in a template rework, which broke

+ 6 - 1
src/pages/tasks.html

@@ -92,7 +92,12 @@
             <summary><strong>New task</strong></summary>
             <form stm-action="tasks.create">
                 <div class="form-row"><label for="task-project">Project</label>
-                    <select id="task-project" name="project_id" required>
+                    <!-- Goal 8 task 4: "No project" posts project_id 0 —
+                         a STANDALONE task with its own ephemeral
+                         workspace; the completion report is the
+                         deliverable. -->
+                    <select id="task-project" name="project_id">
+                        <option value="0" selected>No project (standalone)</option>
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                     </select></div>

+ 6 - 1
test/bundles/__init__.py

@@ -4,7 +4,8 @@ the modules' star-imported runtime globals are the shard's own). The
 order here is the parallel runner's shard order."""
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
-               lifecycle, pages, push, replay, subtasks, users)
+               lifecycle, notes, origins, pages, push, replay, resurrect,
+               stacks, subtasks, users)
 
 BUNDLES = {
     "pages": pages,
@@ -19,6 +20,10 @@ BUNDLES = {
     "replay": replay,
     "goalui": goalui,
     "users": users,
+    "stacks": stacks,
+    "origins": origins,
+    "notes": notes,
+    "resurrect": resurrect,
 }
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

+ 56 - 0
test/bundles/lifecycle.py

@@ -537,6 +537,62 @@ def run(ctx):
     check(wait_task_done(auth, identity_task, timeout=60) is not None,
           "identity task completes")
 
+    print("== goal 8 task 5: per-saddle prompt note ==")
+    # The admin edits the note on the /saddles card; it persists on the
+    # row and rides EVERY later assignment as `prompt_note`.
+    fake_card = saddle_card(auth, "fake-saddle")
+    check(fake_card.get("prompt_note", "") == "",
+          "no prompt note before the admin sets one")
+    check(all(p.get("prompt_note") == "" for p in fake.start_payloads),
+          "unset note carries an empty string (never a null)")
+    act(auth, fake_card["update_prompt_note"],
+        {"prompt_note": "You have root and may install development tooling via DNF."})
+    fake_card = saddle_card(auth, "fake-saddle")
+    check(fake_card.get("prompt_note") == "You have root and may install development tooling via DNF.",
+          "inline edit saves the prompt note")
+    note_task = create_queued(auth, {
+        "project_id": "1",
+        "title": "Prompt note task",
+        "priority": "2",
+    })
+    check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == note_task
+                               for p in fake.start_payloads) or False,
+                   timeout=60, what="prompt-note task dispatched") is not None,
+          "prompt-note task dispatches")
+    note_payload = [p for p in fake.start_payloads
+                    if int(p.get("task", {}).get("id") or 0) == note_task][-1]
+    check(note_payload.get("prompt_note") == "You have root and may install development tooling via DNF.",
+          "the assignment carries the saddle's prompt_note verbatim")
+    check(wait_task_done(auth, note_task, timeout=60) is not None,
+          "prompt-note task completes")
+    # Clearing the note empties the member on later assignments.
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": ""})
+    check(saddle_card(auth, "fake-saddle").get("prompt_note", "") == "",
+          "clearing the prompt note empties the row")
+    # A fat note is clamped: the row keeps the full text but the wire
+    # serves at most PROMPT_NOTE_MAX_CHARS.
+    fat = "N" * 5000
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": fat})
+    clamp_task = create_queued(auth, {
+        "project_id": "1",
+        "title": "Clamp note task",
+        "priority": "2",
+    })
+    check(wait_for(lambda: any(int(p.get("task", {}).get("id") or 0) == clamp_task
+                               for p in fake.start_payloads) or False,
+                   timeout=60, what="clamp-note task dispatched") is not None,
+          "clamp-note task dispatches")
+    clamp_payload = [p for p in fake.start_payloads
+                     if int(p.get("task", {}).get("id") or 0) == clamp_task][-1]
+    check(len(clamp_payload.get("prompt_note") or "") == 2000,
+          "a fat prompt_note is clamped to 2000 chars on the wire")
+    check(wait_task_done(auth, clamp_task, timeout=60) is not None,
+          "clamp-note task completes")
+    fake_card = saddle_card(auth, "fake-saddle")
+    act(auth, fake_card["update_prompt_note"], {"prompt_note": ""})
+
     print("== phase 2: blocked push -> block row + pulse ==")
     # The browser flow on each pulse: statum.act(task.refresh) — the
     # sealed GET action must rehydrate the task slot with the new

+ 183 - 0
test/bundles/notes.py

@@ -0,0 +1,183 @@
+"""The notes bundle (goal 8 task 3): the dashboard scratchpad — a
+personal todo list for batches of changes that are not ready to be
+full-on goals yet, with promote-to-goal (a DRAFT goal whose brief is
+the whole note body, the note frozen and linked to the goal)."""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    def dashboard():
+        return slot_public(auth, "/", "dashboard")
+
+    def note_rows():
+        return dashboard()["notes"]
+
+    def note_by_body(fragment):
+        rows = [n for n in note_rows() if fragment in n["body"]]
+        return rows[0] if rows else None
+
+    def db_one(sql, params=()):
+        conn = db()
+        try:
+            return conn.execute(sql, params).fetchone()
+        finally:
+            conn.close()
+
+    print("== notes: the dashboard carries the scratchpad ==")
+    dash = dashboard()
+    check(dash.get("add_note") is not None,
+          "the dashboard exposes the add-note action")
+    check(dash["notes"] == [],
+          "the scratchpad starts empty")
+    check(len(dash["projects"]) >= 1,
+          "the promote form's project options list the projects")
+    check(any(p["name"] == "demo" for p in dash["projects"]),
+          "the demo project is a promote option")
+
+    print("== notes: add ==")
+    result = act(auth, dash["add_note"], {"body": ""})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in result),
+          "an empty note body refuses")
+    act(auth, dash["add_note"],
+        {"body": "Fix the login redirect\n\nAlso cover the remember-me path in tests."})
+    act(auth, dashboard()["add_note"], {"body": "Second note, older work"})
+    rows = note_rows()
+    check(len(rows) == 2,
+          "added notes land in the list")
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "notes list newest first")
+    first = note_by_body("Fix the login redirect")
+    check(first["done"] is False and str(first["goal_id"]) == "0",
+          "a fresh note is undone and unpromoted")
+    check(first.get("toggle") is not None and first.get("delete") is not None
+          and first.get("promote") is not None,
+          "an undone unpromoted note carries toggle, delete and promote actions")
+
+    print("== notes: toggle done -> ordering flips ==")
+    act(auth, first["toggle"], {})
+    rows = note_rows()
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "a done note sinks below every undone note")
+    done_row = note_by_body("Fix the login redirect")
+    check(done_row["done"] is True and len(done_row.get("done_display") or "") > 0,
+          "the done flag sticks with its stamp")
+    check(done_row.get("promote") is None,
+          "a done note offers no promote action")
+    conn = db()
+    stamped = conn.execute(
+        "select done, done_at from notes where id = ?", (first["id"],)).fetchone()
+    conn.close()
+    check(stamped is not None and stamped[0] == 1 and stamped[1] is not None,
+          "the done transition stamped done_at in the database")
+    # Toggle back: the note returns to the top and promote returns.
+    act(auth, done_row["toggle"], {})
+    rows = note_rows()
+    check(rows[0]["body"] == "Second note, older work"
+          and rows[1]["body"].startswith("Fix the login redirect"),
+          "untoggling restores the newest-first ordering among undone notes")
+    reopened = note_by_body("Fix the login redirect")
+    check(reopened["done"] is False and reopened.get("promote") is not None,
+          "an undone note regains its promote action")
+    conn = db()
+    cleared = conn.execute(
+        "select done, done_at from notes where id = ?", (first["id"],)).fetchone()
+    conn.close()
+    check(cleared[0] == 0 and cleared[1] is None,
+          "untoggling clears the done stamp")
+
+    print("== notes: delete ==")
+    act(auth, dashboard()["add_note"], {"body": "Throwaway note"})
+    doomed = note_by_body("Throwaway note")
+    act(auth, doomed["delete"], {})
+    check(note_by_body("Throwaway note") is None,
+          "a deleted note disappears from the list")
+    conn = db()
+    leftover = conn.execute(
+        "select count(*) from notes where body like 'Throwaway%'").fetchone()[0]
+    conn.close()
+    check(leftover == 0, "the deleted note's row is gone")
+
+    print("== notes: promote to goal ==")
+    body = ("Fix the login redirect\n\nAlso cover the remember-me path in tests.\n"
+            "The redirect should honour the originally requested page.")
+    act(auth, dashboard()["add_note"], {"body": body})
+    note = note_by_body("Fix the login redirect")
+    promote = note["promote"]
+    result = act(auth, promote,
+                 {"project_id": "1", "branch_name": "goal/note-promote"})
+    navigated = [d for d in result if d.get("type") == "navigate"]
+    check(len(navigated) == 1 and navigated[0].get("uri", "").startswith("/goals/"),
+          "promoting navigates to the created goal's page")
+    goal_id = int(navigated[0]["uri"].rsplit("/", 1)[1])
+
+    goal_row = db_one("select id, project_id, title, brief, branch_name, status"
+                      " from goals where id = ?", (goal_id,))
+    check(goal_row is not None,
+          "promoting created a goal row")
+    check(goal_row[1] == 1 and goal_row[4] == "goal/note-promote"
+          and goal_row[5] == "draft",
+          "the promoted goal sits on the chosen project and branch in draft")
+    check(goal_row[3] == body,
+          "the promoted goal's brief is the full note body")
+    check(goal_row[2] == "Fix the login redirect",
+          "the promoted goal's title is the note's first line")
+
+    state = goal_state(goal_id)
+    check(state.get("exists") and state.get("title") == "Fix the login redirect",
+          "the goal page shows the promoted goal")
+
+    rows = note_rows()
+    promoted = [n for n in rows if n["id"] == note["id"]][0]
+    check(str(promoted["goal_id"]) == str(goal_id)
+          and promoted["goal_title"] == "Fix the login redirect",
+          "the promoted note links its goal with the goal's title")
+    check(promoted.get("promote") is None,
+          "a promoted note is frozen (no further promote)")
+
+    # Promoting a promoted note refuses — the action DTO is gone from
+    # the slot, so the service-level guard is exercised via a direct
+    # re-promote attempt through the old DTO.
+    again = act(auth, promote, {"project_id": "1", "branch_name": "goal/note-promote-2"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already promoted" in (d.get("message") or "") for d in again),
+          "re-promoting a promoted note refuses with the frozen error")
+    conn = db()
+    duplicates = conn.execute(
+        "select count(*) from goals where branch_name = 'goal/note-promote-2'"
+    ).fetchone()[0]
+    conn.close()
+    check(duplicates == 0, "the refused re-promotion created no second goal")
+
+    print("== notes: promote validation ==")
+    act(auth, dashboard()["add_note"], {"body": "Never promoted\nbody"})
+    bad = note_by_body("Never promoted")
+    missing = act(auth, bad["promote"], {"project_id": "9999",
+                                         "branch_name": "goal/notes-bad"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in missing),
+          "promoting onto a missing project refuses")
+    nobranch = act(auth, note_by_body("Never promoted")["promote"],
+                   {"project_id": "1", "branch_name": ""})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error" for d in nobranch),
+          "promoting without a branch name refuses")
+    survivor = note_by_body("Never promoted")
+    check(str(survivor["goal_id"]) == "0" and survivor.get("promote") is not None,
+          "the refused promotions left the note unpromoted")
+
+    print("== notes: the long-title clamp ==")
+    long_line = "L" * 120 + "\nand a second line"
+    act(auth, dashboard()["add_note"], {"body": long_line})
+    clamped = note_by_body("LLLL")
+    result = act(auth, clamped["promote"],
+                 {"project_id": "1", "branch_name": "goal/note-clamp"})
+    goal_id2 = int([d for d in result if d.get("type") == "navigate"][0]["uri"]
+                   .rsplit("/", 1)[1])
+    title = db_one("select title from goals where id = ?", (goal_id2,))[0]
+    check(len(title) <= 81 and title.endswith("…"),
+          "a first line over 80 characters clamps with an ellipsis")
+    brief = db_one("select brief from goals where id = ?", (goal_id2,))[0]
+    check(brief == long_line,
+          "the clamped title never truncates the brief (the full body rides along)")

+ 142 - 0
test/bundles/origins.py

@@ -0,0 +1,142 @@
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+
+
+"""Goal 8 task 6 (web side): the register handshake stores the daemon's
+reported `workspace_root`, and assignment project paths are addressed on
+the ASKING saddle's filesystem — the saddle row's root first, then the
+SADDLE_PROJECTS_ROOT environment override, then the historical
+"/workspace" default. The web app needs NO access to saddle workspaces:
+paths are advisory and multi-saddle goals coordinate through origin."""
+
+
+def run(ctx):
+    auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
+
+    print("== origin decoupling: the handshake stores the reported root ==")
+    # The primary fake reports /workspace by default; the row carries it
+    # once registration completes (the stacks bundle's demo project is
+    # project 1 in every shard).
+    def saddle_row(name):
+        return next((s for s in slot_public(auth, "/saddles", "saddles")["saddles"]
+                     if s["name"] == name), None)
+
+    primary = saddle_row("fake-saddle")
+    check(primary is not None, "the primary fake saddle is registered")
+    if primary is not None:
+        check(primary.get("workspace_root") == "/workspace",
+              "the register handshake stores the daemon-reported workspace_root (%r)"
+              % primary.get("workspace_root"))
+
+    print("== origin decoupling: assignment paths follow the ASKING saddle ==")
+    # A second saddle on its own volume: its daemon reports a DIFFERENT
+    # workspace root — the fleet's separate-mount deployment shape.
+    other = FakeSaddle()
+    other.workspace_root = "/volumes/saddle-b"
+    other.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"], {"name": "volume-b",
+                                          "host": "127.0.0.1:%d" % other.port})
+    check(wait_for(lambda: len(other.register_log) > 0 or False,
+                   timeout=15, what="volume-b /register delivery") is not None,
+          "the second saddle received /register")
+    row_b = saddle_row("volume-b")
+    check(row_b is not None and row_b.get("workspace_root") == "/volumes/saddle-b",
+          "the second saddle's reported root lands on its row (%r)"
+          % ((row_b or {}).get("workspace_root")))
+
+    # One task on the demo project, dispatched to EACH saddle in turn:
+    # the assignment's project `path` must sit under the ASKING saddle's
+    # root, not a shared default.
+    def path_of(saddle_fake, title):
+        payloads = [p for p in saddle_fake.start_payloads
+                    if (p.get("task") or {}).get("title") == title]
+        if not payloads:
+            return None
+        co = payloads[-1]["task"].get("co_developed") or []
+        return co[0].get("path") if co else None
+
+    # deterministic routing: a capability each saddle alone carries
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["create_capability"], {"name": "primary-vol"})
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "volume-b-vol"})
+    caps = {c["name"]: int(c["id"])
+            for c in slot_public(auth, "/saddles", "saddles")["capabilities"]}
+    act(auth, saddle_card(auth, "fake-saddle")["attach_capability"],
+        {"capability_id": str(caps["primary-vol"])})
+    act(auth, saddle_card(auth, "volume-b")["attach_capability"],
+        {"capability_id": str(caps["volume-b-vol"])})
+
+    def create_routed(title, cap_name, description):
+        task_id = create_queued(auth, {"project_id": "1", "title": title,
+                                       "description": description, "priority": "2"})
+        act(auth, task_state(auth, task_id)["attach_capability"],
+            {"capability_id": str(caps[cap_name])})
+        wait_for(lambda: [c["name"] for c in
+                          task_state(auth, task_id)["capabilities"]] == [cap_name]
+                 or False, timeout=30, what="%s attached" % cap_name)
+        return task_id
+
+    first = create_routed("Path probe A", "primary-vol",
+                          "Dispatch to the primary volume.")
+    check(wait_for(lambda: path_of(fake, "Path probe A") is not None,
+                   timeout=40, what="primary-saddle assignment") is not None,
+          "the primary saddle receives the task")
+    path_a = path_of(fake, "Path probe A")
+    check(path_a is not None and path_a.startswith("/workspace/task-"),
+          "the primary saddle's paths sit under its reported /workspace (%r)" % path_a)
+
+    second = create_routed("Path probe B", "volume-b-vol",
+                           "Dispatch to the second volume.")
+    check(wait_for(lambda: path_of(other, "Path probe B") is not None,
+                   timeout=40, what="volume-b assignment") is not None,
+          "the volume-b saddle receives the task")
+    path_b = path_of(other, "Path probe B")
+    check(path_b is not None and path_b.startswith("/volumes/saddle-b/task-"),
+          "the volume-b saddle's paths sit under ITS OWN reported root (%r)" % path_b)
+    check(path_a is not None and path_b is not None
+          and path_a.split("/task-")[0] != path_b.split("/task-")[0],
+          "the two saddles' assignments address different volumes")
+
+    print("== origin decoupling: the compat fallback chain ==")
+    # A daemon that does NOT report a workspace_root (pre-task-6 daemon)
+    # keeps a null root; its paths fall back to SADDLE_PROJECTS_ROOT
+    # (the harness runs the app with it pointed at the scratch dir).
+    # The probe task REQUIRES a capability only this saddle carries, so
+    # the dispatcher deterministically targets it (tag routing).
+    legacy = FakeSaddle()
+    legacy.workspace_root = ""
+    legacy.serve(port=free_port())
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    act(auth, saddles_state["register"],
+        {"name": "legacy-daemon", "host": "127.0.0.1:%d" % legacy.port})
+    check(wait_for(lambda: len(legacy.register_log) > 0 or False,
+                   timeout=15, what="legacy /register delivery") is not None,
+          "the legacy daemon received /register")
+    row_legacy = saddle_row("legacy-daemon")
+    check(row_legacy is not None and not row_legacy.get("workspace_root"),
+          "a daemon reporting no root keeps the column empty (%r)"
+          % ((row_legacy or {}).get("workspace_root")))
+    act(auth, slot_public(auth, "/saddles", "saddles")["create_capability"],
+        {"name": "legacy-only"})
+    saddles_state = slot_public(auth, "/saddles", "saddles")
+    legacy_cap = int([c["id"] for c in saddles_state["capabilities"]
+                      if c["name"] == "legacy-only"][0])
+    legacy_card = saddle_card(auth, "legacy-daemon")
+    act(auth, legacy_card["attach_capability"], {"capability_id": str(legacy_cap)})
+    third = create_queued(auth, {"project_id": "1", "title": "Path probe C",
+                                 "description": "Dispatch to the legacy daemon.",
+                                 "priority": "4"})
+    detail = task_state(auth, third)
+    act(auth, detail["attach_capability"], {"capability_id": str(legacy_cap)})
+    check(wait_for(lambda: [c["name"] for c in
+                            task_state(auth, third)["capabilities"]] == ["legacy-only"]
+                   or False, timeout=30, what="capability attached") is not None,
+          "the probe task requires the legacy-only capability")
+    check(wait_for(lambda: path_of(legacy, "Path probe C") is not None,
+                   timeout=60, what="legacy-saddle assignment") is not None,
+          "the legacy saddle receives the task")
+    path_c = path_of(legacy, "Path probe C")
+    harness_root = os.path.join(WORK, "workspace")
+    check(path_c is not None and path_c.startswith(harness_root + "/task-"),
+          "a null root falls back to SADDLE_PROJECTS_ROOT, not /workspace (%r)" % path_c)

+ 30 - 0
test/bundles/pages.py

@@ -810,3 +810,33 @@ def run(ctx):
           "an active task's Edit button is draft-gated (stm-if hides it)")
     check(editor_at < 0 or card_html.rfind('stm-if="task.editable_all"', 0, editor_at) > 0,
           "an active task's editor form sits inside the draft-gated branch")
+
+    # =====================================================================
+    # Goal 8 task 38: the schema is UPGRADEABLE now — M0002 is the
+    # incremental migration that un-flattened it. On this bundle's
+    # fresh boot both migrations ran: assert serial 2 is recorded and
+    # every object M0002 owns exists, so a future in-place edit of
+    # M0001 (the regression this goal shipped against) is caught by
+    # the serial staying 1 here while the schema drifts.
+    print("== migrations: incremental schema (M0002 recorded) ==")
+    conn = db()
+    serials = conn.execute(
+        "select serial from __migrations where namespace = 'clanker-saddle'"
+        " order by serial").fetchall()
+    conn.close()
+    check([s[0] for s in serials] == [1, 2],
+          "the clanker-saddle namespace carries exactly serials 1 and 2"
+          " (a fresh boot ran M0001 then the M0002 no-op)")
+    check(len(serials) == 2,
+          "no duplicate migration rows (M0002's guarded up() applied once)")
+
+    conn = db()
+    tables = {r[0] for r in conn.execute(
+        "select name from sqlite_master where type = 'table'")}
+    saddle_cols = {r[1] for r in conn.execute(
+        "pragma table_info('saddles')")}
+    conn.close()
+    check({"notes", "stacks", "stack_projects"} <= tables,
+          "M0002's tables (notes, stacks, stack_projects) exist")
+    check({"prompt_note", "workspace_root"} <= saddle_cols,
+          "M0002's saddles columns (prompt_note, workspace_root) exist")

+ 15 - 1
test/bundles/push.py

@@ -219,6 +219,15 @@ def run(ctx):
     check(block_hit["headers"].get("ttl") == "60"
           and block_hit["headers"].get("content-type") == "application/octet-stream",
           "the push POST carries TTL and the encrypted content type")
+    # RFC 8291 section 4: the aes128gcm body MUST announce its encoding
+    # — push services answer HTTP 400 without it (the test-notification
+    # 400 regression).
+    check(block_hit["headers"].get("content-encoding") == "aes128gcm",
+          "the push POST carries Content-Encoding: aes128gcm (RFC 8291)")
+    check(block_hit["headers"].get("urgency") == "high"
+          and block_hit["headers"].get("authorization", "")
+          .startswith("vapid t="),
+          "the push POST carries Urgency and the VAPID Authorization")
 
     # Task 2: only the OTHER user opted in — start + failure reach them
     # alone. Baseline again before the enqueue (first start fires at
@@ -449,12 +458,17 @@ def run(ctx):
                    and len(push2.hits("/b")) > b_before, timeout=15,
                    what="test notification POSTs") is not None,
           "push_test fires a REAL push to both of the user's endpoints")
-    _, test_payload = decrypt_webpush(push2.hits("/a")[-1], browser_a,
+    test_hit = push2.hits("/a")[-1]
+    _, test_payload = decrypt_webpush(test_hit, browser_a,
                                       browser_a_public, browser_a_auth)
     check(test_payload["title"] == "Test notification"
           and test_payload["url"] == "/",
           "the test payload decrypts with title + url (goes to the dashboard)"
           " (%s)" % test_payload)
+    check(test_hit["headers"].get("content-encoding") == "aes128gcm"
+          and test_hit["headers"].get("ttl") == "60",
+          "the test-notification POST carries Content-Encoding: aes128gcm"
+          " + TTL (the HTTP 400 fix)")
     check("2 browser" in message,
           "the action response reports both deliveries (%s)" % message)
     set_types = [d.get("type") for d in result if d.get("type") == "set"]

+ 227 - 0
test/bundles/resurrect.py

@@ -0,0 +1,227 @@
+"""The resurrect bundle (goal 8 task 4): standalone (no-project) tasks
+and comment-resurrected terminal items.
+
+- A task created with project_id 0 dispatches with EMPTY co-developed
+  and reference arrays, the "standalone" project name, and completes on
+  its completion report.
+- A human comment on a TERMINAL goal (complete) re-opens the goal, its
+  run task returns to suspended-offerable, and the resuming assignment
+  carries the comment + the re-open line in its resume notice.
+- A human comment on a TERMINAL plain task re-queues it, the next
+  assignment carries the comment.
+- A terminal goal-owned WORKER task keeps comments closed (the page
+  hides the composer; the service path is the goal's alone).
+"""
+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 create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    def run_task_of(goal_id):
+        conn = db()
+        try:
+            row = conn.execute(
+                "select id from tasks where goal_id = ? and kind = 'goal_run'",
+                (goal_id,)).fetchone()
+            return int(row[0]) if row else None
+        finally:
+            conn.close()
+
+    print("== standalone task: created with no project, dispatched, done ==")
+    state = slot_public(auth, "/tasks", "tasks")
+    act(auth, state["create"], {
+        "project_id": "0",
+        "title": "Standalone research",
+        "description": "Summarise the fleet's flaky tests.",
+        "priority": "3",
+    })
+    standalone = task_id_of("Standalone research")
+    detail = task_state(auth, standalone)
+    check(detail["status"] == "draft" and int(detail["project_id"]) == 0,
+          "a project_id 0 task creates in draft (project_id %s)" % detail["project_id"])
+    page = http_get_page(auth, "/tasks/%d" % standalone)
+    check("(standalone)" in page and "/projects/0" not in page,
+          "the task page renders the (standalone) marker, not a project link")
+    check("No project (standalone)" in http_get_page(auth, "/tasks"),
+          "the create form offers the No project (standalone) option")
+    enqueue_task(auth, standalone)
+    check(wait_for(lambda: len(fake.start_payloads) > 0 or False,
+                   timeout=30, what="standalone /start push") is not None,
+          "the dispatcher pushes the standalone task to the fake saddle")
+    payload = fake.start_payloads[-1]
+    task_obj = payload.get("task") or {}
+    check(task_obj.get("project_name") == "standalone",
+          "the standalone assignment names the project 'standalone' (%s)"
+          % task_obj.get("project_name"))
+    check(task_obj.get("co_developed") == [] and task_obj.get("reference") == [],
+          "the standalone assignment ships EMPTY co-developed and reference arrays")
+    check(task_obj.get("branch_from", "x") == "",
+          "the standalone assignment carries no branch_from")
+    check(wait_task_done(auth, standalone) is not None,
+          "the standalone task completes on its report")
+
+    print("== terminal plain task: a comment re-queues it ==")
+    plain = create_queued(auth, {
+        "project_id": "1", "title": "Resurrect me",
+        "description": "A plain project task that finishes.", "priority": "4"})
+    check(wait_task_done(auth, plain) is not None, "the plain task completes")
+    detail = task_state(auth, plain)
+    act(auth, detail["comment"], {"body": "Please also cover the retry path."})
+    detail = task_state(auth, plain)
+    check(detail["status"] == "queued",
+          "the comment re-queued the terminal plain task (%s)" % detail["status"])
+    check(any("cover the retry path" in c["body"] for c in detail["comments"]),
+          "the resurrecting comment rides the thread")
+    conn = db()
+    summary_kept = conn.execute(
+        "select summary from tasks where id = ?", (plain,)).fetchone()[0]
+    conn.close()
+    check(summary_kept and len(summary_kept) > 0,
+          "the earlier round's completion summary survives the re-open")
+    check(wait_for(lambda: len(fake.start_payloads) > 1 or False,
+                   timeout=30, what="requeued dispatch") is not None,
+          "the re-queued task dispatches again")
+
+    def comment_reached_agent():
+        for payload in fake.start_payloads:
+            task_obj = payload.get("task") or {}
+            if task_obj.get("id") == plain and any(
+                    "cover the retry path" in c["body"]
+                    for c in task_obj.get("comments", [])):
+                return True
+        return any("cover the retry path" in (c.get("text") or "")
+                   for c in fake.comment_log)
+    # The comment reaches the agent by EITHER channel: inside an
+    # assignment payload, or via the live /task-update push to the
+    # RUNNING daemon (the delivered flag) — the dispatcher tick races
+    # the dispatch, so wait for whichever lands.
+    check(wait_for(comment_reached_agent, timeout=30,
+                   what="comment delivery") is not None,
+          "the second round receives the resurrecting comment (assignment or live push)")
+    check(wait_task_done(auth, plain) is not None, "the second round completes")
+
+    print("== terminal goal: a comment re-opens it and resumes the agent ==")
+    # One behaviour per ROUND: round 1 plans (parks blocked), round 2
+    # (the approved resume) suspends, round 3 (the force-woke resume)
+    # submits the completion report.
+    fake.goal_script = [("plan", "# Resurrect plan\n\n1. Finish quickly"),
+                        ("suspend",),
+                        ("done", "# Completion\n\nThe report stands.")]
+    goal = create_goal({"project_id": "1", "title": "Resurrect goal",
+                        "brief": "Completes, then re-opens on a comment.",
+                        "branch_name": "goal/resurrect"})
+    act(auth, goal_state(goal)["start"], {})
+    check(wait_for(lambda: run_task_of(goal) is not None or False,
+                   timeout=30, what="goal run task appears") is not None,
+          "starting the goal creates its run task")
+    run_task = run_task_of(goal)
+    check(wait_for(lambda: goal_state(goal)["status"] == "plan_review" or False,
+                   timeout=90, what="goal plans") is not None,
+          "the goal reaches plan review")
+    pending = goal_state(goal)["plan_pending_block"]
+    act(auth, pending["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=60, what="plan approved") is not None,
+          "approving the plan activates the goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended" or False,
+                   timeout=60, what="agent parks") is not None,
+          "the agent parks after the plan")
+    # Round 3 needs a wake: force-wake the parked agent so its scripted
+    # done round (the completion report) runs.
+    state = goal_state(goal)
+    act(auth, state["force_wake"], {})
+    # The completion round: the scripted done parks the run for review.
+    check(wait_for(lambda: goal_state(goal)["status"] == "completion_review" or False,
+                   timeout=90, what="completion review opens") is not None,
+          "the scripted done round enters completion review")
+    block = goal_state(goal)["completion_pending_block"]
+    act(auth, block["answer_action"], {"verdict": "accept"})
+    check(wait_for(lambda: goal_state(goal)["status"] == "complete" or False,
+                   timeout=30, what="goal completes") is not None,
+          "accepting the completion completes the goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "done" or False,
+                   timeout=30, what="run task done") is not None,
+          "the run task closed done with the goal")
+
+    goal_page = http_get_page(auth, "/goals/%d" % goal)
+    check("stm-action=\"goal.comment\"" in goal_page,
+          "the terminal goal page keeps the comment composer open")
+    check("commenting re-opens it" in goal_page,
+          "the composer explains the resurrect semantics on a terminal goal")
+    state = goal_state(goal)
+    act(auth, state["comment"], {"body": "One more sweep of the edge cases, please."})
+    check(wait_for(lambda: goal_state(goal)["status"] == "active" or False,
+                   timeout=30, what="goal re-opens") is not None,
+          "the comment re-opened the completed goal")
+    check(wait_for(lambda: task_state(auth, run_task)["status"] == "suspended" or False,
+                   timeout=30, what="run re-offerable") is not None,
+          "the DONE run task returned to suspended (the parked-resume shape)")
+    conn = db()
+    report_kept = conn.execute(
+        "select completion_report from goals where id = ?", (goal,)).fetchone()[0]
+    seeded = conn.execute(
+        "select count(*) from sessions where task_id = ?", (run_task,)).fetchone()[0]
+    conn.close()
+    check(report_kept and len(report_kept) > 0,
+          "the completion report stays on the re-opened goal")
+    check(seeded >= 1, "the resurrected run carries a session row (the resume anchor)")
+    goal_comments = goal_state(goal)["comments"]
+    check(any("edge cases" in c["body"] for c in goal_comments),
+          "the resurrecting comment rides the goal thread")
+    # force_wake made the parked run offerable at once: the dispatcher
+    # hands it back and the resuming assignment carries the notice.
+    assignments_before = len(fake.goal_assignments)
+    check(wait_for(lambda: len(fake.goal_assignments) > assignments_before or False,
+                   timeout=60, what="resurrected agent resumes") is not None,
+          "the resurrected goal agent resumes on its saddle")
+    resumed = fake.goal_assignments[-1]
+    check((resumed.get("task") or {}).get("id") == run_task,
+          "the resuming assignment is the run task")
+    check("re-opened by a human comment" in (resumed.get("resume_notice") or ""),
+          "the resume notice names the comment-driven re-open")
+    check(any("edge cases" in c["body"]
+              for c in (resumed.get("task") or {}).get("comments", []))
+          or any("edge cases" in (c.get("text") or "") for c in fake.comment_log),
+          "the resuming agent receives the comment (assignment or live push)")
+
+    print("== terminal goal worker: comments stay closed ==")
+    conn = db()
+    worker = conn.execute(
+        "insert into tasks (project_id, goal_id, kind, title, status, priority,"
+        " mode, creator, completed_at, created_at)"
+        " values (1, ?, 'task', 'Dead worker', 'done', 5,"
+        " 'implement', 'goal', strftime('%s','now'), strftime('%s','now'))",
+        (goal,)).lastrowid
+    conn.commit()
+    conn.close()
+    worker_page = http_get_page(auth, "/tasks/%d" % worker)
+    check("Comments are closed" in worker_page,
+          "a terminal goal-owned task page explains the closed comments")
+    # The gate lives in the template (statum hides the branch at render;
+    # the served markup carries no branch text to match — pages.py's
+    # Edit-button precedent): the composer must sit inside the
+    # goal-owned-terminal negation and the closed notice inside the
+    # positive gate.
+    template = open(os.path.join(REPO, "src", "pages", "task_detail.html")).read()
+    composer_at = template.find('<form stm-if="!(task.terminal && task.goal_id > 0)"')
+    notice_at = template.find('<p stm-if="task.terminal && task.goal_id > 0"')
+    check(composer_at > 0 and notice_at > composer_at,
+          "the task template gates the composer on goal-owned terminal tasks")
+    # The service path is the goal's alone: the page-level gate is the
+    # only barrier a human meets, and a direct AddCommentAction post on
+    # the dead worker must NOT resurrect it (the action ignores
+    # goal-owned terminal tasks).
+    state = task_state(auth, worker)
+    if state.get("comment"):
+        act(auth, state["comment"], {"body": "Sneaky resurrection attempt"})
+        check(task_state(auth, worker)["status"] == "done",
+              "a comment on a terminal goal worker does not resurrect it")
+    else:
+        check(True, "a terminal goal worker exposes no comment action at all")

+ 316 - 0
test/bundles/stacks.py

@@ -0,0 +1,316 @@
+"""The stacks bundle (goal 8 task 2): the goal link pre-fill from the
+primary project's project_links, and the stacks feature — a named
+collection of reference projects applied one-click to a project's or a
+goal's link set (stacks/stack_projects tables, /stacks pages, the apply
+actions with exact-duplicate skipping)."""
+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 create_goal(fields):
+        act(auth, slot_public(auth, "/goals", "goals")["create"], fields)
+        rows = [g for g in slot_public(auth, "/goals", "goals")["goals"]
+                if g["title"] == fields["title"]]
+        return int(rows[0]["id"])
+
+    def stack_state(stack_id):
+        return slot_public(auth, "/stacks/%d" % stack_id, "stack")
+
+    def link_rows(goal_id):
+        conn = db()
+        try:
+            return conn.execute(
+                "select gl.project_id, gl.relationship from goal_links gl"
+                " where gl.goal_id = ? order by gl.id", (goal_id,)).fetchall()
+        finally:
+            conn.close()
+
+    def project_link_rows(parent_id):
+        conn = db()
+        try:
+            return conn.execute(
+                "select linked_project_id, relationship from project_links"
+                " where parent_project_id = ? order by id", (parent_id,)).fetchall()
+        finally:
+            conn.close()
+
+    print("== goal 8 task 2 A: goal links pre-fill from the project ==")
+    # Two satellites around the demo project: one co-developed, one
+    # reference. The goal created on demo must inherit both.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-codev', 'https://example.com/codev.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-ref', 'https://example.com/ref.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    codev_id = int(conn.execute(
+        "select id from projects where name = 'stacks-codev'").fetchone()[0])
+    ref_id = int(conn.execute(
+        "select id from projects where name = 'stacks-ref'").fetchone()[0])
+    # A third project linked to the project but AFTER goal creation —
+    # it must NOT propagate (the pre-fill is a one-time snapshot).
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-late', 'https://example.com/late.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    late_id = int(conn.execute(
+        "select id from projects where name = 'stacks-late'").fetchone()[0])
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'co-developed')", (codev_id,))
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'reference')", (ref_id,))
+    conn.commit()
+    conn.close()
+
+    goal = create_goal({"project_id": "1", "title": "Prefill probe",
+                        "brief": "Check the link pre-fill.",
+                        "branch_name": "goal/prefill", "priority": "3"})
+    check(set(link_rows(goal)) == {(codev_id, "co-developed"), (ref_id, "reference")},
+          "a goal created on a linked project pre-fills goal_links from"
+          " project_links (same targets, same relationships)")
+    state = goal_state(goal)
+    check(sorted(link["name"] for link in state["links"]) == ["stacks-codev", "stacks-ref"],
+          "the goal page lists the pre-filled links")
+    check(state["links"][0].get("remove") is not None,
+          "the pre-filled links are prunable like manual ones")
+
+    # The late link must not propagate into the existing goal.
+    conn = db()
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (1, ?, 'reference')", (late_id,))
+    conn.commit()
+    conn.close()
+    check(all(row[0] != late_id for row in link_rows(goal)),
+          "a project link added AFTER creation never propagates (snapshot)")
+
+    # A NEW goal created after the late link lands DOES carry it.
+    goal2 = create_goal({"project_id": "1", "title": "Prefill probe 2",
+                         "brief": "Snapshot at creation time.",
+                         "branch_name": "goal/prefill2", "priority": "3"})
+    check(set(link_rows(goal2)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference"),
+                                    (late_id, "reference")},
+          "a goal created after a new project link snapshots that link too")
+
+    # Self-links in the project graph are never copied (a project link
+    # to the goal's own primary is redundant — it is implicit).
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-self', 'https://example.com/self.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    goal3 = create_goal({"project_id": "1", "title": "No self links",
+                         "brief": "The primary is implicit.",
+                         "branch_name": "goal/prefill3", "priority": "3"})
+    check(all(row[0] != 1 for row in link_rows(goal3)),
+          "the primary project is never duplicated as an explicit link")
+
+    print("== the pre-filled links ride the assignment payload ==")
+    # Removing one pre-filled link must remove it from the payload —
+    # goal_links stays the single source of truth after the snapshot.
+    state = goal_state(goal)
+    codev_link = [link for link in state["links"] if link["name"] == "stacks-codev"][0]
+    act(auth, codev_link["remove"], {"link_id": str(codev_link["id"])})
+    check(set(link_rows(goal)) == {(ref_id, "reference")},
+          "removing a pre-filled goal link removes the row")
+    fake.goal_script = [("suspend",)]
+    act(auth, goal_state(goal)["start"], {})
+    check(wait_for(lambda: next(
+        (True for row in db().execute(
+            "select id from tasks where goal_id = ? and kind = 'goal_run'",
+            (goal,)).fetchall()), False) or False,
+        timeout=30, what="goal run task creation") is not None,
+        "start creates the goal-run task")
+    conn = db()
+    run_task = int(conn.execute(
+        "select id from tasks where goal_id = ? and kind = 'goal_run'",
+        (goal,)).fetchone()[0])
+    conn.close()
+
+    def assigned_payload():
+        for payload in reversed(fake.goal_assignments):
+            if (payload.get("task") or {}).get("id") == run_task:
+                return payload
+        return None
+
+    check(wait_for(lambda: assigned_payload() is not None or False,
+                   timeout=60, what="goal run assignment") is not None,
+          "the fake saddle captures the goal-run assignment")
+    payload = assigned_payload()
+    codev_names = [p["name"] for p in payload["task"]["co_developed"]]
+    ref_names = [p["name"] for p in payload["task"]["reference"]]
+    check("demo" in codev_names and "stacks-codev" not in codev_names,
+          "the assignment payload's co-developed carries the primary and the"
+          " surviving pre-filled link (the removed one is gone)")
+    check(ref_names == ["stacks-ref"],
+          "the assignment payload's reference set comes from the goal's links")
+
+    print("== stacks: CRUD ==")
+    stacks_state = slot_public(auth, "/stacks", "stacks")
+    check(stacks_state.get("create") is not None and stacks_state["stacks"] == [],
+          "the stacks page starts empty with a create action")
+    act(auth, stacks_state["create"],
+        {"name": "web-stack-refs", "summary": "The usual reference collection"})
+    stacks = slot_public(auth, "/stacks", "stacks")["stacks"]
+    check(len(stacks) == 1 and stacks[0]["name"] == "web-stack-refs"
+          and int(stacks[0]["member_count"]) == 0,
+          "a stack lands in the list with its (empty) member count")
+    stack_id = int(stacks[0]["id"])
+
+    detail = stack_state(stack_id)
+    check(detail["exists"] and detail["name"] == "web-stack-refs"
+          and detail["members"] == [] and detail.get("update") is not None
+          and detail.get("add_member") is not None and detail.get("delete") is not None,
+          "the stack page exposes edit, add-member and delete actions")
+    check(any(option["name"] == "demo" for option in detail["options"]),
+          "every project is offerable as a stack member")
+
+    # Duplicate name refused.
+    dup = act(auth, slot_public(auth, "/stacks", "stacks")["create"],
+              {"name": "web-stack-refs"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in dup),
+          "a duplicate stack name refuses")
+
+    # Rename + summary clear.
+    act(auth, detail["update"], {"name": "web-stack-refs-2", "summary": ""})
+    detail = stack_state(stack_id)
+    check(detail["name"] == "web-stack-refs-2" and detail["summary"] == "",
+          "a stack renames and its summary clears")
+
+    print("== stacks: members ==")
+    detail = stack_state(stack_id)
+    act(auth, detail["add_member"],
+        {"project_id": str(codev_id), "relationship": "co-developed"})
+    act(auth, stack_state(stack_id)["add_member"],
+        {"project_id": str(ref_id), "relationship": "reference"})
+    detail = stack_state(stack_id)
+    member_names = [(m["name"], m["relationship"]) for m in detail["members"]]
+    check(member_names == [("stacks-codev", "co-developed"),
+                           ("stacks-ref", "reference")],
+          "members land with their relationships")
+    dup = act(auth, stack_state(stack_id)["add_member"],
+              {"project_id": str(ref_id), "relationship": "reference"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              and "already" in (d.get("message") or "") for d in dup),
+          "adding the same project twice refuses")
+    bad = act(auth, stack_state(stack_id)["add_member"],
+              {"project_id": str(late_id), "relationship": "sponsor"})
+    check(any(d.get("type") == "notify" and d.get("kind") == "error"
+              for d in bad),
+          "an unknown relationship refuses")
+    detail = stack_state(stack_id)
+    check(len(detail["members"]) == 2,
+          "the refused additions left the stack untouched")
+
+    print("== stacks: apply to a project ==")
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-target', 'https://example.com/target.git', 'main',"
+                 " strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    target_id = int(db().execute(
+        "select id from projects where name = 'stacks-target'"
+    ).fetchone()[0])
+
+    project_state = slot_public(auth, "/projects/%d" % target_id, "project")
+    check(project_state.get("apply_stack") is not None
+          and any(opt["name"] == "web-stack-refs-2" for opt in project_state["stack_options"]),
+          "the project page offers Apply stack with the stacks as options")
+    result = act(auth, project_state["apply_stack"], {"stack_id": str(stack_id)})
+    check("2 link" in (action_notify(result) or ""),
+          "applying reports how many links landed")
+    check(set(project_link_rows(target_id)) == {(codev_id, "co-developed"),
+                                                (ref_id, "reference")},
+          "the stack's members became project_links rows")
+
+    # Re-apply: exact duplicates skipped, nothing duplicated.
+    result = act(auth, slot_public(auth, "/projects/%d" % target_id,
+                                   "project")["apply_stack"],
+                 {"stack_id": str(stack_id)})
+    check("nothing new" in (action_notify(result) or ""),
+          "re-applying skips exact duplicates")
+    check(set(project_link_rows(target_id)) == {(codev_id, "co-developed"),
+                                                (ref_id, "reference")},
+          "no duplicate project_links rows appear")
+
+    # A pre-existing DIFFERENT relationship on the same target is not a
+    # duplicate: applying adds the stack's own relationship alongside.
+    conn = db()
+    conn.execute("insert into project_links (parent_project_id, linked_project_id,"
+                 " relationship) values (?, ?, 'co-developed')",
+                 (target_id, ref_id))
+    conn.commit()
+    conn.close()
+    act(auth, slot_public(auth, "/projects/%d" % target_id, "project")["apply_stack"],
+        {"stack_id": str(stack_id)})
+    check((ref_id, "reference") in project_link_rows(target_id)
+          and project_link_rows(target_id).count((ref_id, "co-developed")) == 1,
+          "a different relationship on the same target coexists (exact pairs skip)")
+
+    print("== stacks: apply to a goal ==")
+    # A goal on a LINK-FREE host project — the pre-fill seeds nothing,
+    # so the stack's members are exactly what applying must land.
+    conn = db()
+    conn.execute("insert into projects (name, git_url, default_branch, created_at)"
+                 " values ('stacks-goalhost', 'https://example.com/goalhost.git',"
+                 " 'main', strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    host_id = int(db().execute(
+        "select id from projects where name = 'stacks-goalhost'").fetchone()[0])
+    goal4 = create_goal({"project_id": str(host_id), "title": "Stack target",
+                         "brief": "Apply a stack here.",
+                         "branch_name": "goal/stacktarget", "priority": "3"})
+    check(link_rows(goal4) == [],
+          "a goal on a link-free project pre-fills nothing")
+    gstate = goal_state(goal4)
+    check(gstate.get("apply_stack") is not None
+          and any(opt["name"] == "web-stack-refs-2" for opt in gstate["stack_options"]),
+          "the goal page offers Apply stack with the stacks as options")
+    result = act(auth, gstate["apply_stack"], {"stack_id": str(stack_id)})
+    check("2 link" in (action_notify(result) or ""),
+          "applying to the goal reports the links landed")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "the stack's members became goal_links rows")
+
+    # Re-apply: idempotent.
+    result = act(auth, goal_state(goal4)["apply_stack"], {"stack_id": str(stack_id)})
+    check("nothing new" in (action_notify(result) or ""),
+          "re-applying to the goal skips exact duplicates")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "no duplicate goal_links rows appear")
+
+    # Removing a stack member then re-applying adds only the newcomer.
+    detail = stack_state(stack_id)
+    ref_view = [m for m in detail["members"]
+                if int(m["project_id"]) == ref_id][0]
+    act(auth, ref_view["remove"], {})
+    check(len(stack_state(stack_id)["members"]) == 1,
+          "removing a member shrinks the stack")
+    act(auth, goal_state(goal4)["apply_stack"], {"stack_id": str(stack_id)})
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "removing a stack member never unlinks what was already applied")
+
+    print("== stacks: delete ==")
+    conn = db()
+    act(auth, slot_public(auth, "/stacks/%d" % stack_id, "stack")["delete"], {})
+    conn.close()
+    check(slot_public(auth, "/stacks", "stacks")["stacks"] == [],
+          "deleting a stack empties the list")
+    conn = db()
+    leftover = conn.execute("select count(*) from stack_projects").fetchone()[0]
+    conn.close()
+    check(leftover == 0, "the stack's member rows die with it")
+    check(set(link_rows(goal4)) == {(codev_id, "co-developed"),
+                                    (ref_id, "reference")},
+          "deleting a stack keeps every link it already applied")

+ 5 - 2
test/harness.py

@@ -518,6 +518,7 @@ class FakeSaddle:
         self.session_id = None           # the web-side run row of the running task
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.ssh_public_key = ""         # reported on /status + /register
+        self.workspace_root = "/workspace"  # goal 8 task 6: reported on /register
         self.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
         self.silent = False              # suppress pushes (staleness tests)
@@ -811,7 +812,8 @@ class FakeSaddle:
                     self.daemon.token = payload["saddle_token"]
                 self._send(200, {"ok": True,
                                  "ssh_public_key": self.daemon.ssh_public_key,
-                                 "version": "0.1-fake"})
+                                 "version": "0.1-fake",
+                                 "workspace_root": self.daemon.workspace_root})
                 if self.daemon.ask_on_register:
                     self.daemon.maybe_trigger_ask("register")
                 return
@@ -1314,7 +1316,8 @@ def start_bundle(name):
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui", "forcewake", "users")
+                 "replay", "goalui", "forcewake", "users", "stacks", "notes",
+                 "resurrect", "origins")
 
 
 def run_bundle(name):