ruvnet-brain 4.3.25 → 4.3.27

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 (51) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +19 -4
  3. package/console/app.js +48 -0
  4. package/console/index.html +19 -0
  5. package/console/style.css +26 -0
  6. package/data/model-catalog.json +1 -1
  7. package/docs/RELEASE-NOTES-4.0.md +4 -3
  8. package/kb/capability-only.mjs +27 -0
  9. package/kb/capability-summaries/cognitum-ruos/CAPABILITIES.md +26 -0
  10. package/kb/verify-citation.mjs +16 -4
  11. package/package.json +5 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/docs/RELEASE-NOTES-4.0.md +4 -3
  15. package/plugin/hooks/codex-hooks.json +6 -1
  16. package/plugin/hooks/hook-contracts.json +24 -2
  17. package/plugin/hooks/hooks.json +6 -1
  18. package/plugin/scripts/capacity-aware-parallel-work.mjs +200 -0
  19. package/plugin/scripts/codex-hook-adapter.mjs +37 -0
  20. package/plugin/scripts/continuity-hook-policy.mjs +4 -0
  21. package/plugin/scripts/coverage-integrity.mjs +17 -0
  22. package/plugin/scripts/hook-shim.mjs +1 -0
  23. package/plugin/scripts/lesson-gate.mjs +4 -1
  24. package/plugin/scripts/project-progression-reader.mjs +12 -1
  25. package/plugin/scripts/project-progression-session-start.mjs +24 -6
  26. package/plugin/scripts/project-progression-store.mjs +76 -4
  27. package/plugin/skills/release-proof/SKILL.md +28 -4
  28. package/plugin/skills/release-proof/scripts/release-proof.mjs +48 -24
  29. package/scripts/brain-novice-50.mjs +14 -16
  30. package/scripts/build-bundle.mjs +2 -0
  31. package/scripts/corpus-dispatch-receipt.mjs +22 -0
  32. package/scripts/corpus-reconcile.mjs +26 -4
  33. package/scripts/doc-currency.mjs +12 -4
  34. package/scripts/eval-brain.mjs +7 -5
  35. package/scripts/gist-git-transport.mjs +218 -0
  36. package/scripts/gist-receipts.mjs +168 -38
  37. package/scripts/ingest-gists.mjs +5 -2
  38. package/scripts/installed-brain-health.mjs +99 -0
  39. package/scripts/onboarding-console.mjs +45 -1
  40. package/scripts/public-inputs.mjs +2 -1
  41. package/scripts/public-verification-inputs.mjs +14 -6
  42. package/scripts/refresh-capability-only-store.mjs +143 -0
  43. package/scripts/release-transaction-provider.mjs +25 -5
  44. package/scripts/release-transaction.mjs +40 -11
  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/sync-version.mjs +10 -2
  51. 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)
@@ -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
+ }
@@ -729,6 +729,50 @@ export function reconcileNightly({ choice, schedule }) {
729
729
  };
730
730
  }
731
731
 
732
+ // The one thing the console never showed: WHICH repositories are in the brain, by name. It has
733
+ // always carried the count (206) and never the inventory — the owner asked for this list more than
734
+ // a dozen times. RVF-GENERATIONS.json is the manifest that matches disk exactly (verified 206 = 206);
735
+ // SOURCE.json is the provenance subset (104) that carries the upstream URL. Join on store name,
736
+ // never restate either. Ranked by build recency — never alphabetical (relevance-ordering rule).
737
+ function gatherInventory() {
738
+ const generations = readJSON(path.join(INSTALLED_KB, 'RVF-GENERATIONS.json'));
739
+ const source = readJSON(path.join(INSTALLED_KB, 'SOURCE.json'));
740
+ const stores = generations?.stores && typeof generations.stores === 'object' ? generations.stores : null;
741
+ if (!stores) return { available: false, reason: 'RVF-GENERATIONS.json not readable in the installed brain', count: 0, stores: [] };
742
+ // SOURCE.json has carried `stores` as an array in some generations and a name-keyed object in
743
+ // others; accept both rather than silently joining nothing (measured 2026-09-16: object form,
744
+ // and the array-only read reported withProvenance=0 for a brain with 104 provenance rows).
745
+ const provenance = new Map();
746
+ const sourceRows = Array.isArray(source?.stores) ? source.stores
747
+ : source?.stores && typeof source.stores === 'object'
748
+ ? Object.entries(source.stores).map(([name, row]) => ({ name, ...row })) : [];
749
+ for (const row of sourceRows) {
750
+ if (row?.name) provenance.set(String(row.name).toLowerCase(), row);
751
+ }
752
+ const rows = Object.entries(stores).map(([name, gen]) => {
753
+ const prov = provenance.get(name.toLowerCase());
754
+ return {
755
+ name,
756
+ sourceRepo: prov?.sourceRepo || null,
757
+ sourceCommit: gen?.sourceCommit ? String(gen.sourceCommit).slice(0, 7) : null,
758
+ builtUtc: gen?.builtUtc || null,
759
+ bytes: Number(gen?.bytes) || 0,
760
+ model: gen?.model || null,
761
+ private: prov?.updateManaged === false,
762
+ };
763
+ }).sort((a, b) => String(b.builtUtc || '').localeCompare(String(a.builtUtc || '')) || a.name.localeCompare(b.name));
764
+ return {
765
+ available: true,
766
+ count: rows.length,
767
+ withProvenance: rows.filter((r) => r.sourceRepo).length,
768
+ privateCount: rows.filter((r) => r.private).length,
769
+ brainVersion: generations.brainVersion || null,
770
+ releaseTag: generations.releaseTag || null,
771
+ totalBytes: rows.reduce((sum, r) => sum + r.bytes, 0),
772
+ stores: rows,
773
+ };
774
+ }
775
+
732
776
  function gatherConfig() {
733
777
  const cfg = readJSON(CONFIG_PATH) || {};
734
778
  const credential = openRouterCredentialStatus({ cwd: process.cwd() });
@@ -2268,7 +2312,7 @@ function gatherState(cwd, { fleet = true } = {}) {
2268
2312
  generatedAt: new Date().toISOString(),
2269
2313
  preStateHash,
2270
2314
  host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(SYSTEM_HOME, '~'), brainVersion: brainVersionOnDisk() },
2271
- sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations },
2315
+ sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations, inventory: gatherInventory() },
2272
2316
  };
