@sellable/install 0.1.315 → 0.1.317

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ # Hermes Slack Profile Scoping
2
+
3
+ Use this when creating or updating customer Hermes profiles that need Slack
4
+ access.
5
+
6
+ ## Default Rule
7
+
8
+ For Sellable-managed customer channels, use one dedicated Slack app/token pair
9
+ per active Hermes customer profile. Run native `hermes -p <profile> gateway run`
10
+ for that profile only. Do not run multiple active Hermes profile gateways
11
+ against the same `SLACK_APP_TOKEN`.
12
+
13
+ The customer's Slack app is bound to the exact Slack channel id and the
14
+ customer's profile-local Sellable MCP config. Customer profiles must not store
15
+ or read Sellable Admin MCP credentials, admin Slack app tokens, or sibling
16
+ profile config.
17
+
18
+ The shared dispatcher remains a fallback/research path only. Use it only when
19
+ Sellable Admin explicitly owns one shared listener and route registry.
20
+
21
+ The 2026-07-06 Hostinger POC proved the native Hermes listener for the
22
+ `sellable-admin` profile with the existing `Sellable Reply Bot` app in
23
+ `#team-chat`: a human mention produced a new Hermes session and a threaded bot
24
+ reply. Treat that as proof that the gateway/listener path works for one owning
25
+ profile, not as approval for multiple active customer profiles sharing one app
26
+ token.
27
+
28
+ The same day, the `acme` profile reused the same redacted Slack token
29
+ fingerprints for an outbound-only smoke to `#sellable-acme` via:
30
+
31
+ ```bash
32
+ hermes --profile acme send --to slack:C0BFERDV3N0 --subject '[Hermes acme smoke]' ...
33
+ ```
34
+
35
+ That succeeded and is useful as an internal smoke test. It does not change the
36
+ inbound listener rule: each active customer profile needs its own native Slack
37
+ app token pair unless Sellable Admin deliberately chooses the dispatcher
38
+ fallback.
39
+
40
+ ## Which Slack Values Are Enough
41
+
42
+ For outbound sends and ordinary Slack Web API calls, the important value is the
43
+ profile-local `SLACK_BOT_TOKEN`.
44
+
45
+ For native Hermes live listening through Slack Socket Mode, the profile also
46
+ needs `SLACK_APP_TOKEN`, an app-level `xapp-...` token with `connections:write`.
47
+ `SLACK_APP_ID`, `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`,
48
+ `SLACK_SIGNING_SECRET`, `SLACK_VERIFICATION_TOKEN`, `SLACK_TEAM_ID`, and
49
+ `SLACK_OPERATOR_EMAIL` do not replace `SLACK_APP_TOKEN`.
50
+
51
+ ## Required Slack App Shape
52
+
53
+ Outbound Slack sends can work with fewer permissions. Native Hermes inbound
54
+ listening needs the Slack app itself to be configured for Socket Mode and event
55
+ delivery:
56
+
57
+ - Enable Socket Mode.
58
+ - Create an app-level token with `connections:write`.
59
+ - Install the bot with `app_mentions:read`, `channels:history`,
60
+ `channels:read`, `chat:write`, `groups:history`, `groups:read`,
61
+ `im:history`, `im:read`, `mpim:history`, `mpim:read`, and `users:read`.
62
+ - Subscribe bot events for `app_mention`, `message.channels`,
63
+ `message.groups`, `message.im`, and `message.mpim`.
64
+ - Reinstall the app after changing scopes or event subscriptions.
65
+
66
+ If the Slack app has `incoming-webhook`, Slack will ask for a webhook channel
67
+ during reinstall. Pick the profile home channel. That webhook selection does
68
+ not replace `SLACK_ALLOWED_CHANNELS`; keep the profile-local allowlist explicit.
69
+
70
+ ## Profile Bootstrap Command
71
+
72
+ Use the Sellable installer profile bootstrap:
73
+
74
+ ```bash
75
+ sellable hermes profile bootstrap \
76
+ --profile acme \
77
+ --profiles-root /srv/hermes/profiles \
78
+ --workspace-id ws_acme \
79
+ --workspace-name Acme \
80
+ --token-file /run/secrets/sellable-acme-token \
81
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
82
+ --slack-app-token "$SLACK_APP_TOKEN" \
83
+ --slack-home-channel C0ACME12345 \
84
+ --slack-home-channel-name sellable-acme \
85
+ --slack-allowed-users U0OPERATOR1 \
86
+ --slack-require-mention true \
87
+ --json
88
+ ```
89
+
90
+ If `--slack-home-channel` is supplied and `--slack-allowed-channels` is omitted,
91
+ the installer writes `SLACK_ALLOWED_CHANNELS=<home-channel-id>` automatically.
92
+ That makes the profile fail closed to the customer channel by default.
93
+
94
+ Use `--slack-allowed-channels C...,G...` only when a profile is intentionally
95
+ allowed to listen in more than one channel. Pass Slack IDs, not `#channel-name`
96
+ strings or wildcards.
97
+
98
+ ## Resulting Profile Env
99
+
100
+ For the dedicated customer Slack app model, the profile-local `.env` should
101
+ contain only scoped Slack keys for that profile:
102
+
103
+ ```env
104
+ SLACK_BOT_TOKEN=...
105
+ SLACK_APP_TOKEN=...
106
+ SLACK_HOME_CHANNEL=C0ACME12345
107
+ SLACK_HOME_CHANNEL_NAME=sellable-acme
108
+ SLACK_ALLOWED_CHANNELS=C0ACME12345
109
+ SLACK_ALLOWED_USERS=U0OPERATOR1
110
+ SLACK_REQUIRE_MENTION=true
111
+ ```
112
+
113
+ Keep the file mode at `0600`. Do not copy raw tokens into planning docs, chat,
114
+ or committed artifacts. Record token fingerprints only.
115
+
116
+ The profile's Hermes MCP env must also include the profile-local Sellable
117
+ config, customer workspace lock, and fail-closed guard:
118
+
119
+ ```yaml
120
+ SELLABLE_CONFIG_PATH: /srv/hermes/profiles/acme/sellable/config.json
121
+ SELLABLE_CONFIGS_DIR: /srv/hermes/profiles/acme/sellable/configs
122
+ SELLABLE_LOCK_WORKSPACE_ID: ws_acme
123
+ SELLABLE_REQUIRE_WORKSPACE_LOCK: "1"
124
+ ```
125
+
126
+ `SELLABLE_REQUIRE_WORKSPACE_LOCK=1` means an unbound customer profile fails
127
+ closed before making Sellable API calls instead of falling back to shared or
128
+ admin config state. Only the `sellable-admin` profile should provision,
129
+ inspect, or repair other profiles.
130
+
131
+ For the shared dispatcher model, do not copy the shared `SLACK_BOT_TOKEN` or
132
+ `SLACK_APP_TOKEN` into customer profile `.env` files. The dispatcher owns those
133
+ tokens and route metadata decides the customer profile.
134
+
135
+ ## Shared Dispatcher Ownership
136
+
137
+ The shared Slack dispatcher and its route registry are fallback Sellable Admin
138
+ surfaces. This customer-facing installer must not create shared route bundles or
139
+ write shared listener tokens. Sellable Admin owns Slack app creation and profile
140
+ provisioning, then calls this installer for profile-local Sellable runtime
141
+ bootstrap inside the already-provisioned Hermes profile.
142
+
143
+ ## Verification
144
+
145
+ Run the installer and dispatcher UAT before publishing installer changes:
146
+
147
+ ```bash
148
+ npm run test:unit -- tests/install-package/agent-preferences.test.ts
149
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode local-temp
150
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode linux-shaped
151
+ scripts/run-phase031-hermes-slack-dispatcher-uat.sh --mode local-fixture
152
+ ```
153
+
154
+ Expected proof:
155
+
156
+ - profile-local Sellable config paths remain authoritative
157
+ - generated profile `.env` has `SLACK_HOME_CHANNEL`
158
+ - generated profile `.env` has `SLACK_ALLOWED_CHANNELS`
159
+ - `acme` allowlist points at the `sellable-acme` fixture channel id
160
+ - redaction scan passes
161
+
162
+ ## Current Phase 03 Status
163
+
164
+ Phase 03 proved the profile-scoped installer path, outbound Hermes send path,
165
+ and live Slack listener for the `sellable-admin` profile. Phase 03.1 now targets
166
+ dedicated native Slack apps per customer profile, profile-local Sellable MCP
167
+ locks, and fail-closed customer profile behavior so managed customer channels
168
+ can be onboarded through the VPS without local-only browser state, manual SSH
169
+ edits, or untracked processes.
package/README.md CHANGED
@@ -122,6 +122,17 @@ It creates:
122
122
  /srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
