@sellable/install 0.1.605 → 0.1.606-wip.phase187.20260811.15

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.
@@ -3,13 +3,13 @@ FROM ${HERMES_IMAGE}
3
3
 
4
4
  USER root
5
5
 
6
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.605
7
- ARG MCP_PACKAGE=@sellable/mcp@0.1.864
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.606-wip.phase187.20260811.15
7
+ ARG MCP_PACKAGE=@sellable/mcp@0.1.865-wip.phase187.20260811.15
8
8
  ARG INSTALLER_INTEGRITY
9
- ARG MCP_INTEGRITY=sha512-UevVd71zQL2FgnqYaeHBkTi73iUV0Lg5BchM5JNhUbvaFxAFQ2M1n9uSS18qVuM9Km0aGZ7a0k8M9e2AVPboAw==
9
+ ARG MCP_INTEGRITY=sha512-eMgElGCRnQia1P9NUNuBoc3T/OKHbmDAGwurVljWK5ED+tifDYmXla3VWEmUrqImkthr+9zfJnApCqVBLZIpSA==
10
10
 
11
- RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.605" \
12
- && test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.864" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.606-wip.phase187.20260811.15" \
12
+ && test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.865-wip.phase187.20260811.15" \
13
13
  && test -n "${INSTALLER_INTEGRITY}" \
14
14
  && test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
15
15
  && test "$(npm view "${MCP_PACKAGE}" dist.integrity)" = "${MCP_INTEGRITY}" \
@@ -23,9 +23,9 @@ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.605" \
23
23
  && mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
24
24
  && npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
25
25
  && npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
26
- && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.605-wip.hermes-desktop.20260811160530' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.864') throw new Error('package identity rejected');" \
26
+ && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.606-wip.hermes-desktop.20260811160530' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.865-wip.phase187.20260811.15') throw new Error('package identity rejected');" \
27
27
  && node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
28
- && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.605', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.864', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
28
+ && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.606-wip.phase187.20260811.15', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.865-wip.phase187.20260811.15', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
29
29
  && npm cache clean --force
30
30
 
31
31
  COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
@@ -3,13 +3,13 @@
3
3
  This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
4
4
 
5
5
  Build from this directory after
6
- `@sellable/install@0.1.605` is
6
+ `@sellable/install@0.1.606-wip.phase187.20260811.15` is
7
7
  published:
8
8
 
9
9
  ```sh
10
10
  docker build --platform linux/amd64 \
11
11
  --build-arg INSTALLER_INTEGRITY='sha512-…' \
12
- --tag sellable-agent-host:0.1.605-wip.hermes-desktop.20260811160530 .
12
+ --tag sellable-agent-host:0.1.606-wip.hermes-desktop.20260811160530 .
13
13
  ```
14
14
 
15
15
  Create a private `bootstrap/` directory owned by root with mode `0700`. It must contain `bootstrap.json`, `provider-auth`, and `slack-app`, each owned by root with mode `0600`. `bootstrap.json` contains only non-secret identities, hashes, package integrities, and the control-plane URL. The other two files contain the provider JSON and global Slack app token whose hashes are pinned by `bootstrap.json`.
@@ -20,6 +20,7 @@ import { createHash } from "node:crypto";
20
20
 
21
21
  const SHA256 = /^[a-f0-9]{64}$/;
22
22
  const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-.A-Za-z0-9]+)?$/;
23
+ const PACKAGE_DISPOSITIONS = new Set(["changed", "reused"]);
23
24
  const PUBLIC_ACTIONS = new Set(["INSTANTIATE", "REFRESH"]);
