Files
gstack/bin/gstack-learnings-search
Garry Tanandcapy-ai[bot] 7204a39a27 v1.91.287.0 feat(write-skill): write, change and diet skills; /learn at half its template size (#3524)
* test(skill-evals): /learn before/after tasks, written before any cut (GSTA-133)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(learnings): --help, unknown-flag errors and --stats on the learnings bins; cross-project search no longer drops every project when the state root path contains the slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor(learn): diet /learn onto the levels: tool flags replace the inline stats program, one mode table, prune rules kept

Skill-Diet: learn
Skill-Size: 5797→2586 bytes (SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(write-skill): /write-skill skill, gstack-skill-bill, static skill-to-bin drift ratchet, shared skill eval contract

Skill-Size: 0→3245 bytes (write-skill/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(learn): guard the state-root lookup in Prune; re-measure the learn ceiling

Skill-Size: 2586→2707 bytes (learn/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (drift scanner, learnings flags, skill-bill baselines, eval fixtures)

- skill-bin-drift: a redirect no longer ends a command, so `bin/x 2>/dev/null --bogus` is checked
- gstack-learnings-search: a missing value or a non-positive --limit exits 2 like an unknown flag
- gstack-skill-bill: the gstack root skill is a checkout skill; Skill-Diet trailers match the exact name; the baseline counts the committed bytes untrimmed
- skill-evals task files get a free contract test and are registered in FREE_FIXTURES, with the drift allowlist
- learn eval: the no-code-change safety task no longer asks the model to fix code (its check penalised obeying the user)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(learn,write-skill): full bin paths in the mode table; prune asks per entry unless the request names the action

Skill-Diet: learn
Skill-Size: 2707→2955 bytes (learn/SKILL.md.tmpl)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): /write-skill, gstack-skill-bill, drift ratchet, eval contract, /learn diet

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: drift ratchet in CONTRIBUTING's static tests; /learn prune example asks per entry

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(skill-bill): report a checkout skill's eval task file and check it against the contract

The skill-evals task-file test now exercises lib/skill-bill.ts's checkEvalTasks, with a negative control, instead of a test-only validator (test-of-test ratchet).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(skill-bill): EVAL TASKS path uses forward slashes on every platform; a non-object task entry is a problem, not a crash

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(learn): BASH_COMPAT guard on in-window heredocs; count skill-e2e-write-skill in the paid census

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>
2026-10-28 02:58:29 +00:00

253 lines
10 KiB
Bash
Executable File

#!/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('');
}
"