src/web/dist/assets/Lightbox-LwFuZbPX.js
src/web/dist/assets/main-BcENyYMd.js
src/web/dist/assets/share-ClRLDXuL.js
src/web/dist/assets/share-Dah8Y-qB.js
src/web/dist/assets/share-L_Wb7daq.js
src/web/dist/assets/suggestionScreen-CU2zqOJu.js
src/web/dist/assets/suggestionScreen-Cghk6HBc.js
src/web/dist/assets/suggestionScreen-DNh23x3s.js
src/web/dist/assets/tokens-DzniBB2c.js
src/web/dist/index.html
src/web/dist/phone.html
src/web/dist/share.html

CONFLICT (rename/delete): src/web/dist/assets/Lightbox-BgykohIZ.js renamed to src/web/dist/assets/Lightbox-LwFuZbPX.js in origin/project-settings, but deleted in origin/main.
CONFLICT (modify/delete): src/web/dist/assets/Lightbox-LwFuZbPX.js deleted in origin/main and modified in origin/project-settings.  Version origin/project-settings of src/web/dist/assets/Lightbox-LwFuZbPX.js left in tree.
CONFLICT (rename/delete): src/web/dist/assets/main-DKzBZ8tS.js renamed to src/web/dist/assets/main-BcENyYMd.js in origin/main, but deleted in origin/project-settings.
CONFLICT (modify/delete): src/web/dist/assets/main-BcENyYMd.js deleted in origin/project-settings and modified in origin/main.  Version origin/main of src/web/dist/assets/main-BcENyYMd.js left in tree.
Auto-merging src/web/dist/assets/share-L_Wb7daq.js
CONFLICT (rename/rename): src/web/dist/assets/share-L_Wb7daq.js renamed to src/web/dist/assets/share-Dah8Y-qB.js in origin/main and to src/web/dist/assets/share-ClRLDXuL.js in origin/project-settings.
Auto-merging src/web/dist/assets/suggestionScreen-Cghk6HBc.js
diff --git a/src/engine/record.py b/src/engine/record.py
index ef733f186..ffa341a51 100644
--- a/src/engine/record.py
+++ b/src/engine/record.py
@@ -10,7 +10,7 @@ from typing import Callable
 
 from engine import bus, runtime, waits
 from engine.event_log import EventLog
-from engine.settings_file import SettingsFile
+from engine.settings_file import PROJECT_PARTS, ScopedSettings
 from resources.base import ACTIONS, ACTORS, PROJECT, SYSTEM, Event
 from engine.state import State
 from engine.transaction import held_back
@@ -20,11 +20,13 @@ RESOURCES = "project"
 
 
 class Setting:
-    def __init__(self, default=None):
-        self.default = default
+    def __init__(self, default=None, project: tuple[str, ...] = ()):
+        self.default, self.project = default, project
 
     def __set_name__(self, owner, name: str) -> None:
         self.name = name
+        if self.project:
+            PROJECT_PARTS.keep(name, *self.project)
 
     def __get__(self, obj, owner=None):
         if obj is None:
@@ -48,8 +50,8 @@ class Record:
     agents = Setting(dict)
     skills = Setting(list)
     questions = Setting(dict)
-    delivery = Setting(dict)
-    viewer = Setting(dict)
+    delivery = Setting(dict, project=("channel",))
+    viewer = Setting(dict, project=("color_scheme", "chat_hidden", "away", "tour_seen", "open_with"))
 
     def __init__(self, root: Path, env: str, memo: bool = False):
         self.root = Path(root)
@@ -62,7 +64,7 @@ class Record:
         self.memo = {} if memo else None
         self._made: set[Path] = set()
         self.event_log = EventLog(self.home, self.locked)
-        self.settings_file = SettingsFile(self.home)
+        self.settings_file = ScopedSettings(self.home, self.root)
 
     @classmethod
     def every(cls, root: Path) -> list["Record"]:
@@ -164,7 +166,7 @@ class Record:
             self.memo.pop("settings", None)
 
     def set_setting(self, key: str, value) -> None:
-        with self.locked():
-            self.settings_file.write(key, value)
+        shared = self.settings_file.write(key, value, self.locked)
         self.reread_settings()
