Browse Source

Goal link pre-fill from the primary project; stacks feature

Two features.

Goal link pre-fill: creating a goal now copies the primary project's
project_links rows into the new goal's goal_links (same linked project,
same relationship). This is a one-time snapshot at creation, not a
runtime union: goal_links stays the single user-editable source of
truth, links added to the project later never propagate, and the goal
page's existing add/remove link UI prunes what the snapshot carried.
The goal-create form carries no link fields so the pre-fill is
unconditional. build_assignment_payload is unchanged - it already
scopes goal work through goal_links, so a goal on a linked project no
longer silently loses the project's reference/co-developed set.

Stacks: a named collection of reference projects applied one-click to a
project's or a goal's link set. New stacks and stack_projects tables in
the flattened initial migration (unique stack+project pair,
relationship defaulting to reference), Stack and StackProject entities,
and a StackService (create/rename/delete, member add/remove with
relationship validation, apply_to_project and apply_to_goal both
skipping exact duplicates so re-applying is idempotent; deleting a
stack never unlinks what it applied). UI: /stacks list page with
create, /stacks/{id} detail with edit/member management/delete, an
Apply stack select on the project detail page and inside the goal
page's Link a project expander (non-terminal goals only), and a Stacks
nav entry.

Also fixed a latent bug the pre-fill surfaced: the goal page's link
remove button never carried the link_id its action reads from the form
(since the goals feature landed), so pressing it in the browser removed
nothing. The row now renders the hidden link_id input the action
already expected.

Tests: new stacks bundle (39 assertions) covering the snapshot
semantics (pre-fill, late-link non-propagation, primary never
duplicated), the pre-filled links riding the goal-run assignment
payload and a removed link disappearing from it, stack CRUD, member
validation, apply-to-project and apply-to-goal with duplicate skips,
member removal and stack deletion leaving applied links intact. Full
sequential suite: 1003 passed, 0 failed (baseline 966).
saddle-Clanker-Saddle 1 month ago
parent
commit
487db9c1da

+ 18 - 0
README.md

@@ -206,6 +206,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 +352,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

+ 17 - 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',
@@ -170,6 +182,7 @@ 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/CapabilityService.vala',
     'src/Services/CapabilityService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/TimelineService.vala',
     'src/Services/PushService.vala',
     'src/Services/PushService.vala',
@@ -184,6 +197,7 @@ 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/CapabilityActions.vala',
     'src/actions/CapabilityActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/UserActions.vala',
     'src/actions/ProfileActions.vala',
     'src/actions/ProfileActions.vala',
@@ -197,6 +211,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 +234,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

+ 45 - 0
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`

+ 36 - 0
src/Data/Migrations/M0001_Initial.vala

@@ -57,6 +57,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()
@@ -394,6 +428,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");
         }
         }
     }
     }

+ 29 - 0
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>()

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

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

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

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

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

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

+ 12 - 0
src/main.vala

@@ -62,12 +62,15 @@ 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.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<SaddleService>();
         application.add_scoped<SaddleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<LifecycleService>();
         application.add_scoped<RuleService>();
         application.add_scoped<RuleService>();
@@ -103,6 +106,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
@@ -157,6 +162,13 @@ 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<CreateCapabilityAction>();
         statum.action<CreateCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<DeleteCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();
         statum.action<AttachSaddleCapabilityAction>();

+ 10 - 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>
 
 

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

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

+ 2 - 1
test/bundles/__init__.py

@@ -4,7 +4,7 @@ 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, pages, push, replay, stacks, subtasks, users)
 
 
 BUNDLES = {
 BUNDLES = {
     "pages": pages,
     "pages": pages,
@@ -19,6 +19,7 @@ BUNDLES = {
     "replay": replay,
     "replay": replay,
     "goalui": goalui,
     "goalui": goalui,
     "users": users,
     "users": users,
+    "stacks": stacks,
 }
 }
 
 
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
 BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",

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

+ 1 - 1
test/harness.py

@@ -1314,7 +1314,7 @@ 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")
 
 
 
 
 def run_bundle(name):
 def run_bundle(name):