From 9ad07253965b33540c4dfe6477fb48ca6675c28e Mon Sep 17 00:00:00 2001 From: Jayesh Betala Date: Wed, 20 May 2026 11:16:10 +0530 Subject: [PATCH] fix(learnings): preserve current entries in cross-project search --- bin/gstack-learnings-search | 44 ++++++++++++++++++++-------- test/gstack-learnings-search.test.ts | 28 ++++++++++++++++-- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 95825635ac..665be6fc1e 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -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) diff --git a/test/gstack-learnings-search.test.ts b/test/gstack-learnings-search.test.ts index 7218d60f13..bef562598e 100644 --- a/test/gstack-learnings-search.test.ts +++ b/test/gstack-learnings-search.test.ts @@ -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'); + }); +});