@smilintux/skcapstone 0.9.0 → 0.12.5

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 (284) hide show
  1. package/.env.example +10 -4
  2. package/.github/workflows/ci.yml +2 -2
  3. package/.github/workflows/publish.yml +9 -2
  4. package/.openclaw-workspace.json +2 -2
  5. package/CLAUDE.md +37 -0
  6. package/MISSION.md +17 -2
  7. package/README.md +282 -3
  8. package/docker/Dockerfile +7 -7
  9. package/docker/compose-templates/dev-team.yml +12 -12
  10. package/docker/compose-templates/mini-team.yml +9 -9
  11. package/docker/compose-templates/ops-team.yml +10 -10
  12. package/docker/compose-templates/research-team.yml +10 -10
  13. package/docker/entrypoint.sh +4 -4
  14. package/docs/ADR-optional-integration-backbone.md +181 -0
  15. package/docs/ARCHITECTURE.md +186 -43
  16. package/docs/BOND_WITH_GROK.md +6 -6
  17. package/docs/CUSTOM_AGENT.md +278 -1
  18. package/docs/DREAMING.md +70 -0
  19. package/docs/GETTING_STARTED.md +10 -7
  20. package/docs/QUICKSTART.md +10 -6
  21. package/docs/SKJOULE_ARCHITECTURE.md +3 -3
  22. package/docs/SOUL_SWAPPER.md +5 -5
  23. package/docs/hammertime-audit.md +402 -0
  24. package/docs/sk-integration-HANDOFF.md +117 -0
  25. package/docs/skscheduler.md +155 -0
  26. package/docs/superpowers/examples/jobs.yaml +31 -0
  27. package/docs/superpowers/plans/2026-06-08-skscheduler.md +1265 -0
  28. package/docs/superpowers/specs/2026-06-08-skscheduler-design.md +186 -0
  29. package/examples/custom-bond-template.json +1 -1
  30. package/examples/grok-feb.json +1 -1
  31. package/examples/queen-ava-feb.json +1 -1
  32. package/launchd/com.skcapstone.daemon.plist +52 -0
  33. package/launchd/com.skcapstone.memory-compress.plist +45 -0
  34. package/launchd/com.skcapstone.skcomms-heartbeat.plist +33 -0
  35. package/launchd/com.skcapstone.skcomms-queue-drain.plist +34 -0
  36. package/launchd/install-launchd.sh +156 -0
  37. package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/index.ts +3 -2
  38. package/package.json +1 -1
  39. package/pyproject.toml +16 -10
  40. package/scripts/archive-sessions.sh +95 -0
  41. package/scripts/check-updates.py +4 -4
  42. package/scripts/install-bundle.sh +8 -8
  43. package/scripts/install.ps1 +12 -11
  44. package/scripts/install.sh +196 -11
  45. package/scripts/model-fallback-monitor.sh +102 -0
  46. package/scripts/notion-api.py +259 -0
  47. package/scripts/nvidia-proxy.mjs +908 -0
  48. package/scripts/proxy-monitor.sh +89 -0
  49. package/scripts/refresh-anthropic-token.sh +172 -0
  50. package/scripts/release.sh +98 -0
  51. package/scripts/session-to-memory.py +219 -0
  52. package/scripts/skgateway.mjs +856 -0
  53. package/scripts/telegram-catchup-all.sh +147 -0
  54. package/scripts/verify_install.sh +2 -2
  55. package/scripts/wargov-ufo-capture/README.md +43 -0
  56. package/scripts/wargov-ufo-capture/cdp_capture_release2.py +273 -0
  57. package/scripts/wargov-ufo-capture/cdp_capture_splc_doj.py +246 -0
  58. package/scripts/wargov-ufo-capture/cdp_finish.py +271 -0
  59. package/scripts/wargov-ufo-capture/cdp_probe.py +188 -0
  60. package/scripts/wargov-ufo-capture/cdp_splc_pressrelease.py +101 -0
  61. package/scripts/wargov-ufo-capture/parse_csv.py +95 -0
  62. package/scripts/wargov-ufo-capture/pull_dvids.sh +107 -0
  63. package/scripts/watch-anthropic-token.sh +212 -0
  64. package/scripts/windows/install-tasks.ps1 +7 -7
  65. package/scripts/windows/skcapstone-task.xml +1 -1
  66. package/src/skcapstone/__init__.py +45 -3
  67. package/src/skcapstone/_cli_monolith.py +20 -15
  68. package/src/skcapstone/activity.py +5 -1
  69. package/src/skcapstone/agent_card.py +3 -2
  70. package/src/skcapstone/api.py +41 -40
  71. package/src/skcapstone/auction.py +14 -11
  72. package/src/skcapstone/backup.py +2 -1
  73. package/src/skcapstone/blueprint_registry.py +4 -3
  74. package/src/skcapstone/blueprints/builtins/itil-operations.yaml +40 -0
  75. package/src/skcapstone/brain_first.py +238 -0
  76. package/src/skcapstone/changelog.py +1 -1
  77. package/src/skcapstone/chat.py +22 -17
  78. package/src/skcapstone/cli/__init__.py +9 -1
  79. package/src/skcapstone/cli/_common.py +1 -0
  80. package/src/skcapstone/cli/agents_spawner.py +5 -2
  81. package/src/skcapstone/cli/alerts.py +25 -4
  82. package/src/skcapstone/cli/bench.py +15 -15
  83. package/src/skcapstone/cli/chat.py +7 -4
  84. package/src/skcapstone/cli/consciousness.py +5 -2
  85. package/src/skcapstone/cli/context_cmd.py +18 -4
  86. package/src/skcapstone/cli/daemon.py +121 -42
  87. package/src/skcapstone/cli/gtd.py +26 -1
  88. package/src/skcapstone/cli/housekeeping.py +3 -3
  89. package/src/skcapstone/cli/identity_cmd.py +378 -0
  90. package/src/skcapstone/cli/joule_cmd.py +7 -3
  91. package/src/skcapstone/cli/memory.py +8 -6
  92. package/src/skcapstone/cli/peers_dir.py +1 -1
  93. package/src/skcapstone/cli/register_cmd.py +29 -3
  94. package/src/skcapstone/cli/scheduler_cmd.py +167 -0
  95. package/src/skcapstone/cli/session.py +25 -0
  96. package/src/skcapstone/cli/setup.py +96 -29
  97. package/src/skcapstone/cli/shell_cmd.py +53 -1
  98. package/src/skcapstone/cli/skills_cmd.py +2 -2
  99. package/src/skcapstone/cli/soul.py +8 -5
  100. package/src/skcapstone/cli/status.py +37 -11
  101. package/src/skcapstone/cli/telegram.py +21 -0
  102. package/src/skcapstone/cli/test_cmd.py +5 -5
  103. package/src/skcapstone/cli/test_connection.py +2 -2
  104. package/src/skcapstone/cli/upgrade_cmd.py +23 -14
  105. package/src/skcapstone/cli/version_cmd.py +1 -1
  106. package/src/skcapstone/cli/watch_cmd.py +9 -6
  107. package/src/skcapstone/cloud9_bridge.py +14 -14
  108. package/src/skcapstone/codex_setup.py +255 -0
  109. package/src/skcapstone/config_validator.py +7 -4
  110. package/src/skcapstone/consciousness_config.py +5 -1
  111. package/src/skcapstone/consciousness_loop.py +313 -273
  112. package/src/skcapstone/context_loader.py +121 -0
  113. package/src/skcapstone/coord_federation.py +2 -1
  114. package/src/skcapstone/coordination.py +23 -6
  115. package/src/skcapstone/crush_integration.py +2 -1
  116. package/src/skcapstone/daemon.py +151 -88
  117. package/src/skcapstone/dashboard.py +10 -10
  118. package/src/skcapstone/data/sk-agent-picker.sh +421 -0
  119. package/src/skcapstone/data/systemd/skcapstone-api.socket +9 -0
  120. package/src/skcapstone/data/systemd/skcapstone-memory-compress.service +18 -0
  121. package/src/skcapstone/data/systemd/skcapstone-memory-compress.timer +11 -0
  122. package/src/skcapstone/data/systemd/skcapstone.service +37 -0
  123. package/src/skcapstone/data/systemd/skcapstone@.service +50 -0
  124. package/src/skcapstone/data/systemd/skcomms-heartbeat.service +18 -0
  125. package/{systemd/skcomm-heartbeat.timer → src/skcapstone/data/systemd/skcomms-heartbeat.timer} +2 -2
  126. package/src/skcapstone/data/systemd/skcomms-queue-drain.service +17 -0
  127. package/{systemd/skcomm-queue-drain.timer → src/skcapstone/data/systemd/skcomms-queue-drain.timer} +2 -2
  128. package/src/skcapstone/defaults/claude/CLAUDE.md +67 -0
  129. package/src/skcapstone/defaults/claude/settings.json +74 -0
  130. package/src/skcapstone/defaults/lumina/config/claude-hooks.md +57 -0
  131. package/src/skcapstone/defaults/lumina/config/skgraph.yaml +55 -10
  132. package/src/skcapstone/defaults/lumina/config/skmemory.yaml +79 -13
  133. package/src/skcapstone/defaults/lumina/config/skvector.yaml +60 -9
  134. package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +2 -2
  135. package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +2 -2
  136. package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +9 -9
  137. package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +2 -2
  138. package/src/skcapstone/defaults/unhinged.json +13 -0
  139. package/src/skcapstone/discovery.py +43 -20
  140. package/src/skcapstone/doctor.py +941 -22
  141. package/src/skcapstone/dreaming.py +1183 -109
  142. package/src/skcapstone/emotion_tracker.py +2 -2
  143. package/src/skcapstone/export.py +4 -3
  144. package/src/skcapstone/fuse_mount.py +35 -25
  145. package/src/skcapstone/gui_installer.py +2 -2
  146. package/src/skcapstone/heartbeat.py +34 -30
  147. package/src/skcapstone/housekeeping.py +14 -14
  148. package/src/skcapstone/install_wizard.py +209 -7
  149. package/src/skcapstone/itil.py +13 -4
  150. package/src/skcapstone/kms_scheduler.py +10 -8
  151. package/src/skcapstone/launchd.py +426 -0
  152. package/src/skcapstone/mcp_launcher.py +15 -1
  153. package/src/skcapstone/mcp_server.py +341 -49
  154. package/src/skcapstone/mcp_tools/__init__.py +2 -0
  155. package/src/skcapstone/mcp_tools/_helpers.py +2 -2
  156. package/src/skcapstone/mcp_tools/ansible_tools.py +7 -4
  157. package/src/skcapstone/mcp_tools/brain_first_tools.py +90 -0
  158. package/src/skcapstone/mcp_tools/capauth_tools.py +7 -4
  159. package/src/skcapstone/mcp_tools/comm_tools.py +10 -10
  160. package/src/skcapstone/mcp_tools/coord_tools.py +8 -4
  161. package/src/skcapstone/mcp_tools/did_tools.py +11 -8
  162. package/src/skcapstone/mcp_tools/gtd_tools.py +4 -4
  163. package/src/skcapstone/mcp_tools/memory_tools.py +6 -2
  164. package/src/skcapstone/mcp_tools/notification_tools.py +22 -6
  165. package/src/skcapstone/mcp_tools/{skcomm_tools.py → skcomms_tools.py} +14 -14
  166. package/src/skcapstone/mcp_tools/soul_tools.py +8 -2
  167. package/src/skcapstone/mdns_discovery.py +2 -2
  168. package/src/skcapstone/memory_curator.py +1 -1
  169. package/src/skcapstone/memory_engine.py +10 -3
  170. package/src/skcapstone/metrics.py +30 -16
  171. package/src/skcapstone/migrate_memories.py +4 -3
  172. package/src/skcapstone/migrate_multi_agent.py +8 -7
  173. package/src/skcapstone/models.py +47 -5
  174. package/src/skcapstone/notifications.py +42 -18
  175. package/src/skcapstone/onboard.py +1000 -126
  176. package/src/skcapstone/operator_link.py +170 -0
  177. package/src/skcapstone/peer_directory.py +4 -4
  178. package/src/skcapstone/peers.py +19 -19
  179. package/src/skcapstone/pillars/__init__.py +7 -5
  180. package/src/skcapstone/pillars/consciousness.py +191 -0
  181. package/src/skcapstone/pillars/identity.py +51 -7
  182. package/src/skcapstone/pillars/memory.py +9 -3
  183. package/src/skcapstone/pillars/sync.py +2 -2
  184. package/src/skcapstone/preflight.py +3 -3
  185. package/src/skcapstone/providers/docker.py +28 -28
  186. package/src/skcapstone/register.py +6 -6
  187. package/src/skcapstone/registry_client.py +5 -4
  188. package/src/skcapstone/runtime.py +14 -3
  189. package/src/skcapstone/scheduled_tasks.py +254 -19
  190. package/src/skcapstone/scheduler_jobs.py +456 -0
  191. package/src/skcapstone/scheduler_runner.py +239 -0
  192. package/src/skcapstone/scheduler_state.py +162 -0
  193. package/src/skcapstone/sdk.py +310 -0
  194. package/src/skcapstone/service_health.py +279 -39
  195. package/src/skcapstone/session_briefing.py +108 -0
  196. package/src/skcapstone/session_capture.py +1 -1
  197. package/src/skcapstone/shell.py +7 -1
  198. package/src/skcapstone/soul.py +3 -1
  199. package/src/skcapstone/soul_switch.py +3 -1
  200. package/src/skcapstone/summary.py +6 -6
  201. package/src/skcapstone/sync_engine.py +15 -15
  202. package/src/skcapstone/sync_watcher.py +2 -2
  203. package/src/skcapstone/systemd.py +72 -21
  204. package/src/skcapstone/team_comms.py +8 -8
  205. package/src/skcapstone/team_engine.py +1 -1
  206. package/src/skcapstone/testrunner.py +3 -3
  207. package/src/skcapstone/trust_graph.py +40 -5
  208. package/src/skcapstone/unified_search.py +15 -6
  209. package/src/skcapstone/uninstall_wizard.py +11 -3
  210. package/src/skcapstone/version_check.py +8 -4
  211. package/src/skcapstone/warmth_anchor.py +4 -2
  212. package/src/skcapstone/whoami.py +4 -4
  213. package/systemd/skcapstone.service +4 -6
  214. package/systemd/skcapstone@.service +7 -8
  215. package/systemd/skcomms-heartbeat.service +21 -0
  216. package/systemd/skcomms-heartbeat.timer +12 -0
  217. package/systemd/skcomms-queue-drain.service +17 -0
  218. package/systemd/skcomms-queue-drain.timer +12 -0
  219. package/tests/conftest.py +39 -0
  220. package/tests/integration/test_consciousness_e2e.py +39 -39
  221. package/tests/test_agent_card.py +1 -1
  222. package/tests/test_agent_home_scaffold.py +34 -0
  223. package/tests/test_alerts_consumer_topics.py +27 -0
  224. package/tests/test_backup.py +2 -1
  225. package/tests/test_chat.py +6 -6
  226. package/tests/test_claude_md.py +2 -2
  227. package/tests/test_cli_skills.py +10 -10
  228. package/tests/test_cli_test_cmd.py +4 -4
  229. package/tests/test_cli_test_connection.py +1 -1
  230. package/tests/test_cloud9_bridge.py +6 -6
  231. package/tests/test_consciousness_e2e.py +1 -1
  232. package/tests/test_consciousness_loop.py +10 -10
  233. package/tests/test_coordination.py +25 -0
  234. package/tests/test_cross_package.py +21 -21
  235. package/tests/test_daemon.py +4 -4
  236. package/tests/test_daemon_shutdown.py +1 -1
  237. package/tests/test_docker_provider.py +29 -29
  238. package/tests/test_doctor.py +400 -0
  239. package/tests/test_doctor_skscheduler.py +50 -0
  240. package/tests/test_dreaming_engine.py +147 -0
  241. package/tests/test_dreaming_gtd_capture.py +35 -0
  242. package/tests/test_e2e_automated.py +8 -5
  243. package/tests/test_fuse_mount.py +10 -10
  244. package/tests/test_gtd_brief.py +46 -0
  245. package/tests/test_gtd_malformed_tolerance.py +31 -0
  246. package/tests/test_housekeeping.py +15 -15
  247. package/tests/test_identity_migrate.py +251 -0
  248. package/tests/test_integration_backbone.py +598 -0
  249. package/tests/test_itil_gtd_lifecycle.py +37 -0
  250. package/tests/test_jobs_dropins.py +84 -0
  251. package/tests/test_mcp_server.py +82 -37
  252. package/tests/test_models.py +48 -4
  253. package/tests/test_multi_agent.py +31 -29
  254. package/tests/test_notifications.py +122 -32
  255. package/tests/test_onboard.py +63 -75
  256. package/tests/test_operator_link.py +78 -0
  257. package/tests/test_peers.py +14 -14
  258. package/tests/test_pillars.py +98 -0
  259. package/tests/test_preflight.py +3 -3
  260. package/tests/test_runtime.py +21 -0
  261. package/tests/test_scheduled_tasks.py +11 -6
  262. package/tests/test_scheduler_cli.py +47 -0
  263. package/tests/test_scheduler_features.py +133 -0
  264. package/tests/test_scheduler_integration.py +87 -0
  265. package/tests/test_scheduler_jobs.py +155 -0
  266. package/tests/test_scheduler_runner.py +64 -0
  267. package/tests/test_scheduler_state.py +57 -0
  268. package/tests/test_sdk.py +70 -0
  269. package/tests/test_service_health_incidents.py +34 -0
  270. package/tests/test_service_registry.py +52 -0
  271. package/tests/test_session_briefing.py +130 -0
  272. package/tests/test_snapshots.py +4 -4
  273. package/tests/test_sync_pipeline.py +26 -26
  274. package/tests/test_team_comms.py +2 -2
  275. package/tests/test_testrunner.py +2 -2
  276. package/tests/test_trust_graph.py +18 -0
  277. package/tests/test_unified_search.py +2 -2
  278. package/tests/test_version_check.py +10 -0
  279. package/tests/test_version_cmd.py +8 -8
  280. package/tests/test_whoami.py +1 -1
  281. package/systemd/skcomm-heartbeat.service +0 -18
  282. package/systemd/skcomm-queue-drain.service +0 -17
  283. /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/package.json +0 -0
  284. /package/{openclaw-plugin → openclaw-plugin.archived-2026-04-23}/src/openclaw.plugin.json +0 -0
