ruvnet-brain 3.9.134-dev → 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.
Files changed (218) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +5 -5
  3. package/bin/install.mjs +382 -36
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/kb/zip-extract.mjs +53 -14
  25. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  26. package/package.json +14 -22
  27. package/plugin/.claude-plugin/marketplace.json +14 -0
  28. package/plugin/.claude-plugin/plugin.json +22 -0
  29. package/plugin/.codex-plugin/plugin.json +21 -0
  30. package/plugin/.mcp.json +8 -0
  31. package/plugin/commands/brain-console.md +16 -0
  32. package/plugin/commands/configure.md +33 -0
  33. package/plugin/commands/rvbc.md +79 -0
  34. package/plugin/commands/rvcb.md +16 -0
  35. package/plugin/commands/whats-new.md +57 -0
  36. package/plugin/hooks/codex-hooks.json +160 -0
  37. package/plugin/hooks/hook-contracts.json +77 -0
  38. package/plugin/hooks/hooks.json +202 -0
  39. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  40. package/plugin/mcp/server.mjs +56 -6
  41. package/plugin/scripts/anticipate.sh +534 -0
  42. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  43. package/plugin/scripts/continuation-gate.mjs +267 -0
  44. package/plugin/scripts/design-wall.sh +137 -0
  45. package/plugin/scripts/detach.mjs +182 -0
  46. package/plugin/scripts/first-session-worker.mjs +38 -0
  47. package/plugin/scripts/gate-receipt.sh +35 -0
  48. package/plugin/scripts/ground-before-write.sh +199 -0
  49. package/plugin/scripts/ground-ruvnet.sh +517 -0
  50. package/plugin/scripts/grounding-stamp.sh +113 -0
  51. package/plugin/scripts/grounding-substance.mjs +595 -0
  52. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  53. package/plugin/scripts/hook-input.mjs +558 -0
  54. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  55. package/plugin/scripts/hook-shim.mjs +303 -0
  56. package/plugin/scripts/host-update.mjs +58 -0
  57. package/plugin/scripts/kling-preflight.sh +146 -0
  58. package/plugin/scripts/learn-capture.sh +173 -0
  59. package/plugin/scripts/learn-flush.mjs +155 -0
  60. package/plugin/scripts/lesson-hooks.sh +213 -0
  61. package/plugin/scripts/md-stamp.mjs +219 -0
  62. package/plugin/scripts/protect-brain-state.sh +84 -0
  63. package/plugin/scripts/route-dispatch.sh +147 -0
  64. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  65. package/plugin/scripts/runtime-preferences.mjs +269 -0
  66. package/plugin/scripts/session-start-core.mjs +477 -0
  67. package/plugin/scripts/session-start.sh +13 -0
  68. package/plugin/scripts/signal-watch.mjs +193 -0
  69. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  70. package/plugin/scripts/update-apply.mjs +419 -0
  71. package/plugin/scripts/verify-interface.sh +53 -0
  72. package/plugin/scripts/version-bump-gate.sh +112 -0
  73. package/plugin/skills/brain-build/SKILL.md +123 -0
  74. package/plugin/skills/brain-console/SKILL.md +22 -0
  75. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  76. package/plugin/skills/brain-score/SKILL.md +101 -0
  77. package/plugin/skills/release-proof/SKILL.md +81 -0
  78. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  79. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  80. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  81. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
  82. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  83. package/plugin/skills/rvbc/SKILL.md +23 -0
  84. package/plugin/skills/savings/SKILL.md +46 -0
  85. package/plugin/skills/whats-new/SKILL.md +22 -0
  86. package/scripts/adr-backfill.mjs +107 -0
  87. package/scripts/advocacy-outcomes.mjs +808 -0
  88. package/scripts/agentdb-context.mjs +216 -0
  89. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  90. package/scripts/ascii-drift.mjs +236 -0
  91. package/scripts/behavioral-l1-l4.mjs +210 -0
  92. package/scripts/brain-capability-check.mjs +72 -0
  93. package/scripts/brain-grade-groundtruth.mjs +100 -0
  94. package/scripts/brain-latency-50.mjs +227 -0
  95. package/scripts/brain-novice-50.mjs +189 -0
  96. package/scripts/brain-stamp.mjs +94 -0
  97. package/scripts/brain-state.mjs +212 -0
  98. package/scripts/build-bundle.mjs +522 -0
  99. package/scripts/build-concepts.mjs +132 -0
  100. package/scripts/build-l2.mjs +71 -0
  101. package/scripts/build-primer.mjs +73 -0
  102. package/scripts/build-symbols.mjs +68 -0
  103. package/scripts/calibrate-router.mjs +97 -0
  104. package/scripts/capability-audit.mjs +321 -0
  105. package/scripts/capability-registry.mjs +876 -0
  106. package/scripts/check-indexation.mjs +108 -0
  107. package/scripts/check-legibility.mjs +189 -0
  108. package/scripts/ci/build-fixture-kb.mjs +67 -0
  109. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  110. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  111. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  112. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  113. package/scripts/ci/stranger-scenario.mjs +228 -0
  114. package/scripts/ci/stranger-timeout.mjs +25 -0
  115. package/scripts/ci-verdict.mjs +29 -0
  116. package/scripts/claims-verify.mjs +710 -0
  117. package/scripts/clear-claude-tmp.sh +31 -0
  118. package/scripts/console-engine.mjs +434 -0
  119. package/scripts/console-engine.test.mjs +125 -0
  120. package/scripts/corpus-qa.mjs +250 -0
  121. package/scripts/correction-detect-embed.mjs +346 -0
  122. package/scripts/correction-detect-measure.mjs +270 -0
  123. package/scripts/correction-detect.mjs +686 -0
  124. package/scripts/count-chunks.mjs +54 -0
  125. package/scripts/described-questions.json +30 -0
  126. package/scripts/design-grade.mjs +58 -0
  127. package/scripts/dev-plugin-link.sh +105 -0
  128. package/scripts/distill-project.mjs +200 -0
  129. package/scripts/doc-currency.mjs +801 -0
  130. package/scripts/eval-brain.mjs +244 -0
  131. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  132. package/scripts/full-hints.mjs +87 -0
  133. package/scripts/gate.sh +39 -0
  134. package/scripts/gates.mjs +146 -0
  135. package/scripts/gen-console-images.mjs +54 -0
  136. package/scripts/gen-images.mjs +47 -0
  137. package/scripts/git-clone-refresh.mjs +52 -0
  138. package/scripts/git-hooks/pre-push +126 -0
  139. package/scripts/goal-match.mjs +398 -0
  140. package/scripts/goldie-research.mjs +223 -0
  141. package/scripts/goldie-weekly.sh +67 -0
  142. package/scripts/health-repair.mjs +250 -0
  143. package/scripts/helix-scenario-questions.json +10 -0
  144. package/scripts/ingest-gists.mjs +230 -0
  145. package/scripts/ingest-meeting.mjs +115 -0
  146. package/scripts/ingest-repo.mjs +79 -0
  147. package/scripts/install-npx-witness.sh +49 -0
  148. package/scripts/issue-fix.mjs +639 -0
  149. package/scripts/issue-watch.mjs +276 -0
  150. package/scripts/issue4-close-note.md +31 -0
  151. package/scripts/key-canary.mjs +91 -0
  152. package/scripts/latency-to-surface.mjs +233 -0
  153. package/scripts/learning-enable.mjs +380 -0
  154. package/scripts/learning-replay.mjs +1570 -0
  155. package/scripts/learnings.mjs +62 -0
  156. package/scripts/lesson-gate.mjs +680 -0
  157. package/scripts/lesson-lifecycle.mjs +449 -0
  158. package/scripts/lesson-promote.mjs +262 -0
  159. package/scripts/lesson-ratify.mjs +98 -0
  160. package/scripts/lesson-seed.mjs +252 -0
  161. package/scripts/lesson-store.mjs +447 -0
  162. package/scripts/loop-checkpoint.mjs +86 -0
  163. package/scripts/memdb-health.sh +14 -0
  164. package/scripts/memory-doctor.mjs +271 -0
  165. package/scripts/model-catalog.mjs +79 -0
  166. package/scripts/nightly-controller.mjs +66 -0
  167. package/scripts/nightly-gists.sh +72 -0
  168. package/scripts/nightly-wrapper.sh +180 -0
  169. package/scripts/notify.sh +12 -0
  170. package/scripts/npx-witness.sh +56 -0
  171. package/scripts/onboarding-console.mjs +2749 -0
  172. package/scripts/private-fence.mjs +69 -0
  173. package/scripts/proactivity-metrics.mjs +118 -0
  174. package/scripts/proof-questions.json +56 -0
  175. package/scripts/prove.mjs +95 -0
  176. package/scripts/proxy/claude-proxied.sh +57 -0
  177. package/scripts/proxy/proxy-revert.sh +59 -0
  178. package/scripts/proxy/proxy-up.sh +60 -0
  179. package/scripts/proxy/proxy-verify.mjs +142 -0
  180. package/scripts/published-surface-probe.mjs +241 -0
  181. package/scripts/qe/card-lane-gate.mjs +162 -0
  182. package/scripts/qe/session-start-gate.mjs +229 -0
  183. package/scripts/qe/ux-suite.mjs +323 -0
  184. package/scripts/reconcile-project.mjs +0 -0
  185. package/scripts/record-lesson.mjs +113 -0
  186. package/scripts/refresh-model-catalog.mjs +99 -0
  187. package/scripts/release-proof.mjs +9 -0
  188. package/scripts/release-vector.mjs +281 -0
  189. package/scripts/release.mjs +395 -0
  190. package/scripts/remedy-registry.mjs +247 -0
  191. package/scripts/rerank-cap-eval.mjs +265 -0
  192. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  193. package/scripts/route-cheap.mjs +20 -15
  194. package/scripts/router-utilization.mjs +182 -0
  195. package/scripts/routing-flywheel.mjs +596 -0
  196. package/scripts/rvf-generation.mjs +104 -0
  197. package/scripts/rvf-index-audit.mjs +138 -0
  198. package/scripts/self-update.mjs +508 -0
  199. package/scripts/selfcheck.mjs +7 -1
  200. package/scripts/sign-bundle.mjs +69 -0
  201. package/scripts/signal-watch.mjs +171 -0
  202. package/scripts/stack-sync.mjs +469 -0
  203. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  204. package/scripts/stamp-sweep.mjs +144 -0
  205. package/scripts/status-honesty.mjs +102 -0
  206. package/scripts/sync-version.mjs +217 -0
  207. package/scripts/token-report.mjs +102 -0
  208. package/scripts/top100-benchmark.mjs +479 -0
  209. package/scripts/top100-corpus.mjs +112 -0
  210. package/scripts/top100-semantic-assertions.mjs +449 -0
  211. package/scripts/update-apply.mjs +9 -0
  212. package/scripts/upgrade-notice.mjs +14 -0
  213. package/scripts/verify-bundle.mjs +51 -0
  214. package/scripts/verify-channels.mjs +184 -0
  215. package/scripts/verify-model-catalog.mjs +104 -0
  216. package/scripts/verify-nightly-close-issue4.sh +31 -0
  217. package/scripts/version.mjs +40 -0
  218. package/scripts/wired-check.mjs +864 -0
