mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
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:
@@ -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,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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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)'}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
\`\`\`
|
||||
|
||||
Reference in New Issue
Block a user