@sellable/install 0.1.314 → 0.1.316

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,154 @@
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 shared Sellable Reply Bot
9
+ Socket Mode listener and route inbound events by exact channel id. Do not run
10
+ multiple active Hermes profile gateways against the same `SLACK_APP_TOKEN`.
11
+
12
+ The shared dispatcher maps `slackTeamId + channelId` to `customerSlug`,
13
+ `workspaceId`, `hermesProfile`, and `responsePolicy`. It then invokes
14
+ `hermes -p <profile>` with profile-local `SELLABLE_CONFIG_PATH` and
15
+ `SELLABLE_CONFIGS_DIR`, and posts the response back to the original Slack
16
+ channel/thread.
17
+
18
+ Customer profiles do not store the shared raw Slack token pair; route records
19
+ store metadata and secret references only. Use dedicated per-customer Slack
20
+ app/token pairs only for customer-owned Slack workspaces or strict enterprise
21
+ isolation.
22
+
23
+ The 2026-07-06 Hostinger POC proved the native Hermes listener for the
24
+ `sellable-admin` profile with the existing `Sellable Reply Bot` app in
25
+ `#team-chat`: a human mention produced a new Hermes session and a threaded bot
26
+ reply. Treat that as proof that the gateway/listener path works for one owning
27
+ profile, not as approval for multiple active customer profiles sharing one app
28
+ token.
29
+
30
+ The same day, the `acme` profile reused the same redacted Slack token
31
+ fingerprints for an outbound-only smoke to `#sellable-acme` via:
32
+
33
+ ```bash
34
+ hermes --profile acme send --to slack:C0BFERDV3N0 --subject '[Hermes acme smoke]' ...
35
+ ```
36
+
37
+ That succeeded and is useful as an internal smoke test. It does not change the
38
+ inbound listener rule: one shared dispatcher should own Socket Mode for
39
+ Sellable-managed channels.
40
+
41
+ ## Which Slack Values Are Enough
42
+
43
+ For outbound sends and ordinary Slack Web API calls, the important value is the
44
+ profile-local `SLACK_BOT_TOKEN`.
45
+
46
+ For native Hermes live listening through Slack Socket Mode, the profile also
47
+ needs `SLACK_APP_TOKEN`, an app-level `xapp-...` token with `connections:write`.
48
+ `SLACK_APP_ID`, `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`,
49
+ `SLACK_SIGNING_SECRET`, `SLACK_VERIFICATION_TOKEN`, `SLACK_TEAM_ID`, and
50
+ `SLACK_OPERATOR_EMAIL` do not replace `SLACK_APP_TOKEN`.
51
+
52
+ ## Required Slack App Shape
53
+
54
+ Outbound Slack sends can work with fewer permissions. Native Hermes inbound
55
+ listening needs the Slack app itself to be configured for Socket Mode and event
56
+ delivery:
57
+
58
+ - Enable Socket Mode.
59
+ - Create an app-level token with `connections:write`.
60
+ - Install the bot with `app_mentions:read`, `channels:history`,
61
+ `channels:read`, `chat:write`, `groups:history`, `groups:read`,
62
+ `im:history`, `im:read`, `mpim:history`, `mpim:read`, and `users:read`.
63
+ - Subscribe bot events for `app_mention`, `message.channels`,
64
+ `message.groups`, `message.im`, and `message.mpim`.
65
+ - Reinstall the app after changing scopes or event subscriptions.
66
+
67
+ If the Slack app has `incoming-webhook`, Slack will ask for a webhook channel
68
+ during reinstall. Pick the profile home channel. That webhook selection does
69
+ not replace `SLACK_ALLOWED_CHANNELS`; keep the profile-local allowlist explicit.
70
+
71
+ ## Profile Bootstrap Command
72
+
73
+ Use the Sellable installer profile bootstrap:
74
+
75
+ ```bash
76
+ sellable hermes profile bootstrap \
77
+ --profile acme \
78
+ --profiles-root /srv/hermes/profiles \
79
+ --workspace-id ws_acme \
80
+ --workspace-name Acme \
81
+ --token-file /run/secrets/sellable-acme-token \
82
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
83
+ --slack-app-token "$SLACK_APP_TOKEN" \
84
+ --slack-home-channel C0ACME12345 \
85
+ --slack-home-channel-name sellable-acme \
86
+ --slack-allowed-users U0OPERATOR1 \
87
+ --slack-require-mention true \
88
+ --json
89
+ ```
90
+
91
+ If `--slack-home-channel` is supplied and `--slack-allowed-channels` is omitted,
92
+ the installer writes `SLACK_ALLOWED_CHANNELS=<home-channel-id>` automatically.
93
+ That makes the profile fail closed to the customer channel by default.
94
+
95
+ Use `--slack-allowed-channels C...,G...` only when a profile is intentionally
96
+ allowed to listen in more than one channel. Pass Slack IDs, not `#channel-name`
97
+ strings or wildcards.
98
+
99
+ ## Resulting Profile Env
100
+
101
+ For the dedicated customer Slack app model, the profile-local `.env` should
102
+ contain only scoped Slack keys for that profile:
103
+
104
+ ```env
105
+ SLACK_BOT_TOKEN=...
106
+ SLACK_APP_TOKEN=...
107
+ SLACK_HOME_CHANNEL=C0ACME12345
108
+ SLACK_HOME_CHANNEL_NAME=sellable-acme
109
+ SLACK_ALLOWED_CHANNELS=C0ACME12345
110
+ SLACK_ALLOWED_USERS=U0OPERATOR1
111
+ SLACK_REQUIRE_MENTION=true
112
+ ```
113
+
114
+ Keep the file mode at `0600`. Do not copy raw tokens into planning docs, chat,
115
+ or committed artifacts. Record token fingerprints only.
116
+
117
+ For the shared dispatcher model, do not copy the shared `SLACK_BOT_TOKEN` or
118
+ `SLACK_APP_TOKEN` into customer profile `.env` files. The dispatcher owns those
119
+ tokens and route metadata decides the customer profile.
120
+
121
+ ## Shared Dispatcher Ownership
122
+
123
+ The shared Slack dispatcher and its route registry are internal Sellable Admin
124
+ surfaces. This customer-facing installer must not create Slack apps, write
125
+ shared listener tokens, or validate cross-customer route bundles. Sellable Admin
126
+ owns those steps, then calls this installer only for profile-local Sellable
127
+ runtime bootstrap inside the already-provisioned Hermes profile.
128
+
129
+ ## Verification
130
+
131
+ Run the installer and dispatcher UAT before publishing installer changes:
132
+
133
+ ```bash
134
+ npm run test:unit -- tests/install-package/agent-preferences.test.ts
135
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode local-temp
136
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode linux-shaped
137
+ scripts/run-phase031-hermes-slack-dispatcher-uat.sh --mode local-fixture
138
+ ```
139
+
140
+ Expected proof:
141
+
142
+ - profile-local Sellable config paths remain authoritative
143
+ - generated profile `.env` has `SLACK_HOME_CHANNEL`
144
+ - generated profile `.env` has `SLACK_ALLOWED_CHANNELS`
145
+ - `acme` allowlist points at the `sellable-acme` fixture channel id
146
+ - redaction scan passes
147
+
148
+ ## Current Phase 03 Status
149
+
150
+ Phase 03 has proved the profile-scoped installer path, outbound Hermes send
151
+ path, and live Slack listener for the `sellable-admin` profile. Phase 03.1 adds
152
+ the shared dispatcher route registry as a Sellable Admin-owned onboarding
153
+ surface so managed customer channels can be onboarded through the VPS without
154
+ local-only browser state, manual SSH edits, or untracked processes.
package/README.md CHANGED
@@ -122,6 +122,15 @@ 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>` into the
126
+ profile's Hermes MCP env. Customer profiles are therefore locked to their own
127
+ Sellable workspace: Sellable MCP requests are forced to that workspace,
128
+ workspace switching/creation is blocked, and workspace listings are filtered.
129
+ Use the `sellable-admin` profile for provisioning or cross-profile inspection.
130
+
131
+ See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
132
+ the customer Slack isolation contract and Socket Mode token requirements.
133
+
125
134
  If no token is supplied, bootstrap still creates a pending
126
135
  `sellable/config.json` so the profile path is concrete from the first MCP
127
136
  launch. Finish auth with:
@@ -142,6 +151,36 @@ sellable hermes profile bootstrap \
142
151
  --slack-app-token "$SLACK_APP_TOKEN"
143
152
  ```