123
123
  ```
124
124
 
125
+ Bootstrap also writes `SELLABLE_LOCK_WORKSPACE_ID=<workspace-id>` and
126
+ `SELLABLE_REQUIRE_WORKSPACE_LOCK=1` into the profile's Hermes MCP env.
127
+ Customer profiles are therefore locked to their own Sellable workspace:
128
+ Sellable MCP requests are forced to that workspace, workspace switching/creation
129
+ is blocked, workspace listings are filtered, and a missing lock fails closed
130
+ instead of falling back to shared/admin config. Use the `sellable-admin` profile
131
+ for provisioning or cross-profile inspection.
132
+
133
+ See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
134
+ the customer Slack isolation contract and Socket Mode token requirements.
135
+
125
136
  If no token is supplied, bootstrap still creates a pending
126
137
  `sellable/config.json` so the profile path is concrete from the first MCP
127
138
  launch. Finish auth with:
@@ -142,6 +153,36 @@ sellable hermes profile bootstrap \
142
153
  --slack-app-token "$SLACK_APP_TOKEN"
143
154
  ```
144
155
 
156
+ The Slack app must also be configured for inbound Socket Mode delivery. Outbound
157
+ Slack Web API sends can work before this is complete, but Hermes will not hear
158
+ mentions until the app has Socket Mode enabled, an app-level token with
159
+ `connections:write`, bot scopes/events for `app_mention` plus channel/group/IM
160
+ message events, and a fresh workspace reinstall after scope or event changes.
161
+
162
+ Scope the profile to its customer channel at install time:
163
+
164
+ ```bash
165
+ sellable hermes profile bootstrap \
166
+ --profile acme \
167
+ --profiles-root /srv/hermes/profiles \
168
+ --workspace-id ws_acme \
169
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
170
+ --slack-app-token "$SLACK_APP_TOKEN" \
171
+ --slack-home-channel C0ACME12345 \
172
+ --slack-home-channel-name sellable-acme \
173
+ --slack-allowed-users U0OPERATOR1 \
174
+ --slack-require-mention true
175
+ ```
176
+
177
+ When `--slack-home-channel` is supplied without
178
+ `--slack-allowed-channels`, bootstrap writes
179
+ `SLACK_ALLOWED_CHANNELS=<home-channel-id>` too. That fail-closes a customer
180
+ profile to the intended channel by default. Use
181
+ `--slack-allowed-channels C...,G...` only when a profile is deliberately allowed
182
+ to listen in multiple channels. Pass channel IDs, not `#channel-name`, and use
183
+ separate Slack bot/app tokens per customer profile when you need hard isolation
184
+ between concurrently listening Hermes profiles.
185
+
145
186
  Customer profiles should use customer-scoped Sellable credentials. A shared
146
187
  Sellable admin token can work for internal admin automation, but it is weaker
147
188
  isolation and should not be the default for customer profiles. After changing
@@ -179,6 +179,8 @@ Options:
179
179
  --server <mode> package, local, or hosted. Default: package
180
180
  --token <token> Sellable API token. Also reads SELLABLE_TOKEN.
181
181
  --workspace-id <id> Sellable workspace id. Also reads SELLABLE_WORKSPACE_ID.
182
+ --require-workspace-lock For Hermes customer profiles, fail closed unless
183
+ SELLABLE_LOCK_WORKSPACE_ID is present in MCP env.
182
184
  --api-url <url> Sellable API URL. Default: ${DEFAULT_API_URL}
183
185
  --sellable-config-path <path>
184
186
  Profile-scoped Sellable config path. Also reads SELLABLE_CONFIG_PATH.
@@ -217,9 +219,20 @@ Hermes profile bootstrap:
217
219
 
218
220
  Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
219
221
  <profileRoot>/sellable/configs, Sellable Hermes skills, and optional
