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>
699 lines
26 KiB
TypeScript
699 lines
26 KiB
TypeScript
/**
|
|
* bin/gstack-developer-profile — subcommand behavior tests.
|
|
*
|
|
* Covers:
|
|
* - --read (legacy /office-hours KEY: VALUE format, with defaults when no profile)
|
|
* - --migrate (idempotent; preserves sessions + signals_accumulated)
|
|
* - --derive (recomputes inferred from question-log events)
|
|
* - --trace <dim> (shows contributing events)
|
|
* - --gap (declared vs inferred)
|
|
* - --vibe (archetype match from inferred)
|
|
* - --check-mismatch (threshold behavior; requires 10+ samples)
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN_DEV = path.join(ROOT, 'bin', 'gstack-developer-profile');
|
|
const BIN_LOG = path.join(ROOT, 'bin', 'gstack-question-log');
|
|
|
|
let tmpHome: string;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
function runDev(...args: string[]): { stdout: string; stderr: string; status: number } {
|
|
const res = spawnSync(BIN_DEV, args, {
|
|
env: { ...process.env, GSTACK_HOME: tmpHome },
|
|
encoding: 'utf-8',
|
|
cwd: ROOT,
|
|
});
|
|
return {
|
|
stdout: res.stdout ?? '',
|
|
stderr: res.stderr ?? '',
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
function logQuestion(payload: Record<string, unknown>): number {
|
|
const res = spawnSync(BIN_LOG, [JSON.stringify(payload)], {
|
|
env: { ...process.env, GSTACK_HOME: tmpHome },
|
|
encoding: 'utf-8',
|
|
cwd: ROOT,
|
|
});
|
|
return res.status ?? -1;
|
|
}
|
|
|
|
function writeLegacyProfile(sessions: Array<Record<string, unknown>>) {
|
|
const content = sessions.map((s) => JSON.stringify(s)).join('\n') + '\n';
|
|
fs.writeFileSync(path.join(tmpHome, 'builder-profile.jsonl'), content);
|
|
}
|
|
|
|
function readProfile(): Record<string, unknown> {
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --read (defaults + compat)
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --read', () => {
|
|
test('emits defaults when no profile exists (creates stub)', () => {
|
|
const r = runDev('--read');
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('SESSION_COUNT: 0');
|
|
expect(r.stdout).toContain('TIER: introduction');
|
|
expect(r.stdout).toContain('CROSS_PROJECT: false');
|
|
});
|
|
|
|
test('creates a stub profile file when missing', () => {
|
|
runDev('--read');
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
expect(fs.existsSync(file)).toBe(true);
|
|
const p = readProfile();
|
|
expect(p.schema_version).toBe(1);
|
|
});
|
|
|
|
test('omits --read flag and still returns default output', () => {
|
|
const r = runDev();
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('TIER:');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --migrate (legacy jsonl → unified profile)
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --migrate', () => {
|
|
test('migrates 3 sessions with signals, resources, topics', () => {
|
|
writeLegacyProfile([
|
|
{
|
|
date: '2026-03-01',
|
|
mode: 'builder',
|
|
project_slug: 'alpha',
|
|
signals: ['taste', 'agency'],
|
|
resources_shown: ['https://a.example'],
|
|
topics: ['onboarding'],
|
|
design_doc: '/tmp/a.md',
|
|
assignment: 'watch 3 users',
|
|
},
|
|
{
|
|
date: '2026-03-10',
|
|
mode: 'startup',
|
|
project_slug: 'beta',
|
|
signals: ['named_users', 'pushback', 'taste'],
|
|
resources_shown: ['https://b.example'],
|
|
topics: ['fit'],
|
|
design_doc: '/tmp/b.md',
|
|
assignment: 'interview 5',
|
|
},
|
|
{
|
|
date: '2026-04-01',
|
|
mode: 'builder',
|
|
project_slug: 'alpha',
|
|
signals: ['agency'],
|
|
resources_shown: [],
|
|
topics: ['iter'],
|
|
design_doc: '/tmp/c.md',
|
|
assignment: 'ship v1',
|
|
},
|
|
]);
|
|
|
|
const r = runDev('--migrate');
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('migrated 3 sessions');
|
|
|
|
const p = readProfile() as {
|
|
sessions: Array<{ project_slug: string; signals: string[] }>;
|
|
signals_accumulated: Record<string, number>;
|
|
resources_shown: string[];
|
|
topics: string[];
|
|
};
|
|
|
|
expect(p.sessions.length).toBe(3);
|
|
// Accumulated signals are correctly tallied
|
|
expect(p.signals_accumulated.taste).toBe(2);
|
|
expect(p.signals_accumulated.agency).toBe(2);
|
|
expect(p.signals_accumulated.named_users).toBe(1);
|
|
expect(p.signals_accumulated.pushback).toBe(1);
|
|
expect(p.resources_shown.length).toBe(2);
|
|
expect(p.topics.length).toBe(3);
|
|
});
|
|
|
|
test('idempotent — second migrate is no-op when profile exists', () => {
|
|
writeLegacyProfile([{ date: '2026-03-01', mode: 'builder', project_slug: 'x', signals: ['taste'] }]);
|
|
runDev('--migrate');
|
|
const p1 = readProfile();
|
|
const r2 = runDev('--migrate');
|
|
expect(r2.stdout).toMatch(/no legacy file|already migrated/);
|
|
const p2 = readProfile();
|
|
// Sessions count should be identical — migration didn't duplicate
|
|
expect((p1 as any).sessions.length).toBe((p2 as any).sessions.length);
|
|
});
|
|
|
|
test('archives legacy file after successful migration', () => {
|
|
writeLegacyProfile([{ date: '2026-03-01', mode: 'builder', project_slug: 'x', signals: [] }]);
|
|
runDev('--migrate');
|
|
// Legacy file should be renamed to *.migrated-<timestamp>
|
|
const files = fs.readdirSync(tmpHome);
|
|
const archived = files.filter((f) => f.startsWith('builder-profile.jsonl.migrated-'));
|
|
expect(archived.length).toBe(1);
|
|
// Original name should no longer exist
|
|
expect(fs.existsSync(path.join(tmpHome, 'builder-profile.jsonl'))).toBe(false);
|
|
});
|
|
|
|
test('no-op when no legacy file exists', () => {
|
|
const r = runDev('--migrate');
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('no legacy file');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --read tier calculation
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile tier calculation', () => {
|
|
test('1-3 sessions → welcome_back', () => {
|
|
writeLegacyProfile([
|
|
{ date: 'x', mode: 'builder', project_slug: 'a', signals: [] },
|
|
{ date: 'x', mode: 'builder', project_slug: 'a', signals: [] },
|
|
{ date: 'x', mode: 'builder', project_slug: 'a', signals: [] },
|
|
]);
|
|
runDev('--migrate');
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('TIER: welcome_back');
|
|
});
|
|
|
|
test('4-7 sessions → regular', () => {
|
|
const sessions = Array.from({ length: 5 }, () => ({
|
|
date: 'x',
|
|
mode: 'builder',
|
|
project_slug: 'a',
|
|
signals: [],
|
|
}));
|
|
writeLegacyProfile(sessions);
|
|
runDev('--migrate');
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('TIER: regular');
|
|
});
|
|
|
|
test('8+ sessions → inner_circle', () => {
|
|
const sessions = Array.from({ length: 9 }, () => ({
|
|
date: 'x',
|
|
mode: 'builder',
|
|
project_slug: 'a',
|
|
signals: [],
|
|
}));
|
|
writeLegacyProfile(sessions);
|
|
runDev('--migrate');
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('TIER: inner_circle');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --derive: inferred dimensions from question-log events
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --derive', () => {
|
|
test('derive with no events yields neutral (0.5) dimensions', () => {
|
|
runDev('--derive');
|
|
const p = readProfile() as {
|
|
inferred: { values: Record<string, number>; sample_size: number };
|
|
};
|
|
expect(p.inferred.sample_size).toBe(0);
|
|
expect(p.inferred.values.scope_appetite).toBeCloseTo(0.5, 2);
|
|
});
|
|
|
|
test('derive nudges scope_appetite upward after expand choices', () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
expect(
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'plan-ceo-review-mode',
|
|
question_summary: 'mode?',
|
|
user_choice: 'expand',
|
|
session_id: `s${i}`,
|
|
ts: `2026-04-0${i + 1}T10:00:00Z`,
|
|
}),
|
|
).toBe(0);
|
|
}
|
|
runDev('--derive');
|
|
const p = readProfile() as {
|
|
inferred: { values: Record<string, number>; sample_size: number; diversity: Record<string, number> };
|
|
};
|
|
expect(p.inferred.sample_size).toBe(5);
|
|
expect(p.inferred.values.scope_appetite).toBeGreaterThan(0.5);
|
|
expect(p.inferred.diversity.question_ids_covered).toBe(1);
|
|
expect(p.inferred.diversity.skills_covered).toBe(1);
|
|
});
|
|
|
|
test('derive nudges scope_appetite downward after reduce choices', () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'plan-ceo-review-mode',
|
|
question_summary: 'mode?',
|
|
user_choice: 'reduce',
|
|
session_id: `s${i}`,
|
|
});
|
|
}
|
|
runDev('--derive');
|
|
const p = readProfile() as { inferred: { values: Record<string, number> } };
|
|
expect(p.inferred.values.scope_appetite).toBeLessThan(0.5);
|
|
});
|
|
|
|
test('derive is recomputable — same input, same output', () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'plan-ceo-review-mode',
|
|
question_summary: 'mode?',
|
|
user_choice: 'expand',
|
|
session_id: `s${i}`,
|
|
});
|
|
}
|
|
runDev('--derive');
|
|
const v1 = (readProfile() as any).inferred.values;
|
|
runDev('--derive');
|
|
const v2 = (readProfile() as any).inferred.values;
|
|
expect(v1).toEqual(v2);
|
|
});
|
|
|
|
test('derive ignores events for questions not in registry (ad-hoc ids)', () => {
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'adhoc-unregistered-question',
|
|
question_summary: 'mystery',
|
|
user_choice: 'anything',
|
|
session_id: 's1',
|
|
});
|
|
runDev('--derive');
|
|
const p = readProfile() as { inferred: { values: Record<string, number>; sample_size: number } };
|
|
// Sample size counts the log entry, but no signal delta applied
|
|
expect(p.inferred.sample_size).toBe(1);
|
|
expect(p.inferred.values.scope_appetite).toBeCloseTo(0.5, 2);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --trace
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --trace <dim>', () => {
|
|
test('shows contributing events with delta values', () => {
|
|
for (let i = 0; i < 3; i++) {
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'plan-ceo-review-mode',
|
|
question_summary: 'mode?',
|
|
user_choice: 'expand',
|
|
session_id: `s${i}`,
|
|
});
|
|
}
|
|
const r = runDev('--trace', 'scope_appetite');
|
|
expect(r.stdout).toContain('3 events for scope_appetite');
|
|
expect(r.stdout).toContain('plan-ceo-review-mode');
|
|
expect(r.stdout).toContain('expand');
|
|
});
|
|
|
|
test('reports no contributions for untouched dimension', () => {
|
|
logQuestion({
|
|
skill: 'plan-ceo-review',
|
|
question_id: 'plan-ceo-review-mode',
|
|
question_summary: 'x',
|
|
user_choice: 'expand',
|
|
session_id: 's1',
|
|
});
|
|
const r = runDev('--trace', 'autonomy');
|
|
expect(r.stdout).toContain('no events contribute to autonomy');
|
|
});
|
|
|
|
test('errors without dimension argument', () => {
|
|
const r = runDev('--trace');
|
|
expect(r.status).not.toBe(0);
|
|
expect(r.stderr).toContain('missing dimension');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --gap
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --gap', () => {
|
|
test('gap is empty when nothing is declared', () => {
|
|
runDev('--read');
|
|
const r = runDev('--gap');
|
|
expect(r.status).toBe(0);
|
|
const out = JSON.parse(r.stdout);
|
|
expect(out.gap).toEqual({});
|
|
});
|
|
|
|
test('gap computed when declared and inferred both present', () => {
|
|
runDev('--read');
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
const p = readProfile() as any;
|
|
p.declared = { scope_appetite: 0.8 };
|
|
p.inferred.values.scope_appetite = 0.55;
|
|
fs.writeFileSync(file, JSON.stringify(p));
|
|
const r = runDev('--gap');
|
|
const out = JSON.parse(r.stdout);
|
|
expect(out.gap.scope_appetite).toBeCloseTo(0.25, 2);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --vibe (archetype match)
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --vibe', () => {
|
|
test('returns archetype name and description', () => {
|
|
runDev('--read');
|
|
const r = runDev('--vibe');
|
|
expect(r.status).toBe(0);
|
|
const lines = r.stdout.trim().split('\n');
|
|
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
// Default profile (all 0.5) is closest to Builder-Coach or Polymath
|
|
expect(lines[0].length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --check-mismatch
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --check-mismatch', () => {
|
|
test('reports insufficient data when < 10 events', () => {
|
|
runDev('--read');
|
|
const r = runDev('--check-mismatch');
|
|
expect(r.stdout).toContain('not enough data');
|
|
});
|
|
|
|
test('reports no mismatch when declared tracks inferred closely', () => {
|
|
runDev('--read');
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
const p = readProfile() as any;
|
|
p.declared = { scope_appetite: 0.5, architecture_care: 0.5 };
|
|
p.inferred.sample_size = 20;
|
|
fs.writeFileSync(file, JSON.stringify(p));
|
|
const r = runDev('--check-mismatch');
|
|
expect(r.stdout).toContain('MISMATCH: none');
|
|
});
|
|
|
|
test('flags dimensions with gap > 0.3 when enough data', () => {
|
|
runDev('--read');
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
const p = readProfile() as any;
|
|
p.declared = { scope_appetite: 0.9, autonomy: 0.2 };
|
|
p.inferred.values.scope_appetite = 0.4;
|
|
p.inferred.values.autonomy = 0.8;
|
|
p.inferred.sample_size = 25;
|
|
fs.writeFileSync(file, JSON.stringify(p));
|
|
const r = runDev('--check-mismatch');
|
|
expect(r.stdout).toContain('2 dimension(s) disagree');
|
|
expect(r.stdout).toContain('scope_appetite');
|
|
expect(r.stdout).toContain('autonomy');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Error handling
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile errors', () => {
|
|
test('unknown subcommand exits non-zero', () => {
|
|
const r = runDev('--not-a-real-subcommand');
|
|
expect(r.status).not.toBe(0);
|
|
expect(r.stderr).toContain('unknown subcommand');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// --log-session — the #1671 fix: writer that matches the reader.
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile --log-session (#1671 fix)', () => {
|
|
test('regression: read-write-read sequence on fresh $HOME promotes to welcome_back', () => {
|
|
// First --read creates an empty stub (this is the bug-shape on current main).
|
|
const r1 = runDev('--read');
|
|
expect(r1.stdout).toContain('SESSION_COUNT: 0');
|
|
expect(r1.stdout).toContain('TIER: introduction');
|
|
|
|
// Office-hours writes a session via the new subcommand.
|
|
const r2 = runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-23T00:00:00Z',
|
|
mode: 'startup',
|
|
project_slug: 'test',
|
|
signal_count: 2,
|
|
signals: ['s1', 's2'],
|
|
}));
|
|
expect(r2.status).toBe(0);
|
|
|
|
// Second --read sees the session — this is what was broken.
|
|
const r3 = runDev('--read');
|
|
expect(r3.stdout).toContain('SESSION_COUNT: 1');
|
|
expect(r3.stdout).toContain('TIER: welcome_back');
|
|
expect(r3.stdout).toContain('LAST_PROJECT: test');
|
|
expect(r3.stdout).toContain('TOTAL_SIGNAL_COUNT: 2');
|
|
});
|
|
|
|
test('aggregates signals across multiple sessions', () => {
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T00:00:00Z', mode: 'startup', project_slug: 'p', signals: ['a', 'b'],
|
|
}));
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-21T00:00:00Z', mode: 'startup', project_slug: 'p', signals: ['a', 'c'],
|
|
}));
|
|
const p = readProfile() as { sessions: unknown[]; signals_accumulated: Record<string, number> };
|
|
expect(p.sessions.length).toBe(2);
|
|
expect(p.signals_accumulated).toEqual({ a: 2, b: 1, c: 1 });
|
|
});
|
|
|
|
test('aggregates resources_shown and topics as deduped unions', () => {
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T00:00:00Z', mode: 'resources', project_slug: 'p',
|
|
resources_shown: ['url1', 'url2'], topics: ['ai'],
|
|
}));
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-21T00:00:00Z', mode: 'resources', project_slug: 'p',
|
|
resources_shown: ['url2', 'url3'], topics: ['ai', 'eng'],
|
|
}));
|
|
const p = readProfile() as { resources_shown: string[]; topics: string[] };
|
|
expect(p.resources_shown.sort()).toEqual(['url1', 'url2', 'url3']);
|
|
expect(p.topics.sort()).toEqual(['ai', 'eng']);
|
|
});
|
|
|
|
test('silently skips invalid JSON input (matches gstack-timeline-log pattern)', () => {
|
|
const r = runDev('--log-session', 'not-json');
|
|
expect(r.status).toBe(0); // silent skip, not error
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
expect(fs.existsSync(file)).toBe(false); // no stub created either
|
|
});
|
|
|
|
test('silently skips JSON missing required fields', () => {
|
|
const r = runDev('--log-session', JSON.stringify({ foo: 'bar' }));
|
|
expect(r.status).toBe(0);
|
|
const file = path.join(tmpHome, 'developer-profile.json');
|
|
expect(fs.existsSync(file)).toBe(false);
|
|
});
|
|
|
|
test('injects ts field if missing', () => {
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-23T00:00:00Z', mode: 'startup', project_slug: 'p',
|
|
}));
|
|
const p = readProfile() as { sessions: Array<{ ts: string }> };
|
|
expect(p.sessions[0].ts).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
});
|
|
|
|
test('preserves user-set ts field if provided', () => {
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-23T00:00:00Z', mode: 'startup', project_slug: 'p',
|
|
ts: '2026-05-23T12:34:56Z',
|
|
}));
|
|
const p = readProfile() as { sessions: Array<{ ts: string }> };
|
|
expect(p.sessions[0].ts).toBe('2026-05-23T12:34:56Z');
|
|
});
|
|
|
|
test('do_read picks LAST_* from real sessions, not from a trailing mode:resources entry', () => {
|
|
// The Phase 6 resources auto-append happens AFTER the real session in the
|
|
// same /office-hours invocation. Without the mode filter, that resources
|
|
// entry would clobber LAST_PROJECT/LAST_ASSIGNMENT/LAST_DESIGN_TITLE for
|
|
// the next session.
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T00:00:00Z',
|
|
mode: 'startup',
|
|
project_slug: 'realproj',
|
|
assignment: 'real assignment text',
|
|
design_doc: 'plans/real.md',
|
|
}));
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T01:00:00Z',
|
|
mode: 'resources',
|
|
project_slug: 'realproj',
|
|
assignment: '',
|
|
design_doc: '',
|
|
resources_shown: ['url1'],
|
|
}));
|
|
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('LAST_PROJECT: realproj');
|
|
expect(r.stdout).toContain('LAST_ASSIGNMENT: real assignment text');
|
|
expect(r.stdout).toContain('LAST_DESIGN_TITLE: plans/real.md');
|
|
// Resources still aggregate into RESOURCES_SHOWN.
|
|
expect(r.stdout).toContain('RESOURCES_SHOWN: url1');
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SESSION_COUNT / TIER / NUDGE_ELIGIBLE must ignore mode:resources entries.
|
|
//
|
|
// Phase 6 of /office-hours auto-appends one (or more) mode:resources bookkeeping
|
|
// entries every run, to dedupe which founder-resource links the user has seen.
|
|
// Those are not sessions. Counting them inflated SESSION_COUNT (and therefore
|
|
// TIER) and pushed NUDGE_ELIGIBLE over its threshold from bookkeeping alone —
|
|
// e.g. a single real session plus three closings reported as tier `regular`
|
|
// with the builder->founder nudge armed.
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('gstack-developer-profile resources entries do not inflate count/tier/nudge', () => {
|
|
function logStartup(extra: Record<string, unknown> = {}) {
|
|
return runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T00:00:00Z', mode: 'startup', project_slug: 'p',
|
|
signal_count: 5, signals: ['a', 'b', 'c', 'd', 'e'], ...extra,
|
|
}));
|
|
}
|
|
function logResources(i: number) {
|
|
return runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T01:00:00Z', mode: 'resources', project_slug: 'p',
|
|
resources_shown: [`url${i}`],
|
|
}));
|
|
}
|
|
|
|
test('SESSION_COUNT counts only real sessions, not resources entries', () => {
|
|
logStartup();
|
|
logResources(1);
|
|
logResources(2);
|
|
logResources(3);
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('SESSION_COUNT: 1');
|
|
expect(r.stdout).toContain('TIER: welcome_back');
|
|
});
|
|
|
|
test('TIER is not bumped to regular by resources bookkeeping', () => {
|
|
// 3 real sessions = welcome_back; adding resources entries must not reach the
|
|
// 4-session `regular` threshold.
|
|
logStartup();
|
|
logStartup();
|
|
logStartup();
|
|
for (let i = 0; i < 4; i++) logResources(i);
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('SESSION_COUNT: 3');
|
|
expect(r.stdout).toContain('TIER: welcome_back');
|
|
});
|
|
|
|
test('NUDGE_ELIGIBLE stays false when builder-session bar is unmet despite resources noise', () => {
|
|
// One startup session carrying 5 signals, plus resources entries. builderSessions
|
|
// (mode === "builder") is 0, so the nudge must not arm regardless of signal count.
|
|
logStartup();
|
|
logResources(1);
|
|
logResources(2);
|
|
logResources(3);
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
|
|
});
|
|
|
|
test('NUDGE_ELIGIBLE arms on 3 real builder sessions with enough signals', () => {
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['a', 'b'],
|
|
}));
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-21T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['c', 'd'],
|
|
}));
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-22T00:00:00Z', mode: 'builder', project_slug: 'p', signals: ['e'],
|
|
}));
|
|
logResources(1); // bookkeeping must not change the verdict either way
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('NUDGE_ELIGIBLE: true');
|
|
});
|
|
|
|
// Boundary cases around the two `>=` gates, so a future >= → > regression
|
|
// (or a re-loosening of the builder filter) is caught, not just the happy path.
|
|
function logBuilder(signals: string[], day = 20) {
|
|
return runDev('--log-session', JSON.stringify({
|
|
date: `2026-05-${day}T00:00:00Z`, mode: 'builder', project_slug: 'p', signals,
|
|
}));
|
|
}
|
|
|
|
test('NUDGE_ELIGIBLE stays false at 2 builder sessions (below the 3-session gate)', () => {
|
|
logBuilder(['a', 'b', 'c'], 20);
|
|
logBuilder(['d', 'e', 'f'], 21); // 6 signals total — signal gate met, session gate is not
|
|
logResources(1);
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
|
|
});
|
|
|
|
test('NUDGE_ELIGIBLE stays false at 3 builder sessions with too few signals', () => {
|
|
logBuilder(['a'], 20);
|
|
logBuilder(['b'], 21);
|
|
logBuilder(['c', 'd'], 22); // 4 signals total — session gate met, signal gate (>=5) is not
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('NUDGE_ELIGIBLE: false');
|
|
});
|
|
|
|
test('TIER reaches regular at 4 real sessions even when resources entries are present', () => {
|
|
logStartup();
|
|
logStartup();
|
|
logStartup();
|
|
logStartup();
|
|
for (let i = 0; i < 5; i++) logResources(i);
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('SESSION_COUNT: 4');
|
|
expect(r.stdout).toContain('TIER: regular');
|
|
});
|
|
|
|
test('TIER stays regular at 7 real sessions and crosses to inner_circle at 8 (resources ignored)', () => {
|
|
// Upper-tier boundary: the >=8 inner_circle gate must key off real sessions
|
|
// only, so a pile of resources bookkeeping can never tip a regular into the
|
|
// inner circle, and 8 genuine sessions still reach it.
|
|
for (let i = 0; i < 7; i++) logStartup();
|
|
for (let i = 0; i < 6; i++) logResources(i); // 13 raw rows; pre-fix would read inner_circle
|
|
let r = runDev('--read');
|
|
expect(r.stdout).toContain('SESSION_COUNT: 7');
|
|
expect(r.stdout).toContain('TIER: regular');
|
|
|
|
logStartup(); // 8th real session
|
|
r = runDev('--read');
|
|
expect(r.stdout).toContain('SESSION_COUNT: 8');
|
|
expect(r.stdout).toContain('TIER: inner_circle');
|
|
});
|
|
|
|
test('CROSS_PROJECT ignores a trailing resources entry on a different project', () => {
|
|
// The last two REAL sessions are the same project, so CROSS_PROJECT is false.
|
|
// A trailing resources row carrying a different project_slug must not become
|
|
// the `last` entry and flip CROSS_PROJECT true off bookkeeping.
|
|
logStartup({ project_slug: 'samep' });
|
|
logStartup({ project_slug: 'samep' });
|
|
runDev('--log-session', JSON.stringify({
|
|
date: '2026-05-20T02:00:00Z', mode: 'resources', project_slug: 'otherp',
|
|
resources_shown: ['url1'],
|
|
}));
|
|
const r = runDev('--read');
|
|
expect(r.stdout).toContain('CROSS_PROJECT: false');
|
|
expect(r.stdout).toContain('LAST_PROJECT: samep');
|
|
});
|
|
});
|
|
|