multi-harness 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_harness/__init__.py +1 -0
- multi_harness/agents/__init__.py +13 -0
- multi_harness/agents/antigravity.py +12 -0
- multi_harness/agents/claude.py +12 -0
- multi_harness/agents/codex.py +12 -0
- multi_harness/agents/copilot.py +12 -0
- multi_harness/agents/opencode.py +12 -0
- multi_harness/agents/spec.py +14 -0
- multi_harness/cli.py +207 -0
- multi_harness/config.py +45 -0
- multi_harness/harness.py +254 -0
- multi_harness/status.py +60 -0
- multi_harness/symlinks.py +35 -0
- multi_harness-0.1.0.dist-info/METADATA +189 -0
- multi_harness-0.1.0.dist-info/RECORD +17 -0
- multi_harness-0.1.0.dist-info/WHEEL +4 -0
- multi_harness-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .antigravity import SPEC as _ANTIGRAVITY
|
|
2
|
+
from .claude import SPEC as _CLAUDE
|
|
3
|
+
from .codex import SPEC as _CODEX
|
|
4
|
+
from .copilot import SPEC as _COPILOT
|
|
5
|
+
from .opencode import SPEC as _OPENCODE
|
|
6
|
+
from .spec import AgentSpec
|
|
7
|
+
|
|
8
|
+
AGENT_REGISTRY: dict[str, AgentSpec] = {
|
|
9
|
+
spec.name: spec
|
|
10
|
+
for spec in (_CLAUDE, _CODEX, _OPENCODE, _COPILOT, _ANTIGRAVITY)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
__all__ = ["AGENT_REGISTRY", "AgentSpec"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .spec import AgentSpec
|
|
4
|
+
|
|
5
|
+
SPEC = AgentSpec(
|
|
6
|
+
name="antigravity",
|
|
7
|
+
display_name="Google Antigravity",
|
|
8
|
+
instructions_path=None,
|
|
9
|
+
skills_path=Path(".agents/skills"),
|
|
10
|
+
subagents_path=Path(".subagents"),
|
|
11
|
+
detection_paths=(Path(".agents"), Path(".subagents")),
|
|
12
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .spec import AgentSpec
|
|
4
|
+
|
|
5
|
+
SPEC = AgentSpec(
|
|
6
|
+
name="claude",
|
|
7
|
+
display_name="Claude Code",
|
|
8
|
+
instructions_path=Path("CLAUDE.md"),
|
|
9
|
+
skills_path=Path(".claude/skills"),
|
|
10
|
+
subagents_path=Path(".claude/agents"),
|
|
11
|
+
detection_paths=(Path("CLAUDE.md"), Path(".claude")),
|
|
12
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .spec import AgentSpec
|
|
4
|
+
|
|
5
|
+
SPEC = AgentSpec(
|
|
6
|
+
name="codex",
|
|
7
|
+
display_name="OpenAI Codex",
|
|
8
|
+
instructions_path=None,
|
|
9
|
+
skills_path=Path(".codex/skills"),
|
|
10
|
+
subagents_path=Path(".codex/agents"),
|
|
11
|
+
detection_paths=(Path(".codex"),),
|
|
12
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .spec import AgentSpec
|
|
4
|
+
|
|
5
|
+
SPEC = AgentSpec(
|
|
6
|
+
name="copilot",
|
|
7
|
+
display_name="GitHub Copilot",
|
|
8
|
+
instructions_path=Path(".github/copilot-instructions.md"),
|
|
9
|
+
skills_path=Path(".github/skills"),
|
|
10
|
+
subagents_path=Path(".github/agents"),
|
|
11
|
+
detection_paths=(Path(".github/copilot-instructions.md"),),
|
|
12
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .spec import AgentSpec
|
|
4
|
+
|
|
5
|
+
SPEC = AgentSpec(
|
|
6
|
+
name="opencode",
|
|
7
|
+
display_name="OpenCode",
|
|
8
|
+
instructions_path=None,
|
|
9
|
+
skills_path=Path(".opencode/skills"),
|
|
10
|
+
subagents_path=Path(".opencode/agent"),
|
|
11
|
+
detection_paths=(Path(".opencode"),),
|
|
12
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class AgentSpec:
|
|
9
|
+
name: str
|
|
10
|
+
display_name: str
|
|
11
|
+
instructions_path: Path | None
|
|
12
|
+
skills_path: Path
|
|
13
|
+
subagents_path: Path
|
|
14
|
+
detection_paths: tuple[Path, ...]
|
multi_harness/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .agents import AGENT_REGISTRY
|
|
10
|
+
from .config import ConfigError, read_agent_names
|
|
11
|
+
from .harness import HarnessError, add as harness_add, init as harness_init, remove as harness_remove
|
|
12
|
+
from .status import check_all
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
help="Manage projects targeting multiple coding agents from a shared harness.",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.callback()
|
|
21
|
+
def _callback() -> None:
|
|
22
|
+
"""multi-harness — shared harness for multi-agent coding projects."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_agents(value: Optional[str]) -> list[str]:
|
|
26
|
+
if value is None:
|
|
27
|
+
return list(AGENT_REGISTRY)
|
|
28
|
+
names = [n.strip() for n in value.split(",") if n.strip()]
|
|
29
|
+
if not names:
|
|
30
|
+
raise typer.BadParameter("--agents must list at least one agent.")
|
|
31
|
+
return names
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def init(
|
|
36
|
+
path: Annotated[
|
|
37
|
+
Path,
|
|
38
|
+
typer.Argument(
|
|
39
|
+
help="Project directory to initialize. Defaults to the current dir.",
|
|
40
|
+
file_okay=False,
|
|
41
|
+
dir_okay=True,
|
|
42
|
+
exists=True,
|
|
43
|
+
resolve_path=True,
|
|
44
|
+
),
|
|
45
|
+
] = Path("."),
|
|
46
|
+
agents: Annotated[
|
|
47
|
+
Optional[str],
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--agents",
|
|
50
|
+
help=(
|
|
51
|
+
"Comma-separated list of agents to register. "
|
|
52
|
+
f"Defaults to all supported: {','.join(AGENT_REGISTRY)}."
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
] = None,
|
|
56
|
+
template: Annotated[
|
|
57
|
+
Optional[Path],
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--template",
|
|
60
|
+
help="Path to a file used to seed AGENTS.md when it doesn't already exist.",
|
|
61
|
+
file_okay=True,
|
|
62
|
+
dir_okay=False,
|
|
63
|
+
exists=True,
|
|
64
|
+
readable=True,
|
|
65
|
+
resolve_path=True,
|
|
66
|
+
),
|
|
67
|
+
] = None,
|
|
68
|
+
force: Annotated[
|
|
69
|
+
bool,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--force",
|
|
72
|
+
help="Re-link symlinks even if .harness/ already exists. Never overwrites real files.",
|
|
73
|
+
),
|
|
74
|
+
] = False,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Bootstrap or migrate a project into the multi-harness layout."""
|
|
77
|
+
selected = _parse_agents(agents)
|
|
78
|
+
try:
|
|
79
|
+
report = harness_init(path, selected, template=template, force=force)
|
|
80
|
+
except HarnessError as exc:
|
|
81
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
|
|
84
|
+
if report.detected:
|
|
85
|
+
names = ", ".join(s.display_name for s in report.detected)
|
|
86
|
+
typer.echo(f"Detected existing agent: {names}")
|
|
87
|
+
for src, dst in report.moved:
|
|
88
|
+
typer.echo(f" moved {src.relative_to(path)} -> {dst.relative_to(path)}")
|
|
89
|
+
for created in report.created_files:
|
|
90
|
+
typer.echo(f" created {created.relative_to(path)}")
|
|
91
|
+
for link, result in report.symlinks:
|
|
92
|
+
typer.echo(f" link {link.relative_to(path)} ({result})")
|
|
93
|
+
typer.echo("Done.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.command()
|
|
97
|
+
def add(
|
|
98
|
+
agents: Annotated[
|
|
99
|
+
list[str],
|
|
100
|
+
typer.Argument(help="Agent name(s) to register."),
|
|
101
|
+
],
|
|
102
|
+
path: Annotated[
|
|
103
|
+
Path,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--path",
|
|
106
|
+
help="Project directory. Defaults to the current dir.",
|
|
107
|
+
file_okay=False,
|
|
108
|
+
dir_okay=True,
|
|
109
|
+
exists=True,
|
|
110
|
+
resolve_path=True,
|
|
111
|
+
),
|
|
112
|
+
] = Path("."),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Register one or more new agents to an existing harness."""
|
|
115
|
+
try:
|
|
116
|
+
report = harness_add(path, agents)
|
|
117
|
+
except HarnessError as exc:
|
|
118
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
119
|
+
raise typer.Exit(code=1)
|
|
120
|
+
|
|
121
|
+
for link, result in report.symlinks:
|
|
122
|
+
typer.echo(f" link {link.relative_to(path)} ({result})")
|
|
123
|
+
typer.echo("Done.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command()
|
|
127
|
+
def remove(
|
|
128
|
+
agents: Annotated[
|
|
129
|
+
list[str],
|
|
130
|
+
typer.Argument(help="Agent name(s) to remove."),
|
|
131
|
+
],
|
|
132
|
+
path: Annotated[
|
|
133
|
+
Path,
|
|
134
|
+
typer.Option(
|
|
135
|
+
"--path",
|
|
136
|
+
help="Project directory. Defaults to the current dir.",
|
|
137
|
+
file_okay=False,
|
|
138
|
+
dir_okay=True,
|
|
139
|
+
exists=True,
|
|
140
|
+
resolve_path=True,
|
|
141
|
+
),
|
|
142
|
+
] = Path("."),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Remove one or more agents from the harness."""
|
|
145
|
+
try:
|
|
146
|
+
report = harness_remove(path, agents)
|
|
147
|
+
except HarnessError as exc:
|
|
148
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
149
|
+
raise typer.Exit(code=1)
|
|
150
|
+
|
|
151
|
+
for link in report.removed_symlinks:
|
|
152
|
+
typer.echo(f" unlinked {link.relative_to(path)}")
|
|
153
|
+
for d in report.removed_dirs:
|
|
154
|
+
typer.echo(f" removed {d.relative_to(path)}")
|
|
155
|
+
for d in report.nonempty_dirs:
|
|
156
|
+
rel = d.relative_to(path)
|
|
157
|
+
if typer.confirm(f" {rel} is not empty. Remove it?", default=False):
|
|
158
|
+
shutil.rmtree(d)
|
|
159
|
+
typer.echo(f" removed {rel}")
|
|
160
|
+
typer.echo("Done.")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def status(
|
|
165
|
+
path: Annotated[
|
|
166
|
+
Path,
|
|
167
|
+
typer.Argument(
|
|
168
|
+
help="Project directory to check. Defaults to the current dir.",
|
|
169
|
+
file_okay=False,
|
|
170
|
+
dir_okay=True,
|
|
171
|
+
exists=True,
|
|
172
|
+
resolve_path=True,
|
|
173
|
+
),
|
|
174
|
+
] = Path("."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Show symlink health for all registered agents."""
|
|
177
|
+
try:
|
|
178
|
+
agent_names = read_agent_names(path)
|
|
179
|
+
except ConfigError as exc:
|
|
180
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
statuses = check_all(path, agent_names)
|
|
184
|
+
if not statuses:
|
|
185
|
+
typer.echo("No agents registered.")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
_STATUS_COLOR = {
|
|
189
|
+
"ok": typer.colors.GREEN,
|
|
190
|
+
"missing": typer.colors.YELLOW,
|
|
191
|
+
"broken": typer.colors.RED,
|
|
192
|
+
"detached": typer.colors.RED,
|
|
193
|
+
}
|
|
194
|
+
max_label = max(len(r.label) for s in statuses for r in s.rows)
|
|
195
|
+
for agent_status in statuses:
|
|
196
|
+
typer.echo(agent_status.spec.display_name)
|
|
197
|
+
for row in agent_status.rows:
|
|
198
|
+
typer.echo(f" {row.label.ljust(max_label + 2)}", nl=False)
|
|
199
|
+
typer.secho(row.status, fg=_STATUS_COLOR[row.status])
|
|
200
|
+
typer.echo("")
|
|
201
|
+
|
|
202
|
+
if not all(s.is_ok() for s in statuses):
|
|
203
|
+
raise typer.Exit(code=1)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
app()
|
multi_harness/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_HARNESS_DIR = Path(".harness")
|
|
7
|
+
HARNESS_CONFIG = _HARNESS_DIR / "config.toml"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(Exception):
|
|
11
|
+
"""Raised when .harness/config.toml is absent or has unexpected structure."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_config(root: Path, agent_names: list[str]) -> None:
|
|
15
|
+
agents_repr = ", ".join(f'"{n}"' for n in agent_names)
|
|
16
|
+
(root / HARNESS_CONFIG).write_text(
|
|
17
|
+
f"[harness]\nversion = 1\nagents = [{agents_repr}]\n"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_agent_names(root: Path) -> list[str]:
|
|
22
|
+
config_path = root / HARNESS_CONFIG
|
|
23
|
+
if not config_path.exists():
|
|
24
|
+
raise ConfigError(
|
|
25
|
+
f"{HARNESS_CONFIG} not found. Run `mh init` to register agents."
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with config_path.open("rb") as fh:
|
|
29
|
+
data = tomllib.load(fh)
|
|
30
|
+
except tomllib.TOMLDecodeError as exc:
|
|
31
|
+
raise ConfigError(f"{HARNESS_CONFIG} is malformed: {exc}") from exc
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
agents = data["harness"]["agents"]
|
|
35
|
+
except KeyError as exc:
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
f"{HARNESS_CONFIG} is missing key {exc}. Re-run `mh init`."
|
|
38
|
+
) from exc
|
|
39
|
+
|
|
40
|
+
if not isinstance(agents, list) or not all(isinstance(n, str) for n in agents):
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
f"{HARNESS_CONFIG}: harness.agents must be a list of strings."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return agents
|
multi_harness/harness.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .agents import AGENT_REGISTRY, AgentSpec
|
|
7
|
+
from .config import ConfigError, read_agent_names, write_config
|
|
8
|
+
from .symlinks import SymlinkResult, ensure_symlink
|
|
9
|
+
|
|
10
|
+
HARNESS_DIR = Path(".harness")
|
|
11
|
+
HARNESS_SKILLS = HARNESS_DIR / "skills"
|
|
12
|
+
HARNESS_AGENTS = HARNESS_DIR / "agents"
|
|
13
|
+
AGENTS_MD = Path("AGENTS.md")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HarnessError(Exception):
|
|
17
|
+
"""Raised when `mh init` cannot safely proceed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class InitReport:
|
|
22
|
+
detected: list[AgentSpec] = field(default_factory=list)
|
|
23
|
+
moved: list[tuple[Path, Path]] = field(default_factory=list)
|
|
24
|
+
created_files: list[Path] = field(default_factory=list)
|
|
25
|
+
symlinks: list[tuple[Path, SymlinkResult]] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AddReport:
|
|
30
|
+
symlinks: list[tuple[Path, SymlinkResult]] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RemoveReport:
|
|
35
|
+
removed_symlinks: list[Path] = field(default_factory=list)
|
|
36
|
+
removed_dirs: list[Path] = field(default_factory=list)
|
|
37
|
+
nonempty_dirs: list[Path] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_configured_agents(root: Path) -> list[AgentSpec]:
|
|
41
|
+
"""Return registered agents whose real detection paths exist under ``root``."""
|
|
42
|
+
detected: list[AgentSpec] = []
|
|
43
|
+
for spec in AGENT_REGISTRY.values():
|
|
44
|
+
for p in spec.detection_paths:
|
|
45
|
+
candidate = root / p
|
|
46
|
+
if candidate.exists() and not candidate.is_symlink():
|
|
47
|
+
detected.append(spec)
|
|
48
|
+
break
|
|
49
|
+
return detected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _move(src: Path, dst: Path, report: InitReport) -> None:
|
|
53
|
+
if dst.exists() or dst.is_symlink():
|
|
54
|
+
raise HarnessError(
|
|
55
|
+
f"Cannot migrate {src} -> {dst}: destination already exists. "
|
|
56
|
+
f"Resolve the conflict and re-run."
|
|
57
|
+
)
|
|
58
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
src.rename(dst)
|
|
60
|
+
report.moved.append((src, dst))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _migrate(root: Path, spec: AgentSpec, report: InitReport) -> None:
|
|
64
|
+
if spec.instructions_path is not None:
|
|
65
|
+
src = root / spec.instructions_path
|
|
66
|
+
dst = root / AGENTS_MD
|
|
67
|
+
if src.exists() and not src.is_symlink() and src != dst:
|
|
68
|
+
_move(src, dst, report)
|
|
69
|
+
|
|
70
|
+
src_skills = root / spec.skills_path
|
|
71
|
+
if src_skills.is_dir() and not src_skills.is_symlink():
|
|
72
|
+
_move(src_skills, root / HARNESS_SKILLS, report)
|
|
73
|
+
|
|
74
|
+
src_subagents = root / spec.subagents_path
|
|
75
|
+
if src_subagents.is_dir() and not src_subagents.is_symlink():
|
|
76
|
+
_move(src_subagents, root / HARNESS_AGENTS, report)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def init(
|
|
80
|
+
root: Path,
|
|
81
|
+
agent_names: list[str],
|
|
82
|
+
template: Path | None = None,
|
|
83
|
+
force: bool = False,
|
|
84
|
+
) -> InitReport:
|
|
85
|
+
"""Initialize (or re-initialize) the harness layout at ``root``.
|
|
86
|
+
|
|
87
|
+
Raises :class:`HarnessError` if the project is in a state that requires manual
|
|
88
|
+
resolution (e.g. multiple agents already configured, or `.harness/` already
|
|
89
|
+
exists without ``--force``).
|
|
90
|
+
"""
|
|
91
|
+
unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
|
|
92
|
+
if unknown:
|
|
93
|
+
raise HarnessError(
|
|
94
|
+
f"Unknown agent(s): {', '.join(unknown)}. "
|
|
95
|
+
f"Supported: {', '.join(AGENT_REGISTRY)}."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
report = InitReport()
|
|
99
|
+
harness_existed = (root / HARNESS_DIR).exists()
|
|
100
|
+
|
|
101
|
+
if harness_existed and not force:
|
|
102
|
+
raise HarnessError(
|
|
103
|
+
f"{HARNESS_DIR}/ already exists. Pass --force to re-link symlinks idempotently."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not harness_existed:
|
|
107
|
+
report.detected = detect_configured_agents(root)
|
|
108
|
+
if len(report.detected) > 1:
|
|
109
|
+
names = ", ".join(s.display_name for s in report.detected)
|
|
110
|
+
raise HarnessError(
|
|
111
|
+
f"More than one agent is already configured ({names}). "
|
|
112
|
+
f"Resolve manually (remove all but one) and re-run."
|
|
113
|
+
)
|
|
114
|
+
if len(report.detected) == 1:
|
|
115
|
+
_migrate(root, report.detected[0], report)
|
|
116
|
+
|
|
117
|
+
(root / HARNESS_SKILLS).mkdir(parents=True, exist_ok=True)
|
|
118
|
+
(root / HARNESS_AGENTS).mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
agents_md = root / AGENTS_MD
|
|
121
|
+
if not agents_md.exists() and not agents_md.is_symlink():
|
|
122
|
+
if template is not None:
|
|
123
|
+
agents_md.write_text(template.read_text())
|
|
124
|
+
else:
|
|
125
|
+
agents_md.touch()
|
|
126
|
+
report.created_files.append(agents_md)
|
|
127
|
+
|
|
128
|
+
for name in agent_names:
|
|
129
|
+
_create_agent_symlinks(root, AGENT_REGISTRY[name], report.symlinks)
|
|
130
|
+
|
|
131
|
+
write_config(root, agent_names)
|
|
132
|
+
return report
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _create_agent_symlinks(
|
|
136
|
+
root: Path,
|
|
137
|
+
spec: AgentSpec,
|
|
138
|
+
out: list[tuple[Path, SymlinkResult]],
|
|
139
|
+
) -> None:
|
|
140
|
+
if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
|
|
141
|
+
link = root / spec.instructions_path
|
|
142
|
+
try:
|
|
143
|
+
result = ensure_symlink(link, root / AGENTS_MD)
|
|
144
|
+
except FileExistsError as exc:
|
|
145
|
+
raise HarnessError(str(exc)) from exc
|
|
146
|
+
out.append((link, result))
|
|
147
|
+
for link_rel, target_rel in (
|
|
148
|
+
(spec.skills_path, HARNESS_SKILLS),
|
|
149
|
+
(spec.subagents_path, HARNESS_AGENTS),
|
|
150
|
+
):
|
|
151
|
+
link = root / link_rel
|
|
152
|
+
try:
|
|
153
|
+
result = ensure_symlink(link, root / target_rel)
|
|
154
|
+
except FileExistsError as exc:
|
|
155
|
+
raise HarnessError(str(exc)) from exc
|
|
156
|
+
out.append((link, result))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def add(root: Path, agent_names: list[str]) -> AddReport:
|
|
160
|
+
"""Register new agents to an already-initialized harness.
|
|
161
|
+
|
|
162
|
+
Raises :class:`HarnessError` if the harness is absent, any name is unknown,
|
|
163
|
+
or any agent is already registered.
|
|
164
|
+
"""
|
|
165
|
+
unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
|
|
166
|
+
if unknown:
|
|
167
|
+
raise HarnessError(
|
|
168
|
+
f"Unknown agent(s): {', '.join(unknown)}. "
|
|
169
|
+
f"Supported: {', '.join(AGENT_REGISTRY)}."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not (root / HARNESS_DIR).exists():
|
|
173
|
+
raise HarnessError(f"{HARNESS_DIR}/ not found. Run `mh init` first.")
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
current = read_agent_names(root)
|
|
177
|
+
except ConfigError as exc:
|
|
178
|
+
raise HarnessError(str(exc)) from exc
|
|
179
|
+
|
|
180
|
+
already = [n for n in agent_names if n in current]
|
|
181
|
+
if already:
|
|
182
|
+
raise HarnessError(
|
|
183
|
+
f"Agent(s) already registered: {', '.join(already)}. "
|
|
184
|
+
f"Run `mh remove` first if you want to re-add."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
report = AddReport()
|
|
188
|
+
for name in agent_names:
|
|
189
|
+
_create_agent_symlinks(root, AGENT_REGISTRY[name], report.symlinks)
|
|
190
|
+
|
|
191
|
+
write_config(root, current + agent_names)
|
|
192
|
+
return report
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove(root: Path, agent_names: list[str]) -> RemoveReport:
|
|
196
|
+
"""Unlink symlinks for the given agents and clean up empty parent directories.
|
|
197
|
+
|
|
198
|
+
Raises :class:`HarnessError` if the harness is absent, any name is unknown,
|
|
199
|
+
or any agent is not currently registered.
|
|
200
|
+
|
|
201
|
+
Non-empty parent directories are reported in ``RemoveReport.nonempty_dirs``
|
|
202
|
+
and left for the caller to handle (e.g. interactive prompt).
|
|
203
|
+
"""
|
|
204
|
+
unknown = [n for n in agent_names if n not in AGENT_REGISTRY]
|
|
205
|
+
if unknown:
|
|
206
|
+
raise HarnessError(
|
|
207
|
+
f"Unknown agent(s): {', '.join(unknown)}. "
|
|
208
|
+
f"Supported: {', '.join(AGENT_REGISTRY)}."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if not (root / HARNESS_DIR).exists():
|
|
212
|
+
raise HarnessError(f"{HARNESS_DIR}/ not found. Run `mh init` first.")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
current = read_agent_names(root)
|
|
216
|
+
except ConfigError as exc:
|
|
217
|
+
raise HarnessError(str(exc)) from exc
|
|
218
|
+
|
|
219
|
+
not_registered = [n for n in agent_names if n not in current]
|
|
220
|
+
if not_registered:
|
|
221
|
+
raise HarnessError(
|
|
222
|
+
f"Agent(s) not registered: {', '.join(not_registered)}. "
|
|
223
|
+
f"Run `mh status` to see registered agents."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
report = RemoveReport()
|
|
227
|
+
parent_dirs: set[Path] = set()
|
|
228
|
+
|
|
229
|
+
for name in agent_names:
|
|
230
|
+
spec = AGENT_REGISTRY[name]
|
|
231
|
+
links: list[Path] = []
|
|
232
|
+
if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
|
|
233
|
+
links.append(root / spec.instructions_path)
|
|
234
|
+
links.append(root / spec.skills_path)
|
|
235
|
+
links.append(root / spec.subagents_path)
|
|
236
|
+
|
|
237
|
+
for link in links:
|
|
238
|
+
if link.is_symlink():
|
|
239
|
+
parent = link.parent
|
|
240
|
+
link.unlink()
|
|
241
|
+
report.removed_symlinks.append(link)
|
|
242
|
+
if parent != root:
|
|
243
|
+
parent_dirs.add(parent)
|
|
244
|
+
|
|
245
|
+
for d in sorted(parent_dirs):
|
|
246
|
+
if not any(d.iterdir()):
|
|
247
|
+
d.rmdir()
|
|
248
|
+
report.removed_dirs.append(d)
|
|
249
|
+
else:
|
|
250
|
+
report.nonempty_dirs.append(d)
|
|
251
|
+
|
|
252
|
+
remaining = [n for n in current if n not in set(agent_names)]
|
|
253
|
+
write_config(root, remaining)
|
|
254
|
+
return report
|
multi_harness/status.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from .agents import AGENT_REGISTRY, AgentSpec
|
|
9
|
+
from .harness import AGENTS_MD, HARNESS_AGENTS, HARNESS_SKILLS
|
|
10
|
+
|
|
11
|
+
LinkStatus = Literal["ok", "missing", "broken", "detached"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_symlink(link: Path, expected_target: Path) -> LinkStatus:
|
|
15
|
+
if link.is_symlink():
|
|
16
|
+
resolved = (link.parent / os.readlink(link)).resolve()
|
|
17
|
+
return "ok" if resolved == expected_target.resolve() else "broken"
|
|
18
|
+
if link.exists():
|
|
19
|
+
return "detached"
|
|
20
|
+
return "missing"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AgentStatusRow:
|
|
25
|
+
label: str
|
|
26
|
+
status: LinkStatus
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentStatus:
|
|
31
|
+
spec: AgentSpec
|
|
32
|
+
rows: list[AgentStatusRow]
|
|
33
|
+
|
|
34
|
+
def is_ok(self) -> bool:
|
|
35
|
+
return all(r.status == "ok" for r in self.rows)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_agent(root: Path, spec: AgentSpec) -> AgentStatus:
|
|
39
|
+
rows: list[AgentStatusRow] = []
|
|
40
|
+
|
|
41
|
+
if spec.instructions_path is not None and spec.instructions_path != AGENTS_MD:
|
|
42
|
+
rows.append(AgentStatusRow(
|
|
43
|
+
label=str(spec.instructions_path),
|
|
44
|
+
status=check_symlink(root / spec.instructions_path, root / AGENTS_MD),
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
rows.append(AgentStatusRow(
|
|
48
|
+
label=str(spec.skills_path),
|
|
49
|
+
status=check_symlink(root / spec.skills_path, root / HARNESS_SKILLS),
|
|
50
|
+
))
|
|
51
|
+
rows.append(AgentStatusRow(
|
|
52
|
+
label=str(spec.subagents_path),
|
|
53
|
+
status=check_symlink(root / spec.subagents_path, root / HARNESS_AGENTS),
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
return AgentStatus(spec=spec, rows=rows)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_all(root: Path, agent_names: list[str]) -> list[AgentStatus]:
|
|
60
|
+
return [check_agent(root, AGENT_REGISTRY[name]) for name in agent_names]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
SymlinkResult = Literal["created", "ok", "replaced"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_symlink(link: Path, target: Path) -> SymlinkResult:
|
|
11
|
+
"""Create or update a relative symlink at ``link`` pointing at ``target``.
|
|
12
|
+
|
|
13
|
+
Returns ``"created"`` if newly made, ``"ok"`` if already correct, or
|
|
14
|
+
``"replaced"`` if an existing symlink was repointed. Raises ``FileExistsError``
|
|
15
|
+
if ``link`` exists as a real file or directory (we never overwrite user data).
|
|
16
|
+
"""
|
|
17
|
+
rel = Path(os.path.relpath(target, start=link.parent))
|
|
18
|
+
|
|
19
|
+
if link.is_symlink():
|
|
20
|
+
current = Path(os.readlink(link))
|
|
21
|
+
if current == rel:
|
|
22
|
+
return "ok"
|
|
23
|
+
link.unlink()
|
|
24
|
+
link.symlink_to(rel)
|
|
25
|
+
return "replaced"
|
|
26
|
+
|
|
27
|
+
if link.exists():
|
|
28
|
+
raise FileExistsError(
|
|
29
|
+
f"{link} already exists as a real file or directory; refusing to overwrite. "
|
|
30
|
+
f"Move or remove it manually, then re-run `mh init`."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
link.symlink_to(rel)
|
|
35
|
+
return "created"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multi-harness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Manage projects that target multiple coding agents from a single shared harness.
|
|
5
|
+
Author-email: jslarraz <jslarraz@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: typer>=0.12
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# multi-harness 🔗
|
|
14
|
+
|
|
15
|
+
> One project. Many coding agents. Zero duplication.
|
|
16
|
+
|
|
17
|
+
`mh` is a CLI that keeps your skills, subagents, and project instructions in a single
|
|
18
|
+
canonical `.harness/` directory and materialises per-agent symlinks so every agent sees
|
|
19
|
+
the paths it expects — no copy-paste, no drift.
|
|
20
|
+
|
|
21
|
+
**Supported agents:** Claude Code · OpenAI Codex CLI · OpenCode · GitHub Copilot · Google Antigravity
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## ✨ Why multi-harness?
|
|
26
|
+
|
|
27
|
+
Each coding agent looks for instructions, skills, and subagents in different places:
|
|
28
|
+
`CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `.claude/skills/`, `.codex/skills/` …
|
|
29
|
+
|
|
30
|
+
Without `mh` you either duplicate these files across every agent-specific location or
|
|
31
|
+
keep them in sync by hand. `mh` solves this by:
|
|
32
|
+
|
|
33
|
+
1. Creating one real `.harness/` directory with the canonical files.
|
|
34
|
+
2. Writing relative symlinks everywhere each agent expects to look.
|
|
35
|
+
3. Detecting an existing single-agent project and **migrating** it automatically.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 📦 Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install multi-harness
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Verify the install:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
mh --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 🚀 Quick start
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd my-project
|
|
57
|
+
mh init
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That's it. `mh` detects which agents are already configured, migrates their files into
|
|
61
|
+
`.harness/`, and creates symlinks for all five supported agents.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📖 Commands
|
|
66
|
+
|
|
67
|
+
### `mh init` — bootstrap or migrate a project
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
mh init # all five agents, current directory
|
|
71
|
+
mh init --agents claude,codex # register only two agents
|
|
72
|
+
mh init --template ./AGENTS_TEMPLATE.md # seed AGENTS.md from a template file
|
|
73
|
+
mh init --force # re-create symlinks idempotently (safe)
|
|
74
|
+
mh init /path/to/project # target a different directory
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
On first run `mh init`:
|
|
78
|
+
- Creates `AGENTS.md` (or uses your `--template`) as the shared project instructions file.
|
|
79
|
+
- Creates `.harness/skills/` and `.harness/agents/` as the shared canonical directories.
|
|
80
|
+
- Writes relative symlinks for every registered agent (see layout below).
|
|
81
|
+
- Records the registered agents in `.harness/config.toml`.
|
|
82
|
+
|
|
83
|
+
If exactly **one** agent is already configured, `mh init` migrates its files into
|
|
84
|
+
`.harness/` before creating symlinks — your existing work is preserved, not overwritten.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### `mh add` — register new agents
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
mh add copilot # add a single agent
|
|
92
|
+
mh add opencode antigravity # add multiple agents at once
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Creates the symlinks for the new agent(s) and updates `.harness/config.toml`. Requires
|
|
96
|
+
`mh init` to have been run first.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `mh remove` — unregister agents
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
mh remove codex # remove one agent
|
|
104
|
+
mh remove opencode copilot # remove several at once
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Removes the agent's symlinks (instructions, skills, subagents) without touching any file
|
|
108
|
+
inside `.harness/`. Your shared content is never deleted.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### `mh status` — inspect symlink health
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
mh status # check current directory
|
|
116
|
+
mh status /path/to/project # check another directory
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Reports the state of every symlink for every registered agent:
|
|
120
|
+
|
|
121
|
+
| Status | Meaning |
|
|
122
|
+
|--------|---------|
|
|
123
|
+
| ✅ `ok` | Symlink exists and resolves correctly |
|
|
124
|
+
| ❌ `missing` | Symlink is absent |
|
|
125
|
+
| 💔 `broken` | Symlink exists but target doesn't resolve |
|
|
126
|
+
| ⚠️ `detached` | A real file/dir sits where a symlink should be |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 🗂️ Layout produced by `mh init`
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
my-project/
|
|
134
|
+
├── AGENTS.md ← canonical shared instructions (real file)
|
|
135
|
+
├── CLAUDE.md → AGENTS.md
|
|
136
|
+
├── .harness/
|
|
137
|
+
│ ├── config.toml ← registered agents list
|
|
138
|
+
│ ├── skills/ ← shared skills (real dir)
|
|
139
|
+
│ └── agents/ ← shared subagents (real dir)
|
|
140
|
+
├── .claude/
|
|
141
|
+
│ ├── skills → ../.harness/skills
|
|
142
|
+
│ └── agents → ../.harness/agents
|
|
143
|
+
├── .codex/
|
|
144
|
+
│ ├── skills → ../.harness/skills
|
|
145
|
+
│ └── agents → ../.harness/agents
|
|
146
|
+
├── .opencode/
|
|
147
|
+
│ ├── skills → ../.harness/skills
|
|
148
|
+
│ └── agent → ../.harness/agents
|
|
149
|
+
├── .github/
|
|
150
|
+
│ ├── copilot-instructions.md → ../AGENTS.md
|
|
151
|
+
│ ├── skills → ../.harness/skills
|
|
152
|
+
│ └── agents → ../.harness/agents
|
|
153
|
+
├── .agents/
|
|
154
|
+
│ └── skills → ../.harness/skills
|
|
155
|
+
└── .subagents → .harness/agents
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
All symlinks are **relative**, so the tree is fully portable across machines and paths.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 🔄 Typical workflow
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# 1. Bootstrap a new project
|
|
166
|
+
mh init --agents claude,codex
|
|
167
|
+
|
|
168
|
+
# 2. Add an agent later
|
|
169
|
+
mh add copilot
|
|
170
|
+
|
|
171
|
+
# 3. Check everything is wired up correctly
|
|
172
|
+
mh status
|
|
173
|
+
|
|
174
|
+
# 4. Remove an agent you no longer use
|
|
175
|
+
mh remove antigravity
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Write your instructions once in `AGENTS.md`, drop skills into `.harness/skills/`, and
|
|
179
|
+
place shared subagents in `.harness/agents/` — every registered agent picks them up
|
|
180
|
+
automatically.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 🛡️ Safety guarantees
|
|
185
|
+
|
|
186
|
+
- **Symlinks never overwrite real files.** If a real file or directory already exists
|
|
187
|
+
where a symlink should go, `mh` aborts with an error rather than deleting your data.
|
|
188
|
+
- **`--force` is safe.** It re-creates symlinks but still refuses to touch real files.
|
|
189
|
+
- **Migration is non-destructive.** Files are moved into `.harness/`; nothing is deleted.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
multi_harness/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
multi_harness/cli.py,sha256=SVtmA_f54VarZVaLjebLiA3L0Lt6TG2wNPG08e6mjes,6146
|
|
3
|
+
multi_harness/config.py,sha256=27V5UA7u8PTCBkjvzsF3qeuth4C27V02_r3MzdnBkPw,1358
|
|
4
|
+
multi_harness/harness.py,sha256=rmQc-kxDZb7sCyZxOZlIgcjr0Qk-OFTLj_bH-i9pr5g,8575
|
|
5
|
+
multi_harness/status.py,sha256=6NFiv7FNfzvpKEUymnfDhhhqnh8vhDbdkYX8Iy0sW1o,1720
|
|
6
|
+
multi_harness/symlinks.py,sha256=T8AisxDGW1HyWnoWwIAjTe74QYpuUKHE0LEUU6HTasc,1119
|
|
7
|
+
multi_harness/agents/__init__.py,sha256=jqH76KDGwwn7t2GEcSBHdORYKCMnhWomm1_Gl-rDLaM,398
|
|
8
|
+
multi_harness/agents/antigravity.py,sha256=BSUBmzEsquJ8zH6ICr_nQJPWauVkxVpeNiplz5w7Q_I,304
|
|
9
|
+
multi_harness/agents/claude.py,sha256=RHmukKe5TnjpniyF2GNFCMYFT68P2Eyb8irhZf5DuQE,308
|
|
10
|
+
multi_harness/agents/codex.py,sha256=X_xDHYT5IbCdLsKMPI0D6xpfneN0FNRXNNX6aHj9bhE,274
|
|
11
|
+
multi_harness/agents/copilot.py,sha256=u0LgCST2ByI9QLQqRvMJItZoLTT4hijQ2uOralBUJaU,340
|
|
12
|
+
multi_harness/agents/opencode.py,sha256=27iCFSc1WiwLQ1tPo9NVrFPL7A9Wlx8GzNV0Zb9jLlU,281
|
|
13
|
+
multi_harness/agents/spec.py,sha256=hqqXGLW43fVBxR7s1On-Jw-WvElQYQjx3QELC2_Rm5Q,294
|
|
14
|
+
multi_harness-0.1.0.dist-info/METADATA,sha256=G7dGzOkm5JsdbUJi4egjxhFTVwIwKt30pK9RSf-frqE,6084
|
|
15
|
+
multi_harness-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
multi_harness-0.1.0.dist-info/entry_points.txt,sha256=uSRPViv2-Oilz9mOBvAk_n8W1r9Zr_3drrGEX10ZQeE,45
|
|
17
|
+
multi_harness-0.1.0.dist-info/RECORD,,
|