From ee66c074864f4e5eac4502769f18aefbe5232376 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 22 Sep 2026 15:13:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20--refine=3D=20+=20issue=5Fpropo?= =?UTF-8?q?se=20=E2=80=94=20the=20agent=20proposes,=20a=20human=20approves?= =?UTF-8?q?=20(#173=20item=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's half of lex-lang #956. A free-form issue has no oracle; the agent can now do the spec labor and propose one, and stop there. - `issue_propose` tool (build + minimal toolsets): `lex issue propose`, signed `--by lex-code//` from the turn's recorded intent. List fields (api/examples/invariants) are one entry per line. There is deliberately no approve tool — the human is the arbiter. - `--refine= ["guidance"]`: refuses a non-free_form issue (points at --issue instead); prompts the agent to read the code, then propose exact signatures + edge-case examples (or one failing example) with a rationale, and NOT implement. The run ends by listing the proposals and the `lex issue approve|reject --by ` command. The session is not bound to the issue (refining produces no code to link). Runs in Build mode — a read-only toolset would need wiring through all eight provider files (#88) — so the no-implementing rule is prompt-level. - issue_contract: `--issue` uses `effective_acceptance` once a proposal is approved and says which proposal supplied the contract; free_form issues point the agent at `issue_propose`. Verified live with local qwen3.8:27b-mlx against a free_form "reverse the digits" issue: --refine read the package, proposed reverse_digits with 7 examples covering sign and trailing zeros (citing digit_sum's negative-handling convention), wrote no code, and printed the approve command; after approving (as a test fixture), `--issue` implemented it against the approved contract → [ISSUE_VERDICT] verified, confirmed by an independent `lex issue verify`. CI replica green (smoke: 30 tools incl. issue_propose). Needs lex >= 0.11.68. Co-Authored-By: Claude Opus 5 --- README.md | 27 +++++++- src/issue_contract.lex | 50 ++++++++++++--- src/tools/index.lex | 4 +- src/tools/issue.lex | 86 +++++++++++++++++++++++++ src/tools/smoke.lex | 1 + src/tui/main.lex | 142 ++++++++++++++++++++++++++++++----------- 6 files changed, 259 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 7538663..cf8c1cf 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ lex-code --plan --ollama "how should we structure the session module?" | `--bar` | Bar | Walk a project against the minimum bar, read-only ([below](#minimum-bar-mode)) | | `--multi` | Multi | Run Build + Test in parallel via `std.conc` | | `--issue=` | Build | Implement a typed issue from its declared acceptance, then verify it ([below](#implementing-a-typed-issue)) | +| `--refine=` | Build | Propose a typed acceptance for a free-form issue; a human approves it ([below](#refining-a-free-form-issue)) | ### Implementing a typed issue @@ -113,8 +114,29 @@ lex-code --issue= ["optional extra guidance"] `[ISSUE_VERDICT]\t\t`. `typed_delta` and `failing_example` issues close by proof. `free_form`, -`metric_invariant` and `evidence` verify as `inconclusive` for now. For -a `free_form` issue the agent ends by proposing a typed acceptance. +`metric_invariant` and `evidence` verify as `inconclusive` for now. + +### Refining a free-form issue + +Not every issue starts with a contract. `--refine=` has the agent read +the code and **propose** one — exact signatures plus the examples that pin +them, or the one failing example for a bug — with the `issue_propose` +tool ([lex-lang #956](https://github.com/alpibrusl/lex-lang/issues/956)). +It stops there: lex-code has no tool that approves, and the run ends by +listing the proposals and the command that decides them. + +```sh +lex-code --refine= # agent proposes +lex issue proposals # review +lex issue approve --by # or: reject --notes "..." +lex-code --issue= # implement against the approved contract +``` + +Approving never rewrites the issue — its id, intents and verdicts stay +put; the gate judges it against the latest approved proposal +(`effective_acceptance` in `lex issue show`). `--refine` runs in Build +mode with a prompt that forbids implementing; there is no dedicated +read-only toolset yet (#88). ## Providers @@ -585,6 +607,7 @@ for production interop. | `lex_test` | Run tests | | `issue_show` | Render a typed issue's acceptance as the contract to implement | | `issue_verify` | Evaluate a typed issue at head, record an `IssueVerified` attestation | +| `issue_propose` | Propose a typed acceptance for a free-form issue (a human approves it) | ### Spec tools diff --git a/src/issue_contract.lex b/src/issue_contract.lex index 911354c..0dd212e 100644 --- a/src/issue_contract.lex +++ b/src/issue_contract.lex @@ -103,7 +103,7 @@ fn evidence_section(acc :: jv.Json) -> Str { } fn free_form_section() -> Str { - "Shape: free_form. There is no machine oracle — a human closes this issue. Work from the title and body, and end by proposing a typed acceptance (signatures + examples, or a failing example) that would have made it checkable.\n" + "Shape: free_form. There is no machine oracle yet — a human closes this issue. Work from the title and body. If you can state what \"done\" means as signatures + examples (or one failing example), call `issue_propose` with it: once a human approves the proposal, the gate judges this issue against it.\n" } fn acceptance_section(acc :: jv.Json) -> Str { @@ -142,22 +142,41 @@ fn machine_closable(shape :: Str) -> Bool shape == "typed_delta" or shape == "failing_example" } -fn shape_of(issue :: jv.Json) -> Str { - match jv.get_field(issue, "acceptance") { - None => "free_form", - Some(acc) => field_text(acc, "shape"), +# The acceptance the gate evaluates: an approved proposal's +# (`effective_acceptance`, #956) when the issue was refined, else its own. +fn acceptance_of(issue :: jv.Json) -> jv.Json { + match jv.get_field(issue, "effective_acceptance") { + Some(a) => a, + None => match jv.get_field(issue, "acceptance") { + Some(a) => a, + None => JObj([]), + }, } } +fn shape_of(issue :: jv.Json) -> Str + examples { + shape_of(JObj([("acceptance", JObj([("shape", JStr("free_form"))]))])) => "free_form", + shape_of(JObj([("acceptance", JObj([("shape", JStr("free_form"))])), ("effective_acceptance", JObj([("shape", JStr("typed_delta"))]))])) => "typed_delta", + shape_of(JObj([])) => "" + } +{ + field_text(acceptance_of(issue), "shape") +} + # The build task for an issue: `lex issue show --output json` in, the # prompt the agent runs out. fn contract_prompt(issue :: jv.Json) -> Str { let id := field_text(issue, "issue_id") let body := field_text(issue, "body") let shape := shape_of(issue) - let acc := match jv.get_field(issue, "acceptance") { - None => JObj([]), - Some(a) => a, + let acc := acceptance_of(issue) + let refined := match jv.get_field(issue, "approved_proposal") { + None => "", + Some(p) => match jv.as_str(p) { + None => "", + Some(pid) => str.join(["(Filed free-form; refined by approved proposal ", pid, " — that acceptance is the contract.)\n"], ""), + }, } let closing := if machine_closable(shape) { str.join(["\nDone is a proof, not a claim: call `issue_verify` with issue_id `", id, "` and iterate until its verdict is `verified`. A `failed` verdict's detail says exactly which signature or example is wrong. Every .lex file you write is published with this issue as its intent, so the ops link back to it.\n"], "") @@ -168,7 +187,20 @@ fn contract_prompt(issue :: jv.Json) -> Str { "" } else { str.join(["\n", body, "\n"], "") - }, "\n", acceptance_section(acc), closing], "") + }, "\n", refined, acceptance_section(acc), closing], "") +} + +# `--refine=`: the agent's half of #956. It does the spec labor — +# reads the code the issue is about and proposes what "done" means as a +# typed acceptance — and stops there; approving is the human's. +fn refine_prompt(issue :: jv.Json) -> Str { + let id := field_text(issue, "issue_id") + let body := field_text(issue, "body") + str.join(["Refine free-form issue ", id, ": ", field_text(issue, "title"), "\n", if str.is_empty(str.trim(body)) { + "" + } else { + str.join(["\n", body, "\n"], "") + }, "\nDo NOT implement it. Your job is to state what \"done\" means so a machine can check it:\n", bullets(["read the relevant code first (the package, existing signatures and naming) so the proposal fits it", "prefer typed_delta: the exact signatures to add/change/remove (`name:(a :: T, ...) -> R[:kind]`, one per line) plus examples `name(args) => expected` that pin the behavior, edge cases included", "use failing_example for a bug: the one example that fails today and must pass when fixed", str.join(["call `issue_propose` with issue_id `", id, "` and a rationale explaining why this captures the issue — more than one proposal is fine when the issue is genuinely ambiguous"], ""), "you cannot approve a proposal; end by summarising what you proposed and any judgment calls a human should check"])], "") } # `lex --output json issue verify` → the verdict word, or None when the diff --git a/src/tools/index.lex b/src/tools/index.lex index 4e5da07..a7eee37 100644 --- a/src/tools/index.lex +++ b/src/tools/index.lex @@ -136,7 +136,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(), find_packages_tool.tool(), edit_files_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(), github_pr_merge_tool.tool(), issue_tool.show_tool(), issue_tool.verify_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(), find_packages_tool.tool(), edit_files_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(), github_pr_merge_tool.tool(), issue_tool.show_tool(), issue_tool.verify_tool(), issue_tool.propose_tool()], vcs_tools()) } # The build agent's own toolset: build's grant forbids nothing, so this @@ -169,7 +169,7 @@ fn all_tools() -> List[t.Tool] { # enough for the curated core; it doesn't need load_toolset gating the # way the heavier vcs/spec/store groups do. fn minimal_tools() -> List[t.Tool] { - [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(), run_tool.tool(), test_tool.tool(), stdlib_tool.tool(), guide_tool.tool(), cli_help_tool.tool(), find_packages_tool.tool(), edit_files_tool.tool(), issue_tool.show_tool(), issue_tool.verify_tool()] + [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(), run_tool.tool(), test_tool.tool(), stdlib_tool.tool(), guide_tool.tool(), cli_help_tool.tool(), find_packages_tool.tool(), edit_files_tool.tool(), issue_tool.show_tool(), issue_tool.verify_tool(), issue_tool.propose_tool()] } # Model name advertised to the LiteLLM proxy (must match a model_name in diff --git a/src/tools/issue.lex b/src/tools/issue.lex index 64c6c56..0c0aa3f 100644 --- a/src/tools/issue.lex +++ b/src/tools/issue.lex @@ -10,11 +10,20 @@ # `detail` naming the wrong signature or example), so it reaches the # model as a successful tool result it can act on. Only a command that # could not answer — unknown issue, no store — is an Err. +# +# `issue_propose` (#956) is the agent's half of refining a free-form issue: +# it proposes a typed acceptance, signed with the model that wrote it. +# There is deliberately no approve tool — the human is the arbiter, and +# `lex issue approve --by WHO` is theirs to run. import "std.process" as proc import "std.str" as str +import "std.list" as list + +import "std.io" as io + import "lex-llm/tool" as t import "lex-schema/json_value" as jv @@ -60,6 +69,83 @@ fn verify(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { } } +# One entry per non-blank line — how a model passes a list through a +# string field without inventing a JSON-in-JSON encoding. +fn lines(s :: Str) -> List[Str] + examples { + lines("a\n\n b \n") => ["a", "b"], + lines("") => [] + } +{ + list.filter(list.map(str.split(s, "\n"), fn (l :: Str) -> Str { + str.trim(l) + }), fn (l :: Str) -> Bool { + not str.is_empty(l) + }) +} + +fn repeat_flag(flag :: Str, values :: List[Str]) -> List[Str] + examples { + repeat_flag("--api", ["a", "b"]) => ["--api", "a", "--api", "b"], + repeat_flag("--api", []) => [] + } +{ + list.fold(values, [], fn (acc :: List[Str], v :: Str) -> List[Str] { + list.concat(acc, [flag, v]) + }) +} + +fn opt_flag(flag :: Str, value :: Option[Str]) -> List[Str] { + match value { + None => [], + Some(v) => [flag, v], + } +} + +type ProposeInput = { issue_id :: Str, shape :: Str, api :: Str, examples :: Str, predicate :: Option[Str], window :: Option[Str], subject :: Option[Str], invariants :: Str, rationale :: Str, by :: Str } + +# The `lex issue propose` argv for a proposal. +fn propose_argv(p :: ProposeInput) -> List[Str] + examples { + propose_argv({ issue_id: "i1", shape: "typed_delta", api: "clamp:(x :: Int) -> Int", examples: "clamp(5) => 3\nclamp(0) => 0", predicate: None, window: None, subject: None, invariants: "", rationale: "r", by: "ollama/qwen" }) => ["--output", "json", "issue", "propose", "i1", "--shape", "typed_delta", "--api", "clamp:(x :: Int) -> Int", "--example", "clamp(5) => 3", "--example", "clamp(0) => 0", "--rationale", "r", "--by", "ollama/qwen"], + propose_argv({ issue_id: "i1", shape: "metric_invariant", api: "", examples: "", predicate: Some("p99 < 200"), window: Some("7d"), subject: None, invariants: "", rationale: "", by: "" }) => ["--output", "json", "issue", "propose", "i1", "--shape", "metric_invariant", "--predicate", "p99 < 200", "--window", "7d", "--rationale", "", "--by", ""] + } +{ + list.concat(util.json_cmd(["issue", "propose", p.issue_id, "--shape", p.shape]), list.concat(repeat_flag("--api", lines(p.api)), list.concat(repeat_flag("--example", lines(p.examples)), list.concat(opt_flag("--predicate", p.predicate), list.concat(opt_flag("--window", p.window), list.concat(opt_flag("--subject", p.subject), list.concat(repeat_flag("--invariant", lines(p.invariants)), ["--rationale", p.rationale, "--by", p.by]))))))) +} + +# Who is proposing: the turn's provider/model, as session.lex recorded it. +fn proposer() -> [io] Str { + match io.read(".lex/intent/model") { + Err(_) => "lex-code", + Ok(m) => str.concat("lex-code/", str.trim(m)), + } +} + +fn propose_params() -> s.ModelSchema { + { title: "IssueProposeArgs", description: "Propose a typed acceptance for a free_form issue; a human approves or rejects it.", fields: [s.required_str("issue_id", []), s.required_str("shape", []), s.optional(s.required_str("api", [])), s.optional(s.required_str("examples", [])), s.optional(s.required_str("predicate", [])), s.optional(s.required_str("window", [])), s.optional(s.required_str("subject", [])), s.optional(s.required_str("invariants", [])), s.required_str("rationale", [])] } +} + +fn propose(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { + match (util.field_str(args, "issue_id"), util.field_str(args, "shape")) { + (Some(id), Some(shape)) => { + let argv := propose_argv({ issue_id: id, shape: shape, api: util.field_str_or(args, "api", ""), examples: util.field_str_or(args, "examples", ""), predicate: util.field_str(args, "predicate"), window: util.field_str(args, "window"), subject: util.field_str(args, "subject"), invariants: util.field_str_or(args, "invariants", ""), rationale: util.field_str_or(args, "rationale", ""), by: proposer() }) + match proc.run("lex", argv) { + Err(msg) => Err(e.single("", "proc_error", msg)), + Ok(out) => match util.cli_result(out) { + Err(detail) => Err(e.single("", "cli_failed", detail)), + Ok(body) => Ok(JStr(str.trim(body))), + }, + } + }, + _ => Err(e.single("", "missing", "issue_id and shape are required")), + } +} + +fn propose_tool() -> t.Tool { + t.define("issue_propose", "Propose a typed acceptance for a free_form issue (lex issue propose). shape: typed_delta (api: one `name:signature[:added|changed|removed]` per line, e.g. `clamp:(x :: Int, lo :: Int, hi :: Int) -> Int`; examples: one `name(args) => expected` per line) | failing_example (examples: exactly one line) | metric_invariant (predicate, window) | evidence (subject, invariants one per line). rationale: why this captures the issue. Nothing changes until a HUMAN approves it; you cannot approve it yourself.", propose_params(), propose) +} + fn show_tool() -> t.Tool { t.define("issue_show", "Read a typed issue's declared acceptance (lex issue show) and render it as the contract to implement: exact signatures to add/change/remove, the examples that are its oracle, or the failing example to fix.", id_params("IssueShowArgs", "Show a typed issue's acceptance."), show) } diff --git a/src/tools/smoke.lex b/src/tools/smoke.lex index 9c7d3c2..320767e 100644 --- a/src/tools/smoke.lex +++ b/src/tools/smoke.lex @@ -84,6 +84,7 @@ fn args_for(name :: Str) -> Option[jv.Json] { "find_packages" => Some(JObj([("query", JStr("gcd"))])), "issue_show" => Some(JObj([("issue_id", JStr("0000000000000000000000000000000000000000000000000000000000000000"))])), "issue_verify" => Some(JObj([("issue_id", JStr("0000000000000000000000000000000000000000000000000000000000000000"))])), + "issue_propose" => Some(JObj([("issue_id", JStr("0000000000000000000000000000000000000000000000000000000000000000")), ("shape", JStr("failing_example")), ("examples", JStr("f(1) => 1")), ("rationale", JStr("smoke"))])), _ => None, } } diff --git a/src/tui/main.lex b/src/tui/main.lex index d3c6fd9..a60e963 100644 --- a/src/tui/main.lex +++ b/src/tui/main.lex @@ -116,37 +116,75 @@ fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, i # verdict on a machine-readable last line: # # [ISSUE_VERDICT]\t\t -fn run_issue(issue_id :: Str, guidance :: Option[Str], mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random] Nil { +fn fetch_issue(issue_id :: Str) -> [proc] Result[jv.Json, Str] { match proc.run("lex", ["--output", "json", "issue", "show", issue_id]) { - Err(e) => io.print(str.join(["error: ", e, "\n"], "")), + Err(e) => Err(e), Ok(out) => if out.exit_code != 0 { - io.print(str.join(["error: cannot read issue ", issue_id, ": ", str.trim(str.concat(out.stdout, out.stderr)), "\n"], "")) + Err(str.join(["cannot read issue ", issue_id, ": ", str.trim(str.concat(out.stdout, out.stderr))], "")) } else { match jv.parse(str.trim(out.stdout)) { - Err(_) => io.print(str.join(["error: unreadable issue ", issue_id, "\n"], "")), - Ok(issue) => { - let contract := ic.contract_prompt(issue) - let task := match guidance { - None => contract, - Some(g) => str.join([contract, "\nAdditional guidance from the user:\n", g, "\n"], ""), + Err(_) => Err(str.concat("unreadable issue ", issue_id)), + Ok(issue) => Ok(issue), + } + }, + } +} + +# `--refine= ["guidance"]` (#956): the agent reads the code and proposes +# a typed acceptance for a free-form issue with `issue_propose`; the run +# ends by listing the issue's proposals and the command that approves one. +# Approving stays with the human — lex-code has no tool for it. The +# session is NOT bound to the issue: refining produces no code to link. +fn run_refine(issue_id :: Str, guidance :: Option[Str], provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random] Nil { + match fetch_issue(issue_id) { + Err(e) => io.print(str.join(["error: ", e, "\n"], "")), + Ok(issue) => if ic.shape_of(issue) != "free_form" { + io.print(str.join(["issue ", issue_id, " is already ", ic.shape_of(issue), " — nothing to refine; implement it with --issue=", issue_id, "\n"], "")) + } else { + let prompt := match guidance { + None => ic.refine_prompt(issue), + Some(g) => str.join([ic.refine_prompt(issue), "\nAdditional guidance from the user:\n", g, "\n"], ""), + } + let session_id := cli_session_id() + match sess.new_session_persistent_with_provider(session_id, Build, provider_tag) { + Err(e) => io.print(str.concat(str.concat("error: ", e), "\n")), + Ok(session) => { + let __printed := sess.run_turn_streaming_with_provider(session, prompt, provider_tag, print_step) + let listed := match proc.run("lex", ["issue", "proposals", issue_id]) { + Err(e) => e, + Ok(o) => str.trim(str.concat(o.stdout, o.stderr)), } - let session_id := cli_session_id() - match sess.new_session_persistent_with_provider(session_id, mode, provider_tag) { - Err(e) => io.print(str.concat(str.concat("error: ", e), "\n")), - Ok(session) => { - let __d := proc.run("mkdir", ["-p", ".lex/intent"]) - let __w := io.write(".lex/intent/issue", str.join([session_id, "\t", issue_id], "")) - let __printed := sess.run_turn_streaming_with_provider(session, task, provider_tag, print_step) - let verdict := match proc.run("lex", ["--output", "json", "issue", "verify", issue_id]) { - Err(_) => "unavailable", - Ok(v) => match ic.verdict_of(v.stdout) { - None => "unavailable", - Some(word) => word, - }, - } - io.print(str.join(["\n(trail: .lex/sessions/", session_id, ".db)\n[ISSUE_VERDICT]\t", verdict, "\t", issue_id, "\n"], "")) + io.print(str.join(["\n(trail: .lex/sessions/", session_id, ".db)\nproposals for ", issue_id, ":\n", listed, "\n\nreview, then: lex issue approve --by (or: lex issue reject --by --notes ...)\n"], "")) + }, + } + }, + } +} + +fn run_issue(issue_id :: Str, guidance :: Option[Str], mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random] Nil { + match fetch_issue(issue_id) { + Err(e) => io.print(str.join(["error: ", e, "\n"], "")), + Ok(issue) => { + let contract := ic.contract_prompt(issue) + let task := match guidance { + None => contract, + Some(g) => str.join([contract, "\nAdditional guidance from the user:\n", g, "\n"], ""), + } + let session_id := cli_session_id() + match sess.new_session_persistent_with_provider(session_id, mode, provider_tag) { + Err(e) => io.print(str.concat(str.concat("error: ", e), "\n")), + Ok(session) => { + let __d := proc.run("mkdir", ["-p", ".lex/intent"]) + let __w := io.write(".lex/intent/issue", str.join([session_id, "\t", issue_id], "")) + let __printed := sess.run_turn_streaming_with_provider(session, task, provider_tag, print_step) + let verdict := match proc.run("lex", ["--output", "json", "issue", "verify", issue_id]) { + Err(_) => "unavailable", + Ok(v) => match ic.verdict_of(v.stdout) { + None => "unavailable", + Some(word) => word, }, } + io.print(str.join(["\n(trail: .lex/sessions/", session_id, ".db)\n[ISSUE_VERDICT]\t", verdict, "\t", issue_id, "\n"], "")) }, } }, @@ -295,6 +333,29 @@ fn find_issue(argv :: List[Str]) -> Option[Str] } } +# `--refine=` (#956): propose a typed acceptance for a free-form issue. +fn find_refine(argv :: List[Str]) -> Option[Str] + examples { + find_refine(["--refine=abc"]) => Some("abc"), + find_refine(["--refine="]) => None, + find_refine(["--issue=abc"]) => None + } +{ + match list.head(list.filter(argv, fn (a :: Str) -> Bool { + str.starts_with(a, "--refine=") + })) { + None => None, + Some(tok) => match str.strip_prefix(tok, "--refine=") { + None => None, + Some(id) => if str.is_empty(id) { + None + } else { + Some(id) + }, + }, + } +} + # A `--pipeline=` value that names nothing must not quietly become the # default pipeline: the user asked for a specific arrangement of agents, # and running a different one is a wrong answer dressed as a working run. @@ -595,7 +656,7 @@ fn regenerate(provider_tag :: Str) -> [env, io, net, llm, stream] Nil { } } -type Invocation = { mode :: sess.AgentMode, provider :: Str, task :: Option[Str], multi :: Bool, pipeline :: Option[Str], regenerate :: Bool, issue :: Option[Str] } +type Invocation = { mode :: sess.AgentMode, provider :: Str, task :: Option[Str], multi :: Bool, pipeline :: Option[Str], regenerate :: Bool, issue :: Option[Str], refine :: Option[Str] } # The whole command line, resolved in one pure function. # @@ -611,18 +672,19 @@ type Invocation = { mode :: sess.AgentMode, provider :: Str, task :: Option[Str] # then cover the whole parse path rather than its pieces. fn plan_invocation(argv :: List[Str]) -> Invocation examples { - plan_invocation([]) => { mode: Build, provider: "litellm", task: None, multi: false, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--bar", "walk this repo"]) => { mode: Bar, provider: "litellm", task: Some("walk this repo"), multi: false, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--ollama", "--plan"]) => { mode: Plan, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--multi"]) => { mode: Build, provider: "litellm", task: None, multi: true, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--litellm", "--review", "check the diff"]) => { mode: Review, provider: "litellm", task: Some("check the diff"), multi: false, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--litellm", "--verify", "check src/abi.lex against the ABI spec"]) => { mode: Verify, provider: "litellm", task: Some("check src/abi.lex against the ABI spec"), multi: false, pipeline: None, regenerate: false, issue: None }, - plan_invocation(["--multi", "--pipeline=impl_then_spec_then_test"]) => { mode: Build, provider: "litellm", task: None, multi: true, pipeline: Some("impl_then_spec_then_test"), regenerate: false, issue: None }, - plan_invocation(["--ollama", "--regenerate"]) => { mode: Build, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: true, issue: None }, - plan_invocation(["--opencode", "--issue=9fd3cc"]) => { mode: Build, provider: "opencode", task: None, multi: false, pipeline: None, regenerate: false, issue: Some("9fd3cc") } + plan_invocation([]) => { mode: Build, provider: "litellm", task: None, multi: false, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--bar", "walk this repo"]) => { mode: Bar, provider: "litellm", task: Some("walk this repo"), multi: false, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--ollama", "--plan"]) => { mode: Plan, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--multi"]) => { mode: Build, provider: "litellm", task: None, multi: true, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--litellm", "--review", "check the diff"]) => { mode: Review, provider: "litellm", task: Some("check the diff"), multi: false, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--litellm", "--verify", "check src/abi.lex against the ABI spec"]) => { mode: Verify, provider: "litellm", task: Some("check src/abi.lex against the ABI spec"), multi: false, pipeline: None, regenerate: false, issue: None, refine: None }, + plan_invocation(["--multi", "--pipeline=impl_then_spec_then_test"]) => { mode: Build, provider: "litellm", task: None, multi: true, pipeline: Some("impl_then_spec_then_test"), regenerate: false, issue: None, refine: None }, + plan_invocation(["--ollama", "--regenerate"]) => { mode: Build, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: true, issue: None, refine: None }, + plan_invocation(["--opencode", "--issue=9fd3cc"]) => { mode: Build, provider: "opencode", task: None, multi: false, pipeline: None, regenerate: false, issue: Some("9fd3cc"), refine: None }, + plan_invocation(["--ollama", "--refine=ab12"]) => { mode: Build, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: false, issue: None, refine: Some("ab12") } } { - { mode: select_mode(argv), provider: select_provider_tag(argv), task: find_task(argv), multi: has_flag(argv, "--multi"), pipeline: find_pipeline(argv), regenerate: has_flag(argv, "--regenerate"), issue: find_issue(argv) } + { mode: select_mode(argv), provider: select_provider_tag(argv), task: find_task(argv), multi: has_flag(argv, "--multi"), pipeline: find_pipeline(argv), regenerate: has_flag(argv, "--regenerate"), issue: find_issue(argv), refine: find_refine(argv) } } fn main() -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random, concurrent] Nil { @@ -632,9 +694,12 @@ fn main() -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, ap if inv.regenerate { regenerate(provider_tag) } else { - match inv.issue { - Some(issue_id) => run_issue(issue_id, inv.task, mode, provider_tag), - None => dispatch(inv, mode, provider_tag), + match inv.refine { + Some(issue_id) => run_refine(issue_id, inv.task, provider_tag), + None => match inv.issue { + Some(issue_id) => run_issue(issue_id, inv.task, mode, provider_tag), + None => dispatch(inv, mode, provider_tag), + }, } } } @@ -656,6 +721,7 @@ fn dispatch(inv :: Invocation, mode :: sess.AgentMode, provider_tag :: Str) -> [ io.print(str.concat("providers: --mistral | --openai | --google | --vertex | --litellm | --ollama | --vllm | --opencode (default: anthropic)", "\n")) io.print(str.concat("one-shot: lex run src/tui/main.lex -- [flags] \"your task\"", "\n")) io.print(str.concat("issue: --issue= [\"extra guidance\"] implement a typed issue from its acceptance, then verify it", "\n")) + io.print(str.concat("refine: --refine= propose a typed acceptance for a free_form issue (you approve it)", "\n")) io.print(str.concat("Ctrl-D to exit", "\n")) if inv.multi { match resolve_pipeline(inv.pipeline) { From 61954a389a447193eca505c5504e980182a42c17 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 22 Sep 2026 16:16:03 +0200 Subject: [PATCH 2/2] chore: bump Lex toolchain to 0.11.68 0.11.68 carries `lex issue propose|proposals|approve|reject` (lex-lang #956), which issue_propose and --refine call, and the one-ls-remote-per-load fix (lex-lang #1015) that made 0.11.67 unusable for this repo's per-file CI check (src/tools/index.lex: ~2 min on 0.11.67, 3.3s on 0.11.68). Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- lex.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f696c..5ece365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - name: Install Lex toolchain run: | set -euxo pipefail - LEX_VERSION="0.11.66" + LEX_VERSION="0.11.68" # Backed off and limited: the release CDN returns a 504 often # enough to redden a build on its own, and re-running the job # by hand is not a fix. Retries are safe here — the request is diff --git a/lex.toml b/lex.toml index 8a06dd2..c6b0a1f 100644 --- a/lex.toml +++ b/lex.toml @@ -1,7 +1,7 @@ [package] name = "lex-code" version = "0.1.2" -lex = "0.11.66" +lex = "0.11.68" license = "EUPL-1.2" description = "A Lex-native coding assistant — build/plan/explore/refactor/spec/test/review agents, TUI + A2A + ACP servers, lex-vcs tools."