ruvnet-brain 4.0.5 → 4.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +112 -7
  3. package/config/model-router/catalog.template.json +17 -2
  4. package/console/app.js +32 -14
  5. package/console/architecture.html +1 -1
  6. package/data/model-catalog.json +54 -0
  7. package/package.json +2 -1
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/hooks/codex-hooks.json +17 -17
  11. package/plugin/hooks/hooks.json +34 -1
  12. package/plugin/scripts/codex-hook-wrapper.mjs +36 -4
  13. package/plugin/scripts/ground-ruvnet.sh +2 -1
  14. package/plugin/scripts/hook-shim.mjs +13 -6
  15. package/plugin/scripts/lesson-provenance.mjs +21 -0
  16. package/plugin/scripts/lesson-store.mjs +33 -3
  17. package/plugin/scripts/route-dispatch.sh +23 -49
  18. package/plugin/scripts/session-snapshot-contract.mjs +100 -0
  19. package/plugin/scripts/session-snapshot-hook.mjs +34 -0
  20. package/plugin/scripts/session-start-core.mjs +25 -7
  21. package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
  22. package/plugin/skills/release-proof/SKILL.md +4 -4
  23. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
  24. package/scripts/capability-registry.mjs +3 -3
  25. package/scripts/lesson-lifecycle.mjs +2 -1
  26. package/scripts/lesson-ratify.mjs +6 -3
  27. package/scripts/lesson-seed.mjs +21 -16
  28. package/scripts/model-router-catalog.mjs +16 -0
  29. package/scripts/model-router-engine.mjs +4 -1
  30. package/scripts/onboarding-console.mjs +52 -14
  31. package/scripts/provider-availability.mjs +12 -0
  32. package/scripts/release-authority.mjs +14 -2
  33. package/scripts/release-transaction-provider.mjs +236 -0
  34. package/scripts/release-transaction.mjs +214 -0
  35. package/scripts/release.mjs +67 -279
  36. package/scripts/session-snapshot-contract.mjs +1 -0
  37. package/scripts/stabilization-receipt.mjs +1 -1
  38. package/scripts/staged-host-verifier.mjs +106 -0
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const SNAPSHOT_SCHEMA = 'ruvnet-brain.session-snapshot';
5
+ export const SNAPSHOT_VERSION = 1;
6
+ export const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
+
8
+ const EVENTS = new Set(['PreCompact', 'PostCompact', 'SessionEnd']);
9
+
10
+ export function createSessionSnapshot({ event, capturedAt = new Date().toISOString() }) {
11
+ if (!EVENTS.has(event)) throw new Error(`unsupported snapshot event: ${event}`);
12
+ if (!Number.isFinite(Date.parse(capturedAt))) throw new Error('capturedAt must be an ISO-8601 timestamp');
13
+ return {
14
+ schema: SNAPSHOT_SCHEMA,
15
+ version: SNAPSHOT_VERSION,
16
+ capturedAt,
17
+ boundary: { event },
18
+ privacy: { rawTranscriptStored: false, credentialValuesStored: false },
19
+ };
20
+ }
21
+
22
+ export function validateSessionSnapshot(value) {
23
+ return Boolean(value)
24
+ && typeof value === 'object'
25
+ && !Array.isArray(value)
26
+ && value.schema === SNAPSHOT_SCHEMA
27
+ && value.version === SNAPSHOT_VERSION
28
+ && EVENTS.has(value.boundary?.event)
29
+ && Number.isFinite(Date.parse(value.capturedAt))
30
+ && value.privacy?.rawTranscriptStored === false
31
+ && value.privacy?.credentialValuesStored === false;
32
+ }
33
+
34
+ function freshness(capturedAt, now) {
35
+ const age = now - Date.parse(capturedAt);
36
+ return age >= 0 && age <= SNAPSHOT_MAX_AGE_MS;
37
+ }
38
+
39
+ function canonical(projectDir, now) {
40
+ const file = path.join(projectDir, '.swarm', 'agentdb-sessions.jsonl');
41
+ if (!fs.existsSync(file)) return { values: [], malformed: false };
42
+ try {
43
+ const stat = fs.lstatSync(file);
44
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) {
45
+ return { values: [], malformed: true };
46
+ }
47
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-128);
48
+ const values = [];
49
+ let malformed = false;
50
+ for (const line of lines) {
51
+ try {
52
+ const value = JSON.parse(line);
53
+ if (validateSessionSnapshot(value)) values.push({ kind: 'canonical', fresh: freshness(value.capturedAt, now), capturedAt: value.capturedAt });
54
+ else malformed = true;
55
+ } catch { malformed = true; }
56
+ }
57
+ return { values, malformed };
58
+ } catch { return { values: [], malformed: true }; }
59
+ }
60
+
61
+ function legacy(projectDir, now) {
62
+ const values = [];
63
+ let malformed = false;
64
+ for (const root of ['.claude', '.claude-flow']) {
65
+ const directory = path.join(projectDir, root, 'sessions');
66
+ if (!fs.existsSync(directory)) continue;
67
+ let names;
68
+ try {
69
+ const stat = fs.lstatSync(directory);
70
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return { values: [], malformed: true };
71
+ names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64);
72
+ } catch { malformed = true; continue; }
73
+ for (const name of names) {
74
+ try {
75
+ const file = path.join(directory, name);
76
+ const stat = fs.lstatSync(file);
77
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) { malformed = true; continue; }
78
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ const capturedAt = value.endedAt || value.startedAt;
80
+ if (typeof value.id !== 'string' || !value.id || !value.context || !value.metrics || !Number.isFinite(Date.parse(capturedAt))) {
81
+ malformed = true;
82
+ continue;
83
+ }
84
+ values.push({ kind: 'legacy', fresh: freshness(capturedAt, now), capturedAt });
85
+ } catch { malformed = true; }
86
+ }
87
+ }
88
+ return { values, malformed };
89
+ }
90
+
91
+ export function inspectSessionSnapshots(projectDir, { now = Date.now() } = {}) {
92
+ const results = [canonical(projectDir, now), legacy(projectDir, now)];
93
+ const values = results.flatMap((result) => result.values);
94
+ const priority = { canonical: 0, legacy: 1 };
95
+ const fresh = values.filter((value) => value.fresh).sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
96
+ if (fresh) return fresh;
97
+ if (values.length) return values.sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
98
+ if (results.some((result) => result.malformed)) return { kind: 'malformed', fresh: false };
99
+ return { kind: 'absent', fresh: false };
100
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createSessionSnapshot } from './session-snapshot-contract.mjs';
4
+
5
+ function regularOrAbsent(file) {
6
+ try {
7
+ const stat = fs.lstatSync(file);
8
+ return stat.isFile() && !stat.isSymbolicLink();
9
+ } catch (error) {
10
+ return error?.code === 'ENOENT';
11
+ }
12
+ }
13
+
14
+ export function writeSessionSnapshot(projectDir, event) {
15
+ const swarm = path.join(projectDir, '.swarm');
16
+ const target = path.join(swarm, 'agentdb-sessions.jsonl');
17
+ try {
18
+ if (fs.existsSync(swarm)) {
19
+ const stat = fs.lstatSync(swarm);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
21
+ } else {
22
+ fs.mkdirSync(swarm, { recursive: false, mode: 0o700 });
23
+ }
24
+ if (!regularOrAbsent(target)) return false;
25
+ fs.appendFileSync(target, `${JSON.stringify(createSessionSnapshot({ event }))}\n`, { mode: 0o600 });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
33
+ writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd');
34
+ }
@@ -62,9 +62,30 @@ const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.e
62
62
  String(ttl), log, command, ...args,
63
63
  ], { env, stdio: 'ignore', timeout: 2000 })?.status === 0;
