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
 # hooks/check-mail.sh
 # PreToolUse hook - checks for unread inter-session mail
 # PreToolUse hook - checks for unread inter-session mail
 # Runs on every tool call. Silent when inbox is empty.
 # 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"
 MAIL_DB="$HOME/.claude/mail.db"
 COOLDOWN_SECONDS=10
 COOLDOWN_SECONDS=10
@@ -23,8 +13,16 @@ COOLDOWN_SECONDS=10
 # Skip if no database exists yet
 # Skip if no database exists yet
 [ -f "$MAIL_DB" ] || exit 0
 [ -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)
 # Cooldown: skip if checked recently (within COOLDOWN_SECONDS)
 if [ -f "$COOLDOWN_FILE" ]; then
 if [ -f "$COOLDOWN_FILE" ]; then
@@ -37,13 +35,21 @@ fi
 touch "$COOLDOWN_FILE"
 touch "$COOLDOWN_FILE"
 
 
 # Single fast query - count unread
 # 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
 # Silent exit if no mail
 [ "${UNREAD:-0}" -eq 0 ] && exit 0
 [ "${UNREAD:-0}" -eq 0 ] && exit 0
 
 
 # Check for urgent messages
 # 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
 # Show notification with preview of first 3 messages
 echo ""
 echo ""
