#!/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 show events that contributed to a dimension, each # UTC day clamped by the per-day bound, and the total. # --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. # --reconcile backfill count-only sessions[] entries from office-hours # `completed` events in projects/*/timeline.jsonl. # Idempotent; backfilled rows count for tenure only. # # Writers (--derive, --log-session, --reconcile, --migrate) hold # developer-profile.json.lock for their read-modify-write and exit 5 when # another writer keeps it past the give-up window (#3437). # # 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 GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac 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" # A relative state root is anchored to the starting directory once, here: # do_derive changes directory, and the profile lock must name the same path # when it is taken and when it is released. case "$GSTACK_HOME" in /*|[A-Za-z]:[\\/]*) ;; *) GSTACK_HOME="$PWD/$GSTACK_HOME" ;; esac # 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" MIN_EVIDENCE=3 eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" SLUG="${SLUG:-unknown}" CMD="${1:---read}" shift || true # ----------------------------------------------------------------------- # Profile lock (#3437). Every subcommand that rewrites the profile reads, # changes and renames the whole file, and gstack-question-log starts a # background --derive after every append, so writers overlap: a shared temp # name made renames fail and an unlocked rewrite dropped a concurrent # --log-session. Writers hold a mkdir lock (.lock/, whose owner file # names the holder's pid and a random token; release is ownership-checked) # from before their read until after their rename, and write a unique temp # file. A lock whose holder process is gone is taken over at once; one older # than 5 minutes is taken over even when its pid is alive, since no writer # runs that long and the pid may have been reused. Takeovers are serialized by a second mkdir lock # (.lock.takeover/) and re-read the owner under it, so two waiters # cannot both take over or remove a lock a third writer just took. A lock or # takeover directory older than 30 s with no owner belongs to a writer that # died before writing one; cleanup removes a takeover lock this writer holds. A writer that cannot get the lock within the # give-up window writes nothing and exits 5. Writers outside this binary # (gstack-distill-apply, /plan-tune's declared edits) do not take it yet. # ----------------------------------------------------------------------- LOCK_DIR="$PROFILE_FILE.lock" TAKEOVER_DIR="$LOCK_DIR.takeover" LOCK_TOKEN="" HOLDS_TAKEOVER="" PROFILE_TMP="" cleanup() { if [ -n "$PROFILE_TMP" ]; then rm -f "$PROFILE_TMP"; fi if [ -n "$HOLDS_TAKEOVER" ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi if [ -n "$LOCK_TOKEN" ] && [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$LOCK_TOKEN" ]; then rm -rf "$LOCK_DIR" fi } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM dir_age_s() { local mtime # GNU stat first: BSD-style stat -f on Linux prints a filesystem block. mtime=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "") case "$mtime" in ""|*[!0-9]*) echo 0 ;; *) echo $(( $(date +%s) - mtime )) ;; esac } lock_abandoned() { if [ -n "$1" ]; then ! kill -0 "${1%%-*}" 2>/dev/null || [ "$(dir_age_s "$LOCK_DIR")" -gt 300 ] else [ "$(dir_age_s "$LOCK_DIR")" -gt 30 ] fi } acquire_lock() { if [ -n "$LOCK_TOKEN" ]; then return 0; fi mkdir -p "$GSTACK_HOME" # GSTACK_PROFILE_LOCK_TIMEOUT_MS: test-only override for the give-up. local give_up_ms="${GSTACK_PROFILE_LOCK_TIMEOUT_MS:-10000}" waited_ms=0 started=$SECONDS vanished=0 owner local token="$$-$RANDOM$RANDOM" until mkdir "$LOCK_DIR" 2>/dev/null; do # mkdir failed with no lock to wait for: retry once in case a holder just # released it, then report the directory instead of waiting out the window. if [ ! -e "$LOCK_DIR" ]; then if [ "$vanished" -eq 0 ]; then vanished=1; continue; fi echo "gstack-developer-profile: cannot create $LOCK_DIR (is $GSTACK_HOME writable?), so nothing was written (exit 5)." >&2 exit 5 fi vanished=0 owner=$(cat "$LOCK_DIR/owner" 2>/dev/null || true) if [ -d "$LOCK_DIR" ] && lock_abandoned "$owner"; then if [ -d "$TAKEOVER_DIR" ] && [ "$(dir_age_s "$TAKEOVER_DIR")" -gt 30 ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi if mkdir "$TAKEOVER_DIR" 2>/dev/null; then HOLDS_TAKEOVER=1 if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$owner" ] && lock_abandoned "$owner"; then rm -rf "$LOCK_DIR" 2>/dev/null || true fi rmdir "$TAKEOVER_DIR" 2>/dev/null || true HOLDS_TAKEOVER="" if [ ! -e "$LOCK_DIR" ]; then continue; fi fi fi if [ "$waited_ms" -ge "$give_up_ms" ] || [ $(( SECONDS - started )) -gt $(( give_up_ms / 1000 )) ]; then echo "gstack-developer-profile: another writer held $LOCK_DIR for ${give_up_ms} ms, so nothing was written (exit 5)." >&2 exit 5 fi sleep 0.05 waited_ms=$(( waited_ms + 50 )) done printf '%s\n' "$token" > "$LOCK_DIR/owner" LOCK_TOKEN="$token" } # ----------------------------------------------------------------------- # Migration: builder-profile.jsonl → developer-profile.json # ----------------------------------------------------------------------- do_migrate() { acquire_lock 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. PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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)); " > "$PROFILE_TMP" # Atomic rename. mv "$PROFILE_TMP" "$PROFILE_FILE" PROFILE_TMP="" # 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 --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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" <&2 return "$VALIDATE_RC" fi case "$VALIDATE_OUT" in valid) ;; invalid) return 0 ;; *) echo "gstack-developer-profile: bun printed no verdict while validating the session; nothing was logged" >&2; return 1 ;; esac acquire_lock ensure_profile PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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 "$PROFILE_TMP" "$PROFILE_FILE" PROFILE_TMP="" "$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & } # ----------------------------------------------------------------------- # Reconcile: sessions[] is written only at office-hours Phase 4.5, so runs # that ended earlier never count toward tenure. Backfill one count-only row # per office-hours `completed` timeline event that has no sessions[] entry # for the same project within 60 minutes (extended back by the run's # duration_s when it is under 8h, since Phase 4.5 logs mid-run). The file is # rewritten only when something was added. # ----------------------------------------------------------------------- do_reconcile() { acquire_lock ensure_profile PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const path = require('path'); const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8')); profile.sessions = profile.sessions || []; const runs = []; let slugs = []; try { slugs = fs.readdirSync(process.env.PROJECTS_DIR); } catch {} for (const slug of slugs) { let lines = []; try { lines = fs.readFileSync(path.join(process.env.PROJECTS_DIR, slug, 'timeline.jsonl'), 'utf-8').split('\n'); } catch { continue; } for (const line of lines) { let e; try { e = JSON.parse(line); } catch { continue; } if (!e || e.skill !== 'office-hours' || e.event !== 'completed') continue; const t = Date.parse(e.ts); if (Number.isNaN(t)) continue; const dur = Number(e.duration_s); runs.push({ t, ts: e.ts, slug, mode: e.mode, span: dur > 0 && dur < 8 * 3600 ? dur * 1000 : 0 }); } } runs.sort((a, b) => a.t - b.t); const HOUR = 60 * 60 * 1000; const covered = profile.sessions .filter(s => s && s.mode !== 'resources') .map(s => ({ t: Date.parse(s.ts || s.date || ''), slug: s.project_slug || null })) .filter(c => !Number.isNaN(c.t)); let added = 0; for (const run of runs) { const matched = covered.some(c => (c.slug === null || c.slug === run.slug) && c.t >= run.t - run.span - HOUR && c.t <= run.t + HOUR); if (matched) continue; covered.push({ t: run.t, slug: run.slug }); profile.sessions.push({ date: run.ts, mode: run.mode && run.mode !== 'resources' ? run.mode : 'unknown', project_slug: run.slug, signal_count: 0, signals: [], backfilled: true, }); added++; } if (added > 0) { const at = s => { const t = Date.parse(s.ts || s.date || ''); return Number.isNaN(t) ? Infinity : t; }; profile.sessions.sort((a, b) => at(a) - at(b)); fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2)); } console.log('RECONCILE: ' + added + ' backfilled from ' + runs.length + ' office-hours run(s)'); " if [ -s "$PROFILE_TMP" ]; then mv "$PROFILE_TMP" "$PROFILE_FILE" PROFILE_TMP="" "$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & else rm -f "$PROFILE_TMP" PROFILE_TMP="" fi } # ----------------------------------------------------------------------- # Read: emit legacy KEY: VALUE output for /office-hours compat. # ----------------------------------------------------------------------- do_read() { ensure_profile cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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'); // Backfilled rows (--reconcile) carry no content: they count for tenure only. const loggedSessions = realSessions.filter(e => !e.backfilled); 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 = loggedSessions[loggedSessions.length - 1] || {}; const prev = loggedSessions[loggedSessions.length - 2] || {}; const crossProject = prev.project_slug && last.project_slug ? prev.project_slug !== last.project_slug : false; const designs = loggedSessions.map(e => e.design_doc || '').filter(Boolean); const designTitles = loggedSessions .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 = loggedSessions.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 local PARSE_ERR if ! PARSE_ERR=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const text = require('fs').readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8'); try { JSON.parse(text); } catch (e) { const markers = /^(<{7}|={7}|>{7})( |\$)/m.test(text) ? ' (it holds git conflict markers from a merge)' : ''; console.log(e.message + markers); process.exit(1); } " 2>&1); then echo "gstack-developer-profile: $PROFILE_FILE is not valid JSON: $PARSE_ERR; repair the file or restore it from git -C $GSTACK_HOME log -- developer-profile.json" >&2 return 1 fi cat "$PROFILE_FILE" } # ----------------------------------------------------------------------- # Gap: declared vs inferred diff # ----------------------------------------------------------------------- do_gap() { ensure_profile cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const p = JSON.parse(await Bun.stdin.text()); const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; const contributing = p.inferred && p.inferred.contributing_events; const dims = ['scope_appetite','risk_tolerance','detail_preference','autonomy','architecture_care']; const gap = {}; const low_evidence = {}; for (const d of dims) { if (declared[d] === undefined || inferred[d] === undefined) continue; if (contributing && (contributing[d] || 0) < $MIN_EVIDENCE) { low_evidence[d] = contributing[d] || 0; continue; } gap[d] = +(Math.abs(declared[d] - inferred[d])).toFixed(3); } console.log(JSON.stringify({ declared, inferred, gap, low_evidence }, null, 2)); " } # ----------------------------------------------------------------------- # Signal accumulation shared by --derive and --trace, so the trace always # explains exactly the value derive wrote. Each row's SIGNAL_MAP deltas are # summed per (UTC day, dimension) and each day's sum is clamped to ±cap, # where cap is the largest single delta in SIGNAL_MAP: one day moves a # dimension at most as far as one strong answer. Without the bound a single # /plan-eng-review (about five answers) moved a dimension by 0.25 and about # eight reviews pinned it at 1.0 (#2623). The bound keys on the row's ts, # not its session_id: writers log different ids for one review (the # PostToolUse hook logs the host conversation, the agent the preamble run) # and an unsubstituted placeholder such as SESSION_ID would merge or split # reviews. Only an ISO 8601 date or date-time counts as dated; a time # without a zone is read as UTC. Every other row shares one undated bucket, # so junk timestamps cannot escape the bound (Date alone parses strings # like "junk 1" as real days). diversity.days_span counts the same days. # The body is a single-quoted bash string: its JS must not contain a single # quote (use double quotes or backticks), or the assignment ends early and # every subcommand fails to parse. # ----------------------------------------------------------------------- ACCUMULATE_SIGNALS_JS=' function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) { let cap = 0; for (const byChoice of Object.values(SIGNAL_MAP)) { for (const deltas of Object.values(byChoice)) { for (const d of deltas) cap = Math.max(cap, Math.abs(d.delta)); } } const contributing = Object.fromEntries(dims.map((d) => [d, 0])); const buckets = new Map(); const rows = []; const skills = new Set(); const qids = new Set(); const days = new Set(); let count = 0; let signalEvents = 0; for (const line of lines) { let e; try { e = JSON.parse(line); } catch { continue; } if (!e || typeof e !== "object" || typeof e.question_id !== "string" || typeof e.user_choice !== "string" || !e.question_id || !e.user_choice) continue; count++; skills.add(e.skill); qids.add(e.question_id); const m = typeof e.ts === "string" ? /^(\d{4}-\d\d-\d\d)(?:[Tt ](\d\d:\d\d(?::\d\d(?:\.\d+)?)?)(Z|z|[+-]\d\d:\d\d)?)?$/.exec(e.ts) : null; const when = m ? new Date(m[2] ? m[1] + "T" + m[2] + (m[3] || "Z").toUpperCase() : m[1]) : null; const day = when && !Number.isNaN(when.getTime()) ? when.toISOString().slice(0, 10) : null; if (day) days.add(day); const def = Object.hasOwn(QUESTIONS, e.question_id) ? QUESTIONS[e.question_id] : undefined; const byChoice = def && def.signal_key && Object.hasOwn(SIGNAL_MAP, def.signal_key) ? SIGNAL_MAP[def.signal_key] : {}; const deltas = Object.hasOwn(byChoice, e.user_choice) ? byChoice[e.user_choice] : []; if (!Array.isArray(deltas) || deltas.length === 0) continue; signalEvents++; const key = day || "undated"; const sums = buckets.get(key) || {}; buckets.set(key, sums); for (const d of deltas) { sums[d.dim] = (sums[d.dim] || 0) + d.delta; rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, dim: d.dim, delta: d.delta }); } for (const dim of new Set(deltas.map((d) => d.dim))) contributing[dim] = (contributing[dim] ?? 0) + 1; } const totals = Object.fromEntries(dims.map((d) => [d, 0])); const capped = []; for (const [day, sums] of buckets) { for (const [dim, sum] of Object.entries(sums)) { const bounded = Math.max(-cap, Math.min(cap, sum)); if (Math.abs(sum - bounded) > 1e-9) capped.push({ day, dim, sum, bounded }); totals[dim] = (totals[dim] ?? 0) + bounded; } } return { cap, totals, contributing, rows, capped, count, signalEvents, skills, qids, days }; } ' # ----------------------------------------------------------------------- # Derive: recompute inferred dimensions from question-events.jsonl # ----------------------------------------------------------------------- do_derive() { acquire_lock 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 PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") cd "$ROOT_DIR" PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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, ALL_DIMENSIONS, normalizeToDimensionValue } = sigmod; $ACCUMULATE_SIGNALS_JS 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, contributing, count, signalEvents, skills, qids, days } = accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS); const values = {}; for (const [dim, total] of Object.entries(totals)) { values[dim] = +normalizeToDimensionValue(total).toFixed(3); } profile.inferred = { values, sample_size: count, // Events that actually moved a dimension (B10): sample_size counts every // logged answer, most of which match no signal and leave the 0.5 seed. signal_events: signalEvents, // Unique rows that moved each dimension (#2625). Distinguishes 'no // evidence' from 'balanced evidence': both print 0.5, which is how // five 0.5s sat beside sample_size 1286 (#2623). contributing_events: contributing, 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 ((contributing[d] || 0) < $MIN_EVIDENCE) continue; 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(); fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2)); console.log('DERIVE: ok — ' + count + ' events, ' + skills.size + ' skills, ' + qids.size + ' questions'); }).catch(err => { console.error('DERIVE:', err.message); process.exit(1); }); " mv "$PROFILE_TMP" "$PROFILE_FILE" PROFILE_TMP="" } # ----------------------------------------------------------------------- # 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 --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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, ALL_DIMENSIONS, normalizeToDimensionValue } = sigmod; $ACCUMULATE_SIGNALS_JS const target = process.env.TRACE_DIM; const lines = fs.readFileSync(process.env.EVENTS_PATH, 'utf-8').trim().split('\n').filter(Boolean); const acc = accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, ALL_DIMENSIONS); const rows = acc.rows.filter((r) => r.dim === target); const signed = (n, digits) => (n > 0 ? '+' : '') + (digits === undefined ? String(n) : n.toFixed(digits)); const clean = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '?'); 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(' ' + clean(r.ts).slice(0,19) + ' ' + clean(r.question_id) + ' → ' + clean(r.choice) + ' (' + signed(r.delta) + ')'); } for (const c of acc.capped.filter((c) => c.dim === target)) { console.log(' day ' + clean(c.day) + ': ' + signed(c.sum, 3) + ' capped to ' + signed(c.bounded, 3)); } const total = acc.totals[target] ?? 0; console.log('TOTAL: ' + signed(total, 3) + ' (each UTC day capped at ±' + acc.cap + ') → ' + normalizeToDimensionValue(total).toFixed(3)); } }); " } # ----------------------------------------------------------------------- # Check mismatch: flag when declared ≠ inferred by > threshold # ----------------------------------------------------------------------- do_check_mismatch() { ensure_profile cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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 contributing = p.inferred && p.inferred.contributing_events; if (!contributing && sampleSize < 10) { console.log('MISMATCH: not enough data (' + sampleSize + ' events; need 10+)'); process.exit(0); } const THRESHOLD = 0.3; const flagged = []; const thin = []; for (const d of Object.keys(declared)) { if (inferred[d] === undefined) continue; const n = contributing ? (contributing[d] || 0) : undefined; if (n !== undefined && n < $MIN_EVIDENCE) { thin.push(' ' + d + ': ' + (n === 0 ? 'no observed evidence' : 'too little evidence (' + n + ' of $MIN_EVIDENCE events)')); 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 + ')'); } } for (const line of thin) console.log(line); " } # ----------------------------------------------------------------------- # 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 --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -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 "$@" ;; --reconcile) do_reconcile ;; --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