@julioborges/gantry 0.1.0

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 (46) hide show
  1. package/.agents/skills/gantry/SKILL.md +166 -0
  2. package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
  3. package/.agents/skills/gantry/capabilities/codex.json +14 -0
  4. package/.agents/skills/gantry/capabilities/opencode.json +15 -0
  5. package/.agents/skills/gantry/dashboard/static/app.js +100 -0
  6. package/.agents/skills/gantry/dashboard/static/index.html +16 -0
  7. package/.agents/skills/gantry/dashboard/static/style.css +74 -0
  8. package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
  9. package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
  10. package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
  11. package/.agents/skills/gantry/hooks/git/pre-push +123 -0
  12. package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
  13. package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
  14. package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
  15. package/.agents/skills/gantry/reference/round-workflow.md +755 -0
  16. package/.agents/skills/gantry/schemas/critic.json +93 -0
  17. package/.agents/skills/gantry/schemas/implementer.json +52 -0
  18. package/.agents/skills/gantry/schemas/learner.json +35 -0
  19. package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
  20. package/.agents/skills/gantry/schemas/planner.json +64 -0
  21. package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
  22. package/.agents/skills/gantry/schemas/reviewer.json +52 -0
  23. package/.agents/skills/gantry/scripts/acceptance.py +66 -0
  24. package/.agents/skills/gantry/scripts/budget.py +162 -0
  25. package/.agents/skills/gantry/scripts/cleanup.py +186 -0
  26. package/.agents/skills/gantry/scripts/common.py +361 -0
  27. package/.agents/skills/gantry/scripts/dashboard.py +233 -0
  28. package/.agents/skills/gantry/scripts/frontier.py +192 -0
  29. package/.agents/skills/gantry/scripts/gates.py +401 -0
  30. package/.agents/skills/gantry/scripts/guard.py +568 -0
  31. package/.agents/skills/gantry/scripts/learner.py +99 -0
  32. package/.agents/skills/gantry/scripts/result.py +104 -0
  33. package/.agents/skills/gantry/scripts/roadmap.py +212 -0
  34. package/.agents/skills/gantry/scripts/runlog.py +491 -0
  35. package/.agents/skills/gantry/scripts/setup.py +139 -0
  36. package/.agents/skills/gantry/scripts/spec.py +252 -0
  37. package/.agents/skills/gantry/templates/issue.md +32 -0
  38. package/.agents/skills/gantry/templates/prd.md +26 -0
  39. package/.agents/skills/gantry/templates/spec.md +48 -0
  40. package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
  41. package/.agents/skills/gantry-setup/SKILL.md +30 -0
  42. package/LICENSE +201 -0
  43. package/README.md +437 -0
  44. package/bin/gantry.mjs +45 -0
  45. package/package.json +36 -0
  46. package/scripts/ensure-npm-author.mjs +29 -0
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env python3
2
+ """Detect and run repository quality gates, emitting a machine-readable verdict."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ from common import resolve_policy
16
+
17
+ FRONTEND_RE = re.compile(r"(\.(tsx|jsx|vue|svelte|css|scss|less|html)$)|(/|^)(components|pages|app|ui|views|layouts|public|styles|dashboard/src)/", re.IGNORECASE)
18
+ NODE_SCRIPT_ORDER = ["lint", "typecheck", "format:check", "test", "build", "pack:check"]
19
+ MAKE_TARGETS = ["lint", "typecheck", "test", "build"]
20
+ MAPPING_FIELDS = ("findings", "rule", "file", "line", "message", "severity")
21
+ SEVERITY_ORDER = {"info": 0, "warning": 1, "error": 2}
22
+
23
+
24
+ def sh(command: list[str] | str, cwd: Path, timeout: int) -> tuple[int, str]:
25
+ try:
26
+ result = subprocess.run(command, cwd=cwd, shell=isinstance(command, str), capture_output=True, text=True, timeout=timeout,
27
+ env={**os.environ, "CI": "1", "FORCE_COLOR": "0", "NO_COLOR": "1"})
28
+ return result.returncode, result.stdout + result.stderr
29
+ except subprocess.TimeoutExpired as error:
30
+ return 124, f"timed out after {timeout}s\n{error.stdout or ''}{error.stderr or ''}"
31
+ except FileNotFoundError as error:
32
+ return 127, str(error)
33
+
34
+
35
+ def pointer_parts(pointer: object) -> list[str]:
36
+ """Parse one RFC 6901 JSON Pointer, rejecting malformed escape sequences."""
37
+ if not isinstance(pointer, str) or (pointer and not pointer.startswith("/")):
38
+ raise ValueError("must be an RFC 6901 JSON Pointer")
39
+ parts: list[str] = []
40
+ for raw_part in pointer[1:].split("/") if pointer else []:
41
+ part: list[str] = []
42
+ index = 0
43
+ while index < len(raw_part):
44
+ if raw_part[index] != "~":
45
+ part.append(raw_part[index])
46
+ index += 1
47
+ continue
48
+ if index + 1 == len(raw_part) or raw_part[index + 1] not in "01":
49
+ raise ValueError(f"has an invalid RFC 6901 escape in {pointer!r}")
50
+ part.append("~" if raw_part[index + 1] == "0" else "/")
51
+ index += 2
52
+ parts.append("".join(part))
53
+ return parts
54
+
55
+
56
+ def resolve_pointer(document: object, pointer: object) -> object:
57
+ """Resolve an RFC 6901 pointer against JSON data."""
58
+ current = document
59
+ for part in pointer_parts(pointer):
60
+ if isinstance(current, dict):
61
+ if part not in current:
62
+ raise ValueError(f"does not resolve {pointer!r}")
63
+ current = current[part]
64
+ elif isinstance(current, list):
65
+ if not part.isdigit() or (len(part) > 1 and part.startswith("0")):
66
+ raise ValueError(f"does not resolve {pointer!r}")
67
+ index = int(part)
68
+ if index >= len(current):
69
+ raise ValueError(f"does not resolve {pointer!r}")
70
+ current = current[index]
71
+ else:
72
+ raise ValueError(f"does not resolve {pointer!r}")
73
+ return current
74
+
75
+
76
+ def scalar(document: object, pointer: object, name: str) -> object:
77
+ value = resolve_pointer(document, pointer)
78
+ if value is None or isinstance(value, (dict, list)):
79
+ raise ValueError(f"{name} must resolve to one scalar")
80
+ return value
81
+
82
+
83
+ def normalise_file(value: object, root: Path) -> str:
84
+ if not isinstance(value, str) or not value.strip():
85
+ raise ValueError("file must resolve to a non-empty string")
86
+ root = root.resolve()
87
+ candidate = Path(value)
88
+ candidate = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
89
+ try:
90
+ return candidate.relative_to(root).as_posix()
91
+ except ValueError as error:
92
+ raise ValueError(f"file {value!r} is outside the repository root") from error
93
+
94
+
95
+ def validate_mapping(mapping: object) -> dict:
96
+ if not isinstance(mapping, dict):
97
+ raise ValueError("differential checks require a mapping object")
98
+ missing = [field for field in MAPPING_FIELDS if field not in mapping]
99
+ if missing:
100
+ raise ValueError(f"mapping is missing {', '.join(missing)}")
101
+ for field in MAPPING_FIELDS:
102
+ pointer_parts(mapping[field])
103
+ return mapping
104
+
105
+
106
+ def parse_findings(output: str, mapping: dict, root: Path, source: str) -> list[dict]:
107
+ try:
108
+ document = json.loads(output)
109
+ except json.JSONDecodeError as error:
110
+ raise ValueError(f"{source} command did not emit JSON: {error.msg}") from error
111
+ findings = resolve_pointer(document, mapping["findings"])
112
+ if not isinstance(findings, list):
113
+ raise ValueError("findings must resolve to an array")
114
+ parsed: list[dict] = []
115
+ for index, finding in enumerate(findings):
116
+ values = {field: scalar(finding, mapping[field], field) for field in MAPPING_FIELDS[1:]}
117
+ if not isinstance(values["rule"], str) or not values["rule"]:
118
+ raise ValueError(f"finding {index} rule must be a non-empty string")
119
+ if not isinstance(values["message"], str) or not values["message"]:
120
+ raise ValueError(f"finding {index} message must be a non-empty string")
121
+ if values["severity"] not in SEVERITY_ORDER:
122
+ raise ValueError(f"finding {index} severity must be info, warning or error")
123
+ parsed.append(
124
+ {
125
+ "rule": values["rule"],
126
+ "file": normalise_file(values["file"], root),
127
+ "line": values["line"],
128
+ "message": values["message"],
129
+ "severity": values["severity"],
130
+ }
131
+ )
132
+ return parsed
133
+
134
+
135
+ def index_findings(findings: list[dict], source: str) -> tuple[dict[tuple[str, str, str], dict], list[dict]]:
136
+ indexed: dict[tuple[str, str, str], dict] = {}
137
+ invalid: list[dict] = []
138
+ for finding in findings:
139
+ identity = (finding["rule"], finding["file"], finding["message"])
140
+ if identity in indexed:
141
+ invalid.append(
142
+ {
143
+ "source": source,
144
+ "identity": {"rule": identity[0], "file": identity[1], "message": identity[2]},
145
+ }
146
+ )
147
+ else:
148
+ indexed[identity] = finding
149
+ return indexed, invalid
150
+
151
+
152
+ def differential_gate(gate: dict, cwd: Path, diff_base: str | None, timeout: int) -> dict:
153
+ gate = dict(gate)
154
+ gate.update({"new": [], "aggravated": [], "resolved": [], "preexisting": [], "invalid": []})
155
+ try:
156
+ mapping = validate_mapping(gate.get("mapping"))
157
+ except ValueError as error:
158
+ gate["invalid"].append({"source": "config", "error": str(error)})
159
+ gate["status"] = "fail"
160
+ return gate
161
+ if not diff_base:
162
+ gate["invalid"].append({"source": "config", "error": "differential checks require --diff-base"})
163
+ gate["status"] = "fail"
164
+ return gate
165
+
166
+ base_root = Path(tempfile.mkdtemp(prefix="gantry-gates-"))
167
+ try:
168
+ code, output = sh(["git", "worktree", "add", "--detach", "--quiet", str(base_root), diff_base], cwd, timeout)
169
+ if code:
170
+ gate["invalid"].append({"source": "base", "error": f"could not create base worktree: {output.strip()}"})
171
+ gate["status"] = "fail"
172
+ return gate
173
+ base_code, base_output = sh(gate["command"], base_root, timeout)
174
+ delivery_code, delivery_output = sh(gate["command"], cwd, timeout)
175
+ gate["base_exit_code"] = base_code
176
+ gate["delivery_exit_code"] = delivery_code
177
+ try:
178
+ base_findings = parse_findings(base_output, mapping, base_root, "base")
179
+ except ValueError as error:
180
+ gate["invalid"].append({"source": "base", "error": str(error)})
181
+ gate["status"] = "fail"
182
+ return gate
183
+ try:
184
+ delivery_findings = parse_findings(delivery_output, mapping, cwd, "delivery")
185
+ except ValueError as error:
186
+ gate["invalid"].append({"source": "delivery", "error": str(error)})
187
+ gate["status"] = "fail"
188
+ return gate
189
+
190
+ base_index, base_invalid = index_findings(base_findings, "base")
191
+ delivery_index, delivery_invalid = index_findings(delivery_findings, "delivery")
192
+ gate["invalid"].extend(base_invalid + delivery_invalid)
193
+ if gate["invalid"]:
194
+ gate["status"] = "fail"
195
+ return gate
196
+ for identity in sorted(base_index.keys() | delivery_index.keys()):
197
+ base_finding = base_index.get(identity)
198
+ delivery_finding = delivery_index.get(identity)
199
+ if base_finding is None:
200
+ gate["new"].append(delivery_finding)
201
+ elif delivery_finding is None:
202
+ gate["resolved"].append(base_finding)
203
+ elif SEVERITY_ORDER[delivery_finding["severity"]] > SEVERITY_ORDER[base_finding["severity"]]:
204
+ gate["aggravated"].append(delivery_finding)
205
+ else:
206
+ gate["preexisting"].append(delivery_finding)
207
+ gate["status"] = "fail" if gate["new"] or gate["aggravated"] else "pass"
208
+ return gate
209
+ finally:
210
+ sh(["git", "worktree", "remove", "--force", str(base_root)], cwd, timeout)
211
+ shutil.rmtree(base_root, ignore_errors=True)
212
+
213
+
214
+ def package_manager(cwd: Path, package: dict) -> str:
215
+ requested = str(package.get("packageManager", ""))
216
+ for name in ("pnpm", "yarn", "bun", "npm"):
217
+ if requested.startswith(name):
218
+ return name
219
+ if (cwd / "pnpm-lock.yaml").exists():
220
+ return "pnpm"
221
+ if (cwd / "yarn.lock").exists():
222
+ return "yarn"
223
+ return "bun" if (cwd / "bun.lockb").exists() or (cwd / "bun.lock").exists() else "npm"
224
+
225
+
226
+ def detect(cwd: Path) -> dict:
227
+ gates: list[dict] = []
228
+ notes: list[str] = []
229
+ package_path = cwd / "package.json"
230
+ if package_path.exists():
231
+ try:
232
+ package = json.loads(package_path.read_text(encoding="utf-8"))
233
+ except json.JSONDecodeError as error:
234
+ package, _ = {}, notes.append(f"package.json is not valid JSON: {error}")
235
+ manager = package_manager(cwd, package)
236
+ scripts = package.get("scripts", {}) or {}
237
+ for name in NODE_SCRIPT_ORDER:
238
+ if name in scripts:
239
+ gates.append({"name": name, "source": "package.json", "command": f"{manager} run {name}"})
240
+ if scripts and not any(gate["name"] == "test" for gate in gates):
241
+ notes.append("package.json has scripts but no `test` script")
242
+ if not (cwd / "node_modules").exists():
243
+ notes.append("node_modules missing — install dependencies before running node gates")
244
+ if any((cwd / name).exists() for name in ("pyproject.toml", "pytest.ini", "setup.cfg", "tox.ini")):
245
+ config = (cwd / "pyproject.toml").read_text(encoding="utf-8") if (cwd / "pyproject.toml").exists() else ""
246
+ if "ruff" in config:
247
+ gates.append({"name": "ruff", "source": "pyproject.toml", "command": "ruff check ."})
248
+ if "mypy" in config:
249
+ gates.append({"name": "mypy", "source": "pyproject.toml", "command": "mypy ."})
250
+ if "pytest" in config or (cwd / "pytest.ini").exists() or (cwd / "tests").exists():
251
+ gates.append({"name": "pytest", "source": "pyproject.toml", "command": "pytest -q"})
252
+ makefile = cwd / "Makefile"
253
+ if makefile.exists():
254
+ targets = set(re.findall(r"^([a-zA-Z0-9_.-]+):", makefile.read_text(encoding="utf-8"), re.MULTILINE))
255
+ for name in MAKE_TARGETS:
256
+ if name in targets and not any(gate["name"] == name for gate in gates):
257
+ gates.append({"name": name, "source": "Makefile", "command": f"make {name}"})
258
+ if (cwd / ".pre-commit-config.yaml").exists():
259
+ gates.append({"name": "pre-commit", "source": ".pre-commit-config.yaml", "command": "pre-commit run --all-files"})
260
+ hooks: list[str] = []
261
+ if (cwd / ".husky").is_dir():
262
+ hooks.extend(f".husky/{path.name}" for path in (cwd / ".husky").iterdir() if path.is_file() and not path.name.startswith("_"))
263
+ for name in (".claude/settings.json", ".claude/settings.local.json"):
264
+ path = cwd / name
265
+ if path.exists():
266
+ try:
267
+ hooks.extend(f"{name}: {event} x{len(entries)}" for event, entries in (json.loads(path.read_text(encoding="utf-8")).get("hooks") or {}).items())
268
+ except json.JSONDecodeError:
269
+ notes.append(f"{name} is not valid JSON")
270
+ ci: list[dict] = []
271
+ workflows = cwd / ".github" / "workflows"
272
+ if workflows.is_dir():
273
+ for workflow in sorted(workflows.glob("*.y*ml")):
274
+ text = workflow.read_text(encoding="utf-8")
275
+ jobs: list[str] = []
276
+ marker = re.search(r"^jobs:\s*$", text, re.MULTILINE)
277
+ if marker:
278
+ for line in text[marker.end():].splitlines():
279
+ if line and not line.startswith((" ", "\t", "#")):
280
+ break
281
+ match = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
282
+ if match:
283
+ jobs.append(match.group(1))
284
+ commands = [item.strip() for item in re.findall(r"^\s*(?:-\s*)?run:\s*(.+)$", text, re.MULTILINE)]
285
+ ci.append({"workflow": str(workflow.relative_to(cwd)), "jobs": jobs, "run_steps": commands})
286
+ declared = {gate["name"] for gate in gates}
287
+ package_scripts = set()
288
+ if package_path.exists():
289
+ try:
290
+ package_scripts = set((json.loads(package_path.read_text(encoding="utf-8")).get("scripts") or {}).keys())
291
+ except json.JSONDecodeError:
292
+ pass
293
+ for workflow in ci:
294
+ for job in workflow["jobs"]:
295
+ if job in NODE_SCRIPT_ORDER and job not in declared:
296
+ notes.append(f"CI job `{job}` in {workflow['workflow']} has no matching package script")
297
+ for command in workflow["run_steps"]:
298
+ match = re.match(r"^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([A-Za-z0-9:_-]+)", command)
299
+ if match and package_scripts and match.group(1) not in package_scripts and match.group(1) not in {"install", "ci", "test"}:
300
+ notes.append(f"CI runs `{command}` in {workflow['workflow']} but package.json has no `{match.group(1)}` script — CI and local commands have diverged")
301
+ return {"gates": gates, "ci": ci, "hooks": hooks, "notes": notes}
302
+
303
+
304
+ def declared_or_detected(cwd: Path) -> dict:
305
+ policy = resolve_policy(cwd)
306
+ checks = policy.get("checks", [])
307
+ if not checks:
308
+ return detect(cwd)
309
+ if not isinstance(checks, list):
310
+ return {"gates": [{"name": "policy", "invalid": [{"source": "config", "error": "checks must be a list"}]}],
311
+ "ci": [], "hooks": [], "notes": []}
312
+ gates: list[dict] = []
313
+ for index, check in enumerate(checks):
314
+ if not isinstance(check, dict):
315
+ gates.append({"name": f"check-{index + 1}", "invalid": [{"source": "config", "error": "check must be an object"}]})
316
+ continue
317
+ gate = dict(check)
318
+ gate["source"] = ".gantry/config.json"
319
+ if not isinstance(gate.get("name"), str) or not gate["name"]:
320
+ gate["name"] = f"check-{index + 1}"
321
+ gate["invalid"] = [{"source": "config", "error": "check name must be a non-empty string"}]
322
+ elif not isinstance(gate.get("command"), str) or not gate["command"]:
323
+ gate["invalid"] = [{"source": "config", "error": "check command must be a non-empty string"}]
324
+ elif gate.get("mode") not in {"absolute", "differential"}:
325
+ gate["invalid"] = [{"source": "config", "error": "check mode must be absolute or differential"}]
326
+ gates.append(gate)
327
+ return {"gates": gates, "ci": [], "hooks": [], "notes": []}
328
+
329
+
330
+ def git_facts(cwd: Path, diff_base: str | None) -> dict:
331
+ facts = {"is_git": (cwd / ".git").exists() or shutil.which("git") is not None}
332
+ code, output = sh(["git", "status", "--porcelain"], cwd, 30)
333
+ facts["tree_clean"] = code == 0 and not output.strip()
334
+ facts["dirty_files"] = [line[3:] for line in output.splitlines()] if code == 0 else []
335
+ code, output = sh(["git", "branch", "--show-current"], cwd, 30)
336
+ facts["branch"] = output.strip() if code == 0 else None
337
+ changed: list[str] = []
338
+ if diff_base:
339
+ code, output = sh(["git", "diff", "--name-only", f"{diff_base}...HEAD"], cwd, 60)
340
+ if code:
341
+ code, output = sh(["git", "diff", "--name-only", diff_base, "HEAD"], cwd, 60)
342
+ changed = [line for line in output.splitlines() if line.strip()] if code == 0 else []
343
+ facts.update({"diff_base": diff_base, "diff_error": None if code == 0 else output.strip()[:500]})
344
+ facts["changed_files"] = changed
345
+ facts["frontend_touched"] = any(FRONTEND_RE.search(path) for path in changed)
346
+ return facts
347
+
348
+
349
+ def main() -> int:
350
+ parser = argparse.ArgumentParser(description=__doc__)
351
+ parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
352
+ parser.add_argument("--run", action="store_true", help="execute detected gates")
353
+ parser.add_argument("--diff-base", help="Git ref to diff against")
354
+ parser.add_argument("--timeout", type=int, default=900, help="seconds per gate")
355
+ parser.add_argument("--only", help="comma-separated gate names")
356
+ parser.add_argument("--tail", type=int, default=60, help="output lines per gate")
357
+ parser.add_argument("--json", action="store_true")
358
+ args = parser.parse_args()
359
+ cwd = Path(args.cwd).resolve()
360
+ try:
361
+ result = declared_or_detected(cwd)
362
+ except ValueError as error:
363
+ result = {"gates": [{"name": "policy", "invalid": [{"source": "config", "error": str(error)}]}],
364
+ "ci": [], "hooks": [], "notes": []}
365
+ result.update({"cwd": str(cwd), "git": git_facts(cwd, args.diff_base)})
366
+ only = {item.strip() for item in args.only.split(",")} if args.only else None
367
+ failures = 0
368
+ for index, gate in enumerate(result["gates"]):
369
+ if only and gate["name"] not in only:
370
+ gate["status"] = "skipped"
371
+ elif not args.run:
372
+ gate["status"] = "not_run"
373
+ elif gate.get("invalid"):
374
+ gate["status"] = "fail"
375
+ failures += 1
376
+ elif gate.get("mode") == "differential":
377
+ gate = differential_gate(gate, cwd, args.diff_base, args.timeout)
378
+ result["gates"][index] = gate
379
+ failures += gate["status"] == "fail"
380
+ else:
381
+ code, output = sh(gate["command"], cwd, args.timeout)
382
+ gate.update({"exit_code": code, "output_tail": "\n".join(output.splitlines()[-args.tail:]), "status": "pass" if code == 0 else "fail"})
383
+ failures += code != 0
384
+ result["verdict"] = "no_gates" if not result["gates"] else ("not_run" if not args.run else ("fail" if failures else "pass"))
385
+ result["requirements"] = []
386
+ if result["git"]["frontend_touched"]:
387
+ result["requirements"].append("frontend files changed: AGENTS.md requires Playwright validation before completion")
388
+ if not result["git"]["tree_clean"]:
389
+ result["requirements"].append("working tree is dirty: uncommitted changes are not part of the delivery")
390
+ if args.json:
391
+ print(json.dumps(result, indent=2))
392
+ else:
393
+ print(f"cwd: {cwd} branch: {result['git'].get('branch')} tree_clean: {result['git']['tree_clean']}")
394
+ for gate in result["gates"]:
395
+ print(f" [{gate['status']}] {gate['name']}: {gate['command']}")
396
+ print(f"verdict: {result['verdict']}")
397
+ return {"pass": 0, "fail": 1, "no_gates": 2, "not_run": 0}[result["verdict"]]
398
+
399
+
400
+ if __name__ == "__main__":
401
+ sys.exit(main())