portable-agent-layer 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ # PAL Status Line
2
+
3
+ A customizable status line for Claude Code that displays context usage, active model, session cost, current directory, and git branch.
4
+
5
+ ## What It Shows
6
+
7
+ **Line 1:** Model name, current directory, git branch, hook health, open ISCs, signal trend
8
+ **Line 2:** Context usage progress bar, percentage used, session cost, remaining context, rate limits
9
+ **Line 3:** Update notice (only when a new PAL version is available)
10
+
11
+ Example output (macOS/Linux):
12
+ ```
13
+ [Opus] 📁 portable-agent-layer 🌿 main 📋 21 open ISCs sig: 3.0/10 ↓
14
+ ████████░░░░░░░░░░░░ 45% │ $0.12 │ 55% free
15
+ ```
16
+
17
+ Example output (Windows):
18
+ ```
19
+ [Opus] folder: portable-agent-layer (git: main) 21 open ISCs sig: 3.0/10 v
20
+ ########------------ 45% - $0.12 - 55% free
21
+ ```
22
+
23
+ Colors adapt based on context usage:
24
+ - 🟢 Green: 0-60% used
25
+ - 🟡 Yellow: 60-80% used
26
+ - 🔴 Red: >80% used
27
+
28
+ ## Setup
29
+
30
+ ### On macOS / Linux
31
+
32
+ 1. Make the script executable:
33
+ ```bash
34
+ chmod +x ~/.claude/statusline.sh
35
+ ```
36
+
37
+ 2. Add to `~/.claude/settings.json`:
38
+ ```json
39
+ {
40
+ "statusLine": {
41
+ "type": "command",
42
+ "command": "~/.claude/statusline.sh",
43
+ "padding": 2
44
+ }
45
+ }
46
+ ```
47
+
48
+ The statusline script is at: `portable-agent-layer/assets/statusline.sh`
49
+
50
+ ### On Windows
51
+
52
+ 1. Add to `~/.claude/settings.json`:
53
+ ```json
54
+ {
55
+ "statusLine": {
56
+ "type": "command",
57
+ "command": "powershell -NoProfile -Command \"Get-Content -Raw | & 'C:\\path\\to\\statusline.ps1'\"",
58
+ "padding": 2
59
+ }
60
+ }
61
+ ```
62
+
63
+ Or copy `statusline.ps1` to `~/.claude/statusline.ps1` and reference it from settings.
64
+
65
+ The statusline script is at: `portable-agent-layer/assets/statusline.ps1`
66
+
67
+ ## Data Displayed
68
+
69
+ - **Model:** Active Claude model (Opus, Sonnet, Haiku, etc.)
70
+ - **Directory:** Repository name (if in a git repo) or folder name
71
+ - **Git Branch:** Current branch, fetched from git worktree or `git` command
72
+ - **Hook Health:** Count of ERROR lines in `~/.pal/memory/state/debug.log` from last 24h (hidden when 0)
73
+ - **Open ISCs:** Count of unchecked ISCs for the current project (hidden when 0)
74
+ - **Signal Trend:** Session quality score from `~/.pal/memory/state/signal-cache.json` with trend arrow
75
+ - **Context Bar:** Visual progress bar showing token usage
76
+ - **Context %:** Percentage of context window used
77
+ - **Cost:** Estimated session cost in USD (shows "free" if <$0.01 on macOS/Linux, `$0.00` on Windows)
78
+ - **Remaining %:** Percentage of context window remaining
79
+ - **Rate Limits:** 5h and 7d usage percentages (Pro/Max plans only — hidden on other plans)
80
+ - **Update Notice:** Shown on line 3 when `~/.pal/memory/state/update-available.json` flags a new version
81
+
82
+ ## Dependencies
83
+
84
+ - **macOS/Linux:** `bash`, `jq`, `git` (for branch detection)
85
+ - **Windows:** PowerShell 7+, `git` (for branch detection)
86
+
87
+ ## Customization
88
+
89
+ Both scripts use `jq` to parse the JSON data Claude Code sends. You can modify them to:
90
+ - Add or remove fields
91
+ - Change colors (ANSI escape codes)
92
+ - Adjust progress bar width
93
+ - Show additional data like effort level, rate limits, or PR status
94
+
95
+ Available fields are documented in the Claude Code statusline reference:
96
+ https://code.claude.com/docs/en/statusline#available-data
97
+
98
+ ## Updates
99
+
100
+ The status line runs after each assistant message, after `/compact`, and when vim mode toggles. It does not consume API tokens.
101
+
102
+ Changes to the script appear on your next interaction with Claude Code.
@@ -0,0 +1,142 @@
1
+ # PAL Status Line - Windows PowerShell version
2
+ # Reads JSON from stdin (Claude Code session data) and prints a formatted status line
3
+
4
+ $data = $input | Out-String | ConvertFrom-Json
5
+
6
+ # Extract data with fallbacks (PowerShell 5.1 compatible)
7
+ $MODEL = if ($data.model.display_name) { $data.model.display_name } else { "Unknown" }
8
+ $USED_RAW = $data.context_window.used_percentage
9
+ $USED = if ($USED_RAW -ne $null) { [int]$USED_RAW } else { 0 }
10
+ $REM_RAW = $data.context_window.remaining_percentage
11
+ $REM = if ($REM_RAW -ne $null) { [int]$REM_RAW } else { 0 }
12
+ $COST_RAW = $data.cost.total_cost_usd
13
+ $COST = if ($COST_RAW -ne $null) { [double]$COST_RAW } else { 0 }
14
+ $CWD = if ($data.workspace.current_dir) { $data.workspace.current_dir } else { "~" }
15
+ $REPO = if ($data.workspace.repo.name) { $data.workspace.repo.name } else { "" }
16
+
17
+ # Extract git branch
18
+ $BRANCH = ""
19
+ try { $BRANCH = & git -C $CWD rev-parse --abbrev-ref HEAD 2>$null } catch { $BRANCH = "" }
20
+
21
+ # Format directory
22
+ $DIR_DISPLAY = if ($REPO) { $REPO } else { Split-Path -Leaf $CWD }
23
+
24
+ # Format git branch indicator
25
+ $GIT_INDICATOR = if ($BRANCH) { " (git: $BRANCH)" } else { "" }
26
+
27
+ # Format cost
28
+ $COST_STR = '$' + ([math]::Round($COST, 2)).ToString("0.00")
29
+
30
+ # PAL: Hook health - count ERROR lines in debug.log from last 24h
31
+ $HOOK_ERRORS = 0
32
+ $debugLog = Join-Path $env:USERPROFILE ".pal\memory\state\debug.log"
33
+ if (Test-Path $debugLog) {
34
+ $cutoff = (Get-Date).AddHours(-24)
35
+ try {
36
+ $HOOK_ERRORS = (Get-Content $debugLog -ErrorAction SilentlyContinue |
37
+ Where-Object { $_ -match '\] ERROR ' } |
38
+ Where-Object {
39
+ $m = [regex]::Match($_, '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]')
40
+ if ($m.Success) { [datetime]$m.Groups[1].Value -gt $cutoff } else { $false }
41
+ }).Count
42
+ } catch { $HOOK_ERRORS = 0 }
43
+ }
44
+
45
+ # PAL: Open ISC count - find project matching current directory
46
+ $OPEN_ISCS = 0
47
+ $PROJECT_NAME = ""
48
+ $projectsDir = Join-Path $env:USERPROFILE ".pal\memory\projects"
49
+ if (Test-Path $projectsDir) {
50
+ try {
51
+ $CWD_NORM = $CWD.Replace('/', '\').TrimEnd('\')
52
+ foreach ($slug in Get-ChildItem $projectsDir -Directory | Select-Object -ExpandProperty Name) {
53
+ $isa = Join-Path $projectsDir "$slug\ISA.md"
54
+ if (-not (Test-Path $isa)) { continue }
55
+ $content = Get-Content $isa -Raw -ErrorAction SilentlyContinue
56
+ $pathMatch = [regex]::Match($content, 'path:\s*"?([^"\n\r]+)"?')
57
+ if ($pathMatch.Success) {
58
+ $projPath = $pathMatch.Groups[1].Value.Trim().Replace('/', '\').TrimEnd('\')
59
+ if ($projPath -eq $CWD_NORM -or $CWD_NORM.StartsWith($projPath)) {
60
+ $PROJECT_NAME = $slug
61
+ $OPEN_ISCS = ([regex]::Matches($content, '- \[ \] ISC-')).Count
62
+ break
63
+ }
64
+ }
65
+ }
66
+ } catch { $OPEN_ISCS = 0 }
67
+ }
68
+
69
+ # Rate limits (Pro/Max only - absent for other plans)
70
+ $FIVE_H_RAW = $data.rate_limits.five_hour.used_percentage
71
+ $SEVEN_D_RAW = $data.rate_limits.seven_day.used_percentage
72
+ $RATE_PARTS = @()
73
+ if ($FIVE_H_RAW -ne $null) { $RATE_PARTS += "5h: $([int]$FIVE_H_RAW)%" }
74
+ if ($SEVEN_D_RAW -ne $null) { $RATE_PARTS += "7d: $([int]$SEVEN_D_RAW)%" }
75
+ $RATE_STR = if ($RATE_PARTS.Count -gt 0) { " - " + ($RATE_PARTS -join " | ") } else { "" }
76
+
77
+ # Create context progress bar - if both are 0, data not yet available (pre-first API call)
78
+ $NO_DATA = ($USED_RAW -eq $null -and $REM_RAW -eq $null)
79
+ if ($NO_DATA) { $USED = 0; $REM = 100 }
80
+ $FILLED = [int]($USED / 5)
81
+ $EMPTY = 20 - $FILLED
82
+ $BAR = ("#" * $FILLED) + ("-" * $EMPTY)
83
+
84
+ # ANSI color codes (PowerShell 5.1 compatible - use concatenation to avoid array-index expansion)
85
+ $ESC = [char]27
86
+ $CYAN = $ESC + "[36m"
87
+ $GREEN = $ESC + "[32m"
88
+ $YELLOW = $ESC + "[33m"
89
+ $RED = $ESC + "[31m"
90
+ $DIM = $ESC + "[90m"
91
+ $RESET = $ESC + "[0m"
92
+
93
+ # Choose bar color based on context usage
94
+ $BAR_COLOR = if ($USED -gt 80) { $RED } elseif ($USED -gt 60) { $YELLOW } else { $GREEN }
95
+
96
+ # PAL: Signal trend (reads 10-min cache written by session intelligence)
97
+ $SIGNAL_STR = ""
98
+ $signalCache = Join-Path $env:USERPROFILE ".pal\memory\state\signal-cache.json"
99
+ if (Test-Path $signalCache) {
100
+ try {
101
+ $sc = Get-Content $signalCache -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
102
+ if ($sc.today -ne $null) {
103
+ $ARROW = switch ($sc.trend) {
104
+ "up" { "^"; break }
105
+ "down" { "v"; break }
106
+ "stable" { "-"; break }
107
+ default { "?"; break }
108
+ }
109
+ $SIG_TODAY = ([math]::Round([double]$sc.today, 1)).ToString("0.0")
110
+ $SIG_COLOR = if ($sc.trend -eq "up") { $GREEN } elseif ($sc.trend -eq "down") { $RED } else { $DIM }
111
+ $SIGNAL_STR = " " + $SIG_COLOR + "sig: $SIG_TODAY/10 $ARROW" + $RESET
112
+ }
113
+ } catch {}
114
+ }
115
+
116
+ # PAL: Update available check (reads cached result, no network call)
117
+ # Emoji constructed at runtime to avoid PS5.1 UTF-8-without-BOM encoding issues
118
+ $UPDATE_LINE = ""
119
+ $updateCache = Join-Path $env:USERPROFILE ".pal\memory\state\update-available.json"
120
+ if (Test-Path $updateCache) {
121
+ try {
122
+ $uc = Get-Content $updateCache -Raw -ErrorAction SilentlyContinue | ConvertFrom-Json
123
+ if ($uc.available -eq $true) {
124
+ $versionStr = if ($uc.current -ne $uc.latest) { "$($uc.current) -> $($uc.latest)" } else { $uc.current + " (new commits)" }
125
+ $UPDATE_LINE = "[update] $versionStr run: pal cli update"
126
+ }
127
+ } catch {}
128
+ }
129
+
130
+ # Build PAL indicators
131
+ $HOOK_STR = if ($HOOK_ERRORS -gt 0) { " " + $RED + "! $HOOK_ERRORS hook err" + $RESET } else { "" }
132
+ $ISC_STR = if ($OPEN_ISCS -gt 0) { " " + $DIM + "$OPEN_ISCS open ISCs" + $RESET } else { "" }
133
+
134
+ # Line 1: Model, Directory, Git Branch, Hook Health, Open ISCs
135
+ Write-Host ($CYAN + "[$MODEL]" + $RESET + " folder: $DIR_DISPLAY" + $DIM + $GIT_INDICATOR + $RESET + $HOOK_STR + $ISC_STR + $SIGNAL_STR)
136
+
137
+ # Line 2: Context bar with usage percentage, cost, and rate limits
138
+ $CTX_STR = "$USED% - $COST_STR - ${REM}% free"
139
+ Write-Host ($BAR_COLOR + $BAR + $RESET + " " + $CTX_STR + $DIM + $RATE_STR + $RESET)
140
+
141
+ # Line 3: Update available (only when cache says available AND versions differ)
142
+ if ($UPDATE_LINE) { Write-Host ($YELLOW + $UPDATE_LINE + $RESET) }
@@ -0,0 +1,167 @@
1
+ #!/bin/bash
2
+ # PAL Status Line — macOS/Linux
3
+ # Reads JSON from stdin (Claude Code session data) and prints a formatted status line
4
+
5
+ input=$(cat)
6
+
7
+ # Extract data with fallbacks
8
+ MODEL=$(echo "$input" | jq -r '.model.display_name // "Unknown"')
9
+ USED_RAW=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
10
+ REM_RAW=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty')
11
+ USED=$( [ -n "$USED_RAW" ] && echo "${USED_RAW%.*}" || echo 0 )
12
+ REMAINING=$( [ -n "$REM_RAW" ] && echo "${REM_RAW%.*}" || echo 0 )
13
+ COST=$(echo "$input" | jq -r '.cost.total_cost_usd // 0')
14
+ CWD=$(echo "$input" | jq -r '.workspace.current_dir // "~"')
15
+ REPO=$(echo "$input" | jq -r '.workspace.repo.name // ""')
16
+
17
+ # No data yet (pre-first API call)
18
+ if [ -z "$USED_RAW" ] && [ -z "$REM_RAW" ]; then
19
+ USED=0; REMAINING=100
20
+ fi
21
+
22
+ # Extract git branch — try multiple sources for robustness
23
+ BRANCH=$(echo "$input" | jq -r '.worktree.branch // ""')
24
+ if [ -z "$BRANCH" ]; then
25
+ BRANCH=$(git -C "$CWD" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
26
+ fi
27
+
28
+ # Format directory — show repo name if available, else just folder name
29
+ if [ -n "$REPO" ]; then
30
+ DIR_DISPLAY="$REPO"
31
+ else
32
+ DIR_DISPLAY="${CWD##*/}"
33
+ fi
34
+
35
+ # Format git branch with emoji indicator
36
+ if [ -n "$BRANCH" ]; then
37
+ GIT_INDICATOR=" (🌿 ${BRANCH})"
38
+ else
39
+ GIT_INDICATOR=""
40
+ fi
41
+
42
+ # Format cost — show as $X.XX or 'free' if < $0.01
43
+ if (( $(echo "$COST < 0.01" | bc -l) )); then
44
+ COST_STR="free"
45
+ else
46
+ COST_STR=$(printf '$%.2f' "$COST")
47
+ fi
48
+
49
+ # PAL: Hook health — count ERROR lines in debug.log from last 24h
50
+ HOOK_ERRORS=0
51
+ DEBUG_LOG="$HOME/.pal/memory/state/debug.log"
52
+ if [ -f "$DEBUG_LOG" ]; then
53
+ # Cross-platform 24h cutoff: macOS uses date -v, GNU Linux uses date -d
54
+ CUTOFF=$(date -v-24H "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -d '24 hours ago' "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
55
+ if [ -n "$CUTOFF" ]; then
56
+ # Log format: [YYYY-MM-DD HH:MM:SS] LEVEL ... — split on [ or ] to get $2 = timestamp
57
+ HOOK_ERRORS=$(grep '\] ERROR ' "$DEBUG_LOG" | awk -F'[][]]' '$2 > "'"$CUTOFF"'"' | wc -l | tr -d ' ')
58
+ else
59
+ HOOK_ERRORS=$(grep -c '\] ERROR ' "$DEBUG_LOG" 2>/dev/null || echo 0)
60
+ fi
61
+ fi
62
+
63
+ # PAL: Open ISC count — find project matching current directory
64
+ OPEN_ISCS=0
65
+ PROJECTS_DIR="$HOME/.pal/memory/projects"
66
+ if [ -d "$PROJECTS_DIR" ]; then
67
+ CWD_NORM="${CWD%/}"
68
+ for ISA in "$PROJECTS_DIR"/*/ISA.md; do
69
+ [ -f "$ISA" ] || continue
70
+ PROJ_PATH=$(grep -m1 'path:' "$ISA" | sed 's/.*path:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs)
71
+ PROJ_PATH="${PROJ_PATH%/}"
72
+ if [ "$PROJ_PATH" = "$CWD_NORM" ] || [[ "$CWD_NORM" == "${PROJ_PATH}/"* ]]; then
73
+ OPEN_ISCS=$(grep -c '- \[ \] ISC-' "$ISA" 2>/dev/null || echo 0)
74
+ break
75
+ fi
76
+ done
77
+ fi
78
+
79
+ # Rate limits (Pro/Max only — absent for other plans)
80
+ FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
81
+ SEVEN_D=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
82
+ RATE_PARTS=()
83
+ [ -n "$FIVE_H" ] && RATE_PARTS+=("5h: ${FIVE_H%.*}%")
84
+ [ -n "$SEVEN_D" ] && RATE_PARTS+=("7d: ${SEVEN_D%.*}%")
85
+ RATE_STR=""
86
+ if [ ${#RATE_PARTS[@]} -gt 0 ]; then
87
+ RATE_STR=" │ $(IFS=" | "; echo "${RATE_PARTS[*]}")"
88
+ fi
89
+
90
+ # Create context progress bar (20 chars wide)
91
+ FILLED=$((USED / 5))
92
+ EMPTY=$((20 - FILLED))
93
+ if [ "$FILLED" -le 0 ]; then
94
+ BAR="░░░░░░░░░░░░░░░░░░░░"
95
+ elif [ "$FILLED" -ge 20 ]; then
96
+ BAR="████████████████████"
97
+ else
98
+ BAR=$(printf '█%.0s' $(seq 1 $FILLED))$(printf '░%.0s' $(seq 1 $EMPTY))
99
+ fi
100
+
101
+ # Color codes (ANSI)
102
+ CYAN='\033[36m'
103
+ GREEN='\033[32m'
104
+ YELLOW='\033[33m'
105
+ RED='\033[31m'
106
+ DIM='\033[90m'
107
+ RESET='\033[0m'
108
+
109
+ # Choose bar color based on context usage
110
+ if [ "$USED" -gt 80 ]; then
111
+ CONTEXT_COLOR=$RED
112
+ elif [ "$USED" -gt 60 ]; then
113
+ CONTEXT_COLOR=$YELLOW
114
+ else
115
+ CONTEXT_COLOR=$GREEN
116
+ fi
117
+
118
+ # PAL: Signal trend (reads 10-min cache written by session intelligence)
119
+ SIGNAL_STR=""
120
+ SIGNAL_CACHE="$HOME/.pal/memory/state/signal-cache.json"
121
+ if [ -f "$SIGNAL_CACHE" ]; then
122
+ SC_TODAY=$(jq -r '.today // empty' "$SIGNAL_CACHE" 2>/dev/null)
123
+ SC_TREND=$(jq -r '.trend // empty' "$SIGNAL_CACHE" 2>/dev/null)
124
+ if [ -n "$SC_TODAY" ]; then
125
+ case "$SC_TREND" in
126
+ up) ARROW="↑"; SIG_COLOR=$GREEN ;;
127
+ down) ARROW="↓"; SIG_COLOR=$RED ;;
128
+ stable) ARROW="→"; SIG_COLOR=$DIM ;;
129
+ *) ARROW="?"; SIG_COLOR=$DIM ;;
130
+ esac
131
+ SIG_TODAY=$(printf "%.1f" "$SC_TODAY")
132
+ SIGNAL_STR=" ${SIG_COLOR}sig: ${SIG_TODAY}/10 ${ARROW}${RESET}"
133
+ fi
134
+ fi
135
+
136
+ # PAL: Update available check (reads cached result, no network call)
137
+ UPDATE_LINE=""
138
+ UPDATE_CACHE="$HOME/.pal/memory/state/update-available.json"
139
+ if [ -f "$UPDATE_CACHE" ]; then
140
+ UC_AVAIL=$(jq -r '.available // false' "$UPDATE_CACHE" 2>/dev/null)
141
+ if [ "$UC_AVAIL" = "true" ]; then
142
+ UC_CURRENT=$(jq -r '.current // ""' "$UPDATE_CACHE" 2>/dev/null)
143
+ UC_LATEST=$(jq -r '.latest // ""' "$UPDATE_CACHE" 2>/dev/null)
144
+ if [ "$UC_CURRENT" != "$UC_LATEST" ]; then
145
+ VERSION_STR="$UC_CURRENT → $UC_LATEST"
146
+ else
147
+ VERSION_STR="$UC_CURRENT (new commits)"
148
+ fi
149
+ UPDATE_LINE="📦 update: $VERSION_STR run: pal cli update"
150
+ fi
151
+ fi
152
+
153
+ # Build PAL indicators
154
+ HOOK_STR=""
155
+ [ "$HOOK_ERRORS" -gt 0 ] && HOOK_STR=" ${RED}⚠️ ${HOOK_ERRORS} hook err${RESET}"
156
+ ISC_STR=""
157
+ [ "$OPEN_ISCS" -gt 0 ] && ISC_STR=" ${DIM}📋 ${OPEN_ISCS} open ISCs${RESET}"
158
+
159
+ # Line 1: Model, Directory, Git Branch, Hook Health, Open ISCs, Signal
160
+ echo -e "${CYAN}[${MODEL}]${RESET} 📁 ${DIR_DISPLAY}${DIM}${GIT_INDICATOR}${RESET}${HOOK_STR}${ISC_STR}${SIGNAL_STR}"
161
+
162
+ # Line 2: Context bar with usage percentage, cost, and rate limits
163
+ echo -e "${CONTEXT_COLOR}${BAR}${RESET} ${USED}% │ ${COST_STR} │ ${REMAINING}% free${DIM}${RATE_STR}${RESET}"
164
+
165
+ # Line 3: Update available (only when cache says available AND versions differ)
166
+ [ -n "$UPDATE_LINE" ] && echo -e "${YELLOW}${UPDATE_LINE}${RESET}"
167
+ exit 0
@@ -25,6 +25,27 @@
25
25
  "Bash(node --experimental-strip-types ~/.pal/skills/consulting-report/tools/generate-pdf.ts *)"
26
26
  ]
27
27
  },
28
+ "skillOverrides": {
29
+ "claude-api": "off",
30
+ "update-config": "off",
31
+ "deep-research": "off",
32
+ "code-review": "off"
33
+ },
34
+ "attribution": {
35
+ "commit": "",
36
+ "pr": ""
37
+ },
38
+ "showClearContextOnPlanAccept": true,
39
+ "respectGitignore": true,
40
+ "spinnerTipsEnabled": true,
41
+ "spinnerTipsOverride": {
42
+ "tips": [
43
+ "Need something reusable? Use the /create-skill command.",
44
+ "PAL learns from your feedback and improves over time. Don't be shy to give feedback!",
45
+ "For simple PDFs use the /create-pdf skill. Need a styled one? Use /consulting-report instead.",
46
+ "Use the /research skill to run comprehensive web research with multiple parallel agents."
47
+ ]
48
+ },
28
49
  "hooks": {
29
50
  "SessionStart": [
30
51
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -85,6 +85,8 @@ export const assets = {
85
85
  copilotHooksTemplate: () => pkg("assets", "templates", "hooks.copilot.json"),
86
86
  codexHooksTemplate: () => pkg("assets", "templates", "hooks.codex.json"),
87
87
  codexRulesTemplate: () => pkg("assets", "templates", "rules.codex.rules"),
88
+ statuslineScriptBash: () => pkg("assets", "statusline.sh"),
89
+ statuslineScriptPs1: () => pkg("assets", "statusline.ps1"),
88
90
  agentTools: () => pkg("src", "tools", "agent"),
89
91
  tools: () => pkg("src", "tools"),
90
92
  palDocs: () => pkg("assets", "templates", "PAL"),
@@ -9,9 +9,11 @@ import { resolve } from "node:path";
9
9
  import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
10
10
  import { assets, palHome, palPkg, platform } from "../../hooks/lib/paths";
11
11
  import {
12
+ addStatuslineConfig,
12
13
  copyAgents,
13
14
  copyPalDocs,
14
15
  copySkills,
16
+ copyStatusline,
15
17
  countAgents,
16
18
  countMd,
17
19
  countSkills,
@@ -43,7 +45,10 @@ log.info("Backed up settings.json");
43
45
  // --- Load template and merge into existing settings ---
44
46
  const template = loadSettingsTemplate(assets.claudeSettingsTemplate(), PKG_ROOT);
45
47
  const existing = readJson<Record<string, unknown>>(SETTINGS, {});
46
- const merged = mergeSettings(existing, template);
48
+ let merged = mergeSettings(existing, template);
49
+
50
+ // Add platform-specific statusLine config
51
+ merged = addStatuslineConfig(merged);
47
52
 
48
53
  writeJson(SETTINGS, merged);
49
54
  log.success("Merged PAL settings into settings.json");
@@ -56,6 +61,9 @@ generateSkillIndex();
56
61
  // --- Copy agents ---
57
62
  copyAgents();
58
63
 
64
+ // --- Copy statusline script ---
65
+ copyStatusline();
66
+
59
67
  // --- Copy PAL system docs ---
60
68
  const palDocsCount = copyPalDocs();
61
69
  log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
@@ -13,6 +13,8 @@ import {
13
13
  removeAgents,
14
14
  removePalDocs,
15
15
  removeSkills,
16
+ removeStatusline,
17
+ removeStatuslineConfig,
16
18
  unmergeSettings,
17
19
  writeJson,
18
20
  } from "../lib";
@@ -33,7 +35,10 @@ log.info("Backed up settings.json");
33
35
  // --- Load template and unmerge from existing settings ---
34
36
  const template = loadSettingsTemplate(assets.claudeSettingsTemplate(), PKG_ROOT);
35
37
  const existing = readJson<Record<string, unknown>>(SETTINGS, {});
36
- const cleaned = unmergeSettings(existing, template);
38
+ let cleaned = unmergeSettings(existing, template);
39
+
40
+ // Remove statusLine config
41
+ cleaned = removeStatuslineConfig(cleaned);
37
42
 
38
43
  writeJson(SETTINGS, cleaned);
39
44
  log.success("Removed PAL settings from settings.json");
@@ -54,6 +59,9 @@ if (removedAgents.length > 0) {
54
59
  log.info("No PAL agents found");
55
60
  }
56
61
 
62
+ // --- Remove statusline script ---
63
+ removeStatusline();
64
+
57
65
  // --- Remove PAL system docs ---
58
66
  removePalDocs();
59
67
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import {
6
+ chmodSync,
6
7
  copyFileSync,
7
8
  existsSync,
8
9
  lstatSync,
@@ -123,6 +124,65 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
123
124
  }
124
125
  }
125
126
 
127
+ // Merge skillOverrides (object with skill name keys, add if not present)
128
+ if (template.skillOverrides && typeof template.skillOverrides === "object") {
129
+ result.skillOverrides ??= {};
130
+ const resultOverrides = result.skillOverrides as Record<string, unknown>;
131
+ for (const [skill, value] of Object.entries(template.skillOverrides)) {
132
+ if (!(skill in resultOverrides)) {
133
+ resultOverrides[skill] = value;
134
+ }
135
+ }
136
+ }
137
+
138
+ // Merge attribution (object with commit/pr keys, add if not present)
139
+ if (template.attribution && typeof template.attribution === "object") {
140
+ if (!("attribution" in result)) {
141
+ result.attribution = template.attribution;
142
+ }
143
+ }
144
+
145
+ // Merge showClearContextOnPlanAccept (boolean, add if not present)
146
+ if ("showClearContextOnPlanAccept" in template) {
147
+ if (!("showClearContextOnPlanAccept" in result)) {
148
+ result.showClearContextOnPlanAccept = template.showClearContextOnPlanAccept;
149
+ }
150
+ }
151
+
152
+ // Merge respectGitignore (boolean, add if not present)
153
+ if ("respectGitignore" in template) {
154
+ if (!("respectGitignore" in result)) {
155
+ result.respectGitignore = template.respectGitignore;
156
+ }
157
+ }
158
+
159
+ // Merge spinnerTipsEnabled (boolean, add if not present)
160
+ if ("spinnerTipsEnabled" in template) {
161
+ if (!("spinnerTipsEnabled" in result)) {
162
+ result.spinnerTipsEnabled = template.spinnerTipsEnabled;
163
+ }
164
+ }
165
+
166
+ // Merge spinnerTipsOverride (merge tips array, deduplicate by content)
167
+ if (
168
+ template.spinnerTipsOverride &&
169
+ typeof template.spinnerTipsOverride === "object" &&
170
+ "tips" in template.spinnerTipsOverride &&
171
+ Array.isArray((template.spinnerTipsOverride as Record<string, unknown>).tips)
172
+ ) {
173
+ result.spinnerTipsOverride ??= { tips: [] };
174
+ const resultOverride = result.spinnerTipsOverride as Record<string, unknown>;
175
+ resultOverride.tips ??= [];
176
+ const tips = resultOverride.tips as string[];
177
+ const templateTips = (template.spinnerTipsOverride as Record<string, unknown>)
178
+ .tips as string[];
179
+ for (const tip of templateTips) {
180
+ if (typeof tip === "string" && !tips.includes(tip)) {
181
+ tips.push(tip);
182
+ }
183
+ }
184
+ }
185
+
126
186
  return result;
127
187
  }
128
188
 
@@ -163,6 +223,68 @@ export function unmergeSettings(existing: Settings, template: Settings): Setting
163
223
  if (Object.keys(result.permissions).length === 0) delete result.permissions;
164
224
  }
165
225
 
226
+ // Remove PAL skillOverrides (remove keys that appear in template)
227
+ if (
228
+ template.skillOverrides &&
229
+ typeof template.skillOverrides === "object" &&
230
+ result.skillOverrides &&
231
+ typeof result.skillOverrides === "object"
232
+ ) {
233
+ const palSkills = new Set(Object.keys(template.skillOverrides));
234
+ for (const skill of palSkills) {
235
+ delete (result.skillOverrides as Record<string, unknown>)[skill];
236
+ }
237
+ if (Object.keys(result.skillOverrides).length === 0) delete result.skillOverrides;
238
+ }
239
+
240
+ // Remove PAL attribution if it matches template structure
241
+ if (template.attribution && "attribution" in result) {
242
+ delete result.attribution;
243
+ }
244
+
245
+ // Remove PAL showClearContextOnPlanAccept
246
+ if (
247
+ "showClearContextOnPlanAccept" in template &&
248
+ "showClearContextOnPlanAccept" in result
249
+ ) {
250
+ delete result.showClearContextOnPlanAccept;
251
+ }
252
+
253
+ // Remove PAL respectGitignore
254
+ if ("respectGitignore" in template && "respectGitignore" in result) {
255
+ delete result.respectGitignore;
256
+ }
257
+
258
+ // Remove PAL spinnerTipsEnabled
259
+ if ("spinnerTipsEnabled" in template && "spinnerTipsEnabled" in result) {
260
+ delete result.spinnerTipsEnabled;
261
+ }
262
+
263
+ // Remove PAL spinnerTipsOverride (remove only template tips, preserve user tips)
264
+ if (
265
+ template.spinnerTipsOverride &&
266
+ typeof template.spinnerTipsOverride === "object" &&
267
+ "tips" in template.spinnerTipsOverride &&
268
+ Array.isArray((template.spinnerTipsOverride as Record<string, unknown>).tips) &&
269
+ result.spinnerTipsOverride &&
270
+ typeof result.spinnerTipsOverride === "object" &&
271
+ "tips" in result.spinnerTipsOverride &&
272
+ Array.isArray((result.spinnerTipsOverride as Record<string, unknown>).tips)
273
+ ) {
274
+ const templateTipsArray = (template.spinnerTipsOverride as Record<string, unknown>)
275
+ .tips as string[];
276
+ const templateTips = new Set(templateTipsArray);
277
+ const resultOverride = result.spinnerTipsOverride as Record<string, unknown>;
278
+ const tips = resultOverride.tips as string[];
279
+ resultOverride.tips = tips.filter((tip) => !templateTips.has(tip));
280
+ if (
281
+ (resultOverride.tips as string[]).length === 0 &&
282
+ Object.keys(resultOverride).length === 1
283
+ ) {
284
+ delete result.spinnerTipsOverride;
285
+ }
286
+ }
287
+
166
288
  return result;
167
289
  }
168
290
 
@@ -768,6 +890,100 @@ export function loadCopilotHooksTemplate(templatePath: string, pkgRoot: string):
768
890
  }
769
891
  }
770
892
 
893
+ // --- Statusline ---
894
+
895
+ /** Copy statusline script to ~/.claude/ for the current platform */
896
+ export function copyStatusline(): boolean {
897
+ const claudeDir = platform.claudeDir();
898
+ mkdirSync(claudeDir, { recursive: true });
899
+
900
+ const isPlatformWin32 = process.platform === "win32";
901
+ const sourcePath = isPlatformWin32
902
+ ? assets.statuslineScriptPs1()
903
+ : assets.statuslineScriptBash();
904
+ const scriptName = isPlatformWin32 ? "statusline.ps1" : "statusline.sh";
905
+ const destPath = resolve(claudeDir, scriptName);
906
+
907
+ if (!existsSync(sourcePath)) {
908
+ log.warn(`Statusline script not found at ${sourcePath}`);
909
+ return false;
910
+ }
911
+
912
+ try {
913
+ copyFileSync(sourcePath, destPath);
914
+
915
+ // Make executable on Unix
916
+ if (process.platform !== "win32") {
917
+ chmodSync(destPath, 0o755);
918
+ }
919
+
920
+ log.success(`Statusline installed to ${destPath}`);
921
+ return true;
922
+ } catch (e) {
923
+ log.warn(`Failed to copy statusline script: ${e}`);
924
+ return false;
925
+ }
926
+ }
927
+
928
+ /** Remove statusline script from ~/.claude/ */
929
+ export function removeStatusline(): boolean {
930
+ const claudeDir = platform.claudeDir();
931
+ const isPlatformWin32 = process.platform === "win32";
932
+ const scriptName = isPlatformWin32 ? "statusline.ps1" : "statusline.sh";
933
+ const scriptPath = resolve(claudeDir, scriptName);
934
+
935
+ if (!existsSync(scriptPath)) {
936
+ log.info("Statusline script not found, nothing to remove");
937
+ return true;
938
+ }
939
+
940
+ try {
941
+ unlinkSync(scriptPath);
942
+ log.success(`Removed statusline script from ${scriptPath}`);
943
+ return true;
944
+ } catch (e) {
945
+ log.warn(`Failed to remove statusline script: ${e}`);
946
+ return false;
947
+ }
948
+ }
949
+
950
+ /** Add statusLine config to settings if not already present or if using old broken command */
951
+ export function addStatuslineConfig(
952
+ settings: Record<string, unknown>
953
+ ): Record<string, unknown> {
954
+ const statusLine = settings.statusLine as Record<string, unknown> | undefined;
955
+
956
+ // Check if already configured with a valid command (not the old broken one)
957
+ if (statusLine && typeof statusLine === "object" && statusLine.command) {
958
+ const cmd = statusLine.command as string;
959
+ // Keep existing config unless it's the old broken Get-Content pattern
960
+ if (!cmd.includes("Get-Content -Raw")) {
961
+ return settings;
962
+ }
963
+ }
964
+
965
+ const isPlatformWin32 = process.platform === "win32";
966
+ const command = isPlatformWin32
967
+ ? "powershell -NoProfile -File ~/.claude/statusline.ps1"
968
+ : "~/.claude/statusline.sh";
969
+
970
+ settings.statusLine = {
971
+ type: "command",
972
+ command,
973
+ padding: 2,
974
+ };
975
+
976
+ return settings;
977
+ }
978
+
979
+ /** Remove statusLine config from settings */
980
+ export function removeStatuslineConfig(
981
+ settings: Record<string, unknown>
982
+ ): Record<string, unknown> {
983
+ delete settings.statusLine;
984
+ return settings;
985
+ }
986
+
771
987
  // --- Skill Index ---
772
988
 
773
989
  interface SkillIndexEntry {