ruvnet-brain 4.3.20 → 4.3.25

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +383 -78
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/host-shell-boundary.mjs +43 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/scripts/update-apply.mjs +2 -32
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  54. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  55. package/scripts/adr-072-completion.mjs +1 -1
  56. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  57. package/scripts/approved-runtime.mjs +197 -0
  58. package/scripts/brain-novice-50.mjs +16 -1
  59. package/scripts/brain-score.mjs +23 -5
  60. package/scripts/build-bundle.mjs +971 -530
  61. package/scripts/build-concepts.mjs +36 -116
  62. package/scripts/console-engine.test.mjs +8 -7
  63. package/scripts/console-runtime-identity.mjs +4 -0
  64. package/scripts/corpus-aggregates.mjs +94 -77
  65. package/scripts/corpus-candidate.mjs +475 -222
  66. package/scripts/corpus-next-seed.mjs +225 -0
  67. package/scripts/corpus-promotion.mjs +58 -0
  68. package/scripts/corpus-reconcile.mjs +411 -105
  69. package/scripts/doc-currency.mjs +16 -1
  70. package/scripts/dual-host-deliberation.mjs +25 -2
  71. package/scripts/dual-host-suggest.mjs +17 -1
  72. package/scripts/falsify.mjs +13 -3
  73. package/scripts/gist-receipts.mjs +482 -87
  74. package/scripts/github-health-watch.mjs +12 -2
  75. package/scripts/handoff-asset.mjs +34 -0
  76. package/scripts/hook-retirement-check.mjs +8 -1
  77. package/scripts/host-registry.mjs +1 -1
  78. package/scripts/ingest-gists.mjs +74 -101
  79. package/scripts/job-heartbeat.sh +77 -14
  80. package/scripts/learning-replay-execution.mjs +10 -4
  81. package/scripts/nightly-gists.sh +27 -13
  82. package/scripts/nightly-two-run-proof.mjs +1 -1
  83. package/scripts/nightly-watchdog.mjs +61 -4
  84. package/scripts/onboarding-console.mjs +319 -27
  85. package/scripts/oracle/produce-questions.mjs +293 -0
  86. package/scripts/oracle/producer-hosts.mjs +235 -0
  87. package/scripts/oracle/repo-recall.mjs +448 -0
  88. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  89. package/scripts/oracle/source-tree.mjs +165 -0
  90. package/scripts/oracle/source-units.mjs +391 -0
  91. package/scripts/oracle/spike-run.mjs +98 -0
  92. package/scripts/oracle/unit-inventory.mjs +141 -0
  93. package/scripts/oracle/unit-sampling.mjs +128 -0
  94. package/scripts/oracle/validate-labels.mjs +250 -0
  95. package/scripts/private-overlay.mjs +248 -0
  96. package/scripts/product-integrity-contract.mjs +1 -1
  97. package/scripts/proxy/claude-proxied.sh +6 -0
  98. package/scripts/proxy/proxy-revert.sh +5 -0
  99. package/scripts/proxy/proxy-up.sh +6 -0
  100. package/scripts/proxy/proxy-verify.mjs +4 -0
  101. package/scripts/public-inputs.mjs +409 -0
  102. package/scripts/public-verification-inputs.mjs +112 -26
  103. package/scripts/public-verification-lane.mjs +1 -1
  104. package/scripts/published-surface-probe.mjs +34 -4
  105. package/scripts/qe/card-lane-gate.mjs +16 -1
  106. package/scripts/qe/session-start-gate.mjs +16 -1
  107. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  108. package/scripts/record-lesson.mjs +4 -1
  109. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  110. package/scripts/release-abort-stale.mjs +5 -1
  111. package/scripts/release-authority.mjs +104 -12
  112. package/scripts/release-channel-kind.mjs +86 -0
  113. package/scripts/release-convergence-watchdog.mjs +7 -2
  114. package/scripts/release-projection.mjs +177 -72
  115. package/scripts/release-transaction-provider.mjs +23 -6
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -24,6 +24,7 @@
24
24
  * node scripts/github-health-watch.mjs --json # machine-readable, for hooks/CI
25
25
  */
26
26
  import { execFileSync } from 'node:child_process';
27
+ import { describeLatestPointer, latestCodeReleaseTag } from './release-channel-kind.mjs';
27
28
 
28
29
  const REPO = 'stuinfla/ruvnet-brain';
29
30
  const JSON_OUT = process.argv.includes('--json');
@@ -92,12 +93,21 @@ const npmLatest = (() => {
92
93
  try { return execFileSync('npm', ['view', 'ruvnet-brain', 'dist-tags.latest'], { encoding: 'utf8', timeout: 60_000 }).trim(); }
93
94
  catch { return null; }
94
95
  })();
