lab-kit-cli 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.
- lab_kit/__init__.py +3 -0
- lab_kit/__main__.py +3 -0
- lab_kit/_data/agents/reporter.md +30 -0
- lab_kit/_data/agents/reviewer.md +36 -0
- lab_kit/_data/agents/runner.md +41 -0
- lab_kit/_data/agents/scout.md +28 -0
- lab_kit/_data/method/DISCIPLINE.md +110 -0
- lab_kit/_data/method/LADDER.md +72 -0
- lab_kit/_data/skills/experiment/SKILL.md +80 -0
- lab_kit/_data/skills/plan-mission/SKILL.md +78 -0
- lab_kit/_data/skills/review/SKILL.md +75 -0
- lab_kit/_data/skills/run-mission/SKILL.md +83 -0
- lab_kit/_data/skills/set-up-lab/SKILL.md +81 -0
- lab_kit/checks/__init__.py +1 -0
- lab_kit/checks/base.py +41 -0
- lab_kit/checks/files.py +312 -0
- lab_kit/checks/locks.py +134 -0
- lab_kit/checks/results.py +172 -0
- lab_kit/checks/run.py +63 -0
- lab_kit/cli.py +174 -0
- lab_kit/commands/__init__.py +1 -0
- lab_kit/commands/experiments.py +89 -0
- lab_kit/commands/runs.py +105 -0
- lab_kit/commands/setup.py +126 -0
- lab_kit/commands/status.py +26 -0
- lab_kit/data.py +49 -0
- lab_kit/errors.py +2 -0
- lab_kit/frozen.py +48 -0
- lab_kit/gitlog.py +55 -0
- lab_kit/lab.py +135 -0
- lab_kit/ops.py +94 -0
- lab_kit/protocol.py +100 -0
- lab_kit/records.py +245 -0
- lab_kit/rederive.py +54 -0
- lab_kit/supervise.py +80 -0
- lab_kit_cli-0.1.0.dist-info/METADATA +92 -0
- lab_kit_cli-0.1.0.dist-info/RECORD +40 -0
- lab_kit_cli-0.1.0.dist-info/WHEEL +4 -0
- lab_kit_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lab_kit_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""`lab-kit init`: lab.yaml, ops/, experiments/, .lab/, the skills and the agent roles, and the lab pack on.
|
|
2
|
+
|
|
3
|
+
Before a folio library exists, `lab-kit init` installs only the method, the skills and the roles,
|
|
4
|
+
so the set-up-lab skill is there to read: it creates the library and runs `lab-kit init --library` again.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from folio import library as folio_library
|
|
15
|
+
from folio.commands.charter_cmds import pack_on
|
|
16
|
+
from folio.errors import FolioError
|
|
17
|
+
|
|
18
|
+
from .. import __version__, data
|
|
19
|
+
from ..errors import LabError
|
|
20
|
+
from ..frozen import FROZEN
|
|
21
|
+
from ..lab import LAB_YAML
|
|
22
|
+
from ..ops import MISSIONS, STATE
|
|
23
|
+
|
|
24
|
+
_LAB_YAML = """\
|
|
25
|
+
lab-kit: {version} # the version this lab is checked with
|
|
26
|
+
library: {library} # where folio.yaml is; "." for the root
|
|
27
|
+
front: # the front door's id: a project document
|
|
28
|
+
frozen: [] # surfaces never edited in place; record each with `lab-kit freeze`
|
|
29
|
+
tools: [] # lab-wide scripts; each must pass --selftest
|
|
30
|
+
rederive_timeout: 120 # seconds per re-derive command
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_STATE = """\
|
|
34
|
+
# State
|
|
35
|
+
|
|
36
|
+
Active mission: none
|
|
37
|
+
|
|
38
|
+
Next: the operator's first objective, planned through the plan-mission skill.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _link(root: Path, link: str, wanted: str) -> list[str]:
|
|
43
|
+
path = root / link
|
|
44
|
+
if path.is_symlink():
|
|
45
|
+
if os.readlink(path) != wanted:
|
|
46
|
+
raise LabError(f"{link} links to {os.readlink(path)}, not {wanted}; move it aside first")
|
|
47
|
+
return []
|
|
48
|
+
if path.exists():
|
|
49
|
+
raise LabError(f"{link} exists and is not a link to {wanted}; move it aside first")
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
path.symlink_to(wanted)
|
|
52
|
+
return [f"linked {link} -> {wanted}"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def install(root: Path) -> list[str]:
|
|
56
|
+
"""Copy the method, the skills and the roles into the lab, as this version ships them."""
|
|
57
|
+
changed: list[str] = []
|
|
58
|
+
for src, dest in data.shipped_files():
|
|
59
|
+
target = root / dest
|
|
60
|
+
if target.is_file() and target.read_bytes() == src.read_bytes():
|
|
61
|
+
continue
|
|
62
|
+
existed = target.exists()
|
|
63
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
shutil.copyfile(src, target)
|
|
65
|
+
changed.append(f"{'updated' if existed else 'created'} {dest}")
|
|
66
|
+
changed.extend(_link(root, ".claude/skills", os.path.join("..", ".agents", "skills")))
|
|
67
|
+
changed.extend(_link(root, ".claude/agents", os.path.join("..", ".agents", "agents")))
|
|
68
|
+
return changed
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_new(root: Path, rel: str, text: str) -> list[str]:
|
|
72
|
+
path = root / rel
|
|
73
|
+
if path.exists():
|
|
74
|
+
return []
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(text, encoding="utf-8")
|
|
77
|
+
return [f"created {rel}"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
SKILLS_ONLY = ("No folio library here yet: installed lab-kit's method, skills and roles only. "
|
|
81
|
+
"Next: read .agents/skills/set-up-lab/SKILL.md and follow it.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init(root: Path, library: str | None) -> list[str]:
|
|
85
|
+
root = root.resolve()
|
|
86
|
+
existing = root / LAB_YAML
|
|
87
|
+
changed: list[str] = []
|
|
88
|
+
if library is None and not existing.is_file() and not (root / "folio.yaml").is_file():
|
|
89
|
+
return install(root) + [SKILLS_ONLY]
|
|
90
|
+
library = library or "."
|
|
91
|
+
if existing.is_file():
|
|
92
|
+
text = existing.read_text(encoding="utf-8")
|
|
93
|
+
updated = re.sub(r"(?m)^lab-kit:\s*\S+", f"lab-kit: {__version__}", text, count=1)
|
|
94
|
+
if updated != text:
|
|
95
|
+
existing.write_text(updated, encoding="utf-8")
|
|
96
|
+
changed.append(f"updated {LAB_YAML}: lab-kit {__version__}")
|
|
97
|
+
library = _library_of(updated)
|
|
98
|
+
lib_dir = (root / library).resolve()
|
|
99
|
+
if not (lib_dir / "folio.yaml").is_file():
|
|
100
|
+
raise LabError(f"`{library}` holds no folio.yaml; set the library up with folio first")
|
|
101
|
+
try:
|
|
102
|
+
lib = folio_library.load_at(lib_dir)
|
|
103
|
+
except FolioError as exc:
|
|
104
|
+
raise LabError(f"the library in `{library}` does not load: {exc}") from exc
|
|
105
|
+
if lib.charter.root_dir != root:
|
|
106
|
+
depth = len(lib_dir.relative_to(root).parts)
|
|
107
|
+
raise LabError(f"{library}/folio.yaml: `root` resolves to {lib.charter.root_dir}, not the lab root; "
|
|
108
|
+
f"run `folio config set root {'/'.join(['..'] * depth) or '.'}` in the library first")
|
|
109
|
+
if not existing.is_file():
|
|
110
|
+
changed.extend(_write_new(root, LAB_YAML, _LAB_YAML.format(version=__version__, library=library)))
|
|
111
|
+
changed.extend(_write_new(root, STATE, _STATE))
|
|
112
|
+
changed.extend(_write_new(root, f"{MISSIONS}/.gitkeep", ""))
|
|
113
|
+
changed.extend(_write_new(root, "experiments/.gitkeep", ""))
|
|
114
|
+
changed.extend(_write_new(root, FROZEN, ""))
|
|
115
|
+
changed.extend(install(root))
|
|
116
|
+
if "lab" not in lib.charter.pack_names:
|
|
117
|
+
lines = pack_on(lib, "lab").splitlines()
|
|
118
|
+
changed.extend(f"{line} (in {library})" if library not in (".", "") else line for line in lines)
|
|
119
|
+
return changed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _library_of(text: str) -> str:
|
|
123
|
+
match = re.search(r"(?m)^library:\s*(\S+)", text)
|
|
124
|
+
if match is None:
|
|
125
|
+
raise LabError(f"{LAB_YAML} names no `library`")
|
|
126
|
+
return match.group(1)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""`lab-kit status`: the state, the active mission, live and orphaned runs, and protocols by status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .. import ops, records
|
|
6
|
+
from ..lab import Lab
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def status(lab: Lab) -> list[str]:
|
|
10
|
+
lines: list[str] = []
|
|
11
|
+
state = lab.root / ops.STATE
|
|
12
|
+
if state.is_file():
|
|
13
|
+
text = state.read_text(encoding="utf-8")
|
|
14
|
+
lines.append(f"Active mission: {ops.active_mission(text) or 'not named in ' + ops.STATE}")
|
|
15
|
+
lines.append(f"State ({ops.STATE}):")
|
|
16
|
+
lines.extend(" " + line for line in text.strip().splitlines())
|
|
17
|
+
else:
|
|
18
|
+
lines.append(f"No {ops.STATE}.")
|
|
19
|
+
live = [r for r in records.runs(lab) if r.state in ("running", "orphaned")]
|
|
20
|
+
lines.append("Live and orphaned runs:" if live else "Live and orphaned runs: none")
|
|
21
|
+
lines.extend(f" {r.slug}/{r.run_id} {r.state} started {r.data.get('started')}" for r in live)
|
|
22
|
+
protocols = [d for d in lab.library().documents if d.is_a("protocol")]
|
|
23
|
+
for wanted in ("draft", "locked"):
|
|
24
|
+
found = sorted(d.id for d in protocols if d.status == wanted)
|
|
25
|
+
lines.append(f"Protocols {wanted}: {', '.join(found) if found else 'none'}")
|
|
26
|
+
return lines
|
lab_kit/data.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Where lab-kit's shipped method, skills and agent roles live."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import LabError
|
|
8
|
+
|
|
9
|
+
SKILLS = ("set-up-lab", "plan-mission", "run-mission", "experiment", "review")
|
|
10
|
+
AGENTS = ("scout", "runner", "reviewer", "reporter")
|
|
11
|
+
METHOD = ("DISCIPLINE.md", "LADDER.md")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def data_root() -> Path:
|
|
15
|
+
"""The folder holding `method/`, `skills/` and `agents/`.
|
|
16
|
+
|
|
17
|
+
An installed wheel carries them in `lab_kit/_data/`; a source checkout has
|
|
18
|
+
them at the repository root, two levels above this package.
|
|
19
|
+
"""
|
|
20
|
+
packaged = Path(__file__).resolve().parent / "_data"
|
|
21
|
+
if (packaged / "method").is_dir():
|
|
22
|
+
return packaged
|
|
23
|
+
checkout = Path(__file__).resolve().parents[2]
|
|
24
|
+
if (checkout / "method").is_dir():
|
|
25
|
+
return checkout
|
|
26
|
+
raise LabError(f"cannot find lab-kit's shipped method: looked in {packaged} and {checkout}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def shipped(name: str) -> Path:
|
|
30
|
+
if name not in ("method", "skills", "agents"):
|
|
31
|
+
raise ValueError(name)
|
|
32
|
+
return data_root() / name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shipped_files() -> list[tuple[Path, str]]:
|
|
36
|
+
"""Every shipped file a lab carries, as (source, path in the lab from its root)."""
|
|
37
|
+
out: list[tuple[Path, str]] = []
|
|
38
|
+
for name in METHOD:
|
|
39
|
+
out.append((shipped("method") / name, f".lab/method/{name}"))
|
|
40
|
+
for skill in SKILLS:
|
|
41
|
+
folder = shipped("skills") / skill
|
|
42
|
+
for src in sorted(p for p in folder.rglob("*") if p.is_file()):
|
|
43
|
+
out.append((src, f".agents/skills/{skill}/{src.relative_to(folder).as_posix()}"))
|
|
44
|
+
for agent in AGENTS:
|
|
45
|
+
out.append((shipped("agents") / f"{agent}.md", f".agents/agents/{agent}.md"))
|
|
46
|
+
for src, _ in out:
|
|
47
|
+
if not src.is_file():
|
|
48
|
+
raise LabError(f"lab-kit's shipped file {src} is missing")
|
|
49
|
+
return out
|
lab_kit/errors.py
ADDED
lab_kit/frozen.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Frozen surfaces: the hashes `lab-kit freeze` records in `.lab/frozen.sha256`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import records
|
|
8
|
+
from .errors import LabError
|
|
9
|
+
from .lab import Lab
|
|
10
|
+
|
|
11
|
+
FROZEN = ".lab/frozen.sha256"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def surface_hashes(lab: Lab, surface: str) -> dict[str, str]:
|
|
15
|
+
"""Every file of a surface (a file, or a folder and all it holds), by its path from the lab root."""
|
|
16
|
+
target = lab.root / surface
|
|
17
|
+
if not target.exists():
|
|
18
|
+
raise LabError(f"frozen surface `{surface}` does not exist")
|
|
19
|
+
return records.tree_hashes(target, lab.root)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def belongs(path: str, surface: str) -> bool:
|
|
23
|
+
surface = surface.rstrip("/")
|
|
24
|
+
return path == surface or path.startswith(surface + "/")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def recorded(lab: Lab) -> dict[str, str]:
|
|
28
|
+
path = lab.root / FROZEN
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
return {}
|
|
31
|
+
return records.parse_hashes(path.read_text(encoding="utf-8"), FROZEN)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def freeze(lab: Lab, surface: str) -> list[str]:
|
|
35
|
+
"""Record a surface's hashes, replacing what was recorded for it before."""
|
|
36
|
+
surface = Path(surface).as_posix().rstrip("/")
|
|
37
|
+
listed = [s.rstrip("/") for s in lab.settings.frozen]
|
|
38
|
+
if surface not in listed:
|
|
39
|
+
raise LabError(f"`{surface}` is not listed under `frozen:` in lab.yaml; list it there first")
|
|
40
|
+
hashes = surface_hashes(lab, surface)
|
|
41
|
+
if not hashes:
|
|
42
|
+
raise LabError(f"frozen surface `{surface}` holds no file")
|
|
43
|
+
kept = {p: d for p, d in recorded(lab).items() if not belongs(p, surface)}
|
|
44
|
+
kept.update(hashes)
|
|
45
|
+
out = lab.root / FROZEN
|
|
46
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
out.write_text(records.format_hashes(kept), encoding="utf-8")
|
|
48
|
+
return [f"updated {FROZEN}: recorded {len(hashes)} file(s) of {surface}"]
|
lab_kit/gitlog.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Read the history of a lab file from git: each committed version, oldest first, then the working tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import LabError
|
|
10
|
+
|
|
11
|
+
WORKING = "working tree"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _git(root: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
result = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=False)
|
|
16
|
+
if check and result.returncode != 0:
|
|
17
|
+
raise LabError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def in_git(root: Path) -> bool:
|
|
22
|
+
if shutil.which("git") is None:
|
|
23
|
+
return False
|
|
24
|
+
return _git(root, "rev-parse", "--is-inside-work-tree", check=False).stdout.strip() == "true"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _has_head(root: Path) -> bool:
|
|
28
|
+
return _git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).returncode == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tracked(root: Path, folder: str) -> list[str]:
|
|
32
|
+
"""Every path under `folder` that any commit touched, from `root`, including deleted ones."""
|
|
33
|
+
if not _has_head(root):
|
|
34
|
+
return []
|
|
35
|
+
out = _git(root, "log", "--format=", "--name-only", "--relative", "--", folder).stdout
|
|
36
|
+
return sorted({line.strip() for line in out.splitlines() if line.strip()})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def versions(root: Path, path: str) -> list[tuple[str, str | None]]:
|
|
40
|
+
"""(commit, text) for every commit that touched `path`, oldest first, then the working tree if it differs.
|
|
41
|
+
|
|
42
|
+
The text is None where the file did not exist.
|
|
43
|
+
"""
|
|
44
|
+
out: list[tuple[str, str | None]] = []
|
|
45
|
+
if _has_head(root):
|
|
46
|
+
shas = _git(root, "log", "--format=%H", "--", path).stdout.split()
|
|
47
|
+
for sha in reversed(shas):
|
|
48
|
+
shown = _git(root, "show", f"{sha}:./{path}", check=False)
|
|
49
|
+
out.append((sha, shown.stdout if shown.returncode == 0 else None))
|
|
50
|
+
target = root / path
|
|
51
|
+
now = target.read_text(encoding="utf-8") if target.is_file() else None
|
|
52
|
+
if not out or out[-1][1] != now:
|
|
53
|
+
if out or now is not None:
|
|
54
|
+
out.append((WORKING, now))
|
|
55
|
+
return out
|
lab_kit/lab.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""A lab: its root, its settings in `lab.yaml`, and the folio library beside them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from folio import library as folio_library
|
|
11
|
+
from folio.errors import FolioError
|
|
12
|
+
from folio.library import Library
|
|
13
|
+
|
|
14
|
+
from .errors import LabError
|
|
15
|
+
|
|
16
|
+
LAB_YAML = "lab.yaml"
|
|
17
|
+
_KEYS = ("lab-kit", "library", "front", "frozen", "tools", "rederive_timeout")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Settings:
|
|
22
|
+
version: str
|
|
23
|
+
library: str
|
|
24
|
+
front: str | None
|
|
25
|
+
frozen: list[str]
|
|
26
|
+
tools: list[str]
|
|
27
|
+
rederive_timeout: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Lab:
|
|
32
|
+
root: Path
|
|
33
|
+
settings: Settings
|
|
34
|
+
cache: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def library_dir(self) -> Path:
|
|
38
|
+
return (self.root / self.settings.library).resolve()
|
|
39
|
+
|
|
40
|
+
def library(self) -> Library:
|
|
41
|
+
"""The folio library, loaded once. Its charter's `root` must be the lab root."""
|
|
42
|
+
if "library" not in self.cache:
|
|
43
|
+
try:
|
|
44
|
+
lib = folio_library.load_at(self.library_dir)
|
|
45
|
+
except FolioError as exc:
|
|
46
|
+
raise LabError(f"the library in `{self.settings.library}` does not load: {exc}") from exc
|
|
47
|
+
if lib.charter.root_dir != self.root:
|
|
48
|
+
raise LabError(
|
|
49
|
+
f"{self.settings.library}/folio.yaml: `root` resolves to {lib.charter.root_dir}, not the lab "
|
|
50
|
+
f"root {self.root}; set it with `folio config set root {_relative_root(self)}` in the library")
|
|
51
|
+
self.cache["library"] = lib
|
|
52
|
+
return self.cache["library"]
|
|
53
|
+
|
|
54
|
+
def reload(self) -> Library:
|
|
55
|
+
self.cache.pop("library", None)
|
|
56
|
+
return self.library()
|
|
57
|
+
|
|
58
|
+
def rel(self, path: Path) -> str:
|
|
59
|
+
"""A path from the lab root, with forward slashes."""
|
|
60
|
+
return path.resolve().relative_to(self.root).as_posix()
|
|
61
|
+
|
|
62
|
+
def doc_path(self, doc_file_path: str) -> str:
|
|
63
|
+
"""A library file's path from the lab root."""
|
|
64
|
+
if self.settings.library in (".", ""):
|
|
65
|
+
return doc_file_path
|
|
66
|
+
return f"{Path(self.settings.library).as_posix().rstrip('/')}/{doc_file_path}"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def experiments(self) -> Path:
|
|
70
|
+
return self.root / "experiments"
|
|
71
|
+
|
|
72
|
+
def experiment_slugs(self) -> list[str]:
|
|
73
|
+
if not self.experiments.is_dir():
|
|
74
|
+
return []
|
|
75
|
+
return sorted(p.name for p in self.experiments.iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _relative_root(lab: Lab) -> str:
|
|
79
|
+
depth = len(Path(lab.settings.library).parts)
|
|
80
|
+
return "/".join([".."] * depth) or "."
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_root(start: Path) -> Path:
|
|
84
|
+
here = start.resolve()
|
|
85
|
+
for folder in (here, *here.parents):
|
|
86
|
+
if (folder / LAB_YAML).is_file():
|
|
87
|
+
return folder
|
|
88
|
+
raise LabError(f"no {LAB_YAML} at or above {here}; run `lab-kit init` in the lab's root first")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_settings(raw: Any, where: str) -> Settings:
|
|
92
|
+
if not isinstance(raw, dict):
|
|
93
|
+
raise LabError(f"{where}: must be a mapping")
|
|
94
|
+
unknown = sorted(set(raw) - set(_KEYS))
|
|
95
|
+
if unknown:
|
|
96
|
+
raise LabError(f"{where}: unknown key(s) {', '.join(unknown)}; the keys are {', '.join(_KEYS)}")
|
|
97
|
+
version = raw.get("lab-kit")
|
|
98
|
+
if not isinstance(version, str) or not version:
|
|
99
|
+
raise LabError(f"{where}: `lab-kit` must name the version this lab is checked with, as a string")
|
|
100
|
+
library = raw.get("library")
|
|
101
|
+
if not isinstance(library, str) or not library:
|
|
102
|
+
raise LabError(f"{where}: `library` must name the folder holding folio.yaml (\".\" for the root)")
|
|
103
|
+
front = raw.get("front")
|
|
104
|
+
if front is not None and not isinstance(front, str):
|
|
105
|
+
raise LabError(f"{where}: `front` must be a document id")
|
|
106
|
+
lists = {}
|
|
107
|
+
for key in ("frozen", "tools"):
|
|
108
|
+
value = raw.get(key) or []
|
|
109
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
|
|
110
|
+
raise LabError(f"{where}: `{key}` must be a list of paths from the lab root")
|
|
111
|
+
for v in value:
|
|
112
|
+
if Path(v).is_absolute() or ".." in Path(v).parts:
|
|
113
|
+
raise LabError(f"{where}: `{key}` path `{v}` must stay inside the lab")
|
|
114
|
+
lists[key] = value
|
|
115
|
+
timeout = raw.get("rederive_timeout", 120)
|
|
116
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
|
|
117
|
+
raise LabError(f"{where}: `rederive_timeout` must be a positive whole number of seconds")
|
|
118
|
+
return Settings(version, library, front or None, lists["frozen"], lists["tools"], timeout)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_at(root: Path) -> Lab:
|
|
122
|
+
root = root.resolve()
|
|
123
|
+
path = root / LAB_YAML
|
|
124
|
+
try:
|
|
125
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
126
|
+
except yaml.YAMLError as exc:
|
|
127
|
+
raise LabError(f"{LAB_YAML}: not valid YAML: {exc}") from exc
|
|
128
|
+
lab = Lab(root, parse_settings(raw, LAB_YAML))
|
|
129
|
+
if not (lab.library_dir / "folio.yaml").is_file():
|
|
130
|
+
raise LabError(f"{LAB_YAML}: `library: {lab.settings.library}` holds no folio.yaml")
|
|
131
|
+
return lab
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load(start: Path) -> Lab:
|
|
135
|
+
return load_at(find_root(start))
|
lab_kit/ops.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The operator's files: `ops/STATE.md` and the missions under `ops/missions/`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from folio.errors import FolioError
|
|
11
|
+
from folio.frontmatter import split
|
|
12
|
+
|
|
13
|
+
from .errors import LabError
|
|
14
|
+
from .lab import Lab
|
|
15
|
+
|
|
16
|
+
STATE = "ops/STATE.md"
|
|
17
|
+
MISSIONS = "ops/missions"
|
|
18
|
+
LOG_HEADING = re.compile(r"^## Log[ \t]*$", re.M)
|
|
19
|
+
ACTIVE = re.compile(r"^\s*Active mission:\s*(.+?)\s*$", re.M | re.I)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Mission:
|
|
24
|
+
path: str # from the lab root
|
|
25
|
+
meta: dict[str, Any]
|
|
26
|
+
text: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def cap(self) -> float | None:
|
|
30
|
+
cap = self.meta.get("cap")
|
|
31
|
+
if isinstance(cap, (int, float)) and not isinstance(cap, bool):
|
|
32
|
+
return float(cap)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def approved(self) -> str:
|
|
37
|
+
return str(self.meta.get("approved") or "").strip()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def status(self) -> str:
|
|
41
|
+
return str(self.meta.get("status") or "").strip()
|
|
42
|
+
|
|
43
|
+
def approval_problem(self) -> str | None:
|
|
44
|
+
"""Why this mission may not run, or None when it is approved and active."""
|
|
45
|
+
if self.status == "draft":
|
|
46
|
+
return (f"{self.path} is a draft: nothing runs under a mission until the operator approves it "
|
|
47
|
+
"(the plan-mission skill records `approved` and sets `status: active`)")
|
|
48
|
+
if self.status != "active":
|
|
49
|
+
return f"{self.path} has status `{self.status or '(none)'}`, not `active`: a run needs an active mission"
|
|
50
|
+
if not approved_names_a_date(self.approved):
|
|
51
|
+
return f"{self.path} records no approval with who and when (`approved:`); nothing runs before it"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
STATUSES = ("draft", "active", "concluded")
|
|
56
|
+
DATE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def approved_names_a_date(approved: str) -> bool:
|
|
60
|
+
"""`approved` says who and when: some words, and a date as YYYY-MM-DD."""
|
|
61
|
+
return bool(DATE.search(approved)) and bool(DATE.sub("", approved).strip(" ,;:.-"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mission_files(lab: Lab) -> list[Path]:
|
|
65
|
+
folder = lab.root / MISSIONS
|
|
66
|
+
return sorted(folder.glob("*.md")) if folder.is_dir() else []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_mission(lab: Lab, path: Path) -> Mission:
|
|
70
|
+
where = lab.rel(path)
|
|
71
|
+
text = path.read_text(encoding="utf-8")
|
|
72
|
+
try:
|
|
73
|
+
meta, _ = split(text, where)
|
|
74
|
+
except FolioError as exc:
|
|
75
|
+
raise LabError(str(exc)) from exc
|
|
76
|
+
return Mission(where, meta or {}, text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log_of(text: str) -> str | None:
|
|
80
|
+
"""The mission log: everything after the `## Log` heading, or None without one."""
|
|
81
|
+
match = LOG_HEADING.search(text)
|
|
82
|
+
return text[match.end():] if match else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def active_mission(text: str) -> str | None:
|
|
86
|
+
"""What `Active mission:` names in the state file: a path, `none`, or None when the line is missing."""
|
|
87
|
+
match = ACTIVE.search(text)
|
|
88
|
+
if match is None:
|
|
89
|
+
return None
|
|
90
|
+
value = match.group(1).strip().strip("`*")
|
|
91
|
+
link = re.match(r"^\[[^\]]*\]\(([^)]+)\)$", value)
|
|
92
|
+
if link:
|
|
93
|
+
value = link.group(1)
|
|
94
|
+
return value.strip("`")
|
lab_kit/protocol.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""A protocol as lab-kit reads it: its file, status, hash with the status set aside, pins and ids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from folio.documents import Document
|
|
13
|
+
from folio.frontmatter import front_block, sections, split
|
|
14
|
+
from folio.errors import FolioError
|
|
15
|
+
|
|
16
|
+
from .errors import LabError
|
|
17
|
+
from .lab import Lab
|
|
18
|
+
|
|
19
|
+
_STATUS_LINE = re.compile(r"^status:[^\n]*\n?", re.M)
|
|
20
|
+
_ITEM_ID = re.compile(r"^\s*[-*]\s+([PD]\d+)\b", re.M)
|
|
21
|
+
_YAML_FENCE = re.compile(r"^(```|~~~)\s*ya?ml\s*\n(.*?)^\1\s*$", re.S | re.M)
|
|
22
|
+
|
|
23
|
+
CONFIG_HEADING = "Pinned configuration"
|
|
24
|
+
PREDICTIONS_HEADING = "Predictions"
|
|
25
|
+
RULES_HEADING = "Decision rules"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Protocol:
|
|
30
|
+
slug: str
|
|
31
|
+
doc: Document
|
|
32
|
+
path: str # from the lab root
|
|
33
|
+
abspath: Path
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def status(self) -> str:
|
|
37
|
+
return self.doc.status
|
|
38
|
+
|
|
39
|
+
def text(self) -> str:
|
|
40
|
+
return self.abspath.read_text(encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find(lab: Lab, slug: str) -> Protocol | None:
|
|
44
|
+
docs = lab.library().find(slug, "protocol")
|
|
45
|
+
if not docs:
|
|
46
|
+
return None
|
|
47
|
+
doc = docs[0]
|
|
48
|
+
main = doc.main
|
|
49
|
+
if main is None:
|
|
50
|
+
return None
|
|
51
|
+
return Protocol(doc.id, doc, lab.doc_path(main.path), main.abspath)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require(lab: Lab, slug: str) -> Protocol:
|
|
55
|
+
proto = find(lab, slug)
|
|
56
|
+
if proto is None:
|
|
57
|
+
raise LabError(f"no protocol `{slug}` in the library; draft it with folio's write skill first")
|
|
58
|
+
return proto
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hash_text(text: str) -> str:
|
|
62
|
+
"""The sha256 of a protocol's text with its front matter's `status` line set aside."""
|
|
63
|
+
span = front_block(text)
|
|
64
|
+
if span is not None:
|
|
65
|
+
start, end = span
|
|
66
|
+
text = text[:start] + _STATUS_LINE.sub("", text[start:end]) + text[end:]
|
|
67
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _section(text: str, heading: str, where: str) -> str:
|
|
71
|
+
try:
|
|
72
|
+
_, body = split(text, where)
|
|
73
|
+
except FolioError as exc:
|
|
74
|
+
raise LabError(str(exc)) from exc
|
|
75
|
+
for name, content in sections(body):
|
|
76
|
+
if name.strip() == heading:
|
|
77
|
+
return content
|
|
78
|
+
raise LabError(f"{where}: has no `## {heading}` section")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pinned_config(text: str, where: str) -> Any:
|
|
82
|
+
"""The parsed `yaml` block under `## Pinned configuration`."""
|
|
83
|
+
blocks = _YAML_FENCE.findall(_section(text, CONFIG_HEADING, where))
|
|
84
|
+
if len(blocks) != 1:
|
|
85
|
+
raise LabError(f"{where}: `## {CONFIG_HEADING}` must hold exactly one fenced yaml block")
|
|
86
|
+
try:
|
|
87
|
+
return yaml.safe_load(blocks[0][1])
|
|
88
|
+
except yaml.YAMLError as exc:
|
|
89
|
+
raise LabError(f"{where}: the pinned configuration is not valid YAML: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def scored_ids(text: str, where: str) -> list[str]:
|
|
93
|
+
"""The prediction ids (`P1`...) and decision-rule ids (`D1`...) the protocol names, in order."""
|
|
94
|
+
found: list[str] = []
|
|
95
|
+
for heading, prefix in ((PREDICTIONS_HEADING, "P"), (RULES_HEADING, "D")):
|
|
96
|
+
ids = [i for i in _ITEM_ID.findall(_section(text, heading, where)) if i.startswith(prefix)]
|
|
97
|
+
if not ids:
|
|
98
|
+
raise LabError(f"{where}: `## {heading}` names no {prefix}-id")
|
|
99
|
+
found.extend(ids)
|
|
100
|
+
return found
|