#!/bin/sh # fx: automates f()'s "enter a fix" prompt by gathering candidate fixes # from a directory of pluggable sources and letting you pick one (or # auto-picking the top one) before piping it into f's stdin. # # Zero changes to f.sh: this only decides what gets typed at the # "[LEARN] ... Enter a fix" prompt, using the stdin f() already reads. # # Usage: # . src/f.sh # . contrib/fx/fx.sh # fx some-command --that --might --fail # # Env vars: # FX_SOURCES_DIR the sources directory. Defaults to ./contrib/fx/sources.d # (i.e. source this file from the repo root). There is no # portable way for a *sourced* POSIX shell file to learn # its own path -- $0 is the enclosing shell's name, and # bash's BASH_SOURCE has no equivalent in dash/ash, so # this does not try to be clever about it. Anything # other than "source from repo root" (a different cwd, # a temp dir a la bootstrap.sh) must set this explicitly # *before* sourcing this file. # FX_AUTO if set, auto-pick the top candidate instead of # showing a picker # FX_NO_PROBE if set, skip fx's own diagnostic run of the command # (cheaper, but the 30-selfdiag.sh source gets no # output to read and will have no opinion) # FX_TRACE_FILE where chosen fixes' source labels are recorded, one # "cmd=source-file" line per accepted fix. Defaults to # ./p.trace, alongside f()'s own ./p. Written whenever # a non-blank candidate is handed to f() -- same # "wrote intent, not confirmed success" semantics as # p itself (see f.sh). This exists because f() alone # has no notion of *where* a fix came from: once a # fix is in p, a manually-typed fix, a locally-known # fix (10-known.sh), and an LLM-guessed fix # (70/80-*.sh) are byte-for-byte indistinguishable. # 80-remote-api.sh's own docstring is explicit that # LLM-sourced fixes must stay visually distinguishable # from verified ones -- without this file, that # distinction is silently lost the moment a fix is # accepted, and permanently lost if p is disk-persisted # across a reboot (see adaptive-loop.sh). p.trace is # what a caller checks before trusting an auto-applied # fix it didn't personally review. # # Source contract (see sources.d/*.sh): # - any executable file in $FX_SOURCES_DIR # - invoked as: sourcefile "$CMD" # - may read $FX_OUTPUT (captured stdout+stderr of one real attempt, # empty if FX_NO_PROBE was set) # - prints zero or more candidate fix lines to stdout, one per line # - non-zero exit or no output = "no opinion", silently skipped # - filename prefix (NN-name) sets display/consideration order # # Adding a source: drop a new executable file in sources.d/ that follows # the contract above. Nothing else to register or edit. # Resolved to an absolute path *now*, at source time -- a relative # default would silently re-resolve against whatever cwd happens to be # active later when fx() is actually called, which is almost never the # repo root in practice. FX_SOURCES_DIR="${FX_SOURCES_DIR:-$(pwd)/contrib/fx/sources.d}" FX_TRACE_FILE="${FX_TRACE_FILE:-p.trace}" fx() { if [ ! -d "$FX_SOURCES_DIR" ]; then echo "fx: FX_SOURCES_DIR '$FX_SOURCES_DIR' not found -- no sources will fire." >&2 echo "fx: set FX_SOURCES_DIR before sourcing fx.sh if not running from the repo root." >&2 fi cmd="$*" FX_OUTPUT="" if [ -z "$FX_NO_PROBE" ]; then FX_OUTPUT=$("$@" 2>&1) if [ $? -eq 0 ]; then printf '%s\n' "$FX_OUTPUT" return 0 fi fi export FX_OUTPUT # Under FX_AUTO only the first candidate is ever used (head -1 # below), so gathering every source unconditionally would mean # always paying for the 25-30s local-LLM/remote-API tiers even # when a free source already answered and that answer is # guaranteed to win -- exactly the cost the NN- numbering claims to # avoid. Stop at the first source that produces anything once # FX_AUTO is set. Without it (the interactive picker), keep # gathering everything: a later, better-verified candidate # shouldn't be hidden behind an earlier weak match when a human is # the one choosing. candidates=$( for src in "$FX_SOURCES_DIR"/*; do [ -x "$src" ] || continue label="${src##*/}" out=$("$src" "$cmd" 2>/dev/null) if [ -n "$out" ]; then printf '%s\n' "$out" | while IFS= read -r line; do [ -n "$line" ] && printf '%s\t%s\n' "$label" "$line" done [ -n "$FX_AUTO" ] && break fi done | awk -F'\t' ' { label = $1 text = substr($0, length(label) + 2) if (!seen[text]++) print "[" label "] " text } ' ) # chosen_line keeps the "[source-label] fix text" form intact so # provenance can be recovered after picking -- stripping the label # too early (as this used to do in each branch below) throws away # the one piece of information that lets a later reader tell an # LLM-guessed fix apart from a locally-verified one. chosen_line="" if [ -n "$candidates" ]; then if [ -n "$FX_AUTO" ]; then chosen_line=$(printf '%s\n' "$candidates" | head -1) elif command -v fzf >/dev/null 2>&1; then chosen_line=$(printf '%s\n' "$candidates" | fzf --prompt="fix for: $cmd > " --height=40%) else i=0 printf '%s\n' "$candidates" | while IFS= read -r line; do i=$((i+1)); printf '%d) %s\n' "$i" "$line" done # "blank to give up", not "type your own": fx already # committed f's stdin to this pipe, so a blank answer here # can't fall through to a real terminal read the way f()'s # own bare prompt does -- it just pipes an empty line into # f(), same as when zero candidates were found at all. printf 'pick a number (blank to give up): ' read -r n [ -n "$n" ] && chosen_line=$(printf '%s\n' "$candidates" | sed -n "${n}p") fi fi chosen=$(printf '%s\n' "$chosen_line" | sed 's/^\[[^]]*\] //') chosen_source=$(printf '%s\n' "$chosen_line" | sed -n 's/^\[\([^]]*\)\].*/\1/p') # Same "record intent, not confirmed success" timing as f()'s own # write to p: logged as soon as a non-blank fix is handed off, not # gated on the retry inside f() actually succeeding. if [ -n "$chosen" ] && [ -n "$chosen_source" ]; then printf '%s=%s\n' "$cmd" "$chosen_source" >> "$FX_TRACE_FILE" fi printf '%s\n' "$chosen" | f "$@" }