144
153
 
154
+ The Slack app must also be configured for inbound Socket Mode delivery. Outbound
155
+ Slack Web API sends can work before this is complete, but Hermes will not hear
156
+ mentions until the app has Socket Mode enabled, an app-level token with
157
+ `connections:write`, bot scopes/events for `app_mention` plus channel/group/IM
158
+ message events, and a fresh workspace reinstall after scope or event changes.
159
+
160
+ Scope the profile to its customer channel at install time:
161
+
162
+ ```bash
163
+ sellable hermes profile bootstrap \
164
+ --profile acme \
165
+ --profiles-root /srv/hermes/profiles \
166
+ --workspace-id ws_acme \
167
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
168
+ --slack-app-token "$SLACK_APP_TOKEN" \
169
+ --slack-home-channel C0ACME12345 \
170
+ --slack-home-channel-name sellable-acme \
171
+ --slack-allowed-users U0OPERATOR1 \
172
+ --slack-require-mention true
173
+ ```
174
+
175
+ When `--slack-home-channel` is supplied without
176
+ `--slack-allowed-channels`, bootstrap writes
177
+ `SLACK_ALLOWED_CHANNELS=<home-channel-id>` too. That fail-closes a customer
178
+ profile to the intended channel by default. Use
179
+ `--slack-allowed-channels C...,G...` only when a profile is deliberately allowed
180
+ to listen in multiple channels. Pass channel IDs, not `#channel-name`, and use
181
+ separate Slack bot/app tokens per customer profile when you need hard isolation
182
+ between concurrently listening Hermes profiles.
183
+
145
184
  Customer profiles should use customer-scoped Sellable credentials. A shared
