ai-test-loop 0.0.0-stage → 1.0.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Medeiros
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.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # ai-test-loop
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Your agent is grading its own homework.** This makes it impossible: a critic script runs real
4
+ checks, scores the artifact 0–100, loops until the threshold is met, and a fail-closed gate blocks
5
+ delivery on security findings.
6
+
7
+ ![loop](https://github.com/Samuelfmedeiros/ai-test-loop/actions/workflows/loop.yml/badge.svg)
8
+
9
+ No API keys, no dependencies, no model calls. `loop_runner.py` is deliberately dumb: exit codes and
10
+ optional output patterns decide pass/fail. The model never gets to *say* the work is good — it has
11
+ to *show* it.
12
+
13
+ > **Quer isso pronto para o seu time?** Os packs com entrega por e-mail, o gate com relatório
14
+ > e o guia de verificação estão em **https://skills.seu.pet/gratis/ai-test-loop?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop**
15
+
16
+ ---
17
+
18
+ ## The problem
19
+
20
+ Ask an agent "is this done?" and it answers about its own reasoning. The same context that produced
21
+ the artifact also decides it is correct, so real defects survive to delivery:
22
+
23
+ - a handler that is never called (tests pass, feature dead)
24
+ - a test that passes because it asserts nothing
25
+ - a page that breaks only at mobile width
26
+ - a migration that works on the author's data and nowhere else
27
+ - a "fix" that reverted a config someone changed on purpose
28
+
29
+ Running "one more review pass" does not help, because the reviewer and the builder are the same
30
+ process. Separating them is the whole trick.
31
+
32
+ ## Quickstart
33
+
34
+ ```bash
35
+ git clone https://github.com/Samuelfmedeiros/ai-test-loop
36
+ cd ai-test-loop
37
+
38
+ # 1. describe your checks (name, command, weight — weights sum to 100)
39
+ cat > loop.json <<'JSON'
40
+ {
41
+ "name": "my-project",
42
+ "threshold": 85,
43
+ "max_rounds": 8,
44
+ "checks": [
45
+ {"name": "unit tests", "cmd": "pytest -q", "weight": 35, "timeout_s": 900},
46
+ {"name": "type check", "cmd": "mypy src", "weight": 10},
47
+ {"name": "build", "cmd": "npm run build", "weight": 20},
48
+ {"name": "api up", "cmd": "curl -sS 127.0.0.1:8000/health", "weight": 15,
49
+ "must_match": "\"status\"\\s*:\\s*\"ok\""},
50
+ {"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 20,
51
+ "fail_if_output_matches": "leaks found: [1-9]", "required": true}
52
+ ]
53
+ }
54
+ JSON
55
+
56
+ # 2. run the critic
57
+ python3 scripts/loop_runner.py --config loop.json
58
+ ```
59
+
60
+ Output is machine-readable, so the *agent* can act on it and a CI job can gate on it:
61
+
62
+ ```
63
+ SCORE=72
64
+ GAPS=["build: command exited 1", "api up: output did not match pattern"]
65
+ EVIDENCE=.loop/report-1.json
66
+ ```
67
+
68
+ Fix the **single largest** gap, run `--round 2`, repeat. Cap the rounds (8 is plenty): if the score
69
+ never converges, the check set is wrong, not the code.
70
+
71
+ A self-contained demo lives in `examples/demo` and runs in CI on every push, so you can see a real
72
+ pass and a real fail without configuring anything.
73
+
74
+ ## The four roles (never merge them)
75
+
76
+ | Role | Who | Job |
77
+ |---|---|---|
78
+ | Orchestrator | the agent running the task | picks checks, runs rounds, owns the report |
79
+ | Builder | the agent/model that wrote the artifact | fixes one class of gap per pass |
80
+ | Critic | this script + your checks | runs real commands, returns score + gaps |
81
+ | Reviewer | a **separate** context/model | audits critic and builder, can reopen the loop |
82
+
83
+ Rule zero: builder ≠ critic ≠ reviewer. If one context does all three, the loop is theatre.
84
+
85
+ ## Three rules that make it honest
86
+
87
+ 1. **Every point is earned by a command that actually ran.** No points for "read the code". Weights
88
+ sum to 100 and the report stores the raw output next to each score.
89
+ 2. **`NOT VERIFIABLE` is not a pass.** If a check cannot run (missing device, no network, jail), it
90
+ scores 0 and the report says why. Silent skips are how a green board lies.
91
+ 3. **The security gate is fail-closed.** A secret in the diff or history, a high-severity SAST
92
+ finding, or an auth/path-traversal/injection issue found in review blocks delivery **even with a
93
+ perfect score**. An inconclusive answer counts as blocked, never as pass.
94
+
95
+ ## Reviewer panel
96
+
97
+ For anything other people will use, ask 2–3 specialists instead of one generalist — quality and
98
+ security always, performance only if there is a build or runtime cost, UX only if there is a
99
+ user-facing surface. Each returns strict JSON:
100
+
101
+ ```json
102
+ {"parecer":"CONCORDO|DISCORDO","nota":0-100,"gaps":["..."],"texto":"one paragraph with evidence"}
103
+ ```
104
+
105
+ Consensus → deliver. Majority disagree → reopen the loop with the combined gaps. Prompt in
106
+ `references/reviewer-prompt.md`, starting rubric in `references/scoring-rubric.md`.
107
+
108
+ ## When not to use it
109
+
110
+ Throwaway exploration, spike branches, and one-line answers where the cost of the loop exceeds the
111
+ cost of being wrong. It is for artifacts a human will rely on.
112
+
113
+ ## Files
114
+
115
+ | Path | What |
116
+ |---|---|
117
+ | `SKILL.md` | the loop, the scoring rules, the fail-closed gate — drop it in your agent's skills dir |
118
+ | `scripts/loop_runner.py` | the deterministic critic (stdlib only) |
119
+ | `references/reviewer-prompt.md` | independent-review prompt, strict JSON output |
120
+ | `references/scoring-rubric.md` | starting weights |
121
+ | `templates/loop.example.json` | config with the usual check set |
122
+ | `examples/demo/` | self-contained example used by CI |
123
+
124
+ ## Get the full pack
125
+
126
+ This repo is the free, complete tool. The store has the ready-made packs for the cases where
127
+ writing the checks is the slow part — CI gates, visual regression, MCP servers, MLOps guards:
128
+
129
+ - Free download / e-mail delivery: **https://skills.seu.pet/gratis/ai-test-loop?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop**
130
+ - Catalog: **https://skills.seu.pet/?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop**
131
+ - Want it installed and wired into your repo: **https://skills.seu.pet/servico?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop**
132
+
133
+ ## License
134
+
135
+ MIT — see `LICENSE`. Use it commercially, fork it, ship it.
136
+
137
+ ---
138
+
139
+ ### Em português
140
+
141
+ O loop de teste para agentes: o construtor não assina o próprio trabalho. Um crítico roda checagens
142
+ de verdade, dá nota 0–100, repete até o limite e barra a entrega se houver achado de segurança.
143
+ `python3 scripts/loop_runner.py --config loop.json` — sem chave de API e sem dependência.
144
+
145
+ Versão com entrega por e-mail e os packs prontos: https://skills.seu.pet/gratis/ai-test-loop?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop
package/SKILL.md ADDED
@@ -0,0 +1,111 @@
1
+ ---
2
+ name: ai-test-loop
3
+ description: "Use before declaring any work done or shipped, when the agent must verify its own output, when a deliverable needs a score before release, when the builder keeps approving its own work, or when repeated review rounds never converge. Runs an independent critic that scores the artifact 0-100 with real checks, loops until the threshold, and blocks delivery on a fail-closed security gate."
4
+ ---
5
+
6
+ # AI Test Loop — never ship unverified work
7
+
8
+ The builder is not allowed to grade its own homework. This skill turns "looks done" into
9
+ "verified, scored, and independently reviewed" — the loop either reaches the threshold with
10
+ evidence, or it reports exactly which gaps remain.
11
+
12
+ Use it whenever an agent produced something a human will rely on: code, UI, a pipeline, a
13
+ document, a data migration. Not for throwaway exploration.
14
+
15
+ ## The four roles (never merge them)
16
+
17
+ | Role | Who | Job |
18
+ |---|---|---|
19
+ | Orchestrator | the agent running the task | picks checks, runs rounds, fixes infrastructure, owns the report |
20
+ | Builder | the agent/model that wrote the artifact | fixes gaps, one class of gap per pass |
21
+ | Critic | a **script you write** (`loop_runner.py` + your checks) | runs real commands, returns a 0-100 score and a gap list |
22
+ | Reviewer | a **separate** agent/model context | audits critic + builder, votes, can reopen the loop |
23
+
24
+ Rule zero: builder ≠ critic ≠ reviewer. If the same context does all three, the loop is theatre.
25
+ Run steps 1-3 below in the working session, then drive the script for the machine part.
26
+
27
+ ## The loop
28
+
29
+ 1. **Declare the checks before writing code.** For each check: name, command, weight, and what
30
+ "pass" means. Weights must sum to 100.
31
+ 2. **Run the critic** — `python scripts/loop_runner.py --config loop.json --round N`. It executes
32
+ every command for real, records raw output as evidence, and prints `SCORE=<0-100>` and
33
+ `GAPS=[...]`. It never asks a model whether the work is good; it measures.
34
+ 3. **Score, compare, decide.** `SCORE >= threshold` → go to the reviewer. Otherwise pick the
35
+ **single largest** gap, fix it, and re-run. Cap the rounds (8 is plenty); if it never
36
+ converges, the check set is wrong, not the code.
37
+ 4. **Independent review** before delivery — same artifact, fresh context, no justification from
38
+ the builder: `references/reviewer-prompt.md`. The reviewer returns strict JSON.
39
+ 5. **Security gate, fail-closed.** Any secret in the diff/history, any high-severity SAST finding,
40
+ any auth/path-traversal/injection issue found by review blocks delivery even with perfect
41
+ tests. Treat an inconclusive answer as blocked, never as pass.
42
+ 6. **Report** with the score, the evidence, the reviewer verdict, and what was *not* verifiable.
43
+
44
+ ## Scoring
45
+
46
+ Weights are yours to set; start from `references/scoring-rubric.md`. Non-negotiables:
47
+
48
+ - Every point must be earned by a command that actually ran. No points for "read the code".
49
+ - Evidence is captured, not summarised: keep the raw output next to the score.
50
+ - If a check cannot run (no environment, no device, dependency missing), it scores 0 and the
51
+ report says `NOT VERIFIABLE: <reason>` — never silently skipped, never counted as passing.
52
+
53
+ ## Reviewer panel (for anything that will be used by others)
54
+
55
+ Ask 2-3 specialists instead of one generalist, each returning strict JSON:
56
+ `{"parecer":"CONCORDO|DISCORDO","nota":0-100,"gaps":["..."],"texto":"one paragraph with evidence"}`.
57
+
58
+ - Profiles by nature of the work: quality (always), security (always), performance (only if the
59
+ artifact has a build or a runtime cost), UX (only if there is a user-facing surface).
60
+ - **Consensus** → deliver. **Majority disagree** → reopen the loop with the combined gaps.
61
+ **Security disagrees or is inconclusive** → block. **Tie 1-1** → a third specialist decides.
62
+ - Do not send an empty `gaps` array as a formality: a reviewer that finds nothing must still cite
63
+ the evidence it inspected.
64
+
65
+ ## Pitfalls (each one measured in production)
66
+
67
+ - **Template echo.** A model asked to answer with an enum often echoes the enum back
68
+ (`"CONCORDO|DISCORDO"`). Reject any value that matches the literal template, treat it as
69
+ inconclusive, retry once, then fail closed. A parser that accepts the echo reopens the loop
70
+ forever with a false gap.
71
+ - **Piping hides the exit code.** `pytest | tail` returns the exit code of `tail` (0) — the critic
72
+ scores 100 with a failing test hidden in the output. Use `set -o pipefail` (or read exit codes
73
+ without a pipe) inside every check.
74
+ - **A timeout shorter than the check is a silent zero.** When the critic is killed by its own
75
+ timeout, the orchestrator sees a score of 0 with an empty gap list and loops with nothing to fix.
76
+ Set the timeout above the real duration and always keep the critic's raw log.
77
+ - **A short-circuiting "pass" is not a pass.** A server that responds 200 may be the wrong
78
+ server: on a port collision the check tests a different app and "fails" for the wrong reason.
79
+ Assert identity, not reachability (title, version, a known payload).
80
+ - **Stale baselines.** A visual or snapshot baseline regenerated "to make it green" erases the
81
+ regression. Regenerating is allowed only when the change was intended — and it must be stated
82
+ in the report, with the diff before and after.
83
+ - **Cold start.** First call to a cold service can take seconds; re-test before declaring a
84
+ timeout, and record that you retested.
85
+ - **Fixing many gap classes at once.** One worker per gap class keeps the next verdict readable.
86
+ Batch everything and you cannot tell which change moved the score.
87
+
88
+ ## Output format
89
+
90
+ ```
91
+ ROUND: <n> SCORE: <0-100> THRESHOLD: <t> GATE: <fail-closed | passed>
92
+ CHECKS: <name> PASS|FAIL <weight> — <one line of real evidence>
93
+ GAPS: <sorted by weight, biggest first>
94
+ REVIEWER: <CONCORDO|DISCORDO> <nota> — <evidence cited> | panel: q=… s=… p=… u=…
95
+ SECURITY: <clean | BLOCKED: <finding>> | INCONCLUSIVE → blocked
96
+ NOT VERIFIABLE: <check — reason>
97
+ NEXT: <single next action>
98
+ ```
99
+
100
+ ## Scope and network access
101
+
102
+ Runs only the commands you put in the config, on your machine. No network calls of its own, no
103
+ telemetry, no uploads; the security step greps the repo locally. It never deploys and never
104
+ merges: delivery decisions stay with the human owner.
105
+
106
+ ## Files
107
+
108
+ - `scripts/loop_runner.py` — the critic: runs weighed checks, scores 0-100, prints gaps, keeps state.
109
+ - `templates/loop.example.json` — a ready-to-edit config; copy it next to the project and adjust.
110
+ - `references/scoring-rubric.md` — starting weights per artifact type.
111
+ - `references/reviewer-prompt.md` — the strict-JSON reviewer prompt plus the anti-echo parser rules.
package/bin/cli.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Wrapper do critic determinístico (Python) para uso via `npx ai-test-loop`.
3
+ // Por que existe: o runner é Python puro (stdlib) e a porta de entrada que dev
4
+ // usa é `npx`. O wrapper só acha o interpretador e repassa os argumentos.
5
+ const { spawn } = require("node:child_process");
6
+ const path = require("node:path");
7
+ const fs = require("node:fs");
8
+
9
+ const script = path.join(__dirname, "..", "scripts", "loop_runner.py");
10
+ if (!fs.existsSync(script)) {
11
+ console.error("ai-test-loop: runner não encontrado em " + script);
12
+ process.exit(2);
13
+ }
14
+
15
+ const candidatos = process.env.AI_TEST_LOOP_PYTHON
16
+ ? [process.env.AI_TEST_LOOP_PYTHON]
17
+ : ["python3", "python"];
18
+
19
+ function tentar(i) {
20
+ if (i >= candidatos.length) {
21
+ console.error("ai-test-loop: Python 3 não encontrado (tente AI_TEST_LOOP_PYTHON=/caminho/python3)");
22
+ process.exit(2);
23
+ }
24
+ const p = spawn(candidatos[i], [script, ...process.argv.slice(2)], { stdio: "inherit" });
25
+ p.on("error", () => tentar(i + 1));
26
+ p.on("exit", (code, signal) => process.exit(signal ? 1 : code ?? 0));
27
+ }
28
+
29
+ tentar(0);
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "ai-test-loop",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.1",
4
+ "description": "Deterministic critic for AI agents: runs weighed checks for real, scores 0-100 and blocks delivery below the threshold. Stdlib-only runner.",
5
+ "license": "MIT",
6
+ "author": "Samuel Medeiros",
7
+ "homepage": "https://skills.seu.pet/en/p/ai-test-loop?utm_source=npm&utm_medium=readme&utm_campaign=ai-test-loop",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Samuelfmedeiros/ai-test-loop.git"
11
+ },
12
+ "keywords": [
13
+ "ai-agents",
14
+ "test-loop",
15
+ "quality-gate",
16
+ "critic",
17
+ "llm",
18
+ "claude-code",
19
+ "codex"
20
+ ],
21
+ "bin": {
22
+ "ai-test-loop": "bin/cli.js"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "scripts/",
27
+ "templates/",
28
+ "references/",
29
+ "SKILL.md",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=16"
35
+ },
36
+ "scripts": {
37
+ "smoke": "node bin/cli.js --config templates/loop.example.json --dry-run"
38
+ }
6
39
  }
