leos-agent 6.3.0 → 10.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/LICENSE +21 -0
- package/README.md +547 -24
- package/commands/handoff.md +11 -0
- package/commands/handon.md +10 -0
- package/commands/leo-doctor.md +22 -0
- package/commands/leo-install.md +9 -0
- package/commands/review-pr.md +9 -0
- package/commands-claude/watch-review.md +9 -0
- package/index.js +12 -0
- package/package.json +30 -18
- package/payload/codex-agents/leo-executor.toml +36 -0
- package/payload/codex-agents/leo-runner.toml +28 -0
- package/rules/preferences.md +97 -0
- package/scripts/check.py +244 -0
- package/scripts/ghreview.py +24 -6
- package/scripts/handoff.py +183 -0
- package/scripts/leo-install.py +509 -0
- package/scripts/measure_context.py +113 -0
- package/scripts/publish-npm.py +138 -0
- package/scripts/resolve_attach_target.py +45 -13
- package/scripts/watch_review.py +169 -0
- package/skills/doctor/SKILL.md +73 -96
- package/skills/doctor/agents/openai.yaml +5 -0
- package/skills/handoff/SKILL.md +99 -0
- package/skills/handoff/agents/openai.yaml +5 -0
- package/skills/handon/SKILL.md +61 -0
- package/skills/install/SKILL.md +79 -0
- package/skills/install/agents/openai.yaml +5 -0
- package/skills/review-pr/SKILL.md +59 -308
- package/skills/review-pr/reference/lenses.md +67 -0
- package/skills/review-pr/reference/procedure.md +348 -0
- package/skills-claude/attach-pr/SKILL.md +178 -0
- package/skills-claude/watch-review/SKILL.md +91 -0
- package/adapters/cursor/agents/executor.md +0 -17
- package/adapters/cursor/agents/expert.md +0 -70
- package/adapters/cursor/agents/explore.md +0 -16
- package/adapters/cursor/agents/implementer.md +0 -18
- package/adapters/cursor/agents/investigator.md +0 -18
- package/adapters/cursor/agents/planner.md +0 -28
- package/adapters/cursor/agents/reviewer.md +0 -34
- package/adapters/opencode/agents.json +0 -66
- package/adapters/opencode/plugin.js +0 -288
- package/config/models.json +0 -408
- package/hooks/bash-guard.py +0 -541
- package/hooks/cursor-guard.py +0 -84
- package/hooks/hooks-cursor.json +0 -11
- package/hooks/hooks.json +0 -20
- package/hooks/session-start.py +0 -148
- package/roles/executor.md +0 -15
- package/roles/expert.md +0 -67
- package/roles/explore.md +0 -13
- package/roles/implementer.md +0 -16
- package/roles/investigator.md +0 -15
- package/roles/planner.md +0 -25
- package/roles/reviewer.md +0 -31
- package/scripts/doctor.py +0 -284
- package/scripts/memory.py +0 -705
- package/scripts/render_adapters.py +0 -473
- package/scripts/setup.py +0 -161
- package/settings.json +0 -7
- package/skills/.gitkeep +0 -0
- package/skills/brainstorming/SKILL.md +0 -109
- package/skills/debugging/SKILL.md +0 -98
- package/skills/delegation/SKILL.md +0 -141
- package/skills/executing-plans/SKILL.md +0 -116
- package/skills/finishing-a-branch/SKILL.md +0 -123
- package/skills/freshness/SKILL.md +0 -118
- package/skills/memory/SKILL.md +0 -144
- package/skills/resolve-ticket/SKILL.md +0 -269
- package/skills/setup/SKILL.md +0 -85
- package/skills/test-first/SKILL.md +0 -90
- package/skills/using-leo/SKILL.md +0 -96
- package/skills/using-leo/references/claude-mapping.md +0 -32
- package/skills/using-leo/references/codex-mapping.md +0 -34
- package/skills/using-leo/references/cursor-mapping.md +0 -34
- package/skills/using-leo/references/hermes-mapping.md +0 -36
- package/skills/using-leo/references/opencode-mapping.md +0 -36
- package/skills/verification/SKILL.md +0 -109
- package/skills/visual-verification/SKILL.md +0 -114
- package/skills/watch-review/SKILL.md +0 -125
- package/skills/worktrees/SKILL.md +0 -129
- package/skills/writing-plans/SKILL.md +0 -96
- package/skills/writing-skills/SKILL.md +0 -134
- package/workflows/cost-tiered-fix.js +0 -259
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure leos-agent's static prompt footprint with a byte-based proxy.
|
|
3
|
+
|
|
4
|
+
This does not estimate total task cost: tool output, conversation history,
|
|
5
|
+
cache state, model choice, and spawned work dominate many real runs. It measures
|
|
6
|
+
the repository-controlled text that is always listed or loaded at dispatch, so
|
|
7
|
+
regressions remain visible without a tokenizer or network access.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
|
|
18
|
+
# Deliberately tight ceilings. Raise one only with a concrete reason and record
|
|
19
|
+
# the before/after output in the change that raises it.
|
|
20
|
+
LIMITS = {
|
|
21
|
+
"global_policy_bytes": 4_500,
|
|
22
|
+
"codex_implicit_skill_metadata_bytes": 600,
|
|
23
|
+
"claude_implicit_skill_metadata_bytes": 800,
|
|
24
|
+
"codex_agent_description_bytes": 550,
|
|
25
|
+
"review_dispatch_bytes": 3_500,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def frontmatter(path):
|
|
30
|
+
text = path.read_text(encoding="utf-8")
|
|
31
|
+
match = re.match(r"---\n(.*?)\n---\n?(.*)", text, re.DOTALL)
|
|
32
|
+
if not match:
|
|
33
|
+
raise ValueError(f"{path.relative_to(ROOT)} has no YAML frontmatter")
|
|
34
|
+
return match.group(1), match.group(2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def field(text, name):
|
|
38
|
+
match = re.search(rf"(?m)^{re.escape(name)}:\s*(.+?)\s*$", text)
|
|
39
|
+
return match.group(1).strip() if match else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def byte_len(text):
|
|
43
|
+
return len(text.encode("utf-8"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def skill_metadata_bytes(paths, implicit):
|
|
47
|
+
total = 0
|
|
48
|
+
for path in paths:
|
|
49
|
+
fm, _ = frontmatter(path)
|
|
50
|
+
if implicit(path, fm):
|
|
51
|
+
total += byte_len(field(fm, "name")) + byte_len(field(fm, "description"))
|
|
52
|
+
return total
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def codex_implicit(path, _frontmatter):
|
|
56
|
+
policy = path.parent / "agents" / "openai.yaml"
|
|
57
|
+
return not policy.is_file() or "allow_implicit_invocation: false" not in policy.read_text(encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def claude_implicit(_path, fm):
|
|
61
|
+
return re.search(r"(?m)^disable-model-invocation:\s*true\s*$", fm) is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def agent_description(path):
|
|
65
|
+
text = path.read_text(encoding="utf-8")
|
|
66
|
+
match = re.search(r'(?m)^description\s*=\s*"(.*)"\s*$', text)
|
|
67
|
+
if not match:
|
|
68
|
+
raise ValueError(f"{path.relative_to(ROOT)} has no one-line description")
|
|
69
|
+
return match.group(1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def measurements():
|
|
73
|
+
portable = sorted((ROOT / "skills").glob("*/SKILL.md"))
|
|
74
|
+
claude_only = sorted((ROOT / "skills-claude").glob("*/SKILL.md"))
|
|
75
|
+
policy_fm, policy_body = frontmatter(ROOT / "rules" / "preferences.md")
|
|
76
|
+
del policy_fm
|
|
77
|
+
review_fm, review_body = frontmatter(ROOT / "skills" / "review-pr" / "SKILL.md")
|
|
78
|
+
del review_fm
|
|
79
|
+
agent_paths = sorted((ROOT / "payload" / "codex-agents").glob("*.toml"))
|
|
80
|
+
return {
|
|
81
|
+
"global_policy_bytes": byte_len(policy_body.strip()),
|
|
82
|
+
"codex_implicit_skill_metadata_bytes": skill_metadata_bytes(portable, codex_implicit),
|
|
83
|
+
"claude_implicit_skill_metadata_bytes": skill_metadata_bytes(portable + claude_only, claude_implicit),
|
|
84
|
+
"codex_agent_description_bytes": sum(byte_len(agent_description(path)) for path in agent_paths),
|
|
85
|
+
"review_dispatch_bytes": byte_len(review_body.strip()),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv=None):
|
|
90
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
91
|
+
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
92
|
+
parser.add_argument("--check", action="store_true", help="fail when a committed ceiling is exceeded")
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
values = measurements()
|
|
96
|
+
if args.json:
|
|
97
|
+
print(json.dumps({"measurements": values, "limits": LIMITS}, indent=2, sort_keys=True))
|
|
98
|
+
else:
|
|
99
|
+
print("Static prompt footprint (bytes; tokens are roughly bytes / 4 for this prose)")
|
|
100
|
+
for name, value in values.items():
|
|
101
|
+
print(f" {name:38} {value:5} limit {LIMITS[name]:5}")
|
|
102
|
+
print("This excludes conversation history, tool output, cache effects, and subagent work.")
|
|
103
|
+
|
|
104
|
+
over = {name: (value, LIMITS[name]) for name, value in values.items() if value > LIMITS[name]}
|
|
105
|
+
if args.check and over:
|
|
106
|
+
for name, (value, limit) in over.items():
|
|
107
|
+
print(f"FAIL {name}: {value} > {limit}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Publish leos-agent to npm exactly once per version.
|
|
3
|
+
|
|
4
|
+
Two properties matter here, and both are inherited from the release path this
|
|
5
|
+
replaces. Publishing is idempotent: an exact version already on the registry is
|
|
6
|
+
a no-op, so re-running a tag is safe, while a lookup that fails for any reason
|
|
7
|
+
other than a confirmed 404 aborts rather than guessing. And the tree npm would
|
|
8
|
+
actually ship is inspected before it ships, because `files` in package.json
|
|
9
|
+
scopes the publish but does not exclude build residue that lands inside a
|
|
10
|
+
directory it lists.
|
|
11
|
+
|
|
12
|
+
Authentication is npm's OIDC trusted publishing: the workflow's `id-token`
|
|
13
|
+
permission supplies a short-lived credential, so there is no token to read here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
23
|
+
PACKAGE = "leos-agent"
|
|
24
|
+
|
|
25
|
+
# Residue that a local checkout accumulates and a publish must never carry.
|
|
26
|
+
FORBIDDEN_PARTS = ("__pycache__",)
|
|
27
|
+
FORBIDDEN_SUFFIXES = (".pyc", ".log")
|
|
28
|
+
FORBIDDEN_NAMES = (".DS_Store",)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReleaseError(Exception):
|
|
32
|
+
"""The release cannot proceed safely; the caller should stop, not retry."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(command):
|
|
36
|
+
return subprocess.run(command, capture_output=True, text=True, check=False, cwd=ROOT)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def declared_version():
|
|
40
|
+
version = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))["version"]
|
|
41
|
+
return version
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pack_inventory(npm="npm"):
|
|
45
|
+
"""Return the file list npm would publish, without publishing it."""
|
|
46
|
+
packed = run([npm, "pack", "--dry-run", "--json"])
|
|
47
|
+
if packed.returncode:
|
|
48
|
+
raise ReleaseError(f"npm pack --dry-run failed: {(packed.stdout + packed.stderr).strip()}")
|
|
49
|
+
try:
|
|
50
|
+
report = json.loads(packed.stdout)
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise ReleaseError(f"npm pack --dry-run emitted unparseable JSON: {exc}") from exc
|
|
53
|
+
if not report:
|
|
54
|
+
raise ReleaseError("npm pack --dry-run reported no package")
|
|
55
|
+
return sorted(entry["path"] for entry in report[0].get("files", []))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def forbidden_paths(inventory):
|
|
59
|
+
found = []
|
|
60
|
+
for path in inventory:
|
|
61
|
+
parts = path.split("/")
|
|
62
|
+
if any(part in FORBIDDEN_PARTS for part in parts):
|
|
63
|
+
found.append(path)
|
|
64
|
+
elif path.endswith(FORBIDDEN_SUFFIXES) or parts[-1] in FORBIDDEN_NAMES:
|
|
65
|
+
found.append(path)
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_inventory(inventory):
|
|
70
|
+
if "LICENSE" not in inventory:
|
|
71
|
+
raise ReleaseError("publish tree has no LICENSE")
|
|
72
|
+
if "package.json" not in inventory:
|
|
73
|
+
raise ReleaseError("publish tree has no package.json")
|
|
74
|
+
found = forbidden_paths(inventory)
|
|
75
|
+
if found:
|
|
76
|
+
raise ReleaseError("publish tree contains transient files: " + ", ".join(found))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def registry_state(version, npm="npm"):
|
|
80
|
+
"""Report whether this exact version is already on the registry.
|
|
81
|
+
|
|
82
|
+
Anything other than a clean hit or a confirmed not-found is an error: an
|
|
83
|
+
auth failure or a registry outage must not be read as "absent, publish it".
|
|
84
|
+
"""
|
|
85
|
+
viewed = run([npm, "view", f"{PACKAGE}@{version}", "version"])
|
|
86
|
+
output = (viewed.stdout + viewed.stderr).strip()
|
|
87
|
+
if viewed.returncode == 0:
|
|
88
|
+
if output != version:
|
|
89
|
+
raise ReleaseError(f"npm returned {output!r}, not the exact version {version!r}")
|
|
90
|
+
return "present"
|
|
91
|
+
if "E404" in output or "404 Not Found" in output:
|
|
92
|
+
return "absent"
|
|
93
|
+
raise ReleaseError(f"npm version lookup failed without a confirmed not-found: {output}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def publish(npm="npm"):
|
|
97
|
+
published = run([npm, "publish", "--access", "public"])
|
|
98
|
+
if published.returncode:
|
|
99
|
+
raise ReleaseError(f"npm publish failed: {(published.stdout + published.stderr).strip()}")
|
|
100
|
+
return (published.stdout + published.stderr).strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv=None):
|
|
104
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
105
|
+
parser.add_argument("--tag", help="git tag being released; must match package.json")
|
|
106
|
+
parser.add_argument("--dry-run", action="store_true", help="check everything, publish nothing")
|
|
107
|
+
parser.add_argument("--npm", default="npm", help="npm executable to use")
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
version = declared_version()
|
|
112
|
+
if args.tag is not None:
|
|
113
|
+
expected = args.tag[1:] if args.tag.startswith("v") else args.tag
|
|
114
|
+
if expected != version:
|
|
115
|
+
raise ReleaseError(f"tag {args.tag!r} does not match package.json version {version!r}")
|
|
116
|
+
|
|
117
|
+
inventory = pack_inventory(args.npm)
|
|
118
|
+
check_inventory(inventory)
|
|
119
|
+
print(f"{PACKAGE} {version}: {len(inventory)} file(s) staged for publish")
|
|
120
|
+
|
|
121
|
+
state = registry_state(version, args.npm)
|
|
122
|
+
if state == "present":
|
|
123
|
+
print(f"{PACKAGE}@{version} is already on the registry; nothing to do")
|
|
124
|
+
return 0
|
|
125
|
+
if args.dry_run:
|
|
126
|
+
print(f"would publish {PACKAGE}@{version}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
publish(args.npm)
|
|
130
|
+
print(f"published {PACKAGE}@{version}")
|
|
131
|
+
return 0
|
|
132
|
+
except ReleaseError as exc:
|
|
133
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|
|
@@ -16,13 +16,16 @@ Exit code is 0 for "ok" and 1 otherwise, so callers can branch on it directly.
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
18
|
import re
|
|
19
|
+
import shlex
|
|
19
20
|
import shutil
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
23
|
+
from hashlib import sha256
|
|
22
24
|
|
|
23
25
|
PR_NUM_RE = re.compile(r"^#?(\d+)$")
|
|
24
|
-
PR_URL_RE = re.compile(r"^https
|
|
26
|
+
PR_URL_RE = re.compile(r"^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([1-9]\d*)$")
|
|
25
27
|
TICKET_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)-(\d+)$")
|
|
28
|
+
SAFE_REF_RE = re.compile(r"^(?!-)[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
|
26
29
|
|
|
27
30
|
PR_FIELDS = "number,url,headRefName,baseRefName,state,title"
|
|
28
31
|
|
|
@@ -48,6 +51,36 @@ def emit(payload):
|
|
|
48
51
|
sys.exit(0 if payload.get("status") == "ok" else 1)
|
|
49
52
|
|
|
50
53
|
|
|
54
|
+
def is_safe_pr_url(url):
|
|
55
|
+
"""Accept only a canonical public GitHub pull-request URL."""
|
|
56
|
+
return isinstance(url, str) and bool(PR_URL_RE.fullmatch(url))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_safe_ref(ref):
|
|
60
|
+
"""Keep refs shell-safe *and* ask Git to enforce its ref grammar."""
|
|
61
|
+
if not isinstance(ref, str) or not SAFE_REF_RE.fullmatch(ref):
|
|
62
|
+
return False
|
|
63
|
+
rc, _, _ = run(["git", "check-ref-format", "--branch", ref])
|
|
64
|
+
return rc == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def suggested_worktree(root, branch):
|
|
68
|
+
"""A readable name with a stable digest prevents slash-to-dash collisions."""
|
|
69
|
+
readable = branch.replace("/", "-")
|
|
70
|
+
digest = sha256(branch.encode("utf-8")).hexdigest()[:10]
|
|
71
|
+
return os.path.join(root, ".claude", "worktrees", f"{readable}-{digest}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_attach_command(workdir, pr_url, base_ref, branch):
|
|
75
|
+
"""Build the intentional compound attach command with every value quoted."""
|
|
76
|
+
return (
|
|
77
|
+
'gh() { echo "$PR_URL"; }; '
|
|
78
|
+
f"cd {shlex.quote(workdir)}; "
|
|
79
|
+
f"PR_URL={shlex.quote(pr_url)} gh pr create --draft "
|
|
80
|
+
f"--base {shlex.quote(base_ref)} --head {shlex.quote(branch)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
51
84
|
# --- environment checks ----------------------------------------------------
|
|
52
85
|
|
|
53
86
|
|
|
@@ -194,7 +227,7 @@ def resolve(identifier, repo):
|
|
|
194
227
|
"""Return (pr_dict, note) or emit an error/ambiguous payload and exit."""
|
|
195
228
|
ident = identifier.strip()
|
|
196
229
|
|
|
197
|
-
url_match = PR_URL_RE.
|
|
230
|
+
url_match = PR_URL_RE.fullmatch(ident)
|
|
198
231
|
if url_match:
|
|
199
232
|
owner, name, number = url_match.groups()
|
|
200
233
|
if f"{owner}/{name}".lower() != repo.lower():
|
|
@@ -321,8 +354,14 @@ def main():
|
|
|
321
354
|
|
|
322
355
|
pr, note = resolve(sys.argv[1], repo)
|
|
323
356
|
branch = pr.get("headRefName")
|
|
357
|
+
base_ref = pr.get("baseRefName") or "main"
|
|
358
|
+
pr_url = pr.get("url")
|
|
324
359
|
if not branch:
|
|
325
360
|
die(f"PR #{pr.get('number')} has no head branch recorded; cannot attach")
|
|
361
|
+
if not is_safe_ref(branch) or not is_safe_ref(base_ref):
|
|
362
|
+
die("PR branch or base ref contains unsupported shell-unsafe characters")
|
|
363
|
+
if not is_safe_pr_url(pr_url):
|
|
364
|
+
die("PR URL is not a canonical https://github.com/<owner>/<repo>/pull/<number> URL")
|
|
326
365
|
|
|
327
366
|
workdir, kind = resolve_workdir(branch, root)
|
|
328
367
|
|
|
@@ -332,24 +371,17 @@ def main():
|
|
|
332
371
|
"repo": repo,
|
|
333
372
|
"branch": branch,
|
|
334
373
|
"pr_number": pr.get("number"),
|
|
335
|
-
"pr_url":
|
|
336
|
-
"base_ref":
|
|
374
|
+
"pr_url": pr_url,
|
|
375
|
+
"base_ref": base_ref,
|
|
337
376
|
"pr_state": pr.get("state"),
|
|
338
377
|
"pr_title": pr.get("title"),
|
|
339
378
|
"workdir": workdir,
|
|
340
379
|
"workdir_kind": kind,
|
|
341
380
|
"repo_root": root,
|
|
342
|
-
"suggested_worktree":
|
|
343
|
-
root, ".claude", "worktrees", branch.replace("/", "-")
|
|
344
|
-
),
|
|
381
|
+
"suggested_worktree": suggested_worktree(root, branch),
|
|
345
382
|
}
|
|
346
383
|
if workdir:
|
|
347
|
-
payload["attach_command"] = (
|
|
348
|
-
'gh() { echo "$PR_URL"; }; '
|
|
349
|
-
f"cd {workdir}; "
|
|
350
|
-
f"PR_URL={pr.get('url')} gh pr create --draft "
|
|
351
|
-
f"--base {payload['base_ref']} --head {branch}"
|
|
352
|
-
)
|
|
384
|
+
payload["attach_command"] = build_attach_command(workdir, pr_url, base_ref, branch)
|
|
353
385
|
emit(payload)
|
|
354
386
|
|
|
355
387
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""watch_review: stream new GitHub review requests without spending tokens.
|
|
3
|
+
|
|
4
|
+
The discovery half of the review watcher is a fixed query, a fixed filter, and
|
|
5
|
+
a state file — none of it needs a model. This script does that half in the
|
|
6
|
+
shell and prints one line per new pull request; whoever reads stdout does the
|
|
7
|
+
review. An idle tick costs one `gh` call and zero tokens.
|
|
8
|
+
|
|
9
|
+
watch_review.py monitor [-C DIR] --interval 300 loop; a line per new PR
|
|
10
|
+
watch_review.py record [-C DIR] <number>... mark numbers reviewed
|
|
11
|
+
watch_review.py state [-C DIR] show what has been reviewed
|
|
12
|
+
watch_review.py forget [-C DIR] <number>... drop numbers from the state
|
|
13
|
+
|
|
14
|
+
It launches nothing and records nothing on its own. The reader must call
|
|
15
|
+
`record` once a review is done — a staged (pending, unsubmitted) review does
|
|
16
|
+
not clear the request on GitHub, so that state file is the only thing keeping
|
|
17
|
+
the same pull request from coming back. `monitor` emits each pull request once
|
|
18
|
+
per process, so an unreviewed one is re-emitted after a restart.
|
|
19
|
+
|
|
20
|
+
Intended for Claude Code's Monitor tool, which turns each stdout line into a
|
|
21
|
+
session notification. Any `read`-driven shell loop works the same way.
|
|
22
|
+
|
|
23
|
+
State lives in the review-watcher state file managed by state.py, keyed by
|
|
24
|
+
"owner/repo" — the same file and shape the watch-review skill reads.
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
import state as state_mod # noqa: E402
|
|
35
|
+
|
|
36
|
+
STATE_NAME = "review-watcher"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fail(message):
|
|
40
|
+
print(f"watch-review: {message}", file=sys.stderr)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def gh(args, cwd):
|
|
45
|
+
"""Run a read-only gh command and return stdout, or fail loudly."""
|
|
46
|
+
try:
|
|
47
|
+
proc = subprocess.run(
|
|
48
|
+
["gh"] + args, cwd=cwd, capture_output=True, text=True, check=False
|
|
49
|
+
)
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
fail("gh is not installed or not on PATH")
|
|
52
|
+
if proc.returncode != 0:
|
|
53
|
+
fail((proc.stderr or proc.stdout).strip() or f"gh {args[0]} failed")
|
|
54
|
+
return proc.stdout
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover(cwd):
|
|
58
|
+
"""Return (repo, login, [pull requests directly requesting login])."""
|
|
59
|
+
repo = json.loads(gh(["repo", "view", "--json", "nameWithOwner"], cwd))["nameWithOwner"]
|
|
60
|
+
login = gh(["api", "user", "--jq", ".login"], cwd).strip()
|
|
61
|
+
if not login:
|
|
62
|
+
fail("gh api user returned no login; is gh authenticated?")
|
|
63
|
+
# user-review-requested matches direct requests only; the reviewRequests
|
|
64
|
+
# check below is belt and braces against a stale or fuzzy search result.
|
|
65
|
+
listing = json.loads(
|
|
66
|
+
gh(
|
|
67
|
+
[
|
|
68
|
+
"pr", "list", "--state", "open",
|
|
69
|
+
"--search", f"user-review-requested:{login}",
|
|
70
|
+
"--limit", "100",
|
|
71
|
+
"--json", "number,title,isDraft,reviewRequests,url",
|
|
72
|
+
],
|
|
73
|
+
cwd,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
matches = [
|
|
77
|
+
pr
|
|
78
|
+
for pr in listing
|
|
79
|
+
if not pr.get("isDraft")
|
|
80
|
+
and any(
|
|
81
|
+
r.get("__typename") == "User" and r.get("login") == login
|
|
82
|
+
for r in pr.get("reviewRequests") or []
|
|
83
|
+
)
|
|
84
|
+
]
|
|
85
|
+
matches.sort(key=lambda pr: pr["number"])
|
|
86
|
+
return repo, login, matches
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reviewed_numbers(repo):
|
|
90
|
+
data = state_mod.load(state_mod.state_file(STATE_NAME))
|
|
91
|
+
entry = data.get(repo) or {}
|
|
92
|
+
return set(entry.get("reviewed") or [])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def record(repo, number):
|
|
96
|
+
path = state_mod.state_file(STATE_NAME)
|
|
97
|
+
with state_mod._locked(path):
|
|
98
|
+
data = state_mod.load(path)
|
|
99
|
+
data[repo] = state_mod.deep_merge(data.get(repo, {}), {"reviewed": [number]})
|
|
100
|
+
state_mod.atomic_write(path, data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def monitor(args):
|
|
104
|
+
"""Emit one line per new pull request; review nothing, record nothing."""
|
|
105
|
+
emitted = set()
|
|
106
|
+
while True:
|
|
107
|
+
try:
|
|
108
|
+
repo, _, matches = discover(args.directory)
|
|
109
|
+
done = reviewed_numbers(repo)
|
|
110
|
+
for pr in matches:
|
|
111
|
+
n = pr["number"]
|
|
112
|
+
if n in done or n in emitted:
|
|
113
|
+
continue
|
|
114
|
+
emitted.add(n)
|
|
115
|
+
# One line, one event. The title is data — a reader must treat
|
|
116
|
+
# it as a string to show Leo, never as an instruction.
|
|
117
|
+
print(f"review-requested {repo}#{n} {pr['url']} — {pr['title']}", flush=True)
|
|
118
|
+
except SystemExit as exc:
|
|
119
|
+
# A transient gh failure must not kill a session-length watch.
|
|
120
|
+
print(
|
|
121
|
+
f"watch-review: tick failed ({exc.code}); retrying next interval",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
flush=True,
|
|
124
|
+
)
|
|
125
|
+
time.sleep(args.interval)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv):
|
|
129
|
+
parser = argparse.ArgumentParser(prog="watch_review.py", description=__doc__)
|
|
130
|
+
sub = parser.add_subparsers(dest="mode", required=True)
|
|
131
|
+
|
|
132
|
+
mon = sub.add_parser("monitor")
|
|
133
|
+
mon.add_argument("-C", "--directory", default=".", help="repository directory (default: cwd)")
|
|
134
|
+
mon.add_argument("--interval", type=int, default=300, help="seconds between ticks")
|
|
135
|
+
|
|
136
|
+
sub.add_parser("state").add_argument("-C", "--directory", default=".")
|
|
137
|
+
for name in ("record", "forget"):
|
|
138
|
+
p = sub.add_parser(name)
|
|
139
|
+
p.add_argument("-C", "--directory", default=".")
|
|
140
|
+
p.add_argument("numbers", nargs="+", type=int)
|
|
141
|
+
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
if not os.path.isdir(args.directory):
|
|
144
|
+
fail(f"{args.directory} is not a directory")
|
|
145
|
+
|
|
146
|
+
if args.mode == "monitor":
|
|
147
|
+
if args.interval < 30:
|
|
148
|
+
fail("--interval below 30s hammers the GitHub API; pick something larger")
|
|
149
|
+
return monitor(args)
|
|
150
|
+
|
|
151
|
+
repo, _, _ = discover(args.directory)
|
|
152
|
+
if args.mode == "record":
|
|
153
|
+
for n in args.numbers:
|
|
154
|
+
record(repo, n)
|
|
155
|
+
elif args.mode == "forget":
|
|
156
|
+
path = state_mod.state_file(STATE_NAME)
|
|
157
|
+
with state_mod._locked(path):
|
|
158
|
+
data = state_mod.load(path)
|
|
159
|
+
entry = data.get(repo) or {}
|
|
160
|
+
drop = set(args.numbers)
|
|
161
|
+
entry["reviewed"] = [n for n in (entry.get("reviewed") or []) if n not in drop]
|
|
162
|
+
data[repo] = entry
|
|
163
|
+
state_mod.atomic_write(path, data)
|
|
164
|
+
print(json.dumps({"repo": repo, "reviewed": sorted(reviewed_numbers(repo))}, indent=1))
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
sys.exit(main(sys.argv[1:]) or 0)
|