mirror of https://github.com/garrytan/gstack.git
feat(brain): brain-aware planning resolvers + 3 new placeholders (T4)
scripts/resolvers/gbrain.ts adds:
- generateBrainPreflight(ctx) — emits per-skill ## Brain Context
block + bash that loads digests via
gstack-brain-cache get (one call per
digest). Per-skill subset comes from
SKILL_DIGEST_SUBSETS (single source).
- generateBrainCacheRefresh(ctx) — at-skill-end background refresh hook;
non-blocking; warms cache for next run.
- generateBrainWriteBack(ctx) — Phase 2 / E5 calibration write-back
with per-skill weight. Gated on
personal trust policy + the
BRAIN_CALIBRATION_WRITEBACK flag.
Includes invalidation bash that busts
affected digests after the write.
scripts/resolvers/index.ts registers three new placeholders:
{{BRAIN_PREFLIGHT}}, {{BRAIN_CACHE_REFRESH}}, {{BRAIN_WRITE_BACK}}
All three resolvers return empty string for skills not in
SKILL_DIGEST_SUBSETS (defensive — skill template authors can drop the
placeholders into non-preflight skills with zero effect).
D9 privacy is mentioned in the rendered preflight prose so the agent
knows to expect filtered salience.
D11 codex tension: write-back gates on brain_trust_policy@<hash> being
personal — shared brains skip write-back to avoid polluting team
calibration profile.
test/brain-preflight.test.ts: 19 tests covering subset rendering,
non-preflight skill gating, cross-project vs per-project --project flag
emission, weight injection per skill, BRAIN_CALIBRATION_WRITEBACK flag
mention, and registration in RESOLVERS map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c9f1c2f8d
commit
8f65b862a1
|
|
@ -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@<endpoint-hash>\`).
|
||||
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: <user identity from whoami>
|
||||
claim: <one-line prediction the skill is making>
|
||||
weight: ${weight}
|
||||
since_date: <today's date>
|
||||
expected_resolution: <date in 1-3 months depending on skill>
|
||||
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)'}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, ResolverValue> = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue