diff --git a/src/core/cycle.ts b/src/core/cycle.ts index da46ce8fe..b6d0484b5 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -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 diff --git a/src/core/cycle/schema-suggest.ts b/src/core/cycle/schema-suggest.ts new file mode 100644 index 000000000..7254fe8f0 --- /dev/null +++ b/src/core/cycle/schema-suggest.ts @@ -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 { + 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, + }; + } +} diff --git a/src/core/operations.ts b/src/core/operations.ts index a88b51ec9..0f5456ed0 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -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 ? '' : '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. diff --git a/src/core/schema-pack/op-trust-gate.ts b/src/core/schema-pack/op-trust-gate.ts index f9f6e48dd..05ea6227c 100644 --- a/src/core/schema-pack/op-trust-gate.ts +++ b/src/core/schema-pack/op-trust-gate.ts @@ -81,7 +81,45 @@ export async function loadActivePackForOp( ): Promise { 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(); + 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 diff --git a/src/core/search/mode.ts b/src/core/search/mode.ts index 0d8830d2f..68944e3e1 100644 --- a/src/core/search/mode.ts +++ b/src/core/search/mode.ts @@ -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('|'));