ruvnet-brain 4.3.9 → 4.3.10

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 (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. package/scripts/wired-check.mjs +31 -2
@@ -175,7 +175,8 @@ const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
175
175
 
176
176
  // ── the governed set ────────────────────────────────────────────────────────────────────────────
177
177
 
178
- // Resolve every declared entry to a concrete blob at HEAD. Globs expand through the index; a
178
+ // Resolve tracked declarations against actual working bytes. HEAD establishes membership,
179
+ // not the content currently being verified. Globs expand through the index; a
179
180
  // literal entry is looked up with `git cat-file --batch-check`, which distinguishes blob / tree /
180
181
  // missing in ONE process and — unlike `git rev-parse HEAD:<p>` — never prints the requested path
181
182
  // back as if it were an answer.
@@ -220,8 +221,30 @@ export function resolveGoverned(root, entries) {
220
221
  continue;
221
222
  }
222
223
  const hit = byPath.get(e.path) || { sha: null, type: onDisk ? 'untracked' : 'missing' };
223
- const type = hit.type === 'missing' && onDisk ? 'untracked' : hit.type;
224
- results.push({ ...e, type, sha: hit.sha, onDisk, resolved: type === 'blob' });
224
+ let type = hit.type === 'missing' && onDisk ? 'untracked' : hit.type;
225
+ let sha = hit.sha;
226
+ if (type === 'blob') {
227
+ try {
228
+ const relative = path.relative(path.resolve(root), path.resolve(abs));
229
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
230
+ throw new Error('governed path escapes project');
231
+ }
232
+ let current = path.resolve(root);
233
+ for (const part of relative.split(path.sep)) {
234
+ current = path.join(current, part);
235
+ if (fs.lstatSync(current).isSymbolicLink()) throw new Error('governed path contains a symlink');
236
+ }
237
+ if (!fs.lstatSync(abs).isFile()) throw new Error('governed blob is not a regular file');
238
+ const bytes = fs.readFileSync(abs);
239
+ // Git blob identity without writing objects or invoking one process per file.
240
+ // Unchanged bytes retain the existing recipe; staged and unstaged edits both expire it.
241
+ sha = crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
242
+ } catch {
243
+ type = onDisk ? 'unsafe-working-source' : 'missing';
244
+ sha = null;
245
+ }
246
+ }
247
+ results.push({ ...e, type, sha, onDisk, resolved: type === 'blob' });
225
248
  }
226
249
  return results;
227
250
  }
