ruvnet-brain 4.0.36 → 4.2.2-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.
- package/README.md +28 -5
- package/bin/install.mjs +405 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +231 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +261 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +38 -4
- package/scripts/card-from-source.mjs +114 -0
- package/scripts/console-engine.mjs +1 -1
- package/scripts/corpus-candidate.mjs +294 -0
- package/scripts/corpus-reconcile.mjs +273 -0
- package/scripts/corpus-seed-publish.mjs +110 -0
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-new-repos.mjs +122 -0
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +55 -7
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/rebuild-gists-from-receipts.mjs +246 -0
- package/scripts/release-transaction.mjs +30 -2
- package/scripts/release.mjs +147 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/rvf-generation.mjs +44 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- 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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|