Parse git-ai authorship note format

ober

77e0226be82cfef6cf5e7a8319bb67cd695671b2

diff --git a/GAPS.md b/GAPS.md
index fe228cd..4a8133f 100644
--- a/GAPS.md
+++ b/GAPS.md
@@ -295,6 +295,12 @@ Acceptance criteria:
 - Preserve unsupported fields in a bounded raw/extra object or warning.
 - Add malformed and alternate-format fixtures.
 
+Status: implemented for git-ai `authorship/3.0.0` text+JSON commit notes,
+including prompt/session hash attestations, external session IDs, quoted file
+paths, simple ranges and range lists, unsupported metadata warning, and smoke
+fixtures. Batch note lookup remains tracked by G-031. Recovered attribution and
+broader non-note inference remain tracked by G-032.
+
 ### G-031: Batch note resolution missing
 
 `git-ai` can resolve notes in batches for commit/blob objects. This project
diff --git a/main-binary.ss b/main-binary.ss
index a06bec9..d9ca378 100644
--- a/main-binary.ss
+++ b/main-binary.ss
@@ -214,12 +214,102 @@
   (let ([parsed (try-result (with-input-from-string text read-json))])
     (if (ok? parsed) (unwrap parsed) #f)))
 (def (parse-note-object note)
-  (if (string-empty? note) #f (parse-json-object note)))
+  (if (string-empty? note)
+      #f
+      (let ([json-note (parse-json-object note)])
+        (if json-note json-note (parse-git-ai-note-object note)))))
 
 (def (hash-get/default obj key fallback)
   (if (hash-table? obj)
       (hash-ref obj key fallback)
       fallback))
+(def git-ai-attestations-key "__git_ai_attestations")
+(def (git-ai-unquote-path line)
+  (let ([n (string-length line)])
+    (if (and (>= n 2)
+             (char=? (string-ref line 0) #\")
+             (char=? (string-ref line (- n 1)) #\"))
+        (substring line 1 (- n 1))
+        line)))
+(def (git-ai-parse-range part)
+  (let ([idx (string-contains part "-")])
+    (if idx
+        (list (parse-int (substring part 0 idx) 0)
+              (parse-int (substring part (+ idx 1) (string-length part)) 0))
+        (let ([n (parse-int part 0)]) (list n n)))))
+(def (git-ai-parse-ranges ranges)
+  (map git-ai-parse-range
+       (filter (lambda (part) (not (blank? part))) (string-split ranges #\,))))
+(def (git-ai-entry-rows path entry-line)
+  (let* ([parts (filter (lambda (part) (not (blank? part))) (string-split (string-trim entry-line) #\space))]
+         [hash (safe-ref parts 0 "")]
+         [ranges (safe-ref parts 1 "")])
+    (map (lambda (pair)
+           (list (cons 'hash hash)
+                 (cons 'path path)
+                 (cons 'start (safe-ref pair 0 0))
+                 (cons 'end (safe-ref pair 1 0))))
+         (git-ai-parse-ranges ranges))))
+(def (git-ai-note-sections note)
+  (let loop ([xs (split-lines note)] [before '()])
+    (cond [(null? xs) #f]
+          [(same-public-string? (car xs) "---") (list (reverse before) (cdr xs))]
+          [else (loop (cdr xs) (cons (car xs) before))])))
+(def (git-ai-attestation-rows attestation-lines)
+  (let loop ([xs attestation-lines] [path ""] [out '()])
+    (cond [(null? xs) (reverse out)]
+          [(blank? (car xs)) (loop (cdr xs) path out)]
+          [(string-prefix? "  " (car xs))
+           (loop (cdr xs) path (append (reverse (git-ai-entry-rows path (substring (car xs) 2 (string-length (car xs))))) out))]
+          [else (loop (cdr xs) (git-ai-unquote-path (string-trim (car xs))) out)])))
+(def (parse-git-ai-note-object note)
+  (let ([sections (git-ai-note-sections note)])
+    (if sections
+        (let* ([attestation-lines (safe-ref sections 0 '())]
+               [json-lines (safe-ref sections 1 '())]
+               [obj (parse-json-object (string-join json-lines "\n"))])
+          (if (and (hash-table? obj)
+                   (same-public-string? (hash-get/default obj "schema_version" "") "authorship/3.0.0"))
+              (begin
+                (hash-put! obj git-ai-attestations-key (git-ai-attestation-rows attestation-lines))
+                obj)
+              #f))
+        #f)))
+(def (git-ai-session-key hash)
+  (let ([idx (string-contains hash "::")])
+    (if idx (substring hash 0 idx) hash)))
+(def (git-ai-record-for-hash note-obj hash)
+  (cond [(string-prefix? "h_" hash)
+         (hash-get/default (hash-get/default note-obj "humans" #f) hash #f)]
+        [(string-prefix? "s_" hash)
+         (hash-get/default (hash-get/default note-obj "sessions" #f) (git-ai-session-key hash) #f)]
+        [else (hash-get/default (hash-get/default note-obj "prompts" #f) hash #f)]))
+(def (git-ai-agent-field record field)
+  (hash-get/default (hash-get/default record "agent_id" #f) field ""))
+(def (git-ai-row-attribution note-obj row)
+  (let* ([hash (alist-ref/default row 'hash "")]
+         [record (git-ai-record-for-hash note-obj hash)]
+         [human? (string-prefix? "h_" hash)]
+         [tool (if human? "known-human" (git-ai-agent-field record "tool"))]
+         [model (if human? "" (git-ai-agent-field record "model"))]
+         [session (if human? (hash-get/default record "author" hash) (git-ai-agent-field record "id"))])
+    (list (cons 'source "refs/notes/ai")
+          (cons 'tool tool)
+          (cons 'model model)
+          (cons 'session session)
+          (cons 'path (alist-ref/default row 'path ""))
+          (cons 'start (alist-ref/default row 'start 0))
+          (cons 'end (alist-ref/default row 'end 0))
+          (cons 'format "git-ai-authorship/3.0.0")
+          (cons 'hash hash))))
+(def (git-ai-note-object? note-obj)
+  (and (hash-table? note-obj)
+       (list? (hash-get/default note-obj git-ai-attestations-key #f))))
+
+(def (git-ai-note-warnings note-obj)
+  (if (git-ai-note-object? note-obj)
+      '("git-ai authorship note parsed; unsupported metadata preserved in recorded note excerpt")
+      '()))
 
 (def (note-line-attribution note-obj line-obj)
   (list (cons 'source "refs/notes/ai")
@@ -232,11 +322,13 @@
 
 (def (note-attributions note-obj)
   (if (hash-table? note-obj)
-      (let* ([lines (hash-get/default note-obj "lines" '())]
+      (let* ([git-ai-rows (hash-get/default note-obj git-ai-attestations-key #f)]
+             [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)]
+        (cond [(list? git-ai-rows) (map (lambda (row) (git-ai-row-attribution note-obj row)) git-ai-rows)]
+              [(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)))]
@@ -1117,6 +1209,7 @@
               (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") '())
+          (git-ai-note-warnings note-obj)
           (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") '())))
 
diff --git a/tests/fixture-smoke.sh b/tests/fixture-smoke.sh
index b72009a..9133653 100755
--- a/tests/fixture-smoke.sh
+++ b/tests/fixture-smoke.sh
@@ -11,7 +11,8 @@ normal_timing_fixture=$(mktemp -d)
 similarity_fixture=$(mktemp -d)
 baseline_fixture=$(mktemp -d)
 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_ai_note_fixture=$(mktemp -d)
+trap 'rm -rf "$fixture" "$shallow" "$provider_tmp" "$shape_fixture" "$timing_fixture" "$normal_timing_fixture" "$similarity_fixture" "$baseline_fixture" "$identity_fixture" "$git_ai_note_fixture"' EXIT
 
 git -C "$fixture" init -q
 git -C "$fixture" config user.name "Human Dev"
@@ -87,6 +88,72 @@ if printf '%s\n' "$json" | grep -q 'vendor/library.py'; then
   exit 1
 fi
 
+git -C "$git_ai_note_fixture" init -q
+git -C "$git_ai_note_fixture" config user.name "Human Dev"
+git -C "$git_ai_note_fixture" config user.email "human@example.test"
+mkdir -p "$git_ai_note_fixture/src" "$git_ai_note_fixture/docs"
+i=1
+while [ "$i" -le 12 ]; do
+  printf 'pub fn generated_%s() -> usize { %s }\n' "$i" "$i" >> "$git_ai_note_fixture/src/example.rs"
+  i=$((i + 1))
+done
+printf 'first\nsecond\nthird\n' > "$git_ai_note_fixture/docs/my file.md"
+git -C "$git_ai_note_fixture" add src/example.rs "docs/my file.md"
+GIT_AUTHOR_DATE='2026-07-29T09:02:00-06:00' \
+GIT_COMMITTER_DATE='2026-07-29T09:02:00-06:00' \
+  git -C "$git_ai_note_fixture" commit -q -m 'add git ai authored files'
+git_ai_note_file="$provider_tmp/git-ai-note.txt"
+cat > "$git_ai_note_file" <<'NOTE'
+src/example.rs
+  c9883b0 1-3,5
+  s_abcdef12345678::t_deadbeef000001 7-9
+"docs/my file.md"
+  c9883b0 1
+---
+{
+  "schema_version": "authorship/3.0.0",
+  "git_ai_version": "development",
+  "base_commit_sha": "",
+  "prompts": {
+    "c9883b0": {
+      "agent_id": {
+        "tool": "cursor",
+        "id": "session_123",
+        "model": "claude-3-sonnet"
+      },
+      "human_author": null,
+      "messages": [],
+      "total_additions": 0,
+      "total_deletions": 0,
+      "accepted_lines": 0
+    }
+  },
+  "sessions": {
+    "s_abcdef12345678": {
+      "agent_id": {
+        "tool": "claude",
+        "id": "external_session_9",
+        "model": "opus"
+      },
+      "human_author": null
+    }
+  }
+}
+NOTE
+git -C "$git_ai_note_fixture" notes --ref=ai add -F "$git_ai_note_file" HEAD
+git_ai_json=$("$root/bin/jerboa-aigit" scan "$git_ai_note_fixture" --format json --count 1)
+printf '%s\n' "$git_ai_json" | grep -q '"recorded_ai_note_present":true'
+printf '%s\n' "$git_ai_json" | grep -q '"format":"git-ai-authorship/3.0.0"'
+printf '%s\n' "$git_ai_json" | grep -q '"tool":"cursor","model":"claude-3-sonnet","session":"session_123","path":"src/example.rs","start":1,"end":3'
+printf '%s\n' "$git_ai_json" | grep -q '"tool":"cursor","model":"claude-3-sonnet","session":"session_123","path":"src/example.rs","start":5,"end":5'
+printf '%s\n' "$git_ai_json" | grep -q '"tool":"claude","model":"opus","session":"external_session_9","path":"src/example.rs","start":7,"end":9'
+printf '%s\n' "$git_ai_json" | grep -q '"path":"docs/my file.md","start":1,"end":1'
+printf '%s\n' "$git_ai_json" | grep -q 'git-ai authorship note parsed; unsupported metadata preserved in recorded note excerpt'
+if printf '%s\n' "$git_ai_json" | grep -q 'refs/notes/ai note is not supported JSON'; then
+  echo "git-ai authorship note should not be reported as unsupported JSON" >&2
+  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"