ruvnet-brain 4.5.6 → 4.5.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 +1 -1
  2. package/bin/install.mjs +50 -1
  3. package/kb/forge-update.mjs +50 -9
  4. package/package.json +5 -3
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/configure.md +13 -1
  8. package/plugin/hooks/codex-hooks.json +31 -1
  9. package/plugin/hooks/hook-contracts.json +82 -2
  10. package/plugin/hooks/hooks.json +31 -1
  11. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  12. package/plugin/mcp/managed-cli-interface.mjs +36 -12
  13. package/plugin/mcp/server.mjs +3 -2
  14. package/plugin/scripts/agentdb-recall.mjs +38 -10
  15. package/plugin/scripts/continuation-gate.mjs +3 -1
  16. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  17. package/plugin/scripts/continuity-journal.mjs +11 -11
  18. package/plugin/scripts/hook-shim.mjs +3 -2
  19. package/plugin/scripts/kb-copy-proof.mjs +84 -22
  20. package/plugin/scripts/learn-capture.mjs +44 -0
  21. package/plugin/scripts/learn-capture.sh +2 -241
  22. package/plugin/scripts/learn-flush.mjs +86 -191
  23. package/plugin/scripts/learning-observation.mjs +51 -0
  24. package/plugin/scripts/learning-queue.mjs +135 -0
  25. package/plugin/scripts/learning-store.mjs +105 -0
  26. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  27. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  28. package/plugin/scripts/project-capture-queue.mjs +16 -7
  29. package/plugin/scripts/project-progression-contract.mjs +58 -4
  30. package/plugin/scripts/project-progression-outbox.mjs +22 -6
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  33. package/plugin/scripts/project-transition-hook.mjs +25 -8
  34. package/plugin/scripts/runtime-preferences.mjs +45 -4
  35. package/plugin/scripts/session-snapshot-hook.mjs +14 -7
  36. package/plugin/scripts/session-start-core.mjs +8 -0
  37. package/plugin/scripts/session-start-proof.mjs +49 -0
  38. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  39. package/scripts/codex-fresh-host-proof.mjs +179 -0
  40. package/scripts/codex-host-execution-proof.mjs +246 -0
  41. package/scripts/codex-host-proof-runtime.mjs +104 -0
  42. package/scripts/console-engine.mjs +29 -16
  43. package/scripts/health-repair.mjs +47 -68
  44. package/scripts/onboarding-console.mjs +25 -54
  45. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  46. package/scripts/release-qualification-contract.mjs +76 -7
  47. package/scripts/remedy-registry.mjs +9 -2
@@ -1,242 +1,3 @@
1
1
  #!/bin/bash
