swarph-cli 0.5.0__tar.gz → 0.6.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 (36) hide show
  1. {swarph_cli-0.5.0/src/swarph_cli.egg-info → swarph_cli-0.6.0}/PKG-INFO +64 -8
  2. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/README.md +61 -6
  3. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/pyproject.toml +6 -2
  4. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/__init__.py +1 -1
  5. swarph_cli-0.6.0/src/swarph_cli/cell.py +371 -0
  6. swarph_cli-0.6.0/src/swarph_cli/commands/spawn.py +307 -0
  7. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/main.py +12 -2
  8. {swarph_cli-0.5.0 → swarph_cli-0.6.0/src/swarph_cli.egg-info}/PKG-INFO +64 -8
  9. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/SOURCES.txt +5 -1
  10. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/requires.txt +1 -0
  11. swarph_cli-0.6.0/tests/test_cell_loader.py +293 -0
  12. swarph_cli-0.6.0/tests/test_spawn_command.py +244 -0
  13. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/LICENSE +0 -0
  14. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/setup.cfg +0 -0
  15. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/caller.py +0 -0
  16. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/__init__.py +0 -0
  17. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/chat.py +0 -0
  18. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/daemon.py +0 -0
  19. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/import_session.py +0 -0
  20. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/onboard.py +0 -0
  21. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/ratify.py +0 -0
  22. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/parsers/__init__.py +0 -0
  23. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/parsers/claude.py +0 -0
  24. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
  25. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
  26. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
  27. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_chat_command.py +0 -0
  28. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_claude_parser.py +0 -0
  29. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_daemon_command.py +0 -0
  30. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_import_command.py +0 -0
  31. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_main.py +0 -0
  32. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_onboard_command.py +0 -0
  33. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_ratify_command.py +0 -0
  34. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_smoke_chat.py +0 -0
  35. {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_smoke_one_shot.py +0 -0
  36. {swarph_cli-0.5.0 → swarph_cli-0.6.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.5.0
4
- Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.5.0 ships Phase 5.6 `swarph daemon` (foreground inbox drainretires the orphaned-tail-F class) on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify (PLAN.md §13 / §16).
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.
5
5
  Author: Pierre Samson, Claude Opus
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/darw007d/swarph-cli
@@ -26,6 +26,7 @@ Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
27
  Requires-Dist: swarph-mesh>=0.5.0
28
28
  Requires-Dist: swarph-shared>=0.2.0
29
+ Requires-Dist: PyYAML>=6.0
29
30
  Provides-Extra: dev
30
31
  Requires-Dist: pytest>=7.0; extra == "dev"
31
32
  Dynamic: license-file
@@ -49,16 +50,71 @@ This is one of three repos in the v0.3.x architecture:
49
50
 
50
51
  ## Status
51
52
 
52
- **v0.5.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify + Phase 5.6 daemon.** Six verbs ship:
53
+ **v0.6.0 — Phase 7 spawn ships.** Seven verbs total:
53
54
 
54
55
  1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
55
56
  2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
56
- 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL swarph-native, with `--report-only` for honest pre-commit inspection)
57
- 4. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
58
- 5. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
59
- 6. `swarph daemon`**NEW** Phase 5.6 foreground inbox drain loop (PLAN.md §16); structurally retires the orphaned-tail-F class
57
+ 3. `swarph spawn <role>` — **NEW** Phase 7 long-lived `claude` session as a named mesh cell (`--name`/`--session-id`/`--append-system-prompt` pinning per substrate-doc R7 §11.1.7 4-layer R2 stack)
58
+ 4. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native)
59
+ 5. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
60
+ 6. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
61
+ 7. `swarph daemon` — Phase 5.6 foreground inbox drain (PLAN.md §16)
60
62
 
61
- Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b).
63
+ Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b; non-Claude `spawn` providers + S-G `mesh-gateway://` URL form in v0.7).
64
+
65
+ ### `swarph spawn` (Phase 7 — v0.6.0)
66
+
67
+ Operator-tooling layer of substrate-doc R7 §11.1.7 4-layer R2 mechanism stack. Wraps `claude` with the three R5/R7 disambiguation flags:
68
+
69
+ * `--name <role>` — display name for `/resume` picker
70
+ * `--session-id <uuid>` — pinned UUID, persisted to `$XDG_STATE_HOME/swarph/sessions/<role>.session-id` so re-spawns reuse the same session (the R5 fix at the operator-tooling layer)
71
+ * `--append-system-prompt <text>` — starter prompt injected without manual paste (the R2 fix at the operator-tooling layer)
72
+
73
+ ```bash
74
+ # 1. Author a cell.yaml (one-time per role)
75
+ $ cat ~/.config/swarph/cells/lab.yaml
76
+ schema_version: v1
77
+ name: lab-ovh
78
+ role: lab
79
+ cwd: /home/ubuntu
80
+ starter_prompt_path: ~/.claude/session_start_reminder.txt
81
+ provider: claude
82
+
83
+ # 2. Summon the cell (long-lived claude session, exec-replaced)
84
+ $ swarph spawn lab
85
+ ╭───╮
86
+ │ ◉ │
87
+ ╭──┴───┴──╮
88
+ │ swarph │ v0.6.0
89
+ ╰──┬───┬──╯ spawn │ chat │ daemon
90
+ │ ◉ │
91
+ ╰───╯
92
+ [claude session takes over the terminal — same flags as `claude --name lab --session-id <uuid> --append-system-prompt <starter>`]
93
+
94
+ # 3. Resume the same cell after exit — same UUID, same session
95
+ $ swarph spawn lab # picker shows ONE entry: "lab" (R5 disambiguation)
96
+ ```
97
+
98
+ Resolution order for `swarph spawn <role-or-path>`:
99
+
100
+ 1. `--onboarding <path-or-url>` (alias: `--cell`) — explicit override
101
+ 2. Positional ending in `.yaml`/`.yml` or containing a path separator — literal path
102
+ 3. Plain role name — `$XDG_CONFIG_HOME/swarph/cells/<role>.yaml` (default `~/.config/swarph/cells/`)
103
+ 4. No positional given — auto-discover `./cell.yaml` in current directory
104
+
105
+ Useful flags:
106
+
107
+ | Flag | Effect |
108
+ |---|---|
109
+ | `--dry-run` | Print resolved `claude` command + cell summary; do not exec |
110
+ | `--no-starter` | Skip starter-prompt injection even if cell.yaml sets one |
111
+ | `--print-id` | Print resolved session-id to stdout (capture for shell scripts) |
112
+ | `--no-banner` | Suppress the swarph banner on stderr |
113
+ | `-- <claude-args>` | Pass remaining args through to claude unchanged |
114
+
115
+ cell.yaml schema is **frozen at `schema_version: "v1"`**. v0.7 migrates the parser to `swarph-shared` as a symbol-relocation only — v0.6 cell.yaml files keep working unchanged. Breaking changes require a `schema_version: "v2"` bump and parallel-supported-version window per `swarph-mesh` DEPRECATIONS discipline.
116
+
117
+ **Known limitations (v0.6).** Single-instance-per-role only. Re-running `swarph spawn <role>` reuses the persisted UUID (R5 fix), so sibling-spawn (alpha + beta co-existing on the same peer-id) requires v0.7's `--new-instance` flag. Manual sibling spawning via `tmux` + explicit `--session-id` pinning still works unchanged; v0.6 does not regress that path, it just doesn't yet expose a CLI shape for it.
62
118
 
63
119
  ### `swarph daemon` (Phase 5.6)
64
120
 
@@ -17,16 +17,71 @@ This is one of three repos in the v0.3.x architecture:
17
17
 
18
18
  ## Status
19
19
 
20
- **v0.5.0 — Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify + Phase 5.6 daemon.** Six verbs ship:
20
+ **v0.6.0 — Phase 7 spawn ships.** Seven verbs total:
21
21
 
22
22
  1. `swarph "prompt"` — Phase 2 one-shot mode (any of five providers)
23
23
  2. `swarph chat` — Phase 5 interactive REPL with multi-turn history + slash commands
24
- 3. `swarph import <path>` — Phase 2.5 session import (Claude JSONL swarph-native, with `--report-only` for honest pre-commit inspection)
25
- 4. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
26
- 5. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
27
- 6. `swarph daemon`**NEW** Phase 5.6 foreground inbox drain loop (PLAN.md §16); structurally retires the orphaned-tail-F class
24
+ 3. `swarph spawn <role>` — **NEW** Phase 7 long-lived `claude` session as a named mesh cell (`--name`/`--session-id`/`--append-system-prompt` pinning per substrate-doc R7 §11.1.7 4-layer R2 stack)
25
+ 4. `swarph import <path>` — Phase 2.5 session import (Claude JSONL → swarph-native)
26
+ 5. `swarph onboard <peer-name>` — Phase 5.5 mechanics-phase onboarding (PLAN.md §15.4)
27
+ 6. `swarph ratify <peer-name>` — Phase 5.5 witness ratification (PLAN.md §15.4a)
28
+ 7. `swarph daemon` — Phase 5.6 foreground inbox drain (PLAN.md §16)
28
29
 
29
- Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b).
30
+ Subsequent phases extend the CLI surface (`--ask <peer>`, REPL drain coroutine + `/inbox` + `/reply` slash commands in 5.6b; non-Claude `spawn` providers + S-G `mesh-gateway://` URL form in v0.7).
31
+
32
+ ### `swarph spawn` (Phase 7 — v0.6.0)
33
+
34
+ Operator-tooling layer of substrate-doc R7 §11.1.7 4-layer R2 mechanism stack. Wraps `claude` with the three R5/R7 disambiguation flags:
35
+
36
+ * `--name <role>` — display name for `/resume` picker
37
+ * `--session-id <uuid>` — pinned UUID, persisted to `$XDG_STATE_HOME/swarph/sessions/<role>.session-id` so re-spawns reuse the same session (the R5 fix at the operator-tooling layer)
38
+ * `--append-system-prompt <text>` — starter prompt injected without manual paste (the R2 fix at the operator-tooling layer)
39
+
40
+ ```bash
41
+ # 1. Author a cell.yaml (one-time per role)
42
+ $ cat ~/.config/swarph/cells/lab.yaml
43
+ schema_version: v1
44
+ name: lab-ovh
45
+ role: lab
46
+ cwd: /home/ubuntu
47
+ starter_prompt_path: ~/.claude/session_start_reminder.txt
48
+ provider: claude
49
+
50
+ # 2. Summon the cell (long-lived claude session, exec-replaced)
51
+ $ swarph spawn lab
52
+ ╭───╮
53
+ │ ◉ │
54
+ ╭──┴───┴──╮
55
+ │ swarph │ v0.6.0
56
+ ╰──┬───┬──╯ spawn │ chat │ daemon
57
+ │ ◉ │
58
+ ╰───╯
59
+ [claude session takes over the terminal — same flags as `claude --name lab --session-id <uuid> --append-system-prompt <starter>`]
60
+
61
+ # 3. Resume the same cell after exit — same UUID, same session
62
+ $ swarph spawn lab # picker shows ONE entry: "lab" (R5 disambiguation)
63
+ ```
64
+
65
+ Resolution order for `swarph spawn <role-or-path>`:
66
+
67
+ 1. `--onboarding <path-or-url>` (alias: `--cell`) — explicit override
68
+ 2. Positional ending in `.yaml`/`.yml` or containing a path separator — literal path
69
+ 3. Plain role name — `$XDG_CONFIG_HOME/swarph/cells/<role>.yaml` (default `~/.config/swarph/cells/`)
70
+ 4. No positional given — auto-discover `./cell.yaml` in current directory
71
+
72
+ Useful flags:
73
+
74
+ | Flag | Effect |
75
+ |---|---|
76
+ | `--dry-run` | Print resolved `claude` command + cell summary; do not exec |
77
+ | `--no-starter` | Skip starter-prompt injection even if cell.yaml sets one |
78
+ | `--print-id` | Print resolved session-id to stdout (capture for shell scripts) |
79
+ | `--no-banner` | Suppress the swarph banner on stderr |
80
+ | `-- <claude-args>` | Pass remaining args through to claude unchanged |
81
+
82
+ cell.yaml schema is **frozen at `schema_version: "v1"`**. v0.7 migrates the parser to `swarph-shared` as a symbol-relocation only — v0.6 cell.yaml files keep working unchanged. Breaking changes require a `schema_version: "v2"` bump and parallel-supported-version window per `swarph-mesh` DEPRECATIONS discipline.
83
+
84
+ **Known limitations (v0.6).** Single-instance-per-role only. Re-running `swarph spawn <role>` reuses the persisted UUID (R5 fix), so sibling-spawn (alpha + beta co-existing on the same peer-id) requires v0.7's `--new-instance` flag. Manual sibling spawning via `tmux` + explicit `--session-id` pinning still works unchanged; v0.6 does not regress that path, it just doesn't yet expose a CLI shape for it.
30
85
 
31
86
  ### `swarph daemon` (Phase 5.6)
32
87
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "swarph-cli"
7
- version = "0.5.0"
8
- description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.5.0 ships Phase 5.6 `swarph daemon` (foreground inbox drainretires the orphaned-tail-F class) on top of Phase 2 one-shot + Phase 2.5 import + Phase 5 REPL + Phase 5.5 onboard/ratify (PLAN.md §13 / §16)."
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."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"
@@ -33,6 +33,10 @@ dependencies = [
33
33
  # OpenAI + Grok availability in /provider switch.
34
34
  "swarph-mesh>=0.5.0",
35
35
  "swarph-shared>=0.2.0",
36
+ # 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).
39
+ "PyYAML>=6.0",
36
40
  ]
37
41
 
38
42
  [project.urls]
@@ -16,6 +16,6 @@ The architecture splits CLI from substrate so:
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.5.0"
19
+ __version__ = "0.6.0"
20
20
 
21
21
  __all__ = ["__version__"]
@@ -0,0 +1,371 @@
1
+ """``cell.yaml`` loader — Phase 7 spawn config (v0.6.0).
2
+
3
+ A *cell* is the unit of mesh participation: one peer-name, one role,
4
+ one working directory, one persistent session-id. The cell.yaml file
5
+ declaratively describes how to summon (spawn or resume) that cell as
6
+ a long-lived `claude` session.
7
+
8
+ Per substrate-doc R7 §11.1.5 (O5) the long-term home for the
9
+ universal-genome cell.yaml format is ``swarph-shared``. v0.6 ships
10
+ the parser inside ``swarph-cli`` to validate the schema in production
11
+ use; v0.7+ migrates to ``swarph-shared`` once the schema has stabilised.
12
+
13
+ Per substrate-doc R7 §11.1.7 the spawn wrapper sits at the
14
+ operator-tooling layer of the 4-layer R2 mechanism stack — it
15
+ consumes substrate primitives (S-A spawn-registration body, S-G
16
+ spawn-context endpoint when those land) but is NOT itself a substrate
17
+ primitive. v0.6 reads the local cell.yaml file only; v0.7 will add
18
+ the optional S-G HTTP polling fallback.
19
+
20
+ v0.6 schema (``schema_version: "v1"`` — minimal viable):
21
+
22
+ schema_version: v1 # optional, defaults to v1
23
+ name: lab-ovh # required — mesh peer name
24
+ role: lab # required — claude --name display value
25
+ cwd: /home/ubuntu # required — working directory for spawn
26
+ session_id: 550e8400-... # optional — pinned UUID; persisted state used otherwise
27
+ starter_prompt_path: ~/.foo # optional — fed as ``claude --append-system-prompt``
28
+ provider: claude # optional — claude-only in v0.6 (errors otherwise)
29
+ identity: # optional — alpha #891 (D1) reserved shape
30
+ lineage:
31
+ parent_peer_id: drop # optional — null for top-level cells
32
+ spawn_manifest_signature: # optional — null in v0.6, validated in v2 cryptographic-lineage tier
33
+
34
+ Fields not declared above are kept verbatim under ``cell.extra`` for
35
+ forward-compat; v0.7 may attach meaning to ``mesh:``, ``capabilities:``,
36
+ ``memory_mirror:`` etc.
37
+
38
+ **Schema-stability commitment** (per drop-mother review #890 (C2) +
39
+ ``feedback_swarph_paper_rev_bar``): v0.6 schema is FROZEN at
40
+ ``schema_version: "v1"``. The v0.7 migration to ``swarph-shared`` is a
41
+ SYMBOL-RELOCATION ONLY — no field renames, no field removals, no type
42
+ changes. Any v0.7+ additions must be additive-optional. v0.6 cell.yaml
43
+ files keep working unchanged in v0.7+. Breaking changes require a
44
+ ``schema_version: "v2"`` bump and parallel-supported-version window per
45
+ ``swarph-mesh`` DEPRECATIONS discipline.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ import re
52
+ import uuid
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+ from typing import Any, Optional
56
+
57
+
58
+ # Conservative peer-name pattern, mirrors swarph_shared.peer_registry
59
+ # discipline — kebab/snake-case, no spaces, no leading hyphen.
60
+ _PEER_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{1,63}$")
61
+ _VALID_PROVIDERS_V0_6 = {"claude"}
62
+
63
+
64
+ class CellError(ValueError):
65
+ """Raised on cell.yaml validation or lookup failure."""
66
+
67
+
68
+ SCHEMA_VERSION_V1 = "v1"
69
+ _VALID_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1}
70
+
71
+
72
+ @dataclass
73
+ class Lineage:
74
+ """Optional lineage block — alpha #891 (D1) reserved shape.
75
+
76
+ v0.6 accepts presence + parses; semantic validation (signature
77
+ verification) graduates with the v2 cryptographic-lineage tier
78
+ per substrate-doc R6 §11.1.2 candidate primitive S-B.
79
+ """
80
+
81
+ parent_peer_id: Optional[str] = None
82
+ spawn_manifest_signature: Optional[str] = None
83
+
84
+
85
+ @dataclass
86
+ class Cell:
87
+ """Parsed cell.yaml — v0.6 schema (``schema_version: "v1"``)."""
88
+
89
+ name: str
90
+ role: str
91
+ cwd: Path
92
+ schema_version: str = SCHEMA_VERSION_V1
93
+ session_id: Optional[str] = None
94
+ starter_prompt_path: Optional[Path] = None
95
+ provider: str = "claude"
96
+ lineage: Optional[Lineage] = None
97
+ source_path: Optional[Path] = None
98
+ extra: dict[str, Any] = field(default_factory=dict)
99
+
100
+ def starter_prompt_text(self) -> Optional[str]:
101
+ """Return contents of starter_prompt_path or None.
102
+
103
+ Raises CellError if the path is set but unreadable so spawn
104
+ fails loudly rather than silently dropping the role-priming.
105
+ """
106
+ if self.starter_prompt_path is None:
107
+ return None
108
+ try:
109
+ return self.starter_prompt_path.read_text(encoding="utf-8")
110
+ except OSError as exc:
111
+ raise CellError(
112
+ f"cell.yaml: starter_prompt_path "
113
+ f"'{self.starter_prompt_path}' is not readable: {exc}"
114
+ ) from exc
115
+
116
+
117
+ def _config_root() -> Path:
118
+ """Return the active config root for cell lookups.
119
+
120
+ Honours ``$XDG_CONFIG_HOME`` per the XDG Base Directory spec; falls
121
+ back to ``~/.config/`` otherwise. The trailing ``swarph/cells/``
122
+ segment is appended by callers, so this returns the parent.
123
+ """
124
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
125
+ if xdg:
126
+ return Path(xdg)
127
+ return Path.home() / ".config"
128
+
129
+
130
+ def cells_dir() -> Path:
131
+ """Default lookup directory for ``<role>.yaml`` files."""
132
+ return _config_root() / "swarph" / "cells"
133
+
134
+
135
+ def _state_root() -> Path:
136
+ xdg = os.environ.get("XDG_STATE_HOME", "").strip()
137
+ if xdg:
138
+ return Path(xdg)
139
+ return Path.home() / ".local" / "state"
140
+
141
+
142
+ def session_state_path(role: str) -> Path:
143
+ """Return the per-role persisted-session-id file path.
144
+
145
+ v0.6 persists generated UUIDs OUTSIDE cell.yaml so the cell file
146
+ stays purely declarative and is safe to commit to git. Roles
147
+ re-spawned with the same role name resume the same session via
148
+ this state file.
149
+ """
150
+ return _state_root() / "swarph" / "sessions" / f"{role}.session-id"
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
+ (alpha #891 D3)
170
+ 4. otherwise → ``<cells_dir>/<spec>.yaml``
171
+
172
+ Mesh-gateway URL inputs (``mesh-gateway://peers/<peer-id>/spawn-context``)
173
+ are caught by ``is_mesh_gateway_url`` BEFORE this function and return
174
+ NotImplementedError — the S-G substrate primitive lands in v0.7+.
175
+ """
176
+ if spec == ".":
177
+ return Path.cwd() / "cell.yaml"
178
+ if spec.endswith((".yaml", ".yml")) or os.sep in spec:
179
+ return Path(spec).expanduser()
180
+ return cells_dir() / f"{spec}.yaml"
181
+
182
+
183
+ def discover_cell_in_cwd() -> Optional[Path]:
184
+ """Return ``./cell.yaml`` if it exists in the current cwd, else None.
185
+
186
+ Implements alpha #891 (D3) auto-discovery for the no-positional case.
187
+ """
188
+ candidate = Path.cwd() / "cell.yaml"
189
+ return candidate if candidate.is_file() else None
190
+
191
+
192
+ def _validate_uuid(value: str) -> str:
193
+ """Validate-and-normalise a UUID string; raise CellError otherwise.
194
+
195
+ ``claude --session-id`` rejects non-UUIDs at the harness layer;
196
+ catching it here gives a substrate-shaped error path instead of a
197
+ bare claude-cli traceback.
198
+ """
199
+ try:
200
+ return str(uuid.UUID(value))
201
+ except (ValueError, AttributeError, TypeError) as exc:
202
+ raise CellError(f"cell.yaml: session_id is not a valid UUID: {value!r}") from exc
203
+
204
+
205
+ def load_cell(path: Path) -> Cell:
206
+ """Parse + validate a cell.yaml file. Raises CellError on any failure."""
207
+ import yaml # local import — keeps `swarph --version` PyYAML-free
208
+
209
+ if not path.exists():
210
+ raise CellError(f"cell.yaml not found: {path}")
211
+
212
+ try:
213
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
214
+ except yaml.YAMLError as exc:
215
+ raise CellError(f"cell.yaml is not valid YAML ({path}): {exc}") from exc
216
+
217
+ if not isinstance(raw, dict):
218
+ raise CellError(
219
+ f"cell.yaml top-level must be a mapping ({path}); got {type(raw).__name__}"
220
+ )
221
+
222
+ schema_version = raw.pop("schema_version", SCHEMA_VERSION_V1)
223
+ name = raw.pop("name", None)
224
+ role = raw.pop("role", None)
225
+ cwd_raw = raw.pop("cwd", None)
226
+ session_id = raw.pop("session_id", None)
227
+ starter_prompt_raw = raw.pop("starter_prompt_path", None)
228
+ provider = raw.pop("provider", "claude")
229
+ identity = raw.pop("identity", None)
230
+
231
+ if schema_version not in _VALID_SCHEMA_VERSIONS:
232
+ raise CellError(
233
+ f"cell.yaml: schema_version {schema_version!r} is not supported "
234
+ f"by this swarph-cli build. Supported: {sorted(_VALID_SCHEMA_VERSIONS)}."
235
+ )
236
+
237
+ if not isinstance(name, str) or not _PEER_NAME_RE.match(name):
238
+ raise CellError(
239
+ f"cell.yaml: 'name' must be a kebab/snake-case peer name "
240
+ f"matching {_PEER_NAME_RE.pattern}; got {name!r}"
241
+ )
242
+ if not isinstance(role, str) or not role.strip():
243
+ raise CellError("cell.yaml: 'role' is required and must be a non-empty string")
244
+ if not isinstance(cwd_raw, str) or not cwd_raw.strip():
245
+ raise CellError("cell.yaml: 'cwd' is required and must be a non-empty string")
246
+
247
+ cwd = Path(cwd_raw).expanduser()
248
+ if not cwd.is_absolute():
249
+ # Resolve relative to cell.yaml's parent dir for ergonomic
250
+ # author-from-anywhere config files.
251
+ cwd = (path.parent / cwd).resolve()
252
+ if not cwd.is_dir():
253
+ raise CellError(f"cell.yaml: 'cwd' is not a directory: {cwd}")
254
+
255
+ if session_id is not None:
256
+ if not isinstance(session_id, str):
257
+ raise CellError(
258
+ f"cell.yaml: 'session_id' must be a string UUID, got "
259
+ f"{type(session_id).__name__}"
260
+ )
261
+ session_id = _validate_uuid(session_id)
262
+
263
+ starter_path: Optional[Path] = None
264
+ if starter_prompt_raw is not None:
265
+ if not isinstance(starter_prompt_raw, str) or not starter_prompt_raw.strip():
266
+ raise CellError(
267
+ "cell.yaml: 'starter_prompt_path' must be a non-empty string"
268
+ )
269
+ starter_path = Path(starter_prompt_raw).expanduser()
270
+ if not starter_path.is_absolute():
271
+ starter_path = (path.parent / starter_path).resolve()
272
+
273
+ if provider not in _VALID_PROVIDERS_V0_6:
274
+ raise CellError(
275
+ f"cell.yaml: provider {provider!r} is not supported in v0.6 "
276
+ f"(valid: {sorted(_VALID_PROVIDERS_V0_6)}). "
277
+ "Non-Claude provider spawn is queued for v0.7+."
278
+ )
279
+
280
+ lineage_obj: Optional[Lineage] = None
281
+ if identity is not None:
282
+ if not isinstance(identity, dict):
283
+ raise CellError(
284
+ f"cell.yaml: 'identity' must be a mapping; got "
285
+ f"{type(identity).__name__}"
286
+ )
287
+ lineage_raw = identity.get("lineage")
288
+ if lineage_raw is not None:
289
+ if not isinstance(lineage_raw, dict):
290
+ raise CellError(
291
+ "cell.yaml: 'identity.lineage' must be a mapping"
292
+ )
293
+ lineage_obj = Lineage(
294
+ parent_peer_id=lineage_raw.get("parent_peer_id"),
295
+ spawn_manifest_signature=lineage_raw.get(
296
+ "spawn_manifest_signature"
297
+ ),
298
+ )
299
+
300
+ return Cell(
301
+ name=name,
302
+ role=role.strip(),
303
+ cwd=cwd,
304
+ schema_version=schema_version,
305
+ session_id=session_id,
306
+ starter_prompt_path=starter_path,
307
+ provider=provider,
308
+ lineage=lineage_obj,
309
+ source_path=path,
310
+ extra=raw, # whatever's left — preserved for forward-compat
311
+ )
312
+
313
+
314
+ def load_or_create_session_id(role: str, cell: Cell) -> tuple[str, bool]:
315
+ """Resolve the session-id for a spawn invocation.
316
+
317
+ Returns ``(session_id, was_generated)`` where ``was_generated``
318
+ indicates whether a fresh UUID was minted (and persisted) on this
319
+ call.
320
+
321
+ Resolution order:
322
+ 1. cell.session_id (cell.yaml-pinned) — never generated
323
+ 2. session_state_path(role) (last-generated for this role)
324
+ 3. mint new uuid4 + persist to session_state_path(role)
325
+ """
326
+ if cell.session_id:
327
+ return cell.session_id, False
328
+
329
+ state_file = session_state_path(role)
330
+ if state_file.exists():
331
+ existing = state_file.read_text(encoding="utf-8").strip()
332
+ if existing:
333
+ try:
334
+ return _validate_uuid(existing), False
335
+ except CellError:
336
+ # Corrupted state — fall through and regenerate.
337
+ pass
338
+
339
+ new_id = str(uuid.uuid4())
340
+ state_file.parent.mkdir(parents=True, exist_ok=True)
341
+ _atomic_write_text(state_file, new_id + "\n")
342
+ return new_id, True
343
+
344
+
345
+ def _atomic_write_text(target: Path, content: str) -> None:
346
+ """Write text atomically: tempfile in the same dir, fsync, rename.
347
+
348
+ Per drop-mother review #890 (C1) — UUID writes are load-bearing for
349
+ R5 (session-resume identity disambiguation). A torn write that left
350
+ half a UUID in the state file would silently regenerate on next
351
+ spawn, defeating the disambiguation primitive entirely.
352
+ """
353
+ import tempfile
354
+
355
+ parent = target.parent
356
+ fd, tmp_path = tempfile.mkstemp(
357
+ prefix=f".{target.name}.", suffix=".tmp", dir=parent
358
+ )
359
+ try:
360
+ with os.fdopen(fd, "w", encoding="utf-8") as fp:
361
+ fp.write(content)
362
+ fp.flush()
363
+ os.fsync(fp.fileno())
364
+ os.replace(tmp_path, target)
365
+ except Exception:
366
+ # Best-effort cleanup of the tempfile on any error path.
367
+ try:
368
+ os.unlink(tmp_path)
369
+ except OSError:
370
+ pass
371
+ raise