agenticros 0.1.18 → 0.1.19

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 (96) hide show
  1. package/dist/__tests__/robot-config.test.d.ts +2 -0
  2. package/dist/__tests__/robot-config.test.d.ts.map +1 -0
  3. package/dist/__tests__/robot-config.test.js +374 -0
  4. package/dist/__tests__/robot-config.test.js.map +1 -0
  5. package/dist/__tests__/transport-shorthand.test.d.ts +2 -0
  6. package/dist/__tests__/transport-shorthand.test.d.ts.map +1 -0
  7. package/dist/__tests__/transport-shorthand.test.js +88 -0
  8. package/dist/__tests__/transport-shorthand.test.js.map +1 -0
  9. package/dist/commands/robots.d.ts +57 -0
  10. package/dist/commands/robots.d.ts.map +1 -0
  11. package/dist/commands/robots.js +678 -0
  12. package/dist/commands/robots.js.map +1 -0
  13. package/dist/index.js +28 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/util/mcp-discovery.d.ts +57 -0
  16. package/dist/util/mcp-discovery.d.ts.map +1 -0
  17. package/dist/util/mcp-discovery.js +153 -0
  18. package/dist/util/mcp-discovery.js.map +1 -0
  19. package/dist/util/robot-config.d.ts +167 -0
  20. package/dist/util/robot-config.d.ts.map +1 -0
  21. package/dist/util/robot-config.js +375 -0
  22. package/dist/util/robot-config.js.map +1 -0
  23. package/dist/util/transport-shorthand.d.ts +66 -0
  24. package/dist/util/transport-shorthand.d.ts.map +1 -0
  25. package/dist/util/transport-shorthand.js +102 -0
  26. package/dist/util/transport-shorthand.js.map +1 -0
  27. package/package.json +2 -1
  28. package/runtime/BUNDLE.json +1 -1
  29. package/runtime/README.md +4 -0
  30. package/runtime/package.json +6 -1
  31. package/runtime/packages/agenticros/openclaw.plugin.json +6 -0
  32. package/runtime/packages/agenticros/package.json +2 -1
  33. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +568 -0
  34. package/runtime/packages/agenticros/src/mission-registry.ts +22 -0
  35. package/runtime/packages/agenticros/src/service.ts +120 -2
  36. package/runtime/packages/agenticros/src/skill-loader.ts +9 -1
  37. package/runtime/packages/agenticros/src/tools/_robot-helpers.ts +68 -0
  38. package/runtime/packages/agenticros/src/tools/index.ts +49 -9
  39. package/runtime/packages/agenticros/src/tools/mission-cancel.ts +70 -0
  40. package/runtime/packages/agenticros/src/tools/ros2-action.ts +11 -4
  41. package/runtime/packages/agenticros/src/tools/ros2-camera.ts +10 -4
  42. package/runtime/packages/agenticros/src/tools/ros2-capabilities.ts +63 -0
  43. package/runtime/packages/agenticros/src/tools/ros2-depth-distance.ts +11 -4
  44. package/runtime/packages/agenticros/src/tools/ros2-discover.ts +65 -0
  45. package/runtime/packages/agenticros/src/tools/ros2-find-robots-for.ts +128 -0
  46. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +311 -0
  47. package/runtime/packages/agenticros/src/tools/ros2-param.ts +20 -7
  48. package/runtime/packages/agenticros/src/tools/ros2-publish.ts +15 -8
  49. package/runtime/packages/agenticros/src/tools/ros2-robots.ts +52 -0
  50. package/runtime/packages/agenticros/src/tools/ros2-service.ts +11 -4
  51. package/runtime/packages/agenticros/src/tools/ros2-subscribe.ts +11 -4
  52. package/runtime/packages/agenticros-claude-code/package.json +2 -1
  53. package/runtime/packages/agenticros-claude-code/src/__tests__/capabilities-mcp.test.ts +964 -0
  54. package/runtime/packages/agenticros-claude-code/src/__tests__/follow-me-registry.test.ts +119 -0
  55. package/runtime/packages/agenticros-claude-code/src/find-object/find-object.ts +7 -6
  56. package/runtime/packages/agenticros-claude-code/src/follow-me/depth-loop.ts +41 -9
  57. package/runtime/packages/agenticros-claude-code/src/follow-me/loop.ts +40 -11
  58. package/runtime/packages/agenticros-claude-code/src/index.ts +5 -5
  59. package/runtime/packages/agenticros-claude-code/src/mission-registry.ts +21 -0
  60. package/runtime/packages/agenticros-claude-code/src/tools.ts +668 -56
  61. package/runtime/packages/agenticros-claude-code/src/transport.ts +43 -43
  62. package/runtime/packages/agenticros-gemini/package.json +1 -0
  63. package/runtime/packages/agenticros-gemini/src/__tests__/mission-cancel.test.ts +197 -0
  64. package/runtime/packages/agenticros-gemini/src/__tests__/mission-transcripts.test.ts +178 -0
  65. package/runtime/packages/agenticros-gemini/src/tools.ts +504 -21
  66. package/runtime/packages/agenticros-gemini/src/transport.ts +40 -43
  67. package/runtime/packages/core/package.json +1 -1
  68. package/runtime/packages/core/src/__tests__/capabilities.test.ts +261 -0
  69. package/runtime/packages/core/src/__tests__/discovery.test.ts +206 -0
  70. package/runtime/packages/core/src/__tests__/find-robots-for.test.ts +258 -0
  71. package/runtime/packages/core/src/__tests__/mission-cancel-transcript.test.ts +371 -0
  72. package/runtime/packages/core/src/__tests__/mission-transcript-sink.test.ts +189 -0
  73. package/runtime/packages/core/src/__tests__/mission.test.ts +501 -0
  74. package/runtime/packages/core/src/__tests__/planner.test.ts +279 -0
  75. package/runtime/packages/core/src/__tests__/robots.test.ts +406 -0
  76. package/runtime/packages/core/src/__tests__/topic-utils.test.ts +105 -0
  77. package/runtime/packages/core/src/__tests__/transport-pool.test.ts +295 -0
  78. package/runtime/packages/core/src/capabilities.ts +340 -0
  79. package/runtime/packages/core/src/config.ts +220 -38
  80. package/runtime/packages/core/src/discovery.ts +193 -0
  81. package/runtime/packages/core/src/find-robots-for.ts +164 -0
  82. package/runtime/packages/core/src/index.ts +67 -1
  83. package/runtime/packages/core/src/mission-registry.ts +142 -0
  84. package/runtime/packages/core/src/mission-transcript-sink.ts +81 -0
  85. package/runtime/packages/core/src/mission.ts +574 -0
  86. package/runtime/packages/core/src/planner/index.ts +564 -0
  87. package/runtime/packages/core/src/robots.ts +282 -0
  88. package/runtime/packages/core/src/topic-utils.ts +47 -9
  89. package/runtime/packages/core/src/transport-pool.ts +171 -0
  90. package/runtime/ros2_ws/src/agenticros_discovery/agenticros_discovery/discovery_node.py +108 -13
  91. package/runtime/ros2_ws/src/agenticros_msgs/CMakeLists.txt +1 -0
  92. package/runtime/ros2_ws/src/agenticros_msgs/msg/RobotInfo.msg +44 -0
  93. package/runtime/scripts/__tests__/refresh-skill-deps.test.mjs +222 -0
  94. package/runtime/scripts/refresh-skill-deps.mjs +381 -0
  95. package/runtime/scripts/setup_gateway_plugin.sh +32 -5
  96. package/runtime/scripts/sync-skill-tools.mjs +67 -10
