@senpi/runtime 3.0.6-dev.runtime-phase-2.20260630134511 → 3.0.6-dev.runtime-phase-2.20260630182800

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.
Files changed (84) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +61 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/senpi-commands.d.ts +14 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +390 -2
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/dsl/plugin/index.d.ts.map +1 -1
  9. package/dist/dsl/plugin/index.js +6 -0
  10. package/dist/dsl/plugin/index.js.map +1 -1
  11. package/dist/health/types.d.ts +14 -0
  12. package/dist/health/types.d.ts.map +1 -1
  13. package/dist/health/types.js.map +1 -1
  14. package/dist/index.d.ts +19 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +183 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/risk/risk-audit-log.d.ts +7 -0
  19. package/dist/risk/risk-audit-log.d.ts.map +1 -1
  20. package/dist/risk/risk-audit-log.js +48 -1
  21. package/dist/risk/risk-audit-log.js.map +1 -1
  22. package/dist/runtime/runtime.d.ts +2 -0
  23. package/dist/runtime/runtime.d.ts.map +1 -1
  24. package/dist/runtime/runtime.js +17 -3
  25. package/dist/runtime/runtime.js.map +1 -1
  26. package/dist/runtime-api/intake/scanner-registry.d.ts +3 -0
  27. package/dist/runtime-api/intake/scanner-registry.d.ts.map +1 -1
  28. package/dist/runtime-api/intake/scanner-registry.js +3 -0
  29. package/dist/runtime-api/intake/scanner-registry.js.map +1 -1
  30. package/dist/scanners/runtime-module.d.ts.map +1 -1
  31. package/dist/scanners/runtime-module.js +6 -0
  32. package/dist/scanners/runtime-module.js.map +1 -1
  33. package/dist/skills-manager/bootstrap.d.ts +12 -7
  34. package/dist/skills-manager/bootstrap.d.ts.map +1 -1
  35. package/dist/skills-manager/bootstrap.js +31 -20
  36. package/dist/skills-manager/bootstrap.js.map +1 -1
  37. package/dist/skills-manager/clone-groups.d.ts +20 -0
  38. package/dist/skills-manager/clone-groups.d.ts.map +1 -0
  39. package/dist/skills-manager/clone-groups.js +45 -0
  40. package/dist/skills-manager/clone-groups.js.map +1 -0
  41. package/dist/skills-manager/constants.d.ts +5 -33
  42. package/dist/skills-manager/constants.d.ts.map +1 -1
  43. package/dist/skills-manager/constants.js +11 -50
  44. package/dist/skills-manager/constants.js.map +1 -1
  45. package/dist/skills-manager/coordinator.d.ts +26 -119
  46. package/dist/skills-manager/coordinator.d.ts.map +1 -1
  47. package/dist/skills-manager/coordinator.js +145 -346
  48. package/dist/skills-manager/coordinator.js.map +1 -1
  49. package/dist/skills-manager/fetcher.d.ts +23 -2
  50. package/dist/skills-manager/fetcher.d.ts.map +1 -1
  51. package/dist/skills-manager/fetcher.js +36 -6
  52. package/dist/skills-manager/fetcher.js.map +1 -1
  53. package/dist/skills-manager/manifest.d.ts +160 -0
  54. package/dist/skills-manager/manifest.d.ts.map +1 -0
  55. package/dist/skills-manager/manifest.js +185 -0
  56. package/dist/skills-manager/manifest.js.map +1 -0
  57. package/dist/skills-manager/skills-manifest.json +16 -0
  58. package/dist/skills-manager/types.d.ts +61 -30
  59. package/dist/skills-manager/types.d.ts.map +1 -1
  60. package/dist/skills-manager/utils.d.ts +10 -26
  61. package/dist/skills-manager/utils.d.ts.map +1 -1
  62. package/dist/skills-manager/utils.js +18 -34
  63. package/dist/skills-manager/utils.js.map +1 -1
  64. package/dist/skills-manager/version-manager.d.ts +21 -91
  65. package/dist/skills-manager/version-manager.d.ts.map +1 -1
  66. package/dist/skills-manager/version-manager.js +48 -178
  67. package/dist/skills-manager/version-manager.js.map +1 -1
  68. package/dist/skills-manager/writer.d.ts +12 -1
  69. package/dist/skills-manager/writer.d.ts.map +1 -1
  70. package/dist/skills-manager/writer.js +50 -4
  71. package/dist/skills-manager/writer.js.map +1 -1
  72. package/dist/telemetry/event-store.d.ts +66 -0
  73. package/dist/telemetry/event-store.d.ts.map +1 -0
  74. package/dist/telemetry/event-store.js +202 -0
  75. package/dist/telemetry/event-store.js.map +1 -0
  76. package/dist/utils/logger.d.ts.map +1 -1
  77. package/dist/utils/logger.js +7 -15
  78. package/dist/utils/logger.js.map +1 -1
  79. package/dist/utils/wallet-address.d.ts +3 -0
  80. package/dist/utils/wallet-address.d.ts.map +1 -0
  81. package/dist/utils/wallet-address.js +17 -0
  82. package/dist/utils/wallet-address.js.map +1 -0
  83. package/openclaw.plugin.json +18 -6
  84. package/package.json +3 -2
@@ -16,17 +16,19 @@
16
16
  * health as healthy. The first tick fires on the next external invocation
