mirror of https://github.com/garrytan/gstack.git
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>
This commit is contained in:
parent
873fcccd74
commit
fa590c4f51
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
|
@ -187,6 +187,23 @@ export const SIGNAL_MAP: Record<string, Record<string, DimensionDelta[]>> = {
|
||||||
skip: [{ dim: 'architecture_care', delta: -0.04 }],
|
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
|
// session-mode — office-hours goal selection
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,7 @@ export const QUESTIONS = {
|
||||||
category: 'approval',
|
category: 'approval',
|
||||||
door_type: 'one-way',
|
door_type: 'one-way',
|
||||||
options: ['accept', 'reject'],
|
options: ['accept', 'reject'],
|
||||||
|
signal_key: 'decision-autonomy',
|
||||||
description: "Merge this PR to base branch?",
|
description: "Merge this PR to base branch?",
|
||||||
},
|
},
|
||||||
'land-and-deploy-rollback': {
|
'land-and-deploy-rollback': {
|
||||||
|
|
@ -463,6 +464,7 @@ export const QUESTIONS = {
|
||||||
category: 'approval',
|
category: 'approval',
|
||||||
door_type: 'one-way',
|
door_type: 'one-way',
|
||||||
options: ['accept', 'reject'],
|
options: ['accept', 'reject'],
|
||||||
|
signal_key: 'decision-autonomy',
|
||||||
description: "Canary detected regressions — roll back the deploy?",
|
description: "Canary detected regressions — roll back the deploy?",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<string, number>): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue