diff --git a/bin/gstack-taste-update b/bin/gstack-taste-update new file mode 100755 index 00000000..4782552d --- /dev/null +++ b/bin/gstack-taste-update @@ -0,0 +1,293 @@ +#!/usr/bin/env bun +// gstack-taste-update — update the persistent taste profile at +// ~/.gstack/projects/$SLUG/taste-profile.json +// +// Usage: +// gstack-taste-update approved [--reason ""] +// gstack-taste-update rejected [--reason ""] +// gstack-taste-update show — print current profile summary +// gstack-taste-update migrate — upgrade legacy approved.json to v1 +// +// Schema v1 at ~/.gstack/projects/$SLUG/taste-profile.json: +// +// { +// "version": 1, +// "updated_at": "", +// "dimensions": { +// "fonts": { "approved": [...], "rejected": [...] }, +// "colors": { "approved": [...], "rejected": [...] }, +// "layouts": { "approved": [...], "rejected": [...] }, +// "aesthetics": { "approved": [...], "rejected": [...] } +// }, +// "sessions": [ // last 50 only — truncated via decay +// { "ts": "", "action": "approved"|"rejected", "variant": "", "reason": "" } +// ] +// } +// +// Each Preference entry: +// { value: string, confidence: number (0-1), approved_count, rejected_count, last_seen } +// +// Confidence is computed with Laplace smoothing + 5% weekly decay at read time. + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +const STATE_DIR = process.env.GSTACK_STATE_DIR || path.join(process.env.HOME || '/', '.gstack'); +const SCHEMA_VERSION = 1; +const SESSION_CAP = 50; +const DECAY_PER_WEEK = 0.05; + +type Dimension = 'fonts' | 'colors' | 'layouts' | 'aesthetics'; +const DIMENSIONS: Dimension[] = ['fonts', 'colors', 'layouts', 'aesthetics']; + +interface Preference { + value: string; + confidence: number; + approved_count: number; + rejected_count: number; + last_seen: string; +} + +interface SessionRecord { + ts: string; + action: 'approved' | 'rejected'; + variant: string; + reason?: string; +} + +interface TasteProfile { + version: number; + updated_at: string; + dimensions: Record; + sessions: SessionRecord[]; +} + +function getSlug(): string { + try { + const output = execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); + return path.basename(output); + } catch { + return 'unknown'; + } +} + +function profilePath(slug: string): string { + return path.join(STATE_DIR, 'projects', slug, 'taste-profile.json'); +} + +function emptyProfile(): TasteProfile { + return { + version: SCHEMA_VERSION, + updated_at: new Date().toISOString(), + dimensions: { + fonts: { approved: [], rejected: [] }, + colors: { approved: [], rejected: [] }, + layouts: { approved: [], rejected: [] }, + aesthetics: { approved: [], rejected: [] }, + }, + sessions: [], + }; +} + +function load(slug: string): TasteProfile { + const p = profilePath(slug); + if (!fs.existsSync(p)) return emptyProfile(); + try { + const raw = JSON.parse(fs.readFileSync(p, 'utf-8')); + if (!raw.version || raw.version < SCHEMA_VERSION) { + return migrate(raw); + } + return raw as TasteProfile; + } catch (err) { + console.error(`WARN: could not parse ${p}:`, (err as Error).message); + return emptyProfile(); + } +} + +function save(slug: string, profile: TasteProfile): void { + const p = profilePath(slug); + fs.mkdirSync(path.dirname(p), { recursive: true }); + profile.updated_at = new Date().toISOString(); + fs.writeFileSync(p, JSON.stringify(profile, null, 2) + '\n'); +} + +/** + * Migrate a legacy profile (no version or version < SCHEMA_VERSION) into the + * current schema, preserving data where possible. Legacy approved.json aggregates + * get normalized into empty-but-valid v1 profiles so the next write populates them. + */ +function migrate(legacy: unknown): TasteProfile { + const fresh = emptyProfile(); + if (legacy && typeof legacy === 'object') { + const anyLegacy = legacy as Record; + // Preserve sessions if present + if (Array.isArray(anyLegacy.sessions)) { + fresh.sessions = anyLegacy.sessions.slice(-SESSION_CAP) as SessionRecord[]; + } + // Preserve dimensions if present and well-formed + if (anyLegacy.dimensions && typeof anyLegacy.dimensions === 'object') { + for (const dim of DIMENSIONS) { + const src = (anyLegacy.dimensions as Record)[dim]; + if (src && typeof src === 'object') { + const ss = src as Record; + if (Array.isArray(ss.approved)) fresh.dimensions[dim].approved = ss.approved as Preference[]; + if (Array.isArray(ss.rejected)) fresh.dimensions[dim].rejected = ss.rejected as Preference[]; + } + } + } + } + return fresh; +} + +/** + * Apply 5% per-week decay to confidence values at read/show time. + * Returns a copy; does NOT mutate or persist the input. + */ +function applyDecay(profile: TasteProfile): TasteProfile { + const now = Date.now(); + const decayed = JSON.parse(JSON.stringify(profile)) as TasteProfile; + for (const dim of DIMENSIONS) { + for (const bucket of ['approved', 'rejected'] as const) { + for (const pref of decayed.dimensions[dim][bucket]) { + const lastSeen = new Date(pref.last_seen).getTime(); + const weeks = Math.max(0, (now - lastSeen) / (7 * 24 * 60 * 60 * 1000)); + pref.confidence = Math.max(0, pref.confidence * Math.pow(1 - DECAY_PER_WEEK, weeks)); + } + } + } + return decayed; +} + +/** + * Extract dimension values from a variant description. V1 keeps this simple: + * the variant is a path/name like "variant-A" — we can't extract real design + * tokens without the mockup's metadata. Callers should pass a reason string + * that mentions fonts/colors/layouts/aesthetics. If the reason is missing, + * the session is recorded but dimensions don't get updated. + * + * Future v2: parse the variant PNG's EXIF, or read an accompanying manifest + * that design-shotgun writes next to each variant. + */ +function extractSignals(reason?: string): Partial> { + if (!reason) return {}; + const out: Partial> = {}; + // naive pattern: "fonts: X, Y; colors: Z" — split by dimension label + const labelRe = /(fonts|colors|layouts|aesthetics):\s*([^;]+)/gi; + let m: RegExpExecArray | null; + while ((m = labelRe.exec(reason)) !== null) { + const dim = m[1].toLowerCase() as Dimension; + const values = m[2].split(',').map(s => s.trim()).filter(Boolean); + out[dim] = values; + } + return out; +} + +function bumpPref(list: Preference[], value: string, opposite: Preference[], action: 'approved' | 'rejected'): Preference[] { + const now = new Date().toISOString(); + let entry = list.find(p => p.value.toLowerCase() === value.toLowerCase()); + if (!entry) { + entry = { value, confidence: 0, approved_count: 0, rejected_count: 0, last_seen: now }; + list.push(entry); + } + if (action === 'approved') { + entry.approved_count += 1; + } else { + entry.rejected_count += 1; + } + entry.last_seen = now; + // Laplace-smoothed confidence + const total = entry.approved_count + entry.rejected_count; + entry.confidence = entry.approved_count / (total + 1); + // Flag conflict if the opposite bucket has a strong entry for this value + const opp = opposite.find(p => p.value.toLowerCase() === value.toLowerCase()); + if (opp && opp.approved_count + opp.rejected_count >= 3 && opp.confidence >= 0.6) { + console.error(`NOTE: taste drift — "${value}" previously ${action === 'approved' ? 'rejected' : 'approved'} with confidence ${opp.confidence.toFixed(2)}. Keep both signals; aggregate confidence will rebalance.`); + } + return list; +} + +function cmdUpdate(action: 'approved' | 'rejected', variant: string, reason?: string): void { + const slug = getSlug(); + const profile = load(slug); + const signals = extractSignals(reason); + + for (const dim of DIMENSIONS) { + const values = signals[dim]; + if (!values) continue; + const bucket = profile.dimensions[dim][action]; + const opposite = profile.dimensions[dim][action === 'approved' ? 'rejected' : 'approved']; + for (const v of values) bumpPref(bucket, v, opposite, action); + } + + // Always record the session even if no dimensions were extracted + profile.sessions.push({ ts: new Date().toISOString(), action, variant, reason }); + // Truncate sessions to last SESSION_CAP entries (FIFO) + if (profile.sessions.length > SESSION_CAP) { + profile.sessions = profile.sessions.slice(-SESSION_CAP); + } + + save(slug, profile); + console.log(`${action}: ${variant} → ${profilePath(slug)}`); +} + +function cmdShow(): void { + const slug = getSlug(); + const profile = applyDecay(load(slug)); + console.log(`taste-profile.json (slug: ${slug}, sessions: ${profile.sessions.length})`); + for (const dim of DIMENSIONS) { + const top = [...profile.dimensions[dim].approved] + .sort((a, b) => b.confidence * b.approved_count - a.confidence * a.approved_count) + .slice(0, 3); + const topRej = [...profile.dimensions[dim].rejected] + .sort((a, b) => b.confidence * b.rejected_count - a.confidence * a.rejected_count) + .slice(0, 3); + if (top.length || topRej.length) { + console.log(`\n[${dim}]`); + if (top.length) { + console.log(' approved (decayed):'); + for (const p of top) console.log(` ${p.value} — conf ${p.confidence.toFixed(2)} (+${p.approved_count}/-${p.rejected_count})`); + } + if (topRej.length) { + console.log(' rejected:'); + for (const p of topRej) console.log(` ${p.value} — conf ${p.confidence.toFixed(2)} (+${p.approved_count}/-${p.rejected_count})`); + } + } + } +} + +function cmdMigrate(): void { + const slug = getSlug(); + const profile = load(slug); + save(slug, profile); + console.log(`migrated taste profile to v${SCHEMA_VERSION} at ${profilePath(slug)}`); +} + +// ─── CLI entry ──────────────────────────────────────────────── + +const args = process.argv.slice(2); +const cmd = args[0]; + +switch (cmd) { + case 'approved': + case 'rejected': { + const variant = args[1]; + if (!variant) { + console.error(`Usage: gstack-taste-update ${cmd} [--reason ""]`); + process.exit(1); + } + const reasonIdx = args.indexOf('--reason'); + const reason = reasonIdx >= 0 ? args[reasonIdx + 1] : undefined; + cmdUpdate(cmd as 'approved' | 'rejected', variant, reason); + break; + } + case 'show': + cmdShow(); + break; + case 'migrate': + cmdMigrate(); + break; + default: + console.error('Usage: gstack-taste-update {approved|rejected|show|migrate} [args]'); + process.exit(1); +} diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 939c1a36..3996bd57 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -859,6 +859,54 @@ a posture ("for builders, not managers"). Write it down. Every subsequent design decision should serve this memorable thing. Design that tries to be memorable for everything is memorable for nothing. +### Taste profile (if this user has prior sessions) + +Read the persistent taste profile if it exists: + +```bash +_TASTE_PROFILE=~/.gstack/projects/$SLUG/taste-profile.json +if [ -f "$_TASTE_PROFILE" ]; then + # Schema v1: { dimensions: { fonts, colors, layouts, aesthetics }, sessions: [] } + # Each dimension has approved[] and rejected[] entries with + # { value, confidence, approved_count, rejected_count, last_seen } + # Confidence decays 5% per week of inactivity — computed at read time. + cat "$_TASTE_PROFILE" 2>/dev/null | head -200 + echo "TASTE_PROFILE_FOUND" +else + echo "NO_TASTE_PROFILE" +fi +``` + +**If TASTE_PROFILE_FOUND:** Summarize the strongest signals (top 3 approved entries +per dimension by confidence * approved_count). Include them in the design brief: + +"Based on \${SESSION_COUNT} prior sessions, this user's taste leans toward: +fonts [top-3], colors [top-3], layouts [top-3], aesthetics [top-3]. Bias +generation toward these unless the user explicitly requests a different direction. +Also avoid their strong rejections: [top-3 rejected per dimension]." + +**If NO_TASTE_PROFILE:** Fall through to per-session approved.json files (legacy). + +**Conflict handling:** If the current user request contradicts a strong persistent +signal (e.g., "make it playful" when taste profile strongly prefers minimal), flag +it: "Note: your taste profile strongly prefers minimal. You're asking for playful +this time — I'll proceed, but want me to update the taste profile, or treat this +as a one-off?" + +**Decay:** Confidence scores decay 5% per week. A font approved 6 months ago with +10 approvals has less weight than one approved last week. The decay calculation +happens at read time, not write time, so the file only grows on change. + +**Schema migration:** If the file has no `version` field or `version: 0`, it's +the legacy approved.json aggregate — `~/.claude/skills/gstack/bin/gstack-taste-update` +will migrate it to schema v1 on the next write. + +If a taste profile exists for this project, factor it into your Phase 3 proposal. +The profile reflects what the user has actually approved in prior sessions — treat +it as a demonstrated preference, not a constraint. You may still deliberately +depart from it if the product direction demands something different; when you do, +say so explicitly and connect the departure to the memorable-thing answer above. + --- ## Phase 2: Research (only if user said yes) diff --git a/design-consultation/SKILL.md.tmpl b/design-consultation/SKILL.md.tmpl index b0e66c47..ddce564c 100644 --- a/design-consultation/SKILL.md.tmpl +++ b/design-consultation/SKILL.md.tmpl @@ -102,6 +102,16 @@ a posture ("for builders, not managers"). Write it down. Every subsequent design decision should serve this memorable thing. Design that tries to be memorable for everything is memorable for nothing. +### Taste profile (if this user has prior sessions) + +{{TASTE_PROFILE}} + +If a taste profile exists for this project, factor it into your Phase 3 proposal. +The profile reflects what the user has actually approved in prior sessions — treat +it as a demonstrated preference, not a constraint. You may still deliberately +depart from it if the product direction demands something different; when you do, +say so explicitly and connect the departure to the memorable-thing answer above. + --- ## Phase 2: Research (only if user said yes) diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 7154f086..8852c3d5 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -785,7 +785,52 @@ Two rounds max of context gathering, then proceed with what you have and note as ## Step 2: Taste Memory -Read prior approved designs to bias generation toward the user's demonstrated taste: +Read both the persistent taste profile (cross-session) AND the per-session approved +designs to bias generation toward the user's demonstrated taste. + +**Persistent taste profile (v1 schema at `~/.gstack/projects/$SLUG/taste-profile.json`):** + +Read the persistent taste profile if it exists: + +```bash +_TASTE_PROFILE=~/.gstack/projects/$SLUG/taste-profile.json +if [ -f "$_TASTE_PROFILE" ]; then + # Schema v1: { dimensions: { fonts, colors, layouts, aesthetics }, sessions: [] } + # Each dimension has approved[] and rejected[] entries with + # { value, confidence, approved_count, rejected_count, last_seen } + # Confidence decays 5% per week of inactivity — computed at read time. + cat "$_TASTE_PROFILE" 2>/dev/null | head -200 + echo "TASTE_PROFILE_FOUND" +else + echo "NO_TASTE_PROFILE" +fi +``` + +**If TASTE_PROFILE_FOUND:** Summarize the strongest signals (top 3 approved entries +per dimension by confidence * approved_count). Include them in the design brief: + +"Based on \${SESSION_COUNT} prior sessions, this user's taste leans toward: +fonts [top-3], colors [top-3], layouts [top-3], aesthetics [top-3]. Bias +generation toward these unless the user explicitly requests a different direction. +Also avoid their strong rejections: [top-3 rejected per dimension]." + +**If NO_TASTE_PROFILE:** Fall through to per-session approved.json files (legacy). + +**Conflict handling:** If the current user request contradicts a strong persistent +signal (e.g., "make it playful" when taste profile strongly prefers minimal), flag +it: "Note: your taste profile strongly prefers minimal. You're asking for playful +this time — I'll proceed, but want me to update the taste profile, or treat this +as a one-off?" + +**Decay:** Confidence scores decay 5% per week. A font approved 6 months ago with +10 approvals has less weight than one approved last week. The decay calculation +happens at read time, not write time, so the file only grows on change. + +**Schema migration:** If the file has no `version` field or `version: 0`, it's +the legacy approved.json aggregate — `~/.claude/skills/gstack/bin/gstack-taste-update` +will migrate it to schema v1 on the next write. + +**Per-session approved.json files (legacy, still supported):** ```bash setopt +o nomatch 2>/dev/null || true @@ -793,14 +838,17 @@ _TASTE=$(find ~/.gstack/projects/$SLUG/designs/ -name "approved.json" -maxdepth ``` If prior sessions exist, read each `approved.json` and extract patterns from the -approved variants. Include a taste summary in the design brief: - -"The user previously approved designs with these characteristics: [high contrast, -generous whitespace, modern sans-serif typography, etc.]. Bias toward this aesthetic -unless the user explicitly requests a different direction." +approved variants. Merge these into the taste-profile.json-derived signal — if the +profile already says "user prefers Geist font" (from aggregated history), the +approved.json files add the specific recent approval context. Limit to last 10 sessions. Try/catch JSON parse on each (skip corrupted files). +**Updating taste profile after a design-shotgun session:** When the user picks a +variant, call `~/.claude/skills/gstack/bin/gstack-taste-update approved `. When they +explicitly reject a variant, call `~/.claude/skills/gstack/bin/gstack-taste-update rejected `. +The CLI handles schema migration from approved.json, decay, and conflict flagging. + ## Step 3: Generate Variants Set up the output directory: diff --git a/design-shotgun/SKILL.md.tmpl b/design-shotgun/SKILL.md.tmpl index c0be886e..48bae062 100644 --- a/design-shotgun/SKILL.md.tmpl +++ b/design-shotgun/SKILL.md.tmpl @@ -116,7 +116,14 @@ Two rounds max of context gathering, then proceed with what you have and note as ## Step 2: Taste Memory -Read prior approved designs to bias generation toward the user's demonstrated taste: +Read both the persistent taste profile (cross-session) AND the per-session approved +designs to bias generation toward the user's demonstrated taste. + +**Persistent taste profile (v1 schema at `~/.gstack/projects/$SLUG/taste-profile.json`):** + +{{TASTE_PROFILE}} + +**Per-session approved.json files (legacy, still supported):** ```bash setopt +o nomatch 2>/dev/null || true @@ -124,14 +131,17 @@ _TASTE=$(find ~/.gstack/projects/$SLUG/designs/ -name "approved.json" -maxdepth ``` If prior sessions exist, read each `approved.json` and extract patterns from the -approved variants. Include a taste summary in the design brief: - -"The user previously approved designs with these characteristics: [high contrast, -generous whitespace, modern sans-serif typography, etc.]. Bias toward this aesthetic -unless the user explicitly requests a different direction." +approved variants. Merge these into the taste-profile.json-derived signal — if the +profile already says "user prefers Geist font" (from aggregated history), the +approved.json files add the specific recent approval context. Limit to last 10 sessions. Try/catch JSON parse on each (skip corrupted files). +**Updating taste profile after a design-shotgun session:** When the user picks a +variant, call `{{BIN_DIR}}/gstack-taste-update approved `. When they +explicitly reject a variant, call `{{BIN_DIR}}/gstack-taste-update rejected `. +The CLI handles schema migration from approved.json, decay, and conflict flagging. + ## Step 3: Generate Variants Set up the output directory: diff --git a/scripts/resolvers/design.ts b/scripts/resolvers/design.ts index 208b1db3..2a7e490b 100644 --- a/scripts/resolvers/design.ts +++ b/scripts/resolvers/design.ts @@ -948,3 +948,45 @@ echo '{"approved_variant":"","feedback":"","date":"'$(date -u +%Y-%m-%dT% \`\`\``; } +export function generateTasteProfile(ctx: TemplateContext): string { + return `Read the persistent taste profile if it exists: + +\`\`\`bash +_TASTE_PROFILE=~/.gstack/projects/$SLUG/taste-profile.json +if [ -f "$_TASTE_PROFILE" ]; then + # Schema v1: { dimensions: { fonts, colors, layouts, aesthetics }, sessions: [] } + # Each dimension has approved[] and rejected[] entries with + # { value, confidence, approved_count, rejected_count, last_seen } + # Confidence decays 5% per week of inactivity — computed at read time. + cat "$_TASTE_PROFILE" 2>/dev/null | head -200 + echo "TASTE_PROFILE_FOUND" +else + echo "NO_TASTE_PROFILE" +fi +\`\`\` + +**If TASTE_PROFILE_FOUND:** Summarize the strongest signals (top 3 approved entries +per dimension by confidence * approved_count). Include them in the design brief: + +"Based on ${'\\${SESSION_COUNT}'} prior sessions, this user's taste leans toward: +fonts [top-3], colors [top-3], layouts [top-3], aesthetics [top-3]. Bias +generation toward these unless the user explicitly requests a different direction. +Also avoid their strong rejections: [top-3 rejected per dimension]." + +**If NO_TASTE_PROFILE:** Fall through to per-session approved.json files (legacy). + +**Conflict handling:** If the current user request contradicts a strong persistent +signal (e.g., "make it playful" when taste profile strongly prefers minimal), flag +it: "Note: your taste profile strongly prefers minimal. You're asking for playful +this time — I'll proceed, but want me to update the taste profile, or treat this +as a one-off?" + +**Decay:** Confidence scores decay 5% per week. A font approved 6 months ago with +10 approvals has less weight than one approved last week. The decay calculation +happens at read time, not write time, so the file only grows on change. + +**Schema migration:** If the file has no \`version\` field or \`version: 0\`, it's +the legacy approved.json aggregate — \`${ctx.paths.binDir}/gstack-taste-update\` +will migrate it to schema v1 on the next write.`; +} + diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 6b77fb9a..5455c488 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -9,7 +9,7 @@ import type { TemplateContext, ResolverFn } from './types'; import { generatePreamble } from './preamble'; import { generateTestFailureTriage } from './preamble'; import { generateCommandReference, generateSnapshotFlags, generateBrowseSetup } from './browse'; -import { generateDesignMethodology, generateDesignHardRules, generateDesignOutsideVoices, generateDesignReviewLite, generateDesignSketch, generateDesignSetup, generateDesignMockup, generateDesignShotgunLoop } from './design'; +import { generateDesignMethodology, generateDesignHardRules, generateDesignOutsideVoices, generateDesignReviewLite, generateDesignSketch, generateDesignSetup, generateDesignMockup, generateDesignShotgunLoop, generateTasteProfile } from './design'; import { generateTestBootstrap, generateTestCoverageAuditPlan, generateTestCoverageAuditShip, generateTestCoverageAuditReview } from './testing'; import { generateReviewDashboard, generatePlanFileReviewReport, generateSpecReviewLoop, generateBenefitsFrom, generateCodexSecondOpinion, generateAdversarialStep, generateCodexPlanReview, generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec, generateScopeDrift, generateCrossReviewDedup } from './review'; import { generateSlugEval, generateSlugSetup, generateBaseBranchDetect, generateDeployBootstrap, generateQAMethodology, generateCoAuthorTrailer, generateChangelogWorkflow } from './utility'; @@ -64,4 +64,6 @@ export const RESOLVERS: Record = { CROSS_REVIEW_DEDUP: generateCrossReviewDedup, DX_FRAMEWORK: generateDxFramework, MODEL_OVERLAY: generateModelOverlay, + TASTE_PROFILE: generateTasteProfile, + BIN_DIR: (ctx) => ctx.paths.binDir, };