ruvnet-brain 4.3.26 → 4.3.28

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 (52) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +19 -4
  3. package/data/model-catalog.json +1 -1
  4. package/docs/RELEASE-NOTES-4.0.md +4 -3
  5. package/kb/capability-only.mjs +27 -0
  6. package/kb/capability-summaries/cognitum-ruos/CAPABILITIES.md +26 -0
  7. package/kb/verify-citation.mjs +16 -4
  8. package/package.json +5 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/docs/RELEASE-NOTES-4.0.md +4 -3
  12. package/plugin/hooks/codex-hooks.json +6 -1
  13. package/plugin/hooks/hook-contracts.json +24 -2
  14. package/plugin/hooks/hooks.json +6 -1
  15. package/plugin/scripts/capacity-aware-parallel-work.mjs +200 -0
  16. package/plugin/scripts/codex-hook-adapter.mjs +37 -0
  17. package/plugin/scripts/continuity-hook-policy.mjs +4 -0
  18. package/plugin/scripts/coverage-integrity.mjs +17 -0
  19. package/plugin/scripts/hook-shim.mjs +1 -0
  20. package/plugin/scripts/lesson-gate.mjs +4 -1
  21. package/plugin/scripts/project-progression-reader.mjs +12 -1
  22. package/plugin/scripts/project-progression-session-start.mjs +24 -6
  23. package/plugin/scripts/project-progression-store.mjs +76 -4
  24. package/plugin/skills/release-proof/SKILL.md +28 -4
  25. package/plugin/skills/release-proof/scripts/release-proof.mjs +48 -24
  26. package/scripts/brain-novice-50.mjs +14 -16
  27. package/scripts/build-bundle.mjs +2 -0
  28. package/scripts/candidate-host-evidence.mjs +44 -16
  29. package/scripts/corpus-dispatch-receipt.mjs +22 -0
  30. package/scripts/corpus-reconcile.mjs +26 -4
  31. package/scripts/doc-currency.mjs +12 -4
  32. package/scripts/eval-brain.mjs +7 -5
  33. package/scripts/gist-git-transport.mjs +218 -0
  34. package/scripts/gist-receipts.mjs +168 -38
  35. package/scripts/host-install-matrix.mjs +54 -11
  36. package/scripts/ingest-gists.mjs +5 -2
  37. package/scripts/installed-brain-health.mjs +99 -0
  38. package/scripts/prepublication-evidence.mjs +8 -0
  39. package/scripts/public-inputs.mjs +2 -1
  40. package/scripts/public-verification-inputs.mjs +14 -6
  41. package/scripts/publication-receipt.mjs +71 -30
  42. package/scripts/qe/ux-suite.mjs +17 -5
  43. package/scripts/refresh-capability-only-store.mjs +143 -0
  44. package/scripts/release-qualification-contract.mjs +11 -1
  45. package/scripts/release-vector.mjs +44 -20
  46. package/scripts/run-operational-benchmark.mjs +151 -0
  47. package/scripts/run-operational-benchmark.v3.mjs +194 -0
  48. package/scripts/self-update.mjs +2 -0
  49. package/scripts/source-coverage.mjs +6 -1
  50. package/scripts/staged-host-verifier.mjs +2 -1
  51. package/scripts/sync-version.mjs +10 -2
  52. package/scripts/wired-check.mjs +73 -45
@@ -33,6 +33,7 @@ import { fileURLToPath } from 'node:url';
33
33
  import { canonicalJson, digest, validateGistAggregateReceipt } from './coverage-integrity.mjs';
34
34
  import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
35
35
  import { writeRvfGeneration } from './rvf-generation.mjs';
36
+ import { fetchGistGitSnapshot } from './gist-git-transport.mjs';
36
37
 
37
38
  const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
39
  const TEXT_EXT = new Set(['.md', '.markdown', '.txt', '.rst']);
@@ -45,6 +46,7 @@ const HEX_GIST = /^[a-f0-9]{20,64}$/;
45
46
  const OWNER_RE = /^[A-Za-z0-9-]{1,39}$/;
46
47
  const compareCanonicalText = (left, right) => String(left) < String(right) ? -1
47
48
  : String(left) > String(right) ? 1 : 0;
49
+ const VERIFIED_CAPTURE_SETS = new WeakMap();
48
50
 