220
- <profileRoot>/.env Slack token keys. Use customer-scoped Sellable credentials
221
- for customer profiles; a shared admin token is weaker isolation and should
222
- remain internal-only.
222
+ <profileRoot>/.env Slack token and channel-scope keys. For customer profiles,
223
+ pass --slack-home-channel; bootstrap defaults SLACK_ALLOWED_CHANNELS to that
224
+ exact channel id unless --slack-allowed-channels is supplied. Use
225
+ customer-scoped Sellable credentials and customer-scoped Slack credentials for
226
+ customer profiles; shared admin tokens are weaker isolation and should remain
227
+ internal-only. Bootstrap writes SELLABLE_REQUIRE_WORKSPACE_LOCK=1 whenever a
228
+ workspace id is supplied, so a customer profile fails closed if the workspace
229
+ lock is later removed or not yet provisioned.
230
+
231
+ Hermes shared Slack dispatcher:
232
+ Shared listener setup and route-registry validation are internal Sellable
233
+ Admin responsibilities. This customer-facing installer only bootstraps a
234
+ profile-local Sellable runtime after Sellable Admin provisions the profile
235
+ and dispatcher route.
223
236
  `;
224
237
  }
225
238
 
@@ -3505,6 +3518,12 @@ function mcpEnvForHost(host, opts) {
3505
3518
  if (opts.sellableConfigsDir) {
3506
3519
  env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
3507
3520
  }
3521
+ if (opts.lockWorkspaceId) {
3522
+ env.SELLABLE_LOCK_WORKSPACE_ID = opts.lockWorkspaceId;
3523
+ }
3524
+ if (opts.requireWorkspaceLock) {
3525
+ env.SELLABLE_REQUIRE_WORKSPACE_LOCK = "1";
3526
+ }
3508
3527
  return env;
3509
3528
  }
3510
3529
 
@@ -3789,6 +3808,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3789
3808
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3790
3809
  slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3791
3810
  slackAppToken: process.env.SLACK_APP_TOKEN || "",
3811
+ slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
3812
+ slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
3813
+ slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
3814
+ slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
3815
+ slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
3816
+ slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
3792
3817
  server: process.env.SELLABLE_INSTALL_SERVER || "package",
3793
3818
  mcpPackage: DEFAULT_SERVER_PACKAGE,
3794
3819
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
@@ -3797,6 +3822,7 @@ function parseHermesProfileBootstrapArgs(argv) {
3797
3822
  force: false,
3798
3823
  overwriteEnv: false,
3799
3824
  allowDuplicateSlackTokens: false,
3825
+ requireWorkspaceLock: false,
3800
3826
  verbose: false,
3801
3827
  };
3802
3828
 
@@ -3821,6 +3847,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3821
3847
  else if (arg === "--api-url") opts.apiUrl = next();
3822
3848
  else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3823
3849
  else if (arg === "--slack-app-token") opts.slackAppToken = next();
3850
+ else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
3851
+ else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
3852
+ else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
3853
+ else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
3854
+ else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
3855
+ else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
3824
3856
  else if (arg === "--server") opts.server = next();
3825
3857
  else if (arg === "--mcp-package") {
3826
3858
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
@@ -3829,7 +3861,9 @@ function parseHermesProfileBootstrapArgs(argv) {
3829
3861
  else if (arg === "--json") opts.json = true;
3830
3862
  else if (arg === "--force") opts.force = true;
3831
3863
  else if (arg === "--overwrite-env") opts.overwriteEnv = true;
3832
- else if (arg === "--allow-duplicate-slack-tokens") {
3864
+ else if (arg === "--require-workspace-lock") {
3865
+ opts.requireWorkspaceLock = true;
3866
+ } else if (arg === "--allow-duplicate-slack-tokens") {
3833
3867
  opts.allowDuplicateSlackTokens = true;
3834
3868
  } else if (arg === "--verbose") opts.verbose = true;
3835
3869
  else {
@@ -3922,6 +3956,111 @@ function renderEnvFile({ entries, passthrough }) {
3922
3956
  return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3923
3957
  }
3924
3958
 
3959
+ function normalizeSlackChannelIds(value, key) {
3960
+ const raw = String(value || "").trim();
3961
+ if (!raw) return "";
3962
+ const ids = raw
3963
+ .split(",")
3964
+ .map((item) => item.trim())
3965
+ .filter(Boolean);
3966
+ if (ids.length === 0) return "";
3967
+
3968
+ for (const id of ids) {
3969
+ if (
3970
+ id === "*" ||
3971
+ /^all$/i.test(id) ||
3972
+ id.startsWith("#") ||
3973
+ /\s/.test(id) ||
3974
+ !/^[A-Z][A-Z0-9]{8,}$/.test(id)
3975
+ ) {
3976
+ throw new Error(
3977
+ `${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
3978
+ );
3979
+ }
3980
+ }
3981
+ return [...new Set(ids)].join(",");
3982
+ }
3983
+
3984
+ function normalizeSlackSingleChannelId(value, key) {
3985
+ const normalized = normalizeSlackChannelIds(value, key);
3986
+ if (normalized.includes(",")) {
3987
+ throw new Error(`${key} must be a single Slack channel ID`);
3988
+ }
3989
+ return normalized;
3990
+ }
3991
+
3992
+ function normalizeSlackUserIds(value, key) {
3993
+ const raw = String(value || "").trim();
3994
+ if (!raw) return "";
3995
+ const ids = raw
3996
+ .split(",")
3997
+ .map((item) => item.trim())
3998
+ .filter(Boolean);
3999
+ if (ids.length === 0) return "";
4000
+
4001
+ for (const id of ids) {
4002
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
4003
+ throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
4004
+ }
4005
+ }
4006
+ return [...new Set(ids)].join(",");
4007
+ }
4008
+
4009
+ function normalizeSlackChannelName(value, key) {
4010
+ const raw = String(value || "").trim();
4011
+ if (!raw) return "";
4012
+ if (raw.startsWith("#") || /[\s,]/.test(raw)) {
4013
+ throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
4014
+ }
4015
+ return raw;
4016
+ }
4017
+
4018
+ function normalizeSlackBoolean(value, key) {
4019
+ const raw = String(value || "").trim().toLowerCase();
4020
+ if (!raw) return "";
4021
+ if (raw !== "true" && raw !== "false") {
4022
+ throw new Error(`${key} must be true or false`);
4023
+ }
4024
+ return raw;
4025
+ }
4026
+
4027
+ function buildSlackProfileEnv(parsed) {
4028
+ const homeChannel = normalizeSlackSingleChannelId(
4029
+ parsed.slackHomeChannel,
4030
+ "SLACK_HOME_CHANNEL"
4031
+ );
4032
+ const explicitAllowedChannels = normalizeSlackChannelIds(
4033
+ parsed.slackAllowedChannels,
4034
+ "SLACK_ALLOWED_CHANNELS"
4035
+ );
4036
+ const allowedChannels = explicitAllowedChannels || homeChannel;
4037
+
4038
+ return {
4039
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4040
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4041
+ SLACK_HOME_CHANNEL: homeChannel,
4042
+ SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
4043
+ parsed.slackHomeChannelName,
4044
+ "SLACK_HOME_CHANNEL_NAME"
4045
+ ),
4046
+ // Customer Hermes profiles must fail closed to their channel. If the caller
4047
+ // supplies only a home channel, we treat that as the allowlist too.
4048
+ SLACK_ALLOWED_CHANNELS: allowedChannels,
4049
+ SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
4050
+ parsed.slackFreeResponseChannels,
4051
+ "SLACK_FREE_RESPONSE_CHANNELS"
4052
+ ),
4053
+ SLACK_ALLOWED_USERS: normalizeSlackUserIds(
4054
+ parsed.slackAllowedUsers,
4055
+ "SLACK_ALLOWED_USERS"
4056
+ ),
4057
+ SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
4058
+ parsed.slackRequireMention,
4059
+ "SLACK_REQUIRE_MENTION"
4060
+ ),
4061
+ };
4062
+ }
4063
+
3925
4064
  function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3926
