Files
gstack/scripts/declared-annotation.ts
Garry Tan fa590c4f51 feat(scripts): declared-annotation helper + autonomy signal_key wiring
Plan-tune cathedral T7. Adds the helper that lets skills inject one-line
plain-English annotations on AUQ recommendations based on the user's
declared profile — read-only, advisory-only, per TODOS.md E1 substrate-risk
guidance (no AUTO_DECIDE off inferred).

scripts/declared-annotation.ts
- getDeclaredAnnotation(signal_key) → annotation | null
- primaryDimensionFor(signal_key) → Dimension | null
- Signature uses kebab signal_key per D2/Codex correction (registry uses
  hyphens; profile dimensions use underscores; helper maps internally).
- Bands: >= 0.7 high, <= 0.3 low, else null. Middle band stays silent.
- Per-dimension plain-English phrasing: 5 dimensions × 2 bands = 10 phrases.
- Reads ~/.gstack/developer-profile.json (honors GSTACK_STATE_ROOT).

scripts/psychographic-signals.ts
- New signal_key 'decision-autonomy' that maps user_choice → autonomy
  dimension nudges. This was the missing signal for the 'autonomy'
  dimension — without it, the cathedral could annotate four of five
  declared dimensions but autonomy stayed silent.

scripts/question-registry.ts
- Add signal_key: 'decision-autonomy' to land-and-deploy-merge-confirm
  and land-and-deploy-rollback. These are the highest-leverage autonomy
  questions in the surface — "let me decide" vs "go ahead" is exactly
  what the dimension captures.

13 unit tests cover the helper's full contract (unknown keys, missing
profile, middle-band null, both band thresholds, all five dimensions
rendering distinct phrases). Existing 47 plan-tune.test.ts tests still
pass after the registry + signal-map enrichment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:45:28 -07:00

126 lines
4.0 KiB
TypeScript

/**
* 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<Dimension, { high: string; low: string }> = {
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<Record<Dimension, number>>;
}
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<Record<Dimension, number>> = {};
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;
}