49
51
  function validDate(value) {
50
52
  return typeof value === 'string' && Number.isFinite(Date.parse(value));
@@ -122,38 +124,67 @@ const RATE_LIMIT_RE = /rate limit/i;
122
124
  const NOT_FOUND_RE = /\bnot found\b|HTTP 404/i;
123
125
  const FORBIDDEN_INTEGRATION_RE = /resource not accessible by integration/i;
124
126
  const TRANSIENT_RE = /timeout|timed out|TLS handshake|ECONNRESET|ECONNREFUSED|EAI_AGAIN|temporary failure|HTTP 5\d\d|HTTP 429|socket hang up/i;
127
+ const MAX_RATE_LIMIT_WAIT_MS = 15 * 60 * 1000;
128
+
129
+ function retryDelay(error, attempt, retryDelayMs) {
130
+ if (error?.code !== 'GIST_RATE_LIMITED') return retryDelayMs * attempt;
131
+ const retryAfter = Number(error.headers?.['retry-after']);
132
+ const resetAt = Number(error.headers?.['x-ratelimit-reset']);
133
+ const requested = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000
134
+ : Number.isFinite(resetAt) && resetAt > 0 ? Math.max(0, resetAt * 1000 - Date.now()) : retryDelayMs * attempt;
135
+ return requested > MAX_RATE_LIMIT_WAIT_MS ? null : Math.max(retryDelayMs * attempt, requested);
136
+ }
125
137
 
126
138
  export class GistFetchError extends Error {
127
- constructor(message, { code, gistId, status, retryable = false } = {}) {
139
+ constructor(message, { code, gistId, status, headers = {}, retryable = false } = {}) {
128
140
  super(message);
129
141
  this.name = 'GistFetchError';
130
142
  this.code = code;
131
143
  this.gistId = gistId;
132
144
  if (status !== undefined) this.status = status;
145
+ if (headers && Object.keys(headers).length) this.headers = headers;
133
146
  this.retryable = retryable;
134
147
  }
135
148
  }
136
149
 
137
- function classifyGistFetchFailure(stderr, gistId) {
150
+ function classifyGistFetchFailure(stderr, gistId, { status, headers = {} } = {}) {
138
151
  const message = String(stderr || '').trim();
139
- if (NOT_FOUND_RE.test(message)) {
140
- return new GistFetchError(`gist ${gistId} was moved or deleted: ${message}`,
141
- { code: 'GIST_NOT_FOUND', gistId, retryable: false });
152
+ const responseStatus = status ?? (Number(message.match(/\bHTTP\s+(\d{3})\b/i)?.[1]) || undefined);
153
+ const safeHeaders = Object.fromEntries(['x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after', 'x-ratelimit-resource', 'x-github-request-id']
154
+ .flatMap((key) => headers[key] == null ? [] : [[key, String(headers[key])]]));
155
+ const responseDetail = [responseStatus ? `HTTP ${responseStatus}` : '', Object.keys(safeHeaders).length
156
+ ? `headers=${JSON.stringify(safeHeaders)}` : '', message].filter(Boolean).join('; ');
157
+ if (responseStatus === 404 || (!responseStatus && NOT_FOUND_RE.test(message))) {
158
+ return new GistFetchError(`gist ${gistId} was moved or deleted: ${responseDetail}`,
159
+ { code: 'GIST_NOT_FOUND', gistId, status: responseStatus, headers: safeHeaders, retryable: false });
142
160
  }
143
- if (FORBIDDEN_INTEGRATION_RE.test(message)) {
144
- return new GistFetchError(`gist ${gistId} fetch rejected -- token lacks gist scope: ${message}`,
145
- { code: 'GIST_FORBIDDEN', gistId, retryable: false });
161
+ if (RATE_LIMIT_RE.test(message) || (responseStatus === 403 && headers['x-ratelimit-remaining'] === '0')
162
+ || (responseStatus === 429 && Boolean(headers['retry-after']))) {
163
+ return new GistFetchError(`gist ${gistId} fetch rate-limited: ${responseDetail}`,
164
+ { code: 'GIST_RATE_LIMITED', gistId, status: responseStatus, headers: safeHeaders, retryable: true });
146
165
  }
147
- if (RATE_LIMIT_RE.test(message)) {
148
- return new GistFetchError(`gist ${gistId} fetch rate-limited: ${message}`,
149
- { code: 'GIST_RATE_LIMITED', gistId, retryable: true });
166
+ if (responseStatus === 403 && (FORBIDDEN_INTEGRATION_RE.test(message) || !headers['x-ratelimit-remaining'])) {
167
+ return new GistFetchError(`gist ${gistId} fetch rejected (HTTP 403): ${responseDetail}`,
168
+ { code: 'GIST_FORBIDDEN', gistId, status: responseStatus, headers: safeHeaders, retryable: false });
150
169
  }
151
- if (TRANSIENT_RE.test(message)) {
152
- return new GistFetchError(`gist ${gistId} fetch failed transiently: ${message}`,
153
- { code: 'GIST_TRANSIENT_FAILURE', gistId, retryable: true });
170
+ if (TRANSIENT_RE.test(message) || (responseStatus >= 500 && responseStatus < 600) || responseStatus === 429) {
171
+ return new GistFetchError(`gist ${gistId} fetch failed transiently: ${responseDetail}`,
172
+ { code: 'GIST_TRANSIENT_FAILURE', gistId, status: responseStatus, headers: safeHeaders, retryable: true });
154
173
  }
155
- return new GistFetchError(`gist ${gistId} fetch failed: ${message}`,
156
- { code: 'GIST_FETCH_FAILED', gistId, retryable: false });
174
+ return new GistFetchError(`gist ${gistId} fetch failed: ${responseDetail}`,
175
+ { code: 'GIST_FETCH_FAILED', gistId, status: responseStatus, headers: safeHeaders, retryable: false });
176
+ }
177
+
178
+ function parseGhResponse(output) {
179
+ const text = String(output || '');
180
+ const match = text.match(/^HTTP\/\S+\s+(\d+)\r?\n([\s\S]*?)\r?\n\r?\n([\s\S]*)$/m);
181
+ if (!match) return { status: undefined, headers: {}, body: text };
182
+ const headers = {};
183
+ for (const line of match[2].split(/\r?\n/)) {
184
+ const split = line.indexOf(':');
185
+ if (split > 0) headers[line.slice(0, split).trim().toLowerCase()] = line.slice(split + 1).trim();
186
+ }
187
+ return { status: Number(match[1]), headers, body: match[3] };
157
188
  }
158
189
 
159
190
  function defaultSleep(ms) {
@@ -172,11 +203,14 @@ export async function defaultFetchGist(id, { spawn = spawnSync, retries = 3, ret
172
203
  const attempts = Math.max(1, retries);
173
204
  let lastError;
174
205
  for (let attempt = 1; attempt <= attempts; attempt++) {
175
- const result = spawn('gh', ['api', `gists/${id}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
176
- if (result.status === 0) return JSON.parse(result.stdout);
177
- lastError = classifyGistFetchFailure(result.stderr, id);
206
+ const result = spawn('gh', ['api', '--include', `gists/${id}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
207
+ const response = parseGhResponse(result.stdout);
208
+ if (result.status === 0) return JSON.parse(response.body);
209
+ lastError = classifyGistFetchFailure(`${result.stderr || ''}\n${response.body || ''}`, id, response);
178
210
  if (!lastError.retryable || attempt === attempts) throw lastError;
179
- await sleep(retryDelayMs * attempt);
211
+ const delay = retryDelay(lastError, attempt, retryDelayMs);
212
+ if (delay === null) throw lastError;
213
+ await sleep(delay);
180
214
  if (signal?.aborted) throw abortError(signal);
181
215
  }
182
216
  throw lastError;
@@ -185,17 +219,34 @@ export async function defaultFetchGist(id, { spawn = spawnSync, retries = 3, ret
185
219
  // The unauthenticated fallback used ONLY for the specific 403 "no gist scope" rejection. Mirrors
186
220
  // source-coverage.mjs's listGistsUnauthenticated: same env var, same accept header, same
187
221
  // unauthenticated-quota reasoning. `fetchImpl` is a test seam (default: global fetch).
188
- async function fetchPublicGistDetail(id, { fetchImpl = globalThis.fetch, signal } = {}) {
222
+ async function fetchPublicGistDetail(id, { fetchImpl = globalThis.fetch, retries = 3, retryDelayMs = 300,
223
+ sleep = defaultSleep, signal } = {}) {
189
224
  const apiBase = process.env.RUVNET_GISTS_API || 'https://api.github.com';
190
- const response = await fetchImpl(`${apiBase}/gists/${id}`, {
191
- headers: { accept: 'application/vnd.github+json', 'user-agent': 'ruvnet-brain-gist-receipts' },
192
- signal,
193
- });
194
- if (!response.ok) {
195
- throw new GistFetchError(`gist ${id} public detail fetch failed: HTTP ${response.status}`,
196
- { code: 'GIST_FETCH_FAILED', gistId: id, status: response.status, retryable: false });
225
+ const attempts = Math.max(1, retries);
226
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
227
+ const response = await fetchImpl(`${apiBase}/gists/${id}`, {
228
+ headers: { accept: 'application/vnd.github+json', 'user-agent': 'ruvnet-brain-gist-receipts' },
229
+ signal,
230
+ });
231
+ if (response.ok) return response.json();
232
+ const headers = {};
233
+ for (const key of ['x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after', 'x-ratelimit-resource', 'x-github-request-id']) {
234
+ const value = response.headers?.get?.(key);
235
+ if (value != null) headers[key] = value;
236
+ }
237
+ const body = await response.json?.().catch?.(() => null);
238
+ const classified = classifyGistFetchFailure(`public detail fetch failed: HTTP ${response.status} ${body?.message || ''}`, id,
239
+ { status: response.status, headers });
240
+ const error = new GistFetchError(`gist ${id} public detail fetch failed: ${classified.message}`,
241
+ { code: classified.code, gistId: id, status: response.status, headers: classified.headers,
242
+ retryable: classified.retryable });
243
+ if (!error.retryable || attempt === attempts) throw error;
244
+ const delay = retryDelay(error, attempt, retryDelayMs);
245
+ if (delay === null) throw error;
246
+ await sleep(delay);
247
+ if (signal?.aborted) throw abortError(signal);
197
248
  }
198
- return response.json();
249
+ throw new Error(`gist ${id} public detail retries exhausted`);
199
250
  }
200
251
 
201
252
  // The transport `captureGistSources` uses by default for per-gist DETAIL. Wraps `defaultFetchGist`
@@ -229,9 +280,18 @@ export async function defaultFetchRaw(file, { fetchImpl = globalThis.fetch, retr
229
280
  headers: { 'user-agent': 'ruvnet-brain-gist-receipts' }, signal,
230
281
  });
231
282
  if (response.ok) return Buffer.from(await response.arrayBuffer());
232
- const transient = response.status === 429 || (response.status >= 500 && response.status < 600);
233
- lastError = new GistFetchError(`raw gist fetch failed: HTTP ${response.status}`,
234
- { code: transient ? 'GIST_RAW_TRANSIENT_FAILURE' : 'GIST_RAW_FETCH_FAILED', status: response.status, retryable: transient });
283
+ const headers = {};
284
+ for (const key of ['x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after', 'x-ratelimit-resource', 'x-github-request-id']) {
285
+ const value = response.headers?.get?.(key);
286
+ if (value != null) headers[key] = value;
287
+ }
288
+ const rateLimited = response.status === 403 && (headers['x-ratelimit-remaining'] === '0'
289
+ || RATE_LIMIT_RE.test(response.statusText || '')) || response.status === 429;
290
+ const transient = rateLimited || (response.status >= 500 && response.status < 600);
291
+ lastError = new GistFetchError(`raw gist fetch failed: HTTP ${response.status}; headers=${JSON.stringify(
292
+ Object.fromEntries(Object.entries(headers).filter(([key]) => ['x-ratelimit-remaining', 'x-ratelimit-reset', 'retry-after', 'x-ratelimit-resource', 'x-github-request-id'].includes(key))))}`,
293
+ { code: rateLimited ? 'GIST_RATE_LIMITED' : transient ? 'GIST_RAW_TRANSIENT_FAILURE' : 'GIST_RAW_FETCH_FAILED',
294
+ status: response.status, headers, retryable: transient });
235
295
  if (!transient) throw lastError;
236
296
  } catch (error) {
237
297
  if (error instanceof GistFetchError) { lastError = error; if (!error.retryable) throw error; }
@@ -243,7 +303,9 @@ export async function defaultFetchRaw(file, { fetchImpl = globalThis.fetch, retr
243
303
  }
244
304
  }
245
305
  if (attempt === attempts) throw lastError;
246
- await sleep(retryDelayMs * attempt);
306
+ const delay = retryDelay(lastError, attempt, retryDelayMs);
307
+ if (delay === null) throw lastError;
308
+ await sleep(delay);
247
309
  if (signal?.aborted) throw abortError(signal);
248
310
  }
249
311
  throw lastError;
@@ -340,18 +402,49 @@ function reusableCachedGist(cached, stub) {
340
402
  return 'reuse';
341
403
  }
342
404
 
343
- export async function captureGistSources({ observation, cache = null, fetchDetail = defaultFetchDetail,
344
- fetchRaw = defaultFetchRaw, signal, now = () => new Date().toISOString() } = {}) {
405
+ export async function captureGistSources({ observation, cache = null, fetchDetail = null,
406
+ fetchRaw = defaultFetchRaw, fetchGitSnapshot = fetchGistGitSnapshot, signal,
407
+ now = () => new Date().toISOString() } = {}) {
345
408
  const stubs = observation?.gists?.rows;
346
409
  if (!Array.isArray(stubs) || stubs.some(({ id }) => !HEX_GIST.test(String(id || '')))
347
410
  || new Set(stubs.map(({ id }) => id)).size !== stubs.length) {
348
411
  throw new Error('source observation has missing or duplicate gist ids');
349
412
  }
413
+ const observedRowsSha256 = digest({ owner: observation.owner, observedAt: observation.observedAt, rows: stubs });
350
414
  const gists = {};
351
415
  const reused = [];
352
416
  const fetched = [];
353
417
  for (const stub of [...stubs].sort((a, b) => String(a.id).localeCompare(String(b.id)))) {
354
418
  if (signal?.aborted) throw abortError(signal);
419
+ if (!fetchDetail) {
420
+ // The production/no-key route never spends one REST detail request per gist and never trusts
421
+ // a body-free cache or timestamps as content identity. A full bare clone gives one immutable
422
+ // Git commit/tree; every observed raw_url's blob identity must match its HEAD file bytes.
423
+ const snapshot = await fetchGitSnapshot({ owner: observation.owner, stub, signal });
424
+ const files = [];
425
+ for (const source of snapshot.files) {
426
+ const filename = source.filename;
427
+ if (!TEXT_EXT.has(path.extname(filename).toLowerCase())) {
428
+ files.push({ filename, included: false, reason: 'non-text policy exclusion', size: source.size,
429
+ sourceGit: { ...source.sourceGit, captureMethod: 'public-bare-git-v1', revisionKind: 'git-commit',
430
+ observedAt: observation.observedAt, observedRowsSha256,
431
+ sourceObservationSha256: observation.observationSha256 } });
432
+ continue;
433
+ }
434
+ let text;
435
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(source.body); }
436
+ catch { throw new Error(`gist ${stub.id}/${filename} is not valid UTF-8 text`); }
437
+ files.push({ filename, included: true, sha256: sha256(source.body), bytes: source.size,
438
+ body: text, sourceGit: { ...source.sourceGit, captureMethod: 'public-bare-git-v1', revisionKind: 'git-commit',
439
+ observedAt: observation.observedAt, observedRowsSha256,
440
+ sourceObservationSha256: observation.observationSha256 } });
441
+ }
442
+ if (files.length !== snapshot.treeFileCount) throw new Error(`gist ${stub.id} Git inventory changed during capture`);
443
+ gists[stub.id] = { gistId: stub.id, versionSha: snapshot.headSha, updatedAt: stub.updated_at,
444
+ ingestedAt: now(), complete: true, files };
445
+ fetched.push(stub.id);
446
+ continue;
447
+ }
355
448
  const cached = cache?.gists?.[stub.id];
356
449
  const verdict = cached ? reusableCachedGist(cached, stub) : 'miss';
357
450
  if (verdict === 'tampered') {
@@ -386,14 +479,31 @@ export async function captureGistSources({ observation, cache = null, fetchDetai
386
479
  complete: files.length === Object.keys(full.files || {}).length, files };
387
480
  fetched.push(stub.id);
388
481
  }
389
- return {
482
+ const captured = {
390
483
  owner: observation.owner,
484
+ observedRowsSha256,
391
485
  observedAt: observation.observedAt,
392
486
  sourceObservationSha256: observation.observationSha256,
393
487
  generatedAt: now(),
394
488
  gists,
395
489
  reuseEvidence: { reused, fetched },
396
490
  };
491
+ VERIFIED_CAPTURE_SETS.set(captured, {
492
+ fingerprint: digest(captured),
493
+ owner: observation.owner,
494
+ observedRowsSha256,
495
+ sourceObservationSha256: observation.observationSha256,
496
+ });
497
+ return captured;
498
+ }
499
+
500
+ function isCurrentVerifiedCapture(captured, observation) {
501
+ const proof = captured && typeof captured === 'object' ? VERIFIED_CAPTURE_SETS.get(captured) : null;
502
+ return Boolean(proof && proof.owner === observation?.owner
503
+ && proof.observedRowsSha256 === digest({ owner: observation?.owner,
504
+ observedAt: observation?.observedAt, rows: observation?.gists?.rows })
505
+ && proof.sourceObservationSha256 === observation?.observationSha256
506
+ && proof.fingerprint === digest(captured));
397
507
  }
398
508
 
399
509
  // ── stage 2: render ──────────────────────────────────────────────────────────────────────────────
@@ -462,6 +572,25 @@ export function validateGistReceipt({ receipt, observation = null, passagesFile,
462
572
  if (!validDate(row?.updatedAt) || !validDate(row?.ingestedAt)) {
463
573
  throw new Error(`gist ${gistId} has an invalid updatedAt/ingestedAt timestamp`);
464
574
  }
575
+ const sourceProofs = (row?.files || []).map((file) => file?.sourceGit).filter(Boolean);
576
+ if (sourceProofs.length && sourceProofs.length !== row.files.length) {
577
+ throw new Error(`gist ${gistId} mixes Git-bound and unbound file evidence`);
578
+ }
579
+ if (sourceProofs.length && sourceProofs.some((proof) => !HEX40.test(String(proof.headSha || ''))
580
+ || !HEX40.test(String(proof.treeSha || '')) || !HEX40.test(String(proof.blobSha || ''))
581
+ || proof.captureMethod !== 'public-bare-git-v1' || proof.revisionKind !== 'git-commit'
582
+ || !validDate(proof.observedAt) || proof.sourceObservationSha256 !== receipt.sourceObservationSha256
583
+ || !HEX64.test(String(proof.observedRowsSha256 || ''))
584
+ || !Number.isSafeInteger(proof.treeFileCount) || proof.treeFileCount !== row.files.length
585
+ || proof.observedTruncated !== false || proof.observedFileCount !== proof.treeFileCount
586
+ || proof.headSha !== row.versionSha || proof.observed !== true
587
+ || !HEX40.test(String(proof.observedRawRevisionSha || ''))
588
+ || !['blob', 'commit'].includes(proof.observedRawRevisionKind)
589
+ || proof.observedRawBlobSha !== proof.blobSha
590
+ || sourceProofs.some((other) => other.treeSha !== proof.treeSha || other.headSha !== proof.headSha
591
+ || other.observedRowsSha256 !== proof.observedRowsSha256))) {
592
+ throw new Error(`gist ${gistId} has an incomplete Git snapshot proof`);
593
+ }
465
594
  for (const file of row?.files || []) {
466
595
  if (!validateFilename(file?.filename)) throw new Error(`gist ${gistId} has an unsafe or missing filename`);
467
596
  }
@@ -517,8 +646,9 @@ export async function buildGistAggregate({ observation, cache = null, outDir, ro
517
646
  sourceMetadata: null, files: [], sourceReceipt: null, reuseEvidence: { reused: [], fetched: [] } };
518
647
  }
519
648
 
520
- const captured = await captureGistSources({
521
- observation, cache, fetchDetail: transport.fetchDetail, fetchRaw: transport.fetchRaw, signal, now,
649
+ const captured = isCurrentVerifiedCapture(cache, observation) ? cache : await captureGistSources({
650
+ observation, cache, fetchDetail: transport.fetchDetail, fetchRaw: transport.fetchRaw,
651
+ fetchGitSnapshot: transport.fetchGitSnapshot, signal, now,
522
652
  });
523
653
  const { passageBytes, metadata, gistRecords } = renderGistPassages({ captured, generatedAt: captured.generatedAt });
524
654
  const includedFileCount = Object.values(captured.gists)
@@ -32,6 +32,11 @@ import { runRetrievalCanaries, validateRetrievalCanaryReceipt, resolveInstalledC
32
32
 
33
33
  /** The three host shapes a release must survive. ONE name each, for every consumer. */
34
34
  export const HOST_MODES = Object.freeze(['claude', 'codex', 'dual']);
35
+ export const SELF_STORE_PROOF_QUERY = 'repo:ruvnet-brain What is the RuvNet Brain release evidence workflow?';
36
+ export const SELF_STORE_PROOF_K = 1;
37
+ export const RELEASE_SEARCH_QUERY = 'repo:ruvnet-brain How does RuvNet Brain prove a public release artifact?';
38
+ export const HOST_WARMUP_TIMEOUT_MS = 300_000;
39
+ export const RELEASE_SEARCH_DEADLINE_MS = 30_000;
35
40
 
36
41
  /** Which CLIs each mode is allowed to see. A codex-only box genuinely has no `claude`. */
37
42
  export const MODE_HOSTS = Object.freeze({
@@ -204,6 +209,7 @@ export async function runHostMatrixAsync({
204
209
  verifyGrounding = verifyInstalledGrounding,
205
210
  resolveMcpServer = resolveInstalledMcpServer,
206
211
  retrieval,
212
+ sequentialSearches = false,
207
213
  }) {
208
214
  const spec = VARIANTS[variant];
209
215
  if (!spec) throw new Error(`unknown host-matrix variant: ${variant}`);
@@ -251,32 +257,57 @@ export async function runHostMatrixAsync({
251
257
 
252
258
  const prewarmContext = contexts[0];
253
259
  const prewarmReader = path.join(prewarmContext.env.RUVNET_BRAIN_KB, 'forge-ask-all.mjs');
260
+ // This query only primes the shared model cache. Keep it in the Brain's own bounded store so
261
+ // Mac/Windows/Linux runner speed does not determine whether the later real host searches run.
262
+ // The following MCP matrix remains the measured, source-grounded candidate acceptance.
254
263
  const prewarm = await runCommand(process.execPath, [prewarmReader, '--dir', prewarmContext.env.RUVNET_BRAIN_KB,
255
- '--q', 'How does RuvNet Brain prove a public release artifact?', '--k', '1'], {
264
+ '--q', RELEASE_SEARCH_QUERY, '--k', '1', '--pool', '8',
265
+ '--repos', 'ruvnet-brain', '--bounded'], {
256
266
  cwd: prewarmContext.env.RUVNET_BRAIN_KB, env: prewarmContext.env, timeout: 300_000,
257
267
  });
258
268
  if (prewarm.error || prewarm.status !== 0) {
259
- return { verdict: 'FAIL', fixtures: {}, error: `shared model prewarm failed (${processDiagnostic(prewarm)})` };
269
+ const detail = String(prewarm.stderr || prewarm.stdout || '').trim().split('\n').slice(-8).join(' | ').slice(-1600);
270
+ return { verdict: 'FAIL', fixtures: {}, error: `shared model prewarm failed (${processDiagnostic(prewarm)})${detail ? `; ${detail}` : ''}` };
260
271
  }
261
272
  const prewarmGrounding = await verifyGrounding(String(prewarm.stdout || ''), prewarmContext.env.RUVNET_BRAIN_KB);
262
273
  if (!prewarmGrounding?.grounded) {
263
274
  return { verdict: 'FAIL', fixtures: {}, error: 'shared model prewarm returned no grounded source receipt' };
264
275
  }
265
276
 
266
- const searches = await Promise.all(contexts.map(async (context) => {
277
+ const searchOne = async (context) => {
267
278
  let session;
268
279
  try {
269
280
  const serverPath = resolveMcpServer(context);
270
281
  // One installed worker per host: model/store state survives smoke and every sealed case.
271
282
  session = runMcpSearch === runInstalledMcpSearch ? createInstalledMcpSession({ serverPath, env: context.env }) : null;
272
283
  const searchMcp = session ? (args) => session.search(args) : runMcpSearch;
273
- const processResult = await searchMcp({ mode: context.mode, serverPath, env: context.env });
284
+ // The installed shell warms its in-process models and stores asynchronously from MCP
285
+ // initialize. Candidate qualification must give that same worker the same cited readiness
286
+ // probe used by the public verifier before timing the first user search; a separate CLI
287
+ // prewarm only primes disk cache and cannot warm this worker's process memory.
288
+ const warmupResult = await searchMcp({ mode: context.mode, serverPath, env: context.env,
289
+ query: SELF_STORE_PROOF_QUERY, k: SELF_STORE_PROOF_K, timeoutMs: HOST_WARMUP_TIMEOUT_MS });
290
+ const warmupOutput = `${warmupResult.stdout || ''}${warmupResult.stderr || ''}`;
291
+ if (warmupResult.error || warmupResult.status !== 0) {
292
+ return { context, processResult: warmupResult,
293
+ error: `MCP warmup failed for ${context.mode} (${processDiagnostic(warmupResult)}): ${warmupOutput.slice(-2000)}` };
294
+ }
295
+ const warmupGrounding = await verifyGrounding(warmupOutput, context.env.RUVNET_BRAIN_KB);
296
+ if (!warmupGrounding?.grounded || warmupGrounding.receipt?.repo !== 'ruvnet-brain') {
297
+ return { context, processResult: warmupResult, warmupMs: warmupResult.broadMs,
298
+ error: `MCP self-store warmup grounding unproven for ${context.mode}` };
299
+ }
300
+ const processResult = await searchMcp({ mode: context.mode, serverPath, env: context.env,
301
+ query: RELEASE_SEARCH_QUERY, k: 5,
302
+ timeoutMs: RELEASE_SEARCH_DEADLINE_MS });
274
303
  const output = `${processResult.stdout || ''}${processResult.stderr || ''}`;
275
304
  if (processResult.error || processResult.status !== 0) {
276
- return { context, processResult, error: `MCP search failed for ${context.mode} (${processDiagnostic(processResult)}): ${output.slice(-2000)}` };
305
+ return { context, processResult, warmupMs: warmupResult.broadMs, warmupGrounding,
306
+ error: `MCP search failed for ${context.mode} (${processDiagnostic(processResult)}): ${output.slice(-2000)}` };
277
307
  }
278
308
  const grounding = await verifyGrounding(output, context.env.RUVNET_BRAIN_KB);
279
- if (!grounding?.grounded) return { context, processResult, error: `MCP search grounding unproven for ${context.mode}` };
309
+ if (!grounding?.grounded) return { context, processResult, warmupMs: warmupResult.broadMs,
310
+ warmupGrounding, error: `MCP search grounding unproven for ${context.mode}` };
280
311
  let receipt;
281
312
  if (retrieval) {
282
313
  receipt = await runRetrievalCanaries({ ...retrieval,
@@ -288,17 +319,29 @@ export async function runHostMatrixAsync({
288
319
  citationResolver: (matched, expected) => resolveInstalledCanaryCitation({ kbDir: context.env.RUVNET_BRAIN_KB, matched, expected }),
289
320
  });
290
321
  try { validateRetrievalCanaryReceipt(receipt, { plan: retrieval.plan }); }
291
- catch (error) { return { context, processResult, grounding, retrieval: receipt, error: `${context.mode} canary rejected: ${error.message}` }; }
322
+ catch (error) { return { context, processResult, warmupMs: warmupResult.broadMs,
323
+ warmupGrounding, grounding, retrieval: receipt, error: `${context.mode} canary rejected: ${error.message}` }; }
292
324
  }
293
- return { context, processResult, grounding, ...(receipt ? { retrieval: receipt } : {}) };
325
+ return { context, processResult, warmupMs: warmupResult.broadMs, warmupGrounding,
326
+ grounding, ...(receipt ? { retrieval: receipt } : {}) };
294
327
  } catch (error) {
295
328
  return { context, processResult: { status: null, error }, error: `${context.mode} host search failed: ${error.message}` };
296
329
  } finally { await session?.close(); }
297
- }));
298
- const fixtures = Object.fromEntries(searches.map(({ context, processResult, grounding, retrieval, error }) => [context.mode, {
330
+ };
331
+ const searches = [];
332
+ if (sequentialSearches) {
333
+ for (const context of contexts) searches.push(await searchOne(context));
334
+ } else {
335
+ searches.push(...await Promise.all(contexts.map(searchOne)));
336
+ }
337
+ const fixtures = Object.fromEntries(searches.map(({ context, processResult, warmupMs, warmupGrounding,
338
+ grounding, retrieval, error }) => [context.mode, {
299
339
  status: error ? 'FAIL' : 'PASS',
300
340
  version,
301
341
  process: processIdentity(processResult),
342
+ warmupMs: Number.isFinite(warmupMs) ? warmupMs : null,
343
+ ...(warmupGrounding?.receipt ? { warmupGrounding: warmupGrounding.receipt } : {}),
344
+ searchMs: Number.isFinite(processResult.broadMs) ? processResult.broadMs : null,
302
345
  ...(grounding?.receipt ? { grounding: grounding.receipt } : {}),
303
346
  ...(retrieval ? { retrieval } : {}),
304
347
  ...(error ? { error } : {}),
@@ -395,7 +438,7 @@ export function createInstalledMcpSession({ serverPath, env, timeout = 300_000,
395
438
  pending.set(id, { resolve, reject });
396
439
  child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
397
440
  });
398
- const execute = async ({ query = 'How does RuvNet Brain prove a public release artifact?', k = 5,
441
+ const execute = async ({ query = RELEASE_SEARCH_QUERY, k = 5,
399
442
  timeoutMs = timeout } = {}) => {
400
443
  if (terminalError) return { status: null, error: terminalError, signal: exitSignal, stdout: '', stderr };
401
444
  stderr = '';
@@ -194,10 +194,13 @@ async function main() {
194
194
  }
195
195
 
196
196
  const observedAt = new Date().toISOString();
197
- const rows = gists.map((g) => ({ id: g.id, updated_at: g.updated_at, files: g.files }));
197
+ const rows = gists.map((g) => ({ id: g.id, updated_at: g.updated_at, html_url: g.html_url,
198
+ truncated: g.truncated, files: g.files }));
198
199
  const observation = {
199
200
  owner: OWNER, observedAt,
200
- observationSha256: digest({ owner: OWNER, rows: rows.map(({ id, updated_at }) => ({ id, updated_at })) }),
201
+ // Bind the exact listed file inventory and its truncation indicator. A timestamp-only digest
202
+ // could otherwise reuse a capture after the API changes a file's pinned raw blob or completeness.
203
+ observationSha256: digest({ owner: OWNER, rows }),
201
204
  gists: { rows },
202
205
  };
203
206
  const now = () => observedAt;
@@ -0,0 +1,99 @@
1
+ // ADR-089: describe installed identities without confusing a validator pin with search-engine proof.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { readInstalledRuntime, isCorpusReleaseTag } from '../kb/corpus-release-identity.mjs';
6
+ import { cmpVersion } from './stack-sync.mjs';
7
+ import { isRuntimeFile } from './approved-runtime.mjs';
8
+
9
+ const SEARCH_FILES = ['forge-mcp-all.mjs', 'forge-ask-all.mjs', 'forge-rerank.mjs', 'card-lane.mjs'];
10
+ // Keep the doctor probe answerable and bounded. A health probe must exercise a real indexed
11
+ // source/citation path; it must not turn an unclear broad question into an all-corpus rerank.
12
+ export const DOCTOR_SMOKE_QUERY = 'What package name is declared in the RuvNet Brain KB package manifest?';
13
+ export function doctorSmokeArgs(cacheDir) {
14
+ return ['forge-ask-all.mjs', '--dir', cacheDir, '--q', DOCTOR_SMOKE_QUERY,
15
+ '--repos', 'ruvnet-brain', '--k', '3', '--pool', '8', '--bounded'];
16
+ }
17
+ const version = value => typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value)
18
+ ? value.replace(/^v/, '') : null;
19
+
20
+ export function inspectInstalledBrain(kbDir, packageVersion) {
21
+ const result = { healthy: false, packageVersion, searchVersion: null, corpusTag: null,
22
+ validatorVersion: null, searchFiles: {}, issues: [] };
23
+ let source;
24
+ try { source = JSON.parse(fs.readFileSync(path.join(kbDir, 'SOURCE.json'), 'utf8')); }
25
+ catch { result.issues.push('SOURCE.json is missing or unreadable'); }
26
+ result.searchVersion = version(source?.brainVersion) || version(source?.releaseTag);
27
+ result.corpusTag = isCorpusReleaseTag(source?.corpusReleaseTag) ? source.corpusReleaseTag : null;
28
+ if (!result.searchVersion) result.issues.push('the search engine version is unverified');
29
+ if (version(source?.brainVersion) && version(source?.releaseTag)
30
+ && version(source.brainVersion) !== version(source.releaseTag)) {
31
+ result.issues.push('SOURCE.json runtime version and release tag disagree');
32
+ }
33
+ const pin = readInstalledRuntime(kbDir);
34
+ result.validatorVersion = pin.brainVersion;
35
+ if (!pin.ok) result.issues.push(pin.reason);
36
+ if (pin.ok && result.searchVersion && version(pin.brainVersion) !== result.searchVersion) {
37
+ result.issues.push(`validator runtime ${pin.brainVersion} and search engine ${result.searchVersion} differ`);
38
+ }
39
+ const expected = version(packageVersion);
40
+ if (expected && result.searchVersion && cmpVersion(result.searchVersion, expected) < 0) {
41
+ result.issues.push(`search engine ${result.searchVersion} is behind package ${packageVersion}`);
42
+ }
43
+ for (const file of SEARCH_FILES) {
44
+ try {
45
+ const bytes = fs.readFileSync(path.join(kbDir, file));
46
+ result.searchFiles[file] = { bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
47
+ if (!bytes.length) result.issues.push(`search executable ${file} is empty`);
48
+ } catch { result.issues.push(`search executable ${file} is missing`); }
49
+ }
50
+ // The installed archive manifest lets doctor detect code drift even if SOURCE's version stayed
51
+ // unchanged. This is a consistency check, not independent authentication of a local manifest.
52
+ try {
53
+ const archive = JSON.parse(fs.readFileSync(path.join(kbDir, 'ARCHIVE-MANIFEST.json'), 'utf8'));
54
+ if (archive.kind !== 'ruvnet-brain-archive-manifest' || !Array.isArray(archive.files)) {
55
+ throw new Error('invalid archive manifest shape');
56
+ }
57
+ if (version(archive.version) !== result.searchVersion) {
58
+ result.issues.push('archive manifest and search engine versions differ');
59
+ }
60
+ const runtimeFiles = new Set([...SEARCH_FILES, ...archive.files.filter(row => isRuntimeFile(row?.path)).map(row => row.path)]);
61
+ for (const file of runtimeFiles) {
62
+ if (typeof file !== 'string' || path.isAbsolute(file) || file.includes('\\')
63
+ || file.split('/').some(part => !part || part === '.' || part === '..')) {
64
+ result.issues.push('archive manifest contains an unsafe runtime path');
65
+ continue;
66
+ }
67
+ const rows = archive.files.filter(row => row?.path === file);
68
+ let actual = result.searchFiles[file];
69
+ if (!actual) {
70
+ try {
71
+ const bytes = fs.readFileSync(path.join(kbDir, file));
72
+ actual = { bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
73
+ result.searchFiles[file] = actual;
74
+ } catch { result.issues.push(`runtime file ${file} is missing`); }
75
+ }
76
+ if (rows.length !== 1 || !/^[a-f0-9]{64}$/.test(rows[0]?.sha256 || '')
77
+ || !Number.isSafeInteger(rows[0]?.bytes) || rows[0].bytes < 0) {
78
+ result.issues.push(`archive manifest has no unique valid identity for ${file}`);
79
+ } else if (actual && (actual.sha256 !== rows[0].sha256 || actual.bytes !== rows[0].bytes)) {
80
+ result.issues.push(`search executable ${file} differs from the installed archive manifest`);
81
+ }
82
+ }
83
+ } catch (error) {
84
+ result.issues.push(`installed archive manifest is missing or unreadable: ${error.message}`);
85
+ }
86
+ // These hashes and the local manifest are NOT independent release approval receipts.
87
+ result.healthy = result.issues.length === 0;
88
+ return result;
89
+ }
90
+
91
+ export function classifySmokeEvidence(verification, answer) {
92
+ if (!verification?.grounded) return { usable: false, reason: verification?.reason || 'citation-not-verified' };
93
+ const weak = /EVIDENCE:\s*(?:INSUFFICIENT_EVIDENCE|THIN)\b/i.test(answer);
94
+ if (weak) return { usable: false, reason: 'retrieval-evidence-insufficient' };
95
+ if (/BUILT\/SHIPPED CLAIM:\s*NOT PROVEN/i.test(answer)) {
96
+ return { usable: false, reason: 'required-implementation-unproven' };
97
+ }
98
+ return { usable: true, reason: null };
99
+ }
@@ -9,6 +9,7 @@ import { RELEASE_QUALIFICATION_POLICY } from './integration-evidence.mjs';
9
9
  import { readCandidateRetrieval } from './staged-host-verifier.mjs';
10
10
  import { validateRetrievalCanaryReceipt } from './retrieval-canary.mjs';
11
11
  import { payloadIdFor } from './release-payload.mjs';
12
+ import { HOST_WARMUP_TIMEOUT_MS, RELEASE_SEARCH_DEADLINE_MS } from './host-install-matrix.mjs';
12
13
 
13
14
  const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
14
15
 
@@ -95,11 +96,18 @@ export function buildPrepublicationEvidence({
95
96
  requireExactSet(host.value.leaves.map(({ name }) => name), ['claude-only', 'codex-only', 'dual-host'], 'candidate host leaves');
96
97
  for (const leaf of host.value.leaves) {
97
98
  const grounding = leaf.grounding;
99
+ const warmupGrounding = leaf.warmupGrounding;
98
100
  const grounded = grounding && ['repo', 'path', 'file', 'storedPath']
99
101
  .every((field) => typeof grounding[field] === 'string' && grounding[field].trim());
102
+ const warmupGrounded = warmupGrounding && ['repo', 'path', 'file', 'storedPath']
103
+ .every((field) => typeof warmupGrounding[field] === 'string' && warmupGrounding[field].trim());
104
+ const selfStoreWarmup = warmupGrounding?.repo === 'ruvnet-brain';
100
105
  if (leaf.sha !== sha || leaf.payloadId !== payload.payloadId || leaf.status !== 'completed'
101
106
  || leaf.conclusion !== 'success' || leaf.verdict !== 'PASS'
102
107
  || leaf.functionalSearch !== true || leaf.searchExit !== 0 || !grounded
108
+ || !Number.isFinite(leaf.warmupMs) || leaf.warmupMs < 0 || leaf.warmupMs > HOST_WARMUP_TIMEOUT_MS
109
+ || !warmupGrounded || !selfStoreWarmup
110
+ || !Number.isFinite(leaf.searchMs) || leaf.searchMs < 0 || leaf.searchMs > RELEASE_SEARCH_DEADLINE_MS
103
111
  || leaf.artifactSha256 !== host.value.artifactSha256) {
104
112
  throw new Error(`candidate host leaf is not an exact PASS: ${leaf.name || '(missing)'}`);
105
113
  }
@@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url';
36
36
  import { digest, fileIdentity } from './coverage-integrity.mjs';
37
37
  import { isPrivate, loadPrivateFence, loadPrivateSlugs, shouldFenceL2 } from './private-fence.mjs';
38
38
  import { promoteArtifactSet } from '../kb/incremental-refresh.mjs';
39
+ import { isCapabilityOnly } from '../kb/capability-only.mjs';
39
40
 
40
41
  const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
41
42
  const HEX40 = /^[a-f0-9]{40}$/;
@@ -299,7 +300,7 @@ export function materializePublicInputs({ builderRoot = DEFAULT_ROOT, policy = {
299
300
  try {
300
301
  for (const repo of allRepos) {
301
302
  const src = path.join(kb, `${repo}-primer.md`);
302
- if (isPrivate(privateSet, repo)) {
303
+ if (isPrivate(privateSet, repo) || isCapabilityOnly(repo)) {
303
304
  excluded.primers.push({ repo, ...excludedIdentity(src) });
304
305
  continue;
305
306
  }