Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions bin/gstack-learnings-search
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
Expand Down
28 changes: 25 additions & 3 deletions test/gstack-learnings-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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');
});
});
Loading