ruvnet-brain 4.0.6 → 4.0.8

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 (47) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +118 -9
  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 +72 -1
  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/release-proof/scripts/release-proof.mjs +12 -5
  24. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
  25. package/scripts/candidate-host-evidence.mjs +49 -0
  26. package/scripts/capability-registry.mjs +3 -3
  27. package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
  28. package/scripts/learning-replay-contract.mjs +14 -4
  29. package/scripts/learning-replay-execution.mjs +4 -3
  30. package/scripts/learning-replay-fixture.mjs +23 -2
  31. package/scripts/lesson-lifecycle.mjs +2 -1
  32. package/scripts/lesson-ratify.mjs +6 -3
  33. package/scripts/lesson-seed.mjs +21 -16
  34. package/scripts/model-router-catalog.mjs +16 -0
  35. package/scripts/model-router-engine.mjs +4 -1
  36. package/scripts/onboarding-console.mjs +52 -14
  37. package/scripts/provider-availability.mjs +12 -0
  38. package/scripts/publication-receipt.mjs +126 -34
  39. package/scripts/release-authority.mjs +14 -2
  40. package/scripts/release-evidence-aggregate.mjs +55 -0
  41. package/scripts/release-payload.mjs +100 -0
  42. package/scripts/release-transaction-provider.mjs +413 -0
  43. package/scripts/release-transaction.mjs +404 -0
  44. package/scripts/release.mjs +97 -280
  45. package/scripts/selfcheck.mjs +20 -5
  46. package/scripts/session-snapshot-contract.mjs +1 -0
  47. package/scripts/staged-host-verifier.mjs +103 -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
 