24
25
  const RESTORATION_ACTIONS = new Set([
25
26
  "START",
@@ -64,14 +65,13 @@ export function validateAdminRuntimeTuple(tuple) {
64
65
  }
65
66
  for (const name of ADMIN_RUNTIME_TUPLE_MEMBERS) {
66
67
  const item = tuple.packages[name];
67
- const expectedDisposition = name === "@sellable/mcp" ? "reused" : "changed";
68
68
  if (
69
69
  !item ||
70
70
  item.name !== name ||
71
71
  !EXACT_VERSION.test(item.version ?? "") ||
72
72
  typeof item.integrity !== "string" ||
73
73
  !/^sha512-[A-Za-z0-9+/]{80,}={0,2}$/.test(item.integrity) ||
74
- item.disposition !== expectedDisposition
74
+ !PACKAGE_DISPOSITIONS.has(item.disposition)
75
75
  ) {
76
76
  return {
77
77
  ok: false,
@@ -28,7 +28,7 @@ import {
28
28
 
29
29
  const MANIFEST_VERSION = "sellable-agent-external-runtime-closure/v1";
30
30
  const MCP_PACKAGE = "@sellable/mcp";
31
- const MCP_VERSION = "0.1.864";
31
+ const MCP_VERSION = "0.1.865-wip.phase187.20260811.15";
32
32
  const SAFE_COMPONENT = /^[A-Za-z0-9@._+-]+$/;
33
33
  const HERMES_EXCLUDES = new Set([".git", ".playwright", "node_modules"]);
34
34
 
@@ -67,6 +67,7 @@ import {
67
67
  HERMES_SNAPSHOT_HOOKS,
68
68
  HERMES_SNAPSHOT_HOOK_ALLOWLIST_BYTES,
69
69
  applyHermesHostedBackgroundReviewPolicy,
70
+ ensureHermesOpportunitySequencesCron,
70
71
  ensureHermesSnapshotCron,
71
72
  markHermesSnapshotDirty,
72
73
  mergeHermesState,
@@ -696,6 +697,22 @@ const ADMIN_LIVE_SKILLS_CONFIG_KEYS = Object.freeze([
696
697
  "disabled",
697
698
  "platform_disabled",
698
699
  ]);
700
+ function packagedAdminSkillDirectories() {
701
+ const skillsRoot = join(ADMIN_INSTALL_ASSET_ROOT, "mcp", "skills");
702
+ const directories = new Set(["sellable"]);
703
+ for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
704
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
705
+ throw new Error("admin_runtime_packaged_skills_inventory_rejected");
706
+ }
707
+ directories.add(entry.name);
708
+ }
709
+ return directories;
710
+ }
711
+
712
+ // Candidate run 31458025989 proved that a fresh profile must not classify
713
+ // package-backed Admin skills as operator state: doing so made both allowed
714
+ // SOUL restage attempts compare an empty live inventory with the sealed stage.
715
+ const ADMIN_SEALED_SKILL_DIRECTORIES = packagedAdminSkillDirectories();
699
716
 
700
717
  function adminPreservedSlackToolsets(config) {
701
718
  const platformToolsets = config?.platform_toolsets;
@@ -715,17 +732,17 @@ function adminPreservedSlackToolsets(config) {
715
732
 
716
733
  function adminPreservedSkillsIdentity(profileRoot) {
717
734
  if (!existsSync(profileRoot)) return null;
718
- const entries = [];
719
735
  const skillsRoot = join(profileRoot, "skills");
720
736
  const configPath = join(profileRoot, "config.yaml");
721
737
  // Admin settings are delivered into .sellable-admin before the Hermes
722
738
  // profile is staged. That settings-only directory is not an existing live
723
739
  // profile and therefore has no skills state to preserve.
724
740
  if (!existsSync(skillsRoot) && !existsSync(configPath)) return null;
741
+ const entries = [];
725
742
  const walk = (directory, relativePath = "") => {
726
743
  for (const name of readdirSync(directory).sort()) {
727
744
  const nextRelative = relativePath ? `${relativePath}/${name}` : name;
728
- if (nextRelative === "sellable") continue;
745
+ if (ADMIN_SEALED_SKILL_DIRECTORIES.has(nextRelative)) continue;
729
746
  const path = join(directory, name);
730
747
  const stat = lstatSync(path);
731
748
  if (stat.isSymbolicLink()) {
@@ -795,6 +812,45 @@ function mergeAdminLiveSkillsConfig(sourceRoot, targetRoot) {
795
812
  atomicPrivateFile(targetPath, targetDocument.toString());
796
813
  }
797
814
 
815
+ function installWritableAdminSlackChannelPolicy(stageRoot, slack) {
816
+ const envPath = join(stageRoot, ".env");
817
+ if (!existsSync(envPath)) {
818
+ throw new Error("admin_runtime_slack_environment_rejected");
819
+ }
820
+ const stat = lstatSync(envPath);
821
+ if (stat.isSymbolicLink() || !stat.isFile()) {
822
+ throw new Error("admin_runtime_slack_environment_rejected");
823
+ }
824
+ if (
825
+ !Array.isArray(slack?.selectedChannelIds) ||
826
+ slack.selectedChannelIds.length < 1 ||
827
+ !slack.selectedChannelIds.includes(slack.primaryChannel?.id)
828
+ ) {
829
+ throw new Error("admin_runtime_slack_contract_rejected");
830
+ }
831
+
832
+ let allowedPolicyCount = 0;
833
+ let readOnlyPolicyCount = 0;
834
+ const updated = readFileSync(envPath, "utf8")
835
+ .split("\n")
836
+ .map((line) => {
837
+ if (line.startsWith("SLACK_ALLOWED_CHANNELS=")) {
838
+ allowedPolicyCount += 1;
839
+ return `SLACK_ALLOWED_CHANNELS=${slack.selectedChannelIds.join(",")}`;
840
+ }
841
+ if (line.startsWith("SLACK_READ_ONLY_CHANNELS=")) {
842
+ readOnlyPolicyCount += 1;
843
+ return "SLACK_READ_ONLY_CHANNELS=";
844
+ }
845
+ return line;
846
+ })
847
+ .join("\n");
848
+ if (allowedPolicyCount !== 1 || readOnlyPolicyCount !== 1) {
849
+ throw new Error("admin_runtime_slack_environment_rejected");
850
+ }
851
+ atomicPrivateFile(envPath, updated);
852
+ }
853
+
798
854
  async function materializeAdminProfile({
799
855
  profileContract,
800
856
  settingsRoot,
@@ -899,6 +955,7 @@ async function materializeAdminProfile({
899
955
  return { ok: true, ...proof };
900
956
  },
901
957
  });
958
+ if (slack) installWritableAdminSlackChannelPolicy(stageRoot, slack);
902
959
  installAdminModelConfig(stageRoot, modelConfig);
903
960
  installAdminHermesMemoryHook(stageRoot);
904
961
  stageAdminRuntimeTree(stageRoot);
@@ -992,12 +1049,22 @@ async function materializeAdminProfile({
992
1049
  hermesCli: HERMES,
993
1050
  });
994
1051
  if (!memoryCron.ok) throw new Error(memoryCron.code);
1052
+ const opportunitySequencesCron = await ensureHermesOpportunitySequencesCron({
1053
+ profileId: profile,
1054
+ profileRoot: profileHome,
1055
+ hermesRoot: DATA_ROOT,
1056
+ statePath: layout.statePath,
1057
+ });
1058
+ if (!opportunitySequencesCron.ok) {
1059
+ throw new Error(opportunitySequencesCron.code);
1060
+ }
995
1061
  return {
996
1062
  stage,
997
1063
  promotion: promoted.promotion,
998
1064
  profile,
999
1065
  profileHome,
1000
1066
  adminHome,
1067
+ opportunitySequencesCron,
1001
1068
  observedSoulRawSha256: promoted.observedSoulRawSha256,
1002
1069
  servers: [serverProofs.sellable, serverProofs["sellable-admin"]].map(
1003
1070
  ({ tools: _tools, ...proof }) => proof
@@ -1644,9 +1711,10 @@ function nativeSlackProfileInput(claim, installed) {
1644
1711
  id: primary.channelId,
1645
1712
  name: primary.channelName ?? primary.channelId,
1646
1713
  },
1647
- secondaryReadChannelIds: bindings
1648
- .filter((binding) => binding.id !== primary.id)
1649
- .map((binding) => binding.channelId),
1714
+ selectedChannelIds: [
1715
+ ...new Set(bindings.map((binding) => binding.channelId)),
1716
+ ].sort(),
1717
+ secondaryReadChannelIds: [],
1650
1718
  allowedSlackUserIds: [...slack.memberAllowlist],
1651
1719
  slackApp: {
1652
1720
  appId: slack.appId,
@@ -2450,6 +2518,7 @@ export async function runAdminCanary() {
2450
2518
  }
2451
2519
  const fixtureWorkspaceId = "phase158-canary-workspace";
2452
2520
  const fixtureAgentId = "phase158-canary-admin-agent";
2521
+ const fixtureNotificationSlackUserId = "U187CANARY";
2453
2522
  const fixtureFiles = {
2454
2523
  ".env": "PHASE158_ADMIN_CANARY=nonsecret\n",
2455
2524
  "settings.json": `${JSON.stringify({
@@ -2462,6 +2531,9 @@ export async function runAdminCanary() {
2462
2531
  })}\n`,
2463
2532
  "config-state.json": `${JSON.stringify({
2464
2533
  sellable: { workspaceIds: [], workspaceNames: {} },
2534
+ opportunitySequences: {
2535
+ notificationSlackUserId: fixtureNotificationSlackUserId,
2536
+ },
2465
2537
  })}\n`,
2466
2538
  };
2467
2539
  for (const [name, value] of Object.entries(fixtureFiles)) {
@@ -4,9 +4,9 @@ FROM ${HERMES_IMAGE}
4
4
 
5
5
  USER root
6
6
 
7
- ARG MCP_PACKAGE=@sellable/mcp@0.1.864
8
- ARG MCP_INTEGRITY=sha512-UevVd71zQL2FgnqYaeHBkTi73iUV0Lg5BchM5JNhUbvaFxAFQ2M1n9uSS18qVuM9Km0aGZ7a0k8M9e2AVPboAw==
9
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.605
7
+ ARG MCP_PACKAGE=@sellable/mcp@0.1.865-wip.phase187.20260811.15
8
+ ARG MCP_INTEGRITY=sha512-eMgElGCRnQia1P9NUNuBoc3T/OKHbmDAGwurVljWK5ED+tifDYmXla3VWEmUrqImkthr+9zfJnApCqVBLZIpSA==
9
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.606-wip.phase187.20260811.15
10
10
  ARG ADMIN_MCP_PACKAGE=@sellable/admin-mcp@0.1.186-wip.phase158.20260802034727
11
11
  ARG ADMIN_MCP_INTEGRITY=sha512-qYNew5wd2IfgVcp1UYOG9QvpF/8Mx19YJ5HQtMKzvSfUf3evePjtaKsoXLf2C/GUJ5QlLlHJo84VWOI3V01+9A==
12
12
  ARG TUS_JS_CLIENT_VERSION=4.3.1
@@ -95,9 +95,9 @@ const RUNTIME_GENERATION = Number(
95
95
  const BOOT_SESSION_ID = randomUUID();
96
96
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.20.0";
97
97
  const INSTALLER_PACKAGE =
98
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.605";
98
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.606-wip.phase187.20260811.15";
99
99
  const MCP_PACKAGE =
100
- process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.864";
100
+ process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
101
101
 
102
102
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
103
103
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -55,24 +55,36 @@ export const HERMES_SNAPSHOT_CRON = Object.freeze({
55
55
  installedScript: "scripts/hermes-memory-reconcile.sh",
56
56
  noAgent: true,
57
57
  });
58
+ export const HERMES_OPPORTUNITY_SEQUENCES_CRON = Object.freeze({
59
+ name: "sellable-opportunity-sequences",
60
+ schedule: "every 30m",
61
+ deliver: "slack",
62
+ skills: Object.freeze(["sellable-admin-opportunity-sequences"]),
63
+ enabledToolsets: Object.freeze(["safe", "sellable", "mcp"]),
64
+ noAgent: false,
65
+ });
66
+ export const HERMES_OPPORTUNITY_SEQUENCES_JOURNAL_ROOT =
67
+ "/opt/data/.sellable-agent/admin-runtime/opportunity-sequences";
58
68
  const LEGACY_HERMES_SNAPSHOT_SCRIPT = "scripts/hermes-memory-reconcile.sh";
59
69
 
60
70
  const HERMES = "/opt/hermes/.venv/bin/hermes";
71
+ const HERMES_PYTHON = "/opt/hermes/.venv/bin/python";
61
72
  const VERSION = "0.20.0";
62
73
  const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
63
74
  const SHA256 = /^[a-f0-9]{64}$/;
64
75
  const MAX_BYTES = 128 * 1024 * 1024;
65
- // Portable snapshots contain only user-authored/customizable state. If native
66
- // Hermes cannot package that bounded set within two minutes, abort the helper
67
- // and let the persistent retry scheduler back off instead of contending with
68
- // the live Slack gateway for a full ten minutes.
76
+ // Portable snapshots contain user-authored/customizable state plus the compact
77
+ // Hermes session database and routing records required to continue an existing
78
+ // Slack thread after a Machine/volume migration. If native Hermes cannot
79
+ // package that bounded set within two minutes, abort the helper and let the
80
+ // persistent retry scheduler back off instead of contending with the live
81
+ // Slack gateway for a full ten minutes.
69
82
  const NATIVE_COMMAND_TIMEOUT_MS = 120_000;
70
83
  const TRANSIENT_PROFILE_ROOTS = new Set([".cache"]);
71
- // The encrypted Fly volume remains the authoritative full profile and keeps
72
- // state.db plus sessions across image updates and Machine replacement. This
73
- // portable disaster-recovery archive is intentionally narrower: conversation
74
- // history is high-churn/generated data, while these entries are the durable
75
- // customizations a user or agent can intentionally change.
84
+ // The encrypted Fly volume remains the authoritative full profile. The
85
+ // portable disaster-recovery archive also carries state.db and sessions so a
86
+ // replacement volume retains chat history and Slack thread routing. Caches,
87
+ // dependency trees, secrets, and live logs remain excluded.
76
88
  export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
77
89
  "AGENTS.md",
78
90
  "SOUL.md",
@@ -88,8 +100,10 @@ export const HERMES_SNAPSHOT_USER_STATE_ENTRIES = Object.freeze([
88
100
  "projects.db",
89
101
  "sandboxes",
90
102
  "scripts",
103
+ "sessions",
91
104
  "skins",
92
105
  "skills",
106
+ "state.db",
93
107
  "workspace",
94
108
  ]);
95
109
  export const HERMES_SNAPSHOT_RETRY_BACKOFF_MS = Object.freeze([
@@ -259,6 +273,68 @@ function cronJobs(profileRoot) {
259
273
  return jobs;
260
274
  }
261
275
 
276
+ const sameJson = (left, right) =>
277
+ JSON.stringify(left) === JSON.stringify(right);
278
+
279
+ export async function ensureHermesNamedCron({
280
+ profileRoot,
281
+ name,
282
+ isExpected,
283
+ isUpdatable = () => true,
284
+ create,
285
+ update,
286
+ codePrefix = "hermes_named_cron",
287
+ }) {
288
+ if (
289
+ !isAbsolute(profileRoot) ||
290
+ !ID.test(name ?? "") ||
291
+ typeof isExpected !== "function" ||
292
+ typeof create !== "function" ||
293
+ typeof update !== "function"
294
+ ) {
295
+ return failed(`${codePrefix}_identity_rejected`);
296
+ }
297
+ try {
298
+ const before = cronJobs(profileRoot);
299
+ const named = before.filter((job) => job?.name === name);
300
+ if (named.length > 1 || (named[0] && !isUpdatable(named[0]))) {
301
+ return failed(`${codePrefix}_inventory_rejected`);
302
+ }
303
+ if (named[0] && isExpected(named[0])) {
304
+ return {
305
+ ok: true,
306
+ status: "REUSED",
307
+ jobId: named[0].id,
308
+ job: named[0],
309
+ };
310
+ }
311
+ const siblings = before.filter((job) => job?.name !== name);
312
+ const mutation = named[0] ? await update(named[0]) : await create();
313
+ if (childFailed(mutation)) {
314
+ return failed(`${codePrefix}_${named[0] ? "update" : "create"}_failed`);
315
+ }
316
+ const after = cronJobs(profileRoot);
317
+ const readback = after.filter((job) => job?.name === name);
318
+ const afterSiblings = after.filter((job) => job?.name !== name);
319
+ if (!sameJson(afterSiblings, siblings)) {
320
+ return failed(`${codePrefix}_sibling_readback_rejected`);
321
+ }
322
+ if (readback.length !== 1 || !isExpected(readback[0])) {
323
+ return failed(
324
+ `${codePrefix}_${named[0] ? "update_readback" : "readback"}_rejected`
325
+ );
326
+ }
327
+ return {
328
+ ok: true,
329
+ status: named[0] ? "UPDATED" : "CREATED",
330
+ jobId: readback[0].id,
331
+ job: readback[0],
332
+ };
333
+ } catch {
334
+ return failed(`${codePrefix}_install_failed`);
335
+ }
336
+ }
337
+
262
338
  function isCron(job) {
263
339
  return Boolean(
264
340
  job?.id &&
@@ -430,25 +506,23 @@ export async function ensureHermesSnapshotCron({
430
506
  readFileSync(reconcileTemplatePath),
431
507
  0o700
432
508
  );
433
- const before = cronJobs(profileRoot).filter(
434
- (job) => job?.name === HERMES_SNAPSHOT_CRON.name
435
- );
436
- if (
437
- before.length > 1 ||
438
- (before[0] && !isCron(before[0]) && !isLegacyCron(before[0]))
439
- ) {
440
- return failed("hermes_snapshot_cron_inventory_rejected");
441
- }
442
- if (before[0]) {
443
- if (isLegacyCron(before[0])) {
444
- const migrated = await childRunner(runChild)(
509
+ const result = await ensureHermesNamedCron({
510
+ profileRoot,
511
+ name: HERMES_SNAPSHOT_CRON.name,
512
+ isExpected: isCron,
513
+ isUpdatable: (job) => isCron(job) || isLegacyCron(job),
514
+ codePrefix: "hermes_snapshot_cron",
515
+ create: () =>
516
+ childRunner(runChild)(
445
517
  invocation(
446
518
  [
447
519
  "--profile",
448
520
  profileId,
449
521
  "cron",
450
- "edit",
451
- before[0].id,
522
+ "create",
523
+ HERMES_SNAPSHOT_CRON.schedule,
524
+ "--name",
525
+ HERMES_SNAPSHOT_CRON.name,
452
526
  "--script",
453
527
  HERMES_SNAPSHOT_CRON.script,
454
528
  "--no-agent",
@@ -456,53 +530,216 @@ export async function ensureHermesSnapshotCron({
456
530
  hermesRoot,
457
531
  hermesCli
458
532
  )
459
- );
460
- if (childFailed(migrated)) {
461
- return failed("hermes_snapshot_cron_migrate_failed");
462
- }
463
- const afterMigration = cronJobs(profileRoot).filter(
464
- (job) => job?.name === HERMES_SNAPSHOT_CRON.name
465
- );
466
- return afterMigration.length === 1 && isCron(afterMigration[0])
467
- ? {
468
- ok: true,
469
- status: "MIGRATED",
470
- jobId: afterMigration[0].id,
533
+ ),
534
+ update: (job) =>
535
+ childRunner(runChild)(
536
+ invocation(
537
+ [
538
+ "--profile",
471
539
  profileId,
472
- }
473
- : failed("hermes_snapshot_cron_migrate_readback_rejected");
540
+ "cron",
541
+ "edit",
542
+ job.id,
543
+ "--script",
544
+ HERMES_SNAPSHOT_CRON.script,
545
+ "--no-agent",
546
+ ],
547
+ hermesRoot,
548
+ hermesCli
549
+ )
550
+ ),
551
+ });
552
+ if (!result.ok) {
553
+ if (result.code === "hermes_snapshot_cron_update_failed") {
554
+ return failed("hermes_snapshot_cron_migrate_failed");
555
+ }
556
+ if (result.code === "hermes_snapshot_cron_update_readback_rejected") {
557
+ return failed("hermes_snapshot_cron_migrate_readback_rejected");
474
558
  }
475
- return { ok: true, status: "REUSED", jobId: before[0].id, profileId };
559
+ return result;
476
560
  }
477
- const result = await childRunner(runChild)(
478
- invocation(
479
- [
480
- "--profile",
481
- profileId,
482
- "cron",
483
- "create",
484
- HERMES_SNAPSHOT_CRON.schedule,
485
- "--name",
486
- HERMES_SNAPSHOT_CRON.name,
487
- "--script",
488
- HERMES_SNAPSHOT_CRON.script,
489
- "--no-agent",
561
+ return {
562
+ ok: true,
563
+ status: result.status === "UPDATED" ? "MIGRATED" : result.status,
564
+ jobId: result.jobId,
565
+ profileId,
566
+ };
567
+ } catch {
568
+ return failed("hermes_snapshot_cron_install_failed");
569
+ }
570
+ }
571
+
572
+ const OPPORTUNITY_SEQUENCE_ASSETS = Object.freeze([
573
+ "SKILL.md",
574
+ "references/outbound-multichannel-v1.md",
575
+ "references/positive-reply-to-agreed-meeting-v1.md",
576
+ ]);
577
+ const SLACK_USER_ID = /^U[A-Z0-9]{8,20}$/;
578
+ const OPPORTUNITY_SEQUENCE_CRON_PYTHON = [
579
+ "import json,sys",
580
+ "from cron.jobs import create_job, update_job",
581
+ "value=json.loads(sys.argv[1])",
582
+ "job_id=value.pop('jobId', None)",
583
+ "enabled=value.pop('enabled')",
584
+ "result=update_job(job_id, {**value, 'enabled': enabled}) if job_id else create_job(**value)",
585
+ "print(json.dumps({'id': result.get('id') if result else None}))",
586
+ ].join("\n");
587
+
588
+ function opportunitySequenceIdentity(profileRoot) {
589
+ const root = join(
590
+ profileRoot,
591
+ "skills",
592
+ "sellable-admin",
593
+ HERMES_OPPORTUNITY_SEQUENCES_CRON.skills[0]
594
+ );
595
+ return Object.fromEntries(
596
+ OPPORTUNITY_SEQUENCE_ASSETS.map((relative) => {
597
+ const path = join(root, relative);
598
+ regularFile(path);
599
+ return [relative, sha256(readFileSync(path))];
600
+ })
601
+ );
602
+ }
603
+
604
+ function opportunitySequencePrompt({
605
+ notificationSlackUserId,
606
+ journalRoot,
607
+ hashes,
608
+ }) {
609
+ return [
610
+ "Run the installed opportunity-sequences skill for one bounded page.",
611
+ `Use the private journal at ${journalRoot}.`,
612
+ `Require these installed asset hashes: ${JSON.stringify(hashes)}.`,
613
+ `End with a compact native Slack summary addressed to <@${notificationSlackUserId}>.`,
614
+ "Never send prospect outreach; never claim exactly-once delivery; never use a direct Slack or Lightfield transport outside the installed skill and Product MCP.",
615
+ ].join(" ");
616
+ }
617
+
618
+ export async function ensureHermesOpportunitySequencesCron({
619
+ profileId,
620
+ profileRoot,
621
+ hermesRoot,
622
+ statePath,
623
+ journalRoot = HERMES_OPPORTUNITY_SEQUENCES_JOURNAL_ROOT,
624
+ hermesPython = HERMES_PYTHON,
625
+ runChild,
626
+ }) {
627
+ if (
628
+ !validProfilePath(profileId, profileRoot, hermesRoot) ||
629
+ !existsSync(profileRoot) ||
630
+ !isAbsolute(statePath) ||
631
+ !isAbsolute(journalRoot)
632
+ ) {
633
+ return failed("hermes_opportunity_sequences_cron_identity_rejected");
634
+ }
635
+ try {
636
+ const stateStat = regularFile(statePath);
637
+ if ((stateStat.mode & 0o077) !== 0) {
638
+ return failed("hermes_opportunity_sequences_cron_state_mode_rejected");
639
+ }
640
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
641
+ const notificationSlackUserId =
642
+ state?.opportunitySequences?.notificationSlackUserId;
643
+ if (!SLACK_USER_ID.test(notificationSlackUserId ?? "")) {
644
+ return failed("hermes_opportunity_sequences_cron_notification_rejected");
645
+ }
646
+ mkdirSync(journalRoot, { recursive: true, mode: 0o700 });
647
+ chmodSync(journalRoot, 0o700);
648
+ const journalStat = lstatSync(journalRoot);
649
+ if (
650
+ journalStat.isSymbolicLink() ||
651
+ !journalStat.isDirectory() ||
652
+ (journalStat.mode & 0o077) !== 0
653
+ ) {
654
+ return failed("hermes_opportunity_sequences_cron_journal_rejected");
655
+ }
656
+ const hashes = opportunitySequenceIdentity(profileRoot);
657
+ const prompt = opportunitySequencePrompt({
658
+ notificationSlackUserId,
659
+ journalRoot,
660
+ hashes,
661
+ });
662
+ const expected = (job) =>
663
+ Boolean(
664
+ job?.id &&
665
+ job.name === HERMES_OPPORTUNITY_SEQUENCES_CRON.name &&
666
+ job.prompt === prompt &&
667
+ job.no_agent === false &&
668
+ job.enabled === true &&
669
+ job.deliver === HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver &&
670
+ sameJson(job.skills, HERMES_OPPORTUNITY_SEQUENCES_CRON.skills) &&
671
+ sameJson(
672
+ job.enabled_toolsets,
673
+ HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets
674
+ ) &&
675
+ job.schedule?.kind === "interval" &&
676
+ job.schedule?.minutes === 30
677
+ );
678
+ const mutate = (jobId = null) => {
679
+ const spec = {
680
+ prompt,
681
+ schedule: HERMES_OPPORTUNITY_SEQUENCES_CRON.schedule,
682
+ name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
683
+ deliver: HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver,
684
+ skills: [...HERMES_OPPORTUNITY_SEQUENCES_CRON.skills],
685
+ enabled_toolsets: [
686
+ ...HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets,
490
687
  ],
491
- hermesRoot,
492
- hermesCli
493
- )
494
- );
495
- if (childFailed(result)) {
496
- return failed("hermes_snapshot_cron_create_failed");
688
+ no_agent: false,
689
+ enabled: true,
690
+ ...(jobId ? { jobId } : {}),
691
+ };
692
+ return childRunner(runChild)({
693
+ command: hermesPython,
694
+ args: ["-c", OPPORTUNITY_SEQUENCE_CRON_PYTHON, JSON.stringify(spec)],
695
+ env: {
696
+ ...process.env,
697
+ HOME: hermesRoot,
698
+ HERMES_HOME: profileRoot,
699
+ HERMES_PROFILE: profileId,
700
+ NO_COLOR: "1",
701
+ },
702
+ shell: false,
703
+ timeoutMs: NATIVE_COMMAND_TIMEOUT_MS,
704
+ maxOutputBytes: 64 * 1024,
705
+ });
706
+ };
707
+ const result = await ensureHermesNamedCron({
708
+ profileRoot,
709
+ name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
710
+ isExpected: expected,
711
+ create: () => mutate(),
712
+ update: (job) => mutate(job.id),
713
+ codePrefix: "hermes_opportunity_sequences_cron",
714
+ });
715
+ if (!result.ok) return result;
716
+ const jobsMode =
717
+ lstatSync(join(profileRoot, "cron", "jobs.json")).mode & 0o777;
718
+ if ((jobsMode & 0o077) !== 0) {
719
+ return failed("hermes_opportunity_sequences_cron_jobs_mode_rejected");
497
720
  }
498
- const after = cronJobs(profileRoot).filter(
499
- (job) => job?.name === HERMES_SNAPSHOT_CRON.name
500
- );
501
- return after.length === 1 && isCron(after[0])
502
- ? { ok: true, status: "CREATED", jobId: after[0].id, profileId }
503
- : failed("hermes_snapshot_cron_readback_rejected");
721
+ return {
722
+ ok: true,
723
+ status: result.status,
724
+ jobId: result.jobId,
725
+ name: result.job.name,
726
+ prompt: result.job.prompt,
727
+ schedule: result.job.schedule,
728
+ profileId,
729
+ enabledToolsets: result.job.enabled_toolsets,
730
+ skills: result.job.skills,
731
+ assetHashes: hashes,
732
+ notificationSlackUserIdSource:
733
+ "config-state.json:opportunitySequences.notificationSlackUserId",
734
+ journalRoot,
735
+ modes: {
736
+ state: stateStat.mode & 0o777,
737
+ journal: journalStat.mode & 0o777,
738
+ jobs: jobsMode,
739
+ },
740
+ };
504
741
  } catch {
505
- return failed("hermes_snapshot_cron_install_failed");
742
+ return failed("hermes_opportunity_sequences_cron_install_failed");
506
743
  }
507
744
  }
508
745
 
@@ -38,8 +38,8 @@ import { installAgentHostServices } from "./service-installer.mjs";
38
38
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
39
39
  const VERSION = "sellable-agent-host-bootstrap/v1";
40
40
  const RECEIPT_VERSION = "sellable-agent-host-registration/v1";
41
- const INSTALLER_PACKAGE = "@sellable/install@0.1.605";
42
- const MCP_PACKAGE = "@sellable/mcp@0.1.864";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.606-wip.phase187.20260811.15";
42
+ const MCP_PACKAGE = "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
43
43
  const HERMES_VERSION = "0.18.0";
44
44
  const SHA256 = /^[a-f0-9]{64}$/;
45
45
  const SHA256_16 = /^[a-f0-9]{16}$/;
@@ -1546,8 +1546,8 @@ export function compileClaimToProfileDesired(activeClaim, config) {
1546
1546
  ]) ||
1547
1547
  pinned.hermesCli !== "hermes" ||
1548
1548
  pinned.hermesVersion !== "0.18.0" ||
1549
- pinned.installerPackage !== "@sellable/install@0.1.605" ||
1550
- pinned.mcpPackage !== "@sellable/mcp@0.1.864" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.606-wip.phase187.20260811.15" ||
1550
+ pinned.mcpPackage !== "@sellable/mcp@0.1.865-wip.phase187.20260811.15" ||
1551
1551
  !Array.isArray(policy.toolInclude) ||
1552
1552
  policy.toolInclude.length === 0 ||
1553
1553
  !Number.isSafeInteger(slack.generation) ||
@@ -278,8 +278,8 @@ function validateDesired(desired) {
278
278
  desired.serviceCredentialGeneration < 1 ||
279
279
  desired.hermesCli !== "hermes" ||
280
280
  desired.hermesVersion !== "0.18.0" ||
281
- desired.installerPackage !== "@sellable/install@0.1.605" ||
282
- desired.mcpPackage !== "@sellable/mcp@0.1.864" ||
281
+ desired.installerPackage !== "@sellable/install@0.1.606-wip.phase187.20260811.15" ||
282
+ desired.mcpPackage !== "@sellable/mcp@0.1.865-wip.phase187.20260811.15" ||
283
283
  !Array.isArray(desired.toolInclude) ||
284
284
  desired.toolInclude.length === 0 ||
285
285
  desired.toolInclude.some((tool) => !/^[a-z][a-z0-9_]{0,127}$/.test(tool)) ||
@@ -25,8 +25,8 @@ import { fileURLToPath } from "node:url";
25
25
  import { deriveContainedProfileId } from "./profile-materializer.mjs";
26
26
 
27
27
  export const PROVISIONING_ACTION = "PROVISION_HERMES_PROFILE";
28
- export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.605";
29
- export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.864";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.606-wip.phase187.20260811.15";
29
+ export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.865-wip.phase187.20260811.15";
30
30
  const PINNED_INSTALL_VERSION = PINNED_INSTALL_PACKAGE.slice(
31
31
  PINNED_INSTALL_PACKAGE.lastIndexOf("@") + 1
32
32
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.605",
3
+ "version": "0.1.606-wip.phase187.20260811.15",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {