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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -7
  3. package/adapters/cursor/agents/executor.md +1 -1
  4. package/adapters/cursor/agents/implementer.md +2 -2
  5. package/adapters/cursor/agents/review-lens.md +22 -0
  6. package/adapters/cursor/agents/reviewer.md +2 -2
  7. package/adapters/opencode/agents.json +43 -4
  8. package/adapters/opencode/plugin.js +325 -37
  9. package/config/MCP_PINS.md +17 -0
  10. package/config/models.json +276 -8
  11. package/hooks/bash-guard.py +51 -9
  12. package/package.json +3 -6
  13. package/roles/executor.md +1 -1
  14. package/roles/implementer.md +2 -2
  15. package/roles/review-lens.md +20 -0
  16. package/roles/reviewer.md +2 -2
  17. package/scripts/doctor.py +267 -31
  18. package/scripts/ghreview.py +7 -3
  19. package/scripts/jsonc_bridge.cjs +23 -0
  20. package/scripts/memory.py +74 -35
  21. package/scripts/render_adapters.py +57 -22
  22. package/scripts/resolve_attach_target.py +45 -13
  23. package/scripts/setup.py +1594 -2
  24. package/skills/brainstorming/SKILL.md +3 -1
  25. package/skills/debugging/SKILL.md +4 -2
  26. package/skills/delegation/SKILL.md +10 -8
  27. package/skills/doctor/SKILL.md +33 -14
  28. package/skills/executing-plans/SKILL.md +2 -1
  29. package/skills/finishing-a-branch/SKILL.md +4 -2
  30. package/skills/freshness/SKILL.md +23 -10
  31. package/skills/memory/SKILL.md +12 -2
  32. package/skills/resolve-ticket/SKILL.md +15 -9
  33. package/skills/review-pr/SKILL.md +26 -16
  34. package/skills/setup/SKILL.md +123 -9
  35. package/skills/setup/agents/openai.yaml +5 -0
  36. package/skills/test-first/SKILL.md +3 -1
  37. package/skills/using-leo/SKILL.md +11 -6
  38. package/skills/using-leo/references/claude-mapping.md +2 -1
  39. package/skills/using-leo/references/codex-mapping.md +4 -5
  40. package/skills/using-leo/references/cursor-mapping.md +2 -1
  41. package/skills/using-leo/references/hermes-mapping.md +2 -1
  42. package/skills/using-leo/references/opencode-mapping.md +6 -3
  43. package/skills/verification/SKILL.md +2 -1
  44. package/skills/visual-verification/SKILL.md +2 -1
  45. package/skills/watch-review/SKILL.md +17 -14
  46. package/skills/watch-review/agents/openai.yaml +5 -0
  47. package/skills/worktrees/SKILL.md +3 -1
  48. package/skills/writing-plans/SKILL.md +2 -1
  49. package/skills/writing-skills/SKILL.md +9 -2
  50. package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
  51. package/vendor/jsonc-parser-3.3.1/README.md +26 -0
  52. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
  53. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
  54. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
  55. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
  56. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
  57. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
  58. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
  59. package/vendor/jsonc-parser-3.3.1/package.json +37 -0
  60. 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 _atomic_text(path, text, mode=0o644):
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; these are readable config files
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 reindex():
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
- state.atomic_write(os.path.join(memory_root(), "index.json"), index)
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 project(index=None):
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 reindex()
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
- mode = 0o644
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
- mode = os.stat(target).st_mode & 0o777
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 state._locked(_lock_path()):
564
- os.makedirs(directory, exist_ok=True)
565
- if len(_listdir(directory)) >= MAX_FACTS_PER_SCOPE:
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 = reindex()
581
- projection = project(index)
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
- """Same title+type updates in place; same title, different type gets -2."""
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 parsed is None:
592
- return candidate, ("created" if not os.path.exists(candidate) else "created"), None
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 state._locked(_lock_path()):
612
- os.makedirs(trash, exist_ok=True)
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 = reindex()
618
- projection = project(index)
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
- index = reindex()
625
- project(index)
626
- return render_context(index, repo=repo_key(cwd))
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 = [t for t in TIERS if t != tier and rows[t]["model"] == rows[tier]["model"]]
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]["model"], []).append(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 — `leo:review-pr`, "
264
- "`leo:resolve-ticket` and `leo:watch-review` all run on this harness. "
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 agent operating policy: cost-tiered model routing, specialist "
379
- "subagent roles, process skills, execute-then-review discipline, and a narrow "
380
- "catastrophic-command guard.\n\n"
381
- "This npm package is the **OpenCode** distribution. Claude Code, Codex, Cursor, and Hermes "
382
- "each install it through their own plugin system — see "
383
- "[the repository](https://github.com/foxhatleo/leos-agent) for those.\n\n"
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 the `plugin` subcommand, add it to "
387
- "`~/.config/opencode/opencode.json` (or `opencode.jsonc`) by hand:\n\n"
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
- "Start a new OpenCode session. The plugin registers the skills directory, the "
390
- f"{len(json.loads(_opencode_agents(config)))} subagent roles, and the operating policy, and installs the "
391
- "bash deletion tripwire.\n\n"
392
- "If the skills do not appear, run `opencode debug skill` each one should list a "
393
- "`location` inside this package. The plugin resolves its own install path and registers "
394
- "it, so none needs to be written by hand.\n\n"
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 full documentation](https://github.com/foxhatleo/leos-agent)\n"
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?://[^/]*github\.com/([^/]+)/([^/]+)/pull/(\d+)")
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.match(ident)
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": pr.get("url"),
336
- "base_ref": pr.get("baseRefName") or "main",
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": os.path.join(
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