swarph-cli 0.6.0__tar.gz → 0.7.0__tar.gz

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 (44) hide show
  1. {swarph_cli-0.6.0/src/swarph_cli.egg-info → swarph_cli-0.7.0}/PKG-INFO +3 -3
  2. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/pyproject.toml +10 -5
  3. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/__init__.py +1 -1
  4. swarph_cli-0.7.0/src/swarph_cli/cell.py +360 -0
  5. swarph_cli-0.7.0/src/swarph_cli/commands/hook_output.py +123 -0
  6. swarph_cli-0.7.0/src/swarph_cli/commands/install_hook.py +313 -0
  7. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/spawn.py +111 -11
  8. swarph_cli-0.7.0/src/swarph_cli/commands/watchdog.py +404 -0
  9. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/main.py +3 -0
  10. {swarph_cli-0.6.0 → swarph_cli-0.7.0/src/swarph_cli.egg-info}/PKG-INFO +3 -3
  11. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/SOURCES.txt +7 -1
  12. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/requires.txt +1 -1
  13. swarph_cli-0.7.0/tests/test_cell_loader.py +500 -0
  14. swarph_cli-0.7.0/tests/test_hook_output.py +210 -0
  15. swarph_cli-0.7.0/tests/test_install_hook.py +269 -0
  16. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_spawn_command.py +97 -0
  17. swarph_cli-0.7.0/tests/test_watchdog.py +305 -0
  18. swarph_cli-0.6.0/src/swarph_cli/cell.py +0 -371
  19. swarph_cli-0.6.0/tests/test_cell_loader.py +0 -293
  20. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/LICENSE +0 -0
  21. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/README.md +0 -0
  22. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/setup.cfg +0 -0
  23. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/caller.py +0 -0
  24. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/__init__.py +0 -0
  25. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/chat.py +0 -0
  26. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/daemon.py +0 -0
  27. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/import_session.py +0 -0
  28. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/onboard.py +0 -0
  29. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/ratify.py +0 -0
  30. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/parsers/__init__.py +0 -0
  31. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/parsers/claude.py +0 -0
  32. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  33. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  34. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  35. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_chat_command.py +0 -0
  36. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_claude_parser.py +0 -0
  37. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_daemon_command.py +0 -0
  38. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_import_command.py +0 -0
  39. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_main.py +0 -0
  40. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_onboard_command.py +0 -0
  41. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_ratify_command.py +0 -0
  42. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_smoke_chat.py +0 -0
  43. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_smoke_one_shot.py +0 -0
  44. {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_smoke_phase_5_5.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: swarph-cli
3
- Version: 0.6.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.6.0 ships Phase 7 `swarph spawn <role>` (operator-tooling layer of substrate-doc R7 §11.1.7 4-layer R2 mechanism stack pins claude --name/--session-id/--append-system-prompt for sibling-cell session-resume disambiguation) on top of Phase 5.6 daemon + Phase 5.5 onboard/ratify + Phase 5 REPL + Phase 2.5 import + Phase 2 one-shot.
3
+ Version: 0.7.0
4
+ Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.7.0 ships Phase 7 substrate-doc R7 §11.1.7 operator-tooling layer in 5 increments: PR-A `--new-instance` flag (sibling-spawn case) + PR-B auto-suffix on collision (sibling-slot persistence) + PR-C SessionStart hook (closes bare-claude operator-paste gap) + watchdog (stranded-session recovery) + PR-D swarph-shared cell.yaml relocation (cell-yaml schema graduates to swarph-shared 0.3.0 kernel-tier; substrate-doc R7 §11.1.5 (O5) RESOLVED).
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -25,7 +25,7 @@ Requires-Python: >=3.10
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: swarph-mesh>=0.5.0
28
- Requires-Dist: swarph-shared>=0.2.0
28
+ Requires-Dist: swarph-shared>=0.3.0
29
29
  Requires-Dist: PyYAML>=6.0
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=7.0; extra == "dev"
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.6.0"
8
- description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.6.0 ships Phase 7 `swarph spawn <role>` (operator-tooling layer of substrate-doc R7 §11.1.7 4-layer R2 mechanism stack pins claude --name/--session-id/--append-system-prompt for sibling-cell session-resume disambiguation) on top of Phase 5.6 daemon + Phase 5.5 onboard/ratify + Phase 5 REPL + Phase 2.5 import + Phase 2 one-shot."
7
+ version = "0.7.0"
8
+ description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.7.0 ships Phase 7 substrate-doc R7 §11.1.7 operator-tooling layer in 5 increments: PR-A `--new-instance` flag (sibling-spawn case) + PR-B auto-suffix on collision (sibling-slot persistence) + PR-C SessionStart hook (closes bare-claude operator-paste gap) + watchdog (stranded-session recovery) + PR-D swarph-shared cell.yaml relocation (cell-yaml schema graduates to swarph-shared 0.3.0 kernel-tier; substrate-doc R7 §11.1.5 (O5) RESOLVED)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -32,10 +32,15 @@ dependencies = [
32
32
  # Phase 5 REPL exercises all five adapters; bumped to 0.5.0 for
33
33
  # OpenAI + Grok availability in /provider switch.
34
34
  "swarph-mesh>=0.5.0",
35
- "swarph-shared>=0.2.0",
35
+ # v0.7 PR-D step 2: swarph-shared 0.3.0 adds the cell module
36
+ # (universal cell.yaml schema; substrate-doc R7 §11.1.5 (O5)
37
+ # cell.yaml-format-home RESOLVED). swarph-cli's cell.py now
38
+ # re-exports data shapes from swarph_shared.cell + keeps file
39
+ # I/O + sidecar + slot allocation locally.
40
+ "swarph-shared>=0.3.0",
36
41
  # Phase 7 spawn — cell.yaml parser. PyYAML 6.x is the standard.
37
- # Migrates to swarph-shared in v0.7+ per cell.yaml format-home
38
- # decision (substrate-doc R7 §11.1.7.4).
42
+ # Stays a swarph-cli dep since file I/O is operator-tooling-layer
43
+ # concern; swarph-shared cell module is pure-stdlib.
39
44
  "PyYAML>=6.0",
40
45
  ]
41
46
 
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.6.0"
19
+ __version__ = "0.7.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,360 @@
1
+ """``swarph_cli.cell`` — file I/O + sidecar + slot allocation (v0.7 PR-D step 2).
2
+
3
+ After substrate-doc R7 §11.1.5 (O5) cell.yaml universal-genome relocation,
4
+ the data shapes + schema validation live in ``swarph-shared`` (v0.3.0+).
5
+ This module is the swarph-cli operator-tooling layer that consumes the
6
+ shared schema + adds:
7
+
8
+ * **File discovery**: ``cells_dir()``, ``discover_cell_in_cwd()``,
9
+ ``resolve_cell_path()``, ``is_mesh_gateway_url()``
10
+ * **File I/O**: ``load_cell()`` (YAML read → ``parse_cell_dict()``),
11
+ ``_atomic_write_text()``
12
+ * **Sidecar persistence**: ``session_state_path()``,
13
+ ``load_or_create_session_id()``
14
+ * **Slot allocation** (substrate-doc R7 §11.1.7 operator-tooling layer
15
+ per beta #892 B1): ``next_free_slot_role()``,
16
+ ``base_role_from_slot_role()``
17
+
18
+ Symbol-relocation only — the v0.6 + v0.7-pre-PR-D + v0.7-post-PR-D-step-2
19
+ APIs are byte-for-byte identical at the swarph_cli.cell import level.
20
+ v0.6 cell.yaml files keep working unchanged in v0.7+ per drop-mother
21
+ review #890 (C2) schema-stability commitment.
22
+
23
+ Re-export shim for backward-compat — historical imports still work:
24
+
25
+ from swarph_cli.cell import Cell, CellError, parse_cell_dict
26
+
27
+ is equivalent to (and recommended going forward):
28
+
29
+ from swarph_shared.cell import Cell, CellError, parse_cell_dict
30
+
31
+ Internal swarph-cli code uses the swarph-shared imports directly to
32
+ avoid two-hop indirection. External consumers of swarph-cli's cell
33
+ module continue to work unchanged.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ import uuid
40
+ from pathlib import Path
41
+ from typing import Optional
42
+
43
+ # Re-export data shapes + schema validation from swarph-shared 0.3.0+
44
+ # (substrate-doc R7 §11.1.5 (O5) cell.yaml-format-home RESOLVED).
45
+ from swarph_shared.cell import (
46
+ Cell,
47
+ CellError,
48
+ Lineage,
49
+ PEER_NAME_RE,
50
+ SCHEMA_VERSION_V1,
51
+ VALID_PROVIDERS,
52
+ VALID_SCHEMA_VERSIONS,
53
+ parse_cell_dict,
54
+ validate_uuid_str,
55
+ )
56
+
57
+ # Backward-compat aliases for v0.6 + v0.7-pre-PR-D-step-2 imports
58
+ # (a few internal swarph-cli call sites used the leading-underscore shape).
59
+ _PEER_NAME_RE = PEER_NAME_RE
60
+ _VALID_SCHEMA_VERSIONS = VALID_SCHEMA_VERSIONS
61
+ _VALID_PROVIDERS_V0_6 = VALID_PROVIDERS # historical name for the v0.6 frozenset
62
+ _validate_uuid = validate_uuid_str # historical helper name
63
+
64
+
65
+ def _config_root() -> Path:
66
+ """Return the active config root for cell lookups.
67
+
68
+ Honours ``$XDG_CONFIG_HOME`` per the XDG Base Directory spec; falls
69
+ back to ``~/.config/`` otherwise.
70
+ """
71
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
72
+ if xdg:
73
+ return Path(xdg)
74
+ return Path.home() / ".config"
75
+
76
+
77
+ def cells_dir() -> Path:
78
+ """Default lookup directory for ``<role>.yaml`` files."""
79
+ return _config_root() / "swarph" / "cells"
80
+
81
+
82
+ def _state_root() -> Path:
83
+ xdg = os.environ.get("XDG_STATE_HOME", "").strip()
84
+ if xdg:
85
+ return Path(xdg)
86
+ return Path.home() / ".local" / "state"
87
+
88
+
89
+ def session_state_path(role: str) -> Path:
90
+ """Return the per-role persisted-session-id file path.
91
+
92
+ v0.6 persists generated UUIDs OUTSIDE cell.yaml so the cell file
93
+ stays purely declarative and is safe to commit to git. Roles
94
+ re-spawned with the same role name resume the same session via
95
+ this state file.
96
+
97
+ v0.7 PR-B (beta #892 B1) extends this to per-slot sidecars for
98
+ sibling instances: ``<role>.session-id`` is slot 1; ``<role>-2``,
99
+ ``<role>-3`` etc. are siblings minted via ``--new-instance``.
100
+ See ``next_free_slot_role()`` for the slot allocation policy.
101
+ """
102
+ return _state_root() / "swarph" / "sessions" / f"{role}.session-id"
103
+
104
+
105
+ def next_free_slot_role(base_role: str) -> str:
106
+ """Find the next free `<base_role>-N` slot for a sibling spawn.
107
+
108
+ v0.7 PR-B (beta #892 B1). Auto-suffix policy: siblings beyond
109
+ the first slot append ``-2``, ``-3``, ``-4`` etc. The naming
110
+ suffix matches the slot index, so:
111
+
112
+ * Slot 1 (the original): ``<base_role>``
113
+ * Slot 2 (first sibling): ``<base_role>-2``
114
+ * Slot 3 (second sibling): ``<base_role>-3``
115
+
116
+ Returns the synthesised role string for the next free slot.
117
+
118
+ Hard cap at slot 99 to avoid runaway loops.
119
+
120
+ Slot-reuse on unclean exit (beta iter-1 #987): a sibling instance
121
+ that dies without removing its sidecar leaves the slot
122
+ sidecar-occupied. Operator workaround at v0.7: ``rm`` the stale
123
+ sidecar. v0.8+ may ship ``swarph cleanup-sessions``.
124
+ """
125
+ for n in range(2, 100):
126
+ candidate = f"{base_role}-{n}"
127
+ if not session_state_path(candidate).exists():
128
+ return candidate
129
+ raise CellError(
130
+ f"next_free_slot_role: 99 sibling slots already occupied for "
131
+ f"base role {base_role!r}. Manual cleanup needed at "
132
+ f"{_state_root() / 'swarph' / 'sessions'}/."
133
+ )
134
+
135
+
136
+ def base_role_from_slot_role(role: str) -> str:
137
+ """Strip a trailing ``-N`` slot suffix from a role string.
138
+
139
+ v0.7 PR-B. Lets ``swarph spawn <base-role>-2`` resolve back to
140
+ the cell.yaml at ``<base-role>.yaml`` for shared cell-context
141
+ (same cwd, starter-prompt, lineage, etc.) while the sidecar
142
+ + display name use the slot-suffixed role.
143
+
144
+ Returns ``role`` unchanged if no trailing ``-<N>`` suffix is
145
+ present (preserves v0.6 behavior for non-sibling spawns).
146
+ """
147
+ parts = role.rsplit("-", 1)
148
+ if len(parts) == 2 and parts[1].isdigit() and 2 <= int(parts[1]) <= 99:
149
+ return parts[0]
150
+ return role
151
+
152
+
153
+ _MESH_GATEWAY_URL_PREFIX = "mesh-gateway://"
154
+
155
+
156
+ def is_mesh_gateway_url(spec: str) -> bool:
157
+ """True for v0.7+ ``mesh-gateway://...`` URL inputs (alpha #891 D2)."""
158
+ return spec.startswith(_MESH_GATEWAY_URL_PREFIX)
159
+
160
+
161
+ def resolve_cell_path(spec: str) -> Path:
162
+ """Resolve a ``swarph spawn`` positional/flag value to a cell file.
163
+
164
+ Precedence:
165
+ 1. spec ends in ``.yaml`` or ``.yml`` → treated as a literal path
166
+ 2. spec contains a path separator → treated as a literal path
167
+ 3. ``./cell.yaml`` exists in current cwd AND spec equals current cwd's
168
+ basename OR equals a special token ``.`` → use ``./cell.yaml``
169
+ 4. ``<cells_dir>/<spec>.yaml`` exists → use it
170
+ 5. v0.7 PR-B sibling-resume — strip trailing ``-N`` suffix from spec
171
+ and try ``<cells_dir>/<base-role>.yaml`` (lets ``swarph spawn
172
+ drop-on-meta-edge-2`` resolve back to base cell.yaml so siblings
173
+ share cell-context). Honors operator-intent: explicit base file
174
+ takes precedence (step 4) over slot-stripped fallback (step 5).
175
+ 6. otherwise → return ``<cells_dir>/<spec>.yaml`` (will fail on load
176
+ with a 'not found' error)
177
+ """
178
+ if spec == ".":
179
+ return Path.cwd() / "cell.yaml"
180
+ if spec.endswith((".yaml", ".yml")) or os.sep in spec:
181
+ return Path(spec).expanduser()
182
+
183
+ direct = cells_dir() / f"{spec}.yaml"
184
+ if direct.is_file():
185
+ return direct
186
+
187
+ # v0.7 PR-B sibling-resume fallback
188
+ base = base_role_from_slot_role(spec)
189
+ if base != spec:
190
+ base_path = cells_dir() / f"{base}.yaml"
191
+ if base_path.is_file():
192
+ return base_path
193
+
194
+ return direct # not found; let load_cell raise with a clear error
195
+
196
+
197
+ def discover_cell_in_cwd() -> Optional[Path]:
198
+ """Return ``./cell.yaml`` if it exists in the current cwd, else None."""
199
+ candidate = Path.cwd() / "cell.yaml"
200
+ return candidate if candidate.is_file() else None
201
+
202
+
203
+ def read_starter_prompt(cell: Cell) -> Optional[str]:
204
+ """Return the starter-prompt text for ``cell``, or None.
205
+
206
+ Free function rather than ``Cell.starter_prompt_text()`` method —
207
+ swarph-shared Cell is intentionally pure-stdlib + no I/O, so the
208
+ file-read lives at the swarph-cli operator-tooling layer. Callers
209
+ that previously did ``cell.starter_prompt_text()`` now do
210
+ ``read_starter_prompt(cell)``.
211
+
212
+ Raises CellError if starter_prompt_path is set but unreadable, so
213
+ spawn fails loudly rather than silently dropping role-priming.
214
+ """
215
+ if cell.starter_prompt_path is None:
216
+ return None
217
+ try:
218
+ return cell.starter_prompt_path.read_text(encoding="utf-8")
219
+ except OSError as exc:
220
+ raise CellError(
221
+ f"cell.yaml: starter_prompt_path "
222
+ f"'{cell.starter_prompt_path}' is not readable: {exc}"
223
+ ) from exc
224
+
225
+
226
+ def load_cell(path: Path) -> Cell:
227
+ """Parse + validate a cell.yaml file. Raises CellError on any failure.
228
+
229
+ Reads YAML from disk, then delegates to ``swarph_shared.cell.parse_cell_dict``
230
+ for the actual schema validation. Sets ``cell.source_path`` to the
231
+ file path (a swarph-cli-specific provenance bit; swarph-shared
232
+ leaves it None since it doesn't know about the file).
233
+ """
234
+ import yaml # local import — keeps `swarph --version` PyYAML-free
235
+
236
+ if not path.exists():
237
+ raise CellError(f"cell.yaml not found: {path}")
238
+
239
+ try:
240
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
241
+ except yaml.YAMLError as exc:
242
+ raise CellError(f"cell.yaml is not valid YAML ({path}): {exc}") from exc
243
+
244
+ cell = parse_cell_dict(raw, source=str(path), base_dir=path.parent)
245
+ # File-I/O wrapper concern: post-validate cwd reachability + tag source
246
+ # path. swarph-shared parse_cell_dict intentionally doesn't touch the
247
+ # filesystem (kernel-tier discipline); the live cwd.is_dir() check is
248
+ # the swarph-cli operator-tooling concern.
249
+ if not cell.cwd.is_dir():
250
+ raise CellError(f"cell.yaml: 'cwd' is not a directory: {cell.cwd}")
251
+ cell.source_path = path
252
+ return cell
253
+
254
+
255
+ def load_or_create_session_id(
256
+ role: str,
257
+ cell: Cell,
258
+ new_instance: bool = False,
259
+ ) -> tuple[str, bool, str]:
260
+ """Resolve the session-id for a spawn invocation.
261
+
262
+ Returns ``(session_id, was_generated, effective_role)``.
263
+
264
+ Resolution order:
265
+ 1. cell.session_id (cell.yaml-pinned) — never generated
266
+ 2. ``new_instance=True`` AND base sidecar exists — auto-suffix slot
267
+ 3. ``new_instance=True`` AND no base sidecar — fall through (degenerate)
268
+ 4. session_state_path(role) reused — default re-resume path
269
+ 5. mint new uuid4 + persist
270
+
271
+ Caller-side discipline (mother iter-1 #986): the ``effective_role``
272
+ value is the authoritative source for ``claude --name`` AND
273
+ sidecar persistence — NOT ``cell.role`` (which stays the BASE role
274
+ for shared cell-context: cwd, starter prompt, lineage, provider).
275
+ """
276
+ if cell.session_id:
277
+ return cell.session_id, False, role
278
+
279
+ if new_instance:
280
+ base_state = session_state_path(role)
281
+ if base_state.exists():
282
+ sibling_role = next_free_slot_role(role)
283
+ sibling_state = session_state_path(sibling_role)
284
+ new_id = str(uuid.uuid4())
285
+ sibling_state.parent.mkdir(parents=True, exist_ok=True)
286
+ _atomic_write_text(sibling_state, new_id + "\n")
287
+ return new_id, True, sibling_role
288
+
289
+ state_file = session_state_path(role)
290
+ if state_file.exists():
291
+ existing = state_file.read_text(encoding="utf-8").strip()
292
+ if existing:
293
+ try:
294
+ return validate_uuid_str(existing), False, role
295
+ except CellError:
296
+ # Corrupted state — fall through and regenerate.
297
+ pass
298
+
299
+ new_id = str(uuid.uuid4())
300
+ state_file.parent.mkdir(parents=True, exist_ok=True)
301
+ _atomic_write_text(state_file, new_id + "\n")
302
+ return new_id, True, role
303
+
304
+
305
+ def _atomic_write_text(target: Path, content: str) -> None:
306
+ """Write text atomically: tempfile in the same dir, fsync, rename.
307
+
308
+ Per drop-mother review #890 (C1) — UUID writes are load-bearing for
309
+ R5 (session-resume identity disambiguation). A torn write that left
310
+ half a UUID in the state file would silently regenerate on next
311
+ spawn, defeating the disambiguation primitive entirely.
312
+ """
313
+ import tempfile
314
+
315
+ parent = target.parent
316
+ fd, tmp_path = tempfile.mkstemp(
317
+ prefix=f".{target.name}.", suffix=".tmp", dir=parent
318
+ )
319
+ try:
320
+ with os.fdopen(fd, "w", encoding="utf-8") as fp:
321
+ fp.write(content)
322
+ fp.flush()
323
+ os.fsync(fp.fileno())
324
+ os.replace(tmp_path, target)
325
+ except Exception:
326
+ try:
327
+ os.unlink(tmp_path)
328
+ except OSError:
329
+ pass
330
+ raise
331
+
332
+
333
+ __all__ = [
334
+ # Re-exports from swarph_shared.cell (v0.3.0+)
335
+ "Cell",
336
+ "CellError",
337
+ "Lineage",
338
+ "PEER_NAME_RE",
339
+ "SCHEMA_VERSION_V1",
340
+ "VALID_PROVIDERS",
341
+ "VALID_SCHEMA_VERSIONS",
342
+ "parse_cell_dict",
343
+ "validate_uuid_str",
344
+ # swarph-cli-local file I/O + sidecar + slot allocation
345
+ "cells_dir",
346
+ "session_state_path",
347
+ "next_free_slot_role",
348
+ "base_role_from_slot_role",
349
+ "is_mesh_gateway_url",
350
+ "resolve_cell_path",
351
+ "discover_cell_in_cwd",
352
+ "read_starter_prompt",
353
+ "load_cell",
354
+ "load_or_create_session_id",
355
+ # Backward-compat aliases
356
+ "_PEER_NAME_RE",
357
+ "_VALID_SCHEMA_VERSIONS",
358
+ "_VALID_PROVIDERS_V0_6",
359
+ "_validate_uuid",
360
+ ]
@@ -0,0 +1,123 @@
1
+ """``swarph hook-output`` — Phase 7 / v0.7 PR-C SessionStart hook callback.
2
+
3
+ Called BY the SessionStart hook configured via ``swarph install-hook``.
4
+ Receives Claude Code's hook-input JSON on stdin, discovers the
5
+ applicable cell.yaml, and emits a hook-output JSON to stdout
6
+ containing the starter prompt as ``additionalContext``.
7
+
8
+ Skipped (no-op) when ``SWARPH_SPAWN=1`` env is set — that environment
9
+ marker is set by ``swarph spawn`` to indicate the spawn path already
10
+ injected the prompt via ``--append-system-prompt``. Avoids
11
+ double-injection when the session was launched through swarph.
12
+
13
+ Cell discovery:
14
+ 1. Cwd-local ``./cell.yaml`` (alpha #891 D3 auto-discovery)
15
+ 2. ``$XDG_CONFIG_HOME/swarph/cells/<basename(cwd)>.yaml`` —
16
+ fallback to user config dir keyed on cwd basename
17
+ 3. No cell found → no-op (empty additionalContext, exit 0)
18
+
19
+ Failure mode philosophy: if anything goes wrong (cell.yaml not
20
+ parseable, starter prompt unreadable, hook-input JSON malformed),
21
+ emit empty additionalContext + exit 0. The hook MUST NOT block
22
+ session startup on swarph-side issues — gracefully degrade to
23
+ "no auto-injection happened" rather than "session refuses to start."
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from swarph_cli.cell import (
35
+ Cell,
36
+ CellError,
37
+ cells_dir,
38
+ discover_cell_in_cwd,
39
+ load_cell,
40
+ read_starter_prompt,
41
+ )
42
+
43
+
44
+ def _emit_no_op() -> int:
45
+ """Emit empty additionalContext + exit 0.
46
+
47
+ Used when there's no cell.yaml to load OR when SWARPH_SPAWN=1 OR
48
+ on any error path. Hook MUST NOT block session startup.
49
+ """
50
+ output = {
51
+ "hookSpecificOutput": {
52
+ "hookEventName": "SessionStart",
53
+ "additionalContext": "",
54
+ }
55
+ }
56
+ print(json.dumps(output))
57
+ return 0
58
+
59
+
60
+ def _discover_cell_path() -> Path | None:
61
+ """Two-tier cell.yaml discovery for hook context.
62
+
63
+ 1. ``./cell.yaml`` in current working directory
64
+ 2. ``<cells_dir>/<basename(cwd)>.yaml`` keyed on cwd's last segment
65
+
66
+ Returns None if neither exists; caller emits no-op then.
67
+ """
68
+ cwd_local = discover_cell_in_cwd()
69
+ if cwd_local is not None:
70
+ return cwd_local
71
+
72
+ cwd_basename = Path.cwd().name
73
+ if cwd_basename:
74
+ candidate = cells_dir() / f"{cwd_basename}.yaml"
75
+ if candidate.is_file():
76
+ return candidate
77
+ return None
78
+
79
+
80
+ def run_hook_output(argv: list[str] | None = None) -> int:
81
+ # Skip when launched-via-swarph-spawn (already injected via
82
+ # --append-system-prompt; double-injection would duplicate context).
83
+ if os.environ.get("SWARPH_SPAWN", "").strip() == "1":
84
+ return _emit_no_op()
85
+
86
+ # Drain hook-input from stdin (we don't actually use any field
87
+ # currently, but Claude Code's protocol expects us to consume it
88
+ # without blocking on TTY-detection edge cases).
89
+ try:
90
+ if not sys.stdin.isatty():
91
+ sys.stdin.read()
92
+ except Exception:
93
+ pass # swallow stdin-read errors; hook output is the only thing that matters
94
+
95
+ cell_path = _discover_cell_path()
96
+ if cell_path is None:
97
+ return _emit_no_op()
98
+
99
+ try:
100
+ cell = load_cell(cell_path)
101
+ except CellError:
102
+ # cell.yaml exists but is malformed — emit no-op + skip rather
103
+ # than blocking session startup. Operator can fix cell.yaml
104
+ # offline; meanwhile sessions still start.
105
+ return _emit_no_op()
106
+
107
+ try:
108
+ starter = read_starter_prompt(cell)
109
+ except CellError:
110
+ # starter_prompt_path set but unreadable — emit no-op.
111
+ return _emit_no_op()
112
+
113
+ if not starter:
114
+ return _emit_no_op()
115
+
116
+ output: dict[str, Any] = {
117
+ "hookSpecificOutput": {
118
+ "hookEventName": "SessionStart",
119
+ "additionalContext": starter,
120
+ }
121
+ }
122
+ print(json.dumps(output))
123
+ return 0