leos-agent 6.3.0 → 7.0.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 +14 -7
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +2 -2
- package/adapters/cursor/agents/review-lens.md +22 -0
- package/adapters/cursor/agents/reviewer.md +2 -2
- package/adapters/opencode/agents.json +43 -4
- package/adapters/opencode/plugin.js +325 -37
- package/config/MCP_PINS.md +17 -0
- package/config/models.json +276 -8
- package/hooks/bash-guard.py +51 -9
- package/package.json +3 -6
- package/roles/executor.md +1 -1
- package/roles/implementer.md +2 -2
- package/roles/review-lens.md +20 -0
- package/roles/reviewer.md +2 -2
- package/scripts/doctor.py +267 -31
- package/scripts/ghreview.py +7 -3
- package/scripts/jsonc_bridge.cjs +23 -0
- package/scripts/memory.py +74 -35
- package/scripts/render_adapters.py +57 -22
- package/scripts/resolve_attach_target.py +45 -13
- package/scripts/setup.py +1594 -2
- package/skills/brainstorming/SKILL.md +3 -1
- package/skills/debugging/SKILL.md +4 -2
- package/skills/delegation/SKILL.md +10 -8
- package/skills/doctor/SKILL.md +33 -14
- package/skills/executing-plans/SKILL.md +2 -1
- package/skills/finishing-a-branch/SKILL.md +4 -2
- package/skills/freshness/SKILL.md +23 -10
- package/skills/memory/SKILL.md +12 -2
- package/skills/resolve-ticket/SKILL.md +15 -9
- package/skills/review-pr/SKILL.md +26 -16
- package/skills/setup/SKILL.md +123 -9
- package/skills/setup/agents/openai.yaml +5 -0
- package/skills/test-first/SKILL.md +3 -1
- package/skills/using-leo/SKILL.md +11 -6
- package/skills/using-leo/references/claude-mapping.md +2 -1
- package/skills/using-leo/references/codex-mapping.md +4 -5
- package/skills/using-leo/references/cursor-mapping.md +2 -1
- package/skills/using-leo/references/hermes-mapping.md +2 -1
- package/skills/using-leo/references/opencode-mapping.md +6 -3
- package/skills/verification/SKILL.md +2 -1
- package/skills/visual-verification/SKILL.md +2 -1
- package/skills/watch-review/SKILL.md +17 -14
- package/skills/watch-review/agents/openai.yaml +5 -0
- package/skills/worktrees/SKILL.md +3 -1
- package/skills/writing-plans/SKILL.md +2 -1
- package/skills/writing-skills/SKILL.md +9 -2
- package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
- package/vendor/jsonc-parser-3.3.1/README.md +26 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
- package/vendor/jsonc-parser-3.3.1/package.json +37 -0
- package/workflows/cost-tiered-fix.js +32 -4
package/scripts/memory.py
CHANGED
|
@@ -45,6 +45,7 @@ Set LEOS_AGENT_NO_PROJECT=1 to disable writing to native surfaces entirely.
|
|
|
45
45
|
Exit codes: 0 ok, non-zero on error (except context/session, which never fail).
|
|
46
46
|
"""
|
|
47
47
|
import datetime
|
|
48
|
+
import contextlib
|
|
48
49
|
import hashlib
|
|
49
50
|
import json
|
|
50
51
|
import os
|
|
@@ -150,14 +151,39 @@ def ref_path(ref):
|
|
|
150
151
|
# fact files
|
|
151
152
|
# --------------------------------------------------------------------------
|
|
152
153
|
|
|
153
|
-
def
|
|
154
|
+
def _private_dir(path):
|
|
155
|
+
"""Create a canonical Leo-owned directory with private permissions."""
|
|
156
|
+
os.makedirs(path, mode=0o700, exist_ok=True)
|
|
157
|
+
os.chmod(path, 0o700)
|
|
158
|
+
return path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _memory_dirs(scope=None, repo=None):
|
|
162
|
+
root = _private_dir(memory_root())
|
|
163
|
+
if scope == "global":
|
|
164
|
+
return _private_dir(os.path.join(root, "global"))
|
|
165
|
+
if scope == "repo":
|
|
166
|
+
repo_root = _private_dir(os.path.join(root, "repo"))
|
|
167
|
+
return _private_dir(os.path.join(repo_root, repo_slug(repo)))
|
|
168
|
+
return root
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@contextlib.contextmanager
|
|
172
|
+
def _memory_lock():
|
|
173
|
+
"""The one lock boundary for every memory read-modify-write operation."""
|
|
174
|
+
_memory_dirs()
|
|
175
|
+
with state._locked(_lock_path()):
|
|
176
|
+
yield
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _atomic_text(path, text, mode=0o600):
|
|
154
180
|
"""Markdown twin of state.atomic_write, which json-dumps its argument."""
|
|
155
181
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
156
182
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
|
|
157
183
|
try:
|
|
158
184
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
159
185
|
fh.write(text)
|
|
160
|
-
os.chmod(tmp, mode) # mkstemp is 0600;
|
|
186
|
+
os.chmod(tmp, mode) # mkstemp is 0600; callers choose the final contract.
|
|
161
187
|
os.replace(tmp, path)
|
|
162
188
|
except BaseException:
|
|
163
189
|
try:
|
|
@@ -254,7 +280,7 @@ def _listdir(path):
|
|
|
254
280
|
# index
|
|
255
281
|
# --------------------------------------------------------------------------
|
|
256
282
|
|
|
257
|
-
def
|
|
283
|
+
def _reindex_unlocked():
|
|
258
284
|
facts, unreadable = _iter_facts()
|
|
259
285
|
entries = []
|
|
260
286
|
repos = {}
|
|
@@ -277,11 +303,17 @@ def reindex():
|
|
|
277
303
|
"repos": repos,
|
|
278
304
|
"unreadable": unreadable,
|
|
279
305
|
}
|
|
280
|
-
|
|
306
|
+
_atomic_text(os.path.join(memory_root(), "index.json"),
|
|
307
|
+
json.dumps(index, indent=1, sort_keys=True) + "\n")
|
|
281
308
|
_atomic_text(os.path.join(memory_root(), "MEMORY.md"), _render_memory_md(index))
|
|
282
309
|
return index
|
|
283
310
|
|
|
284
311
|
|
|
312
|
+
def reindex():
|
|
313
|
+
with _memory_lock():
|
|
314
|
+
return _reindex_unlocked()
|
|
315
|
+
|
|
316
|
+
|
|
285
317
|
def _load_index():
|
|
286
318
|
"""Never state.load(): it sys.exit()s on corruption, breaking fail-open."""
|
|
287
319
|
try:
|
|
@@ -368,14 +400,6 @@ def render_context(index, repo=None, limit=MEMORY_CONTEXT_LIMIT):
|
|
|
368
400
|
# projection
|
|
369
401
|
# --------------------------------------------------------------------------
|
|
370
402
|
|
|
371
|
-
def _home(var, *parts):
|
|
372
|
-
base = os.environ.get(var)
|
|
373
|
-
if not base:
|
|
374
|
-
base = os.path.join(os.path.expanduser("~"), parts[0])
|
|
375
|
-
parts = parts[1:]
|
|
376
|
-
return os.path.join(base, *parts) if parts else base
|
|
377
|
-
|
|
378
|
-
|
|
379
403
|
def hermes_home():
|
|
380
404
|
return os.environ.get("HERMES_HOME") or os.path.join(os.path.expanduser("~"), ".hermes")
|
|
381
405
|
|
|
@@ -476,7 +500,7 @@ def _hermes_absent(targets):
|
|
|
476
500
|
return [{"harness": "hermes", "path": None, "status": "skipped:opt-in-required"}]
|
|
477
501
|
|
|
478
502
|
|
|
479
|
-
def
|
|
503
|
+
def _project_unlocked(index=None):
|
|
480
504
|
if os.environ.get("LEOS_AGENT_NO_PROJECT") == "1":
|
|
481
505
|
targets = projection_targets()
|
|
482
506
|
return [{"harness": h, "path": f, "status": "skipped:disabled"}
|
|
@@ -484,7 +508,7 @@ def project(index=None):
|
|
|
484
508
|
{"harness": t["harness"], "path": None, "status": "skipped:disabled"}
|
|
485
509
|
for t in _hermes_absent(targets)]
|
|
486
510
|
if index is None:
|
|
487
|
-
index = _load_index() or
|
|
511
|
+
index = _load_index() or _reindex_unlocked()
|
|
488
512
|
# GLOBAL facts only — see the module docstring.
|
|
489
513
|
body = render_context({"facts": [e for e in index["facts"] if e["scope"] == "global"],
|
|
490
514
|
"unreadable": index.get("unreadable", [])})
|
|
@@ -498,6 +522,11 @@ def project(index=None):
|
|
|
498
522
|
return results
|
|
499
523
|
|
|
500
524
|
|
|
525
|
+
def project(index=None):
|
|
526
|
+
with _memory_lock():
|
|
527
|
+
return _project_unlocked(index)
|
|
528
|
+
|
|
529
|
+
|
|
501
530
|
def _project_one(gate, path, owned, block, require_file=False):
|
|
502
531
|
try:
|
|
503
532
|
if not os.path.isdir(gate):
|
|
@@ -508,9 +537,13 @@ def _project_one(gate, path, owned, block, require_file=False):
|
|
|
508
537
|
return "skipped:no-soul"
|
|
509
538
|
target = os.path.realpath(path) if os.path.islink(path) else path
|
|
510
539
|
existing = ""
|
|
511
|
-
|
|
540
|
+
# New generated projections are private. If the user already owns the
|
|
541
|
+
# target, retain its existing mode exactly instead of tightening it
|
|
542
|
+
# behind their back.
|
|
543
|
+
mode = 0o600
|
|
512
544
|
if os.path.exists(target):
|
|
513
|
-
|
|
545
|
+
if not owned:
|
|
546
|
+
mode = os.stat(target).st_mode & 0o777
|
|
514
547
|
with open(target, encoding="utf-8", errors="replace") as fh:
|
|
515
548
|
existing = fh.read()
|
|
516
549
|
if owned:
|
|
@@ -560,11 +593,11 @@ def write_fact(scope, type_, title, body, repo=None):
|
|
|
560
593
|
|
|
561
594
|
directory = _scope_dir(scope, repo)
|
|
562
595
|
slug = fact_slug(title)
|
|
563
|
-
with
|
|
564
|
-
|
|
565
|
-
|
|
596
|
+
with _memory_lock():
|
|
597
|
+
_memory_dirs(scope, repo)
|
|
598
|
+
path, action, created = _resolve_slot(directory, slug, type_, title)
|
|
599
|
+
if action == "created" and len(_listdir(directory)) >= MAX_FACTS_PER_SCOPE:
|
|
566
600
|
sys.exit(f"memory: this scope is full ({MAX_FACTS_PER_SCOPE} facts) — consolidate or forget first")
|
|
567
|
-
path, action, created = _resolve_slot(directory, slug, type_)
|
|
568
601
|
now = _now()
|
|
569
602
|
meta = {
|
|
570
603
|
"title": title,
|
|
@@ -577,25 +610,27 @@ def write_fact(scope, type_, title, body, repo=None):
|
|
|
577
610
|
if scope == "repo":
|
|
578
611
|
meta["repo"] = repo
|
|
579
612
|
_atomic_text(path, render_fact(meta, body))
|
|
580
|
-
index =
|
|
581
|
-
projection =
|
|
613
|
+
index = _reindex_unlocked()
|
|
614
|
+
projection = _project_unlocked(index)
|
|
582
615
|
rel = os.path.relpath(path, memory_root())[:-3]
|
|
583
616
|
return {"action": action, "ref": rel, "path": path,
|
|
584
617
|
"description": meta["description"], "projection": projection}
|
|
585
618
|
|
|
586
619
|
|
|
587
|
-
def _resolve_slot(directory, slug, type_):
|
|
588
|
-
"""
|
|
620
|
+
def _resolve_slot(directory, slug, type_, title):
|
|
621
|
+
"""Only an exact title-and-type identity updates; collisions get -N."""
|
|
589
622
|
candidate = os.path.join(directory, f"{slug}.md")
|
|
590
623
|
parsed = parse_fact(candidate) if os.path.exists(candidate) else None
|
|
591
|
-
if
|
|
592
|
-
return candidate,
|
|
593
|
-
if parsed[0].get("type") == type_
|
|
624
|
+
if not os.path.exists(candidate):
|
|
625
|
+
return candidate, "created", None
|
|
626
|
+
if (parsed and parsed[0].get("type") == type_
|
|
627
|
+
and parsed[0].get("title") == title):
|
|
594
628
|
return candidate, "updated", parsed[0].get("created")
|
|
595
629
|
suffix = 2
|
|
596
630
|
while os.path.exists(os.path.join(directory, f"{slug}-{suffix}.md")):
|
|
597
631
|
existing = parse_fact(os.path.join(directory, f"{slug}-{suffix}.md"))
|
|
598
|
-
if existing and existing[0].get("type") == type_
|
|
632
|
+
if (existing and existing[0].get("type") == type_
|
|
633
|
+
and existing[0].get("title") == title):
|
|
599
634
|
return (os.path.join(directory, f"{slug}-{suffix}.md"), "updated",
|
|
600
635
|
existing[0].get("created"))
|
|
601
636
|
suffix += 1
|
|
@@ -608,22 +643,26 @@ def forget(ref):
|
|
|
608
643
|
sys.exit(f"memory: no such memory {ref!r}")
|
|
609
644
|
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
610
645
|
trash = os.path.join(memory_root(), ".trash", os.path.dirname(ref))
|
|
611
|
-
with
|
|
612
|
-
os.
|
|
646
|
+
with _memory_lock():
|
|
647
|
+
trash_root = _private_dir(os.path.join(memory_root(), ".trash"))
|
|
648
|
+
current = trash_root
|
|
649
|
+
for part in os.path.dirname(ref).split("/"):
|
|
650
|
+
current = _private_dir(os.path.join(current, part))
|
|
613
651
|
# Move, never unlink: automatic capture plus hard delete on the model's
|
|
614
652
|
# own judgment is a data-loss path, and the trash costs nothing.
|
|
615
653
|
destination = os.path.join(trash, f"{os.path.basename(ref)}.{stamp}.md")
|
|
616
654
|
shutil.move(path, destination)
|
|
617
|
-
index =
|
|
618
|
-
projection =
|
|
655
|
+
index = _reindex_unlocked()
|
|
656
|
+
projection = _project_unlocked(index)
|
|
619
657
|
return {"forgotten": ref, "path": destination, "projection": projection}
|
|
620
658
|
|
|
621
659
|
|
|
622
660
|
def session(cwd=None):
|
|
623
661
|
"""One call for the bootstraps: refresh, project, return the block."""
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
662
|
+
with _memory_lock():
|
|
663
|
+
index = _reindex_unlocked()
|
|
664
|
+
_project_unlocked(index)
|
|
665
|
+
return render_context(index, repo=repo_key(cwd))
|
|
627
666
|
|
|
628
667
|
|
|
629
668
|
FLAGS = ("--repo", "--cwd")
|
|
@@ -59,7 +59,9 @@ def _validate(config):
|
|
|
59
59
|
f"tier ({TIERS[0]!r}) can be — every other rung's roles keep their "
|
|
60
60
|
"job through a collapse"
|
|
61
61
|
)
|
|
62
|
-
twins = [
|
|
62
|
+
twins = [
|
|
63
|
+
t for t in TIERS if t != tier and _tier_identity(rows[t]) == _tier_identity(rows[tier])
|
|
64
|
+
]
|
|
63
65
|
if not twins:
|
|
64
66
|
raise ValueError(
|
|
65
67
|
f"{harness}: tier {tier!r} is declared absent but has a model of its own — "
|
|
@@ -91,6 +93,15 @@ def _split_role(text):
|
|
|
91
93
|
return lines[1:end], "\n".join(lines[end + 1 :]).strip() + "\n"
|
|
92
94
|
|
|
93
95
|
|
|
96
|
+
def _tier_identity(row):
|
|
97
|
+
"""A tier is a model *and* its requested reasoning effort.
|
|
98
|
+
|
|
99
|
+
Codex deliberately maps Fable and Opus to the same model at different
|
|
100
|
+
efforts. Treating a model id alone as identity falsely removes Fable.
|
|
101
|
+
"""
|
|
102
|
+
return row["model"], row.get("effort")
|
|
103
|
+
|
|
104
|
+
|
|
94
105
|
def _without(frontmatter, keys):
|
|
95
106
|
return [line for line in frontmatter if not any(line.startswith(key + ":") for key in keys)]
|
|
96
107
|
|
|
@@ -177,6 +188,12 @@ def _opencode_agents(config):
|
|
|
177
188
|
# on the catastrophic rm class for write-capable agents. The
|
|
178
189
|
# precise tripwire stays hooks/bash-guard.py.
|
|
179
190
|
permission = {"bash": {cmd: "deny" for cmd in adapter["writeBashDeny"]}}
|
|
191
|
+
# `permission.edit: deny` prevents native edits, not destructive shell
|
|
192
|
+
# commands. Apply the narrow catastrophic Bash denial to every role,
|
|
193
|
+
# including shell-capable read-only agents.
|
|
194
|
+
permission.setdefault("bash", {}).update(
|
|
195
|
+
{cmd: "deny" for cmd in adapter["writeBashDeny"]}
|
|
196
|
+
)
|
|
180
197
|
agents[role] = {
|
|
181
198
|
"description": fm.get("description", ""),
|
|
182
199
|
"mode": "subagent",
|
|
@@ -205,7 +222,7 @@ def _collapse_note(rows):
|
|
|
205
222
|
"""
|
|
206
223
|
by_model = {}
|
|
207
224
|
for tier in ("fable", "opus", "sonnet", "haiku"):
|
|
208
|
-
by_model.setdefault(rows[tier]
|
|
225
|
+
by_model.setdefault(_tier_identity(rows[tier]), []).append(tier)
|
|
209
226
|
collapsed = [tiers for tiers in by_model.values() if len(tiers) > 1]
|
|
210
227
|
if not collapsed:
|
|
211
228
|
return ""
|
|
@@ -260,8 +277,9 @@ def _skill_notes(config, harness):
|
|
|
260
277
|
lines.append("")
|
|
261
278
|
lines.append(
|
|
262
279
|
"Every other skill in the policy's Skill index is registered here and "
|
|
263
|
-
"behaves the same, and so are the operational skills —
|
|
264
|
-
"
|
|
280
|
+
"behaves the same, and so are the operational skills — "
|
|
281
|
+
+ ", ".join(f"`leo:{name}`" for name in config["skills"].get("operational", ()))
|
|
282
|
+
+ " all run on this harness. "
|
|
265
283
|
"Where they name a capability the table above says is missing, take "
|
|
266
284
|
"the fallback each one documents."
|
|
267
285
|
)
|
|
@@ -333,6 +351,8 @@ def _mapping_docs(config):
|
|
|
333
351
|
parts.append(_capability_table(config, harness))
|
|
334
352
|
parts.append(_capability_notes(config, harness))
|
|
335
353
|
parts.append(_collapse_note(rows))
|
|
354
|
+
if harness == "opencode":
|
|
355
|
+
parts.append("\n" + _absent_tier_sentence(config) + "\n")
|
|
336
356
|
parts.append(_skill_notes(config, harness))
|
|
337
357
|
out[harness] = "".join(parts)
|
|
338
358
|
return out
|
|
@@ -375,32 +395,47 @@ def _payload_readme(config):
|
|
|
375
395
|
return (
|
|
376
396
|
GENERATED
|
|
377
397
|
+ "\n# Leo's Agent\n\n"
|
|
378
|
-
"Leo's Agent is a portable
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"
|
|
384
|
-
"## Install\n\n"
|
|
398
|
+
"Leo's Agent is a portable operating policy with cost-tiered routing, "
|
|
399
|
+
"specialist roles, process skills, review discipline, and a narrow command guard. "
|
|
400
|
+
"This npm package is the **OpenCode** distribution; use the "
|
|
401
|
+
"[repository](https://github.com/foxhatleo/leos-agent) for Claude Code, Codex, Cursor, "
|
|
402
|
+
"and Hermes instructions.\n\n"
|
|
403
|
+
"Supported hosts are macOS, Linux, and WSL with Python 3.9+; native Windows is unsupported.\n\n"
|
|
404
|
+
"## Install and update\n\n"
|
|
385
405
|
"```sh\nopencode plugin leos-agent --global\n```\n\n"
|
|
386
|
-
"On builds without
|
|
387
|
-
"`~/.config/opencode/opencode.json`
|
|
406
|
+
"On builds without that subcommand, add `leos-agent` to the `plugin` array in "
|
|
407
|
+
"`~/.config/opencode/opencode.json` or `opencode.jsonc`:\n\n"
|
|
388
408
|
'```json\n{ "$schema": "https://opencode.ai/config.json", "plugin": ["leos-agent"] }\n```\n\n'
|
|
389
|
-
"
|
|
390
|
-
|
|
391
|
-
"
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"
|
|
409
|
+
"Run `opencode auth login` and choose OpenRouter before using the mapped models; Leo "
|
|
410
|
+
"never writes provider credentials. Update with "
|
|
411
|
+
"`opencode plugin leos-agent --global --force`, then start a new session. OpenCode "
|
|
412
|
+
"currently has no plugin removal command; remove the `leos-agent` configuration entry "
|
|
413
|
+
"to uninstall.\n\n"
|
|
414
|
+
"The plugin registers generated shadow skills (`leo-<name>`) and namespaced "
|
|
415
|
+
f"`leo-<role>` agents ({len(json.loads(_opencode_agents(config)))} generated definitions) "
|
|
416
|
+
"from `adapters/opencode/agents.json`, "
|
|
417
|
+
"then injects the operating policy through OpenCode's configuration. Invoke its shadow "
|
|
418
|
+
"skill as `leo-using-leo`. If a skill is absent, "
|
|
419
|
+
"run `opencode debug skill`; its `location` should be an `opencode-skills-<hash>/leo-<name>/` "
|
|
420
|
+
"directory under machine-local state, not a hand-written package path.\n\n"
|
|
421
|
+
"## MCP and durable state\n\n"
|
|
422
|
+
"Use `leo-setup` to inspect or explicitly configure MCP services: `connectors` reports "
|
|
423
|
+
"without writing, while `connect` and `apply` make only reviewed, harness-owned changes. "
|
|
424
|
+
"Vendor connectors are never installed automatically and OAuth stays in OpenCode. "
|
|
425
|
+
"Slack, Gmail, Drive, and providers without dynamic registration remain manual-only.\n\n"
|
|
426
|
+
"Uninstall preserves `${LEOS_AGENT_LOCAL_PATH:-$HOME/.leos-agent-local}`. Before a full "
|
|
427
|
+
"purge, export or copy that directory; only then explicitly remove it. For a 7.0 recovery, "
|
|
428
|
+
"move old `LEOS_AGENT_PATH/local/` data there, rename the variable, restart, and run "
|
|
429
|
+
"`leo-doctor`.\n\n"
|
|
395
430
|
"## Model tiers\n\n"
|
|
396
431
|
"Tier names describe the kind of work, not a fixed provider model.\n\n"
|
|
397
432
|
+ _table({tier: opencode[tier] for tier in TIERS})
|
|
398
433
|
+ "\n\n"
|
|
399
434
|
+ _absent_tier_sentence(config)
|
|
400
|
-
+ " Retier by editing `config/models.json` and re-running "
|
|
401
|
-
"`scripts/render_adapters.py`.\n\n"
|
|
435
|
+
+ " Retier by editing `config/models.json` and re-running `scripts/render_adapters.py`.\n\n"
|
|
402
436
|
"## Links\n\n"
|
|
403
|
-
"- [Repository and
|
|
437
|
+
"- [Repository, contributing, and security policy](https://github.com/foxhatleo/leos-agent)\n"
|
|
438
|
+
"- [GitHub Releases](https://github.com/foxhatleo/leos-agent/releases)\n"
|
|
404
439
|
"- [Operating policy](https://github.com/foxhatleo/leos-agent/blob/main/plugins/leo/skills/using-leo/SKILL.md)\n\n"
|
|
405
440
|
"MIT licensed.\n"
|
|
406
441
|
)
|
|
@@ -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
|
|