17
17
  * of `runTickOnce()`.
18
18
  *
19
- * Each tick: resolve skill names (configured list or discovery), check
20
- * remote versions in parallel, auto-apply patch/minor, notify on major
21
- * (deduped by version), skip on none/unknown.
19
+ * Each tick: take the managed skill set from the manifest
20
+ * (`skills-manifest.json`, the single source of truth), check remote versions
21
+ * in parallel, auto-apply patch/minor, notify on major (deduped by version),
22
+ * skip on none/unknown.
22
23
  */
23
- import { readdir } from "node:fs/promises";
24
24
  import { bootstrapSkillsIfNeeded } from "./bootstrap.js";
25
+ import { withClonedGroups } from "./clone-groups.js";
25
26
  import { cloneSkillsRepo } from "./fetcher.js";
27
+ import { getManifestSkillNames, resolveSkillSource } from "./manifest.js";
28
+ import { parseSemver } from "../runtime/auto-update/policy.js";
26
29
  import { SkillsManagerStateStore } from "./state-store.js";
27
- import { checkSkillVersion } from "./version-manager.js";
30
+ import { checkSkillVersion, normalizeVersion } from "./version-manager.js";
28
31
  import { installSkill } from "./writer.js";
29
- import { NOTIFICATION_SKILL_LIST_TOP_N, SKILLS_RAW_BASE_URL, SKILLS_REPO_URL, TELEGRAM_NOTIFICATION_BUDGET_CHARS, } from "./constants.js";
30
32
  import { createDefaultSkillState, createDefaultSkillsManagerState, formatErr } from "./utils.js";
