feat: bin/gstack-developer-profile — unified profile with migration

bin/gstack-developer-profile supersedes bin/gstack-builder-profile. The old
binary becomes a one-line legacy shim delegating to --read for /office-hours
backward compat.

Subcommands:
  --read              legacy KEY:VALUE output (tier, session_count, etc)
  --migrate           folds ~/.gstack/builder-profile.jsonl into
                      ~/.gstack/developer-profile.json. Atomic (temp + rename),
                      idempotent (no-op when target exists or source absent),
                      archives source as .migrated-YYYY-MM-DD-HHMMSS
  --derive            recomputes inferred dimensions from question-log.jsonl
                      using the signal map in scripts/psychographic-signals.ts
  --profile           full profile JSON
  --gap               declared vs inferred diff JSON
  --trace <dim>       event-level trace of what contributed to a dimension
  --check-mismatch    flags dimensions where declared and inferred disagree by
                      > 0.3 (requires >= 10 events first)
  --vibe              archetype name + description from scripts/archetypes.ts
  --narrative         (v2 stub)

Auto-migration on first read: if legacy file exists and new file doesn't,
migrate before reading. Creates a neutral (all-0.5) stub if nothing exists.

Unified schema (see docs/designs/PLAN_TUNING_V0.md §Architecture):
  {identity, declared, inferred: {values, sample_size, diversity},
   gap, overrides, sessions, signals_accumulated, schema_version}

25 new tests across subcommand behaviors:
- --read defaults + stub creation
- --migrate: 3 sessions preserved with signal tallies, idempotency, archival
- Tier calculation: welcome_back / regular / inner_circle boundaries
- --derive: neutral-when-empty, upward nudge on 'expand', downward on 'reduce',
  recomputable (same input → same output), ad-hoc unregistered ids ignored
- --trace: contributing events, empty for untouched dims, error without arg
- --gap: empty when no declared, correctly computed otherwise
- --vibe: returns archetype name + description
- --check-mismatch: threshold behavior, 10+ sample requirement
- Unknown subcommand errors

25 pass, 0 fail, 60 expect() calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-17 06:20:25 +08:00
parent a949de76b6
commit 2b54677398
3 changed files with 896 additions and 130 deletions
+9 -130
View File
@@ -1,134 +1,13 @@
#!/usr/bin/env bash
# gstack-builder-profile — read builder profile and output structured summary
# gstack-builder-profile — LEGACY SHIM.
#
# 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.
# Superseded by bin/gstack-developer-profile. This binary now delegates to
# `gstack-developer-profile --read` to keep /office-hours working during the
# transition. When all call sites have been updated, this file can be removed.
#
# 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).
# The migration from ~/.gstack/builder-profile.jsonl to the unified
# ~/.gstack/developer-profile.json happens automatically on first read —
# see bin/gstack-developer-profile --migrate for details.
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:"
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$SCRIPT_DIR/gstack-developer-profile" --read "$@"