mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(windows): shell bins pass NUL as Bun's empty config when MSYS path conversion is off (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): detect Git Bash/MSYS2/Cygwin from OSTYPE, as gstack-doctor does, so uname stubs in setup fixtures stay untouched Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (#3415) - Choose NUL only when cygpath is also on PATH, so an OSTYPE=msys inherited by a POSIX bash keeps /dev/null instead of a cwd-relative NUL file. - gstack-developer-profile --log-session reports a Bun failure instead of skipping it as invalid input, like gstack-timeline-log. - learnings-injection tests check that their anchored bun -e block exists. - CHANGELOG names the callers this fix does not cover; curation reason fixed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes, cycle 2 (#3415) - timeline-log and developer-profile validators print their verdict; any non-zero Bun exit is a reported failure (a Bun crash on Windows exits 3). - timeline-log keeps the original event when ts stamping fails. - The definition ratchet scopes a local GSTACK_BUN_CONFIG to its function. - The shell-bins fixture clears its preload marker per test. - CHANGELOG states the callers that discard the report and the Bun 1.3.x case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG notes Bun 1.3.10 evidence and remaining literal /dev/null callers (#3415) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
188 lines
7.6 KiB
Bash
Executable File
188 lines
7.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-learnings-search — read and filter project learnings
|
|
# Usage: gstack-learnings-search [--type TYPE] [--query KEYWORD] [--limit N] [--cross-project]
|
|
#
|
|
# 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".
|
|
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
|
|
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"
|
|
|
|
TYPE=""
|
|
QUERY=""
|
|
LIMIT=10
|
|
CROSS_PROJECT=false
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--type) TYPE="$2"; shift 2 ;;
|
|
--query) QUERY="$2"; shift 2 ;;
|
|
--limit) LIMIT="$2"; shift 2 ;;
|
|
--cross-project) CROSS_PROJECT=true; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
|
|
|
# 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 "*/$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('');
|
|
}
|
|
"
|