Add opt-in local embedding adapter

ober

8fc971fb33ffff916af865cfd54cf73bd3b9a5ae

diff --git a/GAPS.md b/GAPS.md
index 4981f50..110c7d9 100644
--- a/GAPS.md
+++ b/GAPS.md
@@ -110,6 +110,12 @@ Acceptance criteria:
 `sniff` can use `all-MiniLM-L6-v2` embeddings. This project has no embedding
 provider interface.
 
+Status: implemented as an explicit `--embeddings` local-provider adapter using
+`local_embedding_provider_command` or `JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER`.
+The adapter requires an absolute command outside the scanned repository,
+returns secondary `local-embedding-similarity` evidence, and records
+`network_used:false`.
+
 Acceptance criteria:
 
 - Define a local embedding-provider contract equivalent in safety to the local
diff --git a/main-binary.ss b/main-binary.ss
index 0a970e3..b27d640 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -39,7 +39,7 @@
         xs)))
 
 (def (usage)
-  (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--first-parent|--all-parents] [--min-lines N] [--threshold HUMAN,AI] [--no-llm|--llm] [--provider NAME] [--max-files N] [--max-added-lines N] [--max-note-bytes N] [--format table|json|jsonl|markdown]")
+  (displayln "usage: jerboa main-binary.ss scan [PATH] [--config FILE] [--count N|--all] [--from REV] [--to REV] [--file PATH|--include PATH] [--exclude PREFIX] [--first-parent|--all-parents] [--min-lines N] [--threshold HUMAN,AI] [--no-llm|--llm] [--provider NAME] [--embeddings|--no-embeddings] [--max-files N] [--max-added-lines N] [--max-note-bytes N] [--format table|json|jsonl|markdown]")
   (displayln "       jerboa main-binary.ss explain REV [PATH] [--format json|markdown|table]")
   (displayln "       jerboa main-binary.ss stats [PATH] [--count N]")
   (displayln "       jerboa main-binary.ss verify-authorship [PATH] [--count N]"))
