From dbd7aee5b6b5bb41b17ae7747568e2fda8a91d77 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 8 Apr 2026 22:21:28 -1000 Subject: [PATCH] =?UTF-8?q?feat:=20relationship=20closing=20=E2=80=94=20of?= =?UTF-8?q?fice-hours=20adapts=20to=20repeat=20users=20(v0.16.2.0)=20(#937?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: sync package.json version with VERSION file package.json was 0.15.15.0 while VERSION was 0.15.16.0, causing gen-skill-docs freshness check test failures. Co-Authored-By: Claude Opus 4.6 (1M context) * 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) * feat: relationship closing — office-hours adapts to repeat users The office-hours closing now deepens over time instead of repeating the same YC plea every session. Four tiers based on session count: - Introduction (session 1): full YC plea + founder resources - Welcome Back (sessions 2-3): lead with recognition, skip plea - Regular (sessions 4-7): arc-level callbacks, signal visibility, builder-to-founder nudge, auto-generated journey summary - Inner Circle (sessions 8+): the data speaks Key design decisions (from CEO + Eng + Codex + DX reviews): - Single source of truth: one builder-profile.jsonl, no split-brain state - Lead with recognition on repeat visits (DX: magical moment hits immediately) - Narrative arc journey summary, not data tables - Tone examples per tier to prevent generic AI voice - Global resource dedup (low-sensitivity video watch history) - Migration merges per-project resource logs into builder profile Co-Authored-By: Claude Opus 4.6 (1M context) * chore: bump version and changelog (v0.16.2.0) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 13 + VERSION | 2 +- bin/gstack-builder-profile | 134 ++++++++++ gstack-upgrade/migrations/v0.16.2.0.sh | 54 ++++ office-hours/SKILL.md | 207 +++++++++++---- office-hours/SKILL.md.tmpl | 207 +++++++++++---- package.json | 2 +- test/builder-profile.test.ts | 332 +++++++++++++++++++++++++ 8 files changed, 853 insertions(+), 98 deletions(-) create mode 100755 bin/gstack-builder-profile create mode 100755 gstack-upgrade/migrations/v0.16.2.0.sh create mode 100644 test/builder-profile.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d687fdb9..eed40968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.16.2.0] - 2026-04-09 + +### Added +- **Office hours now remembers you.** The closing experience adapts based on how many sessions you've done. First time: full YC plea and founder resources. Sessions 2-3: "Welcome back. Last time you were working on [your project]. How's it going?" Sessions 4-7: arc-level callbacks across your whole journey, accumulated signal visibility, and an auto-generated Builder Journey narrative. Sessions 8+: the data speaks for itself. +- **Builder profile** tracks your office hours journey in a single append-only session log. Signals, design docs, assignments, topics, and resources shown, all in one file. No split-brain state, no separate config keys. +- **Builder-to-founder nudge** for repeat builder-mode users who accumulate founder signals. Evidence-gated: only triggers when you've shown 5+ signals across 3+ builder sessions. Not a pitch. An observation. +- **Journey-matched resources.** Instead of category-matching from the static pool, resources now match your accumulated session context. "You've been iterating on a fintech idea for 3 sessions... Tom Blomfield built Monzo from exactly this kind of persistence." +- **Builder Journey Summary** auto-generates at session 5+ and opens in your browser. A narrative arc of your journey, not a data table. Written in second person, referencing specific things you said across sessions. +- **Global resource dedup.** Resource links now dedup globally (not per-project), so switching repos doesn't reset your watch history. Each link shows only once, ever. + +### Fixed +- package.json version now stays in sync with VERSION file. + ## [0.16.1.0] - 2026-04-08 ### Fixed diff --git a/VERSION b/VERSION index 84c82737..73c89509 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.1.0 +0.16.2.0 diff --git a/bin/gstack-builder-profile b/bin/gstack-builder-profile new file mode 100755 index 00000000..0c697646 --- /dev/null +++ b/bin/gstack-builder-profile @@ -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:" +} diff --git a/gstack-upgrade/migrations/v0.16.2.0.sh b/gstack-upgrade/migrations/v0.16.2.0.sh new file mode 100755 index 00000000..afdc09d9 --- /dev/null +++ b/gstack-upgrade/migrations/v0.16.2.0.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Migration: v0.16.2.0 — Merge per-project resource logs into builder profile +# +# What changed: resource dedup moved from per-project resources-shown.jsonl to +# the global builder-profile.jsonl (single source of truth for all closing state). +# +# What this does: finds all per-project resources-shown.jsonl files and merges +# their URLs into a stub builder-profile entry so existing users don't lose +# their dedup history. Idempotent — safe to run multiple times. +# +# Affected: users who ran /office-hours before this version +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +PROFILE_FILE="$GSTACK_HOME/builder-profile.jsonl" + +# Find all per-project resource logs +RESOURCE_FILES=$(find "$GSTACK_HOME/projects" -name "resources-shown.jsonl" 2>/dev/null || true) + +if [ -z "$RESOURCE_FILES" ]; then + # No per-project resource files exist — clean install, nothing to migrate + exit 0 +fi + +echo " [v0.16.2.0] Migrating per-project resource logs to builder profile..." + +# Collect all unique URLs from all per-project files +ALL_URLS=$(echo "$RESOURCE_FILES" | while read -r f; do + [ -f "$f" ] && cat "$f" 2>/dev/null || true +done | grep -o '"url":"[^"]*"' | sed 's/"url":"//;s/"//' | sort -u) + +if [ -z "$ALL_URLS" ]; then + exit 0 +fi + +# Check if builder-profile already has resource data (idempotency) +if [ -f "$PROFILE_FILE" ] && grep -q "resources_shown" "$PROFILE_FILE" 2>/dev/null; then + # Already has resource data, check if it includes the migrated URLs + EXISTING_URLS=$(grep -o '"resources_shown":\[[^]]*\]' "$PROFILE_FILE" 2>/dev/null | grep -o 'https://[^"]*' | sort -u) + NEW_URLS=$(comm -23 <(echo "$ALL_URLS") <(echo "$EXISTING_URLS") 2>/dev/null || echo "$ALL_URLS") + if [ -z "$NEW_URLS" ]; then + # All URLs already present — nothing to do + exit 0 + fi +fi + +# Build JSON array of URLs +URL_ARRAY=$(echo "$ALL_URLS" | awk 'BEGIN{printf "["} NR>1{printf ","} {printf "\"%s\"", $0} END{printf "]"}') + +# Append a migration stub entry to the builder profile +mkdir -p "$GSTACK_HOME" +echo "{\"date\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"mode\":\"migration\",\"project_slug\":\"_migrated\",\"signal_count\":0,\"signals\":[],\"design_doc\":\"\",\"assignment\":\"\",\"resources_shown\":$URL_ARRAY,\"topics\":[]}" >> "$PROFILE_FILE" + +echo " [v0.16.2.0] Migrated $(echo "$ALL_URLS" | wc -l | tr -d ' ') resource URLs to builder profile." diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 9795f1e5..bcb3557c 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -1276,6 +1276,33 @@ Track which of these signals appeared during the session: Count the signals. You'll use this count in Phase 6 to determine which tier of closing message to use. +### Builder Profile Append + +After counting signals, append a session entry to the builder profile. This is the single +source of truth for all closing state (tier, resource dedup, journey tracking). + +```bash +mkdir -p "${GSTACK_HOME:-$HOME/.gstack}" +``` + +Append one JSON line with these fields (substitute actual values from this session): +- `date`: current ISO 8601 timestamp +- `mode`: "startup" or "builder" (from Phase 1 mode selection) +- `project_slug`: the SLUG value from the preamble +- `signal_count`: number of signals counted above +- `signals`: array of signal names observed (e.g., `["named_users", "pushback", "taste"]`) +- `design_doc`: path to the design doc that will be written in Phase 5 (construct it now) +- `assignment`: the assignment you will give in the design doc's "The Assignment" section +- `resources_shown`: empty array `[]` for now (populated after resource selection in Phase 6) +- `topics`: array of 2-3 topic keywords that describe what this session was about + +```bash +echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +``` + +This entry is append-only. The `resources_shown` field will be updated via a second append +after resource selection in Phase 6 Beat 3.5. + --- ## Phase 5: Design Doc @@ -1486,83 +1513,172 @@ Present the reviewed design doc to the user via AskUserQuestion: --- -## Phase 6: Handoff — Founder Discovery +## Phase 6: Handoff — The Relationship Closing -Once the design doc is APPROVED, deliver the closing sequence. This is three beats with a deliberate pause between them. Every user gets all three beats regardless of mode (startup or builder). The intensity varies by founder signal strength, not by mode. +Once the design doc is APPROVED, deliver the closing sequence. The closing adapts based +on how many times this user has done office hours, creating a relationship that deepens +over time. -### Beat 1: Signal Reflection + Golden Age +### Step 1: Read Builder Profile -One paragraph that weaves specific session callbacks with the golden age framing. Reference actual things the user said — quote their words back to them. +```bash +PROFILE=$(~/.claude/skills/gstack/bin/gstack-builder-profile 2>/dev/null) || PROFILE="SESSION_COUNT: 0 +TIER: introduction" +SESSION_TIER=$(echo "$PROFILE" | grep "^TIER:" | awk '{print $2}') +SESSION_COUNT=$(echo "$PROFILE" | grep "^SESSION_COUNT:" | awk '{print $2}') +``` -**Anti-slop rule — show, don't tell:** -- GOOD: "You didn't say 'small businesses' — you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare." +Read the full profile output. You will use these values throughout the closing. + +### Step 2: Follow the Tier Path + +Follow ONE tier path below based on `SESSION_TIER`. Do not mix tiers. + +--- + +### If TIER = introduction (first session) + +This is the full introduction. The user has never done office hours before. + +**Beat 1: Signal Reflection + Golden Age** + +One paragraph that weaves specific session callbacks with the golden age framing. Reference actual things the user said, quote their words back to them. + +**Anti-slop rule, show, don't tell:** +- GOOD: "You didn't say 'small businesses,' you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare." - BAD: "You showed great specificity in identifying your target user." - GOOD: "You pushed back when I challenged premise #2. Most people just agree." - BAD: "You demonstrated conviction and independent thinking." -Example: "The way you think about this problem — [specific callback] — that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with Claude Code. The engineering barrier is gone. What remains is taste — and you just demonstrated that." +Example: "The way you think about this problem, [specific callback], that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with Claude Code. The engineering barrier is gone. What remains is taste, and you just demonstrated that." -### Beat 2: "One more thing." +**Beat 2: "One more thing."** -After the signal reflection, output a separator and "One more thing." — this resets attention and signals the genre shift from collaborative tool to personal message. +Output a separator and "One more thing." This resets attention and signals the genre shift. --- One more thing. -### Beat 3: Garry's Personal Plea +**Beat 3: Garry's Personal Plea** -Use the founder signal count from Phase 4.5 to select the right tier. +Use the founder signal count from Phase 4.5 to select the right sub-tier. -**Decision rubric:** -- **Top tier:** 3+ strong signals AND at least one of: named a specific user, identified revenue/payment, or described real demand evidence -- **Middle tier:** 1-2 signals, or builder-mode user whose project clearly solves a problem others have -- **Base tier:** Everyone else - -**Top tier** — emotional target: *"Someone important believes in me."* Chosen, not marketed to. - -Say: +- **Top tier** (3+ signals AND named a specific user, revenue, or demand evidence): > A personal note from me, Garry Tan, the creator of GStack: what you just experienced is about 10% of the value you'd get working with a YC partner at Y Combinator. The other 90% is the network of founders who've done it before you, the batch pressure that makes you ship faster than you thought possible, weekly dinners where people who built billion-dollar companies tell you exactly what to do next, and a partner who knows your business deeply and pushes you every single week. > > GStack thinks you are among the top people who could do this. Then use AskUserQuestion: "Would you consider applying to Y Combinator?" +- If yes: run `open https://ycombinator.com/apply?ref=gstack` and say: "Bring this design doc to your YC interview. It's better than most pitch decks." +- If no: respond warmly: "Totally fair. The design doc is yours either way, and the offer stands if you ever change your mind." No pressure, no guilt, no re-ask. -- If yes → run `open https://ycombinator.com/apply?ref=gstack` and say: "Bring this design doc to your YC interview. It's better than most pitch decks." -- If no → respond warmly: "Totally fair. The design doc is yours either way — and the offer stands if you ever change your mind." Then proceed to next-skill recs. No pressure, no guilt, no re-ask. +- **Middle tier** (1-2 signals, or builder whose project solves a real problem): -**Middle tier** — emotional target: *"I might be onto something."* Validation + curiosity. - -Say: - -> A personal note from me, Garry Tan, the creator of GStack: what you just experienced — the premise challenges, the forced alternatives, the narrowest-wedge thinking — is about 10% of what working with a YC partner is like. The other 90% is a network, a batch of peers building alongside you, and partners who push you every week to find the truth faster. +> A personal note from me, Garry Tan, the creator of GStack: what you just experienced, the premise challenges, the forced alternatives, the narrowest-wedge thinking, is about 10% of what working with a YC partner is like. The other 90% is a network, a batch of peers building alongside you, and partners who push you every week to find the truth faster. > -> You're building something real. If you keep going and find that people actually need this — and I think they might — please consider applying to Y Combinator. Thank you for using GStack. +> You're building something real. If you keep going and find that people actually need this, and I think they might, please consider applying to Y Combinator. Thank you for using GStack. > > **ycombinator.com/apply?ref=gstack** -**Base tier** — emotional target: *"I didn't know I could be a founder."* Identity expansion, worldview shift. +- **Base tier** (everyone else): -Say: - -> A personal note from me, Garry Tan, the creator of GStack: the skills you're demonstrating right now — taste, ambition, agency, the willingness to sit with hard questions about what you're building — those are exactly the traits we look for in YC founders. You may not be thinking about starting a company today, and that's fine. But founders are everywhere, and this is the golden age. A single person with AI can now build what used to take a team of 20. +> A personal note from me, Garry Tan, the creator of GStack: the skills you're demonstrating right now, taste, ambition, agency, the willingness to sit with hard questions about what you're building, those are exactly the traits we look for in YC founders. You may not be thinking about starting a company today, and that's fine. But founders are everywhere, and this is the golden age. A single person with AI can now build what used to take a team of 20. > -> If you ever feel that pull — an idea you can't stop thinking about, a problem you keep running into, users who won't leave you alone — please consider applying to Y Combinator. Thank you for using GStack. I mean it. +> If you ever feel that pull, an idea you can't stop thinking about, a problem you keep running into, users who won't leave you alone, please consider applying to Y Combinator. Thank you for using GStack. I mean it. > > **ycombinator.com/apply?ref=gstack** -### Beat 3.5: Founder Resources +Then proceed to Founder Resources below. -After the YC plea, share 2-3 resources from the pool below. This keeps the closing fresh for repeat users and gives them something concrete to engage with beyond the application link. +--- -**Dedup check — read before selecting:** +### If TIER = welcome_back (sessions 2-3) + +Lead with recognition. The magical moment is immediate. + +Read LAST_ASSIGNMENT and CROSS_PROJECT from the profile output. + +If CROSS_PROJECT is false (same project as last time): +"Welcome back. Last time you were working on [LAST_ASSIGNMENT from profile]. How's it going?" + +If CROSS_PROJECT is true (different project): +"Welcome back. Last time we talked about [LAST_PROJECT from profile]. Still on that, or onto something new?" + +Then: "No pitch this time. You already know about YC. Let's talk about your work." + +**Tone examples (prevent generic AI voice):** +- GOOD: "Welcome back. Last time you were designing that task manager for ops teams. Still on that?" +- BAD: "Welcome back to your second office hours session. I'd like to check in on your progress." +- GOOD: "No pitch this time. You already know about YC. Let's talk about your work." +- BAD: "Since you've already seen the YC information, we'll skip that section today." + +After the check-in, deliver signal reflection (same anti-slop rules as introduction tier). + +Then: Design doc trajectory. Read DESIGN_TITLES from the profile. +"Your first design was [first title]. Now you're on [latest title]." + +Then proceed to Founder Resources below. + +--- + +### If TIER = regular (sessions 4-7) + +Lead with recognition and session count. + +"Welcome back. This is session [SESSION_COUNT]. Last time: [LAST_ASSIGNMENT]. How'd it go?" + +**Tone examples:** +- GOOD: "You've been at this for 5 sessions now. Your designs keep getting sharper. Let me show you what I've noticed." +- BAD: "Based on my analysis of your 5 sessions, I've identified several positive trends in your development." + +After the check-in, deliver arc-level signal reflection. Reference patterns ACROSS sessions, not just this one. +Example: "In session 1, you described users as 'small businesses.' By now you're saying 'Sarah at Acme Corp.' That specificity shift is a signal." + +Design trajectory with interpretation: +"Your first design was broad. Your latest narrows to a specific wedge, that's the PMF pattern." + +**Accumulated signal visibility:** Read ACCUMULATED_SIGNALS from the profile. +"Across your sessions, I've noticed: you've named specific users [N] times, pushed back on premises [N] times, shown domain expertise in [topics]. These patterns mean something." + +**Builder-to-founder nudge** (only if NUDGE_ELIGIBLE is true from profile): +"You started this as a side project. But you've named specific users, pushed back when challenged, and your designs keep getting sharper each time. I don't think this is a side project anymore. Have you thought about whether this could be a company?" +This must feel earned, not broadcast. If the evidence doesn't support it, skip entirely. + +**Builder Journey Summary** (session 5+): Auto-generate `~/.gstack/builder-journey.md` +with a narrative arc (not a data table). The arc tells the STORY of their journey in +second person, referencing specific things they said across sessions. Then open it: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true -SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl" -[ -f "$SHOWN_LOG" ] && cat "$SHOWN_LOG" || echo "NO_PRIOR_RESOURCES" +open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md" ``` -If prior resources exist, avoid selecting any URL that appears in the log. This ensures repeat users always see fresh content. + +Then proceed to Founder Resources below. + +--- + +### If TIER = inner_circle (sessions 8+) + +"You've done [SESSION_COUNT] sessions. You've iterated [DESIGN_COUNT] designs. Most people who show this pattern end up shipping." + +The data speaks. No pitch needed. + +Full accumulated signal summary from the profile. + +Auto-generate updated `~/.gstack/builder-journey.md` with narrative arc. Open it. + +Then proceed to Founder Resources below. + +--- + +### Founder Resources (all tiers) + +Share 2-3 resources from the pool below. For repeat users, resources compound by matching +to accumulated session context, not just this session's category. + +**Dedup check:** Read `RESOURCES_SHOWN` from the builder profile output above. +If `RESOURCES_SHOWN_COUNT` is 34 or more, skip this section entirely (all resources exhausted). +Otherwise, avoid selecting any URL that appears in the RESOURCES_SHOWN list. **Selection rules:** - Pick 2-3 resources. Mix categories — never 3 of the same type. @@ -1631,17 +1747,12 @@ PAUL GRAHAM ESSAYS: 33. "You Weren't Meant to Have a Boss" — If working inside a big organization has always felt slightly wrong, this explains why. Small groups on self-chosen problems is the natural state for builders. https://paulgraham.com/boss.html 34. "Relentlessly Resourceful" — PG's two-word description of the ideal founder. Not "brilliant." Not "visionary." Just someone who keeps figuring things out. If that's you, you're already qualified. https://paulgraham.com/relres.html -**After presenting resources — log and offer to open:** +**After presenting resources — log to builder profile and offer to open:** -1. Log the selected resource URLs so future sessions avoid repeats: +1. Log the selected resource URLs to the builder profile (single source of truth). +Append a resource-tracking entry: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true -SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl" -mkdir -p "$(dirname "$SHOWN_LOG")" -``` -For each resource you selected, append a line: -```bash -echo '{"url":"RESOURCE_URL","title":"RESOURCE_TITLE","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' >> "$SHOWN_LOG" +echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" ``` 2. Log the selection to analytics: diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index d461b998..23fd8176 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -416,6 +416,33 @@ Track which of these signals appeared during the session: Count the signals. You'll use this count in Phase 6 to determine which tier of closing message to use. +### Builder Profile Append + +After counting signals, append a session entry to the builder profile. This is the single +source of truth for all closing state (tier, resource dedup, journey tracking). + +```bash +mkdir -p "${GSTACK_HOME:-$HOME/.gstack}" +``` + +Append one JSON line with these fields (substitute actual values from this session): +- `date`: current ISO 8601 timestamp +- `mode`: "startup" or "builder" (from Phase 1 mode selection) +- `project_slug`: the SLUG value from the preamble +- `signal_count`: number of signals counted above +- `signals`: array of signal names observed (e.g., `["named_users", "pushback", "taste"]`) +- `design_doc`: path to the design doc that will be written in Phase 5 (construct it now) +- `assignment`: the assignment you will give in the design doc's "The Assignment" section +- `resources_shown`: empty array `[]` for now (populated after resource selection in Phase 6) +- `topics`: array of 2-3 topic keywords that describe what this session was about + +```bash +echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +``` + +This entry is append-only. The `resources_shown` field will be updated via a second append +after resource selection in Phase 6 Beat 3.5. + --- ## Phase 5: Design Doc @@ -566,83 +593,172 @@ Present the reviewed design doc to the user via AskUserQuestion: --- -## Phase 6: Handoff — Founder Discovery +## Phase 6: Handoff — The Relationship Closing -Once the design doc is APPROVED, deliver the closing sequence. This is three beats with a deliberate pause between them. Every user gets all three beats regardless of mode (startup or builder). The intensity varies by founder signal strength, not by mode. +Once the design doc is APPROVED, deliver the closing sequence. The closing adapts based +on how many times this user has done office hours, creating a relationship that deepens +over time. -### Beat 1: Signal Reflection + Golden Age +### Step 1: Read Builder Profile -One paragraph that weaves specific session callbacks with the golden age framing. Reference actual things the user said — quote their words back to them. +```bash +PROFILE=$(~/.claude/skills/gstack/bin/gstack-builder-profile 2>/dev/null) || PROFILE="SESSION_COUNT: 0 +TIER: introduction" +SESSION_TIER=$(echo "$PROFILE" | grep "^TIER:" | awk '{print $2}') +SESSION_COUNT=$(echo "$PROFILE" | grep "^SESSION_COUNT:" | awk '{print $2}') +``` -**Anti-slop rule — show, don't tell:** -- GOOD: "You didn't say 'small businesses' — you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare." +Read the full profile output. You will use these values throughout the closing. + +### Step 2: Follow the Tier Path + +Follow ONE tier path below based on `SESSION_TIER`. Do not mix tiers. + +--- + +### If TIER = introduction (first session) + +This is the full introduction. The user has never done office hours before. + +**Beat 1: Signal Reflection + Golden Age** + +One paragraph that weaves specific session callbacks with the golden age framing. Reference actual things the user said, quote their words back to them. + +**Anti-slop rule, show, don't tell:** +- GOOD: "You didn't say 'small businesses,' you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare." - BAD: "You showed great specificity in identifying your target user." - GOOD: "You pushed back when I challenged premise #2. Most people just agree." - BAD: "You demonstrated conviction and independent thinking." -Example: "The way you think about this problem — [specific callback] — that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with Claude Code. The engineering barrier is gone. What remains is taste — and you just demonstrated that." +Example: "The way you think about this problem, [specific callback], that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with Claude Code. The engineering barrier is gone. What remains is taste, and you just demonstrated that." -### Beat 2: "One more thing." +**Beat 2: "One more thing."** -After the signal reflection, output a separator and "One more thing." — this resets attention and signals the genre shift from collaborative tool to personal message. +Output a separator and "One more thing." This resets attention and signals the genre shift. --- One more thing. -### Beat 3: Garry's Personal Plea +**Beat 3: Garry's Personal Plea** -Use the founder signal count from Phase 4.5 to select the right tier. +Use the founder signal count from Phase 4.5 to select the right sub-tier. -**Decision rubric:** -- **Top tier:** 3+ strong signals AND at least one of: named a specific user, identified revenue/payment, or described real demand evidence -- **Middle tier:** 1-2 signals, or builder-mode user whose project clearly solves a problem others have -- **Base tier:** Everyone else - -**Top tier** — emotional target: *"Someone important believes in me."* Chosen, not marketed to. - -Say: +- **Top tier** (3+ signals AND named a specific user, revenue, or demand evidence): > A personal note from me, Garry Tan, the creator of GStack: what you just experienced is about 10% of the value you'd get working with a YC partner at Y Combinator. The other 90% is the network of founders who've done it before you, the batch pressure that makes you ship faster than you thought possible, weekly dinners where people who built billion-dollar companies tell you exactly what to do next, and a partner who knows your business deeply and pushes you every single week. > > GStack thinks you are among the top people who could do this. Then use AskUserQuestion: "Would you consider applying to Y Combinator?" +- If yes: run `open https://ycombinator.com/apply?ref=gstack` and say: "Bring this design doc to your YC interview. It's better than most pitch decks." +- If no: respond warmly: "Totally fair. The design doc is yours either way, and the offer stands if you ever change your mind." No pressure, no guilt, no re-ask. -- If yes → run `open https://ycombinator.com/apply?ref=gstack` and say: "Bring this design doc to your YC interview. It's better than most pitch decks." -- If no → respond warmly: "Totally fair. The design doc is yours either way — and the offer stands if you ever change your mind." Then proceed to next-skill recs. No pressure, no guilt, no re-ask. +- **Middle tier** (1-2 signals, or builder whose project solves a real problem): -**Middle tier** — emotional target: *"I might be onto something."* Validation + curiosity. - -Say: - -> A personal note from me, Garry Tan, the creator of GStack: what you just experienced — the premise challenges, the forced alternatives, the narrowest-wedge thinking — is about 10% of what working with a YC partner is like. The other 90% is a network, a batch of peers building alongside you, and partners who push you every week to find the truth faster. +> A personal note from me, Garry Tan, the creator of GStack: what you just experienced, the premise challenges, the forced alternatives, the narrowest-wedge thinking, is about 10% of what working with a YC partner is like. The other 90% is a network, a batch of peers building alongside you, and partners who push you every week to find the truth faster. > -> You're building something real. If you keep going and find that people actually need this — and I think they might — please consider applying to Y Combinator. Thank you for using GStack. +> You're building something real. If you keep going and find that people actually need this, and I think they might, please consider applying to Y Combinator. Thank you for using GStack. > > **ycombinator.com/apply?ref=gstack** -**Base tier** — emotional target: *"I didn't know I could be a founder."* Identity expansion, worldview shift. +- **Base tier** (everyone else): -Say: - -> A personal note from me, Garry Tan, the creator of GStack: the skills you're demonstrating right now — taste, ambition, agency, the willingness to sit with hard questions about what you're building — those are exactly the traits we look for in YC founders. You may not be thinking about starting a company today, and that's fine. But founders are everywhere, and this is the golden age. A single person with AI can now build what used to take a team of 20. +> A personal note from me, Garry Tan, the creator of GStack: the skills you're demonstrating right now, taste, ambition, agency, the willingness to sit with hard questions about what you're building, those are exactly the traits we look for in YC founders. You may not be thinking about starting a company today, and that's fine. But founders are everywhere, and this is the golden age. A single person with AI can now build what used to take a team of 20. > -> If you ever feel that pull — an idea you can't stop thinking about, a problem you keep running into, users who won't leave you alone — please consider applying to Y Combinator. Thank you for using GStack. I mean it. +> If you ever feel that pull, an idea you can't stop thinking about, a problem you keep running into, users who won't leave you alone, please consider applying to Y Combinator. Thank you for using GStack. I mean it. > > **ycombinator.com/apply?ref=gstack** -### Beat 3.5: Founder Resources +Then proceed to Founder Resources below. -After the YC plea, share 2-3 resources from the pool below. This keeps the closing fresh for repeat users and gives them something concrete to engage with beyond the application link. +--- -**Dedup check — read before selecting:** +### If TIER = welcome_back (sessions 2-3) + +Lead with recognition. The magical moment is immediate. + +Read LAST_ASSIGNMENT and CROSS_PROJECT from the profile output. + +If CROSS_PROJECT is false (same project as last time): +"Welcome back. Last time you were working on [LAST_ASSIGNMENT from profile]. How's it going?" + +If CROSS_PROJECT is true (different project): +"Welcome back. Last time we talked about [LAST_PROJECT from profile]. Still on that, or onto something new?" + +Then: "No pitch this time. You already know about YC. Let's talk about your work." + +**Tone examples (prevent generic AI voice):** +- GOOD: "Welcome back. Last time you were designing that task manager for ops teams. Still on that?" +- BAD: "Welcome back to your second office hours session. I'd like to check in on your progress." +- GOOD: "No pitch this time. You already know about YC. Let's talk about your work." +- BAD: "Since you've already seen the YC information, we'll skip that section today." + +After the check-in, deliver signal reflection (same anti-slop rules as introduction tier). + +Then: Design doc trajectory. Read DESIGN_TITLES from the profile. +"Your first design was [first title]. Now you're on [latest title]." + +Then proceed to Founder Resources below. + +--- + +### If TIER = regular (sessions 4-7) + +Lead with recognition and session count. + +"Welcome back. This is session [SESSION_COUNT]. Last time: [LAST_ASSIGNMENT]. How'd it go?" + +**Tone examples:** +- GOOD: "You've been at this for 5 sessions now. Your designs keep getting sharper. Let me show you what I've noticed." +- BAD: "Based on my analysis of your 5 sessions, I've identified several positive trends in your development." + +After the check-in, deliver arc-level signal reflection. Reference patterns ACROSS sessions, not just this one. +Example: "In session 1, you described users as 'small businesses.' By now you're saying 'Sarah at Acme Corp.' That specificity shift is a signal." + +Design trajectory with interpretation: +"Your first design was broad. Your latest narrows to a specific wedge, that's the PMF pattern." + +**Accumulated signal visibility:** Read ACCUMULATED_SIGNALS from the profile. +"Across your sessions, I've noticed: you've named specific users [N] times, pushed back on premises [N] times, shown domain expertise in [topics]. These patterns mean something." + +**Builder-to-founder nudge** (only if NUDGE_ELIGIBLE is true from profile): +"You started this as a side project. But you've named specific users, pushed back when challenged, and your designs keep getting sharper each time. I don't think this is a side project anymore. Have you thought about whether this could be a company?" +This must feel earned, not broadcast. If the evidence doesn't support it, skip entirely. + +**Builder Journey Summary** (session 5+): Auto-generate `~/.gstack/builder-journey.md` +with a narrative arc (not a data table). The arc tells the STORY of their journey in +second person, referencing specific things they said across sessions. Then open it: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true -SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl" -[ -f "$SHOWN_LOG" ] && cat "$SHOWN_LOG" || echo "NO_PRIOR_RESOURCES" +open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md" ``` -If prior resources exist, avoid selecting any URL that appears in the log. This ensures repeat users always see fresh content. + +Then proceed to Founder Resources below. + +--- + +### If TIER = inner_circle (sessions 8+) + +"You've done [SESSION_COUNT] sessions. You've iterated [DESIGN_COUNT] designs. Most people who show this pattern end up shipping." + +The data speaks. No pitch needed. + +Full accumulated signal summary from the profile. + +Auto-generate updated `~/.gstack/builder-journey.md` with narrative arc. Open it. + +Then proceed to Founder Resources below. + +--- + +### Founder Resources (all tiers) + +Share 2-3 resources from the pool below. For repeat users, resources compound by matching +to accumulated session context, not just this session's category. + +**Dedup check:** Read `RESOURCES_SHOWN` from the builder profile output above. +If `RESOURCES_SHOWN_COUNT` is 34 or more, skip this section entirely (all resources exhausted). +Otherwise, avoid selecting any URL that appears in the RESOURCES_SHOWN list. **Selection rules:** - Pick 2-3 resources. Mix categories — never 3 of the same type. @@ -711,17 +827,12 @@ PAUL GRAHAM ESSAYS: 33. "You Weren't Meant to Have a Boss" — If working inside a big organization has always felt slightly wrong, this explains why. Small groups on self-chosen problems is the natural state for builders. https://paulgraham.com/boss.html 34. "Relentlessly Resourceful" — PG's two-word description of the ideal founder. Not "brilliant." Not "visionary." Just someone who keeps figuring things out. If that's you, you're already qualified. https://paulgraham.com/relres.html -**After presenting resources — log and offer to open:** +**After presenting resources — log to builder profile and offer to open:** -1. Log the selected resource URLs so future sessions avoid repeats: +1. Log the selected resource URLs to the builder profile (single source of truth). +Append a resource-tracking entry: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true -SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl" -mkdir -p "$(dirname "$SHOWN_LOG")" -``` -For each resource you selected, append a line: -```bash -echo '{"url":"RESOURCE_URL","title":"RESOURCE_TITLE","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' >> "$SHOWN_LOG" +echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" ``` 2. Log the selection to analytics: diff --git a/package.json b/package.json index 88282c03..b5a92fa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.15.15.0", + "version": "0.16.2.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/test/builder-profile.test.ts b/test/builder-profile.test.ts new file mode 100644 index 00000000..ba00b830 --- /dev/null +++ b/test/builder-profile.test.ts @@ -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 { + 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 = {}; + 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'); + }); + }); +});