Files
gstack/bin/gstack-developer-profile
Garry Tanandcapy-ai[bot] 322906ab2b v1.92.79.0 fix: /plan-tune stops reporting mismatches with no observed answers behind them (#3436) (#3856)
* fix(plan-tune): compare declared and inferred only with 3+ observed answers (#3436)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: derive keeps the gap at 3 events; changelog fragment for #3436

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(plan-tune): profile view and design doc honor the 3-answer floor (#3436)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(plan-tune): keep the 10-answer floor in --check-mismatch beside the per-dimension floor

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-02 13:17:17 +00:00

818 lines
35 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, 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 (<profile>.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
# (<profile>.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" <<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
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 (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