95
- const release = ghJson(['api', `repos/${REPO}/releases/latest`, '--jq', '.tag_name']);
96
+ // ADR-086 S1: `releases/latest` is the customer download pointer and is a corpus generation on any
97
+ // night a corpus round shipped. A `corpus-sha256-<digest>` tag can NEVER equal an npm semver, so
98
+ // comparing against it would fire "issue #77 recurring" every single night, for a healthy system —
99
+ // a false alarm that trains the owner to ignore the one alert that matters.
100
+ const releases = ghJson(['api', `repos/${REPO}/releases?per_page=30`]);
101
+ const release = latestCodeReleaseTag(releases);
102
+ const latestPointer = (Array.isArray(releases) ? releases.find((row) => row && !row.draft) : null)?.tag_name || null;
96
103
  if (npmLatest && typeof release === 'string' && release) {
97
104
  if (npmLatest !== release.replace(/^v/, '')) {
98
- note('fail', 'surfaces', `npm ${npmLatest} != GitHub ${release}`,
105
+ note('fail', 'surfaces', `npm ${npmLatest} != GitHub code release ${release}`,
99
106
  'run scripts/published-surface-probe.mjs; this is issue #77 recurring');
100
107
  }
108
+ } else if (npmLatest && latestPointer) {
109
+ note('warn', 'surfaces', `no semver-tagged GitHub release found (${describeLatestPointer(latestPointer)})`,
110
+ 'npm-to-GitHub coherence could not be proven; check the release list by hand');
101
111
  }
102
112
 
103
113
  // ── 6. Issues past their response SLA. ──────────────────────────────────────────────────────────
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /** Copy a generated asset from a tunneled host to a user's client Downloads folder. */
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
6
+ import path from 'node:path';
7
+
8
+ const args = process.argv.slice(2);
9
+ const assetArg = args.find((arg) => arg.startsWith('--asset='));
10
+ const client = (args.find((arg) => arg.startsWith('--client='))?.slice(9) || 'm4').toLowerCase();
11
+ const clients = {
12
+ m4: 'stuartkerr@100.95.179.114',
13
+ air: 'macbook-air',
14
+ };
15
+ const host = args.find((arg) => arg.startsWith('--host='))?.slice(7) || clients[client];
16
+ const remoteDir = args.find((arg) => arg.startsWith('--remote-dir='))?.slice(13) || '/Users/stuartkerr/Downloads';
17
+ if (!assetArg || args.includes('--help') || args.includes('-h')) {
18
+ console.log('Usage: node scripts/handoff-asset.mjs --asset=/absolute/path/to/file [--client=m4|air] [--host=user@client] [--remote-dir=/Users/user/Downloads]');
19
+ process.exit(assetArg ? 0 : 2);
20
+ }
21
+ if (!host) { console.error(`Unknown client '${client}'. Use --client=m4, --client=air, or an explicit --host.`); process.exit(2); }
22
+ const asset = path.resolve(assetArg.slice(8));
23
+ if (!existsSync(asset)) { console.error(`Asset does not exist: ${asset}`); process.exit(2); }
24
+ const filename = path.basename(asset);
25
+ const localHash = createHash('sha256').update(readFileSync(asset)).digest('hex');
26
+ const copy = spawnSync('scp', ['-q', asset, `${host}:${remoteDir}/${filename}`], { encoding: 'utf8' });
27
+ if (copy.status !== 0) { console.error(copy.stderr || `scp failed with exit ${copy.status}`); process.exit(copy.status || 1); }
28
+ const verify = spawnSync('ssh', ['-o', 'BatchMode=yes', host, `shasum -a 256 ${JSON.stringify(`${remoteDir}/${filename}`)}`], { encoding: 'utf8' });
29
+ const remoteHash = verify.stdout.trim().split(/\s+/)[0];
30
+ if (verify.status !== 0 || remoteHash !== localHash) {
31
+ console.error(`Checksum verification failed for ${filename}: local=${localHash} remote=${remoteHash || 'unavailable'}`);
32
+ process.exit(1);
33
+ }
34
+ console.log(JSON.stringify({ host, path: `${remoteDir}/${filename}`, sha256: localHash, verified: true }, null, 2));
@@ -2,6 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { automaticHookRetirementStatus } from '../bin/install.mjs';
5
+ import { continuityRegistrations } from '../plugin/scripts/continuity-hook-policy.mjs';
5
6
 
6
7
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
8
 
@@ -13,7 +14,13 @@ export function main() {
13
14
  for (const item of result.registrations) console.error(` ${item.file} ${item.event}: ${item.command || '(empty command)'}`);
14
15
  return 1;
15
16
  }
16
- console.log(`hook-policy-check: PASS — continuity-only lifecycle plane (SessionStart + guarded Stop); all legacy Brain gates retired across ${result.files.length} source, contract, and host-pointer surfaces`);
17
+ // NAME THE PLANE, don't describe it from memory. The previous line said "SessionStart + guarded
18
+ // Stop" and kept saying it after the plane changed — a green check that describes the wrong
19
+ // system is worse than no check, because it is the thing people read instead of the manifest.
20
+ const plane = continuityRegistrations()
21
+ .map((spec) => `${spec.event}:${spec.id}[${spec.hosts.join('+')}]`).join(' ');
22
+ console.log(`hook-policy-check: PASS — continuity-only lifecycle plane (${plane});`
23
+ + ` all legacy Brain gates retired across ${result.files.length} source, contract, and host-pointer surfaces`);
17
24
  return 0;
18
25
  }
19
26
 
@@ -115,4 +115,4 @@ export function main(args = process.argv.slice(2)) {
115
115
  }
116
116
  }
117
117
 
118
- if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) process.exitCode = main();
118
+ if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) process.exitCode = main();
@@ -13,24 +13,37 @@
13
13
  // KB already stamps `ADR STATUS: PROPOSED` onto ADR passages. Retrieval then hands the model the
14
14
  // claim AND its epistemic status together; they cannot be separated downstream.
15
15
  //
16
- // node scripts/ingest-gists.mjs # incremental (only re-fetch changed gists)
17
- // node scripts/ingest-gists.mjs --full # ignore the cache, refetch everything
16
+ // STEP 2 (2026-09-13): this file no longer owns its own fetch+write+chunk logic. Actual content
17
+ // ingestion routes through the one canonical pipeline (gist-receipts.mjs's captureGistSources ->
18
+ // buildGistAggregate), the same producer corpus-reconcile.mjs and seal-gist-receipt.mjs use. This
19
+ // file's own job is now just: (1) the cheap human-index/discovery operation (--index-only,
20
+ // --dry-run — pure listing, no per-gist fetch), and (2) deciding WHEN to invoke the canonical
21
+ // pipeline and with what local capture cache, never re-rendering a passage itself.
22
+ //
23
+ // node scripts/ingest-gists.mjs # incremental (only re-fetches changed gists)
24
+ // node scripts/ingest-gists.mjs --full # ignore the local capture cache, refetch everything
18
25
  // node scripts/ingest-gists.mjs --owner ruvnet # default owner
19
26
  // node scripts/ingest-gists.mjs --dry-run # list what would change, write nothing
20
27
  //
21
- // Then embed (the store becomes searchable with no restart — forge-ask-all discovers *.rvf at query
22
- // time):
28
+ // Embedding is a SEPARATE step (this file never builds vectors — nightly-gists.sh's own sharded
29
+ // `forge-big.mjs shard-all` does, after this exits 0 with real changes):
23
30
  // node kb/forge-big.mjs both --dir kb --name ruv-gists
24
31
 
25
32
  import fs from 'node:fs';
26
33
  import path from 'node:path';
27
34
  import { spawnSync } from 'node:child_process';
28
35
  import { fileURLToPath } from 'node:url';
36
+ import { digest } from './coverage-integrity.mjs';
37
+ import { buildGistAggregate, captureGistSources } from './gist-receipts.mjs';
29
38
 
30
39
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
40
  const KB = path.join(ROOT, 'kb');
32
41
  const NAME = 'ruv-gists';
33
- const CACHE = path.join(KB, `.${NAME}.cache.json`);
42
+ // The RESUMABLE capture cache (raw body text included) -- kept in the preparation workspace (kb/,
43
+ // this job's own working directory) and NEVER published: build-bundle.mjs only ever ships named,
44
+ // non-dotfile artifacts. It is purely an optimization; a missing or `--full`-ignored cache just
45
+ // means every currently-listed gist is treated as changed and refetched.
46
+ const CAPTURE_CACHE = path.join(KB, `.${NAME}.capture-cache.json`);
34
47
 
35
48
  const argv = process.argv.slice(2);
