ruvnet-brain 4.0.36 → 4.0.90-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +4 -4
  2. package/bin/install.mjs +283 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +0 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +252 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +25 -1
  45. package/scripts/console-engine.mjs +1 -1
  46. package/scripts/health-repair.mjs +11 -2
  47. package/scripts/ingest-repo.mjs +66 -6
  48. package/scripts/learning-replay-cli.mjs +8 -3
  49. package/scripts/learning-replay-fixture.mjs +25 -6
  50. package/scripts/learning-replay-proof.mjs +38 -0
  51. package/scripts/nightly-wrapper.sh +13 -0
  52. package/scripts/onboarding-console.mjs +21 -1
  53. package/scripts/org-repo-count.mjs +119 -0
  54. package/scripts/repo-count-detector.mjs +62 -0
  55. package/scripts/restore-local-ingests.mjs +116 -0
  56. package/scripts/selfcheck.mjs +9 -1
  57. package/scripts/stabilization-receipt.mjs +11 -1
  58. package/scripts/sync-census.mjs +0 -0
  59. package/scripts/sync-commands.mjs +117 -0
@@ -0,0 +1,134 @@
1
+ /**
2
+ * identifier-preflight.mjs — an external identifier is CHECKED before it is committed to, or the
3
+ * failure is silent and expensive.
4
+ *
5
+ * THE COST, measured 2026-08-13. I launched a 50-minute adversarial audit with
6
+ * `codex exec --model gpt-5.6`. The real model is `gpt-5.6-sol`, and it was sitting in
7
+ * `~/.codex/config.toml` where two seconds of reading would have found it. What made it expensive
8
+ * was not the typo:
9
+ *
10
+ * codex printed `ERROR: 400 The 'gpt-5.6' model is not supported` AND EXITED 0,
11
+ * into a file I had redirected and was not reading.
12
+ *
13
+ * So there was no exit code to catch, no exception, and no output on screen. Fifty minutes of the
14
+ * owner's time bought nothing, and the loss surfaced only because he asked why the result was late.
15
+ * The typo is a two-second fix; the SILENCE is the defect.
16
+ *
17
+ * WHY THIS IS THE FOURTH INSTANCE OF ONE PATTERN, not a new bug. This repo already owns
18
+ * `scripts/verify-model-catalog.mjs`, whose own header calls it "THE WALL for model facts … the
19
+ * enforcement of Rule 0 for the one place it kept getting skipped: model/version claims" (ADR-0016).
20
+ * It verifies models written into `data/model-catalog.json`. It has never verified a model NAME
21
+ * PASSED TO A COMMAND. Same shape as the freshness machinery pointed at coverage but not the eval,
22
+ * `resolveBash()` present but unused at a new call site, and `findInvocations()` existing while a
23
+ * ship glob greps raw text. The machinery keeps existing and keeps pointing one surface away from
24
+ * where the failure happens.
25
+ *
26
+ * DESIGN, corrected against an adversarial review of my other hooks earlier the same day:
27
+ * · FAIL OPEN. An identifier this cannot resolve is ALLOWED, silently. The reviewed sibling turned
28
+ * a missing `sqlite3` into "the memory store is not durably persisting writes" — a fabricated
29
+ * diagnosis is worse than no check, because it burns the credibility the channel runs on.
30
+ * · NO CACHE. The same review found a cache keyed by $USER but probing a per-project path, so one
31
+ * project's verdict refused work in another. There is nothing here worth caching.
32
+ * · REFUSE ONLY ON A KNOWN-WRONG VALUE, never on an unknown one, and SAY THE RIGHT ANSWER — a wall
33
+ * that reports a problem without the fix is one the user routes around.
34
+ */
35
+ import fs from 'node:fs';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ /**
41
+ * Each entry knows how to enumerate the identifiers its CLI actually accepts on THIS machine.
42
+ * `known()` returning an empty list means "cannot tell" and the check stands down — that is the
43
+ * fail-open path and it is the common one.
44
+ */
45
+ export const CLIS = [
46
+ {
47
+ id: 'codex',
48
+ // `codex exec --model X`, `codex -m X`. The flag may appear anywhere in the command.
49
+ matches: (cmd) => /\bcodex\b/.test(cmd),
50
+ flag: /(?:--model|(?<![\w-])-m)[= ]+["']?([\w.:-]+)["']?/,
51
+ known: (home = os.homedir()) => {
52
+ // The config's `model` is the one identifier PROVEN to work on this account — codex rejects
53
+ // others per-account ("not supported when using Codex with a ChatGPT account"), so a static
54
+ // list of "models that exist" would be exactly the stale-memory fact ADR-0016 forbids.
55
+ try {
56
+ const txt = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8');
57
+ const m = txt.match(/^\s*model\s*=\s*["']([^"']+)["']/m);
58
+ return m ? [m[1]] : [];
59
+ } catch { return []; }
60
+ },
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * QUOTED TEXT IS AN ARGUMENT, NOT A FLAG. `codex exec "explain the --model gpt-5.6 error"` passes
66
+ * that string as a PROMPT; refusing it would block asking about the very mistake this file exists
67
+ * to prevent. Caught by testing the edge instead of assuming, minutes after an adversarial review
68
+ * found a sibling fix greping raw command text for `git push` and matching
69
+ * `grep -n "npm publish" docs/…`. Same defect, same day, two files apart: the truth-maker is the
70
+ * EXECUTABLE POSITION, and a prompt is always quoted, so the quoted regions come out first.
71
+ */
72
+ const unquoted = (cmd) => cmd.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
73
+
74
+ /** Pull the identifier a command commits to, if any. */
75
+ export function identifierIn(command, clis = CLIS) {
76
+ const raw = String(command || '');
77
+ const cmd = unquoted(raw);
78
+ for (const c of clis) {
79
+ if (!c.matches(cmd)) continue;
80
+ const m = cmd.match(c.flag);
81
+ if (m) return { cli: c, value: m[1] };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * The verdict. `unknown` is a first-class answer and it ALLOWS — this refuses only when the machine
88
+ * can positively enumerate what the CLI accepts and the requested value is not among them.
89
+ */
90
+ export function check(command, opts = {}) {
91
+ const hit = identifierIn(command, opts.clis ?? CLIS);
92
+ if (!hit) return { verdict: 'not-applicable' };
93
+ const known = hit.cli.known(opts.home);
94
+ if (!known.length) return { verdict: 'unknown', why: `cannot enumerate ${hit.cli.id} models on this machine` };
95
+ if (known.includes(hit.value)) return { verdict: 'ok', value: hit.value };
96
+ // A CONFIGURED MODEL IS A DEFAULT, NOT AN ALLOWLIST — and an audit was right to call the first
97
+ // version a false-refusal waiting to happen: an account may accept several models, so `o3` or
98
+ // `gpt-5.1-codex` are UNKNOWN here, not wrong, and must pass. What is knowably wrong is a NEAR MISS
99
+ // of the configured value, because that is a typo rather than a choice — and it is exactly what
100
+ // happened: `gpt-5.6` for `gpt-5.6-sol`, a truncation, 400 + exit 0, fifty minutes lost.
101
+ const nearMiss = known.find((k) => k.startsWith(hit.value) || hit.value.startsWith(k)
102
+ || k.replace(/[-._]/g, '') === hit.value.replace(/[-._]/g, ''));
103
+ if (!nearMiss) return { verdict: 'unknown', why: `${hit.value} is not this machine's configured model, but may still be valid for the account` };
104
+ return {
105
+ verdict: 'wrong',
106
+ nearMiss,
107
+ value: hit.value,
108
+ known,
109
+ reason:
110
+ `⛔ BLOCKED — "${hit.value}" is not a model this machine's ${hit.cli.id} accepts.\n`
111
+ + ` configured and proven to work: ${known.join(', ')}\n`
112
+ + ` source: ~/.codex/config.toml\n\n`
113
+ + 'Checked because an unverified identifier fails SILENTLY here: on 2026-08-13 `codex exec\n'
114
+ + '--model gpt-5.6` printed a 400 and EXITED 0 into a redirected file, and 50 minutes of a\n'
115
+ + 'long-running audit produced nothing. There was no exit code to catch. Use the name above,\n'
116
+ + 'or read the live source if you believe it is wrong — never retype one from memory.',
117
+ };
118
+ }
119
+
120
+ const isMain = (() => {
121
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
122
+ catch { return false; }
123
+ })();
124
+
125
+ if (isMain) {
126
+ let payload = '';
127
+ try { payload = fs.readFileSync(0, 'utf8'); } catch { /* no stdin is not a refusal */ }
128
+ let command = '';
129
+ try { command = JSON.parse(payload)?.tool_input?.command ?? ''; } catch { /* malformed degrades to allow */ }
130
+ const r = check(command);
131
+ if (r.verdict !== 'wrong') process.exit(0);
132
+ process.stderr.write(`${r.reason}\n`);
133
+ process.exit(2);
134
+ }
@@ -15,6 +15,19 @@ set -uo pipefail
15
15
  # RUVNET_LEARNING_SCOPE so the two halves cannot disagree during one hook invocation.
16
16
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
17
17
  SCOPE="${RUVNET_LEARNING_SCOPE:-}"
18
+ # CONFIGURED vs DEFAULTED, and this must be captured BEFORE the preferences fallback below.
19
+ #
20
+ # `project` is the DEFAULT scope, and `runtime-preferences.mjs --learning-scope` RESOLVES it —
21
+ # measured 2026-08-19 against an empty config root, it returns "project", not "". So after the
22
+ # fallback runs, an explicit opt-in and a bare default are indistinguishable. The first version of
23
+ # this guard read SCOPE afterwards and was therefore always "configured", which let the
24
+ # stranger-project mutation straight back in.
25
+ #
26
+ # The env var is the one signal that is unambiguously explicit: nothing sets it by default. A
27
+ # project that opted in through the console instead is still covered by the `.swarm` clause below,
28
+ # because adopting Ruflo's convention is itself the opt-in.
29
+ SCOPE_CONFIGURED=0
30
+ [ -n "$SCOPE" ] && SCOPE_CONFIGURED=1
18
31
  if [ -z "$SCOPE" ] && [ -f "$HERE/runtime-preferences.mjs" ] && command -v node >/dev/null 2>&1; then
19
32
  SCOPE=$(node "$HERE/runtime-preferences.mjs" --learning-scope 2>/dev/null) || SCOPE=""
20
33
  fi
@@ -122,8 +135,44 @@ SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
122
135
  if [ "$SCOPE" = "user" ]; then
123
136
  DIR="$HOME/.cache/ruvnet-brain/learn"
124
137
  else
125
- DIR="$PWD/.swarm/ruvnet-brain-learn"
138
+ # ISSUE #134 — THE SAME PROJECT ROOT THE READER COMPUTES, BY THE SAME RULE.
139
+ #
140
+ # This was bare `$PWD` while learn-flush.mjs:26 and health-repair.mjs:32 both resolve
141
+ # `RUVNET_BRAIN_PROJECT_DIR || cwd`. This hook is wired on PostToolUse, so it runs after EVERY tool
142
+ # call — and any command that leaves the shell below the project root (a test run, a build, any
143
+ # tooling that cd's) made the WRITER create a queue in a directory the READER never looks in. Those
144
+ # events are not misfiled, they are orphaned: nothing ever drains them.
145
+ #
146
+ # This is issue #104's residual. #104 fixed the two halves of the FLUSH to agree about which project
147
+ # they mean; the component that actually creates the queue was not brought along, so the invariant
148
+ # held for two of three participants and was violated by the one doing the writing. Same shape as
149
+ # ADR-066: a writer and a reader that disagree about the store make the recording theatre.
150
+ DIR="${RUVNET_BRAIN_PROJECT_DIR:-$PWD}/.swarm/ruvnet-brain-learn"
126
151
  fi
152
+ # PROJECT SCOPE MEANS THE PROJECT MUST HAVE OPTED IN. In project scope $DIR sits under `.swarm`,
153
+ # which is Ruflo's own convention and is created by `ruflo init` — so its PRESENCE is the project's
154
+ # opt-in and its ABSENCE is a project that has not adopted the brain. This hook runs machine-wide on
155
+ # every PostToolUse, so an unconditional mkdir planted `.swarm/` in EVERY repository the user opened.
156
+ # Measured 2026-08-14 by the both-hosts conformance gate in a temp project with no git and no brain
157
+ # artifacts; ADR-058 D5 — never touch what we do not own. User scope is unaffected: that queue lives
158
+ # under the brain's OWN cache directory, which we do own and may create.
159
+ # CREATE THE QUEUE ONLY WHERE THE PROJECT ACTUALLY OPTED IN.
160
+ #
161
+ # First attempt required an existing `.swarm`, which stopped the stranger-project mutation but ALSO
162
+ # broke a legitimate first run: a project that explicitly sets RUVNET_LEARNING_SCOPE=project before
163
+ # it has ever captured anything got nothing, and `learning-scope-policy` went red. Presence of
164
+ # `.swarm` was the wrong discriminator — it answers "has Ruflo run here", not "did this project ask
165
+ # for learning".
166
+ #
167
+ # The right one is whether the scope was CONFIGURED (env or runtime-preferences) rather than
168
+ # inherited from the default. A stranger's repo sets neither, so nothing is created there; a project
169
+ # that opted in gets its queue on the very first capture, `.swarm` or not. An existing `.swarm` is
170
+ # still honoured on its own, because a repo already carrying Ruflo's convention has plainly adopted it.
171
+ case "$DIR" in
172
+ */.swarm/*)
173
+ if [ "$SCOPE_CONFIGURED" != "1" ] && [ ! -d "$(dirname "$DIR")" ]; then exit 0; fi
174
+ ;;
175
+ esac
127
176
  # Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
128
177
  # account is normally in `staff`, so any other user on a shared or corporate machine could read it.
129
178
  ( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
@@ -13,7 +13,7 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { readStdinBounded } from './hook-input.mjs';
16
- import { loadRuntimePreferences } from './runtime-preferences.mjs';
16
+ import { learningScope, loadRuntimePreferences } from './runtime-preferences.mjs';
17
17
  import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
18
18
 
19
19
  // ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
@@ -24,10 +24,10 @@ const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } c
24
24
 
25
25
  const HOME = os.homedir();
26
26
  const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
27
- const configuredScope = process.env.RUVNET_LEARNING_SCOPE
28
- || loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
29
- const LEARNING_SCOPE = ['off', 'project', 'user'].includes(configuredScope)
30
- ? configuredScope : 'project';
27
+ // ISSUE #139 — this WRITER resolved scope correctly while two READERS hardcoded it, so they agreed
28
+ // only by coincidence. The resolution moved into runtime-preferences.mjs and all three now call it;
29
+ // a future scope is one edit, not three. Behaviour here is unchanged by design.
30
+ const LEARNING_SCOPE = learningScope({ cwd: PROJECT });
31
31
  if (LEARNING_SCOPE === 'off') process.exit(0);
32
32
 
33
33
  // THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lesson-bridge.mjs — carry AgentDB's tagged lessons into the store lesson-gate already reads.
4
+ *
5
+ * WHY: two stores of "what we learned" existed with nothing connecting them. The full measurement,
6
+ * the trust-boundary reasoning, and what it cost to learn are in docs/adr/0066 — not repeated here.
7
+ *
8
+ * THE ONE RULE THAT SURPRISES PEOPLE: the trigger lives as a TAG ON THE AGENTDB ROW, never inferred
9
+ * from the text. An untagged lesson does not bridge and is reported by name. Guessing which moment a
10
+ * lesson belongs to is the keyword-classifier mistake ADR-065 recorded in its own numbers.
11
+ *
12
+ * Bridged lessons are origin:imported, so makeLesson structurally forbids them from blocking work.
13
+ * Fail-safe both ways: no store → no-op; zero candidates → REFUSES to write (a read failure must not
14
+ * silently strip installed lessons; removal is explicit via --prune).
15
+ *
16
+ * node plugin/scripts/lesson-bridge.mjs # report what would bridge, and what is untagged
17
+ * node plugin/scripts/lesson-bridge.mjs --apply # merge (locked, atomic, backed up)
18
+ * node plugin/scripts/lesson-bridge.mjs --json # machine-readable
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { makeLesson, updateLessons, loadLessons, ENFORCEMENT, ORIGIN, STATUS, TRIGGERS } from './lesson-store.mjs';
26
+
27
+ /** Every bridged lesson id starts with this. It is how a merge knows which rows it owns. */
28
+ export const BRIDGE_PREFIX = 'G-';
29
+ /** Project-tier ids. Distinct prefix so a merge can own both sets without confusing them. */
30
+ export const PROJECT_PREFIX = 'P-';
31
+ /** Any id this bridge owns and may replace wholesale on the next run. */
32
+ export const isBridged = (id) => String(id).startsWith(BRIDGE_PREFIX) || String(id).startsWith(PROJECT_PREFIX);
33
+
34
+ const GLOBAL_DB = process.env.RUVNET_GLOBAL_MEMORY_DB
35
+ || path.join(os.homedir(), '.claude', 'global-memory', '.swarm', 'memory.db');
36
+ const GLOBAL_NS = process.env.RUVNET_GLOBAL_MEMORY_NS || 'global';
37
+
38
+ /**
39
+ * THE PROJECT TIER (ADR-067). Global memory holds lessons that already won twice; a project's own
40
+ * `.swarm/memory.db` holds the ones learned HERE. Both are knowledge with no way to speak, and the
41
+ * bridge was reading only one of them.
42
+ *
43
+ * The difference that matters is SCOPE, and lesson-gate already enforces it: a lesson carrying
44
+ * `projects: [name]` speaks only in that project, while an unscoped one speaks anywhere. So a global
45
+ * row bridges unscoped and a project row bridges scoped to its own directory — no new mechanism, the
46
+ * existing `isHome` check does the work. Without that, a ruvnet-brain lesson would interrupt someone
47
+ * working in a different repo, which is precisely the breakage recorded in lesson-gate.mjs on
48
+ * 2026-07-22: "I've got other repos that are using this thing, and they're breaking."
49
+ */
50
+ const PROJECT_DB = process.env.RUVNET_PROJECT_MEMORY_DB
51
+ || path.join(process.cwd(), '.swarm', 'memory.db');
52
+
53
+ const TRIGGER_KEYS = new Set(Object.values(TRIGGERS).map((t) => t.key));
54
+ const ENFORCEMENTS = new Set(Object.values(ENFORCEMENT));
55
+
56
+ /** A local, one-shot read query. Generous for a wedged lock; still a hard ceiling on a hook path. */
57
+ const SQLITE_CLI_TIMEOUT_MS = Number(process.env.RUVNET_SQLITE_CLI_TIMEOUT_MS) || 5_000;
58
+
59
+ // ── Reading the store ────────────────────────────────────────────────────────────────────────────
60
+ // node:sqlite is preferred: in-process, no shell, exact bytes, no quoting hazard on multi-paragraph
61
+ // lesson text. It is stable on Node 22.5+/24 but absent on older runtimes, so the sqlite3 CLI is the
62
+ // fallback — and if neither exists this returns [] and the whole bridge becomes a no-op rather than
63
+ // an error. A tool that fails loudly on a machine that simply has no global memory would be noise.
64
+ const SQL = `SELECT key, content, COALESCE(tags,'') AS tags, COALESCE(provenance_type,'unknown') AS provenance,
65
+ COALESCE(updated_at, created_at) AS ts
66
+ FROM memory_entries WHERE namespace = ? ORDER BY key`;
67
+
68
+ export function readGlobalRows(dbPath = GLOBAL_DB, ns = GLOBAL_NS) {
69
+ if (!fs.existsSync(dbPath)) return [];
70
+ try {
71
+ const { DatabaseSync } = require$('node:sqlite');
72
+ const db = new DatabaseSync(dbPath, { readOnly: true });
73
+ try { return db.prepare(SQL).all(ns); } finally { db.close(); }
74
+ } catch { /* fall through to the CLI */ }
75
+ try {
76
+ // Unbounded: an execFileSync with no `timeout` blocks the ENTIRE main thread on a synchronous
77
+ // syscall, which stalls vitest's own async testTimeout too (it can't fire without a free event
78
+ // loop) — measured as two consecutive 6h GitHub Actions job-ceiling kills, no error, no test
79
+ // name, after this file's own tests started using this path (2026-08-10). A busy/locked
80
+ // sqlite3 CLI, or one waiting on a lock this process already holds via node:sqlite above, must
81
+ // die on its own schedule, not the host's.
82
+ const out = execFileSync('sqlite3', ['-json', dbPath, SQL.replace('?', `'${ns.replace(/'/g, "''")}'`)],
83
+ { encoding: 'utf8', maxBuffer: 1 << 24, timeout: SQLITE_CLI_TIMEOUT_MS });
84
+ const rows = JSON.parse(out || '[]');
85
+ return Array.isArray(rows) ? rows : [];
86
+ } catch { return []; }
87
+ }
88
+ /**
89
+ * Every `lesson*` row in a store, across ALL namespaces — the project tier scatters them (`lessons`,
90
+ * the project dirname, `default`), and enumerating namespaces by hand is the restatement this repo
91
+ * keeps paying for. The key prefix is the selector, exactly as the promotion tooling already uses it.
92
+ */
93
+ export function readProjectRows(dbPath = PROJECT_DB) {
94
+ const sql = `SELECT key, content, COALESCE(tags,'') AS tags, COALESCE(provenance_type,'unknown') AS provenance,
95
+ COALESCE(updated_at, created_at) AS ts
96
+ FROM memory_entries WHERE key LIKE 'lesson%' ORDER BY key`;
97
+ if (!fs.existsSync(dbPath)) return [];
98
+ try {
99
+ const { DatabaseSync } = require$('node:sqlite');
100
+ const db = new DatabaseSync(dbPath, { readOnly: true });
101
+ try { return db.prepare(sql).all(); } finally { db.close(); }
102
+ } catch { /* fall through */ }
103
+ try {
104
+ const out = execFileSync('sqlite3', ['-json', dbPath, sql],
105
+ { encoding: 'utf8', maxBuffer: 1 << 24, timeout: SQLITE_CLI_TIMEOUT_MS });
106
+ const rows = JSON.parse(out || '[]');
107
+ return Array.isArray(rows) ? rows : [];
108
+ } catch { return []; }
109
+ }
110
+
111
+ /** Indirection so a missing node:sqlite is a caught throw rather than a module-load crash. */
112
+ function require$(id) { return process.getBuiltinModule ? process.getBuiltinModule(id) : null; }
113
+
114
+ // ── Row → lesson ─────────────────────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * `trigger:write-code` tags → { trigger: 'write-code', … }
118
+ *
119
+ * BOTH WIRE SHAPES, and the first one is the one that matters. `ruflo memory store --tags "a,b"`
120
+ * accepts a comma string on the command line and PERSISTS a JSON array:
121
+ *
122
+ * ["trigger:write-code","enforce:inject","severity:high"]
123
+ *
124
+ * Measured 2026-08-10, after this parser was written to the comma form and the bridge read 0 of 30
125
+ * freshly-tagged rows while the CLI reported success on every one. The test fixture had been built to
126
+ * the same assumption, so it would have stayed green while the product read nothing — which is
127
+ * `lesson-fixture-cannot-falsify-its-own-choice` happening to the file that exists to carry that
128
+ * lesson. The fixture now writes what the CLI writes.
129
+ */
130
+ export function parseTags(tags) {
131
+ const raw = String(tags || '').trim();
132
+ let parts;
133
+ try {
134
+ const parsed = JSON.parse(raw);
135
+ parts = Array.isArray(parsed) ? parsed.map(String) : null;
136
+ } catch { parts = null; }
137
+ if (!parts) parts = raw.split(',');
138
+ const out = {};
139
+ for (const part of parts) {
140
+ const [k, v] = String(part).split(':').map((s) => (s || '').trim());
141
+ if (k && v) out[k.toLowerCase()] = v.toLowerCase();
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * The statement is DERIVED from the row, never restated beside it (ADR-065). Global lessons are
148
+ * written headline-first in imperative caps, so the first sentence is the instruction; the rest is
149
+ * the evidence that earned it, which belongs in the store, not in a nudge with a 1200-char budget.
150
+ */
151
+ export function statementOf(content) {
152
+ const text = String(content || '').replace(/\s+/g, ' ').trim();
153
+ const stop = text.search(/(?<=[.!?])\s(?=[A-Z(])/);
154
+ const first = stop > 20 ? text.slice(0, stop) : text;
155
+ return first.length > 300 ? `${first.slice(0, 297).trimEnd()}…` : first;
156
+ }
157
+
158
+ /** `lesson-tests-that-cannot-fail-on-broken-code` → `G-tests-that-cannot-fail-on-broken-code` */
159
+ export const idFor = (key) => BRIDGE_PREFIX + String(key).replace(/^lesson-/, '');
160
+
161
+ /**
162
+ * Build a lesson from one AgentDB row, or explain in one line why it cannot be built.
163
+ * Returns { lesson } or { skip: '<reason>' } — never throws, so one bad row cannot stop the bridge.
164
+ */
165
+ export function lessonFromRow(row, { projects = [], idPrefix = BRIDGE_PREFIX, source = 'global' } = {}) {
166
+ const key = String(row?.key || '');
167
+ if (!key) return { skip: 'row has no key' };
168
+ const tags = parseTags(row.tags);
169
+ if (!tags.trigger) return { skip: 'no trigger: tag — add one to bridge it' };
170
+ if (!TRIGGER_KEYS.has(tags.trigger)) {
171
+ return { skip: `unknown trigger "${tags.trigger}" (expected one of: ${[...TRIGGER_KEYS].join(', ')})` };
172
+ }
173
+ // `checklist` is the default because it is the strongest level that is honest for imported
174
+ // content: it reaches the model at the moment, and it refuses nothing.
175
+ const enforcement = ENFORCEMENTS.has(tags.enforce) ? tags.enforce : ENFORCEMENT.CHECKLIST;
176
+ // Typed provenance is rUv's own field (ADR-323). `user_claim` is the only value that means a human
177
+ // said it; everything else — agent_output, tool_result, system_observation, unknown — is imported.
178
+ const origin = row.provenance === 'user_claim' ? ORIGIN.USER_STATED : ORIGIN.IMPORTED;
179
+ const when = Number(row.ts) > 0 ? new Date(Number(row.ts)).toISOString().slice(0, 10) : 'unknown date';
180
+ try {
181
+ return {
182
+ lesson: makeLesson({
183
+ id: idPrefix + String(key).replace(/^lesson-/, ''),
184
+ statement: statementOf(row.content),
185
+ trigger: tags.trigger,
186
+ enforcement,
187
+ // Real provenance, not a sentence invented to satisfy a non-empty check: where the row is and
188
+ // when it was last written. Anyone can go read it.
189
+ evidence: [`AgentDB ${source}/${key} — ${source === 'global' ? 'machine-wide' : 'project'} lesson store, recorded ${when}`],
190
+ // SCOPE IS THE WHOLE DIFFERENCE BETWEEN THE TIERS. Empty = "applies anywhere by
191
+ // declaration" (Tier 1 earned that by winning twice in projects that could not see each
192
+ // other). A project row carries its own directory, so lesson-gate's isHome() keeps it home.
193
+ projects,
194
+ // NOT invented. Repetition is only used to order lessons of equal force, and a bridged lesson
195
+ // has no per-project repeat count to honestly claim — so it sorts behind the user's directly
196
+ // taught native lessons, which is the correct precedence.
197
+ repeatCount: 0,
198
+ severity: tags.severity === 'high' ? 'high' : 'normal',
199
+ origin,
200
+ // A TAG IS NOT A HUMAN ACT. This said `STATUS.RATIFIED`, commented "the tag on the row IS the
201
+ // human act of ratification" — but `ruflo memory store --tags` runs in every session, from
202
+ // hooks and from the model itself. I wrote a tagged row by hand on 2026-08-13, which under
203
+ // the old line would have RATIFIED MY OWN LESSON. Combined with nightly-wrapper.sh running
204
+ // `lesson-bridge --apply` unattended, any process able to write a `lesson-*` row could inject
205
+ // standing instructions into every later session, stamped as ratified policy — the injection
206
+ // path ADR-066/067 closed at the front door, left open on a timer at the side.
207
+ //
208
+ // The distinction was already computed eight lines up. rUv's typed provenance (ADR-323,
209
+ // v3/@claude-flow/cli/src/commands/memory.ts) marks `user_claim` as the one value meaning a
210
+ // human said it; `origin` honoured that and `status` ignored it. Both now read the same fact.
211
+ // Anything else arrives CANDIDATE, which lessonsFor() will not deliver and makeLesson() will
212
+ // not let block.
213
+ status: origin === ORIGIN.USER_STATED ? STATUS.RATIFIED : STATUS.CANDIDATE,
214
+ ratifiedBy: origin === ORIGIN.USER_STATED ? `agentdb-provenance:user_claim ${source}/${key}` : null,
215
+ }),
216
+ };
217
+ } catch (e) { return { skip: String(e?.message || e).slice(0, 160) }; }
218
+ }
219
+
220
+ // ── Merge ────────────────────────────────────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Replace every bridged row with the current set; leave every other row byte-identical.
224
+ * Pure, so the test can assert the merge without touching a real store.
225
+ */
226
+ export function mergeBridged(existing, bridged) {
227
+ return [...existing.filter((l) => !isBridged(l.id)), ...bridged];
228
+ }
229
+
230
+ // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
231
+
232
+ const argv = process.argv.slice(2);
233
+ /**
234
+ * "Am I the entrypoint?" — and it must NEVER be able to crash the caller that merely imported this.
235
+ *
236
+ * The first version had both Windows defects at once, and CI found them: `new URL(import.meta.url)
237
+ * .pathname` yields `/D:/…` on Windows, which `path`/`fs` then mangle into `D:\D:` — the exact
238
+ * `ENOENT: lstat 'D:\D:'` that failed this module's whole test SUITE at import time. And
239
+ * `realpathSync` THROWS on anything that does not resolve, which under vitest `process.argv[1]`
240
+ * does not.
241
+ *
242
+ * `fileURLToPath` is the fix for the first (hook-shim.mjs learned this in issue #38: "on Windows,
243
+ * pathname yields '/C:/…' which path.resolve mangles into 'C:\\C:\\…'"), and try/catch for the
244
+ * second. Both were ALREADY correct in scripts/selfcheck.mjs:660 and plugin/scripts/hook-input.mjs
245
+ * — the pattern existed in this repo and I wrote a new one instead of reading it.
246
+ */
247
+ function isMain() {
248
+ try {
249
+ if (!process.argv[1]) return false;
250
+ return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
251
+ } catch { return false; }
252
+ }
253
+ if (isMain()) {
254
+ const apply = argv.includes('--apply');
255
+ const prune = argv.includes('--prune');
256
+ const json = argv.includes('--json');
257
+
258
+ const here = path.basename(process.cwd());
259
+ const sources = [
260
+ { name: 'global', rows: readGlobalRows(), opts: { projects: [], idPrefix: BRIDGE_PREFIX, source: 'global' } },
261
+ // Scoped to THIS project by name, so lesson-gate's isHome() keeps it from speaking elsewhere.
262
+ { name: `project:${here}`, rows: readProjectRows(), opts: { projects: [here], idPrefix: PROJECT_PREFIX, source: `project:${here}` } },
263
+ ];
264
+ const bridged = [];
265
+ const skipped = [];
266
+ const seen = new Set();
267
+ for (const src of sources) {
268
+ for (const row of src.rows) {
269
+ const r = lessonFromRow(row, src.opts);
270
+ if (!r.lesson) { skipped.push({ key: `${src.name}/${row.key}`, why: r.skip }); continue; }
271
+ // A lesson promoted from a project to global exists in BOTH stores. The global copy wins: it
272
+ // is the one that earned the right to travel, and surfacing the same correction twice teaches
273
+ // the reader to skim (lesson-gate's own dedupe reasoning, applied across sources).
274
+ const slug = String(row.key).replace(/^lesson-/, '');
275
+ if (seen.has(slug)) { skipped.push({ key: `${src.name}/${row.key}`, why: 'already bridged from a higher tier' }); continue; }
276
+ seen.add(slug);
277
+ bridged.push(r.lesson);
278
+ }
279
+ }
280
+
281
+ const alreadyBridged = loadLessons().filter((l) => isBridged(l.id)).length;
282
+ // THE ANTI-WIPE GUARD. A read failure (store moved, sqlite missing, permissions) produces zero
283
+ // candidates, and without this a "successful" apply would quietly delete every lesson the bridge
284
+ // had previously installed. Nothing about that would look like an error.
285
+ const wouldWipe = bridged.length === 0 && alreadyBridged > 0;
286
+
287
+ if (json) {
288
+ console.log(JSON.stringify({
289
+ sources: sources.map((s) => ({ name: s.name, rows: s.rows.length })),
290
+ bridged: bridged.map((l) => ({ id: l.id, trigger: l.trigger, enforcement: l.enforcement, origin: l.origin })),
291
+ untagged: skipped, alreadyBridged, wouldWipe, applied: false,
292
+ }, null, 2));
293
+ process.exit(0);
294
+ }
295
+
296
+ console.log('\n lesson-bridge');
297
+ for (const s of sources) console.log(` ${s.name.padEnd(24)} ${s.rows.length} row(s)`);
298
+ console.log(` ${bridged.length} carry a trigger tag.\n`);
299
+ const byTrigger = new Map();
300
+ for (const l of bridged) byTrigger.set(l.trigger, [...(byTrigger.get(l.trigger) || []), l]);
301
+ for (const [trigger, ls] of [...byTrigger].sort()) {
302
+ console.log(` ▸ ${trigger}`);
303
+ for (const l of ls) console.log(` ${l.enforcement.padEnd(9)} ${l.id}`);
304
+ }
305
+ // BRIDGED BUT NOT DELIVERED IS ITS OWN STATE, AND IT MUST BE LOUD. A candidate is merged into the
306
+ // store and then filtered out by lessonsFor(), so counting it under "bridged" would report 43
307
+ // lessons in force while 20 of them never fire — the same lie as the flywheel's "configured"
308
+ // reported as "operational". These are the rows the model wrote about itself (`agent_output`);
309
+ // self-ratification is the hole, but going quiet about it would be the bigger one.
310
+ const candidates = bridged.filter((l) => l.status === STATUS.CANDIDATE);
311
+ if (candidates.length) {
312
+ console.log(`\n BRIDGED BUT NOT DELIVERED (${candidates.length}) — provenance is not user_claim, so`);
313
+ console.log(' these merge as CANDIDATES: lessonsFor() will not surface them and they cannot block.');
314
+ for (const l of candidates) console.log(` ${l.trigger.padEnd(24)} ${l.id}`);
315
+ console.log('\n To put one in force, the HUMAN restates it — a tag the model can write is not');
316
+ console.log(' a human act, which is why these are held:');
317
+ console.log(` ruflo memory store --path ${GLOBAL_DB} -n ${GLOBAL_NS} \\`);
318
+ console.log(' -k "<key>" --value "<text>" --tags "trigger:<key>,enforce:checklist" --provenance user_claim');
319
+ }
320
+
321
+ if (skipped.length) {
322
+ // NAMED, never a count. A list that says "12 skipped" and stops is the truncation that reads as
323
+ // completeness — the exact failure Rule 23 was written about.
324
+ console.log(`\n NOT bridged (${skipped.length}) — each needs a trigger tag on its AgentDB row:`);
325
+ for (const s of skipped) console.log(` ${s.key.padEnd(48)} ${s.why}`);
326
+ console.log(`\n ruflo memory store --path ${GLOBAL_DB} -n ${GLOBAL_NS} \\`);
327
+ console.log(' -k "<key>" --value "<text>" --tags "trigger:<key>,enforce:checklist" --provenance user_claim');
328
+ }
329
+
330
+ if (!apply) {
331
+ console.log(`\n read-only. Re-run with --apply to merge ${bridged.length} lesson(s) into the store.\n`);
332
+ process.exit(0);
333
+ }
334
+ if (wouldWipe && !prune) {
335
+ console.error(`\n REFUSING to apply: 0 lessons readable but ${alreadyBridged} bridged lesson(s) are already`);
336
+ console.error(' installed. That is a read failure, not an empty store. Pass --prune if removal is intended.\n');
337
+ process.exit(1);
338
+ }
339
+ const before = loadLessons().length;
340
+ updateLessons((current) => mergeBridged(current, bridged));
341
+ const after = loadLessons().length;
342
+ console.log(`\n applied: store ${before} → ${after} lesson(s) (${bridged.length} bridged, ${after - bridged.length} native)\n`);
343
+ }
@@ -183,6 +183,32 @@ fi
183
183
  if [ "$EVENT" = "PreToolUse-bash" ] && [ -f "$HOOK_INPUT_JS" ]; then
184
184
  CMD=$(printf '%s' "$INPUT" | node "$HOOK_INPUT_JS" command 2>/dev/null) || CMD=""
185
185
  ARGS+=(--command "$CMD")
186
+
187
+ # SHIP RIDES ON THE BASH PAYLOAD, BECAUSE NOTHING EVER SENDS "PreToolUse-push".
188
+ #
189
+ # The `PreToolUse-push) TRIGGERS="ship"` branch above has never executed. hooks.json emits only
190
+ # `unprompted-speech UserPromptSubmit`, and decision-gate.mjs maps EVERY PreToolUse to exactly
191
+ # `PreToolUse-bash` or `PreToolUse-write` — so `git push` has always arrived here as
192
+ # `mutate-machine`, never as `ship`. Measured 2026-08-13: FIVE ratified ship lessons in the live
193
+ # store had therefore never fired once, among them `G-remote-ci-gates-shipping` and
194
+ # `G-test-the-artifact-not-the-checkout` — the two that would have spoken up before I shipped a
195
+ # test that broke windows-unit and red-lit someone else's dependabot PR.
196
+ #
197
+ # Worse, wired-check's Check C greps THIS FILE's case labels to decide which triggers are wired, so
198
+ # it read "ship" off the dead branch and reported it green — an audit that cannot fail on the exact
199
+ # defect class it was written for. The dispatcher is the truth-maker for what fires; a case label
200
+ # nobody sends is not a wire.
201
+ # THE SAME PATTERNS AS degradation-watch.mjs DEPENDENT_COMMANDS, and a test asserts they agree.
202
+ # Two audits noted these two definitions of "is this a ship?" shipped already disagreeing on day
203
+ # one — one fact restated in two files, which is the defect this repo keeps paying for. They also
204
+ # caught both directions of the original glob being wrong: `git -C <abs> push` (the form an agent
205
+ # with absolute paths writes) matched NOTHING, while `grep -n "npm publish" docs/` matched, so
206
+ # reading ABOUT shipping counted as shipping. Quoted regions are stripped first because the
207
+ # truth-maker is what will EXECUTE — a commit message is not a command.
208
+ CMD_EXEC=$(printf '%s' "$CMD" | sed -e 's/"[^"]*"/ /g' -e "s/'[^']*'/ /g")
209
+ if printf '%s' "$CMD_EXEC" | grep -qE '\bgit\b[^|;&]*\bpush\b|\b(npm|yarn|pnpm) publish\b|\bgh release create\b|release\.mjs'; then
210
+ ARGS+=(--trigger ship)
211
+ fi
186
212
  fi
187
213
 
188
214
  # A hard timeout, because this runs on every matching event and must never add perceptible latency.