diff --git a/.claude/agents/explore.md b/.claude/agents/explore.md new file mode 100644 index 00000000..a7754099 --- /dev/null +++ b/.claude/agents/explore.md @@ -0,0 +1,27 @@ +--- +name: explore +description: Explore codebase using sx-tree MCP tools for .sx files +tools: Read, Grep, Glob, Bash, mcp__sx-tree__sx_summarise, mcp__sx-tree__sx_read_tree, mcp__sx-tree__sx_read_subtree, mcp__sx-tree__sx_find_all, mcp__sx-tree__sx_get_context, mcp__sx-tree__sx_get_siblings, mcp__sx-tree__sx_validate +hooks: + PreToolUse: + - matcher: "Read" + hooks: + - type: command + command: "bash .claude/hooks/block-sx-edit.sh" +--- + +Fast codebase exploration agent. Use for finding files, searching code, and answering questions about the codebase. + +## Critical rule for .sx and .sxc files + +NEVER use Read on .sx or .sxc files. The hook will block it. Instead use the sx-tree MCP tools: + +- `mcp__sx-tree__sx_summarise` — structural overview at configurable depth +- `mcp__sx-tree__sx_read_tree` — full annotated tree with path labels +- `mcp__sx-tree__sx_read_subtree` — expand a specific subtree by path +- `mcp__sx-tree__sx_find_all` — search for nodes matching a pattern +- `mcp__sx-tree__sx_get_context` — enclosing chain from root to target +- `mcp__sx-tree__sx_get_siblings` — siblings of a node with target marked +- `mcp__sx-tree__sx_validate` — structural integrity checks + +For all other file types, use Read, Grep, Glob, and Bash as normal. diff --git a/.claude/hooks/block-sx-edit.sh b/.claude/hooks/block-sx-edit.sh new file mode 100755 index 00000000..daab8a1f --- /dev/null +++ b/.claude/hooks/block-sx-edit.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Block Edit/Read/Write on .sx/.sxc files — force use of sx-tree MCP tools +FILE=$(jq -r '.tool_input.file_path // .tool_input.file // empty' 2>/dev/null) +if [ -n "$FILE" ] && echo "$FILE" | grep -qE '\.(sx|sxc)$'; then + printf '{"decision":"block","reason":"Use sx-tree MCP tools instead of Edit/Read/Write on .sx/.sxc files. For new files use sx_write_file, for reading use sx_read_tree/sx_summarise, for editing use sx_replace_node/sx_rename_symbol/etc. See CLAUDE.md for the protocol."}' + exit 2 +fi diff --git a/hosts/ocaml/bin/dune b/hosts/ocaml/bin/dune index b3057669..b94d477f 100644 --- a/hosts/ocaml/bin/dune +++ b/hosts/ocaml/bin/dune @@ -4,4 +4,4 @@ (executable (name mcp_tree) - (libraries sx unix yojson)) + (libraries sx unix yojson str)) diff --git a/hosts/ocaml/bin/mcp_tree.ml b/hosts/ocaml/bin/mcp_tree.ml index 0179b6f1..a03e7955 100644 --- a/hosts/ocaml/bin/mcp_tree.ml +++ b/hosts/ocaml/bin/mcp_tree.ml @@ -220,6 +220,90 @@ let error_result s = ]]); ("isError", `Bool true)] +(* ------------------------------------------------------------------ *) +(* Recursive .sx file discovery *) +(* ------------------------------------------------------------------ *) + +let glob_sx_files dir = + let results = ref [] in + let rec walk path = + if Sys.is_directory path then + let entries = Sys.readdir path in + Array.iter (fun e -> walk (Filename.concat path e)) entries + else if Filename.check_suffix path ".sx" then + results := path :: !results + in + (try walk dir with Sys_error _ -> ()); + List.sort String.compare !results + +let relative_path ~base path = + let blen = String.length base in + if String.length path > blen && String.sub path 0 blen = base then + let rest = String.sub path (blen + 1) (String.length path - blen - 1) in + rest + else path + +(* ------------------------------------------------------------------ *) +(* Pretty printer *) +(* ------------------------------------------------------------------ *) + +let pp_atom = Sx_types.inspect + +(* Estimate single-line width of a value *) +let rec est_width = function + | Nil -> 3 | Bool true -> 4 | Bool false -> 5 + | Number n -> String.length (if Float.is_integer n then string_of_int (int_of_float n) else Printf.sprintf "%g" n) + | String s -> String.length s + 2 + | Symbol s -> String.length s + | Keyword k -> String.length k + 1 + | SxExpr s -> String.length s + 2 + | List items | ListRef { contents = items } -> + 2 + List.fold_left (fun acc x -> acc + est_width x + 1) 0 items + | _ -> 10 + +let pretty_print_value ?(max_width=80) v = + let buf = Buffer.create 4096 in + let rec pp indent v = + match v with + | List items | ListRef { contents = items } when items <> [] -> + if est_width v <= max_width - indent then + (* Fits on one line *) + Buffer.add_string buf (pp_atom v) + else begin + (* Multi-line *) + Buffer.add_char buf '('; + let head = List.hd items in + Buffer.add_string buf (pp_atom head); + let child_indent = indent + 2 in + let rest = List.tl items in + (* Special case: keyword args stay on same line as their value *) + let rec emit = function + | [] -> () + | Keyword k :: v :: rest -> + Buffer.add_char buf '\n'; + Buffer.add_string buf (String.make child_indent ' '); + Buffer.add_char buf ':'; + Buffer.add_string buf k; + Buffer.add_char buf ' '; + pp child_indent v; + emit rest + | item :: rest -> + Buffer.add_char buf '\n'; + Buffer.add_string buf (String.make child_indent ' '); + pp child_indent item; + emit rest + in + emit rest; + Buffer.add_char buf ')' + end + | _ -> Buffer.add_string buf (pp_atom v) + in + pp 0 v; + Buffer.contents buf + +let pretty_print_file exprs = + String.concat "\n\n" (List.map pretty_print_value exprs) ^ "\n" + (* ------------------------------------------------------------------ *) (* Tool handlers *) (* ------------------------------------------------------------------ *) @@ -228,8 +312,35 @@ let rec handle_tool name args = let open Yojson.Safe.Util in match name with | "sx_read_tree" -> - let tree = parse_file (args |> member "file" |> to_string) in - text_result (value_to_string (call_sx "annotate-tree" [tree])) + let file = args |> member "file" |> to_string in + let tree = parse_file file in + let focus = args |> member "focus" |> to_string_option in + let max_depth = args |> member "max_depth" |> to_int_option in + let max_lines = args |> member "max_lines" |> to_int_option in + let offset = args |> member "offset" |> to_int_option |> Option.value ~default:0 in + (match focus with + | Some pattern -> + (* Focus mode: expand matching subtrees, collapse rest *) + text_result (value_to_string (call_sx "annotate-focused" [tree; String pattern])) + | None -> + match max_lines with + | Some limit -> + (* Paginated mode *) + text_result (value_to_string (call_sx "annotate-paginated" + [tree; Number (float_of_int offset); Number (float_of_int limit)])) + | None -> + match max_depth with + | Some depth -> + (* Depth-limited mode *) + text_result (value_to_string (call_sx "summarise" [tree; Number (float_of_int depth)])) + | None -> + (* Auto mode: full tree if small, summarise if large *) + let full = value_to_string (call_sx "annotate-tree" [tree]) in + let line_count = 1 + String.fold_left (fun n c -> if c = '\n' then n + 1 else n) 0 full in + if line_count <= 200 then text_result full + else + let summary = value_to_string (call_sx "summarise" [tree; Number 2.0]) in + text_result (Printf.sprintf ";; File has %d lines — showing depth-2 summary. Use max_depth, max_lines, or focus to control output.\n%s" line_count summary)) | "sx_summarise" -> let tree = parse_file (args |> member "file" |> to_string) in @@ -299,6 +410,265 @@ let rec handle_tool name args = let wrapper = args |> member "wrapper" |> to_string in write_edit file (call_sx "wrap-node" [tree; path; String wrapper]) + | "sx_format_check" -> + let file = args |> member "file" |> to_string in + let tree = parse_file file in + let warnings = call_sx "lint-file" [tree] in + (match warnings with + | List [] | ListRef { contents = [] } -> text_result "OK — no issues found" + | List items | ListRef { contents = items } -> + text_result (String.concat "\n" (List.map value_to_string items)) + | _ -> text_result (value_to_string warnings)) + + | "sx_macroexpand" -> + let file = args |> member "file" |> to_string_option in + let expr_str = args |> member "expr" |> to_string in + (* Create a fresh env with file definitions loaded *) + let e = !env in + (* Optionally load a file's definitions to get its macros *) + (match file with + | Some f -> + (try load_sx_file e f + with exn -> Printf.eprintf "[mcp] Warning: failed to load %s: %s\n%!" f (Printexc.to_string exn)) + | None -> ()); + let exprs = Sx_parser.parse_all expr_str in + let result = List.fold_left (fun _acc expr -> + Sx_ref.eval_expr expr (Env e) + ) Nil exprs in + text_result (Sx_types.inspect result) + + | "sx_build" -> + let target = args |> member "target" |> to_string_option |> Option.value ~default:"js" in + let full = args |> member "full" |> to_bool_option |> Option.value ~default:false in + let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found -> + let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in + Filename.dirname spec_dir + in + let cmd = match target with + | "ocaml" -> + Printf.sprintf "cd %s/hosts/ocaml && eval $(opam env 2>/dev/null) && dune build 2>&1" project_dir + | "js" | _ -> + let extra = if full then " --extensions continuations --spec-modules types" else "" in + Printf.sprintf "cd %s && python3 hosts/javascript/cli.py%s --output shared/static/scripts/sx-browser.js 2>&1" project_dir extra + in + let ic = Unix.open_process_in cmd in + let lines = ref [] in + (try while true do lines := input_line ic :: !lines done with End_of_file -> ()); + let status = Unix.close_process_in ic in + let output = String.concat "\n" (List.rev !lines) in + (match status with + | Unix.WEXITED 0 -> text_result (Printf.sprintf "OK — %s build succeeded\n%s" target (String.trim output)) + | _ -> error_result (Printf.sprintf "%s build failed:\n%s" target output)) + + | "sx_test" -> + let host = args |> member "host" |> to_string_option |> Option.value ~default:"js" in + let full = args |> member "full" |> to_bool_option |> Option.value ~default:false in + let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found -> + (* Walk up from spec dir to find project root *) + let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in + Filename.dirname spec_dir + in + let cmd = match host with + | "ocaml" -> + Printf.sprintf "cd %s/hosts/ocaml && eval $(opam env 2>/dev/null) && dune exec bin/run_tests.exe%s 2>&1" + project_dir (if full then " -- --full" else "") + | "js" | _ -> + Printf.sprintf "cd %s && node hosts/javascript/run_tests.js%s 2>&1" + project_dir (if full then " --full" else "") + in + let ic = Unix.open_process_in cmd in + let lines = ref [] in + (try while true do lines := input_line ic :: !lines done with End_of_file -> ()); + ignore (Unix.close_process_in ic); + let all_lines = List.rev !lines in + (* Extract summary and failures *) + let fails = List.filter (fun l -> let t = String.trim l in + String.length t > 5 && String.sub t 0 4 = "FAIL") all_lines in + let summary = List.find_opt (fun l -> try let _ = Str.search_forward (Str.regexp "Results:") l 0 in true with Not_found -> false) all_lines in + let result = match summary with + | Some s -> + if fails = [] then s + else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails + | None -> + let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 5) all_lines in + String.concat "\n" last_n + in + text_result result + + | "sx_pretty_print" -> + let file = args |> member "file" |> to_string in + let exprs = Sx_parser.parse_all (In_channel.with_open_text file In_channel.input_all) in + let source = pretty_print_file exprs in + Out_channel.with_open_text file (fun oc -> output_string oc source); + text_result (Printf.sprintf "OK — reformatted %s (%d bytes, %d forms)" file (String.length source) (List.length exprs)) + + | "sx_write_file" -> + let file = args |> member "file" |> to_string in + let source = args |> member "source" |> to_string in + (* Validate by parsing first *) + (try + let exprs = Sx_parser.parse_all source in + if exprs = [] then error_result "Source parsed to empty — nothing to write" + else begin + let output = pretty_print_file exprs in + Out_channel.with_open_text file (fun oc -> output_string oc output); + text_result (Printf.sprintf "OK — wrote %d bytes (%d top-level forms) to %s" (String.length output) (List.length exprs) file) + end + with e -> error_result (Printf.sprintf "Parse error — file not written: %s" (Printexc.to_string e))) + + | "sx_rename_symbol" -> + let file = args |> member "file" |> to_string in + let tree = parse_file file in + let old_name = args |> member "old_name" |> to_string in + let new_name = args |> member "new_name" |> to_string in + let new_tree = call_sx "rename-symbol" [tree; String old_name; String new_name] in + let count = call_sx "count-renames" [tree; String old_name] in + let count_str = value_to_string count in + write_edit file (Dict (let d = Hashtbl.create 2 in Hashtbl.replace d "ok" new_tree; d)) + |> (fun result -> + match result with + | `Assoc [("content", `List [`Assoc [("type", _); ("text", `String s)]])] when not (String.starts_with ~prefix:"Error" s) -> + text_result (Printf.sprintf "Renamed %s occurrences of '%s' → '%s' in %s" count_str old_name new_name file) + | other -> other) + + | "sx_replace_by_pattern" -> + let file = args |> member "file" |> to_string in + let tree = parse_file file in + let pattern = args |> member "pattern" |> to_string in + let src = args |> member "new_source" |> to_string in + let all = args |> member "all" |> to_bool_option |> Option.value ~default:false in + if all then + write_edit file (call_sx "replace-all-by-pattern" [tree; String pattern; String src]) + else + write_edit file (call_sx "replace-by-pattern" [tree; String pattern; String src]) + + | "sx_insert_near" -> + let file = args |> member "file" |> to_string in + let tree = parse_file file in + let pattern = args |> member "pattern" |> to_string in + let position = args |> member "position" |> to_string_option |> Option.value ~default:"after" in + let src = args |> member "new_source" |> to_string in + write_edit file (call_sx "insert-near-pattern" [tree; String pattern; String position; String src]) + + | "sx_rename_across" -> + let dir = args |> member "dir" |> to_string in + let old_name = args |> member "old_name" |> to_string in + let new_name = args |> member "new_name" |> to_string in + let dry_run = args |> member "dry_run" |> to_bool_option |> Option.value ~default:false in + let files = glob_sx_files dir in + let results = List.filter_map (fun path -> + let rel = relative_path ~base:dir path in + try + let tree = parse_file path in + let count = call_sx "count-renames" [tree; String old_name] in + match count with + | Number n when n > 0.0 -> + if dry_run then + Some (Printf.sprintf "%s: %d occurrences (dry run)" rel (int_of_float n)) + else begin + let new_tree = call_sx "rename-symbol" [tree; String old_name; String new_name] in + let items = match new_tree with + | List items | ListRef { contents = items } -> items + | _ -> [new_tree] + in + let source = pretty_print_file items in + Out_channel.with_open_text path (fun oc -> output_string oc source); + Some (Printf.sprintf "%s: %d occurrences renamed" rel (int_of_float n)) + end + | _ -> None + with _ -> None + ) files in + if results = [] then text_result (Printf.sprintf "No occurrences of '%s' found" old_name) + else text_result (String.concat "\n" results) + + | "sx_comp_list" -> + let dir = args |> member "dir" |> to_string in + let files = glob_sx_files dir in + let all_lines = List.concat_map (fun path -> + let rel = relative_path ~base:dir path in + try + let exprs = Sx_parser.parse_all (In_channel.with_open_text path In_channel.input_all) in + List.filter_map (fun expr -> + match expr with + | List (Symbol head :: Symbol name :: rest) | ListRef { contents = Symbol head :: Symbol name :: rest } -> + (match head with + | "defcomp" | "defisland" | "defmacro" | "defpage" | "define" -> + let params = match rest with + | List ps :: _ | ListRef { contents = ps } :: _ -> + String.concat " " (List.map Sx_runtime.value_to_str ps) + | _ -> "" + in + Some (Printf.sprintf "%-10s %-40s %-50s %s" head name rel params) + | _ -> None) + | _ -> None + ) exprs + with _ -> [] + ) files in + if all_lines = [] then text_result "(no definitions found)" + else text_result (Printf.sprintf "%-10s %-40s %-50s %s\n%s" "TYPE" "NAME" "FILE" "PARAMS" (String.concat "\n" all_lines)) + + | "sx_find_across" -> + let dir = args |> member "dir" |> to_string in + let pattern = args |> member "pattern" |> to_string in + let files = glob_sx_files dir in + let all_lines = List.concat_map (fun path -> + let rel = relative_path ~base:dir path in + try + let tree = parse_file path in + let results = call_sx "find-all" [tree; String pattern] in + (match results with + | List items | ListRef { contents = items } -> + List.map (fun item -> + match item with + | List [p; s] | ListRef { contents = [p; s] } -> + rel ^ " " ^ value_to_string (call_sx "path-str" [p]) ^ " " ^ value_to_string s + | _ -> rel ^ " " ^ value_to_string item + ) items + | _ -> []) + with _ -> [] + ) files in + if all_lines = [] then text_result "(no matches)" + else text_result (String.concat "\n" all_lines) + + | "sx_diff" -> + let file_a = args |> member "file_a" |> to_string in + let file_b = args |> member "file_b" |> to_string in + let tree_a = parse_file file_a in + let tree_b = parse_file file_b in + text_result (value_to_string (call_sx "tree-diff" [tree_a; tree_b])) + + | "sx_comp_usage" -> + let dir = args |> member "dir" |> to_string in + let name = args |> member "name" |> to_string in + let files = glob_sx_files dir in + let all_lines = List.concat_map (fun path -> + let rel = relative_path ~base:dir path in + try + let tree = parse_file path in + let results = call_sx "find-all" [tree; String name] in + (match results with + | List items | ListRef { contents = items } -> + List.map (fun item -> + match item with + | List [p; s] | ListRef { contents = [p; s] } -> + rel ^ " " ^ value_to_string (call_sx "path-str" [p]) ^ " " ^ value_to_string s + | _ -> rel ^ " " ^ value_to_string item + ) items + | _ -> []) + with _ -> [] + ) files in + if all_lines = [] then text_result "(no usages found)" + else text_result (String.concat "\n" all_lines) + + | "sx_eval" -> + let expr_str = args |> member "expr" |> to_string in + let exprs = Sx_parser.parse_all expr_str in + let e = !env in + let result = List.fold_left (fun _acc expr -> + Sx_ref.eval_expr expr (Env e) + ) Nil exprs in + text_result (Sx_runtime.value_to_str result) + | _ -> error_result ("Unknown tool: " ^ name) and write_edit file result = @@ -306,12 +676,11 @@ and write_edit file result = | Dict d -> (match Hashtbl.find_opt d "ok" with | Some new_tree -> - let parts = match new_tree with - | List items | ListRef { contents = items } -> - List.map (fun expr -> Sx_runtime.value_to_str expr) items - | _ -> [Sx_runtime.value_to_str new_tree] + let items = match new_tree with + | List items | ListRef { contents = items } -> items + | _ -> [new_tree] in - let source = String.concat "\n\n" parts ^ "\n" in + let source = pretty_print_file items in Out_channel.with_open_text file (fun oc -> output_string oc source); text_result (Printf.sprintf "OK — wrote %d bytes to %s" (String.length source) file) | None -> @@ -336,10 +705,16 @@ let tool name desc props required = let file_prop = ("file", `Assoc [("type", `String "string"); ("description", `String "Path to .sx file")]) let path_prop = ("path", `Assoc [("type", `String "string"); ("description", `String "SX path, e.g. \"(0 2 1)\"")]) +let dir_prop = ("dir", `Assoc [("type", `String "string"); ("description", `String "Directory to scan recursively")]) let tool_definitions = `List [ - tool "sx_read_tree" "Read an .sx file as an annotated tree with path labels on every node. Use this to understand structure before editing." - [file_prop] ["file"]; + tool "sx_read_tree" "Read an .sx file as an annotated tree with path labels. Auto-summarises large files (>200 lines). Use focus to expand only matching subtrees, max_depth for depth limit, or max_lines+offset for pagination." + [file_prop; + ("focus", `Assoc [("type", `String "string"); ("description", `String "Pattern — expand matching subtrees, collapse rest")]); + ("max_depth", `Assoc [("type", `String "integer"); ("description", `String "Depth limit (like summarise)")]); + ("max_lines", `Assoc [("type", `String "integer"); ("description", `String "Max lines to return (pagination)")]); + ("offset", `Assoc [("type", `String "integer"); ("description", `String "Line offset for pagination (default 0)")])] + ["file"]; tool "sx_summarise" "Folded structural overview of an .sx file. Use to orient before drilling into a region." [file_prop; ("depth", `Assoc [("type", `String "integer"); ("description", `String "Max depth (0=heads only)")])] ["file"; "depth"]; tool "sx_read_subtree" "Expand a specific subtree by path. Use after summarise to drill in." @@ -360,6 +735,60 @@ let tool_definitions = `List [ [file_prop; path_prop] ["file"; "path"]; tool "sx_wrap_node" "Wrap node in a new form. Use _ as placeholder, e.g. \"(when cond _)\"." [file_prop; path_prop; ("wrapper", `Assoc [("type", `String "string"); ("description", `String "Wrapper with _ placeholder")])] ["file"; "path"; "wrapper"]; + tool "sx_eval" "Evaluate an SX expression. Environment has parser + tree-tools + primitives." + [("expr", `Assoc [("type", `String "string"); ("description", `String "SX expression to evaluate")])] ["expr"]; + tool "sx_find_across" "Search for a pattern across all .sx files under a directory. Returns file paths, tree paths, and summaries." + [dir_prop; ("pattern", `Assoc [("type", `String "string"); ("description", `String "Search pattern")])] ["dir"; "pattern"]; + tool "sx_comp_list" "List all definitions (defcomp, defisland, defmacro, defpage, define) across .sx files in a directory." + [dir_prop] ["dir"]; + tool "sx_comp_usage" "Find all uses of a component or symbol name across .sx files in a directory." + [dir_prop; ("name", `Assoc [("type", `String "string"); ("description", `String "Component or symbol name to search for")])] ["dir"; "name"]; + tool "sx_diff" "Structural diff between two .sx files. Reports ADDED, REMOVED, CHANGED nodes with paths." + [("file_a", `Assoc [("type", `String "string"); ("description", `String "Path to first .sx file")]); + ("file_b", `Assoc [("type", `String "string"); ("description", `String "Path to second .sx file")])] ["file_a"; "file_b"]; + tool "sx_format_check" "Lint an .sx file for common issues: empty let bindings, missing bodies, duplicate params, structural problems." + [file_prop] ["file"]; + tool "sx_macroexpand" "Evaluate an SX expression with a file's definitions loaded. Use to test macros — the file's defmacro forms are available." + [("file", `Assoc [("type", `String "string"); ("description", `String "Optional .sx file to load for macro/component definitions")]); + ("expr", `Assoc [("type", `String "string"); ("description", `String "Expression to expand/evaluate")])] + ["expr"]; + tool "sx_build" "Build the SX runtime. Target \"js\" (default) builds sx-browser.js, \"ocaml\" runs dune build. Set full=true for extensions+types." + [("target", `Assoc [("type", `String "string"); ("description", `String "Build target: \"js\" (default) or \"ocaml\"")]); + ("full", `Assoc [("type", `String "boolean"); ("description", `String "Include extensions and type system (default: false)")])] + []; + tool "sx_test" "Run SX test suite. Returns pass/fail summary and any failures." + [("host", `Assoc [("type", `String "string"); ("description", `String "Test host: \"js\" (default) or \"ocaml\"")]); + ("full", `Assoc [("type", `String "boolean"); ("description", `String "Run full test suite including extensions (default: false)")])] + []; + tool "sx_pretty_print" "Reformat an .sx file with indentation. Short forms stay on one line, longer forms break across lines." + [file_prop] ["file"]; + tool "sx_write_file" "Create or overwrite an .sx file. Source is parsed first — malformed SX is rejected and the file is not touched." + [file_prop; + ("source", `Assoc [("type", `String "string"); ("description", `String "SX source to write")])] + ["file"; "source"]; + tool "sx_rename_symbol" "Rename all occurrences of a symbol in an .sx file. Structural — only renames symbols, not strings." + [file_prop; + ("old_name", `Assoc [("type", `String "string"); ("description", `String "Current symbol name")]); + ("new_name", `Assoc [("type", `String "string"); ("description", `String "New symbol name")])] + ["file"; "old_name"; "new_name"]; + tool "sx_replace_by_pattern" "Find nodes matching a pattern and replace with new source. Set all=true to replace all matches (default: first only)." + [file_prop; + ("pattern", `Assoc [("type", `String "string"); ("description", `String "Search pattern to match")]); + ("new_source", `Assoc [("type", `String "string"); ("description", `String "Replacement SX source")]); + ("all", `Assoc [("type", `String "boolean"); ("description", `String "Replace all matches (default: first only)")])] + ["file"; "pattern"; "new_source"]; + tool "sx_insert_near" "Insert new source before or after the first node matching a pattern. No path needed." + [file_prop; + ("pattern", `Assoc [("type", `String "string"); ("description", `String "Pattern to find insertion point")]); + ("new_source", `Assoc [("type", `String "string"); ("description", `String "SX source to insert")]); + ("position", `Assoc [("type", `String "string"); ("description", `String "\"before\" or \"after\" (default: after)")])] + ["file"; "pattern"; "new_source"]; + tool "sx_rename_across" "Rename a symbol across all .sx files in a directory. Use dry_run=true to preview without writing." + [dir_prop; + ("old_name", `Assoc [("type", `String "string"); ("description", `String "Current symbol name")]); + ("new_name", `Assoc [("type", `String "string"); ("description", `String "New symbol name")]); + ("dry_run", `Assoc [("type", `String "boolean"); ("description", `String "Preview changes without writing (default: false)")])] + ["dir"; "old_name"; "new_name"]; ] (* ------------------------------------------------------------------ *) diff --git a/lib/tree-tools.sx b/lib/tree-tools.sx index a5f07e4a..0aab9ed7 100644 --- a/lib/tree-tools.sx +++ b/lib/tree-tools.sx @@ -57,3 +57,89 @@ (define list-insert :effects () (fn (lst idx val) (concat (slice lst 0 idx) (list val) (slice lst idx)))) (define list-remove :effects () (fn (lst idx) (concat (slice lst 0 idx) (slice lst (+ idx 1))))) + +(define tree-diff :effects () (fn (exprs-a exprs-b) (let ((nodes-a (if (list? exprs-a) exprs-a (list exprs-a))) (nodes-b (if (list? exprs-b) exprs-b (list exprs-b))) (results (list))) (diff-children nodes-a nodes-b (list) results) (if (empty? results) "No differences" (join "\n" results))))) + +(define diff-children :effects () (fn (list-a list-b path results) (let ((len-a (len list-a)) (len-b (len list-b)) (min-len (if (< len-a len-b) len-a len-b))) (for-each (fn (i) (diff-node (nth list-a i) (nth list-b i) (concat path (list i)) results)) (range 0 min-len)) (when (> len-b min-len) (for-each (fn (i) (append! results (str "ADDED " (path-str (concat path (list i))) " " (node-summary-short (nth list-b i))))) (range min-len len-b))) (when (> len-a min-len) (for-each (fn (i) (append! results (str "REMOVED " (path-str (concat path (list i))) " " (node-summary-short (nth list-a i))))) (range min-len len-a)))))) + +(define diff-node :effects () (fn (a b path results) (cond (and (list? a) (list? b)) (diff-children a b path results) (and (not (list? a)) (not (list? b))) (when (not (= (node-display a) (node-display b))) (append! results (str "CHANGED " (path-str path) " " (node-display a) " → " (node-display b)))) :else (append! results (str "CHANGED " (path-str path) " " (node-summary-short a) " → " (node-summary-short b)))))) + +(define path-prefix? :effects () (fn (prefix path) (if (> (len prefix) (len path)) false (let ((result true)) (for-each (fn (i) (when (not (= (nth prefix i) (nth path i))) (set! result false))) (range 0 (len prefix))) result)))) + +(define path-on-match-route? :effects () (fn (path match-paths) (let ((found false)) (for-each (fn (i) (when (not found) (let ((mp (first (nth match-paths i)))) (when (or (path-prefix? path mp) (path-prefix? mp path)) (set! found true))))) (range 0 (len match-paths))) found))) + +(define annotate-focused :effects () (fn (exprs pattern) (let ((nodes (if (list? exprs) exprs (list exprs))) (match-paths (find-all nodes pattern)) (result (list))) (for-each (fn (i) (annotate-node-focused (nth nodes i) (list i) 0 match-paths result)) (range 0 (len nodes))) (join "\n" result)))) + +(define annotate-node-focused :effects () (fn (node path depth match-paths result) (let ((indent (join "" (map (fn (_) " ") (range 0 depth)))) (label (path-str path))) (if (list? node) (if (empty? node) (append! result (str indent label " ()")) (let ((head (first node)) (head-str (node-display head)) (on-route (path-on-match-route? path match-paths))) (if on-route (do (append! result (str indent label " (" head-str)) (for-each (fn (i) (annotate-node-focused (nth node i) (concat path (list i)) (+ depth 1) match-paths result)) (range 1 (len node))) (append! result (str indent " )"))) (append! result (str indent label " (" head-str (if (> (len node) 1) (str " ... " (- (len node) 1) " children") "") ")"))))) (append! result (str indent label " " (node-display node))))))) + +(define annotate-paginated :effects () (fn (exprs offset limit) (let ((nodes (if (list? exprs) exprs (list exprs))) (all-lines (list))) (for-each (fn (i) (annotate-node (nth nodes i) (list i) 0 all-lines)) (range 0 (len nodes))) (let ((total (len all-lines)) (end (if (> (+ offset limit) total) total (+ offset limit))) (sliced (slice all-lines offset end)) (header (str ";; Lines " offset "-" end " of " total (if (< end total) " (more available)" " (complete)")))) (str header "\n" (join "\n" sliced)))))) + +(define rename-symbol :effects () (fn (exprs old-name new-name) (let ((nodes (if (list? exprs) exprs (list exprs)))) (map (fn (node) (rename-in-node node old-name new-name)) nodes)))) + +(define rename-in-node :effects () (fn (node old-name new-name) (cond (and (= (type-of node) "symbol") (= (symbol-name node) old-name)) (make-symbol new-name) (list? node) (map (fn (child) (rename-in-node child old-name new-name)) node) :else node))) + +(define count-renames :effects () (fn (exprs old-name) (let ((nodes (if (list? exprs) exprs (list exprs))) (hits (list))) (count-in-node nodes old-name hits) (len hits)))) + +(define count-in-node :effects () (fn (node old-name hits) (cond (and (= (type-of node) "symbol") (= (symbol-name node) old-name)) (append! hits true) (list? node) (for-each (fn (child) (count-in-node child old-name hits)) node) :else nil))) + +(define replace-by-pattern :effects () (fn (exprs pattern new-source) (let ((nodes (if (list? exprs) exprs (list exprs))) (matches (find-all nodes pattern))) (if (empty? matches) {:error (str "No nodes matching pattern: " pattern)} (let ((target-path (first (first matches))) (fragment (sx-parse new-source))) (if (empty? fragment) {:error (str "Failed to parse new source: " new-source)} (let ((new-node (first fragment)) (result (tree-set nodes target-path new-node))) (if (nil? result) {:error (str "Failed to set node at path " (path-str target-path))} {:ok result :path target-path})))))))) + +(define replace-all-by-pattern :effects () (fn (exprs pattern new-source) (let ((nodes (if (list? exprs) exprs (list exprs))) (matches (find-all nodes pattern)) (fragment (sx-parse new-source))) (if (empty? matches) {:error (str "No nodes matching pattern: " pattern)} (if (empty? fragment) {:error (str "Failed to parse new source: " new-source)} (let ((new-node (first fragment)) (current nodes) (count 0)) (for-each (fn (i) (let ((idx (- (- (len matches) 1) i)) (match (nth matches idx)) (target-path (first match)) (result (tree-set current target-path new-node))) (when (not (nil? result)) (set! current result) (set! count (+ count 1))))) (range 0 (len matches))) {:count count :ok current})))))) + +(define insert-near-pattern :effects () (fn (exprs pattern position new-source) (let ((nodes (if (list? exprs) exprs (list exprs))) (matches (find-all nodes pattern))) (if (empty? matches) {:error (str "No nodes matching pattern: " pattern)} (let ((match-path (first (first matches))) (fragment (sx-parse new-source))) (if (empty? fragment) {:error (str "Failed to parse new source: " new-source)} (if (empty? match-path) {:error "Cannot insert near root node"} (let ((top-idx (first match-path)) (insert-idx (if (= position "after") (+ top-idx 1) top-idx)) (new-node (first fragment)) (new-tree (list-insert nodes insert-idx new-node))) {:ok new-tree :path (list insert-idx)})))))))) + +;; --- Format / lint checks --- + +(define lint-file :effects () + (fn (exprs) + (let ((nodes (if (list? exprs) exprs (list exprs))) + (warnings (list))) + (for-each (fn (i) (lint-node (nth nodes i) (list i) warnings)) + (range 0 (len nodes))) + warnings))) + +(define lint-node :effects () + (fn (node path warnings) + (when (list? node) + (when (not (empty? node)) + (let ((head (first node)) + (head-name (if (= (type-of head) "symbol") (symbol-name head) ""))) + ;; Empty let/letrec bindings + (when (or (= head-name "let") (= head-name "letrec")) + (when (>= (len node) 2) + (let ((bindings (nth node 1))) + (when (and (list? bindings) (empty? bindings)) + (append! warnings + (str "WARN " (path-str path) ": " head-name " with empty bindings")))))) + ;; defcomp/defisland with too few args + (when (or (= head-name "defcomp") (= head-name "defisland")) + (when (< (len node) 4) + (append! warnings + (str "ERROR " (path-str path) ": " head-name " needs (name params body), has " + (- (len node) 1) " args")))) + ;; define with no body + (when (= head-name "define") + (let ((effective-len (len (filter (fn (x) (not (= (type-of x) "keyword"))) (rest node))))) + (when (< effective-len 2) + (append! warnings + (str "WARN " (path-str path) ": define with no body"))))) + ;; Duplicate keys in keyword args + (when (or (= head-name "defcomp") (= head-name "defisland")) + (when (>= (len node) 3) + (let ((params (nth node 2))) + (when (list? params) + (let ((seen (list))) + (for-each (fn (p) + (when (= (type-of p) "symbol") + (let ((pname (symbol-name p))) + (when (and (not (= pname "&key")) + (not (= pname "&rest")) + (not (starts-with? pname "&"))) + (when (contains? seen pname) + (append! warnings + (str "ERROR " (path-str path) ": duplicate param " pname))) + (append! seen pname))))) + params)))))) + ;; Recurse into children + (for-each (fn (i) (lint-node (nth node i) (concat path (list i)) warnings)) + (range 0 (len node)))))))) diff --git a/sx/sx/sx-tools.sx b/sx/sx/sx-tools.sx index e5469bd2..72373f95 100644 --- a/sx/sx/sx-tools.sx +++ b/sx/sx/sx-tools.sx @@ -1 +1 @@ -(defcomp ~sx-tools/overview-content (&key (title "SX Tools") &rest extra) (~docs/page :title title (p :class "text-stone-500 text-sm italic mb-8" "A structural tree editor for s-expression files — because the thing that reads and edits code should understand the code as a tree, not as a sequence of characters.") (~docs/section :title "The problem" :id "problem" (p "On 25 March 2026, the SX documentation site went blank. The home page stepper widget — a 310-line " (code "defisland") " — failed to render. The cause was a single extra closing parenthesis on line 222 of " (code "home-stepper.sx") ".") (p "That parenthesis closed the " (code "letrec") " bindings list one level too early. Two function definitions — " (code "rebuild-preview") " and " (code "do-back") " — silently became body expressions instead of bindings. They were evaluated and discarded rather than bound in scope. Every subsequent reference to " (code "rebuild-preview") " raised " (code "Undefined symbol") ". The island rendered nothing. The page went white.") (p "Finding this took an hour of systematic debugging: adding trace output to the OCaml " (code "env_has") " function, dumping the scope chain at the point of failure, counting keys in the letrec environment (" (em "12 where there should have been 14") "), and finally writing a paren-depth tracer that walked the file character by character to find where the nesting diverged from expectation.") (p "The fix was removing one character.") (p "This is a class of bug, not an incident. S-expressions encode tree structure in linear text using matched delimiters. When those delimiters are wrong, the meaning of every subsequent expression changes. The error is silent — the parser succeeds, the evaluator runs, the wrong thing happens. The gap between the intended tree and the actual tree is invisible in the source.")) (~docs/section :title "Why raw text fails" :id "why-text-fails" (p "Claude Code reads " (code ".sx") " files as raw text and mentally reconstructs the tree structure by tracking bracket nesting. It does this imperfectly — especially in deep or wide trees where closing parentheses pile up and their correspondence to openers is lost. Consider the end of a complex island:") (~docs/code :src "(set-cookie \"sx-home-stepper\" (freeze-to-sx \"home-stepper\"))))))))") (p "Eight closing parentheses. Which one closes " (code "set-cookie") "? Which closes the " (code "fn") "? Which closes the binding pair? Which closes the letrec bindings list? Answering this requires counting backward through hundreds of lines. Counting is not what language models do well.") (p "The same problem compounds when writing. Claude generates plausible-looking s-expression fragments that are structurally wrong — a paren added, a paren dropped, a level of nesting off. The " (code "str_replace") " tool makes this worse: replacing a string inside a deeply nested form can silently unbalance the surrounding structure in ways that are not visible until the file fails to parse — or worse, parses into a different tree.")) (~docs/section :title "The fix: structural tools" :id "structural-tools" (p "If Claude sees trees when the underlying thing is a tree, both the reading and writing problems disappear. Instead of raw text, Claude gets an annotated tree view with explicit paths:") (~docs/code :src (str "[0] (defisland ~home/stepper\n" " [0,0] ~home/stepper\n" " [0,1] ()\n" " [0,2] (let\n" " [0,2,0] let\n" " [0,2,1] ((source ...) ... (code-tokens ...))\n" " [0,2,2] (letrec\n" " [0,2,2,0] letrec\n" " [0,2,2,1] ((split-tag ...) ... (do-back ...))\n" " [0,2,2,2] (freeze-scope ...)\n" " [0,2,2,3] (let ((saved ...)) ...)\n" " [0,2,2,4] (let ((parsed ...)) ...)\n" " [0,2,2,5] (let ((_eff ...)) (div ...)))))")) (p "The structural correspondence that is invisible in raw text is explicit here. Every node has a path. If " (code "rebuild-preview") " appears at " (code "[0,2,2,2]") " instead of " (code "[0,2,2,1,12]") ", it is immediately obvious that it is a body expression, not a letrec binding. The bug that took an hour to find would be visible on inspection.") (p "For editing, Claude specifies tree operations rather than text replacements:") (~docs/code :src (str ";; Replace a node by path — the fragment is parsed before\n" ";; the file is touched. Bracket errors are impossible.\n" "(replace-node \"home-stepper.sx\" [0,2,2,1,12]\n" " \"(rebuild-preview (fn (target) ...))\")\n" "\n" ";; Insert a new child at a specific position\n" "(insert-child \"home-stepper.sx\" [0,2,2,1] 12\n" " \"(new-function (fn () nil))\")\n" "\n" ";; Delete a node — siblings adjust automatically\n" "(delete-node \"home-stepper.sx\" [0,2,2,3])")) (p "Every write operation parses the new fragment as a complete s-expression " (em "before") " navigating to the target path. If the fragment is malformed, the operation returns an error with the line and column of the parse failure. The source file is never left in a partially-edited state. Bracket mismatches become impossible by construction.")) (~docs/section :title "Architecture" :id "architecture" (p "SX Tools is an SX application. The comprehension and editing logic is written in SX, runs on the OCaml evaluator, and is exposed through two interfaces: an MCP server for Claude Code, and an interactive web application for the developer.") (~docs/code :src (str " ┌─────────────────┐\n" " Claude Code ──▶ │ MCP Server │\n" " │ (OCaml stdio) │\n" " └────────┬─────────┘\n" " │\n" " ┌────────▼─────────┐\n" " │ SX Tree Logic │\n" " │ (comprehend.sx) │\n" " │ (edit.sx) │\n" " └────────┬─────────┘\n" " │\n" " ┌───────────────┼───────────────┐\n" " ▼ ▼ ▼\n" " .sx files Web tree editor Validation\n" " (defisland) reports")) (p "The " (strong "parser") " is " (code "sx-parse") " — the same parser that evaluates SX source. No new parser needed. Round-trip fidelity is inherited from the existing serializer.") (p "The " (strong "tree logic") " lives in " (code "web/lib/tree-tools.sx") ". Pure functions: take a parsed tree, return annotated output or a modified tree. No IO, no side effects.") (p "The " (strong "MCP server") " is a thin OCaml binary (" (code "hosts/ocaml/bin/mcp_tree.ml") ") that reads files, calls the SX tree functions via the OCaml bridge, and writes results. Stdio transport, JSON-RPC, single-threaded.") (p "The " (strong "web editor") " is a " (code "defisland") " at " (code "/sx/(applications.(sx-tools))") " — the page you are reading. Interactive tree visualization with click-to-navigate, path display, and structural editing. Islands and signals make it reactive. It is both a tool and a demonstration of the SX platform.")) (~docs/section :title "Comprehension tools" :id "comprehension" (p "These are the tools Claude uses to " (em "understand") " structure before touching anything. They are read-only and have no side effects. They are not a convenience layer — they are as important as the editing tools.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Annotated tree view") (p "The primary comprehension tool. Every node gets its path label inline, making tree structure explicit. The structural correspondence that is invisible in raw text is readable by inspection, with no need to count brackets:") (~docs/code :src (str "[0] (defcomp ~card\n" " [0,1] (&key title subtitle &rest children)\n" " [0,2] (div :class \"card\"\n" " [0,2,1] :class\n" " [0,2,2] \"card\"\n" " [0,2,3] (h2 title)\n" " [0,2,4] (when subtitle (p subtitle))\n" " [0,2,5] children))")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Structural summary") (p "A folded view for large files, showing shape without detail. Claude orients itself in a 300-line island, identifies the region it needs, then calls " (code "read-subtree") " on just that part:") (~docs/code :src (str "(defisland ~home/stepper [0]\n" " (let [0,2]\n" " ((source ...) (code-tokens ...)) [0,2,1]\n" " (letrec [0,2,2]\n" " ((split-tag ...) ... [0,2,2,1]\n" " (rebuild-preview ...) [0,2,2,1,12]\n" " (do-back ...)) [0,2,2,1,13]\n" " (freeze-scope ...) [0,2,2,2]\n" " (let ((_eff ...)) (div ...)))) [0,2,2,5]")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Context view") (p "Given a deep path, shows the enclosing chain back to the root. Essential for working deep in a tree without loading the entire file:") (~docs/code :src (str "Context for [0,2,2,1,12]:\n" " [0] defisland ~home/stepper\n" " [0,2] let ((source ...) ... (code-tokens ...))\n" " [0,2,2] letrec ((split-tag ...) ...)\n" " [0,2,2,1] bindings list (14 pairs)\n" " → [0,2,2,1,12] (rebuild-preview (fn (target) ...))")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Bracket-paired view") (p "The raw source annotated with matched pair labels, for cases where Claude needs to see the actual syntax and verify bracket correspondence:") (~docs/code :src (str "(₁defcomp ~card (₂&key title subtitle &rest children)₂\n" " (₃div :class \"card\"\n" " (₄h2 title)₄\n" " (₅when subtitle (₆p subtitle)₆)₅\n" " children)₃)₁"))) (~docs/section :title "Edit operations" :id "editing" (p "All operations take a tree, perform the operation, and return either a new tree or a structured error. Nothing is mutated in place. File writing is a separate step that only happens after the edit succeeds.") (table :class "min-w-full text-sm mb-6" (thead (tr (th :class "text-left pr-4 pb-2 font-semibold text-stone-700" "Operation") (th :class "text-left pb-2 font-semibold text-stone-700" "Description"))) (tbody :class "text-stone-600" (tr (td :class "pr-4 py-1 font-mono text-xs" "replace-node") (td :class "py-1" "Replace the node at a path with new parsed source")) (tr (td :class "pr-4 py-1 font-mono text-xs" "insert-child") (td :class "py-1" "Insert a new child at a specific index within a list")) (tr (td :class "pr-4 py-1 font-mono text-xs" "delete-node") (td :class "py-1" "Remove a node — siblings shift to fill the gap")) (tr (td :class "pr-4 py-1 font-mono text-xs" "wrap-node") (td :class "py-1" "Wrap a node in a new list — e.g. wrap expression in " (code "(when cond ...)"))) (tr (td :class "pr-4 py-1 font-mono text-xs" "validate") (td :class "py-1" "Check structural integrity — balanced parens, valid paths")))) (p (strong "Fragment-first validation.") " Every write operation parses the new source fragment as a complete s-expression before navigating to the target path. If the fragment is malformed, the operation returns an error with the line and column of the parse failure. The source file is never touched in the failure path.") (p (strong "Named paths.") " Index paths break when sibling nodes are inserted or deleted. Named paths — " (code "[Head \"letrec\", Head \"rebuild-preview\"]") " — survive structural edits and are more natural for Claude to reason about. Claude should prefer named paths; index paths are for mechanical follow-up after " (code "find-node") " has located a target.")) (~docs/section :title "The protocol" :id "protocol" (p "The tools only work if they are actually used. The MCP server must be accompanied by a protocol — enforced via " (code "CLAUDE.md") " — that prevents fallback to raw text editing.") (~docs/code :src (str ";; Before doing anything in an .sx file:\n" ";; 1. summarise → structural overview of the whole file\n" ";; 2. read-subtree → expand the region you intend to work in\n" ";; 3. get-context → understand the position of specific nodes\n" ";; 4. find-all → locate definitions or patterns by name\n" "\n" ";; For every edit:\n" ";; 1. read-subtree → confirm the correct path\n" ";; 2. replace-node / insert-child / delete-node / wrap-node\n" ";; 3. validate → confirm structural integrity\n" ";; 4. read-subtree → verify the result\n" "\n" ";; Never use str_replace on .sx files.\n" ";; Never proceed to an edit without first establishing\n" ";; where you are in the tree using the comprehension tools.")) (p "The comprehension-first discipline is the key insight. Claude cannot edit reliably what it does not understand reliably. The same parsed tree representation serves both needs — reading and writing are two sides of the same structural problem.")) (~docs/section :title "Why SX, not OCaml" :id "why-sx" (p "The original plan called for a pure OCaml implementation with " (code "angstrom") " parser combinators and a Wadler-Lindig pretty-printer. This is unnecessary. The SX ecosystem already has everything:") (ul :class "space-y-2 text-stone-600" (li (strong "Parser: ") (code "sx-parse") " already parses s-expressions correctly — it is the same parser the evaluator uses. No second parser to maintain, no divergence risk.") (li (strong "Serializer: ") (code "sx-serialize") " already handles round-tripping. The existing serializer preserves structure.") (li (strong "Tree operations: ") "Recursive list processing is what SX does best. Annotating a tree, folding to a summary, navigating by path — these are all natural " (code "map") "/" (code "reduce") "/" (code "filter") " operations on nested lists.") (li (strong "Web UI: ") "The interactive tree editor is a " (code "defisland") " — signals for selection state, reactive DOM for the tree view, lakes for server-morphable content. The home stepper widget is proof this works.") (li (strong "OCaml host: ") "The SX functions run on the OCaml evaluator. The MCP server is a thin OCaml wrapper around SX function calls. Native performance for the server, WASM for the browser — same codebase.")) (p "Writing the tree tools in SX means they can run in the browser (via the WASM evaluator) and on the server (via the OCaml kernel). The web editor and the MCP server share identical logic. There is one implementation, not two.")) (~docs/section :title "Build plan" :id "build-plan" (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 1 — Tree comprehension functions") (p "Implement " (code "annotate-tree") ", " (code "summarise") ", " (code "read-subtree") ", " (code "get-context") ", " (code "find-all") ", " (code "bracket-pairs") " as pure SX functions in " (code "web/lib/tree-tools.sx") ". Test against real project " (code ".sx") " files. Iterate on output formats until the output is genuinely easy for a language model to read — the format is load-bearing.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 2 — Edit operations") (p "Implement " (code "replace-node") ", " (code "insert-child") ", " (code "delete-node") ", " (code "wrap-node") ", " (code "validate") " as pure SX functions. Fragment-first validation on all write operations. Test error paths exhaustively — error messages are part of the interface.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 3 — MCP server") (p "Thin OCaml binary: stdio JSON-RPC, calls SX functions via the bridge, reads/writes files. Wire all comprehension and edit tools to MCP handlers. Manual testing with raw JSON.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 4 — Web editor") (p (code "defisland ~sx-tools/tree-editor") " — interactive tree visualization on this page. Click a node to see its path, context, and siblings. Edit nodes through a structural interface. Islands and signals for reactivity. A tool and a demonstration.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 5 — Integration and iteration") (p "Write the " (code "CLAUDE.md") " protocol. Run real tasks with Claude Code — both reading and editing. Observe which comprehension tools Claude actually reaches for. Observe where it still makes structural errors. Iterate on output formats and add any missing tools. The output formats deserve careful design based on observed behaviour, not just on what seems reasonable in advance.")) (~docs/section :title "Try it" :id "try-it" (p "Paste or edit SX source below. The tree view shows every node with its path — click a node to select it, then switch to context view to see the enclosing chain.") (~sx-tools/tree-editor)) (~docs/section :title "What changes" :id "what-changes" (p "With SX Tools, the debugging session that found the home-stepper bug would not have happened. The workflow would have been:") (ul :class "space-y-2 text-stone-600" (li (code "summarise home-stepper.sx") " → see that " (code "rebuild-preview") " is at " (code "[0,4]") " (body expression) instead of " (code "[0,2,2,1,12]") " (letrec binding). The bug is visible on the summary.") (li "Even without noticing the summary, " (code "validate home-stepper.sx") " would report the structural anomaly: 14 names in the letrec bindings list but only 12 binding pairs, with 2 bare expressions following.") (li "The fix — " (code "delete-node") " to remove the extra paren, or " (code "wrap-node") " to restructure — would be a tree operation. No character-counting, no mental stack simulation, no risk of introducing a second paren error while fixing the first.")) (p "The gap between intended tree and actual tree stops being invisible. Claude sees trees, edits trees, and the brackets take care of themselves.")))) +(defcomp ~sx-tools/overview-content (&key (title "SX Tools") &rest extra) (~docs/page :title title (p :class "text-stone-500 text-sm italic mb-8" "A structural tree editor for s-expression files — because the thing that reads and edits code should understand the code as a tree, not as a sequence of characters.") (~docs/section :title "The problem" :id "problem" (p "On 25 March 2026, the SX documentation site went blank. The home page stepper widget — a 310-line " (code "defisland") " — failed to render. The cause was a single extra closing parenthesis on line 222 of " (code "home-stepper.sx") ".") (p "That parenthesis closed the " (code "letrec") " bindings list one level too early. Two function definitions — " (code "rebuild-preview") " and " (code "do-back") " — silently became body expressions instead of bindings. They were evaluated and discarded rather than bound in scope. Every subsequent reference to " (code "rebuild-preview") " raised " (code "Undefined symbol") ". The island rendered nothing. The page went white.") (p "Finding this took an hour of systematic debugging: adding trace output to the OCaml " (code "env_has") " function, dumping the scope chain at the point of failure, counting keys in the letrec environment (" (em "12 where there should have been 14") "), and finally writing a paren-depth tracer that walked the file character by character to find where the nesting diverged from expectation.") (p "The fix was removing one character.") (p "This is a class of bug, not an incident. S-expressions encode tree structure in linear text using matched delimiters. When those delimiters are wrong, the meaning of every subsequent expression changes. The error is silent — the parser succeeds, the evaluator runs, the wrong thing happens. The gap between the intended tree and the actual tree is invisible in the source.")) (~docs/section :title "Why raw text fails" :id "why-text-fails" (p "Claude Code reads " (code ".sx") " files as raw text and mentally reconstructs the tree structure by tracking bracket nesting. It does this imperfectly — especially in deep or wide trees where closing parentheses pile up and their correspondence to openers is lost. Consider the end of a complex island:") (~docs/code :src "(set-cookie \"sx-home-stepper\" (freeze-to-sx \"home-stepper\"))))))))") (p "Eight closing parentheses. Which one closes " (code "set-cookie") "? Which closes the " (code "fn") "? Which closes the binding pair? Which closes the letrec bindings list? Answering this requires counting backward through hundreds of lines. Counting is not what language models do well.") (p "The same problem compounds when writing. Claude generates plausible-looking s-expression fragments that are structurally wrong — a paren added, a paren dropped, a level of nesting off. The " (code "str_replace") " tool makes this worse: replacing a string inside a deeply nested form can silently unbalance the surrounding structure in ways that are not visible until the file fails to parse — or worse, parses into a different tree.")) (~docs/section :title "The fix: structural tools" :id "structural-tools" (p "If Claude sees trees when the underlying thing is a tree, both the reading and writing problems disappear. Instead of raw text, Claude gets an annotated tree view with explicit paths:") (~docs/code :src (str "[0] (defisland ~home/stepper\n" " [0,0] ~home/stepper\n" " [0,1] ()\n" " [0,2] (let\n" " [0,2,0] let\n" " [0,2,1] ((source ...) ... (code-tokens ...))\n" " [0,2,2] (letrec\n" " [0,2,2,0] letrec\n" " [0,2,2,1] ((split-tag ...) ... (do-back ...))\n" " [0,2,2,2] (freeze-scope ...)\n" " [0,2,2,3] (let ((saved ...)) ...)\n" " [0,2,2,4] (let ((parsed ...)) ...)\n" " [0,2,2,5] (let ((_eff ...)) (div ...)))))")) (p "The structural correspondence that is invisible in raw text is explicit here. Every node has a path. If " (code "rebuild-preview") " appears at " (code "[0,2,2,2]") " instead of " (code "[0,2,2,1,12]") ", it is immediately obvious that it is a body expression, not a letrec binding. The bug that took an hour to find would be visible on inspection.") (p "For editing, Claude specifies tree operations rather than text replacements:") (~docs/code :src (str ";; Replace a node by path — the fragment is parsed before\n" ";; the file is touched. Bracket errors are impossible.\n" "(replace-node \"home-stepper.sx\" [0,2,2,1,12]\n" " \"(rebuild-preview (fn (target) ...))\")\n" "\n" ";; Insert a new child at a specific position\n" "(insert-child \"home-stepper.sx\" [0,2,2,1] 12\n" " \"(new-function (fn () nil))\")\n" "\n" ";; Delete a node — siblings adjust automatically\n" "(delete-node \"home-stepper.sx\" [0,2,2,3])")) (p "Every write operation parses the new fragment as a complete s-expression " (em "before") " navigating to the target path. If the fragment is malformed, the operation returns an error with the line and column of the parse failure. The source file is never left in a partially-edited state. Bracket mismatches become impossible by construction.")) (~docs/section :title "Architecture" :id "architecture" (p "SX Tools is an SX application. The comprehension and editing logic is written in SX, runs on the OCaml evaluator, and is exposed through two interfaces: an MCP server for Claude Code, and an interactive web application for the developer.") (~docs/code :src (str " ┌─────────────────┐\n" " Claude Code ──▶ │ MCP Server │\n" " │ (OCaml stdio) │\n" " └────────┬─────────┘\n" " │\n" " ┌────────▼─────────┐\n" " │ SX Tree Logic │\n" " │ (comprehend.sx) │\n" " │ (edit.sx) │\n" " └────────┬─────────┘\n" " │\n" " ┌───────────────┼───────────────┐\n" " ▼ ▼ ▼\n" " .sx files Web tree editor Validation\n" " (defisland) reports")) (p "The " (strong "parser") " is " (code "sx-parse") " — the same parser that evaluates SX source. No new parser needed. Round-trip fidelity is inherited from the existing serializer.") (p "The " (strong "tree logic") " lives in " (code "web/lib/tree-tools.sx") ". Pure functions: take a parsed tree, return annotated output or a modified tree. No IO, no side effects.") (p "The " (strong "MCP server") " is a thin OCaml binary (" (code "hosts/ocaml/bin/mcp_tree.ml") ") that reads files, calls the SX tree functions via the OCaml bridge, and writes results. Stdio transport, JSON-RPC, single-threaded.") (p "The " (strong "web editor") " is a " (code "defisland") " at " (code "/sx/(applications.(sx-tools))") " — the page you are reading. Interactive tree visualization with click-to-navigate, path display, and structural editing. Islands and signals make it reactive. It is both a tool and a demonstration of the SX platform.")) (~docs/section :title "Comprehension tools" :id "comprehension" (p "These are the tools Claude uses to " (em "understand") " structure before touching anything. They are read-only and have no side effects. They are not a convenience layer — they are as important as the editing tools.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Annotated tree view") (p "The primary comprehension tool. Every node gets its path label inline, making tree structure explicit. The structural correspondence that is invisible in raw text is readable by inspection, with no need to count brackets:") (~docs/code :src (str "[0] (defcomp ~card\n" " [0,1] (&key title subtitle &rest children)\n" " [0,2] (div :class \"card\"\n" " [0,2,1] :class\n" " [0,2,2] \"card\"\n" " [0,2,3] (h2 title)\n" " [0,2,4] (when subtitle (p subtitle))\n" " [0,2,5] children))")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Structural summary") (p "A folded view for large files, showing shape without detail. Claude orients itself in a 300-line island, identifies the region it needs, then calls " (code "read-subtree") " on just that part:") (~docs/code :src (str "(defisland ~home/stepper [0]\n" " (let [0,2]\n" " ((source ...) (code-tokens ...)) [0,2,1]\n" " (letrec [0,2,2]\n" " ((split-tag ...) ... [0,2,2,1]\n" " (rebuild-preview ...) [0,2,2,1,12]\n" " (do-back ...)) [0,2,2,1,13]\n" " (freeze-scope ...) [0,2,2,2]\n" " (let ((_eff ...)) (div ...)))) [0,2,2,5]")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Context view") (p "Given a deep path, shows the enclosing chain back to the root. Essential for working deep in a tree without loading the entire file:") (~docs/code :src (str "Context for [0,2,2,1,12]:\n" " [0] defisland ~home/stepper\n" " [0,2] let ((source ...) ... (code-tokens ...))\n" " [0,2,2] letrec ((split-tag ...) ...)\n" " [0,2,2,1] bindings list (14 pairs)\n" " → [0,2,2,1,12] (rebuild-preview (fn (target) ...))")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Bracket-paired view") (p "The raw source annotated with matched pair labels, for cases where Claude needs to see the actual syntax and verify bracket correspondence:") (~docs/code :src (str "(₁defcomp ~card (₂&key title subtitle &rest children)₂\n" " (₃div :class \"card\"\n" " (₄h2 title)₄\n" " (₅when subtitle (₆p subtitle)₆)₅\n" " children)₃)₁"))) (~docs/section :title "Smart tree reading" :id "smart-read" (p "For large files, " (code "sx_read_tree") " automatically manages context size. With no extra parameters, it detects when a file exceeds 200 lines and auto-summarises at depth 2. Four modes give Claude control over how much context to pull in:") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Focus mode") (p "The " (code "focus") " parameter expands only subtrees that match a pattern, collapsing everything else. This is the most useful mode — it combines search and comprehension in one call.") (~docs/code :src (str ";; Show only the parts of the tree that mention \"defisland\"\nsx_read_tree file=\"home.sx\" focus=\"defisland\"\n\n;; The ~docs/section containing defisland expands fully.\n;; Every other section collapses to one line.")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Depth limit") (p "The " (code "max_depth") " parameter works like " (code "sx_summarise") " — it caps how deep the tree expands.") (~docs/code :src (str ";; Just the top-level structure\nsx_read_tree file=\"home.sx\" max_depth=1\n\n;; Two levels deep\nsx_read_tree file=\"home.sx\" max_depth=3")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Pagination") (p "The " (code "max_lines") " and " (code "offset") " parameters paginate the full annotated tree. The output includes a header showing which lines are displayed and whether more are available.") (~docs/code :src (str ";; First 30 lines\nsx_read_tree file=\"home.sx\" max_lines=30\n\n;; Next 30 lines\nsx_read_tree file=\"home.sx\" max_lines=30 offset=30\n\n;; Output header:\n;; ;; Lines 0-30 of 590 (more available)")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Auto mode") (p "With no parameters, files under 200 lines return the full annotated tree. Larger files auto-summarise at depth 2 with a note showing the total line count and how to get more detail.")) (~docs/section :title "Edit operations" :id "editing" (p "All operations take a tree, perform the operation, and return either a new tree or a structured error. Nothing is mutated in place. File writing is a separate step that only happens after the edit succeeds.") (table :class "min-w-full text-sm mb-6" (thead (tr (th :class "text-left pr-4 pb-2 font-semibold text-stone-700" "Operation") (th :class "text-left pb-2 font-semibold text-stone-700" "Description"))) (tbody :class "text-stone-600" (tr (td :class "pr-4 py-1 font-mono text-xs" "replace-node") (td :class "py-1" "Replace the node at a path with new parsed source")) (tr (td :class "pr-4 py-1 font-mono text-xs" "insert-child") (td :class "py-1" "Insert a new child at a specific index within a list")) (tr (td :class "pr-4 py-1 font-mono text-xs" "delete-node") (td :class "py-1" "Remove a node — siblings shift to fill the gap")) (tr (td :class "pr-4 py-1 font-mono text-xs" "wrap-node") (td :class "py-1" "Wrap a node in a new list — e.g. wrap expression in " (code "(when cond ...)"))) (tr (td :class "pr-4 py-1 font-mono text-xs" "validate") (td :class "py-1" "Check structural integrity — balanced parens, valid paths")))) (p (strong "Fragment-first validation.") " Every write operation parses the new source fragment as a complete s-expression before navigating to the target path. If the fragment is malformed, the operation returns an error with the line and column of the parse failure. The source file is never touched in the failure path.") (p (strong "Named paths.") " Index paths break when sibling nodes are inserted or deleted. Named paths — " (code "[Head \"letrec\", Head \"rebuild-preview\"]") " — survive structural edits and are more natural for Claude to reason about. Claude should prefer named paths; index paths are for mechanical follow-up after " (code "find-node") " has located a target.")) (~docs/section :title "Smart editing" :id "smart-editing" (p "The path-based edit tools require knowing the exact path to a node. The smart edit tools combine search with editing — find a node by pattern, then edit it in one call.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Rename symbol") (p (code "sx_rename_symbol") " renames every occurrence of a symbol throughout a file. Structural — only renames symbol nodes, never touches strings or keywords.") (~docs/code :src (str ";; Rename a component\nsx_rename_symbol file=\"home.sx\" old_name=\"~card\" new_name=\"~ui/card\"\n;; → Renamed 3 occurrences of '~card' → '~ui/card' in home.sx")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Replace by pattern") (p (code "sx_replace_by_pattern") " finds the first node matching a pattern and replaces it with new source. Set " (code "all=true") " to replace every match.") (~docs/code :src (str ";; Replace first match\nsx_replace_by_pattern file=\"home.sx\" pattern=\"~old-card\" new_source=\"(~new-card :title t)\"\n\n;; Replace all matches\nsx_replace_by_pattern file=\"home.sx\" pattern=\"~old-card\" new_source=\"(~new-card :title t)\" all=true")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Insert near pattern") (p (code "sx_insert_near") " inserts a new form before or after the top-level definition that contains a pattern match. No path arithmetic needed — just name what you want to insert near.") (~docs/code :src (str ";; Insert a new component before the footer definition\nsx_insert_near file=\"page.sx\" pattern=\"~footer\" position=\"before\"\n new_source=\"(defcomp ~sidebar () (div :class sidebar))\"")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Rename across files") (p (code "sx_rename_across") " renames a symbol in every " (code ".sx") " file under a directory. Use " (code "dry_run=true") " to preview which files would be affected before committing.") (~docs/code :src (str ";; Preview\nsx_rename_across dir=\"/root/rose-ash/sx\" old_name=\"~card\" new_name=\"~ui/card\" dry_run=true\n;; → home.sx: 3 occurrences (dry run)\n;; → layout.sx: 1 occurrences (dry run)\n\n;; Commit\nsx_rename_across dir=\"/root/rose-ash/sx\" old_name=\"~card\" new_name=\"~ui/card\""))) (~docs/section :title "REPL" :id "repl" (p "The " (code "sx_eval") " tool evaluates SX expressions inside the MCP server's environment. The environment has the parser, tree-tools functions, and all primitives loaded — the same context the tree tools themselves run in.") (p "This is useful for testing expressions, checking how primitives behave, or debugging tree-tool functions without a full build cycle.") (~docs/code :src (str ";; Simple evaluation\n(sx_eval \"(+ 1 2)\") → 3\n\n;; Test tree-tools functions\n(sx_eval \"(path-str (list 0 2 1))\") → \"[0,2,1]\"\n\n;; Parse and inspect\n(sx_eval \"(len (sx-parse \\\"(div (p hello))\\\"))\") → 1")) (p (strong "Limitation: ") "the environment contains parser + tree-tools + primitives. It does not have the full evaluator spec, application components, or macros loaded. Use it for testing pure functions and primitives.")) (~docs/section :title "Project-wide tools" :id "project-wide" (p "These tools search across all " (code ".sx") " files in a directory tree. They answer the questions Claude needs most often: where is something defined, what definitions exist, and who uses a given component.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Find across files") (p (code "sx_find_across") " runs " (code "find-all") " on every " (code ".sx") " file under a directory. Results include the file path, tree path, and node summary.") (~docs/code :src (str ";; Find all islands in the project\nsx_find_across dir=\"/root/rose-ash/sx\" pattern=\"defisland\"\n\n;; Output:\n;; sx/home.sx [0] (defisland ~home/stepper ...)\n;; sx/sx-tools-editor.sx [0] (defisland ~sx-tools/tree-editor ...)")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Component list") (p (code "sx_comp_list") " scans a directory for all top-level definitions: " (code "defcomp") ", " (code "defisland") ", " (code "defmacro") ", " (code "defpage") ", " (code "define") ". Returns a structured table with type, name, file, and parameters.") (~docs/code :src (str ";; List everything defined in sx/\nsx_comp_list dir=\"/root/rose-ash/sx\"\n\n;; Output:\n;; TYPE NAME FILE PARAMS\n;; defcomp ~docs/page sx/docs.sx &key title ...")) (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Component usage") (p (code "sx_comp_usage") " finds every reference to a component or symbol across all " (code ".sx") " files. The reverse of " (code "find-all") " — instead of searching one file, it searches the whole project.") (~docs/code :src (str ";; Who uses ~docs/section?\nsx_comp_usage dir=\"/root/rose-ash/sx\" name=\"~docs/section\"\n\n;; Output:\n;; sx/sx-tools.sx [0,3,4] (~docs/section :title \"The problem\" ...)\n;; sx/sx-tools.sx [0,3,5] (~docs/section :title \"Why raw text fails\" ...)\n;; ..."))) (~docs/section :title "Structural diff" :id "diff" (p (code "sx_diff") " compares two " (code ".sx") " files structurally and reports differences with tree paths. Unlike text diff, it understands nesting — a renamed symbol deep in a tree shows as one " (code "CHANGED") " line, not a confusing hunk of parentheses.") (~docs/code :src (str ";; Compare two versions of a file\nsx_diff file_a=\"home.sx.bak\" file_b=\"home.sx\"\n\n;; Output:\n;; CHANGED [0,2,1] \"old-title\" → \"new-title\"\n;; ADDED [0,3] (p \"new paragraph\")\n;; REMOVED [0,5] (div :class \"obsolete\" ...)")) (p "Three change types: " (code "CHANGED") " (same position, different content), " (code "ADDED") " (present in second file but not first), " (code "REMOVED") " (present in first but not second). Comparison is positional — nodes are matched by index, not by name.")) (~docs/section :title "Development tools" :id "dev-tools" (p "Tools for the SX development workflow: building, testing, formatting, linting, and macro expansion.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Pretty print") (p (code "sx_pretty_print") " reformats an " (code ".sx") " file with indentation. Short forms stay on one line, longer forms break across lines with keyword args kept paired. All edit tools use the pretty printer automatically when writing files.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Write file") (p (code "sx_write_file") " creates or overwrites an " (code ".sx") " file. Source is parsed before writing — malformed SX is rejected and the file is not touched. Output is pretty-printed.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Build") (p (code "sx_build") " builds the SX runtime. Target " (code "js") " (default) builds " (code "sx-browser.js") ", " (code "ocaml") " runs " (code "dune build") ". Set " (code "full=true") " for extensions and type system.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Test") (p (code "sx_test") " runs the SX test suite and returns a summary with pass/fail counts. Any failures are listed with details. Host is " (code "js") " (default) or " (code "ocaml") ". Set " (code "full=true") " for the extended suite.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Format check") (p (code "sx_format_check") " lints an " (code ".sx") " file for structural issues: empty " (code "let") " bindings, " (code "defcomp") "/" (code "defisland") " with too few args, " (code "define") " with no body, duplicate parameters.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Macro expand") (p (code "sx_macroexpand") " evaluates an expression with a file's definitions loaded. Use to test macros — the file's " (code "defmacro") " forms are available in the evaluation environment.")) (~docs/section :title "The protocol" :id "protocol" (p "The tools only work if they are actually used. The MCP server must be accompanied by a protocol — enforced via " (code "CLAUDE.md") " — that prevents fallback to raw text editing.") (~docs/code :src (str ";; Before doing anything in an .sx file:\n" ";; 1. summarise → structural overview of the whole file\n" ";; 2. read-subtree → expand the region you intend to work in\n" ";; 3. get-context → understand the position of specific nodes\n" ";; 4. find-all → locate definitions or patterns by name\n" "\n" ";; For every edit:\n" ";; 1. read-subtree → confirm the correct path\n" ";; 2. replace-node / insert-child / delete-node / wrap-node\n" ";; 3. validate → confirm structural integrity\n" ";; 4. read-subtree → verify the result\n" "\n" ";; Never use str_replace on .sx files.\n" ";; Never proceed to an edit without first establishing\n" ";; where you are in the tree using the comprehension tools.")) (p "The comprehension-first discipline is the key insight. Claude cannot edit reliably what it does not understand reliably. The same parsed tree representation serves both needs — reading and writing are two sides of the same structural problem.")) (~docs/section :title "Why SX, not OCaml" :id "why-sx" (p "The original plan called for a pure OCaml implementation with " (code "angstrom") " parser combinators and a Wadler-Lindig pretty-printer. This is unnecessary. The SX ecosystem already has everything:") (ul :class "space-y-2 text-stone-600" (li (strong "Parser: ") (code "sx-parse") " already parses s-expressions correctly — it is the same parser the evaluator uses. No second parser to maintain, no divergence risk.") (li (strong "Serializer: ") (code "sx-serialize") " already handles round-tripping. The existing serializer preserves structure.") (li (strong "Tree operations: ") "Recursive list processing is what SX does best. Annotating a tree, folding to a summary, navigating by path — these are all natural " (code "map") "/" (code "reduce") "/" (code "filter") " operations on nested lists.") (li (strong "Web UI: ") "The interactive tree editor is a " (code "defisland") " — signals for selection state, reactive DOM for the tree view, lakes for server-morphable content. The home stepper widget is proof this works.") (li (strong "OCaml host: ") "The SX functions run on the OCaml evaluator. The MCP server is a thin OCaml wrapper around SX function calls. Native performance for the server, WASM for the browser — same codebase.")) (p "Writing the tree tools in SX means they can run in the browser (via the WASM evaluator) and on the server (via the OCaml kernel). The web editor and the MCP server share identical logic. There is one implementation, not two.")) (~docs/section :title "Build plan" :id "build-plan" (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 1 — Tree comprehension functions") (p "Implement " (code "annotate-tree") ", " (code "summarise") ", " (code "read-subtree") ", " (code "get-context") ", " (code "find-all") ", " (code "bracket-pairs") " as pure SX functions in " (code "web/lib/tree-tools.sx") ". Test against real project " (code ".sx") " files. Iterate on output formats until the output is genuinely easy for a language model to read — the format is load-bearing.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 2 — Edit operations") (p "Implement " (code "replace-node") ", " (code "insert-child") ", " (code "delete-node") ", " (code "wrap-node") ", " (code "validate") " as pure SX functions. Fragment-first validation on all write operations. Test error paths exhaustively — error messages are part of the interface.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 3 — MCP server") (p "Thin OCaml binary: stdio JSON-RPC, calls SX functions via the bridge, reads/writes files. Wire all comprehension and edit tools to MCP handlers. Manual testing with raw JSON.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 4 — Web editor") (p (code "defisland ~sx-tools/tree-editor") " — interactive tree visualization on this page. Click a node to see its path, context, and siblings. Edit nodes through a structural interface. Islands and signals for reactivity. A tool and a demonstration.") (h4 :class "font-semibold text-stone-700 mt-6 mb-2" "Phase 5 — Integration and iteration") (p "Write the " (code "CLAUDE.md") " protocol. Run real tasks with Claude Code — both reading and editing. Observe which comprehension tools Claude actually reaches for. Observe where it still makes structural errors. Iterate on output formats and add any missing tools. The output formats deserve careful design based on observed behaviour, not just on what seems reasonable in advance.")) (~docs/section :title "Try it" :id "try-it" (p "Paste or edit SX source below. The tree view shows every node with its path — click a node to select it, then switch to context view to see the enclosing chain.") (~sx-tools/tree-editor)) (~docs/section :title "What changes" :id "what-changes" (p "With SX Tools, the debugging session that found the home-stepper bug would not have happened. The workflow would have been:") (ul :class "space-y-2 text-stone-600" (li (code "summarise home-stepper.sx") " → see that " (code "rebuild-preview") " is at " (code "[0,4]") " (body expression) instead of " (code "[0,2,2,1,12]") " (letrec binding). The bug is visible on the summary.") (li "Even without noticing the summary, " (code "validate home-stepper.sx") " would report the structural anomaly: 14 names in the letrec bindings list but only 12 binding pairs, with 2 bare expressions following.") (li "The fix — " (code "delete-node") " to remove the extra paren, or " (code "wrap-node") " to restructure — would be a tree operation. No character-counting, no mental stack simulation, no risk of introducing a second paren error while fixing the first.")) (p "The gap between intended tree and actual tree stops being invisible. Claude sees trees, edits trees, and the brackets take care of themselves."))))