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 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)