mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
feat: add builder profile helper for office-hours relationship closing
New bin/gstack-builder-profile reads ~/.gstack/builder-profile.jsonl and outputs structured summary (tier, signals, resources, topics). Single source of truth for all closing state — no separate config keys or logs. Uses bun-based JSONL parsing pattern from gstack-learnings-search. Graceful fallback to introduction tier if bun unavailable or file missing. 26 unit tests covering tier computation, signal accumulation, cross-project detection, nudge eligibility, resource dedup, and malformed JSONL handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-builder-profile — read builder profile and output structured summary
|
||||
#
|
||||
# Reads ~/.gstack/builder-profile.jsonl (append-only session log from /office-hours).
|
||||
# Outputs KEY: VALUE pairs for the template to consume. Computes tier, accumulated
|
||||
# signals, cross-project detection, nudge eligibility, and resource dedup.
|
||||
#
|
||||
# Single source of truth for all closing state. No separate config keys or logs.
|
||||
#
|
||||
# Exit 0 with defaults if no profile exists (first-time user = introduction tier).
|
||||
set -euo pipefail
|
||||
|
||||
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl"
|
||||
|
||||
# Graceful default: no profile = introduction tier
|
||||
if [ ! -f "$PROFILE_FILE" ] || [ ! -s "$PROFILE_FILE" ]; then
|
||||
echo "SESSION_COUNT: 0"
|
||||
echo "TIER: introduction"
|
||||
echo "LAST_PROJECT:"
|
||||
echo "LAST_ASSIGNMENT:"
|
||||
echo "LAST_DESIGN_TITLE:"
|
||||
echo "DESIGN_COUNT: 0"
|
||||
echo "DESIGN_TITLES: []"
|
||||
echo "ACCUMULATED_SIGNALS:"
|
||||
echo "TOTAL_SIGNAL_COUNT: 0"
|
||||
echo "CROSS_PROJECT: false"
|
||||
echo "NUDGE_ELIGIBLE: false"
|
||||
echo "RESOURCES_SHOWN:"
|
||||
echo "RESOURCES_SHOWN_COUNT: 0"
|
||||
echo "TOPICS:"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use bun for JSON parsing (same pattern as gstack-learnings-search).
|
||||
# Fallback to defaults if bun is unavailable.
|
||||
cat "$PROFILE_FILE" 2>/dev/null | bun -e "
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
const entries = [];
|
||||
for (const line of lines) {
|
||||
try { entries.push(JSON.parse(line)); } catch {}
|
||||
}
|
||||
|
||||
const count = entries.length;
|
||||
|
||||
// Tier computation
|
||||
let tier = 'introduction';
|
||||
if (count >= 8) tier = 'inner_circle';
|
||||
else if (count >= 4) tier = 'regular';
|
||||
else if (count >= 1) tier = 'welcome_back';
|
||||
|
||||
// Last session data
|
||||
const last = entries[count - 1] || {};
|
||||
const prev = entries[count - 2] || {};
|
||||
const crossProject = prev.project_slug && last.project_slug
|
||||
? prev.project_slug !== last.project_slug
|
||||
: false;
|
||||
|
||||
// Design docs
|
||||
const designs = entries
|
||||
.map(e => e.design_doc || '')
|
||||
.filter(Boolean);
|
||||
const designTitles = entries
|
||||
.map(e => {
|
||||
const doc = e.design_doc || '';
|
||||
// Extract title from path: ...-design-DATETIME.md -> use the entry's topic or project
|
||||
return doc ? (e.project_slug || 'unknown') : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Accumulated signals
|
||||
const signalCounts = {};
|
||||
let totalSignals = 0;
|
||||
for (const e of entries) {
|
||||
for (const s of (e.signals || [])) {
|
||||
signalCounts[s] = (signalCounts[s] || 0) + 1;
|
||||
totalSignals++;
|
||||
}
|
||||
}
|
||||
const signalStr = Object.entries(signalCounts)
|
||||
.map(([k, v]) => k + ':' + v)
|
||||
.join(',');
|
||||
|
||||
// Nudge eligibility: builder-mode + 5+ signals across 3+ sessions
|
||||
const builderSessions = entries.filter(e => e.mode !== 'startup').length;
|
||||
const nudgeEligible = builderSessions >= 3 && totalSignals >= 5;
|
||||
|
||||
// Resources shown (aggregate all)
|
||||
const allResources = new Set();
|
||||
for (const e of entries) {
|
||||
for (const url of (e.resources_shown || [])) {
|
||||
allResources.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Topics (aggregate all)
|
||||
const allTopics = new Set();
|
||||
for (const e of entries) {
|
||||
for (const t of (e.topics || [])) {
|
||||
allTopics.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('SESSION_COUNT: ' + count);
|
||||
console.log('TIER: ' + tier);
|
||||
console.log('LAST_PROJECT: ' + (last.project_slug || ''));
|
||||
console.log('LAST_ASSIGNMENT: ' + (last.assignment || ''));
|
||||
console.log('LAST_DESIGN_TITLE: ' + (last.design_doc || ''));
|
||||
console.log('DESIGN_COUNT: ' + designs.length);
|
||||
console.log('DESIGN_TITLES: ' + JSON.stringify(designTitles));
|
||||
console.log('ACCUMULATED_SIGNALS: ' + signalStr);
|
||||
console.log('TOTAL_SIGNAL_COUNT: ' + totalSignals);
|
||||
console.log('CROSS_PROJECT: ' + crossProject);
|
||||
console.log('NUDGE_ELIGIBLE: ' + nudgeEligible);
|
||||
console.log('RESOURCES_SHOWN: ' + Array.from(allResources).join(','));
|
||||
console.log('RESOURCES_SHOWN_COUNT: ' + allResources.size);
|
||||
console.log('TOPICS: ' + Array.from(allTopics).join(','));
|
||||
" 2>/dev/null || {
|
||||
# Fallback if bun is unavailable
|
||||
echo "SESSION_COUNT: 0"
|
||||
echo "TIER: introduction"
|
||||
echo "LAST_PROJECT:"
|
||||
echo "LAST_ASSIGNMENT:"
|
||||
echo "LAST_DESIGN_TITLE:"
|
||||
echo "DESIGN_COUNT: 0"
|
||||
echo "DESIGN_TITLES: []"
|
||||
echo "ACCUMULATED_SIGNALS:"
|
||||
echo "TOTAL_SIGNAL_COUNT: 0"
|
||||
echo "CROSS_PROJECT: false"
|
||||
echo "NUDGE_ELIGIBLE: false"
|
||||
echo "RESOURCES_SHOWN:"
|
||||
echo "RESOURCES_SHOWN_COUNT: 0"
|
||||
echo "TOPICS:"
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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');
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
function runProfile(): Record<string, string> {
|
||||
const execOpts: ExecSyncOptionsWithStringEncoding = {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, GSTACK_HOME: tmpDir },
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
};
|
||||
const stdout = execSync(`${BIN}/gstack-builder-profile`, execOpts).trim();
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of stdout.split('\n')) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function writeProfile(entries: object[]): void {
|
||||
const profileFile = path.join(tmpDir, 'builder-profile.jsonl');
|
||||
const content = entries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
||||
fs.writeFileSync(profileFile, content);
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<{
|
||||
date: string;
|
||||
mode: string;
|
||||
project_slug: string;
|
||||
signal_count: number;
|
||||
signals: string[];
|
||||
design_doc: string;
|
||||
assignment: string;
|
||||
resources_shown: string[];
|
||||
topics: string[];
|
||||
}> = {}): object {
|
||||
return {
|
||||
date: '2026-04-01T00:00:00Z',
|
||||
mode: 'startup',
|
||||
project_slug: 'test-app',
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
design_doc: '',
|
||||
assignment: '',
|
||||
resources_shown: [],
|
||||
topics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-profile-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-builder-profile', () => {
|
||||
describe('empty/missing state', () => {
|
||||
test('no profile file → introduction tier with defaults', () => {
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('0');
|
||||
expect(r['TIER']).toBe('introduction');
|
||||
expect(r['TOTAL_SIGNAL_COUNT']).toBe('0');
|
||||
expect(r['CROSS_PROJECT']).toBe('false');
|
||||
expect(r['NUDGE_ELIGIBLE']).toBe('false');
|
||||
expect(r['RESOURCES_SHOWN_COUNT']).toBe('0');
|
||||
});
|
||||
|
||||
test('empty profile file → introduction tier', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'builder-profile.jsonl'), '');
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('0');
|
||||
expect(r['TIER']).toBe('introduction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tier computation', () => {
|
||||
test('1 session → welcome_back', () => {
|
||||
writeProfile([makeEntry()]);
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('1');
|
||||
expect(r['TIER']).toBe('welcome_back');
|
||||
});
|
||||
|
||||
test('2 sessions → welcome_back', () => {
|
||||
writeProfile([makeEntry(), makeEntry({ date: '2026-04-02T00:00:00Z' })]);
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('2');
|
||||
expect(r['TIER']).toBe('welcome_back');
|
||||
});
|
||||
|
||||
test('3 sessions → welcome_back', () => {
|
||||
writeProfile([
|
||||
makeEntry(),
|
||||
makeEntry({ date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('3');
|
||||
expect(r['TIER']).toBe('welcome_back');
|
||||
});
|
||||
|
||||
test('4 sessions → regular', () => {
|
||||
writeProfile(Array.from({ length: 4 }, (_, i) =>
|
||||
makeEntry({ date: `2026-04-0${i + 1}T00:00:00Z` })
|
||||
));
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('4');
|
||||
expect(r['TIER']).toBe('regular');
|
||||
});
|
||||
|
||||
test('7 sessions → regular', () => {
|
||||
writeProfile(Array.from({ length: 7 }, (_, i) =>
|
||||
makeEntry({ date: `2026-04-0${i + 1}T00:00:00Z` })
|
||||
));
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('7');
|
||||
expect(r['TIER']).toBe('regular');
|
||||
});
|
||||
|
||||
test('8 sessions → inner_circle', () => {
|
||||
writeProfile(Array.from({ length: 8 }, (_, i) =>
|
||||
makeEntry({ date: `2026-04-0${i + 1}T00:00:00Z` })
|
||||
));
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('8');
|
||||
expect(r['TIER']).toBe('inner_circle');
|
||||
});
|
||||
|
||||
test('15 sessions → inner_circle', () => {
|
||||
writeProfile(Array.from({ length: 15 }, (_, i) =>
|
||||
makeEntry({ date: `2026-04-${String(i + 1).padStart(2, '0')}T00:00:00Z` })
|
||||
));
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('15');
|
||||
expect(r['TIER']).toBe('inner_circle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('signal accumulation', () => {
|
||||
test('accumulates signals across sessions', () => {
|
||||
writeProfile([
|
||||
makeEntry({ signal_count: 2, signals: ['named_users', 'pushback'] }),
|
||||
makeEntry({ signal_count: 1, signals: ['taste'], date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ signal_count: 2, signals: ['named_users', 'agency'], date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['TOTAL_SIGNAL_COUNT']).toBe('5');
|
||||
expect(r['ACCUMULATED_SIGNALS']).toContain('named_users:2');
|
||||
expect(r['ACCUMULATED_SIGNALS']).toContain('pushback:1');
|
||||
expect(r['ACCUMULATED_SIGNALS']).toContain('taste:1');
|
||||
expect(r['ACCUMULATED_SIGNALS']).toContain('agency:1');
|
||||
});
|
||||
|
||||
test('zero signals → empty accumulation', () => {
|
||||
writeProfile([makeEntry()]);
|
||||
const r = runProfile();
|
||||
expect(r['TOTAL_SIGNAL_COUNT']).toBe('0');
|
||||
expect(r['ACCUMULATED_SIGNALS']).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-project detection', () => {
|
||||
test('same project consecutive → false', () => {
|
||||
writeProfile([
|
||||
makeEntry({ project_slug: 'app-a' }),
|
||||
makeEntry({ project_slug: 'app-a', date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['CROSS_PROJECT']).toBe('false');
|
||||
});
|
||||
|
||||
test('different project consecutive → true', () => {
|
||||
writeProfile([
|
||||
makeEntry({ project_slug: 'app-a' }),
|
||||
makeEntry({ project_slug: 'app-b', date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['CROSS_PROJECT']).toBe('true');
|
||||
});
|
||||
|
||||
test('single session → false', () => {
|
||||
writeProfile([makeEntry()]);
|
||||
const r = runProfile();
|
||||
expect(r['CROSS_PROJECT']).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nudge eligibility', () => {
|
||||
test('3+ builder sessions with 5+ signals → eligible', () => {
|
||||
writeProfile([
|
||||
makeEntry({ mode: 'builder', signal_count: 2, signals: ['taste', 'agency'] }),
|
||||
makeEntry({ mode: 'builder', signal_count: 2, signals: ['named_users', 'pushback'], date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ mode: 'builder', signal_count: 1, signals: ['domain_expertise'], date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['NUDGE_ELIGIBLE']).toBe('true');
|
||||
});
|
||||
|
||||
test('startup mode only → not eligible', () => {
|
||||
writeProfile([
|
||||
makeEntry({ mode: 'startup', signal_count: 3, signals: ['a', 'b', 'c'] }),
|
||||
makeEntry({ mode: 'startup', signal_count: 3, signals: ['d', 'e', 'f'], date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ mode: 'startup', signal_count: 3, signals: ['g', 'h', 'i'], date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['NUDGE_ELIGIBLE']).toBe('false');
|
||||
});
|
||||
|
||||
test('builder mode but <5 signals → not eligible', () => {
|
||||
writeProfile([
|
||||
makeEntry({ mode: 'builder', signal_count: 1, signals: ['taste'] }),
|
||||
makeEntry({ mode: 'builder', signal_count: 1, signals: ['agency'], date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ mode: 'builder', signal_count: 1, signals: ['pushback'], date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['NUDGE_ELIGIBLE']).toBe('false');
|
||||
});
|
||||
|
||||
test('<3 builder sessions → not eligible even with enough signals', () => {
|
||||
writeProfile([
|
||||
makeEntry({ mode: 'builder', signal_count: 3, signals: ['a', 'b', 'c'] }),
|
||||
makeEntry({ mode: 'builder', signal_count: 3, signals: ['d', 'e', 'f'], date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['NUDGE_ELIGIBLE']).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource dedup', () => {
|
||||
test('aggregates resources across sessions', () => {
|
||||
writeProfile([
|
||||
makeEntry({ resources_shown: ['https://url1.com', 'https://url2.com'] }),
|
||||
makeEntry({ resources_shown: ['https://url2.com', 'https://url3.com'], date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['RESOURCES_SHOWN_COUNT']).toBe('3');
|
||||
expect(r['RESOURCES_SHOWN']).toContain('https://url1.com');
|
||||
expect(r['RESOURCES_SHOWN']).toContain('https://url2.com');
|
||||
expect(r['RESOURCES_SHOWN']).toContain('https://url3.com');
|
||||
});
|
||||
|
||||
test('deduplicates identical URLs', () => {
|
||||
writeProfile([
|
||||
makeEntry({ resources_shown: ['https://same.com'] }),
|
||||
makeEntry({ resources_shown: ['https://same.com'], date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['RESOURCES_SHOWN_COUNT']).toBe('1');
|
||||
});
|
||||
|
||||
test('empty resources → count 0', () => {
|
||||
writeProfile([makeEntry()]);
|
||||
const r = runProfile();
|
||||
expect(r['RESOURCES_SHOWN_COUNT']).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topics', () => {
|
||||
test('aggregates topics across sessions', () => {
|
||||
writeProfile([
|
||||
makeEntry({ topics: ['fintech', 'payments'] }),
|
||||
makeEntry({ topics: ['ai-product', 'fintech'], date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['TOPICS']).toContain('fintech');
|
||||
expect(r['TOPICS']).toContain('payments');
|
||||
expect(r['TOPICS']).toContain('ai-product');
|
||||
});
|
||||
});
|
||||
|
||||
describe('last session data', () => {
|
||||
test('returns last session assignment and project', () => {
|
||||
writeProfile([
|
||||
makeEntry({ assignment: 'First task', project_slug: 'old-app' }),
|
||||
makeEntry({ assignment: 'Talk to users', project_slug: 'new-app', date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['LAST_ASSIGNMENT']).toBe('Talk to users');
|
||||
expect(r['LAST_PROJECT']).toBe('new-app');
|
||||
});
|
||||
|
||||
test('returns last design doc', () => {
|
||||
writeProfile([
|
||||
makeEntry({ design_doc: 'path/to/design-1.md' }),
|
||||
makeEntry({ design_doc: 'path/to/design-2.md', date: '2026-04-02T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['LAST_DESIGN_TITLE']).toBe('path/to/design-2.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed JSONL handling', () => {
|
||||
test('skips malformed lines, counts valid ones', () => {
|
||||
const profileFile = path.join(tmpDir, 'builder-profile.jsonl');
|
||||
const lines = [
|
||||
JSON.stringify(makeEntry({ project_slug: 'good-1' })),
|
||||
'this is not json',
|
||||
'{broken json',
|
||||
JSON.stringify(makeEntry({ project_slug: 'good-2', date: '2026-04-02T00:00:00Z' })),
|
||||
];
|
||||
fs.writeFileSync(profileFile, lines.join('\n') + '\n');
|
||||
const r = runProfile();
|
||||
expect(r['SESSION_COUNT']).toBe('2');
|
||||
expect(r['TIER']).toBe('welcome_back');
|
||||
});
|
||||
});
|
||||
|
||||
describe('design count', () => {
|
||||
test('counts entries with non-empty design_doc', () => {
|
||||
writeProfile([
|
||||
makeEntry({ design_doc: 'path/design-1.md' }),
|
||||
makeEntry({ design_doc: '', date: '2026-04-02T00:00:00Z' }),
|
||||
makeEntry({ design_doc: 'path/design-2.md', date: '2026-04-03T00:00:00Z' }),
|
||||
]);
|
||||
const r = runProfile();
|
||||
expect(r['DESIGN_COUNT']).toBe('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user