@@ -0,0 +1,162 @@
1
+ """Node-local (never-synced) state for the skscheduler.
2
+
3
+ This module provides per-host, per-job run state that is intentionally kept
4
+ node-local so it never becomes a Syncthing conflict source. State is stored
5
+ at ``<root>/scheduler/<hostname>/state.json`` and is never replicated.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import threading
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger("skcapstone.scheduler_state")
17
+
18
+
19
+ class SchedulerState:
20
+ """Per-host job state persisted at ``<root>/scheduler/<hostname>/state.json``.
21
+
22
+ State is deliberately node-local: the file lives outside any Syncthing-
23
+ watched subtree so the scheduler never races with sync. Each instance
24
+ reads from disk on construction and writes through on every
25
+ :meth:`record_run` call — there is no in-process cache staleness issue
26
+ because schedulers are single-process per host.
27
+
28
+ Attributes:
29
+ state_file: Absolute path to the JSON state file for this host.
30
+ """
31
+
32
+ def __init__(self, root: Path, hostname: str) -> None:
33
+ """Initialise state for ``hostname`` rooted at ``root``.
34
+
35
+ Reads any existing state from disk. If the file is absent or
36
+ unreadable the state is treated as empty rather than raising.
37
+
38
+ Args:
39
+ root: Repository (or data) root directory. The state file will
40
+ be created at ``root/scheduler/<hostname>/state.json``.
41
+ hostname: Identifier for this node (typically
42
+ ``socket.gethostname()``). Used as the directory name so
43
+ multiple hosts can share the same ``root`` without collision.
44
+ """
45
+ self.state_file: Path = Path(root) / "scheduler" / hostname / "state.json"
46
+ self._data: dict[str, dict] = {}
47
+ self._write_lock = threading.Lock()
48
+ if self.state_file.exists():
49
+ try:
50
+ self._data = json.loads(
51
+ self.state_file.read_text(encoding="utf-8")
52
+ )
53
+ except (OSError, json.JSONDecodeError) as exc:
54
+ logger.warning(
55
+ "Could not read scheduler state from %s: %s", self.state_file, exc
56
+ )
57
+ self._data = {}
58
+
59
+ # ------------------------------------------------------------------
60
+ # Public API
61
+ # ------------------------------------------------------------------
62
+
63
+ def get(self, job: str) -> dict:
64
+ """Return the state record for *job*, or a zeroed default.
65
+
66
+ The returned dict always contains at least the keys
67
+ ``run_count``, ``error_count``, and ``last_run``. Additional keys
68
+ (``last_status``, ``last_error``) are present once the job has been
69
+ recorded at least once.
70
+
71
+ Args:
72
+ job: Unique job identifier string.
73
+
74
+ Returns:
75
+ A copy-on-read dict with the job's state. Mutating the returned
76
+ dict does **not** persist anything; call :meth:`record_run` to
77
+ persist changes.
78
+ """
79
+ return self._data.get(
80
+ job, {"run_count": 0, "error_count": 0, "last_run": None}
81
+ )
82
+
83
+ def last_run(self, job: str) -> Optional[datetime]:
84
+ """Return the timestamp of the most recent run of *job*, or ``None``.
85
+
86
+ The returned :class:`~datetime.datetime` is always timezone-aware
87
+ (UTC) because :meth:`record_run` stores ISO-8601 strings with a
88
+ ``+00:00`` offset.
89
+
90
+ Args:
91
+ job: Unique job identifier string.
92
+
93
+ Returns:
94
+ A timezone-aware :class:`~datetime.datetime` if the job has run
95
+ at least once, otherwise ``None``.
96
+ """
97
+ raw: Optional[str] = self.get(job).get("last_run")
98
+ return datetime.fromisoformat(raw) if raw else None
99
+
100
+ def record_run(
101
+ self,
102
+ job: str,
103
+ now: Optional[datetime] = None,
104
+ ok: bool = True,
105
+ error: str = "",
106
+ ) -> None:
107
+ """Record the result of a job execution and persist to disk.
108
+
109
+ Increments either ``run_count`` (on success) or ``error_count`` (on
110
+ failure) and writes the updated state file atomically via
111
+ :meth:`_flush`.
112
+
113
+ Args:
114
+ job: Unique job identifier string.
115
+ now: Timestamp for the run. Defaults to
116
+ ``datetime.now(timezone.utc)`` when not provided.
117
+ ok: ``True`` if the job completed successfully, ``False`` on
118
+ error.
119
+ error: Human-readable error message. Ignored when *ok* is
120
+ ``True``; stored as ``last_error`` otherwise.
121
+ """
122
+ ts: datetime = now or datetime.now(timezone.utc)
123
+ with self._write_lock:
124
+ rec: dict = self.get(job)
125
+ rec["last_run"] = ts.isoformat()
126
+ rec["last_status"] = "ok" if ok else "error"
127
+ rec["last_error"] = "" if ok else error
128
+ rec["run_count"] = rec.get("run_count", 0) + (1 if ok else 0)
129
+ rec["error_count"] = rec.get("error_count", 0) + (0 if ok else 1)
130
+ self._data[job] = rec
131
+ self._flush()
132
+
133
+ def all(self) -> dict[str, dict]:
134
+ """Return a shallow copy of all job state records.
135
+
136
+ Useful for introspection and dashboards. Mutations to the returned
137
+ dict or its values do not affect persisted state.
138
+
139
+ Returns:
140
+ A dict mapping job identifier → state record for every job that
141
+ has been recorded at least once.
142
+ """
143
+ return dict(self._data)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Private helpers
147
+ # ------------------------------------------------------------------
148
+
149
+ def _flush(self) -> None:
150
+ """Write the in-memory state to :attr:`state_file`.
151
+
152
+ Creates parent directories if they do not exist. Writes the full
153
+ state dict as indented JSON followed by a trailing newline so the
154
+ file is human-readable and POSIX-compliant.
155
+
156
+ Raises:
157
+ OSError: If the file cannot be written (e.g. permission denied).
158
+ """
159
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
160
+ self.state_file.write_text(
161
+ json.dumps(self._data, indent=2) + "\n", encoding="utf-8"
162
+ )
@@ -0,0 +1,310 @@
1
+ """skcapstone.sdk — the stable public integration facade for sk* services.
2
+
3
+ This module is the **only** surface that downstream sk* services
4
+ (skmemory, skcomms/skcomms, skchat, sksecurity, capauth, skvoice, skseed,
5
+ cloud9, skgateway, …) should import. Everything here is semver-tracked and
6
+ will not break across minor releases; the internal modules it wraps
7
+ (``pubsub``, ``scheduler_jobs``, ``coordination``, ``notifications``,
8
+ ``service_health``) are NOT part of the public contract and may change freely.
9
+
10
+ The intended consumer pattern is *optional-by-presence, default-on*::
11
+
12
+ try:
13
+ from skcapstone import sdk as _sk
14
+ _HAS = (not os.environ.get("SK_STANDALONE")) and _sk.is_available()
15
+ except ImportError:
16
+ _sk, _HAS = None, False
17
+
18
+ def alert(topic, payload, level="info"):
19
+ if _HAS:
20
+ return _sk.alert(f"myservice.{topic}", payload, level=level,
21
+ notify=level in ("warn", "error", "critical"))
22
+ return _native_alert(topic, payload, level) # service-native fallback
23
+
24
+ A service that finds skcapstone installed routes alerts through the shared
25
+ PubSub bus and registers scheduled work with the fleet scheduler; a service
26
+ that does not (or that sets ``SK_STANDALONE=1``) keeps using its own
27
+ mechanisms. See ``docs/ADR-optional-integration-backbone.md``.
28
+
29
+ Public API:
30
+ is_available() -> bool
31
+ alert(...) -> bool
32
+ register_job(...) -> str (path)
33
+ unregister_job(...) -> bool
34
+ coord_create(...) -> str (task id)
35
+ register_service(...) -> str (path)
36
+
37
+ Topic naming convention: ``<service>.<severity>`` (e.g. ``skmemory.error``,
38
+ ``sksecurity.critical``). Severities: ``info | warn | error | critical``.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import logging
45
+ import os
46
+ import uuid
47
+ from datetime import datetime, timezone
48
+ from pathlib import Path
49
+ from typing import Any, Optional
50
+
51
+ logger = logging.getLogger("skcapstone.sdk")
52
+
53
+ __all__ = [
54
+ "is_available",
55
+ "alert",
56
+ "register_job",
57
+ "unregister_job",
58
+ "coord_create",
59
+ "register_service",
60
+ "SEVERITIES",
61
+ ]
62
+
63
+ #: Recognised alert severities, low → high.
64
+ SEVERITIES = ("info", "warn", "error", "critical")
65
+
66
+ #: Severities that, by convention, also raise a desktop/Telegram notification
67
+ #: when ``notify`` is left at its default.
68
+ _NOTIFY_SEVERITIES = frozenset({"warn", "error", "critical"})
69
+
70
+ #: severity → desktop notification urgency
71
+ _URGENCY = {
72
+ "info": "low",
73
+ "warn": "normal",
74
+ "error": "normal",
75
+ "critical": "critical",
76
+ }
77
+
78
+
79
+ def _shared_home() -> Path:
80
+ """Resolve the shared skcapstone root (~/.skcapstone), honouring env."""
81
+ from . import shared_home # local import keeps facade import cheap
82
+
83
+ return shared_home()
84
+
85
+
86
+ def _agent_name() -> str:
87
+ """Best-effort active agent name, or 'anonymous'."""
88
+ from . import active_agent_name
89
+
90
+ return active_agent_name() or "anonymous"
91
+
92
+
93
+ def is_available(require_daemon: bool = False) -> bool:
94
+ """Return whether skcapstone integration is usable from this process.
95
+
96
+ Because the alert bus, scheduler drop-ins and coordination board are all
97
+ file-based, in-process integration does *not* require the daemon to be
98
+ running — it only requires that the shared home is resolvable and
99
+ writable. ``is_available()`` therefore returns ``True`` whenever the
100
+ package imported and the home directory can be created.
101
+
102
+ Args:
103
+ require_daemon: When ``True``, additionally probe the local daemon's
104
+ ``/health`` endpoint and only return ``True`` if it answers. Use
105
+ this for capabilities that genuinely need the live daemon (most
106
+ consumers do not).
107
+
108
+ Returns:
109
+ ``True`` if skcapstone integration can be used, else ``False``.
110
+ """
111
+ try:
112
+ home = _shared_home()
113
+ home.mkdir(parents=True, exist_ok=True)
114
+ except Exception as exc: # pragma: no cover - defensive
115
+ logger.debug("skcapstone unavailable: %s", exc)
116
+ return False
117
+
118
+ if not require_daemon:
119
+ return True
120
+
121
+ return _daemon_healthy()
122
+
123
+
124
+ def _daemon_healthy() -> bool:
125
+ """Probe the local skcapstone daemon ``/health`` endpoint (best-effort)."""
126
+ import urllib.request
127
+
128
+ port = int(os.environ.get("SKCAPSTONE_PORT", "9383"))
129
+ url = os.environ.get("SKCAPSTONE_DAEMON_URL", f"http://127.0.0.1:{port}") + "/health"
130
+ try:
131
+ with urllib.request.urlopen(url, timeout=1.5) as resp: # noqa: S310 (localhost)
132
+ return 200 <= resp.status < 300
133
+ except Exception as exc:
134
+ logger.debug("daemon health probe failed (%s): %s", url, exc)
135
+ return False
136
+
137
+
138
+ def alert(
139
+ topic: str,
140
+ payload: dict[str, Any],
141
+ *,
142
+ level: str = "info",
143
+ notify: Optional[bool] = None,
144
+ ttl_seconds: int = 86400,
145
+ ) -> bool:
146
+ """Publish an alert to the shared bus, optionally raising a notification.
147
+
148
+ The alert is published to the PubSub topic ``topic`` (callers should use
149
+ the ``<service>.<severity>`` convention). When ``notify`` is true — or is
150
+ left ``None`` and ``level`` is warn/error/critical — a desktop/Telegram
151
+ notification is also dispatched via the notification manager.
152
+
153
+ Args:
154
+ topic: Fully-qualified topic, e.g. ``"skmemory.error"``.
155
+ payload: JSON-serialisable event body.
156
+ level: One of :data:`SEVERITIES`. Unknown values are treated as
157
+ ``"info"``.
158
+ notify: Force notification on/off. ``None`` (default) means "notify
159
+ iff severity is warn or higher".
160
+ ttl_seconds: Message TTL on the bus (default 24h).
161
+
162
+ Returns:
163
+ ``True`` if the message was published (notification is best-effort and
164
+ does not affect the return value).
165
+ """
166
+ if level not in SEVERITIES:
167
+ level = "info"
168
+
169
+ published = False
170
+ try:
171
+ from .pubsub import PubSub
172
+
173
+ bus = PubSub(_shared_home(), agent_name=_agent_name())
174
+ bus.publish(topic, dict(payload), ttl_seconds=ttl_seconds, tags=[level])
175
+ published = True
176
+ except Exception as exc:
177
+ logger.warning("sdk.alert publish failed for %r: %s", topic, exc)
178
+
179
+ should_notify = (level in _NOTIFY_SEVERITIES) if notify is None else bool(notify)
180
+ if should_notify:
181
+ try:
182
+ from .notifications import notify as _desktop_notify
183
+
184
+ summary = payload.get("message") or payload.get("error") or json.dumps(payload)[:200]
185
+ _desktop_notify(f"[{level}] {topic}", str(summary), _URGENCY.get(level, "normal"))
186
+ except Exception as exc: # pragma: no cover - notification is optional
187
+ logger.debug("sdk.alert notify failed: %s", exc)
188
+
189
+ return published
190
+
191
+
192
+ def register_job(spec: dict[str, Any], home: Optional[Path] = None) -> str:
193
+ """Register a scheduled job with the fleet scheduler (jobs.d drop-in).
194
+
195
+ Thin wrapper over :func:`skcapstone.scheduler_jobs.register_job`. The
196
+ ``spec`` must include a ``name`` and exactly one of ``schedule`` (cron) or
197
+ ``every`` (interval, e.g. ``"15m"``). Re-registering the same ``name`` is
198
+ idempotent, so calling this on every service start is the intended usage.
199
+
200
+ Args:
201
+ spec: Job definition (see jobs.yaml schema).
202
+ home: Override skcapstone root (defaults to ~/.skcapstone).
203
+
204
+ Returns:
205
+ Filesystem path to the written drop-in fragment, as a string.
206
+ """
207
+ from .scheduler_jobs import register_job as _register_job
208
+
209
+ return str(_register_job(spec, home=home))
210
+
211
+
212
+ def unregister_job(name: str, home: Optional[Path] = None) -> bool:
213
+ """Remove a previously registered scheduler drop-in.
214
+
215
+ Args:
216
+ name: The job name used at registration.
217
+ home: Override skcapstone root (defaults to ~/.skcapstone).
218
+
219
+ Returns:
220
+ ``True`` if a fragment existed and was removed.
221
+ """
222
+ from .scheduler_jobs import unregister_job as _unregister_job
223
+
224
+ return _unregister_job(name, home=home)
225
+
226
+
227
+ def coord_create(
228
+ title: str,
229
+ *,
230
+ description: str = "",
231
+ priority: str = "medium",
232
+ tags: Optional[list[str]] = None,
233
+ created_by: str = "",
234
+ acceptance_criteria: Optional[list[str]] = None,
235
+ dependencies: Optional[list[str]] = None,
236
+ ) -> str:
237
+ """Create a task on the shared coordination board.
238
+
239
+ Args:
240
+ title: Task title.
241
+ description: Longer description.
242
+ priority: ``critical | high | medium | low``.
243
+ tags: Optional tag list.
244
+ created_by: Creator name (defaults to the active agent).
245
+ acceptance_criteria: Optional acceptance bullet list.
246
+ dependencies: Optional list of blocking task ids.
247
+
248
+ Returns:
249
+ The new task's id.
250
+ """
251
+ from .coordination import Board, Task, TaskPriority
252
+
253
+ try:
254
+ prio = TaskPriority(priority)
255
+ except ValueError:
256
+ prio = TaskPriority.MEDIUM
257
+
258
+ board = Board(_shared_home())
259
+ task = Task(
260
+ title=title,
261
+ description=description,
262
+ priority=prio,
263
+ tags=tags or [],
264
+ created_by=created_by or _agent_name(),
265
+ acceptance_criteria=acceptance_criteria or [],
266
+ dependencies=dependencies or [],
267
+ )
268
+ board.create_task(task)
269
+ return task.id
270
+
271
+
272
+ def register_service(
273
+ name: str,
274
+ health_url: Optional[str] = None,
275
+ pid_file: Optional[str] = None,
276
+ home: Optional[Path] = None,
277
+ ) -> str:
278
+ """Advertise a service to skcapstone's discovery registry.
279
+
280
+ Writes ``<home>/registry/<name>.json`` describing how to health-check the
281
+ service. ``service_health.check_all_services()`` unions these registry
282
+ entries with its built-in defaults, so a service that calls this on start
283
+ becomes discoverable without being hardcoded. Optional — health checks
284
+ still work with an empty registry.
285
+
286
+ Args:
287
+ name: Service name (unique key).
288
+ health_url: Optional HTTP URL whose 2xx response means "up".
289
+ pid_file: Optional pid-file path used as a liveness fallback.
290
+ home: Override skcapstone root (defaults to ~/.skcapstone).
291
+
292
+ Returns:
293
+ Path to the written registry entry, as a string.
294
+ """
295
+ base = Path(home) if home else _shared_home()
296
+ registry = base / "registry"
297
+ registry.mkdir(parents=True, exist_ok=True)
298
+
299
+ entry = {
300
+ "name": name,
301
+ "health_url": health_url,
302
+ "pid_file": pid_file,
303
+ "registered_by": _agent_name(),
304
+ "registered_at": datetime.now(timezone.utc).isoformat(),
305
+ }
306
+ final = registry / f"{name}.json"
307
+ tmp = registry / f".{name}.json.{uuid.uuid4().hex[:8]}.tmp"
308
+ tmp.write_text(json.dumps(entry, indent=2) + "\n", encoding="utf-8")
309
+ tmp.rename(final)
310
+ return str(final)