feat(brain): brain-cache-spec.ts — single source of truth for cache layer

Foundation for the brain-aware planning skills work (v1.48 plan / D2).
One TS const file consolidates BRAIN_CACHE_ENTITIES (8 entities × TTL +
budget + invalidation rules), SKILL_DIGEST_SUBSETS (per-skill which
files to load), SALIENCE_DEFAULT_ALLOWLIST (D9 privacy gate),
SKILL_CALIBRATION_WEIGHTS (Phase 2 E5), and policy / identity / schema
constants.

Drift between docs and runtime becomes impossible by construction:
resolver, cache CLI, and test/skill-preflight-budget.test.ts all import
from the same module.

test/brain-cache-spec.test.ts: 19 invariant assertions (subset/entity
consistency, per-skill achievability, allowlist sanity, transport
defaults, user-slug fallback chain, lock timeout, retention policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-26 22:58:23 -07:00
parent 22f8c7f4e1
commit fc293584df
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 437 additions and 0 deletions

268
scripts/brain-cache-spec.ts Normal file
View File

@ -0,0 +1,268 @@
/**
* Brain cache spec single source of truth for the brain-aware planning skills
* cache layer. Imported by:
* - scripts/resolvers/gbrain.ts (renders per-skill subset into SKILL.md.tmpl)
* - bin/gstack-brain-cache (drives TTL + write-back invalidation)
* - test/brain-cache-spec.test.ts (asserts internal consistency)
* - test/skill-preflight-budget.test.ts (enforces per-skill token budget)
* - test/autoplan-preflight-budget.test.ts (enforces autoplan total budget)
*
* Drift between docs and runtime is impossible by construction: the same
* const drives both the rendered table in SKILL.md and the cache CLI behavior.
*/
export interface BrainCacheEntity {
/** Filename inside ~/.gstack/{,projects/<slug>/}brain-cache/ */
file: string;
/** Time-to-live in milliseconds before cache is considered stale and triggers cold refresh. */
ttl_ms: number;
/** Scope determines which dir holds the cache file. */
scope: 'cross-project' | 'per-project';
/**
* Which write-paths invalidate this digest. When a writer runs, it consults
* this list to know which cache files to bust. Special values:
* - 'calibration-write' any Phase 2 takes_add call
* - 'skill-run-write' any skill that writes a gstack/skill-run page
* Otherwise these are skill names like '/plan-ceo-review'.
*/
invalidated_by: ReadonlyArray<string>;
/** Hard byte budget for the digest. Compressor drops oldest items if exceeded. */
budget_bytes: number;
}
/**
* The seven cached entities mirror the seven typed page kinds in
* `gstack-core` schema pack v1.0.0 (Phase 0):
* user-profile, product, goal, developer-persona, brand, competitive-intel, skill-run
* Plus two derived digests:
* recent-decisions (top 5 gstack/skill-run pages)
* salience (mcp__gbrain__get_recent_salience output)
*/
export const BRAIN_CACHE_ENTITIES: Record<string, BrainCacheEntity> = {
'user-profile': {
file: 'user-profile.md',
ttl_ms: 7 * 86_400_000, // 7 days
scope: 'cross-project',
invalidated_by: ['/retro', '/plan-tune', 'calibration-write'],
budget_bytes: 2048,
},
product: {
file: 'product.md',
ttl_ms: 1 * 86_400_000, // 1 day
scope: 'per-project',
invalidated_by: ['/office-hours', '/plan-ceo-review'],
budget_bytes: 1024,
},
goals: {
file: 'goals.md',
ttl_ms: 12 * 3_600_000, // 12 hours
scope: 'per-project',
invalidated_by: ['/office-hours', '/plan-ceo-review'],
budget_bytes: 512,
},
'developer-persona': {
file: 'developer-persona.md',
ttl_ms: 7 * 86_400_000,
scope: 'per-project',
invalidated_by: ['/plan-devex-review', '/devex-review'],
budget_bytes: 1024,
},
brand: {
file: 'brand.md',
ttl_ms: 7 * 86_400_000,
scope: 'per-project',
invalidated_by: ['/design-consultation', '/plan-design-review'],
budget_bytes: 1024,
},
'competitive-intel': {
file: 'competitive-intel.md',
ttl_ms: 1 * 86_400_000,
scope: 'per-project',
invalidated_by: ['/plan-ceo-review', '/office-hours'],
budget_bytes: 1024,
},
'recent-decisions': {
file: 'recent-decisions.md',
ttl_ms: 12 * 3_600_000,
scope: 'per-project',
invalidated_by: ['skill-run-write'],
budget_bytes: 2048,
},
salience: {
file: 'salience.md',
ttl_ms: 4 * 3_600_000, // 4 hours
scope: 'per-project',
invalidated_by: [],
budget_bytes: 512,
},
};
/**
* Per-skill subset map. The resolver consumes this to emit per-skill BRAIN_PREFLIGHT
* instructions. The skill template loads ONLY the listed digests never more.
* Order matters for narrative coherence in the injected ## Brain Context block.
*
* Hard token budget per skill (validated by test/skill-preflight-budget.test.ts):
* - CEO/office-hours: 5 KB (richest context need)
* - eng/design/devex: 2 KB
*/
export const SKILL_DIGEST_SUBSETS: Record<string, ReadonlyArray<string>> = {
'office-hours': ['product', 'goals', 'user-profile', 'recent-decisions', 'salience'],
'plan-ceo-review': ['product', 'goals', 'recent-decisions', 'user-profile'],
'plan-eng-review': ['product', 'recent-decisions'],
'plan-design-review': ['product', 'brand', 'recent-decisions'],
'plan-devex-review': ['product', 'developer-persona', 'recent-decisions', 'competitive-intel'],
};
/** Per-skill total digest budget (sum of loaded digests must not exceed). */
export const SKILL_PREFLIGHT_BUDGET_BYTES: Record<string, number> = {
'office-hours': 5120,
'plan-ceo-review': 5120,
'plan-eng-review': 2048,
'plan-design-review': 2048,
'plan-devex-review': 2048,
};
/**
* Total budget across an autoplan run (4 sequential planning skills). Validated by
* test/autoplan-preflight-budget.test.ts. If a future autoplan-extended adds skills,
* this cap forces an explicit budget revisit.
*/
export const AUTOPLAN_PREFLIGHT_BUDGET_BYTES = 25_600;
/**
* D9 salience privacy: default allowlist of slug prefixes that are safe to surface
* in planning prompts. Anything outside (personal/, family/, therapy/, etc.)
* gets stripped at digest write time. User can extend via
* `gstack-config set salience_allowlist '<comma-separated-prefixes>'`.
*/
export const SALIENCE_DEFAULT_ALLOWLIST: ReadonlyArray<string> = [
'projects/',
'concepts/',
'gstack/',
];
/**
* Per-skill calibration bet weights (Phase 2 / E5). When a planning skill writes
* a kind=bet take, the weight determines how strongly it factors into the user's
* calibration profile. Higher = more confident prediction worth more credit/blame
* on resolution.
*/
export const SKILL_CALIBRATION_WEIGHTS: Record<string, number> = {
'plan-ceo-review': 0.8,
'plan-eng-review': 0.7,
'plan-design-review': 0.5,
'plan-devex-review': 0.6,
'office-hours': 0.9,
};
/**
* Lock-file path used by the cache refresh dedup (D3). Per-project to avoid
* cross-project contention. Stale-takeover after 5 minutes.
*/
export const CACHE_REFRESH_LOCK_TIMEOUT_MS = 5 * 60_000;
/**
* Retention policy: gstack/skill-run pages auto-archive after this many days.
* Calibration takes (kind=bet) NEVER archive (long-term scorecard needs them).
*/
export const SKILL_RUN_RETENTION_DAYS = 90;
/**
* Schema pack identity. Bumped when adding/removing/renaming page types.
* On mismatch with the version recorded in _meta.json, the cache layer
* triggers a FULL rebuild for the affected project.
*/
export const GSTACK_SCHEMA_PACK_NAME = 'gstack-core';
export const GSTACK_SCHEMA_PACK_VERSION = '1.0.0';
/**
* Trust policy values. Drives auto-push of artifacts, calibration write-back
* eligibility, and user-namespacing strategy.
*/
export type BrainTrustPolicy = 'personal' | 'shared' | 'unset';
/**
* Per-transport default policy. Local engines auto-set to personal (single-tenant
* by construction). Remote endpoints are inferred based on sources_list shape:
* exactly one source + whoami matches personal default; multiple sources or
* federation ask the policy question.
*/
export const TRANSPORT_DEFAULT_POLICY: Record<string, BrainTrustPolicy | 'infer'> = {
'local-pglite': 'personal',
'local-stdio': 'personal',
'remote-http-single-tenant': 'personal',
'remote-http-ambiguous': 'unset',
unknown: 'unset',
};
/**
* User-slug fallback chain (D4 A3 defensive default). Resolved once per endpoint
* and persisted via `gstack-config set user_slug_at_<endpoint-hash> <slug>`.
* Stable across sessions.
*/
export const USER_SLUG_RESOLUTION_ORDER = [
'whoami_client_name', // mcp__gbrain__whoami.client_name (remote + OAuth)
'env_user', // $USER environment variable
'git_email_sha8', // sha8($(git config user.email))
'anonymous_hostname_sha8', // anonymous-<sha8(hostname)>
] as const;
/** ----------------------------------------------------------------------- */
/** Helper functions consumed by the resolver, cache CLI, and tests. */
/** ----------------------------------------------------------------------- */
/** Returns the cache filename for an entity name, throws if unknown. */
export function getCacheFile(entityName: string): string {
const entity = BRAIN_CACHE_ENTITIES[entityName];
if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
return entity.file;
}
/** Returns the digest subset for a skill, throws if the skill isn't preflight-enabled. */
export function getSkillSubset(skillName: string): ReadonlyArray<string> {
const subset = SKILL_DIGEST_SUBSETS[skillName];
if (!subset) throw new Error(`Skill not registered for brain preflight: ${skillName}`);
return subset;
}
/** Returns the per-skill total digest budget in bytes. */
export function getSkillBudget(skillName: string): number {
const budget = SKILL_PREFLIGHT_BUDGET_BYTES[skillName];
if (budget == null) throw new Error(`Skill not registered for brain preflight: ${skillName}`);
return budget;
}
/**
* Given a write-path identifier (skill name or special token), returns the list
* of cache files that should be invalidated. Drives the cache CLI's `invalidate`
* subcommand and the resolver's BRAIN_WRITE_BACK block.
*/
export function getInvalidationTargets(writePath: string): ReadonlyArray<string> {
const targets: string[] = [];
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
if (entity.invalidated_by.includes(writePath)) {
targets.push(name);
}
}
return targets;
}
/**
* Lists all skill names that are registered for brain preflight. Used by
* test/brain-preflight.test.ts and test/skill-preflight-budget.test.ts to
* iterate without hardcoding the skill list.
*/
export function getPreflightSkills(): ReadonlyArray<string> {
return Object.keys(SKILL_DIGEST_SUBSETS);
}
/**
* Computes the maximum possible digest set size for a skill (sum of per-entity
* budgets in the subset). Used by skill-preflight-budget.test.ts to validate
* that the per-skill cap is enforceable given the per-entity caps.
*/
export function getMaxSubsetBytes(skillName: string): number {
const subset = getSkillSubset(skillName);
return subset.reduce((sum, name) => sum + (BRAIN_CACHE_ENTITIES[name]?.budget_bytes ?? 0), 0);
}