@@ -0,0 +1,61 @@
1
+ # Reviewer prompt (independent critic) — strict JSON, fail-closed
2
+
3
+ Send this to a **separate** model context that did not write the artifact. Never paste the
4
+ builder's justification: the reviewer must see the artifact and the evidence, not the defence.
5
+ Include: the artifact (diff or files), the critic report (`loop-report-rN.json`), the acceptance
6
+ criteria, and the raw evidence of the checks that failed.
7
+
8
+ ---
9
+
10
+ You are the independent reviewer of a deliverable you did not write. You have no stake in it
11
+ being accepted. Your job is to find what is wrong, not to be agreeable.
12
+
13
+ Artifact: <paste diff / file list / content>
14
+ Critic report: <paste SCORE, GAPS, evidence>
15
+ Acceptance criteria: <paste>
16
+
17
+ Answer with ONE JSON object and nothing else:
18
+
19
+ {
20
+ "parecer": "CONCORDO" | "DISCORDO",
21
+ "nota": <integer 0-100>,
22
+ "gaps": ["<gap 1>", "<gap 2>"],
23
+ "texto": "<one paragraph citing concrete evidence: file, line, command output>"
24
+ }
25
+
26
+ Rules:
27
+
28
+ 1. `CONCORDO` means you inspected the artifact and the evidence and believe it is safe to deliver
29
+ at the stated threshold. It does not mean the work is perfect.
30
+ 2. `DISCORDO` requires at least one concrete gap with evidence. A gap is actionable and specific
31
+ ("handler at src/api.py:88 returns 500 when payload is empty"), never vague ("could be cleaner").
32
+ 3. Check these in order, and stop at the first blocking finding:
33
+ a. Do the checks actually exercise the deliverable, or do they test a different process,
34
+ an old build, or a mock of the very thing under test?
35
+ b. Is any security-relevant change present (secrets, auth, crypto, injection, path traversal,
36
+ deserialisation, dependency additions)? If yes, judge it explicitly.
37
+ c. Are there irreversible or destructive operations (DELETE, DROP, TRUNCATE, force push,
38
+ overwrite of production data) without a stated backup and rollback?
39
+ d. Does the score come from real commands, with raw evidence, or from a summary someone wrote?
40
+ 4. If you cannot decide because information is missing, answer `DISCORDO` with
41
+ `"gaps": ["INCONCLUSIVE: <what is missing>"]` and `"nota": 0`. Inconclusive blocks.
42
+ 5. Do not reward length, style, or effort. Only correctness, evidence, and safety.
43
+ 6. Cite at least one piece of inspected evidence in `texto`, even when you agree.
44
+
45
+ ---
46
+
47
+ ## Parser rules (put these in the orchestrator, not in the model)
48
+
49
+ - **Anti-echo.** Models frequently return the enum template literally: `"parecer": "CONCORDO|DISCORDO"`
50
+ or `nota: <integer 0-100>`. If the value equals the template, contains `|`, or is not one of the
51
+ allowed literals, treat the answer as inconclusive: retry once with the template restated as a
52
+ plain sentence, then fail closed (block).
53
+ - **Strict parse.** Extract the first balanced `{...}` block; `json.loads` it. Anything else (prose,
54
+ markdown fences, trailing commentary) is ignored, not interpreted.
55
+ - **Missing keys.** A missing `gaps` list is treated as `[]`; a missing `nota` is treated as 0.
56
+ - **Numeric bounds.** Clamp `nota` to 0-100; a float is rounded down.
57
+ - **Consensus.** Panel of 2-3 reviewers, all `CONCORDO` → deliver. Any `DISCORDO` → reopen the loop
58
+ with the union of gaps. Tie → a third reviewer decides. Security `DISCORDO` or inconclusive →
59
+ block, regardless of the other votes and of the score.
60
+ - **Cost control.** Reviewer calls cost tokens: send the diff and the failing evidence, not the
61
+ whole repository, and keep a per-round cap on reviewer invocations.
@@ -0,0 +1,60 @@
1
+ # Starting weights per artifact type
2
+
3
+ Weights are normalised by `loop_runner.py`, so they need not sum to 100 — but keep them readable.
4
+ A check with weight 0 is useless (it can never move the score), and a check with weight 80 makes
5
+ every other check decoration. Copy the row that matches the artifact and tune after one round.
6
+
7
+ ## Backend / API service
8
+
9
+ | Check | Command example | Weight |
10
+ |---|---|---|
11
+ | unit tests | `pytest -q` | 35 |
12
+ | integration / e2e | `pytest -q tests/e2e` | 20 |
13
+ | type check | `mypy src` | 10 |
14
+ | real HTTP call | `curl -sS localhost:8000/health` with `must_match` on a known payload | 15 |
15
+ | no secrets | `gitleaks detect --no-banner` with `fail_if_output_matches` | 20 |
16
+
17
+ ## Web app / UI
18
+
19
+ | Check | Command example | Weight |
20
+ |---|---|---|
21
+ | build | `npm run build` | 25 |
22
+ | unit tests | `npm test -- --run` | 20 |
23
+ | e2e smoke | `npx playwright test --grep @smoke` | 25 |
24
+ | desktop + mobile screenshot | screenshot script, `must_match` on a non-empty page title | 15 |
25
+ | accessibility | `npx axe` or Lighthouse CI budget | 15 |
26
+
27
+ Visual baselines: a snapshot diff is a check, never a pass by regeneration. If the baseline was
28
+ regenerated, say so in the report and attach the before/after diff.
29
+
30
+ ## Data pipeline / migration
31
+
32
+ | Check | Command example | Weight |
33
+ |---|---|---|
34
+ | row counts before vs after | script printing `src=… dst=…` with `must_match` on equality | 30 |
35
+ | schema matches | `alembic check` / `pg_dump --schema-only` diff | 25 |
36
+ | dry run on a copy | pipeline run against a scratch database | 25 |
37
+ | no destructive statements | `grep -nE 'DROP|TRUNCATE'` with `fail_if_output_matches` | 20 |
38
+
39
+ ## Document / report
40
+
41
+ | Check | Command example | Weight |
42
+ |---|---|---|
43
+ | required sections present | grep for each heading, one check per group | 30 |
44
+ | links resolve | `curl -sI` each URL, `expect_exit: 0` | 20 |
45
+ | numbers traceable to source | command re-deriving the totals | 30 |
46
+ | no placeholders left | `grep -nE 'TODO|TBD|lorem'` with `fail_if_output_matches` | 20 |
47
+
48
+ ## Threshold
49
+
50
+ - 85-90 for anything user-facing or public.
51
+ - 95-100 for money, auth, migrations, or irreversible actions — and the security check must be
52
+ `required: true`.
53
+ - Below 85 the loop keeps going; if three rounds move the score by less than 2 points, the checks
54
+ are measuring noise — fix the checks before fixing the code.
55
+
56
+ ## Non-blocking checks
57
+
58
+ Mark `required: false` for quality-nice-to-haves (coverage delta, bundle-size budget, docs polish).
59
+ They still score 0 when they fail and they are printed as `NON_BLOCKING_GAPS`, but they do not gate
60
+ delivery. Never mark a security, data-integrity or destructive-operation check as non-blocking.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """loop_runner — the deterministic critic of the AI Test Loop.
3
+
4
+ Runs a list of weighed checks for real, captures their raw output as evidence, and prints:
5
+
6
+ SCORE=<0-100>
7
+ GAPS=["<check>: <why it failed>", ...]
8
+ EVIDENCE=<path to the JSON report>
9
+
10
+ The agent (builder) is not allowed to grade its own work; this script measures. It is
11
+ deliberately dumb: exit codes + optional output patterns decide pass/fail. No model calls,
12
+ no network, no telemetry.
13
+
14
+ Config (JSON):
15
+
16
+ {
17
+ "name": "my-project",
18
+ "threshold": 85,
19
+ "max_rounds": 8,
20
+ "checks": [
21
+ {"name": "unit tests", "cmd": "pytest -q", "weight": 40, "timeout_s": 900},
22
+ {"name": "build", "cmd": "npm run build", "weight": 25, "timeout_s": 600},
23
+ {"name": "lint", "cmd": "ruff check .", "weight": 10},
24
+ {"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 15,
25
+ "fail_if_output_matches": "leaks found: [1-9]"},
26
+ {"name": "api responds", "cmd": "curl -sS localhost:8000/health", "weight": 10,
27
+ "must_match": "\"status\"\\s*:\\s*\"ok\""}
28
+ ]
29
+ }
30
+
31
+ Check keys:
32
+ cmd (required) shell command; runs with pipefail semantics via bash -o pipefail -c
33
+ weight (required) points; all weights are normalised, so they need not sum to 100
34
+ timeout_s (optional, default 900) hard timeout — a timeout is a FAIL, never a skip
35
+ expect_exit (optional, default 0) exit code that means pass
36
+ must_match (optional) regex that MUST appear in combined output
37
+ fail_if_output_matches (optional) regex that fails the check when it appears
38
+ required (optional, default true) when false, a FAIL scores 0 but is reported as a
39
+ non-blocking gap (useful for "nice to have" checks)
40
+ cwd (optional) working directory for this check
41
+
42
+ Usage:
43
+ python loop_runner.py --config loop.json [--round 2] [--json] [--quiet]
44
+ python loop_runner.py --config loop.json --show-state # print round history and stop
45
+ python loop_runner.py --config loop.json --dry-run # list checks, run nothing
46
+
47
+ Exit code: 0 when SCORE >= threshold (gate passed), 1 when below, 2 on usage/config error.
48
+
49
+ State: <config dir>/.<config-stem>-state.json accumulates every round, so a crash or a restart
50
+ never loses the evidence trail. The report of the latest round is written to
51
+ <config dir>/<config-stem>-report-r<round>.json. Names are derived from the config file so two
52
+ projects in the same folder never share a history.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import os
59
+ import pathlib
60
+ import re
61
+ import shlex
62
+ import subprocess
63
+ import sys
64
+ import time
65
+ from datetime import datetime, timezone
66
+
67
+ DEFAULT_TIMEOUT = 900
68
+ MAX_OUTPUT_CHARS = 8000
69
+
70
+
71
+ def load_config(path: str) -> dict:
72
+ try:
73
+ with open(path, "r", encoding="utf-8") as fh:
74
+ cfg = json.load(fh)
75
+ except FileNotFoundError:
76
+ sys.exit(f"[loop_runner] config not found: {path}")
77
+ except json.JSONDecodeError as exc:
78
+ sys.exit(f"[loop_runner] config is not valid JSON: {exc}")
79
+ checks = cfg.get("checks")
80
+ if not isinstance(checks, list) or not checks:
81
+ sys.exit("[loop_runner] config needs a non-empty 'checks' list")
82
+ for i, chk in enumerate(checks):
83
+ if not chk.get("cmd"):
84
+ sys.exit(f"[loop_runner] check #{i} has no 'cmd'")
85
+ if not isinstance(chk.get("weight", None), (int, float)) or chk["weight"] <= 0:
86
+ sys.exit(f"[loop_runner] check {chk.get('name', i)!r} needs a positive 'weight'")
87
+ cfg.setdefault("name", os.path.basename(os.path.dirname(os.path.abspath(path))) or "loop")
88
+ cfg.setdefault("threshold", 85)
89
+ cfg.setdefault("max_rounds", 8)
90
+ return cfg
91
+
92
+
93
+ def run_check(chk: dict) -> dict:
94
+ """Run one check for real, return its result record with raw evidence."""
95
+ name = chk.get("name") or chk["cmd"][:40]
96
+ timeout = int(chk.get("timeout_s", DEFAULT_TIMEOUT))
97
+ cwd = chk.get("cwd") or None
98
+ expect_exit = int(chk.get("expect_exit", 0))
99
+ # bash -o pipefail: without it, `pytest | tail` returns tail's exit code (0) and a failing
100
+ # check scores as passing — the single most common false green in this kind of runner.
101
+ wrapped = ["bash", "-o", "pipefail", "-c", chk["cmd"]]
102
+ started = time.time()
103
+ timed_out = False
104
+ try:
105
+ proc = subprocess.run(
106
+ wrapped, cwd=cwd, capture_output=True, text=True, timeout=timeout,
107
+ stdin=subprocess.DEVNULL,
108
+ )
109
+ rc, out, err = proc.returncode, proc.stdout or "", proc.stderr or ""
110
+ except subprocess.TimeoutExpired as exc:
111
+ timed_out = True
112
+ rc = None
113
+ out = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
114
+ err = (exc.stderr or b"").decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
115
+ duration = round(time.time() - started, 2)
116
+ combined = (out or "") + ("\n" + err if err else "")
117
+
118
+ reasons: list[str] = []
119
+ if timed_out:
120
+ reasons.append(f"timed out after {timeout}s")
121
+ elif rc != expect_exit:
122
+ reasons.append(f"exit code {rc}, expected {expect_exit}")
123
+
124
+ must = chk.get("must_match")
125
+ if must and not re.search(must, combined):
126
+ reasons.append(f"output does not match required pattern /{must}/")
127
+
128
+ fail_pat = chk.get("fail_if_output_matches")
129
+ if fail_pat:
130
+ hit = re.search(fail_pat, combined)
131
+ if hit:
132
+ reasons.append(f"output matched failure pattern /{fail_pat}/: {hit.group(0)!r}")
133
+
134
+ passed = not reasons
135
+ evidence = combined.strip()
136
+ if len(evidence) > MAX_OUTPUT_CHARS:
137
+ evidence = evidence[: MAX_OUTPUT_CHARS // 2] + "\n...[truncated]...\n" + evidence[-MAX_OUTPUT_CHARS // 2:]
138
+ return {
139
+ "name": name,
140
+ "cmd": chk["cmd"],
141
+ "weight": float(chk["weight"]),
142
+ "required": bool(chk.get("required", True)),
143
+ "passed": passed,
144
+ "reasons": reasons,
145
+ "exit_code": rc,
146
+ "duration_s": duration,
147
+ "evidence": evidence,
148
+ }
149
+
150
+
151
+ def score(checks: list[dict]) -> float:
152
+ total = sum(c["weight"] for c in checks) or 1.0
153
+ earned = sum(c["weight"] for c in checks if c["passed"])
154
+ return round(earned / total * 100, 1)
155
+
156
+
157
+ def main() -> int:
158
+ ap = argparse.ArgumentParser(add_help=True, description="Deterministic critic for the AI Test Loop")
159
+ ap.add_argument("--config", required=True)
160
+ ap.add_argument("--round", type=int)
161
+ ap.add_argument("--json", action="store_true", help="also dump the full report as JSON to stdout")
162
+ ap.add_argument("--quiet", action="store_true", help="only SCORE/GAPS/EVIDENCE lines")
163
+ ap.add_argument("--dry-run", action="store_true")
164
+ ap.add_argument("--show-state", action="store_true")
165
+ args = ap.parse_args()
166
+
167
+ cfg_path = os.path.abspath(args.config)
168
+ cfg = load_config(cfg_path)
169
+ base = os.path.dirname(cfg_path)
170
+ # Per-config filenames: two configs in the same folder must not share one state file,
171
+ # otherwise rounds from project A pollute project B's history and round numbers.
172
+ stem = os.path.splitext(os.path.basename(cfg_path))[0]
173
+ state_path = os.path.join(base, f".{stem}-state.json")
174
+
175
+ if args.show_state:
176
+ if not os.path.exists(state_path):
177
+ print("[loop_runner] no state file yet")
178
+ return 0
179
+ with open(state_path, "r", encoding="utf-8") as fh:
180
+ st = json.load(fh)
181
+ for r in st.get("rounds", []):
182
+ print(f"round {r['round']}: SCORE={r['score']} threshold={r['threshold']} "
183
+ f"failed={[c['name'] for c in r['checks'] if not c['passed']]}")
184
+ return 0
185
+
186
+ if args.dry_run:
187
+ for c in cfg["checks"]:
188
+ print(f"[dry-run] {c.get('name', c['cmd'][:40])} weight={c['weight']} cmd={c['cmd']}")
189
+ print(f"[dry-run] threshold={cfg['threshold']} max_rounds={cfg['max_rounds']}")
190
+ return 0
191
+
192
+ round_no = args.round
193
+ if round_no is None:
194
+ prev = 0
195
+ if os.path.exists(state_path):
196
+ with open(state_path, "r", encoding="utf-8") as fh:
197
+ prev = len(json.load(fh).get("rounds", []))
198
+ round_no = prev + 1
199
+
200
+ if round_no > int(cfg["max_rounds"]):
201
+ print(f"ROUND: {round_no} ABORT: exceeded max_rounds={cfg['max_rounds']}")
202
+ print("GAPS=[\"round budget exhausted — the check set or the threshold needs revisiting\"]")
203
+ return 1
204
+
205
+ results = []
206
+ for chk in cfg["checks"]:
207
+ if not args.quiet:
208
+ print(f"[loop_runner] round {round_no}: running {chk.get('name', chk['cmd'][:40])!r} ...",
209
+ file=sys.stderr, flush=True)
210
+ results.append(run_check(chk))
211
+
212
+ value = score(results)
213
+ threshold = float(cfg["threshold"])
214
+ gate = value >= threshold
215
+ gaps = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and c["required"]]
216
+ soft = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and not c["required"]]
217
+
218
+ report = {
219
+ "project": cfg["name"],
220
+ "round": round_no,
221
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
222
+ "threshold": threshold,
223
+ "score": value,
224
+ "gate": "passed" if gate else "failed",
225
+ "checks": results,
226
+ "gaps": gaps,
227
+ "non_blocking_gaps": soft,
228
+ }
229
+ report_path = os.path.join(base, f"{stem}-report-r{round_no}.json")
230
+ pathlib.Path(report_path).write_text(
231
+ json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
232
+
233
+ state = {"project": cfg["name"], "rounds": []}
234
+ if os.path.exists(state_path):
235
+ try:
236
+ with open(state_path, "r", encoding="utf-8") as fh:
237
+ state = json.load(fh)
238
+ state.setdefault("rounds", [])
239
+ except json.JSONDecodeError:
240
+ pass
241
+ state["rounds"].append({"round": round_no, "score": value, "threshold": threshold,
242
+ "gate": report["gate"], "ts": report["ts"], "report": report_path,
243
+ "checks": results})
244
+ pathlib.Path(state_path).write_text(
245
+ json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
246
+
247
+ if not args.quiet:
248
+ for c in results:
249
+ status = "PASS" if c["passed"] else "FAIL"
250
+ detail = c["evidence"].strip().splitlines()[-1][:160] if c["evidence"].strip() else "(no output)"
251
+ print(f" {status} {c['name']:<28} w={c['weight']:<5} {c['duration_s']:>7}s {detail}")
252
+
253
+ print(f"SCORE={value}")
254
+ print(f"THRESHOLD={threshold}")
255
+ print(f"GATE={'passed' if gate else 'failed'} (round {round_no}/{cfg['max_rounds']})")
256
+ print(f"GAPS={json.dumps(gaps, ensure_ascii=False)}")
257
+ if soft:
258
+ print(f"NON_BLOCKING_GAPS={json.dumps(soft, ensure_ascii=False)}")
259
+ print(f"EVIDENCE={report_path}")
260
+ if args.json:
261
+ print(json.dumps(report, ensure_ascii=False, indent=2))
262
+ return 0 if gate else 1
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(main())
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "my-project",
3
+ "threshold": 85,
4
+ "max_rounds": 8,
5
+ "checks": [
6
+ {"name": "unit tests", "cmd": "pytest -q", "weight": 35, "timeout_s": 900},
7
+ {"name": "type check", "cmd": "mypy src", "weight": 10, "timeout_s": 300},
8
+ {"name": "build", "cmd": "npm run build", "weight": 20, "timeout_s": 600},
9
+ {"name": "api responds", "cmd": "curl -sS 127.0.0.1:8000/health", "weight": 15,
10
+ "must_match": "\"status\"\\s*:\\s*\"ok\""},
11
+ {"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 20,
12
+ "fail_if_output_matches": "leaks found: [1-9]",
13
+ "required": true}
14
+ ]
15
+ }