Browse Source

feat(skills): Refactor agentmail to git-rooted hash identity

Replace fragile basename-based project identity with stable 6-char
hash IDs derived from git root commit (first commit in repo history).
Falls back to canonical path hash for non-git directories.

- Add project_hash() using git rev-list --max-parents=0 HEAD
- Add projects table (hash, name, path, registered) for name resolution
- Add resolve_target() supporting name, hash, or path lookups
- Add identicon.sh - unique pixel-art avatars per project (11x12 grid)
- Add id, migrate, and alias commands
- Update check-mail.sh hook to use git-rooted identity
- Fix test suite cooldown to use hash-based identity (73/73 passing)
- Update SKILL.md with hash identity docs and new commands

Resolves case-sensitivity collisions on macOS and survives directory
renames, moves, and clones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
4808a2365d

+ 31 - 17
hooks/check-mail.sh

@@ -2,17 +2,7 @@
 # hooks/check-mail.sh
 # PreToolUse hook - checks for unread inter-session mail
 # Runs on every tool call. Silent when inbox is empty.
-# Matcher: * (all tools)
-#
-# Configuration in .claude/settings.json or .claude/settings.local.json:
-# {
-#   "hooks": {
-#     "PreToolUse": [{
-#       "matcher": "*",
-#       "hooks": ["bash hooks/check-mail.sh"]
-#     }]
-#   }
-# }
+# Uses hash-based project identity (resolves case sensitivity).
 
 MAIL_DB="$HOME/.claude/mail.db"
 COOLDOWN_SECONDS=10
@@ -23,8 +13,16 @@ COOLDOWN_SECONDS=10
 # Skip if no database exists yet
 [ -f "$MAIL_DB" ] || exit 0
 
-PROJECT=$(basename "$PWD" | sed "s/'/''/g")
-COOLDOWN_FILE="/tmp/agentmail_${PROJECT}"
+# Project identity: git root commit hash, fallback to path hash
+ROOT_COMMIT=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+if [ -n "$ROOT_COMMIT" ]; then
+  PROJECT_HASH="${ROOT_COMMIT:0:6}"
+else
+  CANONICAL=$(cd "$PWD" && pwd -P)
+  PROJECT_HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-6)
+fi
+
+COOLDOWN_FILE="/tmp/agentmail_${PROJECT_HASH}"
 
 # Cooldown: skip if checked recently (within COOLDOWN_SECONDS)
 if [ -f "$COOLDOWN_FILE" ]; then
@@ -37,13 +35,21 @@ fi
 touch "$COOLDOWN_FILE"
 
 # Single fast query - count unread
-UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0;" 2>/dev/null)
+UNREAD=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0;" 2>/dev/null)
 
 # Silent exit if no mail
 [ "${UNREAD:-0}" -eq 0 ] && exit 0
 
 # Check for urgent messages
-URGENT=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT}' AND read=0 AND priority='urgent';" 2>/dev/null)
+URGENT=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 AND priority='urgent';" 2>/dev/null)
+
+# Resolve display names for preview
+show_from() {
+  local hash="$1"
+  local name
+  name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';" 2>/dev/null)
+  [ -n "$name" ] && echo "$name" || echo "$hash"
+}
 
 # Show notification with preview of first 3 messages
 echo ""
@@ -52,8 +58,16 @@ if [ "${URGENT:-0}" -gt 0 ]; then
 else
   echo "=== MAIL: ${UNREAD} unread message(s) ==="
 fi
-sqlite3 -separator '  ' "$MAIL_DB" \
-  "SELECT '  ' || CASE WHEN priority='urgent' THEN '[!] ' ELSE '' END || 'From: ' || from_project || '  |  ' || subject FROM messages WHERE to_project='${PROJECT}' AND read=0 ORDER BY priority DESC, timestamp DESC LIMIT 3;" 2>/dev/null
+
+# Preview messages with resolved names
+while IFS='|' read -r from_hash priority subject; do
+  from_name=$(show_from "$from_hash")
+  prefix=""
+  [ "$priority" = "urgent" ] && prefix="[!] "
+  echo "  ${prefix}From: ${from_name}  |  ${subject}"
+done < <(sqlite3 -separator '|' "$MAIL_DB" \
+  "SELECT from_project, priority, subject FROM messages WHERE to_project='${PROJECT_HASH}' AND read=0 ORDER BY priority DESC, timestamp DESC LIMIT 3;" 2>/dev/null)
+
 if [ "$UNREAD" -gt 3 ]; then
   echo "  ... and $((UNREAD - 3)) more"
 fi

+ 38 - 11
skills/agentmail/SKILL.md

@@ -44,6 +44,8 @@ Parse the user's input after `agentmail` (or `/agentmail`) and run the matching
 | `agentmail alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` |
 | `agentmail purge` | `bash "$MAIL" purge` |
 | `agentmail purge --all` | `bash "$MAIL" purge --all` |
+| `agentmail id` | `bash "$MAIL" id` |
+| `agentmail migrate` | `bash "$MAIL" migrate` |
 | `agentmail init` | `bash "$MAIL" init` |
 
 When the user just says "check mail", "read mail", "inbox", or "any mail?" - run `bash "$MAIL" read`.
