Преглед изворни кода

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

Billy Barrow пре 1 месец
родитељ
комит
ef672f7aa7
44 измењених фајлова са 3269 додато и 76 уклоњено
  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_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_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_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)
 ## 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.
 alive across restarts and is never packaged or baked into images.
 
 
 The database is `./clanker-saddle.sqlite`, or `SPRY_DB_PATH` when set.
 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)
 ## 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" \
     -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 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_LLM_BASE_URL=http://127.0.0.1:9999 \
-    -e SADDLE_PROJECTS_ROOT=/workspace \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     -e SADDLE_CALLBACK_BASE_URL=http://clanker-web:8080 \
     localhost/clanker-saddle:0.1
     localhost/clanker-saddle:0.1
 podman run -d --name clanker-saddle --userns=keep-id --network clanker-net \
 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
 `web-config.json` `saddle_callback_base_url` key) must be the app's
 address **as the daemon dials it** — every registered daemon pushes
 address **as the daemon dials it** — every registered daemon pushes
 telemetry, task callbacks and notes there. Put the SQLite store on a volume
 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 need `:Z`, and rootless podman needs `--userns=keep-id` for the
 mounts to be writable. For durable sessions mount `web-config.json`
 mounts to be writable. For durable sessions mount `web-config.json`
 read-only and point `ASTRALIS_CONFIG_PATH` at it; otherwise the app runs
 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,
 plus `dnf install` of your tools (C, Vala, GTK, Node, .NET, Android,
 QEMU — anything), then tag the capability names you gave those tools in
 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.
 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
 ## Creating your first project and task
 
 
 1. **Project** — `/projects` → name, git URL, default branch. The
 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.
    *reference* (read-only) from the project page.
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
 2. **Task** — `/tasks` → project, title, **mandate** (the description),
    mode (implement / investigate / review / test) and priority (1 is
    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
 `/api/saddle/*` call is authenticated by the per-saddle bearer token
 (removing the saddle revokes it).
 (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
 ## Users, roles and your profile
 
 
 Two roles decide what a logged-in user can do (the account model is
 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
 rejection) keep the subscription row and retry, since the endpoint
 may recover.
 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 and project notes
 
 
 **Rules** (`/rules`) are reusable instructions dispatched with every task
 **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).
 on branch `main` (the project registration's default branch matches).
 
 
 The daemon's `SADDLE_WORKSPACE_ROOT` and the app's
 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
 published ports, pass `SADDLE_HOST=clanker-saddle:7100`, the container's
 SQLite file as `DB_PATH`, and start the in-container mock via
 SQLite file as `DB_PATH`, and start the in-container mock via
 `MOCK_CMD='podman exec -d clanker-saddle python3
 `MOCK_CMD='podman exec -d clanker-saddle python3
@@ -473,22 +499,42 @@ HTTP/2 removes the cap entirely.
 
 
 ## Deployment assumptions (goal era)
 ## 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
   `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 both sides.** The daemon (`saddle`) and this app must both
   restart onto the goal-era builds together — the wire contract
   restart onto the goal-era builds together — the wire contract
   (assignment `kind`/`mode`/`creator`/`goal`/`ephemeral_branch`, the
   (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@'],
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'SaddlesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
     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',
 rules_page = custom_target('rules-page',
     input: 'src/pages/rules.html',
     input: 'src/pages/rules.html',
     output: 'RulesPage.vala',
     output: 'RulesPage.vala',
@@ -159,6 +171,7 @@ app_sources = files(
     'src/Format.vala',
     'src/Format.vala',
     'src/Data/Entities.vala',
     'src/Data/Entities.vala',
     'src/Data/Migrations/M0001_Initial.vala',
     'src/Data/Migrations/M0001_Initial.vala',
+    'src/Data/Migrations/M0002_BatchImprovements.vala',
     'src/Services/ProjectService.vala',
     'src/Services/ProjectService.vala',
     'src/Services/TaskService.vala',
     'src/Services/TaskService.vala',
     'src/Services/GoalService.vala',
     'src/Services/GoalService.vala',
@@ -170,6 +183,8 @@ app_sources = files(
     'src/Services/StatePulse.vala',
     'src/Services/StatePulse.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/Dispatcher.vala',
     'src/Services/RuleService.vala',
     'src/Services/RuleService.vala',
+    'src/Services/StackService.vala',
+    'src/Services/NoteService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
     'src/Services/PushService.vala',
@@ -184,6 +199,8 @@ app_sources = files(
     'src/actions/DashboardActions.vala',
     'src/actions/DashboardActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/RuleActions.vala',
+    'src/actions/StackActions.vala',
+    'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/ProfileActions.vala',
     'src/actions/ProfileActions.vala',
@@ -197,6 +214,7 @@ app_sources = files(
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/GoalDetailEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
+    'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
     # spry:sources-end
     # spry:sources-end
@@ -219,6 +237,8 @@ app_generated = [
     goal_detail_page,
     goal_detail_page,
     saddles_page,
     saddles_page,
     rules_page,
     rules_page,
+    stacks_page,
+    stack_detail_page,
     users_page,
     users_page,
     profile_page,
     profile_page,
     # spry:generated-end
     # 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
      * One row of the plan-17 unified event ledger (`events`): daemon
      * events carry their session and its per-session monotonic `seq`
      * 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 status { get; set; default = "idle"; }
         public string? git_name { get; set; default = null; }
         public string? git_name { get; set; default = null; }
         public string? git_email { 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? ssh_public_key { get; set; default = null; }
         public string? token { 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) {
         public static void entity_mapping(EntityMapperBuilder<Saddle> cfg) {
             cfg.table("saddles")
             cfg.table("saddles")
@@ -241,8 +300,10 @@ namespace ClankerSaddle.Data {
                 .column<string>("status", o => o.status, (o, v) => o.status = v)
                 .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_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>("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>("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
      * A named tag used for dispatch routing (`capabilities`): saddles
      * declare the tags they carry, tasks the tags they require.
      * 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,
      * narrative, resume-notice and log-view surface derives from it,
      * and daemon chunk pushes are idempotent on its
      * and daemon chunk pushes are idempotent on its
      * UNIQUE(session_id, seq); rules and their attachments; project
      * 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 {
     public class M0001_Initial : Migration {
 
 
@@ -57,6 +58,40 @@ namespace ClankerSaddle.Data.Migrations {
                     .not_null();
                     .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) => {
             b.create_table("goals", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -110,10 +145,14 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
                     .primary_key();
                     .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")
                 t.column<int64?>("project_id")
                     .not_null()
                     .not_null()
-                    .references("projects", "id")
-                    .indexed();
+                    .default_value<int64?>(0);
                 t.column<int64?>("goal_id")
                 t.column<int64?>("goal_id")
                     .references("goals", "id")
                     .references("goals", "id")
                     .indexed();
                     .indexed();
@@ -192,8 +231,13 @@ namespace ClankerSaddle.Data.Migrations {
                     .default_value<string>("idle");
                     .default_value<string>("idle");
                 t.column<string>("git_name");
                 t.column<string>("git_name");
                 t.column<string>("git_email");
                 t.column<string>("git_email");
+                t.column<string>("prompt_note");
                 t.column<string>("ssh_public_key");
                 t.column<string>("ssh_public_key");
                 t.column<string>("token");
                 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) => {
             b.create_table("sessions", (t) => {
@@ -303,6 +347,27 @@ namespace ClankerSaddle.Data.Migrations {
                 t.column<DateTime?>("created_at");
                 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) => {
             b.create_table("capabilities", (t) => {
                 t.column<int64?>("id")
                 t.column<int64?>("id")
                     .auto_increment()
                     .auto_increment()
@@ -381,6 +446,7 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("task_capabilities");
             b.drop_table("task_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("saddle_capabilities");
             b.drop_table("capabilities");
             b.drop_table("capabilities");
+            b.drop_table("notes");
             b.drop_table("project_notes");
             b.drop_table("project_notes");
             b.drop_table("task_rules");
             b.drop_table("task_rules");
             b.drop_table("rule_projects");
             b.drop_table("rule_projects");
@@ -394,6 +460,8 @@ namespace ClankerSaddle.Data.Migrations {
             b.drop_table("goal_links");
             b.drop_table("goal_links");
             b.drop_table("goals");
             b.drop_table("goals");
             b.drop_table("project_links");
             b.drop_table("project_links");
+            b.drop_table("stack_projects");
+            b.drop_table("stacks");
             b.drop_table("projects");
             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,
         private async Vector<string> co_developed_project_ids(TaskEntity task,
                 ProjectService projects) throws Error {
                 ProjectService projects) throws Error {
             var ids = new Vector<string>();
             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) {
             if (task.goal_id > 0) {
                 foreach (var link in yield goals.list_links(task.goal_id)) {
                 foreach (var link in yield goals.list_links(task.goal_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.project_id.to_string());
                         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)) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                     if (link.relationship == LINK_CO_DEVELOPED) {
                         ids.add(link.linked_project_id.to_string());
                         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()
                 created_at = new DateTime.now_local()
             };
             };
             yield db.insert_async<Goal>(goal);
             yield db.insert_async<Goal>(goal);
+            yield prefill_links_from_project(goal, (!)project);
             yield append_goal_event(goal.id, EVENT_STATE_CHANGE,
             yield append_goal_event(goal.id, EVENT_STATE_CHANGE,
                 @"Goal created (branch $(trimmed_branch))", GOAL_DRAFT);
                 @"Goal created (branch $(trimmed_branch))", GOAL_DRAFT);
             yield pulse.bump(0, "goal created");
             yield pulse.bump(0, "goal created");
             return goal;
             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. */
         /** Loads the goal identified by `id`, or null. */
         public async Goal? get_goal(int64 id) throws Error {
         public async Goal? get_goal(int64 id) throws Error {
             return yield db.query<Goal>()
             return yield db.query<Goal>()
@@ -411,6 +440,64 @@ namespace ClankerSaddle.Services {
             return goal;
             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 —
          * 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,
                 throw new Error(Quark.from_string("clanker-saddle"), 87,
                     "The goal agent has not started yet — comments open with its run");
                     "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) {
             if (woken) {
                 report.append("The user explicitly force-woke this goal's supervision — you were parked with no pending wake.\n\n");
                 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("Goal update — resuming your parked session:\n\n");
             report.append("## Task statuses\n");
             report.append("## Task statuses\n");
             var finished = new Vector<TaskEntity>();
             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));
                 new Bytes(body));
             message.request_headers.append("TTL", "60");
             message.request_headers.append("TTL", "60");
             message.request_headers.append("Urgency", "high");
             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",
             message.request_headers.append("Authorization",
                 @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
                 @"vapid t=$(vapid_jwt(job.endpoint)), k=$(vapid_public_key)");
             try {
             try {

+ 8 - 4
src/Services/RuleService.vala

@@ -257,9 +257,13 @@ namespace ClankerSaddle.Services {
                     ordered.add(rule);
                     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) {
             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)) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     if (link.relationship != LINK_CO_DEVELOPED) {
                     if (link.relationship != LINK_CO_DEVELOPED) {
                         continue;
                         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_ENTRIES = 30;
     public const int64 PROJECT_NOTES_MAX_CHARS = 20000;
     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. */
     /** A block answer awaiting collection by one saddle's next poll. */
     public class PendingAnswer : Object {
     public class PendingAnswer : Object {
         public int64 block_id { get; set; }
         public int64 block_id { get; set; }
@@ -191,8 +198,17 @@ namespace ClankerSaddle.Services {
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                 var response = yield request_saddle_json(saddle.host, "POST", "/register",
                     (owned) payload);
                     (owned) payload);
                 string reported_key = EntryJson.string_member(response, "ssh_public_key");
                 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);
                     yield db.update_async<Saddle>(saddle);
                 }
                 }
                 print(@"[ClankerSaddle] Registered with saddle '$(saddle.name)'s daemon (token delivered)\n");
                 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");
             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
          * Persists the daemon's reported `ssh_public_key` onto the saddle
          * row (the plan-9 contract: the key travels on /status, empty
          * 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)");
                     "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,
                 throw new Error(Quark.from_string("clanker-saddle"), 12,
                     @"Task $(task.id) has no project $(task.project_id)");
                     @"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
             // Plan 12: goal work (the agent's own run and its worker
             // tasks) scopes its co-developed/reference set through the
             // tasks) scopes its co-developed/reference set through the
             // GOAL's links; standalone tasks through the project's.
             // 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;
             Goal? goal = null;
             if (task.goal_id > 0) {
             if (task.goal_id > 0) {
                 goal = yield db.query<Goal>()
                 goal = yield db.query<Goal>()
@@ -808,7 +857,7 @@ namespace ClankerSaddle.Services {
                         references.add((!)linked);
                         references.add((!)linked);
                     }
                     }
                 }
                 }
-            } else {
+            } else if (project != null) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                 foreach (var link in yield projects.list_links(task.project_id)) {
                     var linked = yield db.query<Project>()
                     var linked = yield db.query<Project>()
                         .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
                         .where(expr("id == $0", elem<int64?>(link.linked_project_id)))
@@ -835,25 +884,28 @@ namespace ClankerSaddle.Services {
             payload.set_member_name("description");
             payload.set_member_name("description");
             payload.add_string_value(task.description ?? "");
             payload.add_string_value(task.description ?? "");
             payload.set_member_name("project_name");
             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.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.set_member_name("task_branch");
             payload.add_string_value(task.task_branch ?? "");
             payload.add_string_value(task.task_branch ?? "");
             payload.set_member_name("branch_from");
             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.set_member_name("co_developed");
             payload.begin_array();
             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) {
             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.end_array();
             payload.set_member_name("reference");
             payload.set_member_name("reference");
             payload.begin_array();
             payload.begin_array();
             foreach (var linked in references) {
             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();
             payload.end_array();
 
 
@@ -915,7 +967,7 @@ namespace ClankerSaddle.Services {
                 payload.add_string_value(((!)goal).branch_name);
                 payload.add_string_value(((!)goal).branch_name);
                 payload.set_member_name("base_branch");
                 payload.set_member_name("base_branch");
                 payload.add_string_value(((!)goal).base_branch
                 payload.add_string_value(((!)goal).base_branch
-                    ?? ((!)project).default_branch);
+                    ?? (project != null ? ((!)project).default_branch : ""));
                 payload.set_member_name("brief");
                 payload.set_member_name("brief");
                 payload.add_string_value(((!)goal).brief);
                 payload.add_string_value(((!)goal).brief);
                 payload.set_member_name("plan");
                 payload.set_member_name("plan");
@@ -960,6 +1012,18 @@ namespace ClankerSaddle.Services {
                 payload.end_object();
                 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
             // The wire contract's `capabilities` context (plan 10
             // phase 3): the allocated saddle's own tags plus every tag
             // phase 3): the allocated saddle's own tags plus every tag
             // name known across the fleet (the operator-managed
             // name known across the fleet (the operator-managed
@@ -1016,9 +1080,11 @@ namespace ClankerSaddle.Services {
             // 30 entries / 20 000 chars (head kept).
             // 30 entries / 20 000 chars (head kept).
             var note_project_names = new Dictionary<string, string>();
             var note_project_names = new Dictionary<string, string>();
             var project_notes_rows = new Vector<ClankerSaddle.Data.ProjectNote>();
             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) {
             foreach (var linked in co_developed) {
                 note_project_names.set(linked.id.to_string(), linked.name);
                 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
          * saddle's container — one workspace per task, so concurrent
          * tasks never share a checkout.
          * 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();
             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();
             var builder = new Json.Builder();
             builder.begin_object();
             builder.begin_object();
             builder.set_member_name("name");
             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 kind = TASK_KIND_TASK, string mode = "implement",
                 string creator = CREATOR_HUMAN, int64 goal_id = 0,
                 string creator = CREATOR_HUMAN, int64 goal_id = 0,
                 int64 enqueue_after_task_id = 0) throws Error {
                 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 ?? "";
             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>()
                 var project = yield db.query<Project>()
                     .where(expr("id == $0", elem<int64?>(project_id)))
                     .where(expr("id == $0", elem<int64?>(project_id)))
                     .first_async();
                     .first_async();
@@ -959,6 +965,10 @@ namespace ClankerSaddle.Services {
                     || ((!)task).task_branch.length == 0) {
                     || ((!)task).task_branch.length == 0) {
                 return "";
                 return "";
             }
             }
+            if (((!)task).project_id <= 0) {
+                // Standalone tasks own no repository — no compare URL.
+                return "";
+            }
             var project = yield db.query<Project>()
             var project = yield db.query<Project>()
                 .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
                 .where(expr("id == $0", elem<int64?>(((!)task).project_id)))
                 .first_async();
                 .first_async();
@@ -1642,6 +1652,63 @@ namespace ClankerSaddle.Services {
          * stamp; its keep-parked gate (a task that parked mid-flight)
          * stamp; its keep-parked gate (a task that parked mid-flight)
          * skips the release entirely.
          * 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 {
         public async void release_task(int64 task_id, string outcome, bool requeue) throws Error {
             if (requeue) {
             if (requeue) {
                 var task = yield db.query<TaskEntity>()
                 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 TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = inject<PushService>();
         private PushService push_service = inject<PushService>();
         private UserService users = inject<UserService>();
         private UserService users = inject<UserService>();
@@ -28,7 +29,7 @@ namespace ClankerSaddle {
 
 
             var me = SpryAuth.current(request);
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
             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);
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state);
             return directives().set(state);
         }
         }

+ 3 - 1
src/actions/GoalActions.vala

@@ -52,6 +52,7 @@ namespace ClankerSaddle {
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddleService saddle_service = inject<SaddleService>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected SaddlePollState poll_state = inject<SaddlePollState>();
         protected RuleService rule_service = inject<RuleService>();
         protected RuleService rule_service = inject<RuleService>();
+        protected StackService stack_service = inject<StackService>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         protected UserService users = inject<UserService>();
         protected UserService users = inject<UserService>();
 
 
@@ -62,7 +63,8 @@ namespace ClankerSaddle {
          */
          */
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
         protected async Statum.Slot goal_detail_slot(int64 goal_id) throws GLib.Error {
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
             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);
             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 TaskService task_service = inject<TaskService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private SaddleService saddle_service = inject<SaddleService>();
         private TimelineService timeline_service = inject<TimelineService>();
         private TimelineService timeline_service = inject<TimelineService>();
+        private NoteService note_service = inject<NoteService>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
         private PushService push = inject<PushService>();
         private PushService push = inject<PushService>();
         private UserService users = inject<UserService>();
         private UserService users = inject<UserService>();
@@ -138,7 +139,7 @@ namespace ClankerSaddle {
                     }
                     }
                 }
                 }
                 var state = yield DashboardState.build(state_service, task_service,
                 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);
                     push, me.user_id);
                 return directives().notify(kind, message).set(state);
                 return directives().notify(kind, message).set(state);
             } catch (Error e) {
             } 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> {
     public class RefreshSaddleStatusAction : TypedStatumAction<SaddleActionPrivate> {
 
 
         private SaddleService saddles = inject<SaddleService>();
         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 TaskService tasks = inject<TaskService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private CapabilityService capabilities = inject<CapabilityService>();
         private UserService users = inject<UserService>();
         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 {
         public override async DirectiveBuilder handle() throws GLib.Error {
             var guard = yield SpryAuth.require_login(directives(), request, users);
             var guard = yield SpryAuth.require_login(directives(), request, users);
@@ -95,8 +101,21 @@ namespace ClankerSaddle {
 
 
             int64 project_id = Forms.integer(request, "project_id", 0);
             int64 project_id = Forms.integer(request, "project_id", 0);
             string title = Forms.field(request, "title");
             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
             // Required capabilities: the create form's multi-select posts
@@ -237,13 +256,33 @@ namespace ClankerSaddle {
 
 
             var me = SpryAuth.current(request);
             var me = SpryAuth.current(request);
             try {
             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);
                 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
                 // Terminals post with a return_to so the live view survives
                 // the comment; every other caller lands on the task page.
                 // the comment; every other caller lands on the task page.
                 string return_to = Forms.field(request, "return_to");
                 string return_to = Forms.field(request, "return_to");
                 string destination = return_to.length > 0 && return_to.has_prefix("/")
                 string destination = return_to.length > 0 && return_to.has_prefix("/")
                     ? return_to : @"/tasks/$(task_id)";
                     ? 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) {
             } catch (Error e) {
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                 return directives().notify("error", @"Could not add the comment: $(e.message)")
                     .navigate(@"/tasks/$(task_id)");
                     .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? force_wake { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? add_link { 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). */
         /** The sealed GET refresh action (live pulse rehydration). */
         public Statum.Model.ActionDto? refresh { get; set; }
         public Statum.Model.ActionDto? refresh { get; set; }
     }
     }
@@ -234,12 +241,14 @@ namespace ClankerSaddle {
         public SaddleService? saddles { get; set; default = null; }
         public SaddleService? saddles { get; set; default = null; }
         public SaddlePollState? polls { get; set; default = null; }
         public SaddlePollState? polls { get; set; default = null; }
         public RuleService? rules { get; set; default = null; }
         public RuleService? rules { get; set; default = null; }
+        public StackService? stacks { get; set; default = null; }
 
 
         public GoalViewContext(GoalService goals, TaskService tasks,
         public GoalViewContext(GoalService goals, TaskService tasks,
                 ProjectService projects, EventService events,
                 ProjectService projects, EventService events,
                 InvercargillSql.Orm.OrmSession db,
                 InvercargillSql.Orm.OrmSession db,
                 ActionRegistry? actions = null, SaddleService? saddles = null,
                 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.goals = goals;
             this.tasks = tasks;
             this.tasks = tasks;
             this.projects = projects;
             this.projects = projects;
@@ -249,6 +258,7 @@ namespace ClankerSaddle {
             this.saddles = saddles;
             this.saddles = saddles;
             this.polls = polls;
             this.polls = polls;
             this.rules = rules;
             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
                 // Plan 14 B5: the goal's effective rules — the set a
                 // dispatch ships for the run (and every worker):
                 // dispatch ships for the run (and every worker):
                 // run-task rules, the primary project's, then the
                 // run-task rules, the primary project's, then the
@@ -380,6 +400,8 @@ namespace ClankerSaddle {
                 effective_rules.to_immutable_buffer());
                 effective_rules.to_immutable_buffer());
             pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
             pub_root["project_options"] = StateJson.list<GoalProjectOptionView>(
                 project_options.to_immutable_buffer());
                 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
             pub_root["plan_pending_block"] = plan_pending != null
                 ? new JsonElement.from_properties(
                 ? new JsonElement.from_properties(
                     (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
                     (PropertyDictionary) GObjectMapping.to_properties((!)plan_pending))
@@ -444,6 +466,9 @@ namespace ClankerSaddle {
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, 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) {
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, 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 };
                 var sealed = new GoalActionPrivate() { goal_id = goal.id };
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.comment = ((!)ctx.actions).author_private<GoalCommentAction, GoalActionPrivate>(sealed);
                 pub.add_link = ((!)ctx.actions).author_private<AddGoalLinkAction, 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) {
                 if (goal.status == GOAL_DRAFT) {
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.start = ((!)ctx.actions).author_private<StartGoalAction, GoalActionPrivate>(sealed);
                     pub.update = ((!)ctx.actions).author_private<UpdateDraftGoalAction, 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 SaddleService saddle_service = Inversion.inject<SaddleService>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private SaddlePollState poll_state = Inversion.inject<SaddlePollState>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         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 InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private UserService users = Inversion.inject<UserService>();
         private UserService users = Inversion.inject<UserService>();
 
 
@@ -1042,7 +1071,8 @@ namespace ClankerSaddle {
 
 
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             int64 goal_id = int64.parse(request.route("goal_id") ?? "0");
             var ctx = new GoalViewContext(goal_service, task_service, project_service,
             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);
             var state = yield GoalState.build(goal_id, ctx, state_service);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
             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; }
         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 data of the dashboard's `dashboard` slot. */
     public class DashboardPublic : Object {
     public class DashboardPublic : Object {
         public int64 draft_count { get; set; }
         public int64 draft_count { get; set; }
@@ -106,6 +132,11 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         /** Queues a REAL test push to this user (the bell's probe). */
         /** Queues a REAL test push to this user (the bell's probe). */
         public Statum.Model.ActionDto? push_test { get; set; }
         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,
         public static async Statum.Slot build(StateService state_service, TaskService tasks,
                 SaddleService saddles, TimelineService timeline,
                 SaddleService saddles, TimelineService timeline,
-                InvercargillSql.Orm.OrmSession db,
+                InvercargillSql.Orm.OrmSession db, NoteService notes,
                 ActionRegistry? actions = null, PushService? push = null,
                 ActionRegistry? actions = null, PushService? push = null,
                 int64 viewer_id = 0) throws GLib.Error {
                 int64 viewer_id = 0) throws GLib.Error {
             var pub = new DashboardPublic();
             var pub = new DashboardPublic();
@@ -304,6 +335,7 @@ namespace ClankerSaddle {
                 pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
                 pub.push_subscribe = ((!)actions).author<PushSubscribeAction>();
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
+                pub.add_note = ((!)actions).author<AddNoteAction>();
             }
             }
             // Web Push bell state (plan 11 task E; per-browser since
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
             // 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["active_tasks"] = StateJson.list<DashboardActiveView>(active.to_immutable_buffer());
             props["pinned"] = StateJson.list<DashboardBlockView>(pinned_sorted.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() {
             return state_service.new_slot(Scope.PAGE, new Statum.State() {
                 type_name = "dashboard",
                 type_name = "dashboard",
                 public_data = props,
                 public_data = props,
@@ -383,6 +463,7 @@ namespace ClankerSaddle {
         private TaskService task_service = Inversion.inject<TaskService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private SaddleService saddle_service = Inversion.inject<SaddleService>();
         private TimelineService timeline_service = Inversion.inject<TimelineService>();
         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 InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
         private PushService push_service = Inversion.inject<PushService>();
         private PushService push_service = Inversion.inject<PushService>();
         private UserService users = Inversion.inject<UserService>();
         private UserService users = Inversion.inject<UserService>();
@@ -395,7 +476,7 @@ namespace ClankerSaddle {
 
 
             var me = SpryAuth.current(request);
             var me = SpryAuth.current(request);
             var state = yield DashboardState.build(state_service, task_service,
             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);
                 push_service, me != null ? ((!)me).user_id : 0);
             return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
             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; }
         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). */
     /** A rule option in the attach selects (project and task pages). */
     public class RuleOptionView : Object {
     public class RuleOptionView : Object {
         public int64 id { get; set; }
         public int64 id { get; set; }
@@ -144,6 +151,7 @@ namespace ClankerSaddle {
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? add_link { get; set; }
         public Statum.Model.ActionDto? attach_rule { 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 ProjectService project_service = Inversion.inject<ProjectService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private TaskService task_service = Inversion.inject<TaskService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
         private RuleService rule_service = Inversion.inject<RuleService>();
+        private StackService stack_service = Inversion.inject<StackService>();
         private UserService users = Inversion.inject<UserService>();
         private UserService users = Inversion.inject<UserService>();
 
 
         public override async DirectiveBuilder handle() throws GLib.Error {
         public override async DirectiveBuilder handle() throws GLib.Error {
@@ -183,7 +192,8 @@ namespace ClankerSaddle {
                 created_display = format_stamp(((!)project).created_at),
                 created_display = format_stamp(((!)project).created_at),
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 update = action_registry.author_private<UpdateProjectAction, ProjectActionPrivate>(sealed),
                 add_link = action_registry.author_private<AddProjectLinkAction, 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>();
             var links = new Vector<ProjectLinkView>();
@@ -288,6 +298,15 @@ namespace ClankerSaddle {
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["tasks"] = StateJson.list<ProjectTaskRowView>(task_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rules"] = StateJson.list<ProjectRuleRowView>(rule_rows.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
             props["rule_options"] = StateJson.list<RuleOptionView>(rule_options.to_immutable_buffer());
+            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());
             props["notes"] = StateJson.list<ProjectNoteRowView>(note_rows.to_immutable_buffer());
 
 
             var slot = state_service.new_slot(Scope.PAGE, new State() {
             var slot = state_service.new_slot(Scope.PAGE, new State() {

+ 8 - 0
src/entrypoints/SaddlesEntrypoint.vala

@@ -41,7 +41,12 @@ namespace ClankerSaddle {
         public string git_name { get; set; default = ""; }
         public string git_name { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string git_email { get; set; default = ""; }
         public string ssh_public_key { 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; }
         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 int64 current_task_id { get; set; }
         public string current_task_title { get; set; default = ""; }
         public string current_task_title { get; set; default = ""; }
         /** Set when the current task is a goal agent's run: link the goal instead. */
         /** 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_name = saddle.git_name ?? "",
                     git_email = saddle.git_email ?? "",
                     git_email = saddle.git_email ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
                     ssh_public_key = saddle.ssh_public_key ?? "",
+                    workspace_root = saddle.workspace_root ?? "",
                     update_identity = action_registry.author_private<UpdateSaddleIdentityAction, SaddleActionPrivate>(sealed),
                     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,
                     current_task_id = work != null ? ((!)work).task_id : 0,
                     allocated_display = work != null
                     allocated_display = work != null
                         ? format_stamp(((!)work).started_at) : "",
                         ? 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");
             print(@"[ClankerSaddle] sqlite pragmas failed: $(pragma_error.message)\n");
         }
         }
         db.add_migration<ClankerSaddle.Data.Migrations.M0001_Initial>();
         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<Project>(Project.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<ProjectLink>(ProjectLink.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
         db.add_entity<TaskEntity>(TaskEntity.entity_mapping);
@@ -62,12 +63,17 @@ int main(string[] args) {
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<TaskNotifyPref>(TaskNotifyPref.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<Goal>(Goal.entity_mapping);
         db.add_entity<GoalLink>(GoalLink.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();
         db.migrate_on_startup();
 
 
         application.add_scoped<ProjectService>();
         application.add_scoped<ProjectService>();
         application.add_scoped<TaskService>();
         application.add_scoped<TaskService>();
         application.add_scoped<EventService>();
         application.add_scoped<EventService>();
         application.add_scoped<GoalService>();
         application.add_scoped<GoalService>();
+        application.add_scoped<StackService>();
+        application.add_scoped<NoteService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<RuleService>();
@@ -103,6 +109,8 @@ int main(string[] args) {
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<GoalDetailPage, GoalDetailEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
+        statum.add_page<StacksPage, StacksEntrypoint>();
+        statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
         statum.add_page<ProfilePage, ProfileEntrypoint>();
         statum.add_page<ProfilePage, ProfileEntrypoint>();
         // spry:pages-end
         // spry:pages-end
@@ -149,6 +157,7 @@ int main(string[] args) {
         statum.action<UnregisterSaddleAction>();
         statum.action<UnregisterSaddleAction>();
         statum.action<RefreshSaddleStatusAction>();
         statum.action<RefreshSaddleStatusAction>();
         statum.action<UpdateSaddleIdentityAction>();
         statum.action<UpdateSaddleIdentityAction>();
+        statum.action<UpdateSaddlePromptNoteAction>();
         statum.action<CreateRuleAction>();
         statum.action<CreateRuleAction>();
         statum.action<UpdateRuleAction>();
         statum.action<UpdateRuleAction>();
         statum.action<DeleteRuleAction>();
         statum.action<DeleteRuleAction>();
@@ -157,6 +166,17 @@ int main(string[] args) {
         statum.action<AttachTaskRuleAction>();
         statum.action<AttachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DetachTaskRuleAction>();
         statum.action<DeleteProjectNoteAction>();
         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<CreateCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();

+ 18 - 0
src/pages/goal_detail.html

@@ -422,6 +422,7 @@
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
                           stm-text="link.name + ' (' + link.relationship + ')'">link</span>
                     <button type="button" class="inline" stm-action="link.remove"
                     <button type="button" class="inline" stm-action="link.remove"
                             stm-confirm="Remove this link?">&times;</button>
                             stm-confirm="Remove this link?">&times;</button>
+                    <input type="hidden" name="link_id" stm-attribute.value="link.id">
                 </span></p>
                 </span></p>
                 <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
                 <p stm-if="goal.links.length == 0" class="muted">Only the primary project.</p>
                 <details stm-if="!goal.terminal">
                 <details stm-if="!goal.terminal">
@@ -437,6 +438,15 @@
                         </select>
                         </select>
                         <button type="submit">Link</button>
                         <button type="submit">Link</button>
                     </form>
                     </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>
                 </details>
             </section>
             </section>
 
 
@@ -499,10 +509,18 @@
                 <p stm-if="goal.comments.length == 0" class="muted">No comments yet.</p>
                 <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
                 <p stm-if="goal.run_task_id == 0" class="muted">The goal agent has not started
                     yet — comments open with its run.</p>
                     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">
                 <form stm-if="goal.run_task_id > 0" stm-action="goal.comment">
                     <div class="form-row"><label for="goal-comment-body">Add comment</label>
                     <div class="form-row"><label for="goal-comment-body">Add comment</label>
                         <textarea id="goal-comment-body" name="body" rows="3"
                         <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>
                                   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>
                     <button type="submit" class="primary">Comment</button>
                 </form>
                 </form>
                 <dialog id="quote-comment-dialog">
                 <dialog id="quote-comment-dialog">

+ 52 - 0
src/pages/home.html

@@ -276,6 +276,58 @@
                 </section>
                 </section>
             </div>
             </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
             <h3 stm-if="dashboard.pinned.length > 0">Needs a human
                 (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
                 (<span stm-text="dashboard.pinned.length">0</span> open)</h3>
             <p class="muted" stm-if="dashboard.pinned.length > 0">Open questions, RFCs and
             <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="/goals">Goals</a>
             <a href="/saddles">Saddles</a>
             <a href="/saddles">Saddles</a>
             <a href="/rules">Rules</a>
             <a href="/rules">Rules</a>
+            <a href="/stacks">Stacks</a>
             <!-- The users link is display-guarded (the page's entrypoint
             <!-- The users link is display-guarded (the page's entrypoint
                  guard is authoritative); auth.permissions rides the
                  guard is authoritative); auth.permissions rides the
                  signed auth slot, so an admin sees it everywhere. -->
                  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>
                 <button type="submit">Add link</button>
             </form>
             </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>
             <h3>Rules</h3>
             <p class="muted">Attached rules ride along with every task dispatched for this project.</p>
             <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">
             <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>
                             <span class="muted">Empty fields fall back to the daemon's default identity.</span>
                         </form>
                         </form>
                     </details>
                     </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:
                     <p stm-if="saddles.is_admin && saddle.capabilities.length > 0">Capabilities:
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                         <span stm-for-capability-in="saddle.capabilities" stm-key="capability.link_id">
                             <span class="badge"><span stm-text="capability.name">tag</span>
                             <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>
         <p stm-if="task != null && !task.exists" class="muted">Task not found.</p>
 
 
         <div stm-if="task != null && task.exists">
         <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>
                 <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
                 <span stm-if="task.enqueue_after_task_id > 0"> · auto-enqueues after
                     <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
                     <a stm-attribute.href="'/tasks/' + task.enqueue_after_task_id"
                        stm-text="'#' + task.enqueue_after_task_id">after</a></span></p>
                        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):
             <!-- Progress feed ABOVE the status card (plan 11 task D):
                  the agent's newest line leads the page, its history
                  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>
                 <blockquote stm-if="comment.quote.length > 0" stm-text="comment.quote">quote</blockquote>
             </div>
             </div>
             <p stm-if="task.comments.length == 0" class="muted">No comments yet.</p>
             <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">
                 <input type="hidden" name="task_id" stm-attribute.value="task.id">
                 <div class="form-row"><label for="comment-body">Add comment</label>
                 <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>
                     <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>
                 <button type="submit" class="primary">Comment</button>
             </form>
             </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
             <!-- The highlight-to-comment popover (main.html) fills and opens
                  this dialog; it went missing in a template rework, which broke
                  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>
             <summary><strong>New task</strong></summary>
             <form stm-action="tasks.create">
             <form stm-action="tasks.create">
                 <div class="form-row"><label for="task-project">Project</label>
                 <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"
                         <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
                                  stm-text="project.name">project</option>
                                  stm-text="project.name">project</option>
                     </select></div>
                     </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."""
 order here is the parallel runner's shard order."""
 
 
 from . import (allocation, answers, dispatch, goalui, goals, lineage,
 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 = {
 BUNDLES = {
     "pages": pages,
     "pages": pages,
@@ -19,6 +20,10 @@ BUNDLES = {
     "replay": replay,
     "replay": replay,
     "goalui": goalui,
     "goalui": goalui,
     "users": users,
     "users": users,
+    "stacks": stacks,
+    "origins": origins,
+    "notes": notes,
+    "resurrect": resurrect,
 }
 }
 
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 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,
     check(wait_task_done(auth, identity_task, timeout=60) is not None,
           "identity task completes")
           "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 ==")
     print("== phase 2: blocked push -> block row + pulse ==")
     # The browser flow on each pulse: statum.act(task.refresh) — the
     # The browser flow on each pulse: statum.act(task.refresh) — the
     # sealed GET action must rehydrate the task slot with the new
     # 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)")
           "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,
     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")
           "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"
     check(block_hit["headers"].get("ttl") == "60"
           and block_hit["headers"].get("content-type") == "application/octet-stream",
           and block_hit["headers"].get("content-type") == "application/octet-stream",
           "the push POST carries TTL and the encrypted content type")
           "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
     # Task 2: only the OTHER user opted in — start + failure reach them
     # alone. Baseline again before the enqueue (first start fires at
     # 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,
                    and len(push2.hits("/b")) > b_before, timeout=15,
                    what="test notification POSTs") is not None,
                    what="test notification POSTs") is not None,
           "push_test fires a REAL push to both of the user's endpoints")
           "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)
                                       browser_a_public, browser_a_auth)
     check(test_payload["title"] == "Test notification"
     check(test_payload["title"] == "Test notification"
           and test_payload["url"] == "/",
           and test_payload["url"] == "/",
           "the test payload decrypts with title + url (goes to the dashboard)"
           "the test payload decrypts with title + url (goes to the dashboard)"
           " (%s)" % test_payload)
           " (%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,
     check("2 browser" in message,
           "the action response reports both deliveries (%s)" % message)
           "the action response reports both deliveries (%s)" % message)
     set_types = [d.get("type") for d in result if d.get("type") == "set"]
     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.session_id = None           # the web-side run row of the running task
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.hold_state = False          # freeze lifecycle + heartbeat pushes
         self.ssh_public_key = ""         # reported on /status + /register
         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.started_mono = time.monotonic()
         self.freeze_activity = False     # hold last_activity_epoch still
         self.freeze_activity = False     # hold last_activity_epoch still
         self.silent = False              # suppress pushes (staleness tests)
         self.silent = False              # suppress pushes (staleness tests)
@@ -811,7 +812,8 @@ class FakeSaddle:
                     self.daemon.token = payload["saddle_token"]
                     self.daemon.token = payload["saddle_token"]
                 self._send(200, {"ok": True,
                 self._send(200, {"ok": True,
                                  "ssh_public_key": self.daemon.ssh_public_key,
                                  "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:
                 if self.daemon.ask_on_register:
                     self.daemon.maybe_trigger_ask("register")
                     self.daemon.maybe_trigger_ask("register")
                 return
                 return
@@ -1314,7 +1316,8 @@ def start_bundle(name):
 
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "allocation", "lifecycle", "answers", "push", "goals",
-                 "replay", "goalui", "forcewake", "users")
+                 "replay", "goalui", "forcewake", "users", "stacks", "notes",
+                 "resurrect", "origins")
 
 
 
 
 def run_bundle(name):
 def run_bundle(name):