#!/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. # # 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" # 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 --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)); " > "$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 --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 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 --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 "$TMPOUT" "$PROFILE_FILE" trap - EXIT "$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() { ensure_profile local TMPOUT TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") trap 'rm -f "$TMPOUT"' EXIT PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" 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 "$TMPOUT" ]; then mv "$TMPOUT" "$PROFILE_FILE" trap - EXIT "$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & else rm -f "$TMPOUT" trap - EXIT 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 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 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)); " } # ----------------------------------------------------------------------- # 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() { 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 --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 (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 --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 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 --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