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:
Garry Tan
2026-04-17 06:10:54 +08:00
parent c1d6fd8706
commit 9e95a9dc50
7 changed files with 466 additions and 13 deletions
+293
View File
@@ -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);
}
+48
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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:
+16 -6
View File
@@ -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:
+42
View File
@@ -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.`;
}
+3 -1
View File
@@ -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,
};