mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat: design taste engine with persistent schema
Adds a cross-session taste profile that learns from design-shotgun
approval/rejection decisions. Biases future design-consultation and
design-shotgun proposals toward the user's demonstrated preferences.
Codex review caught that the plan had "taste engine" as a vague goal
without schema, decay, migration, or placeholder insertion points. This
commit ships the full spec.
Schema v1 at ~/.gstack/projects/$SLUG/taste-profile.json:
- version, updated_at
- dimensions: fonts, colors, layouts, aesthetics — each with approved[]
and rejected[] preference lists
- sessions: last 50 (FIFO truncation), each with ts/action/variant/reason
- Preference: { value, confidence, approved_count, rejected_count, last_seen }
- Confidence: Laplace-smoothed approved/(total+1)
- Decay: 5% per week of inactivity, computed at read time (not write)
Changes:
- bin/gstack-taste-update: new CLI. Subcommands approved/rejected/show/
migrate. Parses reason string for dimension signals (e.g.,
"fonts: Geist; colors: slate; aesthetics: minimal"). Emits taste-drift
NOTE when a new signal contradicts a strong opposing signal. Legacy
approved.json aggregates migrate to v1 on next write.
- scripts/resolvers/design.ts: new generateTasteProfile() resolver.
Produces the prose that skills see: how to read the profile, how to
factor into proposals, conflict handling, schema migration.
- scripts/resolvers/index.ts: register TASTE_PROFILE and a BIN_DIR
resolver (returns ctx.paths.binDir, used by templates that shell out
to gstack-* binaries).
- design-consultation/SKILL.md.tmpl: insert {{TASTE_PROFILE}} placeholder
in Phase 1 right after the memorable-thing forcing question so the
Phase 3 proposal can factor in learned preferences.
- design-shotgun/SKILL.md.tmpl: taste memory section now reads
taste-profile.json via {{TASTE_PROFILE}}, falls back to per-session
approved.json (legacy). Approval flow documented to call
gstack-taste-update after user picks/rejects a variant.
Known gap: v1 extracts dimension signals from a reason string passed
by the caller ("fonts: X; colors: Y"). Future v2 can read EXIF or an
accompanying manifest written by design-shotgun alongside each variant
for automatic dimension extraction without needing the reason argument.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+293
@@ -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 <variant-path> [--reason "<why>"]
|
||||
// gstack-taste-update rejected <variant-path> [--reason "<why>"]
|
||||
// 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": "<ISO 8601>",
|
||||
// "dimensions": {
|
||||
// "fonts": { "approved": [...], "rejected": [...] },
|
||||
// "colors": { "approved": [...], "rejected": [...] },
|
||||
// "layouts": { "approved": [...], "rejected": [...] },
|
||||
// "aesthetics": { "approved": [...], "rejected": [...] }
|
||||
// },
|
||||
// "sessions": [ // last 50 only — truncated via decay
|
||||
// { "ts": "<ISO>", "action": "approved"|"rejected", "variant": "<path>", "reason": "<optional>" }
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// 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<Dimension, { approved: Preference[]; rejected: Preference[] }>;
|
||||
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<string, unknown>;
|
||||
// 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<string, unknown>)[dim];
|
||||
if (src && typeof src === 'object') {
|
||||
const ss = src as Record<string, unknown>;
|
||||
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<Record<Dimension, string[]>> {
|
||||
if (!reason) return {};
|
||||
const out: Partial<Record<Dimension, string[]>> = {};
|
||||
// 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} <variant-path> [--reason "<why>"]`);
|
||||
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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+54
-6
@@ -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 <variant-path>`. When they
|
||||
explicitly reject a variant, call `~/.claude/skills/gstack/bin/gstack-taste-update rejected <variant-path>`.
|
||||
The CLI handles schema migration from approved.json, decay, and conflict flagging.
|
||||
|
||||
## Step 3: Generate Variants
|
||||
|
||||
Set up the output directory:
|
||||
|
||||
@@ -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 <variant-path>`. When they
|
||||
explicitly reject a variant, call `{{BIN_DIR}}/gstack-taste-update rejected <variant-path>`.
|
||||
The CLI handles schema migration from approved.json, decay, and conflict flagging.
|
||||
|
||||
## Step 3: Generate Variants
|
||||
|
||||
Set up the output directory:
|
||||
|
||||
@@ -948,3 +948,45 @@ echo '{"approved_variant":"<V>","feedback":"<FB>","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.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, ResolverFn> = {
|
||||
CROSS_REVIEW_DEDUP: generateCrossReviewDedup,
|
||||
DX_FRAMEWORK: generateDxFramework,
|
||||
MODEL_OVERLAY: generateModelOverlay,
|
||||
TASTE_PROFILE: generateTasteProfile,
|
||||
BIN_DIR: (ctx) => ctx.paths.binDir,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user