@@ -715,9 +715,12 @@
 (def current-analysis-provider "offline-heuristics")
 (def current-requested-provider "none")
 (def current-llm-used? #f)
+(def current-embeddings-used? #f)
 (def current-network-used? #f)
 (def current-llm-requested? #f)
+(def current-embeddings-requested? #f)
 (def current-local-provider-command '())
+(def current-local-embedding-provider-command '())
 (def local-provider-input-line-limit 80)
 
 (def (set-provider! name)
@@ -725,17 +728,25 @@
 
 (def (request-llm!)
   (set! current-llm-requested? #t))
+(def (request-embeddings!)
+  (set! current-embeddings-requested? #t))
 
 (def (disable-llm!)
   (set! current-llm-requested? #f)
   (set! current-llm-used? #f)
   (set! current-network-used? #f)
   (set! current-analysis-provider "offline-heuristics"))
+(def (disable-embeddings!)
+  (set! current-embeddings-requested? #f)
+  (set! current-embeddings-used? #f))
 
 (def (llm-warnings) '())
 (def (set-local-provider-command! parts)
   (if (list? parts)
       (set! current-local-provider-command (filter string? parts))))
+(def (set-local-embedding-provider-command! parts)
+  (if (list? parts)
+      (set! current-local-embedding-provider-command (filter string? parts))))
 (def (local-provider-command)
   (if (pair? current-local-provider-command)
       current-local-provider-command
@@ -743,6 +754,13 @@
         (if (and (string? path) (not (string-empty? path)))
             (list path)
             '()))))
+(def (local-embedding-provider-command)
+  (if (pair? current-local-embedding-provider-command)
+      current-local-embedding-provider-command
+      (let ([path (getenv "JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER")])
+        (if (and (string? path) (not (string-empty? path)))
+            (list path)
+            '()))))
 (def (local-provider-payload repo rev subject paths lines signals score)
   (json-string
    (list (cons 'detector_version detector-version)
@@ -828,6 +846,49 @@
                            (list '() '("local provider returned malformed JSON or omitted numeric score"))))
                      (list '() '("local provider command failed; using offline heuristics"))))))]))
 
+(def (local-embedding-provider-payload repo rev subject paths lines)
+  (json-string
+   (list (cons 'detector_version detector-version)
+         (cons 'repository repo)
+         (cons 'commit rev)
+         (cons 'subject subject)
+         (cons 'files paths)
+         (cons 'added_lines_sample (bounded-list lines local-provider-input-line-limit)))))
+(def (local-embedding-provider-warning repo cmd)
+  (cond [(null? cmd)
+         "embedding provider requested but no local_embedding_provider_command config or JERBOA_AIGIT_LOCAL_EMBEDDING_PROVIDER is set"]
+        [(not (path-absolute? (car cmd)))
+         "embedding provider command must be an absolute path outside the scanned repository"]
+        [(path-inside-root? (car cmd) repo)
+         "embedding provider command inside the scanned repository was refused"]
+        [else #f]))
+(def (local-embedding-provider-signal obj)
+  (let ([score (provider-score obj)])
+    (if score
+        (list (sig "local-embedding-similarity" "model" score 0.08 "low"
+                   (provider-reason obj)
+                   (provider-evidence obj)
+                   "local embedding output is secondary evidence; network access is never used by this adapter"))
+        '())))
+(def (local-embedding-provider-result repo rev subject paths lines)
+  (if (not current-embeddings-requested?)
+      (list '() '())
+      (let* ([cmd (local-embedding-provider-command)]
+             [command-warning (local-embedding-provider-warning repo cmd)])
+        (if command-warning
+            (list '() (list command-warning))
+            (let* ([payload (local-embedding-provider-payload repo rev subject paths lines)]
+                   [output (try-result (run-process (append cmd (list payload))))])
+              (if (ok? output)
+                  (let ([obj (parse-json-object (unwrap output))])
+                    (if (and (hash-table? obj) (provider-score obj))
+                        (begin
+                          (set! current-analysis-provider "offline-heuristics+local-embeddings")
+                          (set! current-embeddings-used? #t)
+                          (set! current-network-used? #f)
+                          (list (local-embedding-provider-signal obj) (local-provider-input-warnings lines)))
+                        (list '() '("embedding provider returned malformed JSON or omitted numeric score"))))
+                  (list '() '("embedding provider command failed; using offline heuristics"))))))))
 (def (weight-for-category cfg category fallback)
   (cond [(same-public-string? category "text") (scan-config-text-weight cfg)]
         [(same-public-string? category "code") (scan-config-code-weight cfg)]
@@ -1035,8 +1096,9 @@
                           '())]
          [weighted-signals (if metadata-only? '() (map (lambda (s) (with-config-weight current-config s)) raw-signals))]
          [preliminary-score (if metadata-only? 0.0 (aggregate-score weighted-signals))]
-         [provider-pair (local-provider-result repo id subject paths lines weighted-signals preliminary-score)]
-         [signals (append weighted-signals (car provider-pair))]
+         [embedding-pair (local-embedding-provider-result repo id subject paths lines)]
+         [provider-pair (local-provider-result repo id subject paths lines (append weighted-signals (car embedding-pair)) preliminary-score)]
+         [signals (append weighted-signals (car embedding-pair) (car provider-pair))]
          [score (if metadata-only? 0.0 (aggregate-score signals))]
          [insufficient? (and (null? signals) (null? metadata) (string-empty? note))]
          [v (cond [insufficient? "insufficient-evidence"]
@@ -1044,6 +1106,7 @@
                   [else (verdict score metadata note)])])
     (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution metadata signals score v
                         (append (warnings files lines note note-obj metadata-only? heuristics-only? min-lines file-count line-count note-count parents shallow? binary-count rename-copy-count missing-parent?)
+                                (cadr embedding-pair)
                                 (cadr provider-pair)))
           (cadr sim-pair))))
 
@@ -1112,7 +1175,7 @@
 (def (finding-json repo f)
   (list (cons 'detector_version detector-version) (cons 'config_hash (scan-config-hash current-config))
         (cons 'analysis_provider current-analysis-provider) (cons 'provider current-requested-provider)
-        (cons 'llm_used current-llm-used?) (cons 'network_used current-network-used?)
+        (cons 'llm_used current-llm-used?) (cons 'embeddings_used current-embeddings-used?) (cons 'network_used current-network-used?)
         (cons 'repository repo) (cons 'commit (finding-commit f))
         (cons 'parent (finding-parent f)) (cons 'author_name (finding-author-name f)) (cons 'author_email (finding-author-email f))
         (cons 'time (finding-time f)) (cons 'subject (finding-subject f)) (cons 'files (finding-files f))
@@ -1127,7 +1190,7 @@
 (def (report-json repo findings)
   (list (cons 'detector_version detector-version) (cons 'config_hash (scan-config-hash current-config))
         (cons 'analysis_provider current-analysis-provider) (cons 'provider current-requested-provider)
-        (cons 'llm_used current-llm-used?) (cons 'network_used current-network-used?)
+        (cons 'llm_used current-llm-used?) (cons 'embeddings_used current-embeddings-used?) (cons 'network_used current-network-used?)
         (cons 'repository repo) (cons 'count (length findings))
         (cons 'findings (map (lambda (f) (finding-json repo f)) findings))))
 
@@ -1210,7 +1273,8 @@
         (strategy-catalog-entry "history-timing" "history" 0.15 "weight_history" "Git commit timestamps are not typing timestamps")
         (strategy-catalog-entry "author-baseline" "baseline" 0.00 "weight_baseline" "missing or anomalous baseline is contextual evidence")
         (strategy-catalog-entry "repository-baseline" "baseline" 0.00 "weight_baseline" "repository baseline uses commit-size history only")
-        (strategy-catalog-entry "local-llm-opinion" "model" 0.10 "provider" "local model output is secondary evidence and may be wrong")))
+        (strategy-catalog-entry "local-llm-opinion" "model" 0.10 "provider" "local model output is secondary evidence and may be wrong")
+        (strategy-catalog-entry "local-embedding-similarity" "model" 0.08 "local_embedding_provider_command" "local embedding output is secondary evidence and network access is never used")))
 
 (def (alist-ref/default pairs name fallback)
   (let ([hit (assoc name pairs)])
@@ -1278,6 +1342,7 @@
           (cons 'analysis_provider current-analysis-provider)
           (cons 'provider current-requested-provider)
           (cons 'llm_used current-llm-used?)
+          (cons 'embeddings_used current-embeddings-used?)
           (cons 'network_used current-network-used?)
           (cons 'repository repo)
           (cons 'count (length findings))
@@ -1412,6 +1477,7 @@
           (set! current-config (config-scan-config obj))
           (set! current-excludes (append (config-string-list obj "exclude" '()) current-excludes))
           (set-local-provider-command! (config-string-list obj "local_provider_command" current-local-provider-command))
+(set-local-embedding-provider-command! (config-string-list obj "local_embedding_provider_command" current-local-embedding-provider-command))
           (make-options (options-command opts)
                         (config-string obj "path" (options-path opts))
                         (config-number obj "count" (options-count opts))
@@ -1529,10 +1595,18 @@
            (begin
              (disable-llm!)
              (loop (cdr xs) opts path-set?))]
+[(string=? (car xs) "--no-embeddings")
+           (begin
+             (disable-embeddings!)
+             (loop (cdr xs) opts path-set?))]
           [(string=? (car xs) "--llm")
            (begin
              (request-llm!)
              (loop (cdr xs) opts path-set?))]
+[(string=? (car xs) "--embeddings")
+           (begin
+             (request-embeddings!)
+             (loop (cdr xs) opts path-set?))]
           [(and (string=? (car xs) "--provider") (pair? (cdr xs)))
            (begin
              (set-provider! (cadr xs))
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index f448fef..dfd27e7 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -50,6 +50,7 @@ printf '%s\n' "$json" | grep -q '"config_hash":"[0-9]'
 printf '%s\n' "$json" | grep -q '"analysis_provider":"offline-heuristics"'
 printf '%s\n' "$json" | grep -q '"provider":"none"'
 printf '%s\n' "$json" | grep -q '"llm_used":false'
+printf '%s\n' "$json" | grep -q '"embeddings_used":false'
 printf '%s\n' "$json" | grep -q '"network_used":false'
 printf '%s\n' "$json" | grep -q '"verdict":"recorded-ai-authorship"'
 printf '%s\n' "$json" | grep -q '"recorded_ai_note_present":true'
@@ -260,6 +261,12 @@ repo_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$repo_pr
 printf '%s\n' "$repo_provider_json" | grep -q '"llm_used":false'
 printf '%s\n' "$repo_provider_json" | grep -q 'local provider command inside the scanned repository was refused'
 
+repo_embedding_config="$fixture/repo-local-embedding-provider.json"
+printf '{"local_embedding_provider_command":["%s"]}\n' "$repo_provider_script" > "$repo_embedding_config"
+repo_embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$repo_embedding_config" --format json --count 1 --embeddings)
+printf '%s\n' "$repo_embedding_json" | grep -q '"embeddings_used":false'
+printf '%s\n' "$repo_embedding_json" | grep -q 'embedding provider command inside the scanned repository was refused'
+
 provider_script="$provider_tmp/local-provider.sh"
 {
   printf '#!/usr/bin/env sh\n'
@@ -291,6 +298,28 @@ malformed_provider_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$ma
 printf '%s\n' "$malformed_provider_json" | grep -q '"llm_used":false'
 printf '%s\n' "$malformed_provider_json" | grep -q 'local provider returned malformed JSON or omitted numeric score'
 
+embedding_script="$provider_tmp/local-embedding-provider.sh"
+{
+  printf '#!/usr/bin/env sh\n'
+  printf 'printf '\''{"score":0.84,"reason":"fixture embedding provider","evidence":["embedding matched generated helper text"]}\\n'\''\n'
+} > "$embedding_script"
+chmod +x "$embedding_script"
+embedding_config="$fixture/local-embedding-provider.json"
+printf '{"local_embedding_provider_command":["%s"]}\n' "$embedding_script" > "$embedding_config"
+embedding_not_requested_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$embedding_config" --format json --count 1)
+printf '%s\n' "$embedding_not_requested_json" | grep -q '"embeddings_used":false'
+if printf '%s\n' "$embedding_not_requested_json" | grep -q '"name":"local-embedding-similarity"'; then
+  echo "embedding provider should require explicit --embeddings opt-in" >&2
+  exit 1
+fi
+embedding_json=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$embedding_config" --format json --count 1 --embeddings)
+printf '%s\n' "$embedding_json" | grep -q '"analysis_provider":"offline-heuristics+local-embeddings"'
+printf '%s\n' "$embedding_json" | grep -q '"llm_used":false'
+printf '%s\n' "$embedding_json" | grep -q '"embeddings_used":true'
+printf '%s\n' "$embedding_json" | grep -q '"network_used":false'
+printf '%s\n' "$embedding_json" | grep -q '"name":"local-embedding-similarity"'
+printf '%s\n' "$embedding_json" | grep -q '"evidence":\["embedding matched generated helper text"\]'
+
 no_llm_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --llm --provider local --no-llm)
 if printf '%s\n' "$no_llm_json" | grep -q 'local provider requested'; then
   echo "no-llm should disable requested LLM warning" >&2
@@ -331,6 +360,7 @@ printf '%s\n' "$stats_json" | grep -q '"strategy_hits":\[{"name":"message-style"
 printf '%s\n' "$stats_json" | grep -q '{"name":"cadence-diff-shape","count":1'
 printf '%s\n' "$stats_json" | grep -q '"strategy_catalog":\[{"name":"message-style","category":"text","enabled":true'
 printf '%s\n' "$stats_json" | grep -q '{"name":"local-llm-opinion","category":"model","enabled":true'
+printf '%s\n' "$stats_json" | grep -q '{"name":"local-embedding-similarity","category":"model","enabled":true'
 printf '%s\n' "$stats_json" | grep -q '"uncataloged_strategies":\[\]'
 printf '%s\n' "$stats_json" | grep -q '"warning_counts":\['