@@ -52,11 +54,25 @@ When the user says "send mail to X" or "message X" - parse out the project name,
 
 ## Project Identity
 
-Project name = `basename` of current working directory. No configuration needed.
+Each project gets a stable 6-character hash ID derived from its **git root commit** (the very first commit in the repo). This means:
 
-- `X:\Forge\claude-mods` -> `claude-mods`
-- `X:\Forge\some-api` -> `some-api`
-- `X:\Roam\Fathom` -> `Fathom`
+- IDs survive directory renames, moves, and clones
+- Case-insensitive filesystems (macOS) don't cause collisions
+- Every clone of the same repo shares the same identity
+
+For non-git directories, falls back to a hash of the canonical path (`pwd -P`).
+
+Use `agentmail id` to see your project's name and hash:
+
+```
+claude-mods 7663d6
+```
+
+When sending messages, you can address projects by **name**, **hash**, or **path** - they all resolve to the same hash ID.
+
+### Identicons
+
+Each project hash renders as a unique pixel-art identicon (11x11 symmetric grid using Unicode half-block characters). Run `identicon.sh` to see yours, or view all projects with `agentmail projects`.
 
 ## Passive Notification (Hook)
 
@@ -153,8 +169,11 @@ Without this step, agentmail still works but you have to check manually (`agentm
 ### Verify
 
 ```bash
-# Send yourself a test message
-bash ~/.claude/agentmail/mail-db.sh send "$(basename $PWD)" "Test" "Hello from agentmail"
+# Check your project identity
+bash ~/.claude/agentmail/mail-db.sh id
+
+# Send yourself a test message (use your project name from above)
+bash ~/.claude/agentmail/mail-db.sh send "my-project" "Test" "Hello from agentmail"
 
 # Check it arrived
 bash ~/.claude/agentmail/mail-db.sh read
@@ -177,14 +196,21 @@ Single SQLite file at `~/.claude/mail.db`. Auto-created on first `init` or `send
 ```sql
 CREATE TABLE messages (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
-    from_project TEXT NOT NULL,
-    to_project TEXT NOT NULL,
+    from_project TEXT NOT NULL,   -- 6-char hash ID
+    to_project TEXT NOT NULL,     -- 6-char hash ID
     subject TEXT DEFAULT '',
     body TEXT NOT NULL,
     timestamp TEXT DEFAULT (datetime('now')),
     read INTEGER DEFAULT 0,
     priority TEXT DEFAULT 'normal'
 );
+
+CREATE TABLE projects (
+    hash TEXT PRIMARY KEY,        -- 6-char ID (git root commit or path hash)
+    name TEXT NOT NULL,           -- Display name (basename of project dir)
+    path TEXT NOT NULL,           -- Canonical path
+    registered TEXT DEFAULT (datetime('now'))
+);
 ```
 
 ## Troubleshooting
@@ -194,7 +220,8 @@ CREATE TABLE messages (
 | `sqlite3: not found` | Ships with macOS, Linux, and Git Bash on Windows. Run `sqlite3 --version` to check. |
 | Hook not firing | Ensure `hooks` block is in `~/.claude/settings.json` (Step 2 above) |
 | Hook fires but no notification | Working as intended - hook is silent when inbox is empty |
-| Wrong project name | Uses `basename $PWD` - ensure cwd is the project root, not a subdirectory |
-| Messages not arriving | `to_project` must match the target's directory basename exactly. Use `agentmail projects` to see known names |
-| Renamed a project directory | Use `agentmail alias old-name new-name` to update old messages |
+| Messages not arriving | Target must be a known name, hash, or path. Use `agentmail projects` to see registered projects |
+| Upgraded from basename IDs | Run `agentmail migrate` to convert old messages to hash-based IDs |
+| Changed display name | Use `agentmail alias old-name new-name` to update the project's display name |
 | Want to disable for one project | `touch .claude/agentmail.disable` in that project's root |
+| Check your project ID | Run `agentmail id` to see name and 6-char hash |

+ 157 - 0
skills/agentmail/scripts/identicon.sh

@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+# Generate a symmetric pixel art identicon from a hash
+# Usage: bash identicon.sh <path_or_string> [--compact]
+#
+# 11x11 pixel grid (mirrored from 6 columns), rendered with Unicode
+# half-block characters for double vertical resolution. Each project
+# gets a unique colored portrait derived from sha256 of its canonical path.
+
+set -e
+
+INPUT="${1:-$PWD}"
+COMPACT=false
+[[ "${2:-}" == "--compact" || "${1:-}" == "--compact" ]] && COMPACT=true
+[[ "${1:-}" == "--compact" ]] && INPUT="$PWD"
+
+# Identity: git root commit hash > canonical path hash
+# This must match mail-db.sh project_hash() logic
+if [ -d "$INPUT" ]; then
+    CANONICAL=$(cd "$INPUT" && pwd -P)
+    ROOT_COMMIT=$(git -C "$INPUT" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+    if [ -n "$ROOT_COMMIT" ]; then
+        # Use full root commit for visual entropy, short ID from first 6
+        HASH=$(printf '%s' "$ROOT_COMMIT" | shasum -a 256 | cut -c1-40)
+        SHORT="${ROOT_COMMIT:0:6}"
+    else
+        HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
+        SHORT="${HASH:0:6}"
+    fi
+else
+    CANONICAL="$INPUT"
+    HASH=$(printf '%s' "$CANONICAL" | shasum -a 256 | cut -c1-40)
+    SHORT="${HASH:0:6}"
+fi
+NAME=$(basename "$CANONICAL")
+
+# --- Color palette ---
+# Two colors per identicon: foreground + accent, from different hash regions
+FG_IDX=$(( $(printf '%d' "0x${HASH:6:2}") % 7 ))
+BG_IDX=$(( $(printf '%d' "0x${HASH:8:2}") % 4 ))
+
+# Foreground: vivid ANSI colors
+FG_CODES=(31 32 33 34 35 36 91)
+FG="\033[${FG_CODES[$FG_IDX]}m"
+
+# Shade characters: full, dark, medium, light
+CHARS=("█" "▓" "▒" "░")
+
+RESET="\033[0m"
+DIM="\033[2m"
+
+# --- Build 11x12 pixel grid ---
+# 6 columns generated, mirrored to 11 (c0 c1 c2 c3 c4 c5 c4 c3 c2 c1 c0)
+# 12 rows, rendered as 6 lines using half-block characters
+# Each cell has 2 bits (4 shade levels): 6 cols * 12 rows = 72 cells = 144 bits
+# We have 160 bits from 40 hex chars
+
+declare -a GRID  # GRID[row*6+col] = shade level (0-3)
+
+bit_pos=0
+for row in $(seq 0 11); do
+    for col in $(seq 0 5); do
+        hex_pos=$((bit_pos / 4))
+        bit_offset=$((bit_pos % 4))
+        hex_char="${HASH:$hex_pos:1}"
+        nibble=$(printf '%d' "0x${hex_char}")
+
+        # Extract 2 bits for shade level
+        if [ $bit_offset -le 2 ]; then
+            shade=$(( (nibble >> bit_offset) & 3 ))
+        else
+            # Straddle nibble boundary
+            next_char="${HASH:$((hex_pos+1)):1}"
+            next_nibble=$(printf '%d' "0x${next_char}")
+            shade=$(( ((nibble >> bit_offset) | (next_nibble << (4 - bit_offset))) & 3 ))
+        fi
+
+        GRID[$((row * 6 + col))]=$shade
+        bit_pos=$((bit_pos + 2))
+    done
+done
+
+# --- Render with half-blocks ---
+# Each output line combines two pixel rows using ▀▄█ and space
+# Top pixel = upper half, Bottom pixel = lower half
+#
+# Both filled  = █ (full block)
+# Top only     = ▀ (upper half)
+# Bottom only  = ▄ (lower half)
+# Neither      = " " (space)
+
+get_mirrored_col() {
+    local col=$1
+    # Mirror pattern: 0 1 2 3 4 5 4 3 2 1 0
+    if [ $col -le 5 ]; then
+        echo $col
+    else
+        echo $((10 - col))
+    fi
+}
+
+render_cell() {
+    local top_shade=$1
+    local bot_shade=$2
+
+    # Threshold: shades 0-1 = filled, 2-3 = empty (gives ~50% fill)
+    local top_on=$(( top_shade <= 1 ? 1 : 0 ))
+    local bot_on=$(( bot_shade <= 1 ? 1 : 0 ))
+
+    if [ $top_on -eq 1 ] && [ $bot_on -eq 1 ]; then
+        # Both filled - use shade of top for character choice
+        printf '%s' "${CHARS[$top_shade]}"
+    elif [ $top_on -eq 1 ]; then
+        printf '▀'
+    elif [ $bot_on -eq 1 ]; then
+        printf '▄'
+    else
+        printf ' '
+    fi
+}
+
+# Width: 11 columns, each 1 char wide = 11 chars inside frame
+BORDER_TOP="${DIM}┌───────────┐${RESET}"
+BORDER_BOT="${DIM}└───────────┘${RESET}"
+
+if [ "$COMPACT" = true ]; then
+    # Compact: no frame, just the icon + hash
+    for line in $(seq 0 5); do
+        top_row=$((line * 2))
+        bot_row=$((line * 2 + 1))
+        printf '%b' "${FG}"
+        for col in $(seq 0 10); do
+            src_col=$(get_mirrored_col $col)
+            top_shade=${GRID[$((top_row * 6 + src_col))]}
+            bot_shade=${GRID[$((bot_row * 6 + src_col))]}
+            render_cell $top_shade $bot_shade
+        done
+        printf '%b\n' "${RESET}"
+    done
+    echo -e "${FG}${SHORT}${RESET}"
+else
+    # Framed display
+    echo -e "$BORDER_TOP"
+    for line in $(seq 0 5); do
+        top_row=$((line * 2))
+        bot_row=$((line * 2 + 1))
+        printf '%b' "${DIM}│${RESET}${FG}"
+        for col in $(seq 0 10); do
+            src_col=$(get_mirrored_col $col)
+            top_shade=${GRID[$((top_row * 6 + src_col))]}
+            bot_shade=${GRID[$((bot_row * 6 + src_col))]}
+            render_cell $top_shade $bot_shade
+        done
+        printf '%b\n' "${RESET}${DIM}│${RESET}"
+    done
+    echo -e "$BORDER_BOT"
+    echo -e " ${FG}${NAME}${RESET} ${DIM}${SHORT}${RESET}"
+fi

+ 348 - 102
skills/agentmail/scripts/mail-db.sh

@@ -1,13 +1,57 @@
 #!/bin/bash
 # mail-db.sh - SQLite mail database operations
 # Global mail database at ~/.claude/mail.db
-# Project identity derived from basename of working directory
+# Project identity: 6-char ID derived from git root commit (stable across
+# renames, moves, clones) with fallback to canonical path hash for non-git dirs.
 
 set -euo pipefail
 
 MAIL_DB="$HOME/.claude/mail.db"
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# ============================================================================
+# Identity - git-rooted project IDs
+# ============================================================================
+
+# Get canonical path (resolves symlinks + case on macOS)
+canonical_path() {
+  if [ -d "${1:-$PWD}" ]; then
+    (cd "${1:-$PWD}" && pwd -P)
+  else
+    printf '%s' "${1:-$PWD}"
+  fi
+}
+
+# Generate 6-char project ID
+# Priority: git root commit hash > canonical path hash
+project_hash() {
+  local dir="${1:-$PWD}"
+
+  # Try git root commit (first commit in repo history)
+  if [ -d "$dir" ]; then
+    local root_commit
+    root_commit=$(git -C "$dir" rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+    if [ -n "$root_commit" ]; then
+      echo "${root_commit:0:6}"
+      return 0
+    fi
+  fi
+
+  # Fallback: hash of canonical path
+  local path
+  path=$(canonical_path "$dir")
+  printf '%s' "$path" | shasum -a 256 | cut -c1-6
+}
+
+# Get display name (basename of canonical path)
+project_name() {
+  basename "$(canonical_path "${1:-$PWD}")"
+}
+
+# ============================================================================
+# Database
+# ============================================================================
 
-# Ensure database and schema exist
 init_db() {
   mkdir -p "$(dirname "$MAIL_DB")"
   sqlite3 "$MAIL_DB" <<'SQL'
@@ -23,73 +67,185 @@ CREATE TABLE IF NOT EXISTS messages (
 );
 CREATE INDEX IF NOT EXISTS idx_unread ON messages(to_project, read);
 CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
+
+CREATE TABLE IF NOT EXISTS projects (
+    hash TEXT PRIMARY KEY,
+    name TEXT NOT NULL,
+    path TEXT NOT NULL,
+    registered TEXT DEFAULT (datetime('now'))
+);
 SQL
   # Migration: add priority column if missing
   sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
     sqlite3 "$MAIL_DB" "ALTER TABLE messages ADD COLUMN priority TEXT DEFAULT 'normal';" 2>/dev/null
+  # Migration: create projects table if missing (for existing installs)
+  sqlite3 "$MAIL_DB" "SELECT hash FROM projects LIMIT 0;" 2>/dev/null || \
+    sqlite3 "$MAIL_DB" "CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, registered TEXT DEFAULT (datetime('now')));" 2>/dev/null
 }
 
-# Sanitize string for safe SQL interpolation (escape single quotes)
 sql_escape() {
   printf '%s' "$1" | sed "s/'/''/g"
 }
 
-# Get project name from cwd
-get_project() {
-  basename "$PWD"
+# Register current project in the projects table (idempotent)
+register_project() {
+  local hash name path
+  hash=$(project_hash "${1:-$PWD}")
+  name=$(sql_escape "$(project_name "${1:-$PWD}")")
+  path=$(sql_escape "$(canonical_path "${1:-$PWD}")")
+  sqlite3 "$MAIL_DB" \
+    "INSERT OR REPLACE INTO projects (hash, name, path) VALUES ('${hash}', '${name}', '${path}');"
+}
+
+# Get project ID for current directory
+get_project_id() {
+  project_hash "${1:-$PWD}"
+}
+
+# Resolve a user-supplied name/hash to a project hash
+# Accepts: hash (6 chars), project name, or path
+resolve_target() {
+  local target="$1"
+  local safe_target
+  safe_target=$(sql_escape "$target")
+
+  # 1. Exact hash match
+  if [[ ${#target} -eq 6 ]] && [[ "$target" =~ ^[0-9a-f]+$ ]]; then
+    local found
+    found=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE hash='${safe_target}';")
+    if [ -n "$found" ]; then
+      echo "$found"
+      return 0
+    fi
+  fi
+
+  # 2. Name match (case-insensitive)
+  local by_name
+  by_name=$(sqlite3 "$MAIL_DB" "SELECT hash FROM projects WHERE LOWER(name)=LOWER('${safe_target}') ORDER BY registered DESC LIMIT 1;")
+  if [ -n "$by_name" ]; then
+    echo "$by_name"
+    return 0
+  fi
+
+  # 3. Path match - target might be a directory
+  if [ -d "$target" ]; then
+    local hash
+    hash=$(project_hash "$target")
+    echo "$hash"
+    return 0
+  fi
+
+  # 4. Generate hash from target as a string (for unknown projects)
+  # Register it so replies work
+  local hash
+  hash=$(printf '%s' "$target" | shasum -a 256 | cut -c1-6)
+  sqlite3 "$MAIL_DB" \
+    "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${hash}', '${safe_target}', '${safe_target}');"
+  echo "$hash"
+}
+
+# Look up display name for a hash
+display_name() {
+  local hash="$1"
+  local name
+  name=$(sqlite3 "$MAIL_DB" "SELECT name FROM projects WHERE hash='${hash}';")
+  if [ -n "$name" ]; then
+    echo "$name"
+  else
+    echo "$hash"
+  fi
+}
+
+# ============================================================================
+# Identicon display (inline, compact)
+# ============================================================================
+
+show_identicon() {
+  local target="${1:-$PWD}"
+  if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
+    bash "$SCRIPT_DIR/identicon.sh" "$target"
+  fi
 }
 
-# Count unread messages for current project
+# ============================================================================
+# Mail operations
+# ============================================================================
+
 count_unread() {
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
-  sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;"
+  register_project
+  local pid
+  pid=$(get_project_id)
+  sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;"
 }
 
-# List unread messages (brief) for current project
 list_unread() {
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
-  sqlite3 -separator ' | ' "$MAIL_DB" \
-    "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp DESC;"
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp DESC;")
+  [ -z "$rows" ] && return 0
+  while IFS='|' read -r id from_hash subj ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${ts}"
+  done <<< "$rows"
 }
 
-# Read all unread messages (full) and mark as read
 read_mail() {
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
-  sqlite3 -header -separator ' | ' "$MAIL_DB" \
-    "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${project}' AND read=0 ORDER BY timestamp ASC;"
+  register_project
+  local pid
+  pid=$(get_project_id)
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, body, timestamp FROM messages WHERE to_project='${pid}' AND read=0 ORDER BY timestamp ASC;")
+  if [ -z "$rows" ]; then
+    return 0
+  fi
+  echo "id | from_project | subject | body | timestamp"
+  while IFS='|' read -r id from_hash subj body ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${body} | ${ts}"
+  done <<< "$rows"
   sqlite3 "$MAIL_DB" \
-    "UPDATE messages SET read=1 WHERE to_project='${project}' AND read=0;"
+    "UPDATE messages SET read=1 WHERE to_project='${pid}' AND read=0;"
 }
 
-# Read a single message by ID and mark as read
 read_one() {
   local msg_id="$1"
-  # Validate ID is numeric
   if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
     echo "Error: message ID must be numeric" >&2
     return 1
   fi
   init_db
-  sqlite3 -header -separator ' | ' "$MAIL_DB" \
-    "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};"
+  local row
+  row=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, to_project, subject, body, timestamp FROM messages WHERE id=${msg_id};")
+  if [ -n "$row" ]; then
+    echo "id | from_project | to_project | subject | body | timestamp"
+    local id from_hash to_hash subj body ts
+    IFS='|' read -r id from_hash to_hash subj body ts <<< "$row"
+    local from_name to_name
+    from_name=$(display_name "$from_hash")
+    to_name=$(display_name "$to_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${to_name} (${to_hash}) | ${subj} | ${body} | ${ts}"
+  fi
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
 }
 
-# Send a message (optional --urgent flag before args)
 send() {
   local priority="normal"
   if [ "${1:-}" = "--urgent" ]; then
     priority="urgent"
     shift
   fi
-  local to_project="${1:?to_project required}"
+  local to_input="${1:?to_project required}"
   local subject="${2:-no subject}"
   local body="${3:?body required}"
   if [ -z "$body" ]; then
@@ -97,18 +253,20 @@ send() {
     return 1
   fi
   init_db
-  local from_project
-  from_project=$(sql_escape "$(get_project)")
-  local safe_to safe_subject safe_body
-  safe_to=$(sql_escape "$to_project")
+  register_project
+  local from_id to_id
+  from_id=$(get_project_id)
+  to_id=$(resolve_target "$to_input")
+  local safe_subject safe_body
   safe_subject=$(sql_escape "$subject")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
-    "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}', '${priority}');"
-  echo "Sent to ${to_project}: ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
+    "INSERT INTO messages (from_project, to_project, subject, body, priority) VALUES ('${from_id}', '${to_id}', '${safe_subject}', '${safe_body}', '${priority}');"
+  local to_name
+  to_name=$(display_name "$to_id")
+  echo "Sent to ${to_name} (${to_id}): ${subject}$([ "$priority" = "urgent" ] && echo " [URGENT]" || true)"
 }
 
-# Search messages by keyword
 search() {
   local keyword="$1"
   if [ -z "$keyword" ]; then
@@ -116,33 +274,47 @@ search() {
     return 1
   fi
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local safe_keyword
   safe_keyword=$(sql_escape "$keyword")
-  sqlite3 -header -separator ' | ' "$MAIL_DB" \
-    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;"
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' AND (subject LIKE '%${safe_keyword}%' OR body LIKE '%${safe_keyword}%') ORDER BY timestamp DESC LIMIT 20;")
+  [ -z "$rows" ] && return 0
+  echo "id | from | subject | status | timestamp"
+  while IFS='|' read -r id from_hash subj status ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
+  done <<< "$rows"
 }
 
-# List all messages (read and unread) for current project
 list_all() {
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local limit="${1:-20}"
-  # Validate limit is numeric
   if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
     limit=20
   fi
-  sqlite3 -header -separator ' | ' "$MAIL_DB" \
-    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END as status, timestamp FROM messages WHERE to_project='${project}' ORDER BY timestamp DESC LIMIT ${limit};"
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT id, from_project, subject, CASE WHEN read=0 THEN 'UNREAD' ELSE 'read' END, timestamp FROM messages WHERE to_project='${pid}' ORDER BY timestamp DESC LIMIT ${limit};")
+  [ -z "$rows" ] && return 0
+  echo "id | from | subject | status | timestamp"
+  while IFS='|' read -r id from_hash subj status ts; do
+    local from_name
+    from_name=$(display_name "$from_hash")
+    echo "${id} | ${from_name} (${from_hash}) | ${subj} | ${status} | ${ts}"
+  done <<< "$rows"
 }
 
-# Clear old read messages (default: older than 7 days)
 clear_old() {
   init_db
   local days="${1:-7}"
-  # Validate days is numeric
   if ! [[ "$days" =~ ^[0-9]+$ ]]; then
     days=7
   fi
@@ -152,7 +324,6 @@ clear_old() {
   echo "Cleared ${deleted} read messages older than ${days} days"
 }
 
-# Reply to a message by ID
 reply() {
   local msg_id="$1"
   local body="$2"
@@ -165,28 +336,28 @@ reply() {
     return 1
   fi
   init_db
-  # Get original sender and subject
+  register_project
   local orig
   orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
   if [ -z "$orig" ]; then
     echo "Error: message #${msg_id} not found" >&2
     return 1
   fi
-  local orig_from orig_subject
-  orig_from=$(echo "$orig" | cut -d'|' -f1)
+  local orig_from_hash orig_subject
+  orig_from_hash=$(echo "$orig" | cut -d'|' -f1)
   orig_subject=$(echo "$orig" | cut -d'|' -f2)
-  local from_project
-  from_project=$(sql_escape "$(get_project)")
-  local safe_to safe_subject safe_body
-  safe_to=$(sql_escape "$orig_from")
+  local from_id
+  from_id=$(get_project_id)
+  local safe_subject safe_body
   safe_subject=$(sql_escape "Re: ${orig_subject}")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
-    "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_project}', '${safe_to}', '${safe_subject}', '${safe_body}');"
-  echo "Replied to ${orig_from}: Re: ${orig_subject}"
+    "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${orig_from_hash}', '${safe_subject}', '${safe_body}');"
+  local orig_name
+  orig_name=$(display_name "$orig_from_hash")
+  echo "Replied to ${orig_name} (${orig_from_hash}): Re: ${orig_subject}"
 }
 
-# Broadcast a message to all known projects (except self)
 broadcast() {
   local subject="$1"
   local body="$2"
@@ -195,48 +366,46 @@ broadcast() {
     return 1
   fi
   init_db
-  local from_project
-  from_project=$(get_project)
+  register_project
+  local from_id
+  from_id=$(get_project_id)
   local targets
   targets=$(sqlite3 "$MAIL_DB" \
-    "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;")
+    "SELECT hash FROM projects WHERE hash != '${from_id}' ORDER BY name;")
   local count=0
-  local safe_subject safe_body safe_from
-  safe_from=$(sql_escape "$from_project")
+  local safe_subject safe_body
   safe_subject=$(sql_escape "$subject")
   safe_body=$(sql_escape "$body")
-  while IFS= read -r target; do
-    [ -z "$target" ] && continue
-    [ "$target" = "$from_project" ] && continue
-    local safe_to
-    safe_to=$(sql_escape "$target")
+  while IFS= read -r target_hash; do
+    [ -z "$target_hash" ] && continue
     sqlite3 "$MAIL_DB" \
-      "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${safe_from}', '${safe_to}', '${safe_subject}', '${safe_body}');"
+      "INSERT INTO messages (from_project, to_project, subject, body) VALUES ('${from_id}', '${target_hash}', '${safe_subject}', '${safe_body}');"
     count=$((count + 1))
   done <<< "$targets"
   echo "Broadcast to ${count} project(s): ${subject}"
 }
 
-# Show inbox status summary
 status() {
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local unread total
-  unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}' AND read=0;")
-  total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${project}';")
-  local projects
-  projects=$(sqlite3 "$MAIL_DB" \
-    "SELECT COUNT(DISTINCT from_project) FROM messages WHERE to_project='${project}' AND read=0;")
+  unread=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0;")
+  total=$(sqlite3 "$MAIL_DB" "SELECT COUNT(*) FROM messages WHERE to_project='${pid}';")
   echo "Inbox: ${unread} unread / ${total} total"
   if [ "${unread:-0}" -gt 0 ]; then
-    echo "From: ${projects} project(s)"
-    sqlite3 -separator ': ' "$MAIL_DB" \
-      "SELECT from_project, COUNT(*) || ' message(s)' FROM messages WHERE to_project='${project}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;"
+    local senders
+    senders=$(sqlite3 -separator '|' "$MAIL_DB" \
+      "SELECT from_project, COUNT(*) FROM messages WHERE to_project='${pid}' AND read=0 GROUP BY from_project ORDER BY COUNT(*) DESC;")
+    while IFS='|' read -r from_hash cnt; do
+      local from_name
+      from_name=$(display_name "$from_hash")
+      echo "  ${from_name} (${from_hash}): ${cnt} message(s)"
+    done <<< "$senders"
   fi
 }
 
-# Purge all messages for current project (or all projects with --all)
 purge() {
   init_db
   if [ "${1:-}" = "--all" ]; then
@@ -244,16 +413,18 @@ purge() {
     count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
     echo "Purged all ${count} message(s) from database"
   else
-    local project
-    project=$(sql_escape "$(get_project)")
+    register_project
+    local pid
+    pid=$(get_project_id)
     local count
     count=$(sqlite3 "$MAIL_DB" \
-      "DELETE FROM messages WHERE to_project='${project}' OR from_project='${project}'; SELECT changes();")
-    echo "Purged ${count} message(s) for $(get_project)"
+      "DELETE FROM messages WHERE to_project='${pid}' OR from_project='${pid}'; SELECT changes();")
+    local name
+    name=$(project_name)
+    echo "Purged ${count} message(s) for ${name} (${pid})"
   fi
 }
 
-# Rename a project in all messages (for directory renames/moves)
 alias_project() {
   local old_name="$1"
   local new_name="$2"
@@ -262,28 +433,99 @@ alias_project() {
     return 1
   fi
   init_db
-  local safe_old safe_new
-  safe_old=$(sql_escape "$old_name")
+  # Resolve old name to hash, then update the display name
+  local old_hash
+  old_hash=$(resolve_target "$old_name")
+  local safe_new
   safe_new=$(sql_escape "$new_name")
-  local updated=0
-  local count
-  count=$(sqlite3 "$MAIL_DB" \
-    "UPDATE messages SET from_project='${safe_new}' WHERE from_project='${safe_old}'; SELECT changes();")
-  updated=$((updated + count))
-  count=$(sqlite3 "$MAIL_DB" \
-    "UPDATE messages SET to_project='${safe_new}' WHERE to_project='${safe_old}'; SELECT changes();")
-  updated=$((updated + count))
-  echo "Renamed '${old_name}' -> '${new_name}' in ${updated} message(s)"
+  local safe_old
+  safe_old=$(sql_escape "$old_name")
+  sqlite3 "$MAIL_DB" \
+    "UPDATE projects SET name='${safe_new}' WHERE hash='${old_hash}';"
+  # Also update path if it matches the old name (phantom projects)
+  sqlite3 "$MAIL_DB" \
+    "UPDATE projects SET path='${safe_new}' WHERE hash='${old_hash}' AND path='${safe_old}';"
+  echo "Renamed '${old_name}' -> '${new_name}' (hash: ${old_hash})"
 }
 
-# List all known projects (that have sent or received mail)
 list_projects() {
   init_db
-  sqlite3 "$MAIL_DB" \
-    "SELECT DISTINCT from_project FROM messages UNION SELECT DISTINCT to_project FROM messages ORDER BY 1;"
+  register_project
+  local rows
+  rows=$(sqlite3 -separator '|' "$MAIL_DB" \
+    "SELECT hash, name, path FROM projects ORDER BY name;")
+  [ -z "$rows" ] && echo "No known projects" && return 0
+  local my_id
+  my_id=$(get_project_id)
+  while IFS='|' read -r hash name path; do
+    local marker=""
+    [ "$hash" = "$my_id" ] && marker=" (you)"
+    echo ""
+    # Show identicon if available
+    if [ -f "$SCRIPT_DIR/identicon.sh" ]; then
+      bash "$SCRIPT_DIR/identicon.sh" "$path" --compact 2>/dev/null || true
+    fi
+    echo "${name} ${hash}${marker}"
+    echo "${path}"
+  done <<< "$rows"
 }
 
+# Migrate old basename-style messages to hash IDs
+migrate() {
+  init_db
+  register_project
+  echo "Migrating old messages to hash-based IDs..."
+  # Find all unique project names in messages that aren't 6-char hex hashes
+  local old_names
+  old_names=$(sqlite3 "$MAIL_DB" \
+    "SELECT DISTINCT from_project FROM messages WHERE LENGTH(from_project) != 6 OR from_project GLOB '*[^0-9a-f]*' UNION SELECT DISTINCT to_project FROM messages WHERE LENGTH(to_project) != 6 OR to_project GLOB '*[^0-9a-f]*';")
+  if [ -z "$old_names" ]; then
+    echo "No messages need migration."
+    return 0
+  fi
+  local count=0
+  while IFS= read -r old_name; do
+    [ -z "$old_name" ] && continue
+    # Try to find the project path - check common locations
+    local found_path=""
+    for base_dir in "$HOME/projects" "$HOME/Projects" "$HOME/code" "$HOME/Code" "$HOME/dev" "$HOME/repos"; do
+      if [ -d "${base_dir}/${old_name}" ]; then
+        found_path=$(cd "${base_dir}/${old_name}" && pwd -P)
+        break
+      fi
+    done
+
+    local new_hash
+    if [ -n "$found_path" ]; then
+      new_hash=$(printf '%s' "$found_path" | shasum -a 256 | cut -c1-6)
+      local safe_name safe_path
+      safe_name=$(sql_escape "$old_name")
+      safe_path=$(sql_escape "$found_path")
+      sqlite3 "$MAIL_DB" \
+        "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_path}');"
+    else
+      # Can't find directory - hash the name itself
+      new_hash=$(printf '%s' "$old_name" | shasum -a 256 | cut -c1-6)
+      local safe_name
+      safe_name=$(sql_escape "$old_name")
+      sqlite3 "$MAIL_DB" \
+        "INSERT OR IGNORE INTO projects (hash, name, path) VALUES ('${new_hash}', '${safe_name}', '${safe_name}');"
+    fi
+
+    local safe_old
+    safe_old=$(sql_escape "$old_name")
+    sqlite3 "$MAIL_DB" "UPDATE messages SET from_project='${new_hash}' WHERE from_project='${safe_old}';"
+    sqlite3 "$MAIL_DB" "UPDATE messages SET to_project='${new_hash}' WHERE to_project='${safe_old}';"
+    echo "  ${old_name} -> ${new_hash}$([ -n "$found_path" ] && echo " (${found_path})" || echo " (name only)")"
+    count=$((count + 1))
+  done <<< "$old_names"
+  echo "Migrated ${count} project name(s)."
+}
+
+# ============================================================================
 # Dispatch
+# ============================================================================
+
 case "${1:-help}" in
   init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
   count)      count_unread ;;
@@ -299,16 +541,19 @@ case "${1:-help}" in
   purge)      purge "${2:-}" ;;
   alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
   projects)   list_projects ;;
+  migrate)    migrate ;;
+  id)         init_db; register_project; echo "$(project_name) $(get_project_id)" ;;
   help)
     echo "Usage: mail-db.sh <command> [args]"
     echo ""
     echo "Commands:"
     echo "  init                    Initialize database"
+    echo "  id                      Show this project's name and hash"
     echo "  count                   Count unread messages"
     echo "  unread                  List unread messages (brief)"
     echo "  read [id]               Read messages and mark as read"
     echo "  send [--urgent] <to> <subj> <body>"
-    echo "                          Send a message"
+    echo "                          Send a message (to = name, hash, or path)"
     echo "  reply <id> <body>       Reply to a message"
     echo "  list [limit]            List recent messages (default 20)"
     echo "  clear [days]            Clear read messages older than N days"
@@ -316,8 +561,9 @@ case "${1:-help}" in
     echo "  search <keyword>        Search messages by keyword"
     echo "  status                  Inbox summary"
     echo "  purge [--all]           Delete all messages for this project"
-    echo "  alias <old> <new>       Rename project in all messages"
-    echo "  projects                List known projects"
+    echo "  alias <old> <new>       Rename project display name"
+    echo "  projects                List known projects with identicons"
+    echo "  migrate                 Convert old basename messages to hash IDs"
     ;;
   *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
 esac

+ 14 - 3
skills/agentmail/scripts/test-mail.sh

@@ -86,8 +86,19 @@ assert_exit_code() {
 }
 
 # Helper: clear hook cooldown so next hook call fires
+# Uses git root commit hash (matches check-mail.sh identity logic)
 clear_cooldown() {
-  rm -f /tmp/agentmail_claude-mods 2>/dev/null
+  local root_commit
+  root_commit=$(git rev-list --max-parents=0 HEAD 2>/dev/null | head -1)
+  if [ -n "$root_commit" ]; then
+    rm -f "/tmp/agentmail_${root_commit:0:6}" 2>/dev/null
+  else
+    local canonical
+    canonical=$(cd "$PWD" && pwd -P)
+    local hash
+    hash=$(printf '%s' "$canonical" | shasum -a 256 | cut -c1-6)
+    rm -f "/tmp/agentmail_${hash}" 2>/dev/null
+  fi
 }
 
 # --- Setup: clean slate ---
@@ -476,7 +487,7 @@ echo "=== Performance ==="
 # T52: Hook cooldown - second call within cooldown is silent
 bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
 # Clear cooldown file for this project
-rm -f /tmp/agentmail_claude-mods 2>/dev/null
+clear_cooldown
 # First call should show mail
 result1=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_contains "hook fires on first call" "MAIL" "$result1"
@@ -486,7 +497,7 @@ result2=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_empty "hook silent during cooldown" "$result2"
 
 # Cleanup
-rm -f /tmp/agentmail_claude-mods 2>/dev/null
+clear_cooldown
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 echo ""