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.
- package/README.md +1 -1
- package/bin/install.mjs +50 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/codex-hooks.json +31 -1
- package/plugin/hooks/hook-contracts.json +82 -2
- package/plugin/hooks/hooks.json +31 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +36 -12
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/agentdb-recall.mjs +38 -10
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-hook-policy.mjs +3 -0
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +3 -2
- package/plugin/scripts/kb-copy-proof.mjs +84 -22
- package/plugin/scripts/learn-capture.mjs +44 -0
- package/plugin/scripts/learn-capture.sh +2 -241
- package/plugin/scripts/learn-flush.mjs +86 -191
- package/plugin/scripts/learning-observation.mjs +51 -0
- package/plugin/scripts/learning-queue.mjs +135 -0
- package/plugin/scripts/learning-store.mjs +105 -0
- package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +16 -7
- package/plugin/scripts/project-progression-contract.mjs +58 -4
- package/plugin/scripts/project-progression-outbox.mjs +22 -6
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +25 -8
- package/plugin/scripts/runtime-preferences.mjs +45 -4
- package/plugin/scripts/session-snapshot-hook.mjs +14 -7
- package/plugin/scripts/session-start-core.mjs +8 -0
- package/plugin/scripts/session-start-proof.mjs +49 -0
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/codex-fresh-host-proof.mjs +179 -0
- package/scripts/codex-host-execution-proof.mjs +246 -0
- package/scripts/codex-host-proof-runtime.mjs +104 -0
- package/scripts/console-engine.mjs +29 -16
- package/scripts/health-repair.mjs +47 -68
- package/scripts/onboarding-console.mjs +25 -54
- package/scripts/qa/progression-validation-benchmark.mjs +66 -0
- package/scripts/release-qualification-contract.mjs +76 -7
- package/scripts/remedy-registry.mjs +9 -2
|
@@ -1,242 +1,3 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
|
|
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
|
-
//
|
|
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 {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}
|