4065
  const duplicates = [];
3927
4066
  const profilesRoot = dirname(profileRoot);
@@ -3974,10 +4113,16 @@ function runHermesProfileBootstrap(argv) {
3974
4113
  const sellableConfigsDir = join(profileRoot, "sellable", "configs");
3975
4114
  const envPath = join(profileRoot, ".env");
3976
4115
  const { token, source: tokenSource } = readBootstrapToken(parsed);
4116
+ const slackEnv = buildSlackProfileEnv(parsed);
3977
4117
  const slackTokens = {
3978
- SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
3979
- SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4118
+ SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
4119
+ SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
3980
4120
  };
4121
+ const slackEnvKeys = Object.entries(slackEnv)
4122
+ .filter(([, value]) => value)
4123
+ .map(([key]) => key);
4124
+ const requireWorkspaceLock =
4125
+ parsed.requireWorkspaceLock || Boolean(parsed.workspaceId);
3981
4126
  const opts = {
3982
4127
  ...parsed,
3983
4128
  host: "hermes",
@@ -3987,6 +4132,8 @@ function runHermesProfileBootstrap(argv) {
3987
4132
  token,
3988
4133
  tokenSource,
3989
4134
  workspaceId: parsed.workspaceId,
4135
+ lockWorkspaceId: parsed.workspaceId,
4136
+ requireWorkspaceLock,
3990
4137
  apiUrl: parsed.apiUrl,
3991
4138
  };
3992
4139
 
@@ -4011,6 +4158,17 @@ function runHermesProfileBootstrap(argv) {
4011
4158
  apiUrl: opts.apiUrl,
4012
4159
  activeWorkspaceId: parsed.workspaceId || null,
4013
4160
  activeWorkspaceName: parsed.workspaceName || null,
4161
+ workspaceLock: parsed.workspaceId
4162
+ ? {
4163
+ enabled: true,
4164
+ workspaceId: parsed.workspaceId,
4165
+ required: requireWorkspaceLock,
4166
+ }
4167
+ : {
4168
+ enabled: false,
4169
+ workspaceId: null,
4170
+ required: requireWorkspaceLock,
4171
+ },
4014
4172
  tokenPresent: Boolean(token),
4015
4173
  tokenSource,
4016
4174
  tokenFingerprint: tokenFingerprint(token),
@@ -4019,11 +4177,29 @@ function runHermesProfileBootstrap(argv) {
4019
4177
  keys: Object.entries(slackTokens)
4020
4178
  .filter(([, value]) => value)
4021
4179
  .map(([key]) => key),
4180
+ envKeys: slackEnvKeys,
4022
4181
  fingerprints: Object.fromEntries(
4023
4182
  Object.entries(slackTokens)
4024
4183
  .filter(([, value]) => value)
4025
4184
  .map(([key, value]) => [key, tokenFingerprint(value)])
4026
4185
  ),
4186
+ scope: {
4187
+ homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
4188
+ homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
4189
+ allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
4190
+ ? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
4191
+ : [],
4192
+ freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
4193
+ ? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
4194
+ : [],
4195
+ allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
4196
+ ? slackEnv.SLACK_ALLOWED_USERS.split(",").length
4197
+ : 0,
4198
+ requireMention:
4199
+ slackEnv.SLACK_REQUIRE_MENTION === ""
4200
+ ? null
4201
+ : slackEnv.SLACK_REQUIRE_MENTION === "true",
4202
+ },
4027
4203
  },
4028
4204
  created: [],
4029
4205
  updated: [],
@@ -4068,7 +4244,7 @@ function runHermesProfileBootstrap(argv) {
4068
4244
  if (existsSync(envPath)) {
4069
4245
  envState = parseEnvLines(readFileSync(envPath, "utf8"));
4070
4246
  }
4071
- for (const [key, value] of Object.entries(slackTokens)) {
4247
+ for (const [key, value] of Object.entries(slackEnv)) {
4072
4248
  if (!value) continue;
4073
4249
  const existing = envState.entries.get(key);
4074
4250
  if (existing && existing !== value && !opts.overwriteEnv) {
@@ -4128,8 +4304,8 @@ function runHermesProfileBootstrap(argv) {
4128
4304
  mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4129
4305
  writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4130
4306
  installHermes(opts);
4131
- if (Object.values(slackTokens).some(Boolean)) {
4132
- for (const [key, value] of Object.entries(slackTokens)) {
4307
+ if (Object.values(slackEnv).some(Boolean)) {
4308
+ for (const [key, value] of Object.entries(slackEnv)) {
4133
4309
  if (value) envState.entries.set(key, value);
4134
4310
  }
4135
4311
  writeFile(envPath, renderEnvFile(envState), opts, 0o600);
@@ -4144,16 +4320,31 @@ function runHermesProfileBootstrap(argv) {
4144
4320
  hermesConfigPath(opts)
4145
4321
  );
4146
4322
  (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4147
- if (Object.values(slackTokens).some(Boolean)) {
4323
+ if (Object.values(slackEnv).some(Boolean)) {
4148
4324
  (envExisted ? summary.updated : summary.created).push(envPath);
4149
4325
  } else {
4150
- summary.skipped.push("Slack env: no Slack tokens supplied");
4326
+ summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
4327
+ }
4328
+ if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
4329
+ summary.warnings.push(
4330
+ "Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
4331
+ );
4332
+ }
4333
+ if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
4334
+ summary.warnings.push(
4335
+ "Slack bot token is present without SLACK_APP_TOKEN; outbound Slack sends may work, but native Hermes Socket Mode listening still requires an xapp app-level token with connections:write."
4336
+ );
4151
4337
  }
4152
4338
  if (!token) {
4153
4339
  summary.warnings.push(
4154
4340
  `Manual auth pending. Run: sellable auth set <token> --workspace-id <workspace_id> --sellable-config-path ${sellableConfigPath}`
4155
4341
  );
4156
4342
  }
4343
+ if (requireWorkspaceLock && !parsed.workspaceId) {
4344
+ summary.warnings.push(
4345
+ "Sellable MCP will fail closed until sellable-admin provisions SELLABLE_LOCK_WORKSPACE_ID for this customer profile."
4346
+ );
4347
+ }
4157
4348
 
4158
4349
  if (opts.json) {
4159
4350
  console.log(JSON.stringify(summary, null, 2));
@@ -45,14 +45,6 @@ export const REQUIRED_SELLABLE_MCP_TOOLS = [
45
45
 
46
46
  export const FORBIDDEN_SELLABLE_MCP_TOOLS = ["refill_campaign_sends"];
47
47
 
48
- const CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT = 48_000;
49
- const CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS = 8;
50
- const CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES = {
51
- campaignSequenceOptions: 'campaignSequenceOptions:{ mode:"connection_only" }',
52
- templateRef: 'templateRef:"connection_only"',
53
- sendInviteReceipt: 'sequenceReceipt.actionTypes:["send_invite"]',
54
- };
55
-
56
48
  export const CREATE_CAMPAIGN_SMOKE_CALLS = [
57
49
  {
58
50
  name: "get_auth_status",
@@ -92,14 +84,6 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
92
84
  limit: 1200,
93
85
  },
94
86
  },
95
- {
96
- name: "get_subskill_prompt",
97
- arguments: {
98
- subskillName: "create-evergreen-campaigns",
99
- offset: 0,
100
- limit: CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT,
101
- },
102
- },
103
87
  {
104
88
  name: "refill_sends",
105
89
  arguments: {
@@ -203,43 +187,6 @@ function parseJsonText(text) {
203
187
  }
204
188
  }
205
189
 
206
- function emptyConnectionOnlyEvergreenProofs() {
207
- return Object.fromEntries(
208
- Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map((key) => [
209
- key,
210
- false,
211
- ])
212
- );
213
- }
214
-
215
- function getConnectionOnlyEvergreenProofs(prompt) {
216
- return Object.fromEntries(
217
- Object.entries(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map(
218
- ([key, needle]) => [key, prompt.includes(needle)]
219
- )
220
- );
221
- }
222
-
223
- function mergeConnectionOnlyEvergreenProofs(target, source) {
224
- if (!source) return target;
225
- for (const key of Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES)) {
226
- target[key] = target[key] === true || source[key] === true;
227
- }
228
- return target;
229
- }
230
-
231
- function summarizeConnectionOnlyEvergreenProofs(proofs) {
232
- const merged = { ...emptyConnectionOnlyEvergreenProofs(), ...(proofs || {}) };
233
- const missing = Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).filter(
234
- (key) => merged[key] !== true
235
- );
236
- return {
237
- ok: missing.length === 0,
238
- proofs: merged,
239
- missing,
240
- };
241
- }
242
-
243
190
  function summarizeToolResult(name, result) {
244
191
  const text = redact(textFromToolResult(result));
245
192
  const parsed = parseJsonText(text);
@@ -283,26 +230,10 @@ function summarizeToolResult(name, result) {
283
230
  }
284
231
 
285
232
  if (name === "get_subskill_prompt" && parsed) {
286
- const prompt = typeof parsed.prompt === "string" ? parsed.prompt : "";
287
- const connectionOnlyEvergreenProofs =
288
- parsed.name === "create-evergreen-campaigns"
289
- ? getConnectionOnlyEvergreenProofs(prompt)
290
- : null;
291
233
  return {
292
234
  ...summary,
293
235
  subskillName: parsed.name || null,
294
- promptChars: prompt.length,
295
- promptLength:
296
- typeof parsed.promptLength === "number" ? parsed.promptLength : null,
297
- offset: typeof parsed.offset === "number" ? parsed.offset : null,
298
- limit: typeof parsed.limit === "number" ? parsed.limit : null,
299
- connectionOnlyEvergreenProofs,
300
- connectionOnlyEvergreenGuidance:
301
- parsed.name === "create-evergreen-campaigns"
302
- ? summarizeConnectionOnlyEvergreenProofs(
303
- connectionOnlyEvergreenProofs
304
- ).ok
305
- : null,
236
+ promptChars: typeof parsed.prompt === "string" ? parsed.prompt.length : 0,
306
237
  hasMore: parsed.hasMore === true,
307
238
  nextOffset:
308
239
  typeof parsed.nextOffset === "number" ? parsed.nextOffset : null,
@@ -336,7 +267,6 @@ async function verifyCreateCampaignSmoke({
336
267
  .map((call) => call.name)
337
268
  .filter((name) => !available.has(name));
338
269
  const calls = [];
339
- const connectionOnlyEvergreenProofs = emptyConnectionOnlyEvergreenProofs();
340
270
 
341
271
  if (missingTools.length > 0) {
342
272
  return {
@@ -350,195 +280,67 @@ async function verifyCreateCampaignSmoke({
350
280
  }
351
281
 
352
282
  for (const call of smokeCalls) {
353
- let nextCall = call;
354
- let evergreenPromptChunks = 0;
355
- const evergreenPromptOffsets = new Set();
356
-
357
- while (nextCall) {
358
- const callStartedAt = new Date().toISOString();
359
- try {
360
- const result = await client.callTool(
361
- { name: nextCall.name, arguments: nextCall.arguments },
362
- undefined,
363
- {
364
- timeout: timeoutMs,
365
- maxTotalTimeout: timeoutMs,
366
- }
367
- );
368
- const summary = summarizeToolResult(nextCall.name, result);
369
- calls.push({
370
- name: nextCall.name,
371
- arguments: nextCall.arguments,
372
- ok: summary.isError !== true,
373
- summary,
374
- startedAt: callStartedAt,
375
- completedAt: new Date().toISOString(),
376
- });
377
- if (summary.isError === true) {
378
- return {
379
- ok: false,
380
- missingTools: [],
381
- calls,
382
- error: `${nextCall.name} returned an MCP tool error`,
383
- startedAt,
384
- completedAt: new Date().toISOString(),
385
- };
386
- }
387
-
388
- const isConnectionOnlyEvergreenPrompt =
389
- nextCall.name === "get_subskill_prompt" &&
390
- nextCall.arguments?.subskillName === "create-evergreen-campaigns";
391
- if (!isConnectionOnlyEvergreenPrompt) {
392
- break;
393
- }
394
-
395
- evergreenPromptChunks += 1;
396
- evergreenPromptOffsets.add(nextCall.arguments?.offset ?? 0);
397
- mergeConnectionOnlyEvergreenProofs(
398
- connectionOnlyEvergreenProofs,
399
- summary.connectionOnlyEvergreenProofs
400
- );
401
-
402
- if (summary.hasMore !== true) {
403
- break;
404
- }
405
- if (typeof summary.nextOffset !== "number") {
406
- return {
407
- ok: false,
408
- missingTools: [],
409
- calls,
410
- connectionOnlyEvergreenGuidance:
411
- summarizeConnectionOnlyEvergreenProofs(
412
- connectionOnlyEvergreenProofs
413
- ),
414
- error:
415
- "create-evergreen-campaigns prompt chunk is missing nextOffset",
416
- startedAt,
417
- completedAt: new Date().toISOString(),
418
- };
419
- }
420
- if (evergreenPromptOffsets.has(summary.nextOffset)) {
421
- return {
422
- ok: false,
423
- missingTools: [],
424
- calls,
425
- connectionOnlyEvergreenGuidance:
426
- summarizeConnectionOnlyEvergreenProofs(
427
- connectionOnlyEvergreenProofs
428
- ),
429
- error:
430
- "create-evergreen-campaigns prompt chunk repeated nextOffset",
431
- startedAt,
432
- completedAt: new Date().toISOString(),
433
- };
434
- }
435
- if (
436
- evergreenPromptChunks >= CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS
437
- ) {
438
- return {
439
- ok: false,
440
- missingTools: [],
441
- calls,
442
- connectionOnlyEvergreenGuidance:
443
- summarizeConnectionOnlyEvergreenProofs(
444
- connectionOnlyEvergreenProofs
445
- ),
446
- error:
447
- "create-evergreen-campaigns prompt exceeded smoke chunk limit",
448
- startedAt,
449
- completedAt: new Date().toISOString(),
450
- };
283
+ const callStartedAt = new Date().toISOString();
284
+ try {
285
+ const result = await client.callTool(
286
+ { name: call.name, arguments: call.arguments },
287
+ undefined,
288
+ {
289
+ timeout: timeoutMs,
290
+ maxTotalTimeout: timeoutMs,
451
291
  }
452
-
453
- nextCall = {
454
- ...nextCall,
455
- arguments: {
456
- ...nextCall.arguments,
457
- offset: summary.nextOffset,
458
- limit:
459
- nextCall.arguments?.limit ||
460
- CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT,
461
- },
462
- };
463
- } catch (err) {
464
- const error = err instanceof Error ? err.message : String(err);
465
- calls.push({
466
- name: nextCall.name,
467
- arguments: nextCall.arguments,
468
- ok: false,
469
- summary: null,
470
- error: redact(error),
471
- startedAt: callStartedAt,
472
- completedAt: new Date().toISOString(),
473
- });
292
+ );
293
+ const summary = summarizeToolResult(call.name, result);
294
+ calls.push({
295
+ name: call.name,
296
+ arguments: call.arguments,
297
+ ok: summary.isError !== true,
298
+ summary,
299
+ startedAt: callStartedAt,
300
+ completedAt: new Date().toISOString(),
301
+ });
302
+ if (summary.isError === true) {
474
303
  return {
475
304
  ok: false,
476
305
  missingTools: [],
477
306
  calls,
478
- error: redact(error),
307
+ error: `${call.name} returned an MCP tool error`,
479
308
  startedAt,
480
309
  completedAt: new Date().toISOString(),
481
310
  };
482
311
  }
312
+ } catch (err) {
313
+ const error = err instanceof Error ? err.message : String(err);
314
+ calls.push({
315
+ name: call.name,
316
+ arguments: call.arguments,
317
+ ok: false,
318
+ summary: null,
319
+ error: redact(error),
320
+ startedAt: callStartedAt,
321
+ completedAt: new Date().toISOString(),
322
+ });
323
+ return {
324
+ ok: false,
325
+ missingTools: [],
326
+ calls,
327
+ error: redact(error),
328
+ startedAt,
329
+ completedAt: new Date().toISOString(),
330
+ };
483
331
  }
484
332
  }
485
333
 
486
- const connectionOnlyEvergreenGuidance =
487
- summarizeConnectionOnlyEvergreenProofs(connectionOnlyEvergreenProofs);
488
- if (connectionOnlyEvergreenGuidance.ok !== true) {
489
- return {
490
- ok: false,
491
- missingTools: [],
492
- calls,
493
- connectionOnlyEvergreenGuidance,
494
- error: `create-evergreen-campaigns prompt is missing connection-only guidance: ${connectionOnlyEvergreenGuidance.missing.join(", ")}`,
495
- startedAt,
496
- completedAt: new Date().toISOString(),
497
- };
498
- }
499
-
500
334
  return {
501
335
  ok: true,
502
336
  missingTools: [],
503
337
  calls,
504
- connectionOnlyEvergreenGuidance,
505
338
  error: null,
506
339
  startedAt,
507
340
  completedAt: new Date().toISOString(),
508
341
  };
509
342
  }
510
343
 
511
- function verifyConnectionOnlyEvergreenContract(tools) {
512
- const setupTool = (tools || []).find(
513
- (tool) => tool?.name === "setup_evergreen_campaigns"
514
- );
515
- const schema = setupTool?.inputSchema || {};
516
- const properties = schema.properties || {};
517
- const sequenceOptions = properties.campaignSequenceOptions || {};
518
- const mode = sequenceOptions.properties?.mode || {};
519
- const issues = [];
520
-
521
- if (!properties.workspaceId) {
522
- issues.push("setup_evergreen_campaigns.workspaceId");
523
- }
524
- if (!properties.campaignSequenceOptions) {
525
- issues.push("setup_evergreen_campaigns.campaignSequenceOptions");
526
- }
527
- if (!Array.isArray(mode.enum) || !mode.enum.includes("connection_only")) {
528
- issues.push("setup_evergreen_campaigns.campaignSequenceOptions.mode");
529
- }
530
- if (sequenceOptions.additionalProperties !== false) {
531
- issues.push(
532
- "setup_evergreen_campaigns.campaignSequenceOptions.additionalProperties"
533
- );
534
- }
535
-
536
- return {
537
- ok: issues.length === 0,
538
- issues,
539
- };
540
- }
541
-
542
344
  function summarizeAttempt(result, attempt) {
543
345
  return {
544
346
  attempt,
@@ -546,12 +348,6 @@ function summarizeAttempt(result, attempt) {
546
348
  availableToolCount: result.availableTools?.length || 0,
547
349
  missingToolCount: result.missingTools?.length || 0,
548
350
  forbiddenToolCount: result.forbiddenTools?.length || 0,
549
- connectionOnlyEvergreenContract: result.connectionOnlyEvergreenContract
550
- ? {
551
- ok: result.connectionOnlyEvergreenContract.ok === true,
552
- issues: result.connectionOnlyEvergreenContract.issues || [],
553
- }
554
- : null,
555
351
  error: result.error || null,
556
352
  createCampaignSmoke: result.createCampaignSmoke
557
353
  ? {
@@ -623,7 +419,6 @@ async function verifySellableMcpRuntimeOnce({
623
419
  let missingTools = [...requiredTools];
624
420
  let forbiddenTools = [];
625
421
  let createCampaignSmoke = null;
626
- let connectionOnlyEvergreenContract = { ok: false, issues: [] };
627
422
  let error = null;
628
423
 
629
424
  try {
@@ -637,8 +432,6 @@ async function verifySellableMcpRuntimeOnce({
637
432
  forbiddenTools = availableTools.filter((tool) =>
638
433
  FORBIDDEN_SELLABLE_MCP_TOOLS.includes(tool)
639
434
  );
640
- connectionOnlyEvergreenContract =
641
- verifyConnectionOnlyEvergreenContract(toolList.tools);
642
435
  if (forbiddenTools.length > 0) {
643
436
  createCampaignSmoke = {
644
437
  ok: false,
@@ -682,7 +475,6 @@ async function verifySellableMcpRuntimeOnce({
682
475
  !error &&
683
476
  missingTools.length === 0 &&
684
477
  forbiddenTools.length === 0 &&
685
- connectionOnlyEvergreenContract.ok === true &&
686
478
  createCampaignSmoke?.ok === true,
687
479
  skipped: false,
688
480
  command,
@@ -691,7 +483,6 @@ async function verifySellableMcpRuntimeOnce({
691
483
  availableTools,
692
484
  missingTools,
693
485
  forbiddenTools,
694
- connectionOnlyEvergreenContract,
695
486
  createCampaignSmoke,
696
487
  stderrTail: stderrLines,
697
488
  error: error ? redact(error) : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.315",
3
+ "version": "0.1.317",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  "lib",
16
16
  "agents",
17
17
  "skill-templates",
18
- "README.md"
18
+ "README.md",
19
+ "HERMES-SLACK-PROFILE-SCOPING.md"
19
20
  ],
20
21
  "dependencies": {
21
22
  "@modelcontextprotocol/sdk": "^1.25.2",
@@ -134,19 +134,6 @@ run without user input, or similar, run in **automation mode**:
134
134
  exactly one quality-valid route-proof row when needed, and attach the
135
135
  recommended sequence, but it still must not launch campaigns, schedule
136
136
  sends, send messages, or spend paid InMail.
137
- - **Connection-only evergreen completion is optional and non-default.** Use it
138
- only when the prompt explicitly says connection-only, invite-only,
139
- connection request only, no DMs, or no follow-ups. Pass the typed option
140
- `campaignSequenceOptions:{ mode:"connection_only" }` plus an explicit
141
- `workspaceId` to `setup_evergreen_campaigns`. Omit
142
- `campaignSequenceOptions` for the standard tier-recommended Premium,
143
- Sales Nav, Recruiter, and paid InMail behavior.
144
- Connection-only completion still creates/reuses the same evergreen lanes,
145
- source rows, and paused/unlaunched review state, but it attaches only the
146
- canonical invite template and persists `currentStep:"send"` as the paused
147
- review step. It does not require Message Drafting, generated messages,
148
- route-proof approval, first DM, InMail, View Profile fallback, follow-up
149
- branch, launch, scheduling, or sends.
150
137
 
151
138
  If the invoking prompt explicitly asks for interactive message polish or sample
152
139
  proof, run in **interactive polish mode** and use the confirmation/sample steps
@@ -733,9 +720,6 @@ not a parent-thread summary. The receipt must include:
733
720
  creation, preserve that original packet only in a separate `parentLanePacket`
734
721
  object; do not leave `setupPlanCall.campaignId:null` or
735
722
  `setupPlanCall.tableId:null`.
736
- If the parent plan used connection-only, the receipt must also include
737
- `setupPlanCall.campaignSequenceOptions:{ "mode":"connection_only" }` or
738
- `setupPlanCall.sequencePolicy.mode:"connection_only"`.
739
723
  Short form: For create lanes, `setupPlanCall.campaignId` and `setupPlanCall.tableId` must be the actual created campaign/table ids.
740
724
  Short form: Do not leave `setupPlanCall.campaignId:null` or `setupPlanCall.tableId:null`.
741
725
  - `createCampaignWorkflowReceipt`: proof the worker loaded the actual installed
@@ -826,40 +810,22 @@ not a parent-thread summary. The receipt must include:
826
810
  them into canonical `promptLoadedToHasMoreFalse`, `requiredAssetsLoaded`,
827
811
  `validationLoaded`, `reviewBatchRowHash`, and
828
812
  `messageDraftRecommendation`.
829
- Connection-only lanes are the exception: do not include
830
- `messageDraftingReceipt` or run generated-message prep unless the operator
831
- separately asked for message copy. The parent verifier expects no message
832
- proof for connection-only lanes.
833
813
  - `reviewBatchReceipt`: review-batch row ids/hash, generated row count,
834
814
  quality-valid route-proof row id when approved, and proof that no broad
835
815
  approve-all occurred.
836
- Connection-only lanes are the exception: do not approve a route-proof row and
837
- do not include a required review-batch receipt.
838
816
  - `sequenceReceipt`: exact current workflowTableId, recommended non-paid
839
817
  sequence attach/precheck result, and readback showing `hasSequence:true` when
840
818
  completion is claimed. If the tool output uses
841
819
  `attachRecommendedSequenceResult.actionTypes` or
842
820
  `nonPaidRecommendedSequence`, copy them to canonical `actionTypes` and
843
821
  `nonPaid`.
844
- For connection-only lanes, do not call `attach_recommended_sequence`. Attach
845
- with `attach_sequence({ tableId, templateRef:"connection_only", currentStep:"send" })`, then
846
- include exact current-template proof:
847
- `sequenceReceipt.hasSequence:true`,
848
- `sequenceReceipt.actionTypes:["send_invite"]`, and
849
- `sequenceReceipt.nonPaid:true`. Receipts that only say
850
- `tool:"attach_recommended_sequence"` or include `send_dm`,
851
- `send_inmail_open`, `send_inmail_closed`, or `view_profile` are failing
852
- connection-only receipts.
853
822
  - final paused-send proof: if the current campaign table is `DRAFT` after the
854
823
  sequence is attached, call the product `pause_campaign({ campaignId })`
855
824
  endpoint/tool to put the unlaunched campaign into `PAUSED` review state, then
856
825
  reread the campaign/table. Do not raw-write `campaignStatus`, do not start or
857
826
  launch, and do not schedule/send. Completion requires reread proof of
858
- `currentStep:"send"` and `campaignStatus:"PAUSED"` for standard and
859
- connection-only customer-visible lanes. `send` is the product's paused
860
- review step; do not use unsupported aliases such as `review` as final proof.
861
- If the raw output nests this in `finalCampaignRead` or `finalTableRead`, copy
862
- it to canonical
827
+ `currentStep:"send"` and `campaignStatus:"PAUSED"`. If the raw output nests
828
+ this in `finalCampaignRead` or `finalTableRead`, copy it to canonical
863
829
  `finalPausedSendProof.currentStep` and
864
830
  `finalPausedSendProof.campaignStatus`.
865
831
  - `verifyCall`: the exact `setup_evergreen_campaigns({ mode:"verify",
@@ -1122,14 +1088,9 @@ plain row generation. The lane worker must inline the same
1122
1088
  approvedGeneratedMessageCount exactly 1. Do not report completion with 2+
1123
1089
  approved rows.
1124
1090
  Approval shorthand: Do not report completion with 2+ approved rows.
1125
- 8. Standard mode: continue to
1126
- `attach_recommended_sequence({ campaignId, currentStep:"send" })` and, if
1127
- the campaign is still `DRAFT`, `pause_campaign({ campaignId })`. Reread the
1128
- campaign/table before claiming completion.
1129
- Connection-only mode: skip Message Drafting and route-proof approval, call
1130
- `attach_sequence({ tableId, templateRef:"connection_only", currentStep:"send" })`, prove
1131
- `sequenceReceipt.actionTypes:["send_invite"]`, and pause/reread without
1132
- launching or sending.
1091
+ 8. Continue to `attach_recommended_sequence({ campaignId, currentStep:"send" })`
1092
+ and, if the campaign is still `DRAFT`, `pause_campaign({ campaignId })`.
1093
+ Reread the campaign/table before claiming completion.
1133
1094
 
1134
1095
  That packaged worker path must return `messageDraftingReceipt.statusSource:
1135
1096
  "packaged-generate-messages-worker"`. It is accepted only when the receipt
@@ -1554,14 +1515,11 @@ Message, and verify current-revision sample messages before final completion.
1554
1515
  - DM lanes: add a `Delivery format:` line — either `multiline (each paragraph sends as its own DM message)` or `single message`. When multiline, the template's blank-line paragraphs ARE the message boundaries — write each one as a standalone typed message.
1555
1516
  - **InMail lanes can never be multiline**: an InMail is one message and the recipient must reply before anything else can be sent. InMail-bound templates must read as one cohesive message — declare `Delivery format: single message (InMail — no follow-up until reply)` and never structure the copy to depend on multi-message pacing.
1556
1517
 
1557
- - In standard mode, the sequence is auto-selected by sender tier; do not hand-author sequence
1518
+ - The sequence is auto-selected by sender tier; do not hand-author sequence
1558
1519
  templates here. Sales Nav/Recruiter senders may receive the unified Sales
1559
1520
  Nav cascade through `attach_recommended_sequence`; attaching it does not
1560
1521
  spend paid InMail credits by itself. Do not substitute the manual Paid
1561
1522
  InMail Campaign template.
1562
- For explicit connection-only evergreen lanes only, use the backend-owned
1563
- `attach_sequence({ tableId, templateRef:"connection_only", currentStep:"send" })` path and do
1564
- not call `attach_recommended_sequence`.
1565
1523
  3. **Customer-Visible Completion Contract**: a named evergreen lane that appears
1566
1524
  as a campaign card or campaign-backed table is not done when the shell exists.
1567
1525
  It is done only when the customer can open the campaign and land on final
@@ -1585,7 +1543,7 @@ Message, and verify current-revision sample messages before final completion.
1585
1543
  evergreen setup. Do not leave customer-visible campaigns at
1586
1544
  `filter-choice`, `filter-rules`, or `apply-icp-rubric` and report success.
1587
1545
  Do not proceed to Message Drafting until saved filters are applied.
1588
- - In standard mode, Message Drafting has run from the current campaign/table basis, using the
1546
+ - Message Drafting has run from the current campaign/table basis, using the
1589
1547
  create-campaign message prompt/assets and validation gate. Updating
1590
1548
  `currentStep:"messages"` is not proof. Parent-thread handwritten copy is
1591
1549
  not a substitute. The proof receipt must show
@@ -1626,9 +1584,6 @@ Message, and verify current-revision sample messages before final completion.
1626
1584
  Fallback samples must still reject source/conversation hedges such as
1627
1585
  `"hope this is relevant"`, `"might be interested"`, or `"saw you in a few
1628
1586
  conversations"`.
1629
- For explicit connection-only lanes, this whole Message Drafting and sample
1630
- proof block is not required and must not be faked with parent-written
1631
- messages.
1632
1587
  - The first review batch exists and at least 3 review rows have generated
1633
1588
  messages from the approved brief. If fewer than 3 usable rows exist, report
1634
1589
  the actual count and why.
@@ -1641,7 +1596,7 @@ Message, and verify current-revision sample messages before final completion.
1641
1596
  completion, approve exactly one quality-valid generated row. If one or more
1642
1597
  rows are already approved, do not add more approvals during evergreen
1643
1598
  completion. Never broad approve all rows.
1644
- - In standard mode, the recommended tier-aware sequence is attached to the current campaign
1599
+ - The recommended tier-aware sequence is attached to the current campaign
1645
1600
  table, and the watched campaign is on Send. Use
1646
1601
  `attach_recommended_sequence({ campaignId, currentStep:"send" })` when a
1647
1602
  safe attach is needed. After `confirm_lead_list` or any source-list copy,
@@ -1656,10 +1611,6 @@ Message, and verify current-revision sample messages before final completion.
1656
1611
  manual Paid InMail Campaign, and never attach/replace sequence outside the
1657
1612
  current table unless the current lane packet explicitly allowed sequence
1658
1613
  repair.
1659
- For explicit connection-only lanes, the current campaign table must have
1660
- exactly the canonical invite-only sequence:
1661
- `sequenceReceipt.actionTypes:["send_invite"]`. Any DM, InMail, or View
1662
- Profile action means the lane is not complete.
1663
1614
  - If the current campaign table is still `DRAFT` after sequence/readiness
1664
1615
  proof, call `pause_campaign({ campaignId })` and reread. `pause_campaign`
1665
1616
  is the product-native review-state transition; it is not a launch and does
@@ -62,11 +62,6 @@ for lease expiry; never guess a fence.
62
62
  The default `intent:"auto"` inspects `managed_waterfall`,
63
63
  `dashboard_evergreen`, and `active_campaign` lane sources. Report the lane
64
64
  source and lane chain as proof for every selected sender.
65
- Connection-only evergreen lanes are invite-only refill targets. When the packet
66
- or campaign proof shows exactly `["send_invite"]`, report and execute only
67
- `selectedLane:"send_invite"` work; do not infer DM, InMail, Sales Nav cascade,
68
- paid-credit refresh, message generation, follow-up, sequence mutation,
69
- launch/start beyond packet authority, scheduler writes, or direct sends.
70
65
 
71
66
  If membership blocks a workspace read, report the structured
72
67
  `workspace_access` blocker instead of retrying auth or switching workspaces.
@@ -196,11 +196,6 @@ rather than asking which campaign class to fill. If a stale target plan selects
196
196
  planner before mutation.
197
197
  Short form: trust the target plan's inferred lane when it is a connection invite,
198
198
  paid-InMail refill lane, or unified Sales Nav cascade.
199
- Connection-only evergreen campaigns with exact `["send_invite"]` sequence proof
200
- are plain invite capacity: treat them as `selectedLane:"send_invite"` only. Do
201
- not infer DM, InMail, Sales Nav cascade, paid-credit refresh, message
202
- generation, follow-up, sequence mutation, launch/start, scheduling override, or
203
- direct-send work from a connection-only lane.
204
199
 
205
200
  Structured planner packet:
206
201