@@ -1,20 +1,37 @@
1
1
  """
2
2
  AgenticROS Discovery Node
3
3
 
4
- Introspects the running ROS2 system and publishes a capability manifest
5
- describing available topics, services, and actions. This manifest is consumed
6
- by the AgenticROS plugin to inform the AI agent about what the robot can do.
7
-
8
- Published topic:
9
- /agenticros/capabilities (agenticros_msgs/msg/CapabilityManifest)
4
+ Introspects the running ROS2 system and publishes two manifests:
5
+
6
+ 1. A capability manifest describing the available topics, services, and
7
+ actions on the ROS2 graph (consumed by the OpenClaw plugin to inform
8
+ the AI agent about what the robot can do).
9
+ 2. (Phase 1.e) A robot heartbeat (`RobotInfo`) carrying id, name, kind,
10
+ capabilities, and sensor flags — published on `<namespace>/agenticros/
11
+ robot_info` at 1 Hz. This is the live counterpart to the TS-side
12
+ `config.robots[i]` block: the TS agent can subscribe to a fleet's
13
+ heartbeats and answer `ros2_find_robots_for(capability=…)` against
14
+ robots it has never been configured for. See docs/strategy-ai-agents-
15
+ plus-ros.md §4(d).
16
+
17
+ Published topics:
18
+ /agenticros/capabilities (agenticros_msgs/msg/CapabilityManifest)
19
+ <namespace>/agenticros/robot_info (agenticros_msgs/msg/RobotInfo, Phase 1.e)
10
20
 
11
21
  Service:
12
22
  /agenticros/get_capabilities (agenticros_msgs/srv/GetCapabilities)
13
23
 
14
24
  Parameters:
15
- robot_name Name of the robot (default: "Robot")
16
- robot_namespace — Namespace filter; empty = discover all (default: "")
17
- publish_interval Seconds between manifest publications (default: 5.0)
25
+ robot_name Display name (default: "Robot")
26
+ robot_namespace — ROS2 namespace, also used as the heartbeat id fallback (default: "")
27
+ robot_id Stable id for the robot (Phase 1.e). When empty, falls back to robot_namespace.
28
+ robot_kind — Robot kind: amr | arm | drone | rover | … (Phase 1.e, default: "amr")
29
+ capability_ids — Per-robot capability allowlist for ros2_find_robots_for. Empty = inherit gateway registry. (Phase 1.e)
30
+ has_realsense — Sensor tag, surfaces in RobotInfo + ros2_find_robots_for (Phase 1.e)
31
+ has_lidar — Sensor tag (Phase 1.e)
32
+ has_arm — Sensor tag (Phase 1.e)
33
+ publish_interval — Seconds between CapabilityManifest publications (default: 5.0)
34
+ heartbeat_interval — Seconds between RobotInfo heartbeats (Phase 1.e, default: 1.0)
18
35
  """
19
36
 
20
37
  from __future__ import annotations
@@ -23,7 +40,7 @@ import rclpy
23
40
  from rclpy.node import Node
24
41
  from rclpy.qos import QoSProfile, DurabilityPolicy
25
42
 
26
- from agenticros_msgs.msg import CapabilityManifest
43
+ from agenticros_msgs.msg import CapabilityManifest, RobotInfo
27
44
  from agenticros_msgs.srv import GetCapabilities
28
45
 
29
46
 
@@ -35,38 +52,92 @@ _INTERNAL_PREFIXES = (
35
52
  )
36
53
 
37
54
 
55
+ def _normalize_namespace_for_topic(ns: str) -> str:
56
+ """Return `<ns>/agenticros/robot_info` with exactly one leading slash.
57
+
58
+ Empty namespace falls back to a single global topic (no per-robot prefix)
59
+ so single-robot deployments without an explicit namespace still get a
60
+ heartbeat at `/agenticros/robot_info`.
61
+ """
62
+ cleaned = ns.strip().lstrip("/")
63
+ if not cleaned:
64
+ return "/agenticros/robot_info"
65
+ return f"/{cleaned}/agenticros/robot_info"
66
+
67
+
38
68
  class DiscoveryNode(Node):
39
69
  """Periodically discovers ROS2 capabilities and publishes a manifest."""
