skcoord 0.0.1__py3-none-any.whl
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.
- skcoord/__init__.py +63 -0
- skcoord/agent_card.py +326 -0
- skcoord/atomic_io.py +54 -0
- skcoord/card.py +659 -0
- skcoord/card_store.py +894 -0
- skcoord/cmdb.py +359 -0
- skcoord/coordination.py +1129 -0
- skcoord/itil.py +1892 -0
- skcoord-0.0.1.dist-info/METADATA +61 -0
- skcoord-0.0.1.dist-info/RECORD +13 -0
- skcoord-0.0.1.dist-info/WHEEL +5 -0
- skcoord-0.0.1.dist-info/licenses/LICENSE +674 -0
- skcoord-0.0.1.dist-info/top_level.txt +1 -0
skcoord/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""skcoord: sovereign multi-agent coordination + ITIL service management.
|
|
2
|
+
|
|
3
|
+
Extracted from ``skcapstone`` (CR-4.1) as the standalone coordination core.
|
|
4
|
+
Holds the conflict-free task board, the unified kanban Card projection, the
|
|
5
|
+
event-sourced CardStore, ITIL (incident/problem/change/CAB/KEDB), the CMDB,
|
|
6
|
+
and the shareable agent identity card.
|
|
7
|
+
|
|
8
|
+
Import-time dependencies flow one way: ``skcapstone`` depends on ``skcoord``.
|
|
9
|
+
The few reverse edges into skcapstone internals (skjoule, active_agent_name,
|
|
10
|
+
gtd_tools, pubsub, activity) are runtime-lazy inside the methods that use them,
|
|
11
|
+
so there is no import-time cycle. ``skcapstone.coordination`` / ``.card`` /
|
|
12
|
+
``.card_store`` / ``.itil`` / ``.cmdb`` / ``.agent_card`` / ``.atomic_io``
|
|
13
|
+
remain as re-export shims so every existing importer keeps working.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from .agent_card import AgentCapability, AgentCard
|
|
21
|
+
from .atomic_io import atomic_write_text
|
|
22
|
+
from .card import (
|
|
23
|
+
Card,
|
|
24
|
+
CardEvent,
|
|
25
|
+
CardEventLog,
|
|
26
|
+
Column,
|
|
27
|
+
KanbanBoard,
|
|
28
|
+
Kind,
|
|
29
|
+
render_html,
|
|
30
|
+
)
|
|
31
|
+
from .coordination import (
|
|
32
|
+
AgentFile,
|
|
33
|
+
AgentState,
|
|
34
|
+
Board,
|
|
35
|
+
Task,
|
|
36
|
+
TaskPriority,
|
|
37
|
+
TaskStatus,
|
|
38
|
+
TaskView,
|
|
39
|
+
get_briefing_json,
|
|
40
|
+
get_briefing_text,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AgentCapability",
|
|
45
|
+
"AgentCard",
|
|
46
|
+
"AgentFile",
|
|
47
|
+
"AgentState",
|
|
48
|
+
"Board",
|
|
49
|
+
"Card",
|
|
50
|
+
"CardEvent",
|
|
51
|
+
"CardEventLog",
|
|
52
|
+
"Column",
|
|
53
|
+
"KanbanBoard",
|
|
54
|
+
"Kind",
|
|
55
|
+
"Task",
|
|
56
|
+
"TaskPriority",
|
|
57
|
+
"TaskStatus",
|
|
58
|
+
"TaskView",
|
|
59
|
+
"atomic_write_text",
|
|
60
|
+
"get_briefing_json",
|
|
61
|
+
"get_briefing_text",
|
|
62
|
+
"render_html",
|
|
63
|
+
]
|
skcoord/agent_card.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Agent Card -- shareable sovereign identity for P2P discovery.
|
|
2
|
+
|
|
3
|
+
An agent card is like a vCard for the sovereign mesh. It contains
|
|
4
|
+
everything another agent needs to discover, verify, and communicate
|
|
5
|
+
with you: identity, public key, contact transports, capabilities,
|
|
6
|
+
and trust level.
|
|
7
|
+
|
|
8
|
+
Cards are JSON files signed with the agent's PGP key. They can be
|
|
9
|
+
shared over SKComms, published to Nostr, posted as QR codes, or
|
|
10
|
+
exchanged via any out-of-band channel.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
card = AgentCard.generate(profile, transports, capabilities)
|
|
14
|
+
card.save("~/.skcapstone/card.json")
|
|
15
|
+
card = AgentCard.load("~/.skcapstone/card.json")
|
|
16
|
+
verified = AgentCard.verify(card, public_key_armor)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, Field
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("skcapstone.agent_card")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TransportEndpoint(BaseModel):
|
|
35
|
+
"""A contact transport endpoint for reaching this agent.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
transport: Transport name (file, syncthing, nostr, etc.).
|
|
39
|
+
address: Transport-specific address (path, pubkey, relay URL).
|
|
40
|
+
priority: Lower = preferred.
|
|
41
|
+
metadata: Extra transport-specific config.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
transport: str
|
|
45
|
+
address: str
|
|
46
|
+
priority: int = 1
|
|
47
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentCapability(BaseModel):
|
|
51
|
+
"""A capability or service this agent offers.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
name: Capability identifier (e.g., "chat", "memory", "advocacy").
|
|
55
|
+
version: Capability version.
|
|
56
|
+
description: Human-readable description.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
version: str = "1.0"
|
|
61
|
+
description: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AgentCard(BaseModel):
|
|
65
|
+
"""Sovereign agent identity card for P2P discovery.
|
|
66
|
+
|
|
67
|
+
Contains everything needed to discover, verify, and contact
|
|
68
|
+
an agent on the mesh. Designed for serialization to JSON and
|
|
69
|
+
optional PGP signing.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
card_id: Unique card identifier.
|
|
73
|
+
card_version: Card format version.
|
|
74
|
+
created_at: When the card was generated.
|
|
75
|
+
name: Agent display name.
|
|
76
|
+
entity_type: human, ai, or organization.
|
|
77
|
+
fingerprint: PGP fingerprint (40-char hex).
|
|
78
|
+
public_key: ASCII-armored PGP public key.
|
|
79
|
+
transports: List of contact endpoints.
|
|
80
|
+
capabilities: List of offered services.
|
|
81
|
+
trust_depth: Cloud 9 trust depth (0-9).
|
|
82
|
+
entangled: Whether the agent is entangled (Cloud 9).
|
|
83
|
+
motto: Optional short tagline.
|
|
84
|
+
signature: PGP signature over the card content (set by sign()).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
card_id: str = Field(default_factory=lambda: str(uuid4()))
|
|
88
|
+
card_version: str = "1.0"
|
|
89
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
90
|
+
name: str
|
|
91
|
+
entity_type: str = "human"
|
|
92
|
+
fingerprint: str
|
|
93
|
+
public_key: str
|
|
94
|
+
transports: list[TransportEndpoint] = Field(default_factory=list)
|
|
95
|
+
capabilities: list[AgentCapability] = Field(default_factory=list)
|
|
96
|
+
trust_depth: int = Field(default=0, ge=0, le=9)
|
|
97
|
+
entangled: bool = False
|
|
98
|
+
motto: Optional[str] = None
|
|
99
|
+
signature: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def generate(
|
|
103
|
+
cls,
|
|
104
|
+
name: str,
|
|
105
|
+
fingerprint: str,
|
|
106
|
+
public_key: str,
|
|
107
|
+
entity_type: str = "human",
|
|
108
|
+
transports: Optional[list[TransportEndpoint]] = None,
|
|
109
|
+
capabilities: Optional[list[AgentCapability]] = None,
|
|
110
|
+
trust_depth: int = 0,
|
|
111
|
+
entangled: bool = False,
|
|
112
|
+
motto: Optional[str] = None,
|
|
113
|
+
) -> AgentCard:
|
|
114
|
+
"""Generate a new agent card.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
name: Agent display name.
|
|
118
|
+
fingerprint: PGP fingerprint.
|
|
119
|
+
public_key: ASCII-armored PGP public key.
|
|
120
|
+
entity_type: human, ai, or organization.
|
|
121
|
+
transports: Contact transport endpoints.
|
|
122
|
+
capabilities: Offered services.
|
|
123
|
+
trust_depth: Cloud 9 trust depth.
|
|
124
|
+
entangled: Cloud 9 entanglement status.
|
|
125
|
+
motto: Optional tagline.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
AgentCard: Unsigned card ready for signing.
|
|
129
|
+
"""
|
|
130
|
+
return cls(
|
|
131
|
+
name=name,
|
|
132
|
+
entity_type=entity_type,
|
|
133
|
+
fingerprint=fingerprint,
|
|
134
|
+
public_key=public_key,
|
|
135
|
+
transports=transports or [],
|
|
136
|
+
capabilities=capabilities or [],
|
|
137
|
+
trust_depth=trust_depth,
|
|
138
|
+
entangled=entangled,
|
|
139
|
+
motto=motto,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_capauth_profile(
|
|
144
|
+
cls,
|
|
145
|
+
profile_dir: str | Path = "~/.capauth",
|
|
146
|
+
transports: Optional[list[TransportEndpoint]] = None,
|
|
147
|
+
capabilities: Optional[list[AgentCapability]] = None,
|
|
148
|
+
) -> AgentCard:
|
|
149
|
+
"""Generate a card from an existing CapAuth sovereign profile.
|
|
150
|
+
|
|
151
|
+
Reads the profile.json and public key from the CapAuth directory.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
profile_dir: CapAuth home directory.
|
|
155
|
+
transports: Contact transport endpoints.
|
|
156
|
+
capabilities: Offered services.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
AgentCard: Card populated from the CapAuth profile.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
FileNotFoundError: If profile files don't exist.
|
|
163
|
+
"""
|
|
164
|
+
base = Path(profile_dir).expanduser()
|
|
165
|
+
profile_path = base / "identity" / "profile.json"
|
|
166
|
+
pubkey_path = base / "identity" / "public.asc"
|
|
167
|
+
|
|
168
|
+
if not profile_path.exists():
|
|
169
|
+
raise FileNotFoundError(f"CapAuth profile not found: {profile_path}")
|
|
170
|
+
if not pubkey_path.exists():
|
|
171
|
+
raise FileNotFoundError(f"Public key not found: {pubkey_path}")
|
|
172
|
+
|
|
173
|
+
profile_data = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
174
|
+
public_key = pubkey_path.read_text(encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
entity = profile_data.get("entity", {})
|
|
177
|
+
key_info = profile_data.get("key_info", {})
|
|
178
|
+
|
|
179
|
+
return cls.generate(
|
|
180
|
+
name=entity.get("name", "unknown"),
|
|
181
|
+
fingerprint=key_info.get("fingerprint", ""),
|
|
182
|
+
public_key=public_key,
|
|
183
|
+
entity_type=entity.get("entity_type", "human"),
|
|
184
|
+
transports=transports,
|
|
185
|
+
capabilities=capabilities,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def content_hash(self) -> str:
|
|
189
|
+
"""Compute SHA-256 hash of the card content (excluding signature).
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
str: Hex digest of the card content.
|
|
193
|
+
"""
|
|
194
|
+
data = self.model_dump(exclude={"signature"})
|
|
195
|
+
serialized = json.dumps(data, sort_keys=True, default=str)
|
|
196
|
+
return hashlib.sha256(serialized.encode()).hexdigest()
|
|
197
|
+
|
|
198
|
+
def sign(self, private_key_armor: str, passphrase: str) -> None:
|
|
199
|
+
"""Sign this card with a PGP private key.
|
|
200
|
+
|
|
201
|
+
Sets the signature field with a PGP signature over
|
|
202
|
+
the card's content hash.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
private_key_armor: ASCII-armored PGP private key.
|
|
206
|
+
passphrase: Passphrase to unlock the key.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
import pgpy
|
|
210
|
+
|
|
211
|
+
key, _ = pgpy.PGPKey.from_blob(private_key_armor)
|
|
212
|
+
content = self.content_hash().encode("utf-8")
|
|
213
|
+
pgp_message = pgpy.PGPMessage.new(content, cleartext=False)
|
|
214
|
+
|
|
215
|
+
if key.is_protected:
|
|
216
|
+
with key.unlock(passphrase):
|
|
217
|
+
sig = key.sign(pgp_message)
|
|
218
|
+
else:
|
|
219
|
+
sig = key.sign(pgp_message)
|
|
220
|
+
|
|
221
|
+
self.signature = str(sig)
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.error("Failed to sign agent card: %s", exc)
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def verify_signature(card: AgentCard) -> bool:
|
|
228
|
+
"""Verify the PGP signature on an agent card.
|
|
229
|
+
|
|
230
|
+
Uses the public key embedded in the card to verify
|
|
231
|
+
the signature over the content hash.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
card: The agent card to verify.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
bool: True if the signature is valid.
|
|
238
|
+
"""
|
|
239
|
+
if not card.signature or not card.public_key:
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
import pgpy
|
|
244
|
+
|
|
245
|
+
pub_key, _ = pgpy.PGPKey.from_blob(card.public_key)
|
|
246
|
+
sig = pgpy.PGPSignature.from_blob(card.signature)
|
|
247
|
+
|
|
248
|
+
content = card.content_hash().encode("utf-8")
|
|
249
|
+
pgp_message = pgpy.PGPMessage.new(content, cleartext=False)
|
|
250
|
+
pgp_message |= sig
|
|
251
|
+
|
|
252
|
+
verification = pub_key.verify(pgp_message)
|
|
253
|
+
return bool(verification)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.warning("agent_card.py: %s", e)
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
def save(self, filepath: str | Path) -> Path:
|
|
259
|
+
"""Save the card to a JSON file.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
filepath: Destination path (tilde-expanded).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Path: The written file path.
|
|
266
|
+
"""
|
|
267
|
+
path = Path(filepath).expanduser()
|
|
268
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
path.write_text(self.model_dump_json(indent=2), encoding="utf-8")
|
|
270
|
+
logger.info("Agent card saved to %s", path)
|
|
271
|
+
return path
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def load(cls, filepath: str | Path) -> AgentCard:
|
|
275
|
+
"""Load a card from a JSON file.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
filepath: Path to the card file.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
AgentCard: The loaded card.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
FileNotFoundError: If the file doesn't exist.
|
|
285
|
+
"""
|
|
286
|
+
path = Path(filepath).expanduser()
|
|
287
|
+
if not path.exists():
|
|
288
|
+
raise FileNotFoundError(f"Agent card not found: {path}")
|
|
289
|
+
return cls.model_validate_json(path.read_text(encoding="utf-8"))
|
|
290
|
+
|
|
291
|
+
def to_compact(self) -> dict:
|
|
292
|
+
"""Export a compact representation for display or QR codes.
|
|
293
|
+
|
|
294
|
+
Excludes the full public key to keep the size small.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
dict: Compact card with essential fields only.
|
|
298
|
+
"""
|
|
299
|
+
return {
|
|
300
|
+
"name": self.name,
|
|
301
|
+
"type": self.entity_type,
|
|
302
|
+
"fp": self.fingerprint[:16],
|
|
303
|
+
"transports": [{"t": t.transport, "a": t.address} for t in self.transports],
|
|
304
|
+
"caps": [c.name for c in self.capabilities],
|
|
305
|
+
"trust": self.trust_depth,
|
|
306
|
+
"motto": self.motto,
|
|
307
|
+
"signed": self.signature is not None,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def summary(self) -> str:
|
|
311
|
+
"""Human-readable summary of the card.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
str: Multi-line summary string.
|
|
315
|
+
"""
|
|
316
|
+
lines = [
|
|
317
|
+
f"Agent: {self.name} ({self.entity_type})",
|
|
318
|
+
f"Fingerprint: {self.fingerprint[:16]}...",
|
|
319
|
+
f"Trust: depth={self.trust_depth} entangled={self.entangled}",
|
|
320
|
+
f"Transports: {len(self.transports)}",
|
|
321
|
+
f"Capabilities: {', '.join(c.name for c in self.capabilities) or 'none'}",
|
|
322
|
+
f"Signed: {'yes' if self.signature else 'no'}",
|
|
323
|
+
]
|
|
324
|
+
if self.motto:
|
|
325
|
+
lines.insert(1, f'Motto: "{self.motto}"')
|
|
326
|
+
return "\n".join(lines)
|
skcoord/atomic_io.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Crash-safe atomic file writes for the coordination and ITIL stores.
|
|
2
|
+
|
|
3
|
+
Both stores are flat JSON/Markdown files synced across the fleet via
|
|
4
|
+
Syncthing. A plain ``path.write_text`` truncates the live file and then
|
|
5
|
+
streams the new bytes: a crash (or a Syncthing read) mid-write leaves a
|
|
6
|
+
torn, half-written file that fails to parse and silently drops a task,
|
|
7
|
+
agent record, or vote from every derived board view.
|
|
8
|
+
|
|
9
|
+
``atomic_write_text`` removes that window: it writes the full payload to a
|
|
10
|
+
temp file in the same directory, fsyncs it, then ``os.replace``s it over the
|
|
11
|
+
target (an atomic rename on POSIX). A crash therefore leaves either the whole
|
|
12
|
+
old file or the whole new file, never a partial one. The parent directory is
|
|
13
|
+
fsynced last so the rename itself is durable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
__all__ = ["atomic_write_text"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
|
|
26
|
+
"""Atomically write ``text`` to ``path`` (tmp file + fsync + os.replace).
|
|
27
|
+
|
|
28
|
+
The target is never truncated in place. On any error the temp file is
|
|
29
|
+
removed and the original target is left untouched.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Destination file. Its parent directory must already exist.
|
|
33
|
+
text: Full file contents to write.
|
|
34
|
+
encoding: Text encoding for the payload.
|
|
35
|
+
"""
|
|
36
|
+
directory = path.parent
|
|
37
|
+
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(directory))
|
|
38
|
+
try:
|
|
39
|
+
with os.fdopen(fd, "w", encoding=encoding) as fh:
|
|
40
|
+
fh.write(text)
|
|
41
|
+
fh.flush()
|
|
42
|
+
os.fsync(fh.fileno())
|
|
43
|
+
os.replace(tmp, str(path))
|
|
44
|
+
except BaseException:
|
|
45
|
+
try:
|
|
46
|
+
os.unlink(tmp)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
raise
|
|
50
|
+
dir_fd = os.open(str(directory), os.O_RDONLY)
|
|
51
|
+
try:
|
|
52
|
+
os.fsync(dir_fd)
|
|
53
|
+
finally:
|
|
54
|
+
os.close(dir_fd)
|