@sellable/install 0.1.309 → 0.1.311

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,139 @@
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
+ Each Slack-enabled customer profile should have its own Slack app installation:
9
+
10
+ - one profile-local `SLACK_BOT_TOKEN`
11
+ - one profile-local `SLACK_APP_TOKEN`
12
+ - one `SLACK_HOME_CHANNEL`
13
+ - one fail-closed `SLACK_ALLOWED_CHANNELS` allowlist
14
+
15
+ The bot should be invited only to the customer's managed channel, normally
16
+ `sellable-{customerSlug}`.
17
+
18
+ Shared Slack credentials can work for outbound-only smoke tests, but they are a
19
+ weaker isolation model. Do not run multiple active Socket Mode listeners against
20
+ the same Slack app token unless there is a separate dispatcher architecture that
21
+ routes events to profiles.
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: only one profile should own an active Socket Mode
39
+ connection for a given `SLACK_APP_TOKEN`.
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
+ ## 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
+ The profile-local `.env` should 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
+ ## Verification
117
+
118
+ Run the installer UAT before publishing installer changes:
119
+
120
+ ```bash
121
+ npm run test:unit -- tests/install-package/agent-preferences.test.ts
122
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode local-temp
123
+ scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode linux-shaped
124
+ ```
125
+
126
+ Expected proof:
127
+
128
+ - profile-local Sellable config paths remain authoritative
129
+ - generated profile `.env` has `SLACK_HOME_CHANNEL`
130
+ - generated profile `.env` has `SLACK_ALLOWED_CHANNELS`
131
+ - `acme` allowlist points at the `sellable-acme` fixture channel id
132
+ - redaction scan passes
133
+
134
+ ## Current Phase 03 Status
135
+
136
+ Phase 03 has proved the profile-scoped installer path, outbound Hermes send
137
+ path, and live Slack listener for the `sellable-admin` profile. The
138
+ customer-specific app/token creation path remains the next hardening step before
139
+ multiple active customer listeners are safe.
package/README.md CHANGED
@@ -122,12 +122,8 @@ It creates:
122
122
  /srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
123
123
  ```
124
124
 
125
- For profile paths under a gateway root such as
126
- `/srv/hermes/profiles/acme`, bootstrap also registers
127
- `/srv/hermes/profiles/acme/skills/sellable` in the gateway root
128
- `/srv/hermes/config.yaml` as `skills.external_dirs`. Keep this behavior:
129
- Hermes Desktop command autocomplete scans the gateway/root skill config, not
130
- only the selected profile's local skill tree.
125
+ See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
126
+ the customer Slack isolation contract and Socket Mode token requirements.
131
127
 
132
128
  If no token is supplied, bootstrap still creates a pending
133
129
  `sellable/config.json` so the profile path is concrete from the first MCP
@@ -149,6 +145,36 @@ sellable hermes profile bootstrap \
149
145
  --slack-app-token "$SLACK_APP_TOKEN"
150
146
  ```
151
147
 
148
+ The Slack app must also be configured for inbound Socket Mode delivery. Outbound
149
+ Slack Web API sends can work before this is complete, but Hermes will not hear
150
+ mentions until the app has Socket Mode enabled, an app-level token with
151
+ `connections:write`, bot scopes/events for `app_mention` plus channel/group/IM
152
+ message events, and a fresh workspace reinstall after scope or event changes.
153
+
154
+ Scope the profile to its customer channel at install time:
155
+
156
+ ```bash
157
+ sellable hermes profile bootstrap \
158
+ --profile acme \
159
+ --profiles-root /srv/hermes/profiles \
160
+ --workspace-id ws_acme \
161
+ --slack-bot-token "$SLACK_BOT_TOKEN" \
162
+ --slack-app-token "$SLACK_APP_TOKEN" \
163
+ --slack-home-channel C0ACME12345 \
164
+ --slack-home-channel-name sellable-acme \
165
+ --slack-allowed-users U0OPERATOR1 \
166
+ --slack-require-mention true
167
+ ```
168
+
169
+ When `--slack-home-channel` is supplied without
170
+ `--slack-allowed-channels`, bootstrap writes
171
+ `SLACK_ALLOWED_CHANNELS=<home-channel-id>` too. That fail-closes a customer
172
+ profile to the intended channel by default. Use
173
+ `--slack-allowed-channels C...,G...` only when a profile is deliberately allowed
174
+ to listen in multiple channels. Pass channel IDs, not `#channel-name`, and use
175
+ separate Slack bot/app tokens per customer profile when you need hard isolation
176
+ between concurrently listening Hermes profiles.
177
+
152
178
  Customer profiles should use customer-scoped Sellable credentials. A shared
