diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index c102a7ef5..3e987cd12 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -441,7 +441,8 @@ round-trip suite (CHECK admits unresolvable+NULL, still rejects partial+true and unresolvable+true|false, pre-v80 NULL/NULL rows survive). - `src/core/cycle/base-phase.ts` — abstract `BaseCyclePhase` class. Enforces `sourceScopeOpts(ctx)` threading at the type level; closes the source-isolation leak class structurally for every new phase. Inherits source-scope, budget meter, error envelope, progress reporter. propose_takes / grade_takes / calibration_profile all extend it. -- `src/core/cycle/propose-takes.ts` — LLM scans markdown prose, proposes gradeable claims to the `take_proposals` queue. Idempotency cache on `(source_id, page_slug, content_hash, prompt_version)` composite unique index. Fence-dedup: existing canonical takes passed to the extractor as context. Ships a stub prompt; tuned prompt arrives via the synthetic corpus build. +- `src/core/cycle/propose-takes.ts` — LLM scans markdown prose, proposes gradeable claims to the `take_proposals` queue. Idempotency cache on `(source_id, page_slug, content_hash, prompt_version)` composite unique index. Fence-dedup: existing canonical takes passed to the extractor as context. Model resolves explicit `opts.model` > `models.propose_takes` config (alias-aware) > gateway default; the resolved id reaches the extractor AND the `model_id` audit column. +- `src/core/takes-proposals.ts` — take-proposal review queue (#1467): `listTakeProposals` (source-scoped, holder allow-list fail-closed like `takes_list`) + `resolveTakeProposal` (accept promotes to the canonical markdown fence via `upsertTakeRow` under the per-page lock, mirrors to DB via `addTakesBatch`, records `promoted_row_num`; reject keeps the row as audit history; double-resolve throws). Surfaced as the `takes_proposals_list` / `takes_proposal_resolve` ops (resolve is `localOnly` — accept writes repo markdown) and `gbrain takes propose [--accept N | --reject N]`. Pinned by `test/takes-proposals-review.test.ts`. - `src/core/cycle/grade-takes.ts` — walks unresolved takes older than 6 months, retrieves evidence, asks judge model, caches verdict. Auto-resolve DISABLED by default. Conservative thresholds: >=0.95 single OR >=0.85 ensemble 3/3 unanimous. `aggregateEnsemble` reuses the cross-modal substrate; fires on the borderline 0.6-0.95 band. Writes to `take_grade_cache`. - `src/core/cycle/calibration-profile.ts` — aggregates resolved takes into 2-4 narrative pattern statements + active bias tags. Voice-gated via `gateVoice()`. Cold-brain skip when <5 resolved. Writes to `calibration_profiles` with audit columns (`voice_gate_passed`, `voice_gate_attempts`, `grade_completion`). - `src/core/calibration/voice-gate.ts` — single `gateVoice()` function, mode parameter (`pattern_statement` | `nudge` | `forecast_blurb` | `dashboard_caption` | `morning_pulse`). 2 regens then template fallback from `src/core/calibration/templates.ts`. Haiku judge with mode-specific rubrics; all rubrics structurally forbid clinical/preachy voice. diff --git a/docs/guides/cron-schedule.md b/docs/guides/cron-schedule.md index a96609f2d..b4fbd7eb7 100644 --- a/docs/guides/cron-schedule.md +++ b/docs/guides/cron-schedule.md @@ -38,8 +38,9 @@ fixed. You wake up and the brain is smarter than when you went to sleep. # Meeting sync — 10 AM, 4 PM, 9 PM on weekdays 0 10,16,21 * * 1-5 cd /path/to/meeting-sync && node meeting-sync.mjs >> /tmp/meeting-sync.log 2>&1 -# Calendar sync — Sundays at 10 AM -0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date +%Y-%m-%d) +# Calendar sync — Sundays at 10 AM. The +7d end keeps upcoming events in the +# brain — the lookahead window the daily briefing's meeting prep reads from. +0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date -v+7d +%Y-%m-%d) # Brain health — weekly Mondays at 6 AM 0 6 * * 1 gbrain doctor --json >> /tmp/gbrain-health.log 2>&1 && gbrain embed --stale diff --git a/llms-full.txt b/llms-full.txt index 4de2b11e4..bfd5b13fd 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2907,8 +2907,9 @@ fixed. You wake up and the brain is smarter than when you went to sleep. # Meeting sync — 10 AM, 4 PM, 9 PM on weekdays 0 10,16,21 * * 1-5 cd /path/to/meeting-sync && node meeting-sync.mjs >> /tmp/meeting-sync.log 2>&1 -# Calendar sync — Sundays at 10 AM -0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date +%Y-%m-%d) +# Calendar sync — Sundays at 10 AM. The +7d end keeps upcoming events in the +# brain — the lookahead window the daily briefing's meeting prep reads from. +0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date -v+7d +%Y-%m-%d) # Brain health — weekly Mondays at 6 AM 0 6 * * 1 gbrain doctor --json >> /tmp/gbrain-health.log 2>&1 && gbrain embed --stale diff --git a/recipes/calendar-to-brain.md b/recipes/calendar-to-brain.md index afcd87116..afe6b03ae 100644 --- a/recipes/calendar-to-brain.md +++ b/recipes/calendar-to-brain.md @@ -221,9 +221,14 @@ This is the big initial sync. It may take 10-30 minutes depending on how many years of calendar data you have. ```bash -node calendar-sync.mjs --start 2020-01-01 --end $(date +%Y-%m-%d) +node calendar-sync.mjs --start 2020-01-01 --end $(date -v+7d +%Y-%m-%d) ``` +The end date is a week AHEAD of today, not today. The headline promise — +"knows who you're meeting tomorrow" — depends on upcoming events being in the +brain; a sync that stops at today never ingests tomorrow's meetings. (On +Linux, use `$(date -d '+7 days' +%Y-%m-%d)`.) + Tell the user: "Syncing calendar history from [start year]. This creates one markdown file per day. For 4 years of data, expect ~1,400 daily files." @@ -261,10 +266,12 @@ This is YOUR job (the agent). For each person who appears in calendar events: ### Step 7: Set Up Weekly Sync -The calendar should sync weekly to stay current: +The calendar should sync weekly to stay current. The window runs from a week +back (catches edits to recent events) to a week AHEAD (keeps the lookahead +window stocked so tomorrow's meeting prep has something to read): ```bash # Cron: every Sunday at 10 AM -0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date +%Y-%m-%d) +0 10 * * 0 cd /path/to/calendar-sync && node calendar-sync.mjs --start $(date -v-7d +%Y-%m-%d) --end $(date -v+7d +%Y-%m-%d) ``` After sync, import new data: diff --git a/src/commands/takes.ts b/src/commands/takes.ts index 4cad6b7a7..a4956a245 100644 --- a/src/commands/takes.ts +++ b/src/commands/takes.ts @@ -571,6 +571,11 @@ Subcommands: Aggregate calibration scorecard (v0.30.0) takes calibration [] [--bucket-size 0.1] [--json] Calibration curve binned by stated weight (v0.30.0) + takes propose [--status pending] [--page ] [--limit N] [--json] + List queued take proposals from the propose_takes phase (#1467) + takes propose --accept [--dir ] + Promote a proposal into the page's canonical takes fence + takes propose --reject Reject a proposal (kept as audit history) Common flags: --dir Override the brain directory (default: sync.repo_path config) @@ -592,6 +597,7 @@ Common flags: case 'calibration': return cmdCalibration(engine, rest); case 'revisit': return cmdRevisit(engine, rest); case 'extract': return cmdExtract(engine, rest); + case 'propose': return cmdPropose(engine, rest, await resolveTakesSourceId(engine)); default: // No subcommand keyword → treat first arg as for the list path. return cmdList(engine, args); @@ -708,3 +714,72 @@ async function cmdRevisit(_engine: BrainEngine, rest: string[]): Promise { } void execFileSync; } + +/** + * #1467 — `gbrain takes propose`: review queue for the propose_takes cycle + * phase. No verdict flag → list (pending by default). `--accept ` + * promotes the claim into the page's canonical takes fence (markdown + DB, + * same contract as `takes add`); `--reject ` records the verdict and + * keeps the row as audit history. + */ +async function cmdPropose(engine: BrainEngine, args: string[], sourceId?: string): Promise { + const { listTakeProposals, resolveTakeProposal } = await import('../core/takes-proposals.ts'); + const json = flagPresent(args, '--json'); + const acceptId = flagValue(args, '--accept'); + const rejectId = flagValue(args, '--reject'); + + if (acceptId !== undefined || rejectId !== undefined) { + if (acceptId !== undefined && rejectId !== undefined) { + console.error('Pass either --accept or --reject , not both.'); + process.exit(1); + } + const raw = (acceptId ?? rejectId)!; + const id = parseInt(raw, 10); + if (!Number.isFinite(id)) { + console.error(`Expected a numeric proposal id, got "${raw}".`); + process.exit(1); + } + const verdict = acceptId !== undefined ? 'accept' as const : 'reject' as const; + const dirArg = flagValue(args, '--dir'); + const brainDir = verdict === 'accept' + ? await resolveBrainDir(engine, dirArg ?? null) + : undefined; + try { + const result = await resolveTakeProposal(engine, { id, verdict, brainDir, sourceId, actedBy: 'cli' }); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log( + result.status === 'accepted' + ? `Accepted proposal #${result.id} → ${result.page_slug} take #${result.promoted_row_num}.` + : `Rejected proposal #${result.id} (${result.page_slug}).`, + ); + } catch (err) { + console.error((err as Error).message); + process.exit(1); + } + return; + } + + const status = flagValue(args, '--status') ?? 'pending'; + const rows = await listTakeProposals(engine, { + sourceId, + status, + pageSlug: flagValue(args, '--page'), + limit: flagValue(args, '--limit') ? parseInt(flagValue(args, '--limit')!, 10) : undefined, + }); + if (json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + if (rows.length === 0) { + console.log(`No ${status} take proposals. The propose_takes cycle phase fills this queue (\`gbrain cycle --phases propose_takes\`).`); + return; + } + for (const r of rows) { + const w = typeof r.weight === 'number' ? r.weight.toFixed(2) : String(r.weight); + console.log(`#${r.id} ${r.page_slug} [${r.kind} / ${r.holder} / w=${w}] ${r.claim_text}`); + } + console.log(`\n${rows.length} ${status} proposal(s). \`gbrain takes propose --accept \` promotes to the takes fence; \`--reject \` dismisses.`); +} diff --git a/src/core/cycle/propose-takes.ts b/src/core/cycle/propose-takes.ts index 63ada141e..c64baf587 100644 --- a/src/core/cycle/propose-takes.ts +++ b/src/core/cycle/propose-takes.ts @@ -40,6 +40,7 @@ import { randomUUID, createHash } from 'node:crypto'; import { BaseCyclePhase, type ScopedReadOpts, type BasePhaseOpts } from './base-phase.ts'; import { chat as gatewayChat, getChatModel } from '../ai/gateway.ts'; +import { resolveModel } from '../model-config.ts'; import { writeReceipt } from '../extract/receipt-writer.ts'; import { upsertExtractRollup } from '../extract/rollup-writer.ts'; import { GBrainError } from '../types.ts'; @@ -330,7 +331,14 @@ class ProposeTakesPhase extends BaseCyclePhase { opts.reporter.start('propose_takes.pages' as never, pages.length); } - const modelId = opts.model ?? getChatModel(); + // #1467: model resolution — explicit opts.model > `models.propose_takes` + // config (alias-aware via resolveModel, so `sonnet` works) > the gateway + // default chat model (pre-#1467 behavior, and the fallback resolveModel + // lands on when neither key nor models.default/GBRAIN_MODEL is set). + const modelId = opts.model ?? await resolveModel(engine, { + configKey: 'models.propose_takes', + fallback: getChatModel(), + }); for (const page of pages) { result.pages_scanned += 1; @@ -380,7 +388,10 @@ class ProposeTakesPhase extends BaseCyclePhase { pagePath: page.slug, pageBody: body, existingTakes, - modelHint: opts.model, + // #1467: pass the RESOLVED model so a `models.propose_takes` config + // actually routes the LLM call (pre-fix only the explicit opts.model + // reached the extractor; budget check and call could disagree). + modelHint: modelId, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/core/operations.ts b/src/core/operations.ts index ab2d4cbf1..e161b9439 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1840,6 +1840,70 @@ const takes_calibration: Operation = { cliHints: { name: 'takes-calibration' }, }; +/** + * #1467 — take-proposal review queue. The propose_takes cycle phase writes + * candidate claims to `take_proposals`; these ops are the operator review + * surface (list pending → accept/reject). Holder allow-list contract matches + * takes_list: MCP-bound callers only see permitted holders' proposals. + */ +const takes_proposals_list: Operation = { + name: 'takes_proposals_list', + description: 'List take proposals from the propose_takes review queue (default: pending).', + scope: 'read', + params: { + status: { type: 'string', description: 'pending | accepted | rejected | superseded (default pending)' }, + page_slug: { type: 'string', description: 'Filter to this page' }, + limit: { type: 'number', description: 'Max rows (default 50, cap 200)' }, + offset: { type: 'number', description: 'Skip first N rows' }, + }, + handler: async (ctx, p) => { + const { listTakeProposals } = await import('./takes-proposals.ts'); + return listTakeProposals(ctx.engine, { + ...sourceScopeOpts(ctx), + status: p.status as string | undefined, + pageSlug: p.page_slug as string | undefined, + limit: p.limit as number | undefined, + offset: p.offset as number | undefined, + takesHoldersAllowList: ctx.takesHoldersAllowList, + }); + }, + cliHints: { name: 'takes-proposals-list' }, +}; + +const takes_proposal_resolve: Operation = { + name: 'takes_proposal_resolve', + description: 'Accept or reject a pending take proposal. Accept promotes the claim into the page\'s canonical takes fence (markdown + DB mirror).', + scope: 'write', + mutating: true, + // Accept writes markdown into the local brain repo — same trust posture as + // the other repo-writing ops. Remote/MCP callers are blocked fail-closed. + localOnly: true, + params: { + id: { type: 'number', required: true, description: 'Proposal id (from takes_proposals_list)' }, + verdict: { type: 'string', required: true, description: 'accept | reject' }, + dir: { type: 'string', description: 'Brain repo dir override (default: sync.repo_path config)' }, + }, + handler: async (ctx, p) => { + const verdict = p.verdict as string; + if (verdict !== 'accept' && verdict !== 'reject') { + throw new Error(`takes_proposal_resolve: verdict must be "accept" or "reject", got "${verdict}"`); + } + const { resolveTakeProposal } = await import('./takes-proposals.ts'); + let brainDir = p.dir as string | undefined; + if (!brainDir && verdict === 'accept') { + brainDir = (await ctx.engine.getConfig('sync.repo_path')) ?? undefined; + } + return resolveTakeProposal(ctx.engine, { + id: p.id as number, + verdict, + brainDir, + ...sourceScopeOpts(ctx), + actedBy: ctx.remote === false ? 'cli' : 'mcp', + }); + }, + cliHints: { name: 'takes-proposal-resolve' }, +}; + const think: Operation = { name: 'think', description: 'Multi-hop synthesis across pages + takes + graph. Pulls relevant evidence and produces a cited answer with conflict + gap analysis.', @@ -5405,6 +5469,8 @@ export const operations: Operation[] = [ takes_list, takes_search, think, // v0.30: calibration aggregates over takes takes_scorecard, takes_calibration, + // #1467: take-proposal review queue + takes_proposals_list, takes_proposal_resolve, // v0.28: whoami + scoped sources management whoami, sources_add, sources_list, sources_remove, sources_status, // v0.29: Salience + anomalies + recent transcripts diff --git a/src/core/takes-proposals.ts b/src/core/takes-proposals.ts new file mode 100644 index 000000000..2bc92e304 --- /dev/null +++ b/src/core/takes-proposals.ts @@ -0,0 +1,218 @@ +/** + * #1467 — take-proposal review queue (list / accept / reject). + * + * The propose_takes cycle phase writes candidate claims to `take_proposals`; + * this module is the operator path from queue to canonical fence that + * propose-takes.ts's doc comment promises ("User accepts/rejects via + * `gbrain takes propose`"). Shared by the `takes_proposals_list` / + * `takes_proposal_resolve` operations and the `gbrain takes propose` CLI. + * + * Accept follows the same markdown-is-canonical contract as `takes add`: + * per-page file lock → fence append via upsertTakeRow → DB mirror via + * addTakesBatch → verdict row update (promoted_row_num recorded for audit). + */ + +import { join, dirname } from 'node:path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import type { BrainEngine } from './engine.ts'; +import { upsertTakeRow } from './takes-fence.ts'; +import { withPageLock } from './page-lock.ts'; +import { GBrainError } from './types.ts'; + +export interface TakeProposalRow { + id: number; + source_id: string; + page_slug: string; + status: 'pending' | 'accepted' | 'rejected' | 'superseded'; + claim_text: string; + kind: string; + holder: string; + weight: number; + domain: string | null; + model_id: string; + proposed_at: string; + promoted_row_num: number | null; +} + +const PROPOSAL_COLUMNS = + 'id, source_id, page_slug, status, claim_text, kind, holder, weight, ' + + 'domain, model_id, proposed_at, promoted_row_num'; + +export interface ListTakeProposalsOpts { + sourceId?: string; + sourceIds?: string[]; + /** pending (default) | accepted | rejected | superseded */ + status?: string; + pageSlug?: string; + limit?: number; // default 50, cap 200 + offset?: number; + /** Per-token holder allow-list (MCP callers) — same contract as takes_list. */ + takesHoldersAllowList?: string[]; +} + +export async function listTakeProposals( + engine: BrainEngine, + opts: ListTakeProposalsOpts = {}, +): Promise { + const params: unknown[] = []; + const bind = (v: unknown): string => { + params.push(v); + return `$${params.length}`; + }; + const where: string[] = []; + + // Source isolation: federated array beats scalar (sourceScopeOpts precedence). + if (opts.sourceIds && opts.sourceIds.length > 0) { + where.push(`source_id IN (${opts.sourceIds.map(bind).join(', ')})`); + } else if (opts.sourceId) { + where.push(`source_id = ${bind(opts.sourceId)}`); + } + where.push(`status = ${bind(opts.status ?? 'pending')}`); + if (opts.pageSlug) where.push(`page_slug = ${bind(opts.pageSlug)}`); + if (opts.takesHoldersAllowList) { + // Fail-closed: an empty allow-list means "no holders visible", not "all". + if (opts.takesHoldersAllowList.length === 0) return []; + where.push(`holder IN (${opts.takesHoldersAllowList.map(bind).join(', ')})`); + } + + const limit = Math.min(Math.max(1, Math.floor(opts.limit ?? 50)), 200); + const offset = Math.max(0, Math.floor(opts.offset ?? 0)); + return engine.executeRaw( + `SELECT ${PROPOSAL_COLUMNS} + FROM take_proposals + WHERE ${where.join(' AND ')} + ORDER BY proposed_at DESC, id DESC + LIMIT ${bind(limit)} OFFSET ${bind(offset)}`, + params, + ); +} + +export interface ResolveTakeProposalOpts { + id: number; + verdict: 'accept' | 'reject'; + /** Brain repo dir. Required for accept (markdown is canonical). */ + brainDir?: string; + sourceId?: string; + sourceIds?: string[]; + /** Audit tag for acted_by (default 'cli'). */ + actedBy?: string; +} + +export interface ResolveTakeProposalResult { + id: number; + status: 'accepted' | 'rejected'; + page_slug: string; + promoted_row_num?: number; +} + +export async function resolveTakeProposal( + engine: BrainEngine, + opts: ResolveTakeProposalOpts, +): Promise { + const params: unknown[] = []; + const bind = (v: unknown): string => { + params.push(v); + return `$${params.length}`; + }; + const where: string[] = [`id = ${bind(opts.id)}`]; + if (opts.sourceIds && opts.sourceIds.length > 0) { + where.push(`source_id IN (${opts.sourceIds.map(bind).join(', ')})`); + } else if (opts.sourceId) { + where.push(`source_id = ${bind(opts.sourceId)}`); + } + const rows = await engine.executeRaw( + `SELECT ${PROPOSAL_COLUMNS} FROM take_proposals WHERE ${where.join(' AND ')} LIMIT 1`, + params, + ); + const proposal = rows[0]; + if (!proposal) { + throw new GBrainError( + 'PROPOSAL_NOT_FOUND', + `take proposal #${opts.id} not found in scope`, + 'list pending proposals with `gbrain takes propose` and use one of the listed ids', + ); + } + if (proposal.status !== 'pending') { + throw new GBrainError( + 'PROPOSAL_ALREADY_RESOLVED', + `take proposal #${opts.id} is already ${proposal.status}`, + 'only pending proposals can be accepted or rejected', + ); + } + + const actedBy = opts.actedBy ?? 'cli'; + + if (opts.verdict === 'reject') { + await engine.executeRaw( + `UPDATE take_proposals SET status = 'rejected', acted_at = now(), acted_by = $1 WHERE id = $2`, + [actedBy, proposal.id], + ); + return { id: proposal.id, status: 'rejected', page_slug: proposal.page_slug }; + } + + // Accept: promote into the canonical fence + DB, then mark the verdict. + if (!opts.brainDir) { + throw new GBrainError( + 'NO_BRAIN_DIR', + 'accepting a proposal writes the canonical markdown fence, which needs the brain repo path', + 'pass --dir or set the sync.repo_path config', + ); + } + // Page must exist in the DB (proposals are extracted from DB pages; a + // missing page means it was deleted or the source drifted — fail loudly). + const pageRows = await engine.executeRaw<{ id: number }>( + `SELECT id FROM pages WHERE slug = $1 AND source_id = $2 LIMIT 1`, + [proposal.page_slug, proposal.source_id], + ); + const pageId = pageRows[0]?.id; + if (!pageId) { + throw new GBrainError( + 'PROPOSAL_PAGE_NOT_FOUND', + `page ${proposal.page_slug} (source=${proposal.source_id}) is no longer in the brain`, + 'run `gbrain sync` to reconcile, or reject the proposal', + ); + } + + let promotedRowNum = 0; + await withPageLock(proposal.page_slug, async () => { + const path = join(opts.brainDir!, `${proposal.page_slug}.md`); + const body = existsSync(path) ? readFileSync(path, 'utf-8') : ''; + const next = upsertTakeRow(body, { + claim: proposal.claim_text, + kind: proposal.kind, + holder: proposal.holder, + weight: proposal.weight, + source: `proposed by ${proposal.model_id}`, + active: true, + }); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, next.body, 'utf-8'); + promotedRowNum = next.rowNum; + + await engine.addTakesBatch([{ + page_id: pageId, + row_num: promotedRowNum, + claim: proposal.claim_text, + kind: proposal.kind, + holder: proposal.holder, + weight: proposal.weight, + source: `proposed by ${proposal.model_id}`, + active: true, + superseded_by: null, + }]); + + await engine.executeRaw( + `UPDATE take_proposals + SET status = 'accepted', acted_at = now(), acted_by = $1, promoted_row_num = $2 + WHERE id = $3`, + [actedBy, promotedRowNum, proposal.id], + ); + }); + + return { + id: proposal.id, + status: 'accepted', + page_slug: proposal.page_slug, + promoted_row_num: promotedRowNum, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae5ab4c3f..11bf5ea40 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -15,6 +15,21 @@ import { } from '../core/context/resolve-ipc.ts'; import { resolveEntitiesToPointers, logDeliveredReflexPointers } from '../core/context/retrieval-reflex.ts'; +/** + * #2657: stdio takes-holder allow-list. Fail-closed default ['world'] (the + * v0.28 contract — agent-facing callers never see private hunches unless the + * operator opts in). GBRAIN_MCP_TAKES_HOLDERS is the env escape hatch (same + * pattern as GBRAIN_SOURCE): comma-separated holder list, e.g. + * `GBRAIN_MCP_TAKES_HOLDERS=world,brain,people/alice-example`. + * Empty / whitespace-only values fall back to the default (never widen). + */ +export function resolveStdioTakesHolders( + raw: string | undefined = process.env.GBRAIN_MCP_TAKES_HOLDERS, +): string[] { + const parsed = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean); + return parsed.length > 0 ? parsed : ['world']; +} + export async function startMcpServer(engine: BrainEngine) { const server = new Server( { name: 'gbrain', version: VERSION }, @@ -38,11 +53,11 @@ export async function startMcpServer(engine: BrainEngine) { // v0.28: stdio MCP has no per-token auth (local pipe). Default the // takes-holder allow-list to ['world'] so agent-facing callers don't // see private hunches via takes_list / takes_search / query. Operators - // who want stdio to see everything should call ops directly via - // `gbrain call ` (sets remote=false in src/cli.ts). + // widen it with GBRAIN_MCP_TAKES_HOLDERS (comma-separated, #2657) or + // call ops directly via `gbrain call ` (sets remote=false in cli.ts). return dispatchToolCall(engine, name, params, { remote: true, - takesHoldersAllowList: ['world'], + takesHoldersAllowList: resolveStdioTakesHolders(), // v0.31: source defaults to 'default' for stdio (no per-token scope). // Operators who want a different source on stdio MCP should set // GBRAIN_SOURCE in the env or use --source via `gbrain call`. diff --git a/test/mcp-stdio-takes-holders.test.ts b/test/mcp-stdio-takes-holders.test.ts new file mode 100644 index 000000000..1df133753 --- /dev/null +++ b/test/mcp-stdio-takes-holders.test.ts @@ -0,0 +1,34 @@ +/** + * #2657 — stdio MCP takes-holder allow-list operator override. + * + * The stdio transport hardcoded takesHoldersAllowList to ['world'] with no + * escape hatch, making non-world takes unreachable for stdio MCP agents. + * GBRAIN_MCP_TAKES_HOLDERS (comma-separated) is the opt-in widening; the + * fail-closed ['world'] default is preserved when unset/empty/garbage. + */ +import { describe, test, expect } from 'bun:test'; +import { resolveStdioTakesHolders } from '../src/mcp/server.ts'; + +describe('resolveStdioTakesHolders (#2657)', () => { + test('unset → fail-closed default ["world"]', () => { + expect(resolveStdioTakesHolders(undefined)).toEqual(['world']); + }); + + test('empty / whitespace-only → default, never an empty allow-list', () => { + expect(resolveStdioTakesHolders('')).toEqual(['world']); + expect(resolveStdioTakesHolders(' ')).toEqual(['world']); + expect(resolveStdioTakesHolders(', ,')).toEqual(['world']); + }); + + test('comma-split with trimming', () => { + expect(resolveStdioTakesHolders('world, brain , people/alice-example')).toEqual([ + 'world', + 'brain', + 'people/alice-example', + ]); + }); + + test('single non-world holder is honored (the unreachable-takes case)', () => { + expect(resolveStdioTakesHolders('brain')).toEqual(['brain']); + }); +}); diff --git a/test/propose-takes.test.ts b/test/propose-takes.test.ts index 3c0ccb68d..1d33937c0 100644 --- a/test/propose-takes.test.ts +++ b/test/propose-takes.test.ts @@ -41,12 +41,16 @@ interface CapturedSql { function buildMockEngine(opts: { pages: Page[]; existingProposals?: Set; // composite-key strings already in take_proposals + config?: Record; // #1467: models.propose_takes resolution }): { engine: BrainEngine; captured: CapturedSql[] } { const captured: CapturedSql[] = []; const existing = opts.existingProposals ?? new Set(); const engine = { kind: 'pglite', + async getConfig(key: string) { + return opts.config?.[key] ?? null; + }, async listPages() { return opts.pages; }, @@ -267,6 +271,28 @@ describe('runPhaseProposeTakes — phase integration', () => { expect(inserts[0]!.params[9]).toBe('market'); // domain }); + test('#1467: models.propose_takes config routes the extractor model + model_id column', async () => { + const pages = [buildPage({ slug: 'wiki/model-routing', body: 'Configured models should reach the extractor.' })]; + const { engine, captured } = buildMockEngine({ + pages, + config: { 'models.propose_takes': 'anthropic:claude-sonnet-5' }, + }); + let seenModelHint: string | undefined; + const extractor: ProposeTakesExtractor = async (input) => { + seenModelHint = input.modelHint; + return [{ claim_text: 'Config routing works', kind: 'take', holder: 'brain', weight: 0.6 }]; + }; + const result = await runPhaseProposeTakes(buildCtx(engine), { extractor }); + + expect(result.status).toBe('ok'); + // The RESOLVED model (config key, no explicit opts.model) reaches the + // extractor AND lands in the take_proposals.model_id audit column. + expect(seenModelHint).toBe('anthropic:claude-sonnet-5'); + const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals')); + expect(inserts).toHaveLength(1); + expect(inserts[0]!.params[11]).toBe('anthropic:claude-sonnet-5'); // model_id + }); + test('cache hit: page already in take_proposals is skipped', async () => { const body = 'A page that was already processed.'; const pages = [buildPage({ slug: 'wiki/old-page', body })]; diff --git a/test/recipe-pricing-drift.test.ts b/test/recipe-pricing-drift.test.ts new file mode 100644 index 000000000..32393424f --- /dev/null +++ b/test/recipe-pricing-drift.test.ts @@ -0,0 +1,34 @@ +/** + * #2689 — recipe allowlist ↔ canonical pricing drift guard. + * + * The Anthropic chat allowlist once predated the canonical pricing table + * (pricing knew claude-sonnet-5; the recipe rejected it), so a config-set + * model the whole system "knew" still failed the chat-client probe. This + * guard pins the invariant the other way around: every model id the + * anthropic recipe accepts (chat + expansion) MUST resolve in + * CANONICAL_PRICING, so an allowlist addition can never outrun pricing and + * a pricing-known Anthropic model missing from the allowlist shows up in + * review as an explicit divergence, not silent rot. + */ +import { describe, test, expect } from 'bun:test'; +import { anthropic } from '../src/core/ai/recipes/anthropic.ts'; +import { canonicalLookup } from '../src/core/model-pricing.ts'; + +describe('anthropic recipe ↔ canonical pricing (#2689 drift guard)', () => { + const recipeIds = new Set([ + ...(anthropic.touchpoints.chat?.models ?? []), + ...(anthropic.touchpoints.expansion?.models ?? []), + // Alias targets must be priced too — they're what actually hits the wire. + ...Object.values(anthropic.aliases ?? {}), + ]); + + test('recipe declares at least one chat model', () => { + expect(recipeIds.size).toBeGreaterThan(0); + }); + + for (const id of recipeIds) { + test(`recipe model "${id}" is priced in CANONICAL_PRICING`, () => { + expect(canonicalLookup(`anthropic:${id}`)).toBeTruthy(); + }); + } +}); diff --git a/test/takes-proposals-review.test.ts b/test/takes-proposals-review.test.ts new file mode 100644 index 000000000..fb58cabc5 --- /dev/null +++ b/test/takes-proposals-review.test.ts @@ -0,0 +1,143 @@ +/** + * #1467 — take-proposal review queue (list / accept / reject). + * + * Real PGLite engine (in-memory) + a temp brain dir. Pins the queue→fence + * promotion contract: list shows pending proposals (holder allow-list + * respected), accept writes the canonical markdown fence + DB take + + * verdict row with promoted_row_num, reject records the verdict, and a + * second resolve on the same id fails loudly. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { listTakeProposals, resolveTakeProposal } from '../src/core/takes-proposals.ts'; + +let engine: PGLiteEngine; +let brainDir: string; + +async function insertProposal(opts: { + slug: string; + claim: string; + holder?: string; + status?: string; + hash?: string; +}): Promise { + const rows = await engine.executeRaw<{ id: number }>( + `INSERT INTO take_proposals + (source_id, page_slug, content_hash, prompt_version, proposal_run_id, + status, claim_text, kind, holder, weight, domain, model_id) + VALUES ('default', $1, $2, 'test-v1', 'run-test', + $3, $4, 'take', $5, 0.6, NULL, 'anthropic:claude-sonnet-4-6') + RETURNING id`, + [opts.slug, opts.hash ?? `hash-${opts.slug}-${opts.claim.length}`, opts.status ?? 'pending', opts.claim, opts.holder ?? 'brain'], + ); + return rows[0]!.id; +} + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + brainDir = mkdtempSync(join(tmpdir(), 'gbrain-proposals-')); + await engine.putPage('people/alice-example', { + title: 'Alice Example', + type: 'person' as const, + compiled_truth: 'Alice is a strong founder.\n', + }); +}); + +afterAll(async () => { + await engine.disconnect(); + rmSync(brainDir, { recursive: true, force: true }); +}); + +describe('listTakeProposals (#1467)', () => { + test('lists pending by default, respects holder allow-list', async () => { + const brainId = await insertProposal({ slug: 'people/alice-example', claim: 'Alice will raise a Series A', holder: 'brain', hash: 'h-list-1' }); + await insertProposal({ slug: 'people/alice-example', claim: 'Consensus view of Alice', holder: 'world', hash: 'h-list-2' }); + await insertProposal({ slug: 'people/alice-example', claim: 'Already rejected', status: 'rejected', hash: 'h-list-3' }); + + const all = await listTakeProposals(engine, { sourceId: 'default' }); + expect(all.map((r) => r.id)).toContain(brainId); + expect(all.every((r) => r.status === 'pending')).toBe(true); + expect(all.some((r) => r.claim_text === 'Already rejected')).toBe(false); + + // MCP-style allow-list: only 'world' holders visible. + const worldOnly = await listTakeProposals(engine, { sourceId: 'default', takesHoldersAllowList: ['world'] }); + expect(worldOnly.length).toBeGreaterThan(0); + expect(worldOnly.every((r) => r.holder === 'world')).toBe(true); + + // Fail-closed: empty allow-list → nothing, not everything. + expect(await listTakeProposals(engine, { sourceId: 'default', takesHoldersAllowList: [] })).toEqual([]); + + // Source isolation: a different source sees nothing. + expect(await listTakeProposals(engine, { sourceId: 'other-source' })).toEqual([]); + }); +}); + +describe('resolveTakeProposal (#1467)', () => { + test('accept promotes to markdown fence + DB take + verdict row', async () => { + const id = await insertProposal({ slug: 'people/alice-example', claim: 'Alice ships weekly', hash: 'h-accept' }); + const result = await resolveTakeProposal(engine, { id, verdict: 'accept', brainDir, sourceId: 'default', actedBy: 'test' }); + + expect(result.status).toBe('accepted'); + expect(result.promoted_row_num).toBeGreaterThanOrEqual(1); + + // Markdown fence written (markdown is canonical). + const mdPath = join(brainDir, 'people/alice-example.md'); + expect(existsSync(mdPath)).toBe(true); + expect(readFileSync(mdPath, 'utf-8')).toContain('Alice ships weekly'); + + // DB mirror. + const takes = await engine.listTakes({ page_slug: 'people/alice-example' }); + const promoted = takes.find((t) => t.claim === 'Alice ships weekly'); + expect(promoted).toBeTruthy(); + expect(promoted!.row_num).toBe(result.promoted_row_num!); + + // Verdict row. + const rows = await engine.executeRaw<{ status: string; promoted_row_num: number; acted_by: string }>( + `SELECT status, promoted_row_num, acted_by FROM take_proposals WHERE id = $1`, + [id], + ); + expect(rows[0]!.status).toBe('accepted'); + expect(rows[0]!.promoted_row_num).toBe(result.promoted_row_num!); + expect(rows[0]!.acted_by).toBe('test'); + + // Double-resolve fails loudly. + await expect( + resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'default' }), + ).rejects.toThrow(/already accepted/); + }); + + test('reject records the verdict without touching the fence', async () => { + const id = await insertProposal({ slug: 'people/alice-example', claim: 'Overreaching claim', hash: 'h-reject' }); + const result = await resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'default' }); + expect(result.status).toBe('rejected'); + + const rows = await engine.executeRaw<{ status: string }>( + `SELECT status FROM take_proposals WHERE id = $1`, [id], + ); + expect(rows[0]!.status).toBe('rejected'); + expect(readFileSync(join(brainDir, 'people/alice-example.md'), 'utf-8')).not.toContain('Overreaching claim'); + }); + + test('accept without a brain dir fails loudly (markdown is canonical)', async () => { + const id = await insertProposal({ slug: 'people/alice-example', claim: 'No dir claim', hash: 'h-nodir' }); + await expect( + resolveTakeProposal(engine, { id, verdict: 'accept', sourceId: 'default' }), + ).rejects.toThrow(/NO_BRAIN_DIR/); + }); + + test('unknown or out-of-scope id fails loudly', async () => { + await expect( + resolveTakeProposal(engine, { id: 9_999_999, verdict: 'reject', sourceId: 'default' }), + ).rejects.toThrow(/PROPOSAL_NOT_FOUND/); + // Source isolation: an id that exists is invisible from another source. + const id = await insertProposal({ slug: 'people/alice-example', claim: 'Scoped claim', hash: 'h-scope' }); + await expect( + resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'other-source' }), + ).rejects.toThrow(/PROPOSAL_NOT_FOUND/); + }); +});