@@ -290,7 +313,7 @@ export function deriveImpl(root, governed, { checkWiring = true } = {}) {
290
313
  export const DRIFT_COMMITS_STALE = 2;
291
314
  export const DRIFT_DAYS_STALE = 7;
292
315
 
293
- export function deriveDrift(root, docRel, governed) {
316
+ export function deriveDrift(root, docRel, governed, { floorSha = null } = {}) {
294
317
  const paths = governed.filter((g) => g.resolved).map((g) => g.path);
295
318
  if (!paths.length) return { state: 'not-applicable', commits: 0, days: 0, reason: 'no resolvable governed paths' };
296
319
  const doc = lastCommit(root, docRel);
@@ -310,8 +333,16 @@ export function deriveDrift(root, docRel, governed) {
310
333
  // paths is version-identifier lines cannot have changed any decision, so it does not count. Any
311
334
  // commit touching one substantive line still counts in full — this narrows the trigger, never the
312
335
  // verdict.
336
+ const floor = floorSha && git(root, ['rev-parse', '--verify', `${floorSha}^{commit}`]);
337
+ const resolvedFloor = floor?.ok ? floor.out : null;
313
338
  const shas = (() => {
314
- const r = git(root, ['rev-list', `${doc.sha}..HEAD`, '--', ...paths]);
339
+ // With --changed, only movement introduced after the candidate merge-base can consume the
340
+ // candidate's drift budget. Historical debt remains visible in an unscoped report, but cannot
341
+ // turn one new governed edit into a threshold breach merely because the ADR was already stale.
342
+ const range = resolvedFloor
343
+ ? ['HEAD', `^${doc.sha}`, `^${resolvedFloor}`]
344
+ : [`${doc.sha}..HEAD`];
345
+ const r = git(root, ['rev-list', ...range, '--', ...paths]);
315
346
  return r.ok && r.out ? r.out.split('\n').filter(Boolean) : [];
316
347
  })();
317
348
  const VERSION_FIELD = /^[+-]\s*"?(version|releaseTag|brainVersion|softwareVersion|tag)"?\s*[:=]/i;
@@ -327,12 +358,20 @@ export function deriveDrift(root, docRel, governed) {
327
358
  const substantive = shas.filter((s) => !isVersionOnly(s));
328
359
  const commits = substantive.length;
329
360
 
330
- const codeR = git(root, ['log', '-1', '--format=%ad', '--date=short', `${doc.sha}..HEAD`, '--', ...paths]);
331
- const codeDate = codeR.ok && codeR.out ? codeR.out.split('\n')[0] : null;
361
+ const codeR = substantive.length
362
+ ? git(root, ['show', '-s', '--format=%ad', '--date=short', substantive[0]])
363
+ : null;
364
+ const codeDate = codeR?.ok && codeR.out ? codeR.out.split('\n')[0] : null;
365
+
366
+ let anchorDate = doc.date;
367
+ if (resolvedFloor && git(root, ['merge-base', '--is-ancestor', doc.sha, resolvedFloor]).ok) {
368
+ const floorR = git(root, ['show', '-s', '--format=%ad', '--date=short', resolvedFloor]);
369
+ if (floorR.ok && floorR.out) anchorDate = floorR.out.split('\n')[0];
370
+ }
332
371
 
333
372
  let days = 0;
334
373
  if (codeDate) {
335
- const d0 = Date.parse(`${doc.date}T00:00:00Z`);
374
+ const d0 = Date.parse(`${anchorDate}T00:00:00Z`);
336
375
  const d1 = Date.parse(`${codeDate}T00:00:00Z`);
337
376
  if (Number.isFinite(d0) && Number.isFinite(d1)) days = Math.max(0, Math.round((d1 - d0) / 86400000));
338
377
  }
@@ -412,7 +451,7 @@ export function evaluateDoc(root, rel, opts = {}) {
412
451
  const fm = parseFrontmatter(text);
413
452
  const k = fm.keys;
414
453
 
415
- const CONVENTION_KEYS = ['status', 'date', 'updated', 'impl', 'governs', 'verified', 'verified_digest'];
454
+ const CONVENTION_KEYS = ['status', 'date', 'updated', 'impl', 'governs', 'verified', 'verified_digest', 'reviewed_digest'];
416
455
  const declared = CONVENTION_KEYS.filter((key) => k[key] !== undefined);
417
456
  // A document predating the convention carries NONE of its keys. That is a derived property of the
418
457
  // file, not a hardcoded list of filenames — a new doc written to the convention can never be
@@ -586,7 +625,28 @@ export function evaluateDoc(root, rel, opts = {}) {
586
625
  }
587
626
 
588
627
  // ── drift ─────────────────────────────────────────────────────────────────────────────────────
589
- const drift = deriveDrift(root, rel, governed);
628
+ // A review records examination and findings, NOT normative agreement, acceptance, or verified
629
+ // implementation. Use the existing digest: frontmatter and the Currency log already exclude
630
+ // their own metadata, while every governed byte and normative claim remains covered unchanged.
631
+ // Require an explicit review row naming this digest and a governed-source referent. This checks
632
+ // evidence structure, never sincerity; changing a date or merely dirtying a doc proves no review.
633
+ const log = parseCurrencyLog(root, text);
634
+ const reviewedDigest = k.reviewed_digest || null;
635
+ const reviewMatch = Boolean(reviewedDigest && digest.digest && reviewedDigest === digest.digest);
636
+ const reviewRow = reviewedDigest && log.rows.find((row) => /^\d{4}-\d{2}-\d{2}$/.test(row.date)
637
+ && /\breview(?:ed)?\b/i.test(row.what)
638
+ && `${row.what} ${row.why}`.split(/[^a-f0-9]+/i).includes(reviewedDigest)
639
+ && row.referents.some((ref) => ref.kind === 'path'
640
+ && governed.some((g) => g.resolved && g.path === ref.value)));
641
+ const reviewed = reviewMatch && Boolean(reviewRow);
642
+ doc.review = { stored: reviewedDigest, computed: digest.digest, match: reviewMatch,
643
+ recorded: Boolean(reviewRow), current: reviewed };
644
+ if (reviewedDigest && !reviewed) {
645
+ add(WARN, 'review-unsubstantiated', !reviewMatch
646
+ ? 'reviewed_digest does not bind the current document and governed bytes — no current review is established'
647
+ : 'reviewed_digest requires a dated Currency log review row naming this digest and a governed-source path — no current review is established');
648
+ }
649
+ const drift = deriveDrift(root, rel, governed, { floorSha: opts.driftFloor });
590
650
  doc.drift = drift;
591
651
  // A DECISION THAT IS NOT IN FORCE CANNOT DRIFT (ADR-056, 2026-07-27). Rejected / Superseded /
592
652
  // Deprecated documents describe a path NOT taken, or one another document has since taken over.
@@ -600,15 +660,16 @@ export function evaluateDoc(root, rel, opts = {}) {
600
660
  // day someone un-rejects it the finding returns to BLOCK on its own.
601
661
  const notInForce = /^(rejected|superseded|deprecated)$/i.test(statusWord || '');
602
662
  if (drift.state === 'presumed-stale') {
603
- add(notInForce ? WARN : BLOCK, 'presumed-stale',
604
- `governed code moved ${drift.commits} commit(s) (${drift.days}d) after the document's own last commit (${drift.docDate}) — nobody has checked since it moved. Paths: ${drift.paths.join(', ')}`
605
- + (notInForce ? ` — reported, NOT blocked: status "${statusWord}" means this decision is not in force, so it cannot drift from code it never governed` : ''));
663
+ add(notInForce || reviewed ? WARN : BLOCK, 'presumed-stale',
664
+ `governed code moved ${drift.commits} commit(s) (${drift.days}d) after the document's own last commit (${drift.docDate}). Paths: ${drift.paths.join(', ')}`
665
+ + (reviewed ? ' — exact current bytes have a recorded review; historical drift remains visible, but does not establish missing review. This is NOT normative agreement or verification.'
666
+ : notInForce ? ` — reported, NOT blocked: status "${statusWord}" means this decision is not in force, so it cannot drift from code it never governed`
667
+ : ' — no source-bound review is recorded since it moved.'));
606
668
  } else if (drift.state === 'lagging') {
607
669
  add(WARN, 'lagging', `governed code moved ${drift.commits} commit(s) after the document — normal within a session; reported, not blocked`);
608
670
  }
609
671
 
610
672
  // ── currency log ──────────────────────────────────────────────────────────────────────────────
611
- const log = parseCurrencyLog(root, text);
612
673
  doc.currencyLog = log;
613
674
  if (!doc.legacy && !log.present) {
614
675
  add(WARN, 'no-currency-log', 'no `## Currency log` section — the *why* of each change is unrecorded');
@@ -784,12 +845,23 @@ export function main(argv = process.argv.slice(2)) {
784
845
  }
785
846
 
786
847
  const dirs = a.dirs ?? DEFAULT_DIRS;
787
- const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring });
848
+ let touched = null;
849
+ let driftFloor = null;
850
+ if (a.changed) {
851
+ const diff = git(a.root, ['diff', '--name-only', `${a.changed}...HEAD`]);
852
+ if (!diff.ok) {
853
+ process.stderr.write('[doc-currency] candidate base cannot be resolved.\n');
854
+ return 1;
855
+ }
856
+ touched = new Set(diff.ok ? diff.out.split('\n').filter(Boolean) : []);
857
+ const base = git(a.root, ['merge-base', a.changed, 'HEAD']);
858
+ if (!base.ok || !base.out) return 1;
859
+ if (base.ok && base.out) driftFloor = base.out.split('\n')[0];
860
+ }
861
+ const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring, driftFloor });
788
862
 
789
863
  let scope = null;
790
- if (a.changed) {
791
- const r = git(a.root, ['diff', '--name-only', `${a.changed}...HEAD`]);
792
- const touched = new Set(r.ok ? r.out.split('\n').filter(Boolean) : []);
864
+ if (touched) {
793
865
  scope = changedDocumentScope(result.docs, touched);
794
866
  }
795
867
 
@@ -816,7 +888,7 @@ export function main(argv = process.argv.slice(2)) {
816
888
  recipe: DIGEST_RECIPE,
817
889
  docs: result.docs.map((d) => ({
818
890
  file: d.file, id: d.id, legacy: d.legacy, status: d.status, date: d.date, updated: d.updated,
819
- implStored: d.implStored, impl: d.impl, digest: d.digest, drift: d.drift,
891
+ implStored: d.implStored, impl: d.impl, digest: d.digest, review: d.review, drift: d.drift,
820
892
  governs: d.governsDeclared, findings: d.findings,
821
893
  })),
822
894
  }, null, 2) + '\n');
@@ -1,163 +1,5 @@
1
1
  #!/bin/sh
2
- # RuvNet Brain — pre-push gate. NON-OPTIONAL. Runs on every `git push`.
3
- #
4
- # WHY (2026-07-17): a real client (Jan) hit npx ruvnet-brain@latest with a stale package whose
5
- # self-update manifest 404'd. The root cause wasn't the bug — it was that the CHECK was optional
6
- # and I skipped it. This hook removes the discretion: it refuses the push if the version surfaces
7
- # disagree or the self-update manifest 404s. It does not care whether I remembered, or was in a
8
- # hurry, or "was pretty sure it was fine." Pass or the push does not happen.
9
- #
10
- # It blocks ONLY on what is knowable before a push. npm-latest and the live explainer are verified
11
- # post-publish by `node scripts/release.mjs --publish` (the only sanctioned ship path).
12
- #
13
- # Escape hatch is `git push --no-verify` — but if you use it, you are consciously overriding a gate
14
- # that exists because of a specific failure that cost real user trust. Own that out loud.
15
- #
16
- # SECRET SCAN (2026-07-21): a buggy diagnostic echoed live OPENAI and OPENROUTER keys into a
17
- # terminal, and AgentDB's session autocapture wrote them to .swarm/. Nothing reached git — but the
18
- # only thing that stopped it was a GLOBAL gitignore that does not travel to a clone or another
19
- # machine. Relying on "the file happened to be ignored" is not a control. This is: a real key
20
- # pattern in anything being pushed refuses the push. Placeholders (your-key, abc123, xxxx,
21
- # deadbeef, example) are excluded, because rUv's own docs are full of them and a gate that cries
22
- # wolf on documentation is a gate people learn to bypass.
23
- #
24
- # SCOPE FIX (2026-07-22): this scanned `git diff --cached HEAD` — the STAGED INDEX. But a push ships
25
- # COMMITS, and at push time the index is normally clean, so the scan read nothing. Proven both ways
26
- # in a scratch repo: a key COMMITTED then pushed sailed through, while the same key merely STAGED was
27
- # blocked. The gate could only catch the one case that never happens. It is the same defect class the
28
- # 7e715bb commit fixed in the *test* — a check that cannot fail in the situation it was written for —
29
- # which survived here in the *scan*. Now it reads the actual push range from the hook's stdin.
30
- RANGE_DIFF=""
31
- CURRENCY_BASE=""
32
- while read -r _local_ref local_sha _remote_ref remote_sha; do
33
- [ -z "$local_sha" ] && continue
34
- case "$local_sha" in *[!0]*) ;; *) continue ;; esac # all-zeros = branch deletion, nothing to scan
35
- case "$remote_sha" in
36
- ""|*[!0-9a-f]*|0000000000000000000000000000000000000000) # new branch: scan its whole history vs other refs
37
- RANGE_DIFF="$RANGE_DIFF$(git diff --no-color -U0 "$(git merge-base HEAD origin/main 2>/dev/null || echo '')" "$local_sha" 2>/dev/null)"
38
- # ADR-056 §5: a new branch has no remote counterpart, so the merge-base with main is the only
39
- # honest base. If even that cannot resolve, CURRENCY_BASE stays empty and the currency check is
40
- # SKIPPED rather than run against a guess — never a scope invented from nothing.
41
- [ -z "$CURRENCY_BASE" ] && CURRENCY_BASE="$(git merge-base HEAD origin/main 2>/dev/null || true)" ;;
42
- *)
43
- RANGE_DIFF="$RANGE_DIFF$(git diff --no-color -U0 "$remote_sha" "$local_sha" 2>/dev/null)"
44
- # ADR-056: the remote tip is only a valid "what did I change" base when it is an ANCESTOR of
45
- # what we are pushing. After a rebase or squash it is NOT — the range then spans the entire
46
- # rewrite and drags in every document the upstream branch already carried, red ones included.
47
- # Measured 2026-07-27: a force-push after rebasing onto main was refused for ADR-054 and
48
- # ADR-055 — two documents on main that this branch never touched. GPT-5.6 predicted exactly
49
- # this in the ADR-056 duel ("rebase or squash changes the touched-file set without changing
50
- # whether a violation is genuinely new"). So: ancestor => use it; otherwise fall back to the
51
- # merge-base with main, which is the honest answer to "what is mine".
52
- if [ -z "$CURRENCY_BASE" ]; then
53
- if git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
54
- CURRENCY_BASE="$remote_sha"
55
- else
56
- CURRENCY_BASE="$(git merge-base "$local_sha" origin/main 2>/dev/null || true)"
57
- fi
58
- fi ;;
59
- esac
60
- done
61
- # Fall back to the staged index too, so a pre-commit-style invocation still gets covered.
62
- RANGE_DIFF="$RANGE_DIFF
63
- $(git diff --cached --no-color -U0 HEAD 2>/dev/null)"
64
-
65
- SECRET_HITS=$(printf '%s' "$RANGE_DIFF" | grep -E "^\+" \
66
- | grep -oE "sk-(proj|ant|or-v1)-[A-Za-z0-9_-]{20,}" \
67
- | grep -vEi "your-key|abc123|xxxx+|deadbeef|example|placeholder|REDACTED" | head -3)
68
- if [ -n "$SECRET_HITS" ]; then
69
- echo ""
70
- echo " ⛔ PUSH BLOCKED — what looks like a REAL API key is in this push."
71
- echo " matched (prefix only): $(echo "$SECRET_HITS" | cut -c1-14 | tr '\n' ' ')"
72
- echo " Remove it, rotate the key, and push again. Never --no-verify past this one:"
73
- echo " a key that reaches a remote is compromised the moment it lands."
74
- echo ""
75
- exit 1
76
- fi
77
-
78
- # Hooks may be shared by several git worktrees. Resolving from this script's own path is wrong when
79
- # core.hooksPath names another checkout: the hook then validates that checkout while pushing the
80
- # current one (measured 2026-07-28: announced 3.9.128, pushed 3.9.129). Git already knows the
81
- # worktree whose refs are being pushed, so derive the shipping root from that live repository.
82
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
83
- if [ -z "$ROOT" ] || [ ! -f "$ROOT/scripts/verify-channels.mjs" ]; then
84
- echo " ⛔ pre-push gate cannot resolve the worktree being pushed."
85
- exit 1
86
- fi
87
- node "$ROOT/scripts/verify-channels.mjs" --pre-push
88
- status=$?
89
- if [ "$status" -ne 0 ]; then
90
- echo ""
91
- echo " ⛔ pre-push gate refused this push. A user on some path would get a broken build."
92
- echo " Fix the channel(s) above, then push again. Do not route around this."
93
- exit 1
94
- fi
95
-
96
- # Generated public census and command aliases have one source of truth each. Both writers have a
97
- # `--check` mode specifically for CI/pre-push; until 2026-08-22 nothing invoked either check, so the
98
- # writers were built and tested but their promised gate did not exist.
99
- node "$ROOT/scripts/sync-census.mjs" --check
100
- census_status=$?
101
- if [ "$census_status" -ne 0 ]; then
102
- echo ""
103
- echo " ⛔ pre-push gate refused this push — public census claims have drifted."
104
- echo " Run: node scripts/sync-census.mjs"
105
- exit 1
106
- fi
107
-
108
- node "$ROOT/scripts/sync-commands.mjs" --check
109
- commands_status=$?
110
- if [ "$commands_status" -ne 0 ]; then
111
- echo ""
112
- echo " ⛔ pre-push gate refused this push — command aliases no longer share one body."
113
- echo " Run: node scripts/sync-commands.mjs"
114
- exit 1
115
- fi
116
-
117
- # The convergence manifest is the exact source/version/ADR identity consumed by hosted QA.
118
- # Check it here so a push cannot publish a commit whose generated identity was produced before
119
- # the final source edit (the failure mode that repeatedly escaped local pre-push validation).
120
- if [ -f "$ROOT/scripts/convergence-manifest.mjs" ]; then
121
- node "$ROOT/scripts/convergence-manifest.mjs"
122
- convergence_status=$?
123
- else
124
- convergence_status=0
125
- fi
126
- if [ "$convergence_status" -ne 0 ]; then
127
- echo ""
128
- echo " ⛔ pre-push gate refused this push — convergence identity is stale."
129
- echo " Run: npm run convergence:write"
130
- exit 1
131
- fi
132
-
133
- # ── DOCUMENT CURRENCY (ADR-056 §5) ─────────────────────────────────────────────────────────────────
134
- # The tool was written and tested on 2026-07-22 and then invoked by NOTHING for five days — absent
135
- # from this hook, gate.sh, gates.mjs and every workflow, while `wired-check` reported it "wired"
136
- # because package.json DEFINED an npm alias for it. This line is the whole fix.
137
- #
138
- # NO RATCHET, DELIBERATELY. The first draft of ADR-056 proposed a stateful new-vs-pre-existing
139
- # violation tracker so this gate could coexist with 32 pre-existing BLOCKs forever. Two independent
140
- # adversarial reviews killed it: a committed baseline is gamed by the same commit that adds the
141
- # violation, re-deriving at the remote SHA needs a second worktree, and "report the rest forever"
142
- # is the wolf-cry doc-currency.mjs's own header names as its designed failure mode. The debt was
143
- # PAID instead (32 BLOCKs -> 4, all four genuine re-reads), which makes the plain gate safe.
144
- #
145
- # SCOPED to what this push touches. A pre-existing red document must not block someone who merely
146
- # walked past it — that teaches people never to touch the red documents, preserving exactly the drift
147
- # this exists to end. Scope is empty => nothing to check => pass; never a scope invented from nothing.
148
- if [ -n "$CURRENCY_BASE" ]; then
149
- node "$ROOT/scripts/doc-currency.mjs" --check --changed "$CURRENCY_BASE"
150
- dc_status=$?
151
- if [ "$dc_status" -ne 0 ]; then
152
- echo ""
153
- echo " ⛔ pre-push gate refused this push — a document you touched disagrees with its code."
154
- echo " An ADR that describes a world the code left is worse than no ADR."
155
- echo " \`node scripts/doc-currency.mjs --fix\` backfills every date git can prove;"
156
- echo " everything else is a claim only you can make."
157
- exit 1
158
- fi
159
- fi
160
- echo ""
161
- echo " ✓ pre-push gate passed — version, channels, secrets, and document currency are clean."
162
- echo ""
163
- exit 0
2
+ # Select the active checkout's development check. Qualification is explicit and hosted once.
3
+ set -eu
4
+ ROOT="$(git rev-parse --show-toplevel)"
5
+ exec node "$ROOT/scripts/development-push-check.mjs"
@@ -26,6 +26,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { findStores, diagnose } from './memory-doctor.mjs';
27
27
  import { learnerCwd, loadRuntimePreferences } from '../plugin/scripts/runtime-preferences.mjs';
