mirror of https://github.com/garrytan/gstack.git
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:
parent
22f8c7f4e1
commit
fc293584df
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue