mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
fix(learnings): --help, unknown-flag errors and --stats on the learnings bins; cross-project search no longer drops every project when the state root path contains the slug
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
897d33ca0a
commit
cbb49a59d7
3 files changed
+196
-18
No files matched your search
@@ -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 '<json>'
|
||||
gstack-learnings-log --help
|
||||
|
||||
Appends one learning to this project's learnings.jsonl (append-only; the latest entry per key+type wins).
|
||||
{"skill":"<skill>","type":"<type>","key":"<kebab-key>","insight":"<one sentence>","confidence":<1-10>,
|
||||
"source":"<source>","files":["<path>"]}
|
||||
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.
|
||||
|
||||
+73
-17
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user