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.
@@ -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)
@@ -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
+ )