28
28
  import { projectDirectory } from '../plugin/scripts/project-identity.mjs';
29
+ import { newestSnapshot, snapshotInventory, MTIME_GRACE_MS } from './snapshot-freshness.mjs';
29
30
 
30
31
  const HOME = os.homedir();
31
32
  // The SAME project root learn-flush.mjs computes, by the same rule — the two halves of the flush
@@ -263,22 +264,39 @@ function distillFleet() {
263
264
  const receiptPath = argv.includes('--receipt') ? argv[argv.indexOf('--receipt') + 1] : null;
264
265
  const receipt = [];
265
266
  const writeReceipt = () => {
266
- if (!receiptPath) return;
267
+ if (!receiptPath) return true;
267
268
  try {
268
269
  fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
269
270
  fs.writeFileSync(receiptPath, JSON.stringify({ at: new Date().toISOString(), stores: receipt }, null, 2));
270
- } catch { /* the receipt is for undo; failing to write it must not fail the repair itself */ }
271
+ return true;
272
+ } catch { return false; }
271
273
  };
272
274
 
273
275
  for (const t of targets) {
274
276
  const dir = path.join(path.dirname(t.db), 'backups');
277
+ const backupStartedAt = Date.now();
278
+ let priorSnapshots;
279
+ try { priorSnapshots = snapshotInventory(dir); }
280
+ catch (error) { failed.push(`${t.name}: refused to distill — ${error.message}`); continue; }
275
281
  try { execFileSync(RUFLO, ['memory', 'backup', '--db', t.db, '--dir', dir], { env: RUFLO_ENV, stdio: 'ignore', timeout: 300_000 }); }
276
282
  catch (e) { failed.push(`${t.name}: refused to distill — snapshot failed (${String(e.message).slice(0, 80)})`); continue; }
283
+ // `memory backup` exiting 0 is not proof a NEW file landed in `dir` — a stale snapshot from a
284
+ // prior fleet run sitting there already would otherwise be silently accepted as this run's undo
285
+ // (the exact gap `distill-project.mjs` was fixed for in PR #192, applied here to fleet's own,
286
+ // separate call site — see the 2026-08-29 Dream Cycle report, "Next steps #1").
287
+ const snapshot = newestSnapshot(dir, backupStartedAt, MTIME_GRACE_MS, priorSnapshots);
288
+ if (!snapshot) {
289
+ failed.push(`${t.name}: refused to distill — snapshot reported success but no fresh file landed in ${dir}`);
290
+ continue;
291
+ }
277
292
  // Record the snapshot BEFORE distilling, and flush after every store. If the process is killed
278
293
  // mid-fleet, the receipt still names every store already modified — a partial receipt is
279
294
  // recoverable, a missing one is not.
280
- receipt.push({ db: t.db, name: t.name, backupDir: dir });
281
- writeReceipt();
295
+ receipt.push({ db: t.db, name: t.name, backupDir: dir, snapshot });
296
+ if (!writeReceipt()) {
297
+ failed.push(`${t.name}: refused to distill — requested undo receipt could not be written`);
298
+ continue;
299
+ }
282
300
 
283
301
  try {
284
302
  execFileSync(RUFLO, ['memory', 'distill', 'run', '--db', t.db, '--judge', 'structural', '--budget-usd', '0'],
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { automaticHookRetirementStatus } from '../bin/install.mjs';
5
+
6
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ export function main() {
9
+ const result = automaticHookRetirementStatus(ROOT);
10
+ if (!result.ok) {
11
+ console.error(`hook-retirement-check: FAIL — ${result.registrations.length} automatic registration(s), ${result.errors.length} manifest error(s)`);
12
+ for (const error of result.errors) console.error(` ${error}`);
13
+ for (const item of result.registrations) console.error(` ${item.file} ${item.event}: ${item.command || '(empty command)'}`);
14
+ return 1;
15
+ }
16
+ console.log(`hook-retirement-check: PASS — zero automatic Brain registrations across ${result.files.length} source, contract, and host-pointer surfaces`);
17
+ return 0;
18
+ }
19
+
20
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
21
+ process.exitCode = main();
22
+ }