153
179
  Sellable admin token can work for internal admin automation, but it is weaker
154
180
  isolation and should not be the default for customer profiles. After changing
@@ -13,7 +13,7 @@ import {
13
13
  writeFileSync,
14
14
  } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { basename, dirname, join, relative, resolve } from "node:path";
16
+ import { dirname, join, relative, resolve } from "node:path";
17
17
  import { stdout as output } from "node:process";
18
18
  import { createInterface } from "node:readline/promises";
19
19
  import { fileURLToPath } from "node:url";
@@ -217,9 +217,11 @@ 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 and Slack credentials for customer profiles; shared
224
+ admin tokens are weaker isolation and should remain internal-only.
223
225
  `;
224
226
  }
225
227
 
@@ -1180,10 +1182,9 @@ const REFRESH_SENDER_ENGAGEMENT_ALLOWED_TOOLS = [
1180
1182
  "mcp__sellable__get_campaign_table_schema",
1181
1183
  "mcp__sellable__list_tables",
1182
1184
  "mcp__sellable__get_rows_minimal",
1185
+ "mcp__sellable__refresh_sender_engagement",
1183
1186
  "mcp__sellable__fetch_linkedin_posts",
1184
- "mcp__sellable__select_promising_posts",
1185
1187
  "mcp__sellable__fetch_post_engagers",
1186
- "mcp__sellable__add_on_demand_leads",
1187
1188
  ];
1188
1189
 
1189
1190
  function allowedToolsYaml(tools) {
@@ -2145,10 +2146,10 @@ ${allowedToolsYaml(REFRESH_SENDER_ENGAGEMENT_ALLOWED_TOOLS)}
2145
2146
 
2146
2147
  Use this as the customer-facing entrypoint for the Sellable
2147
2148
  \`refresh-sender-engagement\` workflow. It finds active sender-owned Post
2148
- Engagers / Signal Discovery campaigns, fetches recent sender-authored LinkedIn
2149
- posts, pulls latest engagers, filters to ICP, and adds net-new leads with
2150
- dedupe. It never creates messages, approves rows, schedules sends, or launches
2151
- campaigns.
2149
+ Engagers campaigns, runs the typed \`refresh_sender_engagement\` command in
2150
+ \`mode:"dry_run"\`, and gates any \`mode:"apply"\` call with the returned
2151
+ \`dryRunFingerprint\`. It never creates messages, approves rows, schedules
2152
+ sends, or launches campaigns.
2152
2153
 
2153
2154
  ## Bootstrap
2154
2155
 
@@ -2168,9 +2169,15 @@ Desktop, then start a new thread.
2168
2169
  \`mcp__sellable__get_subskill_prompt({ subskillName: "refresh-sender-engagement" })\`.
2169
2170
  If the response has \`hasMore=true\`, continue with \`nextOffset\` until
2170
2171
  \`hasMore=false\`.
2171
- 3. Follow the canonical prompt exactly. Target active/paused sender-owned Post
2172
- Engagers campaigns backed by Signal Discovery, and do not touch shared lanes
2173
- or send-state tools.
2172
+ 3. Follow the canonical prompt exactly. Prefer
2173
+ \`mcp__sellable__refresh_sender_engagement\` for dry-run and apply. Do not use
2174
+ primitive lead import tools as an alternate write path.
2175
+ 4. For manual runs, show the dry-run target workspace, sender, campaign/table,
2176
+ expected impact, and \`dryRunFingerprint\`; wait for explicit approval before
2177
+ apply. For scheduled automations, apply only with the fingerprint from the
2178
+ immediately preceding dry run.
2179
+ 5. Do not touch shared lanes, campaign status, approval, message preparation, or
2180
+ send-state tools.
2174
2181
 
2175
2182
  ## MCP Prompt Fallback
2176
2183
 
@@ -3663,19 +3670,6 @@ function hermesSkillsRoot(opts = {}) {
3663
3670
  return join(hermesHome(opts), "skills", "sellable");
3664
3671
  }
3665
3672
 
3666
- function hermesGatewayRoot(opts = {}) {
3667
- const profileRoot = hermesHome(opts);
3668
- const profilesRoot = dirname(profileRoot);
3669
- if (basename(profilesRoot) !== "profiles") return null;
3670
- return dirname(profilesRoot);
3671
- }
3672
-
3673
- function hermesGatewayConfigPath(opts = {}) {
3674
- const root = hermesGatewayRoot(opts);
3675
- if (!root) return null;
3676
- return join(root, "config.yaml");
3677
- }
3678
-
3679
3673
  function hermesLikelyInstalled() {
3680
3674
  return (
3681
3675
  Boolean(process.env.HERMES_HOME?.trim()) ||
@@ -3755,38 +3749,6 @@ function writeHermesMcpServer(opts) {
3755
3749
  return configPath;
3756
3750
  }
3757
3751
 
3758
- function writeHermesGatewaySkillDirectory(opts) {
3759
- const configPath = hermesGatewayConfigPath(opts);
3760
- if (!configPath || configPath === hermesConfigPath(opts)) return null;
3761
-
3762
- const skillDir = hermesSkillsRoot(opts);
3763
- const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
3764
- const config = parseHermesConfig(raw, configPath);
3765
- // Hermes Desktop slash autocomplete scans the gateway/root HERMES_HOME. A
3766
- // profile can execute its own skills without this, but Desktop will not show
3767
- // /sellable-* commands until the root scanner sees the profile skill dir.
3768
- const skills =
3769
- config.skills && typeof config.skills === "object" && !Array.isArray(config.skills)
3770
- ? config.skills
3771
- : {};
3772
- const current = Array.isArray(skills.external_dirs)
3773
- ? skills.external_dirs
3774
- : skills.external_dirs
3775
- ? [skills.external_dirs]
3776
- : [];
3777
- const externalDirs = current.includes(skillDir) ? current : [...current, skillDir];
3778
- const nextConfig = {
3779
- ...config,
3780
- skills: {
3781
- ...skills,
3782
- external_dirs: externalDirs,
3783
- },
3784
- };
3785
-
3786
- writeHermesConfig(configPath, nextConfig, opts);
3787
- return { configPath, skillDir };
3788
- }
3789
-
3790
3752
  function installHermesSkills(opts) {
3791
3753
  const root = hermesSkillsRoot(opts);
3792
3754
  for (const skill of hermesSkillDefinitions()) {
@@ -3807,15 +3769,8 @@ function installHermes(opts) {
3807
3769
  try {
3808
3770
  skillsRoot = installHermesSkills(opts);
3809
3771
  const configPath = writeHermesMcpServer(opts);
3810
- const gateway = writeHermesGatewaySkillDirectory(opts);
3811
3772
  logVerbose(`${C.grey}+ Hermes config updated: ${configPath}${C.reset}`);
3812
- return {
3813
- installed: true,
3814
- configPath,
3815
- skillsRoot,
3816
- gatewayConfigPath: gateway?.configPath || null,
3817
- gatewayExternalSkillsDir: gateway?.skillDir || null,
3818
- };
3773
+ return { installed: true, configPath, skillsRoot };
3819
3774
  } catch (err) {
3820
3775
  if (skillsRoot && !opts.dryRun) {
3821
3776
  rmSync(skillsRoot, { recursive: true, force: true });
@@ -3836,6 +3791,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3836
3791
  apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
3837
3792
  slackBotToken: process.env.SLACK_BOT_TOKEN || "",
3838
3793
  slackAppToken: process.env.SLACK_APP_TOKEN || "",
3794
+ slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
3795
+ slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
3796
+ slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
3797
+ slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
3798
+ slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
3799
+ slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
3839
3800
  server: process.env.SELLABLE_INSTALL_SERVER || "package",
3840
3801
  mcpPackage: DEFAULT_SERVER_PACKAGE,
3841
3802
  localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
@@ -3868,6 +3829,12 @@ function parseHermesProfileBootstrapArgs(argv) {
3868
3829
  else if (arg === "--api-url") opts.apiUrl = next();
3869
3830
  else if (arg === "--slack-bot-token") opts.slackBotToken = next();
3870
3831
  else if (arg === "--slack-app-token") opts.slackAppToken = next();
3832
+ else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
3833
+ else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
3834
+ else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
3835
+ else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
3836
+ else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
3837
+ else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
3871
3838
  else if (arg === "--server") opts.server = next();
3872
3839
  else if (arg === "--mcp-package") {
3873
3840
  opts.mcpPackage = normalizeWindowsPackageSpec(next());
@@ -3969,6 +3936,111 @@ function renderEnvFile({ entries, passthrough }) {
3969
3936
  return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
3970
3937
  }
3971
3938
 
3939
+ function normalizeSlackChannelIds(value, key) {
3940
+ const raw = String(value || "").trim();
3941
+ if (!raw) return "";
3942
+ const ids = raw
3943
+ .split(",")
3944
+ .map((item) => item.trim())
3945
+ .filter(Boolean);
3946
+ if (ids.length === 0) return "";
3947
+
3948
+ for (const id of ids) {
3949
+ if (
3950
+ id === "*" ||
3951
+ /^all$/i.test(id) ||
3952
+ id.startsWith("#") ||
3953
+ /\s/.test(id) ||
3954
+ !/^[A-Z][A-Z0-9]{8,}$/.test(id)
3955
+ ) {
3956
+ throw new Error(
3957
+ `${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
3958
+ );
3959
+ }
3960
+ }
3961
+ return [...new Set(ids)].join(",");
3962
+ }
3963
+
3964
+ function normalizeSlackSingleChannelId(value, key) {
3965
+ const normalized = normalizeSlackChannelIds(value, key);
3966
+ if (normalized.includes(",")) {
3967
+ throw new Error(`${key} must be a single Slack channel ID`);
3968
+ }
3969
+ return normalized;
3970
+ }
3971
+
3972
+ function normalizeSlackUserIds(value, key) {
3973
+ const raw = String(value || "").trim();
3974
+ if (!raw) return "";
3975
+ const ids = raw
3976
+ .split(",")
3977
+ .map((item) => item.trim())
3978
+ .filter(Boolean);
3979
+ if (ids.length === 0) return "";
3980
+
3981
+ for (const id of ids) {
3982
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
3983
+ throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
3984
+ }
3985
+ }
3986
+ return [...new Set(ids)].join(",");
3987
+ }
3988
+
3989
+ function normalizeSlackChannelName(value, key) {
3990
+ const raw = String(value || "").trim();
3991
+ if (!raw) return "";
3992
+ if (raw.startsWith("#") || /[\s,]/.test(raw)) {
3993
+ throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
3994
+ }
3995
+ return raw;
3996
+ }
3997
+
3998
+ function normalizeSlackBoolean(value, key) {
3999
+ const raw = String(value || "").trim().toLowerCase();
4000
+ if (!raw) return "";
4001
+ if (raw !== "true" && raw !== "false") {
4002
+ throw new Error(`${key} must be true or false`);
4003
+ }
4004
+ return raw;
4005
+ }
4006
+
4007
+ function buildSlackProfileEnv(parsed) {
4008
+ const homeChannel = normalizeSlackSingleChannelId(
4009
+ parsed.slackHomeChannel,
4010
+ "SLACK_HOME_CHANNEL"
4011
+ );
4012
+ const explicitAllowedChannels = normalizeSlackChannelIds(
4013
+ parsed.slackAllowedChannels,
4014
+ "SLACK_ALLOWED_CHANNELS"
4015
+ );
4016
+ const allowedChannels = explicitAllowedChannels || homeChannel;
4017
+
4018
+ return {
4019
+ SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4020
+ SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4021
+ SLACK_HOME_CHANNEL: homeChannel,
4022
+ SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
4023
+ parsed.slackHomeChannelName,
4024
+ "SLACK_HOME_CHANNEL_NAME"
4025
+ ),
4026
+ // Customer Hermes profiles must fail closed to their channel. If the caller
4027
+ // supplies only a home channel, we treat that as the allowlist too.
4028
+ SLACK_ALLOWED_CHANNELS: allowedChannels,
4029
+ SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
4030
+ parsed.slackFreeResponseChannels,
4031
+ "SLACK_FREE_RESPONSE_CHANNELS"
4032
+ ),
4033
+ SLACK_ALLOWED_USERS: normalizeSlackUserIds(
4034
+ parsed.slackAllowedUsers,
4035
+ "SLACK_ALLOWED_USERS"
4036
+ ),
4037
+ SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
4038
+ parsed.slackRequireMention,
4039
+ "SLACK_REQUIRE_MENTION"
4040
+ ),
4041
+ };
4042
+ }
4043
+
3972
4044
  function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
