Skill Tracer
Skills › gstack › gstack › brain-cache-spec.ts

brain-cache-spec.ts

242 lines · scripts/brain-cache-spec.ts at 3f79d81

1/**
2 * Brain cache spec — single source of truth for the brain-aware planning skills
3 * cache layer. Imported by:
4 * - scripts/resolvers/gbrain.ts (renders per-skill subset into SKILL.md.tmpl)
5 * - bin/gstack-brain-cache (drives TTL + write-back invalidation)
6 * - test/brain-cache-spec.test.ts (asserts internal consistency)
7 * - test/skill-preflight-budget.test.ts (enforces per-skill token budget)
8 * - test/autoplan-preflight-budget.test.ts (enforces autoplan total budget)
9 *
10 * Drift between docs and runtime is impossible by construction: the same
11 * const drives both the rendered table in SKILL.md and the cache CLI behavior.
12 */
14export interface BrainCacheEntity {
15 /** Filename inside ~/.gstack/{,projects/<slug>/}brain-cache/ */
16 file: string;
17 /** Time-to-live in milliseconds before cache is considered stale and triggers cold refresh. */
18 ttl_ms: number;
19 /** Scope determines which dir holds the cache file. */
20 scope: 'cross-project' | 'per-project';
21 /**
22 * Which write-paths invalidate this digest. When a writer runs, it consults
23 * this list to know which cache files to bust. Special values:
24 * - 'calibration-write' — any Phase 2 takes_add call
25 * - 'skill-run-write' — any skill that writes a gstack/skill-run page
26 * Otherwise these are skill names like '/plan-ceo-review'.
27 */
28 invalidated_by: ReadonlyArray<string>;
29 /** Hard byte budget for the digest. Compressor drops oldest items if exceeded. */
30 budget_bytes: number;
33/**
34 * The seven cached entities mirror the seven typed page kinds in
35 * `gstack-core` schema pack v1.0.0 (Phase 0):
36 * user-profile, product, goal, developer-persona, brand, competitive-intel, skill-run
37 * Plus two derived digests:
38 * recent-decisions (top 5 gstack/skill-run pages)
39 * salience (mcp__gbrain__get_recent_salience output)
40 */
41export const BRAIN_CACHE_ENTITIES: Record<string, BrainCacheEntity> = {
42 'user-profile': {
43 file: 'user-profile.md',
44 ttl_ms: 7 * 86_400_000, // 7 days
45 scope: 'cross-project',
46 invalidated_by: ['/retro', '/plan-tune', 'calibration-write'],
47 budget_bytes: 2048,
48 },
49 product: {
50 file: 'product.md',
51 ttl_ms: 1 * 86_400_000, // 1 day
52 scope: 'per-project',
53 invalidated_by: ['/office-hours', '/plan-ceo-review'],
54 budget_bytes: 1024,
55 },
56 goals: {
57 file: 'goals.md',
58 ttl_ms: 12 * 3_600_000, // 12 hours
59 scope: 'per-project',
60 invalidated_by: ['/office-hours', '/plan-ceo-review'],
61 budget_bytes: 512,
62 },
63 'developer-persona': {
64 file: 'developer-persona.md',
65 ttl_ms: 7 * 86_400_000,
66 scope: 'per-project',
67 invalidated_by: ['/plan-devex-review', '/devex-review'],
68 budget_bytes: 1024,
69 },
70 brand: {
71 file: 'brand.md',
72 ttl_ms: 7 * 86_400_000,
73 scope: 'per-project',
74 invalidated_by: ['/design-consultation', '/plan-design-review'],
75 budget_bytes: 1024,
76 },
77 'competitive-intel': {
78 file: 'competitive-intel.md',
79 ttl_ms: 1 * 86_400_000,
80 scope: 'per-project',
81 invalidated_by: ['/plan-ceo-review', '/office-hours'],
82 budget_bytes: 1024,
83 },
84 'recent-decisions': {
85 file: 'recent-decisions.md',
86 ttl_ms: 12 * 3_600_000,
87 scope: 'per-project',
88 invalidated_by: ['skill-run-write'],
89 budget_bytes: 2048,
90 },
91 salience: {
92 file: 'salience.md',
93 ttl_ms: 4 * 3_600_000, // 4 hours
94 scope: 'per-project',
95 invalidated_by: [],
96 budget_bytes: 512,
97 },
98};
100/**
101 * Per-skill subset map. The resolver consumes this to emit per-skill BRAIN_PREFLIGHT
102 * instructions. The skill template loads ONLY the listed digests — never more.
103 * Order matters for narrative coherence in the injected ## Brain Context block.
104 *
105 * Hard token budget per skill (validated by test/skill-preflight-budget.test.ts):
106 * - CEO/office-hours: 5 KB (richest context need)
107 * - eng/design/devex: 2 KB
108 */
109export const SKILL_DIGEST_SUBSETS: Record<string, ReadonlyArray<string>> = {
110 'office-hours': ['product', 'goals', 'user-profile', 'recent-decisions', 'salience'],
111 'plan-ceo-review': ['product', 'goals', 'recent-decisions', 'user-profile'],
112 'plan-eng-review': ['product', 'recent-decisions'],
113 'plan-design-review': ['product', 'brand', 'recent-decisions'],
114 'plan-devex-review': ['product', 'developer-persona', 'recent-decisions', 'competitive-intel'],
115};
117/** Per-skill total digest budget (sum of loaded digests must not exceed). */
118export const SKILL_PREFLIGHT_BUDGET_BYTES: Record<string, number> = {
119 'office-hours': 5120,
120 'plan-ceo-review': 5120,
121 'plan-eng-review': 2048,
122 'plan-design-review': 2048,
123 'plan-devex-review': 2048,
124};
126/**
127 * Total budget across an autoplan run (4 sequential planning skills). Validated by
128 * test/autoplan-preflight-budget.test.ts. If a future autoplan-extended adds skills,
129 * this cap forces an explicit budget revisit.
130 */
131export const AUTOPLAN_PREFLIGHT_BUDGET_BYTES = 25_600;
133/**
134 * D9 salience privacy: default allowlist of slug prefixes that are safe to surface
135 * in planning prompts. Anything outside (personal/, family/, therapy/, etc.)
136 * gets stripped at digest write time. User can extend via
137 * `gstack-config set salience_allowlist '<comma-separated-prefixes>'`.
138 */
139export const SALIENCE_DEFAULT_ALLOWLIST: ReadonlyArray<string> = [
140 'projects/',
141 'concepts/',
142 'gstack/',
143];
145/**
146 * Per-skill calibration bet weights (Phase 2 / E5). When a planning skill writes
147 * a kind=bet take, the weight determines how strongly it factors into the user's
148 * calibration profile. Higher = more confident prediction worth more credit/blame
149 * on resolution.
150 */
151export const SKILL_CALIBRATION_WEIGHTS: Record<string, number> = {
152 'plan-ceo-review': 0.8,
153 'plan-eng-review': 0.7,
154 'plan-design-review': 0.5,
155 'plan-devex-review': 0.6,
156 'office-hours': 0.9,
157};
159/**
160 * Lock-file path used by the cache refresh dedup (D3). Per-project to avoid
161 * cross-project contention. Stale-takeover after 5 minutes.
162 */
163export const CACHE_REFRESH_LOCK_TIMEOUT_MS = 5 * 60_000;
165/**
166 * Schema pack identity. Bumped when adding/removing/renaming page types.
167 * On mismatch with the version recorded in _meta.json, the cache layer
168 * triggers a FULL rebuild for the affected project.
169 */
170export const GSTACK_SCHEMA_PACK_NAME = 'gstack-core';
171export const GSTACK_SCHEMA_PACK_VERSION = '1.0.0';
173/**
174 * User-slug fallback chain (D4 A3 defensive default). Resolved once per endpoint
175 * and persisted via `gstack-config set user_slug_at_<endpoint-hash> <slug>`.
176 * Stable across sessions.
177 */
178export const USER_SLUG_RESOLUTION_ORDER = [
179 'whoami_client_name', // mcp__gbrain__whoami.client_name (remote + OAuth)
180 'env_user', // $USER environment variable
181 'git_email_sha8', // sha8($(git config user.email))
182 'anonymous_hostname_sha8', // anonymous-<sha8(hostname)>
183] as const;
185/** ----------------------------------------------------------------------- */
186/** Helper functions consumed by the resolver, cache CLI, and tests. */
187/** ----------------------------------------------------------------------- */
189/** Returns the cache filename for an entity name, throws if unknown. */
190export function getCacheFile(entityName: string): string {
191 const entity = BRAIN_CACHE_ENTITIES[entityName];
192 if (!entity) throw new Error(`Unknown brain cache entity: ${entityName}`);
193 return entity.file;
196/** Returns the digest subset for a skill, throws if the skill isn't preflight-enabled. */
197export function getSkillSubset(skillName: string): ReadonlyArray<string> {
198 const subset = SKILL_DIGEST_SUBSETS[skillName];
199 if (!subset) throw new Error(`Skill not registered for brain preflight: ${skillName}`);
200 return subset;
203/** Returns the per-skill total digest budget in bytes. */
204export function getSkillBudget(skillName: string): number {
205 const budget = SKILL_PREFLIGHT_BUDGET_BYTES[skillName];
206 if (budget == null) throw new Error(`Skill not registered for brain preflight: ${skillName}`);
207 return budget;
210/**
211 * Given a write-path identifier (skill name or special token), returns the list
212 * of cache files that should be invalidated. Drives the cache CLI's `invalidate`
213 * subcommand and the resolver's BRAIN_WRITE_BACK block.
214 */
215export function getInvalidationTargets(writePath: string): ReadonlyArray<string> {
216 const targets: string[] = [];
217 for (const [name, entity] of Object.entries(BRAIN_CACHE_ENTITIES)) {
218 if (entity.invalidated_by.includes(writePath)) {
219 targets.push(name);
220 }
221 }
222 return targets;
225/**
226 * Lists all skill names that are registered for brain preflight. Used by
227 * test/brain-preflight.test.ts and test/skill-preflight-budget.test.ts to
228 * iterate without hardcoding the skill list.
229 */
230export function getPreflightSkills(): ReadonlyArray<string> {
231 return Object.keys(SKILL_DIGEST_SUBSETS);
234/**
235 * Computes the maximum possible digest set size for a skill (sum of per-entity
236 * budgets in the subset). Used by skill-preflight-budget.test.ts to validate
237 * that the per-skill cap is enforceable given the per-entity caps.
238 */
239export function getMaxSubsetBytes(skillName: string): number {
240 const subset = getSkillSubset(skillName);
241 return subset.reduce((sum, name) => sum + (BRAIN_CACHE_ENTITIES[name]?.budget_bytes ?? 0), 0);