@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,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate a role result from standard input against its bundled contract."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
SCHEMA_DIR = Path(__file__).resolve().parents[1] / "schemas"
|
|
12
|
+
ROLES = ("requirement-critic", "planner", "plan-critic", "implementer", "reviewer", "critic", "learner")
|
|
13
|
+
TYPE_NAMES = {
|
|
14
|
+
"object": dict,
|
|
15
|
+
"array": list,
|
|
16
|
+
"string": str,
|
|
17
|
+
"boolean": bool,
|
|
18
|
+
"integer": int,
|
|
19
|
+
"number": (int, float),
|
|
20
|
+
"null": type(None),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def schema_path(role: str) -> Path:
|
|
25
|
+
return SCHEMA_DIR / f"{role}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def matches_type(value: Any, name: str) -> bool:
|
|
29
|
+
if name == "integer":
|
|
30
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
31
|
+
if name == "number":
|
|
32
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
33
|
+
return isinstance(value, TYPE_NAMES[name])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate(value: Any, schema: dict[str, Any], path: str = "$") -> list[str]:
|
|
37
|
+
errors: list[str] = []
|
|
38
|
+
expected_type = schema.get("type")
|
|
39
|
+
if expected_type is not None:
|
|
40
|
+
if expected_type not in TYPE_NAMES:
|
|
41
|
+
return [f"{path}: unsupported schema type {expected_type!r}"]
|
|
42
|
+
if not matches_type(value, expected_type):
|
|
43
|
+
return [f"{path}: expected {expected_type}"]
|
|
44
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
45
|
+
errors.append(f"{path}: expected one of {schema['enum']!r}")
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
properties = schema.get("properties", {})
|
|
48
|
+
for name in schema.get("required", []):
|
|
49
|
+
if name not in value:
|
|
50
|
+
errors.append(f"{path}.{name}: missing required field")
|
|
51
|
+
if schema.get("additionalProperties") is False:
|
|
52
|
+
for name in value:
|
|
53
|
+
if name not in properties:
|
|
54
|
+
errors.append(f"{path}.{name}: additional property is not allowed")
|
|
55
|
+
for name, child in properties.items():
|
|
56
|
+
if name in value:
|
|
57
|
+
errors.extend(validate(value[name], child, f"{path}.{name}"))
|
|
58
|
+
if isinstance(value, list) and "items" in schema:
|
|
59
|
+
for index, item in enumerate(value):
|
|
60
|
+
errors.extend(validate(item, schema["items"], f"{path}[{index}]"))
|
|
61
|
+
return errors
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_schema(role: str) -> dict[str, Any]:
|
|
65
|
+
with schema_path(role).open(encoding="utf-8") as source:
|
|
66
|
+
schema = json.load(source)
|
|
67
|
+
if not isinstance(schema, dict):
|
|
68
|
+
raise ValueError(f"schema for {role} must be an object")
|
|
69
|
+
return schema
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> int:
|
|
73
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
74
|
+
parser.add_argument("--role", required=True, choices=ROLES, help="role contract to validate")
|
|
75
|
+
parser.add_argument("--json", action="store_true", help="emit a machine-readable validation result")
|
|
76
|
+
parser.add_argument("--schema", action="store_true", help="print the selected schema")
|
|
77
|
+
args = parser.parse_args()
|
|
78
|
+
try:
|
|
79
|
+
schema = load_schema(args.role)
|
|
80
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
81
|
+
print(f"result schema error: {error}", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
if args.schema:
|
|
84
|
+
print(json.dumps(schema))
|
|
85
|
+
return 0
|
|
86
|
+
try:
|
|
87
|
+
result = json.load(sys.stdin)
|
|
88
|
+
except json.JSONDecodeError as error:
|
|
89
|
+
errors = [f"$: invalid JSON: {error.msg}"]
|
|
90
|
+
else:
|
|
91
|
+
errors = validate(result, schema)
|
|
92
|
+
payload = {"role": args.role, "valid": not errors, "errors": errors}
|
|
93
|
+
if args.json:
|
|
94
|
+
print(json.dumps(payload))
|
|
95
|
+
elif errors:
|
|
96
|
+
for error in errors:
|
|
97
|
+
print(error)
|
|
98
|
+
else:
|
|
99
|
+
print(f"{args.role}: valid")
|
|
100
|
+
return 0 if not errors else 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Keep ROADMAP.md as a deterministic projection of authoritative Issue state."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import difflib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
14
|
+
from common import Issue, issue_levels, load_issues, load_policy_issues, policy_spec_numbers, repo_root, resolve_issue_arg, spec_numbers # noqa: E402
|
|
15
|
+
|
|
16
|
+
PROGRESS_ISSUES_RE = re.compile(r"^(\| Issues completed \| \*\*)(\d+) / (\d+)(\*\* \|)\s*$")
|
|
17
|
+
PROGRESS_SPECS_RE = re.compile(r"^(\| Specs completed \| \*\*)(\d+) / (\d+)(\*\* \|)\s*$")
|
|
18
|
+
PROGRESS_WAVES_RE = re.compile(r"^(\| Execution waves \| \*\*)(\d+)(\*\* \|)\s*$")
|
|
19
|
+
BLOCK_RE = {
|
|
20
|
+
name: re.compile(rf"(<!-- BEGIN GENERATED: {name} -->\n)(.*?)(<!-- END GENERATED: {name} -->)", re.DOTALL)
|
|
21
|
+
for name in ("spec progress", "issue checklist")
|
|
22
|
+
}
|
|
23
|
+
STATUS_LINE_RE = re.compile(r"^Status:\s*.*$", re.MULTILINE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def counts(issues: dict[str, Issue]) -> tuple[int, int, int, int]:
|
|
27
|
+
grouped: dict[str, list[Issue]] = {}
|
|
28
|
+
for issue in issues.values():
|
|
29
|
+
grouped.setdefault(issue.spec, []).append(issue)
|
|
30
|
+
return (
|
|
31
|
+
sum(issue.done for issue in issues.values()), len(issues),
|
|
32
|
+
sum(all(issue.done for issue in entries) for entries in grouped.values()), len(grouped),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_spec_progress(issues: dict[str, Issue], levels: dict[str, int], numbers: dict[str, int]) -> str:
|
|
37
|
+
rows = ["| # | Spec | Issues done | Waves |", "|---|---|---|---|"]
|
|
38
|
+
for spec in sorted({issue.spec for issue in issues.values()}, key=lambda value: (numbers.get(value, 99), value)):
|
|
39
|
+
entries = [issue for issue in issues.values() if issue.spec == spec]
|
|
40
|
+
depths = sorted(levels[issue.ref] for issue in entries)
|
|
41
|
+
span = str(depths[0]) if depths[0] == depths[-1] else f"{depths[0]}–{depths[-1]}"
|
|
42
|
+
rows.append(f"| {numbers.get(spec, 0):02d} | `{spec}` | {sum(issue.done for issue in entries)}/{len(entries)} | {span} |")
|
|
43
|
+
return "\n".join(rows) + "\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_issue_checklist(issues: dict[str, Issue], levels: dict[str, int], numbers: dict[str, int]) -> str:
|
|
47
|
+
waves: dict[int, list[Issue]] = {}
|
|
48
|
+
for ref, level in levels.items():
|
|
49
|
+
waves.setdefault(level, []).append(issues[ref])
|
|
50
|
+
output: list[str] = []
|
|
51
|
+
for level in sorted(waves):
|
|
52
|
+
members = sorted(waves[level], key=lambda issue: (numbers.get(issue.spec, 99), issue.spec, issue.number))
|
|
53
|
+
output.append(f"\n### Wave {level} — {sum(issue.done for issue in members)}/{len(members)} done\n")
|
|
54
|
+
for issue in members:
|
|
55
|
+
line = f"- [{'x' if issue.done else ' '}] **`{issue.ref}`** — {issue.title}"
|
|
56
|
+
blockers = [blocker for blocker in issue.blocked_by if blocker in issues]
|
|
57
|
+
output.append(line + (" _(no blockers)_" if not blockers else ""))
|
|
58
|
+
if blockers:
|
|
59
|
+
output.append(" <br>↳ blocked by: " + ", ".join(blockers))
|
|
60
|
+
return "\n".join(output).lstrip("\n") + "\n"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def delivery_levels(text: str, issues: dict[str, Issue]) -> dict[str, int]:
|
|
64
|
+
"""Preserve completed wave membership; schedule other Issues after history.
|
|
65
|
+
|
|
66
|
+
The roadmap supplies historical placement only. Authoritative Issue status,
|
|
67
|
+
never a cached checkbox or heading count, determines whether a wave is complete.
|
|
68
|
+
"""
|
|
69
|
+
issue_levels(issues) # Validate cycles before resolving historical placement.
|
|
70
|
+
block = BLOCK_RE["issue checklist"].search(text)
|
|
71
|
+
frozen: dict[str, int] = {}
|
|
72
|
+
if block:
|
|
73
|
+
sections = re.split(r"^### Wave (\d+)[^\n]*\n", block.group(2), flags=re.MULTILINE)
|
|
74
|
+
for index in range(1, len(sections), 2):
|
|
75
|
+
wave = int(sections[index])
|
|
76
|
+
refs = re.findall(r"^- \[[ xX]\] \*\*`([^`]+)`\*\*", sections[index + 1], re.MULTILINE)
|
|
77
|
+
if refs and all(ref in issues and issues[ref].done for ref in refs):
|
|
78
|
+
for ref in refs:
|
|
79
|
+
if ref in frozen:
|
|
80
|
+
raise ValueError(f"completed Issue {ref} appears in multiple waves")
|
|
81
|
+
frozen[ref] = wave
|
|
82
|
+
floor = max(frozen.values(), default=-1) + 1
|
|
83
|
+
levels = dict(frozen)
|
|
84
|
+
|
|
85
|
+
def place(ref: str) -> int:
|
|
86
|
+
if ref not in levels:
|
|
87
|
+
blockers = [place(blocker) + 1 for blocker in issues[ref].blocked_by if blocker in issues]
|
|
88
|
+
levels[ref] = max([floor, *blockers])
|
|
89
|
+
return levels[ref]
|
|
90
|
+
|
|
91
|
+
for ref in sorted(issues):
|
|
92
|
+
place(ref)
|
|
93
|
+
for ref, issue in issues.items():
|
|
94
|
+
if any(levels[blocker] >= levels[ref] for blocker in issue.blocked_by if blocker in issues):
|
|
95
|
+
raise ValueError(f"dependencies of {ref} conflict with completed wave history")
|
|
96
|
+
return levels
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_roadmap(text: str, issues: dict[str, Issue], root: Path, *, scratch: Path | None = None) -> str:
|
|
100
|
+
levels = delivery_levels(text, issues)
|
|
101
|
+
numbers = spec_numbers(scratch) if scratch else policy_spec_numbers(root)
|
|
102
|
+
done_issues, total_issues, done_specs, total_specs = counts(issues)
|
|
103
|
+
blocks = {
|
|
104
|
+
"spec progress": render_spec_progress(issues, levels, numbers),
|
|
105
|
+
"issue checklist": render_issue_checklist(issues, levels, numbers),
|
|
106
|
+
}
|
|
107
|
+
for name, expression in BLOCK_RE.items():
|
|
108
|
+
if not expression.search(text):
|
|
109
|
+
raise SystemExit(f"ROADMAP.md is missing the generated block markers for '{name}'")
|
|
110
|
+
text = expression.sub(lambda match, name=name: match.group(1) + "\n" + blocks[name] + "\n" + match.group(3), text)
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
for line in text.splitlines():
|
|
113
|
+
if match := PROGRESS_ISSUES_RE.match(line):
|
|
114
|
+
line = f"{match.group(1)}{done_issues} / {total_issues}{match.group(4)}"
|
|
115
|
+
if match := PROGRESS_SPECS_RE.match(line):
|
|
116
|
+
line = f"{match.group(1)}{done_specs} / {total_specs}{match.group(4)}"
|
|
117
|
+
if match := PROGRESS_WAVES_RE.match(line):
|
|
118
|
+
line = f"{match.group(1)}{max(levels.values()) + 1 if levels else 0}{match.group(3)}"
|
|
119
|
+
lines.append(line)
|
|
120
|
+
return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def drift(old: str, new: str) -> list[str]:
|
|
124
|
+
changes = [line for line in difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="", n=0)
|
|
125
|
+
if line.startswith(("+", "-")) and not line.startswith(("+++", "---"))]
|
|
126
|
+
return changes[:40] + ([f"… {len(changes) - 40} more"] if len(changes) > 40 else [])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def set_status(path: Path, value: str) -> None:
|
|
130
|
+
text = path.read_text(encoding="utf-8")
|
|
131
|
+
if not STATUS_LINE_RE.search(text):
|
|
132
|
+
raise SystemExit(f"{path} has no Status: line")
|
|
133
|
+
path.write_text(STATUS_LINE_RE.sub(f"Status: {value}", text, count=1), encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def append_comment(path: Path, value: str) -> None:
|
|
137
|
+
text = path.read_text(encoding="utf-8").rstrip("\n")
|
|
138
|
+
entry = f"- {dt.date.today().isoformat()} — {value.strip()}\n"
|
|
139
|
+
text += "\n" + entry if re.search(r"^## Comments\s*$", text, re.MULTILINE) else "\n\n## Comments\n\n" + entry
|
|
140
|
+
path.write_text(text, encoding="utf-8")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> int:
|
|
144
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
145
|
+
parser.add_argument("command", choices=["check", "waves", "done", "status", "comment"])
|
|
146
|
+
parser.add_argument("issue", nargs="?")
|
|
147
|
+
parser.add_argument("value", nargs="?")
|
|
148
|
+
parser.add_argument("--scratch", help="legacy Issue root override")
|
|
149
|
+
parser.add_argument("--roadmap", default="ROADMAP.md")
|
|
150
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
151
|
+
parser.add_argument("--json", action="store_true")
|
|
152
|
+
args = parser.parse_args()
|
|
153
|
+
root = repo_root()
|
|
154
|
+
roadmap = root / args.roadmap
|
|
155
|
+
scratch = root / args.scratch if args.scratch else None
|
|
156
|
+
issues = load_issues(scratch) if scratch else load_policy_issues(root)
|
|
157
|
+
if args.command in ("check", "waves"):
|
|
158
|
+
old = roadmap.read_text(encoding="utf-8")
|
|
159
|
+
try:
|
|
160
|
+
new = render_roadmap(old, issues, root, scratch=scratch)
|
|
161
|
+
except ValueError as exc:
|
|
162
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
changes = drift(old, new)
|
|
165
|
+
if args.command == "waves" and not args.dry_run and changes:
|
|
166
|
+
roadmap.write_text(new, encoding="utf-8")
|
|
167
|
+
done_issues, total_issues, done_specs, total_specs = counts(issues)
|
|
168
|
+
payload = {"issues_done": done_issues, "issues_total": total_issues, "specs_done": done_specs,
|
|
169
|
+
"specs_total": total_specs, "drift": changes, "written": args.command == "waves" and not args.dry_run and bool(changes)}
|
|
170
|
+
print(json.dumps(payload, indent=2) if args.json else f"issues {done_issues}/{total_issues}, specs {done_specs}/{total_specs}")
|
|
171
|
+
return 1 if args.command == "check" and changes else 0
|
|
172
|
+
if not args.issue:
|
|
173
|
+
parser.error(f"{args.command} needs an Issue reference")
|
|
174
|
+
issue = resolve_issue_arg(args.issue, root, issues)
|
|
175
|
+
if not issue:
|
|
176
|
+
print(f"issue not found: {args.issue}", file=sys.stderr)
|
|
177
|
+
return 2
|
|
178
|
+
if args.command == "status":
|
|
179
|
+
if not args.value:
|
|
180
|
+
parser.error("status needs a value")
|
|
181
|
+
if args.value.lower() == "done":
|
|
182
|
+
print("refusing: use `roadmap.py done` so the roadmap is updated in the same change", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
if not args.dry_run:
|
|
185
|
+
set_status(issue.path, args.value)
|
|
186
|
+
print(f"{issue.ref}: Status -> {args.value}")
|
|
187
|
+
return 0
|
|
188
|
+
if args.command == "comment":
|
|
189
|
+
if not args.value:
|
|
190
|
+
parser.error("comment needs text")
|
|
191
|
+
if not args.dry_run:
|
|
192
|
+
append_comment(issue.path, args.value)
|
|
193
|
+
print(f"{issue.ref}: comment appended")
|
|
194
|
+
return 0
|
|
195
|
+
if not issue.criteria:
|
|
196
|
+
print(f"refusing: {issue.ref} has no acceptance criteria to satisfy", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
if args.dry_run:
|
|
199
|
+
print(f"would: mark {issue.ref} done")
|
|
200
|
+
return 0
|
|
201
|
+
from acceptance import tick_all
|
|
202
|
+
tick_all(issue.path)
|
|
203
|
+
set_status(issue.path, "done")
|
|
204
|
+
refreshed_issues = load_issues(scratch) if scratch else load_policy_issues(root)
|
|
205
|
+
new = render_roadmap(roadmap.read_text(encoding="utf-8"), refreshed_issues, root, scratch=scratch)
|
|
206
|
+
roadmap.write_text(new, encoding="utf-8")
|
|
207
|
+
print(f"did: tick criteria and set Status: done in {issue.path.relative_to(root)}")
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
sys.exit(main())
|