#!/bin/bash # ClaudeTools Bidirectional Sync Script # Ensures proper pull BEFORE push on all machines # Prints incoming/outgoing change summary with author attribution set -e # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' # --- Guard: strip Windows path-as-filename cruft before staging ------------- # Windows machines occasionally drop files literally named like "C:\path" or # ".claude\current-mode" into the repo. The illegal ':' / '\' chars get stored # as Unicode Private-Use-Area substitutes (U+F03A, U+F00A, ...). A blind # `git add -A` would stage and propagate them to Gitea. This removes any such # UNTRACKED path before staging. Only untracked files are touched — tracked # content is never auto-deleted (use `git rm` for that, deliberately). purge_garbled_paths() { local rec st p removed=0 # POSIX ERE (not PCRE): `grep -P` refuses to run under non-UTF-8/unibyte locales on # some platforms (Git Bash printed "grep: -P supports only unibyte and UTF-8 locales", # silently disabling this guard). ERE has no such restriction; LC_ALL=C makes the match # byte-wise. Same byte set as before: control chars / backslash / colon, plus the MSYS2 # Private-Use-Area substitutes (0xEE 0x80-0xBF = U+E0xx, 0xEF 0x80-0xA3 = U+F0xx) that # stand in for : \ newline etc. in Windows path-as-filename cruft. local garble_re=$'[\001-\037\\:]|\356[\200-\277]|\357[\200-\243]' while IFS= read -r -d '' rec; do st="${rec:0:2}" p="${rec:3}" [ -z "$p" ] && continue [ "$st" = "??" ] || continue # untracked only if printf '%s' "$p" | LC_ALL=C grep -qaE "$garble_re"; then echo -e "${YELLOW}[WARNING]${NC} Removing garbled untracked path before staging: $(printf '%s' "$p" | cat -v)" rm -f -- "$p" 2>/dev/null || true removed=1 fi done < <(git status --porcelain -z) [ "$removed" = 1 ] && echo -e "${YELLOW}[WARNING]${NC} Garbled Windows path-as-filename cruft removed (not synced)." return 0 } # --- Attribution: force commit authorship to match identity.json ------------- # Commit author is the per-person source of truth and must NEVER ride on stale # local `git config`. If config disagrees with identity.json, correct the LOCAL # repo config (not --global) so this machine's commits are always attributed to # the human who actually owns this identity. Call once per repo (claudetools, # then vault) before any commit happens. reconcile_git_identity() { local want_name="$1" want_email="$2" cur if [ -n "$want_name" ]; then cur=$(git config user.name 2>/dev/null || true) if [ "$cur" != "$want_name" ]; then echo -e "${YELLOW}[WARNING]${NC} git user.name '${cur}' -> '${want_name}' (corrected to match identity.json)" git config user.name "$want_name" fi fi if [ -n "$want_email" ]; then cur=$(git config user.email 2>/dev/null || true) if [ "$cur" != "$want_email" ]; then echo -e "${YELLOW}[WARNING]${NC} git user.email '${cur}' -> '${want_email}' (corrected to match identity.json)" git config user.email "$want_email" fi fi return 0 } # Machine + timestamp if [ -n "$COMPUTERNAME" ]; then MACHINE="$COMPUTERNAME" else MACHINE=$(hostname) fi TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S") echo -e "${GREEN}[OK]${NC} Starting ClaudeTools sync from $MACHINE at $TIMESTAMP" # Navigate to ClaudeTools directory # Read from identity.json (machine-specific, set during onboarding) IDENTITY_PATH="" for candidate in "$HOME/.claude/identity.json" ".claude/identity.json"; do if [ -f "$candidate" ]; then IDENTITY_PATH="$candidate" break fi done if [ -n "$IDENTITY_PATH" ] && command -v jq >/dev/null 2>&1; then REPO_ROOT=$(jq -r '.claudetools_root // empty' "$IDENTITY_PATH" 2>/dev/null) fi # Fallback: git detection if identity.json doesn't have claudetools_root yet if [ -z "$REPO_ROOT" ]; then REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) fi # Last resort: hardcoded paths (legacy machines) if [ -z "$REPO_ROOT" ]; then for candidate in "$HOME/ClaudeTools" "/d/ClaudeTools" "D:/ClaudeTools" "/d/claudetools" "D:/claudetools" "C:/claudetools" "/c/claudetools"; do if [ -d "$candidate/.git" ]; then REPO_ROOT="$candidate" break fi done fi if [ -z "$REPO_ROOT" ] || [ ! -d "$REPO_ROOT/.git" ]; then echo -e "${RED}[ERROR]${NC} Cannot locate ClaudeTools repo. Add 'claudetools_root' to identity.json" exit 1 fi cd "$REPO_ROOT" echo -e "${GREEN}[OK]${NC} Working directory: $(pwd)" # Detect Python interpreter — verify it actually runs (Windows Store stub passes command -v but fails to execute) PYTHON="" for candidate in py python3 python; do if command -v "$candidate" >/dev/null 2>&1; then if "$candidate" -c "import sys; sys.exit(0)" >/dev/null 2>&1; then PYTHON="$candidate" break fi fi done if [ -z "$PYTHON" ]; then echo -e "${RED}[ERROR]${NC} No Python interpreter found (tried: py, python3, python)" exit 1 fi # Load user identity USER_DISPLAY="unknown" USER_GITEA="" USER_EMAIL="" if [ -f ".claude/identity.json" ]; then USER_DISPLAY=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('full_name', d.get('user','unknown')))" 2>/dev/null || echo "unknown") USER_GITEA=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "") USER_EMAIL=$($PYTHON -c "import json,sys; d=json.load(open('.claude/identity.json')); print(d.get('email',''))" 2>/dev/null || echo "") fi echo -e "${GREEN}[OK]${NC} Syncing as: $USER_DISPLAY (machine: $MACHINE)" # Force this repo's commit authorship to match identity.json before any commit. # Skip if identity.json was unreadable (USER_DISPLAY left at the "unknown" # sentinel) — overwriting correct config with "unknown" would be worse than # leaving it alone, and is the exact mis-attribution this guard prevents. if [ "$USER_DISPLAY" != "unknown" ]; then reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL" else echo -e "${YELLOW}[WARNING]${NC} identity.json present but unreadable — leaving existing git config untouched (not reconciling)." fi # Warn (don't block) if identity.json's machine field disagrees with the real # hostname — the classic "stale identity.json copied to a new box" failure that # stamps logs/commits with the wrong machine. Compare case-insensitively and # ignore a trailing ".local" (macOS). if [ -f ".claude/identity.json" ]; then ID_MACHINE=$($PYTHON -c "import json; print(json.load(open('.claude/identity.json')).get('machine',''))" 2>/dev/null || echo "") norm() { printf '%s' "$1" | tr 'A-Z' 'a-z' | sed 's/\.local$//'; } if [ -n "$ID_MACHINE" ] && [ "$(norm "$ID_MACHINE")" != "$(norm "$MACHINE")" ]; then echo -e "${YELLOW}[WARNING]${NC} identity.json machine '${ID_MACHINE}' != hostname '${MACHINE}' — identity.json may be stale on this box. Fix it before attributing work." fi fi # Phase 1a: Update submodules to latest remote # `git submodule foreach` only visits *initialized* submodules, so a fresh clone # would silently skip population and the old "[OK] updated" message lied. We now # explicitly init+populate each submodule declared in .gitmodules, then advance # it to its remote branch tip. Iterating .gitmodules (not the index) sidesteps # any orphaned gitlink that lacks a .gitmodules mapping. Credentials are inherited # from the parent origin URL so non-interactive init works without a credential # helper; .gitmodules itself stays credential-free. echo "" echo "=== Phase 1a: Submodule update ===" if [ -f ".gitmodules" ] && git config --file .gitmodules --get-regexp '^submodule\..*\.path$' >/dev/null 2>&1; then # Temporarily disable errexit — submodule ops emit non-fatal warnings that # would otherwise kill the script under set -e. set +e # If origin embeds credentials (https://user:pass@host/...), reuse its # scheme+userinfo+host for submodule clones. PARENT_URL="$(git config --get remote.origin.url)" CRED_HOST="" case "$PARENT_URL" in https://*@*) CRED_HOST="$(printf '%s' "$PARENT_URL" | sed -E 's#^(https://[^/]+)/.*#\1#')" ;; esac SUB_COUNT=0 while read -r pkey ppath; do [ -z "$ppath" ] && continue name="$(printf '%s' "$pkey" | sed -E 's#^submodule\.(.*)\.path$#\1#')" SUB_COUNT=$((SUB_COUNT + 1)) # Register in .git/config (no-op if already initialized). git submodule init -- "$ppath" >/dev/null 2>&1 # Override the local clone URL with credentials when available. if [ -n "$CRED_HOST" ]; then gm_url="$(git config --file .gitmodules --get "submodule.${name}.url")" case "$gm_url" in https://*) sub_path="$(printf '%s' "$gm_url" | sed -E 's#^https://[^/]+(/.*)#\1#')" git config "submodule.${name}.url" "${CRED_HOST}${sub_path}" ;; esac fi # Populate if missing (fresh clone) at the pinned commit. git submodule update --init -- "$ppath" >/dev/null 2>&1 done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$') # Advance each initialized submodule to its remote branch tip. git submodule foreach --quiet ' git fetch origin --quiet 2>/dev/null git checkout main --quiet 2>/dev/null || git checkout master --quiet 2>/dev/null git merge --ff-only origin/main --quiet 2>/dev/null || \ git merge --ff-only origin/master --quiet 2>/dev/null ' 2>/dev/null set -e echo -e "${GREEN}[OK]${NC} Submodules init+advanced (${SUB_COUNT} configured)." else echo -e "${GREEN}[OK]${NC} No submodules." fi # Phase 1: Local changes echo "" echo "=== Phase 1: Local changes ===" # Detect any change including untracked-only (git diff-index ignores untracked files, # so a brand-new file with no tracked changes would otherwise be silently skipped). if [ -n "$(git status --porcelain)" ]; then echo -e "${YELLOW}[INFO]${NC} Local changes detected:" git status --short echo "" echo -e "${GREEN}[OK]${NC} Staging all changes..." purge_garbled_paths git add -A # Commit message (Co-Authored-By uses local git user if configured) COMMIT_MSG="sync: auto-sync from $MACHINE at $TIMESTAMP Author: $USER_DISPLAY Machine: $MACHINE Timestamp: $TIMESTAMP" if git diff-index --quiet --cached HEAD -- 2>/dev/null; then echo -e "${GREEN}[OK]${NC} No stageable changes (submodule internal changes skipped)." else git commit -m "$COMMIT_MSG" echo -e "${GREEN}[OK]${NC} Committed." fi else echo -e "${GREEN}[OK]${NC} No local changes to commit." fi # Phase 2: Remote sync echo "" echo "=== Phase 2: Fetch + inspect ===" LOCAL_BEFORE=$(git rev-parse HEAD) echo -e "${GREEN}[OK]${NC} Fetching from origin..." git fetch origin --quiet LOCAL=$(git rev-parse HEAD) REMOTE=$(git rev-parse origin/main 2>/dev/null || git rev-parse origin/master 2>/dev/null || echo "$LOCAL") REMOTE_BRANCH="origin/main" if ! git rev-parse origin/main >/dev/null 2>&1; then REMOTE_BRANCH="origin/master" fi # Count and show incoming INCOMING_COUNT=$(git rev-list --count HEAD..$REMOTE_BRANCH 2>/dev/null || echo 0) OUTGOING_COUNT=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0) if [ "$INCOMING_COUNT" -gt 0 ]; then echo "" echo -e "${CYAN}--- Incoming: $INCOMING_COUNT commits from remote ---${NC}" git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' HEAD..$REMOTE_BRANCH | head -30 echo "" echo -e "${CYAN}--- Files touched by incoming commits ---${NC}" git diff --stat HEAD..$REMOTE_BRANCH | tail -20 # Wiki summary section WIKI_CHANGES=$(git diff --name-status HEAD..$REMOTE_BRANCH -- 'wiki/*.md' 'wiki/*/*.md' 2>/dev/null || true) if [ -n "$WIKI_CHANGES" ]; then echo "" echo -e "${CYAN}--- Wiki knowledge layer updates ---${NC}" # Categorize by type WIKI_CLIENTS=$(echo "$WIKI_CHANGES" | grep 'wiki/clients/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/clients/||' | sed 's|\.md$||') WIKI_PROJECTS=$(echo "$WIKI_CHANGES" | grep 'wiki/projects/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/projects/||' | sed 's|\.md$||') WIKI_SYSTEMS=$(echo "$WIKI_CHANGES" | grep 'wiki/systems/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/systems/||' | sed 's|\.md$||') WIKI_PATTERNS=$(echo "$WIKI_CHANGES" | grep 'wiki/patterns/' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/patterns/||' | sed 's|\.md$||') WIKI_META=$(echo "$WIKI_CHANGES" | grep -E 'wiki/(index|overview)\.md' | awk '{printf " %s %s\n", $1, $2}' | sed 's|wiki/||' | sed 's|\.md$||') [ -n "$WIKI_CLIENTS" ] && echo -e "${CYAN}Clients:${NC}\n$WIKI_CLIENTS" [ -n "$WIKI_PROJECTS" ] && echo -e "${CYAN}Projects:${NC}\n$WIKI_PROJECTS" [ -n "$WIKI_SYSTEMS" ] && echo -e "${CYAN}Systems:${NC}\n$WIKI_SYSTEMS" [ -n "$WIKI_PATTERNS" ] && echo -e "${CYAN}Patterns:${NC}\n$WIKI_PATTERNS" [ -n "$WIKI_META" ] && echo -e "${CYAN}Meta:${NC}\n$WIKI_META" # Count by status WIKI_ADDED=$(echo "$WIKI_CHANGES" | grep -c '^A' || echo 0) WIKI_MODIFIED=$(echo "$WIKI_CHANGES" | grep -c '^M' || echo 0) WIKI_DELETED=$(echo "$WIKI_CHANGES" | grep -c '^D' || echo 0) echo -e "${CYAN}Summary:${NC} $WIKI_ADDED added, $WIKI_MODIFIED modified, $WIKI_DELETED deleted" fi else echo -e "${GREEN}[OK]${NC} No incoming changes." fi if [ "$OUTGOING_COUNT" -gt 0 ]; then echo "" echo -e "${CYAN}--- Outgoing: $OUTGOING_COUNT commits to remote ---${NC}" git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' $REMOTE_BRANCH..HEAD | head -30 fi # Phase 3: Pull (if needed) if [ "$INCOMING_COUNT" -gt 0 ]; then echo "" echo "=== Phase 3: Pull (rebase) ===" if git pull origin main --rebase; then echo -e "${GREEN}[OK]${NC} Pulled successfully." else echo -e "${RED}[ERROR]${NC} Pull failed (likely conflicts). Resolve and re-run sync." exit 1 fi fi # Phase 4: Push (if needed) OUTGOING_AFTER_PULL=$(git rev-list --count $REMOTE_BRANCH..HEAD 2>/dev/null || echo 0) if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then echo "" echo "=== Phase 4: Push ===" if git push origin main; then echo -e "${GREEN}[OK]${NC} Pushed successfully." else echo -e "${RED}[ERROR]${NC} Push failed. Check auth / network." exit 1 fi else echo -e "${GREEN}[OK]${NC} Nothing to push." fi # Phase 5: Scan pulled session logs for cross-user messages # Look for "## Note for" or "## Message for" sections in any session log # touched by incoming commits. Print them prominently so they aren't missed. if [ "$INCOMING_COUNT" -gt 0 ] && [ -n "$LOCAL_BEFORE" ]; then CHANGED_LOGS=$(git diff --name-only "$LOCAL_BEFORE"..HEAD -- '**/session-logs/*.md' 'session-logs/*.md' 2>/dev/null || true) if [ -n "$CHANGED_LOGS" ]; then NOTES_FOUND=0 for LOG_FILE in $CHANGED_LOGS; do if [ -f "$LOG_FILE" ]; then # Extract author from "## User" block and any "## Note for" / "## Message for" sections NOTE_CONTENT=$(awk ' /^## (Note|Message) for /{ in_note=1; header=$0; next } in_note && /^## /{ in_note=0 } in_note{ buf=buf"\n"$0 } END{ if(buf) print header buf } ' "$LOG_FILE") if [ -n "$NOTE_CONTENT" ]; then if [ "$NOTES_FOUND" -eq 0 ]; then echo "" echo -e "${YELLOW}============================================================${NC}" echo -e "${YELLOW} MESSAGES FROM OTHER TEAM MEMBERS${NC}" echo -e "${YELLOW}============================================================${NC}" NOTES_FOUND=1 fi LOG_AUTHOR=$(awk '/^- \*\*User:\*\*/{print; exit}' "$LOG_FILE" | sed 's/.*\*\*User:\*\* //') LOG_DATE=$(basename "$LOG_FILE" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1) echo "" echo -e "${YELLOW} From: ${LOG_AUTHOR:-unknown} | ${LOG_DATE:-unknown date} | ${LOG_FILE}${NC}" echo -e "${YELLOW}------------------------------------------------------------${NC}" echo "$NOTE_CONTENT" fi fi done if [ "$NOTES_FOUND" -gt 0 ]; then echo "" echo -e "${YELLOW}============================================================${NC}" echo -e "${YELLOW} Address the above before continuing with other work.${NC}" echo -e "${YELLOW}============================================================${NC}" echo "" fi fi fi # Phase 5b: Apply config — sync slash commands to the global Claude dir. # The repo's .claude/commands/ is canonical; this keeps ~/.claude/commands current # so the CLI loads the latest skills without a manual copy. One-way (repo -> global), # idempotent (only copies new/changed files), and soft-fails so it never aborts a sync. echo "" echo "=== Phase 5b: Apply config (commands -> global) ===" GLOBAL_CMD_DIR="$HOME/.claude/commands" set +e mkdir -p "$GLOBAL_CMD_DIR" CMD_UPDATED=0 CMD_NAMES="" for src in .claude/commands/*.md; do [ -f "$src" ] || continue dst="$GLOBAL_CMD_DIR/$(basename "$src")" if [ ! -f "$dst" ] || ! cmp -s "$src" "$dst"; then if cp "$src" "$dst"; then CMD_UPDATED=$((CMD_UPDATED + 1)) CMD_NAMES="$CMD_NAMES $(basename "$src")" fi fi done set -e if [ "$CMD_UPDATED" -gt 0 ]; then echo -e "${GREEN}[OK]${NC} Commands synced to global: $CMD_UPDATED updated —$CMD_NAMES" else echo -e "${GREEN}[OK]${NC} Global commands already current." fi # Phase 6: Vault sync echo "" echo "=== Phase 6: Vault sync ===" VAULT_PATH="" if [ -f ".claude/identity.json" ]; then VAULT_PATH=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('vault_path',''))" 2>/dev/null || echo "") fi if [ -z "$VAULT_PATH" ]; then echo -e "${YELLOW}[INFO]${NC} vault_path not set in identity.json — skipping vault sync." elif [ ! -d "$VAULT_PATH/.git" ]; then echo -e "${YELLOW}[WARNING]${NC} Vault path '$VAULT_PATH' is not a git repo — skipping." else CLAUDETOOLS_DIR=$(pwd) cd "$VAULT_PATH" echo -e "${GREEN}[OK]${NC} Vault: $VAULT_PATH" # Same attribution guard for the vault repo (it has its own git config). # Same "unknown" skip as the main repo — the main-repo warning already fired. if [ "$USER_DISPLAY" != "unknown" ]; then reconcile_git_identity "$USER_DISPLAY" "$USER_EMAIL" fi # Commit any local vault changes (porcelain catches untracked-only too) if [ -n "$(git status --porcelain)" ]; then echo -e "${YELLOW}[INFO]${NC} Local vault changes detected — committing..." purge_garbled_paths git add -A git commit -m "sync: auto-sync vault from $MACHINE at $TIMESTAMP" echo -e "${GREEN}[OK]${NC} Vault committed." else echo -e "${GREEN}[OK]${NC} No local vault changes." fi VAULT_LOCAL_BEFORE=$(git rev-parse HEAD) git fetch origin --quiet VAULT_REMOTE_BRANCH="origin/main" if ! git rev-parse origin/main >/dev/null 2>&1; then VAULT_REMOTE_BRANCH="origin/master" fi VAULT_INCOMING=$(git rev-list --count HEAD..$VAULT_REMOTE_BRANCH 2>/dev/null || echo 0) VAULT_OUTGOING=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0) if [ "$VAULT_INCOMING" -gt 0 ]; then echo -e "${CYAN}--- Vault: $VAULT_INCOMING incoming commit(s) ---${NC}" git log --oneline --format=' %C(yellow)%h%Creset %C(cyan)%an%Creset %s %C(dim)(%ar)%Creset' HEAD..$VAULT_REMOTE_BRANCH | head -10 if git pull origin main --rebase; then echo -e "${GREEN}[OK]${NC} Vault pulled." else echo -e "${RED}[ERROR]${NC} Vault pull failed — resolve conflicts manually in $VAULT_PATH." fi else echo -e "${GREEN}[OK]${NC} Vault: no incoming changes." fi VAULT_OUTGOING_AFTER=$(git rev-list --count $VAULT_REMOTE_BRANCH..HEAD 2>/dev/null || echo 0) if [ "$VAULT_OUTGOING_AFTER" -gt 0 ]; then if git push origin main; then echo -e "${GREEN}[OK]${NC} Vault pushed ($VAULT_OUTGOING_AFTER commit(s))." else echo -e "${RED}[ERROR]${NC} Vault push failed." fi else echo -e "${GREEN}[OK]${NC} Vault: nothing to push." fi cd "$CLAUDETOOLS_DIR" fi # Phase 7: Pending To-Do Review echo "" echo "=== Phase 7: Pending To-Dos ===" COORD_API="http://172.16.3.30:8001" TODO_USER="" TODO_MACHINE="$MACHINE" if [ -f ".claude/identity.json" ]; then TODO_USER=$($PYTHON -c "import json; d=json.load(open('.claude/identity.json')); print(d.get('user',''))" 2>/dev/null || echo "") fi if [ -n "$TODO_USER" ]; then TODO_JSON=$(curl -s --max-time 5 \ "${COORD_API}/api/coord/todos?for_user=${TODO_USER}&for_machine=${TODO_MACHINE}&status_filter=pending&limit=200" \ 2>/dev/null || echo "[]") TODO_COUNT=$($PYTHON -c " import json, sys try: data = json.loads('''$TODO_JSON''') print(len(data)) except: print(0) " 2>/dev/null || echo 0) if [ "$TODO_COUNT" -gt 0 ]; then echo -e "${YELLOW} $TODO_COUNT pending item(s) for ${TODO_USER}/${TODO_MACHINE}:${NC}" $PYTHON - < "Personal" groups = {} for t in todos: if t.get("parent_id"): continue # sub-tasks shown under parent key = t.get("project_key") or "Personal" groups.setdefault(key, []).append(t) # Build sub-task lookup subtask_map = {} for t in todos: pid = t.get("parent_id") if pid: subtask_map.setdefault(pid, []).append(t) CYAN = "\033[0;36m" RESET = "\033[0m" YELLOW= "\033[1;33m" for group_name in sorted(groups.keys(), key=lambda x: (x == "Personal", x)): print(f"\n {CYAN}[{group_name}]{RESET}") for t in groups[group_name]: assigned = "" if t.get("assigned_to_user") and t["assigned_to_user"] != "$TODO_USER": assigned = f" -> {t['assigned_to_user']}" if t.get("assigned_to_machine") and t["assigned_to_machine"].upper() != "$TODO_MACHINE".upper(): assigned += f"/{t['assigned_to_machine']}" auto = " [auto]" if t.get("auto_created") else "" due = f" due:{t['due_at'][:16]}" if t.get("due_at") else "" print(f" [ ] {t['text']}{auto}{due}{assigned} (id:{t['id'][:8]})") for st in subtask_map.get(t["id"], []): st_due = f" due:{st['due_at'][:16]}" if st.get("due_at") else "" print(f" [ ] {st['text']}{st_due} (id:{st['id'][:8]})") PYEOF echo "" else echo -e "${GREEN}[OK]${NC} No pending to-dos." fi else echo -e "${YELLOW}[INFO]${NC} No identity — skipping todo check." fi # Phase 8: Summary echo "" echo "=== Sync Summary ===" if [ "$INCOMING_COUNT" -gt 0 ]; then INCOMING_AUTHORS=$(git log --format='%an' $LOCAL_BEFORE..HEAD 2>/dev/null | sort | uniq -c | sort -rn | awk '{printf "%s (%s), ", substr($0, index($0,$2)), $1}' | sed 's/, $//') echo -e "${CYAN}Pulled in:${NC} $INCOMING_COUNT commit(s) — authors: ${INCOMING_AUTHORS:-unknown}" fi if [ "$OUTGOING_AFTER_PULL" -gt 0 ]; then echo -e "${CYAN}Pushed out:${NC} $OUTGOING_AFTER_PULL commit(s) by $USER_DISPLAY" fi if [ "$INCOMING_COUNT" -eq 0 ] && [ "$OUTGOING_AFTER_PULL" -eq 0 ]; then echo -e "${GREEN}Already in sync — no commits moved in either direction.${NC}" fi echo -e "${GREEN}[OK]${NC} HEAD: $(git log -1 --oneline)" echo -e "${GREEN}[OK]${NC} Status: $(git status -sb | head -1)" echo "" echo -e "${GREEN}[SUCCESS]${NC} Sync complete."