@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 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
@@ -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 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.
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: slackEnv.SLACK_BOT_TOKEN,
4099
- SLACK_APP_TOKEN: slackEnv.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(slackEnv)) {
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(slackEnv).some(Boolean)) {
4273
- for (const [key, value] of Object.entries(slackEnv)) {
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(slackEnv).some(Boolean)) {
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 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
- );
4150
+ summary.skipped.push("Slack env: no Slack tokens supplied");
4302
4151
  }
4303
4152
  if (!token) {
4304
4153
  summary.warnings.push(
@@ -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: typeof parsed.prompt === "string" ? parsed.prompt.length : 0,
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
- 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,
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
- 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) {
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: `${call.name} returned an MCP tool 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.311",
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"`. If the raw output nests
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. 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.
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
- - The sequence is auto-selected by sender tier; do not hand-author sequence
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
- - The recommended tier-aware sequence is attached to the current campaign
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.