mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
fix(learnings): preserve current entries in cross-project search
This commit is contained in:
+31
-13
@@ -27,35 +27,53 @@ done
|
||||
|
||||
LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl"
|
||||
|
||||
# Collect all JSONL files to search
|
||||
FILES=()
|
||||
[ -f "$LEARNINGS_FILE" ] && FILES+=("$LEARNINGS_FILE")
|
||||
# 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, 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
|
||||
# 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 [ ${#FILES[@]} -eq 0 ]; then
|
||||
if [ ! -f "$LEARNINGS_FILE" ] && [ ${#CROSS_FILES[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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
|
||||
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 "
|
||||
{
|
||||
[ -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 -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) {
|
||||
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;
|
||||
|
||||
@@ -69,7 +87,7 @@ for (const line of lines) {
|
||||
|
||||
// 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';
|
||||
const isCrossProject = sourceTag === 'cross';
|
||||
e._crossProject = isCrossProject;
|
||||
|
||||
// Trust gate: cross-project learnings only loaded if trusted (user-stated)
|
||||
|
||||
@@ -12,6 +12,7 @@ const tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-search-cwd-'));
|
||||
// gstack-slug derives slug from git remote (none here) → falls back to basename of cwd.
|
||||
const slug = path.basename(tmpCwd).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
const projDir = path.join(tmpHome, 'projects', slug);
|
||||
const otherProjDir = path.join(tmpHome, 'projects', 'other-project');
|
||||
|
||||
function run(args: string[]): string {
|
||||
return execFileSync(BIN, args, {
|
||||
@@ -23,12 +24,18 @@ function run(args: string[]): string {
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(projDir, { recursive: true });
|
||||
fs.mkdirSync(otherProjDir, { recursive: true });
|
||||
const entries = [
|
||||
{ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', key: 'foo-pattern', insight: 'A foo-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-02T00:00:00Z', skill: 'test', type: 'pitfall', key: 'bar-pitfall', insight: 'A bar-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-03T00:00:00Z', skill: 'test', type: 'pattern', key: 'baz-pattern', insight: 'A baz-related insight', confidence: 8, source: 'observed', files: [] },
|
||||
{ ts: '2026-05-01T00:00:00Z', skill: 'test', type: 'pattern', key: 'foo-pattern', insight: 'A foo-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-02T00:00:00Z', skill: 'test', type: 'pitfall', key: 'bar-pitfall', insight: 'A bar-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-03T00:00:00Z', skill: 'test', type: 'pattern', key: 'baz-pattern', insight: 'A baz-related insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
];
|
||||
const otherEntries = [
|
||||
{ ts: '2026-05-04T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-observed', insight: 'A foreign observed insight', confidence: 8, source: 'observed', trusted: false, files: [] },
|
||||
{ ts: '2026-05-05T00:00:00Z', skill: 'test', type: 'pattern', key: 'foreign-user', insight: 'A foreign user-stated insight', confidence: 8, source: 'user-stated', trusted: true, files: [] },
|
||||
];
|
||||
fs.writeFileSync(path.join(projDir, 'learnings.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
fs.writeFileSync(path.join(otherProjDir, 'learnings.jsonl'), otherEntries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -58,3 +65,18 @@ describe('gstack-learnings-search token-OR query semantics', () => {
|
||||
expect(out).toContain('baz-pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-learnings-search cross-project trust gating', () => {
|
||||
test('cross-project mode still includes observed entries from the current project', () => {
|
||||
const out = run(['--cross-project', '--query', 'foo']);
|
||||
expect(out).toContain('foo-pattern');
|
||||
expect(out).not.toContain('[cross-project]');
|
||||
});
|
||||
|
||||
test('cross-project mode only imports trusted entries from other projects', () => {
|
||||
const out = run(['--cross-project', '--query', 'foreign']);
|
||||
expect(out).toContain('foreign-user');
|
||||
expect(out).toContain('[cross-project]');
|
||||
expect(out).not.toContain('foreign-observed');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user