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,130 @@
1
+ """Reading a room's stream-json transcript.
2
+
3
+ `claude -p --output-format stream-json --verbose` writes one JSON object per
4
+ line. Everything here reads that record rather than the room's account of what
5
+ it did, and everything here is tolerant of it: a line that does not parse, an
6
+ event whose ``message`` is a plain string, a ``content`` that is a string
7
+ rather than a list of parts. A refusal arrives in exactly that shape - XeFM's
8
+ first parser assumed a dict and crashed on every real room.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ import json
15
+ import pathlib
16
+ from typing import Iterator
17
+
18
+
19
+ def events(transcript: pathlib.Path) -> Iterator[dict]:
20
+ """Every event in the transcript that parses as a JSON object."""
21
+ try:
22
+ text = pathlib.Path(transcript).read_text(encoding="utf-8")
23
+ except OSError:
24
+ return
25
+ for line in text.splitlines():
26
+ try:
27
+ event = json.loads(line)
28
+ except ValueError:
29
+ continue
30
+ if isinstance(event, dict):
31
+ yield event
32
+
33
+
34
+ def parts(event: dict) -> list[dict]:
35
+ """The content parts of an event's message, or none.
36
+
37
+ None when the message is missing, is a string, or carries its content as a
38
+ string: those events say nothing about a tool call.
39
+ """
40
+ message = event.get("message")
41
+ if not isinstance(message, dict):
42
+ return []
43
+ content = message.get("content")
44
+ if not isinstance(content, list):
45
+ return []
46
+ return [part for part in content if isinstance(part, dict)]
47
+
48
+
49
+ def result_text(content) -> str:
50
+ """A tool result's content as text, whichever shape it came in."""
51
+ if isinstance(content, str):
52
+ return content
53
+ if isinstance(content, list):
54
+ return "\n".join(str(part.get("text", "")) for part in content
55
+ if isinstance(part, dict))
56
+ return str(content or "")
57
+
58
+
59
+ @dataclasses.dataclass
60
+ class ToolCall:
61
+ """One tool call, with what came back."""
62
+ id: str
63
+ name: str
64
+ input: dict
65
+ result: str = ""
66
+ is_error: bool = False
67
+ answered: bool = False
68
+
69
+
70
+ def tool_calls(transcript: pathlib.Path) -> list[ToolCall]:
71
+ """Every tool call in the transcript, in order, paired with its result."""
72
+ calls: list[ToolCall] = []
73
+ by_id: dict[str, ToolCall] = {}
74
+ for event in events(transcript):
75
+ kind = event.get("type")
76
+ for part in parts(event):
77
+ if kind == "assistant" and part.get("type") == "tool_use":
78
+ given = part.get("input")
79
+ call = ToolCall(str(part.get("id", "")), str(part.get("name", "")),
80
+ given if isinstance(given, dict) else {})
81
+ calls.append(call)
82
+ by_id[call.id] = call
83
+ elif kind == "user" and part.get("type") == "tool_result":
84
+ call = by_id.get(str(part.get("tool_use_id", "")))
85
+ if call is not None:
86
+ call.result = result_text(part.get("content"))
87
+ call.is_error = bool(part.get("is_error"))
88
+ call.answered = True
89
+ return calls
90
+
91
+
92
+ def init_event(transcript: pathlib.Path) -> dict | None:
93
+ """The session's init event, wherever it is.
94
+
95
+ Looked for rather than assumed to be first: a rate_limit_event can come
96
+ ahead of it, and reading line one as the init event once disqualified a
97
+ clean run.
98
+ """
99
+ return next((e for e in events(transcript)
100
+ if e.get("type") == "system" and e.get("subtype") == "init"), None)
101
+
102
+
103
+ def final_reply(transcript: pathlib.Path) -> str:
104
+ """The session's last word: the result event's text."""
105
+ replies = [e.get("result", "") for e in events(transcript)
106
+ if e.get("type") == "result"]
107
+ return str(replies[-1]) if replies else ""
108
+
109
+
110
+ def result_event(transcript: pathlib.Path) -> dict | None:
111
+ """The last result event: turns, duration, cost, and whether it errored."""
112
+ found = [e for e in events(transcript) if e.get("type") == "result"]
113
+ return found[-1] if found else None
114
+
115
+
116
+ def lost_the_model(transcript: pathlib.Path) -> bool:
117
+ """Whether the session ended because it could not reach the model.
118
+
119
+ A machine that sleeps with a room open wakes with no network, and the
120
+ session's last word is "API Error: Can't reach the API server". Its result
121
+ event still says `success`, with `is_error` set, and everything the run
122
+ left behind looks like an ordinary end. A run the model never finished
123
+ measured the network, not the product.
124
+ """
125
+ for event in events(transcript):
126
+ if event.get("type") == "assistant" and event.get("error"):
127
+ return True
128
+ if event.get("type") == "result" and event.get("is_error"):
129
+ return True
130
+ return False
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: quality-loop
3
+ Version: 0.1.0
4
+ Summary: Measure what an agent-facing product teaches: isolated agent runs of real tasks, reported by an operator agent
5
+ Author: craftware
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/crftwr/quality-loop
8
+ Project-URL: Repository, https://github.com/crftwr/quality-loop
9
+ Project-URL: Issues, https://github.com/crftwr/quality-loop/issues
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Requires-Dist: pytest-timeout>=2; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # quality-loop
30
+
31
+ Measure what an agent-facing product teaches.
32
+
33
+ A skill bundle, an MCP server or a CLI is only as good as what an agent with
34
+ nothing else can do with it. quality-loop runs that agent on a real task, in a
35
+ **room** that holds the product's agent-facing surface and nothing else. The
36
+ room is started so it cannot read the repository where the answers are.
37
+ Afterwards an **operator** agent reports the problems it finds in what the
38
+ room did, each with a severity. A human decides what to do with the report.
39
+
40
+ > Status: early development, run end to end against a toy product
41
+ > (`examples/toy/run.py`). In place: rooms and their isolation, the audit,
42
+ > the stand-in, fixture pages, screens by name on macOS and Windows,
43
+ > Markdown scenarios, the operator agent and its MCP tools, and reports with
44
+ > severities. The first real product, Keyhac, is next. The design is
45
+ > [crftwr/keyhac#205](https://github.com/crftwr/keyhac/issues/205).
46
+
47
+ ## What stays code, and why
48
+
49
+ Anything a run depends on to mean something is code, not something an agent
50
+ decides:
51
+
52
+ - **The prompt the room is handed** is copied verbatim. The operator knows
53
+ what a good answer looks like, so any paraphrase of the prompt can leak it.
54
+ - **Isolation.** The room runs with `--restricted`, an explicit permission
55
+ list under `dontAsk`, one MCP configuration and nothing else, git stopped at
56
+ the room, and stdin closed. A transcript audit then checks what no flag can
57
+ enforce.
58
+ - **The operator's machine.** Their files are moved aside and replaced with
59
+ fixtures. On the way out they are put back in a `finally`, whether the run
60
+ ended normally, by an exception, by Ctrl-C or by SIGTERM.
61
+ - **Evidence.** A report cites what tools recorded, not what the room said.
62
+
63
+ ## Products using it
64
+
65
+ - [Keyhac](https://github.com/crftwr/keyhac)
66
+ - [XeFM](https://github.com/crftwr/xefm) (planned)
67
+
68
+ ## Development
69
+
70
+ ```
71
+ make test
72
+ ```
73
+
74
+ MIT licensed.
@@ -0,0 +1,23 @@
1
+ quality_loop/OPERATOR.md,sha256=SDi3-CnWu-nfnBAXY0h9njxKL6DKe3HZhXvyVPSORd4,4806
2
+ quality_loop/__init__.py,sha256=9aGtGjzNFc4KpykObxIijddHx_evJHkAaW7inNg-WqE,725
3
+ quality_loop/__main__.py,sha256=o95_bhSSku11h5nP6Z9Ga2n_Pe-GQzgDGl-ZO-Vx_mY,1696
4
+ quality_loop/adapter.py,sha256=VHVT4McTAaq1mzJy5D6HqbH9V6xgtkKkuDnTEqCAqBQ,3497
5
+ quality_loop/audit.py,sha256=Z4jHcqTIb3dJJp5rdR1HJET3ZfMycHWRza-0spdlL5Q,6711
6
+ quality_loop/mcp.py,sha256=0n16CIZwerOQJaDNRMZhievoc22vOfuupyogVpWVXMY,6789
7
+ quality_loop/operator.py,sha256=XA3WM0AK7gD-lODkSoOYvrFrNkcE33_VRTU9sLMo60Y,8295
8
+ quality_loop/pages.py,sha256=hQHi3ddVvKi4NkbtDldRnG5G3qWUvNFlxLOcSadO0hc,3119
9
+ quality_loop/report.py,sha256=O3ciNgZZUQKiEBAi5ZlsouK8E2KsIEzymY2oxb5JXKY,6833
10
+ quality_loop/room.py,sha256=xpPuwR0DRK3oYYKNtIG8aVzx1Nn8_MImekbJt5SDZ8M,4485
11
+ quality_loop/runner.py,sha256=YlAZ-F9SftGycGZwlLBuljtUXPyZyxTB0O3VCWLChwc,7532
12
+ quality_loop/scenario.py,sha256=XluMrTMEOys1K46-Wy6swkThh3oMdZaanQLUDksDys0,5653
13
+ quality_loop/session.py,sha256=DucTKTWNQLZSW6R2HNK4oDXZ2HaZyGJKp6eD3qyBqrM,13115
14
+ quality_loop/standin.py,sha256=TPdDlEH6_zj8Jb6IwUnjJTQFz7SmQNrNFB_lmYkn_Kk,8069
15
+ quality_loop/transcript.py,sha256=T9uHTz3Zh9asf_0IPYTwYL4kI0JivAY16rcNs0KxYoU,4758
16
+ quality_loop/screens/__init__.py,sha256=tLnk7-JlOWGy-yHS10KUq8s-CIuJvRwoXh4aFEe2DX0,7031
17
+ quality_loop/screens/_mac.py,sha256=qTTnI9ITIHn7LYWWe6jPeBhHdRCTAEw86SIIKlhWReA,5979
18
+ quality_loop/screens/_windows.py,sha256=FEBHtSOS6NIFJj4qRXsy7SWK87uHuXxI6gdvSaYxFbA,4154
19
+ quality_loop-0.1.0.dist-info/licenses/LICENSE,sha256=zaQF900Kv_gvMjyFe8OCTFqqz_hO8dly1xzeVpIx4is,1066
20
+ quality_loop-0.1.0.dist-info/METADATA,sha256=HFBGzmTQMV68PqzJRQWztrLQwFDJKkeOkzbY9PRhdis,3095
21
+ quality_loop-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
22
+ quality_loop-0.1.0.dist-info/top_level.txt,sha256=yT17UlBVxJo-kb3h1Mt18a4E-4iKnY2Fry31C6jY3Y8,13
23
+ quality_loop-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 craftware
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ quality_loop