diff --git a/scripts/gstack-schema-pack.ts b/scripts/gstack-schema-pack.ts new file mode 100644 index 000000000..4a308fd69 --- /dev/null +++ b/scripts/gstack-schema-pack.ts @@ -0,0 +1,281 @@ +/** + * gstack-core@1.0.0 schema pack (T1 / Phase 0). + * + * Defines the 7 typed page kinds gstack writes into a personal gbrain: + * gstack/user-profile, gstack/product, gstack/goal, gstack/developer-persona, + * gstack/brand, gstack/competitive-intel, gstack/skill-run + * + * Plus the typed take kind gstack writes for Phase 2 calibration: + * gstack/take (kind=bet, holder=, with expected_resolution_date) + * + * Exports JSON consumed by `mcp__gbrain__schema_apply_mutations` at first + * /setup-gbrain or /sync-gbrain after this lands. Registration is idempotent + * (gbrain's mutation handler skips re-registration when pack version matches). + * + * Each type carries frontmatter shape + link types. Link inference enables + * `mcp__gbrain__schema_graph` to render the gstack subgraph correctly. + */ + +import { + GSTACK_SCHEMA_PACK_NAME, + GSTACK_SCHEMA_PACK_VERSION, +} from './brain-cache-spec'; + +export interface SchemaFieldShape { + name: string; + type: 'string' | 'date' | 'number' | 'enum' | 'wikilink-array' | 'string-array'; + required: boolean; + /** For enum types. */ + values?: ReadonlyArray; + description: string; +} + +export interface SchemaTypeDefinition { + /** Page type slug, e.g. `gstack/product`. */ + type: string; + /** Human-readable purpose. Surfaces in `mcp__gbrain__schema_explain_type`. */ + description: string; + /** Per-page-type retention semantics; 'immutable' means never auto-archive. */ + retention: 'immutable' | 'archive-after-90d' | 'never-archive'; + /** Frontmatter fields the page MUST or MAY carry. */ + fields: ReadonlyArray; + /** + * Link types this page emits via `[[wikilink]]` references in body or + * frontmatter. Used by gbrain's link inference + schema_graph rendering. + */ + emits_links?: ReadonlyArray<{ verb: string; target_type: string }>; +} + +export interface SchemaPackJSON { + name: string; + version: string; + page_types: ReadonlyArray; + link_verbs: ReadonlyArray; +} + +/* ────────────────────────────────────────────────────────────────── */ +/* Page type definitions */ +/* ────────────────────────────────────────────────────────────────── */ + +const USER_PROFILE: SchemaTypeDefinition = { + type: 'gstack/user-profile', + description: + 'Cross-project profile of the gstack user: tone/conviction patterns, ' + + 'decision tendencies, calibration profile reference. One per user identity. ' + + 'Read by all planning skills for tone-aware + bias-aware recommendations.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/user-profile' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/user-profile/' }, + { name: 'user_slug', type: 'string', required: true, description: 'Resolved per USER_SLUG_RESOLUTION_ORDER' }, + { name: 'last_updated_by', type: 'string', required: false, description: 'Last skill that touched this page' }, + { name: 'last_updated_at', type: 'date', required: false, description: 'ISO-8601 datetime' }, + { name: 'pattern_statements', type: 'string-array', required: false, description: 'Bias tags from calibration (e.g., "under-expands on infra plans")' }, + { name: 'taste_signals', type: 'string-array', required: false, description: 'Recurring design/eng preferences observed across reviews' }, + ], + emits_links: [ + { verb: 'has_calibration', target_type: 'gstack/take' }, + ], +}; + +const PRODUCT: SchemaTypeDefinition = { + type: 'gstack/product', + description: + 'Per-project product model: what the product IS today (value prop, target user, ' + + 'stage, team), with active goals + recent decisions. Single source of truth ' + + 'every planning skill consults before asking the user about their product.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/product' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/product/' }, + { name: 'title', type: 'string', required: true, description: 'Project / product name' }, + { name: 'last_updated_by', type: 'string', required: false, description: '/office-hours or /plan-ceo-review' }, + { name: 'last_updated_at', type: 'date', required: false, description: 'ISO-8601' }, + { name: 'status', type: 'enum', required: true, values: ['active', 'paused', 'archived'], description: 'Project status' }, + ], + emits_links: [ + { verb: 'targets', target_type: 'gstack/goal' }, + { verb: 'observed_by', target_type: 'gstack/developer-persona' }, + { verb: 'has_brand', target_type: 'gstack/brand' }, + { verb: 'competes_with', target_type: 'gstack/competitive-intel' }, + { verb: 'history', target_type: 'gstack/skill-run' }, + ], +}; + +const GOAL: SchemaTypeDefinition = { + type: 'gstack/goal', + description: + 'A time-bounded outcome the user has committed to (ship X by Y, hit metric Z). ' + + 'Multiple active goals per project. Auto-flips to status=expired when ' + + 'expected_resolution date passes; preflight surfaces expired goals for review.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/goal' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/goal//' }, + { name: 'title', type: 'string', required: true, description: 'One-line goal statement' }, + { name: 'project', type: 'string', required: true, description: 'project slug' }, + { name: 'committed_at', type: 'date', required: true, description: 'When the user committed' }, + { name: 'expected_resolution', type: 'date', required: false, description: 'ISO-8601; flips to expired after' }, + { name: 'status', type: 'enum', required: true, values: ['active', 'resolved', 'expired', 'archived'], description: 'Lifecycle state' }, + { name: 'resolution_note', type: 'string', required: false, description: 'Filled when resolved' }, + ], + emits_links: [ + { verb: 'belongs_to', target_type: 'gstack/product' }, + ], +}; + +const DEVELOPER_PERSONA: SchemaTypeDefinition = { + type: 'gstack/developer-persona', + description: + 'Per-project model of the target developer using this product (when product ' + + 'is developer-facing). Captures persona, friction patterns, prior TTHW ' + + 'measurements. Read by devex + design skills for calibrated recommendations.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/developer-persona' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/developer-persona/' }, + { name: 'persona', type: 'string', required: true, description: 'One-line target developer description' }, + { name: 'tthw_measurements', type: 'string-array', required: false, description: 'Historical TTHW times with dates' }, + { name: 'friction_patterns', type: 'string-array', required: false, description: 'Where developers get stuck' }, + ], +}; + +const BRAND: SchemaTypeDefinition = { + type: 'gstack/brand', + description: + "Per-project brand voice: visual direction, design language, tone-of-voice. " + + 'Read by design skills + devex skills (for consistency checks across CLI/docs/UI).', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/brand' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/brand/' }, + { name: 'aesthetic', type: 'string', required: false, description: 'e.g., "minimal/typographic"' }, + { name: 'typography', type: 'string', required: false, description: 'Font system summary' }, + { name: 'color_system', type: 'string', required: false, description: 'Palette summary' }, + { name: 'voice', type: 'string', required: false, description: 'Tone of writing' }, + ], +}; + +const COMPETITIVE_INTEL: SchemaTypeDefinition = { + type: 'gstack/competitive-intel', + description: + 'Per-project competitive landscape: incumbents, indirect substitutes, measured ' + + 'competitor benchmarks (TTHW, pricing, feature parity). Read by CEO + devex.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/competitive-intel' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/competitive-intel/' }, + { name: 'competitors', type: 'string-array', required: false, description: 'Named competitors with positioning notes' }, + { name: 'benchmarks', type: 'string-array', required: false, description: 'Measured comparison points (TTHW etc.)' }, + ], +}; + +const SKILL_RUN: SchemaTypeDefinition = { + type: 'gstack/skill-run', + description: + 'Every gstack skill invocation that produces output writes one of these on completion. ' + + 'Time-series log of decisions, modes, mode-selected, outcomes. Powers /retro ' + + 'and (deferred) /gstack-reflect. Auto-archives to summary-only after 90 days.', + retention: 'archive-after-90d', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/skill-run' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/skill-run///' }, + { name: 'skill', type: 'string', required: true, description: 'Skill name (e.g., plan-ceo-review)' }, + { name: 'project', type: 'string', required: true, description: 'Project slug' }, + { name: 'branch', type: 'string', required: false, description: 'Git branch' }, + { name: 'commit', type: 'string', required: false, description: 'Short SHA' }, + { name: 'duration_s', type: 'number', required: false, description: 'Skill duration in seconds' }, + { name: 'outcome', type: 'enum', required: true, values: ['success', 'error', 'aborted'], description: 'Completion state' }, + { name: 'mode', type: 'string', required: false, description: 'Mode chosen (for skills with mode)' }, + { name: 'decisions', type: 'number', required: false, description: 'Count of AUQ decisions' }, + { name: 'takes_written', type: 'number', required: false, description: 'Calibration bets written (E5)' }, + ], + emits_links: [ + { verb: 'related_to', target_type: 'gstack/product' }, + { verb: 'related_to', target_type: 'gstack/goal' }, + { verb: 'writes_bet', target_type: 'gstack/take' }, + ], +}; + +const TAKE: SchemaTypeDefinition = { + type: 'gstack/take', + description: + 'Typed predictions (kind=bet) written by planning skills (Phase 2 / E5). ' + + 'Resolved bets feed the user-profile calibration. Never auto-archived.', + retention: 'never-archive', + fields: [ + { name: 'type', type: 'string', required: true, description: 'gstack/take' }, + { name: 'slug', type: 'string', required: true, description: 'gstack/take///' }, + { name: 'kind', type: 'enum', required: true, values: ['bet', 'hunch', 'fact', 'event'], description: 'Take kind' }, + { name: 'holder', type: 'string', required: true, description: 'User identity (whoami / user-slug)' }, + { name: 'claim', type: 'string', required: true, description: 'The prediction text' }, + { name: 'weight', type: 'number', required: false, description: '0-1 confidence (per-skill from SKILL_CALIBRATION_WEIGHTS)' }, + { name: 'since_date', type: 'date', required: false, description: 'When the take was written' }, + { name: 'expected_resolution', type: 'date', required: false, description: 'Target resolution date' }, + { name: 'resolved_at', type: 'date', required: false, description: 'When marked resolved' }, + { name: 'resolved_quality', type: 'enum', required: false, values: ['correct', 'incorrect', 'partial'], description: 'Calibration outcome' }, + { name: 'source_skill', type: 'string', required: false, description: 'Which skill wrote this bet' }, + ], + emits_links: [ + { verb: 'belongs_to', target_type: 'gstack/user-profile' }, + { verb: 'origin', target_type: 'gstack/skill-run' }, + ], +}; + +/* ────────────────────────────────────────────────────────────────── */ +/* Schema pack assembly */ +/* ────────────────────────────────────────────────────────────────── */ + +export const GSTACK_CORE_SCHEMA_PACK: SchemaPackJSON = { + name: GSTACK_SCHEMA_PACK_NAME, + version: GSTACK_SCHEMA_PACK_VERSION, + page_types: [ + USER_PROFILE, + PRODUCT, + GOAL, + DEVELOPER_PERSONA, + BRAND, + COMPETITIVE_INTEL, + SKILL_RUN, + TAKE, + ], + // Link verbs surface in mcp__gbrain__schema_graph as edge labels. + link_verbs: [ + 'has_calibration', + 'targets', + 'observed_by', + 'has_brand', + 'competes_with', + 'history', + 'belongs_to', + 'related_to', + 'writes_bet', + 'origin', + ], +}; + +/** + * Returns the JSON shape gbrain's `schema_apply_mutations` MCP op expects. + * Idempotent on the brain side: gbrain skips re-registration when pack+version match. + */ +export function getSchemaPackMutationPayload(): { + schema_pack: SchemaPackJSON; + schema_version: number; +} { + return { + schema_pack: GSTACK_CORE_SCHEMA_PACK, + schema_version: 1, // gbrain mutation API version, not pack version + }; +} + +/** Returns just the page type names. Used by tests + audit subcommand. */ +export function getSchemaPackTypeNames(): ReadonlyArray { + return GSTACK_CORE_SCHEMA_PACK.page_types.map((t) => t.type); +} + +/** Returns the retention policy for a given page type. Throws on unknown. */ +export function getRetentionPolicy(pageType: string): SchemaTypeDefinition['retention'] { + const def = GSTACK_CORE_SCHEMA_PACK.page_types.find((t) => t.type === pageType); + if (!def) throw new Error(`Unknown page type: ${pageType}`); + return def.retention; +} diff --git a/test/gstack-schema-pack.test.ts b/test/gstack-schema-pack.test.ts new file mode 100644 index 000000000..8d9b55e8f --- /dev/null +++ b/test/gstack-schema-pack.test.ts @@ -0,0 +1,150 @@ +/** + * gstack-core@1.0.0 schema pack validation (T1). + * + * Asserts the schema pack is well-formed and matches the v1.48 plan: + * - Exactly 8 page types (7 entities + 1 take) + * - Frontmatter shape is internally consistent + * - Retention policies match SKILL_RUN_RETENTION_DAYS spec + * - Link verbs only reference declared verbs + * - JSON payload shape is acceptable to mcp__gbrain__schema_apply_mutations + * + * Gate-tier, free, pure import + assertion. + */ + +import { describe, test, expect } from 'bun:test'; +import { + GSTACK_CORE_SCHEMA_PACK, + getSchemaPackMutationPayload, + getSchemaPackTypeNames, + getRetentionPolicy, +} from '../scripts/gstack-schema-pack'; +import { + GSTACK_SCHEMA_PACK_NAME, + GSTACK_SCHEMA_PACK_VERSION, +} from '../scripts/brain-cache-spec'; + +describe('gstack-core schema pack', () => { + test('identity matches brain-cache-spec constants', () => { + expect(GSTACK_CORE_SCHEMA_PACK.name).toBe(GSTACK_SCHEMA_PACK_NAME); + expect(GSTACK_CORE_SCHEMA_PACK.version).toBe(GSTACK_SCHEMA_PACK_VERSION); + }); + + test('declares exactly 8 page types (7 entities + gstack/take)', () => { + expect(GSTACK_CORE_SCHEMA_PACK.page_types.length).toBe(8); + }); + + test('all 7 brain-cache entities have a matching schema page type', () => { + const types = getSchemaPackTypeNames(); + const required = [ + 'gstack/user-profile', + 'gstack/product', + 'gstack/goal', + 'gstack/developer-persona', + 'gstack/brand', + 'gstack/competitive-intel', + 'gstack/skill-run', + ]; + for (const name of required) { + expect(types).toContain(name); + } + }); + + test('gstack/take exists with kind=bet supported (Phase 2 / E5)', () => { + const take = GSTACK_CORE_SCHEMA_PACK.page_types.find((t) => t.type === 'gstack/take'); + expect(take).toBeDefined(); + const kind = take!.fields.find((f) => f.name === 'kind'); + expect(kind?.values).toContain('bet'); + expect(kind?.values).toContain('fact'); + }); + + test('every page type has a required type + slug field', () => { + for (const def of GSTACK_CORE_SCHEMA_PACK.page_types) { + const typeField = def.fields.find((f) => f.name === 'type'); + const slugField = def.fields.find((f) => f.name === 'slug'); + expect(typeField?.required).toBe(true); + expect(slugField?.required).toBe(true); + } + }); + + test('enum fields declare their values', () => { + for (const def of GSTACK_CORE_SCHEMA_PACK.page_types) { + for (const field of def.fields) { + if (field.type === 'enum') { + expect(field.values).toBeDefined(); + expect(field.values!.length).toBeGreaterThan(0); + } + } + } + }); + + test('skill-run is the only archive-after-90d type', () => { + const archived = GSTACK_CORE_SCHEMA_PACK.page_types + .filter((t) => t.retention === 'archive-after-90d') + .map((t) => t.type); + expect(archived).toEqual(['gstack/skill-run']); + }); + + test('gstack/take is never-archive (calibration scorecard preservation)', () => { + expect(getRetentionPolicy('gstack/take')).toBe('never-archive'); + }); + + test('getRetentionPolicy throws on unknown type (defensive)', () => { + expect(() => getRetentionPolicy('gstack/nonexistent')).toThrow(); + }); + + test('link verbs declared on emits_links are also in pack.link_verbs', () => { + const declared = new Set(GSTACK_CORE_SCHEMA_PACK.link_verbs); + for (const def of GSTACK_CORE_SCHEMA_PACK.page_types) { + for (const link of def.emits_links ?? []) { + expect(declared.has(link.verb)).toBe(true); + } + } + }); + + test('link verbs only target declared gstack/ page types', () => { + const declared = new Set(getSchemaPackTypeNames()); + for (const def of GSTACK_CORE_SCHEMA_PACK.page_types) { + for (const link of def.emits_links ?? []) { + expect(declared.has(link.target_type)).toBe(true); + } + } + }); + + test('mutation payload is well-formed JSON', () => { + const payload = getSchemaPackMutationPayload(); + expect(payload.schema_version).toBe(1); + expect(payload.schema_pack).toBeDefined(); + expect(typeof payload.schema_pack.name).toBe('string'); + expect(Array.isArray(payload.schema_pack.page_types)).toBe(true); + // round-trip through JSON to catch unserializable values (functions, undefined, etc.) + const json = JSON.stringify(payload); + const reparsed = JSON.parse(json); + expect(reparsed.schema_pack.name).toBe(payload.schema_pack.name); + }); + + test('gstack/product has expected emits_links graph (product → goal/persona/brand/etc.)', () => { + const product = GSTACK_CORE_SCHEMA_PACK.page_types.find((t) => t.type === 'gstack/product')!; + const verbs = (product.emits_links ?? []).map((l) => `${l.verb}:${l.target_type}`); + expect(verbs).toContain('targets:gstack/goal'); + expect(verbs).toContain('observed_by:gstack/developer-persona'); + expect(verbs).toContain('has_brand:gstack/brand'); + expect(verbs).toContain('competes_with:gstack/competitive-intel'); + }); + + test('gstack/goal has lifecycle status enum (active/resolved/expired/archived)', () => { + const goal = GSTACK_CORE_SCHEMA_PACK.page_types.find((t) => t.type === 'gstack/goal')!; + const status = goal.fields.find((f) => f.name === 'status'); + expect(status?.values).toEqual(['active', 'resolved', 'expired', 'archived']); + }); + + test('gstack/skill-run records the bet count for calibration coverage', () => { + const sr = GSTACK_CORE_SCHEMA_PACK.page_types.find((t) => t.type === 'gstack/skill-run')!; + const takesField = sr.fields.find((f) => f.name === 'takes_written'); + expect(takesField).toBeDefined(); + expect(takesField?.type).toBe('number'); + }); + + test('gstack/user-profile is never-archive (cross-project, long-lived)', () => { + expect(getRetentionPolicy('gstack/user-profile')).toBe('never-archive'); + }); +});