2273
2317
  // Cache the last good state so repeat page-loads paint instantly, same as the stack audit does.
2274
2318
  // TOKEN is per-server-run and must never touch disk — ?fast=1 splices the live one back in.
@@ -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
  }
@@ -113,6 +113,18 @@ function exactNamedFile(root, name, label) {
113
113
  return trustedFile(path.join(root, name), label);
114
114
  }
115
115
 
116
+ function validHistoricalGenerationLedger(ledger, names) {
117
+ const legacy = ledger?.schemaVersion === 1;
118
+ const runtime = ledger?.schemaVersion === 2
119
+ && ledger?.kind === 'ruvnet-brain-runtime-generation-ledger'
120
+ && HEX40.test(String(ledger?.sourceSnapshot || ''));
121
+ return (legacy || runtime)
122
+ && typeof ledger?.brainVersion === 'string' && Boolean(ledger.brainVersion)
123
+ && typeof ledger?.releaseTag === 'string' && Boolean(ledger.releaseTag)
124
+ && names.length > 0
125
+ && new Set(names.map((name) => name.toLowerCase())).size === names.length;
126
+ }
127
+
116
128
  function validateCandidate({ root, bundleFile, packageFile }) {
117
129
  const coverageFile = trustedFile(path.join(root, 'COVERAGE.json'), 'candidate release coverage');
118
130
  const coverageBytes = fs.readFileSync(coverageFile);
@@ -176,9 +188,7 @@ function retrospectiveBaselineFromTree({ extractedRoot, bundleFile, expectedTag,
176
188
  const root = path.dirname(ledgerFile);
177
189
  const { value: ledger } = readJson(ledgerFile, 'historical baseline generation ledger');
178
190
  const names = Object.keys(ledger?.stores || {}).sort();
179
- if (ledger.schemaVersion !== 1 || typeof ledger.brainVersion !== 'string' || !ledger.brainVersion
180
- || typeof ledger.releaseTag !== 'string' || !ledger.releaseTag || !names.length
181
- || new Set(names.map((name) => name.toLowerCase())).size !== names.length) {
191
+ if (!validHistoricalGenerationLedger(ledger, names)) {
182
192
  fail('historical baseline generation ledger is malformed');
183
193
  }
184
194
  const archive = namedIdentity(bundleFile);
@@ -245,9 +255,7 @@ function observedBaselineFromTree({ extractedRoot, bundleFile, expectedTag, expe
245
255
  const root = path.dirname(ledgerFile);
246
256
  const { value: ledger } = readJson(ledgerFile, 'historical baseline generation ledger');
247
257
  const ledgerNames = Object.keys(ledger?.stores || {}).sort();
248
- if (ledger.schemaVersion !== 1 || typeof ledger.brainVersion !== 'string' || !ledger.brainVersion
249
- || typeof ledger.releaseTag !== 'string' || !ledger.releaseTag || !ledgerNames.length
250
- || new Set(ledgerNames.map((name) => name.toLowerCase())).size !== ledgerNames.length) {
258
+ if (!validHistoricalGenerationLedger(ledger, ledgerNames)) {
251
259
  fail('historical baseline generation ledger is malformed');
252
260
  }
253
261
  const archive = namedIdentity(bundleFile);