diff --git a/scripts/resolvers/gbrain.ts b/scripts/resolvers/gbrain.ts index cf6e6f791..78055bb70 100644 --- a/scripts/resolvers/gbrain.ts +++ b/scripts/resolvers/gbrain.ts @@ -6,11 +6,25 @@ * * These resolvers are suppressed on hosts that don't support brain features * (via suppressedResolvers in each host config). For those hosts, - * {{GBRAIN_CONTEXT_LOAD}} and {{GBRAIN_SAVE_RESULTS}} resolve to empty string. + * {{GBRAIN_CONTEXT_LOAD}}, {{GBRAIN_SAVE_RESULTS}}, {{BRAIN_PREFLIGHT}}, + * {{BRAIN_CACHE_REFRESH}}, and {{BRAIN_WRITE_BACK}} all resolve to empty string. * * Compatible with GBrain >= v0.10.0 (search CLI, doctor --fast --json, entity enrichment). + * + * Brain-aware planning (T4 / v1.48 plan): adds three new resolvers powered by + * the bin/gstack-brain-cache CLI and scripts/brain-cache-spec.ts. The new + * resolvers fire only for the 5 planning skills registered in + * SKILL_DIGEST_SUBSETS (office-hours, plan-ceo-review, plan-eng-review, + * plan-design-review, plan-devex-review). */ import type { TemplateContext } from './types'; +import { + SKILL_DIGEST_SUBSETS, + SKILL_CALIBRATION_WEIGHTS, + BRAIN_CACHE_ENTITIES, + getSkillSubset, + getInvalidationTargets, +} from '../brain-cache-spec'; export function generateGBrainContextLoad(ctx: TemplateContext): string { let base = `## Brain Context Load @@ -79,3 +93,155 @@ Add backlinks to related brain pages if they exist. If GBrain is not available, After brain operations complete, note in your completion output: how many pages were found in the initial search, how many entities were enriched, and whether any operations were throttled. This helps the user see brain utilization over time.`; } + +// ──────────────────────────────────────────────────────────────────── +// Brain-aware planning resolvers (T4 / v1.48 plan) +// ──────────────────────────────────────────────────────────────────── + +/** + * Returns true when this skill is registered for brain preflight. Skills not + * in SKILL_DIGEST_SUBSETS get an empty BRAIN_PREFLIGHT block (no behavior). + */ +function isPreflightSkill(skillName: string): boolean { + return Object.prototype.hasOwnProperty.call(SKILL_DIGEST_SUBSETS, skillName); +} + +/** + * Renders the per-skill BRAIN_PREFLIGHT block. The rendered output is a single + * bash script that: + * 1. Reads each digest file from gstack-brain-cache get (one call per digest) + * 2. Falls back to "(brain context unavailable)" on missing + * 3. Concatenates outputs into a single ## Brain Context block injected + * into the skill's prompt context + * 4. Tells the agent: "use this context to skip already-known questions" + * + * The cache CLI handles cold-refresh + lock dedup + stale-but-usable + * fallback internally. From the resolver's perspective the call is one + * shell command per digest. + */ +export function generateBrainPreflight(ctx: TemplateContext): string { + if (!isPreflightSkill(ctx.skillName)) return ''; + const subset = getSkillSubset(ctx.skillName); + const binDir = ctx.paths.binDir; + // Build the bash that loads each digest. Per-skill subset is small (2-5 entries). + const loadLines = subset.map((entityName) => { + const entity = BRAIN_CACHE_ENTITIES[entityName]; + if (!entity) return ''; + const projectFlag = entity.scope === 'per-project' ? '--project "$SLUG"' : ''; + return ` printf '\\n### %s\\n\\n' "${entityName}"\n ${binDir}/gstack-brain-cache get ${entityName} ${projectFlag} 2>/dev/null || printf '_(no ${entityName} digest available yet)_\\n'`; + }).join('\n'); + + return `## Brain Context (preflight) + +Before asking any clarifying questions, load the brain's structured context +for this project. The cache layer handles staleness, refresh, and stale-but- +usable fallback automatically. Skip questions whose answers are already +present in the loaded context; ground recommendations in what the brain +already knows about the user, the product, the goals, and recent decisions. + +\`\`\`bash +eval "$(${binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true +{ + printf '## Brain Context\\n\\n' +${loadLines} +} > /tmp/.gstack-brain-context-$$.md 2>/dev/null +[ -s /tmp/.gstack-brain-context-$$.md ] && cat /tmp/.gstack-brain-context-$$.md +rm -f /tmp/.gstack-brain-context-$$.md 2>/dev/null || true +\`\`\` + +**How to use this context:** +- If \`product\` digest names the value prop, target user, or stage — don't re-ask. +- If \`goals\` digest lists active goals — frame recommendations against them. +- If \`recent-decisions\` digest names a prior scope/architecture choice — flag if this plan contradicts. +- If \`user-profile\` digest carries calibration pattern statements ("tends to over-engineer security") — surface them when relevant. +- If a digest is \`(no X digest available yet)\`, treat that section as cold; ask the user. + +**Privacy:** Salience digest is filtered by allowlist (D9 default: \`projects/\`, +\`gstack/\`, \`concepts/\` only). Personal/family/therapy content never leaks here. +`; +} + +/** + * Renders the at-skill-end background refresh hook. Fires after the skill's + * own work completes (telemetry has already logged); kicks any digest whose + * age exceeds half its TTL but hasn't yet expired, so the NEXT invocation + * gets a fresh cache without paying the cold-miss tax. + * + * Subordinate to {{TELEMETRY}} — runs after. Doesn't block the user. + */ +export function generateBrainCacheRefresh(ctx: TemplateContext): string { + if (!isPreflightSkill(ctx.skillName)) return ''; + const binDir = ctx.paths.binDir; + return `## Brain Cache Background Refresh + +After the skill's work completes (and telemetry has logged), kick a +background refresh of any cache digest that's getting close to its TTL. +This is non-blocking — the user doesn't wait. Next invocation benefits +from the warm cache. + +\`\`\`bash +eval "$(${binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true +(${binDir}/gstack-brain-cache refresh --project "$SLUG" 2>/dev/null &) || true +\`\`\` +`; +} + +/** + * Renders the calibration write-back block. ONLY emits when the skill makes + * typed decisions worth a kind=bet take AND the brain trust policy is + * personal. Phase 2 / E5 cross-skill calibration. + * + * Gated behind BRAIN_CALIBRATION_WRITEBACK feature flag in the resolver + * output — the flag stays false until upstream gbrain ships takes_add MCP + * op (T8). When the flag flips, the existing skill templates pick up the + * write-back behavior without any template changes. + */ +export function generateBrainWriteBack(ctx: TemplateContext): string { + if (!isPreflightSkill(ctx.skillName)) return ''; + const weight = SKILL_CALIBRATION_WEIGHTS[ctx.skillName]; + if (weight == null) return ''; + // List the cache digests this skill's writes should invalidate. Multiple + // skills write to multiple entities; the invalidation map captures this. + const invalidatesEntities = getInvalidationTargets(`/${ctx.skillName}`); + const invalidateBash = invalidatesEntities + .map((e) => ` ${ctx.paths.binDir}/gstack-brain-cache invalidate ${e} --project "$SLUG" 2>/dev/null || true`) + .join('\n'); + + return `## Brain Calibration Write-Back (Phase 2 / gated) + +When the skill makes a typed prediction worth tracking (scope decision, +TTHW target, architectural bet, wedge commitment), it MAY write a +\`kind=bet\` take to the brain so a calibration profile builds over time. + +**Gated on two things:** +1. Brain trust policy for the active endpoint is \`personal\` (check via + \`${ctx.paths.binDir}/gstack-config get brain_trust_policy@\`). + Shared brains skip write-back to avoid polluting team calibration. +2. Feature flag \`BRAIN_CALIBRATION_WRITEBACK\` is set (today: false; flips + to true when upstream gbrain v0.42+ ships \`takes_add\` MCP op). + +When both gates pass, the write-back path uses \`mcp__gbrain__takes_add\` +to record a take with weight ${weight} (per SKILL_CALIBRATION_WEIGHTS). +If the MCP op is unavailable, fall back to \`mcp__gbrain__put_page\` with +a gstack:takes fence block (documented but uglier path). + +Mandatory take frontmatter shape: +\`\`\`yaml +kind: bet +holder: +claim: +weight: ${weight} +since_date: +expected_resolution: +source_skill: ${ctx.skillName} +\`\`\` + +After write, invalidate the affected digests so the next preflight reflects +the new state: + +\`\`\`bash +eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true +${invalidateBash || ' # (no per-skill invalidation targets configured)'} +\`\`\` +`; +} diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 6502960f9..16e16c05c 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -30,7 +30,7 @@ import { generateInvokeSkill } from './composition'; import { generateReviewArmy } from './review-army'; import { generateDxFramework } from './dx'; import { generateModelOverlay } from './model-overlay'; -import { generateGBrainContextLoad, generateGBrainSaveResults } from './gbrain'; +import { generateGBrainContextLoad, generateGBrainSaveResults, generateBrainPreflight, generateBrainCacheRefresh, generateBrainWriteBack } from './gbrain'; import { generateQuestionPreferenceCheck, generateQuestionLog, generateInlineTuneFeedback } from './question-tuning'; import { generateMakePdfSetup } from './make-pdf'; import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section'; @@ -86,6 +86,9 @@ export const RESOLVERS: Record = { BIN_DIR: (ctx) => ctx.paths.binDir, GBRAIN_CONTEXT_LOAD: generateGBrainContextLoad, GBRAIN_SAVE_RESULTS: generateGBrainSaveResults, + BRAIN_PREFLIGHT: generateBrainPreflight, + BRAIN_CACHE_REFRESH: generateBrainCacheRefresh, + BRAIN_WRITE_BACK: generateBrainWriteBack, QUESTION_PREFERENCE_CHECK: generateQuestionPreferenceCheck, QUESTION_LOG: generateQuestionLog, INLINE_TUNE_FEEDBACK: generateInlineTuneFeedback, diff --git a/test/brain-preflight.test.ts b/test/brain-preflight.test.ts new file mode 100644 index 000000000..a93a7d681 --- /dev/null +++ b/test/brain-preflight.test.ts @@ -0,0 +1,166 @@ +/** + * Brain-aware planning resolver tests (T4 / T19). + * + * Verifies the three resolvers in scripts/resolvers/gbrain.ts: + * - generateBrainPreflight — fires for preflight skills, empty for others + * - generateBrainCacheRefresh — same gating + * - generateBrainWriteBack — same gating; only weighted skills emit + * + * Gate-tier, free, pure import + render. + */ + +import { describe, test, expect } from 'bun:test'; +import { + generateBrainPreflight, + generateBrainCacheRefresh, + generateBrainWriteBack, +} from '../scripts/resolvers/gbrain'; +import { SKILL_DIGEST_SUBSETS } from '../scripts/brain-cache-spec'; +import { HOST_PATHS } from '../scripts/resolvers/types'; +import type { TemplateContext } from '../scripts/resolvers/types'; + +function buildCtx(skillName: string): TemplateContext { + return { + skillName, + tmplPath: `/tmp/${skillName}/SKILL.md.tmpl`, + host: 'claude', + paths: HOST_PATHS.claude, + }; +} + +describe('generateBrainPreflight', () => { + test('emits content for every registered preflight skill', () => { + for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) { + const out = generateBrainPreflight(buildCtx(skill)); + expect(out.length).toBeGreaterThan(0); + expect(out).toContain('## Brain Context'); + expect(out).toContain('gstack-brain-cache get'); + } + }); + + test('emits empty string for non-preflight skills (no behavior)', () => { + const nonPlanning = ['ship', 'qa', 'investigate', 'retro', 'design-review']; + for (const skill of nonPlanning) { + expect(generateBrainPreflight(buildCtx(skill))).toBe(''); + } + }); + + test('includes per-skill subset entities (office-hours loads 5 digests)', () => { + const out = generateBrainPreflight(buildCtx('office-hours')); + // office-hours loads: product, goals, user-profile, recent-decisions, salience + expect(out).toContain('product'); + expect(out).toContain('goals'); + expect(out).toContain('user-profile'); + expect(out).toContain('recent-decisions'); + expect(out).toContain('salience'); + }); + + test('plan-eng-review loads minimal subset (2 digests)', () => { + const out = generateBrainPreflight(buildCtx('plan-eng-review')); + expect(out).toContain('product'); + expect(out).toContain('recent-decisions'); + // Should NOT load brand or developer-persona + expect(out).not.toContain('gstack-brain-cache get brand'); + expect(out).not.toContain('gstack-brain-cache get developer-persona'); + }); + + test('mentions D9 salience privacy in the prose (transparency)', () => { + const out = generateBrainPreflight(buildCtx('office-hours')); + expect(out.toLowerCase()).toContain('privacy'); + expect(out.toLowerCase()).toContain('allowlist'); + }); + + test('user-profile is loaded WITHOUT --project flag (cross-project)', () => { + const out = generateBrainPreflight(buildCtx('office-hours')); + const userProfileLine = out.split('\n').find((l) => l.includes('user-profile')) || ''; + // user-profile is cross-project; the get call should NOT have --project + // (the only --project mentions on that line are inside the comment, not in the get call) + const getLine = out.split('\n').find((l) => l.includes('gstack-brain-cache get user-profile')) || ''; + expect(getLine).not.toContain('--project'); + }); + + test('per-project entities are loaded WITH --project "$SLUG"', () => { + const out = generateBrainPreflight(buildCtx('plan-eng-review')); + expect(out).toContain('--project "$SLUG"'); + }); +}); + +describe('generateBrainCacheRefresh', () => { + test('emits refresh hook for preflight skills', () => { + const out = generateBrainCacheRefresh(buildCtx('plan-ceo-review')); + expect(out).toContain('Background Refresh'); + expect(out).toContain('gstack-brain-cache refresh'); + }); + + test('empty for non-preflight skills', () => { + expect(generateBrainCacheRefresh(buildCtx('ship'))).toBe(''); + }); + + test('uses background backgrounding (does not block user)', () => { + const out = generateBrainCacheRefresh(buildCtx('plan-ceo-review')); + // Background refresh fires the cache refresh in a detached process + expect(out).toContain('&'); + }); +}); + +describe('generateBrainWriteBack', () => { + test('emits write-back block for all 5 weighted preflight skills', () => { + for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) { + const out = generateBrainWriteBack(buildCtx(skill)); + expect(out.length).toBeGreaterThan(0); + expect(out).toContain('Calibration Write-Back'); + expect(out).toContain('BRAIN_CALIBRATION_WRITEBACK'); + } + }); + + test('empty for non-preflight skills', () => { + expect(generateBrainWriteBack(buildCtx('ship'))).toBe(''); + }); + + test('includes per-skill calibration weight (E5)', () => { + const ceo = generateBrainWriteBack(buildCtx('plan-ceo-review')); + expect(ceo).toContain('weight: 0.8'); // SKILL_CALIBRATION_WEIGHTS['plan-ceo-review'] = 0.8 + + const office = generateBrainWriteBack(buildCtx('office-hours')); + expect(office).toContain('weight: 0.9'); // strongest calibration weight + + const design = generateBrainWriteBack(buildCtx('plan-design-review')); + expect(design).toContain('weight: 0.5'); // weakest (design predictions are noisy) + }); + + test('mentions personal trust policy gate (D11 codex tension)', () => { + const out = generateBrainWriteBack(buildCtx('plan-ceo-review')); + expect(out.toLowerCase()).toContain('personal'); + expect(out).toContain('brain_trust_policy'); + }); + + test('mentions fallback path when takes_add MCP op unavailable (upstream T8)', () => { + const out = generateBrainWriteBack(buildCtx('plan-ceo-review')); + expect(out).toContain('put_page'); + expect(out).toContain('takes'); + }); + + test('emits invalidation bash for affected cache digests', () => { + const out = generateBrainWriteBack(buildCtx('plan-ceo-review')); + // plan-ceo-review invalidates: product, goals, competitive-intel + expect(out).toContain('gstack-brain-cache invalidate'); + }); +}); + +describe('resolver registration in index.ts', () => { + test('BRAIN_PREFLIGHT placeholder is registered', async () => { + const { RESOLVERS } = await import('../scripts/resolvers/index'); + expect(RESOLVERS.BRAIN_PREFLIGHT).toBeDefined(); + expect(typeof RESOLVERS.BRAIN_PREFLIGHT).toBe('function'); + }); + + test('BRAIN_CACHE_REFRESH placeholder is registered', async () => { + const { RESOLVERS } = await import('../scripts/resolvers/index'); + expect(RESOLVERS.BRAIN_CACHE_REFRESH).toBeDefined(); + }); + + test('BRAIN_WRITE_BACK placeholder is registered', async () => { + const { RESOLVERS } = await import('../scripts/resolvers/index'); + expect(RESOLVERS.BRAIN_WRITE_BACK).toBeDefined(); + }); +});