openvibers/openvibe.codes Commits

Add modular model plans for coding tasks (#410)

OP OpenVibers committed 2 days ago · 8f95c63b6c · 32 files, +1094 −28

* Add modular model plans to Codes tasks

* Cover model plans in account data and public checks

* Merge remote-tracking branch 'origin/main' into feat/codes-model-plans

* Escalate plans on low builder confidence

Changes

ddiff --git a/.env.example b/.env.example
iindex a08d7d1..e69f057 100644
---- a/.env.example
++++ b/.env.example
@@@ -48,6 +48,9 @@ OV_OAUTH_CLIENT_SECRET=
# either off, the pickers are exactly as before. The answer is cached five minutes.
#CODES_AI_CATALOG=0
#CODES_AI_CATALOG_TTL_MS=300000
++# Model plans: saved account/project choices, repository models: config and per-task roles use AI routing.
++# Leave off until the Contracts and SDK model-plan releases are pinned.
++#CODES_MODEL_PLANS=0
# OpenVibe.Exchange's compatible API: the deepseek harness's default path (a run token names the person; the same
# chat-completions API and model ids as OpenVibe.AI). CODES_EXCHANGE_URL overrides https://api.openvibe.exchange.
ddiff --git a/docs/model-plans.md b/docs/model-plans.md
nnew file mode 100644
iindex 0000000..927b539
---- /dev/null
++++ b/docs/model-plans.md
@@@ -0,0 +1,35 @@
++# Model plans
++
++Model plans choose how a coding task uses OpenVibe.AI models. A plan has a mode, a list of roles, and checks. Every role call goes through AI's existing provider routing and uses the task's run token. The saved plan changes task behavior only when the owner enables `CODES_MODEL_PLANS=1`.
++
++## Modes
++
++- `single`: one selected model builds and verifies the result.
++- `pair`: a builder and a reviewer; the reviewer must approve. Findings return to the builder once.
++- `council`: two or three planner proposals; a judge picks or merges them with reasons before building and review.
++- `escalate`: start at the first ladder model and move up on a failed check, model refusal or low confidence. The task records the reason.
++
++The copyable presets are Fast, Balanced, Deep and Council. Read them at `GET /api/v1/model-plans/presets`. Each role names an AI registry model id or `auto`, an effort from `none`, `low`, `medium`, `high`, `xhigh`, `max`, and optionally 1–5 samples and a token, minute or dollar budget. Saving checks availability and price against AI's registry; an unavailable id uses the first available model in the plan's ladder and returns that reason.
++
++## Configuration
++
++The account default is saved at `PUT /api/v1/model-plans` with `{ "plan": { ... } }`. Add `?project=<id>` for a saved project default. A repository may contain `.openvibe/codes.yml`:
++
++```yaml
++models:
++ mode: pair
++ roles:
++ - role: builder
++ model: openvibe/auto
++ effort: high
++ - role: reviewer
++ model: openvibe/auto
++ effort: medium
++ checks:
++ tests: true
++ reviewer: true
++```
++
++For a task, send `plan` or `preset` in `POST /api/v1/tasks`. The task override wins over repository config, which wins over the saved project plan, which wins over the account default. The repository file is read and validated on the machine after cloning. Invalid YAML or a malformed `models:` block ends the task with a path and line or field error.
++
++`GET /api/v1/tasks/:id` returns the resolved `model_plan` and each role's model, effort, tokens, cost, duration and outcome. The event stream exposes one AG-UI step per role call with `metadata.ov.model`. The verifier reads the final diff and repeats the configured tests and lint before success. The current automatic check runner uses `test` and `lint` scripts from the repository's `package.json`; missing scripts fail the requested check explicitly.
ddiff --git a/engine/model-plan-outcomes.js b/engine/model-plan-outcomes.js
nnew file mode 100644
iindex 0000000..9308355
---- /dev/null
++++ b/engine/model-plan-outcomes.js
@@@ -0,0 +1,71 @@
++'use strict';
++
++const { PRESETS, EFFORTS, validatePlan } = require('./model-plan');
++
++const parse = (value) => {
++ if (value && typeof value === 'object') return value;
++ if (typeof value === 'string') { try { return JSON.parse(value); } catch { return null; } }
++ return null;
++};
++
++/** The first tests/lint result for each configured check, from the machine's own event log. */
++function firstChecks(result) {
++ const lines = parse(result)?.log;
++ if (!Array.isArray(lines)) return null;
++ const first = new Map();
++ for (const line of lines) {
++ if (line?.t !== 'step' || !['tests', 'lint'].includes(line.phase) || first.has(line.phase)) continue;
++ first.set(line.phase, /passed$/.test(String(line.text || '')));
++ }
++ return first.size ? [...first.values()].every(Boolean) : null;
++}
++
++function weeklySummary(rows) {
++ const presets = new Map();
++ const models = new Map();
++ const make = (key) => ({ key, tasks: 0, done: 0, checks_first_time: 0, checks_seen: 0, cost_usd: 0, duration_ms: 0 });
++ for (const row of rows || []) {
++ const name = row.preset && PRESETS[row.preset] ? row.preset : 'Custom';
++ if (!presets.has(name)) presets.set(name, make(name));
++ const summary = presets.get(name);
++ summary.tasks++;
++ if (row.state === 'succeeded' && parse(row.result)?.ok === true) summary.done++;
++ const checks = firstChecks(row.result);
++ if (checks !== null) { summary.checks_seen++; if (checks) summary.checks_first_time++; }
++ const roles = parse(row.role_runs);
++ if (!Array.isArray(roles)) continue;
++ const seen = new Set();
++ for (const role of roles) {
++ const model = String(role.model || 'unknown');
++ const cost = Math.max(0, Number(role.cost_usd) || 0);
++ const duration = Math.max(0, Number(role.duration_ms) || 0);
++ summary.cost_usd += cost;
++ summary.duration_ms += duration;
++ if (!models.has(model)) models.set(model, make(model));
++ const aggregate = models.get(model);
++ if (!seen.has(model)) { aggregate.tasks++; seen.add(model); if (row.state === 'succeeded' && parse(row.result)?.ok === true) aggregate.done++; if (checks !== null) { aggregate.checks_seen++; if (checks) aggregate.checks_first_time++; } }
++ aggregate.cost_usd += cost;
++ aggregate.duration_ms += duration;
++ }
++ }
++ const sort = (a, b) => b.tasks - a.tasks || a.key.localeCompare(b.key);
++ return { presets: [...presets.values()].sort(sort), models: [...models.values()].sort(sort) };
++}
++
++/** A proposal only: the caller must get an owner action before storing it. */
++function tuningSuggestions(summary, current) {
++ const suggestions = [];
++ for (const row of summary.presets || []) {
++ if (!PRESETS[row.key] || row.tasks < 5 || row.checks_seen < 5 || row.checks_first_time / row.checks_seen >= 0.8) continue;
++ const plan = validatePlan(current[row.key]);
++ const builder = plan.roles.find((role) => role.role === 'builder');
++ const index = EFFORTS.indexOf(builder.effort);
++ if (index < 0 || index >= EFFORTS.length - 1) continue;
++ const before = builder.effort;
++ builder.effort = EFFORTS[index + 1];
++ suggestions.push({ preset: row.key, before, after: builder.effort, reason: `${row.checks_first_time}/${row.checks_seen} checks passed first time over ${row.tasks} tasks`, plan });
++ }
++ return suggestions;
++}
++
++module.exports = { firstChecks, weeklySummary, tuningSuggestions };
ddiff --git a/engine/model-plan.js b/engine/model-plan.js
nnew file mode 100644
iindex 0000000..218de2c
---- /dev/null
++++ b/engine/model-plan.js
@@@ -0,0 +1,219 @@
++'use strict';
++
++/** A model plan is portable between the API, repository config and the on-machine job. */
++const ROLES = new Set(['planner', 'builder', 'reviewer', 'verifier']);
++const MODES = new Set(['single', 'pair', 'council', 'escalate']);
++const EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
++const MODEL = /^(?:auto|[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199})$/;
++const PRESETS = Object.freeze({
++ Fast: { mode: 'single', roles: [{ role: 'builder', model: 'auto', effort: 'low' }], checks: { tests: true } },
++ Balanced: { mode: 'pair', roles: [{ role: 'builder', model: 'auto', effort: 'medium' }, { role: 'reviewer', model: 'auto', effort: 'medium' }], checks: { tests: true, reviewer: true } },
++ Deep: { mode: 'pair', roles: [{ role: 'planner', model: 'auto', effort: 'high', samples: 3 }, { role: 'builder', model: 'auto', effort: 'high' }, { role: 'reviewer', model: 'auto', effort: 'high' }, { role: 'verifier', model: 'auto', effort: 'high' }], checks: { tests: true, lint: true, reviewer: true } },
++ Council: { mode: 'council', roles: [{ role: 'planner', model: 'auto', effort: 'high', samples: 3 }, { role: 'builder', model: 'auto', effort: 'high' }, { role: 'reviewer', model: 'auto', effort: 'high' }, { role: 'verifier', model: 'auto', effort: 'medium' }], checks: { tests: true, lint: true, reviewer: true } },
++});
++const copy = (value) => JSON.parse(JSON.stringify(value));
++const invalid = (detail) => Object.assign(new Error(detail), { status: 422, code: 'codes.model_plan_invalid' });
++
++function validatePlan(plan) {
++ if (!plan || typeof plan !== 'object' || Array.isArray(plan)) throw invalid('plan must be an object');
++ if (Object.keys(plan).some((key) => !['mode', 'roles', 'ladder', 'checks'].includes(key))) throw invalid('plan has an unknown field');
++ if (!MODES.has(plan.mode)) throw invalid('plan.mode must be single, pair, council or escalate');
++ if (!Array.isArray(plan.roles) || plan.roles.length < 1 || plan.roles.length > 8) throw invalid('plan.roles needs 1 to 8 entries');
++ for (const [index, role] of plan.roles.entries()) {
++ if (!role || typeof role !== 'object' || Array.isArray(role) || Object.keys(role).some((key) => !['role', 'model', 'effort', 'samples', 'budget'].includes(key))) throw invalid(`plan.roles[${index}] has an unknown field`);
++ if (!ROLES.has(role.role)) throw invalid(`plan.roles[${index}].role is unknown`);
++ if (typeof role.model !== 'string' || !MODEL.test(role.model)) throw invalid(`plan.roles[${index}].model must be auto or an AI registry id`);
++ if (!EFFORTS.includes(role.effort)) throw invalid(`plan.roles[${index}].effort is unknown`);
++ if (role.samples !== undefined && (!Number.isInteger(role.samples) || role.samples < 1 || role.samples > 5)) throw invalid(`plan.roles[${index}].samples must be 1 to 5`);
++ if (role.budget !== undefined) {
++ const b = role.budget;
++ if (!b || typeof b !== 'object' || Array.isArray(b) || !Object.keys(b).length || Object.keys(b).some((key) => !['tokens', 'minutes', 'dollars'].includes(key))) throw invalid(`plan.roles[${index}].budget is invalid`);
++ if (b.tokens !== undefined && (!Number.isInteger(b.tokens) || b.tokens < 1 || b.tokens > 100000000)) throw invalid(`plan.roles[${index}].budget.tokens is invalid`);
++ if (b.minutes !== undefined && (typeof b.minutes !== 'number' || b.minutes <= 0 || b.minutes > 1440)) throw invalid(`plan.roles[${index}].budget.minutes is invalid`);
++ if (b.dollars !== undefined && (typeof b.dollars !== 'number' || b.dollars <= 0 || b.dollars > 10000)) throw invalid(`plan.roles[${index}].budget.dollars is invalid`);
++ }
++ }
++ if (!plan.roles.some((r) => r.role === 'builder')) throw invalid('plan.roles needs a builder');
++ if (plan.mode === 'pair' && (!plan.roles.some((r) => r.role === 'reviewer') || plan.checks?.reviewer !== true)) throw invalid('pair needs a reviewer that must approve');
++ if (plan.mode === 'council' && !plan.roles.some((r) => r.role === 'planner' && r.samples >= 2 && r.samples <= 3)) throw invalid('council needs 2 or 3 planner samples');
++ if (plan.ladder !== undefined && (!Array.isArray(plan.ladder) || !plan.ladder.length || plan.ladder.length > 8 || new Set(plan.ladder).size !== plan.ladder.length || plan.ladder.some((m) => typeof m !== 'string' || !MODEL.test(m)))) throw invalid('plan.ladder needs distinct AI registry ids');
++ if (plan.mode === 'escalate' && (!plan.ladder || plan.ladder.length < 2)) throw invalid('escalate needs a ladder of at least two models');
++ if (!plan.checks || typeof plan.checks !== 'object' || Array.isArray(plan.checks) || !Object.keys(plan.checks).length || Object.keys(plan.checks).some((key) => !['tests', 'lint', 'reviewer'].includes(key) || typeof plan.checks[key] !== 'boolean')) throw invalid('plan.checks needs tests, lint or reviewer booleans');
++ return copy(plan);
++}
++
++/** Nearest accepted effort, with an upward tie. Models without a published list keep the requested level. */
++function fitEffort(wanted, accepted) {
++ if (!Array.isArray(accepted) || !accepted.length || accepted.includes(wanted)) return wanted;
++ const at = EFFORTS.indexOf(wanted);
++ for (let distance = 1; distance < EFFORTS.length; distance++) {
++ for (const index of [at + distance, at - distance]) if (accepted.includes(EFFORTS[index])) return EFFORTS[index];
++ }
++ throw invalid(`model accepts none of the plan's effort levels`);
++}
++
++/** AI's catalog is the authority for saved ids, availability and price. */
++function resolvePlan(value, catalog) {
++ const plan = validatePlan(value);
++ if (!catalog || !Array.isArray(catalog.entries)) throw Object.assign(new Error('AI model registry is unavailable; retry when it answers'), { status: 503, code: 'codes.model_registry_unavailable' });
++ const entries = new Map(catalog.entries.map((entry) => [entry.id, entry]));
++ const usable = (id) => id === 'auto' || (entries.has(id) && !['bad', 'off'].includes(entries.get(id).state));
++ const ladder = plan.ladder || [];
++ const fallbacks = [];
++ for (const role of plan.roles) {
++ const requested = role.model;
++ if (!usable(role.model)) {
++ const replacement = ladder.find(usable);
++ if (!replacement) throw invalid(`${role.role} model ${requested} is unavailable and the ladder has no available replacement`);
++ role.model = replacement;
++ fallbacks.push({ role: role.role, requested, model: replacement, reason: 'unavailable in AI registry' });
++ }
++ role.effort = fitEffort(role.effort, entries.get(role.model)?.efforts);
++ }
++ return { plan, fallbacks, prices: plan.roles.map((role) => ({ role: role.role, model: role.model, output_nano_usd_per_m: entries.get(role.model)?.output_nano_usd_per_m || null })) };
++}
++
++function decision(response) {
++ const text = String(response?.text || '');
++ try {
++ const value = JSON.parse(text);
++ return { approved: value.approved === true, confidence: Number(value.confidence), reason: String(value.reason || value.findings || '').slice(0, 1000), text };
++ } catch {
++ return { approved: /^APPROVED\b/i.test(text.trim()), confidence: Number(response?.confidence), reason: text.slice(0, 1000), text };
++ }
++}
++
++function confidenceOf(response) {
++ if (response?.confidence != null && Number.isFinite(Number(response.confidence))) return Number(response.confidence);
++ const text = String(response?.text || '');
++ try {
++ const parsed = JSON.parse(text);
++ if (parsed && Number.isFinite(Number(parsed.confidence))) return Number(parsed.confidence);
++ } catch { /* ordinary role summary */ }
++ const match = /(?:^|\n)confidence:\s*(0(?:\.\d+)?|1(?:\.0+)?)\b/i.exec(text);
++ return match ? Number(match[1]) : null;
++}
++
++/** The stub-friendly role engine. invoke is always the OpenVibe.AI path in the job adapter. */
++async function executePlan(value, { task, invoke, check, diff, onStep = () => {} }) {
++ const plan = validatePlan(value);
++ const role = (name) => plan.roles.find((item) => item.role === name) || plan.roles.find((item) => item.role === 'builder');
++ const runs = [];
++ const reasons = [];
++ let answer = '';
++ let previousPlan = '';
++ let lastCheck = null;
++ const call = async (item, phase, prompt, model = item.model) => {
++ if (plan.mode === 'single') model = role('builder').model;
++ const started = Date.now();
++ onStep({ type: 'start', role: item.role, phase, model });
++ let response;
++ try { response = await invoke({ role: item.role, phase, model, effort: item.effort, budget: item.budget || null, prompt }); }
++ catch (error) { response = { ok: false, refused: /refus|unavailable|not.found/i.test(String(error.message || error)), text: String(error.message || error) }; }
++ const usage = response?.usage || {};
++ const tokens = Math.max(0, Number(response?.tokens ?? usage.total_tokens ?? (Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0))) || 0);
++ const cost = Math.max(0, Number(response?.cost_usd ?? response?.total_cost_usd) || 0);
++ const duration = Math.max(0, Number(response?.duration_ms) || Date.now() - started);
++ const budget = item.budget || {};
++ const over = (budget.tokens && tokens > budget.tokens) || (budget.minutes && duration > budget.minutes * 60000) || (budget.dollars && cost > budget.dollars);
++ let outcome = over ? 'failed' : response?.refused ? 'refused' : response?.ok === false ? 'failed' : 'passed';
++ if (outcome === 'passed' && phase === 'review') outcome = decision(response).approved ? 'approved' : 'rejected';
++ if (outcome === 'passed' && phase === 'verify') outcome = /^FAIL\b/i.test(String(response?.text || '').trim()) ? 'rejected' : 'approved';
++ const run = { role: item.role, phase, model: String(response?.resolved_model || model), effort: item.effort, outcome, tokens: Math.round(tokens), cost_usd: cost, duration_ms: Math.round(duration), ...(over ? { reason: 'role budget exceeded' } : {}) };
++ runs.push(run);
++ onStep({ type: 'end', ...run });
++ return { ...response, ok: outcome === 'passed' || outcome === 'approved', run };
++ };
++ const planner = plan.roles.find((item) => item.role === 'planner');
++ if (planner) {
++ const samples = [];
++ for (let index = 0; index < (planner.samples || 1); index++) {
++ const sampled = await call(planner, 'propose', `Propose a concise implementation plan for this task. Sample ${index + 1}.\nTask: ${task}`);
++ if (sampled.ok) samples.push(String(sampled.text || ''));
++ }
++ if (!samples.length) return { ok: false, reason: 'no planner proposal succeeded', role_runs: runs, reasons };
++ if (samples.length > 1) {
++ const judge = await call(role('reviewer'), 'judge', `Pick or merge these plans with reasons:\n${samples.map((s, i) => `${i + 1}. ${s}`).join('\n')}`);
++ previousPlan = judge.ok ? String(judge.text || samples[0]) : samples[0];
++ } else previousPlan = samples[0];
++ }
++ const ladder = plan.mode === 'escalate' ? plan.ladder : [role('builder').model];
++ for (let rung = 0; rung < ladder.length; rung++) {
++ const builder = role('builder');
++ const model = ladder[rung];
++ const built = await call(builder, 'build', `Task: ${task}\n${previousPlan ? `Plan: ${previousPlan}\n` : ''}${rung ? `Escalation reason: ${reasons.at(-1)}\n` : ''}Implement the task and report what changed.${plan.mode === 'escalate' ? ' End with a Confidence: 0..1 line.' : ''}`, model);
++ answer = String(built.text || '');
++ if (!built.ok) {
++ reasons.push(built.run.outcome === 'refused' ? 'model refusal' : 'builder failed');
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: reasons.at(-1), role_runs: runs, reasons };
++ }
++ const buildConfidence = confidenceOf(built);
++ if (plan.mode === 'escalate' && buildConfidence != null && buildConfidence < 0.5) {
++ reasons.push('low confidence');
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: 'low confidence', role_runs: runs, reasons };
++ }
++ const changed = await diff();
++ if (!changed) return { ok: false, reason: 'diff could not be read', role_runs: runs, reasons };
++ // A check before review gives the reviewer evidence. The verifier repeats it independently before success.
++ for (const kind of ['tests', 'lint']) if (plan.checks[kind]) {
++ lastCheck = await check(kind);
++ onStep({ type: 'check', role: 'verifier', kind, ...lastCheck });
++ if (lastCheck.ok === false) break;
++ }
++ if (lastCheck?.ok === false) {
++ reasons.push(`${lastCheck.kind || 'check'} failed`);
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: reasons.at(-1), role_runs: runs, reasons };
++ }
++ const needsReview = plan.mode === 'pair' || plan.mode === 'council' || plan.checks.reviewer === true;
++ if (needsReview) {
++ let review = await call(role('reviewer'), 'review', `Review this task's diff and checks. Return JSON {"approved":true|false,"confidence":0..1,"reason":"..."}.\nDiff:\n${String(changed).slice(0, 40000)}`);
++ let verdict = decision(review);
++ review.run.outcome = review.ok && verdict.approved ? 'approved' : 'rejected';
++ if (!review.ok || !verdict.approved) {
++ const fixed = await call(builder, 'repair', `Address these review findings once: ${verdict.reason}. Read the diff and fix the issue.`, model);
++ if (!fixed.ok) return { ok: false, reason: 'builder could not address review', role_runs: runs, reasons };
++ for (const kind of ['tests', 'lint']) if (plan.checks[kind]) {
++ lastCheck = await check(kind);
++ if (lastCheck.ok === false) return { ok: false, reason: `${kind} failed after review`, role_runs: runs, reasons };
++ }
++ review = await call(role('reviewer'), 'review', `Review the revised diff. Return JSON approval and reason.\n${String(await diff()).slice(0, 40000)}`);
++ verdict = decision(review);
++ review.run.outcome = review.ok && verdict.approved ? 'approved' : 'rejected';
++ if (!review.ok || !verdict.approved) return { ok: false, reason: 'reviewer rejected the repair', role_runs: runs, reasons };
++ }
++ if (Number.isFinite(verdict.confidence) && verdict.confidence < 0.5) {
++ reasons.push('low confidence');
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: 'low confidence', role_runs: runs, reasons };
++ }
++ }
++ const verifier = { ...role('verifier'), role: 'verifier' };
++ const finalDiff = await diff();
++ for (const kind of ['tests', 'lint']) if (plan.checks[kind]) {
++ lastCheck = await check(kind);
++ onStep({ type: 'check', role: 'verifier', kind, ...lastCheck });
++ if (lastCheck.ok === false) {
++ reasons.push(`${kind} failed on verification`);
++ break;
++ }
++ }
++ if (lastCheck?.ok === false) {
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: reasons.at(-1), role_runs: runs, reasons };
++ }
++ const verified = await call(verifier, 'verify', `Read this final diff and the check results. Say PASS or FAIL with a reason.\n${String(finalDiff).slice(0, 40000)}`);
++ if (!verified.ok || /^FAIL\b/i.test(String(verified.text || '').trim())) {
++ reasons.push('verifier rejected');
++ if (rung + 1 < ladder.length) continue;
++ return { ok: false, reason: reasons.at(-1), role_runs: runs, reasons };
++ }
++ verified.run.outcome = 'approved';
++ return { ok: true, summary: answer, role_runs: runs, reasons };
++ }
++ return { ok: false, reason: reasons.at(-1) || 'plan exhausted', role_runs: runs, reasons };
++}
++
++module.exports = { PRESETS, EFFORTS, validatePlan, fitEffort, resolvePlan, executePlan };
ddiff --git a/harness/index.js b/harness/index.js
iindex 8d893f7..adc9e41 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -347,4 +347,4 @@ function createHarness({ env = process.env, fetch: fetchImpl = globalThis.fetch,
return { agents, route, run, store, adapters };
}
--module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath, exchangeKeyOf, deepseekAdapter, exchangeModelOf, platformHost };
++module.exports = { createHarness, adaptersFor, availability, handoffNote, onPath, exchangeKeyOf, deepseekAdapter, exchangeModelOf, platformHost, networkToken };
ddiff --git a/harness/job.js b/harness/job.js
iindex 261f05b..c2d3627 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -23,6 +23,8 @@ const { collectProof } = require('../engine/proof');
const { createCheckpoints, toolPaths } = require('./checkpoints');
const { runCheck } = require('./goal-check');
const { boundedEvidence } = require('../engine/goal');
++const { validatePlan } = require('../engine/model-plan');
++const { repositoryPlan, selectPlan, resolveRepositoryPlan, runModelPlan } = require('./model-plan');
const skills = require('../engine/skills');
const JOB_PROTOCOL = '1.0.0';
@@@ -275,6 +277,8 @@ function parseArgs(raw, env = process.env) {
budget: rawGoal.budget && typeof rawGoal.budget === 'object' && !Array.isArray(rawGoal.budget) ? rawGoal.budget : {},
no_progress_limit: Number.isInteger(rawGoal.no_progress_limit) ? rawGoal.no_progress_limit : 3,
} : null;
++ const plan = a.plan == null ? null : validatePlan(a.plan);
++ const planSource = ['task', 'project', 'account'].includes(a.plan_source) ? a.plan_source : 'task';
return {
kind, repo: repo ? repo.toString() : null, ref, prompt, permission, push,
// The task this job is an attempt of, when the server names it: the checkpoint refs carry it.
@@@ -296,6 +300,8 @@ function parseArgs(raw, env = process.env) {
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
...(goal ? { goal } : {}),
++ ...(plan ? { plan, planSource, preset: typeof a.preset === 'string' ? a.preset : null,
++ modelFallbacks: Array.isArray(a.model_fallbacks) ? a.model_fallbacks.slice(0, 8).filter((item) => item && typeof item.role === 'string' && typeof item.requested === 'string' && typeof item.model === 'string') : [] } : {}),
};
}
@@@ -776,6 +782,20 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
job = { ...job, prompt: job.prompt + attachmentPrompt(files) };
say({ t: 'step', text: `${files.length} file${files.length === 1 ? '' : 's'} attached` });
}
++ if (job.plan) {
++ for (const fallback of job.modelFallbacks || []) say({ t: 'note', text: `${fallback.role} model ${fallback.requested} unavailable; using ${fallback.model} from the plan ladder` });
++ const projectPlan = repositoryPlan(work);
++ job.plan = selectPlan({ saved: job.plan, repository: projectPlan, task: job.planSource === 'task' ? job.plan : null });
++ if (projectPlan && job.planSource !== 'task') {
++ const resolved = await resolveRepositoryPlan(job.plan, { env, fetchImpl });
++ job.plan = resolved.plan;
++ for (const fallback of resolved.fallbacks) say({ t: 'note', text: `${fallback.role} model ${fallback.requested} unavailable; using ${fallback.model} from the plan ladder` });
++ result.model_fallbacks = resolved.fallbacks;
++ }
++ result.model_plan = job.plan;
++ if (job.preset) result.preset = job.preset;
++ say({ t: 'step', text: `Model plan: ${job.plan.mode}${projectPlan && job.planSource !== 'task' ? ' from .openvibe/codes.yml' : ''}` });
++ }
} else if (job.kind === 'conductor') {
// The owner's own folder: never a clone, never touched by git. Claude Code opens it, so it loads that
// project's memory and AGENTS.md.
@@@ -816,7 +836,16 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
fs.mkdirSync(work, { recursive: true });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
-- const run = harness.run({
++ const run = job.plan ? {
++ runId: job.taskId || 'plan',
++ async *[Symbol.asyncIterator]() {
++ yield await runModelPlan({
++ plan: job.plan, task: agentPrompt(job, work), harness, cwd: work,
++ diff: async () => (await changes(work)).patch,
++ say, compact, permission: job.permission, sandboxed: !!env.OPENVIBE_JOB_ID,
++ });
++ },
++ } : harness.run({
prompt: agentPrompt(job, work), task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null,
advisor: job.advisor || null, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
@@@ -866,6 +895,10 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.summary = answer.slice(0, 4000);
if (job.kind === 'bench') result.output = answer.slice(0, 200000);
result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
++ if (job.plan && last && Array.isArray(last.role_runs)) {
++ result.role_runs = last.role_runs;
++ result.escalation_reasons = last.escalation_reasons || [];
++ }
// OpenVibe.AI's own disclosure: the model that actually ran, inside x_openvibe. Kept where Codes keeps a
// run's result, so the attempt records the resolved model, not only the one that was asked for.
if (last && last.resolved_model) result.resolved_model = String(last.resolved_model).slice(0, 300);
ddiff --git a/harness/model-plan.js b/harness/model-plan.js
nnew file mode 100644
iindex 0000000..818180e
---- /dev/null
++++ b/harness/model-plan.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++const fs = require('node:fs');
++const path = require('node:path');
++const { execFile } = require('node:child_process');
++const YAML = require('yaml');
++const { validatePlan, resolvePlan, executePlan } = require('../engine/model-plan');
++const { parseCatalog } = require('../server/data/ai-catalog');
++
++const MAX_CONFIG = 64 * 1024;
++const MAX_CHECK_OUTPUT = 4000;
++
++/** Read only the project's models block, with errors tied to the file and line. */
++function repositoryPlan(root) {
++ const file = path.join(root, '.openvibe', 'codes.yml');
++ if (!fs.existsSync(file)) return null;
++ const real = fs.realpathSync(file);
++ if (!real.startsWith(`${fs.realpathSync(root)}${path.sep}`)) throw new Error('.openvibe/codes.yml must be inside the repository');
++ const source = fs.readFileSync(file, 'utf8');
++ if (Buffer.byteLength(source) > MAX_CONFIG) throw new Error('.openvibe/codes.yml is over 64 KiB');
++ const doc = YAML.parseDocument(source, { uniqueKeys: true, maxAliasCount: 0, strict: true });
++ if (doc.errors.length) throw new Error(`.openvibe/codes.yml:${doc.errors[0].linePos?.[0]?.line || 1}: ${doc.errors[0].message}`);
++ const body = doc.toJS({ maxAliasCount: 0 });
++ if (!body || typeof body !== 'object' || Array.isArray(body) || !body.models) throw new Error('.openvibe/codes.yml needs a models: block');
++ try { return validatePlan(body.models); }
++ catch (error) { throw new Error(`.openvibe/codes.yml models: ${error.message}`); }
++}
++
++/** Explicit task wins over repository, which wins over a saved project/account choice. */
++function selectPlan({ saved = null, repository = null, task = null, preset = null } = {}) {
++ if (task && preset) throw new Error('choose a plan or preset, not both');
++ const { PRESETS } = require('../engine/model-plan');
++ const picked = task || (preset ? PRESETS[preset] : null) || repository || saved;
++ return picked ? validatePlan(picked) : null;
++}
++
++/** A repository plan is not saved on Codes; check its ids from AI before the first paid call. */
++async function resolveRepositoryPlan(plan, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
++ const url = String(env.OPENVIBE_AI_URL || 'https://ai.openvibe.services/v1').replace(/\/+$/, '');
++ const token = env.OPENVIBE_AI_TOKEN || (env.OPENVIBE_APP_ID && env.OPENVIBE_APP_SECRET
++ ? await require('./index').networkToken()(env, fetchImpl) : '');
++ if (!token) throw new Error('.openvibe/codes.yml needs OpenVibe.AI access to check its models');
++ let response;
++ try {
++ response = await fetchImpl(`${url.endsWith('/v1') ? url : `${url}/v1`}/models`, {
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(5000),
++ });
++ } catch { throw new Error('OpenVibe.AI model registry did not answer for .openvibe/codes.yml'); }
++ if (!response.ok) throw new Error(`OpenVibe.AI model registry answered ${response.status} for .openvibe/codes.yml`);
++ const catalog = parseCatalog(await response.json());
++ return resolvePlan(plan, catalog);
++}
++
++function projectCheck(kind, cwd) {
++ const packageFile = path.join(cwd, 'package.json');
++ let pkg = null;
++ try { pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8')); } catch { /* not a Node project */ }
++ if (!pkg?.scripts?.[kind]) return Promise.resolve({ kind, ok: false, output: `No ${kind} script is configured in package.json` });
++ return new Promise((resolve) => {
++ execFile('npm', ['run', kind, '--silent'], { cwd, timeout: 5 * 60_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
++ resolve({ kind, ok: !error, exit_code: error?.code || 0, output: String(stdout || stderr || '').slice(-MAX_CHECK_OUTPUT) });
++ });
++ });
++}
++
++/** A plan uses only the OpenVibe.AI harness, which already supplies provider routing and receipts. */
++async function runModelPlan({ plan, task, harness, cwd, diff, say, compact, permission = 'edit', sandboxed = false, check = projectCheck }) {
++ const emit = (line) => say(line);
++ const done = await executePlan(plan, {
++ task,
++ diff,
++ check: (kind) => check(kind, cwd),
++ onStep: (step) => {
++ if (step.type === 'start' || step.type === 'end') emit({ t: 'step', event: step.type, role: step.role, phase: step.phase, model: step.model, outcome: step.outcome || null, tokens: step.tokens || 0, cost_usd: step.cost_usd || 0, duration_ms: step.duration_ms || 0, text: `${step.role} ${step.type === 'start' ? 'started' : step.outcome}`, metadata: { ov: { role: step.role, model: step.model, phase: step.phase, outcome: step.outcome || null } } });
++ if (step.type === 'check') emit({ t: 'step', role: 'verifier', phase: step.kind, text: `${step.kind}: ${step.ok ? 'passed' : 'failed'}`, metadata: { ov: { role: 'verifier', check: step.kind, outcome: step.ok ? 'passed' : 'failed' } } });
++ },
++ invoke: async ({ role, phase, model, effort, budget, prompt }) => {
++ const started = Date.now();
++ const run = harness.run({
++ prompt, task: phase === 'build' || phase === 'repair' ? 'edit' : 'review', agent: 'openvibe-ai',
++ model: model === 'auto' ? 'openvibe/auto' : model, effort, cwd,
++ permission: phase === 'build' || phase === 'repair' ? permission : 'read', handoff: false, maxAttempts: 1,
++ sandboxed, ...(budget?.minutes ? { timeoutMs: budget.minutes * 60000 } : {}),
++ });
++ let last = null;
++ for await (const event of run) {
++ last = event;
++ if (event.type === 'result') continue; // the plan emits one final done; role totals live on role steps
++ const line = compact(event);
++ for (const one of Array.isArray(line) ? line : line ? [line] : []) emit({ ...one, role, phase, metadata: { ov: { role, model, phase } } });
++ }
++ const usage = last?.usage || {};
++ return {
++ ok: last?.type === 'result' && !last?.is_error,
++ refused: /model_not_found|model_unavailable|refus/i.test(String(last?.result || '')),
++ text: String(last?.result || ''),
++ resolved_model: last?.resolved_model || last?.model || (model === 'auto' ? 'openvibe/auto' : model),
++ tokens: Number(last?.total_tokens) || Number(usage.input_tokens || 0) + Number(usage.output_tokens || 0),
++ cost_usd: Number(last?.total_cost_usd) || 0,
++ duration_ms: Date.now() - started,
++ };
++ },
++ });
++ return {
++ type: 'result', is_error: !done.ok, result: done.ok ? done.summary : done.reason,
++ total_cost_usd: done.role_runs.reduce((sum, item) => sum + item.cost_usd, 0),
++ total_tokens: done.role_runs.reduce((sum, item) => sum + item.tokens, 0),
++ duration_ms: done.role_runs.reduce((sum, item) => sum + item.duration_ms, 0),
++ role_runs: done.role_runs, escalation_reasons: done.reasons,
++ };
++}
++
++module.exports = { repositoryPlan, selectPlan, resolveRepositoryPlan, projectCheck, runModelPlan };
ddiff --git a/migrations/0038_model_plans.sql b/migrations/0038_model_plans.sql
nnew file mode 100644
iindex 0000000..138831a
---- /dev/null
++++ b/migrations/0038_model_plans.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- Saved account and project choices. The repository's .openvibe/codes.yml and a task override take precedence.
++CREATE TABLE IF NOT EXISTS codes_model_plans (
++ subject text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" NOT NULL,
++ plan jsonb NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (subject, scope)
++);
++
++CREATE TABLE IF NOT EXISTS codes_model_preset_defaults (
++ name text COLLATE "C" PRIMARY KEY,
++ plan jsonb NOT NULL,
++ tuned_at timestamptz NOT NULL DEFAULT now()
++);
++
++ALTER TABLE run_tasks
++ ADD COLUMN IF NOT EXISTS model_plan jsonb,
++ ADD COLUMN IF NOT EXISTS plan_source text COLLATE "C",
++ ADD COLUMN IF NOT EXISTS preset text COLLATE "C",
++ ADD COLUMN IF NOT EXISTS role_runs jsonb;
ddiff --git a/package-lock.json b/package-lock.json
iindex 5141a3d..ddf46ca 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.205.0",
-- "pg": "^8.23.0"
++ "pg": "^8.23.0",
++ "yaml": "^2.9.1"
},
"bin": {
"openvibe-codes": "bin/openvibe-codes.js"
@@@ -1803,6 +1804,21 @@
"engines": {
"node": ">=0.4"
}
++ },
++ "node_modules/yaml": {
++ "version": "2.9.1",
++ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
++ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
++ "license": "ISC",
++ "bin": {
++ "yaml": "bin.mjs"
++ },
++ "engines": {
++ "node": ">= 14.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/eemeli"
++ }
}
}
}
ddiff --git a/package.json b/package.json
iindex b33cd9d..511e841 100644
---- a/package.json
++++ b/package.json
@@@ -33,7 +33,8 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.205.0",
-- "pg": "^8.23.0"
++ "pg": "^8.23.0",
++ "yaml": "^2.9.1"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 6106f8d..ba998cc 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -30,12 +30,13 @@ const TASK_ROWS_MAX = 20000;
const TABLES = [
{
table: 'run_tasks', subject: 'subject', file: 'run-tasks.json', order: 'created_at',
-- columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at', 'machine', 'pin'],
++ columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'started_at', 'finished_at', 'created_at', 'updated_at', 'machine', 'pin', 'model_plan', 'plan_source', 'preset', 'role_runs'],
// Erased by extraErase below, after the rows that point at these task ids (the SDK deletes the table's rows
// before calling it, so a default delete here would lose the ids first).
erase: { keep: 'erased with its task-keyed rows by the service extraErase' },
},
{ table: 'codes_user_settings', subject: 'subject', file: 'settings.json', order: 'updated_at' },
++ { table: 'codes_model_plans', subject: 'subject', file: 'model-plans.json', order: 'updated_at' },
// The files a person attached to a message (owner 2026-11-07): what they are and which turn they belong to. The
// bytes are theirs too, but the export carries the record, not a second copy of every 20 MB file; deletion takes
// the rows (and their bytes) with the person.
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex c67c932..ce72bbd 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -15,6 +15,7 @@ function createAgentPages(ctx, publicViews, { home = null } = {}) {
if (path === '/skills' || path.startsWith('/skills/')) return twin(await ctx.skillPages.publicView(path), path);
if (path === '/start') return twin(views.start(), path);
if (path === '/harnesses') return twin(views.harnesses(), path);
++ if (path === '/model-plans') return twin(views.modelPlans(), path);
if (path === '/improve') return twin(views.improve(), path);
if (path === '/updates') return twin(await views.updates(), path);
if (path === '/policy') return twin(views.policy(), path);
ddiff --git a/server/agent-ui/mapper.js b/server/agent-ui/mapper.js
iindex f316f95..72c5a3f 100644
---- a/server/agent-ui/mapper.js
++++ b/server/agent-ui/mapper.js
@@@ -123,6 +123,26 @@ function createMapper(state, { clean = (x) => x, fallback = async () => null } =
emit('STEP_FINISHED', { stepName: id }, { title: step.title, kind: step.kind, status: step.status, group: state.group });
if (added.length) emit('CUSTOM', { name: 'ov.sources', value: added }, { sources: added });
if (l.ok === false) state.group = null;
++ } else if (l.t === 'step' && l.role) {
++ state.group = null;
++ if (l.event === 'start') {
++ const id = `${state.id}:role:${source}`;
++ const step = { id, role: l.role, phase: l.phase || null, model: l.model || null, title: `${l.role}${l.phase ? ` · ${l.phase}` : ''}`, kind: 'agent', status: 'running', started_at: at };
++ state.steps.push(step);
++ emit('STEP_STARTED', { stepName: id }, { title: step.title, kind: step.kind, role: step.role, model: step.model, phase: step.phase });
++ } else if (l.event === 'end') {
++ const step = state.steps.slice().reverse().find((s) => s.role === l.role && s.phase === (l.phase || null) && s.status === 'running');
++ if (step) {
++ step.status = ['approved', 'passed'].includes(l.outcome) ? 'done' : 'failed';
++ step.ended_at = at;
++ step.tokens = Number(l.tokens) || 0;
++ step.cost_usd = Number(l.cost_usd) || 0;
++ step.duration_ms = Number(l.duration_ms) || 0;
++ emit('STEP_FINISHED', { stepName: step.id }, { title: step.title, kind: step.kind, role: step.role, model: step.model, phase: step.phase, status: step.status, outcome: l.outcome || null, tokens: step.tokens, cost_usd: step.cost_usd, duration_ms: step.duration_ms });
++ }
++ } else if (l.phase) {
++ emit('ACTIVITY_SNAPSHOT', { messageId: `${state.id}:check:${source}`, activityType: 'check', content: { text: oneLine(l.text) } }, { title: oneLine(l.text), kind: 'run', role: l.role, check: l.phase });
++ }
} else if (l.t === 'plan') {
state.group = null;
state.plan = l.plan || [{ title: l.text || 'Plan', status: 'running' }];
ddiff --git a/server/app.js b/server/app.js
iindex a349421..b87a409 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -66,6 +66,7 @@ const { createKnowledge } = require('./domain/knowledge');
const { createFleet } = require('./domain/fleet');
const { createNotices } = require('./domain/notices');
const { createUserSettings } = require('./domain/user-settings');
++const { createModelPlans } = require('./domain/model-plans');
const { createAttachments } = require('./domain/attachments');
const { createPlacementOutcomes } = require('./domain/placement-outcomes');
const { createBenchPriors } = require('./domain/bench-priors');
@@@ -116,6 +117,7 @@ async function createApp(opts = {}) {
// OpenVibe.AI's real model catalog (AI Exchange P9), when CODES_AI_CATALOG is on: the pickers read it, and
// nothing else depends on it.
const aiCatalog = createAiCatalog({ config, fetchImpl: fetchImpl || globalThis.fetch, now: store.now });
++ const modelPlans = createModelPlans({ store, aiCatalog, config });
const runTokens = createRunTokens({ config, store, fetchImpl: fetchImpl || globalThis.fetch, log });
// The task's attempts (stage C2): every Run job is one attempt, and the engine decides what happens next.
const attempts = createAttempts({ store, config, log, harnesses });
@@@ -138,8 +140,8 @@ async function createApp(opts = {}) {
const sharing = opts.sharing || createSharing({ db: store.db, config, fetchImpl: fetchImpl || globalThis.fetch, log });
// The skills hub (Compute plan §5, E6): versioned skills, rules and MCP servers, shared through the same SDK.
const skills = opts.skills || createSkills({ store, sharing, log });
-- const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, goalVerifier: opts.goalVerifier || null, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
-- const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
++ const tasks = createRunTasks({ store, run, config, log, sessions, outbox, runTokens, connections, attempts, fleet, notices, placementContext, harnesses, knowledge, sharing, attachments, modelPlans, goalVerifier: opts.goalVerifier || null, benchAttemptEnded: opts.benchAttemptEnded || benchPublisher.onTaskTerminal });
++ const ctx = { config, store, keys, aiCatalogConfig, sso, harnesses, log, sessions, outbox, run, tasks, connections, aiCatalog, modelPlans, runTokens, attempts, fleet, notices, notify, userSettings, attachments, benchPublisher, placementOutcomes, benchPriors, placementContext, knowledge, sharing, skills };
// The C4b screens: the person's machines (from Network), their own choices, the GitHub reader and their usage.
ctx.machines = createMachines({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
// The person's own memory store (OpenVibe Memory M2b): the notes live in OpenVibe.AI, sealed there, and only the
@@@ -189,6 +191,8 @@ async function createApp(opts = {}) {
}
if (Object.keys(kept).some((k) => METHODS.has(k))) paths[p] = kept;
}
++ Object.assign(paths, require('./http/model-plan-openapi').modelPlanPaths());
++ require('./http/model-plan-openapi').extendTaskCreate(paths);
res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
});
app.disable('x-powered-by');
@@@ -315,7 +319,7 @@ async function createApp(opts = {}) {
}
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
-- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
++ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, modelPlans, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, repoPicker: ctx.repoPicker, forge: ctx.forge, actorLimits: ctx.actorLimits, log }));
@@@ -366,6 +370,7 @@ async function createApp(opts = {}) {
app.use(require('./http/chat').createChatRoutes(ctx));
app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell
app.use(ctx.adminRepos.page);
++ app.use(require('./http/admin-model-plans').createAdminModelPlans(ctx));
app.use(ctx.githubLearningRoutes.page);
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
c4Routes = require('./http/c4').createC4Routes(ctx);
ddiff --git a/server/config.js b/server/config.js
iindex 1a04eb9..14a6552 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -97,6 +97,8 @@ function load(env = process.env) {
enabled: bool(env.CODES_AI_CATALOG),
ttlMs: Math.max(0, int(env.CODES_AI_CATALOG_TTL_MS, 5 * 60_000)),
},
++ // New task behavior is owner opt-in. A saved plan has no effect until this switch is on.
++ modelPlans: { enabled: bool(env.CODES_MODEL_PLANS) },
// A run token (Codes C3): the short-lived Network token an attempt's harness carries, so that AI knows whose
// key and whose budget the run is. Two hours is the longest a coding attempt may run.
runTokens: {
ddiff --git a/server/data/ai-catalog.js b/server/data/ai-catalog.js
iindex 2a6c818..33fc32b 100644
---- a/server/data/ai-catalog.js
++++ b/server/data/ai-catalog.js
@@@ -80,6 +80,7 @@ function aliasEntry(alias) {
policy,
state: avail.state,
badge: 'Dynamic',
++ efforts: Array.isArray(alias.efforts) ? alias.efforts.filter((effort) => ['none', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) : null,
desc: `Dynamic: ${alias.description || 'the router picks the model for each request'} Its price is what the chosen model's retail rate says.`,
};
}
@@@ -101,6 +102,7 @@ function modelEntry(model) {
state: avail.state,
badge: avail.badge,
output_nano_usd_per_m: outputNano(model) == null ? null : String(outputNano(model)),
++ efforts: Array.isArray(model.efforts) ? model.efforts.filter((effort) => ['none', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) : null,
desc: price || 'No published price',
};
}
ddiff --git a/server/domain/ai-catalog.js b/server/domain/ai-catalog.js
iindex d2700e5..5fc6c0a 100644
---- a/server/domain/ai-catalog.js
++++ b/server/domain/ai-catalog.js
@@@ -27,7 +27,7 @@ function modelsUrl(base) {
function createAiCatalog({ config = {}, fetchImpl = globalThis.fetch, now = Date.now } = {}) {
const setting = config.aiCatalog || {};
// D11 owner settings: the switch may change live; the token is created whenever Codes has a client secret.
-- const enabledNow = () => setting.enabled === true;
++ const enabledNow = () => setting.enabled === true || config.modelPlans?.enabled === true;
const base = String((config.ai && config.ai.url) || 'https://ai.openvibe.services').replace(/\/+$/, '');
const url = modelsUrl(base);
const ttlMs = setting.ttlMs == null ? DEFAULT_TTL_MS : Math.max(0, Number(setting.ttlMs) || 0);
@@@ -62,8 +62,8 @@ function createAiCatalog({ config = {}, fetchImpl = globalThis.fetch, now = Date
}
/** The parsed catalog, cached about five minutes; null while the flag is off or AI does not answer with one. */
-- async function read() {
-- if (!enabledNow() || !tokens) return null;
++ async function read({ force = false } = {}) {
++ if ((!enabledNow() && !force) || !tokens) return null;
if (cached && cached.until > now()) return cached.catalog;
if (!inflight) {
inflight = load().then((catalog) => {
@@@ -75,8 +75,8 @@ function createAiCatalog({ config = {}, fetchImpl = globalThis.fetch, now = Date
}
/** The entries for a picker: aliases before exact ids, each with its price and availability; [] without a catalog. */
-- async function choices() {
-- return catalogChoices(await read());
++ async function choices(options = {}) {
++ return catalogChoices(await read(options));
}
return { get enabled() { return enabledNow(); }, url, read, choices };
ddiff --git a/server/domain/model-plans.js b/server/domain/model-plans.js
nnew file mode 100644
iindex 0000000..28daa8f
---- /dev/null
++++ b/server/domain/model-plans.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++const { PRESETS, validatePlan, resolvePlan } = require('../../engine/model-plan');
++const { weeklySummary, tuningSuggestions } = require('../../engine/model-plan-outcomes');
++
++const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
++const scopeOf = (project) => {
++ if (project == null || project === '') return 'account';
++ if (typeof project !== 'string' || !PROJECT.test(project)) throw Object.assign(new Error('project must be a project id'), { status: 422, code: 'codes.project_invalid' });
++ return `project:${project}`;
++};
++const copy = (value) => JSON.parse(JSON.stringify(value));
++
++function createModelPlans({ store, aiCatalog, config }) {
++ const db = store.db;
++ const enabled = () => config.modelPlans?.enabled === true;
++ const requireEnabled = () => {
++ if (!enabled()) throw Object.assign(new Error('model plans are off for this service'), { status: 409, code: 'codes.model_plans_disabled' });
++ };
++ async function stored(subject, project = null) {
++ const row = await db.maybe('SELECT plan FROM codes_model_plans WHERE subject = $1 AND scope = $2', [subject, scopeOf(project)]);
++ return row?.plan || null;
++ }
++ async function presets() {
++ const result = copy(PRESETS);
++ const rows = await db.many('SELECT name, plan FROM codes_model_preset_defaults');
++ for (const row of rows) if (result[row.name]) {
++ try { result[row.name] = validatePlan(row.plan); } catch { /* keep the shipped preset */ }
++ }
++ return result;
++ }
++ async function get(subject, project = null) {
++ const account = await stored(subject) || (await presets()).Balanced;
++ const projectPlan = project ? await stored(subject, project) : null;
++ return { account, project: projectPlan, effective: projectPlan || account, source: projectPlan ? 'project' : 'account' };
++ }
++ async function put(subject, plan, project = null) {
++ const checked = validatePlan(plan);
++ const catalog = await aiCatalog.read({ force: true });
++ const resolved = resolvePlan(checked, catalog);
++ await db.exec(`INSERT INTO codes_model_plans (subject, scope, plan, updated_at) VALUES ($1, $2, $3::jsonb, now())
++ ON CONFLICT (subject, scope) DO UPDATE SET plan = EXCLUDED.plan, updated_at = now()`, [subject, scopeOf(project), JSON.stringify(checked)]);
++ return { plan: checked, resolved: resolved.plan, fallbacks: resolved.fallbacks, prices: resolved.prices };
++ }
++ async function select(subject, project, { plan = null, preset = null } = {}) {
++ requireEnabled();
++ if (plan && preset) throw Object.assign(new Error('choose plan or preset'), { status: 422, code: 'codes.model_plan_invalid' });
++ const availablePresets = await presets();
++ if (preset && !availablePresets[preset]) throw Object.assign(new Error('unknown model plan preset'), { status: 422, code: 'codes.preset_unknown' });
++ const saved = await get(subject, project);
++ const picked = plan ? validatePlan(plan) : preset ? copy(availablePresets[preset]) : saved.effective;
++ const resolved = resolvePlan(picked, await aiCatalog.read({ force: true }));
++ return { ...resolved, preset: preset || null, source: plan || preset ? 'task' : saved.source };
++ }
++ async function outcomes() {
++ const rows = await db.many(`SELECT preset, role_runs, result, state FROM run_tasks
++ WHERE model_plan IS NOT NULL AND created_at >= $1
++ ORDER BY created_at DESC LIMIT 5000`, [store.now() - 7 * 24 * 60 * 60_000]);
++ const summary = weeklySummary(rows);
++ return { ...summary, suggestions: tuningSuggestions(summary, await presets()) };
++ }
++ async function tune() {
++ const { suggestions } = await outcomes();
++ for (const item of suggestions) await db.exec(`INSERT INTO codes_model_preset_defaults (name, plan, tuned_at)
++ VALUES ($1, $2::jsonb, now()) ON CONFLICT (name) DO UPDATE SET plan = EXCLUDED.plan,
++ tuned_at = now()`, [item.preset, JSON.stringify(item.plan)]);
++ return suggestions;
++ }
++ return { get, put, select, presets, stored, enabled, outcomes, tune };
++}
++
++module.exports = { createModelPlans, scopeOf };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 8294c46..430a97c 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -57,6 +57,18 @@ const asObject = (v) => {
if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
return null;
};
++const asArray = (v) => {
++ if (Array.isArray(v)) return v;
++ if (typeof v === 'string') { try { const p = JSON.parse(v); return Array.isArray(p) ? p : null; } catch { return null; } }
++ return null;
++};
++const presentRoleRuns = (value) => (asArray(value) || []).slice(0, 30).map((run) => drop({
++ role: run.role, model: str(run.model, 200), effort: run.effort, outcome: run.outcome,
++ tokens: Number.isFinite(Number(run.tokens)) ? Math.max(0, Math.round(Number(run.tokens))) : 0,
++ cost_usd: Number.isFinite(Number(run.cost_usd)) ? Math.max(0, Number(run.cost_usd)) : 0,
++ duration_ms: Number.isFinite(Number(run.duration_ms)) ? Math.max(0, Math.round(Number(run.duration_ms))) : 0,
++ reason: str(run.reason, 1000),
++}));
/** The job's result document (harness/job.js) as codes.task@1's `result`, or undefined. */
function presentResult(d) {
@@@ -154,7 +166,7 @@ function usageOf(result) {
};
}
--function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, goalVerifier = null }) {
++function createRunTasks({ store, run, config, log = console, sessions = null, outbox = null, runTokens = null, connections = null, attempts = null, proof = null, benchAttemptEnded = null, conductor = null, fleet = null, notices = null, placementContext = null, harnesses = null, knowledge = null, report = null, sharing = null, attachments: filesStore = null, modelPlans = null, goalVerifier = null }) {
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
@@@ -477,9 +489,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
error = COALESCE($4, error),
started_at = COALESCE($5::timestamptz, started_at),
finished_at = COALESCE($6::timestamptz, finished_at),
-- updated_at = $7
++ updated_at = $7,
++ model_plan = COALESCE($8::jsonb, model_plan),
++ role_runs = COALESCE($9::jsonb, role_runs)
WHERE id = $1 RETURNING *`,
-- [current.id, state, result == null ? null : JSON.stringify(result), error, startedAt, finishedAt, store.now()],
++ [current.id, state, result == null ? null : JSON.stringify(result), error, startedAt, finishedAt, store.now(), result?.model_plan ? JSON.stringify(result.model_plan) : null, result?.role_runs ? JSON.stringify(result.role_runs) : null],
);
if (changed) await taskOutbox.emitTask(updated, current.state);
// Every Run job is one attempt: the attempt of this job follows the job.
@@@ -588,8 +602,13 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const turnId = claims.length ? (messageId || store.newId('msg')) : null;
// A task with no agent named is `auto` (Codes C9c): the router reads the fleet — who is signed in, whose window
// still has room — and the task is started on the agent it picks. Never a fixed agent, and never an Anthropic one.
-- let agent = args.agent || null;
-- let model = args.model || null;
++ if (modelPlans?.enabled() && !args.plan && args.kind !== 'bench') {
++ const selected = await modelPlans.select(subject, project);
++ args = { ...args, plan: selected.plan, plan_source: selected.source, preset: selected.preset, model_fallbacks: selected.fallbacks };
++ }
++ let agent = args.plan ? 'openvibe-ai' : args.agent || null;
++ let model = args.plan ? (args.plan.roles.find((role) => role.role === 'builder')?.model || 'auto') : args.model || null;
++ if (model === 'auto') model = 'openvibe/auto';
let why = agent ? `the first try, with ${attemptStore.nameOf(agent)}` : 'the first try, with the agent that fits the task';
// Nothing may take it: the task says why, and needs a person. It never starts on an agent without room.
let refusal = null;
@@@ -647,9 +666,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const placementKind = placed ? `harness.${taskKind}` : null;
let inserted;
await store.tx(async () => {
-- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, mode, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $19, $19) RETURNING *`,
-- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, startMode, at]);
++ inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, mode, model_plan, plan_source, preset, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $19::jsonb, $20, $21, $22, $22) RETURNING *`,
++ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, startMode, runArgs.plan ? JSON.stringify(runArgs.plan) : null, runArgs.plan_source || null, runArgs.preset || null, at]);
if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
await taskOutbox.emitTask(inserted, null);
return inserted;
@@@ -1219,6 +1238,9 @@ ${String(verdict.check_output || '').slice(0, 50000)}`;
id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,
agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),
error: row.error ? String(row.error).slice(0, 1000) : null, result,
++ ...(row.model_plan ? { model_plan: asObject(row.model_plan) } : {}),
++ ...(row.preset ? { preset: row.preset } : {}),
++ ...(row.role_runs ? { role_runs: presentRoleRuns(row.role_runs) } : {}),
...(goal ? { goal: { ...goal, ...(verdict ? { verdict } : {}) } } : {}),
});
}
ddiff --git a/server/http/admin-model-plans.js b/server/http/admin-model-plans.js
nnew file mode 100644
iindex 0000000..f9194da
---- /dev/null
++++ b/server/http/admin-model-plans.js
@@@ -0,0 +1,33 @@
++'use strict';
++
++const express = require('express');
++const { asyncRouter } = require('./router');
++const { esc } = require('../render/html');
++
++function createAdminModelPlans(ctx) {
++ const r = asyncRouter();
++ const owner = (req) => req.viewer?.kind === 'user' && req.viewer.role === 'owner';
++ const deny = (req, res) => res.status(req.viewer ? 403 : 401).type('text/plain').send('Owner access required.\n');
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).origin === new URL(ctx.config.baseUrl).origin; } catch { return false; }
++ };
++ const table = (rows) => `<table><thead><tr><th>Preset or model</th><th>Tasks done</th><th>Checks first time</th><th>Cost</th><th>Time</th></tr></thead><tbody>${rows.map((row) => `<tr><th>${esc(row.key)}</th><td>${row.done}/${row.tasks}</td><td>${row.checks_first_time}/${row.checks_seen}</td><td>$${row.cost_usd.toFixed(2)}</td><td>${Math.round(row.duration_ms / 60000)} min</td></tr>`).join('') || '<tr><td colspan="5">No planned tasks in the last seven days.</td></tr>'}</tbody></table>`;
++ r.get('/admin/model-plans', async (req, res) => {
++ if (!owner(req)) return deny(req, res);
++ const summary = await ctx.modelPlans.outcomes();
++ const notice = req.query.tuned ? `<p role="status">${esc(req.query.tuned)} preset defaults updated.</p>` : '';
++ const body = `<main><h1>Model plan outcomes</h1><p>Last seven days. Presets change only when you use the button below.</p>${notice}<h2>Presets</h2>${table(summary.presets)}<h2>Models</h2>${table(summary.models)}<h2>Suggested tuning</h2>${summary.suggestions.length ? `<ul>${summary.suggestions.map((item) => `<li>${esc(item.preset)} builder effort: ${esc(item.before)} → ${esc(item.after)} (${esc(item.reason)})</li>`).join('')}</ul><form method="post" action="/admin/model-plans/tune"><button class="ovk-btn ovk-btn--primary">Apply these preset changes</button></form>` : '<p>No change suggested from the current sample.</p>'}</main>`;
++ return ctx.chatShell(req, res, { title: 'Model plan outcomes', body, css: ['css/c4.css'], here: '/admin/model-plans' });
++ });
++ r.post('/admin/model-plans/tune', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
++ if (!owner(req)) return deny(req, res);
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const changed = await ctx.modelPlans.tune();
++ return res.redirect(303, `/admin/model-plans?tuned=${changed.length}`);
++ });
++ return r;
++}
++
++module.exports = { createAdminModelPlans };
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 709c5b4..3fc38d3 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -813,7 +813,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>
${conductorSection}
<button class="c4-button" type="submit">Save settings</button>
--</form></section>` : signIn(req)}`;
++</form></section><section class="c4-card"><h2>Model plan</h2><p>Set a default for planning, building and checking tasks.</p><a class="ovk-btn ovk-btn--quiet" href="/settings/model-plan">Edit model plan</a></section>` : signIn(req)}`;
page(req, res, 'Settings', wrap(content));
});
r.post('/settings', form, async (req, res) => {
@@@ -843,6 +843,7 @@ ${conductorSection}
return res.redirect(303, `/settings?error=${encodeURIComponent(String((e && e.message) || 'the settings could not be saved').slice(0, 180))}`);
}
});
++ r.use(require('./model-plan-settings').createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, viewer, form, signIn }));
// The Memory page (OpenVibe Memory M2b): the person's own notes, in Codes' own kit. The routes live in their own
// module and borrow this one's kit (the page, the frame, the card, the pill, the flash and the same-origin check).
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex dd7a6c9..9ae2451 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -33,6 +33,7 @@ const PAGE_TEXT = {
'/run': ['Run locally', 'Start coding agents from any browser and run them on your own computer.'],
'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, Muse Code, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
++ '/model-plans': ['Model plans', 'Single, pair, council and escalating plans for coding tasks, with model roles, checks, configuration and API.'],
'/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],
'/improve': ['Improve OpenVibe', 'How OpenVibe is built in the open: every repository, and the path from a change to a reviewed pull request.'],
'/updates': ['What shipped on OpenVibe.Codes', 'This site\'s update log, from the network changelog feed.'],
@@@ -70,6 +71,7 @@ function publicPages({ governance = [] }) {
{ path: '/run', changefreq: 'weekly', priority: 0.9 },
{ path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
++ { path: '/model-plans', changefreq: 'monthly', priority: 0.6 },
{ path: '/skills', changefreq: 'weekly', priority: 0.9 },
{ path: '/improve', changefreq: 'weekly', priority: 0.8 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
@@@ -151,7 +153,7 @@ function createDiscoveryRoutes(ctx) {
name: SITE_NAME,
base: site,
summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, Muse Code, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',
-- details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
++ details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. Model plans: GET/PUT /api/v1/model-plans, GET /api/v1/model-plans/presets, and POST /api/v1/tasks with plan or preset; see /model-plans. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
sections: [
{ title: 'Public pages', links: (await allPages()).map(p => ({ title: p.title || describePage(p.path, { governance }).title, url: abs(p.path), note: p.text || describePage(p.path, { governance }).text, canonical: true })) },
{ title: 'Start here', links: [
ddiff --git a/server/http/model-plan-openapi.js b/server/http/model-plan-openapi.js
nnew file mode 100644
iindex 0000000..e35b7bd
---- /dev/null
++++ b/server/http/model-plan-openapi.js
@@@ -0,0 +1,37 @@
++'use strict';
++
++const plan = { $ref: 'https://openvibe.network/contracts/codes/model-plan.v1.json' };
++const project = { name: 'project', in: 'query', required: false, schema: { type: 'string' }, description: 'Project id; omit for the account default.' };
++const auth = [{ bearerAuth: [] }];
++const json = (description) => ({ description, content: { 'application/json': { schema: { type: 'object' } } } });
++
++function modelPlanPaths() {
++ return {
++ '/api/v1/model-plans': {
++ get: { operationId: 'codes.modelPlans.get', summary: 'Read account and project model plans', parameters: [project], security: auth, responses: { 200: json('Account, project and effective plan.'), 401: json('Sign in or send a person token.') } },
++ put: { operationId: 'codes.modelPlans.put', summary: 'Save an account or project model plan', parameters: [project], security: auth, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['plan'], properties: { plan }, additionalProperties: false } } } }, responses: { 200: json('Saved plan, resolved models, fallback reasons and registry prices.'), 422: json('Invalid plan or model.'), 503: json('AI model registry unavailable.') } },
++ },
++ '/api/v1/model-plans/presets': {
++ get: { operationId: 'codes.modelPlans.presets', summary: 'Copyable Fast, Balanced, Deep and Council plans', responses: { 200: json('Named plans.') } },
++ },
++ };
++}
++
++function extendTaskCreate(paths) {
++ const post = paths['/api/v1/tasks']?.post;
++ const content = post?.requestBody?.content?.['application/json'];
++ if (!content?.schema) return;
++ const original = content.schema;
++ content.schema = { oneOf: [original, {
++ type: 'object', required: ['repo', 'prompt'],
++ properties: {
++ repo: { type: 'string' }, prompt: { type: 'string' },
++ plan, preset: { type: 'string', enum: ['Fast', 'Balanced', 'Deep', 'Council'] },
++ },
++ oneOf: [{ required: ['plan'] }, { required: ['preset'] }],
++ additionalProperties: true,
++ }] };
++ post.description = `${post.description || ''}\n\nA task may provide a model plan or one of the copyable presets. The task override takes precedence over repository, project and account plans.`;
++}
++
++module.exports = { modelPlanPaths, extendTaskCreate };
ddiff --git a/server/http/model-plan-settings.js b/server/http/model-plan-settings.js
nnew file mode 100644
iindex 0000000..c4d27ff
---- /dev/null
++++ b/server/http/model-plan-settings.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++const { asyncRouter } = require('./router');
++const { esc } = require('../render/html');
++const { EFFORTS } = require('../../engine/model-plan');
++
++const ROLE_ORDER = ['planner', 'builder', 'reviewer', 'verifier'];
++
++function createModelPlanSettings(ctx, { page, wrap, header, flash, sameOrigin, viewer, form, signIn }) {
++ const r = asyncRouter();
++ const { modelPlans, aiCatalog } = ctx;
++ const url = (project, message, value) => `/settings/model-plan?${new URLSearchParams({ ...(project ? { project } : {}), [message]: String(value).slice(0, 180) })}`;
++ const field = (body, name) => String(body[name] || '').trim();
++
++ r.get('/settings/model-plan', async (req, res) => {
++ if (!viewer(req)) return page(req, res, 'Model plan', wrap(`${header('Model plan')}${signIn(req)}`));
++ const project = String(req.query.project || '').trim();
++ let saved;
++ try { saved = await modelPlans.get(req.viewer.subject, project || null); }
++ catch (error) { return page(req, res, 'Model plan', wrap(`${header('Model plan')}${flash(req)}<p role="alert">${esc(error.message)}</p>`)); }
++ const presets = await modelPlans.presets();
++ const chosen = presets[req.query.preset] || saved.effective;
++ const entries = await aiCatalog.choices({ force: true }).catch(() => []);
++ const options = [...new Map([['auto', { id: 'auto', name: 'Auto (AI routes)' }], ...entries.map((item) => [item.id, item])]).values()];
++ const modelInput = (role, current) => `<input name="role_${role}_model" list="model-plan-models" value="${esc(current.model || 'auto')}" maxlength="200" required>`;
++ const effortInput = (role, current) => `<select name="role_${role}_effort">${EFFORTS.map((value) => `<option value="${value}"${current.effort === value ? ' selected' : ''}>${value}</option>`).join('')}</select>`;
++ const roleRow = (role) => {
++ const current = chosen.roles.find((item) => item.role === role) || { model: 'auto', effort: 'medium' };
++ const enabled = chosen.roles.some((item) => item.role === role);
++ return `<fieldset class="c4-card"><legend>${esc(role)}</legend>${role === 'builder' ? '' : `<label><input type="checkbox" name="role_${role}_enabled" value="1"${enabled ? ' checked' : ''}> Use ${esc(role)}</label>`}
++<div class="c4-form-grid"><label>Model ${modelInput(role, current)}</label><label>Effort ${effortInput(role, current)}</label>${role === 'planner' ? `<label>Plan samples <input type="number" name="role_planner_samples" min="1" max="5" value="${current.samples || 1}"></label>` : ''}</div>
++<div class="c4-form-grid"><label>Token budget <input type="number" name="role_${role}_tokens" min="1" value="${esc(current.budget?.tokens || '')}"></label><label>Minute budget <input type="number" name="role_${role}_minutes" min="0.01" step="any" value="${esc(current.budget?.minutes || '')}"></label><label>Dollar budget <input type="number" name="role_${role}_dollars" min="0.01" step="any" value="${esc(current.budget?.dollars || '')}"></label></div></fieldset>`;
++ };
++ const content = `${header('Model plan', 'Choose how each coding task is planned, built and checked.')}${flash(req)}${req.viewer.role === 'owner' ? '<p><a href="/admin/model-plans">Weekly outcomes and preset tuning</a></p>' : ''}
++<section class="c4-card"><p>Start from a preset and edit any field:</p><p>${Object.keys(presets).map((name) => `<a class="ovk-btn ovk-btn--quiet" href="/settings/model-plan?${new URLSearchParams({ ...(project ? { project } : {}), preset: name })}">${esc(name)}</a>`).join(' ')}</p>
++<form class="c4-form" method="post" action="/settings/model-plan"><label>Project id (empty saves your account default) <input name="project" value="${esc(project)}" maxlength="100"></label>
++<label>Mode <select name="mode">${['single', 'pair', 'council', 'escalate'].map((mode) => `<option value="${mode}"${chosen.mode === mode ? ' selected' : ''}>${mode}</option>`).join('')}</select></label>
++<datalist id="model-plan-models">${options.map((item) => `<option value="${esc(item.id)}" label="${esc(item.name || item.display_name || item.id)}${item.desc ? ` · ${esc(item.desc)}` : ''}"></option>`).join('')}</datalist>
++${ROLE_ORDER.map(roleRow).join('')}
++<label>Escalation or fallback ladder (one model per line) <textarea name="ladder" rows="3">${esc((chosen.ladder || []).join('\n'))}</textarea></label>
++<fieldset><legend>Checks</legend><label><input type="checkbox" name="checks_tests" value="1"${chosen.checks.tests ? ' checked' : ''}> Tests</label> <label><input type="checkbox" name="checks_lint" value="1"${chosen.checks.lint ? ' checked' : ''}> Lint</label> <label><input type="checkbox" name="checks_reviewer" value="1"${chosen.checks.reviewer ? ' checked' : ''}> Reviewer approval</label></fieldset>
++<button class="ovk-btn ovk-btn--primary" type="submit">Save model plan</button></form></section>`;
++ return page(req, res, 'Model plan', wrap(content));
++ });
++
++ r.post('/settings/model-plan', form, async (req, res) => {
++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2Fsettings%2Fmodel-plan');
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const body = req.body || {};
++ const project = field(body, 'project');
++ try {
++ const roles = ROLE_ORDER.filter((role) => role === 'builder' || field(body, `role_${role}_enabled`) === '1').map((role) => {
++ const item = { role, model: field(body, `role_${role}_model`) || 'auto', effort: field(body, `role_${role}_effort`) || 'medium' };
++ if (role === 'planner') item.samples = Number(field(body, 'role_planner_samples') || 1);
++ const budget = {};
++ for (const key of ['tokens', 'minutes', 'dollars']) {
++ const value = field(body, `role_${role}_${key}`);
++ if (value) budget[key] = Number(value);
++ }
++ if (Object.keys(budget).length) item.budget = budget;
++ return item;
++ });
++ const ladder = field(body, 'ladder').split(/[\n,]+/).map((line) => line.trim()).filter(Boolean);
++ const plan = { mode: field(body, 'mode'), roles, ...(ladder.length ? { ladder } : {}), checks: { tests: field(body, 'checks_tests') === '1', lint: field(body, 'checks_lint') === '1', reviewer: field(body, 'checks_reviewer') === '1' } };
++ await modelPlans.put(req.viewer.subject, plan, project || null);
++ return res.redirect(303, url(project, 'ok', 'Model plan saved'));
++ } catch (error) { return res.redirect(303, url(project, 'error', error.message || 'The model plan could not be saved')); }
++ });
++ return r;
++}
++
++module.exports = { createModelPlanSettings };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 6240924..4d85dba 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -21,7 +21,7 @@ const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing'
// own API (/me), the Action Center read (codes.actions.read), a person's own connections (Codes C3), the conductor
// with its approvals inbox (Codes C9b), the fleet their machines report (Codes C9c) and the workspace knowledge
// their own agents read and add (Know K5).
--const API_STAYS = /^\/api\/v1\/(harnesses|runs|tasks|repos$|admin\/repo-sources$|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;
++const API_STAYS = /^\/api\/v1\/(harnesses|runs|tasks|model-plans|repos$|admin\/repo-sources$|task\/[^/]+\/share|skill\/[^/]+\/share|skills|memory-store|share|shared-with-me|actions|me|connections|conductor|approvals|fleet|knowledge|attachments)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex bec0960..14499dd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -81,6 +81,11 @@ function createPageRoutes(ctx) {
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const governance = loadGovernance();
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const modelPlansPage = () => ({
++ agent: true, index: true, cache: PUBLIC_CACHE, title: 'Model plans',
++ body: html`<h1>Model plans</h1><article class="prose">${raw(markdown(fs.readFileSync(path.join(ROOT, 'docs/model-plans.md'), 'utf8')))}</article>`,
++ });
++ r.get('/model-plans', (req, res) => page(req, res, modelPlansPage()));
// OpenVibe Git's public repositories for the home (read in the background, never awaited here); tests set ctx.git.
const gitReader = home.createGitReader({ internalUrl: config.git.internalUrl, publicUrl: config.git.publicUrl, enabled: config.nodeEnv !== 'test' });
@@@ -475,7 +480,7 @@ ${g.draft ? notice(html`<strong>Draft — pending owner review.</strong> This is
// The public pages the agent layer's Markdown twins (server/agent-pages.js) render with the same builders.
r.publicViews = {
-- home: homePage, start: startPage, harnesses: harnessesPage, improve: improvePage,
++ home: homePage, start: startPage, harnesses: harnessesPage, modelPlans: modelPlansPage, improve: improvePage,
improveRepo: improveRepoPage, updates: updatesPage, policy: policyIndexPage,
policyDoc: (slug) => { const g = governance.find((x) => x.slug === String(slug || '')); return g ? policyDocPage(g) : null; },
};
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 2840a81..9efe0d7 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -46,7 +46,7 @@ const FIL = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
// The Dock's Code tab shape (the home composer's three ways, as JSON): POST /tasks dispatches on it.
const DOCK_MODES = ['create', 'openvibe', 'repo'];
--function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, conductor = null, attachments = null, log = console }) {
++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, modelPlans = null, fleet = null, sharing = null, conductor = null, attachments = null, log = console }) {
const r = asyncRouter();
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@@ -150,6 +150,18 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return problem(req, res, 401, 'token.required', `${cap} needs a Network service token for ${config.serviceAudience}, or a signed-in owner`);
};
++ r.get('/model-plans/presets', actorLimits.reads('codes.read'), async (_req, res) => res.json({ presets: modelPlans ? await modelPlans.presets() : {} }));
++ r.get('/model-plans', guardAny('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
++ if (!modelPlans) return problem(req, res, 503, 'codes.model_plans_unavailable', 'model plans are unavailable');
++ try { return res.json(await modelPlans.get(req.principal.subject, req.query.project || null)); }
++ catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.model_plans_failed', e.message); }
++ });
++ r.put('/model-plans', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
++ if (!modelPlans) return problem(req, res, 503, 'codes.model_plans_unavailable', 'model plans are unavailable');
++ try { return res.json(await modelPlans.put(req.principal.subject, req.body?.plan, req.query.project || null)); }
++ catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.model_plans_failed', e.message); }
++ });
++
/** The task, its attempts and its outcome in plain words. The task's own fields stay at the top level. */
const timeline = async (row) => {
const attempts = await taskStore.attempts.list(row.id);
@@@ -351,6 +363,8 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
delete bodyForValidate.actor_step_id;
delete bodyForValidate.goal;
delete bodyForValidate.effort;
++ delete bodyForValidate.plan;
++ delete bodyForValidate.preset;
const check = validate('codes.task-start-request@1', bodyForValidate);
if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
let goal = null;
@@@ -387,6 +401,13 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
if (!project) {
return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.my/#machines)`);
}
++ if (b.plan != null || b.preset != null) {
++ if (!modelPlans) return problem(req, res, 503, 'codes.model_plans_unavailable', 'model plans are unavailable');
++ try {
++ const selected = await modelPlans.select(p.subject, project, { plan: b.plan || null, preset: b.preset || null });
++ args = { ...args, plan: selected.plan, plan_source: selected.source, preset: selected.preset, model_fallbacks: selected.fallbacks };
++ } catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.model_plan_invalid', e.message); }
++ }
let row;
try {
row = await taskStore.start({ subject: p.subject, project, args, via: p.via, key, actorRef: Object.keys(actorRef).length ? actorRef : null });
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex b88b84d..ae289d2 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -52,6 +52,7 @@ async function startNetworkStub() {
await db.exec("INSERT INTO codes_task_proof (id, task_id, attempt_n, n, kind, label, ok) VALUES ($1, $2, 1, 1, 'tests', 'tests', true)", [`prf_${ids.ulid()}`, taskId]);
await db.exec("INSERT INTO codes_run_tokens (id, task_id, subject, provider, jti, expires_at) VALUES ($1, $2, $3, 'openai', $4, now())", [`rtk_${ids.ulid()}`, taskId, subject, `jti_${ids.ulid()}`]);
await db.exec("INSERT INTO codes_user_settings (subject, default_agent) VALUES ($1, 'codex')", [subject]);
++ await db.exec("INSERT INTO codes_model_plans (subject, scope, plan) VALUES ($1, 'account', '{\"mode\":\"single\"}'::jsonb)", [subject]);
await db.exec("INSERT INTO codes_outbox (event_id, topic, subject, payload) VALUES ($1, 'codes.task.updated', $2, '{}'::jsonb)", [`evt_${ids.ulid()}`, subject]);
await db.exec("INSERT INTO telemetry_samples (at, service, operation, subject) VALUES (now(), 'codes', 'task.start', $1)", [subject]);
await db.exec("INSERT INTO codes_events_inbox (event_id, event_type, task_id) VALUES ($1, 'run.job.finished', $2)", [`evt_${ids.ulid()}`, taskId]);
@@@ -99,7 +100,7 @@ async function startNetworkStub() {
assert.strictEqual(file.content.length, 1);
assert.strictEqual(file.content[0].prompt, 'Ada’s task');
assert.deepStrictEqual(file.content[0].result, { ok: true });
-- for (const name of ['attempts.json', 'proof.json', 'settings.json', 'run-tokens.json']) {
++ for (const name of ['attempts.json', 'proof.json', 'settings.json', 'model-plans.json', 'run-tokens.json']) {
const f = part.body.files.find((x) => x.name === name);
assert.ok(f, `${name} is in the export`);
assert.strictEqual(f.content.length, 1, `${name} holds Ada’s row only`);
@@@ -133,7 +134,7 @@ async function startNetworkStub() {
for (const [table, key, who] of [
['codes_attempts', 'task_id', [boTask]], ['codes_task_proof', 'task_id', [boTask]], ['codes_events_inbox', 'task_id', [boTask]],
['codes_notices', 'task_id', [boTask]], ['codes_machine_fleet', 'subject', [bo]],
-- ['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
++ ['codes_run_tokens', 'subject', [bo]], ['codes_user_settings', 'subject', [bo]], ['codes_model_plans', 'subject', [bo]], ['codes_outbox', 'subject', [bo]],
['codes_knowledge', 'subject', [bo]],
]) {
const left = (await db.many(`SELECT ${key} FROM ${table}`)).map((r) => r[key]);
ddiff --git a/test/engine-model-plan-outcomes.test.js b/test/engine-model-plan-outcomes.test.js
nnew file mode 100644
iindex 0000000..8ee07e1
---- /dev/null
++++ b/test/engine-model-plan-outcomes.test.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const { PRESETS } = require('../engine/model-plan');
++const { firstChecks, weeklySummary, tuningSuggestions } = require('../engine/model-plan-outcomes');
++
++const row = (passed) => ({
++ state: 'succeeded', preset: 'Fast', role_runs: [{ role: 'builder', model: 'openvibe/economy', tokens: 100, cost_usd: 0.02, duration_ms: 1000 }],
++ result: { ok: true, log: [{ t: 'step', phase: 'tests', text: `tests: ${passed ? 'passed' : 'failed'}` }, { t: 'step', phase: 'tests', text: 'tests: passed' }] },
++});
++assert.equal(firstChecks(row(false).result), false, 'first attempt matters even if a later check passes');
++const summary = weeklySummary([row(false), row(false), row(false), row(false), row(true)]);
++assert.equal(summary.presets[0].tasks, 5);
++assert.equal(summary.presets[0].done, 5);
++assert.equal(summary.presets[0].checks_first_time, 1);
++assert.equal(summary.models[0].key, 'openvibe/economy');
++assert.equal(summary.models[0].cost_usd, 0.1);
++const suggestions = tuningSuggestions(summary, PRESETS);
++assert.equal(suggestions.length, 1);
++assert.equal(suggestions[0].preset, 'Fast');
++assert.equal(suggestions[0].after, 'medium');
++assert.equal(PRESETS.Fast.roles[0].effort, 'low', 'reading outcomes never tunes defaults');
++assert.deepEqual(tuningSuggestions(weeklySummary([row(false)]), PRESETS), [], 'a small sample does not tune');
++console.log('model plan outcomes: weekly preset/model counts and owner-only tuning proposal');
ddiff --git a/test/engine-model-plan.test.js b/test/engine-model-plan.test.js
nnew file mode 100644
iindex 0000000..683f3cf
---- /dev/null
++++ b/test/engine-model-plan.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const { PRESETS, validatePlan, fitEffort, resolvePlan, executePlan } = require('../engine/model-plan');
++
++assert.throws(() => validatePlan({ mode: 'pair', roles: [{ role: 'builder', model: 'auto', effort: 'high' }], checks: { tests: true } }), /reviewer/);
++assert.equal(fitEffort('medium', ['low', 'high']), 'high');
++const registry = { entries: [{ id: 'openvibe/economy', state: 'ok', output_nano_usd_per_m: '1000000', efforts: ['low', 'high'] }, { id: 'openvibe/auto', state: 'ok' }, { id: 'gone/model@1', state: 'bad' }] };
++const fallback = resolvePlan({ mode: 'escalate', roles: [{ role: 'builder', model: 'gone/model@1', effort: 'medium' }], ladder: ['openvibe/economy', 'openvibe/auto'], checks: { tests: true } }, registry);
++assert.equal(fallback.plan.roles[0].model, 'openvibe/economy');
++assert.equal(fallback.plan.roles[0].effort, 'high');
++assert.equal(fallback.fallbacks[0].reason, 'unavailable in AI registry');
++
++(async () => {
++ const phases = [];
++ let tests = 0;
++ const single = await executePlan(PRESETS.Fast, {
++ task: 'fix the parser',
++ invoke: async (call) => { phases.push(call.phase); return { ok: true, text: call.phase === 'verify' ? 'PASS' : 'fixed', tokens: 40, cost_usd: 0.01, duration_ms: 5 }; },
++ check: async (kind) => { tests++; return { kind, ok: true }; }, diff: async () => 'diff --git a/parser.js b/parser.js',
++ });
++ assert.equal(single.ok, true);
++ assert.deepEqual(phases, ['build', 'verify']);
++ assert.equal(tests, 2, 'verifier repeats the test');
++ assert.deepEqual(single.role_runs.map((r) => r.role), ['builder', 'verifier']);
++ assert.equal(single.role_runs[0].tokens, 40);
++
++ const pairPhases = [];
++ let reviews = 0;
++ const pair = await executePlan(PRESETS.Balanced, {
++ task: 'fix a bug',
++ invoke: async (call) => {
++ pairPhases.push(call.phase);
++ if (call.phase === 'review') return { ok: true, text: JSON.stringify({ approved: ++reviews > 1, confidence: 0.9, reason: 'cover the edge case' }) };
++ return { ok: true, text: 'PASS' };
++ }, check: async (kind) => ({ kind, ok: true }), diff: async () => 'a useful patch',
++ });
++ assert.equal(pair.ok, true);
++ assert.deepEqual(pairPhases, ['build', 'review', 'repair', 'review', 'verify']);
++ assert.equal(pair.role_runs.filter((r) => r.phase === 'repair').length, 1);
++ const refusedReview = await executePlan(PRESETS.Balanced, {
++ task: 'fix a bug',
++ invoke: async (call) => call.phase === 'review'
++ ? { ok: false, text: '{"approved":true,"confidence":0.9}', refused: true }
++ : { ok: true, text: 'PASS' },
++ check: async (kind) => ({ kind, ok: true }), diff: async () => 'patch',
++ });
++ assert.equal(refusedReview.ok, false, 'a refused reviewer cannot approve a task');
++
++ const councilPhases = [];
++ const council = await executePlan(PRESETS.Council, {
++ task: 'build the feature',
++ invoke: async (call) => { councilPhases.push(call.phase); return { ok: true, text: call.phase === 'review' ? '{"approved":true,"confidence":0.8}' : 'PASS' }; },
++ check: async (kind) => ({ kind, ok: true }), diff: async () => 'patch',
++ });
++ assert.equal(council.ok, true);
++ assert.equal(councilPhases.filter((p) => p === 'propose').length, 3);
++ assert.ok(councilPhases.includes('judge'));
++
++ const escalated = await executePlan({ mode: 'escalate', roles: [{ role: 'builder', model: 'auto', effort: 'low' }], ladder: ['openvibe/economy', 'openvibe/auto'], checks: { tests: true } }, {
++ task: 'fix a test',
++ invoke: async (call) => call.model === 'openvibe/economy' ? { ok: false, refused: true, text: 'unavailable' } : { ok: true, text: 'PASS' },
++ check: async (kind) => ({ kind, ok: true }), diff: async () => 'patch',
++ });
++ assert.equal(escalated.ok, true);
++ assert.equal(escalated.reasons[0], 'model refusal');
++ assert.deepEqual(escalated.role_runs.filter((r) => r.phase === 'build').map((r) => r.model), ['openvibe/economy', 'openvibe/auto']);
++ const uncertain = await executePlan({ mode: 'escalate', roles: [{ role: 'builder', model: 'auto', effort: 'low' }], ladder: ['openvibe/economy', 'openvibe/auto'], checks: { tests: true } }, {
++ task: 'fix a test',
++ invoke: async (call) => ({ ok: true, text: call.model === 'openvibe/economy' ? 'fixed\nConfidence: 0.3' : 'fixed\nConfidence: 0.9' }),
++ check: async (kind) => ({ kind, ok: true }), diff: async () => 'patch',
++ });
++ assert.equal(uncertain.ok, true);
++ assert.deepEqual(uncertain.reasons, ['low confidence']);
++ console.log('model plans: validation, fallback, single, pair repair, council judge, escalation and repeat checks');
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/model-plan-api.test.js b/test/model-plan-api.test.js
nnew file mode 100644
iindex 0000000..8ec79ba
---- /dev/null
++++ b/test/model-plan-api.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const { boot, check, done } = require('./helpers/boot');
++const { PRESETS } = require('../engine/model-plan');
++
++(async () => {
++ const ai = http.createServer((req, res) => {
++ res.setHeader('content-type', 'application/json');
++ if (req.url === '/v1/models') return res.end(JSON.stringify({ data: [
++ { id: 'openvibe/auto', dynamic: true, availability: 'available' },
++ { id: 'openvibe/economy', dynamic: true, availability: 'available' },
++ { id: 'acme/strong@1', availability: 'available', retail_rate: { output_nano_usd_per_m: '1000000000' } },
++ { id: 'acme/gone@1', availability: 'unavailable' },
++ ] }));
++ res.statusCode = 404; res.end('{}');
++ });
++ await new Promise((resolve) => ai.listen(0, '127.0.0.1', resolve));
++ const submitted = [];
++ const run = { enabled: true, submit: async (input) => { submitted.push(input); return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; } };
++ const t = await boot({ env: { CODES_MODEL_PLANS: '1', CODES_AI_URL: `http://127.0.0.1:${ai.address().port}` }, run });
++ const ada = t.network.addUser('ada');
++ const token = t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.start', 'codes.task.read']);
++ const headers = { Authorization: `Bearer ${token}`, 'X-OV-Subject': ada.subject };
++ const call = async (method, path, body) => {
++ const response = await fetch(`${t.base}/api/v1${path}`, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
++ return { status: response.status, body: await response.json() };
++ };
++ try {
++ await check('presets and account/project plan API validate against the AI registry', async () => {
++ const presets = await call('GET', '/model-plans/presets');
++ assert.equal(presets.status, 200);
++ assert.deepEqual(Object.keys(presets.body.presets), ['Fast', 'Balanced', 'Deep', 'Council']);
++ const before = await call('GET', '/model-plans');
++ assert.equal(before.body.account.mode, 'pair');
++ const saved = await call('PUT', '/model-plans', { plan: PRESETS.Fast });
++ assert.equal(saved.status, 200, JSON.stringify(saved.body));
++ assert.equal(saved.body.resolved.mode, 'single');
++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
++ const withProject = await call('PUT', `/model-plans?project=${project}`, { plan: PRESETS.Deep });
++ assert.equal(withProject.status, 200, JSON.stringify(withProject.body));
++ assert.equal((await call('GET', `/model-plans?project=${project}`)).body.effective.mode, 'pair');
++ const bad = await call('PUT', '/model-plans', { plan: { ...PRESETS.Fast, roles: [{ role: 'builder', model: 'acme/gone@1', effort: 'low' }] } });
++ assert.equal(bad.status, 422);
++ const fallback = await call('PUT', '/model-plans', { plan: { ...PRESETS.Fast, roles: [{ role: 'builder', model: 'acme/gone@1', effort: 'low' }], ladder: ['acme/strong@1'] } });
++ assert.equal(fallback.status, 200, JSON.stringify(fallback.body));
++ assert.equal(fallback.body.fallbacks[0].model, 'acme/strong@1');
++ });
++ await check('task preset reaches Run with the AI harness and records its resolved plan', async () => {
++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
++ await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, state, created_at, updated_at)
++ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, $2, 'https://github.com/a/b', 'previous', 'succeeded', 1, 2)`, [ada.subject, project]);
++ const created = await call('POST', '/tasks', { repo: 'https://github.com/a/b', prompt: 'Fix the parser', preset: 'Council' });
++ assert.equal(created.status, 201, JSON.stringify(created.body));
++ assert.equal(submitted.at(-1).args.agent, 'openvibe-ai');
++ assert.equal(submitted.at(-1).args.plan.mode, 'council');
++ assert.equal(submitted.at(-1).args.plan_source, 'task');
++ assert.equal(created.body.task.model_plan.mode, 'council');
++ await t.ctx.store.db.exec('UPDATE run_tasks SET role_runs = $2::jsonb WHERE id = $1', [created.body.task.id, JSON.stringify([
++ { role: 'builder', phase: 'build', model: 'openvibe/auto', effort: 'high', outcome: 'passed', tokens: 45, cost_usd: 0.02, duration_ms: 900 },
++ ])]);
++ const recorded = await call('GET', `/tasks/${created.body.task.id}`);
++ assert.equal(recorded.status, 200, JSON.stringify(recorded.body));
++ assert.equal(recorded.body.role_runs[0].tokens, 45);
++ assert.equal(recorded.body.role_runs[0].model, 'openvibe/auto');
++ const page = await t.get('/settings/model-plan', { as: ada });
++ assert.equal(page.status, 200);
++ assert.match(page.text, /Save model plan/);
++ });
++ await check('weekly admin page is owner-only and tuning requires its button', async () => {
++ const other = await t.get('/admin/model-plans', { as: ada });
++ assert.equal(other.status, 403);
++ const owner = t.network.addUser('owner', { role: 'owner' });
++ const page = await t.get('/admin/model-plans', { as: owner });
++ assert.equal(page.status, 200, page.text.slice(0, 300));
++ assert.match(page.text, /Model plan outcomes/);
++ assert.match(page.text, /Presets/);
++ const presets = await call('GET', '/model-plans/presets');
++ assert.equal(presets.body.presets.Fast.roles[0].effort, 'low', 'reading the page never tunes');
++ });
++ done();
++ } finally { await t.close(); await new Promise((resolve) => ai.close(resolve)); }
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/model-plan-config.test.js b/test/model-plan-config.test.js
nnew file mode 100644
iindex 0000000..ee426a4
---- /dev/null
++++ b/test/model-plan-config.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { PRESETS } = require('../engine/model-plan');
++const { repositoryPlan, selectPlan, resolveRepositoryPlan, runModelPlan } = require('../harness/model-plan');
++const { initial, createMapper } = require('../server/agent-ui/mapper');
++
++const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-plan-config-'));
++try {
++ fs.mkdirSync(path.join(root, '.openvibe'));
++ fs.writeFileSync(path.join(root, '.openvibe', 'codes.yml'), 'models:\n mode: pair\n roles:\n - role: builder\n model: auto\n effort: high\n - role: reviewer\n model: auto\n effort: medium\n checks:\n tests: true\n reviewer: true\n');
++ const repository = repositoryPlan(root);
++ assert.equal(repository.mode, 'pair');
++ assert.equal(selectPlan({ saved: PRESETS.Fast, repository }).mode, 'pair');
++ assert.equal(selectPlan({ saved: PRESETS.Fast, repository, task: PRESETS.Council }).mode, 'council');
++ fs.writeFileSync(path.join(root, '.openvibe', 'codes.yml'), 'models:\n mode: pair\n roles: invalid\n');
++ assert.throws(() => repositoryPlan(root), /codes\.yml models: plan\.roles/);
++} finally { fs.rmSync(root, { recursive: true, force: true }); }
++
++(async () => {
++ const resolved = await resolveRepositoryPlan({ ...PRESETS.Fast, roles: [{ role: 'builder', model: 'acme/gone@1', effort: 'low' }], ladder: ['openvibe/economy'] }, {
++ env: { OPENVIBE_AI_URL: 'https://ai.test/v1', OPENVIBE_AI_TOKEN: 'stub' },
++ fetchImpl: async (url) => {
++ assert.equal(url, 'https://ai.test/v1/models');
++ return { ok: true, json: async () => ({ data: [
++ { id: 'acme/gone@1', availability: 'unavailable' },
++ { id: 'openvibe/economy', availability: 'available' },
++ ] }) };
++ },
++ });
++ assert.equal(resolved.plan.roles[0].model, 'openvibe/economy');
++ assert.equal(resolved.fallbacks.length, 1);
++ const appUrls = [];
++ const appResolved = await resolveRepositoryPlan(PRESETS.Fast, {
++ env: { OPENVIBE_AI_URL: 'https://ai.test/v1', OPENVIBE_NETWORK_URL: 'https://network.test', OPENVIBE_APP_ID: 'app', OPENVIBE_APP_SECRET: 'stub' },
++ fetchImpl: async (url) => {
++ appUrls.push(url);
++ return url.endsWith('/oauth/token')
++ ? { ok: true, json: async () => ({ access_token: 'stub', expires_in: 300 }) }
++ : { ok: true, json: async () => ({ data: [{ id: 'openvibe/auto', availability: 'available' }] }) };
++ },
++ });
++ assert.equal(appResolved.plan.mode, 'single');
++ assert.deepEqual(appUrls, ['https://network.test/oauth/token', 'https://ai.test/v1/models']);
++
++ const calls = [];
++ const lines = [];
++ const ran = await runModelPlan({
++ plan: PRESETS.Fast, task: 'fix', cwd: root, diff: async () => 'diff --git a/a b/a',
++ harness: { run: (request) => {
++ calls.push(request);
++ return { async *[Symbol.asyncIterator]() { yield { type: 'result', result: request.task === 'edit' ? 'fixed' : 'PASS', total_tokens: 12, total_cost_usd: 0.001 }; } };
++ } },
++ check: async (kind) => ({ kind, ok: true }), say: (line) => lines.push(line), compact: () => null,
++ });
++ assert.equal(ran.is_error, false);
++ assert.deepEqual(calls.map((call) => call.agent), ['openvibe-ai', 'openvibe-ai']);
++ assert.deepEqual(ran.role_runs.map((run) => run.role), ['builder', 'verifier']);
++ assert.equal(ran.total_tokens, 24);
++ assert.equal(lines.filter((line) => line.t === 'step' && line.event === 'end').length, 2);
++
++ const mapper = createMapper(initial('task_1', 'codes', new Date().toISOString()));
++ await mapper.push({ t: 'step', event: 'start', role: 'builder', phase: 'build', model: 'openvibe/auto' }, 1, new Date().toISOString());
++ await mapper.push({ t: 'step', event: 'end', role: 'builder', phase: 'build', model: 'openvibe/auto', outcome: 'passed', tokens: 100, cost_usd: 0.01, duration_ms: 100 }, 2, new Date().toISOString());
++ assert.equal(mapper.events.filter((e) => e.event.type === 'STEP_STARTED').length, 1);
++ const finished = mapper.events.find((e) => e.event.type === 'STEP_FINISHED').event;
++ assert.equal(finished.metadata.ov.model, 'openvibe/auto');
++ assert.equal(finished.metadata.ov.tokens, 100);
++ console.log('model plan config: repository YAML, precedence, AI fallback, harness routing and AG-UI role metadata');
++})().catch((error) => { console.error(error); process.exitCode = 1; });