#!/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, 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" <<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.
# Invalid input is skipped silently; a Bun failure is reported and returned,
# as in gstack-timeline-log (#3415).
# -----------------------------------------------------------------------
do_log_session() {
  local INPUT="${1:-}"
  if [ -z "$INPUT" ]; then
    return 0
  fi

  # Validate: input must be parseable JSON with required fields (date, mode).
  # The validator prints its verdict; a non-zero exit means Bun itself failed.
  local VALIDATE_RC=0 VALIDATE_OUT
  VALIDATE_OUT=$(printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "
    const text = await Bun.stdin.text();
    let j;
    try { j = JSON.parse(text); } catch {}
    console.log(j && j.date && j.mode ? 'valid' : 'invalid');
  ") || VALIDATE_RC=$?
  if [ "$VALIDATE_RC" -ne 0 ]; then
    echo "gstack-developer-profile: bun exited $VALIDATE_RC before the session was validated; nothing was logged" >&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
