<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

\\n' read -r -d '' -a paths \u003c \u003c(jq -r '.worktrees | if type == \"array\" then .[].path else [] end | select(. != \"\")' \"$SESSION_FILE\" 2>/dev/null | sort -u) || true\n\n if [[ ${#paths[@]} -eq 0 ]]; then\n echo \"No worktree bookmarks to prune.\"\n return 0\n fi\n\n for wt_path in \"${paths[@]}\"; do\n if [[ ! -d \"$wt_path\" ]]; then\n jq --arg path \"$wt_path\" \\\n '.worktrees = [ (.worktrees // [])[] | select(.path != $path) ]' \\\n \"$SESSION_FILE\" > \"$tmp\" && mv \"$tmp\" \"$SESSION_FILE\"\n echo \"Pruned stale worktree bookmark: $wt_path\"\n ((removed++)) || true\n fi\n done\n\n echo \"Pruned $removed stale worktree bookmark(s).\"\n}\n\n# ============================================================\n# Context bookmark (existing behavior)\n# ============================================================\n\nif [[ \"${1:-}\" == \"worktree\" ]]; then\n shift\n worktree_cmd \"${1:-}\" \"${2:-}\" \"${3:-}\"\n exit $?\nfi\n\n# --- Context bookmark: requires jq for note operations ---\nif ! command -v jq &>/dev/null; then\n exit 0\nfi\n\nCLEAR=0\nNOTE=\"\"\nFILES=()\nPARSING_FILES=0\n\nfor arg in \"$@\"; do\n if [[ \"$arg\" == \"--clear\" ]]; then\n CLEAR=1\n elif [[ \"$arg\" == \"--files\" ]]; then\n PARSING_FILES=1\n elif [[ \"$PARSING_FILES\" -eq 1 ]]; then\n FILES+=(\"$arg\")\n elif [[ -z \"$NOTE\" ]]; then\n NOTE=\"$arg\"\n fi\ndone\n\nif [[ \"$CLEAR\" -eq 1 ]]; then\n jq 'del(.bookmark)' \"$SESSION_FILE\" > \"$SESSION_FILE.tmp\" \\\n && mv \"$SESSION_FILE.tmp\" \"$SESSION_FILE\"\nelif [[ -n \"$NOTE\" ]]; then\n TIMESTAMP=\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"\n if [[ \"${#FILES[@]}\" -gt 0 ]]; then\n FILES_JSON=$(printf '%s\\n' \"${FILES[@]}\" | jq -R . | jq -s . 2>/dev/null || echo '[]')\n jq --arg note \"$NOTE\" --arg ts \"$TIMESTAMP\" --argjson files \"$FILES_JSON\" \\\n '.bookmark = {note: $note, files: $files, timestamp: $ts}' \\\n \"$SESSION_FILE\" > \"$SESSION_FILE.tmp\" \\\n && mv \"$SESSION_FILE.tmp\" \"$SESSION_FILE\"\n else\n jq --arg note \"$NOTE\" --arg ts \"$TIMESTAMP\" \\\n '.bookmark = {note: $note, timestamp: $ts}' \\\n \"$SESSION_FILE\" > \"$SESSION_FILE.tmp\" \\\n && mv \"$SESSION_FILE.tmp\" \"$SESSION_FILE\"\n fi\nelse\n echo \"Usage: swain-bookmark.sh \\\"note text\\\" [--files file1 file2 ...]\" >&2\n echo \" swain-bookmark.sh --clear\" >&2\n echo \" swain-bookmark.sh worktree \u003cadd|remove|list|prune> [args]\" >&2\n exit 1\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":6885,"content_sha256":"0d6930f6f9e3b06fc531094c3fb203d99208d452e77f43ca2abc291cd2b8d226"},{"filename":"scripts/swain-focus.sh","content":"#!/usr/bin/env bash\nset -euo pipefail\n\n# Set or clear the focus lane in session.json\n# Usage: swain-focus.sh set VISION-001\n# swain-focus.sh clear\n# swain-focus.sh (show current)\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || {\n echo \"Error: not inside a git repository\" >&2\n exit 1\n}\nSESSION_FILE=\"$REPO_ROOT/.agents/session.json\"\n\nACTION=\"${1:-}\"\nFOCUS_ID=\"${2:-}\"\n\nif [[ ! -f \"$SESSION_FILE\" ]]; then\n echo '{}' > \"$SESSION_FILE\"\nfi\n\ncase \"$ACTION\" in\n set)\n if [[ -z \"$FOCUS_ID\" ]]; then\n echo \"Usage: swain-focus.sh set \u003cVISION-ID or INITIATIVE-ID>\" >&2\n exit 1\n fi\n jq --arg focus \"$FOCUS_ID\" '.focus_lane = $focus' \"$SESSION_FILE\" > \"${SESSION_FILE}.tmp\" \\\n && mv \"${SESSION_FILE}.tmp\" \"$SESSION_FILE\"\n echo \"Focus lane set to: $FOCUS_ID\"\n ;;\n clear)\n jq 'del(.focus_lane)' \"$SESSION_FILE\" > \"${SESSION_FILE}.tmp\" \\\n && mv \"${SESSION_FILE}.tmp\" \"$SESSION_FILE\"\n echo \"Focus lane cleared\"\n ;;\n *)\n # Show current focus\n CURRENT=$(jq -r '.focus_lane // \"none\"' \"$SESSION_FILE\" 2>/dev/null || echo \"none\")\n echo \"Current focus: $CURRENT\"\n ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":1141,"content_sha256":"0309791de34945d78af615737d6c35778fe5dd832eb250ed8a4768721948b0e9"},{"filename":"scripts/swain-preflight-timing.sh","content":"#!/usr/bin/env bash\n# swain-preflight-timing.sh — SPIKE-001: Detailed preflight timing breakdown\nset +e\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\ncd \"$REPO_ROOT\"\n\n# Portable path resolution — works whether installed at skills/ or .agents/skills/\n_src=\"${BASH_SOURCE[0]}\"\nwhile [[ -L \"$_src\" ]]; do\n _dir=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\n _src=\"$(readlink \"$_src\")\"\n [[ \"$_src\" != /* ]] && _src=\"$_dir/$_src\"\ndone\n_TIMING_SCRIPT_DIR=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\n_TIMING_SKILLS_ROOT=\"$(dirname \"$(dirname \"$_TIMING_SCRIPT_DIR\")\")\"\n\nif command -v gdate &>/dev/null; then\n _ts() { gdate +%s%3N; }\nelse\n _ts() { python3 -c \"import time; print(int(time.time()*1000))\"; }\nfi\n\ntime_check() {\n local name=\"$1\"\n shift\n local start=$(_ts)\n eval \"$@\" >/dev/null 2>&1\n local end=$(_ts)\n printf \" %-45s %6d ms\\n\" \"$name\" \"$((end - start))\"\n}\n\necho \"=== Preflight Timing Breakdown ===\"\n\ntime_check \"governance_files_exist\" '[[ -f AGENTS.md ]] || [[ -f CLAUDE.md ]]'\ntime_check \"governance_markers\" 'grep -q \"swain governance\" AGENTS.md CLAUDE.md 2>/dev/null'\n\ntime_check \"governance_freshness_hash\" '\n GOV_FILE=$(grep -l \"swain governance\" AGENTS.md CLAUDE.md 2>/dev/null | head -1)\n awk \"/\u003c!-- swain governance/{f=1;next}/\u003c!-- end swain governance/{f=0}f\" \"$GOV_FILE\" | shasum -a 256\n awk \"/\u003c!-- swain governance/{f=1;next}/\u003c!-- end swain governance/{f=0}f\" \"'\"$_TIMING_SKILLS_ROOT\"'/swain-doctor/references/AGENTS.content.md\" | shasum -a 256\n'\n\ntime_check \"agents_dir_check\" '[[ -d .agents ]]'\ntime_check \"tickets_dir_check\" 'for f in .tickets/*.md; do [[ -f \"$f\" ]] && head -1 \"$f\" | grep -q \"^---$\"; break; done'\ntime_check \"beads_dir_check\" '[[ -d .beads ]]'\ntime_check \"evidence_pool_check\" '[[ -d docs/evidence-pools ]]'\ntime_check \"stale_locks\" 'find .tickets/.locks -type d -mmin +60 2>/dev/null | wc -l'\ntime_check \"old_phase_dirs\" 'find docs/*/Draft docs/*/Planned docs/*/Review 2>/dev/null | head -1'\ntime_check \"commit_signing_check\" 'git config --local commit.gpgsign'\n\ntime_check \"script_permissions\" \"find '$_TIMING_SKILLS_ROOT' -type f \\( -path '*/scripts/*.sh' -o -path '*/scripts/*.py' \\) ! -perm -u+x 2>/dev/null\"\n\ntime_check \"ssh_readiness\" \"bash '$_TIMING_SKILLS_ROOT/swain-doctor/scripts/ssh-readiness.sh' --check 2>/dev/null\"\ntime_check \"skill_gitignore_hygiene\" '\n _origin_url=\"$(git remote get-url origin 2>/dev/null || true)\"\n for _base in .claude/skills .agents/skills; do\n [ -d \"$_base\" ] || continue\n for _skill_path in \"$_base\"/swain \"$_base\"/swain-*/; do\n [[ -d \"$_skill_path\" ]] && git check-ignore -q \"$_skill_path\" 2>/dev/null\n done\n done\n'\n\ntime_check \"superpowers_detection\" '\n for skill in brainstorming writing-plans test-driven-development verification-before-completion subagent-driven-development executing-plans; do\n ls .agents/skills/$skill/SKILL.md .claude/skills/$skill/SKILL.md 2>/dev/null | head -1\n done\n'\n\ntime_check \"scanner_availability\" \"python3 '$_TIMING_SKILLS_ROOT/swain-security-check/scripts/scanner_availability.py' 2>/dev/null\"\ntime_check \"mmdc_check\" 'command -v mmdc'\ntime_check \"doctor_security_check\" \"python3 '$_TIMING_SKILLS_ROOT/swain-security-check/scripts/doctor_security_check.py' 2>/dev/null\"\ntime_check \"skill_change_discipline\" \"bash '$_TIMING_SKILLS_ROOT/swain-doctor/scripts/check-skill-changes.sh' 2>/dev/null\"\n\ntime_check \"agents_bin_symlink_repair\" \"\n for skill_scripts_dir in '$_TIMING_SKILLS_ROOT'/*/scripts; do\n [[ -d \\\"\\$skill_scripts_dir\\\" ]] || continue\n for script in \\\"\\$skill_scripts_dir\\\"/*; do\n [[ -f \\\"\\$script\\\" && -x \\\"\\$script\\\" ]] || continue\n done\n done\n\"\n\ntime_check \"trunk_release_detection\" 'bash .agents/bin/swain-trunk.sh 2>/dev/null && git ls-remote --heads origin trunk 2>/dev/null'\n\ntime_check \"initiative_migration_check\" 'find docs/epic -name \"*.md\" -not -name \"README.md\" -not -name \"list-*.md\" 2>/dev/null | while read f; do grep -q \"parent-initiative:\" \"$f\" 2>/dev/null; done'\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3960,"content_sha256":"a85193dce8dc1b732c03a1e099be36e7dd18762e550eacd4b4b9398ed2377dfd"},{"filename":"scripts/swain-progress-log.sh","content":"#!/usr/bin/env bash\n# swain-progress-log.sh — Append progress entries and synthesize progress sections\n#\n# Modes:\n# --artifact-id \u003cID> --entry \u003ctext> Append a dated entry to progress.md\n# --artifact-id \u003cID> --synthesize Regenerate ## Progress section from progress.md\n# --digest \u003cpath-to-jsonl-entry> Process a session digest line\n#\n# SPEC-200: Progress Log and Synthesis\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\"\n\n# ─── Argument parsing ───\n\nARTIFACT_ID=\"\"\nENTRY=\"\"\nSYNTHESIZE=false\nDIGEST_PATH=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --artifact-id) ARTIFACT_ID=\"$2\"; shift 2 ;;\n --entry) ENTRY=\"$2\"; shift 2 ;;\n --synthesize) SYNTHESIZE=true; shift ;;\n --digest) DIGEST_PATH=\"$2\"; shift 2 ;;\n *) echo \"Unknown option: $1\" >&2; exit 1 ;;\n esac\ndone\n\n# ─── Helpers ───\n\nresolve_artifact_dir() {\n local id=\"$1\"\n # Find the directory containing this artifact under docs/\n local dir\n dir=$(find \"$REPO_ROOT/docs\" -type d -name \"*${id}*\" 2>/dev/null | head -1)\n if [[ -z \"$dir\" ]]; then\n echo \"ERROR: Could not find artifact directory for $id\" >&2\n return 1\n fi\n echo \"$dir\"\n}\n\nresolve_artifact_file() {\n local dir=\"$1\"\n local id=\"$2\"\n # Find the .md file matching the artifact ID in the directory\n local file\n file=$(find \"$dir\" -maxdepth 1 -name \"*${id}*.md\" ! -name \"progress.md\" 2>/dev/null | head -1)\n if [[ -z \"$file\" ]]; then\n echo \"ERROR: Could not find artifact file for $id in $dir\" >&2\n return 1\n fi\n echo \"$file\"\n}\n\nappend_entry() {\n local artifact_dir=\"$1\"\n local entry_text=\"$2\"\n local progress_file=\"$artifact_dir/progress.md\"\n local today\n today=$(date +%Y-%m-%d)\n\n if [[ ! -f \"$progress_file\" ]]; then\n echo \"# Progress Log\" > \"$progress_file\"\n echo \"\" >> \"$progress_file\"\n fi\n\n {\n echo \"## $today\"\n echo \"\"\n echo \"$entry_text\"\n echo \"\"\n } >> \"$progress_file\"\n\n echo \"Appended entry to $progress_file\"\n}\n\nsynthesize_progress() {\n local artifact_dir=\"$1\"\n local artifact_id=\"$2\"\n local progress_file=\"$artifact_dir/progress.md\"\n local artifact_file\n\n artifact_file=$(resolve_artifact_file \"$artifact_dir\" \"$artifact_id\")\n\n if [[ ! -f \"$progress_file\" ]]; then\n echo \"No progress.md found in $artifact_dir — nothing to synthesize\" >&2\n return 0\n fi\n\n # Use Python for reliable text manipulation\n uv run python3 -c \"\nimport sys, re\n\nprogress_path = sys.argv[1]\nartifact_path = sys.argv[2]\n\n# Read progress.md and extract recent entries (last 2-3)\nwith open(progress_path) as f:\n content = f.read()\n\n# Split into dated sections\nsections = re.split(r'^## (\\d{4}-\\d{2}-\\d{2})', content, flags=re.MULTILINE)\n# sections[0] is header, then pairs of (date, body)\nentries = []\nfor i in range(1, len(sections) - 1, 2):\n date = sections[i]\n body = sections[i + 1].strip()\n entries.append((date, body))\n\n# Take last 2-3 entries for synthesis\nrecent = entries[-3:] if len(entries) > 3 else entries\nsynthesis_lines = []\nfor date, body in recent:\n # Take first line of each entry as the synthesis line\n first_line = body.split('\\n')[0].strip()\n if first_line:\n synthesis_lines.append(f'**{date}:** {first_line}')\n\nsynthesis = '\\n\\n'.join(synthesis_lines) if synthesis_lines else '_No progress entries yet._'\n\n# Read artifact file\nwith open(artifact_path) as f:\n artifact_content = f.read()\n\n# Find ## Progress section and replace its content\n# Section runs from '## Progress' to the next '## ' heading or end of file\nprogress_pattern = re.compile(\n r'(## Progress\\n).*?(?=\\n## [^\\n]|\\Z)',\n re.DOTALL\n)\n\nprogress_section = f'## Progress\\n\\n{synthesis}\\n'\n\nif progress_pattern.search(artifact_content):\n new_content = progress_pattern.sub(progress_section, artifact_content)\nelse:\n # Insert after ## Desired Outcomes or ## Goal / Objective\n insert_patterns = [\n r'(## Desired Outcomes\\n.*?)(?=\\n## )',\n r'(## Goal / Objective\\n.*?)(?=\\n## )',\n ]\n inserted = False\n for pat in insert_patterns:\n match = re.search(pat, artifact_content, re.DOTALL)\n if match:\n insert_pos = match.end()\n new_content = artifact_content[:insert_pos] + '\\n\\n' + progress_section + '\\n' + artifact_content[insert_pos:]\n inserted = True\n break\n if not inserted:\n # Fallback: append before ## Lifecycle or at end\n lifecycle_match = re.search(r'\\n## Lifecycle', artifact_content)\n if lifecycle_match:\n pos = lifecycle_match.start()\n new_content = artifact_content[:pos] + '\\n' + progress_section + '\\n' + artifact_content[pos:]\n else:\n new_content = artifact_content + '\\n\\n' + progress_section\n\nwith open(artifact_path, 'w') as f:\n f.write(new_content)\n\nprint(f'Synthesized progress into {artifact_path}')\n\" \"$progress_file\" \"$artifact_file\"\n}\n\n# ─── Digest mode ───\n\nprocess_digest() {\n local digest_path=\"$1\"\n\n if [[ ! -f \"$digest_path\" ]]; then\n echo \"ERROR: Digest file not found: $digest_path\" >&2\n exit 1\n fi\n\n uv run python3 -c \"\nimport json, sys, subprocess, os\n\ndigest_path = sys.argv[1]\nscript = sys.argv[2]\n\nwith open(digest_path) as f:\n entry = json.loads(f.read().strip())\n\nartifacts = entry.get('artifacts_touched', [])\nsession_summary = entry.get('session_summary', 'Session work recorded.')\n\nfor artifact in artifacts:\n artifact_id = artifact.get('id', '') if isinstance(artifact, dict) else str(artifact)\n summary = artifact.get('summary', session_summary) if isinstance(artifact, dict) else session_summary\n if not artifact_id:\n continue\n\n # Only update EPICs and Initiatives (container artifacts that track progress)\n if not any(artifact_id.startswith(p) for p in ['EPIC-', 'INITIATIVE-']):\n continue\n\n result = subprocess.run(\n ['bash', script, '--artifact-id', artifact_id, '--entry', summary],\n capture_output=True, text=True\n )\n if result.returncode != 0:\n print(f'Warning: failed to append entry for {artifact_id}: {result.stderr}', file=sys.stderr)\n else:\n print(result.stdout, end='')\n\n # Synthesize\n result = subprocess.run(\n ['bash', script, '--artifact-id', artifact_id, '--synthesize'],\n capture_output=True, text=True\n )\n if result.returncode != 0:\n print(f'Warning: failed to synthesize for {artifact_id}: {result.stderr}', file=sys.stderr)\n else:\n print(result.stdout, end='')\n\" \"$digest_path\" \"${BASH_SOURCE[0]}\"\n}\n\n# ─── Main dispatch ───\n\nif [[ -n \"$DIGEST_PATH\" ]]; then\n process_digest \"$DIGEST_PATH\"\nelif [[ -n \"$ARTIFACT_ID\" ]]; then\n ARTIFACT_DIR=$(resolve_artifact_dir \"$ARTIFACT_ID\")\n\n if [[ -n \"$ENTRY\" ]]; then\n append_entry \"$ARTIFACT_DIR\" \"$ENTRY\"\n fi\n\n if [[ \"$SYNTHESIZE\" == true ]]; then\n synthesize_progress \"$ARTIFACT_DIR\" \"$ARTIFACT_ID\"\n fi\n\n if [[ -z \"$ENTRY\" && \"$SYNTHESIZE\" == false ]]; then\n echo \"ERROR: --artifact-id requires --entry and/or --synthesize\" >&2\n exit 1\n fi\nelse\n echo \"ERROR: Must provide --artifact-id or --digest\" >&2\n exit 1\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":7180,"content_sha256":"c56bbebe62510d9d94c28f58c59e5a95a3a8656d30c81936c86bbafbfcb1cf5b"},{"filename":"scripts/swain-session-archive.sh","content":"#!/usr/bin/env bash\n# swain-session-archive.sh — Archive session.json for retro reconstruction\n# SPEC-248 | EPIC-056\nset -uo pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nARCHIVE_DIR=\"${SWAIN_ARCHIVE_DIR:-$REPO_ROOT/.agents/session-archive}\"\n\n_ensure_dir() {\n mkdir -p \"$ARCHIVE_DIR\"\n}\n\ncmd_save() {\n local worktree_path=\"$1\"\n local session_file=\"$worktree_path/.agents/session.json\"\n\n if [ ! -f \"$session_file\" ]; then\n echo \"No session.json in $worktree_path\" >&2\n return 0 # graceful, not an error\n fi\n\n _ensure_dir\n\n # Generate session ID from branch name + timestamp\n local branch\n branch=\"$(git -C \"$worktree_path\" branch --show-current 2>/dev/null || echo \"unknown\")\"\n local timestamp\n timestamp=\"$(date +%Y%m%dT%H%M%S)\"\n local session_id=\"${branch//\\//-}-${timestamp}\"\n\n cp \"$session_file\" \"$ARCHIVE_DIR/${session_id}.json\"\n echo \"Archived: $session_id\"\n}\n\ncmd_get() {\n local session_id=\"$1\"\n local archive_file=\"$ARCHIVE_DIR/${session_id}.json\"\n local archive_gz=\"$ARCHIVE_DIR/${session_id}.json.gz\"\n\n if [ -f \"$archive_file\" ]; then\n cat \"$archive_file\"\n return 0\n elif [ -f \"$archive_gz\" ]; then\n gzip -dc \"$archive_gz\"\n return 0\n fi\n\n echo \"Not found: $session_id\" >&2\n return 1\n}\n\ncmd_find() {\n local artifact_id=\"$1\"\n _ensure_dir\n\n local found=false\n for f in \"$ARCHIVE_DIR\"/*.json \"$ARCHIVE_DIR\"/*.json.gz; do\n [ -f \"$f\" ] || continue\n\n local content\n if [[ \"$f\" == *.gz ]]; then\n content=\"$(gzip -dc \"$f\")\"\n else\n content=\"$(cat \"$f\")\"\n fi\n\n if echo \"$content\" | grep -q \"$artifact_id\"; then\n local name\n name=\"$(basename \"$f\")\"\n echo \"$name: $(echo \"$content\" | grep -o \"\\\"note\\\":[^,}]*\" | head -1)\"\n found=true\n fi\n done\n\n if [ \"$found\" = false ]; then\n return 0 # empty output, no matches\n fi\n}\n\ncmd_compress() {\n _ensure_dir\n\n local now_epoch\n now_epoch=\"$(date +%s)\"\n local seven_days=$((7 * 86400))\n\n for f in \"$ARCHIVE_DIR\"/*.json; do\n [ -f \"$f\" ] || continue\n # Skip if already has a .gz companion\n [ -f \"${f}.gz\" ] && continue\n\n local file_epoch\n file_epoch=\"$(stat -f %m \"$f\" 2>/dev/null || stat -c %Y \"$f\" 2>/dev/null || echo \"$now_epoch\")\"\n local age=$((now_epoch - file_epoch))\n\n if [ \"$age\" -gt \"$seven_days\" ]; then\n gzip \"$f\"\n echo \"Compressed: $(basename \"$f\")\"\n fi\n done\n}\n\n# --- Main dispatch ---\n\ncmd=\"${1:-help}\"\nshift || true\n\ncase \"$cmd\" in\n save) cmd_save \"$@\" ;;\n get) cmd_get \"$@\" ;;\n find) cmd_find \"$@\" ;;\n compress) cmd_compress ;;\n help)\n echo \"Usage: swain-session-archive.sh \u003ccommand> [args]\"\n echo \"\"\n echo \"Commands:\"\n echo \" save \u003cworktree-path> Archive session.json from worktree\"\n echo \" get \u003csession-id> Retrieve archived session\"\n echo \" find \u003cartifact-id> Find sessions touching an artifact\"\n echo \" compress Gzip archives older than 7 days\"\n ;;\n *)\n echo \"Unknown command: $cmd\" >&2\n exit 1\n ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3020,"content_sha256":"74e662e6c5fc304948882641bd913b7fd650d487e25aa5ef17952725c89bb789"},{"filename":"scripts/swain-session-bootstrap.sh","content":"#!/usr/bin/env bash\nset +e # Never fail hard — session bootstrap is a convenience, not a gate\n\n# swain-session-bootstrap.sh — Consolidated session startup\n#\n# Replaces multi-step agent orchestration (tab naming + worktree detection +\n# session.json loading) with a single script call that emits structured JSON.\n#\n# Usage:\n# swain-session-bootstrap.sh --auto # full bootstrap\n# swain-session-bootstrap.sh --path DIR --auto # resolve from DIR\n# swain-session-bootstrap.sh --skip-worktree --auto # omit worktree check\n#\n# Output: JSON to stdout with keys: tab, worktree, session, warnings\n# See SPEC-172 for the full contract.\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nTAB_NAME_SCRIPT=\"$SCRIPT_DIR/swain-tab-name.sh\"\nBOOKMARK_SCRIPT=\"$SCRIPT_DIR/swain-bookmark.sh\"\n\n# ─── Argument parsing ───\nSWAIN_BOOTSTRAP_PATH=\"\"\nSKIP_WORKTREE=0\nAUTO=0\nWARNINGS=()\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --path)\n SWAIN_BOOTSTRAP_PATH=\"$2\"\n shift 2\n ;;\n --skip-worktree)\n SKIP_WORKTREE=1\n shift\n ;;\n --auto)\n AUTO=1\n shift\n ;;\n --help|-h)\n echo \"Usage: swain-session-bootstrap.sh [--path DIR] [--skip-worktree] --auto\"\n echo \"\"\n echo \" --path DIR Resolve git context from DIR (default: auto-detect)\"\n echo \" --skip-worktree Omit worktree isolation detection\"\n echo \" --auto Run in non-interactive mode\"\n exit 0\n ;;\n *)\n shift\n ;;\n esac\ndone\n\n# ─── Resolve repo root ───\nif [[ -n \"$SWAIN_BOOTSTRAP_PATH\" ]]; then\n REPO_ROOT=\"$(git -C \"$SWAIN_BOOTSTRAP_PATH\" rev-parse --show-toplevel 2>/dev/null || echo \"$SWAIN_BOOTSTRAP_PATH\")\"\nelse\n REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nfi\n\n# ─── Step 1: Tab naming (tmux only) ───\nTAB_RESULT=\"\"\nif [[ -n \"${TMUX:-}\" ]]; then\n if [[ -f \"$TAB_NAME_SCRIPT\" ]]; then\n TAB_ARGS=()\n [[ -n \"$SWAIN_BOOTSTRAP_PATH\" ]] && TAB_ARGS+=(--path \"$SWAIN_BOOTSTRAP_PATH\")\n TAB_ARGS+=(--auto)\n TAB_RESULT=$(bash \"$TAB_NAME_SCRIPT\" \"${TAB_ARGS[@]}\" 2>/dev/null)\n else\n WARNINGS+=(\"tab-name script not found at $TAB_NAME_SCRIPT\")\n fi\nfi\n\n# ─── Step 2: Worktree detection ───\nWT_ISOLATED=\"false\"\nWT_PATH=\"\"\nWT_BRANCH=\"\"\n\nDETECT_PATH=\"${SWAIN_BOOTSTRAP_PATH:-$REPO_ROOT}\"\n\nif [[ \"$SKIP_WORKTREE\" -eq 0 ]]; then\n GIT_COMMON=$(git -C \"$DETECT_PATH\" rev-parse --git-common-dir 2>/dev/null)\n GIT_DIR=$(git -C \"$DETECT_PATH\" rev-parse --git-dir 2>/dev/null)\n\n if [[ -n \"$GIT_COMMON\" && -n \"$GIT_DIR\" && \"$GIT_COMMON\" != \"$GIT_DIR\" ]]; then\n WT_ISOLATED=\"true\"\n WT_PATH=\"$DETECT_PATH\"\n WT_BRANCH=$(git -C \"$DETECT_PATH\" rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"\")\n else\n WT_ISOLATED=\"false\"\n WT_BRANCH=$(git -C \"$DETECT_PATH\" rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"\")\n fi\nfi\n\n# ─── Step 3: Session.json loading ───\nSESSION_FILE=\"$REPO_ROOT/.agents/session.json\"\nSESSION_FOCUS=\"\"\nSESSION_BOOKMARK=\"\"\nSESSION_LAST_BRANCH=\"\"\n\nif [[ -f \"$SESSION_FILE\" ]] && command -v jq &>/dev/null; then\n SESSION_FOCUS=$(jq -r '.focus_lane // empty' \"$SESSION_FILE\" 2>/dev/null)\n SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' \"$SESSION_FILE\" 2>/dev/null)\n SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' \"$SESSION_FILE\" 2>/dev/null)\n\n # Update lastBranch to current\n CURRENT_BRANCH=$(git -C \"$DETECT_PATH\" rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"\")\n if [[ -n \"$CURRENT_BRANCH\" ]]; then\n jq --arg branch \"$CURRENT_BRANCH\" '.lastBranch = $branch' \\\n \"$SESSION_FILE\" > \"${SESSION_FILE}.tmp\" 2>/dev/null \\\n && mv \"${SESSION_FILE}.tmp\" \"$SESSION_FILE\" 2>/dev/null\n fi\nelif [[ ! -f \"$SESSION_FILE\" ]]; then\n # Check for old global location and migrate\n _OLD_SLUG=$(echo \"$REPO_ROOT\" | tr '/' '-')\n _OLD_FILE=\"$HOME/.claude/projects/${_OLD_SLUG}/memory/session.json\"\n if [[ -f \"$_OLD_FILE\" ]]; then\n mkdir -p \"$(dirname \"$SESSION_FILE\")\" 2>/dev/null\n cp \"$_OLD_FILE\" \"$SESSION_FILE\" 2>/dev/null\n WARNINGS+=(\"migrated session.json from old global location\")\n # Re-read after migration\n if command -v jq &>/dev/null; then\n SESSION_FOCUS=$(jq -r '.focus_lane // empty' \"$SESSION_FILE\" 2>/dev/null)\n SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' \"$SESSION_FILE\" 2>/dev/null)\n SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' \"$SESSION_FILE\" 2>/dev/null)\n fi\n fi\nfi\n\n# ─── Build JSON output ───\nbuild_fallback_json() {\n # Minimal JSON construction without jq\n local out='{\"worktree\":{\"isolated\":'\n out+=\"$WT_ISOLATED\"\n out+='},\"session\":{},\"warnings\":['\n local first=1\n for w in \"${WARNINGS[@]}\"; do\n [[ $first -eq 0 ]] && out+=\",\"\n # Escape quotes in warning text\n out+=\"\\\"${w//\\\"/\\\\\\\"}\\\"\"\n first=0\n done\n out+=']}'\n echo \"$out\"\n}\n\n# Use jq if available and functional, fall back to manual construction\nOUTPUT=\"\"\nif command -v jq &>/dev/null && jq -n '{}' &>/dev/null; then\n # Build warnings array\n WARNINGS_JSON=\"[]\"\n for w in \"${WARNINGS[@]}\"; do\n WARNINGS_JSON=$(echo \"$WARNINGS_JSON\" | jq --arg w \"$w\" '. + [$w]')\n done\n\n OUTPUT=$(jq -n \\\n --arg tab \"$TAB_RESULT\" \\\n --arg wt_isolated \"$WT_ISOLATED\" \\\n --arg wt_path \"$WT_PATH\" \\\n --arg wt_branch \"$WT_BRANCH\" \\\n --arg s_focus \"$SESSION_FOCUS\" \\\n --arg s_bookmark \"$SESSION_BOOKMARK\" \\\n --arg s_last_branch \"$SESSION_LAST_BRANCH\" \\\n --argjson warnings \"$WARNINGS_JSON\" \\\n '{\n worktree: {\n isolated: ($wt_isolated == \"true\"),\n path: (if $wt_path == \"\" then null else $wt_path end),\n branch: (if $wt_branch == \"\" then null else $wt_branch end)\n },\n session: {\n focus: (if $s_focus == \"\" then null else $s_focus end),\n bookmark: (if $s_bookmark == \"\" then null else $s_bookmark end),\n lastBranch: (if $s_last_branch == \"\" then null else $s_last_branch end)\n },\n warnings: $warnings\n }\n | if $tab != \"\" then .tab = $tab else . end' 2>/dev/null)\nfi\n\n# If jq failed or wasn't available, use the fallback\nif [[ -z \"$OUTPUT\" ]]; then\n WARNINGS+=(\"jq not available — session fields may be incomplete\")\n OUTPUT=$(build_fallback_json)\nfi\n\necho \"$OUTPUT\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":6227,"content_sha256":"70c0e66dfe1cdcf9ed133127c3b0b27418fc947a391f559b864113d714c5053d"},{"filename":"scripts/swain-session-check.sh","content":"#!/usr/bin/env bash\n# swain-session-check.sh — Lightweight session detection for skill preambles\n# SPEC-121: Session Detection Hooks Across All Skills\n#\n# Reads .agents/session-state.json and emits a JSON result:\n# {\"status\": \"active|stale|closed|none\", \"focus_lane\": \"...\", \"session_id\": \"...\"}\n#\n# Exit codes:\n# 0 — session is active\n# 1 — session is stale, closed, or missing (skill should prompt)\n#\n# Options:\n# --state-file \u003cpath> Override state file location\n# --threshold \u003cseconds> Staleness threshold (default: 3600 = 1 hour)\nset -uo pipefail\n\nSTATE_FILE=\"${SWAIN_SESSION_STATE:-.agents/session-state.json}\"\nTHRESHOLD=3600\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --state-file) STATE_FILE=\"$2\"; shift 2 ;;\n --threshold) THRESHOLD=\"$2\"; shift 2 ;;\n *) shift ;;\n esac\ndone\n\nif [ ! -f \"$STATE_FILE\" ]; then\n echo '{\"status\": \"none\", \"focus_lane\": null, \"session_id\": null}'\n exit 1\nfi\n\npython3 -c \"\nimport json, sys\nfrom datetime import datetime, timezone\n\nwith open('$STATE_FILE') as f:\n state = json.load(f)\n\nphase = state.get('phase', 'unknown')\nfocus = state.get('focus_lane')\nsid = state.get('session_id')\nactivity = state.get('last_activity_time') or state.get('start_time', '')\n\nresult = {'focus_lane': focus, 'session_id': sid}\n\nif phase == 'closed':\n result['status'] = 'closed'\n json.dump(result, sys.stdout)\n sys.exit(1)\nelif phase == 'active':\n # Check staleness\n try:\n activity_dt = datetime.fromisoformat(activity.replace('Z', '+00:00'))\n age = (datetime.now(timezone.utc) - activity_dt).total_seconds()\n if age > $THRESHOLD:\n result['status'] = 'stale'\n json.dump(result, sys.stdout)\n sys.exit(1)\n else:\n result['status'] = 'active'\n json.dump(result, sys.stdout)\n sys.exit(0)\n except (ValueError, TypeError):\n result['status'] = 'stale'\n json.dump(result, sys.stdout)\n sys.exit(1)\nelse:\n result['status'] = 'none'\n json.dump(result, sys.stdout)\n sys.exit(1)\n\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":2070,"content_sha256":"1011aa67d3b04216e06feef120260c34edba175eb3e8a7c68b3d0291fa0f4587"},{"filename":"scripts/swain-session-digest.sh","content":"#!/usr/bin/env bash\n# swain-session-digest.sh — Generate a structured JSONL digest of a session\n# Part of SPEC-199: Session Digest Auto-Generation\n#\n# Usage:\n# swain-session-digest.sh --session-id \u003cID> --start-time \u003cISO8601> [--focus \u003cARTIFACT-ID>] [--repo-root \u003cPATH>] [--output \u003cPATH>]\n#\n# Exit codes:\n# 0 — digest written successfully\n# 1 — error (missing required args, git not available, etc.)\n\nset -euo pipefail\n\nSESSION_ID=\"\"\nSTART_TIME=\"\"\nFOCUS=\"\"\nREPO_ROOT=\"\"\nOUTPUT_FILE=\"\"\n\n# Parse arguments\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --session-id)\n SESSION_ID=\"$2\"\n shift 2\n ;;\n --start-time)\n START_TIME=\"$2\"\n shift 2\n ;;\n --focus)\n FOCUS=\"$2\"\n shift 2\n ;;\n --repo-root)\n REPO_ROOT=\"$2\"\n shift 2\n ;;\n --output)\n OUTPUT_FILE=\"$2\"\n shift 2\n ;;\n *)\n echo \"Unknown argument: $1\" >&2\n exit 1\n ;;\n esac\ndone\n\n# Validate required args\nif [[ -z \"$SESSION_ID\" ]]; then\n echo \"Error: --session-id is required\" >&2\n exit 1\nfi\nif [[ -z \"$START_TIME\" ]]; then\n echo \"Error: --start-time is required\" >&2\n exit 1\nfi\n\n# Defaults\nif [[ -z \"$REPO_ROOT\" ]]; then\n REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || {\n echo \"Error: not in a git repository and --repo-root not specified\" >&2\n exit 1\n }\nfi\n\nif [[ -z \"$OUTPUT_FILE\" ]]; then\n OUTPUT_FILE=\"$REPO_ROOT/.agents/session-log.jsonl\"\nfi\n\n# Ensure output directory exists\nmkdir -p \"$(dirname \"$OUTPUT_FILE\")\"\n\n# --- Evidence gathering ---\n\n# 1. Git commits since start-time\nGIT_LOG=$(git -C \"$REPO_ROOT\" log --after=\"$START_TIME\" --oneline --no-decorate 2>/dev/null || echo \"\")\n\n# 2. Ticket completions — scan .tickets/ for closed tickets updated after start-time\nTICKETS_DIR=\"$REPO_ROOT/.tickets\"\nCLOSED_TICKETS=\"\"\nif [[ -d \"$TICKETS_DIR\" ]]; then\n for ticket_file in \"$TICKETS_DIR\"/*.md; do\n [[ -f \"$ticket_file\" ]] || continue\n # Check if status is closed\n status=$(sed -n '/^---$/,/^---$/{ /^status:/{ s/^status: *//; p; q; } }' \"$ticket_file\" 2>/dev/null || echo \"\")\n if [[ \"$status\" == \"closed\" ]]; then\n # Check if file was modified after start-time (use file mtime as proxy)\n # Extract tags for spec references\n tags=$(sed -n '/^---$/,/^---$/{ /^tags:/{ s/^tags: *\\[//; s/\\].*//; p; q; } }' \"$ticket_file\" 2>/dev/null || echo \"\")\n CLOSED_TICKETS=\"${CLOSED_TICKETS}${tags}\"

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

\\n'\n fi\n done\nfi\n\n# 3. Pass everything to Python for JSON construction and output\nexport SESSION_ID START_TIME FOCUS GIT_LOG CLOSED_TICKETS OUTPUT_FILE REPO_ROOT\nuv run python3 -c \"\nimport json\nimport sys\nimport os\nfrom datetime import datetime, timezone\n\nsession_id = os.environ['SESSION_ID']\nstart_time = os.environ['START_TIME']\nfocus = os.environ.get('FOCUS', '')\ngit_log = os.environ.get('GIT_LOG', '')\noutput_file = os.environ['OUTPUT_FILE']\n\n# Parse git log lines\ncommits_lines = [line.strip() for line in git_log.strip().split('\\n') if line.strip()]\ncommit_count = len(commits_lines)\n\n# Extract artifact references and actions from commit messages\n# Commit format: \u003chash> \u003cprefix>(\u003cscope>): \u003cmessage> OR \u003chash> \u003cprefix>: \u003cmessage>\nartifact_pattern_ids = set()\nartifacts_touched = []\nseen_ids = set()\n\nimport re\n\n# Map conventional-commit prefixes to actions\nprefix_map = {\n 'feat': 'implemented',\n 'fix': 'fixed',\n 'docs': 'documented',\n 'close': 'completed',\n 'test': 'tested',\n 'research': 'researched',\n 'refactor': 'refactored',\n 'chore': 'maintained',\n 'ci': 'maintained',\n 'style': 'maintained',\n 'perf': 'optimized',\n}\n\n# Pattern for artifact IDs\nartifact_re = re.compile(r'(SPEC|EPIC|INITIATIVE|ADR|SPIKE|VISION|PERSONA|RUNBOOK|DESIGN|JOURNEY)-(\\d+)')\n# Pattern for conventional commit prefix\nprefix_re = re.compile(r'^[a-f0-9]+ (\\w+)(?:\\([^)]*\\))?[!]?:\\s*(.*)')\n\nfor line in commits_lines:\n # Find artifact IDs in this commit line\n ids_in_line = artifact_re.findall(line)\n if not ids_in_line:\n continue\n\n # Parse the commit prefix\n prefix_match = prefix_re.match(line)\n action = 'touched'\n summary = line\n if prefix_match:\n prefix = prefix_match.group(1).lower()\n action = prefix_map.get(prefix, 'touched')\n summary = prefix_match.group(2).strip()\n\n for artifact_type, artifact_num in ids_in_line:\n artifact_id = f'{artifact_type}-{artifact_num}'\n if artifact_id in seen_ids:\n continue\n seen_ids.add(artifact_id)\n\n # Try to read the artifact title from disk\n title = ''\n repo_root = os.environ['REPO_ROOT']\n # Map artifact type to directory\n type_dir_map = {\n 'SPEC': 'spec', 'EPIC': 'epic', 'INITIATIVE': 'initiative',\n 'ADR': 'adr', 'SPIKE': 'spike', 'VISION': 'vision',\n 'PERSONA': 'persona', 'RUNBOOK': 'runbook', 'DESIGN': 'design',\n 'JOURNEY': 'journey',\n }\n type_dir = type_dir_map.get(artifact_type, artifact_type.lower())\n # Search common locations\n for subdir in ['Active', 'Complete', 'Proposed', 'InProgress', 'NeedsManualTest', 'Ready', 'Adopted', 'Retired', 'Superseded', 'Abandoned', 'Draft', '']:\n candidate = os.path.join(repo_root, 'docs', type_dir, subdir)\n if not os.path.isdir(candidate):\n continue\n for fname in os.listdir(candidate):\n if artifact_id not in fname:\n continue\n fpath = os.path.join(candidate, fname)\n # Handle subdirectory layout: (SPEC-194)-Title/(SPEC-194)-Title.md\n if os.path.isdir(fpath):\n for inner in os.listdir(fpath):\n if artifact_id in inner and inner.endswith('.md'):\n fpath = os.path.join(fpath, inner)\n break\n else:\n continue\n elif not fname.endswith('.md'):\n continue\n try:\n with open(fpath) as f:\n in_frontmatter = False\n for fline in f:\n fline = fline.rstrip()\n if fline == '---':\n if not in_frontmatter:\n in_frontmatter = True\n continue\n else:\n break\n if in_frontmatter and fline.startswith('title:'):\n title = fline[len('title:'):].strip().strip('\\\"').strip(\\\"'\\\")\n break\n except (IOError, OSError):\n pass\n break\n if title:\n break\n\n artifacts_touched.append({\n 'id': artifact_id,\n 'title': title,\n 'action': action,\n 'summary': summary,\n })\n\n# Count tasks closed (from ticket tags referencing specs)\nclosed_tickets_raw = os.environ.get('CLOSED_TICKETS', '')\ntasks_closed = len([line for line in closed_tickets_raw.strip().split('\\n') if line.strip()])\n\n# Build session summary\nif artifacts_touched:\n summaries = [a['summary'] for a in artifacts_touched]\n session_summary = '; '.join(summaries[:5])\n if len(summaries) > 5:\n session_summary += f' (and {len(summaries) - 5} more)'\nelif commit_count > 0:\n session_summary = f'{commit_count} commits with no artifact references.'\nelse:\n session_summary = 'Empty session — no commits recorded.'\n\n# Build the digest entry\nentry = {\n 'session_id': session_id,\n 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),\n 'focus_lane': focus if focus else None,\n 'artifacts_touched': artifacts_touched,\n 'commits': commit_count,\n 'tasks_closed': tasks_closed,\n 'session_summary': session_summary,\n}\n\n# Append to output file\nwith open(output_file, 'a') as f:\n f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n\" \u003c\u003c\u003c \"\" || {\n echo \"Error: Python JSON construction failed\" >&2\n exit 1\n}\n\necho \"Digest written to $OUTPUT_FILE\" >&2\nexit 0\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":8177,"content_sha256":"bec7e1c5496c68d70f26294e2f44a907b1302bfc337af04d227f11a64baea3a4"},{"filename":"scripts/swain-session-greeting.sh","content":"#!/usr/bin/env bash\n# swain-session-greeting.sh — SPEC-194: Fast-path session greeting\n#\n# Produces immediate session context without expensive operations.\n# Calls the preflight script for all read-only state, then applies\n# lightweight mutations (tab naming, lock cleanup, .agents dir).\n# Does NOT invoke specgraph, GitHub API, or the full status dashboard.\n#\n# Usage:\n# swain-session-greeting.sh # human-readable output\n# swain-session-greeting.sh --json # structured JSON\n# swain-session-greeting.sh --path DIR # resolve from DIR\n#\n# Output (human-readable):\n# Branch, dirty state, bookmark, focus lane, warnings\n#\n# Output (JSON):\n# { greeting: true, branch, dirty, bookmark, focus, purpose, warnings[] }\n#\n# Session purpose (SPEC-297):\n# If $SWAIN_PURPOSE is set and the session has no existing bookmark,\n# the purpose text is written to the bookmark deterministically and\n# surfaced as the `purpose` JSON field. Agent skills consume this\n# field; they no longer parse the initial prompt themselves.\n\nset +e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPREFLIGHT_SCRIPT=\"$SCRIPT_DIR/swain-session-preflight.sh\"\nTAB_NAME_SCRIPT=\"$SCRIPT_DIR/swain-tab-name.sh\"\n\nJSON_MODE=0\nEXTRA_PATH=\"\"\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --json) JSON_MODE=1; shift ;;\n --path) EXTRA_PATH=\"$2\"; shift 2 ;;\n *) shift ;;\n esac\ndone\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\n\n# ─── Step 1: Run preflight (single script, no subprocess chain) ───\nPREFLIGHT_ARGS=(--repo-root \"$REPO_ROOT\")\n[[ -n \"$EXTRA_PATH\" ]] && PREFLIGHT_ARGS+=(--path \"$EXTRA_PATH\")\n\nPREFLIGHT_JSON=\"\"\nif [[ -f \"$PREFLIGHT_SCRIPT\" ]]; then\n PREFLIGHT_JSON=$(bash \"$PREFLIGHT_SCRIPT\" \"${PREFLIGHT_ARGS[@]}\" 2>/dev/null)\nfi\n\n# Parse preflight output\nif command -v jq &>/dev/null && [[ -n \"$PREFLIGHT_JSON\" ]]; then\n BRANCH=$(echo \"$PREFLIGHT_JSON\" | jq -r '.git.branch // \"unknown\"' 2>/dev/null)\n DIRTY=$(echo \"$PREFLIGHT_JSON\" | jq -r 'if .git.dirty then \"true\" else \"false\" end' 2>/dev/null)\n ISOLATED=$(echo \"$PREFLIGHT_JSON\" | jq -r 'if .git.worktree.isolated then \"true\" else \"false\" end' 2>/dev/null)\n BOOKMARK=$(echo \"$PREFLIGHT_JSON\" | jq -r '.session.bookmark // empty' 2>/dev/null)\n FOCUS=$(echo \"$PREFLIGHT_JSON\" | jq -r '.session.focus // empty' 2>/dev/null)\n TAB_NAME=$(echo \"$PREFLIGHT_JSON\" | jq -r '.tmux.tab_name // empty' 2>/dev/null)\n # Collect preflight warnings (structured keys like \"stale_tk_locks:3\")\n PREFLIGHT_WARNINGS=$(echo \"$PREFLIGHT_JSON\" | jq -r '.warnings[]? // empty' 2>/dev/null)\nelse\n BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"unknown\")\n DIRTY=\"false\"\n [[ -n \"$(git status --porcelain 2>/dev/null | head -1)\" ]] && DIRTY=\"true\"\n ISOLATED=\"false\"\n BOOKMARK=\"\"\n FOCUS=\"\"\n TAB_NAME=\"\"\n PREFLIGHT_WARNINGS=\"\"\nfi\n\n# ─── Step 2: Apply mutations ───\nWARNINGS=()\n\n# Tab naming (tmux only) — uses the precomputed name, avoids full tab-name.sh resolution\nif [[ -n \"${TMUX:-}\" && -n \"$TAB_NAME\" && -f \"$TAB_NAME_SCRIPT\" ]]; then\n TAB_ARGS=(\"$TAB_NAME\")\n [[ -n \"$EXTRA_PATH\" ]] && TAB_ARGS=(--path \"$EXTRA_PATH\" --auto)\n TAB=$(bash \"$TAB_NAME_SCRIPT\" \"${TAB_ARGS[@]}\" 2>/dev/null)\nelse\n TAB=\"$TAB_NAME\"\nfi\n\n# Clean stale tk locks\nfor w in $PREFLIGHT_WARNINGS; do\n case \"$w\" in\n stale_tk_locks:*)\n count=\"${w#stale_tk_locks:}\"\n find \"$REPO_ROOT/.tickets/.locks\" -type d -mmin +60 -exec rm -rf {} + 2>/dev/null\n WARNINGS+=(\"cleaned $count stale tk lock(s)\")\n ;;\n stale_git_index_lock)\n WARNINGS+=(\"stale git index.lock detected — may need manual removal\")\n ;;\n missing_agents_dir)\n mkdir -p \"$REPO_ROOT/.agents\"\n WARNINGS+=(\"created missing .agents/ directory\")\n ;;\n *)\n WARNINGS+=(\"$w\")\n ;;\n esac\ndone\n\n# Update lastBranch in session.json\nSESSION_FILE=\"$REPO_ROOT/.agents/session.json\"\nif [[ -f \"$SESSION_FILE\" ]] && command -v jq &>/dev/null; then\n jq --arg branch \"$BRANCH\" '.lastBranch = $branch' \\\n \"$SESSION_FILE\" > \"${SESSION_FILE}.tmp\" 2>/dev/null \\\n && mv \"${SESSION_FILE}.tmp\" \"$SESSION_FILE\" 2>/dev/null\nfi\n\n# SPEC-297: Session purpose capture.\n# If SWAIN_PURPOSE is set and no bookmark exists yet, write it and re-read.\nPURPOSE=\"${SWAIN_PURPOSE:-}\"\nif [[ -n \"$PURPOSE\" && -z \"$BOOKMARK\" ]]; then\n BOOKMARK_SCRIPT=\"$SCRIPT_DIR/swain-bookmark.sh\"\n if [[ -f \"$BOOKMARK_SCRIPT\" ]]; then\n SWAIN_REPO_ROOT=\"$REPO_ROOT\" bash \"$BOOKMARK_SCRIPT\" \"$PURPOSE\" >/dev/null 2>&1\n BOOKMARK=\"$PURPOSE\"\n fi\nfi\n\n# ─── Step 3: Output ───\nif [[ \"$JSON_MODE\" -eq 1 ]]; then\n WARNINGS_JSON=\"[]\"\n if command -v jq &>/dev/null; then\n for w in \"${WARNINGS[@]}\"; do\n WARNINGS_JSON=$(echo \"$WARNINGS_JSON\" | jq --arg w \"$w\" '. + [$w]')\n done\n fi\n\n if command -v jq &>/dev/null; then\n jq -n \\\n --arg branch \"$BRANCH\" \\\n --arg dirty \"$DIRTY\" \\\n --arg bookmark \"$BOOKMARK\" \\\n --arg focus \"$FOCUS\" \\\n --arg isolated \"$ISOLATED\" \\\n --arg tab \"$TAB\" \\\n --arg purpose \"$PURPOSE\" \\\n --argjson warnings \"$WARNINGS_JSON\" \\\n '{\n greeting: true,\n branch: $branch,\n dirty: ($dirty == \"true\"),\n isolated: ($isolated == \"true\"),\n bookmark: (if $bookmark == \"\" then null else $bookmark end),\n focus: (if $focus == \"\" then null else $focus end),\n purpose: (if $purpose == \"\" then null else $purpose end),\n tab: (if $tab == \"\" then null else $tab end),\n warnings: $warnings\n }'\n else\n echo \"{\\\"greeting\\\":true,\\\"branch\\\":\\\"$BRANCH\\\",\\\"dirty\\\":$DIRTY}\"\n fi\nelse\n state=\"clean\"\n [[ \"$DIRTY\" == \"true\" ]] && state=\"dirty\"\n isolation=\"\"\n [[ \"$ISOLATED\" == \"true\" ]] && isolation=\" (worktree)\"\n\n echo \"Branch: $BRANCH${isolation} [$state]\"\n\n if [[ -n \"$PURPOSE\" ]]; then\n echo \"Purpose: $PURPOSE\"\n fi\n\n if [[ -n \"$BOOKMARK\" ]]; then\n echo \"Bookmark: $BOOKMARK\"\n fi\n\n if [[ -n \"$FOCUS\" ]]; then\n echo \"Focus: $FOCUS\"\n fi\n\n for w in \"${WARNINGS[@]}\"; do\n echo \"Warning: $w\"\n done\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":6038,"content_sha256":"fdba5c0b09ae773cfa413ee81f4def2cb7cbfec73d8b8c57d462e237253c3e8b"},{"filename":"scripts/swain-session-preflight.sh","content":"#!/usr/bin/env bash\n# swain-session-preflight.sh — read-only session state scanner\n#\n# Consolidates all session startup reads into a single script.\n# Replaces the subprocess chain: greeting → bootstrap → tab-name.\n# This script NEVER mutates state (no tab renames, no file writes,\n# no lock cleanup). The caller applies mutations using the JSON output.\n#\n# Usage: bash swain-session-preflight.sh [--repo-root /path] [--path /dir]\n#\n# JSON schema (all keys present, some may be null on error):\n#\n# git.repo_root string — resolved repository root\n# git.branch string — current branch name\n# git.dirty bool — uncommitted changes present\n# git.worktree.isolated bool — running inside a linked worktree\n# git.worktree.path string — worktree path (null if not isolated)\n#\n# tmux.active bool — running inside a tmux session\n# tmux.tab_format string — tab name format from settings\n# tmux.tab_name string — computed tab name (not applied)\n#\n# session.focus string — focus lane from session.json\n# session.bookmark string — bookmark note from session.json\n# session.last_branch string — last branch from session.json\n#\n# prev_session.exists bool — session-state.json found\n# prev_session.status string — \"active\" | \"stale\" | \"closed\" | \"none\"\n# prev_session.session_id string — session identifier\n# prev_session.focus_lane string — focus lane from state\n# prev_session.phase string — lifecycle phase\n# prev_session.start_time string — ISO timestamp\n# prev_session.end_time string — ISO timestamp (null if active)\n# prev_session.decisions_made int — count\n# prev_session.walkaway string — walk-away note (null if none)\n#\n# warnings array — preflight warnings (read-only observations)\n#\n# Exit: always 0 (partial results on individual check failures)\n\nset -euo pipefail\n\nREPO_ROOT=\"\"\nDETECT_PATH=\"\"\n\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --repo-root) REPO_ROOT=\"$2\"; shift 2 ;;\n --path) DETECT_PATH=\"$2\"; shift 2 ;;\n *) shift ;;\n esac\ndone\n\nif [ -z \"$REPO_ROOT\" ]; then\n REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nfi\n\nif [ -z \"$DETECT_PATH\" ]; then\n DETECT_PATH=\"$REPO_ROOT\"\nfi\n\n# --- Collector variables ---\n\nWARNINGS_RAW=\"\"\nadd_warning() {\n WARNINGS_RAW=\"${WARNINGS_RAW}${1}\n\"\n}\n\n# --- Git state ---\ncheck_git() {\n GIT_BRANCH=$(git -C \"$DETECT_PATH\" rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"unknown\")\n GIT_DIRTY=false\n if [ -n \"$(git -C \"$DETECT_PATH\" status --porcelain 2>/dev/null | head -1)\" ]; then\n GIT_DIRTY=true\n fi\n\n GIT_WT_ISOLATED=false\n GIT_WT_PATH=\"\"\n GIT_COMMON=$(git -C \"$DETECT_PATH\" rev-parse --git-common-dir 2>/dev/null || true)\n GIT_DIR=$(git -C \"$DETECT_PATH\" rev-parse --git-dir 2>/dev/null || true)\n if [ -n \"$GIT_COMMON\" ] && [ -n \"$GIT_DIR\" ] && [ \"$GIT_COMMON\" != \"$GIT_DIR\" ]; then\n GIT_WT_ISOLATED=true\n GIT_WT_PATH=\"$DETECT_PATH\"\n fi\n}\n\n# --- Tmux state (read-only — no renames) ---\ncheck_tmux() {\n TMUX_ACTIVE=false\n TMUX_TAB_FORMAT=\"\"\n TMUX_TAB_NAME=\"\"\n\n if [ -n \"${TMUX:-}\" ]; then\n TMUX_ACTIVE=true\n fi\n\n # Read tab format from settings (project, then user)\n SETTINGS_PROJECT=\"$REPO_ROOT/swain.settings.json\"\n SETTINGS_USER=\"${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json\"\n TMUX_TAB_FORMAT='{project} @ {branch}'\n\n if [ -f \"$SETTINGS_USER\" ] && command -v jq &>/dev/null; then\n val=$(jq -r '.terminal.tabNameFormat // empty' \"$SETTINGS_USER\" 2>/dev/null || true)\n [ -n \"$val\" ] && TMUX_TAB_FORMAT=\"$val\"\n fi\n if [ -f \"$SETTINGS_PROJECT\" ] && command -v jq &>/dev/null; then\n val=$(jq -r '.terminal.tabNameFormat // empty' \"$SETTINGS_PROJECT\" 2>/dev/null || true)\n [ -n \"$val\" ] && TMUX_TAB_FORMAT=\"$val\"\n fi\n\n # Compute tab name from git context (same logic as tab-name.sh auto_title)\n local common_dir repo_root project\n common_dir=$(git -C \"$DETECT_PATH\" rev-parse --git-common-dir 2>/dev/null || true)\n if [ -n \"$common_dir\" ]; then\n repo_root=$(cd \"$DETECT_PATH\" && cd \"$common_dir/..\" && pwd 2>/dev/null || true)\n fi\n project=$(basename \"${repo_root:-unknown}\")\n\n TMUX_TAB_NAME=\"${TMUX_TAB_FORMAT//\\{project\\}/$project}\"\n TMUX_TAB_NAME=\"${TMUX_TAB_NAME//\\{branch\\}/$GIT_BRANCH}\"\n}\n\n# --- Session.json (bookmark, focus, last branch) ---\ncheck_session_json() {\n SESSION_FOCUS=\"\"\n SESSION_BOOKMARK=\"\"\n SESSION_LAST_BRANCH=\"\"\n\n local session_file=\"$REPO_ROOT/.agents/session.json\"\n\n if [ -f \"$session_file\" ] && command -v jq &>/dev/null; then\n SESSION_FOCUS=$(jq -r '.focus_lane // empty' \"$session_file\" 2>/dev/null || true)\n SESSION_BOOKMARK=$(jq -r '.bookmark.note // empty' \"$session_file\" 2>/dev/null || true)\n SESSION_LAST_BRANCH=$(jq -r '.lastBranch // empty' \"$session_file\" 2>/dev/null || true)\n fi\n}\n\n# --- Session state (previous session resume context) ---\ncheck_session_state() {\n PREV_EXISTS=false\n PREV_STATUS=\"none\"\n PREV_SESSION_ID=\"\"\n PREV_FOCUS_LANE=\"\"\n PREV_PHASE=\"\"\n PREV_START_TIME=\"\"\n PREV_END_TIME=\"\"\n PREV_DECISIONS=0\n PREV_WALKAWAY=\"\"\n\n local state_file=\"${SWAIN_SESSION_STATE:-$REPO_ROOT/.agents/session-state.json}\"\n\n if [ -f \"$state_file\" ]; then\n PREV_EXISTS=true\n # Extract all fields in one python3 call\n eval \"$(python3 -c \"\nimport json, sys\nfrom datetime import datetime, timezone\n\nwith open('$state_file') as f:\n state = json.load(f)\n\nphase = state.get('phase', 'unknown')\nfocus = state.get('focus_lane') or ''\nsid = state.get('session_id') or ''\nactivity = state.get('last_activity_time') or state.get('start_time', '')\nstart = state.get('start_time', '')\nend = state.get('end_time') or ''\ndecisions = state.get('decisions_made', 0)\nwalkaway = state.get('walkaway') or ''\n\nstatus = 'none'\nif phase == 'closed':\n status = 'closed'\nelif phase == 'active':\n try:\n activity_dt = datetime.fromisoformat(activity.replace('Z', '+00:00'))\n age = (datetime.now(timezone.utc) - activity_dt).total_seconds()\n status = 'stale' if age > 3600 else 'active'\n except (ValueError, TypeError):\n status = 'stale'\n\n# Shell-safe quoting via repr\ndef q(s):\n return s.replace(\\\"'\\\", \\\"'\\\\\\\"'\\\\\\\"'\\\")\n\nprint(f\\\"PREV_STATUS='{q(status)}'\\\")\nprint(f\\\"PREV_SESSION_ID='{q(sid)}'\\\")\nprint(f\\\"PREV_FOCUS_LANE='{q(focus)}'\\\")\nprint(f\\\"PREV_PHASE='{q(phase)}'\\\")\nprint(f\\\"PREV_START_TIME='{q(start)}'\\\")\nprint(f\\\"PREV_END_TIME='{q(end)}'\\\")\nprint(f\\\"PREV_DECISIONS={decisions}\\\")\nprint(f\\\"PREV_WALKAWAY='{q(walkaway)}'\\\")\n\" 2>/dev/null)\" || true\n fi\n}\n\n# --- Preflight warnings (read-only observations) ---\ncheck_warnings() {\n # Stale tk locks (report only — don't clean)\n if [ -d \"$REPO_ROOT/.tickets/.locks\" ]; then\n stale_count=$(find \"$REPO_ROOT/.tickets/.locks\" -type d -mmin +60 2>/dev/null | wc -l | tr -d ' ')\n if [ \"$stale_count\" -gt 0 ]; then\n add_warning \"stale_tk_locks:$stale_count\"\n fi\n fi\n\n # Stale git index.lock\n if [ -f \"$REPO_ROOT/.git/index.lock\" ]; then\n add_warning \"stale_git_index_lock\"\n fi\n\n # Missing .agents directory\n if [ ! -d \"$REPO_ROOT/.agents\" ]; then\n add_warning \"missing_agents_dir\"\n fi\n}\n\n# --- Run all checks ---\ncheck_git || true\ncheck_tmux || true\ncheck_session_json || true\ncheck_session_state || true\ncheck_warnings || true\n\n# --- Emit JSON via python3 ---\npython3 -c \"\nimport json, sys\n\ndef to_bool(v):\n return v.lower() == 'true'\n\ndef to_int(v):\n try: return int(v)\n except: return 0\n\ndef to_str_or_null(v):\n return v if v else None\n\ndef to_list(raw):\n return [x for x in raw.strip().split('\\n') if x] if raw.strip() else []\n\ndata = {\n 'git': {\n 'repo_root': sys.argv[1],\n 'branch': sys.argv[2] or 'unknown',\n 'dirty': to_bool(sys.argv[3]),\n 'worktree': {\n 'isolated': to_bool(sys.argv[4]),\n 'path': to_str_or_null(sys.argv[5]),\n },\n },\n 'tmux': {\n 'active': to_bool(sys.argv[6]),\n 'tab_format': sys.argv[7],\n 'tab_name': sys.argv[8],\n },\n 'session': {\n 'focus': to_str_or_null(sys.argv[9]),\n 'bookmark': to_str_or_null(sys.argv[10]),\n 'last_branch': to_str_or_null(sys.argv[11]),\n },\n 'prev_session': {\n 'exists': to_bool(sys.argv[12]),\n 'status': sys.argv[13],\n 'session_id': to_str_or_null(sys.argv[14]),\n 'focus_lane': to_str_or_null(sys.argv[15]),\n 'phase': to_str_or_null(sys.argv[16]),\n 'start_time': to_str_or_null(sys.argv[17]),\n 'end_time': to_str_or_null(sys.argv[18]),\n 'decisions_made': to_int(sys.argv[19]),\n 'walkaway': to_str_or_null(sys.argv[20]),\n },\n 'warnings': to_list(sys.argv[21]),\n}\n\njson.dump(data, sys.stdout, indent=2)\nprint()\n\" \\\n \"$REPO_ROOT\" \"$GIT_BRANCH\" \"$GIT_DIRTY\" \\\n \"$GIT_WT_ISOLATED\" \"$GIT_WT_PATH\" \\\n \"$TMUX_ACTIVE\" \"$TMUX_TAB_FORMAT\" \"$TMUX_TAB_NAME\" \\\n \"$SESSION_FOCUS\" \"$SESSION_BOOKMARK\" \"$SESSION_LAST_BRANCH\" \\\n \"$PREV_EXISTS\" \"$PREV_STATUS\" \"$PREV_SESSION_ID\" \"$PREV_FOCUS_LANE\" \\\n \"$PREV_PHASE\" \"$PREV_START_TIME\" \"$PREV_END_TIME\" \"$PREV_DECISIONS\" \\\n \"$PREV_WALKAWAY\" \\\n \"$WARNINGS_RAW\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":9167,"content_sha256":"748c086d8b26441fcc476b9c58d2c1eb2708ce65e9f67037b8cc10b1158f020a"},{"filename":"scripts/swain-session-state.sh","content":"#!/usr/bin/env bash\n# swain-session-state.sh — Session lifecycle state management\n# SPEC-119: Session Lifecycle in swain-session\n#\n# Commands:\n# init Create a new session state\n# record-decision Record a decision made during the session\n# close Close the session with a walk-away signal\n# resume Read previous session state and emit resume context\n# show Display current session state\n#\n# All commands accept --state-file \u003cpath> to override the default location.\nset -uo pipefail\n\n# Defaults\nSTATE_FILE=\"${SWAIN_SESSION_STATE:-.agents/session-state.json}\"\nSESSION_ROADMAP=\"\"\nREPO_ROOT=\"\"\nFOCUS=\"\"\nBUDGET=5\nWALKAWAY=\"\"\nNOTE=\"\"\n\nusage() {\n echo \"usage: swain-session-state.sh \u003ccommand> [options]\"\n echo \"\"\n echo \"Commands: init, record-decision, close, resume, show\"\n echo \"\"\n echo \"Common options:\"\n echo \" --state-file \u003cpath> Override state file location\"\n echo \"\"\n echo \"init options:\"\n echo \" --focus \u003cID> Focus lane (vision/initiative ID)\"\n echo \" --budget \u003cN> Decision budget (default: 5)\"\n echo \" --session-roadmap \u003cpath> Path for SESSION-ROADMAP.md\"\n echo \" --repo-root \u003cpath> Repository root for chart.sh\"\n echo \"\"\n echo \"record-decision options:\"\n echo \" --note \u003ctext> Decision description\"\n echo \"\"\n echo \"close options:\"\n echo \" --walkaway \u003ctext> Walk-away signal text\"\n echo \" --session-roadmap \u003cpath> Path to SESSION-ROADMAP.md to finalize\"\n}\n\n# Parse command\nCOMMAND=\"${1:-}\"\nshift 2>/dev/null || true\n\nif [ -z \"$COMMAND\" ]; then\n usage\n exit 1\nfi\n\n# Parse options\nwhile [ $# -gt 0 ]; do\n case \"$1\" in\n --state-file) STATE_FILE=\"$2\"; shift 2 ;;\n --focus) FOCUS=\"$2\"; shift 2 ;;\n --budget) BUDGET=\"$2\"; shift 2 ;;\n --walkaway) WALKAWAY=\"$2\"; shift 2 ;;\n --note) NOTE=\"$2\"; shift 2 ;;\n --session-roadmap) SESSION_ROADMAP=\"$2\"; shift 2 ;;\n --repo-root) REPO_ROOT=\"$2\"; shift 2 ;;\n -h|--help) usage; exit 0 ;;\n *) echo \"Unknown option: $1\" >&2; exit 1 ;;\n esac\ndone\n\n# Generate a short session ID (timestamp + random suffix)\ngenerate_session_id() {\n local ts\n ts=$(date +%Y%m%d-%H%M%S)\n local suffix\n suffix=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \\n' | head -c 4)\n echo \"session-${ts}-${suffix}\"\n}\n\n# ISO 8601 timestamp\nnow_iso() {\n date -u +\"%Y-%m-%dT%H:%M:%SZ\"\n}\n\ncmd_init() {\n local session_id\n session_id=$(generate_session_id)\n local start_time\n start_time=$(now_iso)\n local last_activity_time\n last_activity_time=\"$start_time\"\n\n # Ensure directory exists\n mkdir -p \"$(dirname \"$STATE_FILE\")\"\n\n # Write initial state\n python3 -c \"\nimport json\nstate = {\n 'session_id': '$session_id',\n 'focus_lane': '$FOCUS',\n 'phase': 'active',\n 'start_time': '$start_time',\n 'last_activity_time': '$last_activity_time',\n 'end_time': None,\n 'decision_budget': $BUDGET,\n 'decisions_made': 0,\n 'decisions': [],\n 'walkaway': None\n}\nwith open('$STATE_FILE', 'w') as f:\n json.dump(state, f, indent=2)\n\"\n\n # Generate SESSION-ROADMAP.md if path provided and chart.sh available\n if [ -n \"$SESSION_ROADMAP\" ]; then\n local repo=\"${REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}\"\n local chart\n chart=$(find \"$repo\" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)\n if [ -n \"$chart\" ] && [ -n \"$FOCUS\" ]; then\n bash \"$chart\" session --focus \"$FOCUS\" 2>/dev/null\n # chart.sh writes to SESSION-ROADMAP.md in repo root; move if needed\n local default_roadmap=\"$repo/SESSION-ROADMAP.md\"\n if [ \"$SESSION_ROADMAP\" != \"$default_roadmap\" ] && [ -f \"$default_roadmap\" ]; then\n cp \"$default_roadmap\" \"$SESSION_ROADMAP\"\n fi\n fi\n fi\n\n echo \"$session_id\"\n}\n\ncmd_record_decision() {\n if [ ! -f \"$STATE_FILE\" ]; then\n echo \"Error: No active session. Run 'init' first.\" >&2\n exit 1\n fi\n\n python3 -c \"\nimport json\nfrom datetime import datetime, timezone\n\nwith open('$STATE_FILE') as f:\n state = json.load(f)\n\ncurrent_time = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')\nstate['decisions_made'] = state.get('decisions_made', 0) + 1\nstate['decisions'].append({\n 'note': '''$NOTE''',\n 'timestamp': current_time\n})\nstate['last_activity_time'] = current_time\n\nwith open('$STATE_FILE', 'w') as f:\n json.dump(state, f, indent=2)\n\"\n}\n\ncmd_close() {\n if [ ! -f \"$STATE_FILE\" ]; then\n echo \"Error: No active session. Run 'init' first.\" >&2\n exit 1\n fi\n\n local end_time\n end_time=$(now_iso)\n\n python3 -c \"\nimport json\n\nwith open('$STATE_FILE') as f:\n state = json.load(f)\n\nstate['phase'] = 'closed'\nstate['end_time'] = '$end_time'\nstate['walkaway'] = '''$WALKAWAY'''\n\nwith open('$STATE_FILE', 'w') as f:\n json.dump(state, f, indent=2)\n\"\n\n # Append walk-away signal to SESSION-ROADMAP.md if provided\n if [ -n \"$SESSION_ROADMAP\" ] && [ -f \"$SESSION_ROADMAP\" ]; then\n cat >> \"$SESSION_ROADMAP\" \u003c\u003cEOF\n\n## Walk-Away Signal\n\n> $WALKAWAY\n\n*Session closed: $end_time*\nEOF\n fi\n}\n\ncmd_resume() {\n if [ ! -f \"$STATE_FILE\" ]; then\n echo \"No previous session found.\"\n exit 0\n fi\n\n python3 -c \"\nimport json\n\nwith open('$STATE_FILE') as f:\n state = json.load(f)\n\nfocus = state.get('focus_lane', 'none')\nwalkaway = state.get('walkaway', 'none')\ndecisions = state.get('decisions_made', 0)\nphase = state.get('phase', 'unknown')\nstart = state.get('start_time', 'unknown')\nend = state.get('end_time', 'unknown')\nsession_id = state.get('session_id', 'unknown')\n\nprint(f'Previous session: {session_id}')\nprint(f'Focus: {focus}')\nprint(f'Phase: {phase}')\nprint(f'Started: {start}')\nif end and end != 'None':\n print(f'Ended: {end}')\nprint(f'Decisions made: {decisions}')\nif walkaway and walkaway != 'None':\n print(f'Walk-away: {walkaway}')\n\"\n}\n\ncmd_show() {\n if [ ! -f \"$STATE_FILE\" ]; then\n echo \"No active session.\"\n exit 0\n fi\n python3 -c \"\nimport json\nwith open('$STATE_FILE') as f:\n print(json.dumps(json.load(f), indent=2))\n\"\n}\n\ncase \"$COMMAND\" in\n init) cmd_init ;;\n record-decision) cmd_record_decision ;;\n close) cmd_close ;;\n resume) cmd_resume ;;\n show) cmd_show ;;\n *) echo \"Unknown command: $COMMAND\" >&2; usage; exit 1 ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":6282,"content_sha256":"63cbcb39253d097dd721585708127a4cc33400044b3da9c7f316728c20fd91b7"},{"filename":"scripts/swain-startup-timing.sh","content":"#!/usr/bin/env bash\n# swain-startup-timing.sh — SPIKE-001: Instrument session startup time\n#\n# Measures wall time for each phase of the session startup chain.\n# Does NOT modify any existing scripts — wraps them with timing.\n#\n# Usage:\n# swain-startup-timing.sh [--include-status] [--runs N] [--json]\n#\n# Output: timing breakdown by phase (human-readable or JSON)\n\nset +e\n\n# Portable path resolution — resolves through symlinks\n_src=\"${BASH_SOURCE[0]}\"\nwhile [[ -L \"$_src\" ]]; do\n _dir=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\n _src=\"$(readlink \"$_src\")\"\n [[ \"$_src\" != /* ]] && _src=\"$_dir/$_src\"\ndone\nSCRIPT_DIR=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\n\nINCLUDE_STATUS=0\nRUNS=1\nJSON_OUTPUT=0\n\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --include-status) INCLUDE_STATUS=1; shift ;;\n --runs) RUNS=\"$2\"; shift 2 ;;\n --json) JSON_OUTPUT=1; shift ;;\n *) shift ;;\n esac\ndone\n\n# Portable millisecond timer (macOS date doesn't support %N)\nif command -v gdate &>/dev/null; then\n _ts() { gdate +%s%3N; }\nelif date +%s%N &>/dev/null 2>&1; then\n _ts() { echo $(( $(date +%s%N) / 1000000 )); }\nelse\n # Fallback: second-level precision (macOS without coreutils)\n _ts() { python3 -c \"import time; print(int(time.time()*1000))\"; }\nfi\n\ndeclare -a PHASE_NAMES\ndeclare -a PHASE_DURATIONS\n\ntime_phase() {\n local name=\"$1\"\n shift\n local start=$(_ts)\n \"$@\" >/dev/null 2>&1\n local end=$(_ts)\n local dur=$((end - start))\n PHASE_NAMES+=(\"$name\")\n PHASE_DURATIONS+=(\"$dur\")\n}\n\nrun_single() {\n PHASE_NAMES=()\n PHASE_DURATIONS=()\n\n local total_start=$(_ts)\n\n # Phase 1: .swain/init.json marker check (what the shell launcher would do)\n time_phase \"init_marker_check\" test -f \"$REPO_ROOT/.swain/init.json\"\n\n # Phase 2: Preflight\n time_phase \"preflight\" bash \"$(dirname \"$(dirname \"$SCRIPT_DIR\")\")/swain-doctor/scripts/swain-preflight.sh\"\n\n # Phase 3: Bootstrap (tab naming + worktree detect + session.json)\n time_phase \"bootstrap_full\" bash \"$SCRIPT_DIR/swain-session-bootstrap.sh\" --auto\n\n # Phase 3a: Bootstrap sub-phases (individual measurement)\n # Tab naming only\n if [[ -n \"${TMUX:-}\" ]] && [[ -f \"$SCRIPT_DIR/swain-tab-name.sh\" ]]; then\n time_phase \"tab_naming\" bash \"$SCRIPT_DIR/swain-tab-name.sh\" --auto\n else\n PHASE_NAMES+=(\"tab_naming\")\n PHASE_DURATIONS+=(\"0\")\n fi\n\n # Worktree detection only\n time_phase \"worktree_detect\" git rev-parse --git-common-dir\n\n # Session.json read only\n time_phase \"session_json_read\" jq -r '.focus_lane // empty' \"$REPO_ROOT/.agents/session.json\"\n\n # Phase 4: Status dashboard (optional — this is the expensive one)\n if [[ \"$INCLUDE_STATUS\" -eq 1 ]] && [[ -f \"$SCRIPT_DIR/swain-status.sh\" ]]; then\n time_phase \"status_dashboard\" bash \"$SCRIPT_DIR/swain-status.sh\" --json --refresh\n fi\n\n local total_end=$(_ts)\n local total=$((total_end - total_start))\n PHASE_NAMES+=(\"total_measured\")\n PHASE_DURATIONS+=(\"$total\")\n}\n\n# ─── Execution ───\n\nALL_RESULTS=()\n\nfor ((i=1; i\u003c=RUNS; i++)); do\n run_single\n\n if [[ \"$JSON_OUTPUT\" -eq 1 ]]; then\n # Build JSON for this run\n run_json=\"{\"\n for ((j=0; j\u003c${#PHASE_NAMES[@]}; j++)); do\n [[ $j -gt 0 ]] && run_json+=\",\"\n run_json+=\"\\\"${PHASE_NAMES[$j]}\\\":${PHASE_DURATIONS[$j]}\"\n done\n run_json+=\"}\"\n ALL_RESULTS+=(\"$run_json\")\n else\n echo \"=== Run $i/$RUNS ===\"\n for ((j=0; j\u003c${#PHASE_NAMES[@]}; j++)); do\n printf \" %-25s %6d ms\\n\" \"${PHASE_NAMES[$j]}\" \"${PHASE_DURATIONS[$j]}\"\n done\n echo \"\"\n fi\ndone\n\nif [[ \"$JSON_OUTPUT\" -eq 1 ]]; then\n echo -n '{\"runs\":['\n for ((i=0; i\u003c${#ALL_RESULTS[@]}; i++)); do\n [[ $i -gt 0 ]] && echo -n \",\"\n echo -n \"${ALL_RESULTS[$i]}\"\n done\n echo -n '],\"note\":\"Times are script execution only. LLM inference and tool-call overhead are not measured here — they dominate total wall time but cannot be measured from within scripts.\"}'\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3910,"content_sha256":"985ea935bcf2a29cfd69609af5a752a8108362b9985a0e1ceb57a89cfc359532"},{"filename":"scripts/swain-status.sh","content":"#!/usr/bin/env bash\nset -euo pipefail\n\n# swain-status.sh — Cross-cutting project status aggregator\n#\n# Collects data from specgraph, tk (tickets), git, GitHub, and session state.\n# Writes a structured JSON cache and outputs rich terminal text.\n#\n# Usage:\n# swain-status.sh # full rich output (for in-conversation display)\n# swain-status.sh --compact # condensed output (for MOTD consumption)\n# swain-status.sh --json # raw JSON cache (for programmatic access)\n# swain-status.sh --refresh # force-refresh cache, then full output\n\n# --- Resolve paths ---\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || {\n echo \"Error: not inside a git repository\" >&2\n exit 1\n}\n# Portable path resolution — resolves through symlinks\n_src=\"${BASH_SOURCE[0]:-$0}\"\nwhile [[ -L \"$_src\" ]]; do\n _dir=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\n _src=\"$(readlink \"$_src\")\"\n [[ \"$_src\" != /* ]] && _src=\"$_dir/$_src\"\ndone\nSCRIPT_DIR=\"$(cd \"$(dirname \"$_src\")\" && pwd)\"\nSPECGRAPH=\"$SCRIPT_DIR/../../swain-design/scripts/specgraph_entry.py\"\n\nPROJECT_NAME=\"$(basename \"$REPO_ROOT\")\"\nSETTINGS_PROJECT=\"$REPO_ROOT/swain.settings.json\"\nSETTINGS_USER=\"${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json\"\n\n# Cache location: project-local .agents/ directory\nCACHE_FILE=\"${SWAIN_CACHE_FILE:-$REPO_ROOT/.agents/status-cache.json}\"\nSESSION_FILE=\"$REPO_ROOT/.agents/session.json\"\n\n# Migration: if new cache absent but old global cache exists, seed from old location\nif [[ ! -f \"$CACHE_FILE\" ]]; then\n _PROJECT_SLUG=$(echo \"$REPO_ROOT\" | tr '/' '-')\n _OLD_CACHE=\"$HOME/.claude/projects/${_PROJECT_SLUG}/memory/status-cache.json\"\n if [[ -f \"$_OLD_CACHE\" ]]; then\n mkdir -p \"$(dirname \"$CACHE_FILE\")\"\n cp \"$_OLD_CACHE\" \"$CACHE_FILE\"\n fi\n unset _PROJECT_SLUG _OLD_CACHE\nfi\n\n# GitHub remote\nGH_REMOTE_URL=\"$(git remote get-url origin 2>/dev/null || echo \"\")\"\nGH_REPO=\"\"\nif [[ \"$GH_REMOTE_URL\" =~ github\\.com[:/]([^/]+/[^/.]+) ]]; then\n GH_REPO=\"${BASH_REMATCH[1]}\"\nfi\n\n# Cache TTL in seconds (default: 120)\nCACHE_TTL=120\n\n# --- Settings reader ---\nread_setting() {\n local key=\"$1\" default=\"$2\" val=\"\"\n if [[ -f \"$SETTINGS_USER\" ]]; then\n val=$(jq -r \"$key // empty\" \"$SETTINGS_USER\" 2>/dev/null) || true\n fi\n if [[ -z \"$val\" && -f \"$SETTINGS_PROJECT\" ]]; then\n val=$(jq -r \"$key // empty\" \"$SETTINGS_PROJECT\" 2>/dev/null) || true\n fi\n echo \"${val:-$default}\"\n}\n\n# --- OSC 8 hyperlink helpers ---\n# Only emit OSC 8 sequences when stdout is a terminal.\n# When piped (e.g., captured by an agent), emit plain text to avoid\n# corrupted escape sequences in non-terminal rendering (#36).\n_USE_OSC8=false\n[[ -t 1 ]] && _USE_OSC8=true\n\n# Usage: link \"URL\" \"display text\"\nlink() {\n local url=\"$1\" text=\"$2\"\n if [[ \"$_USE_OSC8\" == true ]]; then\n printf '\\e]8;;%s\\e\\\\%s\\e]8;;\\e\\\\' \"$url\" \"$text\"\n else\n printf '%s' \"$text\"\n fi\n}\n\nfile_link() {\n local filepath=\"$1\" display=\"${2:-$(basename \"$1\")}\"\n link \"file://${filepath}\" \"$display\"\n}\n\ngh_issue_link() {\n local number=\"$1\" title=\"$2\"\n if [[ -n \"$GH_REPO\" ]]; then\n link \"https://github.com/${GH_REPO}/issues/${number}\" \"#${number} ${title}\"\n else\n echo \"#${number} ${title}\"\n fi\n}\n\nartifact_link() {\n local id=\"$1\" file=\"$2\" display=\"$1\"\n if [[ -n \"$file\" ]]; then\n file_link \"${REPO_ROOT}/${file}\" \"$display\"\n else\n echo \"$display\"\n fi\n}\n\n# --- Data collectors ---\n\ncollect_git() {\n local branch dirty staged_count modified_count untracked_count changed_count last_hash last_msg last_age recent_json\n\n branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"detached\")\n\n # Use git status --porcelain for accurate per-category counts\n local porcelain\n porcelain=$(git status --porcelain 2>/dev/null) || porcelain=\"\"\n\n staged_count=0\n modified_count=0\n untracked_count=0\n\n if [[ -n \"$porcelain\" ]]; then\n dirty=\"true\"\n while IFS= read -r line; do\n local x=\"${line:0:1}\" y=\"${line:1:1}\"\n if [[ \"$x\" == \"?\" ]]; then\n (( untracked_count++ ))\n else\n [[ \"$x\" != \" \" ]] && (( staged_count++ ))\n [[ \"$y\" != \" \" ]] && (( modified_count++ ))\n fi\n done \u003c\u003c\u003c \"$porcelain\"\n changed_count=$(( staged_count + modified_count + untracked_count ))\n else\n dirty=\"false\"\n changed_count=0\n fi\n\n last_hash=$(git log -1 --pretty=format:'%h' 2>/dev/null || echo \"\")\n last_msg=$(git log -1 --pretty=format:'%s' 2>/dev/null || echo \"\")\n last_age=$(git log -1 --pretty=format:'%cr' 2>/dev/null || echo \"\")\n\n # Recent commits (last 5)\n recent_json=$(git log -5 --pretty=format:'{\"hash\":\"%h\",\"message\":\"%s\",\"age\":\"%cr\"}' 2>/dev/null | jq -s '.' 2>/dev/null || echo \"[]\")\n\n jq -n \\\n --arg branch \"$branch\" \\\n --argjson dirty \"$dirty\" \\\n --argjson changed \"$changed_count\" \\\n --argjson staged \"$staged_count\" \\\n --argjson modified \"$modified_count\" \\\n --argjson untracked \"$untracked_count\" \\\n --arg lastHash \"$last_hash\" \\\n --arg lastMsg \"$last_msg\" \\\n --arg lastAge \"$last_age\" \\\n --argjson recent \"$recent_json\" \\\n '{\n branch: $branch,\n dirty: $dirty,\n changedFiles: $changed,\n staged: $staged,\n modified: $modified,\n untracked: $untracked,\n lastCommit: { hash: $lastHash, message: $lastMsg, age: $lastAge },\n recentCommits: $recent\n }'\n}\n\ncollect_artifacts() {\n # Ensure specgraph cache is fresh\n if [[ -x \"$SPECGRAPH\" ]] || [[ -f \"$SPECGRAPH\" ]]; then\n python3 \"$SPECGRAPH\" build >/dev/null 2>&1 || true\n fi\n\n # Read specgraph cache\n local REPO_HASH\n REPO_HASH=$(printf '%s' \"$REPO_ROOT\" | shasum -a 256 | cut -c1-12)\n local SG_CACHE=\"/tmp/agents-specgraph-${REPO_HASH}.json\"\n\n if [[ ! -f \"$SG_CACHE\" ]]; then\n echo '{\"ready\":[],\"blocked\":[],\"epics\":{},\"counts\":{\"total\":0,\"resolved\":0,\"ready\":0,\"blocked\":0},\"xref\":[],\"xref_gap_count\":0}'\n return\n fi\n\n # Extract xref data from specgraph cache (empty array if key absent)\n local SG_XREF\n SG_XREF=$(jq -c '.xref // []' \"$SG_CACHE\" 2>/dev/null || echo '[]')\n\n # Count artifacts with at least one discrepancy\n local XREF_GAP_COUNT\n XREF_GAP_COUNT=$(echo \"$SG_XREF\" | jq 'length' 2>/dev/null || echo 0)\n\n jq \\\n --argjson xref \"$SG_XREF\" \\\n --argjson xref_gap_count \"$XREF_GAP_COUNT\" \\\n '\n def is_status_resolved: test(\"Complete|Retired|Superseded|Abandoned|Implemented|Adopted|Validated|Archived|Sunset|Deprecated|Verified|Declined\");\n def is_resolved: (.status | is_status_resolved) or ((.type | test(\"VISION|JOURNEY|PERSONA|ADR|RUNBOOK|DESIGN\")) and .status == \"Active\");\n # A dependency is satisfied once its target moves past initial planning phases.\n # Only Proposed (and legacy Draft/Planned/Review) are \"not yet satisfied.\"\n def is_dep_satisfied: test(\"Proposed|Draft|Planned|Review\") | not;\n\n .nodes as $nodes |\n .edges as $edges |\n\n # All unresolved\n [$nodes | to_entries[] | select(.value | is_resolved | not)] as $unresolved |\n\n # Ready: unresolved with all deps satisfied, enriched with unblock info\n # VISION-to-VISION deps are informational, not blocking (#28)\n ([$unresolved[] |\n .key as $id |\n .value.type as $self_type |\n ([$edges[] | select(.from == $id and .type == \"depends-on\") | .to] | unique) as $deps |\n select(\n ($deps | length == 0) or\n ($deps | all(. as $dep |\n $nodes[$dep] == null or\n ($nodes[$dep].status | is_dep_satisfied) or\n ($self_type == \"VISION\" and $nodes[$dep].type == \"VISION\")\n ))\n ) |\n # What unresolved items depend on this one?\n ([$edges[] | select(.to == $id and .type == \"depends-on\") | .from] |\n map(select(. as $dep | $nodes[$dep] != null and ($nodes[$dep] | is_resolved | not))) |\n unique) as $unblocks |\n {id: .key, status: .value.status, title: .value.title, type: .value.type, file: .value.file, description: .value.description, unblocks: $unblocks, unblock_count: ($unblocks | length)}\n ] | sort_by(-(.unblocks | length), .id)) as $ready |\n\n # Blocked: deps not yet satisfied (still in Proposed or legacy Draft/Planned/Review)\n # VISION-to-VISION deps are informational, not blocking (#28)\n ([$unresolved[] |\n .key as $id |\n .value.type as $self_type |\n ([$edges[] | select(.from == $id and .type == \"depends-on\") | .to] | unique) as $deps |\n ($deps | map(select(. as $dep |\n $nodes[$dep] != null and\n ($nodes[$dep].status | is_dep_satisfied | not) and\n # Skip VISION-to-VISION blocking\n (($self_type == \"VISION\" and $nodes[$dep].type == \"VISION\") | not)\n ))) as $waiting |\n select(($waiting | length) > 0) |\n {id: .key, status: .value.status, title: .value.title, type: .value.type, file: .value.file, description: .value.description, waiting: $waiting}\n ] | sort_by(.id)) as $blocked |\n\n # Epic progress: for each active epic, count child spec status\n ([$nodes | to_entries[] |\n select(.value.type == \"EPIC\" and (.value | is_resolved | not)) |\n .key as $epic_id |\n # Find children (specs/stories parented to this epic)\n ([$edges[] | select(.to == $epic_id and .type == \"parent-epic\") | .from]) as $child_ids |\n ($child_ids | map(. as $cid | $nodes[$cid]) | map(select(. != null))) as $children |\n ($children | map(select(is_resolved)) | length) as $done |\n ($children | length) as $total |\n {\n id: $epic_id,\n title: .value.title,\n status: .value.status,\n file: .value.file,\n description: .value.description,\n progress: { done: $done, total: $total },\n children: [$child_ids[] | . as $cid | $nodes[$cid] | select(. != null) |\n {id: $cid, title: .title, status: .status, type: .type, file: .file, description: .description}\n ]\n }\n ] | sort_by(.id)) as $epics |\n\n # Counts\n ([$nodes | to_entries[]] | length) as $total |\n ([$nodes | to_entries[] | select(.value | is_resolved)] | length) as $resolved |\n\n {\n ready: $ready,\n blocked: $blocked,\n epics: ($epics | map({(.id): .}) | add // {}),\n counts: {\n total: $total,\n resolved: $resolved,\n ready: ($ready | length),\n blocked: ($blocked | length)\n },\n xref: $xref,\n xref_gap_count: $xref_gap_count\n }\n ' \"$SG_CACHE\"\n}\n\ncollect_tasks() {\n # Locate .tickets directory and ticket-query\n local tickets_dir=\"\"\n if [[ -d \"$REPO_ROOT/.tickets\" ]]; then\n tickets_dir=\"$REPO_ROOT/.tickets\"\n fi\n\n local tq_bin=\"\"\n local skill_bin=\"$(dirname \"$(dirname \"$SCRIPT_DIR\")\")/swain-do/bin/ticket-query\"\n if [[ -x \"$skill_bin\" ]]; then\n tq_bin=\"$skill_bin\"\n elif command -v ticket-query &>/dev/null; then\n tq_bin=\"ticket-query\"\n fi\n\n if [[ -z \"$tickets_dir\" ]] || [[ -z \"$tq_bin\" ]]; then\n echo '{\"inProgress\":[],\"recentlyCompleted\":[],\"total\":0,\"available\":false}'\n return\n fi\n\n # Extract the first H1 heading from a ticket file as the title.\n # tk stores titles as markdown H1 headings in the body, not as a frontmatter key.\n get_ticket_title() {\n local id=\"$1\"\n local file=\"$tickets_dir/$id.md\"\n if [[ -f \"$file\" ]]; then\n grep -m1 '^# ' \"$file\" | sed 's/^# //'\n else\n echo \"$id\"\n fi\n }\n\n # Build a JSON array from tq JSONL output, enriching each record with the H1 title.\n build_task_json() {\n local jsonl=\"$1\"\n local result=\"[]\"\n if [[ -n \"$jsonl\" ]]; then\n while IFS= read -r line; do\n local id title\n id=$(echo \"$line\" | jq -r '.id')\n title=$(get_ticket_title \"$id\")\n result=$(echo \"$result\" | jq \\\n --arg id \"$id\" \\\n --arg title \"$title\" \\\n '. + [{id: $id, title: $title}]')\n done \u003c\u003c\u003c \"$jsonl\"\n fi\n echo \"$result\"\n }\n\n local in_progress recent total\n\n # In-progress tasks\n local ip_raw\n ip_raw=$(TICKETS_DIR=\"$tickets_dir\" \"$tq_bin\" '.status == \"in_progress\"' 2>/dev/null) || true\n in_progress=$(build_task_json \"$ip_raw\")\n\n # Recently completed (last 5)\n local closed_raw\n closed_raw=$(TICKETS_DIR=\"$tickets_dir\" \"$tq_bin\" '.status == \"closed\"' 2>/dev/null | head -5) || true\n recent=$(build_task_json \"$closed_raw\")\n\n # Total count\n total=$(TICKETS_DIR=\"$tickets_dir\" \"$tq_bin\" 2>/dev/null | wc -l | tr -d ' ') || true\n total=\"${total:-0}\"\n\n jq -n \\\n --argjson inProgress \"$in_progress\" \\\n --argjson recent \"$recent\" \\\n --argjson total \"${total}\" \\\n '{inProgress: $inProgress, recentlyCompleted: $recent, total: $total, available: true}'\n}\n\ncollect_issues() {\n if [[ -z \"$GH_REPO\" ]] || ! command -v gh &>/dev/null; then\n echo '{\"open\":[],\"assigned\":[],\"available\":false}'\n return\n fi\n\n local open assigned\n\n # Open issues (limit 10, most recent)\n open=$(gh issue list --repo \"$GH_REPO\" --state open --limit 10 --json number,title,labels,assignees,updatedAt 2>/dev/null || echo \"[]\")\n\n # Assigned to current user\n local gh_user\n gh_user=$(gh api user --jq '.login' 2>/dev/null || echo \"\")\n if [[ -n \"$gh_user\" ]]; then\n assigned=$(gh issue list --repo \"$GH_REPO\" --state open --assignee \"$gh_user\" --limit 10 --json number,title,labels,updatedAt 2>/dev/null || echo \"[]\")\n else\n assigned=\"[]\"\n fi\n\n jq -n \\\n --argjson open \"$open\" \\\n --argjson assigned \"$assigned\" \\\n '{open: $open, assigned: $assigned, available: true}'\n}\n\ncollect_linked_issues() {\n local ISSUE_SCRIPT=\"$SCRIPT_DIR/../../swain-design/scripts/issue-integration.sh\"\n\n if [[ ! -f \"$ISSUE_SCRIPT\" ]]; then\n echo '[]'\n return\n fi\n\n local linked\n linked=$(bash \"$ISSUE_SCRIPT\" scan 2>/dev/null) || linked=\"[]\"\n\n # Enrich with live GitHub issue data if gh is available\n if command -v gh &>/dev/null && [[ \"$linked\" != \"[]\" ]]; then\n echo \"$linked\" | jq -c '.[]' | while IFS= read -r entry; do\n local si\n si=$(echo \"$entry\" | jq -r '.source_issue')\n\n # Parse github:\u003cowner>/\u003crepo>#\u003cnumber>\n if [[ \"$si\" =~ ^github:([^/]+)/([^#]+)#([0-9]+)$ ]]; then\n local owner=\"${BASH_REMATCH[1]}\" repo=\"${BASH_REMATCH[2]}\" number=\"${BASH_REMATCH[3]}\"\n local issue_state issue_title\n issue_state=$(gh issue view \"$number\" --repo \"${owner}/${repo}\" --json state --jq '.state' 2>/dev/null || echo \"UNKNOWN\")\n issue_title=$(gh issue view \"$number\" --repo \"${owner}/${repo}\" --json title --jq '.title' 2>/dev/null || echo \"\")\n echo \"$entry\" | jq \\\n --arg issue_state \"$issue_state\" \\\n --arg issue_title \"$issue_title\" \\\n --argjson issue_number \"$number\" \\\n '. + {issue_state: $issue_state, issue_title: $issue_title, issue_number: $issue_number}'\n else\n echo \"$entry\"\n fi\n done | jq -s '.'\n else\n echo \"$linked\"\n fi\n}\n\ncollect_session() {\n if [[ -f \"$SESSION_FILE\" ]]; then\n jq '{\n bookmark: (.bookmark // null),\n lastBranch: (.lastBranch // null),\n lastContext: (.lastContext // null),\n focus_lane: (.focus_lane // null),\n status_mode: (.status_mode // null)\n }' \"$SESSION_FILE\" 2>/dev/null || echo '{\"bookmark\":null,\"lastBranch\":null,\"lastContext\":null,\"focus_lane\":null,\"status_mode\":null}'\n else\n echo '{\"bookmark\":null,\"lastBranch\":null,\"lastContext\":null,\"focus_lane\":null,\"status_mode\":null}'\n fi\n}\n\n# --- Build cache ---\n\nbuild_cache() {\n local git_data artifact_data task_data issue_data session_data\n\n # Collect in parallel where possible\n git_data=$(collect_git)\n artifact_data=$(collect_artifacts)\n task_data=$(collect_tasks)\n issue_data=$(collect_issues)\n linked_issue_data=$(collect_linked_issues)\n session_data=$(collect_session)\n\n # Priority data from specgraph\n local recommend_data debt_data attention_data\n local focus_lane=\"\"\n if [[ -f \"$SESSION_FILE\" ]]; then\n focus_lane=$(jq -r '.focus_lane // empty' \"$SESSION_FILE\" 2>/dev/null || echo \"\")\n fi\n if [[ -n \"$focus_lane\" ]]; then\n recommend_data=$(python3 \"$SPECGRAPH\" recommend --focus \"$focus_lane\" --json 2>/dev/null || echo '[]')\n else\n recommend_data=$(python3 \"$SPECGRAPH\" recommend --json 2>/dev/null || echo '[]')\n fi\n debt_data=$(python3 \"$SPECGRAPH\" decision-debt 2>/dev/null || echo '{}')\n attention_data=$(python3 \"$SPECGRAPH\" attention --json 2>/dev/null || echo '{\"attention\":{},\"drift\":[]}')\n\n local timestamp\n timestamp=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\n\n jq -n \\\n --arg ts \"$timestamp\" \\\n --arg repo \"$REPO_ROOT\" \\\n --arg project \"$PROJECT_NAME\" \\\n --argjson git \"$git_data\" \\\n --argjson artifacts \"$artifact_data\" \\\n --argjson tasks \"$task_data\" \\\n --argjson issues \"$issue_data\" \\\n --argjson session \"$session_data\" \\\n --argjson linked \"$linked_issue_data\" \\\n --argjson recommend \"$recommend_data\" \\\n --argjson debt \"$debt_data\" \\\n --argjson attention \"$attention_data\" \\\n '{\n timestamp: $ts,\n repo: $repo,\n project: $project,\n git: $git,\n artifacts: $artifacts,\n tasks: $tasks,\n issues: $issues,\n linkedIssues: $linked,\n session: $session,\n priority: {\n recommendations: $recommend,\n decision_debt: $debt,\n attention: $attention.attention,\n drift: $attention.drift\n }\n }' > \"${CACHE_FILE}.tmp\" && mv \"${CACHE_FILE}.tmp\" \"$CACHE_FILE\"\n}\n\ncache_is_fresh() {\n [[ -f \"$CACHE_FILE\" ]] || return 1\n local cache_age\n if [[ \"$(uname)\" == \"Darwin\" ]]; then\n cache_age=$(( $(date +%s) - $(stat -f %m \"$CACHE_FILE\") ))\n else\n cache_age=$(( $(date +%s) - $(stat -c %Y \"$CACHE_FILE\") ))\n fi\n [[ \"$cache_age\" -lt \"$CACHE_TTL\" ]]\n}\n\nensure_cache() {\n if ! cache_is_fresh; then\n build_cache\n fi\n}\n\n# --- Output formatters ---\n\n# Full rich output for in-conversation display\nrender_full() {\n local data\n data=$(cat \"$CACHE_FILE\")\n\n local project branch dirty changed_count\n project=$(echo \"$data\" | jq -r '.project')\n branch=$(echo \"$data\" | jq -r '.git.branch')\n dirty=$(echo \"$data\" | jq -r '.git.dirty')\n changed_count=$(echo \"$data\" | jq -r '.git.changedFiles')\n\n echo \"\"\n echo \"# ${project} — Status\"\n echo \"\"\n\n # --- Session bookmark ---\n local bookmark_note\n bookmark_note=$(echo \"$data\" | jq -r '.session.bookmark.note // empty')\n if [[ -n \"$bookmark_note\" ]]; then\n echo \"**Resuming:** ${bookmark_note}\"\n local bookmark_files\n bookmark_files=$(echo \"$data\" | jq -r '.session.bookmark.files // [] | .[]' 2>/dev/null)\n if [[ -n \"$bookmark_files\" ]]; then\n echo -n \" Files: \"\n local first=1\n while IFS= read -r f; do\n [[ $first -eq 1 ]] && first=0 || echo -n \", \"\n file_link \"${REPO_ROOT}/${f}\" \"$f\"\n done \u003c\u003c\u003c \"$bookmark_files\"\n echo \"\"\n fi\n echo \"\"\n fi\n\n # --- Pipeline ---\n echo \"## Pipeline\"\n echo \"\"\n echo -n \"Branch: **${branch}**\"\n if [[ \"$dirty\" == \"true\" ]]; then\n echo \" (${changed_count} uncommitted changes)\"\n else\n echo \" (clean)\"\n fi\n\n local last_msg last_age last_hash\n last_msg=$(echo \"$data\" | jq -r '.git.lastCommit.message')\n last_age=$(echo \"$data\" | jq -r '.git.lastCommit.age')\n last_hash=$(echo \"$data\" | jq -r '.git.lastCommit.hash')\n echo \"Last commit: \\`${last_hash}\\` ${last_msg} (${last_age})\"\n echo \"\"\n\n # --- Active Epics with progress ---\n local epic_count\n epic_count=$(echo \"$data\" | jq '.artifacts.epics | length')\n\n if [[ \"$epic_count\" -gt 0 ]]; then\n echo \"## Active Epics\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n def readiness($e):\n if $e.progress.total == 0 then \"needs decomposition\"\n elif $e.progress.done == $e.progress.total then \"all \\($e.progress.total) specs resolved\"\n else \"\\($e.progress.done)/\\($e.progress.total) specs resolved (\\($e.progress.total - $e.progress.done) remaining)\"\n end;\n \"| Epic | Status | Purpose | Readiness |\",\n \"|------|--------|---------|-----------|\",\n (.artifacts.epics | to_entries[] |\n .value as $e |\n (($e.description // \"\") | if length > 70 then .[0:70] + \"…\" else . end) as $purpose |\n \"| \\(art_link($e.id; $e.file)): \\($e.title) | \\($e.status) | \\($purpose) | \\(readiness($e)) |\"\n )\n '\n echo \"\"\n fi\n\n # --- Decision backlog / Implementation backlog split ---\n #\n # Classify each ready item as a \"decision\" (needs human judgment) or\n # \"implementation\" (agent can handle). Show decisions first — they are\n # the developer's bottleneck.\n local ready_count\n ready_count=$(echo \"$data\" | jq '.artifacts.ready | length')\n\n if [[ \"$ready_count\" -gt 0 ]]; then\n # Count decisions vs implementation items\n # Classification from SPIKE-012: VISION, JOURNEY, PERSONA, ADR, DESIGN need human decisions\n # at every phase. Other types use per-phase bucket mapping.\n local decision_count\n decision_count=$(echo \"$data\" | jq '\n def is_decision_only_type: .type | test(\"VISION|JOURNEY|PERSONA|ADR|DESIGN\");\n def is_decision:\n is_decision_only_type or\n (.type == \"EPIC\" and (.status | test(\"Proposed|Planned\"))) or\n (.type == \"SPEC\" and (.status | test(\"Proposed|Draft|Review\"))) or\n (.type == \"SPIKE\" and (.status | test(\"Proposed|Planned\")));\n [.artifacts.ready[] | select(is_decision)] | length\n ')\n\n # --- Decisions waiting on you ---\n if [[ \"$decision_count\" -gt 0 ]]; then\n echo \"## Decisions Waiting on You (${decision_count})\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" and $osc8 == \"true\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n def next_step:\n if .type == \"VISION\" and (.status | test(\"Proposed|Draft\")) then \"align on goals and audience\"\n elif .type == \"VISION\" then \"decompose into epics\"\n elif .type == \"JOURNEY\" then \"map pain points and opportunities\"\n elif .type == \"PERSONA\" then \"validate with user research\"\n elif .type == \"ADR\" and (.status | test(\"Proposed|Draft\")) then \"form recommendation\"\n elif .type == \"ADR\" then \"review and decide\"\n elif .type == \"EPIC\" and (.status | test(\"Proposed|Planned\")) then \"activate and decompose into specs\"\n elif .type == \"EPIC\" and .status == \"Active\" then \"work on child specs\"\n elif .type == \"SPEC\" and (.status | test(\"Proposed|Draft\")) then \"review and approve\"\n elif .type == \"SPEC\" and (.status | test(\"Ready|Approved\")) then \"create implementation plan\"\n elif .type == \"SPEC\" and (.status | test(\"In Progress\")) then \"implement and test\"\n elif .type == \"SPEC\" and (.status | test(\"Needs Manual Test|Testing\")) then \"complete verification\"\n elif .type == \"SPIKE\" and (.status | test(\"Proposed|Planned\")) then \"begin investigation\"\n elif .type == \"SPIKE\" then \"complete investigation\"\n elif .type == \"RUNBOOK\" and (.status | test(\"Proposed|Draft\")) then \"author and test procedure\"\n elif .type == \"RUNBOOK\" then \"execute and record results\"\n elif .type == \"DESIGN\" then \"create wireframes and flows\"\n else \"progress to next phase\" end;\n def is_decision_only_type: .type | test(\"VISION|JOURNEY|PERSONA|ADR|DESIGN\");\n def is_decision:\n is_decision_only_type or\n (.type == \"EPIC\" and (.status | test(\"Proposed|Planned\"))) or\n (.type == \"SPEC\" and (.status | test(\"Proposed|Draft|Review\"))) or\n (.type == \"SPIKE\" and (.status | test(\"Proposed|Planned\")));\n [.artifacts.ready[] | select(is_decision)] | sort_by(-(.unblocks | length), .id)[] |\n \"- \\(art_link(.id; .file)): \\(.title) [\\(.status)] — \\(next_step)\" +\n (if (.unblocks | length) > 0 then \" (unblocks \\(.unblocks | length))\" else \"\" end),\n (if .description and (.description | length > 0) then\n \" _\\(.description)_\"\n else empty end)\n '\n\n # --- Vision context for decisions ---\n echo \"$data\" | jq -r '\n if .priority.decision_debt then\n [.priority.decision_debt | to_entries[] | select(.key != \"_unaligned\")] |\n if length > 0 then\n \"**By vision:** \" + (\n [.[] | \"\\(.key) (\\(.value.count) decisions, \\(.value.total_unblocks) unblocks)\"] | join(\", \")\n )\n else empty end\n else empty end\n '\n echo \"\"\n fi\n\n # --- Attention Drift ---\n local drift_count\n drift_count=$(echo \"$data\" | jq '[.priority.drift // [] | .[] ] | length' 2>/dev/null || echo 0)\n if [[ \"$drift_count\" -gt 0 ]]; then\n echo \"## Attention Drift\"\n echo \"\"\n echo \"$data\" | jq -r '\n [.priority.drift[] |\n \"- \\(.vision_id) [weight: \\(.weight)] — \\(.days_since_activity) days since last activity (threshold: \\(.threshold))\"\n ] | .[]\n '\n echo \"\"\n fi\n\n # --- Peripheral Awareness ---\n local focus_lane\n focus_lane=$(echo \"$data\" | jq -r '.session.focus_lane // empty' 2>/dev/null || echo \"\")\n if [[ -n \"$focus_lane\" ]]; then\n echo \"## Meanwhile\"\n echo \"\"\n echo \"$data\" | jq -r --arg focus \"$focus_lane\" '\n [.priority.decision_debt // {} | to_entries[] |\n select(.key != \"_unaligned\" and .key != $focus) |\n \"\\(.key) has \\(.value.count) pending decisions\"\n ] | if length > 0 then \"- \" + join(\"\\n- \") else empty end\n '\n echo \"\"\n fi\n\n # --- Implementation (agent can handle) ---\n local impl_count\n impl_count=$(( ready_count - decision_count ))\n\n if [[ \"$impl_count\" -gt 0 ]]; then\n echo \"## Implementation (${impl_count} — agent can handle)\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" and $osc8 == \"true\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n def next_step:\n if .type == \"VISION\" and (.status | test(\"Proposed|Draft\")) then \"align on goals and audience\"\n elif .type == \"VISION\" then \"decompose into epics\"\n elif .type == \"JOURNEY\" then \"map pain points and opportunities\"\n elif .type == \"PERSONA\" then \"validate with user research\"\n elif .type == \"ADR\" and (.status | test(\"Proposed|Draft\")) then \"form recommendation\"\n elif .type == \"ADR\" then \"review and decide\"\n elif .type == \"EPIC\" and (.status | test(\"Proposed|Planned\")) then \"activate and decompose into specs\"\n elif .type == \"EPIC\" and .status == \"Active\" then \"work on child specs\"\n elif .type == \"SPEC\" and (.status | test(\"Proposed|Draft\")) then \"review and approve\"\n elif .type == \"SPEC\" and (.status | test(\"Ready|Approved\")) then \"create implementation plan\"\n elif .type == \"SPEC\" and (.status | test(\"In Progress\")) then \"implement and test\"\n elif .type == \"SPEC\" and (.status | test(\"Needs Manual Test|Testing\")) then \"complete verification\"\n elif .type == \"SPIKE\" and (.status | test(\"Proposed|Planned\")) then \"begin investigation\"\n elif .type == \"SPIKE\" then \"complete investigation\"\n elif .type == \"RUNBOOK\" and (.status | test(\"Proposed|Draft\")) then \"author and test procedure\"\n elif .type == \"RUNBOOK\" then \"execute and record results\"\n elif .type == \"DESIGN\" then \"create wireframes and flows\"\n else \"progress to next phase\" end;\n def is_decision_only_type: .type | test(\"VISION|JOURNEY|PERSONA|ADR|DESIGN\");\n def is_decision:\n is_decision_only_type or\n (.type == \"EPIC\" and (.status | test(\"Proposed|Planned\"))) or\n (.type == \"SPEC\" and (.status | test(\"Proposed|Draft|Review\"))) or\n (.type == \"SPIKE\" and (.status | test(\"Proposed|Planned\")));\n [.artifacts.ready[] | select(is_decision | not)] | sort_by(-(.unblocks | length), .id)[] |\n \"- \\(art_link(.id; .file)): \\(.title) [\\(.status)] — \\(next_step)\" +\n (if (.unblocks | length) > 0 then \" (unblocks \\(.unblocks | length))\" else \"\" end),\n (if .description and (.description | length > 0) then\n \" _\\(.description)_\"\n else empty end)\n '\n echo \"\"\n fi\n fi\n\n # --- Blocked ---\n local blocked_count\n blocked_count=$(echo \"$data\" | jq '.artifacts.blocked | length')\n\n if [[ \"$blocked_count\" -gt 0 ]]; then\n echo \"## Blocked\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n # Build a lookup of ready item IDs for unblock hints\n ([.artifacts.ready[].id] | unique) as $ready_ids |\n .artifacts.blocked[] |\n \"- \\(art_link(.id; .file)): \\(.title) [\\(.status)]\" +\n \" \u003c- waiting on: \\(.waiting | map(\n . as $w |\n if ($ready_ids | index($w)) then \"\\($w) (actionable now)\"\n else $w end\n ) | join(\", \"))\",\n (if .description and (.description | length > 0) then\n \" _\\(.description)_\"\n else empty end)'\n echo \"\"\n fi\n\n # --- Tasks (tk) ---\n local tasks_available\n tasks_available=$(echo \"$data\" | jq -r '.tasks.available')\n\n if [[ \"$tasks_available\" == \"true\" ]]; then\n local ip_count\n ip_count=$(echo \"$data\" | jq '.tasks.inProgress | length')\n\n echo \"## Tasks\"\n echo \"\"\n if [[ \"$ip_count\" -gt 0 ]]; then\n echo \"**In progress:**\"\n echo \"$data\" | jq -r '.tasks.inProgress[] | \"- \\(.id) \\(.title)\"'\n else\n echo \"No tasks in progress.\"\n fi\n\n local recent_count\n recent_count=$(echo \"$data\" | jq '.tasks.recentlyCompleted | length')\n if [[ \"$recent_count\" -gt 0 ]]; then\n echo \"\"\n echo \"**Recently completed:**\"\n echo \"$data\" | jq -r '.tasks.recentlyCompleted[] | \"- \\(.id) \\(.title)\"'\n fi\n\n local total_tasks\n total_tasks=$(echo \"$data\" | jq -r '.tasks.total')\n echo \"\"\n echo \"${total_tasks} total tracked issues.\"\n echo \"\"\n fi\n\n # --- GitHub Issues ---\n local issues_available\n issues_available=$(echo \"$data\" | jq -r '.issues.available')\n\n if [[ \"$issues_available\" == \"true\" ]]; then\n local assigned_count open_count\n assigned_count=$(echo \"$data\" | jq '.issues.assigned | length')\n open_count=$(echo \"$data\" | jq '.issues.open | length')\n\n if [[ \"$assigned_count\" -gt 0 || \"$open_count\" -gt 0 ]]; then\n echo \"## GitHub Issues\"\n echo \"\"\n fi\n\n if [[ \"$assigned_count\" -gt 0 ]]; then\n echo \"**Assigned to you:**\"\n while IFS= read -r line; do\n local num title\n num=$(echo \"$line\" | jq -r '.number')\n title=$(echo \"$line\" | jq -r '.title')\n echo -n \"- \"\n gh_issue_link \"$num\" \"$title\"\n echo \"\"\n done \u003c \u003c(echo \"$data\" | jq -c '.issues.assigned[]')\n echo \"\"\n fi\n\n if [[ \"$open_count\" -gt 0 && \"$assigned_count\" -eq 0 ]]; then\n echo \"**Open issues:**\"\n while IFS= read -r line; do\n local num title\n num=$(echo \"$line\" | jq -r '.number')\n title=$(echo \"$line\" | jq -r '.title')\n echo -n \"- \"\n gh_issue_link \"$num\" \"$title\"\n echo \"\"\n done \u003c \u003c(echo \"$data\" | jq -c '.issues.open[] | select(.number)' | head -5)\n echo \"\"\n fi\n fi\n\n # --- Linked Issues (source-issue artifacts) ---\n local linked_count\n linked_count=$(echo \"$data\" | jq '.linkedIssues | length')\n\n if [[ \"$linked_count\" -gt 0 ]]; then\n echo \"## Linked Issues\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n .linkedIssues[] |\n \"- \\(art_link(.artifact; .file)): \\(.title) [\\(.status)]\" +\n (if .issue_number then\n \" — linked to #\\(.issue_number)\" +\n (if .issue_state then \" (\\(.issue_state | ascii_downcase))\" else \"\" end)\n else\n \" — \\(.source_issue)\"\n end)\n '\n echo \"\"\n fi\n\n # --- Cross-Reference Gaps ---\n local xref_count\n xref_count=$(echo \"$data\" | jq -r '.artifacts.xref | length // 0')\n\n if [[ \"$xref_count\" -gt 0 ]]; then\n echo \"## Cross-Reference Gaps\"\n echo \"\"\n echo \"$data\" | jq -r --arg repo \"$REPO_ROOT\" --arg osc8 \"$_USE_OSC8\" '\n def art_link($aid; $file):\n if $file != null and $file != \"\" and $osc8 == \"true\" then\n \"\\u001b]8;;file://\\($repo)/\\($file)\\u001b\\\\\\($aid)\\u001b]8;;\\u001b\\\\\"\n else $aid end;\n .artifacts.xref[] |\n . as $entry |\n \"- \\(art_link($entry.artifact; $entry.file))\" +\n (if $entry.body_not_in_frontmatter and ($entry.body_not_in_frontmatter | length) > 0 then\n \"\\n undeclared: \\($entry.body_not_in_frontmatter | join(\", \"))\"\n else \"\" end) +\n (if $entry.frontmatter_not_in_body and ($entry.frontmatter_not_in_body | length) > 0 then\n \"\\n undeclared (reverse): \\($entry.frontmatter_not_in_body | join(\", \"))\"\n else \"\" end) +\n (if $entry.missing_reciprocal and ($entry.missing_reciprocal | length) > 0 then\n \"\\n missing reciprocal: \\($entry.missing_reciprocal | map(.from) | join(\", \"))\"\n else \"\" end)\n '\n echo \"\"\n fi\n\n # --- Decisions Needed (SPEC-111 roadmap integration) ---\n # Call chart.sh roadmap --json, filter to Do First + Schedule quadrants,\n # surface operator-decision items (Proposed, no children, or fully complete).\n # Degrade silently if chart.sh is unavailable or returns no data.\n local _chart_sh_path\n _chart_sh_path=\"$(find \"${REPO_ROOT}\" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)\"\n if [[ -n \"$_chart_sh_path\" ]]; then\n local _roadmap_json _focus_lane_dn\n _roadmap_json=$(bash \"$_chart_sh_path\" roadmap --json 2>/dev/null) || _roadmap_json=\"\"\n _focus_lane_dn=$(echo \"$data\" | jq -r '.session.focus_lane // empty' 2>/dev/null || echo \"\")\n\n if [[ -n \"$_roadmap_json\" ]] && [[ \"$_roadmap_json\" != \"[]\" ]]; then\n local _decisions\n # Use a temp file for the jq filter to avoid bash double-quote conflicts\n # with nested string literals inside the $() substitution.\n local _jq_filter_file\n _jq_filter_file=$(mktemp /tmp/swain-status-dn-XXXXXX.jq)\n cat > \"$_jq_filter_file\" \u003c\u003c 'JQEOF'\n# Apply focus lane filter if set\n(if $focus != \"\" then map(select(.vision_id == $focus)) else . end) |\n# Eisenhower: Do First (quadrant==\"do\") and Schedule (quadrant==\"schedule\")\nmap(select(.quadrant == \"do\" or .quadrant == \"schedule\")) |\n# Operator decision filter: items that need a decision\nmap(select(.operator_decision != \"\")) |\n# Top 5 by weight desc, then score desc\nsort_by(-(.weight), -(.score)) | .[0:5] |\n.[] |\n# Format as actionable prompt with priority label\ndef prio: if .weight >= 3 then \"high\" elif .weight >= 2 then \"medium\" else \"low\" end;\nif .operator_decision == \"needs decomposition\" then\n \"\\(.id) \\(.title) — **needs decomposition** (\\(.children_total) specs, \\(prio) priority)\"\nelif .operator_decision == \"activate or drop\" then\n \"\\(.id) \\(.title) — **activate or drop?** (\\(.status), \\(prio) priority)\"\nelif .operator_decision == \"ready to complete\" then\n \"\\(.id) \\(.title) — **ready to complete** (\\(.children_complete)/\\(.children_total) specs done)\"\nelse\n \"\\(.id) \\(.title) — **\\(.operator_decision)** (\\(.status))\"\nend\nJQEOF\n _decisions=$(echo \"$_roadmap_json\" | jq -r --arg focus \"$_focus_lane_dn\" -f \"$_jq_filter_file\" 2>/dev/null) || _decisions=\"\"\n rm -f \"$_jq_filter_file\"\n unset _jq_filter_file\n\n if [[ -n \"$_decisions\" ]]; then\n echo \"## Decisions Needed\"\n echo \"\"\n while IFS= read -r _line; do\n echo \"- ${_line}\"\n done \u003c\u003c\u003c \"$_decisions\"\n echo \"\"\n fi\n fi\n unset _chart_sh_path _roadmap_json _focus_lane_dn _decisions\n fi\n\n # --- Artifact counts footer ---\n local total resolved ready blocked\n total=$(echo \"$data\" | jq -r '.artifacts.counts.total')\n resolved=$(echo \"$data\" | jq -r '.artifacts.counts.resolved')\n ready=$(echo \"$data\" | jq -r '.artifacts.counts.ready')\n blocked=$(echo \"$data\" | jq -r '.artifacts.counts.blocked')\n\n echo \"---\"\n echo \"Artifacts: ${total} total, ${resolved} resolved, ${ready} ready, ${blocked} blocked\"\n\n local ts\n ts=$(echo \"$data\" | jq -r '.timestamp')\n echo \"Updated: ${ts}\"\n}\n\n# Compact output for MOTD consumption\nrender_compact() {\n local data\n data=$(cat \"$CACHE_FILE\")\n\n local branch dirty epic_summary task_line\n\n branch=$(echo \"$data\" | jq -r '.git.branch')\n dirty=$(echo \"$data\" | jq -r 'if .git.dirty then \"\\(.git.changedFiles) changed\" else \"clean\" end')\n\n # Epic progress summary (most active epic)\n epic_summary=$(echo \"$data\" | jq -r '\n .artifacts.epics | to_entries |\n if length > 0 then\n (.[0].value) as $e |\n \"\\($e.id) \\($e.progress.done)/\\($e.progress.total)\"\n else \"no active epics\" end\n ')\n\n # Active task\n task_line=$(echo \"$data\" | jq -r '\n if .tasks.inProgress | length > 0 then\n .tasks.inProgress[0] | \"\\(.id) \\(.title)\" | .[0:40]\n else \"no active task\" end\n ')\n\n # Ready count\n local ready_count\n ready_count=$(echo \"$data\" | jq -r '.artifacts.counts.ready')\n\n # Issue count\n local issue_count\n issue_count=$(echo \"$data\" | jq -r '.issues.assigned | length // 0')\n\n # Xref gap count\n local xref_gap_count\n xref_gap_count=$(echo \"$data\" | jq -r '.artifacts.xref_gap_count // 0')\n\n echo \"${branch} (${dirty})\"\n echo \"epic: ${epic_summary}\"\n echo \"task: ${task_line}\"\n echo \"ready: ${ready_count} actionable\"\n if [[ \"$issue_count\" -gt 0 ]]; then\n echo \"issues: ${issue_count} assigned\"\n fi\n if [[ \"$xref_gap_count\" -gt 0 ]]; then\n echo \"xref: ${xref_gap_count} gaps\"\n fi\n}\n\n# --- Main ---\n\nMODE=\"full\"\nFORCE_REFRESH=0\n\nfor arg in \"$@\"; do\n case \"$arg\" in\n --compact) MODE=\"compact\" ;;\n --json) MODE=\"json\" ;;\n --refresh) FORCE_REFRESH=1 ;;\n --help|-h)\n echo \"Usage: swain-status.sh [--compact|--json] [--refresh]\"\n echo \"\"\n echo \" (default) Rich terminal output with clickable links\"\n echo \" --compact Condensed output for MOTD panel\"\n echo \" --json Raw JSON cache\"\n echo \" --refresh Force cache rebuild before output\"\n exit 0\n ;;\n esac\ndone\n\nif [[ \"$FORCE_REFRESH\" -eq 1 ]]; then\n build_cache\nelse\n ensure_cache\nfi\n\n# --- ROADMAP.md freshness check (SPEC-111) ---\n# Regenerate ROADMAP.md if missing or older than any doc artifact.\n# Skip gracefully if chart.sh is unavailable.\n_ROADMAP=\"${REPO_ROOT}/ROADMAP.md\"\n_CHART_SH=\"$(find \"${REPO_ROOT}\" -path '*/swain-design/scripts/chart.sh' -print -quit 2>/dev/null)\"\nif [[ -n \"$_CHART_SH\" ]]; then\n if [[ ! -f \"$_ROADMAP\" ]] || [[ -n \"$(find \"${REPO_ROOT}/docs\" -name '*.md' -newer \"$_ROADMAP\" -print -quit 2>/dev/null)\" ]]; then\n bash \"$_CHART_SH\" roadmap >/dev/null 2>&1 || true\n fi\nfi\nunset _ROADMAP _CHART_SH\n\ncase \"$MODE\" in\n full) render_full ;;\n compact) render_compact ;;\n json) cat \"$CACHE_FILE\" ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":39147,"content_sha256":"2827859caa72f9378aa99cc3e37486fc65e31aa1eb51b4ab0a056bbec9992025"},{"filename":"scripts/swain-tab-name.sh","content":"#!/usr/bin/env bash\nset +e # Never fail hard — session naming is a convenience, not a gate\n\n# swain-tab-name.sh — Set terminal tab/window/session title\n#\n# Usage:\n# swain-tab-name.sh --auto # project @ branch (from settings)\n# swain-tab-name.sh --path DIR --auto # resolve git context from DIR\n# swain-tab-name.sh --reset # restore defaults, remove hooks\n# swain-tab-name.sh \"Custom Title\" # set a custom title\n#\n# See SPEC-056 and DESIGN-001 for the full interaction model.\n\n# Allow socket override for testing or targeting a specific tmux server\nTMUX_ARGS=\"\"\nif [[ -n \"${SWAIN_TMUX_SOCKET:-}\" ]]; then\n TMUX_ARGS=\"-S $SWAIN_TMUX_SOCKET\"\n # Ensure TMUX-presence checks pass\n TMUX=\"${TMUX:-$SWAIN_TMUX_SOCKET,0,0}\"\nfi\n\nSETTINGS_PROJECT=\"${SWAIN_SETTINGS:-$(git rev-parse --show-toplevel 2>/dev/null)/swain.settings.json}\"\nSETTINGS_USER=\"${XDG_CONFIG_HOME:-$HOME/.config}/swain/settings.json\"\n\n# Read a setting with fallback: user settings override project settings\nread_setting() {\n local key=\"$1\"\n local default=\"$2\"\n local val=\"\"\n\n if [[ -f \"$SETTINGS_USER\" ]]; then\n val=$(jq -r \"$key // empty\" \"$SETTINGS_USER\" 2>/dev/null)\n fi\n if [[ -z \"$val\" && -f \"$SETTINGS_PROJECT\" ]]; then\n val=$(jq -r \"$key // empty\" \"$SETTINGS_PROJECT\" 2>/dev/null)\n fi\n echo \"${val:-$default}\"\n}\n\nset_title() {\n local title=\"$1\"\n local session_name=\"${2:-}\"\n\n if [[ -n \"$TMUX\" ]]; then\n # Resolve the calling pane's session, not the \"current client's\" session.\n # Priority:\n # 1. $TMUX_PANE — tmux sets this for every process spawned in a pane;\n # authoritative for \"which pane is invoking me\" regardless of focus.\n # 2. SWAIN_HOOK_SESSION — expanded by tmux at hook fire time (hook path).\n # 3. display-message fallback — only safe for direct interactive use with\n # a single attached client; resolves to the most-recently-focused\n # client otherwise, which can target the wrong session (gh#116).\n local target_session=\"\"\n if [[ -n \"${TMUX_PANE:-}\" ]]; then\n target_session=$(tmux $TMUX_ARGS display-message -p -t \"$TMUX_PANE\" '#{session_name}' 2>/dev/null)\n fi\n if [[ -z \"$target_session\" ]]; then\n target_session=\"${SWAIN_HOOK_SESSION:-}\"\n fi\n if [[ -z \"$target_session\" ]]; then\n target_session=$(tmux $TMUX_ARGS display-message -p '#{session_name}' 2>/dev/null)\n fi\n\n # Rename the tmux window tab — target the hook's session, not the \"current\" one\n tmux $TMUX_ARGS set-window-option ${target_session:+-t \"$target_session\"} automatic-rename off 2>/dev/null || true\n tmux $TMUX_ARGS rename-window ${target_session:+-t \"$target_session\"} \"$title\" 2>/dev/null || true\n # Rename the tmux session\n if [[ -n \"$session_name\" ]]; then\n tmux $TMUX_ARGS rename-session ${target_session:+-t \"$target_session\"} \"$session_name\" 2>/dev/null || true\n fi\n # Disable global set-titles — it broadcasts the focused client's window name\n # to ALL client terminals, causing inactive iTerm tabs to show the wrong name.\n # See SPEC-138.\n tmux $TMUX_ARGS set-option -g set-titles off 2>/dev/null || true\n # Instead, send OSC title escapes directly to THIS session's client terminal.\n local client_tty\n if [[ -n \"$target_session\" ]]; then\n client_tty=$(tmux $TMUX_ARGS list-clients -t \"$target_session\" -F '#{client_tty}' 2>/dev/null | head -1)\n fi\n if [[ -n \"${client_tty:-}\" && -w \"$client_tty\" ]]; then\n printf '\\033]1;%s\\007' \"$title\" > \"$client_tty\" 2>/dev/null || true\n printf '\\033]0;%s\\007' \"$title\" > \"$client_tty\" 2>/dev/null || true\n fi\n elif [[ -t 1 ]]; then\n if [[ \"$TERM_PROGRAM\" == \"iTerm.app\" ]]; then\n printf '\\033]1;%s\\007' \"$title\"\n fi\n printf '\\033]0;%s\\007' \"$title\"\n fi\n}\n\ninstall_hook() {\n # Install a per-window pane-focus-in hook so titles update on pane switch.\n # Per-window (set-hook -w) avoids interfering with other tmux sessions.\n # Idempotent — re-running replaces the previous hook.\n #\n # IMPORTANT: Pass hook context via env vars. tmux expands #{...} format strings\n # at hook fire time, giving the script the correct session/pane context. Without\n # this, the script's tmux commands resolve to the \"current client\" (whichever\n # session last had input), not the session where the hook fired. See SPEC-138.\n if [[ -z \"$TMUX\" ]]; then\n return\n fi\n local self\n self=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)/$(basename \"${BASH_SOURCE[0]}\")\"\n tmux $TMUX_ARGS set-hook -w pane-focus-in \"run-shell 'SWAIN_HOOK_SESSION=#{q:session_name} SWAIN_HOOK_PANE_PATH=#{q:pane_current_path} SWAIN_HOOK_PANE_ID=#{q:pane_id} bash \\\"$self\\\" --auto'\" 2>/dev/null || true\n}\n\nreset_title() {\n # Restore default behavior: remove hook, clear @swain_path, re-enable auto-rename\n if [[ -n \"$TMUX\" ]]; then\n tmux $TMUX_ARGS set-window-option automatic-rename on 2>/dev/null || true\n tmux $TMUX_ARGS set-option -g set-titles off 2>/dev/null || true\n tmux $TMUX_ARGS set-hook -uw pane-focus-in 2>/dev/null || true\n tmux $TMUX_ARGS set-option -pu @swain_path 2>/dev/null || true\n tmux $TMUX_ARGS set-option -pu @swain_path_explicit 2>/dev/null || true\n # Reset the outer terminal title via this session's client only.\n # Resolve target session from $TMUX_PANE (authoritative for calling pane)\n # before falling back to display-message. See gh#116.\n local session_name_resolved client_tty\n if [[ -n \"${TMUX_PANE:-}\" ]]; then\n session_name_resolved=$(tmux $TMUX_ARGS display-message -p -t \"$TMUX_PANE\" '#{session_name}' 2>/dev/null)\n fi\n if [[ -z \"${session_name_resolved:-}\" ]]; then\n session_name_resolved=\"${SWAIN_HOOK_SESSION:-}\"\n fi\n if [[ -z \"$session_name_resolved\" ]]; then\n session_name_resolved=$(tmux $TMUX_ARGS display-message -p '#{session_name}' 2>/dev/null)\n fi\n if [[ -n \"$session_name_resolved\" ]]; then\n client_tty=$(tmux $TMUX_ARGS list-clients -t \"$session_name_resolved\" -F '#{client_tty}' 2>/dev/null | head -1)\n fi\n if [[ -n \"${client_tty:-}\" && -w \"$client_tty\" ]]; then\n printf '\\033]0;%s\\007' \"${SHELL##*/}\" > \"$client_tty\" 2>/dev/null || true\n fi\n fi\n printf '\\033]0;%s\\007' \"${SHELL##*/}\"\n}\n\nresolve_path() {\n # Resolution priority:\n # 1. --path arg (SWAIN_TAB_PATH) — explicit call-time override\n # 2. @swain_path_explicit=1 on pane — agent explicitly set a worktree path\n # 3. SWAIN_HOOK_PANE_PATH — provided by hook context (run-shell can't use pwd)\n # 4. pwd — normal interactive use; wins over stale @swain_path\n # 5. #{pane_current_path} — fallback when pwd is not in a git repo\n local path=\"$SWAIN_TAB_PATH\"\n\n # Use @swain_path only when it was explicitly set via --path (agent/worktree use case)\n # Target the correct pane via SWAIN_HOOK_PANE_ID when available.\n if [[ -z \"$path\" && -n \"$TMUX\" ]]; then\n local pane_target=\"${SWAIN_HOOK_PANE_ID:+-t $SWAIN_HOOK_PANE_ID}\"\n local explicit\n explicit=$(tmux $TMUX_ARGS show-options ${pane_target} -pqv @swain_path_explicit 2>/dev/null)\n if [[ \"$explicit\" == \"1\" ]]; then\n path=$(tmux $TMUX_ARGS show-options ${pane_target} -pqv @swain_path 2>/dev/null)\n fi\n fi\n\n # In hook context, use the pane path tmux expanded at fire time (pwd is wrong\n # inside run-shell — it's the tmux server's cwd, not the pane's).\n if [[ -z \"$path\" && -n \"${SWAIN_HOOK_PANE_PATH:-}\" ]]; then\n path=\"$SWAIN_HOOK_PANE_PATH\"\n fi\n\n # Use pwd (only reliable in direct invocations, not hooks)\n if [[ -z \"$path\" ]]; then\n path=\"$(pwd)\"\n fi\n\n # Fallback to tmux pane path if pwd isn't in a git repo\n if [[ -z \"$(git -C \"$path\" rev-parse --git-common-dir 2>/dev/null)\" && -n \"$TMUX\" ]]; then\n path=\"${SWAIN_HOOK_PANE_PATH:-$(tmux $TMUX_ARGS display-message -p '#{pane_current_path}' 2>/dev/null)}\"\n path=\"${path:-$(pwd)}\"\n fi\n\n echo \"$path\"\n}\n\nauto_title() {\n local project branch fmt title pane_path\n\n pane_path=$(resolve_path)\n\n # Use --git-common-dir to resolve the main repo root (not the worktree root)\n local common_dir repo_root\n common_dir=$(git -C \"$pane_path\" rev-parse --git-common-dir 2>/dev/null) || true\n if [[ -n \"$common_dir\" ]]; then\n repo_root=$(cd \"$pane_path\" && cd \"$common_dir/..\" && pwd 2>/dev/null) || true\n fi\n project=$(basename \"${repo_root:-unknown}\")\n branch=$(git -C \"$pane_path\" rev-parse --abbrev-ref HEAD 2>/dev/null) || true\n branch=\"${branch:-no-branch}\"\n fmt=$(read_setting '.terminal.tabNameFormat' '{project} @ {branch}')\n\n title=\"${fmt//\\{project\\}/$project}\"\n title=\"${title//\\{branch\\}/$branch}\"\n\n set_title \"$title\" \"$title\"\n\n # Store the resolved path as @swain_path on this pane.\n # Only mark it as explicit when --path was given in this invocation (agent/worktree case).\n # Without --path, we intentionally do NOT set @swain_path_explicit so that future\n # --auto calls will prefer pwd over the stored value (prevents stale override on cd).\n if [[ -n \"$TMUX\" ]]; then\n local pane_target=\"${SWAIN_HOOK_PANE_ID:+-t $SWAIN_HOOK_PANE_ID}\"\n tmux $TMUX_ARGS set-option ${pane_target} -p @swain_path \"$pane_path\" 2>/dev/null || true\n if [[ -n \"$SWAIN_TAB_PATH\" ]]; then\n tmux $TMUX_ARGS set-option ${pane_target} -p @swain_path_explicit 1 2>/dev/null || true\n fi\n fi\n\n echo \"$title\"\n}\n\n# ─── Argument parsing ───\nSWAIN_TAB_PATH=\"\"\nargs=()\nwhile [[ $# -gt 0 ]]; do\n case \"$1\" in\n --path)\n SWAIN_TAB_PATH=\"$2\"\n shift 2\n ;;\n *)\n args+=(\"$1\")\n shift\n ;;\n esac\ndone\n\ncase \"${args[0]:-}\" in\n --auto)\n auto_title\n install_hook\n ;;\n --reset)\n reset_title\n echo \"(reset)\"\n ;;\n --help|-h)\n echo \"Usage: swain-tab-name.sh [--path DIR] [TITLE | --auto | --reset]\"\n echo \"\"\n echo \" --path DIR Resolve git context from DIR (for agents in worktrees)\"\n echo \" TITLE Set a custom tab/window title\"\n echo \" --auto Generate title from git project + branch (uses settings)\"\n echo \" --reset Restore default terminal title\"\n exit 0\n ;;\n \"\")\n auto_title\n ;;\n *)\n set_title \"${args[0]}\" \"${args[0]}\"\n echo \"${args[0]}\"\n ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":10191,"content_sha256":"13e2a04b622f896cb9078d8b183c9976f6461a52b4feebf08354d90eea613821"},{"filename":"scripts/swain-worktree-name.sh","content":"#!/usr/bin/env bash\n# Generates artifact-aware worktree names (SPEC-251, ADR-025)\n#\n# Naming rules by track:\n# Implementable (SPEC, SPIKE): \u003cid>-\u003ctitle-slug>\n# Container (EPIC, INITIATIVE): \u003cpurpose-slug>-\u003cYYYYMMDD>-\u003cid>-\u003ctitle-slug>\n# Standing (VISION, ADR, etc.): \u003cid>-\u003ctitle-slug>\n# No artifact: session-\u003cYYYYMMDD>-\u003cHHMMSS>-\u003crandom>\n#\n# Usage: swain-worktree-name.sh [purpose-text]\nset -uo pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nPURPOSE=\"${1:-}\"\nTIMESTAMP=\"$(date +%Y%m%d-%H%M%S)\"\nSUFFIX=\"$(head -c 2 /dev/urandom | od -An -tx1 | tr -d ' \\n')\"\n\n# --- Helpers ---\n\nslugify() {\n echo \"$1\" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-50\n}\n\n# Track classification (ADR-025)\nartifact_track() {\n local type=\"$1\"\n case \"$(echo \"$type\" | tr '[:upper:]' '[:lower:]')\" in\n spec|spike) echo \"implementable\" ;;\n epic|initiative) echo \"container\" ;;\n vision|journey|persona|adr|runbook|design|train) echo \"standing\" ;;\n *) echo \"unknown\" ;;\n esac\n}\n\n# Find artifact file and extract title from frontmatter\nartifact_title() {\n local id=\"$1\"\n local type num\n type=\"$(echo \"$id\" | sed 's/-[0-9]*//' | tr '[:upper:]' '[:lower:]')\"\n num=\"$(echo \"$id\" | grep -oE '[0-9]+')\"\n local padded\n padded=\"$(printf '%03d' \"$num\" 2>/dev/null || echo \"$num\")\"\n\n # Search for the artifact file\n local artifact_file\n artifact_file=\"$(find \"$REPO_ROOT/docs\" -name \"*${id^^}*\" -name \"*.md\" 2>/dev/null | head -1)\"\n # Try case-insensitive if not found\n if [ -z \"$artifact_file\" ]; then\n artifact_file=\"$(find \"$REPO_ROOT/docs\" -iname \"*$(echo \"$id\" | tr '[:lower:]' '[:upper:]')*\" -name \"*.md\" 2>/dev/null | head -1)\"\n fi\n\n if [ -z \"$artifact_file\" ]; then\n return 1\n fi\n\n # Extract title from frontmatter\n local title\n title=\"$(grep -m1 '^title:' \"$artifact_file\" | sed 's/^title:[[:space:]]*//' | sed 's/^\"//;s/\"$//')\"\n if [ -n \"$title\" ]; then\n echo \"$title\"\n return 0\n fi\n return 1\n}\n\n# --- Extract artifact ID ---\n\n# Regex: TYPE-NNN (case insensitive)\nARTIFACT_ID=\"\"\nif [ -n \"$PURPOSE\" ]; then\n # Extract all artifact IDs\n ALL_IDS=\"$(echo \"$PURPOSE\" | grep -ioE '(SPEC|EPIC|SPIKE|VISION|INITIATIVE|ADR|DESIGN|JOURNEY|PERSONA|RUNBOOK|TRAIN)-[0-9]+' || true)\"\n\n if [ -n \"$ALL_IDS\" ]; then\n # Prefer container type if present (EPIC, INITIATIVE), else take first\n CONTAINER_ID=\"$(echo \"$ALL_IDS\" | grep -iE '^(EPIC|INITIATIVE)-' | head -1)\"\n if [ -n \"$CONTAINER_ID\" ]; then\n ARTIFACT_ID=\"$CONTAINER_ID\"\n else\n ARTIFACT_ID=\"$(echo \"$ALL_IDS\" | head -1)\"\n fi\n # Normalize to uppercase type, lowercase for slug\n ARTIFACT_ID=\"$(echo \"$ARTIFACT_ID\" | tr '[:lower:]' '[:upper:]')\"\n fi\nfi\n\n# --- Generate name ---\n\nif [ -z \"$ARTIFACT_ID\" ]; then\n # Fallback: session-YYYYMMDD-HHMMSS-XXXX\n if [ -n \"$PURPOSE\" ]; then\n PURPOSE_SLUG=\"$(slugify \"$PURPOSE\")\"\n # Use purpose slug if meaningful, else session\n if [ -n \"$PURPOSE_SLUG\" ] && [ \"${#PURPOSE_SLUG}\" -gt 3 ]; then\n printf 'session-%s-%s\\n' \"$TIMESTAMP\" \"$SUFFIX\"\n else\n printf 'session-%s-%s\\n' \"$TIMESTAMP\" \"$SUFFIX\"\n fi\n else\n printf 'session-%s-%s\\n' \"$TIMESTAMP\" \"$SUFFIX\"\n fi\n exit 0\nfi\n\n# Extract type and number\nART_TYPE=\"$(echo \"$ARTIFACT_ID\" | sed 's/-[0-9]*//')\"\nART_NUM=\"$(echo \"$ARTIFACT_ID\" | grep -oE '[0-9]+')\"\nART_ID_LOWER=\"$(echo \"$ARTIFACT_ID\" | tr '[:upper:]' '[:lower:]')\"\nTRACK=\"$(artifact_track \"$ART_TYPE\")\"\n\n# Try to get title from frontmatter\nTITLE=\"\"\nif TITLE_RAW=\"$(artifact_title \"$ARTIFACT_ID\")\"; then\n TITLE=\"$(slugify \"$TITLE_RAW\")\"\nfi\n\ncase \"$TRACK\" in\n implementable)\n # Pattern: \u003cid>-\u003ctitle-slug>\n if [ -n \"$TITLE\" ]; then\n printf '%s-%s\\n' \"$ART_ID_LOWER\" \"$TITLE\"\n else\n printf '%s\\n' \"$ART_ID_LOWER\"\n fi\n ;;\n container)\n # Pattern: \u003cpurpose-slug>-\u003cYYYYMMDD>-\u003cid>-\u003ctitle-slug>\n # Extract purpose words (remove the artifact ID from purpose text)\n PURPOSE_CLEAN=\"$(echo \"$PURPOSE\" | sed -E \"s/$ARTIFACT_ID//i\" | xargs)\"\n PURPOSE_SLUG=\"$(slugify \"$PURPOSE_CLEAN\")\"\n DATE_ONLY=\"$(date +%Y%m%d)\"\n if [ -n \"$PURPOSE_SLUG\" ] && [ \"${#PURPOSE_SLUG}\" -gt 2 ]; then\n if [ -n \"$TITLE\" ]; then\n printf '%s-%s-%s-%s\\n' \"$PURPOSE_SLUG\" \"$DATE_ONLY\" \"$ART_ID_LOWER\" \"$TITLE\"\n else\n printf '%s-%s-%s\\n' \"$PURPOSE_SLUG\" \"$DATE_ONLY\" \"$ART_ID_LOWER\"\n fi\n else\n if [ -n \"$TITLE\" ]; then\n printf '%s-%s-%s\\n' \"$DATE_ONLY\" \"$ART_ID_LOWER\" \"$TITLE\"\n else\n printf '%s-%s\\n' \"$DATE_ONLY\" \"$ART_ID_LOWER\"\n fi\n fi\n ;;\n standing)\n # Pattern: \u003cid>-\u003ctitle-slug>\n if [ -n \"$TITLE\" ]; then\n printf '%s-%s\\n' \"$ART_ID_LOWER\" \"$TITLE\"\n else\n printf '%s\\n' \"$ART_ID_LOWER\"\n fi\n ;;\n *)\n # Unknown type — use ID + fallback\n printf '%s-%s-%s\\n' \"$ART_ID_LOWER\" \"$TIMESTAMP\" \"$SUFFIX\"\n ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":5007,"content_sha256":"8df2d9187e383da38c9d780d8023589e29ea4885ef3451c99efcb563bb98c2ee"},{"filename":"scripts/test-session-greeting.sh","content":"#!/usr/bin/env bash\n# test-session-greeting.sh — SPEC-194: Test the fast-path session greeting\n#\n# Tests swain-session-greeting.sh output for completeness and performance.\n#\n# Usage: bash test-session-greeting.sh [--verbose]\n\nset -euo pipefail\n\nVERBOSE=0\n[[ \"${1:-}\" == \"--verbose\" ]] && VERBOSE=1\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nGREETING_SCRIPT=\"$SCRIPT_DIR/swain-session-greeting.sh\"\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\n\nPASS=0\nFAIL=0\nTOTAL=0\n\nassert_contains() {\n local test_name=\"$1\" expected=\"$2\" actual=\"$3\"\n TOTAL=$((TOTAL + 1))\n if echo \"$actual\" | grep -q \"$expected\"; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: $test_name\"\n else\n FAIL=$((FAIL + 1))\n echo \" FAIL: $test_name (expected to contain: '$expected')\"\n [[ $VERBOSE -eq 1 ]] && echo \" Got: $(echo \"$actual\" | head -5)\"\n fi\n}\n\nassert_not_contains() {\n local test_name=\"$1\" unexpected=\"$2\" actual=\"$3\"\n TOTAL=$((TOTAL + 1))\n if ! echo \"$actual\" | grep -q \"$unexpected\"; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: $test_name\"\n else\n FAIL=$((FAIL + 1))\n echo \" FAIL: $test_name (should NOT contain: '$unexpected')\"\n fi\n}\n\nassert_eq() {\n local test_name=\"$1\" expected=\"$2\" actual=\"$3\"\n TOTAL=$((TOTAL + 1))\n if [[ \"$expected\" == \"$actual\" ]]; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: $test_name\"\n else\n FAIL=$((FAIL + 1))\n echo \" FAIL: $test_name (expected: '$expected', got: '$actual')\"\n fi\n}\n\n# ─── Setup ───\necho \"=== SPEC-194: Session greeting tests ===\"\n\n# Check greeting script exists\nTOTAL=$((TOTAL + 1))\nif [[ -x \"$GREETING_SCRIPT\" ]]; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: greeting script exists and is executable\"\nelse\n FAIL=$((FAIL + 1))\n echo \" FAIL: greeting script not found or not executable at $GREETING_SCRIPT\"\n echo \"Results: $PASS/$TOTAL passed, $FAIL failed\"\n exit 1\nfi\n\n# ─── Test 0b: .agents/bin/ symlinks for all swain-session scripts (SPEC-206) ───\necho \"Test 0b: Symlinks in .agents/bin/ for swain-session scripts\"\nOPERATOR_SCRIPTS=\"swain swain-box\"\nfor script in \"$SCRIPT_DIR\"/*; do\n [[ -f \"$script\" && -x \"$script\" ]] || continue\n sname=\"$(basename \"$script\")\"\n [[ \"$sname\" == test-* || \"$sname\" == test_* ]] && continue\n echo \" $OPERATOR_SCRIPTS \" | grep -q \" $sname \" && continue\n SYMLINK_PATH=\"$REPO_ROOT/.agents/bin/$sname\"\n TOTAL=$((TOTAL + 1))\n if [[ -L \"$SYMLINK_PATH\" && -e \"$SYMLINK_PATH\" ]]; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: .agents/bin/$sname symlink resolves\"\n else\n FAIL=$((FAIL + 1))\n if [[ -L \"$SYMLINK_PATH\" ]]; then\n echo \" FAIL: .agents/bin/$sname symlink is broken\"\n else\n echo \" FAIL: .agents/bin/$sname symlink missing\"\n fi\n fi\ndone\n\n# ─── Test 1: Greeting includes branch info ───\necho \"Test 1: Branch info in output\"\noutput=$(bash \"$GREETING_SCRIPT\" 2>/dev/null)\nbranch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)\nassert_contains \"greeting contains branch name\" \"$branch\" \"$output\"\n\n# ─── Test 2: JSON output mode ───\necho \"Test 2: JSON output mode\"\njson_output=$(bash \"$GREETING_SCRIPT\" --json 2>/dev/null)\nassert_contains \"json has branch key\" '\"branch\"' \"$json_output\"\nassert_contains \"json has greeting key\" '\"greeting\"' \"$json_output\"\n\n# ─── Test 3: Bookmark shown if present ───\necho \"Test 3: Bookmark in output (if session.json has one)\"\nif [[ -f \"$REPO_ROOT/.agents/session.json\" ]]; then\n bookmark=$(jq -r '.bookmark.note // empty' \"$REPO_ROOT/.agents/session.json\" 2>/dev/null)\n if [[ -n \"$bookmark\" ]]; then\n assert_contains \"greeting contains bookmark\" \"Bookmark\" \"$output\"\n else\n TOTAL=$((TOTAL + 1))\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: no bookmark set (skip)\"\n fi\nelse\n TOTAL=$((TOTAL + 1))\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: no session.json (skip)\"\nfi\n\n# ─── Test 4: Focus lane shown if present ───\necho \"Test 4: Focus lane in output (if set)\"\nif [[ -f \"$REPO_ROOT/.agents/session.json\" ]]; then\n focus=$(jq -r '.focus_lane // empty' \"$REPO_ROOT/.agents/session.json\" 2>/dev/null)\n if [[ -n \"$focus\" ]]; then\n assert_contains \"greeting contains focus lane\" \"$focus\" \"$output\"\n else\n TOTAL=$((TOTAL + 1))\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: no focus lane set (skip)\"\n fi\nelse\n TOTAL=$((TOTAL + 1))\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: no session.json (skip)\"\nfi\n\n# ─── Test 5: Does NOT include specgraph or GitHub data ───\necho \"Test 5: No specgraph/GitHub data in greeting\"\nassert_not_contains \"no specgraph output\" \"specgraph\" \"$output\"\nassert_not_contains \"no GitHub issues\" \"github.com\" \"$output\"\n\n# ─── Test 6: Performance — greeting completes in \u003c2000ms ───\necho \"Test 6: Performance (\u003c2000ms)\"\nstart_ms=$(python3 -c \"import time; print(int(time.time()*1000))\")\nbash \"$GREETING_SCRIPT\" >/dev/null 2>&1\nend_ms=$(python3 -c \"import time; print(int(time.time()*1000))\")\nelapsed=$((end_ms - start_ms))\nTOTAL=$((TOTAL + 1))\nif [[ $elapsed -lt 2000 ]]; then\n PASS=$((PASS + 1))\n [[ $VERBOSE -eq 1 ]] && echo \" PASS: performance (${elapsed}ms)\"\nelse\n FAIL=$((FAIL + 1))\n echo \" FAIL: performance (${elapsed}ms, expected \u003c2000ms)\"\nfi\n\n# ─── Test 7: Dirty/clean state shown ───\necho \"Test 7: Working tree state in output\"\njson_output=$(bash \"$GREETING_SCRIPT\" --json 2>/dev/null)\nassert_contains \"json has dirty key\" '\"dirty\"' \"$json_output\"\n\n# ─── Summary ───\necho \"\"\necho \"Results: $PASS/$TOTAL passed, $FAIL failed\"\n[[ $FAIL -eq 0 ]] && exit 0 || exit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":5727,"content_sha256":"b8b29ce82df22323c467abc3f4d07d5c9b5b700e84cd2dbcc1d3457c618e9b8b"},{"filename":"tests/test-bootstrap.sh","content":"#!/usr/bin/env bash\n# test-bootstrap.sh — Acceptance tests for swain-session-bootstrap.sh (SPEC-172)\n#\n# Tests the consolidated bootstrap script that replaces multi-step session startup.\n# Runs in an isolated tmux server and temp git repos. Requires: tmux, git, jq.\n#\n# Usage: bash skills/swain-session/tests/test-bootstrap.sh\n\nset +e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nBOOTSTRAP=\"$(cd \"$SCRIPT_DIR/..\" && pwd)/scripts/swain-session-bootstrap.sh\"\nGIT_COMMON=\"$(git rev-parse --git-common-dir 2>/dev/null)\"\nREPO_ROOT=\"$(cd \"$GIT_COMMON/..\" && pwd 2>/dev/null)\"\n\n# Isolated tmux server\nTMUX_SOCK=\"/tmp/swain-test-bootstrap-$\"\nT=\"tmux -S $TMUX_SOCK\"\n\n# Temp dir for test repos\nTMPDIR_BASE=\"/tmp/swain-test-bootstrap-repos-$\"\n\nPASS=0\nFAIL=0\nSKIP=0\n\npass() { echo \" PASS: $1\"; ((PASS++)); }\nfail() { echo \" FAIL: $1 — $2\"; ((FAIL++)); }\nskip() { echo \" SKIP: $1 — $2\"; ((SKIP++)); }\n\ncleanup() {\n $T kill-server 2>/dev/null\n rm -f \"$TMUX_SOCK\"\n rm -rf \"$TMPDIR_BASE\"\n}\ntrap cleanup EXIT\n\nstart_session() {\n local name=\"${1:-test}\"\n local dir=\"${2:-$REPO_ROOT}\"\n $T new-session -d -s \"$name\" -c \"$dir\" 2>/dev/null\n}\n\n# Run the bootstrap script with given args, capturing JSON output\nrun_bootstrap() {\n local extra_env=\"${1:-}\"\n shift\n SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" $extra_env \\\n bash \"$BOOTSTRAP\" \"$@\" 2>/dev/null\n}\n\n# ─── Preflight ───\n\nif [[ ! -x \"$BOOTSTRAP\" && ! -f \"$BOOTSTRAP\" ]]; then\n echo \"FATAL: bootstrap script not found at $BOOTSTRAP\"\n exit 1\nfi\n\nif ! command -v jq &>/dev/null; then\n echo \"FATAL: jq is required for tests\"\n exit 1\nfi\n\nmkdir -p \"$TMPDIR_BASE\"\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ AC1: tmux session — single call does tab + worktree + session\"\n# ═══════════════════════════════════════════════════════════\n\nstart_session \"ac1\" \"$REPO_ROOT\"\n\nOUTPUT=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\nif echo \"$OUTPUT\" | jq empty 2>/dev/null; then\n pass \"AC1: output is valid JSON\"\nelse\n fail \"AC1: output is valid JSON\" \"got: $OUTPUT\"\nfi\n\n# Check tab field exists\nTAB=$(echo \"$OUTPUT\" | jq -r '.tab // empty' 2>/dev/null)\nif [[ -n \"$TAB\" ]]; then\n pass \"AC1: tab field present\"\nelse\n fail \"AC1: tab field present\" \"missing from output\"\nfi\n\n# Check worktree field exists (use has() since isolated can be false)\nWT_HAS=$(echo \"$OUTPUT\" | jq 'has(\"worktree\") and (.worktree | has(\"isolated\"))' 2>/dev/null)\nif [[ \"$WT_HAS\" == \"true\" ]]; then\n pass \"AC1: worktree.isolated field present\"\nelse\n fail \"AC1: worktree.isolated field present\" \"missing from output\"\nfi\n\n# Check session field exists\nSESSION=$(echo \"$OUTPUT\" | jq -r '.session // empty' 2>/dev/null)\nif [[ -n \"$SESSION\" && \"$SESSION\" != \"null\" ]]; then\n pass \"AC1: session field present\"\nelse\n fail \"AC1: session field present\" \"missing from output\"\nfi\n\n$T kill-session -t ac1 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ AC2: non-tmux terminal — tab field omitted\"\n# ═══════════════════════════════════════════════════════════\n\n# Run WITHOUT TMUX env var\nOUTPUT_NO_TMUX=$(TMUX=\"\" SWAIN_TMUX_SOCKET=\"\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\nif echo \"$OUTPUT_NO_TMUX\" | jq empty 2>/dev/null; then\n pass \"AC2: output is valid JSON without tmux\"\nelse\n fail \"AC2: output is valid JSON without tmux\" \"got: $OUTPUT_NO_TMUX\"\nfi\n\nTAB_NO_TMUX=$(echo \"$OUTPUT_NO_TMUX\" | jq -r '.tab // \"MISSING\"' 2>/dev/null)\nif [[ \"$TAB_NO_TMUX\" == \"null\" || \"$TAB_NO_TMUX\" == \"MISSING\" ]]; then\n pass \"AC2: tab field omitted without tmux\"\nelse\n fail \"AC2: tab field omitted without tmux\" \"got: $TAB_NO_TMUX\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ AC3: already in a worktree — worktree.isolated is true\"\n# ═══════════════════════════════════════════════════════════\n\n# We're running from a worktree already (spec-172-session-bootstrap)\nWT_DIR=\"$(pwd)\"\nGIT_COMMON_TEST=$(git -C \"$WT_DIR\" rev-parse --git-common-dir 2>/dev/null)\nGIT_DIR_TEST=$(git -C \"$WT_DIR\" rev-parse --git-dir 2>/dev/null)\n\nif [[ \"$GIT_COMMON_TEST\" != \"$GIT_DIR_TEST\" ]]; then\n # We are in a worktree\n start_session \"ac3\" \"$WT_DIR\"\n OUTPUT_WT=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$WT_DIR\" --auto 2>/dev/null)\n\n WT_IS=$(echo \"$OUTPUT_WT\" | jq -r '.worktree.isolated' 2>/dev/null)\n if [[ \"$WT_IS\" == \"true\" ]]; then\n pass \"AC3: worktree.isolated is true in worktree\"\n else\n fail \"AC3: worktree.isolated is true in worktree\" \"got: $WT_IS\"\n fi\n\n WT_BRANCH=$(echo \"$OUTPUT_WT\" | jq -r '.worktree.branch // empty' 2>/dev/null)\n if [[ -n \"$WT_BRANCH\" ]]; then\n pass \"AC3: worktree.branch is populated\"\n else\n fail \"AC3: worktree.branch is populated\" \"empty\"\n fi\n\n $T kill-session -t ac3 2>/dev/null\nelse\n # Running from main worktree — use REPO_ROOT and check isolated=false\n start_session \"ac3\" \"$REPO_ROOT\"\n OUTPUT_MAIN=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\n WT_IS=$(echo \"$OUTPUT_MAIN\" | jq -r '.worktree.isolated' 2>/dev/null)\n if [[ \"$WT_IS\" == \"false\" ]]; then\n pass \"AC3: worktree.isolated is false in main worktree\"\n else\n fail \"AC3: worktree.isolated is false in main worktree\" \"got: $WT_IS\"\n fi\n\n $T kill-session -t ac3 2>/dev/null\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ AC4: no session.json — session fields are null/empty\"\n# ═══════════════════════════════════════════════════════════\n\n# Create a temp git repo with no session.json\nTEMP_REPO=\"$TMPDIR_BASE/no-session\"\nmkdir -p \"$TEMP_REPO\"\ngit -C \"$TEMP_REPO\" init -q 2>/dev/null\ngit -C \"$TEMP_REPO\" commit --allow-empty -m \"init\" -q 2>/dev/null\n\nstart_session \"ac4\" \"$TEMP_REPO\"\nOUTPUT_NO_SESSION=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO\" --auto 2>/dev/null)\n\nif echo \"$OUTPUT_NO_SESSION\" | jq empty 2>/dev/null; then\n pass \"AC4: valid JSON with no session.json\"\nelse\n fail \"AC4: valid JSON with no session.json\" \"got: $OUTPUT_NO_SESSION\"\nfi\n\nBOOKMARK=$(echo \"$OUTPUT_NO_SESSION\" | jq -r '.session.bookmark // \"null\"' 2>/dev/null)\nif [[ \"$BOOKMARK\" == \"null\" || \"$BOOKMARK\" == \"\" ]]; then\n pass \"AC4: bookmark is null when no session.json\"\nelse\n fail \"AC4: bookmark is null when no session.json\" \"got: $BOOKMARK\"\nfi\n\n$T kill-session -t ac4 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ AC4b: session.json with bookmark — values populated\"\n# ═══════════════════════════════════════════════════════════\n\nTEMP_REPO_B=\"$TMPDIR_BASE/with-session\"\nmkdir -p \"$TEMP_REPO_B/.agents\"\ngit -C \"$TEMP_REPO_B\" init -q 2>/dev/null\ngit -C \"$TEMP_REPO_B\" commit --allow-empty -m \"init\" -q 2>/dev/null\ncat > \"$TEMP_REPO_B/.agents/session.json\" \u003c\u003c'SESS'\n{\n \"lastBranch\": \"trunk\",\n \"focus_lane\": \"VISION-001\",\n \"bookmark\": {\n \"note\": \"Working on bootstrap consolidation\",\n \"timestamp\": \"2026-03-26T01:00:00Z\"\n }\n}\nSESS\n\nstart_session \"ac4b\" \"$TEMP_REPO_B\"\nOUTPUT_WITH_SESSION=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO_B\" --auto 2>/dev/null)\n\nFOCUS=$(echo \"$OUTPUT_WITH_SESSION\" | jq -r '.session.focus // empty' 2>/dev/null)\nif [[ \"$FOCUS\" == \"VISION-001\" ]]; then\n pass \"AC4b: focus lane read from session.json\"\nelse\n fail \"AC4b: focus lane read from session.json\" \"got: $FOCUS\"\nfi\n\nBM_NOTE=$(echo \"$OUTPUT_WITH_SESSION\" | jq -r '.session.bookmark // empty' 2>/dev/null)\nif [[ -n \"$BM_NOTE\" && \"$BM_NOTE\" != \"null\" ]]; then\n pass \"AC4b: bookmark note read from session.json\"\nelse\n fail \"AC4b: bookmark note read from session.json\" \"got: $BM_NOTE\"\nfi\n\n$T kill-session -t ac4b 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ JSON schema validation\"\n# ═══════════════════════════════════════════════════════════\n\n# Re-run on the real repo and validate all expected top-level keys\nstart_session \"schema\" \"$REPO_ROOT\"\nOUTPUT_SCHEMA=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\nKEYS=$(echo \"$OUTPUT_SCHEMA\" | jq -r 'keys[]' 2>/dev/null | sort | tr '\\n' ',')\n# Expected keys: session, tab, warnings, worktree (alphabetical)\nif [[ \"$KEYS\" == *\"session\"* && \"$KEYS\" == *\"worktree\"* && \"$KEYS\" == *\"warnings\"* ]]; then\n pass \"Schema: required keys present (session, worktree, warnings)\"\nelse\n fail \"Schema: required keys present\" \"got keys: $KEYS\"\nfi\n\nWARNINGS_TYPE=$(echo \"$OUTPUT_SCHEMA\" | jq -r '.warnings | type' 2>/dev/null)\nif [[ \"$WARNINGS_TYPE\" == \"array\" ]]; then\n pass \"Schema: warnings is an array\"\nelse\n fail \"Schema: warnings is an array\" \"got type: $WARNINGS_TYPE\"\nfi\n\n$T kill-session -t schema 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ --skip-worktree flag\"\n# ═══════════════════════════════════════════════════════════\n\nstart_session \"skipwt\" \"$REPO_ROOT\"\nOUTPUT_SKIP_WT=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --skip-worktree --auto 2>/dev/null)\n\n# When --skip-worktree is set, worktree fields should be at defaults\nWT_SKIP_ISOLATED=$(echo \"$OUTPUT_SKIP_WT\" | jq -r '.worktree.isolated' 2>/dev/null)\nWT_SKIP_BRANCH=$(echo \"$OUTPUT_SKIP_WT\" | jq -r '.worktree.branch' 2>/dev/null)\nif [[ \"$WT_SKIP_ISOLATED\" == \"false\" && \"$WT_SKIP_BRANCH\" == \"null\" ]]; then\n pass \"--skip-worktree: worktree detection skipped (isolated=false, branch=null)\"\nelse\n fail \"--skip-worktree: worktree detection skipped\" \"isolated=$WT_SKIP_ISOLATED, branch=$WT_SKIP_BRANCH\"\nfi\n\n$T kill-session -t skipwt 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ lastBranch write side effect\"\n# ═══════════════════════════════════════════════════════════\n\nTEMP_REPO_LB=\"$TMPDIR_BASE/lastbranch\"\nmkdir -p \"$TEMP_REPO_LB/.agents\"\ngit -C \"$TEMP_REPO_LB\" init -q 2>/dev/null\ngit -C \"$TEMP_REPO_LB\" commit --allow-empty -m \"init\" -q 2>/dev/null\ncat > \"$TEMP_REPO_LB/.agents/session.json\" \u003c\u003c'SESS'\n{\n \"lastBranch\": \"old-branch\"\n}\nSESS\n\nstart_session \"lb\" \"$TEMP_REPO_LB\"\nSWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO_LB\" --auto >/dev/null 2>&1\n\n# Check that session.json was updated with the current branch\nCURRENT=$(git -C \"$TEMP_REPO_LB\" rev-parse --abbrev-ref HEAD 2>/dev/null)\nWRITTEN=$(jq -r '.lastBranch' \"$TEMP_REPO_LB/.agents/session.json\" 2>/dev/null)\nif [[ \"$WRITTEN\" == \"$CURRENT\" ]]; then\n pass \"lastBranch: session.json updated to current branch ($CURRENT)\"\nelse\n fail \"lastBranch: session.json updated\" \"expected=$CURRENT, got=$WRITTEN\"\nfi\n\n$T kill-session -t lb 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ jq-unavailable fallback\"\n# ═══════════════════════════════════════════════════════════\n\n# Hide jq by creating a wrapper that makes `command -v jq` fail.\n# We rename jq temporarily via a PATH-prefix dir with a non-executable jq.\nFAKE_BIN=\"$TMPDIR_BASE/fake-bin\"\nmkdir -p \"$FAKE_BIN\"\n# Create a non-executable jq placeholder — command -v still finds executables\n# in PATH, so instead create a wrapper that always fails\ncat > \"$FAKE_BIN/jq\" \u003c\u003c'FAKE'\n#!/usr/bin/env bash\nexit 127\nFAKE\nchmod +x \"$FAKE_BIN/jq\"\n\nstart_session \"nojq\" \"$REPO_ROOT\"\n# Prepend FAKE_BIN so our broken jq shadows the real one\nOUTPUT_NOJQ=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n PATH=\"$FAKE_BIN:$PATH\" bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\n# The fallback path fires when `command -v jq` succeeds but jq calls fail.\n# Our fake jq makes command -v succeed, so the script takes the jq path but\n# jq -n fails, producing broken output. The REAL no-jq fallback requires\n# jq to be completely absent. Test what actually matters: the script doesn't crash.\nif [[ -n \"$OUTPUT_NOJQ\" ]]; then\n pass \"jq-unavailable: script produces output (does not crash)\"\nelse\n fail \"jq-unavailable: script produces output\" \"empty output\"\nfi\n\n# Test the actual no-jq codepath by using a clean PATH\nCLEAN_PATH=\"\"\nwhile IFS=: read -ra dirs; do\n for dir in \"${dirs[@]}\"; do\n if ! [[ -x \"$dir/jq\" ]]; then\n CLEAN_PATH=\"${CLEAN_PATH:+$CLEAN_PATH:}$dir\"\n fi\n done\ndone \u003c\u003c\u003c \"$PATH\"\n\nOUTPUT_NOJQ_REAL=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n PATH=\"$CLEAN_PATH\" bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\n# The no-jq fallback constructs JSON manually — verify it's parseable\nif echo \"$OUTPUT_NOJQ_REAL\" | jq empty 2>/dev/null; then\n pass \"jq-unavailable (real): fallback JSON is valid\"\nelse\n fail \"jq-unavailable (real): fallback JSON is valid\" \"got: $OUTPUT_NOJQ_REAL\"\nfi\n\n# Check for the jq warning in the fallback output\nif echo \"$OUTPUT_NOJQ_REAL\" | grep -q \"jq not available\"; then\n pass \"jq-unavailable (real): warning present about missing jq\"\nelse\n fail \"jq-unavailable (real): warning present about missing jq\" \"output: $OUTPUT_NOJQ_REAL\"\nfi\n\n$T kill-session -t nojq 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ Warnings population (missing tab-name script)\"\n# ═══════════════════════════════════════════════════════════\n\n# Create a temp copy of the bootstrap script with a bogus SCRIPT_DIR\nTEMP_REPO_WARN=\"$TMPDIR_BASE/warn-test\"\nmkdir -p \"$TEMP_REPO_WARN/scripts\"\ngit -C \"$TEMP_REPO_WARN\" init -q 2>/dev/null\ngit -C \"$TEMP_REPO_WARN\" commit --allow-empty -m \"init\" -q 2>/dev/null\n\n# Copy bootstrap but override SCRIPT_DIR to a dir without tab-name\ncp \"$BOOTSTRAP\" \"$TEMP_REPO_WARN/scripts/swain-session-bootstrap.sh\"\n# The script resolves SCRIPT_DIR from its own location — since tab-name.sh\n# won't exist in the temp dir, it should warn.\n\nstart_session \"warn\" \"$TEMP_REPO_WARN\"\nOUTPUT_WARN=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$TEMP_REPO_WARN/scripts/swain-session-bootstrap.sh\" --path \"$TEMP_REPO_WARN\" --auto 2>/dev/null)\n\nWARN_COUNT=$(echo \"$OUTPUT_WARN\" | jq '.warnings | length' 2>/dev/null)\nif [[ \"$WARN_COUNT\" -gt 0 ]]; then\n pass \"warnings: populated when tab-name script missing ($WARN_COUNT warning(s))\"\nelse\n fail \"warnings: populated when tab-name script missing\" \"warnings array empty\"\nfi\n\nHAS_TAB_WARN=$(echo \"$OUTPUT_WARN\" | jq -r '.warnings[]' 2>/dev/null | grep -c \"tab-name\")\nif [[ \"$HAS_TAB_WARN\" -gt 0 ]]; then\n pass \"warnings: mentions tab-name script\"\nelse\n fail \"warnings: mentions tab-name script\" \"no tab-name warning found\"\nfi\n\n$T kill-session -t warn 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ Idempotency (two consecutive calls)\"\n# ═══════════════════════════════════════════════════════════\n\nTEMP_REPO_IDEM=\"$TMPDIR_BASE/idempotent\"\nmkdir -p \"$TEMP_REPO_IDEM/.agents\"\ngit -C \"$TEMP_REPO_IDEM\" init -q 2>/dev/null\ngit -C \"$TEMP_REPO_IDEM\" commit --allow-empty -m \"init\" -q 2>/dev/null\ncat > \"$TEMP_REPO_IDEM/.agents/session.json\" \u003c\u003c'SESS'\n{\n \"lastBranch\": \"trunk\",\n \"focus_lane\": \"VISION-001\",\n \"bookmark\": { \"note\": \"idempotency test\" }\n}\nSESS\n\nstart_session \"idem\" \"$TEMP_REPO_IDEM\"\n\n# Run 1 reads stale lastBranch then writes current. Run 2+ reads the updated value.\n# Idempotency means run 2 == run 3 (after the write stabilizes).\nSWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO_IDEM\" --auto >/dev/null 2>&1 # prime the write\n\nOUTPUT_RUN2=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO_IDEM\" --auto 2>/dev/null)\nOUTPUT_RUN3=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$TEMP_REPO_IDEM\" --auto 2>/dev/null)\n\n# Normalize: strip tab field (tmux state may differ slightly) and compare core fields\nCORE2=$(echo \"$OUTPUT_RUN2\" | jq '{worktree, session, warnings}' 2>/dev/null)\nCORE3=$(echo \"$OUTPUT_RUN3\" | jq '{worktree, session, warnings}' 2>/dev/null)\n\nif [[ \"$CORE2\" == \"$CORE3\" ]]; then\n pass \"idempotency: consecutive runs after stabilization produce identical output\"\nelse\n fail \"idempotency: consecutive runs differ\" \"run2=$CORE2 run3=$CORE3\"\nfi\n\n$T kill-session -t idem 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ Main worktree detection (isolated=false)\"\n# ═══════════════════════════════════════════════════════════\n\n# Explicitly test against the main repo root (not a worktree)\nstart_session \"mainwt\" \"$REPO_ROOT\"\nOUTPUT_MAIN_WT=$(SWAIN_TMUX_SOCKET=\"$TMUX_SOCK\" TMUX=\"$TMUX_SOCK,0,0\" \\\n bash \"$BOOTSTRAP\" --path \"$REPO_ROOT\" --auto 2>/dev/null)\n\nMAIN_ISOLATED=$(echo \"$OUTPUT_MAIN_WT\" | jq -r '.worktree.isolated' 2>/dev/null)\nif [[ \"$MAIN_ISOLATED\" == \"false\" ]]; then\n pass \"main worktree: isolated is false\"\nelse\n fail \"main worktree: isolated is false\" \"got: $MAIN_ISOLATED\"\nfi\n\nMAIN_BRANCH=$(echo \"$OUTPUT_MAIN_WT\" | jq -r '.worktree.branch' 2>/dev/null)\nif [[ -n \"$MAIN_BRANCH\" && \"$MAIN_BRANCH\" != \"null\" ]]; then\n pass \"main worktree: branch is populated\"\nelse\n fail \"main worktree: branch is populated\" \"got: $MAIN_BRANCH\"\nfi\n\n$T kill-session -t mainwt 2>/dev/null\n\n# ═══════════════════════════════════════════════════════════\necho \"\"\necho \"Results: $PASS passed, $FAIL failed, $SKIP skipped\"\n[[ $FAIL -eq 0 ]] && exit 0 || exit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":20324,"content_sha256":"d6897dcf23497d7cb9cc7d0f3d7f50e0ee07f2c4e72f8001c2ea24d5c1fe7274"},{"filename":"tests/test-progress-log.sh","content":"#!/usr/bin/env bash\n# test-progress-log.sh — Tests for swain-progress-log.sh (SPEC-200)\n#\n# Uses the bash assert pattern (PASS/FAIL counters).\n# Tests against EPIC-048 as a real artifact, reverting changes after each test.\n#\n# Usage: bash skills/swain-session/tests/test-progress-log.sh\n\nset +e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPROGRESS_LOG=\"$(cd \"$SCRIPT_DIR/..\" && pwd)/scripts/swain-progress-log.sh\"\nREPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null)\"\n\n# Test artifact — must exist in the repo\nTEST_ARTIFACT_ID=\"EPIC-048\"\nTEST_ARTIFACT_DIR=\"$REPO_ROOT/docs/epic/Active/(EPIC-048)-Session-Startup-Fast-Path\"\nTEST_ARTIFACT_FILE=\"$TEST_ARTIFACT_DIR/(EPIC-048)-Session-Startup-Fast-Path.md\"\nTEST_PROGRESS_FILE=\"$TEST_ARTIFACT_DIR/progress.md\"\n\nPASS=0\nFAIL=0\n\npass() { echo \" PASS: $1\"; ((PASS++)); }\nfail() { echo \" FAIL: $1 — $2\"; ((FAIL++)); }\n\ncleanup() {\n # Revert any changes to the test artifact\n git checkout -- \"$TEST_ARTIFACT_FILE\" 2>/dev/null\n rm -f \"$TEST_PROGRESS_FILE\"\n}\ntrap cleanup EXIT\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T1: Script exists and is executable\"\n# ═══════════════════════════════════════════════════════════\n\nif [[ -f \"$PROGRESS_LOG\" ]]; then\n pass \"T1a: script file exists\"\nelse\n fail \"T1a: script file exists\" \"not found at $PROGRESS_LOG\"\nfi\n\nif [[ -x \"$PROGRESS_LOG\" ]]; then\n pass \"T1b: script is executable\"\nelse\n fail \"T1b: script is executable\" \"missing execute permission\"\nfi\n\n# Verify symlink in .agents/bin/\nSYMLINK=\"$REPO_ROOT/.agents/bin/swain-progress-log.sh\"\nif [[ -L \"$SYMLINK\" ]]; then\n pass \"T1c: symlink exists in .agents/bin/\"\nelse\n fail \"T1c: symlink exists in .agents/bin/\" \"not found at $SYMLINK\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T2: --entry creates progress.md if missing\"\n# ═══════════════════════════════════════════════════════════\n\n# Ensure clean state\nrm -f \"$TEST_PROGRESS_FILE\"\n\nOUTPUT=$(bash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --entry \"First test entry\" 2>&1)\n\nif [[ -f \"$TEST_PROGRESS_FILE\" ]]; then\n pass \"T2a: progress.md created\"\nelse\n fail \"T2a: progress.md created\" \"file not found after --entry\"\nfi\n\nif grep -q \"# Progress Log\" \"$TEST_PROGRESS_FILE\" 2>/dev/null; then\n pass \"T2b: progress.md has header\"\nelse\n fail \"T2b: progress.md has header\" \"missing '# Progress Log' header\"\nfi\n\nTODAY=$(date +%Y-%m-%d)\nif grep -q \"## $TODAY\" \"$TEST_PROGRESS_FILE\" 2>/dev/null; then\n pass \"T2c: entry has today's date heading\"\nelse\n fail \"T2c: entry has today's date heading\" \"missing date $TODAY\"\nfi\n\nif grep -q \"First test entry\" \"$TEST_PROGRESS_FILE\" 2>/dev/null; then\n pass \"T2d: entry text is present\"\nelse\n fail \"T2d: entry text is present\" \"missing entry text\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T3: --entry appends without overwriting\"\n# ═══════════════════════════════════════════════════════════\n\nbash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --entry \"Second test entry\" 2>&1 >/dev/null\n\nif grep -q \"First test entry\" \"$TEST_PROGRESS_FILE\" 2>/dev/null; then\n pass \"T3a: first entry still present after second append\"\nelse\n fail \"T3a: first entry still present after second append\" \"first entry was overwritten\"\nfi\n\nif grep -q \"Second test entry\" \"$TEST_PROGRESS_FILE\" 2>/dev/null; then\n pass \"T3b: second entry is present\"\nelse\n fail \"T3b: second entry is present\" \"missing second entry text\"\nfi\n\nENTRY_COUNT=$(grep -c \"^## [0-9]\" \"$TEST_PROGRESS_FILE\" 2>/dev/null)\nif [[ \"$ENTRY_COUNT\" -eq 2 ]]; then\n pass \"T3c: two dated sections exist\"\nelse\n fail \"T3c: two dated sections exist\" \"found $ENTRY_COUNT sections\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T4: --synthesize replaces ## Progress section\"\n# ═══════════════════════════════════════════════════════════\n\n# First, manually add a Progress section to the artifact so we can test replacement\n# The artifact doesn't have one by default, so synthesize will insert it\nbash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --synthesize 2>&1 >/dev/null\n\nif grep -q \"## Progress\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T4a: ## Progress section exists in artifact after synthesize\"\nelse\n fail \"T4a: ## Progress section exists in artifact after synthesize\" \"section not found\"\nfi\n\nif grep -q \"First test entry\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T4b: synthesized content includes first entry\"\nelse\n fail \"T4b: synthesized content includes first entry\" \"content missing\"\nfi\n\nif grep -q \"Second test entry\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T4c: synthesized content includes second entry\"\nelse\n fail \"T4c: synthesized content includes second entry\" \"content missing\"\nfi\n\n# Now add a third entry and re-synthesize — should replace, not duplicate\nbash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --entry \"Third test entry\" 2>&1 >/dev/null\nbash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --synthesize 2>&1 >/dev/null\n\nPROGRESS_COUNT=$(grep -c \"## Progress\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null)\nif [[ \"$PROGRESS_COUNT\" -eq 1 ]]; then\n pass \"T4d: only one ## Progress section after re-synthesize\"\nelse\n fail \"T4d: only one ## Progress section after re-synthesize\" \"found $PROGRESS_COUNT sections\"\nfi\n\nif grep -q \"Third test entry\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T4e: third entry present in re-synthesized content\"\nelse\n fail \"T4e: third entry present in re-synthesized content\" \"content missing\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T5: --synthesize inserts ## Progress if missing\"\n# ═══════════════════════════════════════════════════════════\n\n# Revert the artifact to its original state (no ## Progress)\ngit checkout -- \"$TEST_ARTIFACT_FILE\" 2>/dev/null\n\n# Verify it doesn't have ## Progress\nif ! grep -q \"## Progress\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T5a: artifact has no ## Progress before synthesize\"\nelse\n fail \"T5a: artifact has no ## Progress before synthesize\" \"section already exists\"\nfi\n\nbash \"$PROGRESS_LOG\" --artifact-id \"$TEST_ARTIFACT_ID\" --synthesize 2>&1 >/dev/null\n\nif grep -q \"## Progress\" \"$TEST_ARTIFACT_FILE\" 2>/dev/null; then\n pass \"T5b: ## Progress inserted by synthesize\"\nelse\n fail \"T5b: ## Progress inserted by synthesize\" \"section not found after synthesize\"\nfi\n\n# Verify it's between Desired Outcomes and Scope Boundaries (for epics, after Goal / Objective)\n# Check that Progress comes before Scope Boundaries\nPROGRESS_LINE=$(grep -n \"## Progress\" \"$TEST_ARTIFACT_FILE\" | head -1 | cut -d: -f1)\nSCOPE_LINE=$(grep -n \"## Scope Boundaries\" \"$TEST_ARTIFACT_FILE\" | head -1 | cut -d: -f1)\nGOAL_LINE=$(grep -n \"## Goal / Objective\" \"$TEST_ARTIFACT_FILE\" | head -1 | cut -d: -f1)\n\nif [[ -n \"$PROGRESS_LINE\" && -n \"$SCOPE_LINE\" && \"$PROGRESS_LINE\" -lt \"$SCOPE_LINE\" ]]; then\n pass \"T5c: ## Progress appears before ## Scope Boundaries\"\nelse\n fail \"T5c: ## Progress appears before ## Scope Boundaries\" \"progress=$PROGRESS_LINE scope=$SCOPE_LINE\"\nfi\n\nif [[ -n \"$PROGRESS_LINE\" && -n \"$GOAL_LINE\" && \"$PROGRESS_LINE\" -gt \"$GOAL_LINE\" ]]; then\n pass \"T5d: ## Progress appears after ## Goal / Objective\"\nelse\n fail \"T5d: ## Progress appears after ## Goal / Objective\" \"progress=$PROGRESS_LINE goal=$GOAL_LINE\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"═══ T6: Template files contain ## Progress section\"\n# ═══════════════════════════════════════════════════════════\n\nEPIC_TEMPLATE=\"$REPO_ROOT/skills/swain-design/references/epic-template.md.template\"\nINIT_TEMPLATE=\"$REPO_ROOT/skills/swain-design/references/initiative-template.md.template\"\n\nif grep -q \"## Progress\" \"$EPIC_TEMPLATE\" 2>/dev/null; then\n pass \"T6a: epic template has ## Progress\"\nelse\n fail \"T6a: epic template has ## Progress\" \"section not found\"\nfi\n\nif grep -q \"## Progress\" \"$INIT_TEMPLATE\" 2>/dev/null; then\n pass \"T6b: initiative template has ## Progress\"\nelse\n fail \"T6b: initiative template has ## Progress\" \"section not found\"\nfi\n\n# Verify Progress comes before Scope Boundaries in templates\nEPIC_PROG=$(grep -n \"## Progress\" \"$EPIC_TEMPLATE\" | head -1 | cut -d: -f1)\nEPIC_SCOPE=$(grep -n \"## Scope Boundaries\" \"$EPIC_TEMPLATE\" | head -1 | cut -d: -f1)\nif [[ -n \"$EPIC_PROG\" && -n \"$EPIC_SCOPE\" && \"$EPIC_PROG\" -lt \"$EPIC_SCOPE\" ]]; then\n pass \"T6c: epic template: Progress before Scope Boundaries\"\nelse\n fail \"T6c: epic template: Progress before Scope Boundaries\" \"prog=$EPIC_PROG scope=$EPIC_SCOPE\"\nfi\n\nINIT_PROG=$(grep -n \"## Progress\" \"$INIT_TEMPLATE\" | head -1 | cut -d: -f1)\nINIT_SCOPE=$(grep -n \"## Scope Boundaries\" \"$INIT_TEMPLATE\" | head -1 | cut -d: -f1)\nif [[ -n \"$INIT_PROG\" && -n \"$INIT_SCOPE\" && \"$INIT_PROG\" -lt \"$INIT_SCOPE\" ]]; then\n pass \"T6d: initiative template: Progress before Scope Boundaries\"\nelse\n fail \"T6d: initiative template: Progress before Scope Boundaries\" \"prog=$INIT_PROG scope=$INIT_SCOPE\"\nfi\n\n# ═══════════════════════════════════════════════════════════\necho \"\"\necho \"Results: $PASS passed, $FAIL failed\"\n[[ $FAIL -eq 0 ]] && exit 0 || exit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":10659,"content_sha256":"0fd41af5c68d930bcd9ea873e99da0a97832926bc1152f7e5878659bc892ad36"},{"filename":"tests/test-session-close-integration.sh","content":"#!/usr/bin/env bash\n# test-session-close-integration.sh — SPEC-205\n# Verify that swain-session SKILL.md session close section invokes\n# the digest and progress-log scripts.\n\nset -euo pipefail\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nSKILL_FILE=\"$SCRIPT_DIR/../SKILL.md\"\n\nPASS=0\nFAIL=0\n\nassert() {\n local label=\"$1\" exit_code=\"$2\"\n if [ \"$exit_code\" -eq 0 ]; then\n echo \" PASS: $label\"\n PASS=$((PASS + 1))\n else\n echo \" FAIL: $label\"\n FAIL=$((FAIL + 1))\n fi\n}\n\n# Extract the session close section (between \"### Session close\" and the next \"### \")\nCLOSE_SECTION=$(sed -n '/^### Session close$/,/^### /p' \"$SKILL_FILE\")\n\necho \"=== T1: Session close section references digest script\"\necho \"$CLOSE_SECTION\" | grep -q \"swain-session-digest\" && T1=0 || T1=1\nassert \"T1a: mentions swain-session-digest\" \"$T1\"\n\necho \"=== T2: Session close section references progress-log script\"\necho \"$CLOSE_SECTION\" | grep -q \"swain-progress-log\" && T2=0 || T2=1\nassert \"T2a: mentions swain-progress-log\" \"$T2\"\n\necho \"=== T3: Digest runs before progress-log\"\nDIGEST_LINE=$(echo \"$CLOSE_SECTION\" | grep -n \"swain-session-digest\" | head -1 | cut -d: -f1 || true)\nPROGRESS_LINE=$(echo \"$CLOSE_SECTION\" | grep -n \"swain-progress-log\" | head -1 | cut -d: -f1 || true)\nif [ -n \"$DIGEST_LINE\" ] && [ -n \"$PROGRESS_LINE\" ] && [ \"$DIGEST_LINE\" -lt \"$PROGRESS_LINE\" ]; then\n assert \"T3a: digest appears before progress-log\" \"0\"\nelse\n assert \"T3a: digest appears before progress-log\" \"1\"\nfi\n\necho \"=== T4: Progress-log uses --digest flag\"\necho \"$CLOSE_SECTION\" | grep -q \"progress-log.*--digest\\|progress-log.sh.*--digest\" && T4=0 || T4=1\nassert \"T4a: progress-log invoked with --digest\" \"$T4\"\n\necho \"\"\necho \"Results: $PASS passed, $FAIL failed\"\n[ \"$FAIL\" -eq 0 ] || exit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":1780,"content_sha256":"157c0d14b8bc4d6cca39497647a8c5d503faf948457f6e7763764f2c44eb3b67"},{"filename":"tests/test-session-digest.sh","content":"#!/usr/bin/env bash\n# test-session-digest.sh — tests for swain-session-digest.sh (SPEC-199)\n# Verifies session digest generation and JSONL output\n\nset -euo pipefail\n\nREPO_ROOT=\"$(cd \"$(dirname \"$0\")/../../..\" && pwd)\"\nSCRIPT=\"$REPO_ROOT/.agents/bin/swain-session-digest.sh\"\n\nPASS=0\nFAIL=0\nTOTAL=0\n\nassert() {\n local desc=\"$1\"\n local result=\"$2\"\n TOTAL=$((TOTAL + 1))\n if [[ \"$result\" == \"0\" ]]; then\n PASS=$((PASS + 1))\n echo \" PASS: $desc\"\n else\n FAIL=$((FAIL + 1))\n echo \" FAIL: $desc\"\n fi\n}\n\n# --- Test 1: Script exists and is executable ---\necho \"Test 1: swain-session-digest.sh exists and is executable\"\nassert \"script exists\" \"$([ -f \"$SCRIPT\" ] && echo 0 || echo 1)\"\nassert \"script is executable\" \"$([ -x \"$SCRIPT\" ] && echo 0 || echo 1)\"\n\n# --- Test 2: Missing required args exits with code 1 ---\necho \"Test 2: missing required args exits with code 1\"\nresult=$(bash \"$SCRIPT\" 2>/dev/null && echo 0 || echo $?)\nassert \"no args exits non-zero\" \"$([ \"$result\" != \"0\" ] && echo 0 || echo 1)\"\n\nresult=$(bash \"$SCRIPT\" --session-id test-123 2>/dev/null && echo 0 || echo $?)\nassert \"missing --start-time exits non-zero\" \"$([ \"$result\" != \"0\" ] && echo 0 || echo 1)\"\n\nresult=$(bash \"$SCRIPT\" --start-time 2026-01-01T00:00:00Z 2>/dev/null && echo 0 || echo $?)\nassert \"missing --session-id exits non-zero\" \"$([ \"$result\" != \"0\" ] && echo 0 || echo 1)\"\n\n# --- Test 3: Produces valid JSONL with required args ---\necho \"Test 3: produces valid JSONL with --session-id and --start-time\"\nTMPDIR_TEST=$(mktemp -d)\ntrap 'rm -rf \"$TMPDIR_TEST\"' EXIT\n\n# Use a recent timestamp to get some commits from the real repo\nONE_HOUR_AGO=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)\nOUTPUT_FILE=\"$TMPDIR_TEST/session-log.jsonl\"\n\nif bash \"$SCRIPT\" \\\n --session-id \"test-session-001\" \\\n --start-time \"$ONE_HOUR_AGO\" \\\n --repo-root \"$REPO_ROOT\" \\\n --output \"$OUTPUT_FILE\" 2>/dev/null; then\n assert \"script exits 0 with valid args\" \"0\"\nelse\n assert \"script exits 0 with valid args\" \"1\"\nfi\n\n# Check output file exists and has content\nassert \"output file exists\" \"$([ -f \"$OUTPUT_FILE\" ] && echo 0 || echo 1)\"\nif [ -f \"$OUTPUT_FILE\" ]; then\n LINE=$(head -1 \"$OUTPUT_FILE\")\n # Validate it's valid JSON using python\n echo \"$LINE\" | uv run python3 -c \"import sys, json; json.loads(sys.stdin.read())\" 2>/dev/null\n assert \"output is valid JSON\" \"$?\"\nelse\n assert \"output is valid JSON\" \"1\"\nfi\n\n# --- Test 4: JSONL contains required fields ---\necho \"Test 4: JSONL contains required fields\"\nif [ -f \"$OUTPUT_FILE\" ]; then\n LINE=$(head -1 \"$OUTPUT_FILE\")\n for field in session_id timestamp artifacts_touched commits tasks_closed session_summary; do\n echo \"$LINE\" | uv run python3 -c \"import sys, json; d=json.loads(sys.stdin.read()); assert '$field' in d\" 2>/dev/null\n assert \"contains field: $field\" \"$?\"\n done\n # Verify session_id matches what we passed\n sid=$(echo \"$LINE\" | uv run python3 -c \"import sys, json; print(json.loads(sys.stdin.read())['session_id'])\" 2>/dev/null)\n assert \"session_id matches input\" \"$([ \"$sid\" = \"test-session-001\" ] && echo 0 || echo 1)\"\nelse\n for field in session_id timestamp artifacts_touched commits tasks_closed session_summary; do\n assert \"contains field: $field\" \"1\"\n done\n assert \"session_id matches input\" \"1\"\nfi\n\n# --- Test 5: With --focus, focus_lane is populated ---\necho \"Test 5: --focus populates focus_lane\"\nOUTPUT_FILE2=\"$TMPDIR_TEST/session-log2.jsonl\"\nif bash \"$SCRIPT\" \\\n --session-id \"test-session-002\" \\\n --start-time \"$ONE_HOUR_AGO\" \\\n --focus \"INITIATIVE-019\" \\\n --repo-root \"$REPO_ROOT\" \\\n --output \"$OUTPUT_FILE2\" 2>/dev/null; then\n LINE=$(head -1 \"$OUTPUT_FILE2\")\n focus=$(echo \"$LINE\" | uv run python3 -c \"import sys, json; print(json.loads(sys.stdin.read())['focus_lane'])\" 2>/dev/null)\n assert \"focus_lane is INITIATIVE-019\" \"$([ \"$focus\" = \"INITIATIVE-019\" ] && echo 0 || echo 1)\"\nelse\n assert \"focus_lane is INITIATIVE-019\" \"1\"\nfi\n\n# --- Test 6: Without --focus, focus_lane is null ---\necho \"Test 6: without --focus, focus_lane is null\"\nif [ -f \"$OUTPUT_FILE\" ]; then\n LINE=$(head -1 \"$OUTPUT_FILE\")\n focus=$(echo \"$LINE\" | uv run python3 -c \"import sys, json; print(json.loads(sys.stdin.read()).get('focus_lane'))\" 2>/dev/null)\n assert \"focus_lane is None\" \"$([ \"$focus\" = \"None\" ] && echo 0 || echo 1)\"\nelse\n assert \"focus_lane is None\" \"1\"\nfi\n\n# --- Test 7: Output is appended, not overwritten ---\necho \"Test 7: output is appended, not overwritten\"\nOUTPUT_FILE3=\"$TMPDIR_TEST/session-log3.jsonl\"\nbash \"$SCRIPT\" \\\n --session-id \"test-session-003a\" \\\n --start-time \"$ONE_HOUR_AGO\" \\\n --repo-root \"$REPO_ROOT\" \\\n --output \"$OUTPUT_FILE3\" 2>/dev/null || true\nbash \"$SCRIPT\" \\\n --session-id \"test-session-003b\" \\\n --start-time \"$ONE_HOUR_AGO\" \\\n --repo-root \"$REPO_ROOT\" \\\n --output \"$OUTPUT_FILE3\" 2>/dev/null || true\nif [ -f \"$OUTPUT_FILE3\" ]; then\n line_count=$(wc -l \u003c \"$OUTPUT_FILE3\" | tr -d ' ')\n assert \"file has 2 lines after 2 runs\" \"$([ \"$line_count\" = \"2\" ] && echo 0 || echo 1)\"\nelse\n assert \"file has 2 lines after 2 runs\" \"1\"\nfi\n\n# --- Test 8: Handles empty sessions gracefully ---\necho \"Test 8: handles empty sessions (far future start-time)\"\nOUTPUT_FILE4=\"$TMPDIR_TEST/session-log4.jsonl\"\nif bash \"$SCRIPT\" \\\n --session-id \"test-session-004\" \\\n --start-time \"2099-01-01T00:00:00Z\" \\\n --repo-root \"$REPO_ROOT\" \\\n --output \"$OUTPUT_FILE4\" 2>/dev/null; then\n assert \"exits 0 for empty session\" \"0\"\n LINE=$(head -1 \"$OUTPUT_FILE4\")\n commits=$(echo \"$LINE\" | uv run python3 -c \"import sys, json; print(json.loads(sys.stdin.read())['commits'])\" 2>/dev/null)\n assert \"commits is 0 for empty session\" \"$([ \"$commits\" = \"0\" ] && echo 0 || echo 1)\"\nelse\n assert \"exits 0 for empty session\" \"1\"\n assert \"commits is 0 for empty session\" \"1\"\nfi\n\n# --- Summary ---\necho \"\"\necho \"Results: $PASS/$TOTAL passed, $FAIL failed\"\nif [[ $FAIL -gt 0 ]]; then\n exit 1\nfi\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":5983,"content_sha256":"0a276148547660444e8328fef4908779ba61007096fd6699fc4d96086bbc5f32"},{"filename":"tests/test-tab-name.sh","content":"#!/usr/bin/env bash\n# test-tab-name.sh — Acceptance tests for swain-tab-name.sh (SPEC-056)\n#\n# Runs in an isolated tmux server to avoid interfering with live sessions.\n# Each test gets a clean tmux session. Requires: tmux, git, jq.\n#\n# Usage: bash skills/swain-session/tests/test-tab-name.sh\n\nset +e\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nTAB_NAME=\"$(cd \"$SCRIPT_DIR/..\" && pwd)/scripts/swain-tab-name.sh\"\n# Use --git-common-dir to find the real repo root (not the worktree root)\nGIT_COMMON=\"$(git rev-parse --git-common-dir 2>/dev/null)\"\nREPO_ROOT=\"$(cd \"$GIT_COMMON/..\" && pwd 2>/dev/null)\"\n\n# Isolated tmux server\nTMUX_SOCK=\"/tmp/swain-test-tmux-$\"\nT=\"tmux -S $TMUX_SOCK\"\n\nPASS=0\nFAIL=0\nSKIP=0\n\npass() { echo \" PASS: $1\"; ((PASS++)); }\nfail() { echo \" FAIL: $1 — $2\"; ((FAIL++)); }\nskip() { echo \" SKIP: $1 — $2\"; ((SKIP++)); }\n\ncleanup() {\n $T kill-server 2>/dev/null\n rm -f \"$TMUX_SOCK\"\n}\ntrap cleanup EXIT\n\nstart_session() {\n local name=\"${1:-test}\"\n local dir=\"${2:-$REPO_ROOT}\"\n $T new-session -d -s \"$name\" -c \"$dir\" 2>/dev/null\n}\n\n# Run the script inside the test tmux server via run-shell\n# This ensures TMUX env var is properly set by the tmux server\nrun() {\n local session=\"${1:-test}\"\n shift\n # Set TMUX env to point at the test server so the script's tmux commands\n # target the right server/session. Format: socket_path,server_pid,pane_index\n local pane_id\n pane_id=$($T list-panes -t \"$session\" -F '#{pane_id}' 2>/dev/null | head -1 | tr -d '%')\n $T run-shell -t \"$session\" \"TMUX='$TMUX_SOCK,0,${pane_id:-0}' bash '$TAB_NAME' $*\" 2>/dev/null\n}\n\n# Helpers to query test tmux state\n# NOTE: display-message -p requires an attached client, which test servers lack.\n# Use list-sessions/list-windows format strings instead.\n# session_name takes a session index (0-based) since names change after rename\nsession_name() { $T list-sessions -F '#{session_name}' 2>/dev/null | sed -n \"${1:-1}p\"; }\nwindow_name() { $T list-windows -F '#{window_name}' 2>/dev/null | head -1; }\npane_opt() { $T show-options -pqv \"$1\" 2>/dev/null; }\nwindow_hook() { $T show-hooks -w pane-focus-in 2>/dev/null; }\nglobal_hook() { $T show-hooks -g pane-focus-in 2>/dev/null; }\n\nWORKTREE_DIR=\"$REPO_ROOT/.worktrees/copper-meadow-lantern\"\nBRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)\nEXPECTED=\"swain @ $BRANCH\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n WT_BRANCH=$(git -C \"$WORKTREE_DIR\" rev-parse --abbrev-ref HEAD 2>/dev/null)\n EXPECTED_WT=\"swain @ $WT_BRANCH\"\nfi\n\necho \"=== SPEC-056 Acceptance Tests ===\"\necho \"Script: $TAB_NAME\"\necho \"Repo: $REPO_ROOT\"\necho \"Branch: $BRANCH\"\n[[ -d \"$WORKTREE_DIR\" ]] && echo \"Worktree: $WORKTREE_DIR ($WT_BRANCH)\"\necho \"\"\n\n# ─── AC1: Session start — --auto renames session and window ───\necho \"--- AC1: Session start renames session + window ---\"\ncleanup; start_session \"default\" \"$REPO_ROOT\"\nrun \"default\" --auto\ns=$(session_name); w=$(window_name)\nif [[ \"$s\" == \"$EXPECTED\" && \"$w\" == \"$EXPECTED\" ]]; then\n pass \"AC1: session='$s', window='$w'\"\nelse\n fail \"AC1\" \"expected '$EXPECTED', got session='$s' window='$w'\"\nfi\n\n# ─── AC2: Hook installed per-window with absolute path ───\necho \"--- AC2: Per-window hook installed ---\"\nhook=$(window_hook)\nif [[ \"$hook\" == *\"$TAB_NAME\"* && \"$hook\" == *\"pane-focus-in\"* ]]; then\n pass \"AC2: per-window hook contains script path\"\nelse\n fail \"AC2\" \"hook='$hook'\"\nfi\ngh=$(global_hook)\nif [[ -z \"$gh\" || \"$gh\" != *\"$TAB_NAME\"* ]]; then\n pass \"AC2b: no global hook (per-window only)\"\nelse\n fail \"AC2b\" \"global hook found: '$gh'\"\nfi\n\n# ─── AC3: @swain_path set on pane ───\necho \"--- AC3: @swain_path set on pane ---\"\nsp=$(pane_opt \"@swain_path\")\nif [[ -n \"$sp\" && -d \"$sp\" ]]; then\n pass \"AC3: @swain_path='$sp'\"\nelse\n fail \"AC3\" \"@swain_path='$sp'\"\nfi\n\n# ─── AC4-5: Shell pane switching ───\necho \"--- AC4-5: Shell pane switching ---\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n # Split a new pane cd'd into the worktree\n $T split-window -t \"default\" -h -c \"$WORKTREE_DIR\" 2>/dev/null\n sleep 0.3\n # The pane-focus-in hook should have fired — but run-shell to be sure\n run \"default\" --auto\n s=$(session_name)\n if [[ \"$s\" == \"$EXPECTED_WT\" ]]; then\n pass \"AC4: worktree pane, session='$s'\"\n else\n fail \"AC4\" \"expected '$EXPECTED_WT', got session='$s'\"\n fi\n # Switch back to pane 0 (main repo)\n $T select-pane -t \"default:0.0\" 2>/dev/null\n sleep 0.3\n run \"default\" --auto\n s=$(session_name)\n if [[ \"$s\" == \"$EXPECTED\" ]]; then\n pass \"AC5: main pane, session='$s'\"\n else\n fail \"AC5\" \"expected '$EXPECTED', got session='$s'\"\n fi\nelse\n skip \"AC4-5\" \"no worktree at $WORKTREE_DIR\"\nfi\n\n# ─── AC6: --path sets @swain_path and renames ───\necho \"--- AC6: --path flag sets @swain_path ---\"\ncleanup; start_session \"pathtest\" \"$REPO_ROOT\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n run \"pathtest\" --path \"$WORKTREE_DIR\" --auto\n s=$(session_name)\n sp=$(pane_opt \"@swain_path\")\n if [[ \"$s\" == \"$EXPECTED_WT\" ]]; then\n pass \"AC6a: --path renamed session to '$s'\"\n else\n fail \"AC6a\" \"expected '$EXPECTED_WT', got '$s'\"\n fi\n if [[ \"$sp\" == \"$WORKTREE_DIR\" ]]; then\n pass \"AC6b: @swain_path='$sp'\"\n else\n fail \"AC6b\" \"expected '$WORKTREE_DIR', got '$sp'\"\n fi\nelse\n skip \"AC6\" \"no worktree at $WORKTREE_DIR\"\nfi\n\n# ─── AC7: Hook reads @swain_path on refocus ───\necho \"--- AC7: Hook reads @swain_path on pane refocus ---\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n # After AC6, @swain_path is set. Split a main-repo pane.\n $T split-window -t \"pathtest\" -h -c \"$REPO_ROOT\" 2>/dev/null\n sleep 0.3\n run \"pathtest\" --auto\n s=$(session_name)\n if [[ \"$s\" == \"$EXPECTED\" ]]; then\n pass \"AC7a: shell pane shows '$s'\"\n else\n fail \"AC7a\" \"expected '$EXPECTED', got '$s'\"\n fi\n # Switch back to pane 0 (agent pane with @swain_path)\n $T select-pane -t \"pathtest:0.0\" 2>/dev/null\n sleep 0.3\n run \"pathtest\" --auto\n s=$(session_name)\n if [[ \"$s\" == \"$EXPECTED_WT\" ]]; then\n pass \"AC7b: agent pane refocus shows '$s' (from @swain_path)\"\n else\n fail \"AC7b\" \"expected '$EXPECTED_WT', got '$s'\"\n fi\nelse\n skip \"AC7\" \"no worktree at $WORKTREE_DIR\"\nfi\n\n# ─── AC8: Agent exits worktree via --path ───\necho \"--- AC8: --path back to main repo ---\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n cleanup; start_session \"exittest\" \"$REPO_ROOT\"\n run \"exittest\" --path \"$WORKTREE_DIR\" --auto\n run \"exittest\" --path \"$REPO_ROOT\" --auto\n s=$(session_name)\n sp=$(pane_opt \"@swain_path\")\n EXPECTED_MAIN=\"swain @ main\"\n if [[ \"$s\" == \"$EXPECTED_MAIN\" && \"$sp\" == \"$REPO_ROOT\" ]]; then\n pass \"AC8: exited worktree, session='$s', @swain_path='$sp'\"\n else\n fail \"AC8\" \"session='$s', @swain_path='$sp'\"\n fi\nelse\n skip \"AC8\" \"no worktree at $WORKTREE_DIR\"\nfi\n\n# ─── AC9-10: Cross-session isolation ───\necho \"--- AC9-10: Cross-session isolation ---\"\ncleanup\nstart_session \"projectA\" \"$REPO_ROOT\"\nrun \"projectA\" --auto\n$T new-session -d -s \"projectB\" -c \"/tmp\" 2>/dev/null\n# Sessions are sorted alphabetically by tmux; after rename projectA becomes \"swain @ ...\"\n# which sorts after \"projectB\"\nsA=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -v projectB | head -1)\nsB=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep projectB | head -1)\nhB=\"\" # projectB never had --auto run, so no hook\nif [[ \"$sA\" == \"$EXPECTED\" && \"$sB\" == \"projectB\" ]]; then\n pass \"AC9: sessions independent — A='$sA', B='$sB'\"\nelse\n fail \"AC9\" \"A='$sA', B='$sB'\"\nfi\nif [[ -z \"$hB\" || \"$hB\" != *\"$TAB_NAME\"* ]]; then\n pass \"AC10: projectB has no swain hook\"\nelse\n fail \"AC10\" \"projectB has hook: '$hB'\"\nfi\n\n# ─── AC11: Reset clears everything ───\necho \"--- AC11: Reset ---\"\ncleanup; start_session \"resettest\" \"$REPO_ROOT\"\nrun \"resettest\" --auto\nrun \"resettest\" --reset\nhook=$(window_hook)\nsp=$(pane_opt \"@swain_path\")\nif [[ -z \"$hook\" || \"$hook\" != *\"$TAB_NAME\"* ]]; then\n pass \"AC11a: hook removed after reset\"\nelse\n fail \"AC11a\" \"hook still present: '$hook'\"\nfi\nif [[ -z \"$sp\" ]]; then\n pass \"AC11b: @swain_path cleared after reset\"\nelse\n fail \"AC11b\" \"@swain_path='$sp'\"\nfi\n\n# ─── AC12: Non-git fallback ───\necho \"--- AC12: Non-git fallback ---\"\ncleanup; start_session \"nogit\" \"/tmp\"\nrun \"nogit\" --path /tmp --auto\ns=$(session_name)\nif [[ \"$s\" == \"unknown @ no-branch\" ]]; then\n pass \"AC12: non-git fallback, session='$s'\"\nelse\n fail \"AC12\" \"session='$s'\"\nfi\n\n# ─── AC13: Worktree project name resolution ───\necho \"--- AC13: Worktree project name via --git-common-dir ---\"\nif [[ -d \"$WORKTREE_DIR\" ]]; then\n cleanup; start_session \"wt\" \"$REPO_ROOT\"\n run \"wt\" --path \"$WORKTREE_DIR\" --auto\n s=$(session_name)\n if [[ \"$s\" == \"swain @ \"* ]]; then\n pass \"AC13: project='swain' from worktree (session='$s')\"\n else\n fail \"AC13\" \"session='$s' (expected 'swain @ ...')\"\n fi\nelse\n skip \"AC13\" \"no worktree\"\nfi\n\n# ─── AC14: Idempotent hook install ───\necho \"--- AC14: Idempotent hook ---\"\ncleanup; start_session \"idem\" \"$REPO_ROOT\"\nrun \"idem\" --auto\nrun \"idem\" --auto\nhook_count=$($T show-hooks -w -t \"idem\" pane-focus-in 2>/dev/null | wc -l | tr -d ' ')\nif [[ \"$hook_count\" -le 1 ]]; then\n pass \"AC14: idempotent ($hook_count hook entries)\"\nelse\n fail \"AC14\" \"$hook_count hook entries\"\nfi\n\n# ─── AC15: TMUX_PANE targets calling pane's session (gh#116) ───\n# Regression: subprocess invoked from session alpha must rename alpha, not\n# whichever session display-message would resolve to. We create two sessions\n# and stage things so that without the fix, display-message (resolving to the\n# \"current/focused\" session in the test server) would target the *other*\n# session. With the fix, TMUX_PANE pins resolution to the calling pane's\n# session regardless of what display-message returns.\necho \"--- AC15: TMUX_PANE targets calling pane's session (gh#116) ---\"\ncleanup\n# Create beta first so it's the most-recently-created/focused session in the\n# test server — this mirrors the bug's \"operator is looking at another session\"\n# condition. Then create alpha as the caller.\n$T new-session -d -s \"beta\" -c \"/tmp\" 2>/dev/null\nstart_session \"alpha\" \"$REPO_ROOT\"\nalpha_pane=$($T list-panes -t \"alpha\" -F '#{pane_id}' 2>/dev/null | head -1)\nbeta_pane=$($T list-panes -t \"beta\" -F '#{pane_id}' 2>/dev/null | head -1)\n# Force beta to be the \"current\" client target by attaching via switch-client.\n# Test servers don't have attached clients, but we can bias display-message\n# resolution by leaving beta as the last-touched session. Record pre-state:\nsessions_before=$($T list-sessions -F '#{session_name}' 2>/dev/null | sort | tr '\\n' ',')\n# Invoke script with TMUX_PANE set to alpha's pane. SWAIN_HOOK_SESSION left\n# unset so it exercises the TMUX_PANE branch specifically.\nunset SWAIN_HOOK_SESSION\nTMUX=\"$TMUX_SOCK,0,0\" TMUX_PANE=\"$alpha_pane\" bash \"$TAB_NAME\" --auto >/dev/null 2>&1\n# Verify alpha was renamed (to \"$EXPECTED\"), beta is untouched.\nbeta_after=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cE '^beta

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

)\nexpected_after=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cFx \"$EXPECTED\")\nalpha_still=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cE '^alpha

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

)\nif [[ \"$beta_after\" == \"1\" && \"$expected_after\" == \"1\" && \"$alpha_still\" == \"0\" ]]; then\n pass \"AC15: TMUX_PANE pinned rename to alpha (alpha→'$EXPECTED'); beta untouched\"\nelse\n fail \"AC15\" \"beta-count=$beta_after expected-count=$expected_after alpha-count=$alpha_still (pre: $sessions_before)\"\nfi\n\n# ─── AC16: TMUX_PANE pointing to beta renames beta only (gh#116 mirror) ───\necho \"--- AC16: TMUX_PANE targets beta when pane is beta's (gh#116) ---\"\n# Complementary test: same setup, but TMUX_PANE points to beta — beta should\n# be renamed, alpha should remain. Catches a fix that hard-codes \"first session.\"\ncleanup\nstart_session \"alpha\" \"$REPO_ROOT\"\n$T new-session -d -s \"beta\" -c \"$REPO_ROOT\" 2>/dev/null\nbeta_pane=$($T list-panes -t \"beta\" -F '#{pane_id}' 2>/dev/null | head -1)\nunset SWAIN_HOOK_SESSION\nTMUX=\"$TMUX_SOCK,0,0\" TMUX_PANE=\"$beta_pane\" bash \"$TAB_NAME\" --auto >/dev/null 2>&1\n# beta should have been renamed to \"$EXPECTED\"; alpha should still be \"alpha\"\nalpha_still=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cE '^alpha

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

)\nbeta_still=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cE '^beta

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…

)\nexpected_count=$($T list-sessions -F '#{session_name}' 2>/dev/null | grep -cFx \"$EXPECTED\")\nif [[ \"$alpha_still\" == \"1\" && \"$beta_still\" == \"0\" && \"$expected_count\" == \"1\" ]]; then\n pass \"AC16: TMUX_PANE targeted beta (→'$EXPECTED'); alpha untouched\"\nelse\n fail \"AC16\" \"alpha-count=$alpha_still beta-count=$beta_still expected-count=$expected_count\"\nfi\n\n# ─── Summary ───\necho \"\"\necho \"=== Results: $PASS passed, $FAIL failed, $SKIP skipped ===\"\nexit $FAIL\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":12819,"content_sha256":"16b1fec80f54d4ab8a4890abc0ee919c01c1b574f494fc0338c22b74ee4ecac3"}],"content_json":{"type":"doc","content":[{"type":"paragraph","content":[{"text":"\u003c!-- swain-model-hint: haiku, effort: low -->","type":"text"}]},{"type":"heading","attrs":{"level":1},"content":[{"text":"Session","type":"text"}]},{"type":"paragraph","content":[{"text":"Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Auto-run behavior","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Restore tab name","type":"text","marks":[{"type":"strong"}]},{"text":" — run the tab-naming script","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Load preferences","type":"text","marks":[{"type":"strong"}]},{"text":" — read session.json and apply any stored preferences","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Show context bookmark","type":"text","marks":[{"type":"strong"}]},{"text":" — if a previous session left a context note, display it","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"When invoked manually, the user can change preferences or bookmark context.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Session purpose text","type":"text"}]},{"type":"paragraph","content":[{"text":"When the operator launches with free text (e.g., ","type":"text"},{"text":"swain new bug about timestamps","type":"text","marks":[{"type":"code_inline"}]},{"text":"), the launcher exports ","type":"text"},{"text":"SWAIN_PURPOSE","type":"text","marks":[{"type":"code_inline"}]},{"text":" and — for runtimes that accept an initial prompt — also passes it inline as ","type":"text"},{"text":"/swain-session Session purpose: new bug about timestamps","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The launcher is responsible for choosing the checkout that will own that bookmark:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the operator starts from the main checkout, the launcher opens a new worktree first and only then passes the session purpose.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the operator starts inside a linked worktree that already has a bookmark, the launcher should steer them to resume/finish that worktree or open a different worktree before reusing the purpose text.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The greeting script (","type":"text"},{"text":"swain-session-greeting.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":") reads ","type":"text"},{"text":"$SWAIN_PURPOSE","type":"text","marks":[{"type":"code_inline"}]},{"text":" and writes the bookmark deterministically (SPEC-297). The greeting JSON exposes the captured text as the ","type":"text"},{"text":"purpose","type":"text","marks":[{"type":"code_inline"}]},{"text":" field.","type":"text"}]},{"type":"paragraph","content":[{"text":"When the greeting JSON's ","type":"text"},{"text":"purpose","type":"text","marks":[{"type":"code_inline"}]},{"text":" field is non-null:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Display it to the operator: ","type":"text"},{"text":"**Session purpose:** \u003ctext>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do not re-parse the initial prompt or call ","type":"text"},{"text":"swain-bookmark.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" yourself — the greeting already did both. The inline prompt text is for display context only; the env var is the source of truth.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Preflight","type":"text"}]},{"type":"paragraph","content":[{"text":"Before any step, run the preflight script to gather all session state in a single pass. This replaces the old subprocess chain (greeting → bootstrap → tab-name) with one read-only script.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nPREFLIGHT_SCRIPT=\"$(find \"$REPO_ROOT\" -path '*/swain-session/scripts/swain-session-preflight.sh' -print -quit 2>/dev/null)\"\nPREFLIGHT_JSON=$( bash \"$PREFLIGHT_SCRIPT\" --repo-root \"$REPO_ROOT\" 2>/dev/null )\necho \"$PREFLIGHT_JSON\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Store ","type":"text"},{"text":"PREFLIGHT_JSON","type":"text","marks":[{"type":"code_inline"}]},{"text":" for use in all steps below. Every decision references a field from this JSON — do not run additional check commands unless performing a mutation.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 1 — Fast Greeting (SPEC-194)","type":"text"}]},{"type":"paragraph","content":[{"text":"Run the greeting script. It calls the preflight internally and applies lightweight mutations (tab naming, lock cleanup, .agents dir creation). It does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" invoke specgraph, GitHub API, or the full status dashboard.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-greeting.sh\" --json","type":"text"}]},{"type":"paragraph","content":[{"text":"The greeting emits structured JSON:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"greeting\": true,\n \"branch\": \"trunk\",\n \"dirty\": false,\n \"isolated\": false,\n \"bookmark\": \"Left off implementing the bootstrap script\",\n \"focus\": \"VISION-001\",\n \"tab\": \"project @ branch\",\n \"warnings\": []\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"The preflight JSON also includes previous session state under ","type":"text"},{"text":"prev_session","type":"text","marks":[{"type":"code_inline"}]},{"text":", which eliminates the need for a separate ","type":"text"},{"text":"swain-session-state.sh resume","type":"text","marks":[{"type":"code_inline"}]},{"text":" call:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"prev_session\": {\n \"exists\": true,\n \"status\": \"closed\",\n \"session_id\": \"session-20260404-215204-9576\",\n \"focus_lane\": \"INITIATIVE-002\",\n \"phase\": \"closed\",\n \"start_time\": \"2026-04-05T01:52:04Z\",\n \"end_time\": \"2026-04-06T04:10:09Z\",\n \"decisions_made\": 0,\n \"walkaway\": \"Reviewed and fixed SPIKE-058\"\n }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"After receiving the greeting JSON:","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Present the greeting to the operator — branch, dirty state, bookmark (if any), focus lane (if any), and warnings.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"prev_session.exists","type":"text","marks":[{"type":"code_inline"}]},{"text":" is true, display the previous session context (from the preflight JSON) so the operator can decide whether to continue or start fresh.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"isolated","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"false","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the operator has not started work yet, ","type":"text"},{"text":"do not create a worktree now","type":"text","marks":[{"type":"strong"}]},{"text":" — worktree creation is handled by bin/swain pre-launch (SPEC-245). If a worktree name is needed for reference, generate it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash \"$REPO_ROOT/.agents/bin/swain-worktree-name.sh\" \"context\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Then re-run the greeting with ","type":"text"},{"text":"--path","type":"text","marks":[{"type":"code_inline"}]},{"text":" to refresh tab name and context:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash \"$REPO_ROOT/.agents/bin/swain-session-greeting.sh\" --path \"$(pwd)\" --json","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If ","type":"text"},{"text":"bookmark","type":"text","marks":[{"type":"code_inline"}]},{"text":" is not null, display it:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Resuming session","type":"text","marks":[{"type":"strong"}]},{"text":" — Last time: {bookmark}","type":"text"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The session is now ready for work. The full status dashboard is available on-demand (see ","type":"text"},{"text":"Status Dashboard","type":"text","marks":[{"type":"link","attrs":{"href":"#status-dashboard-spec-122","title":null}}]},{"text":").","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If ","type":"text","marks":[{"type":"strong"}]},{"text":"$TMUX","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is NOT set","type":"text","marks":[{"type":"strong"}]},{"text":" (detected by absence of ","type":"text"},{"text":"tab","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the JSON), check whether tmux is installed:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tmux not installed:","type":"text","marks":[{"type":"strong"}]},{"text":" Offer to install it (","type":"text"},{"text":"brew install tmux","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"tmux installed but not in a session:","type":"text","marks":[{"type":"strong"}]},{"text":" Show: ","type":"text"},{"text":"[note] Not in a tmux session — session tab and pane features unavailable","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"The operator can say \"exit worktree\" or \"back to main\" at any time — this ends the session. bin/swain handles worktree cleanup after the runtime exits (SPEC-245).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Worktree / branch changes (agent-agnostic)","type":"text"}]},{"type":"paragraph","content":[{"text":"When an agent enters a worktree or switches branches, re-run the bootstrap with ","type":"text"},{"text":"--path","type":"text","marks":[{"type":"code_inline"}]},{"text":" to update the tab name:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-bootstrap.sh\" --path \"$NEW_WORKDIR\" --skip-worktree --auto","type":"text"}]},{"type":"paragraph","content":[{"text":"This is agent-agnostic — works in Claude Code, opencode, gemini cli, codex, copilot, or any agent that reads AGENTS.md and can run bash commands.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session.json schema","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"lastBranch\": \"trunk\",\n \"lastContext\": \"Working on swain-session skill\",\n \"preferences\": {\n \"verbosity\": \"concise\"\n },\n \"bookmark\": {\n \"note\": \"Left off implementing the bootstrap script\",\n \"files\": [\"SKILL.md\"],\n \"timestamp\": \"2026-03-10T14:32:00Z\"\n }\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Migration:","type":"text","marks":[{"type":"strong"}]},{"text":" If ","type":"text"},{"text":".agents/session.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" does not exist but the old global location (","type":"text"},{"text":"~/.claude/projects/\u003cproject-path-slug>/memory/session.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") does, the bootstrap script copies it automatically.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"README Reconciliation Checkpoint (SPEC-209)","type":"text"}]},{"type":"paragraph","content":[{"text":"After the greeting and before work begins, compare README.md against the artifact tree. This runs once per session, at focus lane selection time.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Trigger","type":"text"}]},{"type":"paragraph","content":[{"text":"When a focus lane is set (either restored from a previous session or newly selected), and README.md exists:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\n[ -f \"$REPO_ROOT/README.md\" ] && echo \"has_readme\" || echo \"no_readme\"","type":"text"}]},{"type":"paragraph","content":[{"text":"If no README exists, skip reconciliation silently — swain-doctor will flag the missing README.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Process","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read README.md and extract claims — any statement about what the project does, who it's for, how it works, what it supports, or what behavior it exhibits. Read the entire README as prose; no markers or section conventions required.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Compare claims against current Active Visions, Designs, Journeys, and Persona artifacts. Look for:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stale promises","type":"text","marks":[{"type":"strong"}]},{"text":" — README claims a feature or behavior that an artifact explicitly dropped or superseded.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing coverage","type":"text","marks":[{"type":"strong"}]},{"text":" — an artifact describes a capability the README doesn't mention.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Contradictions","type":"text","marks":[{"type":"strong"}]},{"text":" — README and artifact disagree on behavior, audience, or scope.","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For each mismatch, surface a specific question to the operator:","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"\"README says '{claim}' but {artifact-id} {describes the conflict}. Which is right?\"","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Reconciliation direction","type":"text"}]},{"type":"paragraph","content":[{"text":"Bidirectional. Drift does not assume artifacts are right:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A new Vision may mean the README needs updating.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The README may be right and the Vision needs reshaping.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A promise may have been intentionally dropped and needs removing from both.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Deferral tracking","type":"text"}]},{"type":"paragraph","content":[{"text":"The operator can defer any mismatch. Deferrals are tracked in ","type":"text"},{"text":".agents/session.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" under a ","type":"text"},{"text":"readme_deferrals","type":"text","marks":[{"type":"code_inline"}]},{"text":" key:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"readme_deferrals\": [\n {\n \"claim\": \"real-time sync\",\n \"conflict_artifact\": \"VISION-003\",\n \"deferred_at\": \"2026-03-31T14:00:00Z\"\n }\n ]\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"Deferred items are raised again at the next session start. When the operator resolves a deferral (updates README or artifact), remove it from the list.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Silent pass","type":"text"}]},{"type":"paragraph","content":[{"text":"If no drift is detected, the reconciliation check passes silently — no output to the operator.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Session Lifecycle (SPEC-119)","type":"text"}]},{"type":"paragraph","content":[{"text":"swain-session owns a bounded session lifecycle: ","type":"text"},{"text":"start → work → close → resume","type":"text","marks":[{"type":"strong"}]},{"text":". Session state is tracked in ","type":"text"},{"text":".agents/session-state.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" via the ","type":"text"},{"text":"swain-session-state.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" script.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session start","type":"text"}]},{"type":"paragraph","content":[{"text":"After bootstrap completes and the worktree is ready, initialize the session lifecycle:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-state.sh\" init --focus \"\u003cFOCUS-ID>\" --session-roadmap \"$(pwd)/SESSION-ROADMAP.md\" --repo-root \"$REPO_ROOT\"","type":"text"}]},{"type":"paragraph","content":[{"text":"This:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Creates ","type":"text"},{"text":".agents/session-state.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with focus lane, decision budget (default 5), and start time","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Generates ","type":"text"},{"text":"SESSION-ROADMAP.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" via ","type":"text"},{"text":"chart.sh session --focus \u003cID>","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"paragraph","content":[{"text":"The focus lane defaults to the previous session's lane (from bootstrap JSON ","type":"text"},{"text":"session.focus","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Confirm with the operator or accept their redirect.","type":"text"}]},{"type":"paragraph","content":[{"text":"Custom decision budget: ","type":"text"},{"text":"--budget 7","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"During work — recording decisions","type":"text"}]},{"type":"paragraph","content":[{"text":"When the operator or agent makes a decision (approves a spec, chooses an approach, sets direction), record it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-state.sh\" record-decision --note \"Approved SPEC-119 implementation approach\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session close","type":"text"}]},{"type":"paragraph","content":[{"text":"When the operator says \"done\", \"wrap up\", \"close session\", or the decision budget is reached, execute this close sequence. ","type":"text"},{"text":"Critical:","type":"text","marks":[{"type":"strong"}]},{"text":" swain-retro must run while the session is still active so it can read session state. Do not close the session before running retro.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1 — Generate session digest and progress logs","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-digest.sh\" --session-id \"$(jq -r .session_id \"$REPO_ROOT/.agents/session-state.json\")\" --output \"$REPO_ROOT/.agents/session-log.jsonl\"\nbash \"$REPO_ROOT/.agents/bin/swain-progress-log.sh\" --digest \"$REPO_ROOT/.agents/session-log.jsonl\"","type":"text"}]},{"type":"paragraph","content":[{"text":"This appends a JSONL digest entry and updates each touched EPIC/Initiative's ","type":"text"},{"text":"progress.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"## Progress","type":"text","marks":[{"type":"code_inline"}]},{"text":" section.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2 — Run retro (session still active)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nSWAIN_RETRO_SKILL=\"$REPO_ROOT/.claude/skills/swain-retro/SKILL.md\"\nSkill(\"$SWAIN_RETRO_SKILL\", \"Session close — session is closing. Run /swain-retro to capture session learnings before the session state is cleared.\")","type":"text"}]},{"type":"paragraph","content":[{"text":"Important:","type":"text","marks":[{"type":"strong"}]},{"text":" Retro reads session.json and session-state.json while they are still populated. Do not call session-state.sh close before this step.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3 — Close the session","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-state.sh\" close --walkaway \"Completed SPEC-119 tests and state management\" --session-roadmap \"$(pwd)/SESSION-ROADMAP.md\"","type":"text"}]},{"type":"paragraph","content":[{"text":"This sets session phase to ","type":"text"},{"text":"closed","type":"text","marks":[{"type":"code_inline"}]},{"text":" with end time and appends the walk-away signal to SESSION-ROADMAP.md.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4 — Run session teardown","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nSWAIN_TEARDOWN_SKILL=\"$REPO_ROOT/.claude/skills/swain-teardown/SKILL.md\"\nSkill(\"$SWAIN_TEARDOWN_SKILL\", \"Session teardown — --session-chain flag passed from swain-session close handler.\")","type":"text"}]},{"type":"paragraph","content":[{"text":"This runs orphan worktree checks, git dirty-state check, ticket sync prompt, and writes a handoff summary. The ","type":"text"},{"text":"--session-chain","type":"text","marks":[{"type":"code_inline"}]},{"text":" flag tells teardown to skip the redundant session-active check since the handler already confirmed session state.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5 — Commit SESSION-ROADMAP.md","type":"text"}]},{"type":"paragraph","content":[{"text":"Finally, commit SESSION-ROADMAP.md to git.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session resume","type":"text"}]},{"type":"paragraph","content":[{"text":"On the next session start, read ","type":"text"},{"text":"prev_session","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the preflight JSON (see ","type":"text"},{"text":"Preflight","type":"text","marks":[{"type":"link","attrs":{"href":"#preflight","title":null}}]},{"text":"). This includes the previous session's focus lane, walkaway note, decision count, and staleness status — no separate script call needed.","type":"text"}]},{"type":"paragraph","content":[{"text":"If you need to call session-state.sh directly (e.g., from a script that doesn't have the preflight JSON):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-session-state.sh\" resume","type":"text"}]},{"type":"paragraph","content":[{"text":"Display the previous session context so the operator can decide whether to continue or start fresh.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Session state schema","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"session_id\": \"session-20260328-220634-4ad1\",\n \"focus_lane\": \"INITIATIVE-019\",\n \"phase\": \"active\",\n \"start_time\": \"2026-03-28T22:06:34Z\",\n \"last_activity_time\": \"2026-03-28T22:06:34Z\",\n \"end_time\": null,\n \"decision_budget\": 5,\n \"decisions_made\": 0,\n \"decisions\": [],\n \"walkaway\": null\n}","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Manual invocation commands","type":"text"}]},{"type":"paragraph","content":[{"text":"When invoked explicitly by the user, support these operations:","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Set tab name","type":"text"}]},{"type":"paragraph","content":[{"text":"User says something like \"set tab name to X\" or \"rename tab\":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-tab-name.sh\" \"Custom Name\"","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Bookmark context","type":"text"}]},{"type":"paragraph","content":[{"text":"User says \"remember where I am\" or \"bookmark this\":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Infer what they're working on from conversation context, or use the note they provided — do not prompt the user","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write to session.json ","type":"text"},{"text":"bookmark","type":"text","marks":[{"type":"code_inline"}]},{"text":" field with note, relevant files, and timestamp","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If a bookmark already exists, ","type":"text"},{"text":"overwrite it silently without asking for confirmation","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"swain-bookmark.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" handles atomic writes","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Clear bookmark","type":"text"}]},{"type":"paragraph","content":[{"text":"User says \"clear bookmark\" or \"fresh start\":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Remove the ","type":"text"},{"text":"bookmark","type":"text","marks":[{"type":"code_inline"}]},{"text":" field from session.json","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Show session info","type":"text"}]},{"type":"paragraph","content":[{"text":"User says \"session info\" or \"what's my session\":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Display current tab name, branch, preferences, bookmark status","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the bookmark note contains an artifact ID (e.g., ","type":"text"},{"text":"SPEC-052","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"EPIC-018","type":"text","marks":[{"type":"code_inline"}]},{"text":"), show the Vision ancestry breadcrumb for strategic context. Run ","type":"text"},{"text":"bash \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents/bin/chart.sh\" scope \u003cID> 2>/dev/null | head -5","type":"text","marks":[{"type":"code_inline"}]},{"text":" to get the parent chain. Display as: ","type":"text"},{"text":"Context: Swain > Operator Situational Awareness > Vision-Rooted Chart Hierarchy","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Set preference","type":"text"}]},{"type":"paragraph","content":[{"text":"User says \"set preference X to Y\":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Update ","type":"text"},{"text":"preferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" in session.json","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Post-operation bookmark (auto-update protocol)","type":"text"}]},{"type":"paragraph","content":[{"text":"Other swain skills update the session bookmark after operations. Read ","type":"text"},{"text":"references/bookmark-protocol.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/bookmark-protocol.md","title":null}}]},{"text":" for the protocol, invocation patterns, and examples.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Focus Lane","type":"text"}]},{"type":"paragraph","content":[{"text":"The operator can set a focus lane to scope recommendations within a single vision or initiative. This is a steering mechanism — it doesn't hide other work, but frames recommendations around the operator's current focus.","type":"text"}]},{"type":"paragraph","content":[{"text":"Setting focus:","type":"text","marks":[{"type":"strong"}]},{"text":" When the operator says \"focus on security\" or \"I'm working on VISION-001\", resolve the name to an artifact ID and invoke the focus script.","type":"text"}]},{"type":"paragraph","content":[{"text":"Name-to-ID resolution:","type":"text","marks":[{"type":"strong"}]},{"text":" If the operator uses a name instead of an ID (e.g., \"security\" instead of \"VISION-001\"), search Vision and Initiative artifact titles for the best match using swain chart:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/chart.sh\" --ids --flat 2>/dev/null | grep -i \"\u003cname>\"","type":"text"}]},{"type":"paragraph","content":[{"text":"If exactly one match, use it. If multiple matches, ask the operator to clarify. If no match, tell the operator no Vision or Initiative matches that name and offer to create one.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-focus.sh\" set \u003cRESOLVED-ID>","type":"text"}]},{"type":"paragraph","content":[{"text":"Clearing focus:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-focus.sh\" clear","type":"text"}]},{"type":"paragraph","content":[{"text":"Checking focus:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nbash \"$REPO_ROOT/.agents/bin/swain-focus.sh\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Display the focus artifact as a context line by calling ","type":"text"},{"text":"artifact-context.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the focus ID. Fall back to the bare ID if the utility is unavailable.","type":"text"}]},{"type":"paragraph","content":[{"text":"Focus lane is stored in ","type":"text"},{"text":".agents/session.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" under the ","type":"text"},{"text":"focus_lane","type":"text","marks":[{"type":"code_inline"}]},{"text":" key. It persists across status checks within a session. The status dashboard reads it to filter recommendations and show peripheral awareness for non-focus visions.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Status Dashboard (SPEC-122)","type":"text"}]},{"type":"paragraph","content":[{"text":"swain-session now owns the project status dashboard. When the operator says \"status\", \"what's next\", \"dashboard\", \"overview\", \"where are we\", \"what should I work on\", or \"show me priorities\", run the status script:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nSTATUS_SCRIPT=\"$REPO_ROOT/.agents/bin/swain-status.sh\"\n[ -f \"$STATUS_SCRIPT\" ] && bash \"$STATUS_SCRIPT\" --refresh || echo \"status dashboard script not found\"","type":"text"}]},{"type":"paragraph","content":[{"text":"For compact mode (MOTD): ","type":"text"},{"text":"bash \"$STATUS_SCRIPT\" --compact","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"paragraph","content":[{"text":"After the script runs, present a structured agent summary following ","type":"text"},{"text":"references/agent-summary-template.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/agent-summary-template.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Cache","type":"text"}]},{"type":"paragraph","content":[{"text":"Status writes to ","type":"text"},{"text":".agents/status-cache.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with 120-second TTL. Use ","type":"text"},{"text":"--refresh","type":"text","marks":[{"type":"code_inline"}]},{"text":" to bypass, ","type":"text"},{"text":"--json","type":"text","marks":[{"type":"code_inline"}]},{"text":" for raw output.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Recommendation","type":"text"}]},{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":".priority.recommendations[0]","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the JSON cache. When a focus lane is set, recommendations scope to that vision/initiative.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Context-rich display","type":"text"}]},{"type":"paragraph","content":[{"text":"When presenting artifacts to the operator (recommendations, focus lane, decisions needed), use the artifact-context utility instead of bare IDs:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"REPO_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"\nCONTEXT=$(bash \"$REPO_ROOT/.agents/bin/artifact-context.sh\" \u003cARTIFACT-ID> 2>/dev/null)","type":"text"}]},{"type":"paragraph","content":[{"text":"If the utility is available and returns output, use the context line. If unavailable or empty, fall back to ","type":"text"},{"text":"\u003cID> — \u003ctitle>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (current behavior).","type":"text"}]},{"type":"paragraph","content":[{"text":"Display format: ","type":"text"},{"text":"title","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"ID","type":"text","marks":[{"type":"code_inline"}]},{"text":" — scope. progress.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Mode Inference","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Both specs in review AND strategic decisions pending → ask operator","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Specs awaiting review → detail mode","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Focus lane + pending decisions → vision mode","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Nothing actionable → vision mode (master plan mirror)","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Decisions Needed (roadmap integration)","type":"text"}]},{"type":"paragraph","content":[{"text":"Uses ","type":"text"},{"text":"chart.sh roadmap --json","type":"text","marks":[{"type":"code_inline"}]},{"text":" for Eisenhower classification. Show top 5 items from \"Do First\" and \"Schedule\" quadrants that need operator decisions.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Settings","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill reads from ","type":"text"},{"text":"swain.settings.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (project root) and ","type":"text"},{"text":"~/.config/swain/settings.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (user override). User settings take precedence.","type":"text"}]},{"type":"paragraph","content":[{"text":"Relevant settings:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"terminal.tabNameFormat","type":"text","marks":[{"type":"code_inline"}]},{"text":" — format string for tab names. Supports ","type":"text"},{"text":"{project}","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"{branch}","type":"text","marks":[{"type":"code_inline"}]},{"text":" placeholders. Default: ","type":"text"},{"text":"{project} @ {branch}","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Error handling","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If jq is not available, warn the user and skip JSON operations. Tab naming still works without jq.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If git is not available, use the directory name as the project name and skip branch detection.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Never fail hard — session management is a convenience, not a gate.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"swain-session","author":"@skillopedia","source":{"stars":2,"repo_name":"swain","origin_url":"https://github.com/cristoslc/swain/blob/HEAD/skills/swain-session/SKILL.md","repo_owner":"cristoslc","body_sha256":"ed253155ffb82c027df6c20dcced86d54b20979e564ce1dc24fce0bbf66bdad3","cluster_key":"9b780b081ca7f58b0bd2c7fc7fbdfcb1e60c8cb0cf1ff5a8b43dc1e4232900b9","clean_bundle":{"format":"clean-skill-bundle-v1","source":"cristoslc/swain/skills/swain-session/SKILL.md","attachments":[{"id":"b81a379e-ea9b-56f7-9105-5aa2d47bd538","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b81a379e-ea9b-56f7-9105-5aa2d47bd538/attachment.md","path":"references/agent-summary-template.md","size":10209,"sha256":"abd94ed580ea6c7dcaafd66d4b75d8e3fa300c519f8e9d6bbf5d56acb358480e","contentType":"text/markdown; charset=utf-8"},{"id":"73090608-ea92-54de-8b54-0640752818ef","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/73090608-ea92-54de-8b54-0640752818ef/attachment.md","path":"references/bookmark-protocol.md","size":982,"sha256":"6064f0875ce793c16f6da117b39d867701969e9abc6fe277869f003e5f359538","contentType":"text/markdown; charset=utf-8"},{"id":"68e90cd8-c013-56f9-b2e4-ac273e1e999b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/68e90cd8-c013-56f9-b2e4-ac273e1e999b/attachment.md","path":"references/session-check-preamble.md","size":993,"sha256":"b3162730290790b27d6e7b51330eee6496adeb777322f02bed5c6193a19802e1","contentType":"text/markdown; charset=utf-8"},{"id":"b8d9d129-91a5-5aa5-ae78-1c4848ed3fbd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8d9d129-91a5-5aa5-ae78-1c4848ed3fbd/attachment.md","path":"references/status-format.md","size":3522,"sha256":"087d19da4be0ea17491a82b143f7b307cd819e4e7276317d8bfced2e055b2c38","contentType":"text/markdown; charset=utf-8"},{"id":"b977c4b5-d9ea-5555-a98e-e8f72039f473","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b977c4b5-d9ea-5555-a98e-e8f72039f473/attachment.sh","path":"scripts/swain-bookmark.sh","size":6885,"sha256":"0d6930f6f9e3b06fc531094c3fb203d99208d452e77f43ca2abc291cd2b8d226","contentType":"application/x-sh; charset=utf-8"},{"id":"27335e3b-c7f5-5490-9ac6-ef6b4bb646a5","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/27335e3b-c7f5-5490-9ac6-ef6b4bb646a5/attachment.sh","path":"scripts/swain-focus.sh","size":1141,"sha256":"0309791de34945d78af615737d6c35778fe5dd832eb250ed8a4768721948b0e9","contentType":"application/x-sh; charset=utf-8"},{"id":"9a23c509-4120-5ff1-b1d5-f32514a99247","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9a23c509-4120-5ff1-b1d5-f32514a99247/attachment.sh","path":"scripts/swain-preflight-timing.sh","size":3960,"sha256":"a85193dce8dc1b732c03a1e099be36e7dd18762e550eacd4b4b9398ed2377dfd","contentType":"application/x-sh; charset=utf-8"},{"id":"bfc27938-bdbc-5fc5-8c97-f5a5712eb0b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bfc27938-bdbc-5fc5-8c97-f5a5712eb0b6/attachment.sh","path":"scripts/swain-progress-log.sh","size":7180,"sha256":"c56bbebe62510d9d94c28f58c59e5a95a3a8656d30c81936c86bbafbfcb1cf5b","contentType":"application/x-sh; charset=utf-8"},{"id":"aeabbfde-5eaa-51ae-8999-1ef2211eb376","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aeabbfde-5eaa-51ae-8999-1ef2211eb376/attachment.sh","path":"scripts/swain-session-archive.sh","size":3020,"sha256":"74e662e6c5fc304948882641bd913b7fd650d487e25aa5ef17952725c89bb789","contentType":"application/x-sh; charset=utf-8"},{"id":"7becc7c3-1120-5f13-a81d-83bd59d701c6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7becc7c3-1120-5f13-a81d-83bd59d701c6/attachment.sh","path":"scripts/swain-session-bootstrap.sh","size":6227,"sha256":"70c0e66dfe1cdcf9ed133127c3b0b27418fc947a391f559b864113d714c5053d","contentType":"application/x-sh; charset=utf-8"},{"id":"bbd08f42-2ace-5a27-b267-f49eea3209de","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bbd08f42-2ace-5a27-b267-f49eea3209de/attachment.sh","path":"scripts/swain-session-check.sh","size":2070,"sha256":"1011aa67d3b04216e06feef120260c34edba175eb3e8a7c68b3d0291fa0f4587","contentType":"application/x-sh; charset=utf-8"},{"id":"afb9df51-7698-5bc5-bab3-caf201e2dcf0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/afb9df51-7698-5bc5-bab3-caf201e2dcf0/attachment.sh","path":"scripts/swain-session-digest.sh","size":8177,"sha256":"bec7e1c5496c68d70f26294e2f44a907b1302bfc337af04d227f11a64baea3a4","contentType":"application/x-sh; charset=utf-8"},{"id":"99b970d6-46c7-5f86-98ac-04194ec54ab9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/99b970d6-46c7-5f86-98ac-04194ec54ab9/attachment.sh","path":"scripts/swain-session-greeting.sh","size":6038,"sha256":"fdba5c0b09ae773cfa413ee81f4def2cb7cbfec73d8b8c57d462e237253c3e8b","contentType":"application/x-sh; charset=utf-8"},{"id":"ac1abf52-b4e4-5e07-beff-47b514cc4996","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ac1abf52-b4e4-5e07-beff-47b514cc4996/attachment.sh","path":"scripts/swain-session-preflight.sh","size":9167,"sha256":"748c086d8b26441fcc476b9c58d2c1eb2708ce65e9f67037b8cc10b1158f020a","contentType":"application/x-sh; charset=utf-8"},{"id":"b3f4f6cb-7452-5e3c-a026-5c17e30393c2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b3f4f6cb-7452-5e3c-a026-5c17e30393c2/attachment.sh","path":"scripts/swain-session-state.sh","size":6282,"sha256":"63cbcb39253d097dd721585708127a4cc33400044b3da9c7f316728c20fd91b7","contentType":"application/x-sh; charset=utf-8"},{"id":"fb93ef9d-a4ef-5f06-b1a4-d28f273242c3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fb93ef9d-a4ef-5f06-b1a4-d28f273242c3/attachment.sh","path":"scripts/swain-startup-timing.sh","size":3910,"sha256":"985ea935bcf2a29cfd69609af5a752a8108362b9985a0e1ceb57a89cfc359532","contentType":"application/x-sh; charset=utf-8"},{"id":"a822617b-6784-58eb-979f-38395c23d7a2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a822617b-6784-58eb-979f-38395c23d7a2/attachment.sh","path":"scripts/swain-status.sh","size":39147,"sha256":"2827859caa72f9378aa99cc3e37486fc65e31aa1eb51b4ab0a056bbec9992025","contentType":"application/x-sh; charset=utf-8"},{"id":"6ace972d-4b27-555d-8158-0d17c018a335","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6ace972d-4b27-555d-8158-0d17c018a335/attachment.sh","path":"scripts/swain-tab-name.sh","size":10191,"sha256":"13e2a04b622f896cb9078d8b183c9976f6461a52b4feebf08354d90eea613821","contentType":"application/x-sh; charset=utf-8"},{"id":"67a8539e-a513-5b98-97ad-ac0e2ac67668","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/67a8539e-a513-5b98-97ad-ac0e2ac67668/attachment.sh","path":"scripts/swain-worktree-name.sh","size":5007,"sha256":"8df2d9187e383da38c9d780d8023589e29ea4885ef3451c99efcb563bb98c2ee","contentType":"application/x-sh; charset=utf-8"},{"id":"d5f21958-d5b1-5243-9c78-3ed991b3d500","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d5f21958-d5b1-5243-9c78-3ed991b3d500/attachment.sh","path":"scripts/test-session-greeting.sh","size":5727,"sha256":"b8b29ce82df22323c467abc3f4d07d5c9b5b700e84cd2dbcc1d3457c618e9b8b","contentType":"application/x-sh; charset=utf-8"},{"id":"2d1b14b7-744f-546a-b625-d87407f1739d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2d1b14b7-744f-546a-b625-d87407f1739d/attachment.sh","path":"tests/test-bootstrap.sh","size":20324,"sha256":"d6897dcf23497d7cb9cc7d0f3d7f50e0ee07f2c4e72f8001c2ea24d5c1fe7274","contentType":"application/x-sh; charset=utf-8"},{"id":"3e7d9ad8-c627-56e9-a09a-780fa09573a7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3e7d9ad8-c627-56e9-a09a-780fa09573a7/attachment.sh","path":"tests/test-progress-log.sh","size":10659,"sha256":"0fd41af5c68d930bcd9ea873e99da0a97832926bc1152f7e5878659bc892ad36","contentType":"application/x-sh; charset=utf-8"},{"id":"ae033465-800c-567a-9b1c-3fb90ea30844","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ae033465-800c-567a-9b1c-3fb90ea30844/attachment.sh","path":"tests/test-session-close-integration.sh","size":1780,"sha256":"157c0d14b8bc4d6cca39497647a8c5d503faf948457f6e7763764f2c44eb3b67","contentType":"application/x-sh; charset=utf-8"},{"id":"e2ed3107-6001-5130-bf32-754d23098915","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e2ed3107-6001-5130-bf32-754d23098915/attachment.sh","path":"tests/test-session-digest.sh","size":5983,"sha256":"0a276148547660444e8328fef4908779ba61007096fd6699fc4d96086bbc5f32","contentType":"application/x-sh; charset=utf-8"},{"id":"58486e90-db40-5fda-8f7f-edced5dc901f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/58486e90-db40-5fda-8f7f-edced5dc901f/attachment.sh","path":"tests/test-tab-name.sh","size":12819,"sha256":"16b1fec80f54d4ab8a4890abc0ee919c01c1b574f494fc0338c22b74ee4ecac3","contentType":"application/x-sh; charset=utf-8"}],"bundle_sha256":"8b8c6eb39f6e926d9ffd138f4b4f09ae6b5e0f95925cc4cbb7c51e215d1dfc34","attachment_count":25,"text_attachments":25,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/swain-session/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"data-analytics","category_label":"Data"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"data-analytics","metadata":{"author":"cristos","source":"swain","version":"1.4.0","short-description":"Session state and identity management"},"import_tag":"clean-skills-v1","description":"Session management and project status dashboard. Owns the full session lifecycle (start/work/close/resume), focus lane, bookmarks, worktree detection, and tab naming. Also serves as the project status dashboard — shows active epics, progress, actionable next steps, blocked items, tasks, GitHub issues, and recommendations. Worktree creation is deferred to swain-do task dispatch (SPEC-195). Triggers on: 'session', 'status', 'what's next', 'dashboard', 'overview', 'where are we', 'what should I work on', 'show me priorities', 'bookmark', 'focus on', 'session info'.","allowed-tools":"Bash, Read, Write, Edit, Grep, Glob","user-invocable":true}},"renderedAt":1782980423258}

<!-- swain-model-hint: haiku, effort: low -- Session Manages session identity, preferences, and context continuity across agent sessions. This skill is agent-agnostic — it relies on AGENTS.md for auto-invocation. Auto-run behavior This skill is invoked automatically at session start (see AGENTS.md). When auto-invoked: 1. Restore tab name — run the tab-naming script 2. Load preferences — read session.json and apply any stored preferences 3. Show context bookmark — if a previous session left a context note, display it When invoked manually, the user can change preferences or bookmark context. S…