-        self.emit("feature", 0, "stamped", SYSTEM, quiet=True, setting=key)
+        for record in [self, *(r for r in Record.every(self.root) if shared and r.env != self.env)]:
+            record.emit("feature", 0, "stamped", SYSTEM, quiet=True, setting=key)
diff --git a/src/engine/settings_file.py b/src/engine/settings_file.py
index 1629e56a8..ee1ac54c9 100644
--- a/src/engine/settings_file.py
+++ b/src/engine/settings_file.py
@@ -1,9 +1,11 @@
 from pathlib import Path
 
 from engine.stored import read_json, write_json
+from resources.base import ENVIRONMENT, PROJECT
 
 SETTINGS: dict[str, tuple] = {}
 SETTINGS_VERSION = [0]
+MERGED: dict[str, tuple] = {}
 
 
 class SettingsFile:
@@ -19,5 +21,71 @@ class SettingsFile:
         SETTINGS_VERSION[0] += 1
         SETTINGS[str(self.file)] = (SETTINGS_VERSION[0], read_json(self.file, dict, {}))
 
-    def write(self, key: str, value) -> None:
-        write_json(self.file, {**read_json(self.file, dict, {}), key: value}, indent=2)
+    def write(self, key: str, value) -> bool:
+        """Writes the key and says whether its value changed."""
+        held = read_json(self.file, dict, {})
+        if key in held and held[key] == value:
+            return False
+        write_json(self.file, {**held, key: value}, indent=2)
+        return True
+
+
+class ProjectParts:
+    """The parts of each settings key kept once for the project, in <root>/settings.json, instead of per environment."""
+
+    def __init__(self):
+        self.kept: dict[str, frozenset[str]] = {}
+        self.version = 0
+
+    def keep(self, key: str, *parts: str) -> None:
+        self.kept[key] = self.of(key) | frozenset(parts)
+        self.version += 1
+
+    def of(self, key: str) -> frozenset[str]:
+        return self.kept.get(key, frozenset())
+
+    def split(self, key: str, value) -> dict[str, object]:
+        parts = self.of(key)
+        if not parts or not isinstance(value, dict):
+            return {ENVIRONMENT: value}
+        return {PROJECT: {k: v for k, v in value.items() if k in parts}, ENVIRONMENT: {k: v for k, v in value.items() if k not in parts}}
+
+    def merged(self, own: dict, project: dict) -> dict:
+        out = dict(own)
+        for key, parts in self.kept.items():
+            if key not in own and key not in project:
+                continue
+            mine, shared = own.get(key), project.get(key)
+            out[key] = {**{k: v for k, v in (mine if isinstance(mine, dict) else {}).items() if k not in parts},
+                        **{k: v for k, v in (shared if isinstance(shared, dict) else {}).items() if k in parts}}
+        return out
+
+
+PROJECT_PARTS = ProjectParts()
+
+
+class ScopedSettings:
+    """An environment's settings with the project's parts laid over them: one source for each part."""
+
+    def __init__(self, home: Path, root: Path):
+        self.files = {ENVIRONMENT: SettingsFile(home), PROJECT: SettingsFile(root)}
+
+    def held(self) -> tuple:
+        own, project = self.files[ENVIRONMENT].held(), self.files[PROJECT].held()
+        version = (own[0], project[0], PROJECT_PARTS.version)
+        key = str(self.files[ENVIRONMENT].file)
+        if MERGED.get(key, (None,))[0] != version:
+            MERGED[key] = (version, PROJECT_PARTS.merged(own[1], project[1]))
+        return MERGED[key]
+
+    def reread(self) -> None:
+        for file in self.files.values():
+            file.reread()
+
+    def write(self, key: str, value, locked) -> bool:
+        """Writes each part of the value to the file of its scope, and says whether the project's part changed."""
+        changed = {}
+        for scope, part in PROJECT_PARTS.split(key, value).items():
+            with locked(scope):
+                changed[scope] = self.files[scope].write(key, part)
+        return changed.get(PROJECT, False)
diff --git a/src/features/base.py b/src/features/base.py
index c430714eb..c0a3717cc 100644
--- a/src/features/base.py
+++ b/src/features/base.py
@@ -15,7 +15,9 @@ from features.groups import Group
 from features.journal import Journal
 from features.settings import Setting, Settings
 from resources.text import paragraphs
-from resources.base import Refused, SYSTEM
+from engine.record import Record
+from engine.settings_file import PROJECT_PARTS
+from resources.base import ENVIRONMENT, PROJECT, Refused, SYSTEM
 
 REGISTRY: dict[str, type] = {}
 
@@ -79,6 +81,7 @@ class FeatureDetails:
     has_skill: ClassVar[bool] = True
     skill_of: ClassVar[str] = ""
     default: ClassVar[bool] = True
+    scope: ClassVar[str] = ENVIRONMENT
 
     @classmethod
     def values(cls, record) -> Settings:
@@ -109,6 +112,7 @@ class Feature(ABC):
     fixed: ClassVar[bool] = False
     nudges: ClassVar[tuple] = ()
     sequences: ClassVar[tuple] = ()
+    scope: ClassVar[str] = ENVIRONMENT
 
     def __init_subclass__(cls, **kw):
         super().__init_subclass__(**kw)
@@ -118,7 +122,8 @@ class Feature(ABC):
             cls.title, cls.abstract, cls.help, cls.explains, cls.when = paragraphs(d.title), paragraphs(d.abstract), paragraphs(d.help), paragraphs(d.explains), d.when
             cls.label, cls.hint, cls.group, cls.trigger_label = paragraphs(d.label), paragraphs(d.hint), d.group, paragraphs(d.trigger_label)
             cls.position = d.position
-            cls.aliases, cls.fixed, cls.default = d.aliases, d.fixed, d.default
+            cls.aliases, cls.fixed, cls.default, cls.scope = d.aliases, d.fixed, d.default, d.scope
+            cls.kept_in_project()
             named = d.name.split("_") + [a for a in d.aliases if isinstance(a, str)]
             cls.speaks_while_waiting = d.speaks_while_waiting
             for line in cls.lines.values():
@@ -129,6 +134,13 @@ class Feature(ABC):
         if cls.name:
             REGISTRY[cls.name] = cls
 
+    @classmethod
+    def kept_in_project(cls) -> None:
+        if cls.scope == PROJECT:
+            PROJECT_PARTS.keep(Record.features, cls.name)
+        if shared := [s.name for s in cls.settings if s.scope == PROJECT]:
+            PROJECT_PARTS.keep(cls.name, *shared)
+
     def register(self, journal: Journal) -> None:
         pass
 
@@ -235,7 +247,7 @@ class Feature(ABC):
 
     def describe(self) -> dict:
         return {"name": self.name, "title": self.title, "abstract": self.abstract, "help": self.help, "explains": self.explains, "default": self.default, "fixed": self.fixed,
-                "label": self.label, "hint": self.hint, "position": self.position, "group": self.group.key, "trigger_label": self.trigger_label,
+                "label": self.label, "hint": self.hint, "position": self.position, "group": self.group.key, "scope": self.scope, "trigger_label": self.trigger_label,
                 "keywords": list(self.keywords), "when": self.when,
                 "listens": sorted(set(self.journal.events.names)), "trigger": self.trigger.described(),
                 "behaviours": {key: b.describe() for key, b in self.behaviours.items()},
diff --git a/src/features/groups.py b/src/features/groups.py
index f6464639f..aeb88832d 100644
--- a/src/features/groups.py
+++ b/src/features/groups.py
@@ -14,6 +14,7 @@ class Section(StrEnum):
     AGENT = "Agent"
     WORK = "Work"
     MEMORY = "Memory"
+    PROJECT = "Project"
     CHAT = "Chat"
     SYSTEM = "System"
     SHARING = "Sharing"
@@ -54,17 +55,18 @@ class Group(Enum):
     PLANS = Grouping("Plans", "How the agent moves through a plan", Section.WORK, lead="plans")
     QUESTIONS = Grouping("Questions", "Choices only you can make are asked as questions", Section.WORK, lead="ask_questions")
     MESSAGES = Grouping("Messages", "How the agent handles the messages you send", Section.WORK, lead="messages")
-    SEQUENCES = Grouping("Sequences", "Steps the agent follows in order", Section.WORK, lead="sequences")
-    BOARDS = Grouping("Boards", "Ticket boards and the agents that fill them", Section.WORK, lead="boards")
-    TICKETS = Grouping("Tickets", "Each ticket runs in an environment of its own", Section.WORK, lead="tickets")
+    VOICE = Grouping("Voice", "How the agent talks to you and what it calls you, in every environment of this project", Section.PROJECT)
+    SEQUENCES = Grouping("Sequences", "Steps the agent follows in order, in every environment of this project", Section.PROJECT, lead="sequences")
+    BOARDS = Grouping("Boards", "Ticket boards and the agents that fill them, in every environment of this project", Section.PROJECT, lead="boards")
+    TICKETS = Grouping("Tickets", "Each ticket runs in an environment of its own", Section.PROJECT, lead="tickets")
+    SHARED_RECORDS = Grouping("Checks, triggers and templates", "Kept once for the project and used by every environment", Section.PROJECT)
     MEMORY = Grouping("Memory", "Facts, rules and reminders repeated to the agent", Section.MEMORY)
     RECORDS = Grouping("Documents", "Documents, collections, checks and other things the agent files", Section.MEMORY)
     ARCHIVE = Grouping("Archive", "How long closed items and runtime files are kept", Section.MEMORY)
     CHAT = Grouping("Chat", "What the chat shows", Section.CHAT)
     VIEWER = Grouping("Viewer", "What the viewer shows, and when it opens", Section.CHAT)
-    UPDATES = Grouping("Updates", "How new versions of the journal are installed", Section.SYSTEM, Tab.SYSTEM)
-    PROJECT = Grouping("Project", "Settings for the whole project, in every environment", Section.SYSTEM, Tab.SYSTEM)
-    BROWSER = Grouping("Browser", "Settings saved in this browser only", Section.SYSTEM, Tab.SYSTEM)
+    UPDATES = Grouping("Updates", "How new versions of the journal are installed in this project", Section.PROJECT, Tab.SYSTEM)
+    PROJECT = Grouping("Project", "Settings for the whole project, in every environment", Section.PROJECT, Tab.SYSTEM)
     STOP = Grouping("Journal", "Stop the journal: closes the viewer and every plugin. Nothing is deleted.", Section.SYSTEM, Tab.SYSTEM)
     SHARING = Grouping(
         "Share links",
diff --git a/src/features/settings.py b/src/features/settings.py
index 0ebbae8f7..d4e593528 100644
--- a/src/features/settings.py
+++ b/src/features/settings.py
@@ -1,12 +1,14 @@
+from resources.base import ENVIRONMENT
 from resources.text import paragraphs
 
 
 class Setting:
     def __init__(self, name: str, default, title: str, abstract: str = "", unit: str = "", under: str = "",
                  choices: tuple[str, ...] = (), labels: tuple[tuple[str, str], ...] = (), examples: tuple[tuple[str, str], ...] = (), hidden: bool = False,
-                 runs_commands: bool = False):
+                 runs_commands: bool = False, scope: str = ENVIRONMENT):
         self.name, self.default, self.title, self.abstract, self.unit, self.under = name, default, paragraphs(title), paragraphs(abstract), unit, under
         self.choices, self.labels, self.examples, self.hidden, self.runs_commands = choices, dict(labels), dict(examples), hidden, runs_commands
+        self.scope = scope
 
     def kind(self) -> str:
         if self.choices:
@@ -19,7 +21,8 @@ class Setting:
 
     def describe(self) -> dict:
         return {"name": self.name, "title": self.title, "abstract": self.abstract, "default": self.default, "unit": self.unit, "kind": self.kind(),
-                "under": self.under, "choices": list(self.choices), "labels": self.labels, "examples": self.examples, "hidden": self.hidden}
+                "under": self.under, "choices": list(self.choices), "labels": self.labels, "examples": self.examples, "hidden": self.hidden,
+                "scope": self.scope}
 
     def allows(self, value) -> bool:
         return not self.choices or value in self.choices
diff --git a/src/features/switches.py b/src/features/switches.py
index fb74dc805..1be46b126 100644
--- a/src/features/switches.py
+++ b/src/features/switches.py
@@ -2,6 +2,8 @@ import os
 
 from controllers.stored import CHANGES
 from engine import bus
+from engine.record import Record
+from engine.settings_file import PROJECT_PARTS
 from controllers.types import Environments, Features
 from resources.base import SYSTEM
 
@@ -29,7 +31,8 @@ def written(record) -> int:
 
 def booted(record) -> dict[str, bool]:
     rows = Features(record, actor=SYSTEM)
-    SWITCHES[str(record.home)] = (written(record) if UNEVENTED[0] else 0, {row.title: bool(row.enabled) for row in rows.rows.viewed()})
+    kept = PROJECT_PARTS.of(Record.features)
+    SWITCHES[str(record.home)] = (written(record) if UNEVENTED[0] else 0, {row.title: bool(row.enabled) for row in rows.rows.viewed() if row.title not in kept})
     return SWITCHES[str(record.home)][1]
 
 