146
185
  Sellable admin token can work for internal admin automation, but it is weaker
147
186
  isolation and should not be the default for customer profiles. After changing
@@ -217,9 +217,18 @@ Hermes profile bootstrap:
217
217
 
218
218
  Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
219
219
  <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.
220
+ <profileRoot>/.env Slack token and channel-scope keys. For customer profiles,
221
+ pass --slack-home-channel; bootstrap defaults SLACK_ALLOWED_CHANNELS to that
222
+ exact channel id unless --slack-allowed-channels is supplied. Use
223
+ customer-scoped Sellable credentials and customer-scoped Slack credentials for
224
+ customer profiles; shared admin tokens are weaker isolation and should remain
225
+ internal-only.
226
+
227
+ Hermes shared Slack dispatcher:
228
+ Shared listener setup and route-registry validation are internal Sellable
229
+ Admin responsibilities. This customer-facing installer only bootstraps a
230
+ profile-local Sellable runtime after Sellable Admin provisions the profile
231
+ and dispatcher route.
223
232
  `;
224
233
  }
225
234
 
@@ -3505,6 +3514,9 @@ function mcpEnvForHost(host, opts) {
3505
3514
  if (opts.sellableConfigsDir) {
3506
3515
  env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
3507
3516
  }
3517
+ if (opts.lockWorkspaceId) {
3518
+ env.SELLABLE_LOCK_WORKSPACE_ID = opts.lockWorkspaceId;
3519
+ }
3508
3520
  return env;
3509
3521
  }
3510
3522
 
@@ -3789,6 +3801,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3789
3801
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3790
3802
  slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3791
3803
  slackAppToken: process.env.SLACK_APP_TOKEN || "",
3804
+ slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
3805
+ slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
3806
+ slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
3807
+ slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
3808
+ slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
3809
+ slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
3792
3810
  server: process.env.SELLABLE_INSTALL_SERVER || "package",
3793
3811
  mcpPackage: DEFAULT_SERVER_PACKAGE,
3794
3812
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
@@ -3821,6 +3839,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3821
3839
  else if (arg === "--api-url") opts.apiUrl = next();
3822
3840
  else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3823
3841
  else if (arg === "--slack-app-token") opts.slackAppToken = next();
3842
+ else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
3843
+ else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
3844
+ else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
3845
+ else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
3846
+ else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
3847
+ else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
3824
3848
  else if (arg === "--server") opts.server = next();
3825
3849
  else if (arg === "--mcp-package") {
3826
3850
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
@@ -3922,6 +3946,111 @@ function renderEnvFile({ entries, passthrough }) {
3922
3946
  return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3923
3947
  }
3924
3948
 
3949
+ function normalizeSlackChannelIds(value, key) {
3950
+ const raw = String(value || "").trim();
3951
+ if (!raw) return "";
3952
+ const ids = raw
3953
+ .split(",")
3954
+ .map((item) => item.trim())
3955
+ .filter(Boolean);
3956
+ if (ids.length === 0) return "";
3957
+
3958
+ for (const id of ids) {
3959
+ if (
3960
+ id === "*" ||
3961
+ /^all$/i.test(id) ||
3962
+ id.startsWith("#") ||
3963
+ /\s/.test(id) ||
3964
+ !/^[A-Z][A-Z0-9]{8,}$/.test(id)
3965
+ ) {
3966
+ throw new Error(
3967
+ `${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
3968
+ );
3969
+ }
3970
+ }
3971
+ return [...new Set(ids)].join(",");
3972
+ }
3973
+
3974
+ function normalizeSlackSingleChannelId(value, key) {
3975
+ const normalized = normalizeSlackChannelIds(value, key);
3976
+ if (normalized.includes(",")) {
3977
+ throw new Error(`${key} must be a single Slack channel ID`);
3978
+ }
3979
+ return normalized;
3980
+ }
3981
+
3982
+ function normalizeSlackUserIds(value, key) {
3983
+ const raw = String(value || "").trim();
3984
+ if (!raw) return "";
3985
+ const ids = raw
3986
+ .split(",")
3987
+ .map((item) => item.trim())
3988
+ .filter(Boolean);
3989
+ if (ids.length === 0) return "";
3990
+
3991
+ for (const id of ids) {
3992
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
3993
+ throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
3994
+ }
3995
+ }
3996
+ return [...new Set(ids)].join(",");
3997
+ }
3998
+
3999
+ function normalizeSlackChannelName(value, key) {
4000
+ const raw = String(value || "").trim();
4001
+ if (!raw) return "";
4002
+ if (raw.startsWith("#") || /[\s,]/.test(raw)) {
4003
+ throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
4004
+ }
4005
+ return raw;
4006
+ }
4007
+
4008
+ function normalizeSlackBoolean(value, key) {
4009
+ const raw = String(value || "").trim().toLowerCase();
4010
+ if (!raw) return "";
4011
+ if (raw !== "true" && raw !== "false") {
4012
+ throw new Error(`${key} must be true or false`);
4013
+ }
4014
+ return raw;
4015
+ }
4016
+
4017
+ function buildSlackProfileEnv(parsed) {
4018
+ const homeChannel = normalizeSlackSingleChannelId(
4019
+ parsed.slackHomeChannel,
4020
+ "SLACK_HOME_CHANNEL"
4021
+ );
4022
+ const explicitAllowedChannels = normalizeSlackChannelIds(
4023
+ parsed.slackAllowedChannels,
4024
+ "SLACK_ALLOWED_CHANNELS"
4025
+ );
4026
+ const allowedChannels = explicitAllowedChannels || homeChannel;
4027
+
4028
+ return {
4029
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4030
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4031
+ SLACK_HOME_CHANNEL: homeChannel,
4032
+ SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
4033
+ parsed.slackHomeChannelName,
4034
+ "SLACK_HOME_CHANNEL_NAME"
4035
+ ),
4036
+ // Customer Hermes profiles must fail closed to their channel. If the caller
4037
+ // supplies only a home channel, we treat that as the allowlist too.
4038
+ SLACK_ALLOWED_CHANNELS: allowedChannels,
4039
+ SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
4040
+ parsed.slackFreeResponseChannels,
4041
+ "SLACK_FREE_RESPONSE_CHANNELS"
4042
+ ),
4043
+ SLACK_ALLOWED_USERS: normalizeSlackUserIds(
4044
+ parsed.slackAllowedUsers,
4045
+ "SLACK_ALLOWED_USERS"
4046
+ ),
4047
+ SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
4048
+ parsed.slackRequireMention,
4049
+ "SLACK_REQUIRE_MENTION"
4050
+ ),
4051
+ };
4052
+ }
4053
+
3925
4054
  function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3926
