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.
- {swarph_cli-0.6.0/src/swarph_cli.egg-info → swarph_cli-0.7.0}/PKG-INFO +3 -3
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/pyproject.toml +10 -5
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.7.0/src/swarph_cli/cell.py +360 -0
- swarph_cli-0.7.0/src/swarph_cli/commands/hook_output.py +123 -0
- swarph_cli-0.7.0/src/swarph_cli/commands/install_hook.py +313 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/spawn.py +111 -11
- swarph_cli-0.7.0/src/swarph_cli/commands/watchdog.py +404 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/main.py +3 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0/src/swarph_cli.egg-info}/PKG-INFO +3 -3
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/SOURCES.txt +7 -1
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/requires.txt +1 -1
- swarph_cli-0.7.0/tests/test_cell_loader.py +500 -0
- swarph_cli-0.7.0/tests/test_hook_output.py +210 -0
- swarph_cli-0.7.0/tests/test_install_hook.py +269 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_spawn_command.py +97 -0
- swarph_cli-0.7.0/tests/test_watchdog.py +305 -0
- swarph_cli-0.6.0/src/swarph_cli/cell.py +0 -371
- swarph_cli-0.6.0/tests/test_cell_loader.py +0 -293
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/LICENSE +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/README.md +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/setup.cfg +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_main.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.6.0 → swarph_cli-0.7.0}/tests/test_smoke_one_shot.py +0 -0
- {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.
|
|
4
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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.
|
|
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.
|
|
8
|
-
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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
|
-
|
|
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
|
-
#
|
|
38
|
-
#
|
|
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
|
|
|
@@ -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
|