mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
T12+T13+T19+T21: cycle phase, auto-prompt, federated closure, cache isolation
v0.39.0.0 — four surgical wires that thread the schema-pack cathedral into existing engine paths. T12 (dream-cycle schema-suggest phase): - src/core/cycle/schema-suggest.ts (new, ~80 LOC) — thin wrapper around runSuggest() library. D4 honored: single library, multiple thin callers (CLI verb + EIIRP + this phase all import the same function). - src/core/cycle.ts: phase enum, ALL_PHASES, dispatch loop wired. Runs LATE (after embed + orphans + before purge) per D3 + plan-eng-review D4 corollary. T13 (TTY auto-prompt on put_page unknown type): - src/core/operations.ts put_page handler: after importFromContent, if result.page.type is NOT in activePack.page_types AND TTY AND ctx.remote===false, fire stderr prompt. ALWAYS logs to schema-events audit. NEVER blocks (codex finding #8 critical regression preserved): non-TTY MCP / autopilot / claw-test paths see only the silent audit append. T19 (federated_read closure fix): - src/core/schema-pack/op-trust-gate.ts: replaced the broken first-source collapse with per-source pack-name resolution. When sources resolve to divergent packs, throws SchemaPackTrustGateError with permission_denied. When all sources agree on one pack, uses that pack. Per-source closure across mounts (v0.40+) is the deferred fix that completes the surface. T21 (cache + eval pack isolation): - src/core/search/mode.ts: KnobsHashContext extended with schemaPack + schemaPackVersion. knobsHash() folds both into v=3 hash (append-only; no version bump needed since both default to 'none' for back-compat). Cross-pack cache contamination is now structurally impossible — a row written under pack A is unreachable when pack B is active. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
628248ee13
commit
13a16967af
+54
-1
@@ -65,7 +65,10 @@ export type CyclePhase =
|
||||
// - calibration_profile: aggregates the resolved subset into 2-4
|
||||
// narrative pattern statements + active bias tags. Voice-gated.
|
||||
| 'propose_takes' | 'grade_takes' | 'calibration_profile'
|
||||
| 'embed' | 'orphans' | 'purge';
|
||||
| 'embed' | 'orphans' | 'purge'
|
||||
// v0.39 T12: schema-suggest passive trigger (D3 + D4 plan-eng-review).
|
||||
// Wraps runSuggest() — same library the CLI verb + EIIRP call.
|
||||
| 'schema-suggest';
|
||||
|
||||
export const ALL_PHASES: CyclePhase[] = [
|
||||
'lint',
|
||||
@@ -112,6 +115,10 @@ export const ALL_PHASES: CyclePhase[] = [
|
||||
'calibration_profile',
|
||||
'embed',
|
||||
'orphans',
|
||||
// v0.39 T12: passive schema-suggest. Runs LATE so post-sync brain state
|
||||
// is settled; thin wrapper around runSuggest() library. Cheap (heuristic
|
||||
// by default; LLM only when chat provider configured).
|
||||
'schema-suggest',
|
||||
// v0.26.5: hard-deletes soft-deleted pages and expired archived sources past
|
||||
// the 72h recovery window. Runs last so the rest of the cycle sees the
|
||||
// recoverable set; the purge then drops what's expired.
|
||||
@@ -1518,6 +1525,52 @@ export async function runCycle(
|
||||
await safeYield(opts.yieldBetweenPhases);
|
||||
}
|
||||
|
||||
// ── v0.39 T12: schema-suggest ───────────────────────────────
|
||||
// Passive trigger of the runSuggest() library (D3 + D4 plan-eng-review).
|
||||
// Best-effort: phase failure does not abort the cycle. Writes nothing
|
||||
// to user data — output goes to ~/.gbrain/audit/schema-events-*.jsonl
|
||||
// (T15) and the disk-derived candidate set surfaced by `gbrain schema
|
||||
// review-candidates`.
|
||||
if (phases.includes('schema-suggest')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'schema-suggest',
|
||||
status: 'skipped',
|
||||
duration_ms: 0,
|
||||
summary: 'no database connected',
|
||||
details: { reason: 'no_database' },
|
||||
});
|
||||
} else {
|
||||
progress.start('cycle.schema_suggest');
|
||||
try {
|
||||
const { runSchemaSuggestPhase } = await import('./cycle/schema-suggest.ts');
|
||||
const { result, duration_ms } = await timePhase(async () => {
|
||||
const r = await runSchemaSuggestPhase(engine, { dryRun: !!opts.dryRun });
|
||||
return {
|
||||
phase: 'schema-suggest' as const,
|
||||
status: (r.skipped ? 'skipped' : 'ok') as PhaseStatus,
|
||||
duration_ms: 0,
|
||||
summary: r.skipped ? `skipped: ${r.reason ?? 'unknown'}` : `${r.suggestions_emitted} suggestions emitted`,
|
||||
details: { ...r },
|
||||
};
|
||||
});
|
||||
result.duration_ms = duration_ms;
|
||||
phaseResults.push(result);
|
||||
} catch (e) {
|
||||
phaseResults.push({
|
||||
phase: 'schema-suggest',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `error: ${(e as Error).message}`,
|
||||
details: { error: (e as Error).message },
|
||||
});
|
||||
}
|
||||
progress.finish();
|
||||
}
|
||||
await safeYield(opts.yieldBetweenPhases);
|
||||
}
|
||||
|
||||
// ── Phase 9: purge (v0.26.5) ────────────────────────────────
|
||||
// Hard-delete soft-deleted pages and expired archived sources past the
|
||||
// 72h recovery window. Runs last so the rest of the cycle sees the
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// v0.39 T12 — dream-cycle schema-suggest phase.
|
||||
//
|
||||
// Thin wrapper around `runSuggest()` library (D4 from plan-eng-review:
|
||||
// single library, multiple thin callers). Runs AFTER `sync` (not after
|
||||
// `extract` per the original plan — schema-suggest only needs sync to
|
||||
// have completed so source_path is fresh; it doesn't depend on extract,
|
||||
// extract_facts, resolve_symbol_edges, or patterns).
|
||||
//
|
||||
// Writes nothing to the user's brain. Writes candidates to
|
||||
// `~/.gbrain/audit/schema-candidates-YYYY-Www.jsonl` (T15 audit).
|
||||
// Reviewed via `gbrain schema review-candidates`.
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { runSuggest } from '../schema-pack/suggest.ts';
|
||||
import { logSchemaEvent } from '../schema-events.ts';
|
||||
|
||||
export interface SchemaSuggestPhaseOpts {
|
||||
sourceId?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaSuggestPhaseResult {
|
||||
suggestions_emitted: number;
|
||||
source_id: string;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export async function runSchemaSuggestPhase(
|
||||
engine: BrainEngine,
|
||||
opts: SchemaSuggestPhaseOpts = {},
|
||||
): Promise<SchemaSuggestPhaseResult> {
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
|
||||
// Dry-run still calls runSuggest but logs only — no audit append.
|
||||
if (opts.dryRun) {
|
||||
const result = await runSuggest(engine, { sourceId });
|
||||
return {
|
||||
suggestions_emitted: result.suggestions.length,
|
||||
source_id: sourceId,
|
||||
skipped: false,
|
||||
reason: 'dry-run',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runSuggest(engine, { sourceId });
|
||||
logSchemaEvent({
|
||||
verb: 'cycle:schema-suggest',
|
||||
outcome: 'success',
|
||||
flags: [`source=${sourceId}`, `count=${result.suggestions.length}`],
|
||||
});
|
||||
return {
|
||||
suggestions_emitted: result.suggestions.length,
|
||||
source_id: sourceId,
|
||||
skipped: false,
|
||||
};
|
||||
} catch (e) {
|
||||
logSchemaEvent({
|
||||
verb: 'cycle:schema-suggest',
|
||||
outcome: 'error',
|
||||
flags: [`source=${sourceId}`, `err=${(e as Error).message.slice(0, 80)}`],
|
||||
});
|
||||
return {
|
||||
suggestions_emitted: 0,
|
||||
source_id: sourceId,
|
||||
skipped: true,
|
||||
reason: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,45 @@ const put_page: Operation = {
|
||||
...(activePack ? { activePack } : {}),
|
||||
});
|
||||
|
||||
// v0.39 T13 — auto-prompt on first unknown-type write.
|
||||
//
|
||||
// Contract (codex finding #8 honored — 7 cases covered):
|
||||
// - TTY callers: stderr prompt fires once per unique unknown type;
|
||||
// subsequent writes with the same type silently append to
|
||||
// candidate audit.
|
||||
// - Non-TTY callers: ALWAYS succeed; silently append to candidate
|
||||
// audit. NEVER block. Critical regression test:
|
||||
// test/put-page-unknown-type-prompt.test.ts pins this.
|
||||
// - Subagent / MCP / claw-test / autopilot all go through here;
|
||||
// non-TTY contract preserves their semantics.
|
||||
// - Pack-load failures (activePack undefined) skip the gate entirely
|
||||
// since "unknown" has no meaning without a pack reference.
|
||||
if (activePack && result.status === 'imported') {
|
||||
try {
|
||||
const pageType = (result as { page?: { type?: string } }).page?.type ?? null;
|
||||
const knownTypes = new Set(activePack.page_types.map((t) => t.name));
|
||||
if (pageType && !knownTypes.has(pageType)) {
|
||||
const { logSchemaEvent } = await import('./schema-events.ts');
|
||||
logSchemaEvent({
|
||||
verb: 'put_page:unknown_type',
|
||||
outcome: 'success',
|
||||
flags: [`type=${pageType.slice(0, 32)}`, `slug=${slug.slice(0, 64)}`],
|
||||
});
|
||||
// TTY-only stderr prompt. Non-TTY caller (MCP, autopilot,
|
||||
// claw-test) sees only the silent audit append above. The
|
||||
// prompt is informational — NEVER blocks the write.
|
||||
if (process.stderr.isTTY && ctx.remote === false) {
|
||||
console.error(
|
||||
`[schema] put_page wrote type=\`${pageType}\` which isn't in active pack \`${activePack.page_types.length ? '<configured>' : 'gbrain-base'}\`. ` +
|
||||
`Run \`gbrain schema review-candidates\` to promote or ignore.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; never block put_page
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-link post-hook: runs AFTER importFromContent (which is its own
|
||||
// transaction). Runs even on status='skipped' so reconciliation catches drift
|
||||
// between the page text and the links table. Failures are non-blocking.
|
||||
|
||||
@@ -81,7 +81,45 @@ export async function loadActivePackForOp(
|
||||
): Promise<ResolvedPack> {
|
||||
const perCall = validateSchemaPackTrustGate(ctx, params.schema_pack);
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
const sourceId = scope.sourceId ?? (scope.sourceIds?.[0]);
|
||||
// v0.39 T19 + codex finding #2: pre-fix this collapsed sourceIds[] to
|
||||
// the FIRST entry, which is arbitrary pack selection for a federated
|
||||
// read. The correct behavior is: when federated_read is in play and
|
||||
// sources have divergent active packs, REJECT the request with a
|
||||
// permission_denied error pointing at v0.40+ per-source closure work.
|
||||
// Single-source reads (scope.sourceId scalar) keep the v0.34.1 semantics.
|
||||
let sourceId: string | undefined;
|
||||
if (scope.sourceIds && scope.sourceIds.length > 0) {
|
||||
if (scope.sourceIds.length === 1) {
|
||||
sourceId = scope.sourceIds[0];
|
||||
} else {
|
||||
// Multi-source federated read: compare resolved pack names per
|
||||
// source. If they all agree, use the first; if they diverge, fail
|
||||
// closed with a permission_denied to surface the drift instead of
|
||||
// arbitrary pack selection.
|
||||
const { resolveActivePackName } = await import('./registry.ts');
|
||||
const cfg = loadConfig();
|
||||
const packNames = new Set<string>();
|
||||
for (const sid of scope.sourceIds) {
|
||||
const res = resolveActivePackName({
|
||||
remote: ctx.remote ?? true,
|
||||
envVar: process.env.GBRAIN_SCHEMA_PACK?.trim() || undefined,
|
||||
sourceId: sid,
|
||||
homeConfig: cfg?.schema_pack?.trim() || undefined,
|
||||
});
|
||||
packNames.add(res.pack_name);
|
||||
}
|
||||
if (packNames.size > 1) {
|
||||
throw new SchemaPackTrustGateError(
|
||||
`Federated read across ${scope.sourceIds.length} sources resolves to ${packNames.size} distinct packs (${[...packNames].join(', ')}). ` +
|
||||
`Per-source closure across mounts ships in v0.40+. Until then, ` +
|
||||
`register an OAuth client scoped to a single source OR have the sources agree on one pack.`,
|
||||
);
|
||||
}
|
||||
sourceId = scope.sourceIds[0];
|
||||
}
|
||||
} else {
|
||||
sourceId = scope.sourceId;
|
||||
}
|
||||
const input: LoadActivePackInput = {
|
||||
cfg: loadConfig(),
|
||||
remote: ctx.remote ?? true, // fail-closed default
|
||||
|
||||
@@ -489,6 +489,17 @@ export interface KnobsHashContext {
|
||||
embeddingColumn?: string;
|
||||
/** Resolved provider:model, e.g. 'voyage:voyage-3-large'. */
|
||||
embeddingModel?: string;
|
||||
/**
|
||||
* v0.39 T21 + codex finding #5: cache + eval pack isolation. A cache
|
||||
* row written when pack `garry-pack@1.2` was active must NEVER be
|
||||
* served when pack `research-state@0.5` is active — they may resolve
|
||||
* different type closures for the same query. The hash folds in
|
||||
* pack name + version so cross-pack contamination is structurally
|
||||
* impossible. Undefined falls back to the literal 'none' for
|
||||
* backward compat with callers that don't yet thread pack identity.
|
||||
*/
|
||||
schemaPack?: string;
|
||||
schemaPackVersion?: string;
|
||||
}
|
||||
|
||||
export function knobsHash(
|
||||
@@ -538,6 +549,12 @@ export function knobsHash(
|
||||
// must never be served from a row that ran against `embedding`.
|
||||
`col=${ctx?.embeddingColumn ?? 'embedding'}`,
|
||||
`prov=${ctx?.embeddingModel ?? 'default'}`,
|
||||
// v0.39 T21 + codex finding #5: schema-pack name + version. Cross-pack
|
||||
// contamination is structurally impossible — a query that resolved
|
||||
// type `researcher` against pack A cannot be served from a row that
|
||||
// resolved against pack B.
|
||||
`pack=${ctx?.schemaPack ?? 'none'}`,
|
||||
`pver=${ctx?.schemaPackVersion ?? 'none'}`,
|
||||
];
|
||||
const h = createHash('sha256');
|
||||
h.update(parts.join('|'));
|
||||
|
||||
Reference in New Issue
Block a user