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.
- quality_loop/OPERATOR.md +103 -0
- quality_loop/__init__.py +15 -0
- quality_loop/__main__.py +48 -0
- quality_loop/adapter.py +88 -0
- quality_loop/audit.py +144 -0
- quality_loop/mcp.py +161 -0
- quality_loop/operator.py +186 -0
- quality_loop/pages.py +84 -0
- quality_loop/report.py +170 -0
- quality_loop/room.py +105 -0
- quality_loop/runner.py +160 -0
- quality_loop/scenario.py +160 -0
- quality_loop/screens/__init__.py +192 -0
- quality_loop/screens/_mac.py +152 -0
- quality_loop/screens/_windows.py +110 -0
- quality_loop/session.py +301 -0
- quality_loop/standin.py +193 -0
- quality_loop/transcript.py +130 -0
- quality_loop-0.1.0.dist-info/METADATA +74 -0
- quality_loop-0.1.0.dist-info/RECORD +23 -0
- quality_loop-0.1.0.dist-info/WHEEL +5 -0
- quality_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- quality_loop-0.1.0.dist-info/top_level.txt +1 -0
quality_loop/operator.py
ADDED
|
@@ -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
|