40
70
 
41
71
  def __init__(self) -> None:
42
72
  super().__init__("agenticros_discovery")
43
73
 
44
- # Parameters
74
+ # Parameters — historical first, then Phase 1.e additions.
45
75
  self.declare_parameter("robot_name", "Robot")
46
76
  self.declare_parameter("robot_namespace", "")
47
77
  self.declare_parameter("publish_interval", 5.0)
48
78
 
79
+ # Phase 1.e identity + fleet-metadata parameters. Declared with
80
+ # back-compat defaults so existing single-robot launches keep
81
+ # working without setting anything new.
82
+ self.declare_parameter("robot_id", "")
83
+ self.declare_parameter("robot_kind", "amr")
84
+ self.declare_parameter("capability_ids", [""])
85
+ self.declare_parameter("has_realsense", False)
86
+ self.declare_parameter("has_lidar", False)
87
+ self.declare_parameter("has_arm", False)
88
+ self.declare_parameter("heartbeat_interval", 1.0)
89
+
49
90
  self.robot_name: str = self.get_parameter("robot_name").value
50
91
  self.robot_namespace: str = self.get_parameter("robot_namespace").value
51
92
  self.publish_interval: float = self.get_parameter("publish_interval").value
52
93
 
94
+ # rclpy can't declare an empty string-array default — it falls
95
+ # back to TYPE_NOT_SET unless we seed with [""]. Strip the
96
+ # placeholder here so the published heartbeat carries an
97
+ # accurate count.
98
+ raw_capability_ids = self.get_parameter("capability_ids").value or []
99
+ self.capability_ids = [c for c in raw_capability_ids if c]
100
+
101
+ # robot_id falls back to robot_namespace, matching the TS-side
102
+ # listRobots() legacy-fallback contract.
103
+ configured_id: str = self.get_parameter("robot_id").value
104
+ self.robot_id: str = configured_id.strip() or (self.robot_namespace.strip() or "default")
105
+ self.robot_kind: str = self.get_parameter("robot_kind").value
106
+ self.has_realsense: bool = self.get_parameter("has_realsense").value
107
+ self.has_lidar: bool = self.get_parameter("has_lidar").value
108
+ self.has_arm: bool = self.get_parameter("has_arm").value
109
+ self.heartbeat_interval: float = self.get_parameter("heartbeat_interval").value
110
+
53
111
  # Publisher — transient local so late subscribers get the last manifest
54
112
  qos = QoSProfile(depth=1, durability=DurabilityPolicy.TRANSIENT_LOCAL)
55
113
  self.manifest_pub = self.create_publisher(
56
114
  CapabilityManifest, "/agenticros/capabilities", qos
57
115
  )
116
+ # Phase 1.e heartbeat publisher. Transient-local QoS so a TS
117
+ # adapter that subscribes mid-flight immediately gets the
118
+ # latest sample without waiting up to `heartbeat_interval` for
119
+ # the next tick.
120
+ self.robot_info_topic = _normalize_namespace_for_topic(self.robot_namespace)
121
+ self.robot_info_pub = self.create_publisher(RobotInfo, self.robot_info_topic, qos)
58
122
 
59
123
  # Service — on-demand query
60
124
  self.get_caps_srv = self.create_service(
61
125
  GetCapabilities, "/agenticros/get_capabilities", self._handle_get_capabilities
62
126
  )
63
127
 
64
- # Timerperiodic discovery
128
+ # Timerskeep manifest + heartbeat on independent cadences.
129
+ # The manifest is comparatively heavy (whole topic graph) and
130
+ # changes rarely, so the existing 5 s default stays. The
131
+ # heartbeat at 1 Hz matches the strategy memo's
132
+ # "1 Hz with 5 s staleness window" recommendation.
65
133
  self.timer = self.create_timer(self.publish_interval, self._on_timer)
134
+ self.heartbeat_timer = self.create_timer(self.heartbeat_interval, self._on_heartbeat)
66
135
 