2
- # learn-capture.sh — PostToolUse (Write|Edit|Bash). Appends ONE compact step to this session's learning
3
- # queue. A session is a trajectory (task -> steps -> outcome); learn-flush.mjs feeds the queue to the
4
- # GLOBAL SONA learner at SessionEnd, so "how you work" accumulates per-user across ALL projects — while
5
- # project FACTS stay in each project's .swarm/memory.db, never here. We record the workflow ACTION (a
6
- # command verb, a file's basename), never file CONTENT or secrets. ADR-0017.
7
- #
8
- # CONTRACT: PostToolUse is non-blocking — always exit 0, swallow every failure, no process spawn (fast).
9
-
10
- set -uo pipefail
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
- # CONFIGURED vs DEFAULTED, and this must be captured BEFORE the preferences fallback below.
19
- #
20
- # `project` is the DEFAULT scope, and `runtime-preferences.mjs --learning-scope` RESOLVES it —
21
- # measured 2026-08-19 against an empty config root, it returns "project", not "". So after the
22
- # fallback runs, an explicit opt-in and a bare default are indistinguishable. The first version of
23
- # this guard read SCOPE afterwards and was therefore always "configured", which let the
24
- # stranger-project mutation straight back in.
25
- #
26
- # The env var is the one signal that is unambiguously explicit: nothing sets it by default. A
27
- # project that opted in through the console instead is still covered by the `.swarm` clause below,
28
- # because adopting Ruflo's convention is itself the opt-in.
29
- SCOPE_CONFIGURED=0
30
- [ -n "$SCOPE" ] && SCOPE_CONFIGURED=1
31
- if [ -z "$SCOPE" ] && [ -f "$HERE/runtime-preferences.mjs" ] && command -v node >/dev/null 2>&1; then
32
- SCOPE=$(node "$HERE/runtime-preferences.mjs" --learning-scope 2>/dev/null) || SCOPE=""
33
- fi
34
- case "$SCOPE" in
35
- off) exit 0 ;;
36
- user|project) ;;
37
- *) SCOPE="project" ;;
38
- esac
39
-
40
- # BOUNDED READ. An unqualified `read` waits forever on a stdin that is opened and never closed, and
41
- # an unbounded accumulator turns a large payload into an unbounded regex scan. Claude Code always
42
- # writes the payload and closes, so neither costs a normal turn — which is exactly why a hook that
43
- # CAN hang survives: the only thing ending it is a timeout owned by someone else. -t 2 is ~40x a real
44
- # payload's delivery time; the size cap is ~30x the largest real payload. The trailing `[ -n "$_l" ]`
45
- # keeps the final unterminated line, which is what the original `||` clause was for.
46
- INPUT=""
47
- _l="" # set -u: a read that times out before any byte leaves _l unset ("unbound variable" on stderr)
48
- while IFS= read -r -t 2 _l; do
49
- INPUT+="$_l"
50
- [ ${#INPUT} -ge 65536 ] && break
51
- done
52
- # Truncate the ASSEMBLED string, not just each iteration: a hook payload is one line with no trailing
53
- # newline, so `read` hands back the whole thing at once in $_l and the in-loop cap never fires.
54
- [ -n "$_l" ] && INPUT+="$_l"
55
- INPUT="${INPUT:0:65536}"
56
- [ -n "$INPUT" ] || exit 0
57
-
58
- TOOL=""
59
- re_t='"tool_name"[[:space:]]*:[[:space:]]*"([^"]*)"'
60
- [[ $INPUT =~ $re_t ]] && TOOL="${BASH_REMATCH[1]}"
61
- [ -n "$TOOL" ] || exit 0
62
-
63
- ACTION=""
64
- case "$TOOL" in
65
- Bash)
66
- # Capture the VERB CHAIN ONLY — "git push", "npm test", "npx vercel" — never the arguments.
67
- #
68
- # This previously took the first 120 chars up to an embedded quote and called that "verb, not
69
- # facts". It wasn't. Unquoted inline secrets were captured in full and written to disk, proven
70
- # by test: `export AWS_SECRET_ACCESS_KEY=wJalr... && psql postgres://admin:Hunter2Pass@db/prod`
71
- # landed verbatim in session-*.jsonl, and from there fed the global learner. Real command lines
72
- # routinely carry API keys, DB URLs with inline passwords, and internal hostnames — on a
73
- # corporate laptop the hostnames alone are a DLP finding.
74
- #
75
- # Now: keep at most the first two tokens, and stop at the first token that carries DATA rather
76
- # than INTENT (contains = / @ : , is a flag, or is improbably long). "export FOO=secret" records
77
- # "export"; "cd /Users/me/ClientProject" records "cd". The learner only ever needed the verb.
78
- # THE CAPTURE WAS MANGLED, and the mangling was invisible (fixed 2026-07-27).
79
- #
80
- # `"command"…"([^"]*)"` cannot cross a JSON-escaped quote — the exact bug hook-input.mjs exists to
81
- # end — so `cd "/tmp/some dir"` captured the two bytes `cd \`, and that trailing backslash then
82
- # broke the JSON line it was printed into. Measured on the owner's live queue:
83
- #
84
- # {"tool":"Bash","action":"cd \"} ← JSON.parse: Unterminated string at position 31
85
- #
86
- # learn-flush drops every unparseable line with a bare `continue`, so the capture reported success,
87
- # the queue grew, and the learner received nothing. A pipe severed in the middle while both ends
88
- # report health is this project's signature failure mode.
89
- #
90
- # The fix is to stop at the first quote OR backslash and take a PREFIX — no closing-quote anchor,
91
- # because the first two tokens are all this hook ever wanted. A command that opens with a quoted
92
- # path yields an empty prefix and is simply not captured, which is strictly better than writing a
93
- # line that cannot be read back.
94
- re_c='"command"[[:space:]]*:[[:space:]]*"([^"\]*)'
95
- if [[ $INPUT =~ $re_c ]]; then
96
- set -f # no globbing while we word-split untrusted text
97
- _n=0
98
- for _tok in ${BASH_REMATCH[1]}; do
99
- case "$_tok" in
100
- *=*|*/*|*@*|*:*|-*) break ;;
101
- esac
102
- [ ${#_tok} -gt 24 ] && break
103
- ACTION="${ACTION:+$ACTION }$_tok"
104
- _n=$((_n + 1))
105
- [ "$_n" -ge 2 ] && break
106
- done
107
- set +f
108
- fi
109
- ;;
110
- Write|Edit|MultiEdit)
111
- re_f='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
112
- [[ $INPUT =~ $re_f ]] && ACTION="edit ${BASH_REMATCH[1]##*/}" # basename only — no full path
113
- ;;
114
- esac
115
- [ -n "$ACTION" ] || exit 0
116
-
117
- # THE SESSION ID IS IN THE PAYLOAD WE ALREADY READ — and it was being thrown away.
118
- #
119
- # This read `${CLAUDE_SESSION_ID:-default}`, an env var Claude Code does not set, so EVERY session on
120
- # a machine appended to one shared `session-default.jsonl`. Measured on the owner's machine
121
- # 2026-07-27: one file, 147 lines deep, written concurrently by several live sessions — the same
122
- # many-writers-one-path shape as ADR-050, and it makes "this session's trajectory" a fiction, because
123
- # the queue is a blend of every session that happened to be open.
124
- #
125
- # `session_id` is a field on the very payload this hook already parsed. Prefer it; fall back to the
126
- # env var; then to 'default'. SANITISED before it becomes a filename component: the payload is
127
- # untrusted input, and `session_id` reaching an unfiltered path join is a traversal waiting to happen.
128
- SID=""
129
- re_s='"session_id"[[:space:]]*:[[:space:]]*"([^"\]*)"'
130
- [[ $INPUT =~ $re_s ]] && SID="${BASH_REMATCH[1]}"
131
- [ -n "$SID" ] || SID="${CLAUDE_SESSION_ID:-}"
132
- # Dots are dropped along with everything else outside this set: real session ids are uuids, and
133
- # keeping `.` would let a crafted id survive as `..`-shaped debris in a filename for no benefit.
134
- SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
135
- [ -n "$SID" ] || SID="default"
136
- if [ "$SCOPE" = "user" ]; then
137
- DIR="$HOME/.cache/ruvnet-brain/learn"
138
- else
139
- # ISSUE #134 — THE SAME PROJECT ROOT THE READER COMPUTES, BY THE SAME RULE.
140
- #
141
- # This was bare `$PWD` while learn-flush.mjs:26 and health-repair.mjs:32 both resolve
142
- # `RUVNET_BRAIN_PROJECT_DIR || cwd`. This hook is wired on PostToolUse, so it runs after EVERY tool
143
- # call — and any command that leaves the shell below the project root (a test run, a build, any
144
- # tooling that cd's) made the WRITER create a queue in a directory the READER never looks in. Those
145
- # events are not misfiled, they are orphaned: nothing ever drains them.
146
- #
147
- # This is issue #104's residual. #104 fixed the two halves of the FLUSH to agree about which project
148
- # they mean; the component that actually creates the queue was not brought along, so the invariant
149
- # held for two of three participants and was violated by the one doing the writing. Same shape as
150
- # ADR-066: a writer and a reader that disagree about the store make the recording theatre.
151
- # RESIDUAL of #134/#104: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either
152
- # host (neither hook-shim.mjs nor codex-hook-adapter.mjs seeds it), so it degraded back to bare
153
- # $PWD in production. CLAUDE_PROJECT_DIR is the one project-root signal both hosts DO provide on
154
- # every invocation. Trusted only when $PWD actually lies inside it — the SAME containment rule
155
- # project-identity.mjs's projectDirectory() applies for the identical reason (#85/#107: an
156
- # unrelated declared root must never overrule a cwd it does not contain). A plain string-prefix
157
- # check, not a realpath/inode compare, to honour this hook's own no-process-spawn contract.
158
- ROOT_DIR="$PWD"
159
- if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
160
- CPD="${CLAUDE_PROJECT_DIR%/}"
161
- # Git Bash presents PWD as /c/... while Node supplies CLAUDE_PROJECT_DIR as C:\\... on
162
- # Windows. Compare normalized, case-folded spellings so the real project-root signal works
163
- # on both hosts without spawning a platform-specific path converter.
164
- _pwd_for_compare="$PWD"
165
- if [ -n "$(pwd -W 2>/dev/null || true)" ]; then _pwd_for_compare="$(pwd -W)"; fi
166
- _pwd_cmp=$(printf '%s' "$_pwd_for_compare" | tr '\\\\' '/' | tr '[:upper:]' '[:lower:]')
167
- _cpd_cmp=$(printf '%s' "$CPD" | tr '\\\\' '/' | tr '[:upper:]' '[:lower:]')
168
- case "$_pwd_cmp/" in "$_cpd_cmp"/*) ROOT_DIR="$CPD" ;; esac
169
- fi
170
- DIR="${RUVNET_BRAIN_PROJECT_DIR:-$ROOT_DIR}/.swarm/ruvnet-brain-learn"
171
- fi
172
- # PROJECT SCOPE MEANS THE PROJECT MUST HAVE OPTED IN. In project scope $DIR sits under `.swarm`,
173
- # which is Ruflo's own convention and is created by `ruflo init` — so its PRESENCE is the project's
174
- # opt-in and its ABSENCE is a project that has not adopted the brain. This hook runs machine-wide on
175
- # every PostToolUse, so an unconditional mkdir planted `.swarm/` in EVERY repository the user opened.
176
- # Measured 2026-08-14 by the both-hosts conformance gate in a temp project with no git and no brain
177
- # artifacts; ADR-058 D5 — never touch what we do not own. User scope is unaffected: that queue lives
178
- # under the brain's OWN cache directory, which we do own and may create.
179
- # CREATE THE QUEUE ONLY WHERE THE PROJECT ACTUALLY OPTED IN.
180
- #
181
- # First attempt required an existing `.swarm`, which stopped the stranger-project mutation but ALSO
182
- # broke a legitimate first run: a project that explicitly sets RUVNET_LEARNING_SCOPE=project before
183
- # it has ever captured anything got nothing, and `learning-scope-policy` went red. Presence of
184
- # `.swarm` was the wrong discriminator — it answers "has Ruflo run here", not "did this project ask
185
- # for learning".
186
- #
187
- # The right one is whether the scope was CONFIGURED (env or runtime-preferences) rather than
188
- # inherited from the default. A stranger's repo sets neither, so nothing is created there; a project
189
- # that opted in gets its queue on the very first capture, `.swarm` or not. An existing `.swarm` is
190
- # still honoured on its own, because a repo already carrying Ruflo's convention has plainly adopted it.
191
- case "$DIR" in
192
- */.swarm/*)
193
- if [ "$SCOPE_CONFIGURED" != "1" ] && [ ! -d "$(dirname "$DIR")" ]; then exit 0; fi
194
- ;;
195
- esac
196
- # Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
197
- # account is normally in `staff`, so any other user on a shared or corporate machine could read it.
198
- ( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
199
- QUEUE="$DIR/session-$SID.jsonl"
200
- [ -e "$QUEUE" ] || { : > "$QUEUE" 2>/dev/null && chmod 600 "$QUEUE" 2>/dev/null; } || true
201
- printf '{"tool":"%s","action":"%s"}\n' "$TOOL" "${ACTION//\"/\\\"}" >> "$QUEUE" 2>/dev/null || true
202
-
203
- # ── HEARTBEAT FLUSH (ADR-027) ────────────────────────────────────────────────────────────────────
204
- # The flush used to fire ONLY on a clean SessionEnd. Sessions compact, crash, get resumed, or are
205
- # killed — none of those reach SessionEnd — so the queue silently grew to 1,884 undelivered events
206
- # over days while the learner sat at 5 trajectories, last trained six days earlier. Draining it took
207
- # the learner to 412/412 in one command. A queue that only empties on a graceful exit will always
208
- # leak; activity itself must be the trigger.
209
- #
210
- # So: every HEARTBEAT_EVERY captures, drain in the BACKGROUND. Detached and fully silent — this runs
211
- # inside a PostToolUse hook and must never add latency to the user's turn or fail one. Cheap check
212
- # (a line count) on the common path; real work only at the threshold.
213
- # LEVEL-TRIGGERED, NOT EDGE-TRIGGERED. This is the whole fix, and the bug it replaces was severe.
214
- #
215
- # The condition used to be `LINES >= 200 && LINES % 200 == 0` — it fired ONLY when the count landed
216
- # exactly on a multiple of 200. Two captures arriving between checks, or any concurrent write,
217
- # steps the counter over the window and the flush NEVER fires again. Measured on the owner's machine
218
- # 2026-07-22: the queue was at 491. It had sailed past both 200 and 400 without draining once, and
219
- # would have grown forever.
220
- #
221
- # The failure mode is the nastiest kind: capture works, the learner works, and the PIPE BETWEEN THEM
222
- # is severed — while every surface honestly reports both ends as healthy. "Is learning on?" had no
223
- # true answer, because learning is not a switch; it is a chain, and one link was open.
224
- #
225
- # `-ge` cannot skip a window. It fires on every capture past the threshold until the queue is
226
- # actually drained, which is the definition of level-triggered: the condition is the QUEUE'S DEPTH,
227
- # not the instant it crossed a line.
228
- HEARTBEAT_EVERY=200
229
- LINES=$(wc -l < "$DIR/session-$SID.jsonl" 2>/dev/null || echo 0)
230
- if [ "$LINES" -ge "$HEARTBEAT_EVERY" ]; then
231
- # Debounce so a deep queue doesn't spawn a flush on EVERY subsequent capture: at most one drain
232
- # per minute. Without this, level-triggering trades a stuck queue for a fork storm.
233
- STAMP="$DIR/.last-flush"
234
- NOW=$(date +%s)
235
- LAST=$(cat "$STAMP" 2>/dev/null || echo 0)
236
- if [ $((NOW - LAST)) -ge 60 ]; then
237
- echo "$NOW" > "$STAMP" 2>/dev/null || true
238
- FLUSH="$HERE/learn-flush.mjs"
239
- [ -f "$FLUSH" ] && (RUVNET_LEARNING_SCOPE="$SCOPE" nohup node "$FLUSH" >/dev/null 2>&1 &) || true
240
- fi
241
- fi
242
- exit 0
2
+ # Compatibility entry; native registrations use the bounded Node body on both hosts.
3
+ exec node "$(dirname "$0")/learn-capture.mjs"
@@ -1,201 +1,96 @@
1
1
  #!/usr/bin/env node
2
- // learn-flush.mjs — SessionEnd. Reads this session's learning queue (the workflow you just performed)
3
- // and feeds the distinct steps into the GLOBAL per-user SONA learner — ruflo hooks run with cwd=$HOME
4
- // so learnings accumulate in ONE store (~/.claude-flow), shared across ALL your projects. Project FACTS
5
- // never come here (the queue holds command verbs + file basenames, no content). Each installed RuvNet
6
- // Brain does this for its own user → everyone's brain gets recursively smarter about how THEY work. ADR-0017.
7
- //
8
- // Non-blocking, best-effort. Bounded (distinct actions, short timeouts). `--sync` waits (for tests);
9
- // the hook default backgrounds so SessionEnd never stalls.
10
-
2
+ // SessionEnd schedules a finite detached learner; --sync is the explicit Console drain.
11
3
  import fs from 'node:fs';
12
- import os from 'node:os';
13
4
  import path from 'node:path';
14
- import { execFileSync } from 'node:child_process';
15
- import { readStdinBounded } from './hook-input.mjs';
16
- import { learningScope, loadRuntimePreferences } from './runtime-preferences.mjs';
17
- import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
18
- import { projectDirectory } from './project-identity.mjs';
19
-
20
- // ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
21
- // because a hook that prints a stack trace on every `/clear` gets muted — and a muted diagnostic is
22
- // no diagnostic at all (the same lesson as the session-start line that reported its own defect every
23
- // session for eight days and went unread).
24
- const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } catch { /* stderr gone */ } };
25
-
26
- const HOME = os.homedir();
27
- // RESIDUAL of #134/#104: RUVNET_BRAIN_PROJECT_DIR is never set by real hook dispatch on either host,
28
- // so it degraded back to raw cwd() in production. `projectDirectory()` (project-identity.mjs) is the
29
- // SAME CLAUDE_PROJECT_DIR-with-containment rule #85/#107 already fixed for the receipt/Console
30
- // agreement — reused here rather than trusting the variable unconditionally, which would reopen the
31
- // class of bug #107 was: an unrelated declared root overruling a cwd it does not actually contain.
32
- const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env: process.env });
33
- // ISSUE #139 — this WRITER resolved scope correctly while two READERS hardcoded it, so they agreed
34
- // only by coincidence. The resolution moved into runtime-preferences.mjs and all three now call it;
35
- // a future scope is one edit, not three. Behaviour here is unchanged by design.
36
- const LEARNING_SCOPE = learningScope({ cwd: PROJECT });
37
- if (LEARNING_SCOPE === 'off') process.exit(0);
38
-
39
- // THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
40
- //
41
- // This used to be `process.env.CLAUDE_SESSION_ID || 'default'`. Claude Code does not set that
42
- // variable, so every session on the machine read and rewrote ONE shared session-default.jsonl —
43
- // measured live at 147 lines, appended by several concurrent sessions. Both halves of this pipeline
44
- // have to agree about which file they mean, so both now read `session_id` from the payload the hook
45
- // is already handed, sanitise it the same way, and fall back the same way.
46
- //
47
- // The read is bounded: SessionEnd hands us a small JSON object and closes, but an unbounded
48
- // readFileSync(0) on a stdin that never closes is a hang with no upper bound. A payload we cannot
49
- // read in time simply yields no id, which lands on the same fallback as no payload at all.
50
- async function payloadSessionId() {
51
- if (process.stdin.isTTY) return '';
52
- try {
53
- const raw = (await readStdinBounded()).toString('utf8');
54
- const v = JSON.parse(raw)?.session_id;
55
- return typeof v === 'string' ? v : '';
56
- } catch { return ''; }
57
- }
58
- // A filename COMPONENT, never a path — the payload is untrusted input.
59
- const SID = ((await payloadSessionId()) || process.env.CLAUDE_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '') || 'default';
60
- const QUEUE_ROOT = LEARNING_SCOPE === 'user'
61
- ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
62
- : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
63
- const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
64
- // Issue #105: this was a hardcoded `path.join(HOME, '.npm-global/bin/ruflo')` — the owner's npm
65
- // prefix. On any other prefix (Homebrew, nvm, Volta, plain `npm -g`) the path simply did not exist,
66
- // every feed below threw ENOENT, and every throw landed in a `catch {}` that said nothing. One
67
- // resolver, shared with distill-project.mjs and health-repair.mjs's original — see ruflo-bin.mjs.
68
- const RUFLO = resolveRuflo();
69
- const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
70
- const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
71
-
72
- // THE DEADLINE. SessionEnd's registered timeout is 30s (plugin/hooks/hooks.json) and this hook fires
73
- // on EVERY session end — including every `/clear`. Measured on the owner's machine 2026-07-27, in all
74
- // four stdin regimes: 48–50s wall, killed at the cap every single time.
75
- //
76
- // The arithmetic was never survivable. MAX_ACTIONS is 8 and a real `ruflo hooks` call measured 3.83s,
77
- // so the feed queued ~31s of work into a 30s budget and was killed part-way through it. Worse, the
78
- // kill lands BEFORE the write-back that preserves the remainder, so the queue never shrinks and never
79
- // drains — a cap that guarantees the work it defers can never be done.
80
- //
81
- // A work limit has to be expressed in the currency the budget is denominated in. MAX_ACTIONS bounds
82
- // COUNT; this bounds TIME, and the two together mean the hook stops cleanly, keeps what it did not
83
- // feed, and exits well inside the cap. 20s leaves a full third of the budget for the write-back, the
84
- // process teardown, and a slow machine. Measured with a 4s-per-call stub and a 147-entry queue: 22.5s
85
- // wall at a 20s deadline (execFileSync's own kill handling costs a couple of seconds on top of the
86
- // budget), so the number is set at 18s to keep the real worst case around 20s — a third of the cap in
87
- // hand. The budget is the thing being bounded; the constant is chosen from the measurement, not from
88
- // how round it looks.
89
- const DEADLINE_MS = Number(process.env.LEARN_FLUSH_DEADLINE_MS) || 18_000;
90
- const DEADLINE = Date.now() + DEADLINE_MS;
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { learningContext } from './runtime-preferences.mjs';
8
+ import { recordLearningObservation, distillLearning } from './learning-store.mjs';
9
+ import { superviseLearning } from './learning-worker-supervisor.mjs';
10
+ import { resolveRuflo } from './ruflo-bin.mjs';
11
+ import { safeQueue, queueFiles, pendingRecords, acknowledge, safeAction, takeQueueLock,
12
+ ownsQueueLock, releaseQueueLock, WORKER_BUDGET_MS, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
91
13
 
92
- let lines = [];
93
- try { lines = fs.readFileSync(QUEUE, 'utf8').split('\n').filter(Boolean); } catch { process.exit(0); }
94
- if (!lines.length) process.exit(0);
95
-
96
- // Distinct workflow actions this session (dedupe → a session has only a handful of real patterns).
97
- //
98
- // COLLECT ALL, FEED SOME, KEEP THE REST (fixed 2026-07-22). This used to `break` at MAX_ACTIONS and
99
- // then delete the ENTIRE queue, so a session with 30 distinct actions fed 8 and destroyed 22 —
100
- // permanently, silently, while reporting success. Measured on the owner's machine the same day: the
101
- // queue stood at 491 raw captures, every one of which would have been discarded after feeding 8.
102
- //
103
- // The cap exists for a good reason (SessionEnd must stay fast) but a work LIMIT is not a licence to
104
- // destroy the work you didn't do. Now the remainder is written back and drains on the next flush,
105
- // so a deep queue converges instead of being truncated.
106
- const allDistinct = [];
107
- const seen = new Set();
108
- for (const line of lines) {
109
- let s; try { s = JSON.parse(line); } catch { continue; }
110
- const key = `${s.tool}|${(s.action || '').slice(0, 60)}`;
111
- if (!s.action || seen.has(key)) continue;
112
- seen.add(key);
113
- allDistinct.push(s);
114
- }
115
- const actions = allDistinct.slice(0, MAX_ACTIONS);
116
- const deferred = allDistinct.slice(MAX_ACTIONS);
117
-
118
- // ruflo is genuinely not on this machine. SAY SO — once — and keep the queue. Exiting 0 keeps the
119
- // best-effort contract (an absent optional learner must never break SessionEnd); saying nothing at
120
- // all is what turned #105 into eight invisible ENOENTs and a queue that never drained.
121
- if (!RUFLO && actions.length) {
122
- warn(`0/${actions.length} fed — ${RUFLO_MISSING}. The queue is KEPT for retry.`);
123
- process.exit(0);
124
- }
14
+ const started = Date.now();
15
+ const budget = Math.min(WORKER_BUDGET_MS, Math.max(1, Number(process.env.LEARN_FLUSH_DEADLINE_MS) || WORKER_BUDGET_MS));
16
+ const deadline = Math.min(started + budget, Number(process.env.RUVNET_LEARN_WORKER_EXPIRES) || Infinity);
17
+ const context = () => learningContext();
18
+ const initial = context();
19
+ if (!initial.enabled) process.exit(0);
20
+ const allowed = () => { const current = context(); return current.enabled && current.scope === initial.scope && current.queueDir === initial.queueDir; };
21
+ let token = process.env.RUVNET_LEARN_WORKER_TOKEN;
22
+ try { token ||= takeQueueLock(initial); } catch { process.exit(0); }
23
+ if (!token || !ownsQueueLock(initial, token)) process.exit(0);
125
24
 
126
- let fed = 0;
127
- const failures = []; // WHY each feed failed — the thing `catch {}` used to destroy
128
- let stoppedAt = actions.length; // how far the feed actually got before the deadline
129
- for (let i = 0; i < actions.length; i++) {
130
- const remaining = DEADLINE - Date.now();
131
- // STOP CLEANLY, and stop BEFORE starting work that cannot finish inside the budget. A call begun
132
- // at 19.9s with a 6s timeout would run to 25.9s, which is the whole failure in miniature — the
133
- // budget has to bound the call, not just the decision to make it.
134
- if (remaining <= 0) { stoppedAt = i; break; }
135
- const s = actions[i];
136
- const args = s.tool === 'Bash'
137
- ? ['hooks', 'post-command', '-c', s.action, '-s', 'true']
138
- : ['hooks', 'post-edit', '-f', s.action, '-s', 'true', '-o', 'session edit'];
139
- try {
140
- // One command, two real Ruflo scopes: project cwd keeps patterns local; HOME retains the
141
- // cross-project SONA learner for users who explicitly chose `user`.
142
- execFileSync(RUFLO, args, {
143
- cwd: LEARNING_SCOPE === 'user' ? HOME : PROJECT,
144
- env: RUFLO_ENV,
145
- stdio: 'ignore',
146
- timeout: Math.min(6000, remaining),
147
- });
148
- fed++;
149
- } catch (e) {
150
- // BEST-EFFORT, NOT SILENT. The old `catch { /* best-effort */ }` swallowed the reason, so a
151
- // machine where every call failed looked exactly like one where every call worked: exit 0,
152
- // no output, and the only trace a queue that never shrank. "Reports success while doing
153
- // nothing" is the defect class this project treats as the worst thing it can ship. Keep going
154
- // (one bad record must not stall session end), but keep the reason.
155
- failures.push(String(e?.message || e).split('\n')[0].slice(0, 120));
25
+ // No enumeration/sorting on the native hook's synchronous path.
26
+ if (!process.argv.includes('--worker')) {
27
+ if (process.argv.includes('--sync') || process.argv.includes('--supervisor')) {
28
+ await superviseLearning(initial, token, deadline, { report: process.argv.includes('--sync') });
29
+ } else {
30
+ try {
31
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--supervisor'], {
32
+ cwd: initial.projectDir, detached: true, stdio: 'ignore', windowsHide: true,
33
+ env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
34
+ });
35
+ child.once('error', () => releaseQueueLock(initial, token)); child.unref();
36
+ } catch { releaseQueueLock(initial, token); }
156
37
  }
157
- }
158
- // SURFACE IT. Distinct reasons only, at most two: eight copies of the same ENOENT is noise, and the
159
- // second distinct reason is usually where the real information is.
160
- if (failures.length) {
161
- const distinct = [...new Set(failures)];
162
- warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
163
- + ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
164
- + (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.'
165
- : `. ${fed} succeeded; the entire queue is KEPT for retry (successful actions may replay).`));
166
- // Preserve the original bytes on ANY failed attempt. Rewriting only the deferred tail would
167
- // discard failed actions whenever a sibling succeeded. This is at-least-once retry, not an
168
- // exactly-once or concurrent-capture protocol; successful actions may be fed again.
169
38
  process.exit(0);
170
39
  }
