Skill Tracer
Skills › gstack › gstack › psychographic-signals.ts

psychographic-signals.ts

372 lines · scripts/psychographic-signals.ts at 3f79d81

1/**
2 * Psychographic Signal Map — hand-crafted {question_id, user_choice} → {dimension, delta}.
3 *
4 * Consumed in v1 ONLY to compute inferred dimension values for /plan-tune
5 * inspection output. No skill behavior adapts to these signals in v1.
6 *
7 * When v2 wires 5 skills to consume the profile, this map is the source of
8 * truth for how behavior influences dimensions. Calibration deltas in v1 are
9 * best-guess starting points; v2 recalibrates from real observed data.
10 *
11 * Design principles
12 * -----------------
13 * 1. Hand-crafted, not agent-inferred (Codex #4, user Decision C).
14 * Every mapping is explicit TypeScript — no runtime NL interpretation.
15 *
16 * 2. Small, conservative deltas (±0.03 to ±0.06 typical).
17 * A single answer should nudge the profile, not reshape it. Repeated
18 * answers across sessions accumulate.
19 *
20 * 3. Tied to registry signal_key.
21 * Each entry in this map corresponds to a signal_key declared in
22 * scripts/question-registry.ts. The derivation pipeline uses the
23 * question's signal_key + user_choice as the lookup key.
24 *
25 * 4. Not every question contributes to every dimension.
26 * Many questions have no signal_key — they're logged but don't move
27 * the psychographic. Only questions that genuinely reveal preference
28 * get a signal_key.
29 *
30 * Dimensions
31 * ----------
32 * scope_appetite: 0 = small-scope, ship fast ↔ 1 = boil the ocean
33 * risk_tolerance: 0 = conservative, ask first ↔ 1 = move fast, auto-decide
34 * detail_preference: 0 = terse, just do it ↔ 1 = verbose, explain everything
35 * autonomy: 0 = hands-on, consult me ↔ 1 = delegate, trust the agent
36 * architecture_care: 0 = pragmatic, ship it ↔ 1 = principled, get it right
37 */
39import { QUESTIONS, type QuestionDef } from './question-registry';
41/** The 5 dimensions of the developer psychographic. */
42export type Dimension =
43 | 'scope_appetite'
44 | 'risk_tolerance'
45 | 'detail_preference'
46 | 'autonomy'
47 | 'architecture_care';
49export const ALL_DIMENSIONS: readonly Dimension[] = [
50 'scope_appetite',
51 'risk_tolerance',
52 'detail_preference',
53 'autonomy',
54 'architecture_care',
55] as const;
57/**
58 * Semantic version of the signal map. Increment when deltas change so that
59 * cached profiles can detect staleness and recompute from events.
60 */
61export const SIGNAL_MAP_VERSION = '0.1.0';
63export interface DimensionDelta {
64 dim: Dimension;
65 delta: number;
68/**
69 * Signal map: signal_key → user_choice → list of dimension nudges.
70 *
71 * Indexed by signal_key (declared in question-registry entries), not
72 * question_id directly. This lets multiple questions share a semantic
73 * pattern (e.g., scope-appetite signal comes from both plan-ceo-review
74 * expansion proposals AND office-hours approach selection).
75 */
76export const SIGNAL_MAP: Record<string, Record<string, DimensionDelta[]>> = {
77 // -----------------------------------------------------------------------
78 // scope-appetite — how much the user likes to expand scope
79 // -----------------------------------------------------------------------
80 'scope-appetite': {
81 // plan-ceo-review mode choice
82 expand: [{ dim: 'scope_appetite', delta: +0.06 }],
83 selective: [{ dim: 'scope_appetite', delta: +0.03 }],
84 hold: [{ dim: 'scope_appetite', delta: -0.01 }],
85 reduce: [{ dim: 'scope_appetite', delta: -0.06 }],
86 // plan-ceo-review expansion proposal accepted/deferred/skipped
87 accept: [{ dim: 'scope_appetite', delta: +0.04 }],
88 defer: [{ dim: 'scope_appetite', delta: -0.01 }],
89 skip: [{ dim: 'scope_appetite', delta: -0.03 }],
90 // office-hours approach choice
91 minimal: [{ dim: 'scope_appetite', delta: -0.04 }],
92 ideal: [{ dim: 'scope_appetite', delta: +0.05 }],
93 creative: [{ dim: 'scope_appetite', delta: +0.02 }],
94 },
96 // -----------------------------------------------------------------------
97 // architecture-care — how much the user sweats the details
98 // -----------------------------------------------------------------------
99 'architecture-care': {
100 'fix-now': [
101 { dim: 'architecture_care', delta: +0.05 },
102 { dim: 'risk_tolerance', delta: -0.02 },
103 ],
104 defer: [{ dim: 'architecture_care', delta: -0.02 }],
105 'accept-risk': [
106 { dim: 'architecture_care', delta: -0.04 },
107 { dim: 'risk_tolerance', delta: +0.04 },
108 ],
109 },
111 // -----------------------------------------------------------------------
112 // code-quality-care — proxies detail_preference + architecture_care
113 // -----------------------------------------------------------------------
114 'code-quality-care': {
115 'fix-now': [
116 { dim: 'detail_preference', delta: +0.02 },
117 { dim: 'architecture_care', delta: +0.03 },
118 ],
119 'ack-and-ship': [
120 { dim: 'risk_tolerance', delta: +0.03 },
121 { dim: 'architecture_care', delta: -0.02 },
122 ],
123 'false-positive': [{ dim: 'architecture_care', delta: +0.01 }],
124 defer: [{ dim: 'architecture_care', delta: -0.02 }],
125 skip: [{ dim: 'detail_preference', delta: -0.03 }],
126 },
128 // -----------------------------------------------------------------------
129 // test-discipline — proxies architecture_care + detail_preference
130 // -----------------------------------------------------------------------
131 'test-discipline': {
132 'fix-now': [
133 { dim: 'architecture_care', delta: +0.04 },
134 { dim: 'detail_preference', delta: +0.02 },
135 ],
136 investigate: [{ dim: 'architecture_care', delta: +0.02 }],
137 'ack-and-ship': [
138 { dim: 'risk_tolerance', delta: +0.04 },
139 { dim: 'architecture_care', delta: -0.03 },
140 ],
141 'add-test': [
142 { dim: 'architecture_care', delta: +0.03 },
143 { dim: 'detail_preference', delta: +0.02 },
144 ],
145 defer: [{ dim: 'architecture_care', delta: -0.01 }],
146 skip: [{ dim: 'architecture_care', delta: -0.04 }],
147 },
149 // -----------------------------------------------------------------------
150 // detail-preference — direct signal for verbosity
151 // -----------------------------------------------------------------------
152 'detail-preference': {
153 accept: [{ dim: 'detail_preference', delta: +0.03 }],
154 skip: [{ dim: 'detail_preference', delta: -0.03 }],
155 },
157 // -----------------------------------------------------------------------
158 // design-care — proxies architecture_care for UI-facing work
159 // -----------------------------------------------------------------------
160 'design-care': {
161 expand: [{ dim: 'architecture_care', delta: +0.04 }],
162 polish: [{ dim: 'architecture_care', delta: +0.02 }],
163 triage: [{ dim: 'architecture_care', delta: -0.02 }],
164 'fix-now': [{ dim: 'architecture_care', delta: +0.02 }],
165 defer: [{ dim: 'architecture_care', delta: -0.01 }],
166 skip: [{ dim: 'architecture_care', delta: -0.03 }],
167 },
169 // -----------------------------------------------------------------------
170 // devex-care — DX is UX for developers; proxies architecture_care
171 // -----------------------------------------------------------------------
172 'devex-care': {
173 expand: [{ dim: 'architecture_care', delta: +0.04 }],
174 polish: [{ dim: 'architecture_care', delta: +0.02 }],
175 triage: [{ dim: 'architecture_care', delta: -0.02 }],
176 'fix-now': [{ dim: 'architecture_care', delta: +0.02 }],
177 defer: [{ dim: 'architecture_care', delta: -0.01 }],
178 skip: [{ dim: 'architecture_care', delta: -0.03 }],
179 },
181 // -----------------------------------------------------------------------
182 // distribution-care — does the user care about how code reaches users?
183 // -----------------------------------------------------------------------
184 'distribution-care': {
185 accept: [{ dim: 'architecture_care', delta: +0.03 }],
186 defer: [{ dim: 'architecture_care', delta: -0.02 }],
187 skip: [{ dim: 'architecture_care', delta: -0.04 }],
188 },
190 // -----------------------------------------------------------------------
191 // decision-autonomy — does the user trust the agent to apply decisions
192 // without checking back? (Cathedral T7: was the missing signal for the
193 // 'autonomy' dimension; added so /plan-tune annotations can render
194 // 'consult me' vs 'delegate' guidance on merge/rollback questions.)
195 // -----------------------------------------------------------------------
196 'decision-autonomy': {
197 accept: [{ dim: 'autonomy', delta: +0.04 }],
198 reject: [{ dim: 'autonomy', delta: -0.04 }],
199 // common option keys for "I'll review first" vs "go ahead":
200 'review-first': [{ dim: 'autonomy', delta: -0.05 }],
201 proceed: [{ dim: 'autonomy', delta: +0.05 }],
202 // /investigate-style: "agent applies fix" vs "show me the diff first"
203 'apply-fix': [{ dim: 'autonomy', delta: +0.04 }],
204 'show-diff': [{ dim: 'autonomy', delta: -0.04 }],
205 },
207 // -----------------------------------------------------------------------
208 // session-mode — office-hours goal selection
209 // -----------------------------------------------------------------------
210 'session-mode': {
211 startup: [
212 { dim: 'scope_appetite', delta: +0.02 },
213 { dim: 'architecture_care', delta: +0.02 },
214 ],
215 intrapreneur: [{ dim: 'scope_appetite', delta: +0.02 }],
216 hackathon: [
217 { dim: 'risk_tolerance', delta: +0.03 },
218 { dim: 'architecture_care', delta: -0.02 },
219 ],
220 'oss-research': [{ dim: 'architecture_care', delta: +0.02 }],
221 learning: [{ dim: 'detail_preference', delta: +0.02 }],
222 fun: [{ dim: 'risk_tolerance', delta: +0.02 }],
223 },
224};
226/**
227 * Label cues: option key → leading words of UI labels that mean that key.
228 *
229 * The AskUserQuestion hook only sees the label the user clicked ("A) Fix it
230 * now (recommended)"), never a stable key, while SIGNAL_MAP is keyed by the
231 * registry's option keys (#2623). resolveOptionKey() maps a label back to a
232 * key when bin/gstack-question-log records a hook event. Every key also
233 * matches its own words ("fix-now" → "fix now"), so only phrasings beyond
234 * that need an entry here. Cues are
235 * lowercase words separated by single spaces and match at the start of the
236 * normalized label; candidates are limited to the question's own registered
237 * options, and the longest cue wins.
238 */
239const OPTION_LABEL_CUES: Record<string, readonly string[]> = {
240 accept: ['yes', 'approve', 'agree', 'add', 'include', 'apply', 'adopt', 'enable', 'go ahead', 'do it', 'proceed', 'run', 'create', 'merge', 'roll back', 'migrate', 'open', 'promote', 'reorganize', 'polish'],
241 reject: ['no', 'disagree', 'decline', 'cancel', 'abort', 'dont', 'do not', 'keep current', 'keep as is', 'keep it as is'],
242 skip: ['no', 'no thanks', 'not now', 'dont', 'do not', 'pass', 'drop', 'ignore', 'leave it', 'ship as is', 'ship it as is'],
243 defer: ['later', 'fix later', 'add to todos', 'add to todo', 'add a todo', 'todo', 'postpone', 'backlog', 'park', 'track it'],
244 'fix-now': ['fix', 'fix it', 'fix them', 'fix before shipping'],
245 'fix-anyway': ['fix', 'fix it'],
246 investigate: ['dig in', 'look into', 'debug', 'diagnose', 'root cause'],
247 'ack-and-ship': ['ack', 'acknowledge', 'ship', 'ship anyway', 'ship it'],
248 'accept-risk': ['accept', 'accept the risk', 'live with it'],
249 'false-positive': ['not a bug', 'mark false positive', 'mark as false positive', 'mark it false positive'],
250 'add-test': ['add', 'add tests', 'add the test', 'write', 'write the test', 'test it'],
251 expand: ['scope expansion'],
252 selective: ['cherry pick', 'selective expansion'],
253 hold: ['keep', 'keep scope', 'hold scope'],
254 reduce: ['cut', 'trim', 'shrink', 'scope down', 'scope reduction'],
255 revise: ['edit', 'change', 'rework'],
256 restart: ['start over', 'redo'],
257 'build-now': ['build', 'build it now', 'include in this pr'],
258 'keep-local': ['keep'],
259 'keep-vendored': ['keep'],
260 'yes-upgrade': ['yes', 'upgrade'],
261 'always-auto': ['always'],
262 'not-now': ['later', 'snooze'],
263 'never-ask': ['never', 'disable'],
264};
266function normalizeOptionLabel(label: string): string {
267 return label
268 .toLowerCase()
269 .replace(/\s*\(recommended\)\s*$/, '')
270 .replace(/['\u2019]/g, '')
271 .replace(/^\s*(?:option\s+)?[a-z0-9]\s*(?:[).:]|-(?=\s))\s+/, '')
272 .replace(/[^a-z0-9]+/g, ' ')
273 .trim();
276/**
277 * Map a chosen UI label to the registered option key of its question.
278 *
279 * Returns the key when exactly one of the question's registered options
280 * matches with the longest cue; undefined for unregistered ids, questions
281 * without options, unmatched labels and ties, so an unclear answer stays
282 * unmapped instead of nudging the wrong dimension.
283 */
284export function resolveOptionKey(question_id: string, choice: string): string | undefined {
285 const options = (QUESTIONS as Record<string, QuestionDef>)[question_id]?.options as string[] | undefined;
286 if (!options?.length) return undefined;
287 if (options.includes(choice)) return choice;
288 const label = normalizeOptionLabel(choice);
289 if (!label) return undefined;
290 let best: string | undefined;
291 let bestWords = 0;
292 let tie = false;
293 for (const key of options) {
294 for (const cue of [key.replace(/-/g, ' '), ...(OPTION_LABEL_CUES[key] ?? [])]) {
295 if (label !== cue && !label.startsWith(cue + ' ')) continue;
296 const words = cue.split(' ').length;
297 if (words > bestWords) {
298 best = key;
299 bestWords = words;
300 tie = false;
301 } else if (words === bestWords && best !== key) {
302 tie = true;
303 }
304 }
305 }
306 return tie ? undefined : best;
309/**
310 * Apply a user choice for a question to the running dimension totals.
311 *
312 * @param dims - running total of dimension nudges (mutated)
313 * @param signal_key - from the question registry entry
314 * @param user_choice - the option key the user selected
315 * @returns list of dimension deltas applied (empty if no mapping)
316 */
317export function applySignal(
318 dims: Record<Dimension, number>,
319 signal_key: string,
320 user_choice: string,
321): DimensionDelta[] {
322 const subMap = SIGNAL_MAP[signal_key];
323 if (!subMap) return [];
324 const deltas = subMap[user_choice];
325 if (!deltas) return [];
326 for (const { dim, delta } of deltas) {
327 dims[dim] = (dims[dim] ?? 0) + delta;
328 }
329 return deltas;
332/**
333 * Validate that every signal_key referenced in the registry has a matching
334 * entry in SIGNAL_MAP. Called by tests to catch drift.
335 */
336export function validateRegistrySignalKeys(): {
337 missing: string[];
338 extra: string[];
339} {
340 const registrySignalKeys = new Set<string>();
341 for (const q of Object.values(QUESTIONS) as QuestionDef[]) {
342 if (q.signal_key) registrySignalKeys.add(q.signal_key);
343 }
344 const mapKeys = new Set(Object.keys(SIGNAL_MAP));
345 const missing: string[] = [];
346 const extra: string[] = [];
347 for (const k of registrySignalKeys) {
348 if (!mapKeys.has(k)) missing.push(k);
349 }
350 for (const k of mapKeys) {
351 if (!registrySignalKeys.has(k)) extra.push(k);
352 }
353 return { missing, extra };
356/** Empty dimension totals — starting point for derivation. */
357export function newDimensionTotals(): Record<Dimension, number> {
358 return {
359 scope_appetite: 0,
360 risk_tolerance: 0,
361 detail_preference: 0,
362 autonomy: 0,
363 architecture_care: 0,
364 };
367/** Sigmoid clamp: map accumulated delta total to [0, 1]. */
368export function normalizeToDimensionValue(total: number): number {
369 // Simple sigmoid: each 1.0 of accumulated delta approaches saturation.
370 // 0.5 is neutral. Positive deltas push toward 1, negative toward 0.
371 return 1 / (1 + Math.exp(-total * 3));