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.
- {swarph_cli-0.5.0/src/swarph_cli.egg-info → swarph_cli-0.6.0}/PKG-INFO +64 -8
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/README.md +61 -6
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/pyproject.toml +6 -2
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/__init__.py +1 -1
- swarph_cli-0.6.0/src/swarph_cli/cell.py +371 -0
- swarph_cli-0.6.0/src/swarph_cli/commands/spawn.py +307 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/main.py +12 -2
- {swarph_cli-0.5.0 → swarph_cli-0.6.0/src/swarph_cli.egg-info}/PKG-INFO +64 -8
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/SOURCES.txt +5 -1
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/requires.txt +1 -0
- swarph_cli-0.6.0/tests/test_cell_loader.py +293 -0
- swarph_cli-0.6.0/tests/test_spawn_command.py +244 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/LICENSE +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/setup.cfg +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/caller.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/__init__.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/chat.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/daemon.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/import_session.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/onboard.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/commands/ratify.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/parsers/__init__.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli/parsers/claude.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/dependency_links.txt +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/entry_points.txt +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/src/swarph_cli.egg-info/top_level.txt +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_chat_command.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_claude_parser.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_daemon_command.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_import_command.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_main.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_onboard_command.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_ratify_command.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_smoke_chat.py +0 -0
- {swarph_cli-0.5.0 → swarph_cli-0.6.0}/tests/test_smoke_one_shot.py +0 -0
- {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.
|
|
4
|
-
Summary: The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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.
|
|
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
|
|
57
|
-
4. `swarph
|
|
58
|
-
5. `swarph
|
|
59
|
-
6. `swarph
|
|
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.
|
|
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
|
|
25
|
-
4. `swarph
|
|
26
|
-
5. `swarph
|
|
27
|
-
6. `swarph
|
|
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.
|
|
8
|
-
description = "The `swarph` binary — multi-LLM CLI with mesh-gateway integration. v0.
|
|
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]
|
|
@@ -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
|