From 98cbea57ddf89b4c8d0efd6878a03a55f836ada4 Mon Sep 17 00:00:00 2001 From: Alfonso Sastre Date: Tue, 22 Sep 2026 13:19:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20from=20a=20typed=20issue=20?= =?UTF-8?q?=E2=80=94=20--issue=3D,=20issue=5Fshow/issue=5Fverify=20(#1?= =?UTF-8?q?73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A typed issue (lex-lang #949) is the contract to satisfy: exact signatures plus the examples that decide whether it holds. lex-code now works from it directly: - `--issue= ["guidance"]`: `lex issue show` renders the acceptance as the task (src/issue_contract.lex, pure + examples); the run ends with `lex issue verify` whatever the model claimed, and prints `[ISSUE_VERDICT]\t\t` as its last line. - Provenance: the session is bound to the issue via `.lex/intent/issue` (`\t`, honoured only for the session that wrote it, so a leftover file cannot stamp a later session's ops), and every clean .lex write publishes with `--intent-issue` — issue -> intent -> ops -> attestation. - Tools `issue_show` / `issue_verify` (build + minimal toolsets) so the fix loop iterates against the issue's own oracle. A `failed` verdict is an answer (ok:true, exit 1), not a tool error. - Smoke gate: `unknown command` joins the refusal vocabulary — a toolchain older than the command a tool calls said exactly that, and none of the six phrases caught it. - Toolchain 0.11.32 -> 0.11.66 (`lex issue` needs >= 0.11.50). Not 0.11.67: its unmemoized per-import `git ls-remote` (lex-lang #1005) turns a `lex check` of src/tools/index.lex into ~2 minutes. Verified live with local qwen3.8:27b-mlx (OLLAMA_THINK=false) on a fresh package: typed_delta issue failing at head -> agent wrote digit_sum with the issue's examples -> `[ISSUE_VERDICT] verified`, re-verified independently; both new ops carry the intent whose issue_id is the issue. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- README.md | 30 ++++++ lex.toml | 2 +- src/issue_contract.lex | 196 +++++++++++++++++++++++++++++++++++++++ src/tools/index.lex | 6 +- src/tools/issue.lex | 70 ++++++++++++++ src/tools/linter.lex | 50 +++++++++- src/tools/smoke.lex | 2 + src/tools/util.lex | 7 +- src/tui/main.lex | 156 ++++++++++++++++++++++++------- 10 files changed, 481 insertions(+), 40 deletions(-) create mode 100644 src/issue_contract.lex create mode 100644 src/tools/issue.lex diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 593c7ba..25f696c 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.32" + LEX_VERSION="0.11.66" # 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/README.md b/README.md index ceec2f5..7538663 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,34 @@ lex-code --plan --ollama "how should we structure the session module?" | `--verify` | Verify | Independently re-derive expected output from the task's own spec and check the implementation against it — never trusts the implementation's existing test file ([below](#independent-verification-mode)) | | `--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)) | + +### Implementing a typed issue + +A [typed issue](https://github.com/alpibrusl/lex-lang/issues/949) is a +contract, not a description: exact signatures to add, change, or remove, +plus the examples that decide whether it holds (or, for a bug, the one +example that fails at head). `--issue=` works from that contract: + +```sh +lex issue create --title "digit_sum" --shape typed_delta \ + --api 'digit_sum:(n :: Int) -> Int:added' \ + --example 'digit_sum(1234) => 10' --example 'digit_sum(-56) => 11' +lex-code --issue= ["optional extra guidance"] +``` + +1. `lex issue show` renders the acceptance as the task. +2. The session is bound to the issue, so every clean `.lex` write is + published with `--intent-issue` and its ops link back to it + (issue → intent → ops → attestation). +3. The agent iterates against the oracle with the `issue_verify` tool. +4. Whatever the model claims, the run ends with `lex issue verify` and a + machine-readable last line: + `[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. ## Providers @@ -555,6 +583,8 @@ for production interop. | `lex_audit` | Effect audit | | `lex_run` | Run a Lex expression | | `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 | ### Spec tools diff --git a/lex.toml b/lex.toml index a650362..8a06dd2 100644 --- a/lex.toml +++ b/lex.toml @@ -1,7 +1,7 @@ [package] name = "lex-code" version = "0.1.2" -lex = "0.11.32" +lex = "0.11.66" 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." diff --git a/src/issue_contract.lex b/src/issue_contract.lex new file mode 100644 index 0000000..911354c --- /dev/null +++ b/src/issue_contract.lex @@ -0,0 +1,196 @@ +# lex-code — implement from a typed issue (#173, lex-lang #949) +# +# A typed issue is the contract the manifesto asks an agent to work +# from — "a type signature, a set of examples, a set of properties" — +# rather than a codebase to imitate. `lex issue show ` hands back +# its declared acceptance; this module turns that into the task the +# build agent runs, and reads `lex issue verify`'s verdict back. +# +# Pure on purpose: everything that touches the CLI lives in the tools +# (src/tools/issue.lex) and the `--issue` entry point (src/tui/main.lex), +# so the rendering is covered by examples rather than by a live store. +# +# The acceptance, by shape (lex-store's `Acceptance`, serde tag "shape"): +# typed_delta api: [{name, signature, kind}], examples: [Str] +# failing_example example: Str — fails at HEAD; fixed = passes +# metric_invariant predicate, window — a window over the event log +# evidence subject, invariants — an attested chain +# free_form nothing — human-closed, the explicit exception + +import "std.str" as str + +import "std.list" as list + +import "lex-schema/json_value" as jv + +fn field_text(j :: jv.Json, key :: Str) -> Str { + match jv.get_field(j, key) { + None => "", + Some(v) => match jv.as_str(v) { + None => "", + Some(s) => s, + }, + } +} + +fn field_list(j :: jv.Json, key :: Str) -> List[jv.Json] { + match jv.get_field(j, key) { + None => [], + Some(v) => match jv.as_list(v) { + None => [], + Some(xs) => xs, + }, + } +} + +fn texts(xs :: List[jv.Json]) -> List[Str] { + list.fold(xs, [], fn (acc :: List[Str], x :: jv.Json) -> List[Str] { + match jv.as_str(x) { + None => acc, + Some(s) => list.concat(acc, [s]), + } + }) +} + +fn bullets(lines :: List[Str]) -> Str + examples { + bullets([]) => "", + bullets(["a", "b"]) => " - a\n - b\n" + } +{ + str.join(list.map(lines, fn (l :: Str) -> Str { + str.join([" - ", l, "\n"], "") + }), "") +} + +# One API entry as the declaration the store will compare against: +# `fn `. The gate compares whitespace-insensitively, +# but the model should still copy it verbatim. +fn api_line(entry :: jv.Json) -> Str { + let kind := field_text(entry, "kind") + let decl := str.join(["fn ", field_text(entry, "name"), field_text(entry, "signature")], "") + if kind == "removed" { + str.join(["REMOVE `", decl, "` — it must be absent at head"], "") + } else { + if kind == "changed" { + str.join(["CHANGE to `", decl, "` — this exact signature at head"], "") + } else { + str.join(["ADD `", decl, "` — this exact signature at head"], "") + } + } +} + +fn typed_delta_section(acc :: jv.Json) -> Str { + let api := list.map(field_list(acc, "api"), api_line) + let examples := texts(field_list(acc, "examples")) + str.join(["Shape: typed_delta. The API delta is the contract:\n", bullets(api), if list.is_empty(examples) { + "" + } else { + str.join(["These examples are the oracle — each must hold at head. Put them in the function's own `examples { }` block so the store's write-time gate runs them on every publish:\n", bullets(examples)], "") + }], "") +} + +fn failing_example_section(acc :: jv.Json) -> Str { + str.join(["Shape: failing_example (a bug report). This example FAILS at head today; the issue is fixed when it passes:\n", bullets([field_text(acc, "example")]), "Fix the implementation, not the example. Add the example to the function's `examples { }` block so it cannot regress.\n"], "") +} + +fn metric_section(acc :: jv.Json) -> Str { + str.join(["Shape: metric_invariant. Predicate `", field_text(acc, "predicate"), "` must hold over the window `", field_text(acc, "window"), "`. The gate cannot evaluate this from code alone yet (it reports inconclusive); build what makes the predicate observable and true, and say what you could not verify.\n"], "") +} + +fn evidence_section(acc :: jv.Json) -> Str { + str.join(["Shape: evidence. Subject `", field_text(acc, "subject"), "` needs an attested chain satisfying:\n", bullets(texts(field_list(acc, "invariants"))), "The gate reports inconclusive for this shape today; say what evidence you produced.\n"], "") +} + +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" +} + +fn acceptance_section(acc :: jv.Json) -> Str { + let shape := field_text(acc, "shape") + if shape == "typed_delta" { + typed_delta_section(acc) + } else { + if shape == "failing_example" { + failing_example_section(acc) + } else { + if shape == "metric_invariant" { + metric_section(acc) + } else { + if shape == "evidence" { + evidence_section(acc) + } else { + free_form_section() + } + } + } + } +} + +# Whether the gate can close this issue by itself. Mirrors lex-store's +# `Acceptance::is_machine_evaluable` for the shapes whose evaluator +# exists at head (typed_delta, failing_example); the other three verify +# as inconclusive today. +fn machine_closable(shape :: Str) -> Bool + examples { + machine_closable("typed_delta") => true, + machine_closable("failing_example") => true, + machine_closable("free_form") => false, + machine_closable("metric_invariant") => false + } +{ + 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 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 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"], "") + } else { + str.join(["\nWhen finished, call `issue_verify` with issue_id `", id, "` to record the verdict (expect `inconclusive` for this shape). Every .lex file you write is published with this issue as its intent.\n"], "") + } + str.join(["Implement typed issue ", id, ": ", field_text(issue, "title"), "\n", if str.is_empty(str.trim(body)) { + "" + } else { + str.join(["\n", body, "\n"], "") + }, "\n", acceptance_section(acc), closing], "") +} + +# `lex --output json issue verify` → the verdict word, or None when the +# command did not answer (unknown issue, no store). A `failed` verdict +# exits 1 but is still `"ok": true` — an answer, not an error. +fn verdict_of(stdout :: Str) -> Option[Str] + examples { + verdict_of("{\"ok\": true, \"data\": {\"verdict\": \"failed\", \"detail\": \"x\"}}") => Some("failed"), + verdict_of("{\"ok\": true, \"data\": {\"verdict\": \"verified\"}}") => Some("verified"), + verdict_of("{\"ok\": false, \"error\": {\"message\": \"unknown issue\"}}") => None, + verdict_of("error: unknown command `issue`") => None + } +{ + match jv.parse(str.trim(stdout)) { + Err(_) => None, + Ok(env) => match jv.get_field(env, "data") { + None => None, + Some(data) => match jv.get_field(data, "verdict") { + None => None, + Some(v) => jv.as_str(v), + }, + }, + } +} + diff --git a/src/tools/index.lex b/src/tools/index.lex index 397ffde..4e5da07 100644 --- a/src/tools/index.lex +++ b/src/tools/index.lex @@ -62,6 +62,8 @@ import "./vcs/op_replay" as vcs_op_replay_tool import "./vcs/recall" as vcs_recall_tool +import "./issue" as issue_tool + import "./vcs/op_push" as vcs_op_push_tool import "./vcs/op_pull" as vcs_op_pull_tool @@ -134,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()], 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()], vcs_tools()) } # The build agent's own toolset: build's grant forbids nothing, so this @@ -167,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()] + [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()] } # 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 new file mode 100644 index 0000000..64c6c56 --- /dev/null +++ b/src/tools/issue.lex @@ -0,0 +1,70 @@ +# lex-code — typed issues as agent tools (#173, lex-lang #949) +# +# `issue_show` reads an issue's declared acceptance — the contract to +# satisfy. `issue_verify` evaluates it at the store's head and records an +# `IssueVerified` attestation: done is a proof the gate checks, never a +# status the agent sets. Together they let the fix loop iterate against +# the issue's own oracle rather than against the agent's reading of it. +# +# A `failed` verdict exits 1 but is still an answer (`"ok": true` with a +# `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. + +import "std.process" as proc + +import "std.str" as str + +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 "../issue_contract" as ic + +import "./util" as util + +fn id_params(title :: Str, description :: Str) -> s.ModelSchema { + { title: title, description: description, fields: [s.required_str("issue_id", [])] } +} + +fn show(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { + match util.field_str(args, "issue_id") { + None => Err(e.single("issue_id", "missing", "issue_id is required")), + Some(id) => match proc.run("lex", util.json_cmd(["issue", "show", id])) { + 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) => match jv.parse(str.trim(body)) { + Err(_) => Ok(JStr(body)), + Ok(issue) => Ok(JStr(ic.contract_prompt(issue))), + }, + }, + }, + } +} + +fn verify(args :: jv.Json) -> [net, io, proc] Result[jv.Json, e.Errors] { + match util.field_str(args, "issue_id") { + None => Err(e.single("issue_id", "missing", "issue_id is required")), + Some(id) => match proc.run("lex", util.json_cmd(["issue", "verify", id])) { + Err(msg) => Err(e.single("", "proc_error", msg)), + Ok(out) => match ic.verdict_of(out.stdout) { + Some(_) => Ok(JStr(str.trim(out.stdout))), + None => Err(e.single("", "cli_failed", util.combined(out))), + }, + }, + } +} + +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) +} + +fn verify_tool() -> t.Tool { + t.define("issue_verify", "Evaluate a typed issue's acceptance at the store head (lex issue verify) and record an IssueVerified attestation. Verdict: verified | failed (with detail naming the wrong signature or example) | inconclusive (shape not machine-evaluable). Call it to prove an issue done; iterate until verified.", id_params("IssueVerifyArgs", "Verify a typed issue at head."), verify) +} + diff --git a/src/tools/linter.lex b/src/tools/linter.lex index e3f82ee..0f89251 100644 --- a/src/tools/linter.lex +++ b/src/tools/linter.lex @@ -435,6 +435,54 @@ fn read_intent() -> [io] Option[{ prompt :: Str, model :: Str, session :: Str }] } } +# The typed issue this turn implements, if any (#173). `--issue ` +# writes `.lex/intent/issue` as `\t`; it counts only +# for the session that wrote it, so an `--issue` run can never stamp its +# issue onto a later, unrelated session's ops through a leftover file. +fn issue_for_session(content :: Str, session :: Str) -> Option[Str] + examples { + issue_for_session("cli-1\tabc", "cli-1") => Some("abc"), + issue_for_session("cli-1\tabc\n", "cli-1") => Some("abc"), + issue_for_session("cli-1\tabc", "cli-2") => None, + issue_for_session("cli-1\t", "cli-1") => None, + issue_for_session("cli-10\tabc", "cli-1") => None, + issue_for_session("", "") => None + } +{ + if str.is_empty(session) { + None + } else { + match str.strip_prefix(str.trim(content), str.concat(session, "\t")) { + None => None, + Some(id) => if str.is_empty(str.trim(id)) or str.contains(id, "\t") { + None + } else { + Some(str.trim(id)) + }, + } + } +} + +fn read_intent_issue(session :: Str) -> [io] Option[Str] { + match io.read(str.concat(intent_dir(), "/issue")) { + Err(_) => None, + Ok(content) => issue_for_session(content, session), + } +} + +fn intent_args(i :: { prompt :: Str, model :: Str, session :: Str }, issue :: Option[Str]) -> List[Str] + examples { + intent_args({ prompt: "p", model: "m", session: "s" }, None) => ["--intent-prompt", "p", "--intent-model", "m", "--intent-session", "s"], + intent_args({ prompt: "p", model: "m", session: "s" }, Some("abc")) => ["--intent-prompt", "p", "--intent-model", "m", "--intent-session", "s", "--intent-issue", "abc"] + } +{ + let base := ["--intent-prompt", i.prompt, "--intent-model", i.model, "--intent-session", i.session] + match issue { + None => base, + Some(id) => list.concat(base, ["--intent-issue", id]), + } +} + # After a clean write of a .lex file inside a package, land it in the # op-log store with the turn's intent: `lex publish --activate # --intent-*` (#131/#839). The store runs its write-time gates — the @@ -458,7 +506,7 @@ fn publish_with_intent(path :: Str) -> [proc, io] Option[Str] { let base := ["--output", "json", "publish", path, "--activate"] let args := match read_intent() { None => base, - Some(i) => list.concat(base, ["--intent-prompt", i.prompt, "--intent-model", i.model, "--intent-session", i.session]), + Some(i) => list.concat(base, intent_args(i, read_intent_issue(i.session))), } match proc.run("lex", args) { Err(_) => None, diff --git a/src/tools/smoke.lex b/src/tools/smoke.lex index f963a3e..9c7d3c2 100644 --- a/src/tools/smoke.lex +++ b/src/tools/smoke.lex @@ -82,6 +82,8 @@ fn args_for(name :: Str) -> Option[jv.Json] { "vcs_merge_status" => Some(JObj([("merge_id", JStr("no-such-session"))])), "vcs_merge_show_conflicts" => Some(JObj([("merge_id", JStr("no-such-session"))])), "find_packages" => Some(JObj([("query", JStr("gcd"))])), + "issue_show" => Some(JObj([("issue_id", JStr("0000000000000000000000000000000000000000000000000000000000000000"))])), + "issue_verify" => Some(JObj([("issue_id", JStr("0000000000000000000000000000000000000000000000000000000000000000"))])), _ => None, } } diff --git a/src/tools/util.lex b/src/tools/util.lex index 3f1899e..2facb48 100644 --- a/src/tools/util.lex +++ b/src/tools/util.lex @@ -113,8 +113,12 @@ fn json_cmd(rest :: List[Str]) -> List[Str] # diagnostics, and from every JSON payload the tools return. The cost is # real and worth naming: a refusal phrased outside this vocabulary — the # `error: missing second run id` that `lex diff` emits — is not caught. +# +# `unknown command` joined with the issue tools (#173): a toolchain pinned +# older than the command a tool calls answers "error: unknown command +# `issue`", and none of the six phrases matched it. fn refusal_words() -> List[Str] { - ["unknown flag", "unknown `lex", "subcommand:", "unexpected arg", "unexpected `--", "usage:"] + ["unknown flag", "unknown `lex", "subcommand:", "unexpected arg", "unexpected `--", "usage:", "unknown command"] } fn is_usage_error(text :: Str) -> Bool @@ -124,6 +128,7 @@ fn is_usage_error(text :: Str) -> Bool is_usage_error("error: unexpected arg `5`") => true, is_usage_error("error: unexpected `--output`") => true, is_usage_error("error: usage: lex op {show|log}") => true, + is_usage_error("error: unknown command `issue`. try `lex help`") => true, is_usage_error(": error: unexpected arg `5` [cli_failed]") => true, is_usage_error("output\nerror: unexpected `--output`") => true, is_usage_error("| `-> T` | return type | `: T` (Lex error: missing return arrow) |") => false, diff --git a/src/tui/main.lex b/src/tui/main.lex index 082d7d5..d3c6fd9 100644 --- a/src/tui/main.lex +++ b/src/tui/main.lex @@ -22,6 +22,10 @@ import "lex-llm/message" as msg import "lex-schema/json_value" as jv +import "std.process" as proc + +import "../issue_contract" as ic + import "../server/session" as sess import "../server/multi_agent" as multi @@ -100,6 +104,55 @@ fn run_once(task :: Str, mode :: sess.AgentMode, provider_tag :: Str) -> [env, i } } +# `--issue= ["extra guidance"]` (#173, lex-lang #949): implement a +# typed issue from its declared acceptance, then let the gate say whether +# it is done. +# +# The issue's acceptance becomes the task (issue_contract.contract_prompt) +# — the contract to satisfy, not a paraphrase of it. `.lex/intent/issue` +# ties this session to the issue, so every clean .lex write publishes with +# `--intent-issue` and its ops link back (issue ↔ intent ↔ ops). The run +# ends with `lex issue verify` whatever the model claimed, and prints the +# 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 { + match proc.run("lex", ["--output", "json", "issue", "show", issue_id]) { + Err(e) => io.print(str.join(["error: ", e, "\n"], "")), + 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"], "")) + } 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"], ""), + } + 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"], "")) + }, + } + }, + } + }, + } +} + # `--once --multi --pipeline=NAME "task"` — a graph pipeline run non- # interactively, exactly once, the multi-agent counterpart to `run_once`. # Before this, `--multi`/`--pipeline=` were silently ignored whenever a @@ -216,6 +269,32 @@ fn find_pipeline(argv :: List[Str]) -> Option[Str] } } +# `--issue=` (#173): implement a typed issue from its declared +# acceptance. One token for the same reason as `--pipeline=`. +fn find_issue(argv :: List[Str]) -> Option[Str] + examples { + find_issue(["--issue=9fd3cc"]) => Some("9fd3cc"), + find_issue(["--ollama", "--issue=abc", "keep it small"]) => Some("abc"), + find_issue(["--issue="]) => None, + find_issue(["--issue", "abc"]) => None, + find_issue([]) => None + } +{ + match list.head(list.filter(argv, fn (a :: Str) -> Bool { + str.starts_with(a, "--issue=") + })) { + None => None, + Some(tok) => match str.strip_prefix(tok, "--issue=") { + 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. @@ -516,7 +595,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 } +type Invocation = { mode :: sess.AgentMode, provider :: Str, task :: Option[Str], multi :: Bool, pipeline :: Option[Str], regenerate :: Bool, issue :: Option[Str] } # The whole command line, resolved in one pure function. # @@ -532,17 +611,18 @@ 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 }, - plan_invocation(["--bar", "walk this repo"]) => { mode: Bar, provider: "litellm", task: Some("walk this repo"), multi: false, pipeline: None, regenerate: false }, - plan_invocation(["--ollama", "--plan"]) => { mode: Plan, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: false }, - plan_invocation(["--multi"]) => { mode: Build, provider: "litellm", task: None, multi: true, pipeline: None, regenerate: false }, - plan_invocation(["--litellm", "--review", "check the diff"]) => { mode: Review, provider: "litellm", task: Some("check the diff"), multi: false, pipeline: None, regenerate: false }, - 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 }, - 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 }, - plan_invocation(["--ollama", "--regenerate"]) => { mode: Build, provider: "ollama", task: None, multi: false, pipeline: None, regenerate: true } + 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") } } { - { 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") } + { 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) } } fn main() -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random, concurrent] Nil { @@ -552,35 +632,43 @@ 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.task { - Some(task) => if inv.multi { + match inv.issue { + Some(issue_id) => run_issue(issue_id, inv.task, mode, provider_tag), + None => dispatch(inv, mode, provider_tag), + } + } +} + +fn dispatch(inv :: Invocation, mode :: sess.AgentMode, provider_tag :: Str) -> [env, io, net, llm, proc, sql, fs_read, fs_walk, fs_write, time, approval, stream, crypto, random, concurrent] Nil { + match inv.task { + Some(task) => if inv.multi { + match resolve_pipeline(inv.pipeline) { + Err(msg) => io.print(str.concat(msg, "\n")), + Ok(pipeline) => run_once_multi(task, pipeline, provider_tag), + } + } else { + run_once(task, mode, provider_tag) + }, + None => { + io.print(str.concat("lex-code — Lex-specialized coding assistant", "\n")) + io.print(str.concat("modes: --plan | --explore | --refactor | --spec | --test | --review | --verify | --bar | --multi", "\n")) + io.print(str.join(["pipelines: --pipeline=", str.join(graph.preset_names(), " | --pipeline="), "\n --pipeline=build,spec,test|review (\",\" in order, \"|\" at once)", "\n"], "")) + 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("Ctrl-D to exit", "\n")) + if inv.multi { match resolve_pipeline(inv.pipeline) { Err(msg) => io.print(str.concat(msg, "\n")), - Ok(pipeline) => run_once_multi(task, pipeline, provider_tag), + Ok(pipeline) => multi_repl(provider_tag, pipeline), } } else { - run_once(task, mode, provider_tag) - }, - None => { - io.print(str.concat("lex-code — Lex-specialized coding assistant", "\n")) - io.print(str.concat("modes: --plan | --explore | --refactor | --spec | --test | --review | --verify | --bar | --multi", "\n")) - io.print(str.join(["pipelines: --pipeline=", str.join(graph.preset_names(), " | --pipeline="), "\n --pipeline=build,spec,test|review (\",\" in order, \"|\" at once)", "\n"], "")) - 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("Ctrl-D to exit", "\n")) - if inv.multi { - match resolve_pipeline(inv.pipeline) { - Err(msg) => io.print(str.concat(msg, "\n")), - Ok(pipeline) => multi_repl(provider_tag, pipeline), - } - } else { - match sess.new_session_with_provider("tui", mode, provider_tag) { - Err(e) => io.print(str.concat(str.concat("startup error: ", e), "\n")), - Ok(session) => repl(session, provider_tag), - } + match sess.new_session_with_provider("tui", mode, provider_tag) { + Err(e) => io.print(str.concat(str.concat("startup error: ", e), "\n")), + Ok(session) => repl(session, provider_tag), } - }, - } + } + }, } }