@@ -145,8 +145,11 @@ export function evaluatePublicationReceipt(candidate, publication) {
145
145
  const failures = [...candidateResult.failures];
146
146
  const sha = candidateResult.sha;
147
147
  const digest = candidateResult.artifactSha256;
148
- if (publication?.schemaVersion !== 1 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=1 and phase=publication are required'));
148
+ if (publication?.schemaVersion !== 2 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=2 and phase=publication are required'));
149
149
  if (publication?.sha !== sha || publication?.artifactSha256 !== digest) failures.push(fail('PUBLICATION_SEAL_MISMATCH', 'publication does not reference candidate seal'));
150
+ if (!cleanHex(publication?.payloadId, 64) || !cleanHex(publication?.bundleArtifactSha256, 64)) {
151
+ failures.push(fail('PUBLIC_PAYLOAD_BINDING_MISSING', 'signed payload and public bundle digests are required'));
152
+ }
150
153
  const version = versionField(candidate?.version);
151
154
  const tag = version ? `v${version}` : null;
152
155
  const identityMismatches = versionIdentityFailures(version, candidate?.tag, [
@@ -155,8 +158,9 @@ export function evaluatePublicationReceipt(candidate, publication) {
155
158
  ['GitHub release tag', publication?.githubRelease?.tag, tag],
156
159
  ['bundle brainVersion', publication?.bundle?.brainVersion],
157
160
  ['bundle releaseTag', publication?.bundle?.releaseTag, tag],
158
- ['installed Claude host', publication?.installed?.claude?.version],
159
- ['installed Codex host', publication?.installed?.codex?.version],
161
+ ['Claude-only host', publication?.installed?.claudeOnly?.version],
162
+ ['Codex-only host', publication?.installed?.codexOnly?.version],
163
+ ['dual host', publication?.installed?.dual?.version],
160
164
  ]);
161
165
  if (identityMismatches.length > 0) {
162
166
  failures.push(fail('PUBLIC_VERSION_IDENTITY_MISMATCH', `public surfaces must identify candidate ${version ?? 'UNKNOWN'}: ${identityMismatches.join(', ')}`));
@@ -165,9 +169,12 @@ export function evaluatePublicationReceipt(candidate, publication) {
165
169
  const item = publication?.[surface];
166
170
  if (item?.sha !== sha || item?.artifactSha256 !== digest) failures.push(fail('PUBLIC_ARTIFACT_MISMATCH', `${surface} differs from candidate seal`));
167
171
  }
168
- for (const hostName of ['claude', 'codex']) {
172
+ for (const hostName of ['claudeOnly', 'codexOnly', 'dual']) {
169
173
  const host = publication?.installed?.[hostName];
170
- if (host?.status !== 'PASS' || host?.artifactSha256 !== digest) failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running sealed public artifact`));
174
+ if (host?.status !== 'PASS' || host?.doctorExit !== 0 || host?.artifactSha256 !== digest
175
+ || host?.functionalSearch !== true || !(host?.searchMs <= Number(publication?.brain?.deadlineMs) * 0.8)) {
176
+ failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running a doctor-clean functional sealed public artifact`));
177
+ }
171
178
  }
172
179
  const brain = publication?.brain || {};
173
180
  if (brain.status !== 'PASS' || brain.selfStore !== true || !(brain.broadMs <= Number(brain.deadlineMs) * 0.8)) failures.push(fail('PUBLIC_BRAIN_NOT_PASS', 'public installed Brain acceptance failed'));
@@ -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
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { stagedHostVerifier } from './staged-host-verifier.mjs';
6
+ import { payloadIdFor } from './release-payload.mjs';
7
+
8
+ const arg = (name) => {
9
+ const index = process.argv.indexOf(name);
10
+ return index >= 0 ? process.argv[index + 1] : null;
11
+ };
12
+ const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
13
+
14
+ const manifestPath = path.resolve(arg('--manifest'));
15
+ const packagePath = path.resolve(arg('--package'));
16
+ const bundlePath = path.resolve(arg('--bundle'));
17
+ const out = path.resolve(arg('--out'));
18
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
19
+ const payloadId = payloadIdFor(manifest);
20
+ const identity = { version: manifest.version, candidateSha: manifest.candidateSha, payloadId };
21
+ const result = await stagedHostVerifier({ assets: { packagePath, bundlePath }, identity })
22
+ .verify({ source: 'candidate', assets: { packagePath, bundlePath } });
23
+ if (result.verdict !== 'PASS') throw new Error(`candidate host matrix failed: ${result.error || 'unknown'}`);
24
+
25
+ const modeNames = { claude: 'claude-only', codex: 'codex-only', dual: 'dual-host' };
26
+ const leaves = Object.entries(modeNames).map(([mode, name]) => {
27
+ const fixture = result.fixtures?.[mode];
28
+ if (fixture?.status !== 'PASS' || fixture?.doctorExit !== 0) throw new Error(`${name} did not produce a clean doctor receipt`);
29
+ return {
30
+ name,
31
+ sha: manifest.candidateSha,
32
+ payloadId,
33
+ status: fixture.status === 'PASS' ? 'completed' : 'failed',
34
+ conclusion: 'success',
35
+ verdict: 'PASS',
36
+ source: 'candidate-host-evidence',
37
+ mode,
38
+ doctorExit: fixture.doctorExit,
39
+ artifactSha256: sha256(packagePath),
40
+ };
41
+ });
42
+ fs.writeFileSync(out, `${JSON.stringify({
43
+ schemaVersion: 1,
44
+ sha: manifest.candidateSha,
45
+ payloadId,
46
+ artifactSha256: sha256(packagePath),
47
+ leaves,
48
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
49
+ console.log(JSON.stringify({ verdict: 'PASS', payloadId, leaves: leaves.map(({ name }) => name) }));
@@ -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'));
@@ -23,8 +23,20 @@ const probe = process.env.RUVNET_REPLAY_LESSON_PROBE || '';
23
23
  if (event === 'PreToolUse') {
24
24
  const recorder = process.env.RUVNET_REPLAY_RECORDER || '';
25
25
  if (!recorder) process.exit(2);
26
+ // Codex emits exec_command with tool_input.cmd; the shared Claude/Ruflo boundary consumes
27
+ // Bash with tool_input.command. Normalize the real host envelope before the recorder parses it.
28
+ const recorderInput = Buffer.from(JSON.stringify({
29
+ ...input,
30
+ tool_name: /^(?:functions[._]{1,2})?exec_command$/.test(String(input.tool_name || ''))
31
+ ? 'Bash'
32
+ : input.tool_name,
33
+ tool_input: {
34
+ ...(input.tool_input || {}),
35
+ command: input.tool_input?.command ?? input.tool_input?.cmd ?? input.command ?? '',
36
+ },
37
+ }));
26
38
  const result = spawnSync(process.execPath, [recorder, attemptsFile, sequenceFile], {
27
- input: raw,
39
+ input: recorderInput,
28
40
  encoding: 'utf8',
29
41
  env: process.env,
30
42
  });
@@ -59,6 +59,16 @@ export function trapSpec(id = TRAP.MEMORY_SEARCH) {
59
59
  };
60
60
  }
61
61
 
62
+ // Codex commonly prints the global binary as $HOME/.npm-global/bin/ruflo. That is a real shell
63
+ // executable path, not prose, but the shared command parser intentionally does not expand shell
64
+ // variables. Canonicalize only an executable-position prefix; never rewrite quoted mentions.
65
+ export function normalizeRufloExecutable(command) {
66
+ return String(command || '').replace(
67
+ /(^|(?:&&|\|\||[;|()\n])\s*)\$(?:HOME|\{HOME\})\/\.npm-global\/bin\/(ruflo|claude-flow)(?=\s|$)/g,
68
+ '$1$2',
69
+ );
70
+ }
71
+
62
72
  export const LOAD_BEARING = Object.freeze([
63
73
  'scripts/learning-replay.mjs',
64
74
  'scripts/learning-replay-contract.mjs',
@@ -80,7 +90,7 @@ export const LOAD_BEARING = Object.freeze([
80
90
  ]);
81
91
 
82
92
  export function classifyCommand(cmd) {
83
- const invocations = findInvocations(String(cmd || ''), ['ruflo', 'claude-flow']);
93
+ const invocations = findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']);
84
94
  if (!invocations.length) return 'none';
85
95
  let sawPositional = false;
86
96
  for (const inv of invocations) {
@@ -102,7 +112,7 @@ export function classifyCommand(cmd) {
102
112
  }
103
113
 
104
114
  export function subcommandCorrect(cmd) {
105
- for (const inv of findInvocations(String(cmd || ''), ['ruflo', 'claude-flow'])) {
115
+ for (const inv of findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow'])) {
106
116
  const words = inv.args.filter((a) => a !== '' && !a.startsWith('-'));
107
117
  const memory = words.indexOf('memory');
108
118
  if (memory !== -1 && words[memory + 1] === 'search') return true;
@@ -124,7 +134,7 @@ export function optionValue(args, short, long) {
124
134
  }
125
135
 
126
136
  export function classifyPostTaskCommand(cmd) {
127
- const invocations = findInvocations(String(cmd || ''), ['ruflo', 'claude-flow']);
137
+ const invocations = findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']);
128
138
  if (!invocations.length) return 'none';
129
139
  let sawPostTask = false;
130
140
  for (const inv of invocations) {
@@ -142,7 +152,7 @@ export function classifyPostTaskCommand(cmd) {
142
152
  }
143
153
 
144
154
  export function postTaskSubcommandCorrect(cmd) {
145
- return findInvocations(String(cmd || ''), ['ruflo', 'claude-flow']).some((inv) => {
155
+ return findInvocations(normalizeRufloExecutable(cmd), ['ruflo', 'claude-flow']).some((inv) => {
146
156
  const words = inv.args.filter((a) => a !== '' && !a.startsWith('-'));
147
157
  const hooks = words.indexOf('hooks');
148
158
  return hooks !== -1 && words[hooks + 1] === 'post-task';
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { commandNodes, findInvocations } from '../plugin/scripts/hook-input.mjs';
6
- import { optionValue, TRAP } from './learning-replay-contract.mjs';
6
+ import { normalizeRufloExecutable, optionValue, TRAP } from './learning-replay-contract.mjs';
7
7
 
8
8
  export const PROJECT_B_MEMORY_KEY = 'note-caching-strategy';
9
9
  export const PROJECT_B_MEMORY_VALUE =
@@ -90,9 +90,10 @@ export function executeProducedCommand(cmd, {
90
90
  output: '',
91
91
  ...extra,
92
92
  });
93
- const invocations = findInvocations(String(cmd || ''), ['ruflo', 'claude-flow']);
93
+ const normalized = normalizeRufloExecutable(cmd);
94
+ const invocations = findInvocations(normalized, ['ruflo', 'claude-flow']);
94
95
  if (!invocations.length) return reject('no Ruflo invocation to execute');
95
- const firstExecutable = path.basename(commandNodes(String(cmd || ''))[0]?.exe || '').split('@')[0];
96
+ const firstExecutable = path.basename(commandNodes(normalized)[0]?.exe || '').split('@')[0];
96
97
  if (invocations.length !== 1 || firstExecutable !== 'ruflo') {
97
98
  return reject('corrective command was not the first and only Ruflo invocation');
98
99
  }
@@ -48,6 +48,16 @@ function initMemoryDb(ruflo, db, cwd) {
48
48
  return sh(ruflo, ['memory', 'init', '--path', db, '--backend', 'hybrid'], { cwd });
49
49
  }
50
50
 
51
+ function retrieveExact(ruflo, db, cwd, key, expected) {
52
+ const result = sh(ruflo, [
53
+ 'memory', 'retrieve', '-k', key, '-n', 'default', '--value-only', '--path', db,
54
+ ], { cwd });
55
+ return {
56
+ exit: result.status,
57
+ matched: result.status === 0 && String(result.stdout || '').includes(expected),
58
+ };
59
+ }
60
+
51
61
  export function buildFixtures(baseDir) {
52
62
  fs.mkdirSync(baseDir, { recursive: true });
53
63
  const dirs = {
@@ -90,6 +100,7 @@ export function recordInProjectA(dirs, {
90
100
  'memory', 'search', '-q', spec.recordQuery,
91
101
  '-n', 'default', '--path', db, '-t', 'keyword',
92
102
  ], { cwd: project });
103
+ const exact = retrieveExact(ruflo, db, project, key, spec.lesson);
93
104
  return {
94
105
  project: path.basename(project),
95
106
  db,
@@ -97,7 +108,8 @@ export function recordInProjectA(dirs, {
97
108
  initExit: init.status,
98
109
  storeExit: store.status,
99
110
  readBackExit: readBack.status,
100
- recorded: fs.existsSync(db),
111
+ exactReadExit: exact.exit,
112
+ recorded: exact.matched,
101
113
  };
102
114
  });
103
115
  const lesson = makeLesson({
@@ -138,12 +150,14 @@ export function seedProjectBMemory(dirs, { ruflo = RUFLO_BIN } = {}) {
138
150
  'memory', 'store', '-k', PROJECT_B_MEMORY_KEY,
139
151
  '--value', PROJECT_B_MEMORY_VALUE, '-n', 'default', '--path', db,
140
152
  ], { cwd: dirs.projectB });
153
+ const exact = retrieveExact(ruflo, db, dirs.projectB, PROJECT_B_MEMORY_KEY, PROJECT_B_MEMORY_VALUE);
141
154
  return {
142
155
  db,
143
156
  key: PROJECT_B_MEMORY_KEY,
144
157
  initExit: init.status,
145
158
  storeExit: store.status,
146
- ok: init.status === 0 && store.status === 0 && fs.existsSync(db),
159
+ exactReadExit: exact.exit,
160
+ ok: init.status === 0 && store.status === 0 && exact.matched,
147
161
  };
148
162
  }
149
163
 
@@ -156,6 +170,13 @@ export function nightlyRefresh(dirs, { ruflo = RUFLO_BIN } = {}) {
156
170
  path.join(ROOT, 'scripts', 'ci', 'learning-replay-codex-adapter.mjs'),
157
171
  path.join(versionDir, 'scripts', 'codex-hook-adapter.mjs'),
158
172
  );
173
+ // Codex registrations call this stable, generation-independent wrapper. A fixture that only
174
+ // creates active.json silently falls back to the user's real Brain home and never records the
175
+ // trap's PreToolUse command, making an expensive replay unmeasurable.
176
+ fs.copyFileSync(
177
+ path.join(ROOT, 'plugin', 'scripts', 'codex-hook-wrapper.mjs'),
178
+ path.join(dirs.brainHome, 'codex-hook.mjs'),
179
+ );
159
180
  fs.writeFileSync(path.join(dirs.brainHome, 'active.json'), JSON.stringify({
160
181
  codeRoot: versionDir,
161
182
  generation,
@@ -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;