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.
- package/.claude-plugin/marketplace.json +14 -0
- package/README.md +5 -5
- package/bin/install.mjs +382 -36
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/kb/zip-extract.mjs +53 -14
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +14 -22
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +33 -0
- package/plugin/commands/rvbc.md +79 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +202 -0
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +56 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +182 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +517 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +173 -0
- package/plugin/scripts/learn-flush.mjs +155 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +13 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +22 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +23 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
|
@@ -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
|