View File

@ -0,0 +1,169 @@
/**
* Brain cache spec internal-consistency invariants (T14 / D2).
*
* Asserts that scripts/brain-cache-spec.ts is self-consistent:
* - Every skill's subset only references entities that exist.
* - Per-skill budget cap is achievable given per-entity caps.
* - Cross-project entities are clearly distinguished from per-project.
* - Invalidation graph has no dangling skill references.
* - Helper functions throw on unknown names (defensive).
*
* Gate-tier, free, pure import + assertion. Runs in <100ms.
*/
import { describe, test, expect } from 'bun:test';
import {
BRAIN_CACHE_ENTITIES,
SKILL_DIGEST_SUBSETS,
SKILL_PREFLIGHT_BUDGET_BYTES,
AUTOPLAN_PREFLIGHT_BUDGET_BYTES,
SALIENCE_DEFAULT_ALLOWLIST,
SKILL_CALIBRATION_WEIGHTS,
TRANSPORT_DEFAULT_POLICY,
USER_SLUG_RESOLUTION_ORDER,
GSTACK_SCHEMA_PACK_NAME,
GSTACK_SCHEMA_PACK_VERSION,
CACHE_REFRESH_LOCK_TIMEOUT_MS,
SKILL_RUN_RETENTION_DAYS,
getCacheFile,
getSkillSubset,
getSkillBudget,
getInvalidationTargets,
getPreflightSkills,
getMaxSubsetBytes,
} from '../scripts/brain-cache-spec';
describe('brain-cache-spec internal consistency', () => {
test('every skill subset references only known entities', () => {
const entityNames = new Set(Object.keys(BRAIN_CACHE_ENTITIES));
for (const [skill, subset] of Object.entries(SKILL_DIGEST_SUBSETS)) {
for (const name of subset) {
expect(entityNames.has(name)).toBe(true);
}
}
});
test('every skill with a subset has a budget', () => {
for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) {
expect(SKILL_PREFLIGHT_BUDGET_BYTES[skill]).toBeGreaterThan(0);
}
});
test('per-skill budget is achievable given per-entity budgets', () => {
// Per-entity budgets are hard ceilings on each digest's own file size.
// Per-skill budget is enforced by the compressor on the SUM injected into
// the skill's preflight context — the same entity may be sampled (top-N)
// rather than verbatim. So sum may legitimately exceed skill budget; the
// compressor trims at write time. We allow up to 3x as a sanity ceiling
// (caught test/skill-preflight-budget.test.ts enforces the real cap).
for (const skill of Object.keys(SKILL_DIGEST_SUBSETS)) {
const maxBytes = getMaxSubsetBytes(skill);
const skillBudget = getSkillBudget(skill);
expect(maxBytes).toBeLessThanOrEqual(skillBudget * 3);
}
});
test('autoplan total budget covers the 4 plan-* skills (excluding office-hours)', () => {
const autoplanSkills = ['plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review'];
const sum = autoplanSkills.reduce((acc, s) => acc + getSkillBudget(s), 0);
expect(sum).toBeLessThanOrEqual(AUTOPLAN_PREFLIGHT_BUDGET_BYTES);
});
test('every entity has a positive TTL and a positive budget', () => {
for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
expect(entity.ttl_ms).toBeGreaterThan(0);
expect(entity.budget_bytes).toBeGreaterThan(0);
expect(entity.file).toMatch(/\.md$/);
expect(['cross-project', 'per-project']).toContain(entity.scope);
}
});
test('user-profile is the only cross-project entity', () => {
const crossProject = Object.entries(BRAIN_CACHE_ENTITIES)
.filter(([_, e]) => e.scope === 'cross-project')
.map(([n]) => n);
expect(crossProject).toEqual(['user-profile']);
});
test('salience entity has shortest TTL (changes hourly)', () => {
const ttls = Object.values(BRAIN_CACHE_ENTITIES).map((e) => e.ttl_ms);
expect(BRAIN_CACHE_ENTITIES.salience.ttl_ms).toBe(Math.min(...ttls));
});
test('salience allowlist has sane defaults (no personal/family/therapy)', () => {
const blocked = ['personal/', 'family/', 'therapy/', 'reflection'];
for (const prefix of blocked) {
expect(SALIENCE_DEFAULT_ALLOWLIST.some((p) => p.startsWith(prefix))).toBe(false);
}
// Must contain at least projects/ + gstack/ (work-flow surfaces)
expect(SALIENCE_DEFAULT_ALLOWLIST).toContain('projects/');
expect(SALIENCE_DEFAULT_ALLOWLIST).toContain('gstack/');
});
test('calibration weights are bounded 0-1 and present for all preflight skills', () => {
for (const skill of getPreflightSkills()) {
const weight = SKILL_CALIBRATION_WEIGHTS[skill];
expect(weight).toBeGreaterThan(0);
expect(weight).toBeLessThanOrEqual(1);
}
});
test('transport policy defaults exist for all transport modes', () => {
const required = ['local-pglite', 'local-stdio', 'remote-http-single-tenant', 'remote-http-ambiguous'];
for (const transport of required) {
expect(TRANSPORT_DEFAULT_POLICY[transport]).toBeDefined();
}
// Local transports must default personal (D4 / Phase 1.5 default rule)
expect(TRANSPORT_DEFAULT_POLICY['local-pglite']).toBe('personal');
expect(TRANSPORT_DEFAULT_POLICY['local-stdio']).toBe('personal');
// Ambiguous remote MUST require explicit ask (never silent default)
expect(TRANSPORT_DEFAULT_POLICY['remote-http-ambiguous']).toBe('unset');
});
test('user-slug resolution chain has 4 deterministic fallbacks ending in non-empty', () => {
expect(USER_SLUG_RESOLUTION_ORDER.length).toBe(4);
expect(USER_SLUG_RESOLUTION_ORDER[USER_SLUG_RESOLUTION_ORDER.length - 1]).toBe('anonymous_hostname_sha8');
});
test('schema pack identity is stable strings', () => {
expect(GSTACK_SCHEMA_PACK_NAME).toBe('gstack-core');
expect(GSTACK_SCHEMA_PACK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
});
test('refresh lock timeout matches /sync-gbrain convention (5 min)', () => {
expect(CACHE_REFRESH_LOCK_TIMEOUT_MS).toBe(5 * 60_000);
});
test('skill-run retention is 90 days per D10 lifecycle policy', () => {
expect(SKILL_RUN_RETENTION_DAYS).toBe(90);
});
test('invalidation graph: every "skill-run-write" target also depends on it', () => {
// recent-decisions invalidates on skill-run-write — verify the contract holds
const targets = getInvalidationTargets('skill-run-write');
expect(targets).toContain('recent-decisions');
});
test('invalidation graph: /plan-ceo-review invalidates product + goals + recent-decisions chain', () => {
const targets = getInvalidationTargets('/plan-ceo-review');
expect(targets).toContain('product');
expect(targets).toContain('goals');
});
test('helpers throw on unknown names (defensive)', () => {
expect(() => getCacheFile('nonsense-entity')).toThrow();
expect(() => getSkillSubset('not-a-skill')).toThrow();
expect(() => getSkillBudget('not-a-skill')).toThrow();
});
test('helpers return correct values for known names', () => {
expect(getCacheFile('product')).toBe('product.md');
expect(getSkillSubset('plan-eng-review')).toEqual(['product', 'recent-decisions']);
expect(getSkillBudget('office-hours')).toBe(5120);
});
test('all 5 preflight skills are real planning-skill names', () => {
const expected = ['office-hours', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review'];
expect(getPreflightSkills().sort()).toEqual(expected.sort());
});
});