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.
- package/README.md +4 -4
- package/bin/install.mjs +283 -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 +0 -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 +252 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +25 -1
- package/scripts/console-engine.mjs +1 -1
- package/scripts/health-repair.mjs +11 -2
- 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 +13 -0
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* org-repo-count.mjs — how many repos rUv actually has, DERIVED, with the source recorded.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS REPLACES. `orgTotalApprox: 248` was a literal, written twice:
|
|
5
|
+
*
|
|
6
|
+
* scripts/build-bundle.mjs:402 coverage: { …, orgTotalApprox: 248, … }
|
|
7
|
+
* scripts/brain-stamp.mjs:77 coverage: { …, orgTotalApprox: 248, … }
|
|
8
|
+
*
|
|
9
|
+
* Measured 2026-08-12, two independent ways: `users/ruvnet.public_repos` = 200, and a paginated
|
|
10
|
+
* `users/ruvnet/repos` fetch = 200 distinct names. So the denominator every coverage percentage in
|
|
11
|
+
* this product is computed against was wrong by 48 — and wrong in BOTH copies, which is what a
|
|
12
|
+
* restated fact always does.
|
|
13
|
+
*
|
|
14
|
+
* Replacing 248 with 200 would repeat the mistake with a fresher number. The count changes whenever
|
|
15
|
+
* rUv pushes a new repo, so it is not a constant and must not be stored as one.
|
|
16
|
+
*
|
|
17
|
+
* HONESTY OVER AVAILABILITY. A build with no network must not silently invent a total. `source` is
|
|
18
|
+
* carried alongside the number so every consumer can see whether it is live:
|
|
19
|
+
*
|
|
20
|
+
* { count: 200, source: 'live', at: '2026-08-12T…' } ← queried just now
|
|
21
|
+
* { count: 200, source: 'recorded', at: '2026-08-12T…' } ← last live reading, reused offline
|
|
22
|
+
* { count: null, source: 'unknown', at: null } ← never say a number we cannot source
|
|
23
|
+
*
|
|
24
|
+
* A consumer that receives `unknown` must omit the claim rather than print a guess — the same rule
|
|
25
|
+
* sync-census follows when it refuses to write a non-positive census.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
33
|
+
/** The last LIVE reading, so an offline build reuses a real measurement instead of a literal. */
|
|
34
|
+
export const RECORD_PATH = process.env.RUVNET_ORG_COUNT_RECORD
|
|
35
|
+
|| path.join(ROOT, 'data', 'org-repo-count.json');
|
|
36
|
+
|
|
37
|
+
export const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
|
|
38
|
+
|
|
39
|
+
/** Ask GitHub. Returns a positive integer or null — never a guess, never a partial page. */
|
|
40
|
+
export function fetchLiveCount(owner = OWNER, { run = spawnSync } = {}) {
|
|
41
|
+
// `public_repos` is the account's own count: one request, no pagination to get wrong.
|
|
42
|
+
const r = run('gh', ['api', `users/${owner}`, '--jq', '.public_repos'], { encoding: 'utf8', timeout: 30_000 });
|
|
43
|
+
if (r.error || r.status !== 0) return null;
|
|
44
|
+
const n = Number(String(r.stdout || '').trim());
|
|
45
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const readRecord = (file) => {
|
|
49
|
+
try {
|
|
50
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
return Number.isInteger(j?.count) && j.count > 0 ? j : null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The count, its provenance, and when it was taken.
|
|
57
|
+
* A successful live read updates the record, so the offline fallback is always a real past reading.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* READING A NUMBER MUST NOT MUTATE A TRACKED FILE.
|
|
61
|
+
*
|
|
62
|
+
* `persist` defaults to FALSE, and that default is the fix for a release that could not ship.
|
|
63
|
+
* Every call used to write `data/org-repo-count.json` — a TRACKED file — with a fresh `at`
|
|
64
|
+
* timestamp. Both production callers (`brain-stamp.mjs`, `build-bundle.mjs`) are BUILD scripts, so
|
|
65
|
+
* building the release candidate dirtied the working tree, and `release-qe`'s stabilization seal
|
|
66
|
+
* requires `dirty === false`. Measured 2026-08-19, once the seal was made to name its cause:
|
|
67
|
+
*
|
|
68
|
+
* stabilization seal failed: INVALID_LINEAGE
|
|
69
|
+
* working tree is dirty (1 path(s)):
|
|
70
|
+
* M data/org-repo-count.json
|
|
71
|
+
*
|
|
72
|
+
* That single line had blocked EVERY release since 2026-08-08 (issue #141: nothing published in
|
|
73
|
+
* eleven days while main advanced 46 commits), because the seal previously reported only the code
|
|
74
|
+
* `INVALID_LINEAGE` and named nothing.
|
|
75
|
+
*
|
|
76
|
+
* The committed record still matters — it is the honest offline fallback, so an air-gapped or
|
|
77
|
+
* rate-limited build reuses the last REAL reading instead of inventing one. It just may not be
|
|
78
|
+
* written as a side effect of being read. Refreshing it is now a deliberate act (`--record`), the
|
|
79
|
+
* same separation `refresh-model-catalog.mjs` already uses for the model snapshot.
|
|
80
|
+
*/
|
|
81
|
+
export function orgRepoCount({ owner = OWNER, file = RECORD_PATH, now = new Date(), fetch = fetchLiveCount, persist = false } = {}) {
|
|
82
|
+
const live = fetch(owner);
|
|
83
|
+
if (live) {
|
|
84
|
+
const at = now.toISOString();
|
|
85
|
+
try {
|
|
86
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
if (persist) fs.writeFileSync(file, `${JSON.stringify({ owner, count: live, at }, null, 2)}\n`);
|
|
88
|
+
} catch { /* recording is best-effort; the live number still stands */ }
|
|
89
|
+
return { count: live, source: 'live', at };
|
|
90
|
+
}
|
|
91
|
+
const rec = readRecord(file);
|
|
92
|
+
if (rec) return { count: rec.count, source: 'recorded', at: rec.at || null };
|
|
93
|
+
return { count: null, source: 'unknown', at: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refreshing the committed record is a DELIBERATE ACT, never a side effect of a build.
|
|
98
|
+
*
|
|
99
|
+
* node scripts/org-repo-count.mjs --record
|
|
100
|
+
*
|
|
101
|
+
* Same separation `refresh-model-catalog.mjs` uses for the model snapshot: a build READS, a human
|
|
102
|
+
* (or a scheduled refresh that commits its own result) WRITES. That boundary is what keeps the
|
|
103
|
+
* working tree clean for `release-qe`'s stabilization seal.
|
|
104
|
+
*/
|
|
105
|
+
const isMain = (() => {
|
|
106
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
107
|
+
catch { return false; }
|
|
108
|
+
})();
|
|
109
|
+
|
|
110
|
+
if (isMain) {
|
|
111
|
+
const record = process.argv.includes('--record');
|
|
112
|
+
const r = orgRepoCount({ persist: record });
|
|
113
|
+
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
114
|
+
if (record && r.source !== 'live') {
|
|
115
|
+
process.stderr.write('NOT recorded: the live read failed, and a remembered number may not be '
|
|
116
|
+
+ 'rewritten as if it were fresh.\n');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// repo-count-detector.mjs — THE ONE detector for corpus-size claims in public surfaces.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from tests/unit/repo-count.test.mjs on 2026-08-10 so the WRITER (sync-census.mjs)
|
|
4
|
+
// and the GATE (repo-count.test.mjs) consult the same code. They had drifted by construction:
|
|
5
|
+
// the gate found every phrasing, while the writer enumerated a handful by hand and missed
|
|
6
|
+
// "71 of rUv's repos", "71 RuvNet building-block repos", "71 built today", and the
|
|
7
|
+
// HTML-entity variant. A checker and a fixer that disagree guarantee recurring red builds.
|
|
8
|
+
|
|
9
|
+
export const MIN_CORPUS_MAGNITUDE = 20;
|
|
10
|
+
|
|
11
|
+
// Deliberate, documented exceptions: the literal is correct AS WRITTEN because it describes a
|
|
12
|
+
// PAST state (a "before" baseline in a receipts table), not a current claim — not stale data.
|
|
13
|
+
// Anything else that disagrees with ALLOWED fails. Keyed `${file}::${matched text}` so a typo'd
|
|
14
|
+
// exemption can't silently swallow an unrelated real regression.
|
|
15
|
+
export const EXEMPT = new Set([
|
|
16
|
+
'README.md::24 repos', // v1 (pre-2.0) baseline column in the "what 2.0 proved" before/after table
|
|
17
|
+
'README.md::24→69 verified repos', // same v1-baseline row, "24→<current>" delta phrasing
|
|
18
|
+
// 2026-08-06: the corpus grew 69 → 71 (tonight's rebuild). The CURRENT claims were updated to 71
|
|
19
|
+
// in README.md and SKILL.md, but these two are HISTORICAL — they describe what release 2.0
|
|
20
|
+
// proved, inside a collapsed "Earlier — what 2.0 proved" section and its before/after table.
|
|
21
|
+
// Rewriting them to 71 would be the easy way to make this gate green and would falsify the record:
|
|
22
|
+
// 2.0 shipped with 69, not 71. A gate that pressures you into editing history is worse than the
|
|
23
|
+
// stale number it caught, so historical mentions are exempted by name and current ones are not.
|
|
24
|
+
'README.md::69 verified repos', // "what 2.0 proved" summary line — a claim about 2.0, not today
|
|
25
|
+
'README.md::69 repos', // the 2.0 column of the same v1→v2.0 before/after table
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** @param {string} src @returns {{text: string, n: number}[]} */
|
|
29
|
+
export function findRepoCountLiterals(src) {
|
|
30
|
+
const found = [];
|
|
31
|
+
// Covers "repo(s)" and the spelled-out "repositor(y|ies)" (SKILL.md line 8's own phrasing:
|
|
32
|
+
// "32 RuvNet (rUv / Reuven Cohen) repositories" — real prose puts several filler words/
|
|
33
|
+
// parentheticals between the number and the noun, so this can't be a fixed word count).
|
|
34
|
+
const REPO_WORD = '(?:repos?|repositor(?:y|ies))';
|
|
35
|
+
// Digit NOT immediately followed by "+": an open lower-bound like "20+ repos" is a true,
|
|
36
|
+
// deliberately-vague qualifier, not a precise count claim — exempt from matching at all rather
|
|
37
|
+
// than needing a per-mention exemption. Filler between the digit and the repo-word is capped at
|
|
38
|
+
// 50 chars and may not cross a sentence end / table-cell boundary (".", "|", newline), so this
|
|
39
|
+
// can't accidentally bridge two unrelated numbers separated by prose.
|
|
40
|
+
// `(?<!\d\.)` — never treat a SEMVER COMPONENT as a repo count. Real false positive: the primer's
|
|
41
|
+
// header line "…v3.4.21-dev · Built: 2026-07-20 · Covers: 69/192 repos" made the detector read the
|
|
42
|
+
// patch number `21` (the `-` after it is a word boundary) and then walk 40-odd filler chars to
|
|
43
|
+
// "repos", reporting a stale count on a line whose actual claim — 69/192 — was correct. The
|
|
44
|
+
// published number was right and the gate cried wolf, which is how a gate loses its authority.
|
|
45
|
+
// Two guards, both added after REAL false positives on the primer's own header line
|
|
46
|
+
// "…<version> · Built: <date> · Covers: 69/192 repos", whose actual claim (69/192) is correct:
|
|
47
|
+
// (?<!\d\.) — a SEMVER COMPONENT is never a repo count. The patch number was being read as
|
|
48
|
+
// one, because the `-` before a prerelease suffix is a word boundary.
|
|
49
|
+
// `·` in the filler exclusion — a middle dot separates INDEPENDENT facts. Without it the walk
|
|
50
|
+
// crossed from the build DATE into the count and reported `2026` as a repo count
|
|
51
|
+
// (found by this file's own detector tests, after the semver fix unmasked it).
|
|
52
|
+
const reSpaced = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)\\b(?:(?![.|·\\n])[\\s\\S]){0,50}?\\b${REPO_WORD}\\b`, 'gi');
|
|
53
|
+
const reHyphen = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)-repos?\\b`, 'gi');
|
|
54
|
+
for (const re of [reSpaced, reHyphen]) {
|
|
55
|
+
for (const m of src.matchAll(re)) {
|
|
56
|
+
const n = Number(m[1]);
|
|
57
|
+
if (n >= MIN_CORPUS_MAGNITUDE) found.push({ text: m[0], n });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* restore-local-ingests.mjs — replay every ingest a bundle apply silently removed.
|
|
4
|
+
*
|
|
5
|
+
* WHAT HAPPENED, overnight 2026-08-13 → 08-14. A scheduled bundle apply extracted an Aug-8 bundle
|
|
6
|
+
* over the live brain cache. The outcome for each piece of the previous evening's work was decided
|
|
7
|
+
* entirely by whether it had been committed:
|
|
8
|
+
*
|
|
9
|
+
* SURVIVED capability-cards.md — 5 hand-written cards, committed and pushed that night. The live
|
|
10
|
+
* brain was restored from the repo copy in one command.
|
|
11
|
+
* DIED helix, rvQR, RuCelium, wifi-veil — ingested and VERIFIED ROUTING hours earlier, gone
|
|
12
|
+
* without a trace. Their cards became orphans pointing at stores that no longer existed,
|
|
13
|
+
* which is worse than absence: the router offers an answer whose source is missing.
|
|
14
|
+
*
|
|
15
|
+
* The owner, correctly and for the second time: "you can't ever let work go overnight — you must
|
|
16
|
+
* always push latest changes or things get overwritten."
|
|
17
|
+
*
|
|
18
|
+
* A `.rvf` is a 32MB gitignored build artifact and CANNOT be committed. So the rule's second half
|
|
19
|
+
* applies: when the artifact cannot be pushed, commit the RECIPE. `ingest-repo.mjs` now records each
|
|
20
|
+
* ingest into `kb/local-ingests.json`, and this replays anything the cache is missing.
|
|
21
|
+
*
|
|
22
|
+
* The wipe was not on either adversarial audit's list, because both read code and this only shows
|
|
23
|
+
* up if you watch the machine overnight. A defect that needs a night to appear needs a ledger to be
|
|
24
|
+
* seen at all.
|
|
25
|
+
*
|
|
26
|
+
* MEASURED 2026-08-19, Dream Cycle memory-durability night: this script cannot tell "the store root
|
|
27
|
+
* was wiped" from "the store root never existed on this host" — a fresh checkout, a CI runner, or
|
|
28
|
+
* this very nightly agent's own ephemeral container all read exactly one recorded ingest away from
|
|
29
|
+
* every other, i.e. IDENTICALLY to a real overnight wipe, because `missingIngests()` only diffs the
|
|
30
|
+
* ledger against `storesAt(root)`, and `storesAt()` silently returns `[]` on ENOENT (kb/store-root.mjs).
|
|
31
|
+
* `restore-local-ingests.mjs` had zero test coverage before tonight, and the repo's own dream-cycle
|
|
32
|
+
* operating notes told this very automation to read ANY non-zero exit as "the nightly bundle wiped
|
|
33
|
+
* local work" — so a routine fresh-checkout run would have logged a false wipe every single night.
|
|
34
|
+
*
|
|
35
|
+
* The fix follows the pattern this repo already uses correctly in `nightly-watchdog.mjs`
|
|
36
|
+
* (MISSING / NEVER-RAN / STALE, three states never collapsed into one alarm) and the general
|
|
37
|
+
* pattern for reconciling a durable recipe against a rebuildable cache (npm's `.package-lock.json`
|
|
38
|
+
* witness co-located with `node_modules`, Dynamo-style tombstones): check whether the root was ever
|
|
39
|
+
* initialized BEFORE treating its contents as evidence of loss.
|
|
40
|
+
*/
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { execFileSync } from 'node:child_process';
|
|
44
|
+
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { storeRoot, storesAt } from '../kb/store-root.mjs';
|
|
46
|
+
|
|
47
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
48
|
+
const LEDGER = path.join(ROOT, 'kb', 'local-ingests.json');
|
|
49
|
+
|
|
50
|
+
export const OK = 'OK';
|
|
51
|
+
export const NEVER_MATERIALIZED = 'NEVER-MATERIALIZED';
|
|
52
|
+
export const WIPED = 'WIPED';
|
|
53
|
+
|
|
54
|
+
/** Which recorded ingests are absent from the store root retrieval actually reads. */
|
|
55
|
+
export function missingIngests({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
|
|
56
|
+
let recorded = [];
|
|
57
|
+
try { recorded = JSON.parse(fs.readFileSync(ledgerFile, 'utf8')).ingests ?? []; } catch { return []; }
|
|
58
|
+
const present = new Set(storesAt(root));
|
|
59
|
+
return recorded.filter((e) => !present.has(e.store ?? e.name.toLowerCase()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify a diff between the recipe ledger and the live store root into three states, never one:
|
|
64
|
+
* OK every recorded ingest is present.
|
|
65
|
+
* NEVER-MATERIALIZED root does not exist on this host at all — nothing was ever ingested here.
|
|
66
|
+
* Not evidence of loss: a fresh checkout, CI runner, or this agent's own
|
|
67
|
+
* ephemeral container all look like this on a routine first run.
|
|
68
|
+
* WIPED root exists (so this host DID ingest before) but recorded entries are gone —
|
|
69
|
+
* the actual signal `restore-local-ingests.mjs` exists to catch.
|
|
70
|
+
*/
|
|
71
|
+
export function classify({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
|
|
72
|
+
const missing = missingIngests({ ledgerFile, root });
|
|
73
|
+
if (!missing.length) return { state: OK, missing };
|
|
74
|
+
if (!fs.existsSync(root)) return { state: NEVER_MATERIALIZED, missing };
|
|
75
|
+
return { state: WIPED, missing };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isMain = (() => {
|
|
79
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
80
|
+
catch { return false; }
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
if (isMain) {
|
|
84
|
+
const apply = process.argv.includes('--apply');
|
|
85
|
+
const { state, missing: gone } = classify();
|
|
86
|
+
if (state === OK) {
|
|
87
|
+
console.log(`[restore] every recorded local ingest is present in ${storeRoot()}`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
// NAMED, never counted. "4 stores missing" is the truncation that reads as completeness.
|
|
91
|
+
if (state === NEVER_MATERIALIZED) {
|
|
92
|
+
console.log(`\n[restore] ${storeRoot()} does not exist on this host — ${gone.length} recorded `
|
|
93
|
+
+ `ingest(s) were never materialized here. This is NOT evidence of a wipe (fresh checkout, CI `
|
|
94
|
+
+ `runner, or an ephemeral agent container all look like this):`);
|
|
95
|
+
} else {
|
|
96
|
+
console.log(`\n[restore] ${gone.length} recorded ingest(s) are NOT in ${storeRoot()}:`);
|
|
97
|
+
}
|
|
98
|
+
for (const e of gone) console.log(` ${e.name.padEnd(16)} ingested ${String(e.at).slice(0, 10)}`);
|
|
99
|
+
if (!apply) {
|
|
100
|
+
console.log('\n read-only. Re-run with --apply to re-ingest them.\n');
|
|
101
|
+
// NEVER-MATERIALIZED exits 2 (informational: nothing to restore FROM, just re-ingest) so a
|
|
102
|
+
// caller checking the exit code cannot mistake "never synced here" for "the nightly bundle
|
|
103
|
+
// wiped local work" — WIPED keeps exit 1, the real alarm.
|
|
104
|
+
process.exit(state === NEVER_MATERIALIZED ? 2 : 1);
|
|
105
|
+
}
|
|
106
|
+
let failed = 0;
|
|
107
|
+
for (const e of gone) {
|
|
108
|
+
console.log(`\n[restore] re-ingesting ${e.name}`);
|
|
109
|
+
try {
|
|
110
|
+
execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', e.name],
|
|
111
|
+
{ cwd: ROOT, stdio: 'inherit', env: process.env });
|
|
112
|
+
} catch { failed += 1; console.log(`[restore] ${e.name} FAILED — left recorded so the next run retries it`); }
|
|
113
|
+
}
|
|
114
|
+
console.log(`\n[restore] ${gone.length - failed}/${gone.length} restored.\n`);
|
|
115
|
+
process.exit(failed ? 1 : 0);
|
|
116
|
+
}
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -532,7 +532,15 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
|
|
|
532
532
|
export async function checkCoexistence({ home = os.homedir(), repo = null } = {}) {
|
|
533
533
|
const reg = await loadRegistry();
|
|
534
534
|
const registry = reg.buildRegistry({ repo: repo ?? reg.REPO, home, includeMachine: true });
|
|
535
|
-
|
|
535
|
+
// 'codex' joined hook-registry.mjs's mesh 2026-08-20 (Dream Cycle cross-host-conformance) — it is
|
|
536
|
+
// OURS (plugin/hooks/codex-hooks.json, shipped in package.json's "files" tree same as plugin/),
|
|
537
|
+
// never a foreign registration. Omitting it here silently undercounted `ourCount` and dropped all
|
|
538
|
+
// 16 Codex registrations from both buckets — caught by an independent critique before this shipped.
|
|
539
|
+
// 'codex' joined hook-registry.mjs's mesh 2026-08-20 (Dream Cycle cross-host-conformance) — it is
|
|
540
|
+
// OURS (plugin/hooks/codex-hooks.json, shipped in package.json's "files" tree same as plugin/),
|
|
541
|
+
// never a foreign registration. Omitting it here silently undercounted `ourCount` and dropped all
|
|
542
|
+
// 16 Codex registrations from both buckets — caught by an independent critique before this shipped.
|
|
543
|
+
const ours = registry.records.filter((r) => r.layer === 'plugin' || r.layer === 'codex' || r.layer === 'plugin-installed' || r.layer === 'marketplace-clone');
|
|
536
544
|
const foreign = registry.records.filter((r) => r.layer === 'user' || r.layer.startsWith('third-party:') || r.layer === 'project');
|
|
537
545
|
|
|
538
546
|
// DOUBLE REGISTRATION — reused wholesale from hook-registry (ADR-055 M1): one handler, an
|
|
@@ -52,7 +52,12 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
|
|
|
52
52
|
scoreClaimed: false,
|
|
53
53
|
sha,
|
|
54
54
|
tree: git('rev-parse', 'HEAD^{tree}'),
|
|
55
|
+
// NAME WHAT IS DIRTY, or the failure is unactionable. `release-qe` has failed on EVERY main run
|
|
56
|
+
// with a bare `INVALID_LINEAGE`, which says a clean tree is required and not one word about what
|
|
57
|
+
// made it unclean — so the release chain has been blocked with no way to see why from the log.
|
|
58
|
+
// The receipt now carries the offending paths; the seal is unchanged, only its evidence is.
|
|
55
59
|
dirty: Boolean(git('status', '--porcelain')),
|
|
60
|
+
dirtyPaths: String(git('status', '--porcelain') || '').split('\n').filter(Boolean).slice(0, 40),
|
|
56
61
|
version: manifest.version,
|
|
57
62
|
tag: `v${manifest.version}`,
|
|
58
63
|
sourceVersions: { package: manifest.version, claudePlugin: claude.version, codexPlugin: codex.version },
|
|
@@ -75,7 +80,12 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
|
|
|
75
80
|
],
|
|
76
81
|
};
|
|
77
82
|
const result = evaluateStabilizationCandidateReceipt(receipt);
|
|
78
|
-
if (result.verdict !== 'PASS')
|
|
83
|
+
if (result.verdict !== 'PASS') {
|
|
84
|
+
const detail = receipt.dirty && receipt.dirtyPaths?.length
|
|
85
|
+
? `\n working tree is dirty (${receipt.dirtyPaths.length} path(s)):\n ${receipt.dirtyPaths.join('\n ')}`
|
|
86
|
+
: '';
|
|
87
|
+
throw new Error(`stabilization seal failed: ${result.failures.map(({ code }) => code).join(',')}${detail}`);
|
|
88
|
+
}
|
|
79
89
|
return receipt;
|
|
80
90
|
}
|
|
81
91
|
|
|
Binary file
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-commands.mjs — four spellings of one command share ONE body, generated, never hand-copied.
|
|
4
|
+
*
|
|
5
|
+
* ISSUE #135. `/rvbc`, `/rvcb`, `/brain-console` and `/ruvnet-brain:configure` each declare, in their
|
|
6
|
+
* own words, that every spelling is equally valid and the user must never be corrected. Their BODIES
|
|
7
|
+
* had drifted into four independent hand-written specs, measured on 4.0.36:
|
|
8
|
+
*
|
|
9
|
+
* rvbc.md 4116 B rvcb.md 976 B brain-console.md 985 B configure.md 1680 B
|
|
10
|
+
*
|
|
11
|
+
* Four rules lived only in `rvbc.md`: speak first before any tool call; the "Things NOT to do before
|
|
12
|
+
* the page is open" list; the emphatic `run_in_background: true, ALWAYS` *with its reason*; and
|
|
13
|
+
* "already running is success, not an error." So which spelling a user happened to type changed how
|
|
14
|
+
* the assistant behaved — on the one command whose entire promise is that spelling does not matter.
|
|
15
|
+
*
|
|
16
|
+
* Two of those cost real silence. Without the "no knowledge search first" prohibition, grounding
|
|
17
|
+
* before opening the page is a legal move and a cold embedding-model load is allowed up to 180
|
|
18
|
+
* seconds. And `rvcb.md` kept the `run_in_background` RULE but dropped the REASON — that a foreground
|
|
19
|
+
* run hangs the call until the 120-second timeout — and a rule whose reason is missing is a rule that
|
|
20
|
+
* gets traded away.
|
|
21
|
+
*
|
|
22
|
+
* THE FIX IS NOT "COPY THE FOUR RULES ACROSS." That is what produced this: four copies of one fact,
|
|
23
|
+
* drifting the moment anyone edits one. It is ADR-065's rule applied to prompts instead of values —
|
|
24
|
+
* a fact that appears in more than one place gets exactly ONE producer, and every other occurrence is
|
|
25
|
+
* written from it. `rvbc.md` is the producer; the other three are generated.
|
|
26
|
+
*
|
|
27
|
+
* WHAT EACH ALIAS KEEPS: its own `description:`. That is not duplicated knowledge — it is what the
|
|
28
|
+
* user reads in the command picker, and each spelling legitimately introduces itself differently.
|
|
29
|
+
* Everything below the frontmatter is byte-identical by construction.
|
|
30
|
+
*
|
|
31
|
+
* node scripts/sync-commands.mjs # rewrite the aliases from the canonical body
|
|
32
|
+
* node scripts/sync-commands.mjs --check # fail if any alias has drifted (CI / pre-push)
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
39
|
+
const DIR = path.join(ROOT, 'plugin', 'commands');
|
|
40
|
+
const CANONICAL = 'rvbc.md';
|
|
41
|
+
const ALIASES = ['rvcb.md', 'brain-console.md', 'configure.md'];
|
|
42
|
+
const CHECK = process.argv.includes('--check');
|
|
43
|
+
|
|
44
|
+
/** Split `---\n…\n---\n` frontmatter from the body. Both are returned verbatim. */
|
|
45
|
+
export function splitFrontmatter(text) {
|
|
46
|
+
const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
|
|
47
|
+
if (!m) return { frontmatter: null, body: text };
|
|
48
|
+
return { frontmatter: m[1], body: text.slice(m[0].length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The alias's own description line, kept; everything else in the frontmatter comes from canonical. */
|
|
52
|
+
function rebuild(aliasText, canonicalFrontmatter, canonicalBody) {
|
|
53
|
+
const { frontmatter } = splitFrontmatter(aliasText);
|
|
54
|
+
const desc = /^description:.*(?:\n[ \t]+.*)*$/m.exec(frontmatter || '');
|
|
55
|
+
const rest = canonicalFrontmatter
|
|
56
|
+
.split('\n')
|
|
57
|
+
.filter((l) => !/^description:/.test(l))
|
|
58
|
+
.join('\n');
|
|
59
|
+
const head = [desc ? desc[0] : null, rest].filter(Boolean).join('\n');
|
|
60
|
+
return `---\n${head}\n---\n${canonicalBody}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// RUN THE CLI ONLY WHEN INVOKED AS ONE. Without this guard, `import { splitFrontmatter }` from a test
|
|
64
|
+
// executed the whole generator — measured: importing this file printed "[commands] synced 0 alias(es)"
|
|
65
|
+
// inside the test runner, and had vitest's argv contained `--check` it would have called process.exit
|
|
66
|
+
// and taken the runner down with it. The identical defect was fixed in tests/unit/nightly-convergence
|
|
67
|
+
// one commit earlier (importing bin/install.mjs ran the installer main); it recurred here because that
|
|
68
|
+
// fix was applied to the CALLER instead of to the module. Fixing it at the module is what makes it
|
|
69
|
+
// stop recurring — same reasoning as ADR-066: one producer, not a rule every caller must remember.
|
|
70
|
+
// realpathSync THROWS on a path that does not resolve, and under vitest on Windows `process.argv[1]`
|
|
71
|
+
// is not a real file — measured in CI as `ENOENT: lstat 'D:\D:'`, which failed the whole SUITE at
|
|
72
|
+
// import time rather than any assertion. A guard that decides "am I the entrypoint" must never be
|
|
73
|
+
// able to crash the caller that merely imported this module: not-resolvable means not-main.
|
|
74
|
+
function resolvedIsMain() {
|
|
75
|
+
try {
|
|
76
|
+
return Boolean(process.argv[1])
|
|
77
|
+
&& fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|
|
80
|
+
if (!resolvedIsMain()) { /* imported for its exports — do nothing else */ } else {
|
|
81
|
+
|
|
82
|
+
const canonicalText = fs.readFileSync(path.join(DIR, CANONICAL), 'utf8');
|
|
83
|
+
const { frontmatter: canonicalFrontmatter, body: canonicalBody } = splitFrontmatter(canonicalText);
|
|
84
|
+
if (!canonicalFrontmatter) {
|
|
85
|
+
process.stderr.write(`[commands] ${CANONICAL} has no frontmatter — refusing to generate from it\n`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const drift = [];
|
|
90
|
+
let wrote = 0;
|
|
91
|
+
for (const alias of ALIASES) {
|
|
92
|
+
const file = path.join(DIR, alias);
|
|
93
|
+
if (!fs.existsSync(file)) { drift.push(`${alias} (missing)`); continue; }
|
|
94
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
95
|
+
const after = rebuild(before, canonicalFrontmatter, canonicalBody);
|
|
96
|
+
if (after === before) continue;
|
|
97
|
+
if (CHECK) { drift.push(alias); continue; }
|
|
98
|
+
fs.writeFileSync(file, after);
|
|
99
|
+
process.stdout.write(`[commands] ${alias}: body regenerated from ${CANONICAL}\n`);
|
|
100
|
+
wrote += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (CHECK) {
|
|
104
|
+
if (drift.length) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
`[commands] DRIFT: ${drift.length} alias(es) disagree with ${CANONICAL}. A user who types one\n`
|
|
107
|
+
+ ' spelling would get different behaviour from one who types another, on the command\n'
|
|
108
|
+
+ ' that promises spelling does not matter. Run: node scripts/sync-commands.mjs\n',
|
|
109
|
+
);
|
|
110
|
+
for (const d of drift) process.stderr.write(` ${d}\n`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(`[commands] all ${ALIASES.length} alias(es) share ${CANONICAL}'s body\n`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(`[commands] synced ${wrote} alias(es) to ${CANONICAL}\n`);
|
|
117
|
+
}
|