Merge origin/main (v1.52.1.0) into spec-pii-redaction-guard

Resolve bin/gstack-config (keep both redact_* and brain_* config keys).
Regenerate all SKILL.md from merged templates + resolvers (redact-doc resolver
now coexists with main's brain-aware-planning resolvers). Refresh ship goldens.
Move the redaction taxonomy reference in /cso and /spec to a pointer at
lib/redact-patterns.ts (single source of truth) so neither skill inlines the
full catalog — keeps both under the size budget after the merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-29 18:08:38 -07:00
145 changed files with 14101 additions and 412 deletions
+268
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);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Declared-profile annotation helper (plan-tune cathedral T7).
*
* Given a kebab signal_key from scripts/question-registry.ts, returns a
* one-line plain-English annotation when the user's declared profile is in
* a strong band on the matching dimension, else null. Read-only — never
* mutates the profile.
*
* Signature uses kebab signal_key per D2/Codex correction. Internally maps
* to the underscore Dimension key by consulting SIGNAL_MAP and picking the
* dimension this signal influences most strongly.
*
* Used by:
* - hosts/claude/hooks/question-preference-hook (Layer 3 injection path,
* when AUQ mutation lands)
* - scripts/resolvers/question-tuning.ts preamble (Layer 9 fallback,
* host-portable path on Codex / older Claude Code)
*
* NOT used for AUTO_DECIDE. Annotation is advisory only — declared-only
* per TODOS.md E1 substrate-risk guidance. Inferred-driven AUTO_DECIDE
* remains v2.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { SIGNAL_MAP, type Dimension, ALL_DIMENSIONS } from './psychographic-signals';
const STRONG_HIGH = 0.7;
const STRONG_LOW = 0.3;
/**
* Plain-English phrasing per dimension + band. Keep one sentence each.
* Used directly in question prose, so phrasing matters.
*/
const DIMENSION_PHRASING: Record<Dimension, { high: string; low: string }> = {
scope_appetite: {
high: 'Your declared profile leans complete-implementation (boil the ocean).',
low: 'Your declared profile leans ship-small-fast.',
},
risk_tolerance: {
high: 'Your declared profile leans move-fast.',
low: 'Your declared profile leans check-carefully.',
},
detail_preference: {
high: 'Your declared profile leans verbose-with-tradeoffs.',
low: 'Your declared profile leans terse, just-do-it.',
},
autonomy: {
high: 'Your declared profile leans delegate-and-trust.',
low: 'Your declared profile leans consult-me-first.',
},
architecture_care: {
high: 'Your declared profile leans get-the-design-right.',
low: 'Your declared profile leans pragmatic-ship-it.',
},
};
interface DeveloperProfile {
declared?: Partial<Record<Dimension, number>>;
}
function stateRoot(): string {
return (
process.env.GSTACK_STATE_ROOT ||
process.env.GSTACK_HOME ||
path.join(os.homedir(), '.gstack')
);
}
function readProfile(): DeveloperProfile | null {
try {
const p = path.join(stateRoot(), 'developer-profile.json');
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf-8'));
} catch {
return null;
}
}
/**
* Determine which dimension a signal_key influences most strongly.
* Sums |delta| across all user_choice → DimensionDelta[] entries for that
* signal, returns the dimension with the largest total influence.
* Returns null if the signal_key isn't in the map.
*/
export function primaryDimensionFor(signalKey: string): Dimension | null {
const entry = SIGNAL_MAP[signalKey];
if (!entry) return null;
const totals: Partial<Record<Dimension, number>> = {};
for (const choice of Object.keys(entry)) {
for (const dd of entry[choice]) {
totals[dd.dim] = (totals[dd.dim] ?? 0) + Math.abs(dd.delta);
}
}
let best: Dimension | null = null;
let bestVal = -Infinity;
for (const d of ALL_DIMENSIONS) {
const v = totals[d] ?? 0;
if (v > bestVal) {
bestVal = v;
best = d;
}
}
return bestVal > 0 ? best : null;
}
/**
* Given a signal_key, return a one-line plain-English annotation when
* the user's declared profile is in a strong band on the primary dim,
* else null.
*/
export function getDeclaredAnnotation(signalKey: string): string | null {
if (!signalKey || typeof signalKey !== 'string') return null;
const dim = primaryDimensionFor(signalKey);
if (!dim) return null;
const profile = readProfile();
const declared = profile?.declared?.[dim];
if (typeof declared !== 'number') return null;
if (declared >= STRONG_HIGH) return DIMENSION_PHRASING[dim].high;
if (declared <= STRONG_LOW) return DIMENSION_PHRASING[dim].low;
return null;
}
+48 -2
View File
@@ -26,6 +26,49 @@ import type { HostConfig } from './host-config';
const ROOT = path.resolve(import.meta.dir, '..');
const DRY_RUN = process.argv.includes('--dry-run');
// ─── GBrain Detection Override ──────────────────────────────
// When --respect-detection is passed, read ~/.gstack/gbrain-detection.json
// and un-suppress GBRAIN_CONTEXT_LOAD + GBRAIN_SAVE_RESULTS for hosts that
// statically suppress them (claude, codex, slate, factory, opencode,
// openclaw, cursor, kiro). Detection state is produced by
// bin/gstack-gbrain-detect and persisted by `gstack-config gbrain-refresh`
// or by ./setup.
//
// Default (no flag): static suppressedResolvers honored as-is. Used by
// `bun run gen:skill-docs` (CI + canonical checked-in SKILL.md files) so
// the committed output is reproducible regardless of any developer's
// local gbrain installation state. Use `bun run gen:skill-docs:user`
// (which adds --respect-detection) for user-local installs.
const RESPECT_DETECTION = process.argv.includes('--respect-detection');
function loadGbrainOverride(): { detected: boolean } {
if (!RESPECT_DETECTION) return { detected: false };
const stateDir = process.env.GSTACK_HOME || path.join(process.env.HOME || '', '.gstack');
const detectionPath = path.join(stateDir, 'gbrain-detection.json');
try {
const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
return { detected: json.gbrain_local_status === 'ok' };
} catch {
return { detected: false };
}
}
const GBRAIN_OVERRIDE = loadGbrainOverride();
/**
* Compute effective suppressedResolvers for a host, applying the gbrain
* detection override when enabled. When the override fires, GBRAIN_*
* resolvers are removed from the suppression set so they render in the
* generated SKILL.md.
*/
function effectiveSuppressedResolvers(hostConfig: HostConfig): Set<string> {
let list = hostConfig.suppressedResolvers || [];
if (GBRAIN_OVERRIDE.detected) {
list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS');
}
return new Set(list);
}
// ─── Host Detection (config-driven) ─────────────────────────
const HOST_ARG = process.argv.find(a => a.startsWith('--host'));
@@ -631,9 +674,12 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
const ctx: TemplateContext = { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], preambleTier, model: MODEL_ARG_VAL, interactive, explainLevel: EXPLAIN_LEVEL };
// Replace placeholders (supports parameterized: {{NAME:arg1:arg2}})
// Config-driven: suppressedResolvers return empty string for this host
// Config-driven: suppressedResolvers return empty string for this host.
// effectiveSuppressedResolvers() honors --respect-detection: when gbrain
// is detected locally, GBRAIN_* resolvers un-suppress so brain-aware
// blocks render for users who have gbrain installed.
const currentHostConfig = getHostConfig(host);
const suppressed = new Set(currentHostConfig.suppressedResolvers || []);
const suppressed = effectiveSuppressedResolvers(currentHostConfig);
let content = tmplContent.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (match, fullKey) => {
const parts = fullKey.split(':');
const resolverName = parts[0];
+281
View File
@@ -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=<user>, 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<string>;
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<SchemaFieldShape>;
/**
* 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<SchemaTypeDefinition>;
link_verbs: ReadonlyArray<string>;
}
/* ────────────────────────────────────────────────────────────────── */
/* 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/<user-slug>' },
{ 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/<project-slug>' },
{ 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/<project-slug>/<goal-id>' },
{ 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/<project-slug>' },
{ 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/<project-slug>' },
{ 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/<project-slug>' },
{ 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/<project>/<skill>/<timestamp>' },
{ 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/<project>/<date>/<id>' },
{ 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<string> {
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;
}
+17
View File
@@ -187,6 +187,23 @@ export const SIGNAL_MAP: Record<string, Record<string, DimensionDelta[]>> = {
skip: [{ dim: 'architecture_care', delta: -0.04 }],
},
// -----------------------------------------------------------------------
// decision-autonomy — does the user trust the agent to apply decisions
// without checking back? (Cathedral T7: was the missing signal for the
// 'autonomy' dimension; added so /plan-tune annotations can render
// 'consult me' vs 'delegate' guidance on merge/rollback questions.)
// -----------------------------------------------------------------------
'decision-autonomy': {
accept: [{ dim: 'autonomy', delta: +0.04 }],
reject: [{ dim: 'autonomy', delta: -0.04 }],
// common option keys for "I'll review first" vs "go ahead":
'review-first': [{ dim: 'autonomy', delta: -0.05 }],
proceed: [{ dim: 'autonomy', delta: +0.05 }],
// /investigate-style: "agent applies fix" vs "show me the diff first"
'apply-fix': [{ dim: 'autonomy', delta: +0.04 }],
'show-diff': [{ dim: 'autonomy', delta: -0.04 }],
},
// -----------------------------------------------------------------------
// session-mode — office-hours goal selection
// -----------------------------------------------------------------------
+2
View File
@@ -455,6 +455,7 @@ export const QUESTIONS = {
category: 'approval',
door_type: 'one-way',
options: ['accept', 'reject'],
signal_key: 'decision-autonomy',
description: "Merge this PR to base branch?",
},
'land-and-deploy-rollback': {
@@ -463,6 +464,7 @@ export const QUESTIONS = {
category: 'approval',
door_type: 'one-way',
options: ['accept', 'reject'],
signal_key: 'decision-autonomy',
description: "Canary detected regressions — roll back the deploy?",
},
+230 -41
View File
@@ -6,76 +6,265 @@
*
* 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';
// Per-skill slug + title + tag metadata for SAVE_RESULTS. The full save
// template (heredoc body, entity-stub instructions, throttle handling,
// backlinks) lives in docs/gbrain-write-surfaces.md §Save Template and is
// read on-demand by the agent. Compressing the inline prose keeps the
// token footprint at ~150 tokens per skill (down from ~500), so users with
// gbrain installed pay a small overhead and users without it (whose hosts
// have GBRAIN_SAVE_RESULTS suppressed at gen-time) pay nothing.
interface SkillSaveMeta {
slugPrefix: string;
title: string;
tag: string;
}
const skillSaveMap: Record<string, SkillSaveMeta> = {
'office-hours': { slugPrefix: 'office-hours', title: 'Office Hours', tag: 'design-doc' },
'investigate': { slugPrefix: 'investigations', title: 'Investigation', tag: 'investigation' },
'plan-ceo-review': { slugPrefix: 'ceo-plans', title: 'CEO Plan', tag: 'ceo-plan' },
'plan-eng-review': { slugPrefix: 'eng-reviews', title: 'Eng Review', tag: 'eng-review' },
'plan-design-review': { slugPrefix: 'design-reviews', title: 'Design Review', tag: 'design-review' },
'plan-devex-review': { slugPrefix: 'devex-reviews', title: 'Devex Review', tag: 'devex-review' },
'retro': { slugPrefix: 'retros', title: 'Retro', tag: 'retro' },
'ship': { slugPrefix: 'releases', title: 'Release', tag: 'release' },
'cso': { slugPrefix: 'security-audits', title: 'Security Audit', tag: 'security-audit' },
'design-consultation': { slugPrefix: 'design-systems', title: 'Design System', tag: 'design-system' },
};
export function generateGBrainContextLoad(ctx: TemplateContext): string {
let base = `## Brain Context Load
Before starting this skill, search your brain for relevant context:
**Skip this entire section if \`gbrain\` is not on PATH.**
1. Extract 2-4 keywords from the user's request (nouns, error names, file paths, technical terms).
Search GBrain: \`gbrain search "keyword1 keyword2"\`
Example: for "the login page is broken after deploy", search \`gbrain search "login broken deploy"\`
Search returns lines like: \`[slug] Title (score: 0.85) - first line of content...\`
2. If few results, broaden to the single most specific keyword and search again.
3. For each result page, read it: \`gbrain get_page "<page_slug>"\`
Read the top 3 pages for context.
4. Use this brain context to inform your analysis.
Extract 2-4 keywords from the user's request. Search the brain:
\`gbrain search "<keywords>"\`. Read the top 3 results with
\`gbrain get_page "<slug>"\`. Use that context to inform your analysis.
If GBrain is not available or returns no results, proceed without brain context.
Any non-zero exit code from gbrain commands should be treated as a transient failure.`;
If \`gbrain search\` returns no results or any non-zero exit, proceed
without brain context. Full search/read protocol + examples:
see \`docs/gbrain-write-surfaces.md\` §Context Load.`;
if (ctx.skillName === 'investigate') {
base += `\n\nIf the user's request is about tracking, extracting, or researching structured data (e.g., "track this data", "extract from emails", "build a tracker"), route to GBrain's data-research skill instead: \`gbrain call data-research\`. This skill has a 7-phase pipeline optimized for structured data extraction.`;
base += `\n\nFor structured-data extraction requests ("track this", "extract from emails", "build a tracker"), route to GBrain's data-research skill instead: \`gbrain call data-research\`.`;
}
return base;
}
export function generateGBrainSaveResults(ctx: TemplateContext): string {
// gbrain v0.18+ renamed `put_page` → `put <slug>` and moved --title/--tags
// into YAML frontmatter inside --content. These templates render into
// SKILL.md files as user-facing instructions; using the old subcommand
// ships broken copy-paste to every gstack user.
const skillSaveMap: Record<string, string> = {
'office-hours': 'Save the design document as a brain page:\n```bash\ngbrain put "office-hours/<project-slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "Office Hours: <project name>"\ntags: [design-doc, <project-slug>]\n---\n<design doc content in markdown>\nEOF\n)"\n```',
'investigate': 'Save the root cause analysis as a brain page:\n```bash\ngbrain put "investigations/<issue-slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "Investigation: <issue summary>"\ntags: [investigation, <affected-files>]\n---\n<investigation findings in markdown>\nEOF\n)"\n```',
'plan-ceo-review': 'Save the CEO plan as a brain page:\n```bash\ngbrain put "ceo-plans/<feature-slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "CEO Plan: <feature name>"\ntags: [ceo-plan, <feature-slug>]\n---\n<scope decisions and vision in markdown>\nEOF\n)"\n```',
'retro': 'Save the retrospective as a brain page:\n```bash\ngbrain put "retros/<date>" --content "$(cat <<\'EOF\'\n---\ntitle: "Retro: <date range>"\ntags: [retro, <date>]\n---\n<retro output in markdown>\nEOF\n)"\n```',
'plan-eng-review': 'Save the architecture decisions as a brain page:\n```bash\ngbrain put "eng-reviews/<feature-slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "Eng Review: <feature name>"\ntags: [eng-review, <feature-slug>]\n---\n<review findings and decisions in markdown>\nEOF\n)"\n```',
'ship': 'Save the release notes as a brain page:\n```bash\ngbrain put "releases/<version>" --content "$(cat <<\'EOF\'\n---\ntitle: "Release: <version>"\ntags: [release, <version>]\n---\n<changelog entry and deploy details in markdown>\nEOF\n)"\n```',
'cso': 'Save the security audit as a brain page:\n```bash\ngbrain put "security-audits/<date>" --content "$(cat <<\'EOF\'\n---\ntitle: "Security Audit: <date>"\ntags: [security-audit, <date>]\n---\n<findings and remediation status in markdown>\nEOF\n)"\n```',
'design-consultation': 'Save the design system as a brain page:\n```bash\ngbrain put "design-systems/<project-slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "Design System: <project name>"\ntags: [design-system, <project-slug>]\n---\n<design decisions in markdown>\nEOF\n)"\n```',
};
// gbrain v0.18+ uses `gbrain put <slug>` (NOT the deprecated `put_page`
// MCP op). Compressed in v1.50.0.0: the inline heredoc + entity-stub +
// throttle + backlink prose moved to docs/gbrain-write-surfaces.md
// §Save Template, which the agent reads on demand when it actually
// saves. The compact pointer keeps non-gbrain users' token overhead
// near zero when their host's static suppression is overridden by
// detection.
const meta = skillSaveMap[ctx.skillName];
const saveInstruction = skillSaveMap[ctx.skillName] || 'Save the skill output as a brain page if the results are worth preserving:\n```bash\ngbrain put "<slug>" --content "$(cat <<\'EOF\'\n---\ntitle: "<descriptive title>"\ntags: [<relevant>, <tags>]\n---\n<content in markdown>\nEOF\n)"\n```';
if (!meta) {
return `## Save Results to Brain
**Skip this entire section if \`gbrain\` is not on PATH.**
If the skill output is worth preserving, save it via
\`gbrain put "<slug>" --content "<frontmatter + markdown>"\`. Full template
(heredoc body, frontmatter shape, entity-stub instructions, throttle
handling): see \`docs/gbrain-write-surfaces.md\` §Save Template.`;
}
return `## Save Results to Brain
After completing this skill, persist the results to your brain for future reference:
**Skip this entire section if \`gbrain\` is not on PATH.**
${saveInstruction}
After completing this skill, save the output:
After saving the page, extract and enrich mentioned entities: for each actual person name or company/organization name found in the output, \`gbrain search "<entity name>"\` to check if a page exists. If not, create a stub page:
\`\`\`bash
gbrain put "entities/<entity-slug>" --content "$(cat <<'EOF'
gbrain put "${meta.slugPrefix}/<feature-slug>" --content "$(cat <<'EOF'
---
title: "<Person or Company Name>"
tags: [entity, person]
title: "${meta.title}: <feature name>"
tags: [${meta.tag}, <feature-slug>]
---
Stub page. Mentioned in <skill name> output.
<skill output in markdown>
EOF
)"
\`\`\`
Only extract actual person names and company/organization names. Skip product names, section headings, technical terms, and file paths.
Throttle errors appear as: exit code 1 with stderr containing "throttle", "rate limit", "capacity", or "busy". If GBrain returns a throttle or rate-limit error on any save operation, defer the save and move on. The brain is busy — the content is not lost, just not persisted this run. Any other non-zero exit code should also be treated as a transient failure.
Add backlinks to related brain pages if they exist. If GBrain is not available, skip this step.
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.`;
Then extract person/org entities and create stub pages for each one.
Throttle errors (exit 1 with "throttle"/"rate limit"/"busy") and any
other non-zero exit are transient — don't retry inline. Full entity-stub
template, throttle handling, and backlink protocol:
see \`docs/gbrain-write-surfaces.md\` §Save Template.`;
}
// ────────────────────────────────────────────────────────────────────
// 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)'}
\`\`\`
`;
}
+4 -1
View File
@@ -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';
@@ -89,6 +89,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,
+5 -1
View File
@@ -25,7 +25,11 @@ export function generateQuestionTuning(ctx: TemplateContext): string {
Before each AskUserQuestion, choose \`question_id\` from \`scripts/question-registry.ts\` or \`{skill}-{slug}\`, then run \`${bin}/gstack-question-preference --check "<id>"\`. \`AUTO_DECIDE\` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." \`ASK_NORMALLY\` means ask.
After answer, log best-effort:
**Embed the question_id as a marker in the question text** so hooks can identify it deterministically (plan-tune cathedral T14 / D18 progressive markers). Append \`<gstack-qid:{question_id}>\` somewhere in the rendered question (the leading line or trailing line is fine; the marker doesn't render visibly to the user when wrapped in HTML-style angle brackets, but the hook strips it). Without the marker the PreToolUse enforcement hook treats the AUQ as observed-only and never auto-decides — so always include it when the question matches a registered \`question_id\`.
**Embed the option recommendation via the \`(recommended)\` label suffix** on exactly one option per AUQ. The PreToolUse hook parses \`(recommended)\` first, falls back to "Recommendation: X" prose, and refuses to auto-decide if ambiguous. Two \`(recommended)\` labels = refuse.
After answer, log best-effort (PostToolUse hook also captures deterministically when installed; dedup on (source, tool_use_id) handles double-writes):
\`\`\`bash
${bin}/gstack-question-log '{"skill":"${ctx.skillName}","question_id":"<id>","question_summary":"<short>","category":"<approval|clarification|routing|cherry-pick|feedback-loop>","door_type":"<one-way|two-way>","options_count":N,"user_choice":"<key>","recommended":"<key>","session_id":"'"$_SESSION_ID"'"}' 2>/dev/null || true
\`\`\`