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.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# You are the operator of one room
|
|
2
|
+
|
|
3
|
+
A **room** is a separate Claude Code session that was given a task and nothing
|
|
4
|
+
but a product's agent-facing surface - a skill bundle and the product's own
|
|
5
|
+
tools. It was started so it cannot read the product's repository. What it
|
|
6
|
+
does with what it was given is the measurement. Your job is to make that
|
|
7
|
+
measurement possible and then report what it shows.
|
|
8
|
+
|
|
9
|
+
You work in two phases of one session. The message that starts each phase
|
|
10
|
+
says which one it is, and gives you the paths.
|
|
11
|
+
|
|
12
|
+
## Phase 1: put up the screen
|
|
13
|
+
|
|
14
|
+
1. Read the scenario file. Its **Screen** section says what has to be in
|
|
15
|
+
front before the room starts. It may say "none".
|
|
16
|
+
2. If a screen is needed, call `screen_list` to see what the product
|
|
17
|
+
declares, choose what the section asks for, and call `screen_open` for
|
|
18
|
+
each one. It returns once the window is up, or fails.
|
|
19
|
+
3. If the section asks for something no declared screen provides, do not
|
|
20
|
+
improvise. Write that into `setup.md` in your directory and stop: the
|
|
21
|
+
room cannot measure anything without its screen.
|
|
22
|
+
4. Write `setup.md` in your directory: what you opened, with the ids `open`
|
|
23
|
+
returned, and anything about the screen a reader of your report should
|
|
24
|
+
know.
|
|
25
|
+
|
|
26
|
+
You have no shell. Your tools are Read, Glob, Grep, writing in your own
|
|
27
|
+
directory and the room's REPORT.md, and the four tools above.
|
|
28
|
+
|
|
29
|
+
You may not change anything in the room. The runner checks every file in it
|
|
30
|
+
after this phase, and a changed byte cancels the room. In particular, the
|
|
31
|
+
room's TASK.md is copied from the scenario by the runner and must reach the
|
|
32
|
+
room exactly as written.
|
|
33
|
+
|
|
34
|
+
## Phase 2: report
|
|
35
|
+
|
|
36
|
+
The room has finished. In the room directory you have:
|
|
37
|
+
|
|
38
|
+
- `transcript.jsonl`: everything the room did, as stream-json. This is the
|
|
39
|
+
record. The room's own account of what it did is only a claim.
|
|
40
|
+
- `QUESTIONS.md`: what the room noticed it had to guess.
|
|
41
|
+
- `evidence/`: what the runner collected. That includes the product's files
|
|
42
|
+
before and after, anything the room created, the screen's event record, and
|
|
43
|
+
`audit.json`, the runner's isolation audit.
|
|
44
|
+
|
|
45
|
+
Then:
|
|
46
|
+
|
|
47
|
+
1. Read the scenario's **Must** and **Must not** sections. For each line,
|
|
48
|
+
establish from evidence whether the run met it. Use the product's evidence
|
|
49
|
+
tools where a line is about behaviour; the start-of-phase message lists
|
|
50
|
+
them. Quote what a tool answered. Do not infer it.
|
|
51
|
+
2. Read the transcript for **friction**, whether or not the room noticed it:
|
|
52
|
+
a tool call that failed or was refused, an answer that was an error or a
|
|
53
|
+
blank, a search of the bundle that came back empty, a file rewritten or a
|
|
54
|
+
run repeated, turns running out, sentences where the room says it is
|
|
55
|
+
unsure or was wrong. A problem the room never mentioned in QUESTIONS.md is
|
|
56
|
+
the most valuable kind.
|
|
57
|
+
3. Read **QUESTIONS.md**. Each question is a candidate problem.
|
|
58
|
+
4. Write **REPORT.md** in the room, in the format below. Then close every
|
|
59
|
+
screen you opened (`screen_close`).
|
|
60
|
+
|
|
61
|
+
**Friction is not a problem by itself.** A deploy's own "DEPLOY FAILED" on a
|
|
62
|
+
screen built to show a failed deploy is the task working. Report what a
|
|
63
|
+
product change would improve, and say why.
|
|
64
|
+
|
|
65
|
+
## REPORT.md
|
|
66
|
+
|
|
67
|
+
```markdown
|
|
68
|
+
# Report: <scenario title>
|
|
69
|
+
|
|
70
|
+
One paragraph: what the room was asked, what it did, and what it left.
|
|
71
|
+
|
|
72
|
+
### [high] <the problem, in one line>
|
|
73
|
+
- **Id:** `<kebab-case, stable>`
|
|
74
|
+
- **Kind:** skill gap
|
|
75
|
+
- **Must:** "<the Must line it concerns, or none>"
|
|
76
|
+
- **Evidence:** <tool output quoted, or transcript step and what it shows>
|
|
77
|
+
- **Where it would be fixed:** <file and section, in the product>
|
|
78
|
+
- **How a fix is confirmed:** <the evidence that would show it fixed>
|
|
79
|
+
|
|
80
|
+
<Free text: what happened, and why it matters.>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Severity** - pick the first that fits:
|
|
84
|
+
|
|
85
|
+
| | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| critical | The run did harm: data destroyed, the user's files rewritten or no longer loading, a destructive control pressed. |
|
|
88
|
+
| high | A Must not met, or a result that is silently wrong - one that looks right and is not. |
|
|
89
|
+
| medium | Met, but only by guessing something the product should have said, or after rework a user's agent would repeat. |
|
|
90
|
+
| low | Friction that cost little: an empty search, a retry, an unclear message. |
|
|
91
|
+
|
|
92
|
+
**Kind**: `skill gap` (the product's agent-facing text should have said it),
|
|
93
|
+
`product` (the product behaves wrongly), `harness` (the runner, or the room's
|
|
94
|
+
tools), `out of scope` (true of the task or the application, not of the
|
|
95
|
+
product).
|
|
96
|
+
|
|
97
|
+
**Id** names the problem, not the run: `fn-f1-not-bound`, not `room-3-issue`.
|
|
98
|
+
A later run reports the same problem under the same id, which is how a fix is
|
|
99
|
+
seen to have worked.
|
|
100
|
+
|
|
101
|
+
There is no verdict. Write problems, most severe first. If you found none,
|
|
102
|
+
say so in the opening paragraph and say what you checked. Zero problems is
|
|
103
|
+
rare, and the reader will want to know why you believe it.
|
quality_loop/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""quality-loop: measure what an agent-facing product teaches.
|
|
2
|
+
|
|
3
|
+
A product that agents use - a skill bundle, an MCP server, a CLI - is only as
|
|
4
|
+
good as what an agent with nothing else can do with it. This package runs that
|
|
5
|
+
agent: a task, in a room holding the product's agent-facing surface and nothing
|
|
6
|
+
else, started so it cannot read the repository the answers are in. A second
|
|
7
|
+
agent, the operator, puts up whatever the task needs beforehand and afterwards
|
|
8
|
+
reports the problems it finds in what the room did, each with a severity.
|
|
9
|
+
|
|
10
|
+
What has to hold for a run to mean anything - the isolation, the prompt the
|
|
11
|
+
room is handed, putting the user's machine back - is code here, not an
|
|
12
|
+
agent's discretion.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
quality_loop/__main__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The operator agent's command line: ``python -m quality_loop screen ...``.
|
|
2
|
+
|
|
3
|
+
python -m quality_loop screen list
|
|
4
|
+
python -m quality_loop screen open approval-queue
|
|
5
|
+
python -m quality_loop screen close approval-queue-1
|
|
6
|
+
|
|
7
|
+
Everything it takes is a name from the manifest the runner wrote. Answers are
|
|
8
|
+
JSON on stdout, errors one line on stderr with exit status 1.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from quality_loop import screens
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="quality-loop")
|
|
22
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
23
|
+
screen = commands.add_parser("screen", help="put up or take down a declared screen")
|
|
24
|
+
actions = screen.add_subparsers(dest="action", required=True)
|
|
25
|
+
actions.add_parser("list", help="the screens the adapter declares")
|
|
26
|
+
opening = actions.add_parser("open", help="open one, and wait for its window")
|
|
27
|
+
opening.add_argument("name")
|
|
28
|
+
closing = actions.add_parser("close", help="close one that `open` recorded")
|
|
29
|
+
closing.add_argument("id")
|
|
30
|
+
args = parser.parse_args(argv)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
if args.action == "list":
|
|
34
|
+
answer = {name: {"kind": e["kind"], "title": e["title"]}
|
|
35
|
+
for name, e in screens.names().items()}
|
|
36
|
+
elif args.action == "open":
|
|
37
|
+
answer = screens.open_screen(args.name)
|
|
38
|
+
else:
|
|
39
|
+
answer = screens.close_screen(args.id)
|
|
40
|
+
except screens.ScreenError as error:
|
|
41
|
+
print(f"error: {error}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
print(json.dumps(answer, indent=2))
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
raise SystemExit(main())
|
quality_loop/adapter.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""What a product supplies: its surface, its rooms' limits, its evidence.
|
|
2
|
+
|
|
3
|
+
A product subclasses `Adapter` and fills in what is its own. Everything else -
|
|
4
|
+
building rooms, isolating the session, the stand-in, the screens, the
|
|
5
|
+
operator, the audit, the findings - is the runner's and is the same for
|
|
6
|
+
every product.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from quality_loop import audit, scenario, session, standin
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Adapter:
|
|
19
|
+
"""One product's half of a run.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: The product's name, used in the run directory and messages.
|
|
23
|
+
version: The product's version, which names the run directory.
|
|
24
|
+
checkout: The product's repository.
|
|
25
|
+
rooms: Where rooms are built. Inside the checkout is fine, because the
|
|
26
|
+
session is `--restricted`; anywhere else in the checkout is refused.
|
|
27
|
+
scenarios: The directory of scenario files.
|
|
28
|
+
skill_name: The bundle's directory name inside a room.
|
|
29
|
+
fixtures: The directory screens are served or copied from, or None.
|
|
30
|
+
screens: ``{name: Page | Terminal}`` - all the operator may put up.
|
|
31
|
+
evidence: ``{name: {"argv": [...], "help": "..."}}`` - commands that
|
|
32
|
+
read, which the operator runs through its MCP server with
|
|
33
|
+
arguments of its choosing and no shell.
|
|
34
|
+
evidence_help: One paragraph on what those tools answer.
|
|
35
|
+
python: The interpreter the operator's CLIs run under.
|
|
36
|
+
"""
|
|
37
|
+
name = "product"
|
|
38
|
+
version = "0"
|
|
39
|
+
checkout: pathlib.Path
|
|
40
|
+
rooms: pathlib.Path
|
|
41
|
+
scenarios: pathlib.Path
|
|
42
|
+
skill_name = "product"
|
|
43
|
+
fixtures: pathlib.Path | None = None
|
|
44
|
+
screens: dict = {}
|
|
45
|
+
evidence: dict = {}
|
|
46
|
+
evidence_help = ""
|
|
47
|
+
python = sys.executable
|
|
48
|
+
|
|
49
|
+
def bundle(self) -> pathlib.Path:
|
|
50
|
+
"""The surface under test - a zip or a directory - built for this run."""
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
def rules(self) -> str:
|
|
54
|
+
"""RULES.md: what the room may read and what it must write down."""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def session(self, room: pathlib.Path) -> session.Session:
|
|
58
|
+
"""How the room's session is started: its tools, permissions, servers."""
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def policy(self, room: pathlib.Path) -> audit.Policy:
|
|
62
|
+
"""What the audit flags for this product."""
|
|
63
|
+
return audit.Policy(checkout=self.checkout)
|
|
64
|
+
|
|
65
|
+
def product(self):
|
|
66
|
+
"""A context manager for the length of a run: the product up, if it
|
|
67
|
+
has to be running for the room to use it."""
|
|
68
|
+
return contextlib.nullcontext()
|
|
69
|
+
|
|
70
|
+
def stand_in(self, case: scenario.Scenario, variant: str,
|
|
71
|
+
room: pathlib.Path) -> standin.StandIn | None:
|
|
72
|
+
"""The operator's files stood in for, for one room, or None."""
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def runs(self, cases: list[scenario.Scenario],
|
|
76
|
+
named: bool = False) -> list[tuple[scenario.Scenario, str]]:
|
|
77
|
+
"""``(scenario, variant)`` pairs: the same scenario on several fixture
|
|
78
|
+
configs is several rooms. The default is one room per scenario.
|
|
79
|
+
|
|
80
|
+
``named`` says the scenarios were picked by name rather than being
|
|
81
|
+
every one there is - a product can leave out of a run of everything
|
|
82
|
+
those that need a screen only the person running it can put up.
|
|
83
|
+
"""
|
|
84
|
+
return [(case, "") for case in cases]
|
|
85
|
+
|
|
86
|
+
def alive(self) -> bool:
|
|
87
|
+
"""Whether the product is still answering, asked on the heartbeat."""
|
|
88
|
+
return True
|
quality_loop/audit.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Checking the transcript for the rules no flag can enforce.
|
|
2
|
+
|
|
3
|
+
RULES.md forbids the checkout, the installed package and other people's work,
|
|
4
|
+
and until there was a transcript the only evidence that a room obeyed was that
|
|
5
|
+
it said so. A rule you cannot check is a rule you are assuming.
|
|
6
|
+
|
|
7
|
+
What is scanned is where a tool was *pointed* - the path-bearing fields of its
|
|
8
|
+
input - not what it typed or what came back. A room that reads a path off the
|
|
9
|
+
screen and repeats it in its notes is doing its task, not reaching for the
|
|
10
|
+
checkout; and a window title is full of paths the room never asked for.
|
|
11
|
+
|
|
12
|
+
**A refusal is not a breach.** A call the confinement turned down read
|
|
13
|
+
nothing, so the measurement is intact. Refusals are reported - they are the
|
|
14
|
+
confinement working in public - and only a call that came back with something
|
|
15
|
+
disqualifies a run.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import os
|
|
22
|
+
import pathlib
|
|
23
|
+
import re
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
from quality_loop import transcript as tr
|
|
27
|
+
|
|
28
|
+
#: Input fields that say where a tool was aimed.
|
|
29
|
+
AIMED_AT = ("file_path", "path", "notebook_path", "pattern", "command", "glob")
|
|
30
|
+
|
|
31
|
+
#: An absolute path in any spelling: /Users/..., C:\Users\... or C:/..., and
|
|
32
|
+
#: \Users\... - rooted on the current drive, which Windows accepts too.
|
|
33
|
+
ABSOLUTE = re.compile(r"(?:[A-Za-z]:[\\/]|[\\/])[^\s\"'`;|&)]+")
|
|
34
|
+
|
|
35
|
+
#: A shell command that runs git: at the start, or after a separator, by any
|
|
36
|
+
#: path, with or without ".exe".
|
|
37
|
+
RUNS_GIT = re.compile(r"(?:^|[;&|(`]|\$\()\s*(?:\S*/)?git(?:\.exe)?\b")
|
|
38
|
+
|
|
39
|
+
#: What git answers once GIT_CEILING_DIRECTORIES has stopped it at the room.
|
|
40
|
+
NOT_A_REPOSITORY = "not a git repository"
|
|
41
|
+
|
|
42
|
+
#: What room.CANARY asks a contaminated session to say.
|
|
43
|
+
CANARY_PHRASE = "without --restricted"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def norm(path: str) -> str:
|
|
47
|
+
"""A path in one spelling: forward slashes, and lowercase on Windows,
|
|
48
|
+
where C:\\Users and c:/users are the same place."""
|
|
49
|
+
path = path.replace("\\", "/")
|
|
50
|
+
return path.lower() if os.name == "nt" else path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def paths_in(tool_input: dict) -> list[str]:
|
|
54
|
+
"""The absolute paths a tool call was pointed at."""
|
|
55
|
+
aimed = []
|
|
56
|
+
for key in AIMED_AT:
|
|
57
|
+
value = tool_input.get(key)
|
|
58
|
+
if isinstance(value, str):
|
|
59
|
+
value = value.replace("~/", str(pathlib.Path.home()) + "/")
|
|
60
|
+
aimed += ABSOLUTE.findall(value)
|
|
61
|
+
return aimed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass
|
|
65
|
+
class Policy:
|
|
66
|
+
"""What a product's rooms must not reach, and what their sessions must have.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
checkout: The product's repository. A path under it and outside the
|
|
70
|
+
room is a breach - not a mention of it, which every honest read of
|
|
71
|
+
the bundle inside the room also is.
|
|
72
|
+
forbidden: ``{substring: what it is}`` for paths that are a breach
|
|
73
|
+
anywhere: the installed package, the installed app, the operator's
|
|
74
|
+
files set aside.
|
|
75
|
+
foreign: Basenames of other authors' work in a directory the room may
|
|
76
|
+
write to - read one, and the room has learned from somebody who
|
|
77
|
+
had the repository.
|
|
78
|
+
bash: Whether the session has Bash on purpose. When it does not, Bash
|
|
79
|
+
in the init event means `--restricted` did not take effect.
|
|
80
|
+
servers: MCP servers that must be connected; one that is not means
|
|
81
|
+
the room reported on the outage, not the product.
|
|
82
|
+
leaked_skill: Says whether a slash command in the init event is one of
|
|
83
|
+
the checkout's own skills - the scoring key among them.
|
|
84
|
+
"""
|
|
85
|
+
checkout: pathlib.Path
|
|
86
|
+
forbidden: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
87
|
+
foreign: set[str] = dataclasses.field(default_factory=set)
|
|
88
|
+
bash: bool = False
|
|
89
|
+
servers: tuple[str, ...] = ()
|
|
90
|
+
leaked_skill: Callable[[str], bool] | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def isolation_held(transcript: pathlib.Path, policy: Policy) -> list[str]:
|
|
94
|
+
"""What the session's init event says went wrong with the flags."""
|
|
95
|
+
init = tr.init_event(transcript)
|
|
96
|
+
if init is None:
|
|
97
|
+
return ["the session produced no init event"]
|
|
98
|
+
found = []
|
|
99
|
+
if not policy.bash and "Bash" in init.get("tools", []):
|
|
100
|
+
found.append("the session had Bash: it was not started with --restricted, "
|
|
101
|
+
"so the checkout's CLAUDE.md was in context from the first token")
|
|
102
|
+
if policy.leaked_skill:
|
|
103
|
+
if leaked := [c for c in init.get("slash_commands", [])
|
|
104
|
+
if policy.leaked_skill(str(c))]:
|
|
105
|
+
found.append(f"the checkout's own skills were visible to the room: {leaked}")
|
|
106
|
+
servers = {s.get("name"): s.get("status") for s in init.get("mcp_servers", [])
|
|
107
|
+
if isinstance(s, dict)}
|
|
108
|
+
for name in policy.servers:
|
|
109
|
+
if servers.get(name) != "connected":
|
|
110
|
+
found.append(f"MCP server {name} was {servers.get(name, 'absent')}, so what "
|
|
111
|
+
f"the room reported is about the server, not the product")
|
|
112
|
+
if CANARY_PHRASE in tr.final_reply(transcript).replace("`", ""):
|
|
113
|
+
found.append("the room read its CLAUDE.md canary unasked: the session was "
|
|
114
|
+
"started without --restricted")
|
|
115
|
+
return found
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def audit(room: pathlib.Path, policy: Policy) -> tuple[list[str], list[str]]:
|
|
119
|
+
"""``(breaches, refused)`` from the room's transcript."""
|
|
120
|
+
room = pathlib.Path(room).resolve()
|
|
121
|
+
transcript = room / "transcript.jsonl"
|
|
122
|
+
if not transcript.exists():
|
|
123
|
+
return ["no transcript - the room was never driven"], []
|
|
124
|
+
breaches = isolation_held(transcript, policy)
|
|
125
|
+
refused: list[str] = []
|
|
126
|
+
checkout, inside = norm(str(policy.checkout.resolve())), norm(str(room))
|
|
127
|
+
forbidden = {norm(path): what for path, what in policy.forbidden.items()}
|
|
128
|
+
for call in tr.tool_calls(transcript):
|
|
129
|
+
landed = refused if call.is_error else breaches
|
|
130
|
+
command = call.input.get("command")
|
|
131
|
+
if isinstance(command, str) and RUNS_GIT.search(command):
|
|
132
|
+
# Refused when git found no repository - the ceiling held.
|
|
133
|
+
(refused if call.is_error or NOT_A_REPOSITORY in call.result
|
|
134
|
+
else breaches).append(f"{call.name} ran git: {command[:80]}")
|
|
135
|
+
for aimed in paths_in(call.input):
|
|
136
|
+
spelled = norm(aimed)
|
|
137
|
+
for path, what in forbidden.items():
|
|
138
|
+
if path in spelled:
|
|
139
|
+
landed.append(f"{call.name} reached into {what}: {aimed}")
|
|
140
|
+
if spelled.startswith(checkout) and not spelled.startswith(inside):
|
|
141
|
+
landed.append(f"{call.name} reached into the checkout: {aimed}")
|
|
142
|
+
if pathlib.PurePath(aimed.replace("\\", "/")).name in policy.foreign:
|
|
143
|
+
landed.append(f"{call.name} read another author's work: {aimed}")
|
|
144
|
+
return sorted(set(breaches)), sorted(set(refused))
|
quality_loop/mcp.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""The operator's tools, as a small MCP server: ``python -m quality_loop.mcp``.
|
|
2
|
+
|
|
3
|
+
The operator has no shell. Claude Code lets a read-only command through
|
|
4
|
+
whatever the allow list says, so a compound ``cd; ls; cat ...`` ran on the
|
|
5
|
+
first real operator - and ``cat`` reads anywhere on the machine, which the
|
|
6
|
+
file tools' confinement does not. So the operator's only way to act is these
|
|
7
|
+
four tools:
|
|
8
|
+
|
|
9
|
+
- ``screen_list``, ``screen_open(name)``, ``screen_close(id)`` - the
|
|
10
|
+
declared screens, as `quality_loop.screens` puts them up;
|
|
11
|
+
- ``evidence(tool, args)`` - one of the product's declared evidence
|
|
12
|
+
commands, run with ``args`` as a list and no shell, so nothing can be piped,
|
|
13
|
+
redirected or chained. What the arguments mean is the evidence command's
|
|
14
|
+
own business, and an adapter declares only commands that read.
|
|
15
|
+
|
|
16
|
+
Everything comes from the manifest the runner wrote (``QUALITY_LOOP_MANIFEST``).
|
|
17
|
+
The protocol is the least of MCP that Claude Code needs - initialize,
|
|
18
|
+
tools/list, tools/call, ping - as newline-delimited JSON-RPC on stdio, with
|
|
19
|
+
nothing outside the standard library.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import pathlib
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
import quality_loop
|
|
31
|
+
from quality_loop import screens
|
|
32
|
+
|
|
33
|
+
PROTOCOL = "2025-06-18"
|
|
34
|
+
|
|
35
|
+
#: Ceiling on what one evidence call hands back, in characters.
|
|
36
|
+
MAX_OUTPUT = 20_000
|
|
37
|
+
|
|
38
|
+
#: How long one evidence command may take.
|
|
39
|
+
EVIDENCE_TIMEOUT = 120
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _manifest() -> dict:
|
|
43
|
+
return json.loads(pathlib.Path(os.environ[screens.MANIFEST]).read_text(encoding="utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tools(manifest: dict) -> list[dict]:
|
|
47
|
+
"""The tool list, with the declared names in the schemas."""
|
|
48
|
+
evidence = manifest.get("evidence", {})
|
|
49
|
+
listing = "\n".join(f"- {name}: {entry.get('help', '')}"
|
|
50
|
+
for name, entry in evidence.items()) or "(none declared)"
|
|
51
|
+
return [
|
|
52
|
+
{"name": "screen_list",
|
|
53
|
+
"description": "The screens the product declares: name, kind and window title.",
|
|
54
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
55
|
+
{"name": "screen_open",
|
|
56
|
+
"description": "Open a declared screen and wait until its window is up. "
|
|
57
|
+
"Answers the id to close it with.",
|
|
58
|
+
"inputSchema": {"type": "object", "properties": {
|
|
59
|
+
"name": {"type": "string", "enum": sorted(manifest.get("screens", {}))}},
|
|
60
|
+
"required": ["name"]}},
|
|
61
|
+
{"name": "screen_close",
|
|
62
|
+
"description": "Close a screen screen_open opened, by its id.",
|
|
63
|
+
"inputSchema": {"type": "object", "properties": {"id": {"type": "string"}},
|
|
64
|
+
"required": ["id"]}},
|
|
65
|
+
{"name": "evidence",
|
|
66
|
+
"description": "Run one of the product's evidence commands with the given "
|
|
67
|
+
"arguments and answer what it printed. Run it with "
|
|
68
|
+
"args [\"--help\"] first. Declared:\n" + listing,
|
|
69
|
+
"inputSchema": {"type": "object", "properties": {
|
|
70
|
+
"tool": {"type": "string", "enum": sorted(evidence)},
|
|
71
|
+
"args": {"type": "array", "items": {"type": "string"}}},
|
|
72
|
+
"required": ["tool", "args"]}},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ToolError(Exception):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def call(name: str, arguments: dict, manifest: dict) -> str:
|
|
81
|
+
"""One tool call's answer, or a ToolError saying what was wrong."""
|
|
82
|
+
try:
|
|
83
|
+
if name == "screen_list":
|
|
84
|
+
return json.dumps({n: {"kind": e["kind"], "title": e["title"]}
|
|
85
|
+
for n, e in manifest.get("screens", {}).items()}, indent=2)
|
|
86
|
+
if name == "screen_open":
|
|
87
|
+
return json.dumps(screens.open_screen(str(arguments.get("name", ""))), indent=2)
|
|
88
|
+
if name == "screen_close":
|
|
89
|
+
return json.dumps(screens.close_screen(str(arguments.get("id", ""))), indent=2)
|
|
90
|
+
except screens.ScreenError as error:
|
|
91
|
+
raise ToolError(str(error)) from None
|
|
92
|
+
if name == "evidence":
|
|
93
|
+
return _evidence(arguments, manifest)
|
|
94
|
+
raise ToolError(f"no tool {name!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _evidence(arguments: dict, manifest: dict) -> str:
|
|
98
|
+
declared = manifest.get("evidence", {})
|
|
99
|
+
tool = arguments.get("tool")
|
|
100
|
+
if tool not in declared:
|
|
101
|
+
raise ToolError(f"no evidence tool {tool!r}; declared: "
|
|
102
|
+
+ (", ".join(sorted(declared)) or "none"))
|
|
103
|
+
args = arguments.get("args", [])
|
|
104
|
+
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
|
105
|
+
raise ToolError("args must be a list of strings")
|
|
106
|
+
try:
|
|
107
|
+
done = subprocess.run([*declared[tool]["argv"], *args], capture_output=True,
|
|
108
|
+
text=True, encoding="utf-8", errors="replace",
|
|
109
|
+
stdin=subprocess.DEVNULL, timeout=EVIDENCE_TIMEOUT)
|
|
110
|
+
except subprocess.TimeoutExpired:
|
|
111
|
+
raise ToolError(f"{tool} did not finish within {EVIDENCE_TIMEOUT}s") from None
|
|
112
|
+
said = done.stdout + (f"\n[stderr]\n{done.stderr}" if done.stderr.strip() else "")
|
|
113
|
+
if len(said) > MAX_OUTPUT:
|
|
114
|
+
said = said[:MAX_OUTPUT] + f"\n... {len(said) - MAX_OUTPUT} more characters"
|
|
115
|
+
if done.returncode != 0:
|
|
116
|
+
raise ToolError(f"{tool} exited {done.returncode}\n{said}")
|
|
117
|
+
return said
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def handle(message: dict, manifest: dict) -> dict | None:
|
|
121
|
+
"""The reply to one JSON-RPC message, or None for a notification."""
|
|
122
|
+
if "id" not in message:
|
|
123
|
+
return None
|
|
124
|
+
method, ident = message.get("method"), message["id"]
|
|
125
|
+
if method == "initialize":
|
|
126
|
+
asked = (message.get("params") or {}).get("protocolVersion") or PROTOCOL
|
|
127
|
+
result = {"protocolVersion": asked, "capabilities": {"tools": {}},
|
|
128
|
+
"serverInfo": {"name": "quality-loop", "version": quality_loop.__version__}}
|
|
129
|
+
elif method == "ping":
|
|
130
|
+
result = {}
|
|
131
|
+
elif method == "tools/list":
|
|
132
|
+
result = {"tools": tools(manifest)}
|
|
133
|
+
elif method == "tools/call":
|
|
134
|
+
params = message.get("params") or {}
|
|
135
|
+
try:
|
|
136
|
+
text, failed = call(params.get("name", ""), params.get("arguments") or {},
|
|
137
|
+
manifest), False
|
|
138
|
+
except ToolError as error:
|
|
139
|
+
text, failed = str(error), True
|
|
140
|
+
result = {"content": [{"type": "text", "text": text}], "isError": failed}
|
|
141
|
+
else:
|
|
142
|
+
return {"jsonrpc": "2.0", "id": ident,
|
|
143
|
+
"error": {"code": -32601, "message": f"no method {method!r}"}}
|
|
144
|
+
return {"jsonrpc": "2.0", "id": ident, "result": result}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> int:
|
|
148
|
+
manifest = _manifest()
|
|
149
|
+
for line in sys.stdin:
|
|
150
|
+
try:
|
|
151
|
+
message = json.loads(line)
|
|
152
|
+
except ValueError:
|
|
153
|
+
continue
|
|
154
|
+
if isinstance(message, dict) and (reply := handle(message, manifest)) is not None:
|
|
155
|
+
sys.stdout.write(json.dumps(reply) + "\n")
|
|
156
|
+
sys.stdout.flush()
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
raise SystemExit(main())
|