171
- // Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
172
- // next flush continues from there. Dropping it would turn a time limit into the same silent data
173
- // loss the count limit used to cause.
174
- if (stoppedAt < actions.length) deferred.unshift(...actions.slice(stoppedAt));
175
-
176
- // DERIVED, not asserted (F14, 2026-07-18): the queue is EVIDENCE, and it may only be destroyed when
177
- // its contents were actually fed. The old line deleted it unconditionally — a session where every
178
- // `ruflo hooks` call failed (fed=0) silently discarded the whole learning queue with nothing learned
179
- // and no trace. Now: nothing fed + something to feed ⇒ the queue survives for the next session-end
180
- // to retry. An empty queue (nothing to feed) is safe to remove.
181
- if (fed > 0 || allDistinct.length === 0) {
182
- if (deferred.length) {
183
- // Work remains. Write back ONLY what was not fed, so the next flush continues where this one
184
- // stopped. Deleting here is what turned a rate limit into data loss.
185
- try {
186
- fs.writeFileSync(QUEUE, deferred.map((s) => JSON.stringify(s)).join('\n') + '\n');
187
- } catch { /* if we cannot rewrite it, leaving the full queue is strictly safer than removing it */ }
188
- } else {
189
- try { fs.rmSync(QUEUE); } catch { /* leave it if we can't remove */ }
40
+ const cursorFile = path.join(initial.queueDir, '.scan-cursor');
41
+ let cursor = ''; try { cursor = readSafe(cursorFile, 256).toString(); } catch { /* first pass */ }
42
+ let files; try { files = queueFiles(initial, { cursor, limit: 128, deadline }); } catch { if (!process.send) releaseQueueLock(initial, token); process.exit(0); }
43
+ const result = { schemaVersion: 1, scope: initial.scope, startedAt: new Date().toISOString(),
44
+ fed: 0, acknowledged: 0, failed: 0, malformed: 0, scannedFiles: 0, exhausted: false, recorded: [], distillation: null,
45
+ contract: 'Exact CLI plus independent canonical AgentDB row commits observations; distillation and ratified lessons are separate; originals retained' };
46
+ const binary = resolveRuflo({ home: initial.home });
47
+ try {
48
+ let bytes = 0; let scanned = null;
49
+ for (const file of files.slice(0, 128)) {
50
+ if (Date.now() >= deadline || result.fed + result.failed >= 8 || bytes >= 16 * 1024 * 1024) break;
51
+ safeQueue(initial); result.scannedFiles++; scanned = path.basename(file);
52
+ let state;
53
+ try { state = pendingRecords(file); bytes += fs.statSync(file).size; }
54
+ catch { result.failed++; continue; } // Unsafe/torn sidecars remain intact; siblings still get a fair turn.
55
+ for (const record of state.records) {
56
+ if (Date.now() >= deadline || result.fed + result.failed >= 8) break;
57
+ if (!allowed() || !ownsQueueLock(initial, token)) break;
58
+ let row;
59
+ try { row = JSON.parse(record.raw); } catch { result.malformed++; continue; }
60
+ const action = safeAction(row.tool, row.action);
61
+ if (!record.key || !action) { result.malformed++; continue; }
62
+ if (!binary) { result.failed++; break; }
63
+ let delivery;
64
+ try {
65
+ delivery = recordLearningObservation(binary, initial, file, record, { tool: row.tool, action }, {
66
+ deadline, explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1',
67
+ allowed: () => allowed() && ownsQueueLock(initial, token),
68
+ });
69
+ } catch { result.failed++; continue; }
70
+ result.recorded.push(delivery);
71
+ result.fed++;
72
+ if (!allowed() || !ownsQueueLock(initial, token)) break;
73
+ state.ack[record.key] = true;
74
+ acknowledge(file, state.ack); result.acknowledged++;
75
+ }
190
76
  }
191
- } else if (process.argv.includes('--sync')) {
192
- console.log(`learn-flush: 0/${actions.length} fed (ruflo hooks failing?) — queue KEPT for retry next session-end`);
193
- }
194
- if (process.argv.includes('--sync')) {
195
- console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the ${LEARNING_SCOPE} learner`
196
- // Say the deadline out loud when it fires. A budget that silently truncates reads as "that was
197
- // all there was", which is the same lie as the count cap that preceded it.
198
- + (stoppedAt < actions.length ? `; STOPPED at ${stoppedAt}/${actions.length} on the ${DEADLINE_MS}ms deadline` : '')
199
- + (deferred.length ? `; ${deferred.length} distinct action(s) deferred to the next flush (queue kept, nothing discarded)` : ''));
77
+ if (binary && result.fed && allowed() && ownsQueueLock(initial, token) && deadline - Date.now() > 1000) {
78
+ try { result.distillation = distillLearning(binary, initial, { deadline, automatic: true, allowed: () => allowed() && ownsQueueLock(initial, token), explicitLegacyApply: process.env.RUVNET_LEGACY_USER_APPLY === '1' }); }
79
+ catch { result.distillation = { completed: false, reason: 'bounded canonical distillation unavailable', ratifiedLessons: 0 }; }
80
+ }
81
+ result.exhausted = Date.now() >= deadline || result.fed + result.failed >= 8;
82
+ if (allowed() && ownsQueueLock(initial, token)) {
83
+ if (scanned) writeAtomic(cursorFile, scanned);
84
+ const receipt = path.join(initial.queueDir, `.run-${Date.now()}-${process.pid}.json`);
85
+ writeExclusive(receipt, JSON.stringify({ ...result, endedAt: new Date().toISOString() }));
86
+ }
87
+ } catch { result.failed++; }
88
+ finally { if (!process.send) releaseQueueLock(initial, token); }
89
+ if (process.argv.includes('--report')) console.log(`learn-flush: fed ${result.fed}; acknowledged ${result.acknowledged}; failed ${result.failed}; malformed ${result.malformed}; original queue is KEPT for retry/history`);
90
+
91
+ // Keep the owned root alive until its supervisor terminates and confirms the whole tree.
92
+ // A lost supervisor cannot keep this worker alive beyond its inherited deadline.
93
+ if (process.send) {
94
+ process.send({ type: 'learning-worker-complete' });
95
+ await new Promise(resolve => setTimeout(resolve, Math.max(1, deadline - Date.now() + 600)));
200
96
  }
201
- process.exit(0);
@@ -0,0 +1,51 @@
1
+ // Read-only evidence shared by Console and the training remedy. No queue or learner is initialized.
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { learningTarget, learningStoreStatus } from './learning-store.mjs';
5
+ import { queueFiles, pendingRecords, safeQueue, readSafe } from './learning-queue.mjs';
6
+ import { learningContext } from './runtime-preferences.mjs';
7
+
8
+ const queueContext = (queueDir) => path.basename(queueDir) === 'ruvnet-brain-learn'
9
+ ? { scope: 'project', queueDir, projectDir: path.dirname(path.dirname(queueDir)) }
10
+ : { scope: 'user', queueDir, home: path.dirname(path.dirname(path.dirname(queueDir))) };
11
+
12
+ export function learningQueueFiles(queueDir) { return queueFiles(queueContext(queueDir)); }
13
+
14
+ /** Pending evidence includes malformed/torn lines; acknowledged retained originals are history. */
15
+ export function learningQueueDepth(queueDir) {
16
+ return learningQueueFiles(queueDir).reduce((depth, file) => depth + pendingRecords(file).records.length, 0);
17
+ }
18
+
19
+ export function observeLearning(options = {}) {
20
+ const env = options.env ?? process.env;
21
+ const context = learningContext(options);
22
+ const result = { ...context, queueDepth: 0, queueKnown: true, legacyUserDepth: 0, legacyUserKnown: true, lastTrainSeconds: null, trajectories: 0, statusKnown: false };
23
+ if (!context.enabled) return result;
24
+ try { learningTarget(context, { env }); } catch { result.statusKnown = false; result.queueKnown = false; return result; }
25
+ try { result.queueDepth = learningQueueDepth(context.queueDir); }
26
+ catch { result.queueKnown = false; }
27
+ try { const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock'), 4096)); result.workerRetirementUnconfirmed = lock.retirementUnconfirmed === true || (lock.retirementRequired === true && lock.expires < Date.now()); }
28
+ catch { /* Absent diagnostic lock grants no claim about a running process. */ }
29
+ try {
30
+ const latest = fs.readdirSync(safeQueue(context)).filter(n => /^\.run-\d+-\d+\.json$/.test(n))
31
+ .sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))[0];
32
+ const receipt = latest && JSON.parse(readSafe(path.join(context.queueDir, latest), 65536));
33
+ result.capturePendingFailure = result.queueDepth > 0 && ((Number.isSafeInteger(receipt?.failed) && receipt.failed > 0)
34
+ || (Number.isSafeInteger(receipt?.malformed) && receipt.malformed > 0));
35
+ result.distillationUnavailable = receipt?.distillation?.completed === false;
36
+ } catch { /* Unknown diagnostic is never fabricated success. */ }
37
+ if (context.scope === 'project') {
38
+ try { result.legacyUserDepth = learningQueueDepth(path.join(context.home, '.cache', 'ruvnet-brain', 'learn')); }
39
+ catch { result.legacyUserKnown = false; }
40
+ }
41
+ try {
42
+ result.learningDb = learningTarget(context, { env });
43
+ const state = learningStoreStatus(result.learningDb);
44
+ result.statusKnown = state.known;
45
+ result.trajectories = state.observations; // compatibility field; explicitly observations, never SONA trajectories.
46
+ result.observations = state.observations; result.patterns = state.patterns;
47
+ result.lastDistillAt = state.lastDistillAt;
48
+ if (state.lastDistillAt !== null) result.lastTrainSeconds = Math.max(0, (Date.now() - state.lastDistillAt) / 1000);
49
+ } catch { /* Missing consent/store/readback remains unknown. */ }
50
+ return result;
51
+ }