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.
- package/README.md +3 -3
- package/bin/install.mjs +118 -9
- package/config/model-router/catalog.template.json +17 -2
- package/console/app.js +32 -14
- package/console/architecture.html +1 -1
- package/data/model-catalog.json +54 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/hooks/hooks.json +34 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
- package/plugin/scripts/ground-ruvnet.sh +2 -1
- package/plugin/scripts/hook-shim.mjs +13 -6
- package/plugin/scripts/lesson-provenance.mjs +21 -0
- package/plugin/scripts/lesson-store.mjs +33 -3
- package/plugin/scripts/route-dispatch.sh +23 -49
- package/plugin/scripts/session-snapshot-contract.mjs +100 -0
- package/plugin/scripts/session-snapshot-hook.mjs +34 -0
- package/plugin/scripts/session-start-core.mjs +25 -7
- package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
- package/plugin/skills/release-proof/SKILL.md +4 -4
- package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
- package/scripts/candidate-host-evidence.mjs +49 -0
- package/scripts/capability-registry.mjs +3 -3
- package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
- package/scripts/learning-replay-contract.mjs +14 -4
- package/scripts/learning-replay-execution.mjs +4 -3
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/lesson-lifecycle.mjs +2 -1
- package/scripts/lesson-ratify.mjs +6 -3
- package/scripts/lesson-seed.mjs +21 -16
- package/scripts/model-router-catalog.mjs +16 -0
- package/scripts/model-router-engine.mjs +4 -1
- package/scripts/onboarding-console.mjs +52 -14
- package/scripts/provider-availability.mjs +12 -0
- package/scripts/publication-receipt.mjs +126 -34
- package/scripts/release-authority.mjs +14 -2
- package/scripts/release-evidence-aggregate.mjs +55 -0
- package/scripts/release-payload.mjs +100 -0
- package/scripts/release-transaction-provider.mjs +413 -0
- package/scripts/release-transaction.mjs +404 -0
- package/scripts/release.mjs +97 -280
- package/scripts/selfcheck.mjs +20 -5
- package/scripts/session-snapshot-contract.mjs +1 -0
- 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
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
creating its
|
|
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 !==
|
|
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
|
-
['
|
|
159
|
-
['
|
|
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 ['
|
|
172
|
+
for (const hostName of ['claudeOnly', 'codexOnly', 'dual']) {
|
|
169
173
|
const host = publication?.installed?.[hostName];
|
|
170
|
-
if (host?.status !== 'PASS' || host?.
|
|
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-
|
|
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
|
-
-
|
|
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
|
|
93
|
-
|
|
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
|
|
519
|
-
//
|
|
520
|
-
//
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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;
|