31
33
  export class SkillsCoordinatorUnavailableError extends Error {
32
34
  constructor(message) {
@@ -34,6 +36,37 @@ export class SkillsCoordinatorUnavailableError extends Error {
34
36
  this.name = "SkillsCoordinatorUnavailableError";
35
37
  }
36
38
  }
39
+ /** Parse the major-version number from a SKILL.md version string, or null. */
40
+ function majorOf(version) {
41
+ if (!version)
42
+ return null;
43
+ return parseSemver(normalizeVersion(version))?.major ?? null;
44
+ }
45
+ /**
46
+ * Whether a remote major is **gated** (not auto-applied by the tick).
47
+ *
48
+ * The effective ceiling is `max(maxMajor, installedMajor)` — flooring at the
49
+ * installed major matters because a manual `senpi skills update` applies any
50
+ * major, so a skill's installed major can legitimately exceed `maxMajor`; once
51
+ * it does, patch/minor *within* that already-installed major must keep
52
+ * auto-applying (you can't un-install it). Null handling:
53
+ * - `maxMajor` null → floor is just `installedMajor` (gate all true majors).
54
+ * - `installedMajor` null (unversioned/not-installed) → floor is `maxMajor`
55
+ * (so a null-local "minor" classification can't bypass the ceiling).
56
+ * - both null → no ceiling → not gated.
57
+ */
58
+ function isMajorGated(remoteMajor, installedMajor, maxMajor) {
59
+ if (remoteMajor === null)
60
+ return false;
61
+ const candidates = [];
62
+ if (maxMajor !== null)
63
+ candidates.push(maxMajor);
64
+ if (installedMajor !== null)
65
+ candidates.push(installedMajor);
66
+ if (candidates.length === 0)
67
+ return false;
68
+ return remoteMajor > Math.max(...candidates);
69
+ }
37
70
  export class SkillsUpdateCoordinator {
38
71
  options;
39
72
  store;
@@ -42,38 +75,11 @@ export class SkillsUpdateCoordinator {
42
75
  runToken = 0;
43
76
  state = createDefaultSkillsManagerState();
44
77
  snapshot;
45
- /** Latest remote version per skill name — populated during ticks. */
46
- latestVersions = new Map();
47
- /**
48
- * Local (pre-install) version per skill name — populated during ticks
49
- * and manual RPC paths from `SkillVersionInfo.localVersion`. Used by the
50
- * update-summary and major-available notifications to render the
51
- * `prev → new` transition. Captured *before* the install runs so the
52
- * value still reflects the on-disk version at the time the user chose to
53
- * update; reading `state.skills[name].installedVersion` instead would
54
- * surface the *post-install* version (recorded by `recordSuccess`) and
55
- * collapse the transition to `vX -> vX`.
56
- */
57
- previousVersions = new Map();
58
78
  /**
59
- * Cached short descriptions parsed from each skill's **remote** SKILL.md
60
- * frontmatter — i.e. the description for the upcoming/current remote
61
- * version, not whatever's installed locally. Used to enrich
62
- * update-summary and major-available notifications with a one-line
63
- * "About:" so the user gets context for the version the message is
64
- * actually announcing.
65
- *
66
- * Populated during tick / updateSkill / updateAll alongside
67
- * `latestVersions`; absent / null entries simply omit the line from the
68
- * notification.
69
- *
70
- * Using the remote description (rather than the local one) keeps the
71
- * notification description in sync with the version number it sits next
72
- * to — e.g. when notifying about a major bump v1 → v2, the "About:" line
73
- * reflects the v2 description, even if the v1 local copy on disk is
74
- * stale or worded differently.
79
+ * Latest remote version per skill name — populated during ticks. Read by
80
+ * `buildSnapshot` (status RPC) and used as the install `expectedVersion`.
75
81
  */
76
- skillDescriptions = new Map();
82
+ latestVersions = new Map();
77
83
  constructor(options) {
78
84
  this.options = options;
79
85
  this.store = new SkillsManagerStateStore(options.stateDir, options.config.skillsDir, options.logger);
@@ -99,7 +105,7 @@ export class SkillsUpdateCoordinator {
99
105
  logger.info("[senpi-skills-manager] disabled by config");
100
106
  return;
101
107
  }
102
- logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${config.skills.join(",") || "(discover)"} cadence=external (driven by auto-update)`);
108
+ logger.info(`[senpi-skills-manager] starting | skillsDir=${config.skillsDir.join(",")} skills=${this.resolveSkillNames().join(",")} (manifest) cadence=external (driven by auto-update)`);
103
109
  this.state = await this.store.load();
104
110
  const bootstrapFn = this.options.deps?.bootstrapSkillsIfNeeded ?? bootstrapSkillsIfNeeded;
105
111
  try {
@@ -196,9 +202,10 @@ export class SkillsUpdateCoordinator {
196
202
  const { logger } = this.options;
197
203
  logger.info(`[senpi-skills-manager] updateSkill invoked | skill=${skillName}`);
198
204
  const checkVersionFn = this.resolveCheckFn();
205
+ const source = this.resolveSource(skillName);
199
206
  let versionInfo;
200
207
  try {
201
- versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
208
+ versionInfo = await checkVersionFn(this.options.config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
202
209
  }
203
210
  catch (err) {
204
211
  // Version check threw (5xx, network error, timeout, body-parse failure
@@ -215,11 +222,8 @@ export class SkillsUpdateCoordinator {
215
222
  }
216
223
  // Record the remote version we just learned so the snapshot's latestVersion
217
224
  // field reflects it on the next getSnapshot() call — even when no install
218
- // happens (skip paths below). Local description is cached alongside so
219
- // notifications (sendUpdateSummary / handleMajorAvailable) can include it.
225
+ // happens (skip paths below).
220
226
  this.latestVersions.set(skillName, versionInfo.remoteVersion);
221
- this.previousVersions.set(skillName, versionInfo.localVersion);
222
- this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
223
227
  // Mirror runTick: every skill whose version we just checked gets its
224
228
  // `lastCheckedAt` updated (and `installedVersion` refreshed from the local
225
229
  // read) regardless of whether it proceeds to install. Without this, skills
@@ -287,7 +291,7 @@ export class SkillsUpdateCoordinator {
287
291
  this.assertManualOperationAllowed("updateAll", token);
288
292
  const { logger, config } = this.options;
289
293
  logger.info("[senpi-skills-manager] updateAll invoked");
290
- const skillNames = await this.resolveSkillNames();
294
+ const skillNames = this.resolveSkillNames();
291
295
  if (skillNames.length === 0) {
292
296
  logger.info("[senpi-skills-manager] updateAll — no skills to check");
293
297
  return [];
@@ -296,10 +300,9 @@ export class SkillsUpdateCoordinator {
296
300
  const checkVersionFn = this.resolveCheckFn();
297
301
  // Check every skill's version in parallel — one rejection does not abort others.
298
302
  const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
299
- const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
303
+ const source = this.resolveSource(skillName);
304
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
300
305
  this.latestVersions.set(skillName, versionInfo.remoteVersion);
301
- this.previousVersions.set(skillName, versionInfo.localVersion);
302
- this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
303
306
  return { name: skillName, ...versionInfo };
304
307
  }));
305
308
  const results = [];
@@ -440,7 +443,7 @@ export class SkillsUpdateCoordinator {
440
443
  */
441
444
  async runTick() {
442
445
  const { logger, config } = this.options;
443
- const skillNames = await this.resolveSkillNames();
446
+ const skillNames = this.resolveSkillNames();
444
447
  if (skillNames.length === 0) {
445
448
  logger.info("[senpi-skills-manager] tick — no skills to check");
446
449
  return;
@@ -449,11 +452,12 @@ export class SkillsUpdateCoordinator {
449
452
  const checkVersionFn = this.resolveCheckFn();
450
453
  // Check all versions in parallel — per-skill errors are captured, not thrown.
451
454
  const versionChecks = await Promise.allSettled(skillNames.map(async (skillName) => {
452
- const versionInfo = await checkVersionFn(config.skillsDir, skillName, SKILLS_RAW_BASE_URL, logger);
455
+ const source = this.resolveSource(skillName);
456
+ const versionInfo = await checkVersionFn(config.skillsDir, skillName, source.rawBaseUrl, logger, source.path);
453
457
  this.latestVersions.set(skillName, versionInfo.remoteVersion);
454
- this.previousVersions.set(skillName, versionInfo.localVersion);
455
- this.skillDescriptions.set(skillName, versionInfo.remoteDescription ?? null);
456
- return { name: skillName, ...versionInfo };
458
+ // Carry the resolved major ceiling so the triage loop can decide whether
459
+ // a major bump is authorized without re-resolving the source.
460
+ return { name: skillName, ...versionInfo, maxMajor: source.maxMajor };
457
461
  }));
458
462
  const skillsToInstall = [];
459
463
  let majorPendingCount = 0;
@@ -467,7 +471,7 @@ export class SkillsUpdateCoordinator {
467
471
  logger.warn(`[senpi-skills-manager] tick — version check failed | skill=${skillName} err=${errorMessage}`);
468
472
  continue;
469
473
  }
470
- const { localVersion, remoteVersion, updateKind } = checkResult.value;
474
+ const { localVersion, remoteVersion, updateKind, maxMajor } = checkResult.value;
471
475
  const skillState = this.state.skills[skillName] ?? createDefaultSkillState(skillName);
472
476
  skillState.lastCheckedAt = new Date().toISOString();
473
477
  if (localVersion !== null)
@@ -486,14 +490,25 @@ export class SkillsUpdateCoordinator {
486
490
  upToDateCount += 1;
487
491
  continue;
488
492
  }
489
- if (updateKind === "major") {
490
- // Major versions are never auto-applied — notify the user and let them decide.
491
- await this.handleMajorAvailable(skillState, skillName, remoteVersion);
493
+ // Enforce the `maxMajor` ceiling on the **remote major regardless of how
494
+ // the delta was classified** (a null local version classifies as "minor"
495
+ // but could still pull a remote major above the ceiling). The effective
496
+ // ceiling is `max(maxMajor, installedMajor)` — see `isMajorGated`.
497
+ const remoteMajor = majorOf(remoteVersion);
498
+ const ceilingExceeded = isMajorGated(remoteMajor, majorOf(localVersion), maxMajor);
499
+ if (ceilingExceeded) {
500
+ // Gated: record the newer `latestVersion` (surfaced via
501
+ // `senpi skills status`); the operator applies it with a manual
502
+ // `senpi skills update <name>` or by bumping `maxMajor` in the manifest.
503
+ logger.info(`[senpi-skills-manager] tick — major available, not auto-applied | skill=${skillName} local=${localVersion ?? "none"} remote=${remoteVersion ?? "unknown"} maxMajor=${maxMajor ?? "none"}`);
492
504
  this.state.skills[skillName] = skillState;
493
505
  majorPendingCount += 1;
494
506
  continue;
495
507
  }
496
- // patch / minor — queue for install
508
+ if (updateKind === "major") {
509
+ logger.info(`[senpi-skills-manager] tick — major within maxMajor ceiling, auto-applying | skill=${skillName} local=${localVersion ?? "none"} remote=${remoteVersion ?? "unknown"} maxMajor=${maxMajor}`);
510
+ }
511
+ // patch / minor / authorized-major — queue for install
497
512
  skillsToInstall.push(skillName);
498
513
  this.state.skills[skillName] = skillState;
499
514
  }
@@ -504,14 +519,12 @@ export class SkillsUpdateCoordinator {
504
519
  }
505
520
  /**
506
521
  * Clone the repo once and install every skill in `skillNames`.
507
- * Per-skill errors are recorded in state without aborting the others.
508
- * The update summary notification distinguishes successful installs from
509
- * failures so the user can see exactly which skills need attention.
522
+ * Per-skill errors are recorded in state without aborting the others;
523
+ * outcomes are logged (no notification).
510
524
  *
511
- * If the clone itself fails we log and return without touching state or
512
- * sending a summary — a full-clone failure is almost always a transient
513
- * network/git issue that affects every skill equally, so attributing it
514
- * per-skill would be misleading. The next tick retries.
525
+ * If the clone itself fails we log and return without touching state — a
526
+ * full-clone failure is almost always a transient network/git issue that
527
+ * affects every skill equally. The next tick retries.
515
528
  */
516
529
  async applyUpdates(skillNames) {
517
530
  const { logger } = this.options;
@@ -524,20 +537,19 @@ export class SkillsUpdateCoordinator {
524
537
  logger.warn(`[senpi-skills-manager] applyUpdates — git clone failed, skipping batch: ${formatErr(err)}`);
525
538
  return;
526
539
  }
527
- const succeededSkillNames = [];
528
- const failedSkills = [];
540
+ let succeeded = 0;
541
+ let failed = 0;
529
542
  for (const skillName of skillNames) {
530
543
  const outcome = this.processInstallOutcome(skillName, installOutcomes);
531
544
  if (outcome.success) {
532
- succeededSkillNames.push(skillName);
545
+ succeeded += 1;
533
546
  logger.info(`[senpi-skills-manager] applyUpdates — installed | skill=${skillName} version=${outcome.version ?? "unknown"}`);
534
547
  }
535
548
  else {
536
- failedSkills.push({ name: skillName, reason: outcome.error });
549
+ failed += 1;
537
550
  }
538
551
  }
539
- logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeededSkillNames.length} failed=${failedSkills.length}`);
540
- await this.sendUpdateSummary(succeededSkillNames, failedSkills);
552
+ logger.info(`[senpi-skills-manager] applyUpdates complete | installed=${succeeded} failed=${failed}`);
541
553
  }
542
554
  /**
543
555
  * Clone the remote skills repo exactly once and install every skill in
@@ -573,16 +585,29 @@ export class SkillsUpdateCoordinator {
573
585
  }
574
586
  return outcomes;
575
587
  }
576
- logger.info(`[senpi-skills-manager] cloneAndInstallMany — cloning repo | url=${SKILLS_REPO_URL} timeoutMs=120000`);
577
- const { cloneDir, cleanup } = await cloneSkillsRepo(SKILLS_REPO_URL, 120_000, logger);
578
- logger.info(`[senpi-skills-manager] cloneAndInstallMany — clone ready, installing | count=${skillNames.length} cloneDir=${cloneDir}`);
579
- try {
580
- for (const skillName of skillNames) {
588
+ // Allowlist = the managed (manifest) set, NOT just this batch — otherwise a
589
+ // manual single-skill `updateSkill(name)` (batch `[name]`) would make the
590
+ // installSkill allowlist pass for any name, letting it install an arbitrary
591
+ // repo subdir outside the managed set.
592
+ const allowedSkills = this.resolveSkillNames();
593
+ // Group skills by their resolved (repo, branch, commit) and clone each group
594
+ // once (shared helper handles grouping + per-group clone resilience).
595
+ const sources = skillNames.map((name) => this.resolveSource(name));
596
+ await withClonedGroups(sources, cloneSkillsRepo, logger, async (cloneDir, groupSources) => {
597
+ logger.info(`[senpi-skills-manager] cloneAndInstallMany — clone ready, installing | count=${groupSources.length} cloneDir=${cloneDir}`);
598
+ for (const source of groupSources) {
599
+ const skillName = source.name;
581
600
  try {
582
601
  // Writes always target the *primary* dir (first entry in the
583
602
  // search path). Secondary dirs are read-only mirrors — we'd never
584
603
  // overwrite them on a tick or manual update.
585
- await installSkill(cloneDir, skillName, config.skillsDir[0], logger);
604
+ await installSkill(cloneDir, skillName, config.skillsDir[0], logger, {
605
+ sourcePath: source.path,
606
+ allowedSkills,
607
+ // Verify the freshly-installed SKILL.md matches the version the
608
+ // check decided to install — rolls back on mismatch.
609
+ expectedVersion: this.latestVersions.get(skillName) ?? null,
610
+ });
586
611
  const newVersion = this.latestVersions.get(skillName) ?? null;
587
612
  outcomes.set(skillName, { version: newVersion });
588
613
  logger.info(`[senpi-skills-manager] cloneAndInstallMany — installed | skill=${skillName} version=${newVersion ?? "unknown"}`);
@@ -593,11 +618,15 @@ export class SkillsUpdateCoordinator {
593
618
  logger.warn(`[senpi-skills-manager] cloneAndInstallMany — install failed | skill=${skillName} err=${errorMessage}`);
594
619
  }
595
620
  }
596
- }
597
- finally {
598
- await cleanup();
599
- logger.info("[senpi-skills-manager] cloneAndInstallMany — clone cleanup done");
600
- }
621
+ }, (groupSources, err) => {
622
+ // A clone failure for one group records that group's skills as failed
623
+ // and continues — earlier groups' installs are preserved.
624
+ const errorMessage = `clone failed: ${formatErr(err)}`;
625
+ for (const source of groupSources) {
626
+ outcomes.set(source.name, { version: null, error: errorMessage });
627
+ }
628
+ logger.warn(`[senpi-skills-manager] cloneAndInstallMany — clone failed, skipping group | skills=${groupSources.map((s) => s.name).join(",")} err=${formatErr(err)}`);
629
+ });
601
630
  return outcomes;
602
631
  }
603
632
  /**
@@ -622,7 +651,6 @@ export class SkillsUpdateCoordinator {
622
651
  */
623
652
  recordSuccess(skillState, newVersion) {
624
653
  skillState.installedVersion = newVersion;
625
- skillState.lastAppliedVersion = newVersion;
626
654
  skillState.consecutiveFailures = 0;
627
655
  skillState.lastFailureAt = null;
628
656
  skillState.lastFailureReason = null;
@@ -710,208 +738,6 @@ export class SkillsUpdateCoordinator {
710
738
  logger.info(`[senpi-skills-manager] applyUpdate complete | skill=${skillName} version=${installedVersion ?? "unknown"}`);
711
739
  return installedVersion;
712
740
  }
713
- /**
714
- * Format the per-skill nested-bullet block used in both notification
715
- * shapes. Renders the structure suggested in PR #78 review feedback:
716
- *
717
- * **skill-name**
718
- * • v1.0.0 → v2.0.0 (or "v1.0.0 (install failed)" when failed)
719
- * • description: … (omitted when no remote description)
720
- * • error: … (only on failure, replaces the description)
721
- *
722
- * **Why markdown, not HTML?**
723
- * Openclaw's Telegram channel (`src/telegram/send.ts:sendMessageTelegram`)
724
- * pipes outbound text through `renderTelegramHtmlText`, which defaults
725
- * `textMode` to `"markdown"` — i.e. it expects the source to be markdown
726
- * and runs `markdownToTelegramHtml` over it before handing the converted
727
- * HTML to the Bot API with `parse_mode: "HTML"`. The `message` tool
728
- * (which is what this coordinator's `notify` callback ultimately hits via
729
- * `/tools/invoke`) does not expose `textMode`, so HTML written by us is
730
- * treated as literal characters by the markdown step and rendered as
731
- * visible `<b>` text in the chat (the actual production bug this docstring
732
- * fixes — confirmed against a live notification screenshot).
733
- *
734
- * Using markdown source (`**bold**`, backticks) instead means the channel's
735
- * markdown→HTML converter produces the correct `<b>` / `<code>` HTML
736
- * payload and Telegram renders the formatting properly.
737
- *
738
- * @param skillName Skill identifier (rendered bold via `**name**`).
739
- * @param toVersion Resolved upgraded-to version. `null` for failure rows
740
- * where no install completed; renders as
741
- * "vPREV (install failed)".
742
- * @param errorReason Optional failure reason. When provided, replaces
743
- * the description line with `error: …`.
744
- */
745
- formatSkillBlock(skillName, toVersion, errorReason = null, options = {}) {
746
- const prev = this.previousVersions.get(skillName) ?? null;
747
- const versionLine = errorReason !== null
748
- ? ` • ${prev !== null ? `v${prev} ` : ""}(install failed)`
749
- : ` • ${prev !== null ? `v${prev} → ` : ""}${toVersion !== null ? `v${toVersion}` : "(unknown)"}`;
750
- // `omitDescription` is set when the caller is rebuilding a notification
751
- // in compact mode after the verbose form exceeded Telegram's per-message
752
- // byte budget (see `sendUpdateSummary`'s three-tier sizing). The error
753
- // line for failures is NEVER omitted — it's the actionable bit and a
754
- // single line, not the source of the overflow.
755
- const description = options.omitDescription
756
- ? null
757
- : (this.skillDescriptions.get(skillName) ?? null);
758
- const trailingLine = errorReason !== null
759
- ? ` • error: ${errorReason}`
760
- : description
761
- ? ` • description: ${description}`
762
- : null;
763
- // Skill name line: bold only, no leading bullet — the bold acts as the
764
- // visual anchor for the block, and the nested bullets below already
765
- // convey "these belong to this skill". A leading `•` on the heading
766
- // looked redundant in production.
767
- const lines = [`**${skillName}**`, versionLine];
768
- if (trailingLine)
769
- lines.push(trailingLine);
770
- return lines.join("\n");
771
- }
772
- /**
773
- * Send a one-way Telegram notification for a pending major update, deduped
774
- * by version.
775
- *
776
- * The dedup key (`lastNotifiedMajorVersion`) is only set after the notify
777
- * call resolves successfully. If the notification fails (Telegram API down,
778
- * transient network error, etc.) the key is left unchanged so the next tick
779
- * retries delivery instead of silently suppressing it forever.
780
- */
781
- async handleMajorAvailable(skillState, skillName, remoteVersion) {
782
- if (skillState.lastNotifiedMajorVersion === remoteVersion) {
783
- this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — notification already sent, skipping | skill=${skillName} version=${remoteVersion}`);
784
- return;
785
- }
786
- try {
787
- const block = this.formatSkillBlock(skillName, remoteVersion);
788
- await this.options.notify(`Senpi skills update — major available:\n\n` +
789
- `${block}\n\n` +
790
- `Major updates are not applied automatically.\n` +
791
- // Inline code via markdown backticks — the channel's markdown→HTML
792
- // step converts these to `<code>` so the install command renders
793
- // in monospace. Skill names in the senpi-skills repo are
794
- // kebab-case identifiers (no backtick chars), so no escaping is
795
- // needed here in practice.
796
- `Run: \`openclaw senpi skills update ${skillName}\`\n\n` +
797
- // Yes/no CTA — operator-friendly framing, intentionally short.
798
- // Replies are routed to the agent layer, not to this coordinator.
799
- `Want help with the upgrade? Reply yes or no.`);
800
- skillState.lastNotifiedMajorVersion = remoteVersion;
801
- this.options.logger.info(`[senpi-skills-manager] handleMajorAvailable — major-update notification sent | skill=${skillName} version=${remoteVersion}`);
802
- }
803
- catch (err) {
804
- this.options.logger.warn(`[senpi-skills-manager] failed to send major-update notification for "${skillName}": ${formatErr(err)}`);
805
- // Leave dedup key unchanged — retry on next tick.
806
- }
807
- }
808
- /**
809
- * Send an aggregated notification summarising the outcome of this tick's
810
- * updates. Successful installs and failures are shown in separate sections
811
- * so the user can tell which skills still need attention. Sections with no
812
- * entries are omitted. If neither section has entries, no notification is
813
- * sent.
814
- *
815
- * Format (markdown source — see `formatSkillBlock` for why markdown
816
- * rather than HTML):
817
- *
818
- * Senpi skills update:
819
- *
820
- * Updated:
821
- *
822
- * **skill-a**
823
- * • v1.0.0 → v1.0.1
824
- * • description: …
825
- *
826
- * **skill-b**
827
- * • v2.0.0 → v2.0.1
828
- * • description: …
829
- *
830
- * Failed to update:
831
- *
832
- * **skill-c**
833
- * • v1.0.0 (install failed)
834
- * • error: clone failed: …
835
- */
836
- async sendUpdateSummary(succeededSkillNames, failedSkills) {
837
- if (succeededSkillNames.length === 0 && failedSkills.length === 0)
838
- return;
839
- // ── Three-tier size budget ────────────────────────────────────────────
840
- //
841
- // Telegram caps a single message at 4096 chars; if we send more, the
842
- // Bot API rejects with "MESSAGE_TOO_LONG" and the user sees nothing.
843
- // Discovery-mode deployments with many skills + long descriptions can
844
- // realistically blow past that. Lead's review comment
845
- // (PR #78 #discussion_r3247822298): always render *something* useful,
846
- // never silently drop the notification.
847
- //
848
- // Tier 1 (default — verbose): full per-skill blocks with descriptions.
849
- // Tier 2 (compact): same blocks but with description lines omitted —
850
- // keeps version transitions + error reasons, which are the
851
- // actionable bits.
852
- // Tier 3 (top-N cap): if even compact mode exceeds the budget, render
853
- // only the first NOTIFICATION_SKILL_LIST_TOP_N of each section with a
854
- // "… and N more" tail.
855
- //
856
- // The 4000-char default budget (vs Telegram's hard 4096) leaves headroom
857
- // for the gateway's markdown→HTML conversion to add wrapping tags
858
- // without pushing the payload over. Both the budget and the per-section
859
- // cap are env-overridable (see `constants.ts`) so operators with
860
- // alternative transports or different verbosity preferences can tune
861
- // them without a code change.
862
- const BUDGET_CHARS = TELEGRAM_NOTIFICATION_BUDGET_CHARS;
863
- const TOP_N = NOTIFICATION_SKILL_LIST_TOP_N;
864
- const build = (opts) => {
865
- const succeededRendered = opts.cap !== undefined
866
- ? succeededSkillNames.slice(0, opts.cap)
867
- : succeededSkillNames;
868
- const failedRendered = opts.cap !== undefined
869
- ? failedSkills.slice(0, opts.cap)
870
- : failedSkills;
871
- const sections = [];
872
- if (succeededSkillNames.length > 0) {
873
- const blocks = succeededRendered.map((name) => this.formatSkillBlock(name, this.latestVersions.get(name) ?? null, null, {
874
- omitDescription: opts.omitDescriptions,
875
- }));
876
- // Tail when capped: shows the omitted count so operators know the
877
- // list isn't the complete picture (and can look at logs for full).
878
- const tail = opts.cap !== undefined && succeededSkillNames.length > opts.cap
879
- ? `\n\n… and ${succeededSkillNames.length - opts.cap} more updated skill${succeededSkillNames.length - opts.cap === 1 ? "" : "s"} not shown`
880
- : "";
881
- sections.push(`Updated:\n\n${blocks.join("\n\n")}${tail}`);
882
- }
883
- if (failedSkills.length > 0) {
884
- const blocks = failedRendered.map(({ name, reason }) => this.formatSkillBlock(name, null, reason, {
885
- omitDescription: opts.omitDescriptions,
886
- }));
887
- const tail = opts.cap !== undefined && failedSkills.length > opts.cap
888
- ? `\n\n… and ${failedSkills.length - opts.cap} more failed skill${failedSkills.length - opts.cap === 1 ? "" : "s"} not shown`
889
- : "";
890
- sections.push(`Failed to update:\n\n${blocks.join("\n\n")}${tail}`);
891
- }
892
- return `Senpi skills update:\n\n${sections.join("\n\n")}`;
893
- };
894
- // Tier 1: verbose
895
- let message = build({ omitDescriptions: false });
896
- let tier = "verbose";
897
- if (message.length > BUDGET_CHARS) {
898
- // Tier 2: drop descriptions
899
- message = build({ omitDescriptions: true });
900
- tier = "compact (descriptions omitted)";
901
- if (message.length > BUDGET_CHARS) {
902
- // Tier 3: cap each section to top-N
903
- message = build({ omitDescriptions: true, cap: TOP_N });
904
- tier = `capped (top ${TOP_N} per section, descriptions omitted)`;
905
- }
906
- }
907
- try {
908
- await this.options.notify(message);
909
- this.options.logger.info(`[senpi-skills-manager] sendUpdateSummary — notification sent | succeeded=${succeededSkillNames.length} failed=${failedSkills.length} tier=${tier} messageChars=${message.length}`);
910
- }
911
- catch (err) {
912
- this.options.logger.warn(`[senpi-skills-manager] failed to send update summary notification: ${formatErr(err)}`);
913
- }
914
- }
915
741
  /**
916
742
  * Return the version-check function for this coordinator: either the
917
743
  * injected `deps.checkSkillVersion` (unit tests) or the real
@@ -923,50 +749,29 @@ export class SkillsUpdateCoordinator {
923
749
  */
924
750
  resolveCheckFn() {
925
751
  return (this.options.deps?.checkSkillVersion ??
926
- ((skillsDirs, skillName, rawBaseUrl, logger) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger)));
752
+ ((skillsDirs, skillName, rawBaseUrl, logger, remotePath) => checkSkillVersion(skillsDirs, skillName, rawBaseUrl, undefined, logger, remotePath)));
927
753
  }
