Add file attribution summaries

ober

ce6da463e46439e8be4660558b98c8c05d1f7e36

diff --git a/README.md b/README.md
index 5fb8071..1b3c975 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,10 @@ JSON output includes:
 - recorded AI note presence and excerpt
 - structured `recorded_attribution` entries for supported Git AI note line
   ranges (`source`, `tool`, `model`, `session`, `path`, `start`, `end`)
+- `file_findings` entries that attach matching recorded note ranges to changed
+  files without duplicating commit-level heuristic scores
+- `unmatched_recorded_attribution` for recorded ranges whose path was not part
+  of the changed-file set
 - metadata hits
 - raw signal scores, weights, evidence, and limitations
 - aggregate score and verdict
diff --git a/main-binary.ss b/main-binary.ss
index 0001797..6f8f030 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -694,6 +694,27 @@
   (list (cons 'name (signal-name s)) (cons 'category (signal-category s)) (cons 'score (signal-score s))
         (cons 'weight (signal-weight s)) (cons 'confidence (signal-confidence s)) (cons 'reason (signal-reason s))
         (cons 'evidence (signal-evidence s)) (cons 'limitations (signal-limitations s))))
+(def (attribution-path item)
+  (alist-ref/default item 'path ""))
+
+(def (attribution-for-path attribution path)
+  (filter (lambda (item) (same-public-string? (attribution-path item) path)) attribution))
+
+(def (path-listed? path paths)
+  (for/or ([p paths]) (same-public-string? p path)))
+
+(def (unmatched-attribution attribution paths)
+  (filter (lambda (item) (not (path-listed? (attribution-path item) paths))) attribution))
+
+(def (file-finding-json f path)
+  (let ([items (attribution-for-path (finding-attribution f) path)])
+    (list (cons 'path path)
+          (cons 'recorded_attribution items)
+          (cons 'recorded_ai_lines (sum (map attribution-line-count items)))
+          (cons 'commit_signal_categories (unique (map signal-category (finding-signals f))))
+          (cons 'commit_score (finding-score f))
+          (cons 'commit_verdict (finding-verdict f))
+          (cons 'attribution_status (if (pair? items) "matched-note-range" "no-recorded-range")))))
 (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)
@@ -705,6 +726,8 @@
         (cons 'recorded_ai_note_present (not (string-empty? (finding-note f))))
         (cons 'recorded_ai_note_excerpt (if (string-empty? (finding-note f)) "" (substring (finding-note f) 0 (min 400 (string-length (finding-note f))))))
         (cons 'recorded_attribution (finding-attribution f))
+        (cons 'file_findings (map (lambda (path) (file-finding-json f path)) (finding-files f)))
+        (cons 'unmatched_recorded_attribution (unmatched-attribution (finding-attribution f) (finding-files f)))
         (cons 'metadata_hits (finding-metadata f)) (cons 'signals (map signal-json (finding-signals f)))
         (cons 'score (finding-score f)) (cons 'verdict (finding-verdict f)) (cons 'warnings (finding-warnings f))))
 (def (report-json repo findings)
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index 9aa0fdc..82a9b95 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -48,6 +48,9 @@ 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'
 printf '%s\n' "$json" | grep -q '"recorded_attribution":\[{"source":"refs/notes/ai","tool":"codex","model":"gpt-5","session":"","path":"src/generated.py","start":1,"end":360}\]'
+printf '%s\n' "$json" | grep -q '"file_findings":\[{"path":"src/generated.py","recorded_attribution":\[{"source":"refs/notes/ai","tool":"codex","model":"gpt-5","session":"","path":"src/generated.py","start":1,"end":360}\],"recorded_ai_lines":360'
+printf '%s\n' "$json" | grep -q '"attribution_status":"matched-note-range"'
+printf '%s\n' "$json" | grep -q '"unmatched_recorded_attribution":\[\]'
 printf '%s\n' "$json" | grep -q '"metadata_hits":\["codex","openai"\]'
 printf '%s\n' "$json" | grep -q '"category":"code"'
 printf '%s\n' "$json" | grep -q '"category":"structure"'
@@ -174,6 +177,11 @@ printf '%s\n' "$threshold_json" | grep -q '"verdict":"likely-human-style"'
 threshold_triplet_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1 --heuristics-only --threshold 0.99,0.995,1.0)
 printf '%s\n' "$threshold_triplet_json" | grep -q '"verdict":"likely-human-style"'
 
+git -C "$fixture" notes --ref=ai remove HEAD >/dev/null
+git -C "$fixture" notes --ref=ai add -m '{"tool":"codex","model":"gpt-5","lines":[{"path":"missing.py","start":1,"end":2}]}' HEAD
+unmatched_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1)
+printf '%s\n' "$unmatched_json" | grep -q '"unmatched_recorded_attribution":\[{"source":"refs/notes/ai","tool":"codex","model":"gpt-5","session":"","path":"missing.py","start":1,"end":2}\]'
+
 printf 'not json\n' > "$config_file"
 bad_config_table=$("$root/bin/jerboa-aigit" scan "$fixture" --config "$config_file" --count 1)
 printf '%s\n' "$bad_config_table" | grep -q 'commit        score  verdict'