Files
gstack/bin/gstack-developer-profile
T
garrytan cd7485b0b4 W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.
2026-09-30 06:38:50 +00:00

535 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# gstack-developer-profile — unified developer profile access and derivation.
#
# Supersedes bin/gstack-builder-profile. The old binary remains as a legacy
# shim that delegates to `gstack-developer-profile --read`.
#
# Subcommands:
# --read (default) emit KEY: VALUE pairs in builder-profile format
# for /office-hours compatibility.
# --derive recompute inferred dimensions from question events;
# write updated ~/.gstack/developer-profile.json.
# --profile emit the full profile as JSON (all fields).
# --gap emit declared-vs-inferred gap as JSON.
# --trace <dim> show events that contributed to a dimension.
# --narrative (v2 stub) output a coach bio paragraph.
# --vibe (v2 stub) output the one-word archetype.
# --check-mismatch detect meaningful gaps between declared and observed.
# --migrate migrate builder-profile.jsonl → developer-profile.json.
# Idempotent; archives the source file on success.
# --log-session append a session entry (from /office-hours) to
# sessions[] and update aggregates. Required fields:
# date, mode. Silent skip on invalid input.
#
# Profile file: ~/.gstack/developer-profile.json (unified schema — see
# docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/
# question-events.jsonl.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
. "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; }
gstack_state_root_select
GSTACK_HOME="$_gstack_sr_root"
# Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which
# Bun on Windows cannot open as a filesystem path (bites --derive). Normalize
# once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below —
# doing it after would leave those two on the stale MSYS form.
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && GSTACK_HOME="$(cygpath -m "$GSTACK_HOME")" ;;
esac
PROFILE_FILE="$GSTACK_HOME/developer-profile.json"
LEGACY_FILE="$GSTACK_HOME/builder-profile.jsonl"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)"
SLUG="${SLUG:-unknown}"
CMD="${1:---read}"
shift || true
# -----------------------------------------------------------------------
# Migration: builder-profile.jsonl → developer-profile.json
# -----------------------------------------------------------------------
do_migrate() {
if [ ! -f "$LEGACY_FILE" ]; then
echo "MIGRATE: no legacy file to migrate"
return 0
fi
if [ -f "$PROFILE_FILE" ]; then
# Already migrated — no-op (idempotent).
echo "MIGRATE: already migrated (developer-profile.json exists)"
return 0
fi
# Run migration in a temp file, then atomic rename.
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
cat "$LEGACY_FILE" | bun -e "
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
const sessions = [];
const signalsAcc = {};
const resources = new Set();
const topics = new Set();
for (const line of lines) {
try {
const e = JSON.parse(line);
sessions.push(e);
for (const s of (e.signals || [])) {
signalsAcc[s] = (signalsAcc[s] || 0) + 1;
}
for (const r of (e.resources_shown || [])) resources.add(r);
for (const t of (e.topics || [])) topics.add(t);
} catch {}
}
const profile = {
identity: {},
declared: {},
inferred: {
values: {
scope_appetite: 0.5,
risk_tolerance: 0.5,
detail_preference: 0.5,
autonomy: 0.5,
architecture_care: 0.5,
},
sample_size: 0,
diversity: { skills_covered: 0, question_ids_covered: 0, days_span: 0 },
},
gap: {},
overrides: {},
sessions,
signals_accumulated: signalsAcc,
resources_shown: Array.from(resources),
topics: Array.from(topics),
migrated_at: new Date().toISOString(),
schema_version: 1,
};
console.log(JSON.stringify(profile, null, 2));
" > "$TMPOUT"
# Atomic rename.
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
# gbrain-sync: enqueue the migrated file for cross-machine sync (no-op if off).
SCRIPT_DIR_E="$(cd "$(dirname "$0")" && pwd)"
"$SCRIPT_DIR_E/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
# Archive the legacy file.
local TS
TS="$(date +%Y-%m-%d-%H%M%S)"
mv "$LEGACY_FILE" "$LEGACY_FILE.migrated-$TS"
local COUNT
COUNT=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun -e "console.log(JSON.parse(require('fs').readFileSync(process.env.PROFILE_FILE_PATH,'utf-8')).sessions.length)" 2>/dev/null || echo "?")
echo "MIGRATE: ok — migrated $COUNT sessions from builder-profile.jsonl"
}
# -----------------------------------------------------------------------
# Load-or-migrate helper: ensure developer-profile.json exists.
# Auto-migrates from builder-profile.jsonl if present.
# Returns path to profile file via stdout. Creates a minimal stub if nothing exists.
# -----------------------------------------------------------------------
ensure_profile() {
if [ -f "$PROFILE_FILE" ]; then
return 0
fi
if [ -f "$LEGACY_FILE" ]; then
do_migrate >/dev/null
return 0
fi
# Nothing yet — create a stub.
mkdir -p "$GSTACK_HOME"
cat > "$PROFILE_FILE" <<EOF
{
"identity": {},
"declared": {},
"inferred": {
"values": {
"scope_appetite": 0.5,
"risk_tolerance": 0.5,
"detail_preference": 0.5,
"autonomy": 0.5,
"architecture_care": 0.5
},
"sample_size": 0,
"diversity": { "skills_covered": 0, "question_ids_covered": 0, "days_span": 0 }
},
"gap": {},
"overrides": {},
"sessions": [],
"signals_accumulated": {},
"schema_version": 1
}
EOF
}
# -----------------------------------------------------------------------
# Record session: append a session entry from /office-hours to sessions[]
# and update aggregates (signals_accumulated, resources_shown, topics).
# Fix for #1671: the writer side of the v1.0.0.0 migration. Reader and
# writer now share the same file.
# Silent skip on invalid input (matches gstack-timeline-log:22-26 pattern).
# -----------------------------------------------------------------------
do_log_session() {
local INPUT="${1:-}"
if [ -z "$INPUT" ]; then
return 0
fi
# Validate: input must be parseable JSON with required fields (date, mode).
if ! printf '%s' "$INPUT" | bun -e "
const j = JSON.parse(await Bun.stdin.text());
if (!j.date || !j.mode) process.exit(1);
" 2>/dev/null; then
return 0
fi
ensure_profile
local TMPOUT
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX")
trap 'rm -f "$TMPOUT"' EXIT
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun -e "
const fs = require('fs');
const entry = JSON.parse(process.env.RECORD_INPUT);
if (!entry.ts) entry.ts = new Date().toISOString();
const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'));
profile.sessions = profile.sessions || [];
profile.sessions.push(entry);
profile.signals_accumulated = profile.signals_accumulated || {};
for (const s of (entry.signals || [])) {
profile.signals_accumulated[s] = (profile.signals_accumulated[s] || 0) + 1;
}
profile.resources_shown = profile.resources_shown || [];
const resSet = new Set(profile.resources_shown);
for (const r of (entry.resources_shown || [])) resSet.add(r);
profile.resources_shown = Array.from(resSet);
profile.topics = profile.topics || [];
const topicSet = new Set(profile.topics);
for (const t of (entry.topics || [])) topicSet.add(t);
profile.topics = Array.from(topicSet);
fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2));
"
mv "$TMPOUT" "$PROFILE_FILE"
trap - EXIT
"$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null &
}
# -----------------------------------------------------------------------
# Read: emit legacy KEY: VALUE output for /office-hours compat.
# -----------------------------------------------------------------------
do_read() {
ensure_profile
cat "$PROFILE_FILE" | bun -e "
const p = JSON.parse(await Bun.stdin.text());
const sessions = p.sessions || [];
// SESSION_COUNT / TIER / CROSS_PROJECT / NUDGE must reflect real sessions, not
// resource-tracking events (the Phase 6 auto-append). Without this filter, a
// session's resources entry written immediately after the real session inflates
// the count (bumping TIER), clobbers LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE,
// and pushes NUDGE_ELIGIBLE over its threshold from bookkeeping alone.
const realSessions = sessions.filter(e => e.mode !== 'resources');
const count = realSessions.length;
let tier = 'introduction';
if (count >= 8) tier = 'inner_circle';
else if (count >= 4) tier = 'regular';
else if (count >= 1) tier = 'welcome_back';
const last = realSessions[realSessions.length - 1] || {};
const prev = realSessions[realSessions.length - 2] || {};
const crossProject = prev.project_slug && last.project_slug
? prev.project_slug !== last.project_slug
: false;
const designs = realSessions.map(e => e.design_doc || '').filter(Boolean);
const designTitles = realSessions
.map(e => (e.design_doc ? (e.project_slug || 'unknown') : ''))
.filter(Boolean);
const signalCounts = p.signals_accumulated || {};
let totalSignals = 0;
for (const v of Object.values(signalCounts)) totalSignals += v;
const signalStr = Object.entries(signalCounts).map(([k,v]) => k + ':' + v).join(',');
// Builder-mode design sessions only — the builder->founder nudge is about
// someone who keeps returning to *build*, not startup-mode diagnostics and not
// resources bookkeeping. (Was \`e.mode !== 'startup'\`, which counted resources
// entries and excluded real startup sessions.)
const builderSessions = realSessions.filter(e => e.mode === 'builder').length;
const nudgeEligible = builderSessions >= 3 && totalSignals >= 5;
const resources = p.resources_shown || [];
const topics = p.topics || [];
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: ' + resources.join(','));
console.log('RESOURCES_SHOWN_COUNT: ' + resources.length);
console.log('TOPICS: ' + topics.join(','));
"
}
# -----------------------------------------------------------------------
# Profile: emit the full JSON
# -----------------------------------------------------------------------
do_profile() {
ensure_profile
cat "$PROFILE_FILE"
}
# -----------------------------------------------------------------------
# Gap: declared vs inferred diff
# -----------------------------------------------------------------------
do_gap() {
ensure_profile
cat "$PROFILE_FILE" | bun -e "
const p = JSON.parse(await Bun.stdin.text());
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care'];
const gap = {};
for (const d of dims) {
if (declared[d] !== undefined && inferred[d] !== undefined) {
gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3);
}
}
console.log(JSON.stringify({ declared, inferred, gap }, null, 2));
"
}
# -----------------------------------------------------------------------
# Derive: recompute inferred dimensions from question-events.jsonl
# -----------------------------------------------------------------------
do_derive() {
ensure_profile
local EVENTS="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
local REGISTRY="$ROOT_DIR/scripts/question-registry.ts"
local SIGNALS="$ROOT_DIR/scripts/psychographic-signals.ts"
if [ ! -f "$REGISTRY" ] || [ ! -f "$SIGNALS" ]; then
echo "DERIVE: registry or signals file missing, cannot derive" >&2
exit 1
fi
cd "$ROOT_DIR"
PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun -e "
import('./scripts/question-registry.ts').then(async (regmod) => {
const sigmod = await import('./scripts/psychographic-signals.ts');
const fs = require('fs');
const { QUESTIONS } = regmod;
const { SIGNAL_MAP, applySignal, newDimensionTotals, normalizeToDimensionValue } = sigmod;
const profilePath = process.env.PROFILE_FILE_PATH;
const eventsPath = process.env.EVENTS_PATH;
const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
let lines = [];
if (fs.existsSync(eventsPath)) {
lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n').filter(Boolean);
}
const totals = newDimensionTotals();
const skills = new Set();
const qids = new Set();
const days = new Set();
let count = 0;
for (const line of lines) {
let e;
try { e = JSON.parse(line); } catch { continue; }
if (!e.question_id || !e.user_choice) continue;
count++;
skills.add(e.skill);
qids.add(e.question_id);
if (e.ts) days.add(String(e.ts).slice(0,10));
const def = QUESTIONS[e.question_id];
if (def && def.signal_key) {
applySignal(totals, def.signal_key, e.user_choice);
}
}
const values = {};
for (const [dim, total] of Object.entries(totals)) {
values[dim] = +normalizeToDimensionValue(total).toFixed(3);
}
profile.inferred = {
values,
sample_size: count,
diversity: {
skills_covered: skills.size,
question_ids_covered: qids.size,
days_span: days.size,
},
};
// Recompute gap.
const gap = {};
for (const d of Object.keys(values)) {
if (profile.declared && profile.declared[d] !== undefined) {
gap[d] = +(Math.abs(profile.declared[d] - values[d])).toFixed(3);
}
}
profile.gap = gap;
profile.derived_at = new Date().toISOString();
const tmp = profilePath + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(profile, null, 2));
fs.renameSync(tmp, profilePath);
console.log('DERIVE: ok — ' + count + ' events, ' + skills.size + ' skills, ' + qids.size + ' questions');
}).catch(err => { console.error('DERIVE:', err.message); process.exit(1); });
"
}
# -----------------------------------------------------------------------
# Trace: show events contributing to a dimension
# -----------------------------------------------------------------------
do_trace() {
local DIM="${1:-}"
if [ -z "$DIM" ]; then
echo "TRACE: missing dimension argument" >&2
exit 1
fi
local EVENTS="$GSTACK_HOME/projects/$SLUG/question-log.jsonl"
if [ ! -f "$EVENTS" ]; then
echo "TRACE: no events for this project"
return 0
fi
cd "$ROOT_DIR"
EVENTS_PATH="$EVENTS" TRACE_DIM="$DIM" bun -e "
import('./scripts/question-registry.ts').then(async (regmod) => {
const sigmod = await import('./scripts/psychographic-signals.ts');
const fs = require('fs');
const { QUESTIONS } = regmod;
const { SIGNAL_MAP } = sigmod;
const target = process.env.TRACE_DIM;
const lines = fs.readFileSync(process.env.EVENTS_PATH, 'utf-8').trim().split('\n').filter(Boolean);
const rows = [];
for (const line of lines) {
let e;
try { e = JSON.parse(line); } catch { continue; }
const def = QUESTIONS[e.question_id];
if (!def || !def.signal_key) continue;
const deltas = SIGNAL_MAP[def.signal_key]?.[e.user_choice] || [];
for (const d of deltas) {
if (d.dim === target) {
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, delta: d.delta });
}
}
}
if (rows.length === 0) {
console.log('TRACE: no events contribute to ' + target);
} else {
console.log('TRACE: ' + rows.length + ' events for ' + target);
for (const r of rows) {
console.log(' ' + (r.ts || '').slice(0,19) + ' ' + r.question_id + ' → ' + r.choice + ' (' + (r.delta > 0 ? '+' : '') + r.delta + ')');
}
}
});
"
}
# -----------------------------------------------------------------------
# Check mismatch: flag when declared ≠ inferred by > threshold
# -----------------------------------------------------------------------
do_check_mismatch() {
ensure_profile
cat "$PROFILE_FILE" | bun -e "
const p = JSON.parse(await Bun.stdin.text());
const declared = p.declared || {};
const inferred = (p.inferred && p.inferred.values) || {};
const sampleSize = (p.inferred && p.inferred.sample_size) || 0;
const diversity = (p.inferred && p.inferred.diversity) || {};
// Require enough data before reporting mismatch.
if (sampleSize < 10) {
console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)');
process.exit(0);
}
const THRESHOLD = 0.3;
const flagged = [];
for (const d of Object.keys(declared)) {
if (inferred[d] === undefined) continue;
const gap = Math.abs(declared[d] - inferred[d]);
if (gap > THRESHOLD) {
flagged.push({ dim: d, declared: declared[d], inferred: inferred[d], gap: +gap.toFixed(3) });
}
}
if (flagged.length === 0) {
console.log('MISMATCH: none');
} else {
console.log('MISMATCH: ' + flagged.length + ' dimension(s) disagree (gap > ' + THRESHOLD + ')');
for (const f of flagged) {
console.log(' ' + f.dim + ': declared ' + f.declared + ' vs inferred ' + f.inferred + ' (gap ' + f.gap + ')');
}
}
"
}
# -----------------------------------------------------------------------
# Narrative + Vibe (v2 stubs)
# -----------------------------------------------------------------------
do_narrative() {
echo "NARRATIVE: (v2 — not yet implemented; use /plan-tune profile for now)"
}
do_vibe() {
ensure_profile
cd "$ROOT_DIR"
cat "$PROFILE_FILE" | PROFILE_DATA="$(cat "$PROFILE_FILE")" bun -e "
import('./scripts/archetypes.ts').then(async (mod) => {
const p = JSON.parse(process.env.PROFILE_DATA);
const dims = (p.inferred && p.inferred.values) || {
scope_appetite: 0.5, risk_tolerance: 0.5, detail_preference: 0.5,
autonomy: 0.5, architecture_care: 0.5,
};
const arch = mod.matchArchetype(dims);
console.log(arch.name);
console.log(arch.description);
});
"
}
# -----------------------------------------------------------------------
# Dispatch
# -----------------------------------------------------------------------
case "$CMD" in
--read) do_read ;;
--profile) do_profile ;;
--gap) do_gap ;;
--derive) do_derive ;;
--trace) do_trace "$@" ;;
--narrative) do_narrative ;;
--vibe) do_vibe ;;
--check-mismatch) do_check_mismatch ;;
--migrate) do_migrate ;;
--log-session) do_log_session "$@" ;;
--help|-h) sed -n '1,/^set -euo/p' "$0" | sed 's|^# \?||' ;;
*)
echo "gstack-developer-profile: unknown subcommand '$CMD'" >&2
echo "run --help for usage" >&2
exit 1
;;
esac