928
754
  /**
929
- * Resolve the list of skill names to check this tick.
930
- * Uses `config.skills` when non-empty; otherwise discovers all immediate
931
- * subdirectories across every dir in the `skillsDir` search path,
932
- * deduped by name and sorted.
933
- *
934
- * Per-dir read errors (other than ENOENT) are logged but do not abort the
935
- * scan — a misconfigured secondary dir cannot mask skills present in the
936
- * primary.
755
+ * Resolve a skill name to its concrete source (repo/branch/path/rawBaseUrl)
756
+ * from the manifest, applying any plugin-config per-skill overrides. The
757
+ * `SENPI_SKILLS_BRANCH` / `SENPI_SKILLS_REPO_URL` env testing knobs are
758
+ * applied inside {@link resolveSkillSource}.
937
759
  */
938
- async resolveSkillNames() {
939
- if (this.options.config.skills.length > 0) {
940
- return this.options.config.skills;
941
- }
942
- const seen = new Set();
943
- let anySucceeded = false;
944
- for (const dir of this.options.config.skillsDir) {
945
- try {
946
- const entries = await readdir(dir, { withFileTypes: true });
947
- anySucceeded = true;
948
- for (const entry of entries) {
949
- if (entry.isDirectory() && !entry.name.startsWith(".")) {
950
- seen.add(entry.name);
951
- }
952
- }
953
- }
954
- catch (err) {
955
- // ENOENT for a dir that hasn't been created yet is benign — only
956
- // log the noisier failure modes so operators can diagnose them.
957
- if (err.code !== "ENOENT") {
958
- this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — failed to read skillsDir, continuing | dir=${dir} err=${formatErr(err)}`);
959
- }
960
- }
961
- }
962
- if (!anySucceeded && seen.size === 0) {
963
- // Every configured dir failed to read (or none exist). Previously the
964
- // single-dir version warned in this case; preserve that signal so
965
- // "no skills checked" is still distinguishable from "skillsDir
966
- // configured wrong" in the logs.
967
- this.options.logger.warn(`[senpi-skills-manager] resolveSkillNames — no readable skills dirs | dirs=${this.options.config.skillsDir.join(",")}`);
968
- }
969
- return Array.from(seen).sort();
760
+ resolveSource(skillName) {
761
+ return resolveSkillSource(skillName, { overrides: this.options.config.overrides });
762
+ }
763
+ /**
764
+ * The managed skill set for this tick — **always the manifest** set
765
+ * (`skills-manifest.json`), the single source of truth. `config.skills` and
766
+ * directory discovery no longer define the set: the manifest does, full stop
767
+ * (only `enabled: false` disables the manager entirely).
768
+ *
769
+ * `deps.listManagedSkills` is a test-only seam to substitute the set without
770
+ * a fixture manifest on disk; production always falls through to the
771
+ * manifest.
772
+ */
773
+ resolveSkillNames() {
774
+ return this.options.deps?.listManagedSkills?.() ?? getManifestSkillNames();
970
775
  }
971
776
  /**
972
777
  * Build the point-in-time snapshot returned by {@link getSnapshot}.
@@ -987,18 +792,26 @@ export class SkillsUpdateCoordinator {
987
792
  * state rather than stale pre-action data.
988
793
  */
989
794
  buildSnapshot(health, lastAction, lastError = null) {
990
- const skillSnapshots = Object.values(this.state.skills).map((skillState) => ({
991
- name: skillState.name,
992
- installedVersion: skillState.installedVersion,
795
+ const skillSnapshots = Object.values(this.state.skills).map((skillState) => {
993
796
  // Prefer the in-memory map (freshest — populated synchronously inside
994
797
  // the current tick before `persistState` runs) and fall back to the
995
798
  // persisted field. The persisted fallback covers the post-restart
996
799
  // window where the map is empty until the first external tick fires.
997
- latestVersion: this.latestVersions.get(skillState.name) ?? skillState.latestVersion ?? null,
998
- lastCheckedAt: skillState.lastCheckedAt,
999
- consecutiveFailures: skillState.consecutiveFailures,
1000
- lastFailureReason: skillState.lastFailureReason,
1001
- }));
800
+ const latestVersion = this.latestVersions.get(skillState.name) ?? skillState.latestVersion ?? null;
801
+ return {
802
+ name: skillState.name,
803
+ installedVersion: skillState.installedVersion,
804
+ latestVersion,
805
+ lastCheckedAt: skillState.lastCheckedAt,
806
+ consecutiveFailures: skillState.consecutiveFailures,
807
+ lastFailureReason: skillState.lastFailureReason,
808
+ // A major is pending when the latest observed major is **gated** — i.e.
809
+ // it exceeds the effective ceiling (max(maxMajor, installedMajor)) and so
810
+ // the tick won't auto-apply it. A major within the ceiling is omitted
811
+ // (it auto-applies on the next tick).
812
+ majorPending: isMajorGated(majorOf(latestVersion), majorOf(skillState.installedVersion), this.resolveSource(skillState.name).maxMajor),
813
+ };
814
+ });
1002
815
  return {
1003
816
  enabled: this.options.config.enabled,
1004
817
  // Snapshot is a serializable view consumed by gateway RPCs and the CLI;
@@ -1020,18 +833,4 @@ export class SkillsUpdateCoordinator {
1020
833
  }
1021
834
  }
1022
835
  }
1023
- // `escapeHtml` was removed alongside the switch from HTML to markdown
1024
- // notification source — see `formatSkillBlock` for why. The openclaw
1025
- // Telegram channel runs `markdownToTelegramHtml` over our text and
1026
- // handles HTML escaping itself on the converter's output. Pre-escaping
1027
- // at this layer would double-escape (e.g. `&amp;` becomes `&amp;amp;`)
1028
- // and corrupt the rendered output.
1029
- //
1030
- // Markdown-special characters (`*`, `_`, `[`, `]`, backtick) in dynamic
1031
- // values aren't escaped either — descriptions that legitimately contain
1032
- // these would author them as markdown intentionally; skill names in the
1033
- // senpi-skills repo are kebab-case identifiers; error messages from
1034
- // `formatErr` are typically prose. If a future skill description ever
1035
- // breaks the rendering, the fix is to author the SKILL.md frontmatter
1036
- // using proper markdown.
1037
836
  //# sourceMappingURL=coordinator.js.map