@@ -52,8 +58,16 @@ if [ "${URGENT:-0}" -gt 0 ]; then
 else
 else
   echo "=== MAIL: ${UNREAD} unread message(s) ==="
   echo "=== MAIL: ${UNREAD} unread message(s) ==="
 fi
 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
 if [ "$UNREAD" -gt 3 ]; then
   echo "  ... and $((UNREAD - 3)) more"
   echo "  ... and $((UNREAD - 3)) more"
 fi
 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 alias <old> <new>` | `bash "$MAIL" alias "<old>" "<new>"` |
 | `agentmail purge` | `bash "$MAIL" purge` |
 | `agentmail purge` | `bash "$MAIL" purge` |
 | `agentmail purge --all` | `bash "$MAIL" purge --all` |
 | `agentmail purge --all` | `bash "$MAIL" purge --all` |
+| `agentmail id` | `bash "$MAIL" id` |
+| `agentmail migrate` | `bash "$MAIL" migrate` |
 | `agentmail init` | `bash "$MAIL" init` |
 | `agentmail init` | `bash "$MAIL" init` |
 
 
 When the user just says "check mail", "read mail", "inbox", or "any mail?" - run `bash "$MAIL" read`.
 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 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)
 ## Passive Notification (Hook)
 
 
@@ -153,8 +169,11 @@ Without this step, agentmail still works but you have to check manually (`agentm
 ### Verify
 ### Verify
 
 
 ```bash
 ```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
 # Check it arrived
 bash ~/.claude/agentmail/mail-db.sh read
 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
 ```sql
 CREATE TABLE messages (
 CREATE TABLE messages (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     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 '',
     subject TEXT DEFAULT '',
     body TEXT NOT NULL,
     body TEXT NOT NULL,
     timestamp TEXT DEFAULT (datetime('now')),
     timestamp TEXT DEFAULT (datetime('now')),
     read INTEGER DEFAULT 0,
     read INTEGER DEFAULT 0,
     priority TEXT DEFAULT 'normal'
     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
 ## 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. |
 | `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 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 |
 | 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 |
 | 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
 #!/bin/bash
 # mail-db.sh - SQLite mail database operations
 # mail-db.sh - SQLite mail database operations
 # Global mail database at ~/.claude/mail.db
 # 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
 set -euo pipefail
 
 
 MAIL_DB="$HOME/.claude/mail.db"
 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() {
 init_db() {
   mkdir -p "$(dirname "$MAIL_DB")"
   mkdir -p "$(dirname "$MAIL_DB")"
   sqlite3 "$MAIL_DB" <<'SQL'
   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_unread ON messages(to_project, read);
 CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
 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
 SQL
   # Migration: add priority column if missing
   # Migration: add priority column if missing
   sqlite3 "$MAIL_DB" "SELECT priority FROM messages LIMIT 0;" 2>/dev/null || \
   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
     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() {
 sql_escape() {
   printf '%s' "$1" | sed "s/'/''/g"
   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() {
 count_unread() {
   init_db
   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() {
 list_unread() {
   init_db
   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() {
 read_mail() {
   init_db
   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" \
   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() {
 read_one() {
   local msg_id="$1"
   local msg_id="$1"
-  # Validate ID is numeric
   if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
   if ! [[ "$msg_id" =~ ^[0-9]+$ ]]; then
     echo "Error: message ID must be numeric" >&2
     echo "Error: message ID must be numeric" >&2
     return 1
     return 1
   fi
   fi
   init_db
   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" \
   sqlite3 "$MAIL_DB" \
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
     "UPDATE messages SET read=1 WHERE id=${msg_id};"
 }
 }
 
 
-# Send a message (optional --urgent flag before args)
 send() {
 send() {
   local priority="normal"
   local priority="normal"
   if [ "${1:-}" = "--urgent" ]; then
   if [ "${1:-}" = "--urgent" ]; then
     priority="urgent"
     priority="urgent"
     shift
     shift
   fi
   fi
-  local to_project="${1:?to_project required}"
+  local to_input="${1:?to_project required}"
   local subject="${2:-no subject}"
   local subject="${2:-no subject}"
   local body="${3:?body required}"
   local body="${3:?body required}"
   if [ -z "$body" ]; then
   if [ -z "$body" ]; then
@@ -97,18 +253,20 @@ send() {
     return 1
     return 1
   fi
   fi
   init_db
   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_subject=$(sql_escape "$subject")
   safe_body=$(sql_escape "$body")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
   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() {
 search() {
   local keyword="$1"
   local keyword="$1"
   if [ -z "$keyword" ]; then
   if [ -z "$keyword" ]; then
@@ -116,33 +274,47 @@ search() {
     return 1
     return 1
   fi
   fi
   init_db
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local safe_keyword
   local safe_keyword
   safe_keyword=$(sql_escape "$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() {
 list_all() {
   init_db
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local limit="${1:-20}"
   local limit="${1:-20}"
-  # Validate limit is numeric
   if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
   if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
     limit=20
     limit=20
   fi
   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() {
 clear_old() {
   init_db
   init_db
   local days="${1:-7}"
   local days="${1:-7}"
-  # Validate days is numeric
   if ! [[ "$days" =~ ^[0-9]+$ ]]; then
   if ! [[ "$days" =~ ^[0-9]+$ ]]; then
     days=7
     days=7
   fi
   fi
@@ -152,7 +324,6 @@ clear_old() {
   echo "Cleared ${deleted} read messages older than ${days} days"
   echo "Cleared ${deleted} read messages older than ${days} days"
 }
 }
 
 
-# Reply to a message by ID
 reply() {
 reply() {
   local msg_id="$1"
   local msg_id="$1"
   local body="$2"
   local body="$2"
@@ -165,28 +336,28 @@ reply() {
     return 1
     return 1
   fi
   fi
   init_db
   init_db
-  # Get original sender and subject
+  register_project
   local orig
   local orig
   orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
   orig=$(sqlite3 -separator '|' "$MAIL_DB" "SELECT from_project, subject FROM messages WHERE id=${msg_id};")
   if [ -z "$orig" ]; then
   if [ -z "$orig" ]; then
     echo "Error: message #${msg_id} not found" >&2
     echo "Error: message #${msg_id} not found" >&2
     return 1
     return 1
   fi
   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)
   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_subject=$(sql_escape "Re: ${orig_subject}")
   safe_body=$(sql_escape "$body")
   safe_body=$(sql_escape "$body")
   sqlite3 "$MAIL_DB" \
   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() {
 broadcast() {
   local subject="$1"
   local subject="$1"
   local body="$2"
   local body="$2"
@@ -195,48 +366,46 @@ broadcast() {
     return 1
     return 1
   fi
   fi
   init_db
   init_db
-  local from_project
-  from_project=$(get_project)
+  register_project
+  local from_id
+  from_id=$(get_project_id)
   local targets
   local targets
   targets=$(sqlite3 "$MAIL_DB" \
   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 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_subject=$(sql_escape "$subject")
   safe_body=$(sql_escape "$body")
   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" \
     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))
     count=$((count + 1))
   done <<< "$targets"
   done <<< "$targets"
   echo "Broadcast to ${count} project(s): ${subject}"
   echo "Broadcast to ${count} project(s): ${subject}"
 }
 }
 
 
-# Show inbox status summary
 status() {
 status() {
   init_db
   init_db
-  local project
-  project=$(sql_escape "$(get_project)")
+  register_project
+  local pid
+  pid=$(get_project_id)
   local unread total
   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"
   echo "Inbox: ${unread} unread / ${total} total"
   if [ "${unread:-0}" -gt 0 ]; then
   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
   fi
 }
 }
 
 
-# Purge all messages for current project (or all projects with --all)
 purge() {
 purge() {
   init_db
   init_db
   if [ "${1:-}" = "--all" ]; then
   if [ "${1:-}" = "--all" ]; then
@@ -244,16 +413,18 @@ purge() {
     count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
     count=$(sqlite3 "$MAIL_DB" "DELETE FROM messages; SELECT changes();")
     echo "Purged all ${count} message(s) from database"
     echo "Purged all ${count} message(s) from database"
   else
   else
-    local project
-    project=$(sql_escape "$(get_project)")
+    register_project
+    local pid
+    pid=$(get_project_id)
     local count
     local count
     count=$(sqlite3 "$MAIL_DB" \
     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
   fi
 }
 }
 
 
-# Rename a project in all messages (for directory renames/moves)
 alias_project() {
 alias_project() {
   local old_name="$1"
   local old_name="$1"
   local new_name="$2"
   local new_name="$2"
@@ -262,28 +433,99 @@ alias_project() {
     return 1
     return 1
   fi
   fi
   init_db
   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")
   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() {
 list_projects() {
   init_db
   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
 # Dispatch
+# ============================================================================
+
 case "${1:-help}" in
 case "${1:-help}" in
   init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
   init)       init_db && echo "Mail database initialized at $MAIL_DB" ;;
   count)      count_unread ;;
   count)      count_unread ;;
@@ -299,16 +541,19 @@ case "${1:-help}" in
   purge)      purge "${2:-}" ;;
   purge)      purge "${2:-}" ;;
   alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
   alias)      alias_project "${2:?old name required}" "${3:?new name required}" ;;
   projects)   list_projects ;;
   projects)   list_projects ;;
+  migrate)    migrate ;;
+  id)         init_db; register_project; echo "$(project_name) $(get_project_id)" ;;
   help)
   help)
     echo "Usage: mail-db.sh <command> [args]"
     echo "Usage: mail-db.sh <command> [args]"
     echo ""
     echo ""
     echo "Commands:"
     echo "Commands:"
     echo "  init                    Initialize database"
     echo "  init                    Initialize database"
+    echo "  id                      Show this project's name and hash"
     echo "  count                   Count unread messages"
     echo "  count                   Count unread messages"
     echo "  unread                  List unread messages (brief)"
     echo "  unread                  List unread messages (brief)"
     echo "  read [id]               Read messages and mark as read"
     echo "  read [id]               Read messages and mark as read"
     echo "  send [--urgent] <to> <subj> <body>"
     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 "  reply <id> <body>       Reply to a message"
     echo "  list [limit]            List recent messages (default 20)"
     echo "  list [limit]            List recent messages (default 20)"
     echo "  clear [days]            Clear read messages older than N days"
     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 "  search <keyword>        Search messages by keyword"
     echo "  status                  Inbox summary"
     echo "  status                  Inbox summary"
     echo "  purge [--all]           Delete all messages for this project"
     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 ;;
   *)          echo "Unknown command: $1. Run with 'help' for usage." >&2; exit 1 ;;
 esac
 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
 # Helper: clear hook cooldown so next hook call fires
+# Uses git root commit hash (matches check-mail.sh identity logic)
 clear_cooldown() {
 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 ---
 # --- Setup: clean slate ---
@@ -476,7 +487,7 @@ echo "=== Performance ==="
 # T52: Hook cooldown - second call within cooldown is silent
 # T52: Hook cooldown - second call within cooldown is silent
 bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
 bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
 # Clear cooldown file for this project
 # Clear cooldown file for this project
-rm -f /tmp/agentmail_claude-mods 2>/dev/null
+clear_cooldown
 # First call should show mail
 # First call should show mail
 result1=$(bash "$HOOK_SCRIPT" 2>&1)
 result1=$(bash "$HOOK_SCRIPT" 2>&1)
 assert_contains "hook fires on first call" "MAIL" "$result1"
 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"
 assert_empty "hook silent during cooldown" "$result2"
 
 
 # Cleanup
 # Cleanup
-rm -f /tmp/agentmail_claude-mods 2>/dev/null
+clear_cooldown
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 bash "$MAIL_SCRIPT" read >/dev/null 2>&1
 
 
 echo ""
 echo ""