@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,491 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Append and query the machine-local, append-only Gantry Run log."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import datetime
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
EVENTS = {
|
|
16
|
+
"run.started",
|
|
17
|
+
"run.resumed",
|
|
18
|
+
"run.cancelled",
|
|
19
|
+
"run.finished",
|
|
20
|
+
"round.started",
|
|
21
|
+
"round.finished",
|
|
22
|
+
"phase.started",
|
|
23
|
+
"phase.finished",
|
|
24
|
+
"subagent.started",
|
|
25
|
+
"subagent.stopped",
|
|
26
|
+
"compaction",
|
|
27
|
+
"hook.denied",
|
|
28
|
+
"hook.degraded",
|
|
29
|
+
"policy.changed",
|
|
30
|
+
"issue.done",
|
|
31
|
+
"issue.blocked",
|
|
32
|
+
"refutation",
|
|
33
|
+
"review.finding",
|
|
34
|
+
}
|
|
35
|
+
RUN_EVENTS = {"run.started", "run.resumed", "run.cancelled", "run.finished"}
|
|
36
|
+
ROUND_EVENTS = {"round.started", "round.finished"}
|
|
37
|
+
PHASE_EVENTS = {"phase.started", "phase.finished"}
|
|
38
|
+
SUBAGENT_EVENTS = {"subagent.started", "subagent.stopped"}
|
|
39
|
+
ISSUE_EVENTS = {"issue.done", "issue.blocked", "refutation", "review.finding"}
|
|
40
|
+
FINISHED_EVENTS = {"run.cancelled", "run.finished"}
|
|
41
|
+
UNIT_ID_RE = re.compile(r"^[0-9a-f]{12}$")
|
|
42
|
+
RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
43
|
+
ISSUE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*#\d{2,}$")
|
|
44
|
+
PROHIBITED_KEY_TOKENS = {"command", "commands", "cmd", "diff", "diffs", "output", "outputs", "patch", "patches", "stderr", "stdout"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EventError(ValueError):
|
|
48
|
+
"""An event cannot safely be written to the Run log."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def state_root(value: str | None) -> Path:
|
|
52
|
+
return Path(value).expanduser().resolve() if value else Path.home() / ".gantry" / "state"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unit_id(cwd: Path) -> str:
|
|
56
|
+
"""Derive the shared execution-unit identifier for a repository worktree."""
|
|
57
|
+
try:
|
|
58
|
+
common_dir = subprocess.run(
|
|
59
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
60
|
+
cwd=cwd,
|
|
61
|
+
text=True,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
check=True,
|
|
64
|
+
).stdout.strip()
|
|
65
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
66
|
+
raise EventError(f"could not resolve Git common directory: {error}") from error
|
|
67
|
+
path = Path(common_dir)
|
|
68
|
+
real_common_dir = (cwd / path).resolve() if not path.is_absolute() else path.resolve()
|
|
69
|
+
return hashlib.sha256(str(real_common_dir).encode("utf-8")).hexdigest()[:12]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def git_dir(cwd: Path) -> Path:
|
|
73
|
+
"""The *per-worktree* git directory of `cwd` (`.git`, or `.git/worktrees/<name>`).
|
|
74
|
+
|
|
75
|
+
Unlike `unit_id`, which deliberately shares one identity across every worktree of a
|
|
76
|
+
clone, this is the one directory git itself keeps per worktree -- which is exactly the
|
|
77
|
+
keying a "current Run" marker needs, so two worktrees of one execution unit running
|
|
78
|
+
different Runs concurrently never attribute a hook denial to each other's Run.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
raw = subprocess.run(
|
|
82
|
+
["git", "rev-parse", "--git-dir"],
|
|
83
|
+
cwd=cwd,
|
|
84
|
+
text=True,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
check=True,
|
|
87
|
+
).stdout.strip()
|
|
88
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
89
|
+
raise EventError(f"could not resolve Git directory: {error}") from error
|
|
90
|
+
path = Path(raw)
|
|
91
|
+
return path.resolve() if path.is_absolute() else (cwd / path).resolve()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def marker_path(cwd: Path) -> Path:
|
|
95
|
+
return git_dir(cwd) / "gantry" / "current-run.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_marker(cwd: Path, run: str, state_root_value: str | None) -> Path:
|
|
99
|
+
"""Record which Run is currently executing in this worktree."""
|
|
100
|
+
if not RUN_ID_RE.fullmatch(run):
|
|
101
|
+
raise EventError("run must contain only letters, numbers, dots, colons, underscores, or hyphens")
|
|
102
|
+
path = marker_path(cwd)
|
|
103
|
+
payload: dict[str, str] = {"run": run, "worktree": str(cwd.resolve())}
|
|
104
|
+
if state_root_value:
|
|
105
|
+
payload["stateRoot"] = str(state_root(state_root_value))
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
path.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_marker(cwd: Path) -> dict | None:
|
|
112
|
+
"""The current-Run marker of `cwd`'s worktree, or `None` when there is none to trust."""
|
|
113
|
+
try:
|
|
114
|
+
payload = json.loads(marker_path(cwd).read_text(encoding="utf-8"))
|
|
115
|
+
except (EventError, OSError, json.JSONDecodeError):
|
|
116
|
+
return None
|
|
117
|
+
if not isinstance(payload, dict):
|
|
118
|
+
return None
|
|
119
|
+
run = payload.get("run")
|
|
120
|
+
if not isinstance(run, str) or not RUN_ID_RE.fullmatch(run):
|
|
121
|
+
return None
|
|
122
|
+
return payload
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def clear_marker(cwd: Path) -> None:
|
|
126
|
+
try:
|
|
127
|
+
marker_path(cwd).unlink(missing_ok=True)
|
|
128
|
+
except (EventError, OSError):
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_hook_run(cwd: Path) -> tuple[str | None, str | None]:
|
|
133
|
+
"""Resolve (run id, state root) for a hook process, environment first, marker second.
|
|
134
|
+
|
|
135
|
+
A harness that exports `GANTRY_RUN_ID` into the hook's environment wins; otherwise the
|
|
136
|
+
Run is read from this worktree's own current-Run marker, which the round workflow writes
|
|
137
|
+
before any agent works in the worktree. This is what makes a `hook.denied` recording a
|
|
138
|
+
guarantee rather than a best-effort: a git hook inherits its environment from whatever
|
|
139
|
+
shelled out to `git`, but it always runs inside the worktree the marker describes.
|
|
140
|
+
"""
|
|
141
|
+
env_state_root = os.environ.get("GANTRY_STATE_ROOT") or None
|
|
142
|
+
env_run = os.environ.get("GANTRY_RUN_ID")
|
|
143
|
+
if env_run and RUN_ID_RE.fullmatch(env_run):
|
|
144
|
+
return env_run, env_state_root
|
|
145
|
+
marker = read_marker(cwd)
|
|
146
|
+
if not marker:
|
|
147
|
+
return None, env_state_root
|
|
148
|
+
marked_root = marker.get("stateRoot")
|
|
149
|
+
return marker["run"], env_state_root or (marked_root if isinstance(marked_root, str) else None)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def require_string(value: object, name: str) -> str:
|
|
153
|
+
if not isinstance(value, str) or not value.strip():
|
|
154
|
+
raise EventError(f"{name} must be a non-empty string")
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_positive_integer(value: object, name: str) -> int:
|
|
159
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
160
|
+
raise EventError(f"{name} must be a positive integer")
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validate_timestamp(value: object) -> None:
|
|
165
|
+
timestamp = require_string(value, "ts")
|
|
166
|
+
try:
|
|
167
|
+
datetime.datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
168
|
+
except ValueError as error:
|
|
169
|
+
raise EventError("ts must be an ISO 8601 timestamp") from error
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def validate_sensitive_data(value: object, path: str = "data") -> None:
|
|
173
|
+
"""Reject command, output, diff, and patch fields at every nesting level."""
|
|
174
|
+
if isinstance(value, dict):
|
|
175
|
+
for key, nested in value.items():
|
|
176
|
+
if not isinstance(key, str):
|
|
177
|
+
raise EventError(f"{path} keys must be strings")
|
|
178
|
+
words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", key)
|
|
179
|
+
tokens = re.findall(r"[a-z]+", words.lower())
|
|
180
|
+
if any(token in PROHIBITED_KEY_TOKENS for token in tokens):
|
|
181
|
+
raise EventError(f"{path}.{key} must not be stored in the Run log")
|
|
182
|
+
validate_sensitive_data(nested, f"{path}.{key}")
|
|
183
|
+
elif isinstance(value, list):
|
|
184
|
+
for index, nested in enumerate(value):
|
|
185
|
+
validate_sensitive_data(nested, f"{path}[{index}]")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def validate_secret_checks(value: object, path: str = "data") -> None:
|
|
189
|
+
if isinstance(value, dict):
|
|
190
|
+
if value.get("secrets") is True:
|
|
191
|
+
permitted = {"secrets", "exitCode", "counts"}
|
|
192
|
+
unexpected = sorted(set(value) - permitted)
|
|
193
|
+
if unexpected:
|
|
194
|
+
raise EventError(f"{path} for a secrets check may record only exitCode and counts")
|
|
195
|
+
if "exitCode" not in value or "counts" not in value:
|
|
196
|
+
raise EventError(f"{path} for a secrets check requires exitCode and counts")
|
|
197
|
+
if not isinstance(value["exitCode"], int) or isinstance(value["exitCode"], bool):
|
|
198
|
+
raise EventError(f"{path}.exitCode must be an integer")
|
|
199
|
+
if not isinstance(value["counts"], dict):
|
|
200
|
+
raise EventError(f"{path}.counts must be an object")
|
|
201
|
+
for key, nested in value.items():
|
|
202
|
+
validate_secret_checks(nested, f"{path}.{key}")
|
|
203
|
+
elif isinstance(value, list):
|
|
204
|
+
for index, nested in enumerate(value):
|
|
205
|
+
validate_secret_checks(nested, f"{path}[{index}]")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_event(payload: object) -> dict:
|
|
209
|
+
"""Validate a stable event envelope before it reaches persistent state."""
|
|
210
|
+
if not isinstance(payload, dict):
|
|
211
|
+
raise EventError("event must be a JSON object")
|
|
212
|
+
unknown = sorted(set(payload) - {"ts", "run", "event", "issue", "phase", "data"})
|
|
213
|
+
if unknown:
|
|
214
|
+
raise EventError(f"unknown event fields: {', '.join(unknown)}")
|
|
215
|
+
for field in ("ts", "run", "event"):
|
|
216
|
+
if field not in payload:
|
|
217
|
+
raise EventError(f"event is missing required field: {field}")
|
|
218
|
+
validate_timestamp(payload["ts"])
|
|
219
|
+
run = require_string(payload["run"], "run")
|
|
220
|
+
if not RUN_ID_RE.fullmatch(run):
|
|
221
|
+
raise EventError("run must contain only letters, numbers, dots, colons, underscores, or hyphens")
|
|
222
|
+
event = payload["event"]
|
|
223
|
+
if event not in EVENTS:
|
|
224
|
+
raise EventError(f"unknown event: {event!r}")
|
|
225
|
+
if "issue" in payload:
|
|
226
|
+
issue = require_string(payload["issue"], "issue")
|
|
227
|
+
if not ISSUE_RE.fullmatch(issue):
|
|
228
|
+
raise EventError("issue must be an Issue reference such as sample#01")
|
|
229
|
+
if "phase" in payload:
|
|
230
|
+
require_string(payload["phase"], "phase")
|
|
231
|
+
data = payload.get("data")
|
|
232
|
+
if data is not None and not isinstance(data, dict):
|
|
233
|
+
raise EventError("data must be an object when supplied")
|
|
234
|
+
validate_sensitive_data(data)
|
|
235
|
+
validate_secret_checks(data)
|
|
236
|
+
|
|
237
|
+
if event == "run.started":
|
|
238
|
+
if "data" not in payload:
|
|
239
|
+
raise EventError("run.started requires data")
|
|
240
|
+
for field in ("repositoryRoot", "policyHash", "tier", "staleAfterSeconds"):
|
|
241
|
+
if field not in data:
|
|
242
|
+
raise EventError(f"run.started data is missing required field: {field}")
|
|
243
|
+
require_string(data["repositoryRoot"], "data.repositoryRoot")
|
|
244
|
+
require_string(data["policyHash"], "data.policyHash")
|
|
245
|
+
require_string(data["tier"], "data.tier")
|
|
246
|
+
require_positive_integer(data["staleAfterSeconds"], "data.staleAfterSeconds")
|
|
247
|
+
elif event in ROUND_EVENTS:
|
|
248
|
+
if not isinstance(data, dict) or "round" not in data:
|
|
249
|
+
raise EventError(f"{event} requires data.round")
|
|
250
|
+
require_positive_integer(data["round"], "data.round")
|
|
251
|
+
elif event in PHASE_EVENTS:
|
|
252
|
+
if "issue" not in payload or "phase" not in payload:
|
|
253
|
+
raise EventError(f"{event} requires issue and phase")
|
|
254
|
+
elif event in SUBAGENT_EVENTS:
|
|
255
|
+
if not isinstance(data, dict) or "role" not in data:
|
|
256
|
+
raise EventError(f"{event} requires data.role")
|
|
257
|
+
require_string(data["role"], "data.role")
|
|
258
|
+
elif event == "compaction":
|
|
259
|
+
if not isinstance(data, dict) or "source" not in data:
|
|
260
|
+
raise EventError("compaction requires data.source")
|
|
261
|
+
require_string(data["source"], "data.source")
|
|
262
|
+
elif event == "hook.denied":
|
|
263
|
+
if not isinstance(data, dict) or not {"rule", "path"} <= set(data):
|
|
264
|
+
raise EventError("hook.denied requires data.rule and data.path")
|
|
265
|
+
require_string(data["rule"], "data.rule")
|
|
266
|
+
require_string(data["path"], "data.path")
|
|
267
|
+
elif event == "hook.degraded":
|
|
268
|
+
if not isinstance(data, dict) or not {"source", "missing"} <= set(data):
|
|
269
|
+
raise EventError("hook.degraded requires data.source and data.missing")
|
|
270
|
+
require_string(data["source"], "data.source")
|
|
271
|
+
missing = data["missing"]
|
|
272
|
+
if not isinstance(missing, list) or not all(isinstance(item, str) and item for item in missing):
|
|
273
|
+
raise EventError("data.missing must be a list of non-empty strings")
|
|
274
|
+
if data.get("degraded") is not True:
|
|
275
|
+
raise EventError("hook.degraded requires data.degraded to be true")
|
|
276
|
+
elif event == "policy.changed":
|
|
277
|
+
if not isinstance(data, dict) or "policyHash" not in data:
|
|
278
|
+
raise EventError("policy.changed requires data.policyHash")
|
|
279
|
+
require_string(data["policyHash"], "data.policyHash")
|
|
280
|
+
elif event in ISSUE_EVENTS and "issue" not in payload:
|
|
281
|
+
raise EventError(f"{event} requires issue")
|
|
282
|
+
return payload
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_log_path(root: Path, unit: str, run: str) -> Path:
|
|
286
|
+
return root / unit / "runs" / f"{run}.jsonl"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def append_event(path: Path, payload: dict) -> None:
|
|
290
|
+
"""Append a sub-64 KB JSONL event with exactly one write system call."""
|
|
291
|
+
encoded = (json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8")
|
|
292
|
+
if len(encoded) > 64 * 1024:
|
|
293
|
+
raise EventError("event exceeds the 64 KB atomic append limit")
|
|
294
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
descriptor = os.open(path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
|
296
|
+
try:
|
|
297
|
+
written = os.write(descriptor, encoded)
|
|
298
|
+
if written != len(encoded):
|
|
299
|
+
raise EventError("could not write a complete Run log event")
|
|
300
|
+
finally:
|
|
301
|
+
os.close(descriptor)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def read_valid_events(path: Path) -> list[dict]:
|
|
305
|
+
events: list[dict] = []
|
|
306
|
+
try:
|
|
307
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
308
|
+
except OSError:
|
|
309
|
+
return events
|
|
310
|
+
for line in lines:
|
|
311
|
+
try:
|
|
312
|
+
payload = validate_event(json.loads(line))
|
|
313
|
+
except (EventError, json.JSONDecodeError):
|
|
314
|
+
continue
|
|
315
|
+
if payload["run"] != path.stem:
|
|
316
|
+
continue
|
|
317
|
+
events.append(payload)
|
|
318
|
+
return events
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def inflight(root: Path, unit: str) -> list[dict]:
|
|
322
|
+
"""Derive unfinished Issue phases from valid events only."""
|
|
323
|
+
result: list[dict] = []
|
|
324
|
+
for path in sorted((root / unit / "runs").glob("*.jsonl")):
|
|
325
|
+
events = read_valid_events(path)
|
|
326
|
+
if not events or events[0]["event"] != "run.started":
|
|
327
|
+
continue
|
|
328
|
+
started = events[0]
|
|
329
|
+
active: dict[str, dict] = {}
|
|
330
|
+
finished = False
|
|
331
|
+
for event in events[1:]:
|
|
332
|
+
if event["event"] in FINISHED_EVENTS:
|
|
333
|
+
finished = True
|
|
334
|
+
continue
|
|
335
|
+
if event["event"] == "phase.started":
|
|
336
|
+
active[event["issue"]] = {
|
|
337
|
+
"run": event["run"],
|
|
338
|
+
"issue": event["issue"],
|
|
339
|
+
"phase": event["phase"],
|
|
340
|
+
"worktree": event.get("data", {}).get("worktree"),
|
|
341
|
+
}
|
|
342
|
+
elif event["event"] == "phase.finished":
|
|
343
|
+
active.pop(event["issue"], None)
|
|
344
|
+
elif event["event"] in {"issue.done", "issue.blocked"}:
|
|
345
|
+
active.pop(event["issue"], None)
|
|
346
|
+
if not finished:
|
|
347
|
+
for item in active.values():
|
|
348
|
+
if isinstance(item["worktree"], str) and item["worktree"]:
|
|
349
|
+
result.append(
|
|
350
|
+
{
|
|
351
|
+
**item,
|
|
352
|
+
"repositoryRoot": started["data"]["repositoryRoot"],
|
|
353
|
+
"policyHash": started["data"]["policyHash"],
|
|
354
|
+
"tier": started["data"]["tier"],
|
|
355
|
+
"staleAfterSeconds": started["data"]["staleAfterSeconds"],
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def derive_corrections_spent(events: list[dict], issue_ref: str) -> int:
|
|
362
|
+
"""Apply the documented derivation rule to one Run's own valid events.
|
|
363
|
+
|
|
364
|
+
`runlog.py inflight` never reports `correctionsSpent`: it is derived here from (1) the
|
|
365
|
+
`run.resumed.data.correctionsSpent` recorded on this same Run, but only when that same
|
|
366
|
+
`run.resumed` event also names `issue_ref` as `data.issue` (0 otherwise, including when the Run
|
|
367
|
+
has no `run.resumed` event, or when its `run.resumed` names a different Issue), plus (2) the
|
|
368
|
+
number of `refutation` events for `issue_ref` that are each followed, later in the same log, by a
|
|
369
|
+
`phase.started` `Implement` event for that same Issue — i.e. only refutations whose correction
|
|
370
|
+
pass actually started count toward the spent budget. The base is per-Issue, not per-Run: a Run
|
|
371
|
+
resumed for one Issue must never lend its `correctionsSpent` base to any other Issue that also
|
|
372
|
+
happens to appear in the same Run's log.
|
|
373
|
+
"""
|
|
374
|
+
resumed = next((event for event in events if event["event"] == "run.resumed"), None)
|
|
375
|
+
base = (
|
|
376
|
+
resumed["data"].get("correctionsSpent", 0)
|
|
377
|
+
if resumed and resumed["data"].get("issue") == issue_ref
|
|
378
|
+
else 0
|
|
379
|
+
)
|
|
380
|
+
started_corrections = 0
|
|
381
|
+
for index, event in enumerate(events):
|
|
382
|
+
if event["event"] != "refutation" or event.get("issue") != issue_ref:
|
|
383
|
+
continue
|
|
384
|
+
later = events[index + 1 :]
|
|
385
|
+
if any(
|
|
386
|
+
later_event["event"] == "phase.started"
|
|
387
|
+
and later_event.get("issue") == issue_ref
|
|
388
|
+
and later_event.get("phase") == "Implement"
|
|
389
|
+
for later_event in later
|
|
390
|
+
):
|
|
391
|
+
started_corrections += 1
|
|
392
|
+
return base + started_corrections
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main() -> int:
|
|
396
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
397
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
398
|
+
unit_parser = subparsers.add_parser("unit-id", help="print the current repository execution-unit ID")
|
|
399
|
+
unit_parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
|
|
400
|
+
unit_parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
401
|
+
append_parser = subparsers.add_parser("append", help="append one JSON event from standard input")
|
|
402
|
+
append_parser.add_argument("unit_id", help="twelve-hex repository execution-unit ID")
|
|
403
|
+
append_parser.add_argument("run_id", nargs="?", help="optional Run ID, which must match the event")
|
|
404
|
+
append_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
405
|
+
query_parser = subparsers.add_parser("inflight", help="list interrupted Issue phases")
|
|
406
|
+
query_parser.add_argument("unit_id", help="twelve-hex repository execution-unit ID")
|
|
407
|
+
query_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
408
|
+
query_parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
409
|
+
corrections_parser = subparsers.add_parser(
|
|
410
|
+
"corrections", help="derive correctionsSpent for one Issue from one Run's own log"
|
|
411
|
+
)
|
|
412
|
+
corrections_parser.add_argument("unit_id", help="twelve-hex repository execution-unit ID")
|
|
413
|
+
corrections_parser.add_argument("run_id", help="the Run whose log to read")
|
|
414
|
+
corrections_parser.add_argument("issue", help="Issue reference such as sample#01")
|
|
415
|
+
corrections_parser.add_argument("--state-root", help="override ~/.gantry/state")
|
|
416
|
+
corrections_parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
417
|
+
mark_parser = subparsers.add_parser("mark", help="record which Run is executing in this worktree")
|
|
418
|
+
mark_parser.add_argument("run_id", help="the Run currently executing here")
|
|
419
|
+
mark_parser.add_argument("--cwd", default=".", help="repository or worktree to mark")
|
|
420
|
+
mark_parser.add_argument("--state-root", help="override ~/.gantry/state for hooks in this worktree")
|
|
421
|
+
current_parser = subparsers.add_parser("current", help="print the Run marked as executing in this worktree")
|
|
422
|
+
current_parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
|
|
423
|
+
current_parser.add_argument("--json", action="store_true", help="emit machine-readable output")
|
|
424
|
+
unmark_parser = subparsers.add_parser("unmark", help="remove this worktree's current-Run marker")
|
|
425
|
+
unmark_parser.add_argument("--cwd", default=".", help="repository or worktree to clear")
|
|
426
|
+
args = parser.parse_args()
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
if args.command == "mark":
|
|
430
|
+
write_marker(Path(args.cwd).resolve(), args.run_id, args.state_root)
|
|
431
|
+
return 0
|
|
432
|
+
if args.command == "current":
|
|
433
|
+
marker = read_marker(Path(args.cwd).resolve())
|
|
434
|
+
if not marker:
|
|
435
|
+
return 2
|
|
436
|
+
print(json.dumps(marker, sort_keys=True) if args.json else marker["run"])
|
|
437
|
+
return 0
|
|
438
|
+
if args.command == "unmark":
|
|
439
|
+
clear_marker(Path(args.cwd).resolve())
|
|
440
|
+
return 0
|
|
441
|
+
if args.command == "unit-id":
|
|
442
|
+
value = unit_id(Path(args.cwd).resolve())
|
|
443
|
+
payload = {"unitId": value}
|
|
444
|
+
print(json.dumps(payload, sort_keys=True) if args.json else value)
|
|
445
|
+
return 0
|
|
446
|
+
if not UNIT_ID_RE.fullmatch(args.unit_id):
|
|
447
|
+
raise EventError("unit-id must be exactly twelve lowercase hexadecimal characters")
|
|
448
|
+
root = state_root(args.state_root)
|
|
449
|
+
if args.command == "append":
|
|
450
|
+
try:
|
|
451
|
+
payload = json.loads(sys.stdin.read())
|
|
452
|
+
except json.JSONDecodeError as error:
|
|
453
|
+
raise EventError(f"standard input must contain one JSON event: {error.msg}") from error
|
|
454
|
+
event = validate_event(payload)
|
|
455
|
+
if args.run_id and event["run"] != args.run_id:
|
|
456
|
+
raise EventError("positional run-id must match event.run")
|
|
457
|
+
path = run_log_path(root, args.unit_id, event["run"])
|
|
458
|
+
if path.exists() and event["event"] == "run.started":
|
|
459
|
+
raise EventError("a Run log already starts for this run-id")
|
|
460
|
+
if not path.exists() and event["event"] != "run.started":
|
|
461
|
+
raise EventError("the first event of a Run must be run.started")
|
|
462
|
+
append_event(path, event)
|
|
463
|
+
return 0
|
|
464
|
+
if args.command == "corrections":
|
|
465
|
+
if not ISSUE_RE.fullmatch(args.issue):
|
|
466
|
+
raise EventError("issue must be an Issue reference such as sample#01")
|
|
467
|
+
path = run_log_path(root, args.unit_id, args.run_id)
|
|
468
|
+
if not path.exists():
|
|
469
|
+
raise EventError(f"no Run log for {args.run_id}")
|
|
470
|
+
events = read_valid_events(path)
|
|
471
|
+
spent = derive_corrections_spent(events, args.issue)
|
|
472
|
+
payload = {
|
|
473
|
+
"unitId": args.unit_id,
|
|
474
|
+
"runId": args.run_id,
|
|
475
|
+
"issue": args.issue,
|
|
476
|
+
"correctionsSpent": spent,
|
|
477
|
+
}
|
|
478
|
+
print(json.dumps(payload, sort_keys=True) if args.json else str(spent))
|
|
479
|
+
return 0
|
|
480
|
+
payload = {"unitId": args.unit_id, "inflight": inflight(root, args.unit_id)}
|
|
481
|
+
print(json.dumps(payload, sort_keys=True) if args.json else "\n".join(
|
|
482
|
+
f"{item['run']} {item['issue']} {item['phase']} {item['worktree']}" for item in payload["inflight"]
|
|
483
|
+
))
|
|
484
|
+
return 0
|
|
485
|
+
except EventError as error:
|
|
486
|
+
print(f"runlog error: {error}", file=sys.stderr)
|
|
487
|
+
return 1
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
sys.exit(main())
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Conversational setup wizard for Gantry policy and hooks."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
def merge_dicts(base: dict, update: dict) -> dict:
|
|
9
|
+
for k, v in update.items():
|
|
10
|
+
if isinstance(v, dict) and k in base and isinstance(base[k], dict):
|
|
11
|
+
merge_dicts(base[k], v)
|
|
12
|
+
else:
|
|
13
|
+
base[k] = v
|
|
14
|
+
return base
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(description="Gantry Setup config writer")
|
|
18
|
+
parser.add_argument("--config", help="JSON config string")
|
|
19
|
+
parser.add_argument("--config-file", help="Path to JSON config file")
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
if args.config_file:
|
|
23
|
+
config = json.loads(Path(args.config_file).read_text(encoding="utf-8"))
|
|
24
|
+
elif args.config:
|
|
25
|
+
config = json.loads(args.config)
|
|
26
|
+
else:
|
|
27
|
+
print("Error: No config provided", file=sys.stderr)
|
|
28
|
+
sys.exit(2)
|
|
29
|
+
|
|
30
|
+
print("Proposed .gantry/config.json:")
|
|
31
|
+
print(json.dumps(config, indent=2))
|
|
32
|
+
|
|
33
|
+
repo_root = Path.cwd()
|
|
34
|
+
gantry_dir = repo_root / ".gantry"
|
|
35
|
+
config_path = gantry_dir / "config.json"
|
|
36
|
+
|
|
37
|
+
if config_path.exists():
|
|
38
|
+
choice = input("Config exists. [M]erge, [O]verwrite, or [A]bort? ").strip().lower()
|
|
39
|
+
if choice.startswith('a'):
|
|
40
|
+
print("Aborted.")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
elif choice.startswith('o'):
|
|
43
|
+
final_config = config
|
|
44
|
+
elif choice.startswith('m'):
|
|
45
|
+
existing = json.loads(config_path.read_text(encoding="utf-8"))
|
|
46
|
+
final_config = merge_dicts(existing, config)
|
|
47
|
+
else:
|
|
48
|
+
print("Invalid choice, aborted.")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
else:
|
|
51
|
+
choice = input("Write this policy? [y/N] ").strip().lower()
|
|
52
|
+
if not choice.startswith('y'):
|
|
53
|
+
print("Aborted.")
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
final_config = config
|
|
56
|
+
|
|
57
|
+
gantry_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
config_path.write_text(json.dumps(final_config, indent=2) + "\n", encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
hook_frag_path = Path(__file__).resolve().parents[1] / "hooks" / "claude-code.settings.json"
|
|
61
|
+
if hook_frag_path.exists():
|
|
62
|
+
hook_frag = json.loads(hook_frag_path.read_text(encoding="utf-8"))
|
|
63
|
+
settings_path = repo_root / ".claude" / "settings.json"
|
|
64
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
content = settings_path.read_text(encoding="utf-8") if settings_path.exists() else ""
|
|
66
|
+
|
|
67
|
+
new_hooks = hook_frag.get("hooks", {})
|
|
68
|
+
if not content.strip():
|
|
69
|
+
settings_path.write_text(json.dumps({"hooks": new_hooks}, indent=2) + "\n", encoding="utf-8")
|
|
70
|
+
else:
|
|
71
|
+
# Parse top level to check if hooks exists
|
|
72
|
+
parsed = json.loads(content)
|
|
73
|
+
if "hooks" not in parsed:
|
|
74
|
+
last_brace = content.rfind('}')
|
|
75
|
+
if last_brace != -1:
|
|
76
|
+
hooks_json = json.dumps({"hooks": new_hooks}, indent=2)[1:-1]
|
|
77
|
+
if not parsed:
|
|
78
|
+
new_content = content[:last_brace] + hooks_json + content[last_brace:]
|
|
79
|
+
else:
|
|
80
|
+
new_content = content[:last_brace] + "," + hooks_json + content[last_brace:]
|
|
81
|
+
settings_path.write_text(new_content, encoding="utf-8")
|
|
82
|
+
else:
|
|
83
|
+
import re
|
|
84
|
+
match = re.search(r'"hooks"\s*:\s*\{', content)
|
|
85
|
+
if match:
|
|
86
|
+
start_idx = match.end() - 1
|
|
87
|
+
brace_count = 0
|
|
88
|
+
end_idx = start_idx
|
|
89
|
+
in_string = False
|
|
90
|
+
escape = False
|
|
91
|
+
for i in range(start_idx, len(content)):
|
|
92
|
+
c = content[i]
|
|
93
|
+
if not in_string:
|
|
94
|
+
if c == '{': brace_count += 1
|
|
95
|
+
elif c == '}':
|
|
96
|
+
brace_count -= 1
|
|
97
|
+
if brace_count == 0:
|
|
98
|
+
end_idx = i + 1
|
|
99
|
+
break
|
|
100
|
+
elif c == '"': in_string = True
|
|
101
|
+
else:
|
|
102
|
+
if escape: escape = False
|
|
103
|
+
elif c == '\\': escape = True
|
|
104
|
+
elif c == '"': in_string = False
|
|
105
|
+
old_hooks = json.loads(content[start_idx:end_idx])
|
|
106
|
+
for k, v in new_hooks.items():
|
|
107
|
+
old_hooks[k] = v
|
|
108
|
+
lines = content[:start_idx].split('\n')
|
|
109
|
+
base_indent = len(lines[-1]) - len(lines[-1].lstrip()) if lines else 2
|
|
110
|
+
new_hooks_text = json.dumps(old_hooks, indent=2)
|
|
111
|
+
indented_new_hooks = new_hooks_text.replace('\n', '\n' + ' ' * base_indent)
|
|
112
|
+
settings_path.write_text(content[:start_idx] + indented_new_hooks + content[end_idx:], encoding="utf-8")
|
|
113
|
+
|
|
114
|
+
agents_path = repo_root / "AGENTS.md"
|
|
115
|
+
content = agents_path.read_text(encoding="utf-8") if agents_path.exists() else ""
|
|
116
|
+
begin_marker = "<!-- gantry:begin -->"
|
|
117
|
+
end_marker = "<!-- gantry:end -->"
|
|
118
|
+
|
|
119
|
+
new_section = (
|
|
120
|
+
f"{begin_marker}\n"
|
|
121
|
+
"## Gantry Repository Policy\n\n"
|
|
122
|
+
"This repository uses Gantry for its agentic SDLC.\n"
|
|
123
|
+
"Artifacts, templates, and checks are configured in `.gantry/config.json`.\n"
|
|
124
|
+
f"{end_marker}\n"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if begin_marker in content and end_marker in content:
|
|
128
|
+
start = content.find(begin_marker)
|
|
129
|
+
end = content.find(end_marker) + len(end_marker)
|
|
130
|
+
if content[end:end+1] == "\n":
|
|
131
|
+
end += 1
|
|
132
|
+
new_content = content[:start] + new_section + content[end:]
|
|
133
|
+
else:
|
|
134
|
+
new_content = content + ("\n" if content and not content.endswith("\n") else "") + new_section
|
|
135
|
+
|
|
136
|
+
agents_path.write_text(new_content, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|