loopagent-gate 0.4.2__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.
Files changed (40) hide show
  1. loopagent_gate-0.4.2.dist-info/METADATA +374 -0
  2. loopagent_gate-0.4.2.dist-info/RECORD +40 -0
  3. loopagent_gate-0.4.2.dist-info/WHEEL +5 -0
  4. loopagent_gate-0.4.2.dist-info/entry_points.txt +2 -0
  5. loopagent_gate-0.4.2.dist-info/licenses/LICENSE +21 -0
  6. loopagent_gate-0.4.2.dist-info/top_level.txt +1 -0
  7. loopgate/__init__.py +7 -0
  8. loopgate/__main__.py +8 -0
  9. loopgate/cli.py +147 -0
  10. loopgate/gates/anchor_witness.py +249 -0
  11. loopgate/gates/assert_dims.py +88 -0
  12. loopgate/gates/bootstrap_state.py +111 -0
  13. loopgate/gates/change_health.py +232 -0
  14. loopgate/gates/check_loop.py +1267 -0
  15. loopgate/gates/check_product.py +463 -0
  16. loopgate/gates/check_visual.py +224 -0
  17. loopgate/gates/doctor.py +130 -0
  18. loopgate/gates/estimate_task.py +137 -0
  19. loopgate/gates/export_public.py +289 -0
  20. loopgate/gates/guard_card.py +519 -0
  21. loopgate/gates/loop_watch.py +118 -0
  22. loopgate/gates/regressions.py +463 -0
  23. loopgate/gates/render_status_page.py +151 -0
  24. loopgate/gates/render_task_ledger.py +312 -0
  25. loopgate/gates/review_pr.py +370 -0
  26. loopgate/gates/sync_issues_to_kanban.py +593 -0
  27. loopgate/gates/tree_parity.py +307 -0
  28. loopgate/gates/worker_brief.py +396 -0
  29. loopgate/init.py +128 -0
  30. loopgate/paths.py +43 -0
  31. loopgate/py.typed +0 -0
  32. loopgate/templates/__init__.py +1 -0
  33. loopgate/templates/configs/anchor_witness.example.json +14 -0
  34. loopgate/templates/configs/fixtures/issues_sample.json +75 -0
  35. loopgate/templates/configs/fixtures/review_approve.json +5 -0
  36. loopgate/templates/configs/fixtures/review_changes.json +5 -0
  37. loopgate/templates/configs/fixtures/review_no_verdict.json +5 -0
  38. loopgate/templates/configs/repo_profiles.example.json +63 -0
  39. loopgate/templates/configs/tree_parity.example.json +16 -0
  40. loopgate/templates/skills/loop-engineering/SKILL.md +281 -0
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ """Visual checks — what an operator *sees*, not only what the JSON says.
3
+
4
+ Why a separate script: the product and technical gates read machine reports. A person reads a page. The
5
+ difference matters — a page can be technically correct and visually wrong (a FAIL row rendered green, a
6
+ verdict buried below the fold, an empty queue table that looks like a broken page). Those are user-facing
7
+ defects that no JSON assertion catches, and they are exactly what the `qa` role is for.
8
+
9
+ Mechanism: render the operator status page, serve it locally, capture a screenshot and the DOM with headless
10
+ Chrome, then judge the DOM against the scenario's required/forbidden text. The screenshot is kept as an
11
+ artefact for the vision review step (a human or the agent looks at it; a text assertion cannot see layout).
12
+
13
+ Requires Google Chrome (headless); fail-closed when it is absent — a visual check that silently skips is
14
+ worse than no visual check.
15
+
16
+ Usage
17
+ -----
18
+ python3 -m loopgate.gates.check_visual --list
19
+ python3 -m loopgate.gates.check_visual # capture + judge every scenario
20
+ python3 -m loopgate.gates.check_visual --strict # SKIP becomes FAIL
21
+ python3 -m loopgate.gates.check_visual --scenario V01 --verbose
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from loopgate.paths import project_root, gate_file
27
+
28
+ import argparse
29
+ import datetime as dt
30
+ import functools
31
+ import http.server
32
+ import json
33
+ import os
34
+ import shutil
35
+ import socket
36
+ import subprocess
37
+ import sys
38
+ import threading
39
+
40
+ ROOT = project_root()
41
+
42
+ from loopgate.gates import render_status_page # noqa: E402 (sibling script: the page under test)
43
+
44
+ PASS_MARKER = "VISUAL_CHECK_PASS_0001"
45
+ STOP_MARKER = "VISUAL_CHECK_STOP_0001"
46
+ SCENARIOS_REL = "configs/visual_scenarios.json"
47
+ SHOT_DIR_REL = "artifacts/visual_checks"
48
+ CHROME_CANDIDATES = [
49
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
50
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
51
+ "/usr/bin/google-chrome",
52
+ "/usr/bin/chromium",
53
+ ]
54
+
55
+
56
+ def load_scenarios(root: str = ROOT) -> dict:
57
+ with open(os.path.join(root, SCENARIOS_REL), encoding="utf-8") as fh:
58
+ return json.load(fh)
59
+
60
+
61
+ def chrome_binary() -> str | None:
62
+ for candidate in CHROME_CANDIDATES:
63
+ if os.path.exists(candidate):
64
+ return candidate
65
+ return shutil.which("google-chrome") or shutil.which("chromium") or None
66
+
67
+
68
+ # ---------------------------------------------------------------- evaluation (pure)
69
+
70
+ def evaluate(scenario: dict, observation: dict) -> list[str]:
71
+ """Reasons the observed page violates the scenario; empty list = the page passes.
72
+
73
+ Pure so the technical gate can attack it (D13) with falsified DOM snapshots.
74
+ """
75
+ exp = scenario.get("expect") or {}
76
+ dom = observation.get("dom") or ""
77
+ reasons: list[str] = []
78
+ for needle in exp.get("require_text") or []:
79
+ if needle not in dom:
80
+ reasons.append(f"a user would not see {needle!r} on the page")
81
+ for needle in exp.get("forbid_text") or []:
82
+ if needle in dom:
83
+ reasons.append(f"the page shows {needle!r}, which must not be visible to a user")
84
+ if exp.get("visible_cell_truncation"):
85
+ import re as _re
86
+ width = int(exp.get("cell_width", 70))
87
+ for cell in _re.findall(r"<td[^>]*>([^<>]+)</td>", dom):
88
+ text = cell.strip()
89
+ # The renderer clips long cells to `width` characters, ending with an ellipsis. A cell at or over
90
+ # that width without the ellipsis is text cut mid-word — visible to a user, invisible to a JSON check.
91
+ if len(text) >= width and not text.endswith("…"):
92
+ reasons.append(f"a table cell is cut mid-word without an ellipsis: {text[-28:]!r}")
93
+ shot = observation.get("screenshot")
94
+ if exp.get("screenshot_required") and not (shot and os.path.exists(shot)):
95
+ reasons.append("no screenshot was captured, so nobody can look at the page")
96
+ if exp.get("min_bytes") and observation.get("screenshot_bytes", 0) < exp["min_bytes"]:
97
+ reasons.append(f"the screenshot is {observation.get('screenshot_bytes', 0)} bytes "
98
+ f"(< {exp['min_bytes']}), so the page most likely rendered blank")
99
+ return reasons
100
+
101
+
102
+ # ---------------------------------------------------------------- capture
103
+
104
+ class _Handler(http.server.SimpleHTTPRequestHandler):
105
+ def log_message(self, *args): # keep the check output readable
106
+ pass
107
+
108
+
109
+ def serve(directory: str) -> tuple[http.server.ThreadingHTTPServer, int]:
110
+ with socket.socket() as sock:
111
+ sock.bind(("127.0.0.1", 0))
112
+ port = int(sock.getsockname()[1])
113
+ handler = functools.partial(_Handler, directory=directory)
114
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
115
+ threading.Thread(target=server.serve_forever, daemon=True).start()
116
+ return server, port
117
+
118
+
119
+ def capture(chrome: str, url: str, shot_path: str, width: int = 1280, height: int = 1400) -> tuple[str, int]:
120
+ """Return (dom, screenshot bytes). Both come from one headless Chrome run each."""
121
+ os.makedirs(os.path.dirname(shot_path), exist_ok=True)
122
+ shot = subprocess.run([chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
123
+ f"--window-size={width},{height}", f"--screenshot={shot_path}", url],
124
+ capture_output=True, text=True, timeout=60)
125
+ if shot.returncode != 0 or not os.path.exists(shot_path):
126
+ return "", 0
127
+ dom_proc = subprocess.run([chrome, "--headless=new", "--disable-gpu", "--dump-dom", url],
128
+ capture_output=True, text=True, timeout=60)
129
+ dom = dom_proc.stdout if dom_proc.returncode == 0 else ""
130
+ return dom, os.path.getsize(shot_path)
131
+
132
+
133
+ def main(argv=None) -> int:
134
+ ap = argparse.ArgumentParser(description="Visual checks over the operator status page.")
135
+ ap.add_argument("--root", default=ROOT)
136
+ ap.add_argument("--list", action="store_true")
137
+ ap.add_argument("--scenario", action="append")
138
+ ap.add_argument("--strict", action="store_true")
139
+ ap.add_argument("--verbose", action="store_true")
140
+ ap.add_argument("--json", action="store_true")
141
+ args = ap.parse_args(argv)
142
+
143
+ try:
144
+ config = load_scenarios(args.root)
145
+ except (OSError, json.JSONDecodeError) as exc:
146
+ print(f"config error: {exc}", file=sys.stderr)
147
+ print(STOP_MARKER)
148
+ return 2
149
+
150
+ scenarios = config["scenarios"]
151
+ if args.list:
152
+ for sc in scenarios:
153
+ flag = "optional" if sc.get("optional") else "mandatory"
154
+ print(f" {sc['id']} [{flag}] {sc['page']:<22} {sc['title']}")
155
+ return 0
156
+ if args.scenario:
157
+ wanted = set(args.scenario)
158
+ scenarios = [s for s in scenarios if s["id"] in wanted] or []
159
+
160
+ chrome = chrome_binary()
161
+ print("VISUAL CHECK — loopgate/gates/check_visual.py")
162
+ print(f"page: {config['page']} chrome: {chrome or '(not found)'}")
163
+ print()
164
+ if chrome is None:
165
+ print("[FAIL] no headless Chrome/Chromium on this machine: a visual check that silently skips is "
166
+ "worse than none")
167
+ print(STOP_MARKER)
168
+ return 1
169
+
170
+ render_status_page.main(["--root", args.root])
171
+ server, port = serve(os.path.join(args.root, "artifacts"))
172
+ results, failures, skips = [], [], []
173
+ try:
174
+ for sc in scenarios:
175
+ shot = os.path.join(args.root, SHOT_DIR_REL, f"{sc['id']}_{sc['page'].replace('/', '_')}.png")
176
+ dom, size = capture(chrome, f"http://127.0.0.1:{port}/{sc['page']}", shot)
177
+ observation = {"dom": dom, "screenshot": shot, "screenshot_bytes": size}
178
+ if not dom:
179
+ observation["skip"] = "the page did not render (Chrome returned no DOM)"
180
+ reasons = evaluate(sc, observation)
181
+ status = "SKIP" if observation.get("skip") else ("OK" if not reasons else "FAIL")
182
+ if status == "SKIP" and args.strict:
183
+ status, reasons = "FAIL", [f"prerequisite missing: {observation['skip']}"]
184
+ results.append({"id": sc["id"], "title": sc["title"], "page": sc["page"], "status": status,
185
+ "reasons": reasons, "screenshot": os.path.relpath(shot, args.root),
186
+ "screenshot_bytes": size})
187
+ print(f"{sc['id']} [{status}] {sc['title']}")
188
+ if observation.get("skip"):
189
+ print(f" - SKIP: {observation['skip']}")
190
+ for reason in reasons:
191
+ print(f" - {reason}")
192
+ if args.verbose and size:
193
+ print(f" - screenshot {os.path.relpath(shot, args.root)} ({size} bytes)")
194
+ if status == "FAIL":
195
+ failures.append(sc["id"])
196
+ if status == "SKIP":
197
+ skips.append(sc["id"])
198
+ finally:
199
+ server.shutdown()
200
+
201
+ ok = not failures
202
+ print()
203
+ print(f"visual scenarios: {len(results) - len(failures) - len(skips)} ok, {len(failures)} fail, "
204
+ f"{len(skips)} skip")
205
+ report = {"marker": PASS_MARKER if ok else STOP_MARKER, "page": config["page"],
206
+ "generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
207
+ "scenarios": results, "failures": failures, "skips": skips}
208
+ report_path = os.path.join(args.root, config.get("report", "artifacts/visual_check_report.json"))
209
+ os.makedirs(os.path.dirname(report_path), exist_ok=True)
210
+ with open(report_path, "w", encoding="utf-8") as fh:
211
+ json.dump(report, fh, ensure_ascii=False, indent=2)
212
+ fh.write("\n")
213
+ print(f"report: {os.path.relpath(report_path, args.root)}")
214
+ if args.json:
215
+ print(json.dumps(report, ensure_ascii=False, indent=2))
216
+ if ok:
217
+ print(PASS_MARKER)
218
+ return 0
219
+ print(STOP_MARKER)
220
+ return 1
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Report what this machine can and cannot run, before a failure is mistaken for a code defect.
3
+
4
+ Why: the gates silently degrade when an input is missing — a product scenario SKIPs without jsonschema, a
5
+ visual check fails without Chrome, the queue needs git and (optionally) `gh`. Every one of those is a
6
+ one-line answer to "why is this yellow?", and without this script the answer is an afternoon of reading.
7
+ It also pins the interpreter question: `#!/usr/bin/env python3` resolves to whatever the PATH holds, so
8
+ two interpreters on one machine can give two verdicts for the same tree.
9
+
10
+ python3 -m loopgate.gates.doctor
11
+ python3 -m loopgate.gates.doctor --json
12
+
13
+ Exit 0 always: this is a report, not a gate. The gates decide.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from loopgate.paths import project_root, gate_file
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+
27
+ ROOT = project_root()
28
+ MARKER = "DOCTOR_REPORT_0001"
29
+
30
+
31
+ def probe_jsonschema() -> tuple[str, str]:
32
+ """('ok' | 'missing' | 'damaged', detail) — the distinction the product gate makes."""
33
+ try:
34
+ import jsonschema # noqa: F401
35
+ import referencing # noqa: F401
36
+ import referencing.jsonschema # noqa: F401
37
+ except ImportError as exc:
38
+ detail = str(exc)
39
+ if "rpds" in detail or "referencing" in detail:
40
+ return "damaged", detail
41
+ return "missing", detail
42
+ return "ok", ""
43
+
44
+
45
+ def which(binary: str) -> str | None:
46
+ return shutil.which(binary)
47
+
48
+
49
+ def git_status() -> str:
50
+ try:
51
+ proc = subprocess.run(["git", "-C", ROOT, "status", "--porcelain"], capture_output=True, text=True)
52
+ except OSError as exc:
53
+ return f"unavailable ({exc})"
54
+ if proc.returncode != 0:
55
+ return f"error: {proc.stderr.strip()}"
56
+ changed = len([line for line in proc.stdout.splitlines() if line.strip()])
57
+ return "clean" if changed == 0 else f"{changed} changed path(s)"
58
+
59
+
60
+ def gh_auth() -> str:
61
+ if not which("gh"):
62
+ return "gh not installed (issue bridge unavailable; --issues-file and --target backlog still work)"
63
+ try:
64
+ proc = subprocess.run(["gh", "auth", "status"], capture_output=True, text=True)
65
+ except OSError as exc:
66
+ return f"unavailable ({exc})"
67
+ return "authenticated" if proc.returncode == 0 else "not authenticated (run `gh auth login`)"
68
+
69
+
70
+ def chrome() -> str:
71
+ candidates = [
72
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
73
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
74
+ ]
75
+ for path in candidates:
76
+ if os.path.exists(path):
77
+ return path
78
+ for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
79
+ found = which(name)
80
+ if found:
81
+ return found
82
+ return "not found (visual checks will fail closed)"
83
+
84
+
85
+ def main(argv=None) -> int:
86
+ ap = argparse.ArgumentParser(description="Report interpreter and optional inputs.")
87
+ ap.add_argument("--json", action="store_true")
88
+ args = ap.parse_args(argv)
89
+
90
+ jsonschema_state, jsonschema_detail = probe_jsonschema()
91
+ report = {
92
+ "marker": MARKER,
93
+ "python": {"version": sys.version.split()[0], "executable": sys.executable,
94
+ "meets_floor_3_9": sys.version_info >= (3, 9)},
95
+ "optional": {
96
+ "jsonschema": {"state": jsonschema_state, "detail": jsonschema_detail,
97
+ "repair": ("python3 -m pip install --force-reinstall rpds-py referencing"
98
+ if jsonschema_state == "damaged"
99
+ else "python3 -m pip install jsonschema")},
100
+ "chrome": chrome(),
101
+ "gh": gh_auth(),
102
+ },
103
+ "repo": {"root": ROOT, "git": which("git") or "missing", "working_tree": git_status()},
104
+ }
105
+
106
+ if args.json:
107
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
108
+ else:
109
+ print("doctor — what this machine can run")
110
+ print()
111
+ print(f" python {report['python']['version']} ({report['python']['executable']})")
112
+ if not report["python"]["meets_floor_3_9"]:
113
+ print(" WARNING: below the supported floor (3.9)")
114
+ state = jsonschema_state
115
+ detail = f" — {jsonschema_detail}" if jsonschema_detail else ""
116
+ print(f" jsonschema {state}{detail}")
117
+ if state != "ok":
118
+ print(f" repair: {report['optional']['jsonschema']['repair']}")
119
+ print(" effect: product scenario P08 (published contracts) reports SKIP")
120
+ print(f" chrome {report['optional']['chrome']}")
121
+ print(f" gh {report['optional']['gh']}")
122
+ print(f" git {report['repo']['git']}; working tree {report['repo']['working_tree']}")
123
+ print()
124
+ print("Interpreters disagree: `python3` on PATH is what `make gate` uses, so if this report is "
125
+ "not the verdict you expected, run with `make gate PYTHON=<path>`.")
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Task timeboxes from measured history (the answer to "how long will this take, and who decides?").
3
+
4
+ Why this file exists
5
+ --------------------
6
+ Nobody should guess a task's duration, and the supervisor is the worst possible estimator: it is the party
7
+ whose optimism is being tested. The queue already produces the data — every kanban run records when it
8
+ started and when it finished — so the estimate is *measured*, not promised:
9
+
10
+ p50 = what a task of this kind usually costs
11
+ p90 = what it costs when it goes badly
12
+ cap = 2 x p90, rounded up, passed to the dispatcher as ``--max-runtime``
13
+
14
+ The cap is not a prediction: it is the point where the dispatcher SIGTERMs the worker and re-queues the
15
+ task, which turns "it ran forever" into a visible failure. Per-assignee, because a reviewer's p90 and an
16
+ implementer's p90 have nothing to do with each other.
17
+
18
+ Who estimates: the queue, from its own history (this script), and the operator approves the proposal by
19
+ committing ``configs/task_estimates.json``. The bridge then applies the cap to every queued task.
20
+
21
+ Usage
22
+ -----
23
+ python3 -m loopgate.gates.estimate_task # show the measured distribution and the proposal
24
+ python3 -m loopgate.gates.estimate_task --write # write configs/task_estimates.json (review, then commit)
25
+ python3 -m loopgate.gates.estimate_task --json
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from loopgate.paths import project_root, gate_file
31
+
32
+ import argparse
33
+ import datetime as dt
34
+ import json
35
+ import os
36
+ import subprocess
37
+ import sys
38
+
39
+ ROOT = project_root()
40
+ ESTIMATES_REL = "configs/task_estimates.json"
41
+ MIN_CAP_MINUTES = 15
42
+ ROUNDING_MINUTES = 5
43
+ # Below this many finished runs a percentile is noise, so the proposal stays at the floor and says so.
44
+ MIN_SAMPLES = 3
45
+
46
+
47
+ def kanban_tasks() -> list[dict]:
48
+ proc = subprocess.run(["hermes", "kanban", "list", "--json", "--archived"],
49
+ capture_output=True, text=True)
50
+ if proc.returncode != 0:
51
+ raise RuntimeError(f"hermes kanban list failed ({proc.returncode}): {proc.stderr.strip()}")
52
+ return json.loads(proc.stdout or "[]")
53
+
54
+
55
+ def durations_minutes(tasks: list[dict]) -> dict[str, list[float]]:
56
+ out: dict[str, list[float]] = {}
57
+ for task in tasks:
58
+ started, completed = task.get("started_at"), task.get("completed_at")
59
+ assignee = task.get("assignee") or "(unassigned)"
60
+ if not started or not completed or completed < started:
61
+ continue
62
+ out.setdefault(assignee, []).append((completed - started) / 60.0)
63
+ return out
64
+
65
+
66
+ def percentile(values: list[float], fraction: float) -> float:
67
+ if not values:
68
+ return 0.0
69
+ ordered = sorted(values)
70
+ index = min(len(ordered) - 1, max(0, int(round(fraction * (len(ordered) - 1)))))
71
+ return ordered[index]
72
+
73
+
74
+ def round_up(value: float, step: int = ROUNDING_MINUTES) -> int:
75
+ return int(step * -(-value // step))
76
+
77
+
78
+ def propose(measured: dict[str, list[float]]) -> dict:
79
+ proposal = {"generated_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
80
+ "rule": f"cap = max({MIN_CAP_MINUTES} min, round_up(2 x p90, {ROUNDING_MINUTES} min)); "
81
+ f"needs >= {MIN_SAMPLES} finished runs before the percentiles mean anything",
82
+ "assignees": {}}
83
+ for assignee, values in sorted(measured.items()):
84
+ p50, p90 = percentile(values, 0.5), percentile(values, 0.9)
85
+ if len(values) < MIN_SAMPLES:
86
+ cap = MIN_CAP_MINUTES
87
+ basis = f"floor: only {len(values)} finished run(s)"
88
+ else:
89
+ cap = max(MIN_CAP_MINUTES, round_up(2 * p90))
90
+ basis = f"2 x p90({p90:.1f} min) over {len(values)} run(s)"
91
+ proposal["assignees"][assignee] = {"samples": len(values), "p50_minutes": round(p50, 1),
92
+ "p90_minutes": round(p90, 1), "max_runtime": f"{cap}m",
93
+ "basis": basis}
94
+ return proposal
95
+
96
+
97
+ def main(argv=None) -> int:
98
+ ap = argparse.ArgumentParser(description="Derive per-assignee task timeboxes from kanban history.")
99
+ ap.add_argument("--root", default=ROOT)
100
+ ap.add_argument("--write", action="store_true", help=f"write {ESTIMATES_REL}")
101
+ ap.add_argument("--json", action="store_true")
102
+ args = ap.parse_args(argv)
103
+
104
+ try:
105
+ measured = durations_minutes(kanban_tasks())
106
+ except (OSError, RuntimeError, json.JSONDecodeError) as exc:
107
+ print(f"cannot read the queue history: {exc}", file=sys.stderr)
108
+ return 1
109
+ if not measured:
110
+ print("no finished runs yet: nothing to measure, every cap stays at the floor")
111
+ return 1
112
+
113
+ proposal = propose(measured)
114
+ if args.json:
115
+ print(json.dumps(proposal, ensure_ascii=False, indent=2))
116
+ else:
117
+ print("measured task durations (finished kanban runs)")
118
+ print(f"{'assignee':<14}{'runs':>6}{'p50':>9}{'p90':>9}{'cap':>8} basis")
119
+ for assignee, row in proposal["assignees"].items():
120
+ print(f"{assignee:<14}{row['samples']:>6}{row['p50_minutes']:>9.1f}{row['p90_minutes']:>9.1f}"
121
+ f"{row['max_runtime']:>8} {row['basis']}")
122
+ print()
123
+ print("a cap is not a promise: at the cap the dispatcher SIGTERMs the worker and re-queues the task,")
124
+ print("so 'it ran forever' becomes a visible failure instead of a silent one.")
125
+
126
+ if args.write:
127
+ path = os.path.join(args.root, ESTIMATES_REL)
128
+ os.makedirs(os.path.dirname(path), exist_ok=True)
129
+ with open(path, "w", encoding="utf-8") as fh:
130
+ json.dump(proposal, fh, ensure_ascii=False, indent=2)
131
+ fh.write("\n")
132
+ print(f"wrote {ESTIMATES_REL} — review it and commit; the bridge applies it to queued tasks")
133
+ return 0
134
+
135
+
136
+ if __name__ == "__main__":
137
+ sys.exit(main())