Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/permissions/rules.lex
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fn spec_permission() -> sp.Spec {
}

fn refactor_permission() -> sp.Spec {
allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect"], list.concat(vcs_read_names(), vcs_write_names())))
allow_tools("refactor_tools", list.concat(["read", "write", "edit", "grep", "glob", "bash", "remember", "lex_check", "os_check", "lex_audit", "sigid_lookup", "effects_of", "lex_store_merge", "propagate_effect", "github_pr_create"], list.concat(vcs_read_names(), vcs_write_names())))
}

fn test_permission() -> sp.Spec {
Expand Down
77 changes: 77 additions & 0 deletions src/tools/github_pr_create.lex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# lex-code — open a real GitHub pull request
#
# Every VCS tool in src/tools/vcs/ wraps `lex branch`/`lex merge` — lex-vcs,
# the structural, content-addressed system built into the `lex` CLI itself.
# None of them touch git or GitHub. Shipping a change has always meant a
# human (or Claude, driving `gh` by hand outside this codebase) taking the
# current branch the rest of the way: push, open the PR, wait for CI, merge.
# lex-code itself could build, test and verify a change end to end and then
# had no tool to hand it off with.
#
# This is deliberately the FIRST tool in the codebase to carry an
# approval_scope (`t.with_approval`, lex-llm#41 / lex-lang#737's std.approval
# effect) — plumbing that has existed since #41 landed but nothing here ever
# used: `grep -rln "approval_scope" src/tools/` returned nothing before this
# file. Opening a pull request is visible to everyone with access to the
# repo and cannot be un-opened the way a local edit can be un-written, so it
# is exactly the class of action every other "cutting edge" agent gates
# behind a human — this is lex-code's first one. `bin/lex-code` already
# passes `approval` in its default --allow-effects and never restricts
# --allow-approval, so this blocks on a real stdin prompt (lex-lang's
# StdinApprovalSink) the first time any mode actually calls it — not a
# no-op, and not silently denied by an unconfigured sink.
#
# Shells out to `gh pr create` rather than the GitHub REST API directly:
# every other tool in this codebase that needs a real external CLI (git,
# lex, docker) does the same, and `gh` already owns auth, remote detection
# and the "is this branch pushed" error message this tool would otherwise
# have to reproduce. Pushing the branch itself is deliberately NOT this
# tool's job — that is an ordinary `bash`/`git` action any mode with those
# tools already has, and folding it in here would hide a git push behind
# what looks like a PR-only approval prompt.

import "std.process" as proc

import "std.list" as list

import "lex-llm/tool" as t

import "lex-schema/json_value" as jv

import "lex-schema/error" as e

import "lex-schema/schema" as s

import "./util" as util

fn params() -> s.ModelSchema {
{ title: "GithubPrCreateArgs", description: "Open a pull request on GitHub for the current (already-pushed) branch.", fields: [s.required_str("title", []), s.required_str("body", []), s.optional(s.required_str("base", []))] }
}

fn execute(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] {
match util.field_str(args, "title") {
None => Err(e.single("", "missing_field", "title is required")),
Some(title) => match util.field_str(args, "body") {
None => Err(e.single("", "missing_field", "body is required")),
Some(body) => {
let base_args := match util.field_str(args, "base") {
None => [],
Some(b) => ["--base", b],
}
let cmd := list.concat(["pr", "create", "--title", title, "--body", body], base_args)
match proc.run("gh", cmd) {
Err(msg) => Err(e.single("", "proc_error", msg)),
Ok(out) => match util.cli_result(out) {
Err(detail) => Err(e.single("", "gh_pr_create_failed", detail)),
Ok(body_out) => Ok(JStr(body_out)),
},
}
},
},
}
}

fn tool() -> t.Tool {
t.with_approval(t.define("github_pr_create", "Open a pull request on GitHub for the CURRENT branch, which must already be pushed to the remote. Requires operator approval before it runs.", params(), execute), "github_pr_create")
}

4 changes: 3 additions & 1 deletion src/tools/index.lex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import "./propagate_effect" as propagate_tool

import "./remember" as remember_tool

import "./github_pr_create" as github_pr_tool

import "./lex_store_merge" as store_merge_tool

import "./vcs/ast_diff" as vcs_ast_diff_tool
Expand Down Expand Up @@ -122,7 +124,7 @@ fn vcs_tools() -> List[t.Tool] {
# calling it — it compares a file's effects against that mode's grant —
# so the toolset has to be built per mode rather than shared.
fn all_tools_for_mode(mode :: Str) -> List[t.Tool] {
list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool()], vcs_tools())
list.concat([read_tool.tool(), write_tool.tool(), edit_tool.tool(), grep_tool.tool(), glob_tool.tool(), bash_tool.tool(), todo_tool.tool(), remember_tool.tool(), check_tool.tool(), os_check_tool.tool_for_mode(mode), audit_tool.tool(), semantic_search_tool.tool(), run_tool.tool(), test_tool.tool(), spec_check_tool.tool(), spec_smt_tool.tool(), sigid_tool.tool(), attest_tool.tool(), effects_tool.tool(), store_merge_tool.tool(), propagate_tool.tool(), guidelines_tool.tool(), bar_check_tool.tool(), github_pr_tool.tool()], vcs_tools())
}

# The build agent's own toolset: build's grant forbids nothing, so this
Expand Down
4 changes: 2 additions & 2 deletions src/tools/smoke.lex
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ fn render(o :: Outcome) -> Str {
# one anybody kept.
fn skipped() -> List[Str]
examples {
skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull"]
skipped() => ["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"]
}
{
["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull"]
["read", "write", "edit", "grep", "glob", "bash", "todowrite", "remember", "semantic_search", "load_toolset", "vcs_branch_create", "vcs_branch_use", "vcs_merge_start", "vcs_merge_resolve", "vcs_merge_resolve_one", "vcs_merge_defer", "vcs_merge_commit", "vcs_op_push", "vcs_op_pull", "github_pr_create"]
}

fn is_skipped(name :: Str) -> Bool
Expand Down
Loading