diff --git a/src/migrations/m0067_settings_kept_once_per_project.py b/src/migrations/m0067_settings_kept_once_per_project.py
new file mode 100644
index 000000000..6ff16a3a0
--- /dev/null
+++ b/src/migrations/m0067_settings_kept_once_per_project.py
@@ -0,0 +1,47 @@
+import shutil
+from pathlib import Path
+
+from controllers.types import Features
+from engine import attic, runtime
+from engine.paths import environments
+from engine.record import Record
+from engine.settings_file import PROJECT_PARTS
+from engine.stored import read_json, write_json
+from resources.base import ENVIRONMENT, SYSTEM
+
+ATTIC = "settings-before-project"
+
+
+def run(root: Path) -> str:
+    root = Path(root)
+    homes = sorted((home for home in environments(root).glob("*") if home.is_dir()), key=lambda home: (home.name != runtime.env(root), home.name))
+    if not homes:
+        return "no environment held settings"
+    project = read_json(root / "settings.json", dict, {})
+    folded = {key: dict(value) for key in PROJECT_PARTS.kept if isinstance(value := project.get(key), dict)}
+    for home in homes:
+        own = with_switches(root, home)
+        for key, parts in PROJECT_PARTS.kept.items():
+            values = own.get(key) if isinstance(own.get(key), dict) else {}
+            folded[key] = {**{part: values[part] for part in parts if part in values}, **folded.get(key, {})}
+        kept(root, home)
+    write_json(root / "settings.json", {**project, **{key: value for key, value in folded.items() if value}}, indent=2)
+    return f"the settings of {len(homes)} environments are kept once for the project, the {homes[0].name} environment's first"
+
+
+def with_switches(root: Path, home: Path) -> dict:
+    own = read_json(home / "settings.json", dict, {})
+    rows = {row.title: bool(row.enabled) for row in Features(Record(root, home.name), actor=SYSTEM).rows.every()}
+    switches = own.get(Record.features) if isinstance(own.get(Record.features), dict) else {}
+    return {**own, Record.features: {**switches, **rows}}
+
+
+def kept(root: Path, home: Path) -> None:
+    file = home / "settings.json"
+    if not file.is_file():
+        return
+    copy = attic.folder(root) / ATTIC / f"{home.name}.json"
+    copy.parent.mkdir(parents=True, exist_ok=True)
+    shutil.copy2(file, copy)
+    own = read_json(file, dict, {})
+    write_json(file, {key: PROJECT_PARTS.split(key, value)[ENVIRONMENT] for key, value in own.items()}, indent=2)
diff --git a/src/migrations/m0068_environments_say_their_kind.py b/src/migrations/m0068_environments_say_their_kind.py
new file mode 100644
index 000000000..6b2724b87
--- /dev/null
+++ b/src/migrations/m0068_environments_say_their_kind.py
@@ -0,0 +1,31 @@
+import re
+from pathlib import Path
+
+from controllers.types import Environments, environment_records
+from features.helpers.controller import Helpers
+from resources.base import SYSTEM
+from resources.types import EnvironmentKind
+
+OWNERS = {"helper": EnvironmentKind.HELPER, "todo": EnvironmentKind.SUBAGENT}
+
+
+def run(root: Path) -> list[str]:
+    records = list(environment_records(Path(root)))
+    if not records:
+        return []
+    checkouts = {Path(helper.checkout).name for record in records for helper in Helpers(record, actor=SYSTEM).rows.every() if helper.checkout}
+    environments = Environments(records[0], actor=SYSTEM)
+    tagged = []
+    for place in environments.rows.every():
+        if place.kind:
+            continue
+        kind = kind_of(place.owner, place.title, checkouts)
+        environments.update(place.n, kind=kind)
+        tagged.append(f"{place.title} is a {kind} environment")
+    return tagged
+
+
+def kind_of(owner: str, title: str, checkouts: set[str]) -> EnvironmentKind:
+    if owner:
+        return OWNERS.get(owner.split(":")[0], EnvironmentKind.TICKET)
+    return EnvironmentKind.HELPER if re.sub(r"-\d+$", "", title) in checkouts else EnvironmentKind.MAIN
diff --git a/src/resources/types.py b/src/resources/types.py
index 3f5e26e92..4ea3b4b5b 100644
--- a/src/resources/types.py
+++ b/src/resources/types.py
@@ -1,5 +1,6 @@
 import time
