Parse AI note line attribution

ober

61a33da1c37e5f64b626dbe35f9cb05a1d69c2cd

diff --git a/README.md b/README.md
index 0758890..7cbd85a 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ JSON output includes:
 - author/committer-facing metadata
 - changed files, additions, deletions, and added line count
 - recorded AI note presence and excerpt
+- structured `recorded_attribution` entries for supported Git AI note line
+  ranges (`source`, `tool`, `model`, `session`, `path`, `start`, `end`)
 - metadata hits
 - raw signal scores, weights, evidence, and limitations
 - aggregate score and verdict
diff --git a/main-binary.ss b/main-binary.ss
index 3f73fa7..5d94fd0 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -9,7 +9,7 @@
 (defstruct signal (name category score weight confidence reason evidence limitations))
 (defstruct finding
   (commit parent author-name author-email time subject files additions deletions
-   added-lines note metadata signals score verdict warnings))
+   added-lines note attribution metadata signals score verdict warnings))
 
 (def raw-command-line (command-line))
 (def cli-args
@@ -101,6 +101,38 @@
 
 (def (commit-message repo rev) (git repo (list "show" "-s" "--format=%B" rev)))
 (def (note-text repo rev) (string-trim (git repo (list "notes" "--ref=ai" "show" rev))))
+(def (parse-note-object note)
+  (if (string-empty? note)
+      #f
+      (let ([parsed (try-result (with-input-from-string note read-json))])
+        (if (ok? parsed) (unwrap parsed) #f))))
+
+(def (hash-get/default obj key fallback)
+  (if (hash-table? obj)
+      (hash-ref obj key fallback)
+      fallback))
+
+(def (note-line-attribution note-obj line-obj)
+  (list (cons 'source "refs/notes/ai")
+        (cons 'tool (hash-get/default note-obj "tool" ""))
+        (cons 'model (hash-get/default note-obj "model" ""))
+        (cons 'session (hash-get/default note-obj "session" ""))
+        (cons 'path (hash-get/default line-obj "path" ""))
+        (cons 'start (hash-get/default line-obj "start" 0))
+        (cons 'end (hash-get/default line-obj "end" 0))))
+
+(def (note-attributions note-obj)
+  (if (hash-table? note-obj)
+      (let* ([lines (hash-get/default note-obj "lines" '())]
+             [tool (hash-get/default note-obj "tool" "")]
+             [model (hash-get/default note-obj "model" "")]
+             [session (hash-get/default note-obj "session" "")])
+        (cond [(list? lines) (map (lambda (line-obj) (note-line-attribution note-obj line-obj)) lines)]
+              [(or (not (string-empty? tool)) (not (string-empty? model)) (not (string-empty? session)))
+               (list (list (cons 'source "refs/notes/ai") (cons 'tool tool) (cons 'model model)
+                           (cons 'session session) (cons 'path "") (cons 'start 0) (cons 'end 0)))]
+              [else '()]))
+      '()))
 (def (parent-time repo parent)
   (if (string-empty? parent) 0 (parse-int (string-trim (git repo (list "show" "-s" "--format=%ct" parent))) 0)))
 
@@ -326,12 +358,13 @@
                [t (parse-int (safe-ref fields 4 "0") 0)])
           (if (string=? e email) (loop (cdr xs) (cons t out)) (loop (cdr xs) out))))))
 
-(def (warnings files lines note metadata-only? heuristics-only? min-lines)
+(def (warnings files lines note note-obj metadata-only? heuristics-only? min-lines)
   (append (if (null? files) '("no changed text files found or commit is unavailable") '())
           (if (null? lines) '("no added UTF-8 patch lines available") '())
           (if (and (> min-lines 0) (< (length lines) min-lines))
               (list (str "heuristics skipped below --min-lines " min-lines))
               '())
+          (if (and (not (string-empty? note)) (not note-obj)) '("refs/notes/ai note is not supported JSON") '())
           (if (and (string-empty? note) (not metadata-only?)) '("no refs/notes/ai authorship note found") '())
           (if (and metadata-only? heuristics-only?) '("metadata-only and heuristics-only were both requested") '())))
 
@@ -342,7 +375,9 @@
          [time (parse-int (safe-ref fields 4 "0") 0)] [subject (safe-ref fields 5 "")]
          [body (commit-message repo rev)] [files (changed-files repo rev file)] [paths (numstat-paths files)]
          [adds (numstat-adds files)] [dels (numstat-dels files)] [lines (added-lines repo rev file)]
-         [note (note-text repo rev)] [metadata (metadata-hits author-name author-email subject body note)]
+         [note (note-text repo rev)] [note-obj (parse-note-object note)]
+         [attribution (note-attributions note-obj)]
+         [metadata (metadata-hits author-name author-email subject body note)]
          [eligible? (or (= min-lines 0) (>= (length lines) min-lines))]
          [sim-pair (similarity-signal lines hashes)]
          [raw-signals (if eligible?
@@ -353,8 +388,8 @@
          [signals (if metadata-only? '() raw-signals)]
          [score (if metadata-only? 0.0 (aggregate-score signals))]
          [v (if heuristics-only? (verdict score '() "") (verdict score metadata note))])
-    (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note metadata signals score v
-                        (warnings files lines note metadata-only? heuristics-only? min-lines))
+    (list (make-finding id parent author-name author-email time subject paths adds dels (length lines) note attribution metadata signals score v
+                        (warnings files lines note note-obj metadata-only? heuristics-only? min-lines))
           (cadr sim-pair))))
 
 (def (scan-repo repo revs file min-lines metadata-only? heuristics-only?)
@@ -405,6 +440,7 @@
         (cons 'additions (finding-additions f)) (cons 'deletions (finding-deletions f)) (cons 'added_lines (finding-added-lines f))
         (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 '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 f66877c..f873f57 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -38,6 +38,7 @@ json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 2)
 printf '%s\n' "$json" | grep -q '"count":2'
 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 '"metadata_hits":\["codex","openai"\]'
 printf '%s\n' "$json" | grep -q '"category":"code"'
 printf '%s\n' "$json" | grep -q '"category":"structure"'
@@ -87,6 +88,13 @@ range_json=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --from "$bas
 printf '%s\n' "$range_json" | grep -q '"count":1'
 printf '%s\n' "$range_json" | grep -q "$head_rev"
 
+git -C "$fixture" notes --ref=ai remove HEAD >/dev/null
+git -C "$fixture" notes --ref=ai add -m 'not json' HEAD
+malformed=$("$root/bin/jerboa-aigit" scan "$fixture" --format json --count 1)
+printf '%s\n' "$malformed" | grep -q '"recorded_ai_note_present":true'
+printf '%s\n' "$malformed" | grep -q '"recorded_attribution":\[\]'
+printf '%s\n' "$malformed" | grep -q 'refs/notes/ai note is not supported JSON'
+
 if "$root/bin/jerboa-aigit" scan "$fixture/nope" >/tmp/jerboa-aigit-invalid.out 2>&1; then
   echo "invalid repository path should fail" >&2
   exit 1