@@ -0,0 +1,173 @@
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
+ 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
+
27
+ # BOUNDED READ. An unqualified `read` waits forever on a stdin that is opened and never closed, and
28
+ # an unbounded accumulator turns a large payload into an unbounded regex scan. Claude Code always
29
+ # writes the payload and closes, so neither costs a normal turn — which is exactly why a hook that
30
+ # CAN hang survives: the only thing ending it is a timeout owned by someone else. -t 2 is ~40x a real
31
+ # payload's delivery time; the size cap is ~30x the largest real payload. The trailing `[ -n "$_l" ]`
32
+ # keeps the final unterminated line, which is what the original `||` clause was for.
33
+ INPUT=""
34
+ while IFS= read -r -t 2 _l; do
35
+ INPUT+="$_l"
36
+ [ ${#INPUT} -ge 65536 ] && break
37
+ done
38
+ # Truncate the ASSEMBLED string, not just each iteration: a hook payload is one line with no trailing
39
+ # newline, so `read` hands back the whole thing at once in $_l and the in-loop cap never fires.
40
+ [ -n "$_l" ] && INPUT+="$_l"
41
+ INPUT="${INPUT:0:65536}"
42
+ [ -n "$INPUT" ] || exit 0
43
+
44
+ TOOL=""
45
+ re_t='"tool_name"[[:space:]]*:[[:space:]]*"([^"]*)"'
46
+ [[ $INPUT =~ $re_t ]] && TOOL="${BASH_REMATCH[1]}"
47
+ [ -n "$TOOL" ] || exit 0
48
+
49
+ ACTION=""
50
+ case "$TOOL" in
51
+ Bash)
52
+ # Capture the VERB CHAIN ONLY — "git push", "npm test", "npx vercel" — never the arguments.
53
+ #
54
+ # This previously took the first 120 chars up to an embedded quote and called that "verb, not
55
+ # facts". It wasn't. Unquoted inline secrets were captured in full and written to disk, proven
56
+ # by test: `export AWS_SECRET_ACCESS_KEY=wJalr... && psql postgres://admin:Hunter2Pass@db/prod`
57
+ # landed verbatim in session-*.jsonl, and from there fed the global learner. Real command lines
58
+ # routinely carry API keys, DB URLs with inline passwords, and internal hostnames — on a
59
+ # corporate laptop the hostnames alone are a DLP finding.
60
+ #
61
+ # Now: keep at most the first two tokens, and stop at the first token that carries DATA rather
62
+ # than INTENT (contains = / @ : , is a flag, or is improbably long). "export FOO=secret" records
63
+ # "export"; "cd /Users/me/ClientProject" records "cd". The learner only ever needed the verb.
64
+ # THE CAPTURE WAS MANGLED, and the mangling was invisible (fixed 2026-07-27).
65
+ #
66
+ # `"command"…"([^"]*)"` cannot cross a JSON-escaped quote — the exact bug hook-input.mjs exists to
67
+ # end — so `cd "/tmp/some dir"` captured the two bytes `cd \`, and that trailing backslash then
68
+ # broke the JSON line it was printed into. Measured on the owner's live queue:
69
+ #
70
+ # {"tool":"Bash","action":"cd \"} ← JSON.parse: Unterminated string at position 31
71
+ #
72
+ # learn-flush drops every unparseable line with a bare `continue`, so the capture reported success,
73
+ # the queue grew, and the learner received nothing. A pipe severed in the middle while both ends
74
+ # report health is this project's signature failure mode.
75
+ #
76
+ # The fix is to stop at the first quote OR backslash and take a PREFIX — no closing-quote anchor,
77
+ # because the first two tokens are all this hook ever wanted. A command that opens with a quoted
78
+ # path yields an empty prefix and is simply not captured, which is strictly better than writing a
79
+ # line that cannot be read back.
80
+ re_c='"command"[[:space:]]*:[[:space:]]*"([^"\]*)'
81
+ if [[ $INPUT =~ $re_c ]]; then
82
+ set -f # no globbing while we word-split untrusted text
83
+ _n=0
84
+ for _tok in ${BASH_REMATCH[1]}; do
85
+ case "$_tok" in
86
+ *=*|*/*|*@*|*:*|-*) break ;;
87
+ esac
88
+ [ ${#_tok} -gt 24 ] && break
89
+ ACTION="${ACTION:+$ACTION }$_tok"
90
+ _n=$((_n + 1))
91
+ [ "$_n" -ge 2 ] && break
92
+ done
93
+ set +f
94
+ fi
95
+ ;;
96
+ Write|Edit|MultiEdit)
97
+ re_f='"file_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
98
+ [[ $INPUT =~ $re_f ]] && ACTION="edit ${BASH_REMATCH[1]##*/}" # basename only — no full path
99
+ ;;
100
+ esac
101
+ [ -n "$ACTION" ] || exit 0
102
+
103
+ # THE SESSION ID IS IN THE PAYLOAD WE ALREADY READ — and it was being thrown away.
104
+ #
105
+ # This read `${CLAUDE_SESSION_ID:-default}`, an env var Claude Code does not set, so EVERY session on
106
+ # a machine appended to one shared `session-default.jsonl`. Measured on the owner's machine
107
+ # 2026-07-27: one file, 147 lines deep, written concurrently by several live sessions — the same
108
+ # many-writers-one-path shape as ADR-050, and it makes "this session's trajectory" a fiction, because
109
+ # the queue is a blend of every session that happened to be open.
110
+ #
111
+ # `session_id` is a field on the very payload this hook already parsed. Prefer it; fall back to the
112
+ # env var; then to 'default'. SANITISED before it becomes a filename component: the payload is
113
+ # untrusted input, and `session_id` reaching an unfiltered path join is a traversal waiting to happen.
114
+ SID=""
115
+ re_s='"session_id"[[:space:]]*:[[:space:]]*"([^"\]*)"'
116
+ [[ $INPUT =~ $re_s ]] && SID="${BASH_REMATCH[1]}"
117
+ [ -n "$SID" ] || SID="${CLAUDE_SESSION_ID:-}"
118
+ # Dots are dropped along with everything else outside this set: real session ids are uuids, and
119
+ # keeping `.` would let a crafted id survive as `..`-shaped debris in a filename for no benefit.
120
+ SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
121
+ [ -n "$SID" ] || SID="default"
122
+ if [ "$SCOPE" = "user" ]; then
123
+ DIR="$HOME/.cache/ruvnet-brain/learn"
124
+ else
125
+ DIR="$PWD/.swarm/ruvnet-brain-learn"
126
+ fi
127
+ # Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
128
+ # account is normally in `staff`, so any other user on a shared or corporate machine could read it.
129
+ ( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
130
+ QUEUE="$DIR/session-$SID.jsonl"
131
+ [ -e "$QUEUE" ] || { : > "$QUEUE" 2>/dev/null && chmod 600 "$QUEUE" 2>/dev/null; } || true
132
+ printf '{"tool":"%s","action":"%s"}\n' "$TOOL" "${ACTION//\"/\\\"}" >> "$QUEUE" 2>/dev/null || true
133
+
134
+ # ── HEARTBEAT FLUSH (ADR-027) ────────────────────────────────────────────────────────────────────
135
+ # The flush used to fire ONLY on a clean SessionEnd. Sessions compact, crash, get resumed, or are
136
+ # killed — none of those reach SessionEnd — so the queue silently grew to 1,884 undelivered events
137
+ # over days while the learner sat at 5 trajectories, last trained six days earlier. Draining it took
138
+ # the learner to 412/412 in one command. A queue that only empties on a graceful exit will always
139
+ # leak; activity itself must be the trigger.
140
+ #
141
+ # So: every HEARTBEAT_EVERY captures, drain in the BACKGROUND. Detached and fully silent — this runs
142
+ # inside a PostToolUse hook and must never add latency to the user's turn or fail one. Cheap check
143
+ # (a line count) on the common path; real work only at the threshold.
144
+ # LEVEL-TRIGGERED, NOT EDGE-TRIGGERED. This is the whole fix, and the bug it replaces was severe.
145
+ #
146
+ # The condition used to be `LINES >= 200 && LINES % 200 == 0` — it fired ONLY when the count landed
147
+ # exactly on a multiple of 200. Two captures arriving between checks, or any concurrent write,
148
+ # steps the counter over the window and the flush NEVER fires again. Measured on the owner's machine
149
+ # 2026-07-22: the queue was at 491. It had sailed past both 200 and 400 without draining once, and
150
+ # would have grown forever.
151
+ #
152
+ # The failure mode is the nastiest kind: capture works, the learner works, and the PIPE BETWEEN THEM
153
+ # is severed — while every surface honestly reports both ends as healthy. "Is learning on?" had no
154
+ # true answer, because learning is not a switch; it is a chain, and one link was open.
155
+ #
156
+ # `-ge` cannot skip a window. It fires on every capture past the threshold until the queue is
157
+ # actually drained, which is the definition of level-triggered: the condition is the QUEUE'S DEPTH,
158
+ # not the instant it crossed a line.
159
+ HEARTBEAT_EVERY=200
160
+ LINES=$(wc -l < "$DIR/session-$SID.jsonl" 2>/dev/null || echo 0)
161
+ if [ "$LINES" -ge "$HEARTBEAT_EVERY" ]; then
162
+ # Debounce so a deep queue doesn't spawn a flush on EVERY subsequent capture: at most one drain
163
+ # per minute. Without this, level-triggering trades a stuck queue for a fork storm.
164
+ STAMP="$DIR/.last-flush"
165
+ NOW=$(date +%s)
166
+ LAST=$(cat "$STAMP" 2>/dev/null || echo 0)
167
+ if [ $((NOW - LAST)) -ge 60 ]; then
168
+ echo "$NOW" > "$STAMP" 2>/dev/null || true
169
+ FLUSH="$HERE/learn-flush.mjs"
170
+ [ -f "$FLUSH" ] && (RUVNET_LEARNING_SCOPE="$SCOPE" nohup node "$FLUSH" >/dev/null 2>&1 &) || true
171
+ fi
172
+ fi
173
+ exit 0
@@ -0,0 +1,155 @@
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
+
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { readStdinBounded } from './hook-input.mjs';
16
+ import { loadRuntimePreferences } from './runtime-preferences.mjs';
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);
25
+
26
+ // THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
27
+ //
28
+ // This used to be `process.env.CLAUDE_SESSION_ID || 'default'`. Claude Code does not set that
29
+ // variable, so every session on the machine read and rewrote ONE shared session-default.jsonl —
30
+ // measured live at 147 lines, appended by several concurrent sessions. Both halves of this pipeline
31
+ // have to agree about which file they mean, so both now read `session_id` from the payload the hook
32
+ // is already handed, sanitise it the same way, and fall back the same way.
33
+ //
34
+ // The read is bounded: SessionEnd hands us a small JSON object and closes, but an unbounded
35
+ // readFileSync(0) on a stdin that never closes is a hang with no upper bound. A payload we cannot
36
+ // read in time simply yields no id, which lands on the same fallback as no payload at all.
37
+ async function payloadSessionId() {
38
+ if (process.stdin.isTTY) return '';
39
+ try {
40
+ const raw = (await readStdinBounded()).toString('utf8');
41
+ const v = JSON.parse(raw)?.session_id;
42
+ return typeof v === 'string' ? v : '';
43
+ } catch { return ''; }
44
+ }
45
+ // A filename COMPONENT, never a path — the payload is untrusted input.
46
+ const SID = ((await payloadSessionId()) || process.env.CLAUDE_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '') || 'default';
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`);
51
+ const RUFLO = path.join(HOME, '.npm-global/bin/ruflo');
52
+ const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
53
+ const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
54
+
55
+ // THE DEADLINE. SessionEnd's registered timeout is 30s (plugin/hooks/hooks.json) and this hook fires
56
+ // on EVERY session end — including every `/clear`. Measured on the owner's machine 2026-07-27, in all
57
+ // four stdin regimes: 48–50s wall, killed at the cap every single time.
58
+ //
59
+ // The arithmetic was never survivable. MAX_ACTIONS is 8 and a real `ruflo hooks` call measured 3.83s,
60
+ // so the feed queued ~31s of work into a 30s budget and was killed part-way through it. Worse, the
61
+ // kill lands BEFORE the write-back that preserves the remainder, so the queue never shrinks and never
62
+ // drains — a cap that guarantees the work it defers can never be done.
63
+ //
64
+ // A work limit has to be expressed in the currency the budget is denominated in. MAX_ACTIONS bounds
65
+ // COUNT; this bounds TIME, and the two together mean the hook stops cleanly, keeps what it did not
66
+ // feed, and exits well inside the cap. 20s leaves a full third of the budget for the write-back, the
67
+ // process teardown, and a slow machine. Measured with a 4s-per-call stub and a 147-entry queue: 22.5s
68
+ // wall at a 20s deadline (execFileSync's own kill handling costs a couple of seconds on top of the
69
+ // budget), so the number is set at 18s to keep the real worst case around 20s — a third of the cap in
70
+ // hand. The budget is the thing being bounded; the constant is chosen from the measurement, not from
71
+ // how round it looks.
72
+ const DEADLINE_MS = Number(process.env.LEARN_FLUSH_DEADLINE_MS) || 18_000;
73
+ const DEADLINE = Date.now() + DEADLINE_MS;
74
+
75
+ let lines = [];
76
+ try { lines = fs.readFileSync(QUEUE, 'utf8').split('\n').filter(Boolean); } catch { process.exit(0); }
77
+ if (!lines.length) process.exit(0);
78
+
79
+ // Distinct workflow actions this session (dedupe → a session has only a handful of real patterns).
80
+ //
81
+ // COLLECT ALL, FEED SOME, KEEP THE REST (fixed 2026-07-22). This used to `break` at MAX_ACTIONS and
82
+ // then delete the ENTIRE queue, so a session with 30 distinct actions fed 8 and destroyed 22 —
83
+ // permanently, silently, while reporting success. Measured on the owner's machine the same day: the
84
+ // queue stood at 491 raw captures, every one of which would have been discarded after feeding 8.
85
+ //
86
+ // The cap exists for a good reason (SessionEnd must stay fast) but a work LIMIT is not a licence to
87
+ // destroy the work you didn't do. Now the remainder is written back and drains on the next flush,
88
+ // so a deep queue converges instead of being truncated.
89
+ const allDistinct = [];
90
+ const seen = new Set();
91
+ for (const line of lines) {
92
+ let s; try { s = JSON.parse(line); } catch { continue; }
93
+ const key = `${s.tool}|${(s.action || '').slice(0, 60)}`;
94
+ if (!s.action || seen.has(key)) continue;
95
+ seen.add(key);
96
+ allDistinct.push(s);
97
+ }
98
+ const actions = allDistinct.slice(0, MAX_ACTIONS);
99
+ const deferred = allDistinct.slice(MAX_ACTIONS);
100
+
101
+ let fed = 0;
102
+ let stoppedAt = actions.length; // how far the feed actually got before the deadline
103
+ for (let i = 0; i < actions.length; i++) {
104
+ const remaining = DEADLINE - Date.now();
105
+ // STOP CLEANLY, and stop BEFORE starting work that cannot finish inside the budget. A call begun
106
+ // at 19.9s with a 6s timeout would run to 25.9s, which is the whole failure in miniature — the
107
+ // budget has to bound the call, not just the decision to make it.
108
+ if (remaining <= 0) { stoppedAt = i; break; }
109
+ const s = actions[i];
110
+ const args = s.tool === 'Bash'
111
+ ? ['hooks', 'post-command', '-c', s.action, '-s', 'true']
112
+ : ['hooks', 'post-edit', '-f', s.action, '-s', 'true', '-o', 'session edit'];
113
+ try {
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
+ });
122
+ fed++;
123
+ } catch { /* best-effort — one slow/failed record must not stall session end */ }
124
+ }
125
+ // Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
126
+ // next flush continues from there. Dropping it would turn a time limit into the same silent data
127
+ // loss the count limit used to cause.
128
+ if (stoppedAt < actions.length) deferred.unshift(...actions.slice(stoppedAt));
129
+
130
+ // DERIVED, not asserted (F14, 2026-07-18): the queue is EVIDENCE, and it may only be destroyed when
131
+ // its contents were actually fed. The old line deleted it unconditionally — a session where every
132
+ // `ruflo hooks` call failed (fed=0) silently discarded the whole learning queue with nothing learned
133
+ // and no trace. Now: nothing fed + something to feed ⇒ the queue survives for the next session-end
134
+ // to retry. An empty queue (nothing to feed) is safe to remove.
135
+ if (fed > 0 || allDistinct.length === 0) {
136
+ if (deferred.length) {
137
+ // Work remains. Write back ONLY what was not fed, so the next flush continues where this one
138
+ // stopped. Deleting here is what turned a rate limit into data loss.
139
+ try {
140
+ fs.writeFileSync(QUEUE, deferred.map((s) => JSON.stringify(s)).join('\n') + '\n');
141
+ } catch { /* if we cannot rewrite it, leaving the full queue is strictly safer than removing it */ }
142
+ } else {
143
+ try { fs.rmSync(QUEUE); } catch { /* leave it if we can't remove */ }
144
+ }
145
+ } else if (process.argv.includes('--sync')) {
146
+ console.log(`learn-flush: 0/${actions.length} fed (ruflo hooks failing?) — queue KEPT for retry next session-end`);
147
+ }
148
+ if (process.argv.includes('--sync')) {
149
+ console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the ${LEARNING_SCOPE} learner`
150
+ // Say the deadline out loud when it fires. A budget that silently truncates reads as "that was
151
+ // all there was", which is the same lie as the count cap that preceded it.
152
+ + (stoppedAt < actions.length ? `; STOPPED at ${stoppedAt}/${actions.length} on the ${DEADLINE_MS}ms deadline` : '')
153
+ + (deferred.length ? `; ${deferred.length} distinct action(s) deferred to the next flush (queue kept, nothing discarded)` : ''));
154
+ }
155
+ process.exit(0);
@@ -0,0 +1,213 @@
1
+ #!/bin/bash
2
+ # lesson-hooks.sh — THE LAST MILE. Connects lessons to the events that actually fire.
3
+ #
4
+ # WHY THIS EXISTS, measured 2026-07-22 05:50:
5
+ #
6
+ # trigger enforcing? wired to a hook that runs?
7
+ # ship yes YES ← the only real one
8
+ # assert-fact yes no
9
+ # report-status yes no ← this is why the model still stopped
10
+ # write-code yes no
11
+ # claim-done yes no
12
+ # mutate-machine yes no
13
+ #
14
+ # Five lessons the owner had personally ratified reported "enforcing" and NOTHING CALLED THEM. The
15
+ # store said armed; the machine said unconnected.
16
+ #
17
+ # ONE dispatcher, not five hooks. ADR-030's constraint holds: gates scale with decision TYPES, never
18
+ # with lesson count. This file maps Claude Code's real events onto the store's triggers and asks the
19
+ # store what applies. Adding a lesson requires no change here — that asymmetry is the architecture.
20
+ #
21
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
22
+ # WHAT CHANGED, 2026-07-22: THIS FILE WAS THE THIRD LAYER OF A BLOCK THAT COULD NOT BLOCK.
23
+ #
24
+ # The old body ran the gate as `... 2>/dev/null || true`, echoed whatever came back, and then
25
+ # `exit 0` unconditionally — with this comment above it, which was true as a promise and fatal as a
26
+ # design: "FAILS OPEN, ALWAYS. Exit 0 unconditionally on every path."
27
+ #
28
+ # Measured, before the fix:
29
+ # $ bash plugin/scripts/lesson-hooks.sh Stop ; echo $?
30
+ # ⛔ BLOCKED — you are about to report progress or state.
31
+ # 0 ← printed BLOCKED, returned ALLOW
32
+ #
33
+ # Three bugs stacked so neatly that each one alone would have been enough: the gate exited 1 (not the
34
+ # harness's blocking code 2), wrote its reason to stdout (which exit-2 ignores), and this file threw
35
+ # the code away regardless. `|| true` is what turned "the gate is broken" into "the gate is silent",
36
+ # which is why it survived long enough to be documented as working.
37
+ #
38
+ # FAIL-OPEN IS STILL THE RULE — it was just implemented as "always allow", which is not the same
39
+ # thing. Failing open means an ERROR must not refuse the user: a missing node, an unreadable store, a
40
+ # timeout. It never meant discarding a DELIBERATE refusal. Those are now distinguished by exit code:
41
+ # 2 is a decision and propagates; every other non-zero is a malfunction and allows.
42
+ #
43
+ # AND THE DEFAULT IS NOW A NUDGE, not a block (the owner, same day: "Nudging somebody is very fair.
44
+ # Forcing them through a gate is not."). The gate emits `additionalContext` on exit 0 for nudges —
45
+ # the model reads it, nothing is refused. Exit 2 happens only for a lesson the user has personally
46
+ # opted into blocking, in a file only they write.
47
+
48
+ EVENT="${1:-}"
49
+ [ -z "$EVENT" ] && exit 0
50
+
51
+ # Resolve the gate relative to this script so it works from the repo, the marketplace clone, or an
52
+ # installed bundle without a hardcoded path (a hardcoded ~/.npm-global path told users ruflo was
53
+ # missing when it sat on their PATH — same class of bug).
54
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
55
+ GATE=""
56
+ for c in "$HERE/../../scripts/lesson-gate.mjs" "$HERE/../scripts/lesson-gate.mjs" "$HOME/.claude/plugins/marketplaces/ruvnet-brain/scripts/lesson-gate.mjs"; do
57
+ [ -f "$c" ] && GATE="$c" && break
58
+ done
59
+ [ -z "$GATE" ] && exit 0
60
+ command -v node >/dev/null 2>&1 || exit 0
61
+
62
+ # Read the hook's JSON payload off stdin ONCE, via the same shared Node parser the other PreToolUse
63
+ # gates on Bash already use for this — never a hand-rolled bash regex, which is how a JSON-escaped
64
+ # quote once silently truncated a command in this exact codebase. Safe for every event this script
65
+ # handles: the harness always pipes a payload, and a caller with no stdin (a manual test) gets EOF
66
+ # immediately, not a hang — an empty result just means no command text was found.
67
+ INPUT=""
68
+ # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
69
+ # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
70
+ # harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
71
+ # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
72
+ # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
73
+ # the whole thing back at once and a per-iteration cap never fires.
74
+ while IFS= read -r -t 2 _l; do
75
+ INPUT+="$_l"$'\n'
76
+ [ ${#INPUT} -ge 65536 ] && break
77
+ done
78
+ [ -n "$_l" ] && INPUT+="$_l"$'\n'
79
+ INPUT="${INPUT:0:65536}"
80
+ HOOK_INPUT_JS="$HERE/hook-input.mjs"
81
+
82
+ # Map a real Claude Code event onto the store's decision points, and onto the event name the harness
83
+ # will accept back. An event may carry more than one decision point: ending a turn is simultaneously
84
+ # "reporting status" and "claiming done", and both have lessons.
85
+ #
86
+ # CLAUDE_EVENT is what goes into hookSpecificOutput.hookEventName, and it MUST be the harness's real
87
+ # event name — "PreToolUse", never our internal "PreToolUse-write" — or the envelope is discarded and
88
+ # the nudge silently reaches nobody. That is this project's signature failure; it is not repeating here.
89
+ TRIGGERS=""
90
+ CLAUDE_EVENT=""
91
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
92
+ # WHY `Stop` IS NO LONGER HERE (2026-07-22, and this is the fix for a live, machine-wide defect).
93
+ #
94
+ # `additionalContext` at Stop does not merely inform — it CONTINUES THE TURN, and counts against the
95
+ # 8-consecutive-continuation cap. The doc is explicit: "It keeps the conversation going through the
96
+ # same loop protections as decision: 'block'... Claude Code overrides the hook and ends the turn
97
+ # after 8 consecutive blocks."
98
+ #
99
+ # This dispatcher fired on Stop with triggers `report-status claim-done`, which match lessons that
100
+ # match ALWAYS. So EVERY turn-end was continued, in every project, until the harness killed it on the
101
+ # ninth. Observed live on 2026-07-22 in three separate projects; the harness's own error text named
102
+ # the missing guard.
103
+ #
104
+ # The deeper error was not the missing guard, it was the DESIGN: this gate fires on the ACT of
105
+ # stopping with no state check, so it cannot tell "stopped early" from "genuinely finished". A guard
106
+ # that fires unconditionally carries no information, and paying a forced model turn to deliver a
107
+ # reminder is the ham-fisted behaviour that gets a tool switched off.
108
+ #
109
+ # So Stop is now owned by ONE hook — continuation-gate.mjs — which reads a real work ledger, speaks
110
+ # only when committed work is actually outstanding, honours stop_hook_active, and nudges at most once
111
+ # per session. The `report-status` and `claim-done` lessons moved to UserPromptSubmit, where they
112
+ # reach the model BEFORE the work rather than after it, and cost nothing to deliver.
113
+ TRIGGERS=""
114
+ CLAUDE_EVENT=""
115
+ case "$EVENT" in
116
+ Stop) exit 0 ;; # deliberately inert — see above. continuation-gate.mjs owns Stop.
117
+ PreToolUse-write) TRIGGERS="write-code"; CLAUDE_EVENT="PreToolUse" ;;
118
+ PreToolUse-bash) TRIGGERS="mutate-machine"; CLAUDE_EVENT="PreToolUse" ;;
119
+ PreToolUse-push) TRIGGERS="ship"; CLAUDE_EVENT="PreToolUse" ;;
120
+ # `choose-work` ADDED 2026-07-24 — the omission that made the Learning pillar look broken.
121
+ #
122
+ # THE FAILURE, in the owner's words: "why aren't you spinning up parallel swarms... isn't that a
123
+ # definition of a huge failure of the learning?" He was right, and the cause was one missing word on
124
+ # this line. L16-parallel-by-default ("when given multiple independent pieces of work, FAN OUT
125
+ # IMMEDIATELY... serial execution of independent work is a defect, not a style") is RATIFIED, taught
126
+ # FOUR times — and had never been delivered once, in any session, because nothing ever requested its
127
+ # trigger. The lesson was not ignored; it was never spoken.
128
+ #
129
+ # WHY IT WAS MISSED, and the trap worth naming: `choose-work`'s surface is `plan`, and lesson-store
130
+ # correctly refuses to let a `plan` lesson claim `block` enforcement — no hook can observe the
131
+ # instant a model forms an intention. That true statement was silently over-read as "plan lessons
132
+ # cannot be delivered at all," so the trigger was left off every event. But UserPromptSubmit IS the
133
+ # work-choice moment: a request arrives and the very next act is deciding how to attack it. The
134
+ # lesson cannot BLOCK there — it stays advisory, exactly as its enforcement says — but advisory
135
+ # delivered beats enforcing never. "Cannot be a gate" and "cannot be heard" are different claims,
136
+ # and conflating them cost four repetitions of the same correction.
137
+ #
138
+ # This is the same defect the header table already recorded for `report-status` ("enforcing? yes /
139
+ # wired to a hook that runs? no ← this is why the model still stopped"). Fixed there, missed here.
140
+ # One bug, found once, fixed once, left everywhere else.
141
+ # `relay-number` ADDED 2026-07-24 — the SECOND inert lesson found the same day, and this one is
142
+ # about the failure it was itself unable to prevent.
143
+ #
144
+ # L04-never-relay-a-number ("never repeat a score, benchmark, or subagent result without re-checking
145
+ # the underlying artifact yourself") is RATIFIED and taught 5 times, and no event has ever requested
146
+ # its trigger — so it had never fired, in any session, ever. Found by the new lesson-trigger audit in
147
+ # wired-check.mjs, which exists because L16 had exactly this defect hours earlier. The audit's first
148
+ # real run immediately found a second instance: evidence the class was systemic, not a one-off.
149
+ #
150
+ # The irony is the useful part, not the joke: on the day this was found, three subagents reported
151
+ # results and TWO of them contained a confident claim the artifact contradicted (a "1,266 = 14"
152
+ # equality that a single query disproves, and an upgrade-notice fix reported missing because the grep
153
+ # demanded `function` on a `const` arrow). The lesson that would have flagged both was in force,
154
+ # weighted, and mute.
155
+ #
156
+ # UserPromptSubmit is the right event: relaying happens while composing a reply, and this is the last
157
+ # observable moment before that. It cannot BLOCK there (surface `text`), which matches its
158
+ # `checklist` enforcement exactly — advisory delivered beats enforcing never.
159
+ UserPromptSubmit) TRIGGERS="assert-fact recommend-architecture report-status claim-done choose-work relay-number"; CLAUDE_EVENT="UserPromptSubmit" ;;
160
+ *) exit 0 ;;
161
+ esac
162
+
163
+ # ONE invocation carrying every trigger for this event — not one per trigger. Two reasons, both hard:
164
+ # a nudge must be a SINGLE JSON document (two concatenated objects on stdout parse as neither), and
165
+ # this runs on every matching event, so one node spawn instead of two is latency the user feels.
166
+ ARGS=()
167
+ for t in $TRIGGERS; do ARGS+=(--trigger "$t"); done
168
+
169
+ # Session identity for the gate's per-session frequency cap (scripts/lesson-gate.mjs) — extracted from
170
+ # the same payload via the shared parser, never a hand-rolled regex. A missing/empty id just means the
171
+ # gate falls back to a cwd+day key, so the cap stays bounded either way; it is never a hard requirement.
172
+ if [ -f "$HOOK_INPUT_JS" ]; then
173
+ SID=$(printf '%s' "$INPUT" | node "$HOOK_INPUT_JS" field session_id 2>/dev/null) || SID=""
174
+ [ -n "$SID" ] && ARGS+=(--session "$SID")
175
+ fi
176
+
177
+ # THE FIX for the mutate-machine false-positive nag: `mutate-machine` fired on EVERY Bash call — `ls`,
178
+ # `grep`, `git status` included — because this dispatcher requested the trigger unconditionally, with
179
+ # no inspection of the command at all (there was nothing here to inspect it WITH). The gate now takes
180
+ # `--command <text>` and narrows `mutate-machine` to commands that plausibly mutate something OUTSIDE
181
+ # this repo (scripts/lesson-gate.mjs, looksLikeOutsideRepoMutation) — so extract the real command text
182
+ # for exactly the one event that carries it, and hand it to the gate to decide.
183
+ if [ "$EVENT" = "PreToolUse-bash" ] && [ -f "$HOOK_INPUT_JS" ]; then
184
+ CMD=$(printf '%s' "$INPUT" | node "$HOOK_INPUT_JS" command 2>/dev/null) || CMD=""
185
+ ARGS+=(--command "$CMD")
186
+ fi
187
+
188
+ # A hard timeout, because this runs on every matching event and must never add perceptible latency.
189
+ # BUT `timeout` is GNU coreutils and STOCK macOS DOES NOT SHIP IT — on this dev machine it exists
190
+ # only because Homebrew put it there, which is exactly how a bug like this hides. Unguarded,
191
+ # `timeout 5 node ...` on a clean Mac exits 127 (command not found), so the gate would never run at
192
+ # all: no nudge, no block, and — worst of all — silence indistinguishable from "no lessons applied".
193
+ # So the timeout is used when present and skipped when not. A missing convenience must degrade the
194
+ # latency guarantee, never the gate. (Same lesson as the CI runner that went red because a test
195
+ # assumed macOS-only paths.)
196
+ TIMEOUT=""
197
+ command -v timeout >/dev/null 2>&1 && TIMEOUT="timeout 5"
198
+ [ -z "$TIMEOUT" ] && command -v gtimeout >/dev/null 2>&1 && TIMEOUT="gtimeout 5"
199
+
200
+ # Streams pass straight through, UNREDIRECTED and UNCAPTURED. This is deliberate and it is the fix:
201
+ # • stdout carries the nudge JSON — capturing it into a shell variable and re-echoing it risks
202
+ # mangling (trailing-newline stripping, glob expansion) the harness's parse depends on.
203
+ # • stderr carries a block's reason and must arrive as stderr, because exit 2 ignores stdout.
204
+ # The old `2>/dev/null` discarded precisely the stream a refusal needs.
205
+ $TIMEOUT node "$GATE" --event "$CLAUDE_EVENT" "${ARGS[@]}"
206
+ CODE=$?
207
+
208
+ # THE PROPAGATION. Exit 2 is the gate's considered decision that the user asked to be refused here —
209
+ # it is the only code that means anything to the harness, and it is passed through untouched.
210
+ # Everything else — 0, a crash, a 124 from timeout, node dying — allows the action. An error must
211
+ # never masquerade as a refusal, and a refusal must never be downgraded to an error.
212
+ [ "$CODE" -eq 2 ] && exit 2
213
+ exit 0