ruvnet-brain 4.3.21 → 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.
- package/README.md +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
118
|
+
if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) process.exitCode = main();
|
package/scripts/ingest-gists.mjs
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
package/scripts/job-heartbeat.sh
CHANGED
|
@@ -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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
71
|
-
printf '{"label":"%s","started_at":"%s","ended_at":"%s","state":"skipped","duration_sec":%s}' "$LABEL" "$STARTED" "$ended" "$dur"
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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],
|
|
33
|
-
: spawnSync(bin, args,
|
|
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` };
|
package/scripts/nightly-gists.sh
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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 (
|
|
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,
|