mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-11 00:30:21 +02:00
* fix(careful): warn on chained rm even when the last target is safe The safe-exception block whitelisted rm -rf of build artifacts by extracting targets with a single greedy match (.*rm ...), which only ever inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf node_modules' was therefore judged solely by its trailing safe target and allowed without warning, waving through the destructive 'rm -rf /'. Gate the shortcut to single rm invocations: when any shell separator (; | & newline, incl. JSON-escaped \n/\r from the grep extraction path) is present, fall through to the destructive-pattern check, which warns on any recursive rm. Single-command artifact cleanups still allow. Adds 3 regression tests covering semicolon and && chains in both orders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(careful): substitution separators + capital -R recursive flag (#2039) Two residual fail-opens in the same guard PR #2040 hardened, both verified by executing the script pre-fix: - rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token ends in a whitelisted suffix and the safe-exception early exit skipped ALL downstream checks. $( and backtick now count as chain separators; plain $VAR expansion stays allowed. - rm -R / silently allowed: both greps required a lowercase r in the flag cluster; capital -R is the documented BSD/macOS recursive flag. Both greps now match -[a-zA-Z]*[rR]. Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf node_modules single-command -> still allowed, escaped-newline branch (existing code, previously untested), and a pinned deliberate FP (cd app && rm -rf node_modules -> ask) documenting the fail-closed direction on chains. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(context-restore): prefer the current branch's own checkpoint (#2052) All worktrees of a repo share one origin-derived slug, so they share one `~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the newest checkpoint across the whole dir, so in one worktree it could silently restore a *sibling worktree's* newer checkpoint. Step 1 now orders candidates current-branch-first (read from each file's `branch:` frontmatter), keeping other branches as a fallback. A branch is checked out in at most one worktree, so this stops cross-worktree contamination while preserving Conductor cross-branch handoff: when the current branch has no checkpoint of its own, the full newest-first set is still used. - scan the 200 newest before partitioning so a current-branch checkpoint sitting below a burst of sibling saves is still found; output still capped at 20 - non-git / detached HEAD / branchless legacy saves fall back to the old newest-first behavior (back-compat) - +5 regression tests in context-save-hardening.test.ts (the #2052 bug case fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json Fixes #2052 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(gbrain): pass --confirm-destructive on drift re-register (#1985) ensureSourceRegistered() handles match-but-different-path by removing the old source then re-adding it at the new path. The remove was issued as `gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses the data-loss prompt). The remove therefore fails with "To proceed, pass --confirm-destructive", which ensureSourceRegistered surfaces as "source registration failed" — aborting the entire /sync-gbrain code stage for any already-registered source whose path has drifted. The memory and brain-sync stages still pass, so the code index silently stops refreshing. The orchestrator's own safeSourcesRemove() already passes --confirm-destructive; this brings the lib helper in line with that convention. Keeps --yes for older gbrain. Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1), update the drift re-register assertion, and add a regression test that proves the drift path no longer throws. Both fail on main with the exact "To proceed, pass --confirm-destructive" error and pass with the fix. Fixes #1985 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check Absorbing #2031 un-blocked a destructive remove that bypassed the #1734 data-loss guards: ensureSourceRegistered's drift path issued `gbrain sources remove` directly, without the detectAutopilot + decideSourceRemove checks every other remove routes through via safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking that path; with --confirm-destructive passed it is live again. - Drift remove now refuses LOUDLY (throws, actionable message) while an autopilot is active or when decideSourceRemove disallows; a silent changed=false would hide the drifted registration. - decideSourceRemove's extraArgs (--keep-storage when supported) propagate to the remove call, matching safeSourcesRemove. - Drift is realpath-normalized before being declared: a symlink alias of the same directory (macOS /tmp -> /private/tmp) is a match, not drift — the probable cause of #1985's reporter hitting the remove on an unmoved repo. - Drift fires a loud stderr line (old -> new path); perpetual drift in logs is the trigger for promoting #1985's reindex-in-place design. Tests: autopilot-active refusal (no remove in call log), fail-closed refusal on unreadable sources list, --keep-storage propagation, symlink-alias no-drift; existing drift tests pin the guard probes so a live autopilot on the dev machine can't flip them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067) Every /office-hours run appends a mode:"resources" bookkeeping row alongside the real session row, so --read double-counted sessions (~2x): tiers promoted early and the builder-to-founder nudge armed prematurely. The file already filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful allowlist (mode === 'builder') so a future mode #4 fails closed instead of re-opening this bug. 8 regression tests: count vs resources noise, tier boundaries both sides, nudge false-with-noise / true-at-3-builders, cross-project trailing row. Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump commit is superseded by this wave's consolidated release commit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006) Every AskUserQuestion died with "Tool result missing due to internal error" on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause: the question-preference-hook emitted permissionDecision:'defer' on every pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89 its semantics are "pause this tool call for external resumption" (headless resume) — never "abstain". Interactive sessions have nothing to resume the paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown value, which is why the hook worked when it shipped and broke later. The fix is the two-branch pass-through contract: - no context -> exit 0 with EXACTLY empty stdout - memory nuggets present -> hookSpecificOutput with hookEventName + additionalContext ONLY (the documented shape; plan-tune Layer 8 memory injection ships through this branch and keeps working) defer() is renamed passThrough() so the function says what it does, and docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the hook header) is corrected in the same commit — it taught '"defer" — let permission flow continue' and was the reintroduction vector. Test contract rewritten in the same commit (13 assertions across 3 files, verified fail-first against the unfixed hook): pass-through paths assert exact-empty stdout (a garbage/partial write cannot slip past an optional-chained parse), the nugget path asserts permissionDecision is ABSENT while additionalContext survives, and a new tripwire asserts no non-deny path ever puts the string "permissionDecision" on stdout. The deny (auto-decide) and Conductor prose-redirect paths are unchanged. Deployment: no migration needed — settings.json points at the absolute bash shim which execs the .ts live; /gstack-upgrade delivers the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024) Library fix: revoke/reset/rotate now share ONE noun alternation (api key, token, secret, credential, access key, password) with optional plural s?. Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret" (mismatched per-verb lists) and every plural form ("rotate the credentials", "revoke all tokens" — \b(...)\b cannot match a trailing s). Runtime wiring — the regexes could never fire in production before: - gstack-question-preference --check gains --summary-stdin: the question text pipes via stdin (never argv — summaries carry quotes/newlines/shell metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc destructive question with a stored never-ask preference now forces ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics. - question-preference-hook falls back to classifyQuestion(question text) when the registry lookup misses, so unregistered destructive questions pass through to a human instead of auto-deciding. - question-tuning resolver prose shows the piped form (SKILL.md regen lands in the wave's release commit). Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix with the #2024 repro rows, benign-summary no-over-match rows, stdin transport survival (quotes/newlines), empty-stdin fail-safe, and hook fallback both directions (destructive -> pass-through, benign -> deny). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032) design variants --count abc silently generated ZERO variants and exited 0: parseInt(NaN) flowed through Math.min into the generation loop bound. The same NaN class was live on the two sibling flags in the same file: --retry abc made generate() a silent no-op (attempt <= NaN never true, null output, exit 0) and --timeout abc killed the serve board ~immediately (setTimeout(NaN)). New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) + normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent (error loudly on nonsense — these commands spend real image-API money, a silent fixup hides typos from calling agents): undefined -> default; bare flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1 with usage hint; above-max -> clamp with stderr warning. --count normalizes at the variants() consumption site so programmatic callers are covered, with the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the CLI passes the raw flag through (a pre-parseInt would truncate "3.7"). Tripwires live in test/design-flag-utils.test.ts — deliberately under test/, not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and every workflow (wiring design/test/ into CI is a captured TODO). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051) A thin client (remote-HTTP MCP brain, no local engine by design) probed `gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients (exit 1, no recognized error string), so the classifier fell to its defensive broken-config default and every suppression gate silently hid brain-aware blocks from exactly the users on a shared team brain. New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp config marker via the existing gbrainConfigPath() helper (mirrors gbrain's isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote reachability is deliberately NOT probed by the classifier — that is the #1964 pathology; gbrain calls degrade gracefully at use time, and the detect JSON says so honestly (gbrain_thin_client: {probed: false}). The state is admitted at every suppression gate — gstack-gbrain-detect --is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override, gstack-config gbrain-refresh — while the sync stages (code/memory/dream) SKIP with an accurate reason: code indexing runs on the brain server, memory syncs via the remote brain's artifacts pull. The two consumer classes need opposite answers, which is why this is a distinct state and not a skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose route thin-client to proceed, never into broken-config remediation. detectMcpMode secondary generalization: url-match against the config's remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path) -> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a 3-value enum. Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback classifier paths; 4 detectMcpMode identification cases incl. a non-matching url that must NOT false-positive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups - Regenerate all SKILL.md from templates (question-tuning --summary-stdin prose from #2024, context-restore branch preference from PR #2054, sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt. - VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware: #1815 claims 1.59.0.0, #2213 claims 1.59.1.0). - CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and @mvann. - TODOS.md: three eng-review follow-ups (design/test CI wiring + documented pre-existing retry-after flake, /context-save worktree identity, gbrain reindex-in-place conditional on the new drift log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens The v1.57.7.0 parity suite caps investigate's generated size at 1.09x baseline; the #2024 question-tuning prose (duplicated into every tier->=2 skill) tipped it to 1.092. Compressed to a single inline command + short pointer (the full rationale lives in bin/gstack-question-preference's header and the one-way-doors module docs). Ship goldens re-blessed against the final resolver text (conscious template-change acknowledgment, per the golden-file regression contract). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473) The test failed deterministically with error_max_turns at 9 turns on main and this branch alike (CI attempt logs + local main repro). Root cause from the failing transcript: the Spec Review Loop content is carved out of office-hours/SKILL.md into office-hours/sections/, so the agent needs discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget, which predates the carve. Observed failures wrote a CORRECT summary on tool turn 8 and died on the closing turn. maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the extra headroom absorbs discovery-path nondeterminism). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): review-dashboard-via session budget survives runner contention (#2473) The test failed on CI (and its baseline run) with the timeout signature: 0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session never emitted a single stream event before the 180s inner timeout. The file's tests run concurrently on one runner; session startup queues behind sibling sessions, and this test had the tightest budget in the file (the 240s-budget tests in the same job passed). A clean local run takes 270s wall for 4 turns, confirming 180s was too tight even without contention. Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom over the inner budget. Verified: PASS locally post-fix (4 turns, 270s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): retro-base-branch session budget survives runner contention (#2473) Same class as review-dashboard-via, one test over in the same file: /retro is a long multi-step flow whose clean pass measures 225-239s — a coin flip against the 240s inner budget. First CI run passed at 225s; the rerun timed out at the 240s line on all 3 attempts (exitReason "timeout"); the local verification run passed at 239s, ONE second under the old cap. Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom. Verified: PASS locally post-fix (17 turns, 239s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
526 lines
19 KiB
Bash
Executable File
526 lines
19 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.
|
|
# --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.
|
|
#
|
|
# 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
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
# GSTACK_STATE_ROOT takes precedence over GSTACK_HOME (test isolation per D16).
|
|
GSTACK_HOME="${GSTACK_STATE_ROOT:-${GSTACK_HOME:-$HOME/.gstack}}"
|
|
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.XXXXXX.tmp")
|
|
trap 'rm -f "$TMPOUT"' EXIT
|
|
|
|
cat "$LEGACY_FILE" | bun -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=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('$PROFILE_FILE','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.
|
|
# Silent skip on invalid input (matches gstack-timeline-log:22-26 pattern).
|
|
# -----------------------------------------------------------------------
|
|
do_log_session() {
|
|
local INPUT="${1:-}"
|
|
if [ -z "$INPUT" ]; then
|
|
return 0
|
|
fi
|
|
|
|
# Validate: input must be parseable JSON with required fields (date, mode).
|
|
if ! printf '%s' "$INPUT" | bun -e "
|
|
const j = JSON.parse(await Bun.stdin.text());
|
|
if (!j.date || !j.mode) process.exit(1);
|
|
" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
ensure_profile
|
|
|
|
local TMPOUT
|
|
TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.XXXXXX.tmp")
|
|
trap 'rm -f "$TMPOUT"' EXIT
|
|
|
|
PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun -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 &
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Read: emit legacy KEY: VALUE output for /office-hours compat.
|
|
# -----------------------------------------------------------------------
|
|
do_read() {
|
|
ensure_profile
|
|
cat "$PROFILE_FILE" | bun -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');
|
|
|
|
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 = realSessions[realSessions.length - 1] || {};
|
|
const prev = realSessions[realSessions.length - 2] || {};
|
|
const crossProject = prev.project_slug && last.project_slug
|
|
? prev.project_slug !== last.project_slug
|
|
: false;
|
|
|
|
const designs = realSessions.map(e => e.design_doc || '').filter(Boolean);
|
|
const designTitles = realSessions
|
|
.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 = realSessions.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 -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));
|
|
"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# 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 -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, applySignal, newDimensionTotals, normalizeToDimensionValue } = sigmod;
|
|
|
|
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 = newDimensionTotals();
|
|
const skills = new Set();
|
|
const qids = new Set();
|
|
const days = new Set();
|
|
let count = 0;
|
|
for (const line of lines) {
|
|
let e;
|
|
try { e = JSON.parse(line); } catch { continue; }
|
|
if (!e.question_id || !e.user_choice) continue;
|
|
count++;
|
|
skills.add(e.skill);
|
|
qids.add(e.question_id);
|
|
if (e.ts) days.add(String(e.ts).slice(0,10));
|
|
const def = QUESTIONS[e.question_id];
|
|
if (def && def.signal_key) {
|
|
applySignal(totals, def.signal_key, e.user_choice);
|
|
}
|
|
}
|
|
|
|
const values = {};
|
|
for (const [dim, total] of Object.entries(totals)) {
|
|
values[dim] = +normalizeToDimensionValue(total).toFixed(3);
|
|
}
|
|
|
|
profile.inferred = {
|
|
values,
|
|
sample_size: count,
|
|
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 -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 } = sigmod;
|
|
const target = process.env.TRACE_DIM;
|
|
const lines = fs.readFileSync(process.env.EVENTS_PATH, 'utf-8').trim().split('\n').filter(Boolean);
|
|
const rows = [];
|
|
for (const line of lines) {
|
|
let e;
|
|
try { e = JSON.parse(line); } catch { continue; }
|
|
const def = QUESTIONS[e.question_id];
|
|
if (!def || !def.signal_key) continue;
|
|
const deltas = SIGNAL_MAP[def.signal_key]?.[e.user_choice] || [];
|
|
for (const d of deltas) {
|
|
if (d.dim === target) {
|
|
rows.push({ ts: e.ts, question_id: e.question_id, choice: e.user_choice, delta: d.delta });
|
|
}
|
|
}
|
|
}
|
|
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(' ' + (r.ts || '').slice(0,19) + ' ' + r.question_id + ' → ' + r.choice + ' (' + (r.delta > 0 ? '+' : '') + r.delta + ')');
|
|
}
|
|
}
|
|
});
|
|
"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Check mismatch: flag when declared ≠ inferred by > threshold
|
|
# -----------------------------------------------------------------------
|
|
do_check_mismatch() {
|
|
ensure_profile
|
|
cat "$PROFILE_FILE" | bun -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 -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 "$@" ;;
|
|
--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
|