@sellable/install 0.1.311 → 0.1.313
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.
- package/README.md +0 -33
- package/bin/sellable-install.mjs +10 -161
- package/lib/runtime-verify.mjs +249 -40
- package/package.json +2 -3
- package/skill-templates/create-evergreen-campaigns.md +55 -7
- package/skill-templates/refill-sends-v2.md +5 -0
- package/skill-templates/refill-sends.md +5 -0
- package/HERMES-SLACK-PROFILE-SCOPING.md +0 -139
package/README.md
CHANGED
|
@@ -122,9 +122,6 @@ It creates:
|
|
|
122
122
|
/srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
|
|
126
|
-
the customer Slack isolation contract and Socket Mode token requirements.
|
|
127
|
-
|
|
128
125
|
If no token is supplied, bootstrap still creates a pending
|
|
129
126
|
`sellable/config.json` so the profile path is concrete from the first MCP
|
|
130
127
|
launch. Finish auth with:
|
|
@@ -145,36 +142,6 @@ sellable hermes profile bootstrap \
|
|
|
145
142
|
--slack-app-token "$SLACK_APP_TOKEN"
|
|
146
143
|
```
|
|
147
144
|
|
|
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
|
-
|
|
178
145
|
Customer profiles should use customer-scoped Sellable credentials. A shared
|
|
179
146
|
Sellable admin token can work for internal admin automation, but it is weaker
|
|
180
147
|
isolation and should not be the default for customer profiles. After changing
|
package/bin/sellable-install.mjs
CHANGED
|
@@ -217,11 +217,9 @@ 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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
customer-scoped Sellable and Slack credentials for customer profiles; shared
|
|
224
|
-
admin tokens are weaker isolation and should remain internal-only.
|
|
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.
|
|
225
223
|
`;
|
|
226
224
|
}
|
|
227
225
|
|
|
@@ -3791,12 +3789,6 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3791
3789
|
apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
|
|
3792
3790
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
|
3793
3791
|
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 || "",
|
|
3800
3792
|
server: process.env.SELLABLE_INSTALL_SERVER || "package",
|
|
3801
3793
|
mcpPackage: DEFAULT_SERVER_PACKAGE,
|
|
3802
3794
|
localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
|
|
@@ -3829,12 +3821,6 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3829
3821
|
else if (arg === "--api-url") opts.apiUrl = next();
|
|
3830
3822
|
else if (arg === "--slack-bot-token") opts.slackBotToken = next();
|
|
3831
3823
|
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();
|
|
3838
3824
|
else if (arg === "--server") opts.server = next();
|
|
3839
3825
|
else if (arg === "--mcp-package") {
|
|
3840
3826
|
opts.mcpPackage = normalizeWindowsPackageSpec(next());
|
|
@@ -3936,111 +3922,6 @@ function renderEnvFile({ entries, passthrough }) {
|
|
|
3936
3922
|
return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
|
|
3937
3923
|
}
|
|
3938
3924
|
|
|
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
|
-
|
|
4044
3925
|
function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
|
|
4045
3926
|
const duplicates = [];
|
|
4046
3927
|
const profilesRoot = dirname(profileRoot);
|
|
@@ -4093,14 +3974,10 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4093
3974
|
const sellableConfigsDir = join(profileRoot, "sellable", "configs");
|
|
4094
3975
|
const envPath = join(profileRoot, ".env");
|
|
4095
3976
|
const { token, source: tokenSource } = readBootstrapToken(parsed);
|
|
4096
|
-
const slackEnv = buildSlackProfileEnv(parsed);
|
|
4097
3977
|
const slackTokens = {
|
|
4098
|
-
SLACK_BOT_TOKEN:
|
|
4099
|
-
SLACK_APP_TOKEN:
|
|
3978
|
+
SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
|
|
3979
|
+
SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
|
|
4100
3980
|
};
|
|
4101
|
-
const slackEnvKeys = Object.entries(slackEnv)
|
|
4102
|
-
.filter(([, value]) => value)
|
|
4103
|
-
.map(([key]) => key);
|
|
4104
3981
|
const opts = {
|
|
4105
3982
|
...parsed,
|
|
4106
3983
|
host: "hermes",
|
|
@@ -4142,29 +4019,11 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4142
4019
|
keys: Object.entries(slackTokens)
|
|
4143
4020
|
.filter(([, value]) => value)
|
|
4144
4021
|
.map(([key]) => key),
|
|
4145
|
-
envKeys: slackEnvKeys,
|
|
4146
4022
|
fingerprints: Object.fromEntries(
|
|
4147
4023
|
Object.entries(slackTokens)
|
|
4148
4024
|
.filter(([, value]) => value)
|
|
4149
4025
|
.map(([key, value]) => [key, tokenFingerprint(value)])
|
|
4150
4026
|
),
|
|
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
|
-
},
|
|
4168
4027
|
},
|
|
4169
4028
|
created: [],
|
|
4170
4029
|
updated: [],
|
|
@@ -4209,7 +4068,7 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4209
4068
|
if (existsSync(envPath)) {
|
|
4210
4069
|
envState = parseEnvLines(readFileSync(envPath, "utf8"));
|
|
4211
4070
|
}
|
|
4212
|
-
for (const [key, value] of Object.entries(
|
|
4071
|
+
for (const [key, value] of Object.entries(slackTokens)) {
|
|
4213
4072
|
if (!value) continue;
|
|
4214
4073
|
const existing = envState.entries.get(key);
|
|
4215
4074
|
if (existing && existing !== value && !opts.overwriteEnv) {
|
|
@@ -4269,8 +4128,8 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4269
4128
|
mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
|
|
4270
4129
|
writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
|
|
4271
4130
|
installHermes(opts);
|
|
4272
|
-
if (Object.values(
|
|
4273
|
-
for (const [key, value] of Object.entries(
|
|
4131
|
+
if (Object.values(slackTokens).some(Boolean)) {
|
|
4132
|
+
for (const [key, value] of Object.entries(slackTokens)) {
|
|
4274
4133
|
if (value) envState.entries.set(key, value);
|
|
4275
4134
|
}
|
|
4276
4135
|
writeFile(envPath, renderEnvFile(envState), opts, 0o600);
|
|
@@ -4285,20 +4144,10 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4285
4144
|
hermesConfigPath(opts)
|
|
4286
4145
|
);
|
|
4287
4146
|
(skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
|
|
4288
|
-
if (Object.values(
|
|
4147
|
+
if (Object.values(slackTokens).some(Boolean)) {
|
|
4289
4148
|
(envExisted ? summary.updated : summary.created).push(envPath);
|
|
4290
4149
|
} else {
|
|
4291
|
-
summary.skipped.push("Slack env: no Slack tokens
|
|
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
|
-
);
|
|
4150
|
+
summary.skipped.push("Slack env: no Slack tokens supplied");
|
|
4302
4151
|
}
|
|
4303
4152
|
if (!token) {
|
|
4304
4153
|
summary.warnings.push(
|
package/lib/runtime-verify.mjs
CHANGED
|
@@ -45,6 +45,14 @@ 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
|
+
|
|
48
56
|
export const CREATE_CAMPAIGN_SMOKE_CALLS = [
|
|
49
57
|
{
|
|
50
58
|
name: "get_auth_status",
|
|
@@ -84,6 +92,14 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
|
|
|
84
92
|
limit: 1200,
|
|
85
93
|
},
|
|
86
94
|
},
|
|
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
|
+
},
|
|
87
103
|
{
|
|
88
104
|
name: "refill_sends",
|
|
89
105
|
arguments: {
|
|
@@ -187,6 +203,43 @@ function parseJsonText(text) {
|
|
|
187
203
|
}
|
|
188
204
|
}
|
|
189
205
|
|
|
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
|
+
|
|
190
243
|
function summarizeToolResult(name, result) {
|
|
191
244
|
const text = redact(textFromToolResult(result));
|
|
192
245
|
const parsed = parseJsonText(text);
|
|
@@ -230,10 +283,26 @@ function summarizeToolResult(name, result) {
|
|
|
230
283
|
}
|
|
231
284
|
|
|
232
285
|
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;
|
|
233
291
|
return {
|
|
234
292
|
...summary,
|
|
235
293
|
subskillName: parsed.name || null,
|
|
236
|
-
promptChars:
|
|
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,
|
|
237
306
|
hasMore: parsed.hasMore === true,
|
|
238
307
|
nextOffset:
|
|
239
308
|
typeof parsed.nextOffset === "number" ? parsed.nextOffset : null,
|
|
@@ -267,6 +336,7 @@ async function verifyCreateCampaignSmoke({
|
|
|
267
336
|
.map((call) => call.name)
|
|
268
337
|
.filter((name) => !available.has(name));
|
|
269
338
|
const calls = [];
|
|
339
|
+
const connectionOnlyEvergreenProofs = emptyConnectionOnlyEvergreenProofs();
|
|
270
340
|
|
|
271
341
|
if (missingTools.length > 0) {
|
|
272
342
|
return {
|
|
@@ -280,67 +350,195 @@ async function verifyCreateCampaignSmoke({
|
|
|
280
350
|
}
|
|
281
351
|
|
|
282
352
|
for (const call of smokeCalls) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
};
|
|
291
386
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
};
|
|
451
|
+
}
|
|
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
|
+
});
|
|
303
474
|
return {
|
|
304
475
|
ok: false,
|
|
305
476
|
missingTools: [],
|
|
306
477
|
calls,
|
|
307
|
-
error:
|
|
478
|
+
error: redact(error),
|
|
308
479
|
startedAt,
|
|
309
480
|
completedAt: new Date().toISOString(),
|
|
310
481
|
};
|
|
311
482
|
}
|
|
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
|
-
};
|
|
331
483
|
}
|
|
332
484
|
}
|
|
333
485
|
|
|
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
|
+
|
|
334
500
|
return {
|
|
335
501
|
ok: true,
|
|
336
502
|
missingTools: [],
|
|
337
503
|
calls,
|
|
504
|
+
connectionOnlyEvergreenGuidance,
|
|
338
505
|
error: null,
|
|
339
506
|
startedAt,
|
|
340
507
|
completedAt: new Date().toISOString(),
|
|
341
508
|
};
|
|
342
509
|
}
|
|
343
510
|
|
|
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
|
+
|
|
344
542
|
function summarizeAttempt(result, attempt) {
|
|
345
543
|
return {
|
|
346
544
|
attempt,
|
|
@@ -348,6 +546,12 @@ function summarizeAttempt(result, attempt) {
|
|
|
348
546
|
availableToolCount: result.availableTools?.length || 0,
|
|
349
547
|
missingToolCount: result.missingTools?.length || 0,
|
|
350
548
|
forbiddenToolCount: result.forbiddenTools?.length || 0,
|
|
549
|
+
connectionOnlyEvergreenContract: result.connectionOnlyEvergreenContract
|
|
550
|
+
? {
|
|
551
|
+
ok: result.connectionOnlyEvergreenContract.ok === true,
|
|
552
|
+
issues: result.connectionOnlyEvergreenContract.issues || [],
|
|
553
|
+
}
|
|
554
|
+
: null,
|
|
351
555
|
error: result.error || null,
|
|
352
556
|
createCampaignSmoke: result.createCampaignSmoke
|
|
353
557
|
? {
|
|
@@ -419,6 +623,7 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
419
623
|
let missingTools = [...requiredTools];
|
|
420
624
|
let forbiddenTools = [];
|
|
421
625
|
let createCampaignSmoke = null;
|
|
626
|
+
let connectionOnlyEvergreenContract = { ok: false, issues: [] };
|
|
422
627
|
let error = null;
|
|
423
628
|
|
|
424
629
|
try {
|
|
@@ -432,6 +637,8 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
432
637
|
forbiddenTools = availableTools.filter((tool) =>
|
|
433
638
|
FORBIDDEN_SELLABLE_MCP_TOOLS.includes(tool)
|
|
434
639
|
);
|
|
640
|
+
connectionOnlyEvergreenContract =
|
|
641
|
+
verifyConnectionOnlyEvergreenContract(toolList.tools);
|
|
435
642
|
if (forbiddenTools.length > 0) {
|
|
436
643
|
createCampaignSmoke = {
|
|
437
644
|
ok: false,
|
|
@@ -475,6 +682,7 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
475
682
|
!error &&
|
|
476
683
|
missingTools.length === 0 &&
|
|
477
684
|
forbiddenTools.length === 0 &&
|
|
685
|
+
connectionOnlyEvergreenContract.ok === true &&
|
|
478
686
|
createCampaignSmoke?.ok === true,
|
|
479
687
|
skipped: false,
|
|
480
688
|
command,
|
|
@@ -483,6 +691,7 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
483
691
|
availableTools,
|
|
484
692
|
missingTools,
|
|
485
693
|
forbiddenTools,
|
|
694
|
+
connectionOnlyEvergreenContract,
|
|
486
695
|
createCampaignSmoke,
|
|
487
696
|
stderrTail: stderrLines,
|
|
488
697
|
error: error ? redact(error) : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sellable/install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.313",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
|
|
6
6
|
"bin": {
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
"lib",
|
|
16
16
|
"agents",
|
|
17
17
|
"skill-templates",
|
|
18
|
-
"README.md"
|
|
19
|
-
"HERMES-SLACK-PROFILE-SCOPING.md"
|
|
18
|
+
"README.md"
|
|
20
19
|
],
|
|
21
20
|
"dependencies": {
|
|
22
21
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
@@ -134,6 +134,18 @@ 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.
|
|
137
149
|
|
|
138
150
|
If the invoking prompt explicitly asks for interactive message polish or sample
|
|
139
151
|
proof, run in **interactive polish mode** and use the confirmation/sample steps
|
|
@@ -720,6 +732,9 @@ not a parent-thread summary. The receipt must include:
|
|
|
720
732
|
creation, preserve that original packet only in a separate `parentLanePacket`
|
|
721
733
|
object; do not leave `setupPlanCall.campaignId:null` or
|
|
722
734
|
`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"`.
|
|
723
738
|
Short form: For create lanes, `setupPlanCall.campaignId` and `setupPlanCall.tableId` must be the actual created campaign/table ids.
|
|
724
739
|
Short form: Do not leave `setupPlanCall.campaignId:null` or `setupPlanCall.tableId:null`.
|
|
725
740
|
- `createCampaignWorkflowReceipt`: proof the worker loaded the actual installed
|
|
@@ -810,21 +825,39 @@ not a parent-thread summary. The receipt must include:
|
|
|
810
825
|
them into canonical `promptLoadedToHasMoreFalse`, `requiredAssetsLoaded`,
|
|
811
826
|
`validationLoaded`, `reviewBatchRowHash`, and
|
|
812
827
|
`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.
|
|
813
832
|
- `reviewBatchReceipt`: review-batch row ids/hash, generated row count,
|
|
814
833
|
quality-valid route-proof row id when approved, and proof that no broad
|
|
815
834
|
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.
|
|
816
837
|
- `sequenceReceipt`: exact current workflowTableId, recommended non-paid
|
|
817
838
|
sequence attach/precheck result, and readback showing `hasSequence:true` when
|
|
818
839
|
completion is claimed. If the tool output uses
|
|
819
840
|
`attachRecommendedSequenceResult.actionTypes` or
|
|
820
841
|
`nonPaidRecommendedSequence`, copy them to canonical `actionTypes` and
|
|
821
842
|
`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.
|
|
822
852
|
- final paused-send proof: if the current campaign table is `DRAFT` after the
|
|
823
853
|
sequence is attached, call the product `pause_campaign({ campaignId })`
|
|
824
854
|
endpoint/tool to put the unlaunched campaign into `PAUSED` review state, then
|
|
825
855
|
reread the campaign/table. Do not raw-write `campaignStatus`, do not start or
|
|
826
856
|
launch, and do not schedule/send. Completion requires reread proof of
|
|
827
|
-
`currentStep:"send"` and `campaignStatus:"PAUSED"
|
|
857
|
+
`currentStep:"send"` and `campaignStatus:"PAUSED"` for standard
|
|
858
|
+
customer-visible lanes. Connection-only lanes may finish at a paused
|
|
859
|
+
unlaunched review step such as `currentStep:"review"` as long as the exact
|
|
860
|
+
invite-only sequence proof and source-row proof pass. If the raw output nests
|
|
828
861
|
this in `finalCampaignRead` or `finalTableRead`, copy it to canonical
|
|
829
862
|
`finalPausedSendProof.currentStep` and
|
|
830
863
|
`finalPausedSendProof.campaignStatus`.
|
|
@@ -1088,9 +1121,14 @@ plain row generation. The lane worker must inline the same
|
|
|
1088
1121
|
approvedGeneratedMessageCount exactly 1. Do not report completion with 2+
|
|
1089
1122
|
approved rows.
|
|
1090
1123
|
Approval shorthand: Do not report completion with 2+ approved rows.
|
|
1091
|
-
8.
|
|
1092
|
-
|
|
1093
|
-
|
|
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.
|
|
1094
1132
|
|
|
1095
1133
|
That packaged worker path must return `messageDraftingReceipt.statusSource:
|
|
1096
1134
|
"packaged-generate-messages-worker"`. It is accepted only when the receipt
|
|
@@ -1515,11 +1553,14 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1515
1553
|
- 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.
|
|
1516
1554
|
- **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.
|
|
1517
1555
|
|
|
1518
|
-
-
|
|
1556
|
+
- In standard mode, the sequence is auto-selected by sender tier; do not hand-author sequence
|
|
1519
1557
|
templates here. Sales Nav/Recruiter senders may receive the unified Sales
|
|
1520
1558
|
Nav cascade through `attach_recommended_sequence`; attaching it does not
|
|
1521
1559
|
spend paid InMail credits by itself. Do not substitute the manual Paid
|
|
1522
1560
|
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`.
|
|
1523
1564
|
3. **Customer-Visible Completion Contract**: a named evergreen lane that appears
|
|
1524
1565
|
as a campaign card or campaign-backed table is not done when the shell exists.
|
|
1525
1566
|
It is done only when the customer can open the campaign and land on final
|
|
@@ -1543,7 +1584,7 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1543
1584
|
evergreen setup. Do not leave customer-visible campaigns at
|
|
1544
1585
|
`filter-choice`, `filter-rules`, or `apply-icp-rubric` and report success.
|
|
1545
1586
|
Do not proceed to Message Drafting until saved filters are applied.
|
|
1546
|
-
- Message Drafting has run from the current campaign/table basis, using the
|
|
1587
|
+
- In standard mode, Message Drafting has run from the current campaign/table basis, using the
|
|
1547
1588
|
create-campaign message prompt/assets and validation gate. Updating
|
|
1548
1589
|
`currentStep:"messages"` is not proof. Parent-thread handwritten copy is
|
|
1549
1590
|
not a substitute. The proof receipt must show
|
|
@@ -1584,6 +1625,9 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1584
1625
|
Fallback samples must still reject source/conversation hedges such as
|
|
1585
1626
|
`"hope this is relevant"`, `"might be interested"`, or `"saw you in a few
|
|
1586
1627
|
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.
|
|
1587
1631
|
- The first review batch exists and at least 3 review rows have generated
|
|
1588
1632
|
messages from the approved brief. If fewer than 3 usable rows exist, report
|
|
1589
1633
|
the actual count and why.
|
|
@@ -1596,7 +1640,7 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1596
1640
|
completion, approve exactly one quality-valid generated row. If one or more
|
|
1597
1641
|
rows are already approved, do not add more approvals during evergreen
|
|
1598
1642
|
completion. Never broad approve all rows.
|
|
1599
|
-
-
|
|
1643
|
+
- In standard mode, the recommended tier-aware sequence is attached to the current campaign
|
|
1600
1644
|
table, and the watched campaign is on Send. Use
|
|
1601
1645
|
`attach_recommended_sequence({ campaignId, currentStep:"send" })` when a
|
|
1602
1646
|
safe attach is needed. After `confirm_lead_list` or any source-list copy,
|
|
@@ -1611,6 +1655,10 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1611
1655
|
manual Paid InMail Campaign, and never attach/replace sequence outside the
|
|
1612
1656
|
current table unless the current lane packet explicitly allowed sequence
|
|
1613
1657
|
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.
|
|
1614
1662
|
- If the current campaign table is still `DRAFT` after sequence/readiness
|
|
1615
1663
|
proof, call `pause_campaign({ campaignId })` and reread. `pause_campaign`
|
|
1616
1664
|
is the product-native review-state transition; it is not a launch and does
|
|
@@ -62,6 +62,11 @@ 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.
|
|
65
70
|
|
|
66
71
|
If membership blocks a workspace read, report the structured
|
|
67
72
|
`workspace_access` blocker instead of retrying auth or switching workspaces.
|
|
@@ -196,6 +196,11 @@ 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.
|
|
199
204
|
|
|
200
205
|
Structured planner packet:
|
|
201
206
|
|
|
@@ -1,139 +0,0 @@
|
|
|
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.
|