quality-loop 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,186 @@
1
+ """The operator agent: one session per room, in two phases.
2
+
3
+ Phase 1 puts the scenario's screen up; phase 2, after the room has finished
4
+ and the runner has collected and audited, reports the problems. The same
5
+ session is resumed for phase 2, so the agent that judges a run is the one
6
+ that set it up.
7
+
8
+ What the operator may do is a short list, and this module is where it is
9
+ enforced:
10
+
11
+ - `--restricted` with an explicit `--tools`: no settings file of the
12
+ operator's applies, so their broad permissions cannot widen this one.
13
+ - **No shell.** Claude Code lets read-only commands through whatever the
14
+ allow list says, and `cat` reads anywhere; the first real operator ran
15
+ `cd; ls; cat ...` that way. Its only way to act is quality-loop's own MCP
16
+ server (`quality_loop.mcp`): the declared screens, and the product's
17
+ declared evidence commands run without a shell.
18
+ - No other MCP server, the product's least of all: the screen is checked
19
+ with the OS's own window list, never through the product under test.
20
+ - Writes only into the operator's own directory and the room's REPORT.md.
21
+ - Phase 1 must not change the room. Every file in it is hashed before and
22
+ after, and any difference cancels the room: TASK.md in particular has to
23
+ reach the room exactly as the runner wrote it.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import hashlib
30
+ import json
31
+ import pathlib
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import uuid
36
+
37
+ from quality_loop import session as sessions
38
+
39
+ INSTRUCTIONS = pathlib.Path(__file__).with_name("OPERATOR.md")
40
+
41
+
42
+ class OperatorError(Exception):
43
+ """A phase that cannot be trusted, and the room it cancels."""
44
+
45
+
46
+ def absolute(path: pathlib.Path) -> str:
47
+ """A path as a Claude Code permission rule spells an absolute one:
48
+ ``//Users/me/x`` on macOS, ``//c/Users/me/x`` on Windows."""
49
+ posix = pathlib.Path(path).resolve().as_posix()
50
+ if len(posix) > 1 and posix[1] == ":":
51
+ posix = "/" + posix[0].lower() + posix[2:]
52
+ return "/" + posix
53
+
54
+
55
+ def fingerprint(room: pathlib.Path) -> dict[str, str]:
56
+ """``{relative path: sha256}`` of every file in the room."""
57
+ found = {}
58
+ for path in sorted(pathlib.Path(room).rglob("*")):
59
+ if path.is_file():
60
+ found[path.relative_to(room).as_posix()] = hashlib.sha256(
61
+ path.read_bytes()).hexdigest()
62
+ return found
63
+
64
+
65
+ @dataclasses.dataclass
66
+ class Operator:
67
+ """One room's operator.
68
+
69
+ Attributes:
70
+ room: The room directory.
71
+ directory: The operator's own, beside the room and outside it.
72
+ scenario: The scenario file.
73
+ checkout: The product's repository, readable in both phases.
74
+ evidence_help: One paragraph on what the evidence tools answer.
75
+ manifest: The manifest the runner wrote: screens and evidence tools.
76
+ python: The interpreter quality-loop's MCP server runs under.
77
+ """
78
+ room: pathlib.Path
79
+ directory: pathlib.Path
80
+ scenario: pathlib.Path
81
+ checkout: pathlib.Path
82
+ evidence_help: str
83
+ manifest: pathlib.Path
84
+ python: str = sys.executable
85
+ max_turns: int = 60
86
+ model: str | None = None
87
+ claude: str | None = None
88
+ session_id: str = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
89
+
90
+ def allow(self) -> list[str]:
91
+ """The permission list: read, quality-loop's tools, two places to write.
92
+
93
+ Writes are granted as Edit rules, which govern every built-in tool
94
+ that changes a file, Write included. Their paths are absolute, which
95
+ Claude Code spells with a leading ``//``. A single ``/`` is relative to
96
+ the settings file, and the first real run was refused every write.
97
+ """
98
+ return ["Read", "Glob", "Grep", "mcp__quality-loop",
99
+ f"Edit({absolute(self.directory)}/**)",
100
+ f"Edit({absolute(self.room / 'REPORT.md')})"]
101
+
102
+ def servers(self) -> dict:
103
+ from quality_loop.screens import MANIFEST
104
+ return {"quality-loop": {"command": self.python, "args": ["-m", "quality_loop.mcp"],
105
+ "env": {MANIFEST: str(self.manifest)}}}
106
+
107
+ def command(self, phase: int, message: str) -> list[str]:
108
+ command = [self.claude or sessions.find_claude(), "--restricted",
109
+ "--tools", "Read,Glob,Grep,Write,Edit",
110
+ "--add-dir", str(self.room), "--add-dir", str(self.checkout),
111
+ "--add-dir", str(self.manifest.parent),
112
+ "--mcp-config", json.dumps({"mcpServers": self.servers()}),
113
+ "--strict-mcp-config",
114
+ "--settings", json.dumps({"permissions": {"allow": self.allow()}}),
115
+ "--permission-mode", "dontAsk",
116
+ "--max-turns", str(self.max_turns),
117
+ "--output-format", "stream-json", "--verbose"]
118
+ command += (["--session-id", self.session_id] if phase == 1
119
+ else ["--resume", self.session_id])
120
+ if self.model:
121
+ command += ["--model", self.model]
122
+ return command + ["-p", message]
123
+
124
+ def environment(self) -> dict:
125
+ return sessions.Session(allow=[]).environment(self.directory)
126
+
127
+ @property
128
+ def instructions(self) -> pathlib.Path:
129
+ """OPERATOR.md, copied into the operator's own directory.
130
+
131
+ The package's copy is wherever quality-loop is installed, which is
132
+ outside everything the operator may read. A product's first real run
133
+ had an operator that never saw its job description, and wrote a
134
+ verdict.
135
+ """
136
+ return self.directory / "OPERATOR.md"
137
+
138
+ def phase_one_message(self, platform: str) -> str:
139
+ return (f"Phase 1. Read {self.instructions} first; it is your whole job description.\n\n"
140
+ f"Scenario: {self.scenario}\nRoom: {self.room}\n"
141
+ f"Your directory: {self.directory}\nThis OS: {platform}\n"
142
+ f"Your tools: screen_list, screen_open, screen_close, evidence.\n")
143
+
144
+ def phase_two_message(self) -> str:
145
+ return (f"Phase 2. The room has finished. Room: {self.room}\n"
146
+ f"Write {self.room / 'REPORT.md'} in the format {self.instructions} gives - "
147
+ f"problems with severities, no verdict - then close the screens you opened.\n\n"
148
+ f"The product's evidence tools are in the `evidence` tool - call each "
149
+ f"with args [\"--help\"] first.\n{self.evidence_help}\n")
150
+
151
+ def _run(self, phase: int, message: str, say) -> int:
152
+ self.directory.mkdir(parents=True, exist_ok=True)
153
+ log = self.directory / f"phase{phase}.jsonl"
154
+ with log.open("w", encoding="utf-8") as out, \
155
+ subprocess.Popen(self.command(phase, message), cwd=self.directory,
156
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
157
+ stderr=subprocess.STDOUT, text=True, encoding="utf-8",
158
+ bufsize=1, env=self.environment()) as agent:
159
+ for line in agent.stdout:
160
+ out.write(line)
161
+ try:
162
+ event = json.loads(line)
163
+ except ValueError:
164
+ continue
165
+ if isinstance(event, dict) and (text := sessions.digest(
166
+ event, self.directory, f"operator {phase}")):
167
+ say(text)
168
+ return agent.wait()
169
+
170
+ def phase_one(self, platform: str, say=print) -> None:
171
+ """Put the screen up. Raises when the room was touched."""
172
+ self.directory.mkdir(parents=True, exist_ok=True)
173
+ shutil.copy2(INSTRUCTIONS, self.instructions)
174
+ before = fingerprint(self.room)
175
+ self._run(1, self.phase_one_message(platform), say)
176
+ after = fingerprint(self.room)
177
+ if changed := sorted(set(before.items()) ^ set(after.items())):
178
+ raise OperatorError(
179
+ "phase 1 changed the room, so it is cancelled: "
180
+ + ", ".join(sorted({path for path, _hash in changed})))
181
+
182
+ def phase_two(self, say=print) -> pathlib.Path:
183
+ """Write the report. Returns REPORT.md's path, which may not exist if
184
+ the operator wrote nothing - and that is itself reported."""
185
+ self._run(2, self.phase_two_message(), say)
186
+ return self.room / "REPORT.md"
quality_loop/pages.py ADDED
@@ -0,0 +1,84 @@
1
+ """Serving fixture pages, and recording what is done on them.
2
+
3
+ A task that drives a web page is given one served over a local HTTP origin,
4
+ from a copy outside the checkout. Not ``file://``: the address bar is part of
5
+ the screen, and a path under the repository tells the room where the source it
6
+ may not read is. Safari also refuses a ``file://`` page under the temp
7
+ directory outright. An HTTP origin says nothing but a port number, which is
8
+ what the applications these tasks really drive look like.
9
+
10
+ A page reports what was done on it - an approval, a submission - by POSTing a
11
+ JSON object to ``event``. That list is the screen's own record of the run, and
12
+ it is what a report cites, rather than the room's account.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import functools
18
+ import json
19
+ import pathlib
20
+ import shutil
21
+ import tempfile
22
+ import threading
23
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
24
+
25
+
26
+ class Pages:
27
+ """Fixture pages served for the length of a ``with`` block.
28
+
29
+ Attributes:
30
+ origin: ``http://127.0.0.1:<port>``.
31
+ events: What the pages reported, in order.
32
+ """
33
+
34
+ def __init__(self, fixtures: pathlib.Path):
35
+ self.fixtures = pathlib.Path(fixtures)
36
+ self.events: list[dict] = []
37
+ self.origin = ""
38
+ self._lock = threading.Lock()
39
+ self._server = None
40
+ self._served = None
41
+
42
+ def url(self, page: str) -> str:
43
+ return f"{self.origin}/{page}"
44
+
45
+ def __enter__(self) -> "Pages":
46
+ self._served = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-pages-"))
47
+ shutil.copytree(self.fixtures, self._served, dirs_exist_ok=True)
48
+ pages = self
49
+
50
+ class Handler(SimpleHTTPRequestHandler):
51
+ def log_message(self, *args, **kwargs):
52
+ pass
53
+
54
+ def do_POST(self):
55
+ length = int(self.headers.get("Content-Length") or 0)
56
+ try:
57
+ event = json.loads(self.rfile.read(length).decode("utf-8"))
58
+ except ValueError:
59
+ event = None
60
+ if isinstance(event, dict):
61
+ with pages._lock:
62
+ pages.events.append(event)
63
+ self.send_response(204)
64
+ self.end_headers()
65
+
66
+ self._server = ThreadingHTTPServer(
67
+ ("127.0.0.1", 0), functools.partial(Handler, directory=str(self._served)))
68
+ threading.Thread(target=self._server.serve_forever, daemon=True).start()
69
+ self.origin = f"http://127.0.0.1:{self._server.server_port}"
70
+ return self
71
+
72
+ def __exit__(self, *exc) -> bool:
73
+ if self._server is not None:
74
+ self._server.shutdown()
75
+ self._server.server_close()
76
+ if self._served is not None:
77
+ shutil.rmtree(self._served, ignore_errors=True)
78
+ return False
79
+
80
+ def write(self, path: pathlib.Path) -> None:
81
+ """The events as JSON lines - the screen's record, for the evidence."""
82
+ with self._lock:
83
+ path.write_text("".join(json.dumps(e) + "\n" for e in self.events),
84
+ encoding="utf-8")
quality_loop/report.py ADDED
@@ -0,0 +1,170 @@
1
+ """The report: problems, each with a severity, and what a loop needs to act on it.
2
+
3
+ The operator agent writes ``REPORT.md`` in the room, one ``### `` heading per
4
+ problem:
5
+
6
+ ```markdown
7
+ ### [high] F1 binding misses Fn+F1 on macOS
8
+ - **Id:** `fn-f1-not-bound`
9
+ - **Kind:** skill gap
10
+ - **Must:** "With VS Code focused, F1 does nothing ... includes Fn+F1"
11
+ - **Evidence:** `replay --app Code --keys Fn-F1` -> consumed: false
12
+ - **Where it would be fixed:** references/configuration.md, Key expressions
13
+ - **How a fix is confirmed:** re-run the scenario; the same replay reports consumed: true
14
+
15
+ Free text under the fields, as long as it needs to be.
16
+ ```
17
+
18
+ There is no verdict. A human reads the report and decides what to do with
19
+ it. **Id**, **Where it would be fixed** and **How a fix is confirmed** are
20
+ there so the report can also feed the next step: a later run says of each id
21
+ whether it is fixed, still there, or new, and a fix is checked against the
22
+ same evidence the problem was found with.
23
+
24
+ `parse` is lenient about everything but what a loop depends on: a problem
25
+ with no severity, an unknown severity or kind, or no id is reported as
26
+ malformed rather than silently dropped.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import dataclasses
32
+ import json
33
+ import pathlib
34
+ import re
35
+
36
+ #: Most severe first.
37
+ SEVERITIES = {
38
+ "critical": "The run did harm: data destroyed, the operator's files rewritten "
39
+ "or no longer loading, a destructive control pressed.",
40
+ "high": "A Must not met, or a result that is silently wrong - one that looks "
41
+ "right and is not.",
42
+ "medium": "Met, but only by guessing something the product should have said, "
43
+ "or after rework a user's agent would repeat.",
44
+ "low": "Friction that cost little: an empty search, a retry, an unclear message.",
45
+ }
46
+
47
+ KINDS = {
48
+ "skill gap": "The product's agent-facing text should have said it.",
49
+ "product": "The product itself behaves wrongly.",
50
+ "harness": "This runner, or the room's tools.",
51
+ "out of scope": "True of the task or the application, not of the product.",
52
+ }
53
+
54
+ FIELDS = {
55
+ "id": "Id",
56
+ "kind": "Kind",
57
+ "must": "Must",
58
+ "evidence": "Evidence",
59
+ "where": "Where it would be fixed",
60
+ "confirm": "How a fix is confirmed",
61
+ }
62
+
63
+ _PROBLEM = re.compile(r"^### +\[(?P<severity>[^\]]+)\] +(?P<title>.+?)\s*$")
64
+ _FIELD = re.compile(r"^- +\*\*(?P<label>[^*]+?):\*\* *(?P<value>.*)$")
65
+
66
+
67
+ @dataclasses.dataclass
68
+ class Problem:
69
+ severity: str
70
+ title: str
71
+ id: str = ""
72
+ kind: str = ""
73
+ must: str = ""
74
+ evidence: str = ""
75
+ where: str = ""
76
+ confirm: str = ""
77
+ detail: str = ""
78
+ room: str = ""
79
+
80
+ def problems(self) -> list[str]:
81
+ """What is wrong with this entry as a loop would read it."""
82
+ found = []
83
+ if self.severity not in SEVERITIES:
84
+ found.append(f"severity {self.severity!r} is not one of {list(SEVERITIES)}")
85
+ if self.kind and self.kind not in KINDS:
86
+ found.append(f"kind {self.kind!r} is not one of {list(KINDS)}")
87
+ if not self.id:
88
+ found.append("no id")
89
+ if not self.evidence:
90
+ found.append("no evidence")
91
+ return found
92
+
93
+
94
+ def parse(text: str, room: str = "") -> tuple[list[Problem], list[str]]:
95
+ """``(problems, malformed)`` from a REPORT.md."""
96
+ by_label = {label.lower(): key for key, label in FIELDS.items()}
97
+ problems: list[Problem] = []
98
+ current: Problem | None = None
99
+ detail: list[str] = []
100
+
101
+ def close():
102
+ if current is not None:
103
+ current.detail = "\n".join(detail).strip()
104
+ problems.append(current)
105
+
106
+ for line in text.replace("\r\n", "\n").split("\n"):
107
+ if heading := _PROBLEM.match(line):
108
+ close()
109
+ current = Problem(severity=heading["severity"].strip().lower(),
110
+ title=heading["title"], room=room)
111
+ detail = []
112
+ elif current is not None and (field := _FIELD.match(line)) \
113
+ and field["label"].strip().lower() in by_label:
114
+ value = field["value"].strip()
115
+ key = by_label[field["label"].strip().lower()]
116
+ if key == "id":
117
+ value = value.strip("`")
118
+ if key == "kind":
119
+ value = value.lower()
120
+ setattr(current, key, value)
121
+ elif current is not None:
122
+ detail.append(line)
123
+ close()
124
+ malformed = [f"{p.title}: {issue}" for p in problems for issue in p.problems()]
125
+ return problems, malformed
126
+
127
+
128
+ def ordered(problems: list[Problem]) -> list[Problem]:
129
+ """Most severe first, then by room, then by title."""
130
+ rank = {name: n for n, name in enumerate(SEVERITIES)}
131
+ return sorted(problems, key=lambda p: (rank.get(p.severity, len(rank)), p.room, p.title))
132
+
133
+
134
+ def findings(reports: list[tuple[str, str, str]]) -> tuple[str, list[dict]]:
135
+ """FINDINGS.md and its machine-readable twin, from every room's report.
136
+
137
+ ``reports`` is ``(room name, scenario title, REPORT.md text)`` per room. A
138
+ concatenation and a count, made mechanically - no second agent rewrites
139
+ what the operators wrote.
140
+ """
141
+ everything, sections = [], []
142
+ for room, title, text in reports:
143
+ problems, malformed = parse(text, room)
144
+ everything += problems
145
+ counts = ", ".join(f"{sum(p.severity == s for p in problems)} {s}"
146
+ for s in SEVERITIES)
147
+ sections += [f"## {room}", "", f"{title}. {counts}.", ""]
148
+ sections += [f"- **malformed report** - {m}" for m in malformed]
149
+ sections += ([text.strip(), ""] if text.strip()
150
+ else ["(no report - the operator wrote nothing)", ""])
151
+ total = ", ".join(f"{sum(p.severity == s for p in everything)} {s}"
152
+ for s in SEVERITIES)
153
+ head = ["# Findings", "",
154
+ f"{len(reports)} room(s): {total}. No verdict - each problem is the "
155
+ f"operator's report of what it found, with the evidence it cites.", ""]
156
+ table = ["| Severity | Room | Problem | Kind |", "|---|---|---|---|"]
157
+ table += [f"| {p.severity} | {p.room} | {p.title} | {p.kind} |"
158
+ for p in ordered(everything)]
159
+ machine = [dataclasses.asdict(p) for p in ordered(everything)]
160
+ return "\n".join(head + (table + [""] if everything else []) + sections), machine
161
+
162
+
163
+ def write_findings(directory: pathlib.Path, reports: list[tuple[str, str, str]]) -> pathlib.Path:
164
+ """FINDINGS.md and findings.json in ``directory``."""
165
+ text, machine = findings(reports)
166
+ (directory / "FINDINGS.md").write_text(text, encoding="utf-8")
167
+ (directory / "findings.json").write_text(json.dumps(machine, indent=2,
168
+ ensure_ascii=False),
169
+ encoding="utf-8")
170
+ return directory / "FINDINGS.md"
quality_loop/room.py ADDED
@@ -0,0 +1,105 @@
1
+ """Building a room: the product's agent-facing surface, the rules, the task.
2
+
3
+ A room is a directory holding the bundle under test and nothing else the room
4
+ may learn from. It may sit inside the product's checkout - next to the rest
5
+ of the work, where its notes are kept - only because the session is started
6
+ with `--restricted` (see `session`), which stops Claude Code loading the
7
+ checkout's CLAUDE.md and skills. A room anywhere else in the checkout is
8
+ refused: it would look deliberate and be protected by nothing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pathlib
14
+ import shutil
15
+ import zipfile
16
+
17
+ #: The room's own CLAUDE.md, which a correctly started session never sees.
18
+ #:
19
+ #: `--restricted` skips CLAUDE.md discovery, so under the runner this file is
20
+ #: dead weight. Under a plain `cd <room> && claude` it is loaded first - along
21
+ #: with the CLAUDE.md of the repository above, which is the contamination it
22
+ #: is here to announce. `audit.audit` looks for the phrase it asks for in the
23
+ #: room's reply.
24
+ CANARY = """\
25
+ # If this arrived on its own, stop
26
+
27
+ **Did you open this file yourself?** Then nothing is wrong - it is a check
28
+ addressed to a different reader, and you should carry on with TASK.md.
29
+
30
+ This text is only a problem if it reached you *without* being asked for, as
31
+ part of the context you started with. That would mean the session was started
32
+ without `--restricted`, and Claude Code will have loaded the CLAUDE.md of the
33
+ repository above this directory too. The measurement this room exists to take
34
+ would already be spoilt.
35
+
36
+ In that case, and only that case: **do not do the task.** Say that the room was
37
+ opened without --restricted and stop, because nothing written from there is
38
+ evidence about what you were given.
39
+ """
40
+
41
+ #: What the room is told to write into, already there so that it appends.
42
+ QUESTIONS = """\
43
+ # What the skill did not tell me
44
+
45
+ One section per thing you had to guess, in the form RULES.md gives. If nothing
46
+ had to be guessed, leave it as it is.
47
+ """
48
+
49
+
50
+ class RoomError(Exception):
51
+ """A room that cannot be built where it was asked for."""
52
+
53
+
54
+ def check_location(room: pathlib.Path, rooms: pathlib.Path,
55
+ checkout: pathlib.Path | None) -> None:
56
+ """Refuse a room inside the checkout but outside its rooms directory."""
57
+ room, rooms = room.resolve(), rooms.resolve()
58
+ if checkout is None:
59
+ return
60
+ checkout = checkout.resolve()
61
+ inside = room == checkout or checkout in room.parents
62
+ if inside and rooms not in room.parents:
63
+ raise RoomError(
64
+ f"{room} is inside the checkout but not under {rooms}. A room "
65
+ f"there is only clean while the session is started with "
66
+ f"--restricted, and that is worth keeping in one place.")
67
+
68
+
69
+ def build(room: pathlib.Path, *, prompt: str, rules: str, bundle: pathlib.Path,
70
+ skill_name: str, rooms: pathlib.Path,
71
+ checkout: pathlib.Path | None = None) -> pathlib.Path:
72
+ """Build a room and return its path.
73
+
74
+ Args:
75
+ room: Where to build it. Must not exist, or be empty.
76
+ prompt: The task, exactly as the user would type it. Written into
77
+ TASK.md as given - never a paraphrase, which is how an answer
78
+ leaks into a room.
79
+ rules: RULES.md's text: what the room may and may not read.
80
+ bundle: The surface under test: a zip (as a user downloads it) or a
81
+ directory. Unpacked under ``skills/<skill_name>``.
82
+ skill_name: The skill's directory name inside the room.
83
+ rooms: The directory rooms are built under.
84
+ checkout: The product's repository, when the rooms are inside it.
85
+ """
86
+ room = pathlib.Path(room)
87
+ check_location(room, rooms, checkout)
88
+ if room.exists() and any(room.iterdir()):
89
+ raise RoomError(f"{room} is not empty - a room starts with nothing in it")
90
+ room.mkdir(parents=True, exist_ok=True)
91
+
92
+ target = room / "skills" / skill_name
93
+ bundle = pathlib.Path(bundle)
94
+ if bundle.is_dir():
95
+ shutil.copytree(bundle, target, ignore=shutil.ignore_patterns(
96
+ "__pycache__", "*.pyc"))
97
+ else:
98
+ with zipfile.ZipFile(bundle) as archive:
99
+ archive.extractall(target)
100
+
101
+ (room / "CLAUDE.md").write_text(CANARY, encoding="utf-8")
102
+ (room / "RULES.md").write_text(rules, encoding="utf-8")
103
+ (room / "TASK.md").write_text(f"# The task\n\n{prompt.strip()}\n", encoding="utf-8")
104
+ (room / "QUESTIONS.md").write_text(QUESTIONS, encoding="utf-8")
105
+ return room