mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
Claude Fable 5
parent
0612b0daa8
commit
2d4f980a42
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 })];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user