mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-13 16:04:58 +02:00
1a4f0c9c15
* fix(learnings): use token-OR matching in gstack-learnings-search --query
Split the query on whitespace into tokens; a learning matches if ANY
token appears as a substring in ANY of key/insight/files. Previously
the whole query was a single substring, so multi-word queries like
"debug investigation" only matched learnings whose insight contained
that exact contiguous phrase, which is usually nothing.
Whitespace-only query falls through to no-query (matches today's no-flag
behavior). Single-word queries behave exactly as before.
Adds test/gstack-learnings-search.test.ts: 3 assertions covering
multi-token, single-token, and no-query backwards compat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(resolver): parameterized LEARNINGS_SEARCH with shell-injection guard
The {{LEARNINGS_SEARCH}} macro now accepts a query=KEYWORD argument that
gets interpolated as --query "<keyword>" into the generated bash. Empty
value falls through to no-query (principle of least surprise: a stray
{{LEARNINGS_SEARCH:query=}} placeholder gets today's behavior, not a
build failure). Pattern reuses the parameterized-macro parsing from
composition.ts. The 13 templates that don't pass a query stay
byte-identical in their generated SKILL.md output.
Shell-injection guard: the query value is whitelisted to
^[A-Za-z0-9 _-]+$ at gen-skill-docs time. Any \$(), backticks,
semicolons, or quotes throw a loud build error instead of emitting
executable bash. Static template queries are safe by inspection;
this defends against future contributors writing dangerous values.
Adds 5 assertions to test/gen-skill-docs.test.ts covering no-args,
claude+query=foo bar on both cross-project and project-scoped branches,
codex host variant, empty value semantics, and shell-injection payloads
(\$(whoami), backticks, ;, &, ", \\, \$x) throwing build errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(skills): task-shaped queries + mid-flow refresh in /investigate /qa /ship
The three long skills now pull learnings keyed to their theme at the
top, then re-pull at phase boundaries as work shifts to new sub-tasks.
Top-of-skill queries (5-6 token unions, token-OR matched):
- investigate: "debug investigation root cause hypothesis bug fix"
- qa: "qa testing bug regression flake fixture"
- ship: "release ship version changelog merge pr"
Mid-flow refresh blocks (concrete keyword recipe + worked examples):
- investigate: between Phase 1 (hypothesis) and Phase 2 (analysis),
keyed to the hypothesis noun. Examples: auth-cookie, session-expiry.
- qa: between Phase 7 (triage) and Phase 8 (fix loop), keyed to the
buggy component name. Examples: checkout-button, signup-form.
- ship: just before Step 12 (VERSION bump), keyed to the headline
feature. Examples: learnings-search, pacing, worktree-ship.
Keyword recipe enforces alphanumeric+hyphen only (no quotes, slashes,
dots, colons) so dynamic queries cannot inject shell metacharacters.
The other 13 short-lived skills keep the bare {{LEARNINGS_SEARCH}} form.
Backwards-compat verified via diff: their generated SKILL.md output is
byte-identical to before this change.
Golden ship fixtures regenerated to match the new ship/SKILL.md output.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: bump version and changelog (v1.33.1.0)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: refresh codex+factory ship golden fixtures
Follow-up to 513c9660 — the codex and factory host outputs needed
regeneration too, missed in the initial commit because gen:skill-docs
was only run for the claude host. Now matches gen:skill-docs --host all.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
139 lines
5.0 KiB
Bash
Executable File
139 lines
5.0 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.
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
|
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
|
|
|
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 all JSONL files to search
|
|
FILES=()
|
|
[ -f "$LEARNINGS_FILE" ] && FILES+=("$LEARNINGS_FILE")
|
|
|
|
if [ "$CROSS_PROJECT" = true ]; then
|
|
# Add other projects' learnings (max 5, sorted by mtime)
|
|
for f in $(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "*/$SLUG/*" 2>/dev/null | head -5); do
|
|
FILES+=("$f")
|
|
done
|
|
fi
|
|
|
|
if [ ${#FILES[@]} -eq 0 ]; then
|
|
exit 0
|
|
fi
|
|
|
|
# Process all files through bun for JSON parsing, decay, dedup, filtering
|
|
GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" \
|
|
cat "${FILES[@]}" 2>/dev/null | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_SLUG="$SLUG" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun -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 slug = process.env.GSTACK_SEARCH_SLUG || '';
|
|
|
|
const entries = [];
|
|
for (const line of lines) {
|
|
try {
|
|
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 = !line.includes(slug) && process.env.GSTACK_SEARCH_CROSS === 'true';
|
|
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.
|
|
if (isCrossProject && e.trusted === false) 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)
|
|
if (queryTokens.length > 0) results = results.filter(e => {
|
|
const haystacks = [(e.key || '').toLowerCase(), (e.insight || '').toLowerCase(), ...(e.files || []).map(f => f.toLowerCase())];
|
|
return queryTokens.some(tok => haystacks.some(h => h.includes(tok)));
|
|
});
|
|
|
|
// Sort by effective confidence desc, then recency
|
|
results.sort((a, b) => {
|
|
if (b._effectiveConfidence !== a._effectiveConfidence) return b._effectiveConfidence - a._effectiveConfidence;
|
|
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
|
});
|
|
|
|
// Limit
|
|
results = results.slice(0, limit);
|
|
|
|
if (results.length === 0) process.exit(0);
|
|
|
|
// Format output
|
|
const byType = {};
|
|
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' : ''));
|
|
console.log('LEARNINGS: ' + results.length + ' loaded (' + counts.join(', ') + ')');
|
|
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('');
|
|
}
|
|
" 2>/dev/null || exit 0
|