#!/usr/bin/env bash # gstack-learnings-search — read and filter project learnings # Usage: gstack-learnings-search --help # # Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay, # resolves duplicates (latest winner per key+type), and outputs formatted text. # Exit 0 silently if no learnings file exists or nothing matches. Missing bun # exits 127 and a failed read exits non-zero with its stderr (#2790): "could # not read" must never look like "nothing recorded". # Heredoc delivery guard: bash 5.2+ pipes heredoc bodies <=64KiB, and on # macOS a starved 512-byte pipe hangs bodies >=512B at startup. Compat level # 50 restores the tempfile path (test/heredoc-pipe-deadlock.test.ts). BASH_COMPAT=50 set -euo pipefail GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac usage() { cat <<'EOF' Usage: gstack-learnings-search [--type TYPE] [--query WORDS] [--limit N=10] [--cross-project] gstack-learnings-search --stats gstack-learnings-search --help Prints this project's learnings, latest entry per key+type, grouped by type. --type TYPE only one type: pattern pitfall preference architecture tool operational investigation --query WORDS entries matching any word in key, insight or files, best match first --limit N at most N entries (default 10) --cross-project also read up to 5 other projects' user-stated learnings --stats counts for this project: TOTAL, UNIQUE, RAW_ENTRIES, BY_TYPE, BY_SOURCE, AVG_CONFIDENCE, or NO_LEARNINGS when nothing is recorded Prints nothing when nothing matches. Record a learning with gstack-learnings-log. EOF } TYPE="" QUERY="" LIMIT=10 CROSS_PROJECT=false STATS=false usage_error() { echo "gstack-learnings-search: $1" >&2; exit 2; } while [[ $# -gt 0 ]]; do case "$1" in --type|--query|--limit) [[ $# -ge 2 ]] || usage_error "$1 needs a value" ;; esac case "$1" in --type) TYPE="$2"; shift 2 ;; --query) QUERY="$2"; shift 2 ;; --limit) [[ "$2" =~ ^[1-9][0-9]*$ ]] || usage_error "--limit needs a positive integer, got '$2'"; LIMIT="$2"; shift 2 ;; --cross-project) CROSS_PROJECT=true; shift ;; --stats) STATS=true; shift ;; -h|--help) usage; exit 0 ;; *) usage_error "unknown argument '$1'; valid: --type --query --limit --cross-project --stats --help" ;; esac done SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select GSTACK_HOME="$_gstack_sr_root" LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" if [ "$STATS" = true ]; then if [ ! -f "$LEARNINGS_FILE" ]; then echo "NO_LEARNINGS" exit 0 fi command -v bun >/dev/null 2>&1 || { echo "gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2; exit 127; } echo "TOTAL: $(wc -l < "$LEARNINGS_FILE" | tr -d ' ') entries" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const seen = new Map(); for (const line of lines) { try { const e = JSON.parse(line); const dk = (e.key || '') + '|' + (e.type || ''); const existing = seen.get(dk); if (!existing || new Date(e.ts) > new Date(existing.ts)) seen.set(dk, e); } catch {} } const byType = Object.create(null); const bySource = Object.create(null); let totalConf = 0; for (const e of seen.values()) { byType[e.type] = (byType[e.type] || 0) + 1; bySource[e.source] = (bySource[e.source] || 0) + 1; totalConf += e.confidence || 0; } console.log('UNIQUE: ' + seen.size + ' (after dedup)'); console.log('RAW_ENTRIES: ' + lines.length); console.log('BY_TYPE: ' + JSON.stringify(byType)); console.log('BY_SOURCE: ' + JSON.stringify(bySource)); console.log('AVG_CONFIDENCE: ' + (seen.size ? (totalConf / seen.size).toFixed(1) : '0.0')); " < "$LEARNINGS_FILE" exit 0 fi # Collect cross-project JSONL files separately so the trust gate can distinguish # current-project rows from rows loaded from other projects. CROSS_FILES=() if [ "$CROSS_PROJECT" = true ]; then # Add other projects' learnings (max 5) while IFS= read -r f; do CROSS_FILES+=("$f") [ ${#CROSS_FILES[@]} -ge 5 ] && break done < <(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "$GSTACK_HOME/projects/$SLUG/*" 2>/dev/null) fi if [ ! -f "$LEARNINGS_FILE" ] && [ ${#CROSS_FILES[@]} -eq 0 ]; then exit 0 fi command -v bun >/dev/null 2>&1 || { echo "gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2 exit 127 } emit_tagged_file() { local tag="$1" local file="$2" local line while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] && printf '%s\t%s\n' "$tag" "$line" done < "$file" } # Process all files through bun for JSON parsing, decay, dedup, filtering { [ -f "$LEARNINGS_FILE" ] && emit_tagged_file current "$LEARNINGS_FILE" if [ ${#CROSS_FILES[@]} -gt 0 ]; then for f in "${CROSS_FILES[@]}"; do emit_tagged_file cross "$f" done fi } | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const now = Date.now(); const type = process.env.GSTACK_SEARCH_TYPE || ''; const queryRaw = (process.env.GSTACK_SEARCH_QUERY || '').toLowerCase(); const queryTokens = queryRaw.split(/\s+/).filter(Boolean); const limit = parseInt(process.env.GSTACK_SEARCH_LIMIT || '10', 10); const entries = []; for (const taggedLine of lines) { try { const tabIndex = taggedLine.indexOf('\t'); const sourceTag = tabIndex === -1 ? 'current' : taggedLine.slice(0, tabIndex); const line = tabIndex === -1 ? taggedLine : taggedLine.slice(tabIndex + 1); const e = JSON.parse(line); if (!e.key || !e.type) continue; // Apply confidence decay: observed/inferred lose 1pt per 30 days let conf = e.confidence || 5; if (e.source === 'observed' || e.source === 'inferred') { const days = Math.floor((now - new Date(e.ts).getTime()) / 86400000); conf = Math.max(0, conf - Math.floor(days / 30)); } e._effectiveConfidence = conf; // Determine if this is from the current project or cross-project // Cross-project entries are tagged for display const isCrossProject = sourceTag === 'cross'; e._crossProject = isCrossProject; // Trust gate: cross-project learnings only loaded if trusted (user-stated). // This prevents prompt injection from one project's AI-generated learnings // silently influencing reviews in another project. // #1745: this is an ALLOWLIST, not a denylist. The old equals-false check // admitted any row where trusted is missing/undefined (legacy rows written // before the field existed, hand-edited rows, rows from other tools). // Require trusted to be exactly true. NOTE: this whole block is a // double-quoted bun -e string, so bash still does command substitution // inside it. Keep backticks and dollar-paren out of these comments. if (isCrossProject && e.trusted !== true) continue; entries.push(e); } catch {} } // Dedup: latest winner per key+type const seen = new Map(); for (const e of entries) { const dk = e.key + '|' + e.type; const existing = seen.get(dk); if (!existing || new Date(e.ts) > new Date(existing.ts)) { seen.set(dk, e); } } let results = Array.from(seen.values()); // Filter by type if (type) results = results.filter(e => e.type === type); // Filter by query (token-OR: match if ANY whitespace-split token appears in ANY // haystack) and count how many distinct tokens each entry matched (#2762). The // count lives in a local map, never on the entry: a stored field from disk // must not become a sort key. const tokenHits = new Map(); if (queryTokens.length > 0) results = results.filter(e => { const haystacks = [String(e.key || '').toLowerCase(), String(e.insight || '').toLowerCase(), ...(Array.isArray(e.files) ? e.files : []).map(f => String(f).toLowerCase())]; const hits = [...new Set(queryTokens)].filter(tok => haystacks.some(h => h.includes(tok))).length; tokenHits.set(e, hits); return hits > 0; }); // Sort by matched tokens desc, then effective confidence desc, then recency. // Ranking on confidence alone let high-confidence single-token matches push an // entry matching every token past the limit. results.sort((a, b) => { const ah = tokenHits.get(a) || 0, bh = tokenHits.get(b) || 0; if (bh !== ah) return bh - ah; if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence; return new Date(b.ts).getTime() - new Date(a.ts).getTime(); }); // Limit, and say when a query matched more than fit. const totalMatched = results.length; results = results.slice(0, limit); if (results.length === 0) process.exit(0); // Format output. A null-prototype map: a row typed 'constructor' or // '__proto__' must not find an inherited property and blank the whole run. const byType = Object.create(null); for (const e of results) { const t = e.type || 'unknown'; if (!byType[t]) byType[t] = []; byType[t].push(e); } // Summary line const counts = Object.entries(byType).map(([t, arr]) => arr.length + ' ' + t + (arr.length > 1 ? 's' : '')); const moreNotice = queryTokens.length > 0 && totalMatched > results.length ? ' -- ' + (totalMatched - results.length) + ' more matched, raise --limit to see them' : ''; console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')' + moreNotice); console.log(''); for (const [t, arr] of Object.entries(byType)) { console.log('## ' + t.charAt(0).toUpperCase() + t.slice(1) + 's'); for (const e of arr) { const cross = e._crossProject ? ' [cross-project]' : ''; const files = e.files?.length ? ' (files: ' + e.files.join(', ') + ')' : ''; console.log('- [' + e.key + '] (confidence: ' + e._effectiveConfidence + '/10, ' + e.source + ', ' + (e.ts || '').split('T')[0] + ')' + cross); console.log(' ' + e.insight + files); } console.log(''); } "