ruvnet-brain 4.0.1 → 4.0.2
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/.claude-plugin/marketplace.json +1 -0
- package/README.md +4 -4
- package/bin/install.mjs +100 -5
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +8 -22
- package/plugin/.claude-plugin/marketplace.json +1 -0
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +2 -2
- package/plugin/commands/configure.md +3 -2
- package/plugin/commands/rvbc.md +4 -3
- package/plugin/commands/rvcb.md +2 -2
- package/plugin/hooks/hooks.json +1 -2
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +21 -0
- package/plugin/scripts/detach.mjs +14 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -6
- package/plugin/scripts/hook-shim.mjs +7 -7
- package/plugin/scripts/learn-capture.sh +22 -3
- package/plugin/scripts/learn-flush.mjs +21 -4
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +3 -858
- package/plugin/skills/brain-console/SKILL.md +4 -2
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
- package/plugin/skills/rvbc/SKILL.md +9 -6
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
- package/plugin/scripts/finalize-token-meter.mjs +0 -25
|
@@ -121,7 +121,7 @@ check_memory_db() {
|
|
|
121
121
|
for _db in .swarm/agentdb-memory.db .swarm/memory.db .claude/memory.db; do
|
|
122
122
|
check_memory_db "$_db" && break
|
|
123
123
|
done
|
|
124
|
-
if [ "$MEM_STATE"
|
|
124
|
+
if [ "$MEM_STATE" != "on" ]; then
|
|
125
125
|
_common_git=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
|
|
126
126
|
case "$_common_git" in
|
|
127
127
|
*/.git)
|
|
@@ -136,6 +136,15 @@ if [ "$MEM_STATE" = "off" ]; then
|
|
|
136
136
|
;;
|
|
137
137
|
esac
|
|
138
138
|
fi
|
|
139
|
+
if [ "$MEM_STATE" != "on" ] && [ -n "${HOME:-}" ] && [ "$PWD" != "$HOME" ]; then
|
|
140
|
+
for _db in \
|
|
141
|
+
"$HOME/.swarm/agentdb-memory.db" \
|
|
142
|
+
"$HOME/.swarm/memory.db" \
|
|
143
|
+
"$HOME/.claude/memory.db"
|
|
144
|
+
do
|
|
145
|
+
check_memory_db "$_db" && break
|
|
146
|
+
done
|
|
147
|
+
fi
|
|
139
148
|
unset _db
|
|
140
149
|
unset _common_git _primary_root
|
|
141
150
|
# RUNNING version (this session's loaded plugin) vs STAGED version (marketplace copy on disk).
|
|
@@ -162,11 +171,10 @@ if [ "$RUFLO_STATE" = "yes" ] && [ "$MEM_STATE" = "off" ]; then
|
|
|
162
171
|
This project runs the Ruflo stack but AgentDB persistent project memory is NOT set up (.swarm/memory.db does not exist) — decisions made here are being lost between sessions. rUv's default is memory ON. If you have not already offered this session, offer ONCE, plainly and warmly: "One thing I noticed: this project doesn't have persistent memory turned on — AgentDB would let me carry decisions and context across sessions instead of starting cold each time. Want me to turn it on and wire it up?" On a yes, set it up with the ruflo tools you have (a first memory_store write creates the store) and confirm with the real file path. If they decline, respect it for the rest of the session — the status line keeps them informed without nagging.
|
|
163
172
|
EOF
|
|
164
173
|
fi
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
fi
|
|
174
|
+
# A stale mtime is not a user-facing event. SessionEnd/PreCompact own capture;
|
|
175
|
+
# their persistence receipt must fail closed at the write boundary. Emitting a
|
|
176
|
+
# UserPromptSubmit instruction here interrupted unrelated work and still could
|
|
177
|
+
# not prove that the store command wrote the canonical database.
|
|
170
178
|
|
|
171
179
|
# ── Self-learning flywheel (ruflo ≥3.24, ADR-176) — OFFER it, never switch it on for them ────────
|
|
172
180
|
# Opt-in is a single env var; `harnessLoopOptedIn()` in @claude-flow/cli reads process.env directly,
|
|
@@ -375,6 +383,7 @@ This task touches the RuvNet / rUv stack. Your training priors here are STALE an
|
|
|
375
383
|
- You MUST call the `search_ruvnet` MCP tool and ground your answer in the cited source paths it returns BEFORE stating what any RuvNet tool can/cannot do or which one to use. Asserting a RuvNet capability from memory, without a cited source path, is an error — do not do it.
|
|
376
384
|
- If a needed RuvNet repo isn't covered yet, ingest it first (`node scripts/ingest-repo.mjs --name <repo>`) rather than guessing.
|
|
377
385
|
- SIGNAL that the brain is working so the user has confidence it's in charge: when you ground an answer, say so briefly (e.g. "Grounding in rUv's source…" → cite the path). Silent grounding leaves the user unsure it's even on.
|
|
386
|
+
- MEMORY DIAGNOSIS: never infer a broken write from DB/WAL mtime, semantic-search misses, daemon startup, or the CLI success line. Prove it against ONE canonical absolute path: unique-key `ruflo memory store --path <project>/.swarm/memory.db` → exact-key `ruflo memory retrieve --path ...` → exact SQL row. Only then call it miswired.
|
|
378
387
|
EOF
|
|
379
388
|
fi
|
|
380
389
|
|
|
@@ -398,6 +407,7 @@ This is a build / change request — run THE PLAYBOOK (the standing build playbo
|
|
|
398
407
|
- Read the actual files in THEIR repo this touches — what pattern do they already use? what would duplicate?
|
|
399
408
|
- Call `search_ruvnet` for what the feature technically DOES — never trust memory about what the corpus has.
|
|
400
409
|
- Check project memory (ruflo memory search / AgentDB) for prior decisions on this area.
|
|
410
|
+
- If memory looks broken, use the exact-path store → exact-key retrieve → exact SQL proof; never diagnose from semantic search, mtime, daemon startup, or a success message.
|
|
401
411
|
⛔ NO SILENT SUBSTITUTION: use the real RuvNet tool, or say out loud that you're hand-rolling and why.
|
|
402
412
|
Senior partner: one plan, momentum, end with real work.
|
|
403
413
|
EOF
|
|
@@ -75,13 +75,13 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
|
75
75
|
// stops a subagent fan-out inheriting an expensive model; design-wall stops an
|
|
76
76
|
// ungraded surface shipping; protect-state guards the user's own consent record.
|
|
77
77
|
// None becomes acceptable because retrieval is off.
|
|
78
|
-
// 'partial' — the hook splits INTERNALLY. session-start still runs the auto-updater heartbeat, the
|
|
78
|
+
// 'partial' — the hook splits INTERNALLY. session-start-core still runs the auto-updater heartbeat, the
|
|
79
79
|
// GONG health alarm and the SLA banner (an off machine must still receive fixes,
|
|
80
80
|
// otherwise the fix for an off-state bug can never arrive) while suppressing every
|
|
81
81
|
// advertising byte. The snapshot is forwarded as RUVNET_BRAIN_OFF so the body reads
|
|
82
82
|
// ONE resolved answer instead of racing the filesystem again mid-run.
|
|
83
83
|
const TABLE = {
|
|
84
|
-
'session-start': { file: 'session-start.
|
|
84
|
+
'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
|
|
85
85
|
'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
86
86
|
'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
87
87
|
'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
|
|
@@ -102,7 +102,7 @@ const TABLE = {
|
|
|
102
102
|
// SEPARATE process, SEPARATE cadence) resolves later via `gh run list`. offBehavior 'silence' per
|
|
103
103
|
// the ADR's explicit instruction: this plane observes and advises, it never guards money or
|
|
104
104
|
// honesty on its own — the git-push debt it opens is only ever SURFACED (never gated) by
|
|
105
|
-
// session-start.
|
|
105
|
+
// session-start-core.mjs, and that surfacing already lives under SessionStart's 'partial' contract.
|
|
106
106
|
'signal-watch': { file: 'signal-watch.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
107
107
|
'routing-outcome': { file: 'routing-outcome-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' },
|
|
108
108
|
// The unprompted-speech chokepoint (ADR-040 / DDD-0004). ONE runtime is the sole writer of
|
|
@@ -247,10 +247,10 @@ function runHook(file) {
|
|
|
247
247
|
// The unprompted-speech chokepoint uses this to receive the CC event name:
|
|
248
248
|
// `hook-shim.mjs unprompted-speech UserPromptSubmit` → `unprompted-runtime.mjs UserPromptSubmit`.
|
|
249
249
|
const extraArgs = process.argv.slice(3);
|
|
250
|
-
// Forward the ONE resolved OFF snapshot to a 'partial' body (ADR-054 §4). The
|
|
251
|
-
//
|
|
252
|
-
// this shim
|
|
253
|
-
//
|
|
250
|
+
// Forward the ONE resolved OFF snapshot to a 'partial' body (ADR-054 §4). The native SessionStart
|
|
251
|
+
// core also reads the sentinel because the POSIX compatibility launcher and bare installs invoke
|
|
252
|
+
// it outside this shim. Passing the snapshot means the two readings cannot disagree within one
|
|
253
|
+
// invocation if the user flips the switch while the hook is mid-run.
|
|
254
254
|
const env = (BRAIN_OFF && entry.offBehavior === 'partial')
|
|
255
255
|
? { ...process.env, RUVNET_BRAIN_OFF: '1' }
|
|
256
256
|
: process.env;
|
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
|
|
10
10
|
set -uo pipefail
|
|
11
11
|
|
|
12
|
+
# One policy source for both capture and flush. `off` means zero bytes written. `project` keeps the
|
|
13
|
+
# trajectory queue under this project's .swarm directory; `user` preserves the cross-project learner
|
|
14
|
+
# introduced by ADR-0017. Tests and managed hosts may pass an already-resolved snapshot in
|
|
15
|
+
# RUVNET_LEARNING_SCOPE so the two halves cannot disagree during one hook invocation.
|
|
16
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
|
17
|
+
SCOPE="${RUVNET_LEARNING_SCOPE:-}"
|
|
18
|
+
if [ -z "$SCOPE" ] && [ -f "$HERE/runtime-preferences.mjs" ] && command -v node >/dev/null 2>&1; then
|
|
19
|
+
SCOPE=$(node "$HERE/runtime-preferences.mjs" --learning-scope 2>/dev/null) || SCOPE=""
|
|
20
|
+
fi
|
|
21
|
+
case "$SCOPE" in
|
|
22
|
+
off) exit 0 ;;
|
|
23
|
+
user|project) ;;
|
|
24
|
+
*) SCOPE="project" ;;
|
|
25
|
+
esac
|
|
26
|
+
|
|
12
27
|
# BOUNDED READ. An unqualified `read` waits forever on a stdin that is opened and never closed, and
|
|
13
28
|
# an unbounded accumulator turns a large payload into an unbounded regex scan. Claude Code always
|
|
14
29
|
# writes the payload and closes, so neither costs a normal turn — which is exactly why a hook that
|
|
@@ -104,7 +119,11 @@ re_s='"session_id"[[:space:]]*:[[:space:]]*"([^"\]*)"'
|
|
|
104
119
|
# keeping `.` would let a crafted id survive as `..`-shaped debris in a filename for no benefit.
|
|
105
120
|
SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
|
|
106
121
|
[ -n "$SID" ] || SID="default"
|
|
107
|
-
|
|
122
|
+
if [ "$SCOPE" = "user" ]; then
|
|
123
|
+
DIR="$HOME/.cache/ruvnet-brain/learn"
|
|
124
|
+
else
|
|
125
|
+
DIR="$PWD/.swarm/ruvnet-brain-learn"
|
|
126
|
+
fi
|
|
108
127
|
# Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
|
|
109
128
|
# account is normally in `staff`, so any other user on a shared or corporate machine could read it.
|
|
110
129
|
( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
|
|
@@ -147,8 +166,8 @@ if [ "$LINES" -ge "$HEARTBEAT_EVERY" ]; then
|
|
|
147
166
|
LAST=$(cat "$STAMP" 2>/dev/null || echo 0)
|
|
148
167
|
if [ $((NOW - LAST)) -ge 60 ]; then
|
|
149
168
|
echo "$NOW" > "$STAMP" 2>/dev/null || true
|
|
150
|
-
FLUSH="$
|
|
151
|
-
[ -f "$FLUSH" ] && (nohup node "$FLUSH" >/dev/null 2>&1 &) || true
|
|
169
|
+
FLUSH="$HERE/learn-flush.mjs"
|
|
170
|
+
[ -f "$FLUSH" ] && (RUVNET_LEARNING_SCOPE="$SCOPE" nohup node "$FLUSH" >/dev/null 2>&1 &) || true
|
|
152
171
|
fi
|
|
153
172
|
fi
|
|
154
173
|
exit 0
|
|
@@ -13,8 +13,15 @@ import os from 'node:os';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { readStdinBounded } from './hook-input.mjs';
|
|
16
|
+
import { loadRuntimePreferences } from './runtime-preferences.mjs';
|
|
16
17
|
|
|
17
18
|
const HOME = os.homedir();
|
|
19
|
+
const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
|
|
20
|
+
const configuredScope = process.env.RUVNET_LEARNING_SCOPE
|
|
21
|
+
|| loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
|
|
22
|
+
const LEARNING_SCOPE = ['off', 'project', 'user'].includes(configuredScope)
|
|
23
|
+
? configuredScope : 'project';
|
|
24
|
+
if (LEARNING_SCOPE === 'off') process.exit(0);
|
|
18
25
|
|
|
19
26
|
// THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
|
|
20
27
|
//
|
|
@@ -37,8 +44,12 @@ async function payloadSessionId() {
|
|
|
37
44
|
}
|
|
38
45
|
// A filename COMPONENT, never a path — the payload is untrusted input.
|
|
39
46
|
const SID = ((await payloadSessionId()) || process.env.CLAUDE_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '') || 'default';
|
|
40
|
-
const
|
|
47
|
+
const QUEUE_ROOT = LEARNING_SCOPE === 'user'
|
|
48
|
+
? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
|
|
49
|
+
: path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
|
|
50
|
+
const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
|
|
41
51
|
const RUFLO = path.join(HOME, '.npm-global/bin/ruflo');
|
|
52
|
+
const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
|
|
42
53
|
const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
|
|
43
54
|
|
|
44
55
|
// THE DEADLINE. SessionEnd's registered timeout is 30s (plugin/hooks/hooks.json) and this hook fires
|
|
@@ -100,8 +111,14 @@ for (let i = 0; i < actions.length; i++) {
|
|
|
100
111
|
? ['hooks', 'post-command', '-c', s.action, '-s', 'true']
|
|
101
112
|
: ['hooks', 'post-edit', '-f', s.action, '-s', 'true', '-o', 'session edit'];
|
|
102
113
|
try {
|
|
103
|
-
//
|
|
104
|
-
|
|
114
|
+
// One command, two real Ruflo scopes: project cwd keeps patterns local; HOME retains the
|
|
115
|
+
// cross-project SONA learner for users who explicitly chose `user`.
|
|
116
|
+
execFileSync(RUFLO, args, {
|
|
117
|
+
cwd: LEARNING_SCOPE === 'user' ? HOME : PROJECT,
|
|
118
|
+
env: RUFLO_ENV,
|
|
119
|
+
stdio: 'ignore',
|
|
120
|
+
timeout: Math.min(6000, remaining),
|
|
121
|
+
});
|
|
105
122
|
fed++;
|
|
106
123
|
} catch { /* best-effort — one slow/failed record must not stall session end */ }
|
|
107
124
|
}
|
|
@@ -129,7 +146,7 @@ if (fed > 0 || allDistinct.length === 0) {
|
|
|
129
146
|
console.log(`learn-flush: 0/${actions.length} fed (ruflo hooks failing?) — queue KEPT for retry next session-end`);
|
|
130
147
|
}
|
|
131
148
|
if (process.argv.includes('--sync')) {
|
|
132
|
-
console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the
|
|
149
|
+
console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the ${LEARNING_SCOPE} learner`
|
|
133
150
|
// Say the deadline out loud when it fires. A budget that silently truncates reads as "that was
|
|
134
151
|
// all there was", which is the same lie as the count cap that preceded it.
|
|
135
152
|
+ (stoppedAt < actions.length ? `; STOPPED at ${stoppedAt}/${actions.length} on the ${DEADLINE_MS}ms deadline` : '')
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// runtime-preferences.mjs — one policy boundary for every live Brain control.
|
|
3
|
+
//
|
|
4
|
+
// The console records intent. Runtime code asks this module what is allowed. Keeping that read in
|
|
5
|
+
// one place prevents routing, QE, learning, and first-open initialization from each inventing a
|
|
6
|
+
// subtly different default. Secrets are deliberately separate: ordinary settings and their backup
|
|
7
|
+
// journal must never contain an API key.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_HOME = os.homedir();
|
|
15
|
+
const PROJECT_FILE = '.swarm/ruvnet-brain-settings.json';
|
|
16
|
+
const PROJECT_KEYS = Object.freeze([
|
|
17
|
+
'routing',
|
|
18
|
+
'qeFleet',
|
|
19
|
+
'learningScope',
|
|
20
|
+
'autoApply',
|
|
21
|
+
'advocacy',
|
|
22
|
+
'provider',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const readJSON = (file) => {
|
|
26
|
+
try {
|
|
27
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
28
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function preferencePaths({ env = process.env, cwd = process.cwd() } = {}) {
|
|
35
|
+
const home = env.HOME || DEFAULT_HOME;
|
|
36
|
+
return {
|
|
37
|
+
config: env.RUVNET_BRAIN_CONFIG_FILE
|
|
38
|
+
|| path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
|
|
39
|
+
settings: env.RUVNET_SETTINGS_FILE
|
|
40
|
+
|| path.join(home, '.config', 'ruvnet-brain', 'settings.json'),
|
|
41
|
+
secrets: env.RUVNET_BRAIN_SECRETS_FILE
|
|
42
|
+
|| path.join(home, '.config', 'ruvnet-brain', 'secrets.enc.json'),
|
|
43
|
+
project: env.RUVNET_BRAIN_PROJECT_SETTINGS_FILE
|
|
44
|
+
|| path.join(cwd, PROJECT_FILE),
|
|
45
|
+
ageIdentity: env.SOPS_AGE_KEY_FILE
|
|
46
|
+
|| path.join(home, '.config', 'sops', 'age', 'keys.txt'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validConfig(raw = {}) {
|
|
51
|
+
return {
|
|
52
|
+
provider: ['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'].includes(raw.provider)
|
|
53
|
+
? raw.provider : null,
|
|
54
|
+
routing: raw.routing === 'auto' || raw.routing === 'off' ? raw.routing : null,
|
|
55
|
+
qeFleet: typeof raw.qeFleet === 'boolean' ? raw.qeFleet : null,
|
|
56
|
+
nightly: typeof raw.nightly === 'boolean' ? raw.nightly : null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validSettings(raw = {}) {
|
|
61
|
+
const source = raw.settings && typeof raw.settings === 'object' ? raw.settings : raw;
|
|
62
|
+
return {
|
|
63
|
+
learningScope: ['off', 'project', 'user'].includes(source.learningScope)
|
|
64
|
+
? source.learningScope : 'project',
|
|
65
|
+
autoApply: source.autoApply === true,
|
|
66
|
+
newProjectDefaults: source.newProjectDefaults === true,
|
|
67
|
+
advocacy: Number.isInteger(source.advocacy) && source.advocacy >= 1 && source.advocacy <= 5
|
|
68
|
+
? source.advocacy : 3,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validProject(raw = {}) {
|
|
73
|
+
const out = {};
|
|
74
|
+
if (raw.routing === 'auto' || raw.routing === 'off') out.routing = raw.routing;
|
|
75
|
+
if (typeof raw.qeFleet === 'boolean') out.qeFleet = raw.qeFleet;
|
|
76
|
+
if (['off', 'project', 'user'].includes(raw.learningScope)) out.learningScope = raw.learningScope;
|
|
77
|
+
if (typeof raw.autoApply === 'boolean') out.autoApply = raw.autoApply;
|
|
78
|
+
if (Number.isInteger(raw.advocacy) && raw.advocacy >= 1 && raw.advocacy <= 5) out.advocacy = raw.advocacy;
|
|
79
|
+
if (['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'].includes(raw.provider)) out.provider = raw.provider;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function loadRuntimePreferences(options = {}) {
|
|
84
|
+
const paths = preferencePaths(options);
|
|
85
|
+
const config = validConfig(readJSON(paths.config) || {});
|
|
86
|
+
const settings = validSettings(readJSON(paths.settings) || {});
|
|
87
|
+
const projectRaw = readJSON(paths.project);
|
|
88
|
+
const project = projectRaw ? validProject(projectRaw.values || projectRaw) : {};
|
|
89
|
+
return {
|
|
90
|
+
values: { ...config, ...settings, ...project },
|
|
91
|
+
chosen: {
|
|
92
|
+
routing: config.routing !== null || Object.hasOwn(project, 'routing'),
|
|
93
|
+
qeFleet: config.qeFleet !== null || Object.hasOwn(project, 'qeFleet'),
|
|
94
|
+
nightly: config.nightly !== null,
|
|
95
|
+
},
|
|
96
|
+
paths,
|
|
97
|
+
projectInherited: !!projectRaw,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function commandExists(command, env) {
|
|
102
|
+
const probe = spawnSync(command, ['--version'], {
|
|
103
|
+
env,
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
shell: false,
|
|
106
|
+
timeout: 3000,
|
|
107
|
+
});
|
|
108
|
+
return !probe.error && probe.status === 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function decryptSecrets(paths, env) {
|
|
112
|
+
if (!fs.existsSync(paths.secrets) || !commandExists('sops', env)) return {};
|
|
113
|
+
const run = spawnSync('sops', ['decrypt', '--input-type', 'json', '--output-type', 'json', paths.secrets], {
|
|
114
|
+
env,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
shell: false,
|
|
117
|
+
timeout: 10_000,
|
|
118
|
+
});
|
|
119
|
+
if (run.status !== 0) return {};
|
|
120
|
+
return readJSONString(run.stdout);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readJSONString(text) {
|
|
124
|
+
try {
|
|
125
|
+
const value = JSON.parse(text);
|
|
126
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
127
|
+
} catch {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function openRouterCredentialStatus(options = {}) {
|
|
133
|
+
const env = options.env || process.env;
|
|
134
|
+
if (typeof env.OPENROUTER_API_KEY === 'string' && env.OPENROUTER_API_KEY.trim()) {
|
|
135
|
+
return { configured: true, source: 'environment', legacyPlaintext: false };
|
|
136
|
+
}
|
|
137
|
+
const paths = preferencePaths(options);
|
|
138
|
+
const encrypted = decryptSecrets(paths, env);
|
|
139
|
+
if (typeof encrypted.OPENROUTER_API_KEY === 'string' && encrypted.OPENROUTER_API_KEY.trim()) {
|
|
140
|
+
return { configured: true, source: 'sops-age', legacyPlaintext: false };
|
|
141
|
+
}
|
|
142
|
+
const legacy = readJSON(paths.config);
|
|
143
|
+
return {
|
|
144
|
+
configured: typeof legacy?.openrouterKey === 'string' && legacy.openrouterKey.trim().length > 8,
|
|
145
|
+
source: legacy?.openrouterKey ? 'legacy-plaintext' : null,
|
|
146
|
+
legacyPlaintext: !!legacy?.openrouterKey,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function runtimeChildEnv(options = {}) {
|
|
151
|
+
const env = { ...(options.env || process.env) };
|
|
152
|
+
if (typeof env.OPENROUTER_API_KEY === 'string' && env.OPENROUTER_API_KEY.trim()) return env;
|
|
153
|
+
const paths = preferencePaths({ ...options, env });
|
|
154
|
+
const encrypted = decryptSecrets(paths, env);
|
|
155
|
+
if (typeof encrypted.OPENROUTER_API_KEY === 'string' && encrypted.OPENROUTER_API_KEY.trim()) {
|
|
156
|
+
env.OPENROUTER_API_KEY = encrypted.OPENROUTER_API_KEY.trim();
|
|
157
|
+
return env;
|
|
158
|
+
}
|
|
159
|
+
// Read-only migration compatibility. The legacy value is consumed but never copied into a new
|
|
160
|
+
// file or returned to the caller; the console marks it for migration.
|
|
161
|
+
const legacy = readJSON(paths.config);
|
|
162
|
+
if (typeof legacy?.openrouterKey === 'string' && legacy.openrouterKey.trim()) {
|
|
163
|
+
env.OPENROUTER_API_KEY = legacy.openrouterKey.trim();
|
|
164
|
+
}
|
|
165
|
+
return env;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ageRecipient(identityFile, env) {
|
|
169
|
+
if (!fs.existsSync(identityFile) || !commandExists('age-keygen', env)) return null;
|
|
170
|
+
const run = spawnSync('age-keygen', ['-y', identityFile], {
|
|
171
|
+
env,
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
shell: false,
|
|
174
|
+
timeout: 5000,
|
|
175
|
+
});
|
|
176
|
+
return run.status === 0 ? run.stdout.trim() : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function saveOpenRouterCredential(secret, options = {}) {
|
|
180
|
+
if (typeof secret !== 'string' || secret.trim().length < 9) {
|
|
181
|
+
return { ok: false, log: 'OpenRouter key was not saved — enter a complete key.' };
|
|
182
|
+
}
|
|
183
|
+
const env = options.env || process.env;
|
|
184
|
+
const paths = preferencePaths({ ...options, env });
|
|
185
|
+
if (!commandExists('sops', env) || !commandExists('age-keygen', env)) {
|
|
186
|
+
return { ok: false, log: 'OpenRouter key was not saved — SOPS and age are required for encrypted storage. OPENROUTER_API_KEY in your environment remains supported.' };
|
|
187
|
+
}
|
|
188
|
+
const recipient = ageRecipient(paths.ageIdentity, env);
|
|
189
|
+
if (!recipient) {
|
|
190
|
+
return { ok: false, log: 'OpenRouter key was not saved — no SOPS age identity is configured. Set SOPS_AGE_KEY_FILE or use OPENROUTER_API_KEY.' };
|
|
191
|
+
}
|
|
192
|
+
const input = JSON.stringify({ OPENROUTER_API_KEY: secret.trim() });
|
|
193
|
+
const run = spawnSync('sops', [
|
|
194
|
+
'encrypt',
|
|
195
|
+
'--input-type', 'json',
|
|
196
|
+
'--output-type', 'json',
|
|
197
|
+
'--age', recipient,
|
|
198
|
+
'/dev/stdin',
|
|
199
|
+
], {
|
|
200
|
+
env,
|
|
201
|
+
input,
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
shell: false,
|
|
204
|
+
timeout: 10_000,
|
|
205
|
+
});
|
|
206
|
+
if (run.status !== 0 || !run.stdout.trim()) {
|
|
207
|
+
return { ok: false, log: 'OpenRouter key was not saved — SOPS encryption failed. The plaintext was not written to disk.' };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
fs.mkdirSync(path.dirname(paths.secrets), { recursive: true, mode: 0o700 });
|
|
211
|
+
let backup = null;
|
|
212
|
+
if (fs.existsSync(paths.secrets)) {
|
|
213
|
+
backup = `${paths.secrets}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
214
|
+
fs.copyFileSync(paths.secrets, backup, fs.constants.COPYFILE_EXCL);
|
|
215
|
+
fs.chmodSync(backup, 0o600);
|
|
216
|
+
}
|
|
217
|
+
const tmp = `${paths.secrets}.tmp-${process.pid}-${Date.now()}`;
|
|
218
|
+
fs.writeFileSync(tmp, run.stdout, { flag: 'wx', mode: 0o600 });
|
|
219
|
+
fs.renameSync(tmp, paths.secrets);
|
|
220
|
+
fs.chmodSync(paths.secrets, 0o600);
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
log: 'OpenRouter key saved with SOPS+age encryption.',
|
|
224
|
+
path: paths.secrets,
|
|
225
|
+
backup,
|
|
226
|
+
existed: backup !== null,
|
|
227
|
+
};
|
|
228
|
+
} catch {
|
|
229
|
+
return { ok: false, log: 'OpenRouter key was encrypted but could not be saved. No plaintext file was created.' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function seedProjectDefaults(options = {}) {
|
|
234
|
+
const state = loadRuntimePreferences(options);
|
|
235
|
+
if (!state.values.newProjectDefaults) return { ok: true, action: 'disabled', path: state.paths.project };
|
|
236
|
+
const cwd = options.cwd || process.cwd();
|
|
237
|
+
const looksLikeProject = ['.git', 'package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod']
|
|
238
|
+
.some((name) => fs.existsSync(path.join(cwd, name)));
|
|
239
|
+
if (!looksLikeProject && options.env?.RUVNET_ALLOW_NONPROJECT_DEFAULTS !== '1') {
|
|
240
|
+
return { ok: true, action: 'not-a-project', path: state.paths.project };
|
|
241
|
+
}
|
|
242
|
+
if (fs.existsSync(state.paths.project)) return { ok: true, action: 'already-initialized', path: state.paths.project };
|
|
243
|
+
const values = {};
|
|
244
|
+
for (const key of PROJECT_KEYS) {
|
|
245
|
+
const value = state.values[key];
|
|
246
|
+
if (value !== null && value !== undefined) values[key] = value;
|
|
247
|
+
}
|
|
248
|
+
const payload = {
|
|
249
|
+
version: 1,
|
|
250
|
+
inheritedAt: new Date().toISOString(),
|
|
251
|
+
source: 'user-defaults',
|
|
252
|
+
values,
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
fs.mkdirSync(path.dirname(state.paths.project), { recursive: true, mode: 0o700 });
|
|
256
|
+
fs.writeFileSync(state.paths.project, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
257
|
+
return { ok: true, action: 'created', path: state.paths.project, values };
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error?.code === 'EEXIST') return { ok: true, action: 'already-initialized', path: state.paths.project };
|
|
260
|
+
return { ok: false, action: 'failed', path: state.paths.project, error: error?.message || String(error) };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (process.argv.includes('--learning-scope')) {
|
|
265
|
+
process.stdout.write(`${loadRuntimePreferences().values.learningScope}\n`);
|
|
266
|
+
} else if (process.argv.includes('--seed-project')) {
|
|
267
|
+
const result = seedProjectDefaults();
|
|
268
|
+
if (!result.ok) process.exitCode = 1;
|
|
269
|
+
}
|