Make agent identity catalog configurable

ober

4d8f2737f74f911663a548d6d7c059f37f22b631

diff --git a/GAPS.md b/GAPS.md
index 7e8fa5e..fe228cd 100644
--- a/GAPS.md
+++ b/GAPS.md
@@ -322,6 +322,11 @@ Acceptance criteria:
 
 Known AI tools are detected through hard-coded strings.
 
+Status: implemented with a default identity catalog plus user-defined
+`known_agents` config entries. Matching now uses token/phrase matching instead
+of raw substring checks, with fixture coverage for provider-substring false
+positives and custom identities.
+
 Acceptance criteria:
 
 - Move known tool/provider identity patterns into config/data.
diff --git a/main-binary.ss b/main-binary.ss
index 650cb39..a06bec9 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -349,12 +349,20 @@
   (count-where (lambda (line) (not (blank? line)))
                (bounded-added-lines-for-paths repo rev (numstat-paths (changed-files repo rev file)) file)))
 
-(def known-agents
-  '("codex" "copilot" "claude" "cursor" "openai" "anthropic" "aider" "windsurf" "cody" "tabnine" "ai-agent"))
-
+(def default-known-agents
+  '("codex" "copilot" "github copilot" "claude" "cursor" "openai" "anthropic" "aider" "windsurf" "cody" "tabnine" "ai-agent"))
+(def current-known-agents default-known-agents)
+(def (set-known-agents! parts)
+  (if (list? parts)
+      (set! current-known-agents (unique (append default-known-agents (filter string? parts))))))
+(def (identity-token-hit? tokens marker)
+  (let ([parts (source-lexemes (list marker))])
+    (and (pair? parts)
+         (every (lambda (part) (member part tokens)) parts))))
 (def (metadata-hits author-name author-email subject body note)
-  (let ([text (down (string-join (list author-name author-email subject body note) "\n"))])
-    (filter (lambda (marker) (contains? text marker)) known-agents)))
+  (let* ([text (down (string-join (list author-name author-email subject body note) "\n"))]
+         [tokens (source-lexemes (list text))])
+    (filter (lambda (marker) (identity-token-hit? tokens marker)) current-known-agents)))
 
 (def (sig name category score weight confidence reason evidence limitations)
   (make-signal name category (clamp01 score) weight confidence reason evidence limitations))
@@ -1528,6 +1536,7 @@
           (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))
+(set-known-agents! (config-string-list obj "known_agents" current-known-agents))
           (make-options (options-command opts)
                         (config-string obj "path" (options-path opts))
                         (config-number obj "count" (options-count opts))
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 638b9d7..b72009a 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -10,7 +10,8 @@ timing_fixture=$(mktemp -d)
 normal_timing_fixture=$(mktemp -d)
 similarity_fixture=$(mktemp -d)
 baseline_fixture=$(mktemp -d)
-trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture"' EXIT
+identity_fixture=$(mktemp -d)
+trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture"' EXIT
 
 git -C "$fixture" init -q
 git -C "$fixture" config user.name "Human Dev"
@@ -86,6 +87,28 @@ if printf '%s\n' "$json" | grep -q 'vendor/library.py'; then
   exit 1
 fi
 
+git -C "$identity_fixture" init -q
+git -C "$identity_fixture" config user.name "Human Decoder"
+git -C "$identity_fixture" config user.email "decodex@example.test"
+printf 'identity false positive guard\n' > "$identity_fixture/README.md"
+git -C "$identity_fixture" add README.md
+GIT_AUTHOR_DATE='2026-07-29T07:30:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T07:30:00-06:00' \
+  git -C "$identity_fixture" commit -q -m 'openair cursorial cleanup'
+identity_json=$("$root/bin/jerboa-aigit" scan "$identity_fixture" --format json --count 1)
+printf '%s\n' "$identity_json" | grep -q '"metadata_hits":\[\]'
+git -C "$identity_fixture" config user.name "MyBot"
+git -C "$identity_fixture" config user.email "mybot@example.test"
+printf 'custom identity\n' >> "$identity_fixture/README.md"
+git -C "$identity_fixture" add README.md
+GIT_AUTHOR_DATE='2026-07-29T07:31:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T07:31:00-06:00' \
+  git -C "$identity_fixture" commit -q -m 'custom bot change'
+identity_config="$identity_fixture/aigit.json"
+printf '{"known_agents":["mybot"]}\n' > "$identity_config"
+identity_custom_json=$("$root/bin/jerboa-aigit" scan "$identity_fixture" --config "$identity_config" --format json --count 1)
+printf '%s\n' "$identity_custom_json" | grep -q '"metadata_hits":\["mybot"\]'
+
 git -C "$shape_fixture" init -q
 git -C "$shape_fixture" config user.name "Human Dev"
 git -C "$shape_fixture" config user.email "human@example.test"