4055
  const duplicates = [];
3927
4056
  const profilesRoot = dirname(profileRoot);
@@ -3974,10 +4103,14 @@ function runHermesProfileBootstrap(argv) {
3974
4103
  const sellableConfigsDir = join(profileRoot, "sellable", "configs");
3975
4104
  const envPath = join(profileRoot, ".env");
3976
4105
  const { token, source: tokenSource } = readBootstrapToken(parsed);
4106
+ const slackEnv = buildSlackProfileEnv(parsed);
3977
4107
  const slackTokens = {
3978
- SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
3979
- SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4108
+ SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
4109
+ SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
3980
4110
  };
4111
+ const slackEnvKeys = Object.entries(slackEnv)
4112
+ .filter(([, value]) => value)
4113
+ .map(([key]) => key);
3981
4114
  const opts = {
3982
4115
  ...parsed,
3983
4116
  host: "hermes",
@@ -3987,6 +4120,7 @@ function runHermesProfileBootstrap(argv) {
3987
4120
  token,
3988
4121
  tokenSource,
3989
4122
  workspaceId: parsed.workspaceId,
4123
+ lockWorkspaceId: parsed.workspaceId,
3990
4124
  apiUrl: parsed.apiUrl,
3991
4125
  };
3992
4126
 
@@ -4011,6 +4145,9 @@ function runHermesProfileBootstrap(argv) {
4011
4145
  apiUrl: opts.apiUrl,
4012
4146
  activeWorkspaceId: parsed.workspaceId || null,
4013
4147
  activeWorkspaceName: parsed.workspaceName || null,
4148
+ workspaceLock: parsed.workspaceId
4149
+ ? { enabled: true, workspaceId: parsed.workspaceId }
4150
+ : { enabled: false, workspaceId: null },
4014
4151
  tokenPresent: Boolean(token),
4015
4152
  tokenSource,
4016
4153
  tokenFingerprint: tokenFingerprint(token),
@@ -4019,11 +4156,29 @@ function runHermesProfileBootstrap(argv) {
4019
4156
  keys: Object.entries(slackTokens)
4020
4157
  .filter(([, value]) => value)
4021
4158
  .map(([key]) => key),
4159
+ envKeys: slackEnvKeys,
4022
4160
  fingerprints: Object.fromEntries(
4023
4161
  Object.entries(slackTokens)
4024
4162
  .filter(([, value]) => value)
4025
4163
  .map(([key, value]) => [key, tokenFingerprint(value)])
4026
4164
  ),
4165
+ scope: {
4166
+ homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
4167
+ homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
4168
+ allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
4169
+ ? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
4170
+ : [],
4171
+ freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
4172
+ ? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
4173
+ : [],
4174
+ allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
4175
+ ? slackEnv.SLACK_ALLOWED_USERS.split(",").length
4176
+ : 0,
4177
+ requireMention:
4178
+ slackEnv.SLACK_REQUIRE_MENTION === ""
4179
+ ? null
4180
+ : slackEnv.SLACK_REQUIRE_MENTION === "true",
4181
+ },
4027
4182
  },
4028
4183
  created: [],
4029
4184
  updated: [],
@@ -4068,7 +4223,7 @@ function runHermesProfileBootstrap(argv) {
4068
4223
  if (existsSync(envPath)) {
4069
4224
  envState = parseEnvLines(readFileSync(envPath, "utf8"));
4070
4225
  }
4071
- for (const [key, value] of Object.entries(slackTokens)) {
4226
+ for (const [key, value] of Object.entries(slackEnv)) {
4072
4227
  if (!value) continue;
4073
4228
  const existing = envState.entries.get(key);
4074
4229
  if (existing && existing !== value && !opts.overwriteEnv) {
@@ -4128,8 +4283,8 @@ function runHermesProfileBootstrap(argv) {
4128
4283
  mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4129
4284
  writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4130
4285
  installHermes(opts);
4131
- if (Object.values(slackTokens).some(Boolean)) {
4132
- for (const [key, value] of Object.entries(slackTokens)) {
4286
+ if (Object.values(slackEnv).some(Boolean)) {
4287
+ for (const [key, value] of Object.entries(slackEnv)) {
4133
4288
  if (value) envState.entries.set(key, value);
4134
4289
  }
4135
4290
  writeFile(envPath, renderEnvFile(envState), opts, 0o600);
@@ -4144,10 +4299,20 @@ function runHermesProfileBootstrap(argv) {
4144
4299
  hermesConfigPath(opts)
4145
4300
  );
4146
4301
  (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4147
- if (Object.values(slackTokens).some(Boolean)) {
4302
+ if (Object.values(slackEnv).some(Boolean)) {
4148
4303
  (envExisted ? summary.updated : summary.created).push(envPath);
4149
4304
  } else {
4150
- summary.skipped.push("Slack env: no Slack tokens supplied");
4305
+ summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
4306
+ }
4307
+ if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
4308
+ summary.warnings.push(
4309
+ "Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
4310
+ );
4311
+ }
4312
+ if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
4313
+ summary.warnings.push(
4314
+ "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."
4315
+ );
4151
4316
  }
4152
4317
  if (!token) {
4153
4318
  summary.warnings.push(
@@ -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.314",
3
+ "version": "0.1.316",
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,18 +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. It does not require Message Drafting, generated
147
- messages, route-proof approval, first DM, InMail, View Profile fallback,
148
- follow-up branch, `currentStep:"send"`, launch, scheduling, or sends.
149
137
 
150
138
  If the invoking prompt explicitly asks for interactive message polish or sample
151
139
  proof, run in **interactive polish mode** and use the confirmation/sample steps
@@ -732,9 +720,6 @@ not a parent-thread summary. The receipt must include:
732
720
  creation, preserve that original packet only in a separate `parentLanePacket`
733
721
  object; do not leave `setupPlanCall.campaignId:null` or
734
722
  `setupPlanCall.tableId:null`.
735
- If the parent plan used connection-only, the receipt must also include
736
- `setupPlanCall.campaignSequenceOptions:{ "mode":"connection_only" }` or
737
- `setupPlanCall.sequencePolicy.mode:"connection_only"`.
738
723
  Short form: For create lanes, `setupPlanCall.campaignId` and `setupPlanCall.tableId` must be the actual created campaign/table ids.
739
724
  Short form: Do not leave `setupPlanCall.campaignId:null` or `setupPlanCall.tableId:null`.
740
725
  - `createCampaignWorkflowReceipt`: proof the worker loaded the actual installed
@@ -825,40 +810,22 @@ not a parent-thread summary. The receipt must include:
825
810
  them into canonical `promptLoadedToHasMoreFalse`, `requiredAssetsLoaded`,
826
811
  `validationLoaded`, `reviewBatchRowHash`, and
827
812
  `messageDraftRecommendation`.
828
- Connection-only lanes are the exception: do not include
829
- `messageDraftingReceipt` or run generated-message prep unless the operator
830
- separately asked for message copy. The parent verifier expects no message
831
- proof for connection-only lanes.
832
813
  - `reviewBatchReceipt`: review-batch row ids/hash, generated row count,
833
814
  quality-valid route-proof row id when approved, and proof that no broad
834
815
  approve-all occurred.
835
- Connection-only lanes are the exception: do not approve a route-proof row and
836
- do not include a required review-batch receipt.
837
816
  - `sequenceReceipt`: exact current workflowTableId, recommended non-paid
838
817
  sequence attach/precheck result, and readback showing `hasSequence:true` when
839
818
  completion is claimed. If the tool output uses
840
819
  `attachRecommendedSequenceResult.actionTypes` or
841
820
  `nonPaidRecommendedSequence`, copy them to canonical `actionTypes` and
842
821
  `nonPaid`.
843
- For connection-only lanes, do not call `attach_recommended_sequence`. Attach
844
- with `attach_sequence({ tableId, templateRef:"connection_only" })`, then
845
- include exact current-template proof:
846
- `sequenceReceipt.hasSequence:true`,
847
- `sequenceReceipt.actionTypes:["send_invite"]`, and
848
- `sequenceReceipt.nonPaid:true`. Receipts that only say
849
- `tool:"attach_recommended_sequence"` or include `send_dm`,
850
- `send_inmail_open`, `send_inmail_closed`, or `view_profile` are failing
851
- connection-only receipts.
852
822
  - final paused-send proof: if the current campaign table is `DRAFT` after the
853
823
  sequence is attached, call the product `pause_campaign({ campaignId })`
854
824
  endpoint/tool to put the unlaunched campaign into `PAUSED` review state, then
855
825
  reread the campaign/table. Do not raw-write `campaignStatus`, do not start or
856
826
  launch, and do not schedule/send. Completion requires reread proof of
857
- `currentStep:"send"` and `campaignStatus:"PAUSED"` for standard and
858
- connection-only customer-visible lanes. `send` is the product's paused
859
- review step; do not use unsupported aliases such as `review` as final proof.
860
- If the raw output nests this in `finalCampaignRead` or `finalTableRead`, copy
861
- it to canonical
827
+ `currentStep:"send"` and `campaignStatus:"PAUSED"`. If the raw output nests
828
+ this in `finalCampaignRead` or `finalTableRead`, copy it to canonical
862
829
  `finalPausedSendProof.currentStep` and
863
830
  `finalPausedSendProof.campaignStatus`.
864
831
  - `verifyCall`: the exact `setup_evergreen_campaigns({ mode:"verify",
@@ -1121,14 +1088,9 @@ plain row generation. The lane worker must inline the same
1121
1088
  approvedGeneratedMessageCount exactly 1. Do not report completion with 2+
1122
1089
  approved rows.
1123
1090
  Approval shorthand: Do not report completion with 2+ approved rows.
1124
- 8. Standard mode: continue to
1125
- `attach_recommended_sequence({ campaignId, currentStep:"send" })` and, if
1126
- the campaign is still `DRAFT`, `pause_campaign({ campaignId })`. Reread the
1127
- campaign/table before claiming completion.
1128
- Connection-only mode: skip Message Drafting and route-proof approval, call
1129
- `attach_sequence({ tableId, templateRef:"connection_only" })`, prove
1130
- `sequenceReceipt.actionTypes:["send_invite"]`, and pause/reread without
1131
- 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.
1132
1094
 
1133
1095
  That packaged worker path must return `messageDraftingReceipt.statusSource:
1134
1096
  "packaged-generate-messages-worker"`. It is accepted only when the receipt
@@ -1553,14 +1515,11 @@ Message, and verify current-revision sample messages before final completion.
1553
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.
1554
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.
1555
1517
 
1556
- - 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
1557
1519
  templates here. Sales Nav/Recruiter senders may receive the unified Sales
1558
1520
  Nav cascade through `attach_recommended_sequence`; attaching it does not
1559
1521
  spend paid InMail credits by itself. Do not substitute the manual Paid
1560
1522
  InMail Campaign template.
1561
- For explicit connection-only evergreen lanes only, use the backend-owned
1562
- `attach_sequence({ tableId, templateRef:"connection_only" })` path and do
1563
- not call `attach_recommended_sequence`.
1564
1523
  3. **Customer-Visible Completion Contract**: a named evergreen lane that appears
1565
1524
  as a campaign card or campaign-backed table is not done when the shell exists.
1566
1525
  It is done only when the customer can open the campaign and land on final
@@ -1584,7 +1543,7 @@ Message, and verify current-revision sample messages before final completion.
1584
1543
  evergreen setup. Do not leave customer-visible campaigns at
1585
1544
  `filter-choice`, `filter-rules`, or `apply-icp-rubric` and report success.
1586
1545
  Do not proceed to Message Drafting until saved filters are applied.
1587
- - 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
1588
1547
  create-campaign message prompt/assets and validation gate. Updating
1589
1548
  `currentStep:"messages"` is not proof. Parent-thread handwritten copy is
1590
1549
  not a substitute. The proof receipt must show
@@ -1625,9 +1584,6 @@ Message, and verify current-revision sample messages before final completion.
1625
1584
  Fallback samples must still reject source/conversation hedges such as
1626
1585
  `"hope this is relevant"`, `"might be interested"`, or `"saw you in a few
1627
1586
  conversations"`.
1628
- For explicit connection-only lanes, this whole Message Drafting and sample
1629
- proof block is not required and must not be faked with parent-written
1630
- messages.
1631
1587
  - The first review batch exists and at least 3 review rows have generated
1632
1588
  messages from the approved brief. If fewer than 3 usable rows exist, report
1633
1589
  the actual count and why.
@@ -1640,7 +1596,7 @@ Message, and verify current-revision sample messages before final completion.
1640
1596
  completion, approve exactly one quality-valid generated row. If one or more
1641
1597
  rows are already approved, do not add more approvals during evergreen
1642
1598
  completion. Never broad approve all rows.
1643
- - 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
1644
1600
  table, and the watched campaign is on Send. Use
1645
1601
  `attach_recommended_sequence({ campaignId, currentStep:"send" })` when a
1646
1602
  safe attach is needed. After `confirm_lead_list` or any source-list copy,
@@ -1655,10 +1611,6 @@ Message, and verify current-revision sample messages before final completion.
1655
1611
  manual Paid InMail Campaign, and never attach/replace sequence outside the
1656
1612
  current table unless the current lane packet explicitly allowed sequence
1657
1613
  repair.
1658
- For explicit connection-only lanes, the current campaign table must have
1659
- exactly the canonical invite-only sequence:
1660
- `sequenceReceipt.actionTypes:["send_invite"]`. Any DM, InMail, or View
1661
- Profile action means the lane is not complete.
1662
1614
  - If the current campaign table is still `DRAFT` after sequence/readiness
1663
1615
  proof, call `pause_campaign({ campaignId })` and reread. `pause_campaign`
1664
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