mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(plan-tune): per-dimension contributing_events in the inferred profile Port of the provenance part of #2625 by @v639dragoon: --derive records, per dimension, how many unique log rows moved it, so "no evidence" and "balanced evidence" no longer both print a bare 0.5. Also ports the test harness fix: logQuestion sets GSTACK_QUESTION_LOG_NO_DERIVE=1 so the background derive cannot race the test's own --derive. The registry-independent autonomy signal from #2625 stays parked, as the Oct 8 review decided. Co-authored-by: v639dragoon <matthew@dohma.ai> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-tune): clamp each session's pull on a dimension to one strong answer; trace shares derive's accumulation (#2623) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-tune): shared accumulation JS as a quoted string, not a heredoc (heredoc pipe-deadlock guard) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Own-property signal lookups so a logged choice such as constructor cannot break every later --derive; --trace replaces control characters in the fields it prints; tests for the negative clamp, empty session_id, an unmatched answer and both fixes; docs and CHANGELOG say the bound keys on the writer session_id. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) A session_id that is an unsubstituted placeholder such as SESSION_ID counts as its own session instead of collapsing every agent-logged answer into one bound; a log line that parses to null is skipped; the accumulation tolerates a dimension it did not seed; the trace test covers DEL and C1 controls; docs and CHANGELOG wording match the code. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-tune): bound each UTC day instead of each session_id The per-session bound trusted whatever session_id a writer logged. The PostToolUse hook and the agent log different ids for one review, and an unsubstituted SESSION_ID placeholder either merged every review into one bound or left each answer unbounded. Bucketing by the UTC day of ts stops one burst from pinning a dimension without trusting any id; several reviews on one day count as one. Decision A from GStack Minder. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (day bound) Rows without a parseable string ts share one undated bucket instead of each escaping the bound; a ts without a zone is read as UTC, so the day does not depend on the machine's zone; days_span counts the same UTC days; question_id and user_choice must be strings. Tests pin a zone west of UTC. CHANGELOG says contributing_events is recorded in the profile JSON and not displayed yet (#3436). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (strict ISO ts) Only an ISO 8601 date or date-time ts counts as dated; Date alone parses strings such as "junk 1" as real days, which let junk rows escape the bound and inflate days_span. A zoneless time, T- or space-separated, is read as UTC. Tests cover array-valued fields, an offset ts in days_span, the space separator and every undated row reaching the shared bucket. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: narrow the #2623 TODOS entry to its remaining Gate 1 work (#3442) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: v639dragoon <matthew@dohma.ai> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
697 lines
29 KiB
Bash
Executable File
697 lines
29 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.
|
|
#
|
|
# 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
|