36
49
  const arg = (f, d = null) => { const i = argv.indexOf(f); return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
@@ -44,10 +57,6 @@ const INDEX_ONLY = argv.includes('--index-only');
44
57
  const INDEX_PATH = path.join(ROOT, 'docs', 'RUV-GISTS.md');
45
58
  const FETCH_TIMEOUT_MS = Number(process.env.RUVNET_GISTS_FETCH_TIMEOUT_MS || 30_000);
46
59
 
47
- // Markdown/text only. A gist's code files are better read from the repo they land in; prose is the
48
- // thing repos don't carry.
49
- const TEXT_EXT = new Set(['.md', '.markdown', '.txt', '.rst']);
50
-
51
60
  /** Authenticated GitHub calls via `gh` — 5000 req/hr instead of 60, and no token handling here. */
52
61
  function gh(endpointArgs) {
53
62
  const r = spawnSync('gh', endpointArgs, {
@@ -102,31 +111,6 @@ async function listGistsPublic(owner) {
102
111
  return all;
103
112
  }
104
113
 
105
- /** Raw file bodies. The list endpoint truncates `content`, so fetch each gist individually. */
106
- function fetchGist(id) {
107
- return JSON.parse(gh(['api', `gists/${id}`]));
108
- }
109
-
110
- // ~3200-char paragraph-aligned chunks — same shape build-concepts.mjs uses, so the reader's chunk
111
- // handling and the `#N` suffix convention stay uniform across stores.
112
- function chunk(text, size = 3200) {
113
- const out = [];
114
- let buf = '';
115
- for (const para of text.split(/\n\n+/)) {
116
- if (buf && buf.length + para.length + 2 > size) { out.push(buf); buf = ''; }
117
- buf = buf ? `${buf}\n\n${para}` : para;
118
- }
119
- if (buf.trim()) out.push(buf);
120
- return out.length ? out : [];
121
- }
122
-
123
- /** The banner that travels WITH the text, so a retrieval hit can never lose its provenance. */
124
- const banner = (g, file) =>
125
- `SOURCE: GitHub gist by @${OWNER} — "${(g.description || file).replace(/\s+/g, ' ').trim().slice(0, 160)}"\n` +
126
- `GIST STATUS: rUv's own notes / release announcement — may describe PROPOSED or UNRELEASED work.\n` +
127
- `Treat as intent, not as confirmed shipped behavior: verify against repo source before asserting.\n` +
128
- `updated: ${g.updated_at?.slice(0, 10)} · https://gist.github.com/${OWNER}/${g.id}\n\n`;
129
-
130
114
  /** A tiny, git-trackable feed of what rUv has published, newest first. Costs ~5 API calls. */
131
115
  function writeIndex(gists) {
132
116
  const rows = [...gists].sort((a, b) => b.updated_at.localeCompare(a.updated_at));
@@ -153,10 +137,20 @@ function writeIndex(gists) {
153
137
  console.log(` wrote ${path.relative(ROOT, INDEX_PATH)} (${rows.length} rows)`);
154
138
  }
155
139
 
140
+ function loadCaptureCache() {
141
+ if (FULL || !fs.existsSync(CAPTURE_CACHE)) return null;
142
+ try { return JSON.parse(fs.readFileSync(CAPTURE_CACHE, 'utf8')); }
143
+ catch { return null; } // a corrupt local cache is a MISS, never a crash — everything refetches.
144
+ }
145
+
146
+ function saveCaptureCache(captured) {
147
+ try { fs.writeFileSync(CAPTURE_CACHE, JSON.stringify(captured)); }
148
+ catch (error) { console.error(` note: could not persist the local capture cache (${error.message})`); }
149
+ }
150
+
156
151
  async function main() {
157
152
  if (!fs.existsSync(KB)) { console.error(`ingest-gists: no kb dir at ${KB}`); process.exit(2); }
158
153
 
159
- const cache = !FULL && fs.existsSync(CACHE) ? JSON.parse(fs.readFileSync(CACHE, 'utf8')) : {};
160
154
  console.log(`ingest-gists: listing public gists for @${OWNER}…`);
161
155
  let gists;
162
156
  try {
@@ -174,7 +168,20 @@ async function main() {
174
168
 
175
169
  if (INDEX_ONLY) { writeIndex(gists); return; }
176
170
 
177
- const changed = gists.filter((g) => cache[g.id] !== g.updated_at);
171
+ // "changed" is computed against the ONE canonical schema-3 receipt (kb/ruv-gists.sources.json) —
172
+ // never a private cache file's idea of the truth. Absent/unreadable/pre-schema-3 reads as "nothing
173
+ // known yet", so every listed gist counts as changed (a correct, if conservative, first run).
174
+ const receiptFile = path.join(KB, `${NAME}.sources.json`);
175
+ let knownUpdatedAt = new Map();
176
+ if (!FULL && fs.existsSync(receiptFile)) {
177
+ try {
178
+ const receipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8'));
179
+ if (receipt.schemaVersion === 3) {
180
+ knownUpdatedAt = new Map(Object.entries(receipt.gists || {}).map(([id, row]) => [id, row.updatedAt]));
181
+ }
182
+ } catch { /* treat as "nothing known yet" */ }
183
+ }
184
+ const changed = gists.filter((g) => knownUpdatedAt.get(g.id) !== g.updated_at);
178
185
  console.log(` ${changed.length} new or updated since last run${FULL ? ' (--full: cache ignored)' : ''}`);
179
186
  if (DRY) {
180
187
  for (const g of changed.slice(0, 20)) console.log(` ${g.updated_at.slice(0, 10)} ${Object.keys(g.files)[0]}`);
@@ -186,73 +193,39 @@ async function main() {
186
193
  return;
187
194
  }
188
195
 
189
- // Full rebuild of the passage file (ids must stay dense and aligned with the .rvf idmap; a
190
- // partial append would desynchronise them — the failure mode that maps a vector to the wrong text).
191
- const passages = [];
192
- const entries = {};
193
- let id = 0;
194
- let files = 0;
195
- let skipped = 0;
196
-
197
- for (const [i, stub] of gists.entries()) {
198
- if (i % 25 === 0) process.stdout.write(`\r fetching ${i}/${gists.length}…`);
199
- let g;
200
- try {
201
- g = fetchGist(stub.id);
202
- } catch (err) {
203
- skipped++;
204
- console.error(`\n fetch failed for gist ${stub.id}: ${err.message}`);
205
- continue;
206
- }
207
- for (const [fname, f] of Object.entries(g.files || {})) {
208
- if (!TEXT_EXT.has(path.extname(fname).toLowerCase())) continue;
209
- // JSONL readers treat U+2028/U+2029 as physical line separators on some runtimes. Normalize
210
- // them before serialization so one JSON object always remains one physical passage line.
211
- const body = (f.truncated && f.raw_url ? '' : (f.content || '')).replace(/[\u2028\u2029]/g, '\n');
212
- if (!body.trim()) {
213
- skipped++;
214
- console.error(`\n empty or truncated text file: ${stub.id}/${fname}`);
215
- continue;
216
- }
217
- const title = (g.description || fname).replace(/\s+/g, ' ').trim().slice(0, 180) || fname;
218
- const head = banner(g, fname);
219
- // Banner on EVERY chunk, not just the first. Retrieval returns ONE chunk — if the provenance
220
- // lives only in chunk 0, then chunk 2 reaches the model as an unlabelled assertion, which is
221
- // exactly the fence this file claims to build. (Caught by reading a real retrieval: the top
222
- // hit for "enable the flywheel" was `…flywheel.md#2` and carried no status line.)
223
- const chunks = chunk(body);
224
- chunks.forEach((c, ci) => {
225
- const sid = String(id++);
226
- const p = `${g.id.slice(0, 8)}/${fname}${chunks.length > 1 ? `#${ci}` : ''}`;
227
- const text = head + c;
228
- passages.push({ id: sid, text, path: p, title });
229
- entries[sid] = { path: p, kind: 'doc', title, chunk: ci, preview: text.slice(0, 200) };
230
- });
231
- files++;
232
- }
233
- cache[stub.id] = stub.updated_at;
234
- }
235
- process.stdout.write('\r');
236
-
237
- // A receipt-only or partial corpus is more dangerous than a failed run: its RVF idmap can look
238
- // healthy while silently omitting live source. Preserve the previous complete corpus and make
239
- // the nightly job retry instead of publishing incomplete search data.
240
- if (skipped > 0) {
241
- throw new Error(`ingest-gists: refusing to write partial corpus (${skipped} fetch/content failures)`);
196
+ const observedAt = new Date().toISOString();
197
+ const rows = gists.map((g) => ({ id: g.id, updated_at: g.updated_at, files: g.files }));
198
+ const observation = {
199
+ owner: OWNER, observedAt,
200
+ observationSha256: digest({ owner: OWNER, rows: rows.map(({ id, updated_at }) => ({ id, updated_at })) }),
201
+ gists: { rows },
202
+ };
203
+ const now = () => observedAt;
204
+
205
+ // Capture once ourselves (reusing the local body-bearing cache for anything unchanged) so we have
206
+ // real raw bytes to persist for NEXT run's reuse. Handing that same captured set to
207
+ // buildGistAggregate as ITS cache means its own internal capture pass is a 100%-reuse, zero-network
208
+ // pass — this file still routes every byte through the one canonical pipeline, it just does not
209
+ // throw away the bytes it already paid to fetch.
210
+ const priorCache = loadCaptureCache();
211
+ const captured = await captureGistSources({ observation, cache: priorCache, now });
212
+ // Embedding is deliberately NOT done here (buildVector: null) — nightly-gists.sh's own sharded
213
+ // `forge-big.mjs shard-all` embeds afterward, only when this process reports real changes; see the
214
+ // module header. generation stays null and RVF-GENERATIONS.json is left untouched.
215
+ const result = await buildGistAggregate({ observation, cache: captured, outDir: KB, buildVector: null, now });
216
+ saveCaptureCache(captured);
217
+
218
+ if (result.omitted) {
219
+ console.log(`ingest-gists: @${OWNER} currently has zero public gists — aggregate omitted, nothing ingested.`);
220
+ writeIndex(gists);
221
+ return;
242
222
  }
243
-
244
- fs.writeFileSync(path.join(KB, `${NAME}.passages.jsonl`), passages.map((p) => JSON.stringify(p)).join('\n') + '\n');
245
- fs.writeFileSync(path.join(KB, `${NAME}.meta.json`), JSON.stringify({
246
- model: NAME, dimensions: 0, metric: 'cosine', name: NAME,
247
- generated: new Date().toISOString(), repo: `gists/${OWNER}`,
248
- note: "rUv's public gists — announcements and thinking, PROPOSED unless confirmed in repo source.",
249
- entries,
250
- }, null, 2));
251
- fs.writeFileSync(CACHE, JSON.stringify(cache, null, 2));
252
-
253
- console.log(`ingest-gists: ${gists.length} gists · ${files} text files · ${passages.length} passages · ${skipped} skipped`);
223
+ // Report OUR OWN capture pass's reuseEvidence, not buildGistAggregate's internal one -- the latter
224
+ // is a 100%-reuse pass by construction (it is handed the set WE just captured) and would always
225
+ // read "reused N, fetched 0" regardless of how much real network work just happened.
226
+ console.log(`ingest-gists: ${gists.length} gists · reused ${captured.reuseEvidence.reused.length} · fetched ${captured.reuseEvidence.fetched.length} fresh`);
254
227
  writeIndex(gists);
255
- console.log(` wrote kb/${NAME}.passages.jsonl + kb/${NAME}.meta.json`);
228
+ console.log(` wrote kb/${NAME}.passages.jsonl + kb/${NAME}.meta.json + kb/${NAME}.sources.json`);
256
229
  console.log(` next: node kb/forge-big.mjs both --dir kb --name ${NAME} (embed → ${NAME}.big.rvf)`);
257
230
  }
258
231
 
@@ -6,16 +6,41 @@
6
6
  # identical to one that ran and succeeded. That ambiguity let com.ruvnet.brain-nightly sit unfired
7
7
  # and look healthy. Per-job good intentions rot; a wrapper cannot forget.
8
8
  #
9
+ # WHY THE WRAPPER, NOT THE CHILD, WRITES THE TERMINAL RECORD (2026-09-11 review correction): a
10
+ # SIGKILLed child cannot write its own outcome — no handler runs, by definition. So this script never
11
+ # asks the wrapped command to self-report; the WRAPPER is the supervisor. It writes "running" before
12
+ # the child starts, watches it via `wait`, and derives the terminal record (ok / failed / killed)
13
+ # from the child's OWN wait status — the one piece of truth a dying child cannot fake or withhold.
14
+ #
9
15
  # Usage (from a LaunchAgent's ProgramArguments):
10
16
  # /bin/sh /path/to/job-heartbeat.sh <label> -- <command> [args...]
11
17
  #
12
18
  # Guarantees:
13
- # 1. A "start" receipt is written BEFORE the command runs.
14
- # 2. An "end" receipt with the REAL exit code is written even if the command dies, is killed, or
15
- # the machine yanks it away — the trap fires on EXIT/INT/TERM. There is no silent death.
16
- # 3. A non-zero exit pushes an URGENT ntfy alert immediately (topic: $NTFY_TOPIC, or the file
19
+ # 1. A "start" receipt is written BEFORE the command runs, carrying a `run_id` unique to THIS
20
+ # invocation (pid + start-epoch). A second, concurrent invocation for the same label gets its
21
+ # own run_id and overwrites the "running" record with its own — see the STALE WRITER GUARD
22
+ # below for what that means for the terminal write.
23
+ # 2. A terminal receipt is written even if the command dies, is killed, or the machine yanks it
24
+ # away — the trap fires on EXIT/INT/TERM. Its `state` is one of:
25
+ # ok — exited zero.
26
+ # failed — exited non-zero, NOT via a signal (POSIX: an exit code in 1..128).
27
+ # killed — died to a signal (POSIX: `wait` reports 128+signal for a signal-killed child, and
28
+ # the two in-wrapper traps below reuse that exact convention for TERM/INT delivered
29
+ # to the WRAPPER itself). The signal number is carried as `"signal"`.
30
+ # SIGKILL (-9) of the WRAPPER itself is the one death nothing here can catch — no handler runs,
31
+ # the receipt is left stuck at "running" with its run_id, and that is deliberately NOT
32
+ # "fixed" here: scripts/nightly-watchdog.mjs already derives FAILING from a stale receipt whose
33
+ # pid no longer exists (see its `judge()` — this is the "started and never finished" case,
34
+ # because a run_id can never reach a terminal state if nothing survived to write one).
35
+ # 3. STALE WRITER GUARD: before writing the terminal record, the wrapper re-reads the on-disk
36
+ # receipt's run_id. If some OTHER, newer invocation has since overwritten it (a different
37
+ # run_id — meaning a fresh "running" record was written after this one), this process's
38
+ # terminal write is REFUSED. An old, dying writer must never clobber a newer run's evidence
39
+ # with its own stale outcome; the newer run owns the receipt and will write its own terminal
40
+ # record when it finishes.
41
+ # 4. A non-zero exit pushes an URGENT ntfy alert immediately (topic: $NTFY_TOPIC, or the file
17
42
  # ~/.cache/ruvnet-brain/ntfy-topic). No topic = no push, but the receipt is still written.
18
- # 4. The wrapper's own exit code is the job's exit code — launchd still sees the truth.
43
+ # 5. The wrapper's own exit code is the job's exit code — launchd still sees the truth.
19
44
 
20
45
  set -u
21
46
 
@@ -28,9 +53,23 @@ HB_DIR="${JOB_HEARTBEAT_DIR:-$HOME/.cache/ruvnet-brain/heartbeats}"
28
53
  mkdir -p "$HB_DIR"
29
54
  HB="$HB_DIR/$LABEL.json"
30
55
 
56
+ # Atomic write: tmp file (same filesystem) + rename. `cat > "$HB"` directly would let a concurrent
57
+ # reader (nightly-watchdog.mjs, or another job-heartbeat.sh instance's stale-writer check) observe a
58
+ # half-written file mid-write and fail to parse it — a real, if rare, race under load. `mv` on the
59
+ # same filesystem is a single rename() syscall: readers see either the old content or the new, never
60
+ # a partial file.
61
+ write_receipt() { # write_receipt <content>
62
+ tmp="$HB.tmp-$$"
63
+ printf '%s' "$1" > "$tmp" && mv -f "$tmp" "$HB"
64
+ }
65
+
31
66
  ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
32
67
  STARTED="$(ts)"
33
68
  START_EPOCH="$(date +%s)"
69
+ # RUN_ID: pid + start-epoch-second. Unique enough to tell "this invocation" apart from any other
70
+ # invocation of the SAME label that might be alive at the same time — a second's collision would
71
+ # also require a recycled pid inside that same second, which the OS does not do.
72
+ RUN_ID="$$-$START_EPOCH"
34
73
 
35
74
  # F3 (2026-07-18): remember the receipt as it was BEFORE this fire. A skip-fire (exit 75, the
36
75
  # reserved "another instance is already running" code) must not destroy the live run's evidence —
@@ -42,9 +81,7 @@ PREV_HB=""
42
81
 
43
82
  # Start receipt. If the job vanishes without ever writing an end receipt, THIS is the evidence that
44
83
  # it started and never finished — a state the watchdog reports as FAILING, not as silence.
45
- cat > "$HB" <<EOF
46
- {"label":"$LABEL","started_at":"$STARTED","state":"running","pid":$$,"command":"$(echo "$@" | sed 's/"/\\"/g')"}
47
- EOF
84
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(echo "$@" | sed 's/"/\\"/g')\"}"
48
85
 
49
86
  notify() { # notify <title> <body> <priority>
50
87
  # NTFY_TOPIC set-but-EMPTY is an explicit opt-out (2026-07-18): unit tests wrap this script around
@@ -59,6 +96,12 @@ notify() { # notify <title> <body> <priority>
59
96
  curl -sS -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: rotating_light" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1 || true
60
97
  }
61
98
 
99
+ # The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
100
+ # none (an old receipt written before this field existed). No jq dependency — one anchored sed.
101
+ current_run_id() {
102
+ sed -n 's/.*"run_id":"\([^"]*\)".*/\1/p' "$HB" 2>/dev/null | head -1
103
+ }
104
+
62
105
  finish() {
63
106
  code=${FORCED_CODE:-$?}
64
107
  ended="$(ts)"
@@ -67,15 +110,32 @@ finish() {
67
110
  # survives; report 0 to launchd (a skip is not a failure). If no receipt ever existed, record an
68
111
  # honest "skipped" — which the watchdog treats as NOT proof of a real run.
69
112
  if [ "$code" -eq 75 ]; then
70
- if [ -n "$PREV_HB" ]; then printf '%s' "$PREV_HB" > "$HB"; else
71
- printf '{"label":"%s","started_at":"%s","ended_at":"%s","state":"skipped","duration_sec":%s}' "$LABEL" "$STARTED" "$ended" "$dur" > "$HB"
113
+ if [ -n "$PREV_HB" ]; then write_receipt "$PREV_HB"; else
114
+ write_receipt "$(printf '{"label":"%s","started_at":"%s","ended_at":"%s","state":"skipped","duration_sec":%s,"run_id":"%s"}' "$LABEL" "$STARTED" "$ended" "$dur" "$RUN_ID")"
72
115
  fi
73
116
  exit 0
74
117
  fi
75
- if [ "$code" -eq 0 ]; then state="ok"; else state="failed"; fi
76
- cat > "$HB" <<EOF
77
- {"label":"$LABEL","started_at":"$STARTED","ended_at":"$ended","state":"$state","exit_code":$code,"duration_sec":$dur}
78
- EOF
118
+ # STALE WRITER GUARD: a newer invocation for this same label may have already taken the receipt
119
+ # over (its own "running" record carries a DIFFERENT run_id). If so, this process's outcome is
120
+ # stale — it must not overwrite evidence that belongs to a run that is still, or already, ahead of
121
+ # it. Say so on stderr (so it is not a silent no-op) and exit with the real code regardless.
122
+ ON_DISK_RUN_ID="$(current_run_id)"
123
+ if [ -n "$ON_DISK_RUN_ID" ] && [ "$ON_DISK_RUN_ID" != "$RUN_ID" ]; then
124
+ echo "job-heartbeat: $LABEL receipt now belongs to run $ON_DISK_RUN_ID — not overwriting it with stale run $RUN_ID (exit $code)" >&2
125
+ exit "$code"
126
+ fi
127
+ # Derive ok / failed / killed from the CHILD'S OWN wait status, never from anything the child said
128
+ # about itself. POSIX: a process terminated by signal N is reported by `wait`/`$?` as 128+N — the
129
+ # same convention this wrapper's own TERM/INT traps below use when THEY are what killed the child.
130
+ if [ "$code" -eq 0 ]; then
131
+ state="ok"; extra=""
132
+ elif [ "$code" -gt 128 ]; then
133
+ sig=$((code - 128))
134
+ state="killed"; extra=",\"signal\":$sig"
135
+ else
136
+ state="failed"; extra=""
137
+ fi
138
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
79
139
  # Gong on failure, immediately — not at the next watchdog sweep. A failing nightly should reach the
80
140
  # phone while it is still tonight's problem.
81
141
  if [ "$code" -ne 0 ]; then
@@ -85,6 +145,9 @@ EOF
85
145
  }
86
146
  trap finish EXIT
87
147
  # A signal handler must KILL THE CHILD, then exit — letting the EXIT trap write the receipt once.
148
+ # The exit codes (143 = 128+15 TERM, 130 = 128+2 INT) intentionally reuse the same 128+signal
149
+ # convention `wait` uses for a directly-signalled child, so finish() classifies BOTH as "killed"
150
+ # with the right signal number, whether the signal reached the child directly or via this wrapper.
88
151
  trap 'FORCED_CODE=143; kill -TERM "$CHILD" 2>/dev/null; exit 143' TERM
89
152
  trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
90
153
 
@@ -27,10 +27,15 @@ const MUTATING_SUBCOMMANDS = new Set([
27
27
  'backup', 'init', 'configure',
28
28
  ]);
29
29
 
30
- function spawnRuflo(bin, args, options) {
30
+ // Every `ruflo` invocation auto-starts a project background daemon unless RUFLO_DAEMON_AUTOSTART=0
31
+ // is set (verified live against the installed CLI: ~/.npm-global/lib/node_modules/ruflo/
32
+ // node_modules/@claude-flow/cli/dist/src/services/daemon-autostart.js:85). Applied here, once, so
33
+ // every caller of spawnRuflo() gets it regardless of whether it passed its own `env`.
34
+ function spawnRuflo(bin, args, options = {}) {
35
+ const opts = { ...options, env: { ...(options.env || process.env), RUFLO_DAEMON_AUTOSTART: '0' } };
31
36
  return /\.[cm]?js$/i.test(bin)
32
- ? spawnSync(process.execPath, [bin, ...args], options)
33
- : spawnSync(bin, args, options);
37
+ ? spawnSync(process.execPath, [bin, ...args], opts)
38
+ : spawnSync(bin, args, opts);
34
39
  }
35
40
 
36
41
  export function assertRetrieved(out) {
@@ -115,7 +120,7 @@ export function executeProducedCommand(cmd, {
115
120
  .find((value) => MUTATING_SUBCOMMANDS.has(value));
116
121
  if (mutating) return reject(`refused mutating subcommand "${mutating}"`, { argv: ['ruflo', ...args] });
117
122
 
118
- const env = { ...process.env };
123
+ const env = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
119
124
  delete env.CLAUDE_FLOW_DB_PATH;
120
125
  delete env.CLAUDE_FLOW_MEMORY_PATH;
121
126
  const result = spawnRuflo(ruflo, args, {
@@ -146,6 +151,7 @@ export function verifyRufloFlag(bin = RUFLO_BIN) {
146
151
  const result = spawnSync(bin, ['memory', 'search', '--help'], {
147
152
  encoding: 'utf8',
148
153
  timeout: 30_000,
154
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
149
155
  });
150
156
  const output = `${result.stdout || ''}${result.stderr || ''}`;
151
157
  if (result.status !== 0 && !output) return { ok: false, why: `help exited ${result.status} empty` };
@@ -22,9 +22,27 @@ export KB_MODEL_CACHE="${KB_MODEL_CACHE:-/Users/stuartkerr/Code/PowerPlatePulse/
22
22
 
23
23
  mkdir -p logs
24
24
  LOG="logs/gists-nightly.log"
25
+ # Heartbeat file: written every 30s to prove the job is still running.
26
+ # Must match JOB_HEARTBEAT_DIR convention from job-heartbeat.sh and nightly-watchdog.mjs.
27
+ HEARTBEAT_FILE="${JOB_HEARTBEAT_DIR:-$HOME/.cache/ruvnet-brain/heartbeats}/com.ruvnet.brain-gists.heartbeat"
25
28
  ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
26
29
  log() { printf '[%s] %s\n' "$(ts)" "$1" >>"$LOG"; }
27
30
 
31
+ # Heartbeat: write a timestamp every 30 seconds to prove the job is still running.
32
+ # Runs in background; killed when the main script exits.
33
+ start_heartbeat() {
34
+ mkdir -p "$(dirname "$HEARTBEAT_FILE")"
35
+ (
36
+ while true; do
37
+ printf '%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$HEARTBEAT_FILE"
38
+ sleep 30
39
+ done
40
+ ) &
41
+ HEARTBEAT_PID=$!
42
+ trap "kill $HEARTBEAT_PID 2>/dev/null || true" EXIT
43
+ }
44
+ start_heartbeat
45
+
28
46
  log "start"
29
47
 
30
48
  command -v gh >/dev/null 2>&1 || { log "FATAL: gh not on PATH"; exit 1; }
@@ -52,20 +70,16 @@ fi
52
70
  # POSIX, so a failed shard was structurally invisible — the script would proceed to ingest and log
53
71
  # "done — rebuilt" over a half-embedded corpus. Now every shard PID is waited on individually and a
54
72
  # single failure aborts BEFORE ingest, loudly. "done" is only printed over a fully-embedded corpus.
73
+ #
74
+ # 2026-09-11: the manual `&`/`wait` fan-out above (kept in history, replaced below) could not see
75
+ # whether a shard was still doing WORK or just still alive — an 8-shard run once sat at 0% CPU for
76
+ # six hours with every pid alive and no failure ever detected. `forge-big.mjs shard-all` is the
77
+ # supervising "parent embed process": it owns the fan-out itself, reads each shard's per-batch
78
+ # progress file, and SIGTERMs+exits non-zero the moment any shard stops advancing for longer than
79
+ # its stall budget — so a genuine hang aborts in minutes instead of surviving until a human notices.
55
80
  log "corpus changed — re-embedding"
56
- i=0
57
- pids=""
58
- while [ "$i" -lt 8 ]; do
59
- node kb/forge-big.mjs embed --dir kb --name ruv-gists --shard "$i" --of 8 >>"$LOG" 2>&1 &
60
- pids="$pids $!"
61
- i=$((i + 1))
62
- done
63
- FAILED_SHARDS=0
64
- for p in $pids; do
65
- wait "$p" || FAILED_SHARDS=$((FAILED_SHARDS + 1))
66
- done
67
- if [ "$FAILED_SHARDS" -gt 0 ]; then
68
- log "EMBED FAILED — $FAILED_SHARDS of 8 shards exited nonzero; refusing to ingest a half-embedded corpus"
81
+ if ! node kb/forge-big.mjs shard-all --dir kb --name ruv-gists --shards 8 --stall-minutes 15 >>"$LOG" 2>&1; then
82
+ log "EMBED FAILED — shard-all reported a failure or a stall; refusing to ingest a half-embedded corpus"
69
83
  exit 1
70
84
  fi
71
85
  node kb/forge-big.mjs ingest --dir kb --name ruv-gists >>"$LOG" 2>&1 || { log "INGEST FAILED — store NOT rebuilt"; exit 1; }
@@ -599,7 +599,7 @@ export async function runNightlyTwoRunProof({ packagePath, bundlePath, out, time
599
599
  }
600
600
  }
601
601
 
602
- if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
602
+ if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) {
603
603
  try {
604
604
  const args = parseArgs(process.argv.slice(2));
605
605
  const receipt = await runNightlyTwoRunProof({ packagePath: args.package, bundlePath: args.bundle, out: args.out,