@sellable/install 0.1.589 → 0.1.590-wip.phase187.20260811.3

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.
@@ -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,10 @@ const ADMIN_LIVE_SKILLS_CONFIG_KEYS = Object.freeze([
696
697
  "disabled",
697
698
  "platform_disabled",
698
699
  ]);
700
+ const ADMIN_SEALED_SKILL_DIRECTORIES = new Set([
701
+ "sellable",
702
+ "opportunity-sequences",
703
+ ]);
699
704
 
700
705
  function adminPreservedSkillsIdentity(profileRoot) {
701
706
  if (!existsSync(profileRoot)) return null;
@@ -704,7 +709,7 @@ function adminPreservedSkillsIdentity(profileRoot) {
704
709
  const walk = (directory, relativePath = "") => {
705
710
  for (const name of readdirSync(directory).sort()) {
706
711
  const nextRelative = relativePath ? `${relativePath}/${name}` : name;
707
- if (nextRelative === "sellable") continue;
712
+ if (ADMIN_SEALED_SKILL_DIRECTORIES.has(nextRelative)) continue;
708
713
  const path = join(directory, name);
709
714
  const stat = lstatSync(path);
710
715
  if (stat.isSymbolicLink()) {
@@ -961,12 +966,22 @@ async function materializeAdminProfile({
961
966
  hermesCli: HERMES,
962
967
  });
963
968
  if (!memoryCron.ok) throw new Error(memoryCron.code);
969
+ const opportunitySequencesCron = await ensureHermesOpportunitySequencesCron({
970
+ profileId: profile,
971
+ profileRoot: profileHome,
972
+ hermesRoot: DATA_ROOT,
973
+ statePath: layout.statePath,
974
+ });
975
+ if (!opportunitySequencesCron.ok) {
976
+ throw new Error(opportunitySequencesCron.code);
977
+ }
964
978
  return {
965
979
  stage,
966
980
  promotion: promoted.promotion,
967
981
  profile,
968
982
  profileHome,
969
983
  adminHome,
984
+ opportunitySequencesCron,
970
985
  observedSoulRawSha256: promoted.observedSoulRawSha256,
971
986
  servers: [serverProofs.sellable, serverProofs["sellable-admin"]].map(
972
987
  ({ tools: _tools, ...proof }) => proof
@@ -55,9 +55,20 @@ 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(["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}$/;
@@ -259,6 +270,68 @@ function cronJobs(profileRoot) {
259
270
  return jobs;
260
271
  }
261
272
 
273
+ const sameJson = (left, right) =>
274
+ JSON.stringify(left) === JSON.stringify(right);
275
+
276
+ export async function ensureHermesNamedCron({
277
+ profileRoot,
278
+ name,
279
+ isExpected,
280
+ isUpdatable = () => true,
281
+ create,
282
+ update,
283
+ codePrefix = "hermes_named_cron",
284
+ }) {
285
+ if (
286
+ !isAbsolute(profileRoot) ||
287
+ !ID.test(name ?? "") ||
288
+ typeof isExpected !== "function" ||
289
+ typeof create !== "function" ||
290
+ typeof update !== "function"
291
+ ) {
292
+ return failed(`${codePrefix}_identity_rejected`);
293
+ }
294
+ try {
295
+ const before = cronJobs(profileRoot);
296
+ const named = before.filter((job) => job?.name === name);
297
+ if (named.length > 1 || (named[0] && !isUpdatable(named[0]))) {
298
+ return failed(`${codePrefix}_inventory_rejected`);
299
+ }
300
+ if (named[0] && isExpected(named[0])) {
301
+ return {
302
+ ok: true,
303
+ status: "REUSED",
304
+ jobId: named[0].id,
305
+ job: named[0],
306
+ };
307
+ }
308
+ const siblings = before.filter((job) => job?.name !== name);
309
+ const mutation = named[0] ? await update(named[0]) : await create();
310
+ if (childFailed(mutation)) {
311
+ return failed(`${codePrefix}_${named[0] ? "update" : "create"}_failed`);
312
+ }
313
+ const after = cronJobs(profileRoot);
314
+ const readback = after.filter((job) => job?.name === name);
315
+ const afterSiblings = after.filter((job) => job?.name !== name);
316
+ if (!sameJson(afterSiblings, siblings)) {
317
+ return failed(`${codePrefix}_sibling_readback_rejected`);
318
+ }
319
+ if (readback.length !== 1 || !isExpected(readback[0])) {
320
+ return failed(
321
+ `${codePrefix}_${named[0] ? "update_readback" : "readback"}_rejected`
322
+ );
323
+ }
324
+ return {
325
+ ok: true,
326
+ status: named[0] ? "UPDATED" : "CREATED",
327
+ jobId: readback[0].id,
328
+ job: readback[0],
329
+ };
330
+ } catch {
331
+ return failed(`${codePrefix}_install_failed`);
332
+ }
333
+ }
334
+
262
335
  function isCron(job) {
263
336
  return Boolean(
264
337
  job?.id &&
@@ -430,25 +503,23 @@ export async function ensureHermesSnapshotCron({
430
503
  readFileSync(reconcileTemplatePath),
431
504
  0o700
432
505
  );
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)(
506
+ const result = await ensureHermesNamedCron({
507
+ profileRoot,
508
+ name: HERMES_SNAPSHOT_CRON.name,
509
+ isExpected: isCron,
510
+ isUpdatable: (job) => isCron(job) || isLegacyCron(job),
511
+ codePrefix: "hermes_snapshot_cron",
512
+ create: () =>
513
+ childRunner(runChild)(
445
514
  invocation(
446
515
  [
447
516
  "--profile",
448
517
  profileId,
449
518
  "cron",
450
- "edit",
451
- before[0].id,
519
+ "create",
520
+ HERMES_SNAPSHOT_CRON.schedule,
521
+ "--name",
522
+ HERMES_SNAPSHOT_CRON.name,
452
523
  "--script",
453
524
  HERMES_SNAPSHOT_CRON.script,
454
525
  "--no-agent",
@@ -456,53 +527,211 @@ export async function ensureHermesSnapshotCron({
456
527
  hermesRoot,
457
528
  hermesCli
458
529
  )
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,
530
+ ),
531
+ update: (job) =>
532
+ childRunner(runChild)(
533
+ invocation(
534
+ [
535
+ "--profile",
471
536
  profileId,
472
- }
473
- : failed("hermes_snapshot_cron_migrate_readback_rejected");
537
+ "cron",
538
+ "edit",
539
+ job.id,
540
+ "--script",
541
+ HERMES_SNAPSHOT_CRON.script,
542
+ "--no-agent",
543
+ ],
544
+ hermesRoot,
545
+ hermesCli
546
+ )
547
+ ),
548
+ });
549
+ if (!result.ok) {
550
+ if (result.code === "hermes_snapshot_cron_update_failed") {
551
+ return failed("hermes_snapshot_cron_migrate_failed");
552
+ }
553
+ if (result.code === "hermes_snapshot_cron_update_readback_rejected") {
554
+ return failed("hermes_snapshot_cron_migrate_readback_rejected");
474
555
  }
475
- return { ok: true, status: "REUSED", jobId: before[0].id, profileId };
556
+ return result;
476
557
  }
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",
558
+ return {
559
+ ok: true,
560
+ status: result.status === "UPDATED" ? "MIGRATED" : result.status,
561
+ jobId: result.jobId,
562
+ profileId,
563
+ };
564
+ } catch {
565
+ return failed("hermes_snapshot_cron_install_failed");
566
+ }
567
+ }
568
+
569
+ const OPPORTUNITY_SEQUENCE_ASSETS = Object.freeze([
570
+ "SKILL.md",
571
+ "references/outbound-multichannel-v1.md",
572
+ "references/positive-reply-to-agreed-meeting-v1.md",
573
+ ]);
574
+ const SLACK_USER_ID = /^U[A-Z0-9]{8,20}$/;
575
+ const OPPORTUNITY_SEQUENCE_CRON_PYTHON = [
576
+ "import json,sys",
577
+ "from cron.jobs import create_job, update_job",
578
+ "value=json.loads(sys.argv[1])",
579
+ "job_id=value.pop('jobId', None)",
580
+ "enabled=value.pop('enabled')",
581
+ "result=update_job(job_id, {**value, 'enabled': enabled}) if job_id else create_job(**value)",
582
+ "print(json.dumps({'id': result.get('id') if result else None}))",
583
+ ].join("\n");
584
+
585
+ function opportunitySequenceIdentity(profileRoot) {
586
+ const root = join(profileRoot, "skills", "opportunity-sequences");
587
+ return Object.fromEntries(
588
+ OPPORTUNITY_SEQUENCE_ASSETS.map((relative) => {
589
+ const path = join(root, relative);
590
+ regularFile(path);
591
+ return [relative, sha256(readFileSync(path))];
592
+ })
593
+ );
594
+ }
595
+
596
+ function opportunitySequencePrompt({
597
+ notificationSlackUserId,
598
+ journalRoot,
599
+ hashes,
600
+ }) {
601
+ return [
602
+ "Run the installed opportunity-sequences skill for one bounded page.",
603
+ `Use the private journal at ${journalRoot}.`,
604
+ `Require these installed asset hashes: ${JSON.stringify(hashes)}.`,
605
+ `End with a compact native Slack summary addressed to <@${notificationSlackUserId}>.`,
606
+ "Never send prospect outreach; never claim exactly-once delivery; never use a direct Slack or Lightfield transport outside the installed skill and Product MCP.",
607
+ ].join(" ");
608
+ }
609
+
610
+ export async function ensureHermesOpportunitySequencesCron({
611
+ profileId,
612
+ profileRoot,
613
+ hermesRoot,
614
+ statePath,
615
+ journalRoot = HERMES_OPPORTUNITY_SEQUENCES_JOURNAL_ROOT,
616
+ hermesPython = HERMES_PYTHON,
617
+ runChild,
618
+ }) {
619
+ if (
620
+ !validProfilePath(profileId, profileRoot, hermesRoot) ||
621
+ !existsSync(profileRoot) ||
622
+ !isAbsolute(statePath) ||
623
+ !isAbsolute(journalRoot)
624
+ ) {
625
+ return failed("hermes_opportunity_sequences_cron_identity_rejected");
626
+ }
627
+ try {
628
+ const stateStat = regularFile(statePath);
629
+ if ((stateStat.mode & 0o077) !== 0) {
630
+ return failed("hermes_opportunity_sequences_cron_state_mode_rejected");
631
+ }
632
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
633
+ const notificationSlackUserId =
634
+ state?.opportunitySequences?.notificationSlackUserId;
635
+ if (!SLACK_USER_ID.test(notificationSlackUserId ?? "")) {
636
+ return failed("hermes_opportunity_sequences_cron_notification_rejected");
637
+ }
638
+ mkdirSync(journalRoot, { recursive: true, mode: 0o700 });
639
+ chmodSync(journalRoot, 0o700);
640
+ const journalStat = lstatSync(journalRoot);
641
+ if (
642
+ journalStat.isSymbolicLink() ||
643
+ !journalStat.isDirectory() ||
644
+ (journalStat.mode & 0o077) !== 0
645
+ ) {
646
+ return failed("hermes_opportunity_sequences_cron_journal_rejected");
647
+ }
648
+ const hashes = opportunitySequenceIdentity(profileRoot);
649
+ const prompt = opportunitySequencePrompt({
650
+ notificationSlackUserId,
651
+ journalRoot,
652
+ hashes,
653
+ });
654
+ const expected = (job) =>
655
+ Boolean(
656
+ job?.id &&
657
+ job.name === HERMES_OPPORTUNITY_SEQUENCES_CRON.name &&
658
+ job.prompt === prompt &&
659
+ job.no_agent === false &&
660
+ job.enabled === true &&
661
+ job.deliver === HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver &&
662
+ sameJson(job.skills, HERMES_OPPORTUNITY_SEQUENCES_CRON.skills) &&
663
+ sameJson(
664
+ job.enabled_toolsets,
665
+ HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets
666
+ ) &&
667
+ job.schedule?.kind === "interval" &&
668
+ job.schedule?.minutes === 30
669
+ );
670
+ const mutate = (jobId = null) => {
671
+ const spec = {
672
+ prompt,
673
+ schedule: HERMES_OPPORTUNITY_SEQUENCES_CRON.schedule,
674
+ name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
675
+ deliver: HERMES_OPPORTUNITY_SEQUENCES_CRON.deliver,
676
+ skills: [...HERMES_OPPORTUNITY_SEQUENCES_CRON.skills],
677
+ enabled_toolsets: [
678
+ ...HERMES_OPPORTUNITY_SEQUENCES_CRON.enabledToolsets,
490
679
  ],
491
- hermesRoot,
492
- hermesCli
493
- )
494
- );
495
- if (childFailed(result)) {
496
- return failed("hermes_snapshot_cron_create_failed");
680
+ no_agent: false,
681
+ enabled: true,
682
+ ...(jobId ? { jobId } : {}),
683
+ };
684
+ return childRunner(runChild)({
685
+ command: hermesPython,
686
+ args: ["-c", OPPORTUNITY_SEQUENCE_CRON_PYTHON, JSON.stringify(spec)],
687
+ env: {
688
+ ...process.env,
689
+ HOME: hermesRoot,
690
+ HERMES_HOME: profileRoot,
691
+ HERMES_PROFILE: profileId,
692
+ NO_COLOR: "1",
693
+ },
694
+ shell: false,
695
+ timeoutMs: NATIVE_COMMAND_TIMEOUT_MS,
696
+ maxOutputBytes: 64 * 1024,
697
+ });
698
+ };
699
+ const result = await ensureHermesNamedCron({
700
+ profileRoot,
701
+ name: HERMES_OPPORTUNITY_SEQUENCES_CRON.name,
702
+ isExpected: expected,
703
+ create: () => mutate(),
704
+ update: (job) => mutate(job.id),
705
+ codePrefix: "hermes_opportunity_sequences_cron",
706
+ });
707
+ if (!result.ok) return result;
708
+ const jobsMode =
709
+ lstatSync(join(profileRoot, "cron", "jobs.json")).mode & 0o777;
710
+ if ((jobsMode & 0o077) !== 0) {
711
+ return failed("hermes_opportunity_sequences_cron_jobs_mode_rejected");
497
712
  }
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");
713
+ return {
714
+ ok: true,
715
+ status: result.status,
716
+ jobId: result.jobId,
717
+ name: result.job.name,
718
+ prompt: result.job.prompt,
719
+ schedule: result.job.schedule,
720
+ profileId,
721
+ enabledToolsets: result.job.enabled_toolsets,
722
+ skills: result.job.skills,
723
+ assetHashes: hashes,
724
+ notificationSlackUserIdSource:
725
+ "config-state.json:opportunitySequences.notificationSlackUserId",
726
+ journalRoot,
727
+ modes: {
728
+ state: stateStat.mode & 0o777,
729
+ journal: journalStat.mode & 0o777,
730
+ jobs: jobsMode,
731
+ },
732
+ };
504
733
  } catch {
505
- return failed("hermes_snapshot_cron_install_failed");
734
+ return failed("hermes_opportunity_sequences_cron_install_failed");
506
735
  }
507
736
  }
508
737
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.589",
3
+ "version": "0.1.590-wip.phase187.20260811.3",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {