feat: relationship closing — office-hours adapts to repeat users (v0.16.2.0) (#937)

* 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) <noreply@anthropic.com>

* 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>

* 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) <noreply@anthropic.com>

* chore: bump version and changelog (v0.16.2.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-08 22:21:28 -10:00
committed by GitHub
parent a7593d70ef
commit dbd7aee5b6
8 changed files with 853 additions and 98 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # 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 ## [0.16.1.0] - 2026-04-08
### Fixed ### Fixed
+1 -1
View File
@@ -1 +1 @@
0.16.1.0 0.16.2.0
+134
View File
@@ -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:"
}
+54
View File
@@ -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."
+159 -48
View File
@@ -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. 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 ## 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:** Read the full profile output. You will use these values throughout the closing.
- GOOD: "You didn't say 'small businesses' — you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare."
### 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." - BAD: "You showed great specificity in identifying your target user."
- GOOD: "You pushed back when I challenged premise #2. Most people just agree." - GOOD: "You pushed back when I challenged premise #2. Most people just agree."
- BAD: "You demonstrated conviction and independent thinking." - 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. 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+ signals AND named a specific user, revenue, or demand evidence):
- **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:
> 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. > 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. > GStack thinks you are among the top people who could do this.
Then use AskUserQuestion: "Would you consider applying to Y Combinator?" 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." - **Middle tier** (1-2 signals, or builder whose project solves a real problem):
- 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** — emotional target: *"I might be onto something."* Validation + curiosity. > 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.
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.
> >
> 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** > **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** > **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 ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md"
SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl"
[ -f "$SHOWN_LOG" ] && cat "$SHOWN_LOG" || echo "NO_PRIOR_RESOURCES"
``` ```
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:** **Selection rules:**
- Pick 2-3 resources. Mix categories — never 3 of the same type. - 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 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 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 ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true 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"
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"
``` ```
2. Log the selection to analytics: 2. Log the selection to analytics:
+159 -48
View File
@@ -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. 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 ## 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:** Read the full profile output. You will use these values throughout the closing.
- GOOD: "You didn't say 'small businesses' — you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare."
### 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." - BAD: "You showed great specificity in identifying your target user."
- GOOD: "You pushed back when I challenged premise #2. Most people just agree." - GOOD: "You pushed back when I challenged premise #2. Most people just agree."
- BAD: "You demonstrated conviction and independent thinking." - 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. 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+ signals AND named a specific user, revenue, or demand evidence):
- **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:
> 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. > 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. > GStack thinks you are among the top people who could do this.
Then use AskUserQuestion: "Would you consider applying to Y Combinator?" 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." - **Middle tier** (1-2 signals, or builder whose project solves a real problem):
- 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** — emotional target: *"I might be onto something."* Validation + curiosity. > 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.
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.
> >
> 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** > **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** > **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 ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md"
SHOWN_LOG="${GSTACK_HOME:-$HOME/.gstack}/projects/${SLUG:-unknown}/resources-shown.jsonl"
[ -f "$SHOWN_LOG" ] && cat "$SHOWN_LOG" || echo "NO_PRIOR_RESOURCES"
``` ```
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:** **Selection rules:**
- Pick 2-3 resources. Mix categories — never 3 of the same type. - 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 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 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 ```bash
eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true 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"
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"
``` ```
2. Log the selection to analytics: 2. Log the selection to analytics:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "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.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+332
View File
@@ -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');
});
});
});