|
| 1 | +#!/usr/bin/env bash |
| 2 | +# gstack-builder-profile — read builder profile and output structured summary |
| 3 | +# |
| 4 | +# Reads ~/.gstack/builder-profile.jsonl (append-only session log from /office-hours). |
| 5 | +# Outputs KEY: VALUE pairs for the template to consume. Computes tier, accumulated |
| 6 | +# signals, cross-project detection, nudge eligibility, and resource dedup. |
| 7 | +# |
| 8 | +# Single source of truth for all closing state. No separate config keys or logs. |
| 9 | +# |
| 10 | +# Exit 0 with defaults if no profile exists (first-time user = introduction tier). |
| 11 | +set -euo pipefail |
| 12 | + |
| 13 | +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" |
| 14 | +PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl" |
| 15 | + |
| 16 | +# Graceful default: no profile = introduction tier |
| 17 | +if [ ! -f "$PROFILE_FILE" ] || [ ! -s "$PROFILE_FILE" ]; then |
| 18 | + echo "SESSION_COUNT: 0" |
| 19 | + echo "TIER: introduction" |
| 20 | + echo "LAST_PROJECT:" |
| 21 | + echo "LAST_ASSIGNMENT:" |
| 22 | + echo "LAST_DESIGN_TITLE:" |
| 23 | + echo "DESIGN_COUNT: 0" |
| 24 | + echo "DESIGN_TITLES: []" |
| 25 | + echo "ACCUMULATED_SIGNALS:" |
| 26 | + echo "TOTAL_SIGNAL_COUNT: 0" |
| 27 | + echo "CROSS_PROJECT: false" |
| 28 | + echo "NUDGE_ELIGIBLE: false" |
| 29 | + echo "RESOURCES_SHOWN:" |
| 30 | + echo "RESOURCES_SHOWN_COUNT: 0" |
| 31 | + echo "TOPICS:" |
| 32 | + exit 0 |
| 33 | +fi |
| 34 | + |
| 35 | +# Use bun for JSON parsing (same pattern as gstack-learnings-search). |
| 36 | +# Fallback to defaults if bun is unavailable. |
| 37 | +cat "$PROFILE_FILE" 2>/dev/null | bun -e " |
| 38 | +const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); |
| 39 | +const entries = []; |
| 40 | +for (const line of lines) { |
| 41 | + try { entries.push(JSON.parse(line)); } catch {} |
| 42 | +} |
| 43 | +
|
| 44 | +const count = entries.length; |
| 45 | +
|
| 46 | +// Tier computation |
| 47 | +let tier = 'introduction'; |
| 48 | +if (count >= 8) tier = 'inner_circle'; |
| 49 | +else if (count >= 4) tier = 'regular'; |
| 50 | +else if (count >= 1) tier = 'welcome_back'; |
| 51 | +
|
| 52 | +// Last session data |
| 53 | +const last = entries[count - 1] || {}; |
| 54 | +const prev = entries[count - 2] || {}; |
| 55 | +const crossProject = prev.project_slug && last.project_slug |
| 56 | + ? prev.project_slug !== last.project_slug |
| 57 | + : false; |
| 58 | +
|
| 59 | +// Design docs |
| 60 | +const designs = entries |
| 61 | + .map(e => e.design_doc || '') |
| 62 | + .filter(Boolean); |
| 63 | +const designTitles = entries |
| 64 | + .map(e => { |
| 65 | + const doc = e.design_doc || ''; |
| 66 | + // Extract title from path: ...-design-DATETIME.md -> use the entry's topic or project |
| 67 | + return doc ? (e.project_slug || 'unknown') : ''; |
| 68 | + }) |
| 69 | + .filter(Boolean); |
| 70 | +
|
| 71 | +// Accumulated signals |
| 72 | +const signalCounts = {}; |
| 73 | +let totalSignals = 0; |
| 74 | +for (const e of entries) { |
| 75 | + for (const s of (e.signals || [])) { |
| 76 | + signalCounts[s] = (signalCounts[s] || 0) + 1; |
| 77 | + totalSignals++; |
| 78 | + } |
| 79 | +} |
| 80 | +const signalStr = Object.entries(signalCounts) |
| 81 | + .map(([k, v]) => k + ':' + v) |
| 82 | + .join(','); |
| 83 | +
|
| 84 | +// Nudge eligibility: builder-mode + 5+ signals across 3+ sessions |
| 85 | +const builderSessions = entries.filter(e => e.mode !== 'startup').length; |
| 86 | +const nudgeEligible = builderSessions >= 3 && totalSignals >= 5; |
| 87 | +
|
| 88 | +// Resources shown (aggregate all) |
| 89 | +const allResources = new Set(); |
| 90 | +for (const e of entries) { |
| 91 | + for (const url of (e.resources_shown || [])) { |
| 92 | + allResources.add(url); |
| 93 | + } |
| 94 | +} |
| 95 | +
|
| 96 | +// Topics (aggregate all) |
| 97 | +const allTopics = new Set(); |
| 98 | +for (const e of entries) { |
| 99 | + for (const t of (e.topics || [])) { |
| 100 | + allTopics.add(t); |
| 101 | + } |
| 102 | +} |
| 103 | +
|
| 104 | +console.log('SESSION_COUNT: ' + count); |
| 105 | +console.log('TIER: ' + tier); |
| 106 | +console.log('LAST_PROJECT: ' + (last.project_slug || '')); |
| 107 | +console.log('LAST_ASSIGNMENT: ' + (last.assignment || '')); |
| 108 | +console.log('LAST_DESIGN_TITLE: ' + (last.design_doc || '')); |
| 109 | +console.log('DESIGN_COUNT: ' + designs.length); |
| 110 | +console.log('DESIGN_TITLES: ' + JSON.stringify(designTitles)); |
| 111 | +console.log('ACCUMULATED_SIGNALS: ' + signalStr); |
| 112 | +console.log('TOTAL_SIGNAL_COUNT: ' + totalSignals); |
| 113 | +console.log('CROSS_PROJECT: ' + crossProject); |
| 114 | +console.log('NUDGE_ELIGIBLE: ' + nudgeEligible); |
| 115 | +console.log('RESOURCES_SHOWN: ' + Array.from(allResources).join(',')); |
| 116 | +console.log('RESOURCES_SHOWN_COUNT: ' + allResources.size); |
| 117 | +console.log('TOPICS: ' + Array.from(allTopics).join(',')); |
| 118 | +" 2>/dev/null || { |
| 119 | + # Fallback if bun is unavailable |
| 120 | + echo "SESSION_COUNT: 0" |
| 121 | + echo "TIER: introduction" |
| 122 | + echo "LAST_PROJECT:" |
| 123 | + echo "LAST_ASSIGNMENT:" |
| 124 | + echo "LAST_DESIGN_TITLE:" |
| 125 | + echo "DESIGN_COUNT: 0" |
| 126 | + echo "DESIGN_TITLES: []" |
| 127 | + echo "ACCUMULATED_SIGNALS:" |
| 128 | + echo "TOTAL_SIGNAL_COUNT: 0" |
| 129 | + echo "CROSS_PROJECT: false" |
| 130 | + echo "NUDGE_ELIGIBLE: false" |
| 131 | + echo "RESOURCES_SHOWN:" |
| 132 | + echo "RESOURCES_SHOWN_COUNT: 0" |
| 133 | + echo "TOPICS:" |
| 134 | +} |
0 commit comments