@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.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- 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())
|