diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index f5fa21713..b940dc135 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -1,12 +1,34 @@ #!/usr/bin/env bash # gstack-learnings-log — append a learning to the project learnings file -# Usage: gstack-learnings-log '{"skill":"review","type":"pitfall","key":"n-plus-one","insight":"...","confidence":8,"source":"observed"}' +# Usage: gstack-learnings-log --help # Valid types: pattern, pitfall, preference, architecture, tool, operational, investigation # # Append-only storage. Duplicates (same key+type) are resolved at read time # by gstack-learnings-search ("latest winner" per key+type). set -euo pipefail GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac + +usage() { + cat <<'EOF' +Usage: gstack-learnings-log '' + gstack-learnings-log --help + +Appends one learning to this project's learnings.jsonl (append-only; the latest entry per key+type wins). + {"skill":"","type":"","key":"","insight":"","confidence":<1-10>, + "source":"","files":[""]} + type pattern pitfall preference architecture tool operational investigation + key letters, digits, - and _ only + source observed user-stated inferred cross-model (optional; user-stated marks it trusted) + files optional +Read learnings back with gstack-learnings-search. +EOF +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + "") usage >&2; exit 2 ;; + -*) echo "gstack-learnings-log: unknown argument '$1'; pass one JSON object, or --help" >&2; exit 2 ;; +esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun # on Windows cannot resolve as an ES module specifier in the import below. diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 8e19da27c..ae6bd7f34 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -1,6 +1,6 @@ #!/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 --help # # Reads ~/.gstack/projects/$SLUG/learnings.jsonl, applies confidence decay, # resolves duplicates (latest winner per key+type), and outputs formatted text. @@ -9,29 +9,85 @@ # not read" must never look like "nothing recorded". set -euo pipefail GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac + +usage() { + cat <<'EOF' +Usage: gstack-learnings-search [--type TYPE] [--query WORDS] [--limit N=10] [--cross-project] + gstack-learnings-search --stats + gstack-learnings-search --help + +Prints this project's learnings, latest entry per key+type, grouped by type. + --type TYPE only one type: pattern pitfall preference architecture tool operational investigation + --query WORDS entries matching any word in key, insight or files, best match first + --limit N at most N entries (default 10) + --cross-project also read up to 5 other projects' user-stated learnings + --stats counts for this project: TOTAL, UNIQUE, RAW_ENTRIES, BY_TYPE, BY_SOURCE, + AVG_CONFIDENCE, or NO_LEARNINGS when nothing is recorded +Prints nothing when nothing matches. Record a learning with gstack-learnings-log. +EOF +} + +TYPE="" +QUERY="" +LIMIT=10 +CROSS_PROJECT=false +STATS=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --type) TYPE="${2?--type needs a value}"; shift 2 ;; + --query) QUERY="${2?--query needs a value}"; shift 2 ;; + --limit) LIMIT="${2?--limit needs a value}"; shift 2 ;; + --cross-project) CROSS_PROJECT=true; shift ;; + --stats) STATS=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "gstack-learnings-search: unknown argument '$1'; valid: --type --query --limit --cross-project --stats --help" >&2; exit 2 ;; + esac +done + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select GSTACK_HOME="$_gstack_sr_root" -TYPE="" -QUERY="" -LIMIT=10 -CROSS_PROJECT=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --type) TYPE="$2"; shift 2 ;; - --query) QUERY="$2"; shift 2 ;; - --limit) LIMIT="$2"; shift 2 ;; - --cross-project) CROSS_PROJECT=true; shift ;; - *) shift ;; - esac -done - LEARNINGS_FILE="$GSTACK_HOME/projects/$SLUG/learnings.jsonl" +if [ "$STATS" = true ]; then + if [ ! -f "$LEARNINGS_FILE" ]; then + echo "NO_LEARNINGS" + exit 0 + fi + command -v bun >/dev/null 2>&1 || { echo "gstack-learnings-search: bun not found on PATH, so learnings could not be read. Fix: install Bun (https://bun.sh), then re-run ./setup." >&2; exit 127; } + echo "TOTAL: $(wc -l < "$LEARNINGS_FILE" | tr -d ' ') entries" + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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 = Object.create(null); +const bySource = Object.create(null); +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: ' + (seen.size ? (totalConf / seen.size).toFixed(1) : '0.0')); +" < "$LEARNINGS_FILE" + exit 0 +fi + # Collect cross-project JSONL files separately so the trust gate can distinguish # current-project rows from rows loaded from other projects. CROSS_FILES=() @@ -41,7 +97,7 @@ if [ "$CROSS_PROJECT" = true ]; then while IFS= read -r f; do CROSS_FILES+=("$f") [ ${#CROSS_FILES[@]} -ge 5 ] && break - done < <(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "*/$SLUG/*" 2>/dev/null) + done < <(find "$GSTACK_HOME/projects" -name "learnings.jsonl" -not -path "$GSTACK_HOME/projects/$SLUG/*" 2>/dev/null) fi if [ ! -f "$LEARNINGS_FILE" ] && [ ${#CROSS_FILES[@]} -eq 0 ]; then diff --git a/test/learnings-bin-cli.test.ts b/test/learnings-bin-cli.test.ts new file mode 100644 index 000000000..bb444f308 --- /dev/null +++ b/test/learnings-bin-cli.test.ts @@ -0,0 +1,100 @@ +import { describe, test, expect, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SEARCH = path.join(ROOT, 'bin', 'gstack-learnings-search'); +const LOG = path.join(ROOT, 'bin', 'gstack-learnings-log'); + +const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learnings-cli-')); +const tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learnings-cli-cwd-')); +const slug = path.basename(tmpCwd).replace(/[^a-zA-Z0-9._-]/g, ''); +const projDir = path.join(tmpHome, 'projects', slug); + +function run(bin: string, args: string[]) { + return spawnSync(bin, args, { + timeout: 30_000, + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: tmpCwd, + encoding: 'utf-8', + }); +} + +afterAll(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); +}); + +describe('learnings bins explain themselves', () => { + test('--help prints usage for both bins and writes no state', () => { + for (const bin of [SEARCH, LOG]) { + const r = run(bin, ['--help']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Usage: ' + path.basename(bin)); + } + expect(fs.existsSync(path.join(tmpHome, 'projects'))).toBe(false); + }); + + test('an unknown flag exits 2, names the valid flags, and writes no state', () => { + const search = run(SEARCH, ['--qeury', 'x']); + expect(search.status).toBe(2); + expect(search.stderr).toContain("unknown argument '--qeury'"); + expect(search.stderr).toContain('--query'); + const log = run(LOG, ['--remove']); + expect(log.status).toBe(2); + expect(log.stderr).toContain("unknown argument '--remove'"); + expect(run(LOG, []).status).toBe(2); + expect(fs.existsSync(path.join(tmpHome, 'projects'))).toBe(false); + }); + + test('an empty --query value is still accepted', () => { + expect(run(SEARCH, ['--query', '', '--limit', '5']).status).toBe(0); + }); +}); + +describe('gstack-learnings-search --cross-project', () => { + test('finds other projects when the state root sits under a directory named like the slug', () => { + const nestedHome = path.join(tmpCwd, '.gstack-home'); + fs.mkdirSync(path.join(nestedHome, 'projects', slug), { recursive: true }); + fs.mkdirSync(path.join(nestedHome, 'projects', 'other-service'), { recursive: true }); + fs.writeFileSync(path.join(nestedHome, 'projects', 'other-service', 'learnings.jsonl'), + JSON.stringify({ ts: '2026-10-10T00:00:00Z', type: 'pattern', key: 'docker-layer-cache', insight: 'Copy the lockfile first.', confidence: 9, source: 'user-stated', trusted: true }) + '\n'); + const r = spawnSync(SEARCH, ['--cross-project', '--query', 'docker'], { + timeout: 30_000, cwd: tmpCwd, encoding: 'utf-8', + env: { ...process.env, GSTACK_HOME: nestedHome, GSTACK_STATE_ROOT: nestedHome }, + }); + expect(r.status).toBe(0); + expect(r.stdout).toContain('docker-layer-cache'); + fs.rmSync(nestedHome, { recursive: true, force: true }); + }); +}); + +describe('gstack-learnings-search --stats', () => { + test('prints NO_LEARNINGS when nothing is recorded', () => { + const r = run(SEARCH, ['--stats']); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe('NO_LEARNINGS'); + }); + + test('counts raw rows and the latest entry per key+type', () => { + fs.mkdirSync(projDir, { recursive: true }); + const rows = [ + { ts: '2026-05-01T00:00:00Z', type: 'pitfall', key: 'k1', insight: 'old', confidence: 4, source: 'observed' }, + { ts: '2026-05-02T00:00:00Z', type: 'pitfall', key: 'k1', insight: 'new', confidence: 8, source: 'observed' }, + { ts: '2026-05-03T00:00:00Z', type: 'pattern', key: 'k2', insight: 'two', confidence: 5, source: 'user-stated' }, + ]; + fs.writeFileSync(path.join(projDir, 'learnings.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\nnot json\n'); + const r = run(SEARCH, ['--stats']); + expect(r.status).toBe(0); + expect(r.stdout.split('\n').filter(Boolean)).toEqual([ + 'TOTAL: 4 entries', + 'UNIQUE: 2 (after dedup)', + 'RAW_ENTRIES: 4', + 'BY_TYPE: {"pitfall":1,"pattern":1}', + 'BY_SOURCE: {"observed":1,"user-stated":1}', + 'AVG_CONFIDENCE: 6.5', + ]); + }); +});