67
136
  self.get_logger().info(
68
137
  f"Discovery node started: robot={self.robot_name}, "
69
- f"namespace='{self.robot_namespace}', interval={self.publish_interval}s"
138
+ f"namespace='{self.robot_namespace}', interval={self.publish_interval}s, "
139
+ f"id={self.robot_id}, kind={self.robot_kind}, "
140
+ f"heartbeat={self.heartbeat_interval}s on {self.robot_info_topic}"
70
141
  )
71
142
 
72
143
  def _on_timer(self) -> None:
@@ -79,6 +150,30 @@ class DiscoveryNode(Node):
79
150
  f"{len(manifest.action_names)} actions"
80
151
  )
81
152
 
153
+ def _on_heartbeat(self) -> None:
154
+ """Publish a `RobotInfo` heartbeat on `<namespace>/agenticros/robot_info`.
155
+
156
+ Carries id, name, kind, capability ids, sensor flags, and a
157
+ timestamp so subscribers can apply a staleness window without
158
+ relying on transport-level keepalives.
159
+ """
160
+ info = self._build_robot_info()
161
+ self.robot_info_pub.publish(info)
162
+
163
+ def _build_robot_info(self) -> RobotInfo:
164
+ """Materialise the Phase 1.e heartbeat payload from declared params."""
165
+ msg = RobotInfo()
166
+ msg.id = self.robot_id
167
+ msg.name = self.robot_name
168
+ msg.kind = self.robot_kind
169
+ msg.robot_namespace = self.robot_namespace
170
+ msg.capability_ids = list(self.capability_ids)
171
+ msg.has_realsense = bool(self.has_realsense)
172
+ msg.has_lidar = bool(self.has_lidar)
173
+ msg.has_arm = bool(self.has_arm)
174
+ msg.stamp = self.get_clock().now().to_msg()
175
+ return msg
176
+
82
177
  def _handle_get_capabilities(
83
178
  self,
84
179
  request: GetCapabilities.Request,
@@ -14,6 +14,7 @@ find_package(geometry_msgs REQUIRED)
14
14
 
15
15
  rosidl_generate_interfaces(${PROJECT_NAME}
16
16
  "msg/CapabilityManifest.msg"
17
+ "msg/RobotInfo.msg"
17
18
  "srv/GetCapabilities.srv"
18
19
  "srv/FollowMeStart.srv"
19
20
  "srv/FollowMeStop.srv"
@@ -0,0 +1,44 @@
1
+ # Phase 1.e robot heartbeat — published on `<namespace>/agenticros/robot_info`
2
+ # by agenticros_discovery at 1 Hz. Mirrors the TS-side RobotEntry shape so
3
+ # the discovery service can self-advertise without TS-side static config,
4
+ # and so a future Phase 4 ACP/A2A agent-card payload is a strict superset
5
+ # of these fields. See docs/strategy-ai-agents-plus-ros.md §4(d).
6
+ #
7
+ # Required fields are at the top. Optional fields (sensors, battery, pose)
8
+ # are below — Phase 1.e ships id/name/kind/namespace/capabilities/sensors
9
+ # and leaves the others as forward-compatible placeholders so consumers
10
+ # don't have to handle a schema bump for Phase 3 spatial memory.
11
+
12
+ # Stable, human-readable identifier — the same `id` the TS side uses as
13
+ # `robot_id` in tool calls (`ros2_publish(robot_id, …)`). Falls back to
14
+ # `robot_namespace` when not set.
15
+ string id
16
+
17
+ # Display name shown to users / the chat agent (e.g. "Kitchen Bot").
18
+ string name
19
+
20
+ # Robot kind — "amr" | "arm" | "drone" | "rover" | (free-form). Used by
21
+ # `ros2_find_robots_for(kind=…)` to partition heterogeneous fleets.
22
+ string kind
23
+
24
+ # ROS2 topic namespace prefix (e.g. "robot3946b404…"). Without leading
25
+ # slash — consumers prepend "/" + this when computing topic paths.
26
+ string robot_namespace
27
+
28
+ # Capability ids the robot advertises (matches the verbs returned by
29
+ # `ros2_list_capabilities`, e.g. "drive_base", "follow_person",
30
+ # "arm_grasp"). When empty, consumers fall back to the gateway-wide
31
+ # global registry — same precedence as the TS-side per-robot allowlist.
32
+ string[] capability_ids
33
+
34
+ # Sensor / hardware flags. Three slots match the Phase 1.e schema; add
35
+ # new ones additively (default-false at the consumer when absent) so old
36
+ # subscribers don't break.
37
+ bool has_realsense
38
+ bool has_lidar
39
+ bool has_arm
40
+
41
+ # Heartbeat timestamp — consumers treat the robot as offline when the
42
+ # last observed stamp is older than ~5 s (per the strategy memo's
43
+ # "1 Hz + 5 s staleness window" recommendation).
44
+ builtin_interfaces/Time stamp
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Unit tests for the staleness-detection logic in refresh-skill-deps.mjs.
3
+ *
4
+ * The pure-function pieces (file listing + diff) are what we actually care
5
+ * about pinning down — the spawnSync layer is integration-tested by the
6
+ * `pnpm refresh:skills` smoke run inside `pnpm deploy:plugin`.
7
+ *
8
+ * Why these tests matter: today there's only one staleness-detection
9
+ * algorithm, and if it accidentally treats "target has more files than
10
+ * source" as stale (it should NOT), or treats unrelated files in the
11
+ * skill's @agenticros/core as missing (it should NOT), the refresh will
12
+ * either thrash (always reinstall) or no-op (never reinstall) — both of
13
+ * which break the contract.
14
+ */
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { mkdtemp, rm, writeFile, mkdir, symlink } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import path from "node:path";
20
+
21
+ import {
22
+ listFilesRecursive,
23
+ findMissingFiles,
24
+ checkSkillStaleness,
25
+ discoverSkillPaths,
26
+ } from "../refresh-skill-deps.mjs";
27
+
28
+ async function writeText(file, text) {
29
+ await mkdir(path.dirname(file), { recursive: true });
30
+ await writeFile(file, text, "utf8");
31
+ }
32
+
33
+ test("listFilesRecursive: empty dir returns []", async () => {
34
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
35
+ try {
36
+ const empty = path.join(root, "empty");
37
+ await mkdir(empty);
38
+ assert.deepEqual(listFilesRecursive(empty), []);
39
+ } finally {
40
+ await rm(root, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ test("listFilesRecursive: missing dir returns [] (no throw)", () => {
45
+ assert.deepEqual(listFilesRecursive("/no/such/dir/ever-${process.pid}"), []);
46
+ });
47
+
48
+ test("listFilesRecursive: walks subdirs and returns relative, sorted paths", async () => {
49
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
50
+ try {
51
+ await writeText(path.join(root, "a.js"), "a");
52
+ await writeText(path.join(root, "sub", "b.js"), "b");
53
+ await writeText(path.join(root, "sub", "deep", "c.js"), "c");
54
+ const files = listFilesRecursive(root);
55
+ assert.deepEqual(files, ["a.js", path.join("sub", "b.js"), path.join("sub", "deep", "c.js")]);
56
+ } finally {
57
+ await rm(root, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("findMissingFiles: returns source files absent from target", () => {
62
+ const source = ["a.js", "b.js", "c.js"];
63
+ const target = ["a.js", "c.js"];
64
+ assert.deepEqual(findMissingFiles(source, target), ["b.js"]);
65
+ });
66
+
67
+ test("findMissingFiles: target supersets are NOT treated as missing in source", () => {
68
+ // Skills sometimes contain extra build artifacts; that's not a cascade.
69
+ const source = ["a.js", "b.js"];
70
+ const target = ["a.js", "b.js", "extra.js"];
71
+ assert.deepEqual(findMissingFiles(source, target), []);
72
+ });
73
+
74
+ test("findMissingFiles: empty source ⇒ no missing files (degenerate)", () => {
75
+ assert.deepEqual(findMissingFiles([], ["a.js"]), []);
76
+ });
77
+
78
+ test("findMissingFiles: empty target ⇒ everything in source is missing", () => {
79
+ assert.deepEqual(findMissingFiles(["a.js", "b.js"], []), ["a.js", "b.js"]);
80
+ });
81
+
82
+ test("checkSkillStaleness: hasCoreLink=false when skill has no @agenticros/core dep", async () => {
83
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
84
+ try {
85
+ const skill = path.join(root, "skill-no-core");
86
+ await mkdir(skill, { recursive: true });
87
+ const result = checkSkillStaleness(skill, { sourceCoreDist: path.join(root, "core-src") });
88
+ assert.equal(result.hasCoreLink, false);
89
+ assert.deepEqual(result.missingFiles, []);
90
+ } finally {
91
+ await rm(root, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("checkSkillStaleness: detects missing files when target lacks new source files", async () => {
96
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
97
+ try {
98
+ const coreSrc = path.join(root, "core", "dist");
99
+ await writeText(path.join(coreSrc, "index.js"), "");
100
+ await writeText(path.join(coreSrc, "capabilities.js"), "");
101
+ await writeText(path.join(coreSrc, "mission.js"), "");
102
+
103
+ // Skill has a stale link — only index.js, missing capabilities.js and mission.js.
104
+ const skill = path.join(root, "skill");
105
+ const staleDist = path.join(skill, "node_modules", "@agenticros", "core", "dist");
106
+ await writeText(path.join(staleDist, "index.js"), "");
107
+
108
+ const result = checkSkillStaleness(skill, { sourceCoreDist: coreSrc });
109
+ assert.equal(result.hasCoreLink, true);
110
+ assert.deepEqual(result.missingFiles.sort(), ["capabilities.js", "mission.js"]);
111
+ assert.equal(result.sourceCount, 3);
112
+ assert.equal(result.targetCount, 1);
113
+ } finally {
114
+ await rm(root, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test("checkSkillStaleness: returns missingFiles=[] when target is current", async () => {
119
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
120
+ try {
121
+ const coreSrc = path.join(root, "core", "dist");
122
+ await writeText(path.join(coreSrc, "index.js"), "");
123
+ await writeText(path.join(coreSrc, "capabilities.js"), "");
124
+
125
+ const skill = path.join(root, "skill");
126
+ const dist = path.join(skill, "node_modules", "@agenticros", "core", "dist");
127
+ await writeText(path.join(dist, "index.js"), "");
128
+ await writeText(path.join(dist, "capabilities.js"), "");
129
+
130
+ const result = checkSkillStaleness(skill, { sourceCoreDist: coreSrc });
131
+ assert.equal(result.hasCoreLink, true);
132
+ assert.deepEqual(result.missingFiles, []);
133
+ assert.equal(result.sourceCount, 2);
134
+ assert.equal(result.targetCount, 2);
135
+ } finally {
136
+ await rm(root, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("checkSkillStaleness: works when the skill's @agenticros/core is a symlink (real pnpm layout)", async () => {
141
+ const root = await mkdtemp(path.join(tmpdir(), "refresh-test-"));
142
+ try {
143
+ const coreSrc = path.join(root, "core", "dist");
144
+ await writeText(path.join(coreSrc, "index.js"), "");
145
+ await writeText(path.join(coreSrc, "mission.js"), "");
146
+
147
+ // Mimic the pnpm virtual store layout.
148
+ const pnpmStore = path.join(
149
+ root,
150
+ "skill",
151
+ "node_modules",
152
+ ".pnpm",
153
+ "@agenticros+core@file+stub",
154
+ "node_modules",
155
+ "@agenticros",
156
+ "core",
157
+ );
158
+ await writeText(path.join(pnpmStore, "dist", "index.js"), "");
159
+ await writeText(path.join(pnpmStore, "dist", "mission.js"), "");
160
+
161
+ const linkParent = path.join(root, "skill", "node_modules", "@agenticros");
162
+ await mkdir(linkParent, { recursive: true });
163
+ await symlink(pnpmStore, path.join(linkParent, "core"));
164
+
165
+ const result = checkSkillStaleness(path.join(root, "skill"), { sourceCoreDist: coreSrc });
166
+ assert.equal(result.hasCoreLink, true);
167
+ assert.deepEqual(result.missingFiles, []);
168
+ assert.equal(result.targetCount, 2);
169
+ } finally {
170
+ await rm(root, { recursive: true, force: true });
171
+ }
172
+ });
173
+
174
+ test("discoverSkillPaths: extraSkillPaths are picked up and absolutised", () => {
175
+ // Inject only the extraSkillPaths and isolate from env / config files by
176
+ // clearing the relevant env vars for the duration of the call. The
177
+ // function also reads ~/.openclaw/openclaw.json + ~/.agenticros/config.json
178
+ // unconditionally; we accept that those may add more entries, and just
179
+ // assert our injected path is INCLUDED, not that it's the only one.
180
+ const original = {
181
+ AGENTICROS_SKILL_PATHS: process.env.AGENTICROS_SKILL_PATHS,
182
+ OPENCLAW_CONFIG: process.env.OPENCLAW_CONFIG,
183
+ AGENTICROS_CONFIG_PATH: process.env.AGENTICROS_CONFIG_PATH,
184
+ };
185
+ try {
186
+ delete process.env.AGENTICROS_SKILL_PATHS;
187
+ // Point OpenClaw + AgenticROS config readers at nonexistent files so
188
+ // those branches contribute nothing during the test.
189
+ process.env.OPENCLAW_CONFIG = "/dev/null/nonexistent.json";
190
+ process.env.AGENTICROS_CONFIG_PATH = "/dev/null/nonexistent.json";
191
+
192
+ const paths = discoverSkillPaths({ extraSkillPaths: ["/tmp/my-skill"] });
193
+ assert.ok(paths.includes("/tmp/my-skill"), `expected /tmp/my-skill in discovered paths; got ${paths.join(", ")}`);
194
+ } finally {
195
+ for (const [k, v] of Object.entries(original)) {
196
+ if (v === undefined) delete process.env[k];
197
+ else process.env[k] = v;
198
+ }
199
+ }
200
+ });
201
+
202
+ test("discoverSkillPaths: AGENTICROS_SKILL_PATHS env var splits on ':'", () => {
203
+ const original = process.env.AGENTICROS_SKILL_PATHS;
204
+ const originalOpen = process.env.OPENCLAW_CONFIG;
205
+ const originalAgenticros = process.env.AGENTICROS_CONFIG_PATH;
206
+ try {
207
+ process.env.OPENCLAW_CONFIG = "/dev/null/nonexistent.json";
208
+ process.env.AGENTICROS_CONFIG_PATH = "/dev/null/nonexistent.json";
209
+ process.env.AGENTICROS_SKILL_PATHS = "/tmp/skill-a:/tmp/skill-b";
210
+
211
+ const paths = discoverSkillPaths();
212
+ assert.ok(paths.includes("/tmp/skill-a"));
213
+ assert.ok(paths.includes("/tmp/skill-b"));
214
+ } finally {
215
+ if (original === undefined) delete process.env.AGENTICROS_SKILL_PATHS;
216
+ else process.env.AGENTICROS_SKILL_PATHS = original;
217
+ if (originalOpen === undefined) delete process.env.OPENCLAW_CONFIG;
218
+ else process.env.OPENCLAW_CONFIG = originalOpen;
219
+ if (originalAgenticros === undefined) delete process.env.AGENTICROS_CONFIG_PATH;
220
+ else process.env.AGENTICROS_CONFIG_PATH = originalAgenticros;
221
+ }
222
+ });