64
64
 
65
- const surfaceIssues = (stateDir, emit, now) => {
65
+ export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => {
66
+ const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE
67
+ || path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json');
68
+ // Windows ACL ownership is not available through this dependency-free hot path. Fail closed
69
+ // instead of weakening an owner-only promise into "any local user who can write the file".
70
+ if (platform === 'win32') return false;
71
+ let stat;
72
+ try { stat = fs.lstatSync(file); } catch { return false; }
73
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
74
+ // This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a
75
+ // shared machine cannot turn a private maintainer signal into a terminal banner for other users.
76
+ if ((stat.mode & 0o077) !== 0) return false;
77
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
78
+ const entitlement = json(file);
79
+ return entitlement?.enabled === true
80
+ && Array.isArray(entitlement.repos)
81
+ && entitlement.repos.includes(repo);
82
+ };
83
+
84
+ const surfaceIssues = (stateDir, emit, now, env, home, platform) => {
66
85
  const status = json(path.join(stateDir, 'open-issues.json'));
67
- if (!status?.at || now - new Date(status.at).getTime() > 6 * 3600_000) return;
86
+ const observedAt = Date.parse(status?.at || '');
87
+ if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return;
88
+ if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return;
68
89
  const open = Array.isArray(status.issues) ? status.issues : [];
69
90
  if (!open.length) return;
70
91
  const breaches = open.filter((issue) => issue.breach).sort((a, b) => b.ageHours - a.ageHours);
@@ -383,7 +404,7 @@ export async function runSessionStart({
383
404
  emit(`Offer ONCE: "Want to see your whole RuvNet stack on one page?" — installed parts, learned project knowledge and reversible fixes, read-only until clicked; later it's ${consoleInvoke}. On yes invoke ${consoleInvoke}; on no, don't re-offer.`);
384
405
  }
385
406
 
386
- surfaceIssues(stateDir, emit, now);
407
+ surfaceIssues(stateDir, emit, now, env, home, platform);
387
408
  surfaceSignals({ env, cwd, stateDir, hookDir, emit, now });
388
409
 
389
410
  const routerProfile = path.join(home, '.claude', 'model-router', 'profile.json');
@@ -476,10 +497,7 @@ export async function runSessionStart({
476
497
 
477
498
  const playbook = `${hookDir}${path.sep}..${path.sep}skills${path.sep}ruvnet-brain${path.sep}PLAYBOOK.md`;
478
499
  emit('[RuvNet Brain — standing build playbook for this session (referenced by later turns as THE PLAYBOOK)]');
479
- emit(`Full text: ${playbook} — read it before your first build response this session. Condensed:`);
480
- emit('Every build/change request: take the wheel. FIRST, silently — read the files this touches in THEIR repo; search_ruvnet what the feature technically DOES; check project memory.');
481
- emit('⛔ NO SILENT SUBSTITUTION (#1 trust-killer): never hand-roll, or aim a generic Task subagent at, work a RuvNet tool owns — QE=agentic-qe, swarms=ruflo, routing=agentic-flow, vectors=RuVector, memory=AgentDB, red/blue=@metaharness/redblue. Use the real one; if absent offer the exact install; if unusable say so out loud, every time. Never give your own code its name.');
482
- emit('Beats A-D are in that file, in full. In short: A RESPOND in one voice (hear them; THE ATTACK as one lettered plan over their real files; why it holds; what you checked; "Build it now?") · B ON A YES EXECUTE END-TO-END (SPARC with a QA gate per phase, DDD, ADRs, PARALLEL Ruflo swarm work, AgentDB persistence, frontend-design + real image generation, a PROVEN result scored to >=98, ONE ask for a missing API key) · C TAKE OVER what you do well · D keep them oriented. RUN THE PROCESS.');
500
+ emit(`Read ${playbook} before the first build response. It requires source inspection, search_ruvnet grounding, project-memory recall, and the real owning rUv tool—never a silent hand-roll or generic substitute.`);
483
501
  }
484
502
  } catch (error) {
485
503
  if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // swarm-slot-recycler.mjs — Claude Code TeammateIdle recycling boundary.
3
+ //
4
+ // Claude owns the shared task files and their claim locks. This hook never edits them. It reads the
5
+ // team ledger at the synchronous TeammateIdle boundary and refuses idling only when it can prove an
6
+ // unassigned pending task is ready. Claude then performs the normal locked TaskUpdate claim. Missing,
7
+ // malformed, dependency-blocked, or ambiguous state fails open: a scheduler must not invent work.
8
+ //
9
+ // Host boundary: Claude Code exposes TeammateIdle; Codex 0.146.0 exposes SubagentStop but no
10
+ // TeammateIdle/TaskCompleted event or equivalent shared-task ledger. Do not register this body in the
11
+ // Codex manifest and do not claim Codex recycling is hook-enforced.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { readStdinBounded } from './hook-input.mjs';
17
+
18
+ const MAX_INPUT_BYTES = 64 * 1024;
19
+ const MAX_TASK_BYTES = 256 * 1024;
20
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
21
+
22
+ function allowIdle() { process.exit(0); }
23
+
24
+ let raw = Buffer.alloc(0);
25
+ try {
26
+ raw = await readStdinBounded({ maxBytes: MAX_INPUT_BYTES, idleMs: 50, emptyMs: 250 });
27
+ } catch { allowIdle(); }
28
+
29
+ let event;
30
+ try {
31
+ event = JSON.parse(raw.toString('utf8'));
32
+ } catch { allowIdle(); }
33
+
34
+ if (!event || typeof event !== 'object' || Array.isArray(event)) allowIdle();
35
+ if (event.hook_event_name !== 'TeammateIdle') allowIdle();
36
+
37
+ const teamName = String(event.team_name || '');
38
+ const teammateName = String(event.teammate_name || '');
39
+ if (!SAFE_NAME.test(teamName) || !SAFE_NAME.test(teammateName)) allowIdle();
40
+
41
+ const tasksRoot = process.env.RUVNET_CLAUDE_TASKS_DIR
42
+ || path.join(os.homedir(), '.claude', 'tasks');
43
+ const teamDir = path.resolve(tasksRoot, teamName);
44
+ const root = path.resolve(tasksRoot);
45
+ if (path.dirname(teamDir) !== root) allowIdle();
46
+
47
+ let files;
48
+ try {
49
+ files = fs.readdirSync(teamDir, { withFileTypes: true })
50
+ .filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name))
51
+ .map((entry) => entry.name);
52
+ } catch { allowIdle(); }
53
+
54
+ const tasks = new Map();
55
+ for (const file of files) {
56
+ try {
57
+ const taskPath = path.join(teamDir, file);
58
+ const stat = fs.statSync(taskPath);
59
+ if (!stat.isFile() || stat.size > MAX_TASK_BYTES) continue;
60
+ const task = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
61
+ if (!task || typeof task !== 'object' || Array.isArray(task)) continue;
62
+ const id = String(task.id || path.basename(file, '.json'));
63
+ if (!/^\d+$/.test(id)) continue;
64
+ tasks.set(id, task);
65
+ } catch { /* one bad task cannot make another look ready */ }
66
+ }
67
+
68
+ const completed = new Set(
69
+ [...tasks].filter(([, task]) => task.status === 'completed').map(([id]) => id),
70
+ );
71
+
72
+ const ready = [...tasks]
73
+ .filter(([, task]) => task.status === 'pending')
74
+ .filter(([, task]) => !String(task.owner || '').trim())
75
+ .filter(([, task]) => {
76
+ if (!Array.isArray(task.blockedBy)) return false;
77
+ return task.blockedBy.every((id) => completed.has(String(id)));
78
+ })
79
+ .sort(([left], [right]) => Number(left) - Number(right));
80
+
81
+ if (!ready.length) allowIdle();
82
+
83
+ const [id, task] = ready[0];
84
+ const subject = String(task.subject || 'untitled task').replace(/[\r\n]+/g, ' ').slice(0, 160);
85
+ process.stderr.write(
86
+ `Ready work remains. Claim task ${id} (${subject}) now with TaskUpdate owner=${teammateName} `
87
+ + 'and status=in_progress, then execute it. Do not go idle while an unassigned, unblocked pending task exists.\n',
88
+ );
89
+ process.exit(2);
@@ -30,10 +30,10 @@ green.
30
30
  ## Candidate seal
31
31
 
32
32
  Generate the receipt from commands in the protected candidate workflow. Do not hand-author it.
33
- For generation 4.0.4, dispatch `.github/workflows/protected-release.yml` only with the full candidate
34
- SHA, sealed artifact SHA-256, exact version `4.0.4`, and the successful exact-SHA CI run ID whose
35
- named `release-qe` job produced `release-evidence-<sha>`. The workflow checks every binding before
36
- creating its sealed handoff and again after the production reviewer approves. Missing artifacts,
33
+ Dispatch `.github/workflows/protected-release.yml` only with the full candidate SHA, the exact
34
+ current version, and the successful exact-SHA CI run ID whose named `release-qe` job produced
35
+ `release-evidence-<sha>`. The workflow derives the artifact digest from those sealed bytes and checks
36
+ every binding before creating its handoff and again at the Production boundary. Missing artifacts,
37
37
  pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher.
38
38
  Validate it from the repository with:
39
39
 
@@ -1,6 +1,6 @@
1
1
  # THE PLAYBOOK — the standing build playbook, in full
2
2
 
3
- Updated: 2026-07-30 | Version 1.0.1
3
+ Updated: 2026-08-02 | Version 1.1.0
4
4
  Created: 2026-07-27
5
5
 
6
6
  **Read this before your first build response in a session.** `plugin/scripts/session-start.sh`
@@ -87,10 +87,25 @@ rule-compliance, cite a source the tools didn't return, or claim a check that di
87
87
  Completion, with a QA gate between phases.
88
88
  - For a non-trivial domain, model it first (DDD: bounded contexts, aggregates, domain events) and
89
89
  capture key decisions as ADRs — design before code.
90
- - Spin up PARALLEL work where it helps (a Ruflo swarm / multiple agents) instead of serial drudgery.
90
+ - **Parallel-by-default state machine for multi-part work.** Before the first spawn, decompose the
91
+ whole request, create the complete shared task list/ledger, record dependencies and give every
92
+ writing task its own worktree. Ruflo coordinates roles and state; the native host executes. Fill
93
+ available executor slots with independent ready work immediately, without asking, up to the
94
+ host's configured capacity; never oversubscribe and never put more than one writer in a worktree.
95
+ A completion moves that task to completed, unblocks its dependents, and the freed slot claims the
96
+ first unassigned, unblocked pending task immediately. Only allow a slot to idle when no such task
97
+ exists. Keep dependent integration with the designated integration owner.
98
+ - **Claude Code:** its shared task ledger and `TeammateIdle` hook make recycling enforceable: the
99
+ shipped recycler refuses idle while a ready unassigned task exists, then Claude's locked
100
+ `TaskUpdate` claim performs the transition.
101
+ - **Codex:** Codex 0.146.0 exposes no `TeammateIdle` or `TaskCompleted` hook and no equivalent
102
+ shared-task hook ledger. Initial fan-out and completion-notification recycling are guidance,
103
+ not hook enforcement: the lead must immediately dispatch the next ready ledger item when a
104
+ collaboration slot completes. State this degraded boundary if it affects the run; never call it
105
+ enforced.
91
106
  If Ruflo / RuVector MCP tools aren't available in this environment, DON'T block or stall — degrade
92
- gracefully to Claude Code's native subagents (Task) and local .rvf, and briefly note the tool that
93
- would make it better + how to add it. Never demand a tool the user doesn't have.
107
+ gracefully to the native host's agents and local .rvf, and briefly note the tool that would make
108
+ it better + how to add it. Never demand a tool the user doesn't have.
94
109
  - Persist decisions + state to AgentDB memory so nothing is lost across sessions or compaction.
95
110
  - If it has a UI, treat design as a BUILD STEP, not a coat of paint: apply the frontend-design
96
111
  discipline and GENERATE the visuals (AI image generation for UI mockups / diagrams / the explainer
@@ -515,9 +515,9 @@ export const CAPABILITIES = [
515
515
  // router was in fact never being consulted at all. A quiet week and a severed wire look identical
516
516
  // from the receipt file, so read the wire directly.
517
517
  //
518
- // Two things must both be true for anything to route: a PreToolUse gate on subagent dispatch
519
- // (plugin/scripts/route-dispatch.sh, which is what turns "declare a model" from advice into a
520
- // wall), and the opt-in profile it refuses to act without (route-dispatch.sh:46 exits 0 when
518
+ // Two things must both be true for the host-limited dispatch audit to record anything: a
519
+ // PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without
520
+ // (route-dispatch.sh exits 0 when
521
521
  // profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy
522
522
  // the receipt ledger looks.
523
523
  const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json'));
@@ -43,7 +43,7 @@
43
43
  import fs from 'node:fs';
44
44
  import path from 'node:path';
45
45
  import crypto from 'node:crypto';
46
- import { ORIGIN, STATUS, ENFORCEMENT, loadLessons } from './lesson-store.mjs';
46
+ import { ORIGIN, SOURCE_CLASS, STATUS, ENFORCEMENT, loadLessons } from './lesson-store.mjs';
47
47
 
48
48
  // ── The bars, in one place, as numbers a human can argue with ────────────────────────────────────
49
49
  export const RETIREMENT = Object.freeze({
@@ -385,6 +385,7 @@ export function explainGeneralization(lesson, otherProjects = [], { minProjects
385
385
  // `{...lesson, ...proposal}`, so the result cannot block even if the source lesson was ratified
386
386
  // and blocking — the trust boundary is enforced by the data, not by the caller remembering.
387
387
  origin: ORIGIN.MODEL_INFERRED,
388
+ sourceClass: SOURCE_CLASS.MODEL_INFERRED,
388
389
  status: STATUS.CANDIDATE,
389
390
  enforcement: ENFORCEMENT.CHECKLIST,
390
391
  intendedEnforcement: null,
@@ -26,7 +26,7 @@
26
26
  * correctly, stops using it.
27
27
  */
28
28
  import os from 'node:os';
29
- import { loadLessons, saveLessons, ratify, demote, weightOf, pending, ENFORCEMENT, STATUS, ORIGIN, TRIGGERS } from './lesson-store.mjs';
29
+ import { loadLessons, saveLessons, ratify, demote, weightOf, pending, ENFORCEMENT, STATUS, ORIGIN, SOURCE_CLASS, TRIGGERS } from './lesson-store.mjs';
30
30
 
31
31
  const argv = process.argv.slice(2);
32
32
  const arg = (f) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
@@ -34,7 +34,7 @@ const has = (f) => argv.includes(f);
34
34
 
35
35
  const lessons = loadLessons();
36
36
  if (!lessons.length) {
37
- console.log('\n No lessons stored yet. Seed them with: node scripts/lesson-seed.mjs --apply\n');
37
+ console.log('\n No personal lessons stored yet. They appear after an explicit user correction is captured.\n');
38
38
  process.exit(0);
39
39
  }
40
40
 
@@ -86,7 +86,10 @@ if (has('--ratify')) {
86
86
  // Bulk convenience, deliberately scoped: it can only touch lessons the USER stated. Model-inferred
87
87
  // lessons are never swept up by a bulk action — that would be exactly the hole the boundary closes.
88
88
  let next = lessons;
89
- const targets = lessons.filter((l) => l.origin === ORIGIN.USER_STATED && l.status === STATUS.CANDIDATE && !l.demoted);
89
+ const targets = lessons.filter((l) => l.origin === ORIGIN.USER_STATED
90
+ && l.sourceClass === SOURCE_CLASS.CURRENT_USER
91
+ && l.status === STATUS.CANDIDATE
92
+ && !l.demoted);
90
93
  for (const l of targets) next = ratify(l.id, next);
91
94
  saveLessons(next);
92
95
  const nowBlocking = next.filter((l) => l.enforcement === ENFORCEMENT.BLOCK).length;
@@ -17,17 +17,22 @@
17
17
  import os from 'node:os';
18
18
  import {
19
19
  makeLesson, saveLessons, loadLessons, lessonsFor, unenforceable, pending, weightOf,
20
- TRIGGERS as T, ENFORCEMENT as E, ORIGIN as O,
20
+ TRIGGERS as T, ENFORCEMENT as E, ORIGIN as O, SOURCE_CLASS,
21
21
  } from './lesson-store.mjs';
22
22
 
23
23
  // Shipped at CHECKLIST; `intendedEnforcement` records what it becomes once a human ratifies it.
24
24
  const blocking = { enforcement: E.CHECKLIST, intendedEnforcement: E.BLOCK };
25
+ const ownerImport = {
26
+ origin: O.IMPORTED,
27
+ sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
28
+ demoted: true,
29
+ };
25
30
 
26
31
  export const SEED = [
27
32
  makeLesson({
28
33
  id: 'L01-verify-with-a-capable-channel',
29
34
  ...blocking,
30
- origin: O.USER_STATED,
35
+ ...ownerImport,
31
36
  severity: 'high',
32
37
  statement: 'Before claiming something works, verify through a channel CAPABLE of observing the change — an independent tool, a re-measurement, a read-write connection. Never the exit code of the thing being tested.',
33
38
  trigger: T.CLAIM_DONE.key,
@@ -46,7 +51,7 @@ export const SEED = [
46
51
  makeLesson({
47
52
  id: 'L02-check-before-you-assert',
48
53
  ...blocking,
49
- origin: O.USER_STATED,
54
+ ...ownerImport,
50
55
  severity: 'high',
51
56
  statement: 'Before stating any fact about the world — a version, an API, what a tool does, how an architecture works — read a live source THIS TURN and name it. Recalling is not checking. The urge to skip the check IS the signal you are about to be wrong.',
52
57
  trigger: T.ASSERT_FACT.key,
@@ -64,7 +69,7 @@ export const SEED = [
64
69
  makeLesson({
65
70
  id: 'L03-research-before-recommending',
66
71
  enforcement: E.CHECKLIST,
67
- origin: O.USER_STATED,
72
+ ...ownerImport,
68
73
  statement: 'Before recommending an architecture, research it: compare at least three real options with tradeoffs, and check whether the ecosystem already ships it. Pattern-matching from training data is not a recommendation.',
69
74
  trigger: T.RECOMMEND_ARCH.key,
70
75
  evidence: [
@@ -78,7 +83,7 @@ export const SEED = [
78
83
  makeLesson({
79
84
  id: 'L04-never-relay-a-number',
80
85
  enforcement: E.CHECKLIST,
81
- origin: O.USER_STATED,
86
+ ...ownerImport,
82
87
  severity: 'high',
83
88
  statement: 'Never repeat a score, benchmark, or subagent result without re-checking the underlying artifact yourself. A number you did not measure is a claim you cannot defend.',
84
89
  trigger: T.RELAY_NUMBER.key,
@@ -93,7 +98,7 @@ export const SEED = [
93
98
  makeLesson({
94
99
  id: 'L05-version-is-the-update-signal',
95
100
  ...blocking,
96
- origin: O.USER_STATED,
101
+ ...ownerImport,
97
102
  severity: 'high',
98
103
  statement: 'Any behaviour-changing push bumps the version IN THE SAME COMMIT, and the release narrative is updated to match. A fix label on a new subsystem is a lie about what changed.',
99
104
  trigger: T.SHIP.key,
@@ -109,7 +114,7 @@ export const SEED = [
109
114
  makeLesson({
110
115
  id: 'L06-use-the-real-tool',
111
116
  ...blocking,
112
- origin: O.USER_STATED,
117
+ ...ownerImport,
113
118
  severity: 'high',
114
119
  statement: 'Before writing code in the RuvNet domain, search for the tool that already implements it. If you still disagree after genuinely looking, say so OUT LOUD, cite the source path, and name the hand-roll as a hand-roll. Never silently.',
115
120
  trigger: T.WRITE_CODE.key,
@@ -127,7 +132,7 @@ export const SEED = [
127
132
  makeLesson({
128
133
  id: 'L07-blast-radius-not-social-comfort',
129
134
  ...blocking,
130
- origin: O.USER_STATED,
135
+ ...ownerImport,
131
136
  severity: 'high',
132
137
  statement: 'Gate on blast radius, not on how awkward an action feels. Ask: is it reversible, and is it outward-facing? A silent irreversible change is worse than an awkward reversible one.',
133
138
  trigger: T.MUTATE_MACHINE.key,
@@ -143,7 +148,7 @@ export const SEED = [
143
148
  makeLesson({
144
149
  id: 'L08-status-is-a-table',
145
150
  enforcement: E.CHECKLIST,
146
- origin: O.USER_STATED,
151
+ ...ownerImport,
147
152
  statement: 'Report status as a structured table with an explicit shipped/tested column, never as narrative. Prose lets unfinished work live inside sentences about progress.',
148
153
  trigger: T.REPORT_STATUS.key,
149
154
  evidence: [
@@ -159,7 +164,7 @@ export const SEED = [
159
164
  makeLesson({
160
165
  id: 'L09-gradeable-is-not-valuable',
161
166
  enforcement: E.CHECKLIST,
162
- origin: O.MODEL_INFERRED,
167
+ ...ownerImport,
163
168
  statement: 'When choosing what to work on, name the ungradeable items explicitly and commit to them. The instinct to pick the task with a green test routes systematically away from the user\'s actual value.',
164
169
  trigger: T.CHOOSE_WORK.key,
165
170
  evidence: [
@@ -172,7 +177,7 @@ export const SEED = [
172
177
  makeLesson({
173
178
  id: 'L10-under-enumeration-is-a-tell',
174
179
  enforcement: E.CHECKLIST,
175
- origin: O.MODEL_INFERRED,
180
+ ...ownerImport,
176
181
  statement: 'When producing a list of failure modes, options, or requirements, state what was left out and why. A satisfyingly round number is evidence of rounding, not of completeness.',
177
182
  trigger: T.REPORT_STATUS.key,
178
183
  evidence: [
@@ -185,7 +190,7 @@ export const SEED = [
185
190
  makeLesson({
186
191
  id: 'L11-retrieval-without-volition-is-broken',
187
192
  enforcement: E.REVIEW,
188
- origin: O.MODEL_INFERRED,
193
+ ...ownerImport,
189
194
  statement: 'A surface that CAN detect something useful and stays silent is broken. Judge every feature by whether it volunteers what it knows, not by whether it can answer when asked.',
190
195
  trigger: T.CHOOSE_WORK.key,
191
196
  evidence: [
@@ -199,7 +204,7 @@ export const SEED = [
199
204
  makeLesson({
200
205
  id: 'L12-efficiency-seeking-is-the-tell',
201
206
  enforcement: E.REVIEW,
202
- origin: O.USER_STATED,
207
+ ...ownerImport,
203
208
  statement: 'Treat the impulse to save a step as a defect signal, not a virtue. Skipping a check to save a turn is the specific mechanism that produces wrong answers — effectiveness first, always.',
204
209
  trigger: T.CHOOSE_WORK.key,
205
210
  evidence: [
@@ -228,14 +233,14 @@ if (invokedDirectly) {
228
233
  for (const l of ls) {
229
234
  const shown = l.intendedEnforcement ? `${l.enforcement}→${l.intendedEnforcement}` : l.enforcement;
230
235
  console.log(` ${shown.padEnd(18)} ${l.id}`);
231
- console.log(` ${''.padEnd(18)} ${l.origin === 'user-stated' ? 'you said it' : 'MODEL-INFERRED (quarantined — can never block)'} · taught ${l.repeatCount}× · weight ${weightOf(l)}`);
236
+ console.log(` ${''.padEnd(18)} IMPORTED MAINTAINER HISTORY (quarantined — never personal policy) · observed ${l.repeatCount}× · weight ${weightOf(l)}`);
232
237
  }
233
238
  console.log('');
234
239
  }
235
240
  const un = unenforceable(SEED);
236
241
  const pend = pending(SEED);
237
- console.log(` ${pend.length} awaiting YOUR ratification. Nothing here blocks until you agree to it —`);
238
- console.log(` the model does not get to ratify its own rules.`);
242
+ console.log(` ${pend.length} awaiting ratification. Bundled maintainer history is quarantined and cannot`);
243
+ console.log(` become the installing user's personal policy.`);
239
244
  if (un.length) console.log(` ${un.length} are declared unenforceable (no hook can observe them); checked at review instead.`);
240
245
  console.log('');
241
246
 
@@ -0,0 +1,16 @@
1
+ // Managed facts are additive for existing users. Their existing row is the user overlay: it wins
2
+ // byte-for-byte, including disablement, tier/priority changes, and local candidates. Newly shipped
3
+ // metered rows are not auto-added; that would silently expand spend authority.
4
+ export function mergeManagedCatalog(existing, managed) {
5
+ const current = Array.isArray(existing?.candidates) ? existing.candidates : [];
6
+ const seen = new Set(current.map((candidate) => candidate?.id).filter(Boolean));
7
+ const additions = (managed?.candidates || []).filter((candidate) => candidate?.id
8
+ && !seen.has(candidate.id)
9
+ && ((candidate.subscription || []).length > 0 || candidate.provider === 'local'));
10
+ const next = {
11
+ ...existing,
12
+ managedVersion: managed?.managedVersion || managed?.updated || null,
13
+ candidates: [...current, ...additions],
14
+ };
15
+ return JSON.stringify(next) === JSON.stringify(existing) ? existing : next;
16
+ }
@@ -113,10 +113,13 @@ export function loadCatalog() {
113
113
  } catch {
114
114
  /* fall through to a minimal built-in so the engine still answers */
115
115
  }
116
- // Built-in fallback (verified OpenRouter prices from route-cheap; Anthropic frontier from same).
116
+ // Built-in fallback. Claude launchability was verified against Claude Code 2.1.220 on 2026-08-02;
117
+ // prices remain null where the subscription host, rather than a metered API, is authoritative.
117
118
  return [
118
119
  { id: 'deepseek/deepseek-chat', provider: 'openrouter', harness: ['claude-code', 'codex'], tier: 'cheap', costPerMTok: { in: 0.2, out: 0.8 }, verified: '2026-07-07' },
119
120
  { id: 'claude-opus-4-8', provider: 'anthropic', harness: ['claude-code'], tier: 'frontier', costPerMTok: { in: 5.0, out: 25.0 }, verified: '2026-07-07' },
121
+ { id: 'claude-opus-5', provider: 'anthropic', harness: ['claude-code'], subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: '2026-08-02 Claude Code 2.1.220 launch' },
122
+ { id: 'claude-fable-5', provider: 'anthropic', harness: ['claude-code'], subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: '2026-08-02 Claude Code 2.1.220 launch' },
120
123
  { id: 'gpt-5.5', provider: 'openai', harness: ['codex'], tier: 'frontier', costPerMTok: null, verified: null },
121
124
  ];
122
125
  }