@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,233 @@
1
+ #!/usr/bin/env python3
2
+ """Serve a read-only, loopback-only, multi-Run kanban dashboard.
3
+
4
+ The dashboard never writes to an Issue, a policy, a Run log, a branch or a worktree: every
5
+ HTTP endpoint is a GET that renders data already produced by ``runlog.py``. Staleness is
6
+ always derived from the ``staleAfterSeconds`` snapshot recorded on each Run's ``run.started``
7
+ event, never from the current policy or a mutable dashboard action.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime
13
+ import json
14
+ import sys
15
+ import threading
16
+ import time
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from pathlib import Path
19
+
20
+ from runlog import read_valid_events
21
+ from runlog import state_root as default_state_root
22
+
23
+ COLUMNS = ["Ready", "Plan", "Implement", "Review", "Critic", "Integrate", "Done", "Blocked"]
24
+ STATIC_DIR = Path(__file__).resolve().parent.parent / "dashboard" / "static"
25
+ STATIC_FILES = {
26
+ "/": "index.html",
27
+ "/index.html": "index.html",
28
+ "/app.js": "app.js",
29
+ "/style.css": "style.css",
30
+ }
31
+ MIME_TYPES = {
32
+ ".html": "text/html; charset=utf-8",
33
+ ".js": "application/javascript; charset=utf-8",
34
+ ".css": "text/css; charset=utf-8",
35
+ }
36
+ IGNORED_FOR_ACTIVITY = {"policy.changed"}
37
+
38
+
39
+ class DashboardError(ValueError):
40
+ """The dashboard cannot serve safely with the requested configuration."""
41
+
42
+
43
+ def require_loopback_host(host: str) -> str:
44
+ """Refuse to bind anywhere but the local loopback address."""
45
+ if host != "127.0.0.1":
46
+ raise DashboardError(f"dashboard must bind 127.0.0.1 only, refusing host {host!r}")
47
+ return host
48
+
49
+
50
+ def _parse_ts(value: str) -> float:
51
+ return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
52
+
53
+
54
+ def build_run(unit_id: str, events: list[dict], now: float) -> dict:
55
+ """Reduce one Run's valid event stream into swimlane and Issue-column state."""
56
+ started = events[0]
57
+ data = started["data"]
58
+ stale_after = data["staleAfterSeconds"]
59
+
60
+ activity_events = [event for event in events if event["event"] not in IGNORED_FOR_ACTIVITY]
61
+ last_activity_ts = _parse_ts(activity_events[-1]["ts"]) if activity_events else _parse_ts(started["ts"])
62
+
63
+ issues: dict[str, dict] = {}
64
+ compaction_at = None
65
+ for event in events[1:]:
66
+ name = event["event"]
67
+ if name == "compaction":
68
+ compaction_at = event["ts"]
69
+ continue
70
+ issue = event.get("issue")
71
+ if issue is None:
72
+ continue
73
+ state = issues.setdefault(
74
+ issue,
75
+ {
76
+ "issue": issue,
77
+ "column": "Ready",
78
+ "branch": None,
79
+ "worktree": None,
80
+ "models": {},
81
+ "correctionBudget": None,
82
+ "phaseStartedAt": None,
83
+ "operatorWaiting": False,
84
+ },
85
+ )
86
+ edata = event.get("data") or {}
87
+ if name == "phase.started":
88
+ state["column"] = event["phase"]
89
+ state["phaseStartedAt"] = event["ts"]
90
+ if "branch" in edata:
91
+ state["branch"] = edata["branch"]
92
+ if "worktree" in edata:
93
+ state["worktree"] = edata["worktree"]
94
+ if "models" in edata:
95
+ state["models"] = dict(edata["models"])
96
+ if "correctionBudget" in edata:
97
+ state["correctionBudget"] = edata["correctionBudget"]
98
+ state["operatorWaiting"] = bool(edata.get("operatorWaiting", False))
99
+ elif name == "subagent.started":
100
+ role = edata.get("role")
101
+ if role:
102
+ state["models"][role] = edata.get("model")
103
+ elif name == "issue.done":
104
+ state["column"] = "Done"
105
+ elif name == "issue.blocked":
106
+ state["column"] = "Blocked"
107
+
108
+ for state in issues.values():
109
+ if state["phaseStartedAt"] is not None:
110
+ state["elapsedPhaseSeconds"] = max(0, int(now - _parse_ts(state["phaseStartedAt"])))
111
+ else:
112
+ state["elapsedPhaseSeconds"] = None
113
+
114
+ return {
115
+ "unitId": unit_id,
116
+ "run": started["run"],
117
+ "repositoryRoot": data["repositoryRoot"],
118
+ "tier": data["tier"],
119
+ "staleAfterSeconds": stale_after,
120
+ "lastActivityAt": activity_events[-1]["ts"] if activity_events else started["ts"],
121
+ "stale": (now - last_activity_ts) >= stale_after,
122
+ "compactionAt": compaction_at,
123
+ "issues": sorted(issues.values(), key=lambda item: item["issue"]),
124
+ }
125
+
126
+
127
+ def collect_runs(root: Path, now: float | None = None) -> list[dict]:
128
+ """Read every Run log under every execution unit's state root."""
129
+ now = time.time() if now is None else now
130
+ runs: list[dict] = []
131
+ if not root.exists():
132
+ return runs
133
+ for unit_dir in sorted(path for path in root.iterdir() if path.is_dir()):
134
+ runs_dir = unit_dir / "runs"
135
+ if not runs_dir.exists():
136
+ continue
137
+ for log_path in sorted(runs_dir.glob("*.jsonl")):
138
+ events = read_valid_events(log_path)
139
+ if not events or events[0]["event"] != "run.started":
140
+ continue
141
+ runs.append(build_run(unit_dir.name, events, now))
142
+ return runs
143
+
144
+
145
+ def make_handler(state_root: Path) -> type[BaseHTTPRequestHandler]:
146
+ class DashboardRequestHandler(BaseHTTPRequestHandler):
147
+ server_version = "GantryDashboard/1"
148
+
149
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib signature
150
+ pass
151
+
152
+ def _send_json(self, status: int, payload: object) -> None:
153
+ body = json.dumps(payload).encode("utf-8")
154
+ self.send_response(status)
155
+ self.send_header("Content-Type", "application/json; charset=utf-8")
156
+ self.send_header("Cache-Control", "no-store")
157
+ self.send_header("Content-Length", str(len(body)))
158
+ self.end_headers()
159
+ self.wfile.write(body)
160
+
161
+ def _send_static(self, filename: str) -> None:
162
+ path = STATIC_DIR / filename
163
+ body = path.read_bytes()
164
+ self.send_response(200)
165
+ self.send_header("Content-Type", MIME_TYPES.get(path.suffix, "application/octet-stream"))
166
+ self.send_header("Cache-Control", "no-store")
167
+ self.send_header("Content-Length", str(len(body)))
168
+ self.end_headers()
169
+ self.wfile.write(body)
170
+
171
+ def _send_not_found(self) -> None:
172
+ body = b"not found"
173
+ self.send_response(404)
174
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
175
+ self.send_header("Content-Length", str(len(body)))
176
+ self.end_headers()
177
+ self.wfile.write(body)
178
+
179
+ def do_GET(self) -> None: # noqa: N802 - stdlib method name
180
+ path = self.path.split("?", 1)[0]
181
+ if path == "/api/state":
182
+ self._send_json(200, {"columns": COLUMNS, "runs": collect_runs(state_root)})
183
+ return
184
+ filename = STATIC_FILES.get(path)
185
+ if filename is not None:
186
+ self._send_static(filename)
187
+ return
188
+ self._send_not_found()
189
+
190
+ return DashboardRequestHandler
191
+
192
+
193
+ def create_server(host: str, port: int, state_root: Path) -> ThreadingHTTPServer:
194
+ """Build a validated, loopback-only HTTP server for the dashboard."""
195
+ require_loopback_host(host)
196
+ handler = make_handler(state_root)
197
+ server = ThreadingHTTPServer((host, port), handler)
198
+ return server
199
+
200
+
201
+ def serve(host: str, port: int, state_root: Path) -> None:
202
+ server = create_server(host, port, state_root)
203
+ print(json.dumps({"host": host, "port": server.server_port, "stateRoot": str(state_root)}))
204
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
205
+ thread.start()
206
+ try:
207
+ thread.join()
208
+ except KeyboardInterrupt:
209
+ server.shutdown()
210
+
211
+
212
+ def main(argv: list[str] | None = None) -> int:
213
+ parser = argparse.ArgumentParser(description=__doc__)
214
+ subparsers = parser.add_subparsers(dest="command", required=True)
215
+ serve_parser = subparsers.add_parser("serve", help="serve the read-only kanban dashboard")
216
+ serve_parser.add_argument("--host", default="127.0.0.1", help="must be 127.0.0.1")
217
+ serve_parser.add_argument("--port", type=int, default=4600, help="TCP port, 0 for an ephemeral port")
218
+ serve_parser.add_argument("--state-root", help="override ~/.gantry/state")
219
+ args = parser.parse_args(argv)
220
+
221
+ if args.command == "serve":
222
+ state_root = Path(args.state_root).expanduser().resolve() if args.state_root else default_state_root(None)
223
+ try:
224
+ serve(args.host, args.port, state_root)
225
+ except DashboardError as error:
226
+ print(f"dashboard error: {error}", file=sys.stderr)
227
+ return 1
228
+ return 0
229
+ return 2
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main())
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """Compute ready Issue rounds from authoritative Markdown execution state."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ sys.path.insert(0, str(Path(__file__).parent))
11
+ from common import Issue, load_issues, load_policy_issues, normalise_ref, repo_root, roadmap_waves # noqa: E402
12
+
13
+
14
+ def select_scope(scopes: list[str], issues: dict[str, Issue], roadmap: str) -> tuple[set[str], list[str]]:
15
+ selected: set[str] = set()
16
+ errors: list[str] = []
17
+ waves = roadmap_waves(roadmap) if roadmap else {}
18
+ specs = {issue.spec for issue in issues.values()}
19
+ for raw in scopes:
20
+ scope = raw.strip()
21
+ if scope == "all":
22
+ selected |= {ref for ref, issue in issues.items() if not issue.done}
23
+ elif scope == "frontier":
24
+ selected |= {
25
+ ref for ref, issue in issues.items()
26
+ if not issue.done and (
27
+ issue.parked
28
+ or not issue.blocked_by
29
+ or all(issues.get(blocker) and issues[blocker].done for blocker in issue.blocked_by)
30
+ )
31
+ }
32
+ elif scope.startswith("wave:"):
33
+ try:
34
+ wave = int(scope.split(":", 1)[1])
35
+ except ValueError:
36
+ errors.append(f"bad wave scope: {raw}")
37
+ continue
38
+ if wave not in waves:
39
+ errors.append(f"wave {wave} not found in ROADMAP.md")
40
+ else:
41
+ for ref in waves[wave]:
42
+ if ref in issues and not issues[ref].done:
43
+ selected.add(ref)
44
+ elif ref not in issues:
45
+ errors.append(f"ROADMAP.md wave {wave} lists {ref} but no issue file exists")
46
+ elif normalise_ref(scope):
47
+ ref = normalise_ref(scope)
48
+ if ref in issues:
49
+ selected.add(ref)
50
+ else:
51
+ errors.append(f"issue not found: {ref}")
52
+ elif scope in specs:
53
+ selected |= {ref for ref, issue in issues.items() if issue.spec == scope and not issue.done}
54
+ else:
55
+ errors.append(f"unknown scope: {raw} (not a spec slug, issue ref, wave:N, all or frontier)")
56
+ return selected, errors
57
+
58
+
59
+ def find_cycles(nodes: set[str], issues: dict[str, Issue]) -> list[list[str]]:
60
+ index: dict[str, int] = {}
61
+ low: dict[str, int] = {}
62
+ stack: list[str] = []
63
+ on_stack: set[str] = set()
64
+ cycles: list[list[str]] = []
65
+ counter = 0
66
+
67
+ def visit(ref: str) -> None:
68
+ nonlocal counter
69
+ index[ref] = low[ref] = counter
70
+ counter += 1
71
+ stack.append(ref)
72
+ on_stack.add(ref)
73
+ for blocker in (blocker for blocker in issues[ref].blocked_by if blocker in nodes):
74
+ if blocker not in index:
75
+ visit(blocker)
76
+ low[ref] = min(low[ref], low[blocker])
77
+ elif blocker in on_stack:
78
+ low[ref] = min(low[ref], index[blocker])
79
+ if low[ref] == index[ref]:
80
+ component: list[str] = []
81
+ while True:
82
+ member = stack.pop()
83
+ on_stack.remove(member)
84
+ component.append(member)
85
+ if member == ref:
86
+ break
87
+ if len(component) > 1 or ref in issues[ref].blocked_by:
88
+ cycles.append(component)
89
+
90
+ for ref in sorted(nodes):
91
+ if ref not in index:
92
+ visit(ref)
93
+ return cycles
94
+
95
+
96
+ def validate_graph(issues: dict[str, Issue]) -> list[str]:
97
+ """Return errors that make the authoritative Issue graph invalid."""
98
+ errors = [
99
+ f"{ref} is blocked by {blocker}, which does not exist"
100
+ for ref in sorted(issues)
101
+ for blocker in issues[ref].blocked_by
102
+ if blocker not in issues
103
+ ]
104
+ for cycle in find_cycles(set(issues), issues):
105
+ errors.append("dependency cycle: " + " -> ".join(cycle + [cycle[0]]))
106
+ return errors
107
+
108
+
109
+ def schedule(selected: set[str], issues: dict[str, Issue], limit: int | None) -> tuple[list[list[str]], dict[str, list[str]], list[str]]:
110
+ errors: list[str] = []
111
+ external = {
112
+ ref: [blocker for blocker in issues[ref].blocked_by if blocker in issues and blocker not in selected and not issues[blocker].done]
113
+ for ref in selected
114
+ }
115
+ external = {ref: blockers for ref, blockers in external.items() if blockers}
116
+ schedulable = selected - external.keys()
117
+ changed = True
118
+ while changed:
119
+ changed = False
120
+ for ref in list(schedulable):
121
+ blocked = [blocker for blocker in issues[ref].blocked_by if blocker in external]
122
+ if blocked:
123
+ external[ref] = [f"{blocker} (transitively blocked)" for blocker in blocked]
124
+ schedulable.remove(ref)
125
+ changed = True
126
+ done = {ref for ref, issue in issues.items() if issue.done}
127
+ rounds: list[list[str]] = []
128
+ placed: set[str] = set()
129
+ remaining = set(schedulable)
130
+ while remaining:
131
+ ready = sorted(
132
+ ref for ref in remaining
133
+ if all(blocker in done or blocker in placed for blocker in issues[ref].blocked_by if blocker in issues)
134
+ )
135
+ if not ready:
136
+ for cycle in find_cycles(remaining, issues):
137
+ errors.append("dependency cycle: " + " -> ".join(cycle + [cycle[0]]))
138
+ break
139
+ if limit:
140
+ ready = ready[:limit]
141
+ rounds.append(ready)
142
+ placed.update(ready)
143
+ remaining.difference_update(ready)
144
+ return rounds, external, errors
145
+
146
+
147
+ def main() -> int:
148
+ parser = argparse.ArgumentParser(description=__doc__)
149
+ parser.add_argument("--scope", action="append", default=[], help="scope selector (repeatable)")
150
+ parser.add_argument("--scratch", help="legacy Issue root override")
151
+ parser.add_argument("--roadmap", default="ROADMAP.md")
152
+ parser.add_argument("--limit", type=int, default=None, help="maximum Issues per round")
153
+ parser.add_argument("--include-parked", action="store_true", help="include blocked, needs-operator and draft Issues")
154
+ parser.add_argument("--json", action="store_true")
155
+ args = parser.parse_args()
156
+
157
+ root = repo_root()
158
+ issues = load_issues(root / args.scratch) if args.scratch else load_policy_issues(root)
159
+ if not issues:
160
+ location = root / args.scratch if args.scratch else "the effective artifacts.issues policy path"
161
+ print(f"no issues found under {location}", file=sys.stderr)
162
+ return 2
163
+ roadmap_path = root / args.roadmap
164
+ selected, errors = select_scope(args.scope or ["frontier"], issues, roadmap_path.read_text(encoding="utf-8") if roadmap_path.exists() else "")
165
+ errors.extend(validate_graph(issues))
166
+ parked = sorted(ref for ref in selected if issues[ref].parked)
167
+ if not args.include_parked:
168
+ selected.difference_update(parked)
169
+ rounds, external, schedule_errors = schedule(selected, issues, args.limit)
170
+ errors.extend(schedule_errors)
171
+ result = {
172
+ "scope": args.scope or ["frontier"], "selected": sorted(selected),
173
+ "already_done": sorted(ref for ref in selected if issues[ref].done),
174
+ "rounds": rounds, "externally_blocked": dict(sorted(external.items())),
175
+ "parked": {ref: issues[ref].status for ref in parked},
176
+ "issues": {ref: issues[ref].to_dict(root) for ref in sorted(selected)}, "errors": errors,
177
+ }
178
+ if args.json:
179
+ print(json.dumps(result, indent=2))
180
+ else:
181
+ print(f"scope: {' '.join(result['scope'])} -> {len(selected)} issue(s), {len(rounds)} round(s)")
182
+ for number, members in enumerate(rounds, 1):
183
+ print(f" round {number}: {', '.join(members)}")
184
+ for error in errors:
185
+ print(f" ERROR: {error}")
186
+ if errors:
187
+ return 1
188
+ return 0 if selected or parked else 2
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main())