3973
4045
  const duplicates = [];
3974
4046
  const profilesRoot = dirname(profileRoot);
@@ -4021,10 +4093,14 @@ function runHermesProfileBootstrap(argv) {
4021
4093
  const sellableConfigsDir = join(profileRoot, "sellable", "configs");
4022
4094
  const envPath = join(profileRoot, ".env");
4023
4095
  const { token, source: tokenSource } = readBootstrapToken(parsed);
4096
+ const slackEnv = buildSlackProfileEnv(parsed);
4024
4097
  const slackTokens = {
4025
- SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
4026
- SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
4098
+ SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
4099
+ SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
4027
4100
  };
4101
+ const slackEnvKeys = Object.entries(slackEnv)
4102
+ .filter(([, value]) => value)
4103
+ .map(([key]) => key);
4028
4104
  const opts = {
4029
4105
  ...parsed,
4030
4106
  host: "hermes",
@@ -4053,7 +4129,6 @@ function runHermesProfileBootstrap(argv) {
4053
4129
  sellableConfigPath,
4054
4130
  sellableConfigsDir,
4055
4131
  skillsRoot: hermesSkillsRoot(opts),
4056
- gatewayConfigPath: hermesGatewayConfigPath(opts),
4057
4132
  },
4058
4133
  auth: {
4059
4134
  apiUrl: opts.apiUrl,
@@ -4067,11 +4142,29 @@ function runHermesProfileBootstrap(argv) {
4067
4142
  keys: Object.entries(slackTokens)
4068
4143
  .filter(([, value]) => value)
4069
4144
  .map(([key]) => key),
4145
+ envKeys: slackEnvKeys,
4070
4146
  fingerprints: Object.fromEntries(
4071
4147
  Object.entries(slackTokens)
4072
4148
  .filter(([, value]) => value)
4073
4149
  .map(([key, value]) => [key, tokenFingerprint(value)])
4074
4150
  ),
4151
+ scope: {
4152
+ homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
4153
+ homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
4154
+ allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
4155
+ ? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
4156
+ : [],
4157
+ freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
4158
+ ? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
4159
+ : [],
4160
+ allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
4161
+ ? slackEnv.SLACK_ALLOWED_USERS.split(",").length
4162
+ : 0,
4163
+ requireMention:
4164
+ slackEnv.SLACK_REQUIRE_MENTION === ""
4165
+ ? null
4166
+ : slackEnv.SLACK_REQUIRE_MENTION === "true",
4167
+ },
4075
4168
  },
4076
4169
  created: [],
4077
4170
  updated: [],
@@ -4116,7 +4209,7 @@ function runHermesProfileBootstrap(argv) {
4116
4209
  if (existsSync(envPath)) {
4117
4210
  envState = parseEnvLines(readFileSync(envPath, "utf8"));
4118
4211
  }
4119
- for (const [key, value] of Object.entries(slackTokens)) {
4212
+ for (const [key, value] of Object.entries(slackEnv)) {
4120
4213
  if (!value) continue;
4121
4214
  const existing = envState.entries.get(key);
4122
4215
  if (existing && existing !== value && !opts.overwriteEnv) {
@@ -4138,8 +4231,6 @@ function runHermesProfileBootstrap(argv) {
4138
4231
  }
4139
4232
 
4140
4233
  const hermesConfigExisted = existsSync(hermesConfigPath(opts));
4141
- const gatewayConfigPath = hermesGatewayConfigPath(opts);
4142
- const gatewayConfigExisted = gatewayConfigPath ? existsSync(gatewayConfigPath) : false;
4143
4234
  const skillsExisted = existsSync(hermesSkillsRoot(opts));
4144
4235
  const authExisted = existsSync(sellableConfigPath);
4145
4236
  const configsDirExisted = existsSync(sellableConfigsDir);
@@ -4178,8 +4269,8 @@ function runHermesProfileBootstrap(argv) {
4178
4269
  mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
4179
4270
  writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
4180
4271
  installHermes(opts);
4181
- if (Object.values(slackTokens).some(Boolean)) {
4182
- for (const [key, value] of Object.entries(slackTokens)) {
4272
+ if (Object.values(slackEnv).some(Boolean)) {
4273
+ for (const [key, value] of Object.entries(slackEnv)) {
4183
4274
  if (value) envState.entries.set(key, value);
4184
4275
  }
4185
4276
  writeFile(envPath, renderEnvFile(envState), opts, 0o600);
@@ -4193,16 +4284,21 @@ function runHermesProfileBootstrap(argv) {
4193
4284
  (hermesConfigExisted ? summary.updated : summary.created).push(
4194
4285
  hermesConfigPath(opts)
4195
4286
  );
4196
- if (gatewayConfigPath) {
4197
- (gatewayConfigExisted ? summary.updated : summary.created).push(
4198
- gatewayConfigPath
4199
- );
4200
- }
4201
4287
  (skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
4202
- if (Object.values(slackTokens).some(Boolean)) {
4288
+ if (Object.values(slackEnv).some(Boolean)) {
4203
4289
  (envExisted ? summary.updated : summary.created).push(envPath);
4204
4290
  } else {
4205
- summary.skipped.push("Slack env: no Slack tokens supplied");
4291
+ summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
4292
+ }
4293
+ if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
4294
+ summary.warnings.push(
4295
+ "Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
4296
+ );
4297
+ }
4298
+ if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
4299
+ summary.warnings.push(
4300
+ "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."
4301
+ );
4206
4302
  }
4207
4303
  if (!token) {
4208
4304
  summary.warnings.push(
@@ -35,6 +35,7 @@ export const REQUIRED_SELLABLE_MCP_TOOLS = [
35
35
  "refresh_paid_inmail_credits",
36
36
  "run_scheduler_sweep",
37
37
  "refill_sends",
38
+ "refresh_sender_engagement",
38
39
  "setup_evergreen_campaigns",
39
40
  "start_campaign_message_preparation",
40
41
  "get_campaign_message_preparation_status",
@@ -75,6 +76,14 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
75
76
  limit: 1200,
76
77
  },
77
78
  },
79
+ {
80
+ name: "get_subskill_prompt",
81
+ arguments: {
82
+ subskillName: "refresh-sender-engagement",
83
+ offset: 0,
84
+ limit: 1200,
85
+ },
86
+ },
78
87
  {
79
88
  name: "refill_sends",
80
89
  arguments: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.309",
3
+ "version": "0.1.311",
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",
@@ -19,10 +19,9 @@ allowed-tools:
19
19
  - mcp__sellable__get_campaign_table_schema
20
20
  - mcp__sellable__list_tables
21
21
  - mcp__sellable__get_rows_minimal
22
+ - mcp__sellable__refresh_sender_engagement
22
23
  - mcp__sellable__fetch_linkedin_posts
23
- - mcp__sellable__select_promising_posts
24
24
  - mcp__sellable__fetch_post_engagers
25
- - mcp__sellable__add_on_demand_leads
26
25
  ---
27
26
 
28
27
  # Refresh Sender Engagement
@@ -34,7 +33,7 @@ You are a pipeline supply agent. People who engage with a sender's LinkedIn post
34
33
  <inputs>
35
34
  The invoking prompt names the senders ("refresh sender engagement for csreyes92 and thomas"). Resolve each via `list_senders` (match name/handle/LinkedIn URL). With no names given, inspect the active workspace and refresh every connected sender that has an active/paused sender-owned Post Engagers campaign backed by Signal Discovery.
36
35
 
37
- Optional: lookback window (default: posts from the last 30 days), engagement sources (default `both` reactions+comments), target sender names/ids, maximum posts per sender (default 5, hard cap 5 unless the user explicitly asks for more), and target campaign ids/names when the user wants to force a specific campaign.
36
+ Optional: target sender names/ids, `tableId` when the user wants to force a specific campaign table, maximum posts per sender (default 5, hard cap 5 unless the user explicitly asks for more), and maximum engager pages per tracked post.
38
37
  </inputs>
39
38
 
40
39
  <entrypoint>
@@ -53,39 +52,36 @@ suggest `refill-sends`.
53
52
  <objective>
54
53
  For each target sender/campaign:
55
54
 
56
- 1. **Find active Signal Discovery/Post Engagers campaigns first**:
55
+ 1. **Authenticate and resolve workspace**:
57
56
  - Call `get_auth_status` and confirm the active workspace.
58
- - Call `get_campaigns` and, when needed, `list_tables`/`get_campaign` to find candidate campaigns.
59
- - Keep only active or paused campaign-backed sender-owned Post Engagers campaigns. Strong signals include campaign/table names like `<Sender> - Post Engagers`, `sourceProvider:"signal-discovery"`, sender attachment to exactly one sender, and source/list/table readback indicating post engagers.
60
- - Exclude shared Signal Discovery lanes, Shared Cold Fallback lanes, archived/completed campaigns, non-campaign tables, direct/on-demand-only campaigns, and any campaign attached to multiple senders unless the user explicitly selected it and the readback proves it is sender-owned.
61
- - If no matching campaign exists for a sender, report "no active Post Engagers Signal Discovery campaign — create one first" and skip; do not silently create campaigns.
62
- 2. **Resolve the sender and source boundary**:
63
- - Match each candidate campaign to its sender from `list_senders`/`get_sender`.
64
- - Use the sender's own LinkedIn profile URL/handle as the only source author boundary.
65
- - Never scrape third-party authors into a sender-owned Post Engagers campaign.
66
- 3. **Find relevant sender-authored posts to refresh**:
67
- - Call `fetch_linkedin_posts({ linkedinUrl: sender profile, limit: 25 })`.
68
- - Keep original posts authored by that exact sender, not reposts, from the lookback window.
69
- - Rank posts by recency, engagement count, and fit to the campaign's buyer/problem/topic. Prefer posts likely to attract the target buyer over generic company updates.
70
- - If the campaign has an existing Signal Discovery/source state that names tracked/selected posts, prefer refreshing those posts when they are still inside the lookback window and relevant; otherwise choose the strongest recent sender-authored posts.
71
- - When the product flow expects selected posts to be visible, call `select_promising_posts` before scraping.
72
- 4. **Pull latest engagers**:
73
- - For the top posts (up to 5 per sender per run), call `fetch_post_engagers({ postUrl, sources })`.
74
- - Default `sources` to `"both"` unless the user requested reactions-only or comments-only.
75
- 5. **Filter to ICP** using the campaign's existing headline ICP criteria (from `get_campaign` brief/rubrics/table schema). Judge each engager's headline against those criteria; exclude obvious non-fits, the sender's own colleagues, existing employees, students/job-seekers, competitors, and anyone with no usable headline. When the campaign has no criteria, keep likely decision-makers/operators and exclude weak-fit profiles.
76
- 6. **Add net-new leads only**:
77
- - Use `add_on_demand_leads({ tableId, leads, skipDuplicates: true })` with name, headline-derived title, profile URL, and source/post context where the tool accepts it.
78
- - Dedupe is mandatory. Never disable dedupe on a scheduled run.
79
- - Do not generate, approve, schedule, or send messages.
80
- 7. **Report**: campaigns inspected, target campaigns selected, posts scanned, engagers found, ICP-passing, net-new added per sender/campaign. If a sender posted nothing in the window, say "no recent posts — nothing to refresh" (that is a truthful no-op, not a failure).
57
+ - If there is no active workspace or auth is missing, stop with the MCP guidance. Do not use local env files or repo scripts as a substitute for MCP auth.
58
+ - Resolve each target sender with `list_senders`/`get_sender`. Use the sender id returned by the product, not a guessed handle.
59
+ 2. **Use the typed product command first**:
60
+ - Call `refresh_sender_engagement` in `mode:"dry_run"` for each sender.
61
+ - Always pass `workspaceId`, `senderId`, and any user-specified `tableId`, `maxPosts`, or `maxEngagerPages`.
62
+ - Treat the dry-run response as the campaign/source boundary authority. It should identify the sender-owned Post Engagers slot, campaign, workflow table, source provider `campaign-tracked-post`, source table type `tracked_post_engager_source_list`, expected tracked posts, expected engager refresh/import work, and a `dryRunFingerprint`.
63
+ - If the typed command reports no eligible sender-owned Post Engagers campaign, no tracked posts, no recent posts, or a workspace/sender mismatch, report that exact blocker and stop for that sender. Do not silently create campaigns or fall back to shared lanes.
64
+ 3. **Gate writes with dry-run proof**:
65
+ - Never call `refresh_sender_engagement` in `mode:"apply"` before a successful dry run from this same run.
66
+ - For manual runs, show the target workspace, sender, campaign/table, source lead list, expected tracked posts, expected row/import impact, and `dryRunFingerprint`, then wait for explicit user approval before apply.
67
+ - For scheduled automations where the user has already authorized refreshes, apply is allowed only with the exact `dryRunFingerprint` from the immediately preceding dry run.
68
+ - If apply rejects the fingerprint as stale or mismatched, rerun dry-run and ask for approval again for manual runs.
69
+ 4. **Apply through the typed command only**:
70
+ - Call `refresh_sender_engagement({ mode:"apply", workspaceId, senderId, tableId?, maxPosts?, maxEngagerPages?, dryRunFingerprint })`.
71
+ - The apply path must materialize/refresh tracked posts, import deduped tracked-post engagers into the campaign table, and preserve product idempotency. Do not use `add_on_demand_leads` or raw table row writes for the same operation.
72
+ 5. **Read-only fallback is diagnostic only**:
73
+ - If the typed command is unavailable, use `get_subskill_prompt`/`search_subskill_prompts` and stop with `blocked: missing_refresh_sender_engagement_tool`.
74
+ - `fetch_linkedin_posts` and `fetch_post_engagers` may be used only to explain source availability or diagnose provider issues. They are not an alternate write path.
75
+ 6. **Report**: campaigns inspected, target campaign/table, tracked posts materialized/refreshed, engagers scanned, ICP-passing/importable, net-new rows imported, duplicates skipped, and any no-op reason per sender/campaign. If a sender posted nothing in the window, say "no recent posts — nothing to refresh" (that is a truthful no-op, not a failure).
81
76
  </objective>
82
77
 
83
78
  <safety>
84
- - LinkedIn operations here are read-only fetches plus adding rows to a campaign table. **No messages are generated, approved, or sent by this skill.**
79
+ - LinkedIn operations here are read-only fetches plus a typed product import into a campaign table. **No messages are generated, approved, scheduled, or sent by this skill.**
85
80
  - Respect workspace boundaries: only add leads to campaigns in the active workspace, and only for senders that belong to it.
86
81
  - Respect campaign boundaries: only add engagers to the matched sender-owned Post Engagers campaign/table. Do not mix shared-lane engagers into sender-owned campaigns or sender-owned engagers into shared lanes.
87
82
  - Cap provider usage per run: at most 5 posts × `fetch_post_engagers` per sender. If the invoking automation wants more, it must say so explicitly.
88
- - Never call `start_campaign`, `attach_sequence`, `queue_campaign_cells`, `start_campaign_message_preparation`, approval tools, or any send/schedule tool.
83
+ - Never call `start_campaign`, `attach_sequence`, `queue_campaign_cells`, `start_campaign_message_preparation`, approval tools, send/schedule tools, or shared-lane mutation tools.
84
+ - Never create campaigns, change campaign status, change sender ownership, approve campaign cells, or start message prep as part of this refresh.
89
85
  </safety>
90
86
 
91
87
  <output>