diff --git a/CHANGELOG.md b/CHANGELOG.md index a7239b4a3..2ccf614cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.15.3.0] - 2026-04-03 — Scoped Learnings via Named Groups + +Learnings are no longer all-or-nothing. You can now organize projects into named groups, and gstack shares knowledge within each group. A contractor working for three clients creates three groups. A solo dev puts personal projects in "Personal" and work repos in "Work". Each group gets its own knowledge boundary. + +### Added + +- **Learnings groups.** `gstack-group create Work`, `gstack-group assign Work`, done. Every project belongs to exactly one group. When gstack searches for learnings, it finds knowledge from all projects in your group, not just the current repo. Default group is "Personal". +- **Prompt-on-first-use.** First time you access learnings in a new repo, gstack asks which group it belongs to. Shows smart suggestions based on your git org (repos with matching owners are recommended first). +- **`/learn group` subcommand.** Manage groups without leaving the skill session. List groups, assign projects, create new groups, check which group you're in. +- **Provenance tags.** When learnings come from a different repo in your group, you see exactly where: `[from: other-repo]`. No more mystery knowledge. +- **Group-aware export.** `/learn export` now includes the group name and source attribution per learning. +- **Smart migration.** Existing users get automatic migration that honors their previous preference: if you had cross-project learnings enabled, all repos go to "Personal" (sharing preserved). If disabled or unset, each repo gets its own group (isolation preserved). + +### Changed + +- **Dedup by insight text, not just key.** Two repos can now independently discover the same pattern key with different insights, and both are preserved. Only exact duplicate insights are collapsed (highest confidence wins). +- **Search uses env vars for user input.** `--query` values now pass through `Bun.env` instead of shell interpolation, closing a latent injection vector. +- **Single bun process for search.** Merged 4 bun invocations into 2 on the hot path (~100ms faster per search). + ## [0.15.2.1] - 2026-04-02 — Setup Runs Migrations `git pull && ./setup` now applies version migrations automatically. Previously, migrations only ran during `/gstack-upgrade`, so users who updated via git pull never got state fixes (like the skill directory restructure from v0.15.1.0). Now `./setup` tracks the last version it ran at and applies any pending migrations on every run. diff --git a/VERSION b/VERSION index edfc413fa..d453134b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.2.1 +0.15.3.0 diff --git a/bin/gstack-config b/bin/gstack-config index c118a322a..e5f8702a6 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -38,6 +38,12 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # skill_prefix: false # true = namespace skills as /gstack-qa, /gstack-ship # # false = short names /qa, /ship # +# ─── Learnings ──────────────────────────────────────────────────────── +# learning_scope: group # project | group | global +# # project — just this repo (old cross_project_learnings=false) +# # group — all projects in the same learnings group (default) +# # global — all projects on this machine +# # ─── Advanced ──────────────────────────────────────────────────────── # codex_reviews: enabled # disabled = skip Codex adversarial reviews in /ship # gstack_contributor: false # true = file field reports when gstack misbehaves diff --git a/bin/gstack-group b/bin/gstack-group new file mode 100755 index 000000000..4981b4382 --- /dev/null +++ b/bin/gstack-group @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# gstack-group — manage learnings groups +# +# Usage: +# gstack-group list — show all groups and their projects +# gstack-group list --json — machine-readable JSON output +# gstack-group create — create a new group (idempotent) +# gstack-group assign [] — assign current project to a group +# gstack-group which — show which group the current project belongs to +# +# Groups are stored in ~/.gstack/groups.json. Learnings stay in per-project +# JSONL files; groups only control which projects share learnings during search. +# +# On first access, migration runs: creates groups.json with "Personal" group +# and assigns existing projects based on cross_project_learnings config. +# +# Env overrides (for testing): +# GSTACK_HOME — override ~/.gstack state directory +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +export GSTACK_STATE_DIR="${GSTACK_STATE_DIR:-$GSTACK_HOME}" +GROUPS_FILE="$GSTACK_HOME/groups.json" + +# ── Helpers ──────────────────────────────────────────────────────────── +log_event() { + mkdir -p "$GSTACK_HOME/analytics" + echo "$1" >> "$GSTACK_HOME/analytics/group-events.jsonl" 2>/dev/null || true +} + +write_groups() { + local content="$1" + local tmpfile + tmpfile="$(mktemp "${GROUPS_FILE}.XXXXXX")" + printf '%s' "$content" > "$tmpfile" + mv "$tmpfile" "$GROUPS_FILE" +} + +# ── Migration ────────────────────────────────────────────────────────── +ensure_groups() { + if [ -f "$GROUPS_FILE" ]; then + return 0 + fi + + mkdir -p "$GSTACK_HOME" + + local cross_proj + cross_proj=$("$SCRIPT_DIR/gstack-config" get cross_project_learnings 2>/dev/null || echo "unset") + + # Discover existing projects with learnings, build JSON array in bash + local json_arr="[" + local first=true + if [ -d "$GSTACK_HOME/projects" ]; then + for f in "$GSTACK_HOME/projects"/*/learnings.jsonl; do + if [ -f "$f" ]; then + local proj_slug + proj_slug=$(basename "$(dirname "$f")") + $first || json_arr+="," + json_arr+="\"$proj_slug\"" + first=false + fi + done + fi + json_arr+="]" + + # Single bun call to build groups.json + local tmpfile + tmpfile="$(mktemp "${GROUPS_FILE}.XXXXXX")" + + local mode="solo" + [ "$cross_proj" = "true" ] && mode="shared" + + bun -e " + const projects = $json_arr; + const mode = '$mode'; + const groups = { Personal: { created: new Date().toISOString() } }; + const projectMap = {}; + if (mode === 'shared') { + for (const p of projects) projectMap[p] = 'Personal'; + } else { + for (const p of projects) { + groups[p] = { created: new Date().toISOString() }; + projectMap[p] = p; + } + } + console.log(JSON.stringify({ groups, projects: projectMap }, null, 2)); + " > "$tmpfile" 2>/dev/null + + mv "$tmpfile" "$GROUPS_FILE" + + if [ "$cross_proj" = "true" ]; then + "$SCRIPT_DIR/gstack-config" set learning_scope global 2>/dev/null || true + elif [ "$cross_proj" = "false" ]; then + "$SCRIPT_DIR/gstack-config" set learning_scope project 2>/dev/null || true + else + "$SCRIPT_DIR/gstack-config" set learning_scope group 2>/dev/null || true + fi + + log_event '{"event":"migration","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","cross_proj":"'"$cross_proj"'"}' +} + +# ── Commands ─────────────────────────────────────────────────────────── +cmd_list() { + ensure_groups + + local json_flag="${1:-}" + + if [ "$json_flag" = "--json" ]; then + cat "$GROUPS_FILE" + return 0 + fi + + bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + const groups = Object.keys(data.groups || {}); + const projects = data.projects || {}; + for (const g of groups) { + const members = Object.entries(projects).filter(([_, v]) => v === g).map(([k]) => k); + console.log(g + ' (' + members.length + ' project' + (members.length !== 1 ? 's' : '') + ')'); + for (const m of members) console.log(' - ' + m); + } + " 2>/dev/null +} + +cmd_create() { + local name="${1:?Usage: gstack-group create }" + + if ! printf '%s' "$name" | grep -qE '^[a-zA-Z0-9._-]+$'; then + echo "Error: group name must contain only alphanumeric characters, dots, hyphens, and underscores" >&2 + exit 1 + fi + + ensure_groups + + local result + result=$(bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + if (data.groups['$name']) { + console.log('EXISTS'); + } else { + data.groups['$name'] = { created: new Date().toISOString() }; + console.log(JSON.stringify(data, null, 2)); + } + " 2>/dev/null) + + if [ "$result" = "EXISTS" ]; then + return 0 + fi + + write_groups "$result" + log_event '{"event":"group_create","group":"'"$name"'","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' +} + +cmd_assign() { + local group="${1:-}" + + ensure_groups + + if [ -z "$group" ]; then + echo "Usage: gstack-group assign " >&2 + echo "Available groups:" >&2 + bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + for (const g of Object.keys(data.groups || {})) console.log(' ' + g); + " 2>/dev/null >&2 + exit 1 + fi + + # Single bun call: verify group exists + assign + local result + result=$(bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + if (!data.groups['$group']) { + console.log('NOT_FOUND'); + } else { + data.projects = data.projects || {}; + data.projects['$SLUG'] = '$group'; + console.log(JSON.stringify(data, null, 2)); + } + " 2>/dev/null) + + if [ "$result" = "NOT_FOUND" ] || [ -z "$result" ]; then + echo "Error: group '$group' not found." >&2 + echo "Available groups:" >&2 + bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + for (const g of Object.keys(data.groups || {})) console.log(' ' + g); + " 2>/dev/null >&2 + exit 1 + fi + + write_groups "$result" + log_event '{"event":"group_assign","project":"'"$SLUG"'","group":"'"$group"'","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' +} + +cmd_which() { + ensure_groups + + bun -e " + try { + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + const g = (data.projects || {})['$SLUG']; + console.log(g || 'NO_GROUP'); + } catch { + console.log('NO_GROUP'); + } + " 2>/dev/null || echo "NO_GROUP" +} + +cmd_suggest() { + ensure_groups + + bun -e " + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + const slug = '$SLUG'; + const owner = slug.split('-')[0] || ''; + const groups = Object.keys(data.groups || {}); + const projects = data.projects || {}; + + const scored = groups.map(g => { + const members = Object.entries(projects).filter(([_, v]) => v === g).map(([k]) => k); + const ownerMatches = members.filter(m => m.split('-')[0] === owner).length; + return { group: g, ownerMatches, memberCount: members.length }; + }); + + scored.sort((a, b) => { + if (b.ownerMatches !== a.ownerMatches) return b.ownerMatches - a.ownerMatches; + if (b.memberCount !== a.memberCount) return b.memberCount - a.memberCount; + return a.group.localeCompare(b.group); + }); + + for (const s of scored) { + const tag = s.ownerMatches > 0 ? ' (matches owner)' : ''; + console.log(s.group + tag); + } + " 2>/dev/null +} + +# ── Dispatch ─────────────────────────────────────────────────────────── +case "${1:-}" in + list) + shift + cmd_list "${1:-}" + ;; + create) + shift + cmd_create "${1:?Usage: gstack-group create }" + ;; + assign) + shift + cmd_assign "${1:-}" + ;; + which) + cmd_which + ;; + suggest) + cmd_suggest + ;; + *) + echo "Usage: gstack-group {list|create|assign|which|suggest} [args]" + exit 1 + ;; +esac diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 4ac187ec1..cd4bfb1fc 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -1,9 +1,16 @@ #!/usr/bin/env bash # gstack-learnings-search — read and filter project learnings -# Usage: gstack-learnings-search [--type TYPE] [--query KEYWORD] [--limit N] [--cross-project] +# Usage: gstack-learnings-search [--type TYPE] [--query KEYWORD] [--limit N] [--scope project|group|global] [--cross-project] +# +# Reads learnings JSONL files, applies confidence decay, resolves duplicates, +# and outputs formatted text. +# +# Scope modes: +# --scope project (default) — just this project's learnings +# --scope group — all projects in the same learnings group +# --scope global — all projects on this machine +# --cross-project — backward-compat alias for --scope global # -# 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)" @@ -13,99 +20,144 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" TYPE="" QUERY="" LIMIT=10 -CROSS_PROJECT=false +SCOPE="project" 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 ;; + --scope) SCOPE="$2"; shift 2 ;; + --cross-project) SCOPE="global"; 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 +GROUPS_FILE="$GSTACK_HOME/groups.json" + +# Collect files to search, each as "slug:filepath" for provenance +FILE_ENTRIES=() + +case "$SCOPE" in + project) + [ -f "$LEARNINGS_FILE" ] && FILE_ENTRIES+=("$SLUG:$LEARNINGS_FILE") + ;; + group) + if [ -f "$GROUPS_FILE" ]; then + # Single bun call: look up group + enumerate members + group_members=$(bun -e " + try { + const data = JSON.parse(await Bun.file('$GROUPS_FILE').text()); + const g = (data.projects || {})['$SLUG']; + if (!g) { console.log('NO_GROUP'); process.exit(0); } + const members = Object.entries(data.projects || {}) + .filter(([_, v]) => v === g) + .map(([k]) => k); + console.log(members.join('\n')); + } catch { console.log('NO_GROUP'); } + " 2>/dev/null || echo "NO_GROUP") + + if [ "$group_members" = "NO_GROUP" ]; then + echo "UNASSIGNED_GROUP" >&2 + [ -f "$LEARNINGS_FILE" ] && FILE_ENTRIES+=("$SLUG:$LEARNINGS_FILE") + else + while IFS= read -r member; do + member_file="$GSTACK_HOME/projects/$member/learnings.jsonl" + [ -f "$member_file" ] && FILE_ENTRIES+=("$member:$member_file") + done <<< "$group_members" + fi + else + [ -f "$LEARNINGS_FILE" ] && FILE_ENTRIES+=("$SLUG:$LEARNINGS_FILE") + fi + ;; + global) + [ -f "$LEARNINGS_FILE" ] && FILE_ENTRIES+=("$SLUG:$LEARNINGS_FILE") + if [ -d "$GSTACK_HOME/projects" ]; then + for f in "$GSTACK_HOME/projects"/*/learnings.jsonl; do + if [ -f "$f" ]; then + local_slug=$(basename "$(dirname "$f")") + [ "$local_slug" != "$SLUG" ] && FILE_ENTRIES+=("$local_slug:$f") + fi + done + fi + ;; +esac + +if [ ${#FILE_ENTRIES[@]} -eq 0 ]; then exit 0 fi -# Process all files through bun for JSON parsing, decay, dedup, filtering -cat "${FILES[@]}" 2>/dev/null | bun -e " -const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); +# Single bun process: parse entries from stdin, read files, apply decay, dedup, filter, format +export GSTACK_FILTER_TYPE="$TYPE" +export GSTACK_FILTER_QUERY="$QUERY" +export GSTACK_FILTER_LIMIT="$LIMIT" +export GSTACK_CURRENT_SLUG="$SLUG" + +printf '%s\n' "${FILE_ENTRIES[@]}" | bun -e " +const stdin = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); +const fileEntries = stdin.map(l => { + const idx = l.indexOf(':'); + return { slug: l.substring(0, idx), path: l.substring(idx + 1) }; +}); const now = Date.now(); -const type = '${TYPE}'; -const query = '${QUERY}'.toLowerCase(); -const limit = ${LIMIT}; -const slug = '${SLUG}'; +const type = Bun.env.GSTACK_FILTER_TYPE || ''; +const query = (Bun.env.GSTACK_FILTER_QUERY || '').toLowerCase(); +const limit = parseInt(Bun.env.GSTACK_FILTER_LIMIT || '10', 10); +const currentSlug = Bun.env.GSTACK_CURRENT_SLUG || ''; const entries = []; -for (const line of lines) { +for (const { slug, path } of fileEntries) { 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)); + const text = await Bun.file(path).text(); + for (const line of text.trim().split('\n').filter(Boolean)) { + try { + const e = JSON.parse(line); + if (!e.key || !e.type) continue; + + 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; + e._sourceSlug = slug; + e._crossProject = slug !== currentSlug; + + entries.push(e); + } catch {} } - e._effectiveConfidence = conf; - - // Determine if this is from the current project or cross-project - // Cross-project entries are tagged for display - e._crossProject = !line.includes(slug) && '${CROSS_PROJECT}' === 'true'; - - entries.push(e); } catch {} } -// Dedup: latest winner per key+type +// Dedup: same insight text = collapse (keep highest confidence). +// Different insights with same key+type = show both with provenance. const seen = new Map(); for (const e of entries) { - const dk = e.key + '|' + e.type; + const dk = e.key + '|' + e.type + '|' + (e.insight || '').trim(); const existing = seen.get(dk); - if (!existing || new Date(e.ts) > new Date(existing.ts)) { + if (!existing || e._effectiveConfidence > existing._effectiveConfidence || + (e._effectiveConfidence === existing._effectiveConfidence && 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 if (query) results = results.filter(e => (e.key || '').toLowerCase().includes(query) || (e.insight || '').toLowerCase().includes(query) || (e.files || []).some(f => f.toLowerCase().includes(query)) ); -// 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'; @@ -113,7 +165,6 @@ for (const e of results) { 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(''); @@ -121,7 +172,7 @@ 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 cross = e._crossProject ? ' [from: ' + e._sourceSlug + ']' : ''; 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); diff --git a/cso/SKILL.md b/cso/SKILL.md index 6540eac1a..27f91779e 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -544,33 +544,42 @@ This is NOT a checklist — it's a reasoning phase. The output is understanding, ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 7052ba7d9..d7d301e18 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -613,33 +613,42 @@ If `DESIGN_NOT_AVAILABLE`: Phase 5 falls back to the HTML preview page (still go ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/design-review/SKILL.md b/design-review/SKILL.md index b634d1879..9cdc11602 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -783,33 +783,42 @@ echo "REPORT_DIR: $REPORT_DIR" ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 3f57ded9b..e30386c4f 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -502,33 +502,42 @@ Gather context before forming any hypothesis. ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/learn/SKILL.md b/learn/SKILL.md index e8f6055c2..c3d1d1566 100644 --- a/learn/SKILL.md +++ b/learn/SKILL.md @@ -479,21 +479,31 @@ Parse the user's input to determine which command to run: - `/learn export` → **Export** - `/learn stats` → **Stats** - `/learn add` → **Manual add** +- `/learn group` → **Group management** +- `/learn group list` → **Group list** +- `/learn group assign` → **Group assign** +- `/learn group create ` → **Group create** +- `/learn group which` → **Group which** --- ## Show recent (default) -Show the most recent 20 learnings, grouped by type. +Show the most recent 20 learnings from this project's learnings group, grouped by type. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --limit 20 2>/dev/null || echo "No learnings yet." +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -Present the output in a readable format. If no learnings exist, tell the user: -"No learnings recorded yet. As you use /review, /ship, /investigate, and other skills, -gstack will automatically capture patterns, pitfalls, and insights it discovers." +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 20 2>/dev/null || echo "No learnings yet." +``` + +Present the output in a readable format. Include the group name from the first command +if available (e.g., "Showing learnings from group: Work"). If no learnings exist, tell +the user: "No learnings recorded yet. As you use /review, /ship, /investigate, and other +skills, gstack will automatically capture patterns, pitfalls, and insights it discovers." --- @@ -501,10 +511,11 @@ gstack will automatically capture patterns, pitfalls, and insights it discovers. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --query "USER_QUERY" --limit 20 2>/dev/null || echo "No matches." +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --query "USER_QUERY" --limit 20 2>/dev/null || echo "No matches." ``` -Replace USER_QUERY with the user's search terms. Present results clearly. +Replace USER_QUERY with the user's search terms. Searches within the project's learnings +group by default. Present results clearly. --- @@ -544,25 +555,31 @@ Export learnings as markdown suitable for adding to CLAUDE.md or project documen ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --limit 50 2>/dev/null +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -Format the output as a markdown section: +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 50 2>/dev/null +``` + +Format the output as a markdown section. Include the group name and source projects: ```markdown -## Project Learnings +## Project Learnings (Group: GROUP_NAME) + +Sources: project-a, project-b ### Patterns -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ### Pitfalls -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ### Preferences - **[key]**: [insight] ### Architecture -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ``` Present the formatted output to the user. Ask if they want to append it to CLAUDE.md @@ -572,47 +589,26 @@ or save it as a separate file. ## Stats -Show summary statistics about the project's learnings. +Show summary statistics about the project's learnings group. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -LEARN_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" -if [ -f "$LEARN_FILE" ]; then - TOTAL=$(wc -l < "$LEARN_FILE" | tr -d ' ') - echo "TOTAL: $TOTAL entries" - # Count by type (after dedup) - cat "$LEARN_FILE" | bun -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 = {}; - const bySource = {}; - 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: ' + (totalConf / seen.size).toFixed(1)); - " 2>/dev/null -else - echo "NO_LEARNINGS" -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -Present the stats in a readable table format. +```bash +~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups" +``` + +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 100 2>/dev/null || echo "No learnings" +``` + +Present the stats in a readable table format. Include: +- Group name and member projects +- Total learnings (across group) +- Breakdown by type and source +- Average confidence --- @@ -630,3 +626,60 @@ Then log it: ```bash ~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"learn","type":"TYPE","key":"KEY","insight":"INSIGHT","confidence":N,"source":"user-stated","files":["FILE1"]}' ``` + +--- + +## Group management + +Manage which learnings group this project belongs to. Learnings groups let gstack share +knowledge across related projects (e.g., all repos in your company's org). + +### Group list + +Show all groups and their member projects. + +```bash +~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups configured yet." +``` + +Present the output as a formatted table. + +### Group which + +Show which group the current project belongs to. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" +``` + +If `NO_GROUP`, tell the user this project isn't in a group yet and offer to assign it. + +### Group assign + +Assign this project to an existing group. Show available groups with smart suggestions: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null +``` + +Use AskUserQuestion to present the groups as options. Groups marked "(matches owner)" share +the same git org as this project and are likely the best fit. + +After the user picks a group: + +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +### Group create + +Create a new learnings group. Use AskUserQuestion to ask for the group name. + +Group names must be alphanumeric with dots, hyphens, and underscores only. + +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" +``` + +After creating, offer to assign the current project to the new group. diff --git a/learn/SKILL.md.tmpl b/learn/SKILL.md.tmpl index a79da255d..f323d860e 100644 --- a/learn/SKILL.md.tmpl +++ b/learn/SKILL.md.tmpl @@ -40,21 +40,31 @@ Parse the user's input to determine which command to run: - `/learn export` → **Export** - `/learn stats` → **Stats** - `/learn add` → **Manual add** +- `/learn group` → **Group management** +- `/learn group list` → **Group list** +- `/learn group assign` → **Group assign** +- `/learn group create ` → **Group create** +- `/learn group which` → **Group which** --- ## Show recent (default) -Show the most recent 20 learnings, grouped by type. +Show the most recent 20 learnings from this project's learnings group, grouped by type. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --limit 20 2>/dev/null || echo "No learnings yet." +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -Present the output in a readable format. If no learnings exist, tell the user: -"No learnings recorded yet. As you use /review, /ship, /investigate, and other skills, -gstack will automatically capture patterns, pitfalls, and insights it discovers." +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 20 2>/dev/null || echo "No learnings yet." +``` + +Present the output in a readable format. Include the group name from the first command +if available (e.g., "Showing learnings from group: Work"). If no learnings exist, tell +the user: "No learnings recorded yet. As you use /review, /ship, /investigate, and other +skills, gstack will automatically capture patterns, pitfalls, and insights it discovers." --- @@ -62,10 +72,11 @@ gstack will automatically capture patterns, pitfalls, and insights it discovers. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --query "USER_QUERY" --limit 20 2>/dev/null || echo "No matches." +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --query "USER_QUERY" --limit 20 2>/dev/null || echo "No matches." ``` -Replace USER_QUERY with the user's search terms. Present results clearly. +Replace USER_QUERY with the user's search terms. Searches within the project's learnings +group by default. Present results clearly. --- @@ -105,25 +116,31 @@ Export learnings as markdown suitable for adding to CLAUDE.md or project documen ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -~/.claude/skills/gstack/bin/gstack-learnings-search --limit 50 2>/dev/null +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -Format the output as a markdown section: +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 50 2>/dev/null +``` + +Format the output as a markdown section. Include the group name and source projects: ```markdown -## Project Learnings +## Project Learnings (Group: GROUP_NAME) + +Sources: project-a, project-b ### Patterns -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ### Pitfalls -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ### Preferences - **[key]**: [insight] ### Architecture -- **[key]**: [insight] (confidence: N/10) +- **[key]**: [insight] (confidence: N/10, from: source-project) ``` Present the formatted output to the user. Ask if they want to append it to CLAUDE.md @@ -133,47 +150,26 @@ or save it as a separate file. ## Stats -Show summary statistics about the project's learnings. +Show summary statistics about the project's learnings group. ```bash eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -LEARN_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" -if [ -f "$LEARN_FILE" ]; then - TOTAL=$(wc -l < "$LEARN_FILE" | tr -d ' ') - echo "TOTAL: $TOTAL entries" - # Count by type (after dedup) - cat "$LEARN_FILE" | bun -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 = {}; - const bySource = {}; - 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: ' + (totalConf / seen.size).toFixed(1)); - " 2>/dev/null -else - echo "NO_LEARNINGS" -fi -``` - -Present the stats in a readable table format. +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" +``` + +```bash +~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups" +``` + +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 100 2>/dev/null || echo "No learnings" +``` + +Present the stats in a readable table format. Include: +- Group name and member projects +- Total learnings (across group) +- Breakdown by type and source +- Average confidence --- @@ -191,3 +187,60 @@ Then log it: ```bash ~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"learn","type":"TYPE","key":"KEY","insight":"INSIGHT","confidence":N,"source":"user-stated","files":["FILE1"]}' ``` + +--- + +## Group management + +Manage which learnings group this project belongs to. Learnings groups let gstack share +knowledge across related projects (e.g., all repos in your company's org). + +### Group list + +Show all groups and their member projects. + +```bash +~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups configured yet." +``` + +Present the output as a formatted table. + +### Group which + +Show which group the current project belongs to. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" +``` + +If `NO_GROUP`, tell the user this project isn't in a group yet and offer to assign it. + +### Group assign + +Assign this project to an existing group. Show available groups with smart suggestions: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null +``` + +Use AskUserQuestion to present the groups as options. Groups marked "(matches owner)" share +the same git org as this project and are likely the best fit. + +After the user picks a group: + +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +### Group create + +Create a new learnings group. Use AskUserQuestion to ask for the group name. + +Group names must be alphanumeric with dots, hyphens, and underscores only. + +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" +``` + +After creating, offer to assign the current project to the new group. diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 2fb28fad9..5ad45d714 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -548,33 +548,42 @@ eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/package.json b/package.json index f80c3e56f..ce4e266e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.15.2.0", + "version": "0.15.3.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index 2e692ed3c..df22c111c 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -757,33 +757,42 @@ Feed into the Premise Challenge (0A) and Dream State Mapping (0C). If you find a ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index 43c065a9e..e24cf44a4 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -999,33 +999,42 @@ descriptions of what 10/10 looks like. ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index e05d83428..44bf4c9f2 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -625,33 +625,42 @@ Always work through the full interactive review: one section at a time (Architec ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 336e5c20d..d598b671a 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -543,33 +543,42 @@ mkdir -p "$REPORT_DIR/screenshots" ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/qa/SKILL.md b/qa/SKILL.md index aba5f8f91..b55e1d2a9 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -773,33 +773,42 @@ mkdir -p .gstack/qa-reports/screenshots ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/retro/SKILL.md b/retro/SKILL.md index bd99a7624..b0da2214a 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -536,33 +536,42 @@ Usage: /retro [window | compare | global] ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/review/SKILL.md b/review/SKILL.md index eeb3c2ec1..2188539fe 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -771,33 +771,42 @@ Run `git diff origin/` to get the full diff. This includes both committed ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/scripts/resolvers/learnings.ts b/scripts/resolvers/learnings.ts index 685188fb2..f0a57eb96 100644 --- a/scripts/resolvers/learnings.ts +++ b/scripts/resolvers/learnings.ts @@ -5,23 +5,30 @@ * Each entry is a JSONL line with: ts, skill, type, key, insight, confidence, * source, branch, commit, files[]. * - * Storage is append-only. Duplicates (same key+type) are resolved at read time - * by gstack-learnings-search ("latest winner" per key+type). + * Storage is append-only. Duplicates (same key+type with same insight text) are + * resolved at read time by gstack-learnings-search (highest confidence wins). + * Different insights with the same key+type are preserved (shown with provenance). * - * Cross-project discovery is opt-in. The resolver asks the user once via - * AskUserQuestion and persists the preference via gstack-config. + * Projects belong to named "learnings groups" stored in ~/.gstack/groups.json. + * When searching, --scope group returns learnings from all projects in the same + * group. Prompt-on-first-use assigns new projects to a group. + * + * Cross-project discovery is controlled by the learning_scope config: + * project — just this project (old cross_project_learnings=false) + * group — all projects in the same group (new default) + * global — all projects on this machine (old cross_project_learnings=true) */ import type { TemplateContext } from './types'; export function generateLearningsSearch(ctx: TemplateContext): string { if (ctx.host === 'codex') { - // Codex: simpler version, no cross-project, uses $GSTACK_BIN + // Codex: project-only, no group awareness, no prompting return `## Prior Learnings Search for relevant learnings from previous sessions on this project: \`\`\`bash -$GSTACK_BIN/gstack-learnings-search --limit 10 2>/dev/null || true +$GSTACK_BIN/gstack-learnings-search --scope project --limit 10 2>/dev/null || true \`\`\` If learnings are found, incorporate them into your analysis. When a review finding @@ -30,33 +37,42 @@ matches a past learning, note it: "Prior learning applied: [key] (confidence N, return `## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: \`\`\`bash -_CROSS_PROJ=$(${ctx.paths.binDir}/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ${ctx.paths.binDir}/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ${ctx.paths.binDir}/gstack-learnings-search --limit 10 2>/dev/null || true -fi +${ctx.paths.binDir}/gstack-group which 2>/dev/null || echo "NO_GROUP" \`\`\` -If \`CROSS_PROJECT\` is \`unset\` (first time): Use AskUserQuestion: +If the output is \`NO_GROUP\`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: + +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +To see available groups and get smart suggestions, run: -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +\`\`\`bash +${ctx.paths.binDir}/gstack-group suggest 2>/dev/null || ${ctx.paths.binDir}/gstack-group list 2>/dev/null || echo "No groups yet" +\`\`\` -If A: run \`${ctx.paths.binDir}/gstack-config set cross_project_learnings true\` -If B: run \`${ctx.paths.binDir}/gstack-config set cross_project_learnings false\` +Options: [list the groups from the output above] + "Create a new group" + +If "Create a new group": ask for a name, then run: +\`\`\`bash +${ctx.paths.binDir}/gstack-group create "GROUP_NAME" && ${ctx.paths.binDir}/gstack-group assign "GROUP_NAME" +\`\`\` -Then re-run the search with the appropriate flag. +If an existing group: run: +\`\`\`bash +${ctx.paths.binDir}/gstack-group assign "GROUP_NAME" +\`\`\` + +After assignment (or if the project was already assigned), search for learnings: + +\`\`\`bash +${ctx.paths.binDir}/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +\`\`\` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/ship/SKILL.md b/ship/SKILL.md index 925245824..8268bc0e8 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -1455,33 +1455,42 @@ Add a `## Verification Results` section to the PR body (Step 8): ## Prior Learnings -Search for relevant learnings from previous sessions: +Check which learnings group this project belongs to: ```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi +~/.claude/skills/gstack/bin/gstack-group which 2>/dev/null || echo "NO_GROUP" ``` -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: +If the output is `NO_GROUP`, this project hasn't been assigned to a learnings group yet. +Use AskUserQuestion: -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. +> This project isn't in a learnings group yet. Learnings groups let gstack share +> knowledge across related projects (e.g., all repos in your company's org). +> Which group should this project belong to? -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only +To see available groups and get smart suggestions, run: + +```bash +~/.claude/skills/gstack/bin/gstack-group suggest 2>/dev/null || ~/.claude/skills/gstack/bin/gstack-group list 2>/dev/null || echo "No groups yet" +``` + +Options: [list the groups from the output above] + "Create a new group" -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` +If "Create a new group": ask for a name, then run: +```bash +~/.claude/skills/gstack/bin/gstack-group create "GROUP_NAME" && ~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +If an existing group: run: +```bash +~/.claude/skills/gstack/bin/gstack-group assign "GROUP_NAME" +``` + +After assignment (or if the project was already assigned), search for learnings: -Then re-run the search with the appropriate flag. +```bash +~/.claude/skills/gstack/bin/gstack-learnings-search --scope group --limit 10 2>/dev/null || true +``` If learnings are found, incorporate them into your analysis. When a review finding matches a past learning, display: diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index adb33456b..8cac10c90 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2439,16 +2439,16 @@ describe('LEARNINGS_SEARCH resolver', () => { }); } - test('learnings search includes cross-project config check', () => { + test('learnings search includes group-based scope', () => { const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8'); - expect(content).toContain('cross_project_learnings'); - expect(content).toContain('--cross-project'); + expect(content).toContain('gstack-group which'); + expect(content).toContain('--scope group'); }); - test('learnings search includes AskUserQuestion for first-time cross-project opt-in', () => { + test('learnings search includes AskUserQuestion for first-time group assignment', () => { const content = fs.readFileSync(path.join(ROOT, 'review', 'SKILL.md'), 'utf-8'); - expect(content).toContain('Enable cross-project learnings'); - expect(content).toContain('project-scoped only'); + expect(content).toContain('NO_GROUP'); + expect(content).toContain('learnings group'); }); test('learnings search mentions prior learning applied display format', () => { diff --git a/test/helpers/touchfiles.ts b/test/helpers/touchfiles.ts index ed8bc67ea..710461189 100644 --- a/test/helpers/touchfiles.ts +++ b/test/helpers/touchfiles.ts @@ -105,7 +105,7 @@ export const E2E_TOUCHFILES: Record = { 'cso-infra-scope': ['cso/**'], // Learnings - 'learnings-show': ['learn/**', 'bin/gstack-learnings-search', 'bin/gstack-learnings-log', 'scripts/resolvers/learnings.ts'], + 'learnings-show': ['learn/**', 'bin/gstack-learnings-search', 'bin/gstack-learnings-log', 'bin/gstack-group', 'scripts/resolvers/learnings.ts'], // Session Intelligence (timeline, context recovery, checkpoint) 'timeline-event-flow': ['bin/gstack-timeline-log', 'bin/gstack-timeline-read'], diff --git a/test/learnings-groups.test.ts b/test/learnings-groups.test.ts new file mode 100644 index 000000000..53af3323b --- /dev/null +++ b/test/learnings-groups.test.ts @@ -0,0 +1,397 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); +const SLUG = execSync(`${path.join(ROOT, 'bin', 'gstack-slug')}`, { cwd: ROOT, encoding: 'utf-8' }) + .trim().split('\n')[0].replace('SLUG=', ''); + +let tmpDir: string; + +function run(cmd: string, opts: { expectFail?: boolean } = {}): { stdout: string; stderr: string; exitCode: number } { + const execOpts: ExecSyncOptionsWithStringEncoding = { + cwd: ROOT, + env: { ...process.env, GSTACK_HOME: tmpDir, GSTACK_STATE_DIR: tmpDir }, + encoding: 'utf-8', + timeout: 15000, + }; + try { + const stdout = execSync(cmd, execOpts).trim(); + return { stdout, stderr: '', exitCode: 0 }; + } catch (e: any) { + if (opts.expectFail) { + return { stdout: e.stdout?.toString().trim() || '', stderr: e.stderr?.toString().trim() || '', exitCode: e.status || 1 }; + } + throw e; + } +} + +function runGroup(args: string, opts: { expectFail?: boolean } = {}) { + return run(`${BIN}/gstack-group ${args}`, opts); +} + +function runSearch(args: string = '') { + return run(`${BIN}/gstack-learnings-search ${args}`); +} + +function runLog(input: string) { + return run(`${BIN}/gstack-learnings-log '${input.replace(/'/g, "'\\''")}'`); +} + +function writeGroupsJson(data: object) { + fs.writeFileSync(path.join(tmpDir, 'groups.json'), JSON.stringify(data, null, 2)); +} + +function readGroupsJson(): any { + return JSON.parse(fs.readFileSync(path.join(tmpDir, 'groups.json'), 'utf-8')); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-groups-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('gstack-group create', () => { + test('creates a group in groups.json', () => { + runGroup('create Work'); + const data = readGroupsJson(); + expect(data.groups.Work).toBeDefined(); + expect(data.groups.Work.created).toBeDefined(); + expect(data.groups.Personal).toBeDefined(); // Default from migration + }); + + test('idempotent: creating existing group is a no-op', () => { + runGroup('create Work'); + const data1 = readGroupsJson(); + runGroup('create Work'); + const data2 = readGroupsJson(); + expect(data2.groups.Work.created).toBe(data1.groups.Work.created); + }); + + test('rejects invalid group names', () => { + const result = runGroup('create "bad name!"', { expectFail: true }); + expect(result.exitCode).not.toBe(0); + }); + + test('accepts valid names with dots and hyphens', () => { + runGroup('create my-team.v2'); + const data = readGroupsJson(); + expect(data.groups['my-team.v2']).toBeDefined(); + }); +}); + +describe('gstack-group list', () => { + test('shows groups and members', () => { + runGroup('create Work'); + runGroup('assign Work'); + const result = runGroup('list'); + expect(result.stdout).toContain('Work'); + expect(result.stdout).toContain('1 project'); + }); + + test('--json returns valid JSON', () => { + runGroup('create Work'); + const result = runGroup('list --json'); + const data = JSON.parse(result.stdout); + expect(data.groups).toBeDefined(); + expect(data.projects).toBeDefined(); + }); + + test('empty groups show 0 projects', () => { + runGroup('create EmptyGroup'); + const result = runGroup('list'); + expect(result.stdout).toContain('EmptyGroup (0 projects)'); + }); +}); + +describe('gstack-group assign', () => { + test('assigns current project to a group', () => { + runGroup('create Work'); + runGroup('assign Work'); + const data = readGroupsJson(); + const slug = Object.keys(data.projects)[0]; + expect(data.projects[slug]).toBe('Work'); + }); + + test('reassigns project to different group', () => { + runGroup('create Work'); + runGroup('create Personal2'); + runGroup('assign Work'); + runGroup('assign Personal2'); + const data = readGroupsJson(); + const slug = Object.keys(data.projects).find(k => data.projects[k] === 'Personal2'); + expect(slug).toBeDefined(); + }); + + test('rejects assignment to nonexistent group', () => { + runGroup('which'); // trigger migration + const result = runGroup('assign NoSuchGroup', { expectFail: true }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('not found'); + }); +}); + +describe('gstack-group which', () => { + test('returns group name when assigned', () => { + runGroup('create Work'); + runGroup('assign Work'); + const result = runGroup('which'); + expect(result.stdout).toBe('Work'); + }); + + test('returns NO_GROUP when unassigned', () => { + runGroup('which'); // triggers migration, but current project has no learnings + const result = runGroup('which'); + expect(result.stdout).toBe('NO_GROUP'); + }); +}); + +describe('gstack-group suggest', () => { + test('returns groups sorted by owner match', () => { + runGroup('create Work'); + runGroup('create Personal'); + // Add a project with matching owner prefix to Work + const owner = SLUG.split('-')[0]; // e.g., "Madrox" from "Madrox-gstack" + const data = readGroupsJson(); + data.projects[`${owner}-other`] = 'Work'; + writeGroupsJson(data); + const result = runGroup('suggest'); + // Work should appear first because it has a member with matching owner + const lines = result.stdout.split('\n'); + expect(lines[0]).toContain('Work'); + expect(lines[0]).toContain('matches owner'); + }); +}); + +describe('migration', () => { + test('creates groups.json with Personal group on first access', () => { + runGroup('which'); + expect(fs.existsSync(path.join(tmpDir, 'groups.json'))).toBe(true); + const data = readGroupsJson(); + expect(data.groups.Personal).toBeDefined(); + }); + + test('cross_project=true: assigns all projects to Personal', () => { + // Set up config + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'cross_project_learnings: true\n'); + // Create some project learnings + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo1'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo2'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo1', 'learnings.jsonl'), '{"key":"k","type":"pattern","insight":"i"}\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo2', 'learnings.jsonl'), '{"key":"k2","type":"pattern","insight":"i2"}\n'); + + runGroup('which'); // triggers migration + const data = readGroupsJson(); + expect(data.projects['org-repo1']).toBe('Personal'); + expect(data.projects['org-repo2']).toBe('Personal'); + }); + + test('cross_project=false: each project gets solo group', () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'cross_project_learnings: false\n'); + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo1'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo2'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo1', 'learnings.jsonl'), '{"key":"k","type":"pattern","insight":"i"}\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo2', 'learnings.jsonl'), '{"key":"k2","type":"pattern","insight":"i2"}\n'); + + runGroup('which'); + const data = readGroupsJson(); + expect(data.projects['org-repo1']).toBe('org-repo1'); + expect(data.projects['org-repo2']).toBe('org-repo2'); + expect(data.groups['org-repo1']).toBeDefined(); + expect(data.groups['org-repo2']).toBeDefined(); + }); + + test('unset cross_project: same as false (solo groups)', () => { + // No config file at all + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo1'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo1', 'learnings.jsonl'), '{"key":"k","type":"pattern","insight":"i"}\n'); + + runGroup('which'); + const data = readGroupsJson(); + expect(data.projects['org-repo1']).toBe('org-repo1'); + }); + + test('migration on brand-new machine (no projects dir)', () => { + // tmpDir exists but no projects subdirectory + runGroup('which'); + const data = readGroupsJson(); + expect(data.groups.Personal).toBeDefined(); + expect(Object.keys(data.projects)).toHaveLength(0); + }); + + test('migration with empty project dirs (no learnings.jsonl)', () => { + fs.mkdirSync(path.join(tmpDir, 'projects', 'empty-project'), { recursive: true }); + // No learnings.jsonl inside + runGroup('which'); + const data = readGroupsJson(); + // Empty project should NOT be in groups (no learnings to migrate) + expect(data.projects['empty-project']).toBeUndefined(); + }); + + test('migration runs exactly once (idempotent)', () => { + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo1'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo1', 'learnings.jsonl'), '{"key":"k","type":"pattern","insight":"i"}\n'); + + runGroup('which'); // first run + const data1 = readGroupsJson(); + + // Add another project AFTER migration + fs.mkdirSync(path.join(tmpDir, 'projects', 'org-repo2'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', 'org-repo2', 'learnings.jsonl'), '{"key":"k2","type":"pattern","insight":"i2"}\n'); + + runGroup('which'); // second run — should NOT re-migrate + const data2 = readGroupsJson(); + // org-repo2 should NOT be in groups (migration already ran) + expect(data2.projects['org-repo2']).toBeUndefined(); + }); + + test('config migration: true -> learning_scope global', () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'cross_project_learnings: true\n'); + runGroup('which'); + const scope = run(`${BIN}/gstack-config get learning_scope`); + expect(scope.stdout).toBe('global'); + }); + + test('config migration: unset -> learning_scope group', () => { + runGroup('which'); + const scope = run(`${BIN}/gstack-config get learning_scope`); + expect(scope.stdout).toBe('group'); + }); +}); + +describe('gstack-learnings-search --scope group', () => { + test('returns learnings from all projects in same group', () => { + // Set up two projects in same group + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'other-repo'), { recursive: true }); + + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'local-pattern', type: 'pattern', insight: 'from local', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'other-repo', 'learnings.jsonl'), + JSON.stringify({ key: 'remote-pattern', type: 'pattern', insight: 'from remote', confidence: 7, source: 'observed', ts: '2026-03-29T00:00:00Z' }) + '\n'); + + writeGroupsJson({ + groups: { Work: { created: '2026-03-30T00:00:00Z' } }, + projects: { [slug]: 'Work', 'other-repo': 'Work' } + }); + + const result = runSearch('--scope group'); + expect(result.stdout).toContain('local-pattern'); + expect(result.stdout).toContain('remote-pattern'); + expect(result.stdout).toContain('[from: other-repo]'); + expect(result.stdout).not.toContain('[from: ' + slug + ']'); // local project has no provenance tag + }); + + test('falls back to project scope when unassigned', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'local-only', type: 'pattern', insight: 'just local', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + + // groups.json exists but project not assigned + writeGroupsJson({ groups: { Work: { created: '2026-03-30T00:00:00Z' } }, projects: {} }); + + // UNASSIGNED_GROUP goes to stderr; the search still succeeds and returns local results + const result = runSearch('--scope group'); + expect(result.stdout).toContain('local-only'); + }); + + test('falls back gracefully when groups.json missing', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'fallback', type: 'pattern', insight: 'still works', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + + // No groups.json at all + const result = runSearch('--scope group'); + expect(result.stdout).toContain('fallback'); + }); + + test('handles missing JSONL for a group member', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'local', type: 'pattern', insight: 'local insight', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + + // ghost-repo is in the group but has no JSONL file + writeGroupsJson({ + groups: { Work: { created: '2026-03-30T00:00:00Z' } }, + projects: { [slug]: 'Work', 'ghost-repo': 'Work' } + }); + + const result = runSearch('--scope group'); + expect(result.stdout).toContain('local'); + expect(result.exitCode).toBe(0); // should not crash + }); + + test('preserves different insights with same key+type across repos', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'other-repo'), { recursive: true }); + + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'n-plus-one', type: 'pattern', insight: 'use includes for has_many', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'other-repo', 'learnings.jsonl'), + JSON.stringify({ key: 'n-plus-one', type: 'pattern', insight: 'use eager_load for polymorphic', confidence: 6, source: 'observed', ts: '2026-03-29T00:00:00Z' }) + '\n'); + + writeGroupsJson({ + groups: { Work: { created: '2026-03-30T00:00:00Z' } }, + projects: { [slug]: 'Work', 'other-repo': 'Work' } + }); + + const result = runSearch('--scope group'); + expect(result.stdout).toContain('use includes for has_many'); + expect(result.stdout).toContain('use eager_load for polymorphic'); + }); + + test('collapses exact duplicate insights across repos', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'other-repo'), { recursive: true }); + + const sameInsight = 'always check null returns from find queries'; + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'null-check', type: 'pitfall', insight: sameInsight, confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'other-repo', 'learnings.jsonl'), + JSON.stringify({ key: 'null-check', type: 'pitfall', insight: sameInsight, confidence: 6, source: 'observed', ts: '2026-03-29T00:00:00Z' }) + '\n'); + + writeGroupsJson({ + groups: { Work: { created: '2026-03-30T00:00:00Z' } }, + projects: { [slug]: 'Work', 'other-repo': 'Work' } + }); + + const result = runSearch('--scope group'); + // Should show only 1 entry (collapsed, highest confidence kept) + expect(result.stdout).toContain('1 loaded'); + expect(result.stdout).toContain('confidence: 8/10'); + }); + + test('--cross-project backward compat maps to --scope global', () => { + const slug = SLUG; + + fs.mkdirSync(path.join(tmpDir, 'projects', slug), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'projects', 'unrelated-repo'), { recursive: true }); + + fs.writeFileSync(path.join(tmpDir, 'projects', slug, 'learnings.jsonl'), + JSON.stringify({ key: 'local', type: 'pattern', insight: 'local', confidence: 8, source: 'observed', ts: '2026-03-30T00:00:00Z' }) + '\n'); + fs.writeFileSync(path.join(tmpDir, 'projects', 'unrelated-repo', 'learnings.jsonl'), + JSON.stringify({ key: 'remote', type: 'pattern', insight: 'remote', confidence: 7, source: 'observed', ts: '2026-03-29T00:00:00Z' }) + '\n'); + + const result = runSearch('--cross-project'); + expect(result.stdout).toContain('local'); + expect(result.stdout).toContain('remote'); + }); +}); diff --git a/test/learnings.test.ts b/test/learnings.test.ts index 6d72266c4..e9dc11d51 100644 --- a/test/learnings.test.ts +++ b/test/learnings.test.ts @@ -118,16 +118,28 @@ describe('gstack-learnings-search', () => { expect(output).toContain('search test insight'); }); - test('deduplicates entries by key+type (latest wins)', () => { - const old = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'old version', confidence: 5, source: 'observed', ts: '2026-01-01T00:00:00Z' }); - const newer = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'new version', confidence: 8, source: 'observed', ts: '2026-03-28T00:00:00Z' }); + test('deduplicates entries with same insight text (highest confidence wins)', () => { + const old = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'same insight text', confidence: 5, source: 'observed', ts: '2026-01-01T00:00:00Z' }); + const newer = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'same insight text', confidence: 8, source: 'observed', ts: '2026-03-28T00:00:00Z' }); runLog(old); runLog(newer); const output = runSearch(); - expect(output).toContain('new version'); - expect(output).not.toContain('old version'); + expect(output).toContain('same insight text'); expect(output).toContain('1 loaded'); + expect(output).toContain('confidence: 8/10'); + }); + + test('preserves different insights with same key+type', () => { + const v1 = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'old version', confidence: 5, source: 'observed', ts: '2026-01-01T00:00:00Z' }); + const v2 = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'new version', confidence: 8, source: 'observed', ts: '2026-03-28T00:00:00Z' }); + runLog(v1); + runLog(v2); + + const output = runSearch(); + expect(output).toContain('new version'); + expect(output).toContain('old version'); + expect(output).toContain('2 loaded'); }); test('filters by --type', () => {