ruvnet-brain 4.0.90-dev → 4.3.1-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +31 -7
  2. package/bin/install.mjs +153 -22
  3. package/console/architecture.html +12 -14
  4. package/console/install-mockup.html +3 -3
  5. package/kb/zip-extract.mjs +8 -2
  6. package/package.json +7 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/hooks/codex-hooks.json +1 -1
  10. package/plugin/hooks/hooks.json +10 -0
  11. package/plugin/host-adapters/claude.json +11 -0
  12. package/plugin/host-adapters/codex.json +11 -0
  13. package/plugin/mcp/managed-cli-interface.mjs +77 -10
  14. package/plugin/mcp/server.mjs +2 -1
  15. package/plugin/scripts/anticipate.sh +30 -1
  16. package/plugin/scripts/capability-claim-evidence.mjs +431 -0
  17. package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
  18. package/plugin/scripts/capability-registry.mjs +6 -0
  19. package/plugin/scripts/capability-routing.mjs +70 -0
  20. package/plugin/scripts/continuation-gate.mjs +68 -2
  21. package/plugin/scripts/coverage-integrity.mjs +575 -0
  22. package/plugin/scripts/decision-outcomes.mjs +0 -0
  23. package/plugin/scripts/ground-ruvnet.sh +2 -2
  24. package/plugin/scripts/nightly-controller.mjs +19 -2
  25. package/plugin/scripts/project-progression-contract.mjs +409 -0
  26. package/plugin/scripts/project-progression-hook.mjs +196 -0
  27. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  28. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  29. package/plugin/scripts/project-progression-store.mjs +248 -0
  30. package/plugin/scripts/project-store-resolver.mjs +106 -0
  31. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  32. package/plugin/scripts/session-start-core.mjs +22 -18
  33. package/plugin/scripts/spend-guard.mjs +6 -1
  34. package/scripts/adr-072-completion.mjs +98 -0
  35. package/scripts/behavioral-l1-l4.mjs +11 -8
  36. package/scripts/brain-score.mjs +23 -9
  37. package/scripts/build-bundle.mjs +94 -8
  38. package/scripts/card-from-source.mjs +140 -0
  39. package/scripts/corpus-aggregates.mjs +194 -0
  40. package/scripts/corpus-candidate.mjs +314 -0
  41. package/scripts/corpus-reconcile.mjs +540 -0
  42. package/scripts/corpus-seed-publish.mjs +110 -0
  43. package/scripts/coverage-integrity.mjs +3 -0
  44. package/scripts/gist-receipts.mjs +183 -0
  45. package/scripts/git-hooks/pre-push +21 -0
  46. package/scripts/host-registry.mjs +118 -0
  47. package/scripts/independent-review-receipt.mjs +499 -0
  48. package/scripts/ingest-gists.mjs +34 -5
  49. package/scripts/ingest-new-repos.mjs +127 -0
  50. package/scripts/metaharness-gate.mjs +58 -0
  51. package/scripts/nightly-gists.sh +3 -3
  52. package/scripts/nightly-wrapper.sh +105 -64
  53. package/scripts/product-integrity-contract.mjs +156 -0
  54. package/scripts/public-inventory.mjs +4 -0
  55. package/scripts/public-verification-aggregate.mjs +342 -0
  56. package/scripts/public-verification-finalizer.mjs +59 -0
  57. package/scripts/public-verification-inputs.mjs +473 -0
  58. package/scripts/public-verification-lane.mjs +244 -0
  59. package/scripts/publication-receipt.mjs +50 -6
  60. package/scripts/rebuild-gists-from-receipts.mjs +291 -0
  61. package/scripts/release-projection.mjs +86 -0
  62. package/scripts/release-transaction-provider.mjs +43 -102
  63. package/scripts/release-transaction.mjs +165 -49
  64. package/scripts/release.mjs +158 -3
  65. package/scripts/restore-local-ingests.mjs +2 -2
  66. package/scripts/retrieval-canary.mjs +533 -0
  67. package/scripts/routing-flywheel.mjs +12 -1
  68. package/scripts/rvf-generation.mjs +50 -0
  69. package/scripts/self-update.mjs +19 -44
  70. package/scripts/source-coverage.mjs +415 -0
  71. package/scripts/source-scope-receipt.mjs +84 -0
  72. package/scripts/sync-census.mjs +0 -0
  73. package/scripts/wired-check.mjs +114 -24
  74. package/scripts/worktree-integrity.mjs +123 -0
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ingest-new-repos.mjs — load what rUv shipped, without being asked.
4
+ *
5
+ * THE GAP THIS CLOSES, and it is embarrassing rather than subtle. The owner asked on 2026-08-20
6
+ * why `ruvnet/ultrasonic` was not in the brain: *"aren't you loading everything Ruv creates every
7
+ * day?????"* The honest answer was no, and nothing ever had. `nightly-wrapper.sh` refreshed
8
+ * lessons, health, the release watchdog and the replay proof — and contained ZERO references to
9
+ * ingestion. New rUv repos only ever entered the brain when a human typed `ingest-repo.mjs` by
10
+ * hand, so the corpus drifted behind the org silently and the only symptom was a question the
11
+ * brain could not answer.
12
+ *
13
+ * `brain-stamp.mjs` already COMPUTES built-vs-pending every night. Nothing acted on it. Measuring
14
+ * a gap and never closing it is how 137 repos stayed uningested while the scorecard reported the
15
+ * number cheerfully.
16
+ *
17
+ * WHY IT IS BOUNDED. Ingestion embeds a whole repository; doing 137 in one night would run for
18
+ * hours and starve everything after it in the nightly. `--max` (default 3) keeps the nightly
19
+ * bounded and lets the corpus converge over days instead of blocking on one enormous run. That is
20
+ * a deliberate trade, and the run SAYS how many remain rather than implying it finished.
21
+ *
22
+ * WHAT IT WILL NOT DO. It does not write capability cards. An ingested store with no card is DARK —
23
+ * valid bytes that no by-description query can reach — and `ingest-repo.mjs` already says so on
24
+ * every run. Inventing a card from a repo name would put a confident description in the routing
25
+ * layer that nobody grounded in the source, which is worse than an honest gap: it would route real
26
+ * questions to a corpus that cannot answer them. So this reports the dark stores it created and
27
+ * leaves the card to someone who reads the repo.
28
+ *
29
+ * node scripts/ingest-new-repos.mjs # report only
30
+ * node scripts/ingest-new-repos.mjs --apply # ingest up to --max new repos
31
+ * node scripts/ingest-new-repos.mjs --apply --max 5
32
+ */
33
+ import { execFileSync, spawnSync } from 'node:child_process';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { storeRoot, storesAt, darkStores } from '../kb/store-root.mjs';
37
+ import { assertIsolatedMutationWorktree } from './worktree-integrity.mjs';
38
+
39
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
40
+ const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
41
+ const APPLY = process.argv.includes('--apply');
42
+ const arg = (n, d) => {
43
+ const i = process.argv.indexOf(n);
44
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d;
45
+ };
46
+ const MAX = Number(arg('--max', '3'));
47
+
48
+ /** Every repo the org actually has right now. Live, never a remembered list. */
49
+ function liveOrgRepos() {
50
+ try {
51
+ const out = execFileSync('gh', [
52
+ 'repo', 'list', OWNER, '--limit', '500', '--no-archived', '--json', 'name,isFork,pushedAt,diskUsage',
53
+ ], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
54
+ return JSON.parse(out)
55
+ .filter((r) => !r.isFork)
56
+ // AN EMPTY REPO IS NOT A FAILURE TO RETRY FOREVER (measured 2026-08-20).
57
+ //
58
+ // The first bulk run reported 7 failures; six of them are size=0KB — genuinely empty
59
+ // repositories (socket, Auto-GPT, CodeGPT, ruvGPT2, rUvGPT, AIConverse). `git fetch` succeeds
60
+ // and there is simply nothing to embed, so ingestion fails correctly and then the repo stays
61
+ // "missing", so the NEXT night retries it, forever. A permanent nightly failure that is
62
+ // actually correct behaviour trains the reader to ignore the failure line — which is how a
63
+ // real failure would then hide inside it.
64
+ .filter((r) => Number(r.diskUsage ?? 1) > 0)
65
+ // Newest first: if the budget only allows a few tonight, spend it on what rUv shipped most
66
+ // recently, which is what a question is most likely to be about.
67
+ .sort((a, b) => String(b.pushedAt).localeCompare(String(a.pushedAt)))
68
+ .map((r) => r.name);
69
+ } catch (error) {
70
+ return { error: error?.message || String(error) };
71
+ }
72
+ }
73
+
74
+ export function pendingRepos(orgRepos, root = storeRoot()) {
75
+ const have = new Set(storesAt(root).map((s) => String(s).toLowerCase()));
76
+ return orgRepos.filter((name) => !have.has(String(name).toLowerCase()));
77
+ }
78
+
79
+ const isMain = (() => {
80
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
81
+ catch { return false; }
82
+ })();
83
+
84
+ if (isMain) {
85
+ if (APPLY) {
86
+ try { assertIsolatedMutationWorktree(ROOT, 'ingest-new-repos --apply'); }
87
+ catch (error) { console.error(error?.message || String(error)); process.exit(2); }
88
+ }
89
+ const repos = liveOrgRepos();
90
+ if (!Array.isArray(repos)) {
91
+ // A LIST WE COULD NOT FETCH IS NOT AN EMPTY LIST. Reporting "0 new repos" here would read as
92
+ // "the brain is current" when the truth is "I could not look" — the exact conflation this
93
+ // repository keeps paying for.
94
+ console.error(`[ingest-new] could NOT list ${OWNER}'s repos: ${repos.error}`);
95
+ console.error('[ingest-new] this is NOT "nothing new" — it is "I could not look". Exiting 1.');
96
+ process.exit(1);
97
+ }
98
+
99
+ const root = storeRoot();
100
+ const missing = pendingRepos(repos, root);
101
+ console.log(`[ingest-new] ${OWNER}: ${repos.length} live repos, ${storesAt(root).length} ingested, ${missing.length} missing`);
102
+ if (!missing.length) { console.log('[ingest-new] corpus is level with the org.'); process.exit(0); }
103
+
104
+ console.log(`[ingest-new] next up (newest first): ${missing.slice(0, MAX).join(', ')}`);
105
+ if (!APPLY) {
106
+ console.log(`[ingest-new] report only. Re-run with --apply to ingest up to ${MAX}.`);
107
+ process.exit(0);
108
+ }
109
+
110
+ let done = 0; let failed = 0;
111
+ for (const name of missing.slice(0, MAX)) {
112
+ console.log(`\n[ingest-new] ingesting ${name}`);
113
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', name],
114
+ { cwd: ROOT, stdio: 'inherit', env: process.env, timeout: 45 * 60_000 });
115
+ if (r.status === 0) done += 1;
116
+ else { failed += 1; console.log(`[ingest-new] ${name} FAILED — left missing so the next run retries it`); }
117
+ }
118
+
119
+ const stillMissing = missing.length - done;
120
+ const dark = darkStores(root);
121
+ console.log(`\n[ingest-new] ingested ${done}, failed ${failed}, ${stillMissing} still missing.`);
122
+ if (dark.length) {
123
+ console.log(`[ingest-new] ${dark.length} store(s) are DARK — valid bytes no by-description query can reach.`);
124
+ console.log('[ingest-new] a card is a claim about what a repo is FOR; write it from the source, not the name.');
125
+ }
126
+ process.exit(failed ? 1 : 0);
127
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /** Compose measured MetaHarness read surfaces into one fail-closed gate. */
3
+ import crypto from 'node:crypto';
4
+
5
+ const REQUIRED_SCORE_FIELDS = Object.freeze(['harnessFit', 'compileConfidence', 'taskCoverage', 'toolSafety', 'memoryUsefulness']);
6
+ function unwrap(value) { return value && value.data && typeof value.data === 'object' ? value.data : value; }
7
+ function finite(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; }
8
+ function severity(value) { const key = String(value ?? '').toLowerCase(); return ['clean', 'low', 'medium', 'high'].includes(key) ? key : null; }
9
+ function digest(value) { return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
10
+
11
+ export function summarizeScore(input) {
12
+ const score = unwrap(input);
13
+ if (!score || score.degraded === true) return { status: 'unmeasured', degraded: true, reason: score?.reason || 'score unavailable' };
14
+ const dimensions = Object.fromEntries(REQUIRED_SCORE_FIELDS.map((key) => [key, finite(score[key])]));
15
+ const missing = REQUIRED_SCORE_FIELDS.filter((key) => dimensions[key] === null);
16
+ return { status: missing.length ? 'unmeasured' : 'measured', dimensions, missing, costPerRunUsd: finite(score.estCostPerRunUsd), recommendedMode: typeof score.recommendedMode === 'string' ? score.recommendedMode : null, scaffoldReady: typeof score.scaffoldReady === 'boolean' ? score.scaffoldReady : null };
17
+ }
18
+
19
+ export function summarizeAudit(input) {
20
+ const audit = unwrap(input);
21
+ const worst = severity(audit?.composite?.worst ?? audit?.worst);
22
+ const degraded = audit?.degraded === true || Object.values(audit?.components || {}).some((v) => v?.degraded === true);
23
+ return { status: worst ? 'measured' : 'unmeasured', worst, degraded, fingerprint: typeof audit?.fingerprint === 'string' ? audit.fingerprint : null };
24
+ }
25
+
26
+ export function summarizeDrift(input) {
27
+ const drift = unwrap(input);
28
+ const alert = typeof drift?.alert === 'boolean' ? drift.alert : null;
29
+ const verdict = typeof drift?.verdict === 'string' ? drift.verdict : null;
30
+ return { status: alert === null && !verdict ? 'unmeasured' : 'measured', alert, verdict, similarity: finite(drift?.similarity), reason: drift?.reason || null };
31
+ }
32
+
33
+ export function summarizeRoutingReceipts(rows = []) {
34
+ if (!Array.isArray(rows)) return { status: 'unmeasured', count: 0, costUsd: null, savedUsd: null };
35
+ const valid = rows.filter((r) => r && typeof r === 'object' && typeof r.model === 'string');
36
+ const withCost = valid.filter((r) => finite(r.est_cost) !== null);
37
+ const withSaved = valid.filter((r) => finite(r.saved) !== null);
38
+ return { status: valid.length ? 'measured' : 'unmeasured', count: valid.length, costUsd: withCost.length ? withCost.reduce((sum, r) => sum + r.est_cost, 0) : null, savedUsd: withSaved.length ? withSaved.reduce((sum, r) => sum + r.saved, 0) : null, unpriced: valid.length - withCost.length };
39
+ }
40
+
41
+ /** Read-only composition. `allowMetered` defaults false: readiness never authorizes spend. */
42
+ export function buildGateReceipt({ score, audit, drift, routingReceipts = [], candidate = null, allowMetered = false, now = new Date().toISOString() } = {}) {
43
+ const measuredScore = summarizeScore(score), measuredAudit = summarizeAudit(audit), measuredDrift = summarizeDrift(drift), routing = summarizeRoutingReceipts(routingReceipts);
44
+ const reasons = [];
45
+ if (measuredScore.status !== 'measured') reasons.push('score is missing required measured dimensions');
46
+ if (measuredAudit.status !== 'measured') reasons.push('OIA audit has no measured composite severity');
47
+ if (measuredAudit.degraded) reasons.push('OIA audit is degraded');
48
+ if (measuredDrift.status !== 'measured') reasons.push('drift result is unmeasured');
49
+ if (measuredDrift.alert === true || /major|high|regress/i.test(measuredDrift.verdict || '')) reasons.push('drift is alerting');
50
+ if (measuredAudit.worst === 'high') reasons.push('OIA audit is high severity');
51
+ const ready = reasons.length === 0;
52
+ const candidateVerified = candidate?.bundle_verified === true && candidate?.data_source === 'SYNTHETIC';
53
+ const receipt = { schemaVersion: 1, kind: 'metaharness-gate', ts: now, score: measuredScore, audit: measuredAudit, drift: measuredDrift, routing, readiness: ready ? 'ready' : 'blocked', evolution: { allowed: ready && allowMetered === true, reason: ready ? (allowMetered ? 'measured preflight passed and metered spend explicitly allowed' : 'metered spend is disabled') : reasons.join('; ') }, promotion: { allowed: ready && candidateVerified, reason: !ready ? reasons.join('; ') : candidateVerified ? 'verified synthetic replay candidate' : 'candidate is absent or not a verified synthetic replay' } };
54
+ return { ...receipt, evidenceDigest: digest(receipt) };
55
+ }
56
+
57
+ export function assertEvolutionAllowed(receipt) { if (!receipt?.evolution?.allowed) throw new Error(`MetaHarness evolution blocked: ${receipt?.evolution?.reason || 'missing gate receipt'}`); return receipt; }
58
+ export function assertPromotionAllowed(receipt) { if (!receipt?.promotion?.allowed) throw new Error(`MetaHarness promotion blocked: ${receipt?.promotion?.reason || 'missing gate receipt'}`); return receipt; }
@@ -10,9 +10,9 @@
10
10
  # 3. re-embed — the expensive step (~18 min, 8 shards). SKIPPED ENTIRELY when step 2
11
11
  # reports "nothing to do". A quiet night costs seconds, not minutes.
12
12
  #
13
- # PATH is set explicitly: launchd does not inherit a login shell's environment, which is why the
14
- # sibling agent com.ruvnet.brain-nightly has been dying on `spawnSync gh ENOENT` — gh lives in
15
- # /opt/homebrew/bin and launchd never saw it.
13
+ # PATH is set explicitly because launchd does not inherit a login shell's environment. The retired
14
+ # com.ruvnet.brain-nightly source writer previously exposed this exact failure mode: gh lived in
15
+ # /opt/homebrew/bin and launchd could not see it.
16
16
 
17
17
  set -eu
18
18
 
@@ -1,5 +1,9 @@
1
1
  #!/bin/bash
2
- # nightly-wrapper.sh — the ONLY thing launchd invokes for the nightly. Per Stuart's standing order
2
+ # nightly-wrapper.sh — author-side maintenance, intentionally NOT a primary-checkout scheduler.
3
+ # The old com.ruvnet.brain-nightly LaunchAgent invoked this in the primary checkout and accumulated
4
+ # generated source changes beside active human work. That job is retired. The supported scheduled
5
+ # updater is com.ruvnet.brain-update and mutates only the installed cache. If this author harness is
6
+ # run manually, every source-writing command below requires a clean linked worktree.
3
7
  # (2026-07-12): no noise for success or no-op — only ACTIVE alerts for real failure, and the system
4
8
  # must attempt to self-heal before escalating, then make failure impossible to miss next session.
5
9
  #
@@ -7,26 +11,69 @@
7
11
  # class a blind retry can fix) -> if it fails AGAIN, loud phone alert + a durable marker file that
8
12
  # ground-ruvnet.sh surfaces at the top of the very next session, unprompted. Success or a legitimate
9
13
  # "nothing was due tonight" no-op: log only, phone stays silent.
14
+
15
+ # WHICH NODE RUNS THIS DECIDES WHETHER TONIGHT'S WRITES SURVIVE (measured 2026-08-20).
16
+ #
17
+ # This file hardcoded "$NODE_BIN" in eight places. That is node v22.13.1 (ABI 127), and the
18
+ # better-sqlite3 binding agentdb needs is built for ABI 137 — so under it agentdb SILENTLY falls
19
+ # back to non-persistent sql.js and every write is lost. Silently, because better-sqlite3 is an
20
+ # OPTIONAL dependency (ruvnet/ruflo#2219): the failure never errors and the CLI still prints a
21
+ # success table. `lesson-bridge.mjs --apply` and `learning-replay.mjs` both write, so the nightly
22
+ # was discarding its own results. Measured, same command, same DB, only the interpreter differing:
23
+ # "$NODE_BIN" v22.13.1 -> 2 sql.js fallback msgs, DURABLE=NO
24
+ # /opt/homebrew/bin/node v24.18.0 -> 0 fallback msgs, DURABLE=YES
25
+ # Resolve a node whose ABI matches the binding, and fail loudly rather than silently losing data.
26
+ NODE_BIN=""
27
+ for _cand in /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node "$(command -v node 2>/dev/null)"; do
28
+ [ -x "$_cand" ] || continue
29
+ if [ "$("$_cand" -p 'process.versions.modules' 2>/dev/null)" = "137" ]; then NODE_BIN="$_cand"; break; fi
30
+ done
31
+ if [ -z "$NODE_BIN" ]; then
32
+ echo "[nightly] FATAL: no node with ABI 137 found — agentdb writes would silently not persist." >&2
33
+ echo "[nightly] checked: /opt/homebrew/opt/node@24/bin/node /opt/homebrew/bin/node \$(command -v node)" >&2
34
+ exit 1
35
+ fi
36
+
10
37
  set -u
11
38
  # kb/models-cache (the fallback) starts cold every time -> every nightly re-downloads the ONNX
12
39
  # embedder from HuggingFace. Point at the already-warm cache instead (verified present).
13
40
  export KB_MODEL_CACHE="/Users/stuartkerr/Code/PowerPlatePulse/scripts/models-cache"
14
- cd /Users/stuartkerr/Code/ruvnet-brain 2>/dev/null || {
41
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
42
+ WORKTREE_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd)
43
+ cd "$WORKTREE_ROOT" 2>/dev/null || {
15
44
  curl -sS --max-time 10 -H "Title: 🔴 Nightly CRASHED before it could even start" \
16
45
  -H "Priority: urgent" -H "Tags: rotating_light" \
17
46
  -d "cd into the repo failed — filesystem or mount problem. Investigate the machine directly." \
18
47
  "https://ntfy.sh/$(grep -m1 '^NTFY_TOPIC=' /Users/stuartkerr/Code/ruvnet-brain/.env 2>/dev/null | cut -d= -f2)" >/dev/null 2>&1
19
48
  exit 1
20
49
  }
21
- mkdir -p logs .ruvnet-brain
22
- LOG=logs/nightly.log
23
- MARKER=.ruvnet-brain/nightly-failure.json
24
- LOCK=.ruvnet-brain/nightly.lock
25
-
26
- # Single-instance guard (2026-07-12): the plist has no built-in one. The updater is incremental now,
27
- # but a very large upstream change, a slow model, or a stalled gate can still outlive the window and
28
- # overlap the NEXT scheduled 3:15am fire. A stale lock from a crashed run (PID no longer alive) is
29
- # treated as no lock.
50
+ GIT_COMMON_DIR=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || exit 1
51
+ PRIMARY_ROOT=$(dirname "$GIT_COMMON_DIR")
52
+ export RUVNET_PROJECT_MEMORY_DB="$PRIMARY_ROOT/.swarm/memory.db"
53
+ mkdir -p "$PRIMARY_ROOT/logs" "$PRIMARY_ROOT/.ruvnet-brain"
54
+ LOG="$PRIMARY_ROOT/logs/nightly.log"
55
+ MARKER="$PRIMARY_ROOT/.ruvnet-brain/nightly-failure.json"
56
+ LOCK="$PRIMARY_ROOT/.ruvnet-brain/nightly.lock"
57
+ PRIMARY_SNAPSHOT=$(mktemp "${TMPDIR:-/tmp}/ruvnet-brain-primary-snapshot.XXXXXX") || exit 1
58
+ if ! "$NODE_BIN" scripts/worktree-integrity.mjs snapshot "$WORKTREE_ROOT" > "$PRIMARY_SNAPSHOT"; then
59
+ rm -f "$PRIMARY_SNAPSHOT"
60
+ exit 2
61
+ fi
62
+
63
+ finish() {
64
+ rc=$?
65
+ trap - EXIT
66
+ rm -f "$LOCK"
67
+ if ! "$NODE_BIN" scripts/worktree-integrity.mjs verify "$WORKTREE_ROOT" "$PRIMARY_SNAPSHOT" >> "$LOG" 2>&1; then
68
+ echo "===== FATAL: primary checkout changed during isolated author maintenance =====" >> "$LOG"
69
+ rc=3
70
+ fi
71
+ rm -f "$PRIMARY_SNAPSHOT"
72
+ exit "$rc"
73
+ }
74
+
75
+ # Single-instance guard for explicit author invocations. A stale lock from a crashed run (PID no
76
+ # longer alive) is treated as no lock.
30
77
  if [ -f "$LOCK" ]; then
31
78
  OLD_PID=$(cat "$LOCK" 2>/dev/null)
32
79
  if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
@@ -38,26 +85,22 @@ if [ -f "$LOCK" ]; then
38
85
  echo "===== $(date -u +%FT%TZ) — stale lock from pid $OLD_PID (not running) — clearing =====" >> "$LOG"
39
86
  fi
40
87
  echo $$ > "$LOCK"
41
- trap 'rm -f "$LOCK"' EXIT
42
-
43
- # NIGHTLY_SMOKE=1 — prove this whole chain (launchd -> heartbeat wrapper -> here -> node -> the repo
44
- # scan -> the log) WITHOUT a multi-hour rebuild. self-update.mjs is dry-run by default, so dropping
45
- # --apply exercises every link except the build itself. Publication is never part of this job.
46
- # WHY IT EXISTS (2026-07-13): the 03:15 fire on 07-12 died before it could even write its own log
47
- # ("/bin/bash: logs/nightly.log: No such file or directory" — relative path, no working directory) and
48
- # NOBODY KNEW, because the only way to test this chain was to wait until 03:15 and hope. A scheduled job
49
- # you can only test by waiting for it is a job you are not testing. Now: `NIGHTLY_SMOKE=1 <the exact
50
- # plist command>` proves it on demand, any time, in seconds.
88
+ trap finish EXIT
89
+
90
+ # NIGHTLY_SMOKE=1 exercises the author harness and source planner without a multi-hour rebuild. It
91
+ # still requires an isolated clean linked worktree: a diagnostic escape hatch must not reopen the
92
+ # primary-checkout writer. Publication is never part of this job.
51
93
  SMOKE="${NIGHTLY_SMOKE:-0}"
94
+ "$NODE_BIN" scripts/worktree-integrity.mjs "$WORKTREE_ROOT" "nightly author maintenance" >> "$LOG" 2>&1 || exit 2
52
95
 
53
96
  run_once() {
54
97
  local rc
55
98
  { echo "===== nightly-wrapper rebuild attempt $1 — $(date -u +%FT%TZ) ====="
56
99
  if [ "$SMOKE" = "1" ]; then
57
100
  echo "[SMOKE] dry-run: exercising the full chain, NOT building"
58
- /usr/local/bin/node scripts/self-update.mjs --fresh-window 60
101
+ "$NODE_BIN" scripts/self-update.mjs --fresh-window 60
59
102
  else
60
- /usr/local/bin/node scripts/self-update.mjs --apply --fresh-window 60
103
+ "$NODE_BIN" scripts/self-update.mjs --apply --fresh-window 60
61
104
  fi
62
105
  } >> "$LOG" 2>&1
63
106
  rc=$?
@@ -75,13 +118,42 @@ run_once() {
75
118
  return 0
76
119
  }
77
120
 
121
+ # Prepare the source candidate before any optional evidence refresh that writes tracked artifacts.
122
+ # Otherwise learning-replay dirties the isolated lane first and self-update correctly refuses it.
123
+ if run_once 1; then
124
+ if [ "$SMOKE" = "1" ]; then exit 0; fi
125
+ else
126
+ echo "===== first attempt failed — waiting 3 min, retrying once (self-heal for transient issues) =====" >> "$LOG"
127
+ sleep 180
128
+ if run_once 2; then
129
+ echo "===== SELF-HEALED on retry — no escalation needed =====" >> "$LOG"
130
+ else
131
+ # Both attempts genuinely failed. Escalate loudly AND leave a marker the next session cannot miss.
132
+ AFTER=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "unknown")
133
+ TAIL=$(tail -25 "$LOG" | tr '\n' ' ' | cut -c1-2000)
134
+ mkdir -p "$(dirname "$MARKER")"
135
+ python3 -c "
136
+ import json, datetime
137
+ json.dump({
138
+ 'at': datetime.datetime.utcnow().isoformat() + 'Z',
139
+ 'last_release_tag_observed': '$AFTER',
140
+ 'tail': '''$TAIL''',
141
+ 'note': 'Author candidate rebuild failed twice (immediate + 3min retry). See logs/nightly.log.'
142
+ }, open('$MARKER', 'w'), indent=2)
143
+ "
144
+ sh scripts/notify.sh "🔴 Author candidate rebuild FAILED twice" "candidate rebuild failed; last release remains $AFTER. Last: $TAIL" urgent "rotating_light"
145
+ echo "===== ESCALATED: marker written at $MARKER =====" >> "$LOG"
146
+ exit 1
147
+ fi
148
+ fi
149
+
78
150
  # Memory distillation (ADR-174) had gone stale 3 days — same silent-death pattern as everything
79
151
  # else tonight. Independent of rebuild success/failure: mine raw memory_entries into structured
80
152
  # episodes/reasoning_patterns on every nightly run. Best-effort, never blocks the real job.
81
- RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swarm/memory.db >> "$LOG" 2>&1 || true
153
+ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 || true
82
154
  # Durability: was a one-off manual snapshot before tonight. Now recurring — WAL-safe, rotates
83
155
  # automatically (keeps last 14), zero risk to the live DB (reads only).
84
- RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memory.db --keep 14 >> "$LOG" 2>&1 || true
156
+ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db "$RUVNET_PROJECT_MEMORY_DB" --keep 14 >> "$LOG" 2>&1 || true
85
157
 
86
158
  # LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
87
159
  # hooks actually read. Without this the bridge is a command someone has to remember, and a learning
@@ -93,7 +165,7 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memor
93
165
  # it REFUSES to apply when it reads zero rows while bridged lessons are installed — so a transient
94
166
  # read failure can never strip the store. Best-effort, never blocks the rebuild.
95
167
  echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
96
- /usr/local/bin/node plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
168
+ "$NODE_BIN" plugin/scripts/lesson-bridge.mjs --apply >> "$LOG" 2>&1 \
97
169
  || echo "===== lesson-bridge: SKIPPED (see line above) =====" >> "$LOG"
98
170
 
99
171
  # AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
@@ -101,7 +173,7 @@ echo "===== lesson-bridge — $(date -u +%FT%TZ) =====" >> "$LOG"
101
173
  # while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
102
174
  # blocks the real rebuild, just makes a WAL-mode/integrity regression loud instead of silent.
103
175
  echo "===== memdb-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
104
- sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
176
+ sh scripts/memdb-health.sh "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 \
105
177
  && echo "===== memdb-health canary: OK =====" >> "$LOG" \
106
178
  || echo "===== memdb-health canary: UNHEALTHY (see line above) =====" >> "$LOG"
107
179
 
@@ -141,15 +213,15 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
141
213
  # REPORTS ONLY. It cannot push, merge, close or publish (asserted by test). Exit 1 means a human
142
214
  # needs to look, and the reasons print in full rather than as a count.
143
215
  echo "===== GITHUB-HEALTH watch — $(date -u +%FT%TZ) =====" >> "$LOG"
144
- /usr/local/bin/node scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
216
+ "$NODE_BIN" scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
145
217
  || echo "[github-health] findings above need attention" >> "$LOG"
146
218
 
147
219
  echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
148
- /usr/local/bin/node scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
220
+ "$NODE_BIN" scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
149
221
  || echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
150
222
 
151
223
  echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
152
- /usr/local/bin/node scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
224
+ "$NODE_BIN" scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
153
225
  LR_RC=$?
154
226
  case "$LR_RC" in
155
227
  0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;
@@ -165,7 +237,7 @@ esac
165
237
  BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
166
238
  if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
167
239
  echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
168
- if (cd "$BRAIN_KB" && /usr/local/bin/node forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
240
+ if (cd "$BRAIN_KB" && "$NODE_BIN" forge-ask-all.mjs --dir . --q "HNSW vector index" --k 1) >> "$LOG" 2>&1; then
169
241
  echo "===== brain-health canary: OK =====" >> "$LOG"
170
242
  else
171
243
  echo "===== brain-health canary: DOWN — escalating =====" >> "$LOG"
@@ -181,39 +253,8 @@ fi
181
253
  # The canary itself handles urgent pushes on alive->DEAD transitions (and recovery notices), so a
182
254
  # known-dead key doesn't re-alarm every night; here we only log.
183
255
  echo "===== key-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
184
- zsh -lc 'cd /Users/stuartkerr/Code/ruvnet-brain && /usr/local/bin/node scripts/key-canary.mjs --notify' >> "$LOG" 2>&1 \
256
+ zsh -lc 'cd "$1" && "$2" scripts/key-canary.mjs --notify' _ "$WORKTREE_ROOT" "$NODE_BIN" >> "$LOG" 2>&1 \
185
257
  && echo "===== key-health canary: all present keys alive =====" >> "$LOG" \
186
258
  || echo "===== key-health canary: at least one key DEAD (push sent on new deaths) =====" >> "$LOG"
187
259
 
188
- if run_once 1; then
189
- exit 0
190
- fi
191
-
192
- echo "===== first attempt failed — waiting 3 min, retrying once (self-heal for transient issues) =====" >> "$LOG"
193
- sleep 180
194
-
195
- if run_once 2; then
196
- echo "===== SELF-HEALED on retry — no escalation needed =====" >> "$LOG"
197
- exit 0
198
- fi
199
-
200
- # Both attempts genuinely failed. Escalate loudly AND leave a marker the next session cannot miss.
201
- AFTER=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "unknown")
202
- # Was `tail -8 | cut -c1-600`. self-update's [FATAL] block is 4+ lines on its own and now carries a
203
- # 'reason:' line per failed repo, so 8 lines / 600 chars truncated the alert mid-argv — every one of
204
- # the six identical 2026-08-03..08-06 failures escalated with no reason in it. Widen enough that the
205
- # whole [FATAL] block, reasons included, reaches the marker file and the push.
206
- TAIL=$(tail -25 "$LOG" | tr '\n' ' ' | cut -c1-2000)
207
- mkdir -p .ruvnet-brain
208
- python3 -c "
209
- import json, datetime
210
- json.dump({
211
- 'at': datetime.datetime.utcnow().isoformat() + 'Z',
212
- 'last_release_tag_observed': '$AFTER',
213
- 'tail': '''$TAIL''',
214
- 'note': 'Nightly failed twice (immediate + 3min retry). Needs a live session to diagnose — see logs/nightly.log.'
215
- }, open('$MARKER', 'w'), indent=2)
216
- "
217
- sh scripts/notify.sh "🔴 Nightly FAILED twice — needs you" "candidate rebuild failed; last release remains $AFTER. Last: $TAIL" urgent "rotating_light"
218
- echo "===== ESCALATED: marker written at $MARKER =====" >> "$LOG"
219
- exit 1
260
+ exit 0
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ buildSourceScopeReceipt,
7
+ canonicalJson,
8
+ digest,
9
+ validateSourceScopeReceipt,
10
+ } from './source-scope-receipt.mjs';
11
+
12
+ export const PRODUCT_INTEGRITY_SCHEMA_VERSION = 1;
13
+ export const PRODUCT_INTEGRITY_TEST_CLASSES = Object.freeze(['essential', 'supporting', 'obsolete']);
14
+ export const PRODUCT_INTEGRITY_PROOF_STRENGTH = Object.freeze(['unit', 'integration', 'packed-artifact', 'candidate-host', 'public-byte']);
15
+ export const PRODUCT_INTEGRITY_PROCESSES = Object.freeze([
16
+ { id: 'SourceCoverage', upstream: [] }, { id: 'CorpusGeneration', upstream: ['SourceCoverage'] },
17
+ { id: 'ReleaseProjection', upstream: ['CorpusGeneration'] }, { id: 'RefreshLifecycle', upstream: ['ReleaseProjection'] },
18
+ { id: 'HostConvergence', upstream: ['ReleaseProjection'] }, { id: 'ReleaseTransaction', upstream: ['ReleaseProjection'] },
19
+ { id: 'PublicVerification', upstream: ['ReleaseTransaction', 'HostConvergence'] },
20
+ { id: 'ProductIntegrityCase', upstream: ['RefreshLifecycle', 'PublicVerification'] },
21
+ ]);
22
+ export const PRODUCT_INTEGRITY_CONTEXTS = Object.freeze(PRODUCT_INTEGRITY_PROCESSES.map(({ id }) => id));
23
+ const ARCHITECTURE = Object.freeze([
24
+ { id: 'ADR-072', path: 'docs/adr/0072-whole-product-integrity-conformance.md', status: 'Accepted' },
25
+ { id: 'DDD-0018', path: 'docs/ddd/0018-product-integrity-context.md', status: 'Accepted' },
26
+ { id: 'ADR-073', path: 'docs/adr/0073-agentdb-perennial-project-continuity.md', status: 'Accepted' },
27
+ { id: 'DDD-0019', path: 'docs/ddd/0019-project-continuity-context.md', status: 'Accepted' },
28
+ { id: 'ADR-074', path: 'docs/adr/0074-ruvnet-capability-claim-integrity.md', status: 'Accepted' },
29
+ { id: 'DDD-0020', path: 'docs/ddd/0020-capability-claim-integrity-context.md', status: 'Accepted' },
30
+ ]);
31
+ const proofStrength = (file) => file.startsWith('tests/acceptance/') ? 'packed-artifact'
32
+ : file.startsWith('tests/integration/') ? 'integration' : file.startsWith('tests/qe/') ? 'candidate-host' : 'unit';
33
+ const make = (id, statement, owner, contributors, implementation, tests, receiptKinds, supportingArchitecture = []) => Object.freeze({
34
+ id, statement, owner, contributors: Object.freeze(contributors),
35
+ architecture: Object.freeze(['ADR-072', 'DDD-0018', ...supportingArchitecture]),
36
+ implementation: Object.freeze(implementation), behaviors: Object.freeze([Object.freeze({
37
+ id: `${id}.essential`, class: 'essential', commands: Object.freeze(tests.map((file) => `npx vitest run ${file}`)),
38
+ positive: Object.freeze(tests.map((file) => Object.freeze({ file, strength: proofStrength(file) }))),
39
+ adversarial: Object.freeze(tests.map((file) => Object.freeze({ file, strength: proofStrength(file) }))),
40
+ receiptKinds: Object.freeze(receiptKinds),
41
+ })]),
42
+ });
43
+ export const PRODUCT_INTEGRITY_OBLIGATIONS = Object.freeze([
44
+ make('S-1', 'One complete public corpus', 'CorpusGeneration', ['SourceCoverage'], ['scripts/source-coverage.mjs', 'scripts/corpus-reconcile.mjs', 'scripts/corpus-candidate.mjs'], ['tests/unit/source-coverage.test.mjs', 'tests/unit/corpus-reconcile.test.mjs'], ['ruvnet-brain-source-observation', 'ruvnet-brain-corpus-candidate']),
45
+ make('S-2', 'One immutable public release projection', 'ReleaseProjection', ['CorpusGeneration'], ['scripts/build-bundle.mjs', 'scripts/coverage-integrity.mjs', 'scripts/public-inventory.mjs'], ['tests/integration/build-bundle-fence.test.mjs', 'tests/unit/public-inventory.test.mjs'], ['ruvnet-brain-release-coverage']),
46
+ make('S-3', 'Recall at 10 is at least 98 percent and delta citations are complete', 'PublicVerification', ['SourceCoverage', 'CorpusGeneration'], ['scripts/retrieval-canary.mjs', 'scripts/packed-retrieval-canary.mjs'], ['tests/unit/retrieval-canary.test.mjs', 'tests/unit/packed-retrieval-canary.test.mjs'], ['ruvnet-brain-retrieval-canary-plan', 'ruvnet-brain-retrieval-canary-receipt']),
47
+ make('S-4', 'Native nightly runs are ordered, idempotent, and retained within budget', 'RefreshLifecycle', [], ['bin/nightly-refresh.mjs', 'plugin/scripts/nightly-scheduler.mjs', 'scripts/nightly-two-run-proof.mjs', 'kb/refresh-run.mjs'], ['tests/unit/nightly-scheduler.test.mjs', 'tests/unit/nightly-two-run-proof.test.mjs'], ['ruvnet-brain-refresh-run', 'ruvnet-brain-native-two-run-nightly-proof']),
48
+ make('S-5', 'One active corpus preserves private stores and bounded recovery', 'RefreshLifecycle', ['CorpusGeneration'], ['kb/update-storage-transaction.mjs', 'kb/forge-update.mjs', 'kb/brain-profile.mjs', 'bin/install.mjs'], ['tests/unit/update-storage-transaction.test.mjs', 'tests/unit/brain-profile.test.mjs'], ['ruvnet-brain-update-storage-transaction', 'ruvnet-brain-installed-profile']),
49
+ make('S-6', 'Every supported OS and host loader converges on exact public bytes', 'HostConvergence', ['PublicVerification'], ['scripts/host-registry.mjs', 'scripts/host-install-matrix.mjs', 'scripts/public-verification-aggregate.mjs'], ['tests/unit/host-registry.test.mjs', 'tests/integration/dual-host-install.test.mjs'], ['ruvnet-brain-host-registry', 'ruvnet-brain-public-verification-leaf']),
50
+ make('S-7', 'Publication ends only after signed public verification', 'PublicVerification', ['ReleaseTransaction'], ['scripts/release-transaction.mjs', 'scripts/public-verification-finalizer.mjs', '.github/workflows/protected-release.yml'], ['tests/qe/release/release-transaction-faults.test.mjs', 'tests/unit/public-verification-finalizer.test.mjs'], ['ruvnet-brain-release-transaction-receipt', 'ruvnet-brain-public-verification-aggregate']),
51
+ make('S-8', 'Accepted architecture has one executable owner and source-bound trace', 'ProductIntegrityCase', [], ['scripts/product-integrity-contract.mjs', 'scripts/source-scope-receipt.mjs'], ['tests/unit/product-integrity-contract.test.mjs', 'tests/unit/source-scope-receipt.test.mjs'], ['ruvnet-brain-product-integrity-contract', 'ruvnet-brain-product-integrity-trace']),
52
+ make('S-9', 'Every essential behavior has positive and adversarial proof', 'ProductIntegrityCase', [], ['scripts/product-integrity-contract.mjs', 'scripts/adr-072-completion.mjs'], ['tests/unit/product-integrity-contract.test.mjs', 'tests/unit/adr-072-completion.test.mjs'], ['ruvnet-brain-product-integrity-trace', 'ruvnet-brain-adr-072-completion']),
53
+ make('S-10', 'Fable 5 and GPT-5.6-Sol review identical immutable inputs', 'ProductIntegrityCase', ['PublicVerification'], ['scripts/public-verification-aggregate.mjs', '.github/workflows/protected-release.yml'], ['tests/unit/public-verification-aggregate.test.mjs', 'tests/unit/protected-release-workflow.test.mjs'], ['ruvnet-brain-independent-review', 'ruvnet-brain-public-verification-aggregate']),
54
+ make('S-11', 'Project continuity is complete and host-neutral', 'ProductIntegrityCase', ['HostConvergence', 'RefreshLifecycle'], ['plugin/scripts/project-progression-contract.mjs', 'plugin/scripts/project-progression-hook.mjs'], ['tests/unit/project-progression-contract.test.mjs', 'tests/acceptance/cross-host-project-resume.test.mjs'], ['ruvnet-brain-project-progression', 'ruvnet-brain-cross-host-resume'], ['ADR-073', 'DDD-0019']),
55
+ make('S-12', 'RuvNet capability claims are bound to live evidence before delivery', 'ProductIntegrityCase', ['HostConvergence', 'PublicVerification'], [
56
+ 'plugin/scripts/capability-inventory-receipt.mjs',
57
+ 'plugin/scripts/capability-claim-evidence.mjs',
58
+ 'plugin/scripts/continuation-gate.mjs',
59
+ 'plugin/mcp/managed-cli-interface.mjs',
60
+ 'kb/forge-evidence.mjs',
61
+ ], [
62
+ 'tests/unit/capability-inventory-receipt.test.mjs',
63
+ 'tests/unit/capability-claim-evidence.test.mjs',
64
+ 'tests/unit/continuation-gate-capability-truth.test.mjs',
65
+ 'tests/unit/managed-cli-interface.test.mjs',
66
+ 'tests/unit/grounding-receipt-lanes.test.mjs',
67
+ 'tests/acceptance/adr-074-packed-capability-claims.acceptance.test.mjs',
68
+ ], [
69
+ 'ruvnet-brain-capability-inventory',
70
+ 'ruvnet-brain-capability-claim-audit',
71
+ 'ruvnet-brain-source-claim',
72
+ 'ruvnet-brain-live-surface',
73
+ 'ruvnet-brain-capability-claim-aggregate',
74
+ ], ['ADR-074', 'DDD-0020']),
75
+ ]);
76
+ const IDS = Object.freeze(Array.from({ length: 12 }, (_, index) => `S-${index + 1}`));
77
+ const safePath = (value) => typeof value === 'string' && value.length > 0 && !path.isAbsolute(value) && !value.split(/[\\/]/).includes('..');
78
+
79
+ function validateProcesses(processes) {
80
+ if (!Array.isArray(processes) || canonicalJson(processes.map(({ id }) => id)) !== canonicalJson(PRODUCT_INTEGRITY_CONTEXTS)) throw new Error('product integrity contract must contain the exact eight processes');
81
+ const ids = new Set(processes.map(({ id }) => id)); const visiting = new Set(); const visited = new Set();
82
+ const visit = (id) => { if (visiting.has(id)) throw new Error('product integrity process graph contains a cycle'); if (visited.has(id)) return;
83
+ visiting.add(id); const row = processes.find((entry) => entry.id === id);
84
+ if (!Array.isArray(row?.upstream) || new Set(row.upstream).size !== row.upstream.length || row.upstream.some((upstream) => !ids.has(upstream) || upstream === id)) throw new Error(`${id} has invalid upstream processes`);
85
+ row.upstream.forEach(visit); visiting.delete(id); visited.add(id); };
86
+ processes.forEach(({ id }) => visit(id));
87
+ }
88
+
89
+ export function validateProductIntegrityContract(input = {}) {
90
+ const processes = Array.isArray(input) ? PRODUCT_INTEGRITY_PROCESSES : input.processes || PRODUCT_INTEGRITY_PROCESSES;
91
+ const obligations = Array.isArray(input) ? input : input.obligations || PRODUCT_INTEGRITY_OBLIGATIONS;
92
+ const architecture = input.architecture || ARCHITECTURE;
93
+ validateProcesses(processes);
94
+ if (!Array.isArray(architecture) || new Set(architecture.map(({ id }) => id)).size !== architecture.length || architecture.some((row) => row.status !== 'Accepted' || !safePath(row.path))) throw new Error('governing architecture is missing or not Accepted');
95
+ if (!Array.isArray(obligations) || canonicalJson(obligations.map(({ id }) => id)) !== canonicalJson(IDS)) throw new Error('product integrity obligations are missing, duplicated, or out of order');
96
+ const contexts = new Set(processes.map(({ id }) => id)); const architectureIds = new Set(architecture.map(({ id }) => id)); const behaviorIds = new Set();
97
+ for (const row of obligations) {
98
+ if (!contexts.has(row.owner)) throw new Error(`${row.id} has no valid sole owner`);
99
+ if (!Array.isArray(row.contributors) || new Set(row.contributors).size !== row.contributors.length || row.contributors.some((context) => !contexts.has(context)) || row.contributors.includes(row.owner)) throw new Error(`${row.id} has an invalid contributor`);
100
+ if (!Array.isArray(row.architecture) || row.architecture.some((id) => !architectureIds.has(id))) throw new Error(`${row.id} has missing architecture`);
101
+ if (!Array.isArray(row.implementation) || !row.implementation.length || row.implementation.some((file) => !safePath(file))) throw new Error(`${row.id} has no implementation`);
102
+ if (!Array.isArray(row.behaviors) || !row.behaviors.length) throw new Error(`${row.id} has no behaviors`);
103
+ for (const behavior of row.behaviors) {
104
+ if (!behavior.id || behaviorIds.has(behavior.id)) throw new Error(`${row.id} has duplicate behavior identity`); behaviorIds.add(behavior.id);
105
+ if (!PRODUCT_INTEGRITY_TEST_CLASSES.includes(behavior.class)) throw new Error(`${behavior.id} has invalid test class`);
106
+ if (behavior.class === 'essential') {
107
+ for (const field of ['commands', 'positive', 'adversarial', 'receiptKinds']) if (!Array.isArray(behavior[field]) || !behavior[field].length) throw new Error(`${behavior.id} has no ${field}`);
108
+ for (const proof of [...behavior.positive, ...behavior.adversarial]) if (!safePath(proof.file) || !PRODUCT_INTEGRITY_PROOF_STRENGTH.includes(proof.strength)) throw new Error(`${behavior.id} has invalid proof`);
109
+ }
110
+ }
111
+ }
112
+ return { schemaVersion: 1, kind: 'ruvnet-brain-product-integrity-contract', architecture,
113
+ processes: processes.map((row) => ({ ...row, owns: obligations.filter(({ owner }) => owner === row.id).map(({ id }) => id), contributes: obligations.filter(({ contributors }) => contributors.includes(row.id)).map(({ id }) => id) })), obligations };
114
+ }
115
+
116
+ export function productIntegrityGovernedPaths(contract = validateProductIntegrityContract()) {
117
+ return [...new Set([...contract.architecture.map(({ path: file }) => file), ...contract.obligations.flatMap(({ implementation, behaviors }) => [...implementation, ...behaviors.flatMap(({ positive, adversarial }) => [...positive, ...adversarial].map(({ file }) => file))])])].sort();
118
+ }
119
+ export function buildProductIntegrityTrace({ root = '.', sourceSha, contract = validateProductIntegrityContract(), inventory } = {}) {
120
+ if (!/^[a-f0-9]{40}$/.test(String(sourceSha || ''))) throw new Error('product integrity trace requires an exact source SHA');
121
+ const validated = validateProductIntegrityContract(contract);
122
+ const unsigned = { schemaVersion: 1, kind: 'ruvnet-brain-product-integrity-trace', sourceSha, contractSha256: digest(validated), sourceScope: buildSourceScopeReceipt({ root, sourceSha, governedPaths: productIntegrityGovernedPaths(validated), inventory }), contract: validated, verdict: 'PASS', untested: [] };
123
+ return { ...unsigned, traceSha256: digest(unsigned) };
124
+ }
125
+ export function validateProductIntegrityTrace(trace, { root = '.', sourceSha = trace?.sourceSha, inventory } = {}) {
126
+ const { traceSha256, ...unsigned } = trace || {};
127
+ if (trace?.schemaVersion !== 1 || trace?.kind !== 'ruvnet-brain-product-integrity-trace' || trace.sourceSha !== sourceSha || trace.verdict !== 'PASS' || canonicalJson(trace.untested) !== '[]' || trace.contractSha256 !== digest(trace.contract) || traceSha256 !== digest(unsigned)) throw new Error('product integrity trace identity is invalid');
128
+ validateSourceScopeReceipt(trace.sourceScope, { root, inventory });
129
+ const rebuilt = buildProductIntegrityTrace({ root, sourceSha, contract: trace.contract, inventory });
130
+ if (canonicalJson(rebuilt) !== canonicalJson(trace)) throw new Error('product integrity trace differs from the exact source');
131
+ return trace;
132
+ }
133
+ export function renderProductIntegrityTraceMarkdown(contract = validateProductIntegrityContract()) {
134
+ const lines = ['# ADR-072 generated traceability', '', '> Generated from `scripts/product-integrity-contract.mjs`; do not hand-edit.', '', '## Processes', '', '| Process | Upstream | Owns | Contributes |', '|---|---|---|---|'];
135
+ for (const row of contract.processes) lines.push(`| ${row.id} | ${row.upstream.join(', ') || '—'} | ${row.owns.join(', ') || '—'} | ${row.contributes.join(', ') || '—'} |`);
136
+ lines.push('', '## Obligations', '', '| ID | Statement | Owner | Contributors |', '|---|---|---|---|');
137
+ for (const row of contract.obligations) lines.push(`| ${row.id} | ${row.statement} | ${row.owner} | ${row.contributors.join(', ') || '—'} |`);
138
+ lines.push('', '## Essential behavior proofs', '', '| Behavior | Positive | Adversarial | Receipts |', '|---|---|---|---|');
139
+ for (const row of contract.obligations) for (const behavior of row.behaviors.filter(({ class: type }) => type === 'essential')) lines.push(`| ${behavior.id} | ${behavior.positive.map(({ file, strength }) => `${file} (${strength})`).join('<br>')} | ${behavior.adversarial.map(({ file, strength }) => `${file} (${strength})`).join('<br>')} | ${behavior.receiptKinds.join('<br>')} |`);
140
+ return `${lines.join('\n')}\n`;
141
+ }
142
+ const valueAfter = (argv, flag) => { const index = argv.indexOf(flag); return index >= 0 ? argv[index + 1] : null; };
143
+ export function runProductIntegrityCli(argv = process.argv.slice(2), io = { stdout: process.stdout }) {
144
+ const known = new Set(['--markdown', '--check-markdown', '--trace', '--source-sha', '--out', '--verify-trace']);
145
+ if (argv.some((arg) => arg.startsWith('--') && !known.has(arg))) throw new Error('unknown product integrity argument');
146
+ if (!argv.length) { io.stdout.write(`${canonicalJson(validateProductIntegrityContract())}\n`); return 0; }
147
+ if (argv.length === 1 && argv[0] === '--markdown') { io.stdout.write(renderProductIntegrityTraceMarkdown()); return 0; }
148
+ const check = valueAfter(argv, '--check-markdown');
149
+ if (check) { if (fs.readFileSync(path.resolve(check), 'utf8') !== renderProductIntegrityTraceMarkdown()) throw new Error('generated traceability Markdown differs'); io.stdout.write('product integrity Markdown: PASS\n'); return 0; }
150
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const sourceSha = valueAfter(argv, '--source-sha'); const out = valueAfter(argv, '--out');
151
+ if (argv.includes('--trace') && sourceSha && out) { fs.writeFileSync(path.resolve(out), `${canonicalJson(buildProductIntegrityTrace({ root, sourceSha }))}\n`, { flag: 'wx', mode: 0o600 }); return 0; }
152
+ const verify = valueAfter(argv, '--verify-trace');
153
+ if (verify && sourceSha) { const trace = JSON.parse(fs.readFileSync(path.resolve(verify), 'utf8')); validateProductIntegrityTrace(trace, { root, sourceSha }); io.stdout.write(`${canonicalJson({ verdict: 'PASS', traceSha256: trace.traceSha256 })}\n`); return 0; }
154
+ throw new Error('invalid product integrity argument combination');
155
+ }
156
+ if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) { try { process.exitCode = runProductIntegrityCli(); } catch (error) { console.error(`[product-integrity-contract] ${error.message}`); process.exitCode = 1; } }