fix(gateway): provider recipes, stdio takes allow-list override, proposal review CLI

- #2657: stdio MCP takesHoldersAllowList is operator-overridable via
  GBRAIN_MCP_TAKES_HOLDERS (comma-separated). Fail-closed ['world']
  default preserved; empty/garbage values never widen.
- #2689 (drift-guard half): pin every anthropic-recipe model id (chat +
  expansion + alias targets) to CANONICAL_PRICING so the allowlist can
  never rot ahead of / behind the canonical pricing table again. The
  Claude 5 allowlist entries and the honest MODEL_NOT_USABLE labeling
  for configured models already landed on master.
- #2790: calendar-to-brain recipe backfill + weekly cron now end a week
  AHEAD of today so the lookahead window ('knows who you're meeting
  tomorrow') actually has upcoming events to read; cron guide updated.
- #1467: take-proposal review surface — new src/core/takes-proposals.ts
  (list/accept/reject; accept promotes to the canonical takes fence
  under the per-page lock + DB mirror + promoted_row_num audit), wired
  as contract-first ops takes_proposals_list / takes_proposal_resolve
  (resolve is localOnly) and 'gbrain takes propose [--accept N|--reject N]'.
  propose_takes phase model now resolves models.propose_takes config
  (alias-aware) and threads the resolved id to the extractor + model_id
  column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-21 14:37:55 -07:00
co-authored by Claude Fable 5
parent 0612b0daa8
commit 2d4f980a42
13 changed files with 645 additions and 13 deletions
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+10 -3
View File
@@ -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:
+75
View File
@@ -571,6 +571,11 @@ Subcommands:
Aggregate calibration scorecard (v0.30.0)
takes calibration [<holder>] [--bucket-size 0.1] [--json]
Calibration curve binned by stated weight (v0.30.0)
takes propose [--status pending] [--page <slug>] [--limit N] [--json]
List queued take proposals from the propose_takes phase (#1467)
takes propose --accept <id> [--dir <path>]
Promote a proposal into the page's canonical takes fence
takes propose --reject <id> Reject a proposal (kept as audit history)
Common flags:
--dir <path> 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 <slug> for the list path.
return cmdList(engine, args);
@@ -708,3 +714,72 @@ async function cmdRevisit(_engine: BrainEngine, rest: string[]): Promise<void> {
}
void execFileSync;
}
/**
* #1467 — `gbrain takes propose`: review queue for the propose_takes cycle
* phase. No verdict flag → list (pending by default). `--accept <id>`
* promotes the claim into the page's canonical takes fence (markdown + DB,
* same contract as `takes add`); `--reject <id>` records the verdict and
* keeps the row as audit history.
*/
async function cmdPropose(engine: BrainEngine, args: string[], sourceId?: string): Promise<void> {
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 <id> or --reject <id>, 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 <id>\` promotes to the takes fence; \`--reject <id>\` dismisses.`);
}
+13 -2
View File
@@ -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);
+66
View File
@@ -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
+218
View File
@@ -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<TakeProposalRow[]> {
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<TakeProposalRow>(
`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<ResolveTakeProposalResult> {
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<TakeProposalRow>(
`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 <path> 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,
};
}
+18 -3
View File
@@ -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 <op>` (sets remote=false in src/cli.ts).
// widen it with GBRAIN_MCP_TAKES_HOLDERS (comma-separated, #2657) or
// call ops directly via `gbrain call <op>` (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`.
+34
View File
@@ -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']);
});
});
+26
View File
@@ -41,12 +41,16 @@ interface CapturedSql {
function buildMockEngine(opts: {
pages: Page[];
existingProposals?: Set<string>; // composite-key strings already in take_proposals
config?: Record<string, string>; // #1467: models.propose_takes resolution
}): { engine: BrainEngine; captured: CapturedSql[] } {
const captured: CapturedSql[] = [];
const existing = opts.existingProposals ?? new Set<string>();
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 })];
+34
View File
@@ -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<string>([
...(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();
});
}
});
+143
View File
@@ -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<number> {
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/);
});
});