marcus-toolkit 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.
- marcus_toolkit/__init__.py +27 -0
- marcus_toolkit/agent.py +451 -0
- marcus_toolkit/candidates.py +489 -0
- marcus_toolkit/client.py +319 -0
- marcus_toolkit/funnel.py +154 -0
- marcus_toolkit/gate.py +150 -0
- marcus_toolkit/integrity.py +251 -0
- marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit/measure.py +197 -0
- marcus_toolkit/pricing.py +96 -0
- marcus_toolkit/record.py +84 -0
- marcus_toolkit/sampler.py +169 -0
- marcus_toolkit/seed.py +189 -0
- marcus_toolkit/server.py +348 -0
- marcus_toolkit/tender.py +369 -0
- marcus_toolkit/tending.py +946 -0
- marcus_toolkit/usage.py +222 -0
- marcus_toolkit/views.py +150 -0
- marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0.dist-info/METADATA +98 -0
- marcus_toolkit-0.1.0.dist-info/RECORD +25 -0
- marcus_toolkit-0.1.0.dist-info/WHEEL +5 -0
- marcus_toolkit-0.1.0.dist-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- marcus_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One price table for every extension.
|
|
3
|
+
|
|
4
|
+
The Minecraft plumbing and the numerical verifier each carried their own
|
|
5
|
+
(``cost_lib.py``, 129 lines, and ``numeric_cost_lib.py``, 183 lines, 240
|
|
6
|
+
differing lines between them). This is the only copy.
|
|
7
|
+
|
|
8
|
+
Prices are dollars per million tokens as ``(input, output, cache_read,
|
|
9
|
+
cache_write)``; cache read is a tenth of input and cache write is one and a
|
|
10
|
+
quarter times input, which is Anthropic's published rule, with the exact
|
|
11
|
+
figures where a model lists one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
#: $/M tokens: (input, output, cache read, cache write).
|
|
20
|
+
PRICES: dict[str, tuple[float, float, float, float]] = {
|
|
21
|
+
"claude-sonnet-4-5": (3.0, 15.0, 0.30, 3.75),
|
|
22
|
+
"claude-sonnet-5": (2.0, 10.0, 0.20, 2.50),
|
|
23
|
+
"claude-haiku-4-5": (1.0, 5.0, 0.10, 1.25),
|
|
24
|
+
"claude-opus-5": (5.0, 25.0, 0.50, 6.25),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#: Short names the Minecraft runs recorded, kept so their records price the same.
|
|
28
|
+
ALIASES: dict[str, str] = {
|
|
29
|
+
"sonnet": "claude-sonnet-4-5",
|
|
30
|
+
"haiku": "claude-haiku-4-5",
|
|
31
|
+
"sonnet-5": "claude-sonnet-5",
|
|
32
|
+
"opus": "claude-opus-5",
|
|
33
|
+
"opus-5": "claude-opus-5",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_DATE_SUFFIX = re.compile(r"-\d{8}$")
|
|
37
|
+
|
|
38
|
+
USAGE_KEYS: tuple[str, str, str, str] = ("input", "output", "cacheRead", "cacheWrite")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve(model: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Map a model name, alias or dated id to a price-table key.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
model : str
|
|
48
|
+
``sonnet``, ``claude-sonnet-5``, ``claude-haiku-4-5-20251001``, and so on.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
str
|
|
53
|
+
A key of :data:`PRICES`.
|
|
54
|
+
|
|
55
|
+
Raises
|
|
56
|
+
------
|
|
57
|
+
KeyError
|
|
58
|
+
When no price is known for the model; a run must not be priced by guess.
|
|
59
|
+
"""
|
|
60
|
+
name = model.strip().lower()
|
|
61
|
+
name = ALIASES.get(name, name)
|
|
62
|
+
name = name.split("/", 1)[1] if name.startswith("anthropic/") else name
|
|
63
|
+
name = _DATE_SUFFIX.sub("", name)
|
|
64
|
+
name = ALIASES.get(name, name)
|
|
65
|
+
if name not in PRICES:
|
|
66
|
+
raise KeyError(
|
|
67
|
+
f"no price for model {model!r}; add it to marcus_toolkit.pricing.PRICES"
|
|
68
|
+
)
|
|
69
|
+
return name
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def price(usage: Mapping[str, int], model: str) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Price one usage row.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
usage : Mapping[str, int]
|
|
79
|
+
``{input, output, cacheRead, cacheWrite}`` token counts; a missing key
|
|
80
|
+
counts as zero.
|
|
81
|
+
model : str
|
|
82
|
+
The model the row was billed at.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
float
|
|
87
|
+
Dollars, rounded to six places.
|
|
88
|
+
"""
|
|
89
|
+
rate_in, rate_out, rate_read, rate_write = PRICES[resolve(model)]
|
|
90
|
+
dollars = (
|
|
91
|
+
int(usage.get("input") or 0) * rate_in
|
|
92
|
+
+ int(usage.get("output") or 0) * rate_out
|
|
93
|
+
+ int(usage.get("cacheRead") or 0) * rate_read
|
|
94
|
+
+ int(usage.get("cacheWrite") or 0) * rate_write
|
|
95
|
+
) / 1_000_000
|
|
96
|
+
return round(dollars, 6)
|
marcus_toolkit/record.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The run record: one directory per run, every default that applied, every file.
|
|
3
|
+
|
|
4
|
+
The Minecraft runs settled the layout: ``runs/<stamp>/`` holding
|
|
5
|
+
``run-config.txt`` (one ``key: value`` line per setting), ``marcus-version.txt``,
|
|
6
|
+
the logs, the JSONL event streams, and at the end a copy of the board and the
|
|
7
|
+
agents' state. Every figure in a write-up is read from this directory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def stamp_now() -> str:
|
|
20
|
+
"""Return a run stamp: ``YYYYMMDDTHHMMSS`` in local time, as Minecraft does."""
|
|
21
|
+
return time.strftime("%Y%m%dT%H%M%S")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RunRecord:
|
|
25
|
+
"""
|
|
26
|
+
The directory a run writes into.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
root : Path
|
|
31
|
+
The ``runs/`` directory.
|
|
32
|
+
stamp : Optional[str]
|
|
33
|
+
The run's stamp; now by default.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, root: Path, stamp: str | None = None) -> None:
|
|
37
|
+
self.stamp = stamp or stamp_now()
|
|
38
|
+
self.dir = root / self.stamp
|
|
39
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
def path(self, name: str) -> Path:
|
|
42
|
+
"""Return a file path inside the record."""
|
|
43
|
+
return self.dir / name
|
|
44
|
+
|
|
45
|
+
def config_line(self, key: str, value: Any) -> None:
|
|
46
|
+
"""Append one ``key: value`` line to ``run-config.txt``."""
|
|
47
|
+
with self.path("run-config.txt").open("a") as handle:
|
|
48
|
+
handle.write(f"{key + ':':<20}{value}\n")
|
|
49
|
+
|
|
50
|
+
def config(self) -> dict[str, str]:
|
|
51
|
+
"""Read ``run-config.txt`` back as a mapping (last value wins)."""
|
|
52
|
+
out: dict[str, str] = {}
|
|
53
|
+
file = self.path("run-config.txt")
|
|
54
|
+
if not file.is_file():
|
|
55
|
+
return out
|
|
56
|
+
for line in file.read_text().splitlines():
|
|
57
|
+
if ":" in line:
|
|
58
|
+
key, _, value = line.partition(":")
|
|
59
|
+
out[key.strip()] = value.strip()
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
def write(self, name: str, text: str) -> Path:
|
|
63
|
+
"""Write a text file into the record."""
|
|
64
|
+
target = self.path(name)
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target.write_text(text)
|
|
67
|
+
return target
|
|
68
|
+
|
|
69
|
+
def append_jsonl(self, name: str, row: dict[str, Any]) -> None:
|
|
70
|
+
"""Append one row to a JSONL stream, flushed at once."""
|
|
71
|
+
with self.path(name).open("a") as handle:
|
|
72
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
73
|
+
handle.flush()
|
|
74
|
+
|
|
75
|
+
def copy_in(self, source: Path, name: str | None = None) -> Path | None:
|
|
76
|
+
"""Copy a file or directory into the record; ``None`` if it is missing."""
|
|
77
|
+
if not source.exists():
|
|
78
|
+
return None
|
|
79
|
+
target = self.path(name or source.name)
|
|
80
|
+
if source.is_dir():
|
|
81
|
+
shutil.copytree(source, target, dirs_exist_ok=True)
|
|
82
|
+
else:
|
|
83
|
+
shutil.copy2(source, target)
|
|
84
|
+
return target
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sample the board while a run is going, so frontier width has a time series.
|
|
3
|
+
|
|
4
|
+
Marcus reports its own ready-frontier width (``tasks_claimable``) only on a
|
|
5
|
+
``request_next_task`` reply that is not an assignment (#804), so on a healthy
|
|
6
|
+
wide board the agents' logs say little about the frontier. This sampler reads
|
|
7
|
+
the run's SQLite board read-only every few seconds and applies the rule the
|
|
8
|
+
drain verdict applies:
|
|
9
|
+
|
|
10
|
+
* DONE is done;
|
|
11
|
+
* BLOCKED or DECLINED is blocked, and nobody is coming back to it;
|
|
12
|
+
* IN_PROGRESS, or TODO with a name on it, is in flight;
|
|
13
|
+
* a TODO whose dependencies are not all done is unclaimable;
|
|
14
|
+
* everything else is claimable, and that count is the frontier width.
|
|
15
|
+
|
|
16
|
+
Lifted from the Minecraft plumbing's ``sample_board.py``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
#: Statuses nobody comes back to; the drain verdict counts both as blocked
|
|
29
|
+
#: (ADR-0013 K5).
|
|
30
|
+
TERMINAL = frozenset({"blocked", "declined"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_board(where: Path) -> Path | None:
|
|
34
|
+
"""
|
|
35
|
+
Resolve the board file from a path that may be the file or its directory.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
where : Path
|
|
40
|
+
The ``kanban.db`` file, or a data directory to search.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
Optional[Path]
|
|
45
|
+
The board file, or ``None`` when there is not one yet.
|
|
46
|
+
"""
|
|
47
|
+
if where.is_file():
|
|
48
|
+
return where
|
|
49
|
+
if not where.is_dir():
|
|
50
|
+
return None
|
|
51
|
+
found = [p for p in where.rglob("kanban.db") if "backups" not in p.parts]
|
|
52
|
+
return min(found, key=lambda p: len(p.parts)) if found else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sample(db: Path, project_id: str | None = None) -> dict[str, Any]:
|
|
56
|
+
"""
|
|
57
|
+
Take one sample of the board.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
db : Path
|
|
62
|
+
The run's SQLite board file.
|
|
63
|
+
project_id : Optional[str]
|
|
64
|
+
Count only this project's tasks; a data directory persists across
|
|
65
|
+
runs and holds every past run's tasks too.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
Dict[str, Any]
|
|
70
|
+
Counts, the claimable task names, the blocked names and who holds what.
|
|
71
|
+
"""
|
|
72
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=5)
|
|
73
|
+
try:
|
|
74
|
+
query = (
|
|
75
|
+
"select id, name, status, assigned_to from tasks"
|
|
76
|
+
" where name not like 'About:%'"
|
|
77
|
+
)
|
|
78
|
+
params: tuple[str, ...] = ()
|
|
79
|
+
if project_id:
|
|
80
|
+
query += " and project_id = ?"
|
|
81
|
+
params = (project_id,)
|
|
82
|
+
rows = con.execute(query, params).fetchall()
|
|
83
|
+
deps = con.execute(
|
|
84
|
+
"select task_id, depends_on_id from task_dependencies"
|
|
85
|
+
).fetchall()
|
|
86
|
+
finally:
|
|
87
|
+
con.close()
|
|
88
|
+
|
|
89
|
+
status = {row[0]: row[2] for row in rows}
|
|
90
|
+
dependencies: dict[str, list[str]] = {}
|
|
91
|
+
for task_id, depends_on in deps:
|
|
92
|
+
dependencies.setdefault(task_id, []).append(depends_on)
|
|
93
|
+
|
|
94
|
+
done: list[str] = []
|
|
95
|
+
in_flight: list[str] = []
|
|
96
|
+
blocked: list[str] = []
|
|
97
|
+
unclaimable: list[str] = []
|
|
98
|
+
claimable: list[str] = []
|
|
99
|
+
holders: dict[str, str] = {}
|
|
100
|
+
for task_id, name, state, assignee in rows:
|
|
101
|
+
if state == "done":
|
|
102
|
+
done.append(name)
|
|
103
|
+
elif state in TERMINAL:
|
|
104
|
+
blocked.append(name)
|
|
105
|
+
elif state == "in_progress" or (state == "todo" and assignee):
|
|
106
|
+
in_flight.append(name)
|
|
107
|
+
if assignee:
|
|
108
|
+
holders[assignee] = name
|
|
109
|
+
elif any(status.get(dep) != "done" for dep in dependencies.get(task_id, [])):
|
|
110
|
+
unclaimable.append(name)
|
|
111
|
+
else:
|
|
112
|
+
claimable.append(name)
|
|
113
|
+
return {
|
|
114
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
115
|
+
"tasks_total": len(rows),
|
|
116
|
+
"tasks_done": len(done),
|
|
117
|
+
"tasks_in_flight": len(in_flight),
|
|
118
|
+
"tasks_blocked": len(blocked),
|
|
119
|
+
"tasks_unclaimable": len(unclaimable),
|
|
120
|
+
"tasks_claimable": len(claimable),
|
|
121
|
+
"claimable": sorted(claimable),
|
|
122
|
+
"blocked": sorted(blocked),
|
|
123
|
+
"holders": holders,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
"""Sample until the deadline. Returns an exit code."""
|
|
129
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--db", required=True, help="the board file or the data directory holding it"
|
|
132
|
+
)
|
|
133
|
+
parser.add_argument("--project", default=None)
|
|
134
|
+
parser.add_argument("--out", required=True)
|
|
135
|
+
parser.add_argument("--every", type=float, default=15.0)
|
|
136
|
+
parser.add_argument("--minutes", type=float, default=30.0)
|
|
137
|
+
args = parser.parse_args(argv)
|
|
138
|
+
|
|
139
|
+
where = Path(args.db)
|
|
140
|
+
out = Path(args.out)
|
|
141
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
deadline = time.time() + args.minutes * 60
|
|
143
|
+
db = find_board(where)
|
|
144
|
+
with out.open("a") as handle:
|
|
145
|
+
while time.time() < deadline:
|
|
146
|
+
if db is None:
|
|
147
|
+
db = find_board(where)
|
|
148
|
+
if db is None:
|
|
149
|
+
time.sleep(args.every)
|
|
150
|
+
continue
|
|
151
|
+
try:
|
|
152
|
+
handle.write(json.dumps(sample(db, args.project)) + "\n")
|
|
153
|
+
except sqlite3.Error as error:
|
|
154
|
+
handle.write(
|
|
155
|
+
json.dumps(
|
|
156
|
+
{
|
|
157
|
+
"at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
158
|
+
"error": str(error),
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
+ "\n"
|
|
162
|
+
)
|
|
163
|
+
handle.flush()
|
|
164
|
+
time.sleep(args.every)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
raise SystemExit(main())
|
marcus_toolkit/seed.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Seed a project with exactly the tasks an extension names, through MCP.
|
|
3
|
+
|
|
4
|
+
``create_project(options.tasks)`` with ``options.path = "direct"`` puts the
|
|
5
|
+
listed tasks on the board and runs no model decomposition. The two traps the
|
|
6
|
+
Minecraft runs found are enforced here: the criteria key is
|
|
7
|
+
``completion_criteria``, ``depends_on`` names tasks by name, and an extra key
|
|
8
|
+
on a spec breaks the call (six of seven promotions in Minecraft run 6).
|
|
9
|
+
|
|
10
|
+
No label from the ``build`` family goes on any task: those labels switch on
|
|
11
|
+
the software validation gate, which rejects a task with no source files
|
|
12
|
+
(ADR-0013 K3, issue #798).
|
|
13
|
+
|
|
14
|
+
Every seeded project also carries one extra done card, ``About: <name>``,
|
|
15
|
+
which Marcus creates itself; counts read back from the board are seeded + 1.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .client import MarcusMCPClient
|
|
27
|
+
|
|
28
|
+
#: The keys a task spec may carry on ``create_project`` and ``create_tasks``.
|
|
29
|
+
TASK_KEYS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"name",
|
|
32
|
+
"description",
|
|
33
|
+
"labels",
|
|
34
|
+
"depends_on",
|
|
35
|
+
"completion_criteria",
|
|
36
|
+
"estimated_hours",
|
|
37
|
+
"priority",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
#: Labels that switch on the software validation gate (K3); never on a board-only task.
|
|
41
|
+
FORBIDDEN_LABELS = frozenset({"build", "create", "implement", "develop", "feature"})
|
|
42
|
+
PRIORITIES = frozenset({"high", "medium", "low"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class SeedResult:
|
|
47
|
+
"""What seeding produced."""
|
|
48
|
+
|
|
49
|
+
project_id: str
|
|
50
|
+
task_names: list[str]
|
|
51
|
+
total_tasks: int | None
|
|
52
|
+
reply: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_specs(specs: Sequence[dict[str, Any]]) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Refuse a task list the board would mis-handle.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
specs : Sequence[Dict[str, Any]]
|
|
62
|
+
Task specs.
|
|
63
|
+
|
|
64
|
+
Raises
|
|
65
|
+
------
|
|
66
|
+
ValueError
|
|
67
|
+
On an unknown key, a forbidden label, a bad priority, a duplicate
|
|
68
|
+
name, or a ``depends_on`` that names no task in the list.
|
|
69
|
+
"""
|
|
70
|
+
names = [str(s.get("name") or "") for s in specs]
|
|
71
|
+
if len(set(names)) != len(names) or any(not n for n in names):
|
|
72
|
+
raise ValueError("every task needs a distinct, non-empty name")
|
|
73
|
+
for spec in specs:
|
|
74
|
+
unknown = set(spec) - TASK_KEYS
|
|
75
|
+
if unknown:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"task {spec['name']!r}: unknown keys {sorted(unknown)};"
|
|
78
|
+
f" allowed {sorted(TASK_KEYS)}"
|
|
79
|
+
)
|
|
80
|
+
bad = {
|
|
81
|
+
str(label).lower() for label in spec.get("labels") or []
|
|
82
|
+
} & FORBIDDEN_LABELS
|
|
83
|
+
if bad:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"task {spec['name']!r}: label(s) {sorted(bad)} switch on the"
|
|
86
|
+
" software validation gate (K3)"
|
|
87
|
+
)
|
|
88
|
+
if "priority" in spec and spec["priority"] not in PRIORITIES:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"task {spec['name']!r}: priority must be one of {sorted(PRIORITIES)}"
|
|
91
|
+
)
|
|
92
|
+
for dep in spec.get("depends_on") or []:
|
|
93
|
+
if dep not in names:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"task {spec['name']!r}: depends_on {dep!r} names no task"
|
|
96
|
+
" in this list"
|
|
97
|
+
)
|
|
98
|
+
criteria = spec.get("completion_criteria")
|
|
99
|
+
if criteria is not None and not isinstance(criteria, list):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"task {spec['name']!r}: completion_criteria must be a list"
|
|
102
|
+
" of sentences"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def seed_project(
|
|
107
|
+
client: MarcusMCPClient,
|
|
108
|
+
*,
|
|
109
|
+
name: str,
|
|
110
|
+
description: str,
|
|
111
|
+
tasks: Sequence[dict[str, Any]],
|
|
112
|
+
project_root: Path,
|
|
113
|
+
objective: str | None = None,
|
|
114
|
+
wait_seconds: float = 120.0,
|
|
115
|
+
sleep_seconds: float = 2.0,
|
|
116
|
+
) -> SeedResult:
|
|
117
|
+
"""
|
|
118
|
+
Create a project holding exactly ``tasks``, and read back that it did.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
client : MarcusMCPClient
|
|
123
|
+
A connected client.
|
|
124
|
+
name : str
|
|
125
|
+
The project name. Never contains "test".
|
|
126
|
+
description : str
|
|
127
|
+
The project description.
|
|
128
|
+
tasks : Sequence[Dict[str, Any]]
|
|
129
|
+
Task specs, validated by :func:`validate_specs`.
|
|
130
|
+
project_root : Path
|
|
131
|
+
Where artifacts are written; the kernel refuses a project without one
|
|
132
|
+
(``project_root_required``). Created if missing, resolved to an absolute
|
|
133
|
+
path, and deliberately not a repository.
|
|
134
|
+
objective : Optional[str]
|
|
135
|
+
A human-written objective every agent reads once at registration.
|
|
136
|
+
wait_seconds : float
|
|
137
|
+
How long to wait for the board to show the tasks.
|
|
138
|
+
sleep_seconds : float
|
|
139
|
+
The poll interval while waiting.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
SeedResult
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
On a bad task list or a project name containing "test".
|
|
149
|
+
RuntimeError
|
|
150
|
+
When the server returns no project id.
|
|
151
|
+
"""
|
|
152
|
+
if "test" in name.lower():
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"a project name containing 'test' changes Marcus's behaviour;"
|
|
155
|
+
" pick another name"
|
|
156
|
+
)
|
|
157
|
+
validate_specs(tasks)
|
|
158
|
+
root = Path(project_root).resolve()
|
|
159
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
options: dict[str, Any] = {
|
|
161
|
+
"provider": "sqlite",
|
|
162
|
+
"complexity": "prototype",
|
|
163
|
+
"path": "direct",
|
|
164
|
+
"project_root": str(root),
|
|
165
|
+
"tasks": list(tasks),
|
|
166
|
+
}
|
|
167
|
+
if objective:
|
|
168
|
+
options["objective"] = objective
|
|
169
|
+
reply = client.call(
|
|
170
|
+
"create_project",
|
|
171
|
+
{"description": description, "project_name": name, "options": options},
|
|
172
|
+
)
|
|
173
|
+
project_id = reply.get("project_id")
|
|
174
|
+
if not project_id:
|
|
175
|
+
raise RuntimeError(f"create_project returned no project id: {reply}")
|
|
176
|
+
total: int | None = None
|
|
177
|
+
deadline = time.time() + wait_seconds
|
|
178
|
+
while time.time() < deadline:
|
|
179
|
+
status = (client.call_tool("get_project_status") or {}).get("project") or {}
|
|
180
|
+
if (status.get("total_tasks") or 0) >= len(tasks):
|
|
181
|
+
total = int(status["total_tasks"])
|
|
182
|
+
break
|
|
183
|
+
time.sleep(sleep_seconds)
|
|
184
|
+
return SeedResult(
|
|
185
|
+
project_id=str(project_id),
|
|
186
|
+
task_names=[str(t["name"]) for t in tasks],
|
|
187
|
+
total_tasks=total,
|
|
188
|
+
reply=reply,
|
|
189
|
+
)
|