ruvnet-brain 4.0.1 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -0
- package/README.md +4 -4
- package/bin/install.mjs +303 -24
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +8 -22
- package/plugin/.claude-plugin/marketplace.json +1 -0
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +2 -2
- package/plugin/commands/configure.md +3 -2
- package/plugin/commands/rvbc.md +4 -3
- package/plugin/commands/rvcb.md +2 -2
- package/plugin/commands/whats-new.md +6 -6
- package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
- package/plugin/hooks/hooks.json +1 -2
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +90 -32
- package/plugin/scripts/detach.mjs +14 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -6
- package/plugin/scripts/hook-shim.mjs +34 -29
- package/plugin/scripts/learn-capture.sh +22 -3
- package/plugin/scripts/learn-flush.mjs +21 -4
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +503 -0
- package/plugin/scripts/session-start.sh +3 -858
- package/plugin/scripts/whats-new.mjs +42 -0
- package/plugin/skills/brain-console/SKILL.md +4 -2
- package/plugin/skills/release-proof/SKILL.md +98 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +44 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +286 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
- package/plugin/skills/rvbc/SKILL.md +9 -6
- package/plugin/skills/whats-new/SKILL.md +4 -4
- 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 +531 -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/fix-workstream.mjs +291 -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 +237 -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 +558 -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 +326 -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 +172 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2922 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/protected-release-invocation.mjs +76 -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/publication-receipt.mjs +307 -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-authority.mjs +93 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +439 -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 +296 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stabilization-receipt.mjs +108 -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 +867 -0
- package/plugin/scripts/finalize-token-meter.mjs +0 -25
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lesson-gate.mjs — the wire that makes a stored lesson actually change behaviour.
|
|
4
|
+
*
|
|
5
|
+
* THIS IS THE L3 STEP. ADR-029 mines which lessons are universal; ADR-030 says a lesson must
|
|
6
|
+
* INTERRUPT at a decision point or it is prose. Both shipped. And nothing read the store: a grep for
|
|
7
|
+
* `lessonsFor` across every gate returned zero. Lessons were written, schema-validated, weighted,
|
|
8
|
+
* trust-boundaried — and consumed by nobody.
|
|
9
|
+
*
|
|
10
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
* WHAT CHANGED, 2026-07-22, and it is two separate corrections that happen to point the same way.
|
|
12
|
+
*
|
|
13
|
+
* CORRECTION 1 — THE BLOCK NEVER BLOCKED. Two independent reviewers found it; running it confirmed
|
|
14
|
+
* it. The previous version printed the word "BLOCKED" and then allowed the action, in three
|
|
15
|
+
* compounding ways:
|
|
16
|
+
*
|
|
17
|
+
* scripts/lesson-gate.mjs:86 exited 1, not 2. Exit 1 is a NON-BLOCKING error: the live hooks
|
|
18
|
+
* doc says other non-zero codes show a "hook error" notice to the
|
|
19
|
+
* USER and "execution continues". Only exit 2 refuses anything.
|
|
20
|
+
* scripts/lesson-gate.mjs 15 console.log, 0 console.error. On exit 2 the doc is explicit:
|
|
21
|
+
* "Claude Code ignores stdout... stderr text is fed back to
|
|
22
|
+
* Claude". The refusal reason went to the one stream a refusal
|
|
23
|
+
* cannot use.
|
|
24
|
+
* plugin/scripts/lesson-hooks.sh `|| true` then exit 0 — discarding whatever code did survive.
|
|
25
|
+
*
|
|
26
|
+
* Measured before the fix: `bash plugin/scripts/lesson-hooks.sh Stop` → printed "⛔ BLOCKED", exit 0.
|
|
27
|
+
*
|
|
28
|
+
* ADR-028 claimed "five gates exit 1 and refuse the action — proven by exit code". That proof was
|
|
29
|
+
* obtained by running this file BY HAND on a terminal, which is the one caller that is not a hook.
|
|
30
|
+
* The exit code was real; the claim that it blocked anything was not. This is L01 — verify through a
|
|
31
|
+
* channel CAPABLE of observing the change — violated by the very file that enforces L01.
|
|
32
|
+
*
|
|
33
|
+
* CORRECTION 2 — AND WE DO NOT WANT IT TO BLOCK. The owner, the same day:
|
|
34
|
+
*
|
|
35
|
+
* "Nudging somebody is very fair. Forcing them through a gate is not."
|
|
36
|
+
* "That respect for the individual and how they do it is a big part of the win."
|
|
37
|
+
*
|
|
38
|
+
* So the fix is NOT to turn six silent blocks into six real ones. That would ship, for the first
|
|
39
|
+
* time, the product the owner has just rejected — and it would land on existing users as a machine
|
|
40
|
+
* that suddenly started refusing work it accepted yesterday. Every ratified `block` lesson is now a
|
|
41
|
+
* NUDGE. Blocking is a per-lesson decision the USER makes, in a file only the user writes.
|
|
42
|
+
*
|
|
43
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
* THE CONTRACT, verified against code.claude.com/docs/en/hooks on 2026-07-22 rather than recalled:
|
|
45
|
+
*
|
|
46
|
+
* NUDGE → exit 0 + JSON `hookSpecificOutput.additionalContext` on stdout. Informs, never refuses.
|
|
47
|
+
* BLOCK → exit 2 + reason on stderr. Refuses. Opt-in per lesson, by the user, only.
|
|
48
|
+
*
|
|
49
|
+
* The nudge channel is NOT stderr, and this is the subtle part that a plausible-sounding design got
|
|
50
|
+
* wrong twice. On exit 0 the doc says stdout "is written to the debug log but not shown in the
|
|
51
|
+
* transcript" for most events, with only UserPromptSubmit / UserPromptExpansion / SessionStart as
|
|
52
|
+
* exceptions — and it says nothing about exit-0 stderr at all, because exit-0 stderr is not a
|
|
53
|
+
* delivery channel. A nudge written to stderr at Stop or PreToolUse reaches NOBODY: it would have
|
|
54
|
+
* been the identical built-tested-unwired defect, rebuilt one file to the left.
|
|
55
|
+
*
|
|
56
|
+
* What actually works, quoted from the live doc:
|
|
57
|
+
*
|
|
58
|
+
* "The `additionalContext` field passes a string from your hook into Claude's context window.
|
|
59
|
+
* Claude Code wraps the string in a system reminder and inserts it into the conversation at the
|
|
60
|
+
* point where the hook fired."
|
|
61
|
+
*
|
|
62
|
+
* and it is supported at every event this gate fires on — PreToolUse, Stop, UserPromptSubmit
|
|
63
|
+
* included. (An adversarial review asserted a non-blocking nudge at Stop was IMPOSSIBLE because Stop
|
|
64
|
+
* accepts only `decision: "block"`. The live doc contradicts it: "Stop and SubagentStop also accept
|
|
65
|
+
* hookSpecificOutput.additionalContext for non-error feedback that continues the conversation." The
|
|
66
|
+
* reviewer was reasoning from an older contract. Checked, not assumed — which is the whole rule.)
|
|
67
|
+
*
|
|
68
|
+
* That gives a nudge everything the block was supposed to have and the one thing it should not:
|
|
69
|
+
* it reaches the model, at the decision point, carrying the user's own words — and it refuses nothing.
|
|
70
|
+
*
|
|
71
|
+
* ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
* DESIGN CONSTRAINT, unchanged and load-bearing: a gate must never break the thing it guards. Any
|
|
73
|
+
* failure here — missing store, corrupt JSON, unreadable file — exits 0 silently. A lesson gate that
|
|
74
|
+
* blocked a push because it could not read a config file would be worse than no lesson gate, and
|
|
75
|
+
* would be switched off within a day, which is how every over-eager gate dies.
|
|
76
|
+
*/
|
|
77
|
+
import fs from 'node:fs';
|
|
78
|
+
import os from 'node:os';
|
|
79
|
+
import path from 'node:path';
|
|
80
|
+
import { loadLessons, lessonsFor, ENFORCEMENT, STATUS, ORIGIN, TRIGGERS } from './lesson-store.mjs';
|
|
81
|
+
|
|
82
|
+
// The two codes that mean something to the harness. Anything else is an error, and an error here
|
|
83
|
+
// must never be mistaken for a refusal — see ALLOW-on-failure throughout.
|
|
84
|
+
const EXIT_ALLOW = 0;
|
|
85
|
+
const EXIT_BLOCK = 2;
|
|
86
|
+
|
|
87
|
+
const argv = process.argv.slice(2);
|
|
88
|
+
const arg = (f, d = null) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
89
|
+
// --trigger is REPEATABLE. One real event carries several decision points at once (ending a turn is
|
|
90
|
+
// simultaneously "reporting status" and "claiming done"), and they must resolve to ONE verdict and
|
|
91
|
+
// ONE JSON object — two JSON documents on stdout is not JSON, and two node spawns on every event is
|
|
92
|
+
// latency on the hot path for no gain.
|
|
93
|
+
const allArgs = (f) => argv.reduce((acc, v, i) => (v === f && argv[i + 1] ? [...acc, argv[i + 1]] : acc), []);
|
|
94
|
+
|
|
95
|
+
const triggers = allArgs('--trigger');
|
|
96
|
+
const event = arg('--event'); // the real Claude Code event name → hook mode
|
|
97
|
+
const quiet = argv.includes('--quiet');
|
|
98
|
+
const json = argv.includes('--json');
|
|
99
|
+
// Session identity for the per-session frequency cap (below). The dispatcher passes the harness's
|
|
100
|
+
// real session_id; a caller that predates this (or a manual run) gets a cwd+day fallback so the cap
|
|
101
|
+
// is still BOUNDED — at worst it repeats an advisory once per project per day — rather than unbounded.
|
|
102
|
+
const session = arg('--session');
|
|
103
|
+
// NOT `arg('--command')`: that helper treats a falsy VALUE the same as an ABSENT flag
|
|
104
|
+
// (`argv[i+1] ? ... : d`), so a real event whose command happens to be "" would silently fall back to
|
|
105
|
+
// the unfiltered default — precisely the false-nag path this fix exists to close. Presence of the flag,
|
|
106
|
+
// not truthiness of its value, is what distinguishes "an old caller that never learned about this" from
|
|
107
|
+
// "the dispatcher, telling us the command text (however short)".
|
|
108
|
+
const commandIdx = argv.indexOf('--command');
|
|
109
|
+
const command = commandIdx >= 0 ? (argv[commandIdx + 1] ?? '') : null;
|
|
110
|
+
|
|
111
|
+
// CANDIDATE MODE (ADR-040 / DDD-0004 "the enforcement chokepoint"). Set by unprompted-runtime.mjs on
|
|
112
|
+
// every producer child. When on, hook mode writes ZERO user-facing bytes and NEVER exits 2 itself:
|
|
113
|
+
// it emits ONE JSON candidate per line on stdout and lets the runtime — the SOLE writer of user bytes
|
|
114
|
+
// — turn a `block` candidate into the real exit 2 + stderr and an `advisory` candidate into the
|
|
115
|
+
// nudge. Unset (every direct/legacy/CLI invocation, and every existing test), behaviour is byte-for-
|
|
116
|
+
// byte unchanged, exit-2 block semantics included. Purely additive.
|
|
117
|
+
const EMIT_CANDIDATES = process.env.RUVNET_EMIT_CANDIDATES === '1';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* THE MUTATE-MACHINE PREDICATE — narrows "about to change something outside this repo" to commands
|
|
121
|
+
* that plausibly do that, instead of firing on every Bash call.
|
|
122
|
+
*
|
|
123
|
+
* THE BUG, found by an independent grader reading real session transcripts (2026-07-22/23): the
|
|
124
|
+
* dispatcher maps `PreToolUse-bash` → `--trigger mutate-machine` UNCONDITIONALLY —
|
|
125
|
+
* plugin/scripts/lesson-hooks.sh:98 — with no inspection of the command at all. Not a weak keyword
|
|
126
|
+
* match: NO match. Every `ls`, `grep`, `wc`, `git status`, `git rev-parse` fired the identical L07
|
|
127
|
+
* advisory, ~10x/session verbatim. A true finding repeated on false triggers is exactly the nagging
|
|
128
|
+
* ADR-030's own P3 (nudge, never force) exists to prevent — a correct lesson trains itself to be
|
|
129
|
+
* ignored by firing when it has nothing to say.
|
|
130
|
+
*
|
|
131
|
+
* THE FIX is an ALLOWLIST OF MUTATING PATTERNS, not a read-only allowlist — chosen because the
|
|
132
|
+
* trigger's own label is "about to change something", so the honest default for an unrecognized
|
|
133
|
+
* command is SILENCE, not suspicion. Under-firing on some obscure mutating command is the safe
|
|
134
|
+
* failure mode for an advisory nudge; over-firing is the bug this whole fix exists to close.
|
|
135
|
+
*
|
|
136
|
+
* Every pattern is anchored to COMMAND POSITION (the leading word of a shell segment), never a bare
|
|
137
|
+
* substring search — the same discipline verify-interface.sh already uses and for the identical
|
|
138
|
+
* reason: an unanchored match fires on `grep -r "npm install -g" .` or `echo "curl -X POST"`, which
|
|
139
|
+
* would reintroduce the exact false-positive nagging this fix removes, just spelled differently.
|
|
140
|
+
*
|
|
141
|
+
* Known, accepted limitation: command substitution (`$(rm -rf ~/x)`) and path traversal (`../../etc`)
|
|
142
|
+
* are not resolved — this is a heuristic for an ADVISORY nudge, not a security boundary. It is
|
|
143
|
+
* layered on top of the existing consent/ratification trust boundary (ORIGIN/STATUS/opt-in above),
|
|
144
|
+
* which is where the real security property already lives.
|
|
145
|
+
*/
|
|
146
|
+
const REPO_ROOT = (() => {
|
|
147
|
+
let d = process.cwd();
|
|
148
|
+
for (let i = 0; i < 12; i++) {
|
|
149
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
150
|
+
const up = path.dirname(d);
|
|
151
|
+
if (up === d) break;
|
|
152
|
+
d = up;
|
|
153
|
+
}
|
|
154
|
+
return process.cwd();
|
|
155
|
+
})();
|
|
156
|
+
|
|
157
|
+
/** Split on top-level `;`, `&&`, `||`, `&`, `|`, newline — NOT inside single/double quotes. One
|
|
158
|
+
* compound command ("cmd1 && rm -rf ~/x") must be judged by its most dangerous segment, not its first. */
|
|
159
|
+
function splitTopLevelSegments(cmd) {
|
|
160
|
+
const segments = [];
|
|
161
|
+
let cur = '';
|
|
162
|
+
let quote = null;
|
|
163
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
164
|
+
const c = cmd[i];
|
|
165
|
+
if (quote) { cur += c; if (c === quote) quote = null; continue; }
|
|
166
|
+
if (c === '"' || c === "'") { quote = c; cur += c; continue; }
|
|
167
|
+
if ((c === '&' && cmd[i + 1] === '&') || (c === '|' && cmd[i + 1] === '|')) { segments.push(cur); cur = ''; i++; continue; }
|
|
168
|
+
if (c === ';' || c === '&' || c === '|' || c === '\n') { segments.push(cur); cur = ''; continue; }
|
|
169
|
+
cur += c;
|
|
170
|
+
}
|
|
171
|
+
if (cur.trim()) segments.push(cur);
|
|
172
|
+
return segments.map((s) => s.trim()).filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whitespace tokenizer that keeps a quoted argument ("a path with spaces") as ONE token. */
|
|
176
|
+
function tokenize(segment) {
|
|
177
|
+
const tokens = [];
|
|
178
|
+
let cur = '';
|
|
179
|
+
let quote = null;
|
|
180
|
+
for (let i = 0; i < segment.length; i++) {
|
|
181
|
+
const c = segment[i];
|
|
182
|
+
if (quote) { if (c === quote) quote = null; else cur += c; continue; }
|
|
183
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
184
|
+
if (/\s/.test(c)) { if (cur) { tokens.push(cur); cur = ''; } continue; }
|
|
185
|
+
cur += c;
|
|
186
|
+
}
|
|
187
|
+
if (cur) tokens.push(cur);
|
|
188
|
+
return tokens;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const SYSTEM_PATH_PREFIXES = ['/etc', '/usr', '/bin', '/sbin', '/System', '/Library', '/private', '/var', '/opt'];
|
|
192
|
+
/** A path argument that targets a system-wide location — "chmod 777 /etc/x", never "chmod +x ./run.sh". */
|
|
193
|
+
function isSystemAbsolutePath(tok) {
|
|
194
|
+
if (!tok || tok.startsWith('-')) return false;
|
|
195
|
+
return SYSTEM_PATH_PREFIXES.some((p) => tok === p || tok.startsWith(p + '/'));
|
|
196
|
+
}
|
|
197
|
+
/** A path argument that resolves OUTSIDE this repo — a bare `~` reference, or an absolute path that
|
|
198
|
+
* is not rooted under REPO_ROOT. A relative path ("./tmp", "build") is inside the repo by construction. */
|
|
199
|
+
function isOutsideRepoPath(tok) {
|
|
200
|
+
if (!tok || tok.startsWith('-')) return false;
|
|
201
|
+
if (tok.startsWith('~')) return true;
|
|
202
|
+
if (tok.startsWith('/')) return tok !== REPO_ROOT && !tok.startsWith(REPO_ROOT + path.sep);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
/** curl mutates when it names a non-GET verb or attaches a request body — "-X POST", "--data", etc. */
|
|
206
|
+
function curlMutates(tokens) {
|
|
207
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
208
|
+
const t = tokens[i];
|
|
209
|
+
if ((t === '-X' || t === '--request') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes((tokens[i + 1] || '').toUpperCase())) return true;
|
|
210
|
+
if (/^--request=/.test(t) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(t.split('=')[1].toUpperCase())) return true;
|
|
211
|
+
if (t === '-d' || t === '--data' || /^--data(-raw|-binary|-urlencode)?$/.test(t) || t === '--upload-file' || t === '-T') return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const INSTALL_VERBS = new Set(['install', 'i', 'add', 'uninstall', 'remove', 'rm', 'un']);
|
|
217
|
+
const GLOBAL_FLAGS = new Set(['-g', '--global']);
|
|
218
|
+
const SECURITY_MUTATING = new Set([
|
|
219
|
+
'create-keychain', 'delete-keychain', 'set-keychain-password', 'set-keychain-settings',
|
|
220
|
+
'unlock-keychain', 'lock-keychain', 'import', 'add-generic-password', 'add-internet-password',
|
|
221
|
+
'delete-generic-password', 'delete-internet-password', 'default-keychain',
|
|
222
|
+
]);
|
|
223
|
+
const BREW_MUTATING = new Set(['install', 'uninstall', 'remove', 'rm', 'upgrade', 'reinstall', 'tap', 'untap', 'link', 'unlink', 'pin', 'unpin', 'services']);
|
|
224
|
+
const PKG_MUTATING = new Set(['install', 'remove', 'purge', 'upgrade']);
|
|
225
|
+
const FS_MUTATING_VERBS = new Set(['rm', 'mv', 'cp', 'ln', 'shred', 'truncate', 'unlink']);
|
|
226
|
+
|
|
227
|
+
/** One shell segment → does its LEADING command plausibly mutate something outside this repo? */
|
|
228
|
+
function classifySegment(segment) {
|
|
229
|
+
let tokens = tokenize(segment);
|
|
230
|
+
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1); // FOO=bar cmd
|
|
231
|
+
if (!tokens.length) return false;
|
|
232
|
+
const lead = tokens[0];
|
|
233
|
+
if (lead === 'sudo') return true; // elevated privilege is outside-repo blast radius by definition
|
|
234
|
+
switch (lead) {
|
|
235
|
+
case 'launchctl': return true; // any subcommand — LaunchAgents/system services, never repo-scoped
|
|
236
|
+
case 'security': return SECURITY_MUTATING.has(tokens[1]);
|
|
237
|
+
case 'defaults': return tokens[1] === 'write' || tokens[1] === 'delete';
|
|
238
|
+
case 'npm': case 'pnpm': case 'yarn':
|
|
239
|
+
return INSTALL_VERBS.has(tokens[1]) && tokens.some((t) => GLOBAL_FLAGS.has(t));
|
|
240
|
+
case 'pip': case 'pip3': case 'pipx':
|
|
241
|
+
return tokens[1] === 'install' || tokens[1] === 'uninstall';
|
|
242
|
+
case 'brew': return BREW_MUTATING.has(tokens[1]);
|
|
243
|
+
case 'gem': return tokens[1] === 'install' || tokens[1] === 'uninstall';
|
|
244
|
+
case 'apt': case 'apt-get': case 'yum': case 'dnf': case 'pacman': case 'port':
|
|
245
|
+
return PKG_MUTATING.has(tokens[1]);
|
|
246
|
+
case 'git': return tokens[1] === 'push';
|
|
247
|
+
case 'curl': return curlMutates(tokens);
|
|
248
|
+
case 'wget': return tokens.some((t) => t.startsWith('--post-data') || t.startsWith('--post-file'));
|
|
249
|
+
case 'chmod': case 'chown': case 'chgrp':
|
|
250
|
+
return tokens.slice(1).some(isSystemAbsolutePath);
|
|
251
|
+
case 'dd': case 'mkfs': case 'diskutil': return true;
|
|
252
|
+
case 'crontab': return tokens[1] === '-e' || tokens[1] === '-r';
|
|
253
|
+
default:
|
|
254
|
+
return FS_MUTATING_VERBS.has(lead) && tokens.slice(1).some(isOutsideRepoPath);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Exported for the test suite: does this WHOLE command plausibly mutate something outside the repo? */
|
|
259
|
+
export function looksLikeOutsideRepoMutation(cmd) {
|
|
260
|
+
if (!cmd || typeof cmd !== 'string') return false;
|
|
261
|
+
return splitTopLevelSegments(cmd).some(classifySegment);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* THE CONSENT FILE — where "yes, actually refuse me" is recorded, and why it is not in the lesson store.
|
|
266
|
+
*
|
|
267
|
+
* Two reasons, and the first is a measured fact rather than a preference:
|
|
268
|
+
*
|
|
269
|
+
* 1. A per-lesson flag in the store WOULD NOT SURVIVE. `makeLesson()` destructures a fixed field set
|
|
270
|
+
* and returns a frozen object built from exactly those fields; any unknown key is silently
|
|
271
|
+
* dropped on load. Verified 2026-07-22 by construction — `makeLesson({...,userOptedIntoBlocking:
|
|
272
|
+
* true})` returns an object without it. A consent flag stored there would read back as absent on
|
|
273
|
+
* every load, i.e. consent that silently evaporates, which is the worst possible failure mode for
|
|
274
|
+
* a consent record.
|
|
275
|
+
*
|
|
276
|
+
* 2. It is the right trust boundary anyway. `lessons.json` is written by the MINING pipeline —
|
|
277
|
+
* unattended, over session transcripts, over content that may have been planted (ADR-031's whole
|
|
278
|
+
* subject). If "may block" lived in that file, anything that could get a lesson mined could grant
|
|
279
|
+
* itself the power to refuse the user's work. It lives in a file the pipeline never writes.
|
|
280
|
+
*
|
|
281
|
+
* Honest about what this is NOT: it is not a defence against a model that can run shell commands —
|
|
282
|
+
* such a model can write any file on the machine. It is a boundary against MINED and IMPORTED
|
|
283
|
+
* content reaching enforcement, which is the path the adversarial review actually found.
|
|
284
|
+
*
|
|
285
|
+
* The empty default is the point: no file, no blocking. Consent is never inferred from silence.
|
|
286
|
+
*/
|
|
287
|
+
export const OPTIN_PATH = process.env.RUVNET_LESSON_OPTIN
|
|
288
|
+
|| path.join(os.homedir(), '.config', 'ruvnet-brain', 'blocking-optin.json');
|
|
289
|
+
|
|
290
|
+
if (!triggers.length) {
|
|
291
|
+
console.log('lesson-gate — surface the lessons in force at a decision point\n');
|
|
292
|
+
console.log(' --trigger <key> one of: ' + Object.values(TRIGGERS).map((t) => t.key).join(', '));
|
|
293
|
+
console.log(' repeatable; one event may carry several decision points');
|
|
294
|
+
console.log(' --event <name> Claude Code event (Stop, PreToolUse, UserPromptSubmit) → hook mode:');
|
|
295
|
+
console.log(' nudges emit JSON additionalContext (exit 0), blocks emit stderr (exit 2)');
|
|
296
|
+
console.log(' --json machine-readable');
|
|
297
|
+
console.log(' --quiet print nothing; exit code only\n');
|
|
298
|
+
console.log(' blocking is OPT-IN per lesson: ' + OPTIN_PATH);
|
|
299
|
+
process.exit(EXIT_ALLOW);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function loadBlockingOptIn(file = OPTIN_PATH) {
|
|
303
|
+
try {
|
|
304
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
305
|
+
// Tolerant of both shapes because a human is expected to hand-edit this: a bare array reads
|
|
306
|
+
// fine, and so does the documented object. Being fussy about a consent file's punctuation would
|
|
307
|
+
// silently downgrade someone's explicit "yes" to a "no".
|
|
308
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.blocking) ? raw.blocking : [];
|
|
309
|
+
return new Set(list.filter((x) => typeof x === 'string' && x.length));
|
|
310
|
+
} catch { return new Set(); } // absent or unparseable → nobody blocks. Never fail INTO refusing.
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let lessons = [];
|
|
314
|
+
try { lessons = loadLessons(); } catch { process.exit(EXIT_ALLOW); } // never break the caller
|
|
315
|
+
|
|
316
|
+
const optedIn = loadBlockingOptIn();
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* THE PER-SESSION FREQUENCY CAP — because a true advisory repeated verbatim on every matching event is
|
|
320
|
+
* the nag ADR-030 bans (measured 2026-07-22: the mutate-machine advisory rendered on every Bash call of
|
|
321
|
+
* a session). anticipate.sh already caps its own nudges per session; this is the same discipline for the
|
|
322
|
+
* lesson gate. A PURE-ADVISORY lesson is shown at most MAX_SHOWS times per session, then stays silent
|
|
323
|
+
* until a new session.
|
|
324
|
+
*
|
|
325
|
+
* THE LOAD-BEARING INVARIANT: an actual REFUSAL is never capped. A lesson the user has opted into as a
|
|
326
|
+
* block (isBlocking, below) exits 2 and refuses the action — the cap must never touch it. But a merely
|
|
327
|
+
* block-CAPABLE lesson the user has NOT opted into renders as an ADVISORY (exit 0): it is a reminder, not
|
|
328
|
+
* a refusal, and it is capped like any other advisory. An earlier version exempted block-capable lessons
|
|
329
|
+
* too — which left exactly the lesson doing the nagging (the block-capable "gate on blast radius", never
|
|
330
|
+
* opted in) repeating unbounded on every mutating command; an independent regrade caught it. Capping a
|
|
331
|
+
* block-capable ADVISORY silences no refusal: a refusal exits 2 regardless of this file's display budget,
|
|
332
|
+
* and the one-time "you could turn this into a refusal" offer needs to be seen a few times, not forever.
|
|
333
|
+
*
|
|
334
|
+
* FAIL-OPEN: any error reading or writing the state degrades to the pre-cap behaviour (show it), never to
|
|
335
|
+
* suppression — a gate that goes quiet because it could not read a JSON file is worse than a repeat.
|
|
336
|
+
*/
|
|
337
|
+
const GATE_STATE_PATH = process.env.RUVNET_LESSON_GATE_STATE
|
|
338
|
+
|| path.join(os.homedir(), '.config', 'ruvnet-brain', 'lesson-gate-state.json');
|
|
339
|
+
const MAX_SHOWS = (() => {
|
|
340
|
+
const n = Number(process.env.RUVNET_LESSON_MAX_SHOWS);
|
|
341
|
+
return Number.isInteger(n) && n > 0 ? n : 3;
|
|
342
|
+
})();
|
|
343
|
+
const KEEP_SESSIONS = 20; // bound the state file to the most-recent sessions, same as anticipate.sh
|
|
344
|
+
const SID = (typeof session === 'string' && session.trim())
|
|
345
|
+
? session.trim()
|
|
346
|
+
: `fallback:${process.cwd()}:${new Date().toISOString().slice(0, 10)}`;
|
|
347
|
+
/**
|
|
348
|
+
* BLOCKING = four conditions, all required; the user's opt-in is necessary and NOT sufficient. Defined
|
|
349
|
+
* here (rather than at the emit site) because the frequency cap below must key its exemption on it: the
|
|
350
|
+
* last two conditions are also guaranteed by makeLesson, and are re-asserted deliberately — this is the
|
|
351
|
+
* one place the answer is "refuse the human's work", and a security invariant enforced only at a distance
|
|
352
|
+
* is one refactor from being enforced nowhere.
|
|
353
|
+
*/
|
|
354
|
+
const isBlocking = (l) => optedIn.has(l.id)
|
|
355
|
+
&& l.enforcement === ENFORCEMENT.BLOCK
|
|
356
|
+
&& (l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE)
|
|
357
|
+
&& l.origin === ORIGIN.USER_STATED;
|
|
358
|
+
/** Exempt from the cap: ONLY a lesson that refuses RIGHT NOW (an opted-in block, exit 2). A block-capable
|
|
359
|
+
* lesson that is not opted in is an advisory and is capped like any other — see the invariant above. */
|
|
360
|
+
const capExempt = isBlocking;
|
|
361
|
+
function readGateState() {
|
|
362
|
+
try { const s = JSON.parse(fs.readFileSync(GATE_STATE_PATH, 'utf8')); return s && typeof s === 'object' ? s : {}; }
|
|
363
|
+
catch { return {}; }
|
|
364
|
+
}
|
|
365
|
+
function writeGateState(st) {
|
|
366
|
+
try {
|
|
367
|
+
fs.mkdirSync(path.dirname(GATE_STATE_PATH), { recursive: true });
|
|
368
|
+
fs.writeFileSync(GATE_STATE_PATH, JSON.stringify(st));
|
|
369
|
+
return true;
|
|
370
|
+
} catch { return false; }
|
|
371
|
+
}
|
|
372
|
+
/** How many times THIS session has already surfaced a given advisory lesson (0 if never). */
|
|
373
|
+
function shownCount(st, id) {
|
|
374
|
+
const c = st?.sessions?.[SID]?.shown?.[id];
|
|
375
|
+
return Number.isInteger(c) && c > 0 ? c : 0;
|
|
376
|
+
}
|
|
377
|
+
// Read ONCE, up front — the same snapshot gates the filter and seeds the write below.
|
|
378
|
+
const gateState = event ? readGateState() : {};
|
|
379
|
+
|
|
380
|
+
// Merge every requested decision point into one ranked, de-duplicated list. A lesson registered at
|
|
381
|
+
// two triggers must appear once, or the model reads the same correction twice and learns to skim.
|
|
382
|
+
/**
|
|
383
|
+
* PROJECT SCOPE — a lesson learned in one project has no standing to interrupt work in another.
|
|
384
|
+
*
|
|
385
|
+
* THE BREAKAGE, 2026-07-22: these hooks were installed machine-wide and 8 of 16 lessons were scoped
|
|
386
|
+
* to a SINGLE project, yet fired everywhere. A WhitSentry session was being told about
|
|
387
|
+
* ruvnet-brain's stop-and-report habit on every prompt. The owner's report was blunt: "I've got
|
|
388
|
+
* other repos that are using this thing, and they're breaking."
|
|
389
|
+
*
|
|
390
|
+
* The rule is ADR-029's own promotion bar applied at read time: cross-project rediscovery is what
|
|
391
|
+
* makes a lesson universal. Taught in ONE project, it is local knowledge — real, worth keeping, and
|
|
392
|
+
* not entitled to speak elsewhere. Taught in two or more, it has earned the right to travel.
|
|
393
|
+
*
|
|
394
|
+
* This is P3 (nudge, never force) and P4 (the user is the arbiter) applied to OUR OWN footprint:
|
|
395
|
+
* the fastest way to make someone uninstall a nudge is to nudge them about something that has
|
|
396
|
+
* nothing to do with what they are doing.
|
|
397
|
+
*/
|
|
398
|
+
const HERE = (() => {
|
|
399
|
+
let d = process.cwd();
|
|
400
|
+
for (let i = 0; i < 12; i++) {
|
|
401
|
+
if (fs.existsSync(path.join(d, '.git'))) break;
|
|
402
|
+
const up = path.dirname(d);
|
|
403
|
+
if (up === d) { d = process.cwd(); break; }
|
|
404
|
+
d = up;
|
|
405
|
+
}
|
|
406
|
+
return path.basename(d);
|
|
407
|
+
})();
|
|
408
|
+
/** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
|
|
409
|
+
* names carry prefixes like `Code-` that the directory name does not. */
|
|
410
|
+
const isHome = (l) => {
|
|
411
|
+
const ps = Array.isArray(l.projects) ? l.projects : [];
|
|
412
|
+
if (!ps.length) return true; // unscoped: applies anywhere, by declaration
|
|
413
|
+
return ps.some((p) => {
|
|
414
|
+
const n = String(p).replace(/^Code-/, '');
|
|
415
|
+
return n === HERE || String(p) === HERE || HERE.endsWith(n) || n.endsWith(HERE);
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
|
|
419
|
+
|
|
420
|
+
// Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
|
|
421
|
+
// call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
|
|
422
|
+
// on a tool, so it is the only one that needs narrowing here. When no `--command` was supplied (a bare
|
|
423
|
+
// CLI invocation, or a caller that predates this fix), behavior is UNCHANGED — fail open to the old,
|
|
424
|
+
// unfiltered behavior rather than silently swallow a trigger nobody asked to have filtered.
|
|
425
|
+
const MUTATE_KEY = TRIGGERS.MUTATE_MACHINE.key;
|
|
426
|
+
const effectiveTriggers = command === null
|
|
427
|
+
? triggers
|
|
428
|
+
: triggers.filter((t) => t !== MUTATE_KEY || looksLikeOutsideRepoMutation(command));
|
|
429
|
+
|
|
430
|
+
const seen = new Set();
|
|
431
|
+
const candidates = [];
|
|
432
|
+
for (const t of effectiveTriggers) {
|
|
433
|
+
for (const l of lessonsFor(t, lessons, { limit: 3 })) {
|
|
434
|
+
if (seen.has(l.id)) continue;
|
|
435
|
+
// Away from home, only a lesson with cross-project evidence may speak.
|
|
436
|
+
if (!isHome(l) && !isUniversal(l)) continue;
|
|
437
|
+
seen.add(l.id); candidates.push(l);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* THE CHARACTER BUDGET — because an event can now carry four decision points.
|
|
443
|
+
*
|
|
444
|
+
* Measured 2026-07-22: UserPromptSubmit rendered 6 lessons / 3,757 characters, injected on EVERY
|
|
445
|
+
* prompt. That is not a reminder, it is a wall, and a wall gets skimmed and then switched off —
|
|
446
|
+
* which costs the user every lesson at once, including the ones that were working.
|
|
447
|
+
*
|
|
448
|
+
* Budget the COST, not the COUNT: a lesson is ~300 chars, so "three lessons" and "900 characters"
|
|
449
|
+
* are the same rule until an unusually long lesson arrives, and then only the character rule holds.
|
|
450
|
+
*
|
|
451
|
+
* Ranked by repeatCount — how many times the user has actually had to say it — so the correction
|
|
452
|
+
* they are most tired of repeating is the one that always survives the trim.
|
|
453
|
+
*
|
|
454
|
+
* AND THE TRIM IS ANNOUNCED. A silent truncation reads as "that is all there is", which is the same
|
|
455
|
+
* lie as a silent cap one layer up. If something was dropped, the model is told how many and where
|
|
456
|
+
* to read the rest.
|
|
457
|
+
*/
|
|
458
|
+
const NUDGE_CHAR_BUDGET = Number(process.env.RUVNET_NUDGE_BUDGET) || 1200;
|
|
459
|
+
// PER-SESSION FREQUENCY CAP (hook mode only — a human running the CLI asked to see everything). Drop a
|
|
460
|
+
// PURE-ADVISORY lesson already shown MAX_SHOWS times this session; a block-capable lesson passes untouched
|
|
461
|
+
// (capExempt), so a refusal the user opted into is never silenced to reduce noise.
|
|
462
|
+
const capped = event
|
|
463
|
+
? candidates.filter((l) => capExempt(l) || shownCount(gateState, l.id) < MAX_SHOWS)
|
|
464
|
+
: candidates;
|
|
465
|
+
const ranked = [...capped].sort((a, b) => (b.repeatCount || 0) - (a.repeatCount || 0));
|
|
466
|
+
|
|
467
|
+
/* ONE VOICE PER DECISION POINT, BEFORE ANY SECOND VOICE.
|
|
468
|
+
*
|
|
469
|
+
* Ranking by repeatCount alone has a failure mode that hid a lesson for its entire life. An event can
|
|
470
|
+
* carry several triggers at once — UserPromptSubmit now fires five — and the character budget is
|
|
471
|
+
* spent strictly in repeat-count order. So the lessons attached to ONE trigger, if they happen to be
|
|
472
|
+
* the most-repeated, consume the whole budget and every OTHER decision point that genuinely fired
|
|
473
|
+
* goes silent.
|
|
474
|
+
*
|
|
475
|
+
* Measured 2026-07-24: L16-parallel-by-default (trigger `choose-work`, taught 4x, weight ~0.5) was
|
|
476
|
+
* competing against L14-architecture-recipe (36x, 4.13) and L02-check-before-you-assert (28x, 4.50).
|
|
477
|
+
* It could never win a slot — not because it was irrelevant to the moment, but because a DIFFERENT
|
|
478
|
+
* aspect of the same moment had louder lessons. The owner had to supply that correction by hand a
|
|
479
|
+
* fourth time, and the honest diagnosis was: the lesson was in force and structurally unseeable.
|
|
480
|
+
*
|
|
481
|
+
* repeatCount measures HOW OFTEN A LESSON HAS BEEN NEEDED. It does not measure how relevant it is to
|
|
482
|
+
* the decision in front of us, and treating it as a global priority silently converts "taught most
|
|
483
|
+
* often overall" into "the only thing you may be told right now."
|
|
484
|
+
*
|
|
485
|
+
* So: seed the set with the single highest-ranked lesson per DISTINCT TRIGGER that fired, then spend
|
|
486
|
+
* whatever budget remains by rank as before. Each decision point that fired gets to say one thing;
|
|
487
|
+
* the loudest lessons still fill the rest. The first-lesson overrun allowance is preserved.
|
|
488
|
+
*/
|
|
489
|
+
const seeded = [];
|
|
490
|
+
const seenTriggers = new Set();
|
|
491
|
+
for (const l of ranked) {
|
|
492
|
+
if (seenTriggers.has(l.trigger)) continue;
|
|
493
|
+
seenTriggers.add(l.trigger);
|
|
494
|
+
seeded.push(l);
|
|
495
|
+
}
|
|
496
|
+
const order = [...seeded, ...ranked.filter((l) => !seeded.includes(l))];
|
|
497
|
+
|
|
498
|
+
const inForce = [];
|
|
499
|
+
let spent = 0;
|
|
500
|
+
for (const l of order) {
|
|
501
|
+
const cost = renderLesson(l, '·').length;
|
|
502
|
+
// Always admit the first lesson even if it alone exceeds the budget — a budget that can render
|
|
503
|
+
// nothing is worse than a budget that overruns once.
|
|
504
|
+
if (inForce.length && spent + cost > NUDGE_CHAR_BUDGET) continue;
|
|
505
|
+
inForce.push(l); spent += cost;
|
|
506
|
+
}
|
|
507
|
+
const trimmed = capped.length - inForce.length;
|
|
508
|
+
|
|
509
|
+
/* EVERY DECISION POINT THAT FIRED GETS AT LEAST ONE LINE — compactly, if that is all that fits.
|
|
510
|
+
*
|
|
511
|
+
* Seeding one lesson per trigger (above) fixed the ORDER but not the outcome: a full render carries
|
|
512
|
+
* statement + evidence + repeat-count, ~400-600 chars, so the 1200-char budget is spent after TWO of
|
|
513
|
+
* them. Five triggers fire at UserPromptSubmit; three decision points still said nothing. Measured:
|
|
514
|
+
* L16 was seeded first for `choose-work` and still never reached the page.
|
|
515
|
+
*
|
|
516
|
+
* The budget exists to stop flooding, and that is right. But "do not flood" and "stay silent about
|
|
517
|
+
* three of the five things that just became relevant" are different policies, and the character cap
|
|
518
|
+
* was quietly enforcing the second. The repo's own standing rule on this is explicit: budget the
|
|
519
|
+
* COST, not the count, and a cap that trips is a signal to grow the container — never to drop the
|
|
520
|
+
* knowledge.
|
|
521
|
+
*
|
|
522
|
+
* So: any trigger left unrepresented after the budget is spent gets a ONE-LINE compact entry —
|
|
523
|
+
* statement only, clipped, no evidence, no counts. A clipped sentence the model actually reads beats
|
|
524
|
+
* a perfectly-formatted one it never sees. Full renders still go to the highest-ranked lessons.
|
|
525
|
+
*/
|
|
526
|
+
const representedTriggers = new Set(inForce.map((l) => l.trigger));
|
|
527
|
+
const compactExtras = seeded.filter((l) => !representedTriggers.has(l.trigger));
|
|
528
|
+
|
|
529
|
+
// isBlocking is defined above (the frequency cap keys its exemption on it). BLOCKING = four ANDed
|
|
530
|
+
// conditions; the user's opt-in is necessary and NOT sufficient, and the security invariant lives there.
|
|
531
|
+
const blocking = inForce.filter(isBlocking);
|
|
532
|
+
// Lessons that COULD block if the user asked them to. Shown, because the entire product claim is
|
|
533
|
+
// that the user can see what is available and choose — not discover enforcement by being refused.
|
|
534
|
+
const blockCapable = inForce.filter((l) => !isBlocking(l)
|
|
535
|
+
&& (l.enforcement === ENFORCEMENT.BLOCK || l.intendedEnforcement === ENFORCEMENT.BLOCK));
|
|
536
|
+
|
|
537
|
+
/** Cut at the last word boundary inside the cap, with an ellipsis, rather than mid-word — the live
|
|
538
|
+
* output truncated "…the ris" (from "the risk"), which reads as a bug in the lesson, not a length
|
|
539
|
+
* cap. Falls back to a hard slice when there is no space to break on (a single very long token).
|
|
540
|
+
* Same rule anticipate.sh already applies to its `why`. */
|
|
541
|
+
function clip(text, max) {
|
|
542
|
+
if (text.length <= max) return text;
|
|
543
|
+
const cut = text.slice(0, max);
|
|
544
|
+
const brk = cut.lastIndexOf(' ');
|
|
545
|
+
return `${(brk > max * 0.6 ? cut.slice(0, brk) : cut).trimEnd()}…`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** One lesson, rendered. Evidence is what makes this a lesson rather than a nag — it says why, from
|
|
549
|
+
* real history, in the user's own words. Counts come from the store; nothing here is invented. */
|
|
550
|
+
function renderLesson(l, mark) {
|
|
551
|
+
const out = [` ${mark} ${l.statement}`];
|
|
552
|
+
if (l.evidence?.[0]?.observed) out.push(` ${clip(String(l.evidence[0].observed), 150)}`);
|
|
553
|
+
if (l.repeatCount >= 3) out.push(` you have had to say this ${l.repeatCount} times across ${l.projects.length} project(s)`);
|
|
554
|
+
return out.join('\n');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function renderBody() {
|
|
558
|
+
const lines = [''];
|
|
559
|
+
const label = Object.values(TRIGGERS).find((t) => t.key === triggers[0])?.label || triggers.join(', ');
|
|
560
|
+
lines.push(` ⚑ ${blocking.length ? 'BLOCKED' : 'Before you continue'} — you are ${label}.`);
|
|
561
|
+
lines.push('');
|
|
562
|
+
for (const l of inForce) {
|
|
563
|
+
lines.push(renderLesson(l, isBlocking(l) ? '⛔' : '·'));
|
|
564
|
+
lines.push('');
|
|
565
|
+
}
|
|
566
|
+
// The decision points that fired but lost the budget — one clipped line each, so none is silent.
|
|
567
|
+
if (compactExtras.length) {
|
|
568
|
+
lines.push(' Also live at this moment:');
|
|
569
|
+
for (const l of compactExtras) lines.push(` · ${clip(String(l.statement), 150)}`);
|
|
570
|
+
lines.push('');
|
|
571
|
+
}
|
|
572
|
+
// Say it out loud when the budget trips. A silent truncation reads as "that is all there is".
|
|
573
|
+
if (trimmed > 0) {
|
|
574
|
+
lines.push(` (${trimmed} further lesson${trimmed === 1 ? '' : 's'} also applies here, trimmed to keep this short —`);
|
|
575
|
+
lines.push(` see them all with: node scripts/lesson-ratify.mjs --list)`);
|
|
576
|
+
lines.push('');
|
|
577
|
+
}
|
|
578
|
+
if (blockCapable.length && !blocking.length) {
|
|
579
|
+
// Deliberately phrased as an available choice, not as a pending threat. The previous wording
|
|
580
|
+
// ("would REFUSE this action once you ratify them") described enforcement arriving on its own.
|
|
581
|
+
// It does not arrive on its own any more, and telling someone a refusal is coming when they
|
|
582
|
+
// never asked for one is the coercive framing the owner rejected.
|
|
583
|
+
lines.push(` ${blockCapable.length} of these can REFUSE this action instead of mentioning it,`);
|
|
584
|
+
lines.push(` if you want that. Entirely your call — nothing changes unless you add the id:`);
|
|
585
|
+
lines.push(` ${OPTIN_PATH}`);
|
|
586
|
+
lines.push('');
|
|
587
|
+
}
|
|
588
|
+
return lines.join('\n');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Emit ─────────────────────────────────────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
if (json) {
|
|
594
|
+
console.log(JSON.stringify({
|
|
595
|
+
triggers, event: event ?? null, inForce,
|
|
596
|
+
blocking: blocking.map((l) => l.id),
|
|
597
|
+
blockCapable: blockCapable.map((l) => l.id),
|
|
598
|
+
optInPath: OPTIN_PATH,
|
|
599
|
+
}, null, 2));
|
|
600
|
+
process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (event) {
|
|
604
|
+
// RECORD what this session is about to SURFACE, so the frequency cap can act next time. Only
|
|
605
|
+
// pure-advisory lessons count toward their own cap; block-capable lessons are exempt (capExempt) and
|
|
606
|
+
// never recorded. Skipped when nothing will render (quiet with no block). Best-effort and fail-open —
|
|
607
|
+
// a lost write repeats an advisory once more, it never suppresses one. Persisted BEFORE the streams
|
|
608
|
+
// are touched, so a crash mid-emit under-counts (safe) rather than over-counts.
|
|
609
|
+
const willRender = blocking.length > 0 || (inForce.length > 0 && !quiet);
|
|
610
|
+
if (willRender) {
|
|
611
|
+
const st = gateState && typeof gateState === 'object' ? gateState : {};
|
|
612
|
+
st.sessions = st.sessions && typeof st.sessions === 'object' ? st.sessions : {};
|
|
613
|
+
const prev = st.sessions[SID] && typeof st.sessions[SID] === 'object' ? st.sessions[SID] : {};
|
|
614
|
+
const shown = prev.shown && typeof prev.shown === 'object' ? { ...prev.shown } : {};
|
|
615
|
+
for (const l of inForce) {
|
|
616
|
+
if (capExempt(l)) continue;
|
|
617
|
+
shown[l.id] = (Number.isInteger(shown[l.id]) && shown[l.id] > 0 ? shown[l.id] : 0) + 1;
|
|
618
|
+
}
|
|
619
|
+
st.sessions[SID] = { shown, ts: Date.now() };
|
|
620
|
+
// Bound the file to the most-recent sessions, same discipline as anticipate.sh.
|
|
621
|
+
st.sessions = Object.fromEntries(
|
|
622
|
+
Object.entries(st.sessions).sort((a, b) => (b[1]?.ts || 0) - (a[1]?.ts || 0)).slice(0, KEEP_SESSIONS),
|
|
623
|
+
);
|
|
624
|
+
writeGateState(st);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// CANDIDATE MODE — emit JSON candidates, let the runtime own the real streams and the exit code.
|
|
628
|
+
// A block DOMINATES exactly as in the stream contract below: when any opted-in block is in force we
|
|
629
|
+
// emit only the block candidate and no advisory. The `copy` of each candidate is byte-identical to
|
|
630
|
+
// what the legacy path would have written (renderBody() to stderr for a block; the advisory preamble
|
|
631
|
+
// + renderBody() as additionalContext for a nudge), so the runtime's delivered bytes match. The
|
|
632
|
+
// frequency-cap persist above already ran, so persist-before-speak holds here too.
|
|
633
|
+
if (EMIT_CANDIDATES) {
|
|
634
|
+
if (blocking.length) {
|
|
635
|
+
process.stdout.write(JSON.stringify({
|
|
636
|
+
channel: 'lesson', effect: 'block', copy: renderBody(), hookEventName: event,
|
|
637
|
+
}) + '\n');
|
|
638
|
+
} else if (inForce.length && !quiet) {
|
|
639
|
+
process.stdout.write(JSON.stringify({
|
|
640
|
+
channel: 'lesson', effect: 'advisory', hookEventName: event,
|
|
641
|
+
copy: [
|
|
642
|
+
'Your own recorded corrections apply at this moment. These are advisory — they do not',
|
|
643
|
+
'refuse anything, and you may proceed. Weigh them and say so if you go another way.',
|
|
644
|
+
renderBody(),
|
|
645
|
+
].join('\n'),
|
|
646
|
+
}) + '\n');
|
|
647
|
+
}
|
|
648
|
+
process.exit(EXIT_ALLOW);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// HOOK MODE — the streams are the contract, so nothing else may touch them.
|
|
652
|
+
if (blocking.length) {
|
|
653
|
+
// Exit 2: stdout is ignored by the harness, stderr becomes the model's error message. Writing
|
|
654
|
+
// the reason anywhere but stderr is exactly the bug this file exists to fix.
|
|
655
|
+
process.stderr.write(renderBody() + '\n');
|
|
656
|
+
process.exit(EXIT_BLOCK);
|
|
657
|
+
}
|
|
658
|
+
if (inForce.length && !quiet) {
|
|
659
|
+
// Exit 0 + additionalContext: reaches the model, at the decision point, refusing nothing.
|
|
660
|
+
// hookEventName MUST name the firing event or the harness discards the envelope.
|
|
661
|
+
process.stdout.write(JSON.stringify({
|
|
662
|
+
hookSpecificOutput: {
|
|
663
|
+
hookEventName: event,
|
|
664
|
+
additionalContext: [
|
|
665
|
+
'Your own recorded corrections apply at this moment. These are advisory — they do not',
|
|
666
|
+
'refuse anything, and you may proceed. Weigh them and say so if you go another way.',
|
|
667
|
+
renderBody(),
|
|
668
|
+
].join('\n'),
|
|
669
|
+
},
|
|
670
|
+
}));
|
|
671
|
+
}
|
|
672
|
+
process.exit(EXIT_ALLOW);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── CLI MODE (no --event) ────────────────────────────────────────────────────────────────────────
|
|
676
|
+
// Plain text on stdout, unchanged. version-bump-gate.sh captures this stdout verbatim and appends it
|
|
677
|
+
// to its own refusal under "── from your own lesson store ──"; changing the stream or the shape here
|
|
678
|
+
// would silently empty that section of the only gate in the system that genuinely works.
|
|
679
|
+
if (!quiet && inForce.length) console.log(renderBody());
|
|
680
|
+
process.exit(blocking.length ? EXIT_BLOCK : EXIT_ALLOW);
|