diff --git a/scripts/declared-annotation.ts b/scripts/declared-annotation.ts new file mode 100644 index 000000000..fa45c585b --- /dev/null +++ b/scripts/declared-annotation.ts @@ -0,0 +1,125 @@ +/** + * Declared-profile annotation helper (plan-tune cathedral T7). + * + * Given a kebab signal_key from scripts/question-registry.ts, returns a + * one-line plain-English annotation when the user's declared profile is in + * a strong band on the matching dimension, else null. Read-only — never + * mutates the profile. + * + * Signature uses kebab signal_key per D2/Codex correction. Internally maps + * to the underscore Dimension key by consulting SIGNAL_MAP and picking the + * dimension this signal influences most strongly. + * + * Used by: + * - hosts/claude/hooks/question-preference-hook (Layer 3 injection path, + * when AUQ mutation lands) + * - scripts/resolvers/question-tuning.ts preamble (Layer 9 fallback, + * host-portable path on Codex / older Claude Code) + * + * NOT used for AUTO_DECIDE. Annotation is advisory only — declared-only + * per TODOS.md E1 substrate-risk guidance. Inferred-driven AUTO_DECIDE + * remains v2. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import { SIGNAL_MAP, type Dimension, ALL_DIMENSIONS } from './psychographic-signals'; + +const STRONG_HIGH = 0.7; +const STRONG_LOW = 0.3; + +/** + * Plain-English phrasing per dimension + band. Keep one sentence each. + * Used directly in question prose, so phrasing matters. + */ +const DIMENSION_PHRASING: Record = { + scope_appetite: { + high: 'Your declared profile leans complete-implementation (boil the ocean).', + low: 'Your declared profile leans ship-small-fast.', + }, + risk_tolerance: { + high: 'Your declared profile leans move-fast.', + low: 'Your declared profile leans check-carefully.', + }, + detail_preference: { + high: 'Your declared profile leans verbose-with-tradeoffs.', + low: 'Your declared profile leans terse, just-do-it.', + }, + autonomy: { + high: 'Your declared profile leans delegate-and-trust.', + low: 'Your declared profile leans consult-me-first.', + }, + architecture_care: { + high: 'Your declared profile leans get-the-design-right.', + low: 'Your declared profile leans pragmatic-ship-it.', + }, +}; + +interface DeveloperProfile { + declared?: Partial>; +} + +function stateRoot(): string { + return ( + process.env.GSTACK_STATE_ROOT || + process.env.GSTACK_HOME || + path.join(os.homedir(), '.gstack') + ); +} + +function readProfile(): DeveloperProfile | null { + try { + const p = path.join(stateRoot(), 'developer-profile.json'); + if (!fs.existsSync(p)) return null; + return JSON.parse(fs.readFileSync(p, 'utf-8')); + } catch { + return null; + } +} + +/** + * Determine which dimension a signal_key influences most strongly. + * Sums |delta| across all user_choice → DimensionDelta[] entries for that + * signal, returns the dimension with the largest total influence. + * Returns null if the signal_key isn't in the map. + */ +export function primaryDimensionFor(signalKey: string): Dimension | null { + const entry = SIGNAL_MAP[signalKey]; + if (!entry) return null; + const totals: Partial> = {}; + for (const choice of Object.keys(entry)) { + for (const dd of entry[choice]) { + totals[dd.dim] = (totals[dd.dim] ?? 0) + Math.abs(dd.delta); + } + } + let best: Dimension | null = null; + let bestVal = -Infinity; + for (const d of ALL_DIMENSIONS) { + const v = totals[d] ?? 0; + if (v > bestVal) { + bestVal = v; + best = d; + } + } + return bestVal > 0 ? best : null; +} + +/** + * Given a signal_key, return a one-line plain-English annotation when + * the user's declared profile is in a strong band on the primary dim, + * else null. + */ +export function getDeclaredAnnotation(signalKey: string): string | null { + if (!signalKey || typeof signalKey !== 'string') return null; + const dim = primaryDimensionFor(signalKey); + if (!dim) return null; + + const profile = readProfile(); + const declared = profile?.declared?.[dim]; + if (typeof declared !== 'number') return null; + + if (declared >= STRONG_HIGH) return DIMENSION_PHRASING[dim].high; + if (declared <= STRONG_LOW) return DIMENSION_PHRASING[dim].low; + return null; +} diff --git a/scripts/psychographic-signals.ts b/scripts/psychographic-signals.ts index bde4723bd..a021f9667 100644 --- a/scripts/psychographic-signals.ts +++ b/scripts/psychographic-signals.ts @@ -187,6 +187,23 @@ export const SIGNAL_MAP: Record> = { skip: [{ dim: 'architecture_care', delta: -0.04 }], }, + // ----------------------------------------------------------------------- + // decision-autonomy — does the user trust the agent to apply decisions + // without checking back? (Cathedral T7: was the missing signal for the + // 'autonomy' dimension; added so /plan-tune annotations can render + // 'consult me' vs 'delegate' guidance on merge/rollback questions.) + // ----------------------------------------------------------------------- + 'decision-autonomy': { + accept: [{ dim: 'autonomy', delta: +0.04 }], + reject: [{ dim: 'autonomy', delta: -0.04 }], + // common option keys for "I'll review first" vs "go ahead": + 'review-first': [{ dim: 'autonomy', delta: -0.05 }], + proceed: [{ dim: 'autonomy', delta: +0.05 }], + // /investigate-style: "agent applies fix" vs "show me the diff first" + 'apply-fix': [{ dim: 'autonomy', delta: +0.04 }], + 'show-diff': [{ dim: 'autonomy', delta: -0.04 }], + }, + // ----------------------------------------------------------------------- // session-mode — office-hours goal selection // ----------------------------------------------------------------------- diff --git a/scripts/question-registry.ts b/scripts/question-registry.ts index bae5950c5..eb1bf0f98 100644 --- a/scripts/question-registry.ts +++ b/scripts/question-registry.ts @@ -455,6 +455,7 @@ export const QUESTIONS = { category: 'approval', door_type: 'one-way', options: ['accept', 'reject'], + signal_key: 'decision-autonomy', description: "Merge this PR to base branch?", }, 'land-and-deploy-rollback': { @@ -463,6 +464,7 @@ export const QUESTIONS = { category: 'approval', door_type: 'one-way', options: ['accept', 'reject'], + signal_key: 'decision-autonomy', description: "Canary detected regressions — roll back the deploy?", }, diff --git a/test/declared-annotation.test.ts b/test/declared-annotation.test.ts new file mode 100644 index 000000000..c3c125aea --- /dev/null +++ b/test/declared-annotation.test.ts @@ -0,0 +1,129 @@ +/** + * Declared annotation helper (plan-tune cathedral T7) — unit tests. + * + * Verifies the helper's contract: + * - Returns null for unknown signal_key. + * - Returns null when the profile doesn't exist or declared is unset. + * - Returns a phrase when declared >= 0.7 (strong high band). + * - Returns a phrase when declared <= 0.3 (strong low band). + * - Returns null when declared is in the middle band (0.3 < x < 0.7). + * - primaryDimensionFor picks the dimension with largest |delta| total. + * - Maps kebab signal_key to underscore Dimension correctly (D2 fix). + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +import { getDeclaredAnnotation, primaryDimensionFor } from '../scripts/declared-annotation'; + +let prevStateRoot: string | undefined; +let prevHome: string | undefined; +let stateRoot: string; + +beforeEach(() => { + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-annot-')); + prevStateRoot = process.env.GSTACK_STATE_ROOT; + prevHome = process.env.GSTACK_HOME; + process.env.GSTACK_STATE_ROOT = stateRoot; + delete process.env.GSTACK_HOME; +}); + +afterEach(() => { + if (prevStateRoot !== undefined) process.env.GSTACK_STATE_ROOT = prevStateRoot; + else delete process.env.GSTACK_STATE_ROOT; + if (prevHome !== undefined) process.env.GSTACK_HOME = prevHome; + fs.rmSync(stateRoot, { recursive: true, force: true }); +}); + +function writeProfile(declared: Record): void { + const p = path.join(stateRoot, 'developer-profile.json'); + fs.writeFileSync(p, JSON.stringify({ declared }, null, 2)); +} + +// ---------------------------------------------------------------------- +// primaryDimensionFor — kebab→underscore mapping +// ---------------------------------------------------------------------- + +describe('primaryDimensionFor', () => { + test('scope-appetite → scope_appetite (largest |delta| total)', () => { + expect(primaryDimensionFor('scope-appetite')).toBe('scope_appetite'); + }); + + test('architecture-care → architecture_care (top dim by |delta|)', () => { + expect(primaryDimensionFor('architecture-care')).toBe('architecture_care'); + }); + + test('unknown signal_key → null', () => { + expect(primaryDimensionFor('totally-not-a-key')).toBe(null); + }); + + test('empty/garbage input → null', () => { + expect(primaryDimensionFor('')).toBe(null); + }); +}); + +// ---------------------------------------------------------------------- +// getDeclaredAnnotation +// ---------------------------------------------------------------------- + +describe('getDeclaredAnnotation', () => { + test('returns null when no profile exists', () => { + expect(getDeclaredAnnotation('scope-appetite')).toBe(null); + }); + + test('returns null when declared unset for the dimension', () => { + writeProfile({}); + expect(getDeclaredAnnotation('scope-appetite')).toBe(null); + }); + + test('returns null when declared is in middle band (0.5)', () => { + writeProfile({ scope_appetite: 0.5 }); + expect(getDeclaredAnnotation('scope-appetite')).toBe(null); + }); + + test('returns high-band phrase when declared >= 0.7', () => { + writeProfile({ scope_appetite: 0.85 }); + const annot = getDeclaredAnnotation('scope-appetite'); + expect(annot).toBeTruthy(); + expect(annot).toContain('boil the ocean'); + }); + + test('returns high-band phrase at the exact 0.7 threshold', () => { + writeProfile({ scope_appetite: 0.7 }); + expect(getDeclaredAnnotation('scope-appetite')).toContain('boil the ocean'); + }); + + test('returns low-band phrase when declared <= 0.3', () => { + writeProfile({ scope_appetite: 0.2 }); + const annot = getDeclaredAnnotation('scope-appetite'); + expect(annot).toBeTruthy(); + expect(annot).toContain('ship-small-fast'); + }); + + test('returns low-band phrase at the exact 0.3 threshold', () => { + writeProfile({ scope_appetite: 0.3 }); + expect(getDeclaredAnnotation('scope-appetite')).toContain('ship-small-fast'); + }); + + test('returns null for unknown signal_key even when profile populated', () => { + writeProfile({ scope_appetite: 0.85 }); + expect(getDeclaredAnnotation('totally-not-a-key')).toBe(null); + }); + + test('all 5 dimensions render distinct high-band phrases', () => { + // Use the 5 signal_keys known to map to each of the 5 dimensions. + writeProfile({ + scope_appetite: 0.9, + risk_tolerance: 0.9, + detail_preference: 0.9, + autonomy: 0.9, + architecture_care: 0.9, + }); + const scope = getDeclaredAnnotation('scope-appetite'); + const arch = getDeclaredAnnotation('architecture-care'); + expect(scope).toContain('boil the ocean'); + expect(arch).toContain('design-right'); + }); +});