ruvnet-brain 4.7.20 → 4.7.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/install.mjs +618 -104
- package/console/activity.js +122 -38
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-boot-surface-proof.mjs +208 -0
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +26 -20
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +41 -18
- package/scripts/onboarding-console.mjs +123 -23
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recall-latency-probe.mjs +48 -0
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +82 -28
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +13 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
// listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a public store family (named by either COVERAGE.json
|
|
15
15
|
// or the live SOURCE.json) that a newer release replaced or retired;
|
|
16
16
|
// * installer-written or reinstallable (node_modules/, the updater/validator files the installer places);
|
|
17
|
-
// * a symbolic link identical in the live brain (links are compared, never followed)
|
|
17
|
+
// * a symbolic link identical in the live brain (links are compared, never followed);
|
|
18
|
+
// * (#335) a regular file byte-identical at the same path in a KEPT sibling: another copy the caller has
|
|
19
|
+
// already decided to retain, or a `kb.archive/<stamp>/<copy>/` tree. The caller passes only copies that
|
|
20
|
+
// stay on disk, so duplicate preserved copies release down to one and no unique byte is lost.
|
|
21
|
+
// * an OPERATIONAL file the Brain's own processes (or a ruflo hook run with the KB as its cwd) wrote into
|
|
22
|
+
// the tree: a top-level update.log / forge-update.log / forge-guard-injection.log, or ruflo's
|
|
23
|
+
// .claude-flow/ scratch, each a regular file of at most RESCUE_CAP_BYTES (RESCUE_TOTAL_CAP_BYTES per
|
|
24
|
+
// copy). No bundle ships these and nothing reads them back, but a log can be the only trace of a failed
|
|
25
|
+
// night, so they are not just dropped: they are returned as `rescue` (with their bytes), and the caller
|
|
26
|
+
// copies them out with footprint-io.mjs rescueOperationalFiles BEFORE it removes the copy; a failed
|
|
27
|
+
// rescue keeps the copy. The rescued files are kept for the newest released copies (brain-footprint.mjs
|
|
28
|
+
// FOOTPRINT_POLICY.rescuedCopiesKept).
|
|
18
29
|
// Anything else — a user's own file, an unfenced store, a link the live brain lacks — KEEPS the copy, and
|
|
19
30
|
// is named. Paths are checked for symlink ancestors within both KB roots before any exemption; host
|
|
20
31
|
// aliases above those roots (such as macOS /tmp) are not traversed as part of this check. Invalid metadata,
|
|
@@ -54,11 +65,25 @@ const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i
|
|
|
54
65
|
const storeStem = (file) => path.basename(String(file)).toLowerCase()
|
|
55
66
|
.replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
|
|
56
67
|
.replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
|
|
68
|
+
// Operational files (see the header). Top-level logs by exact name: plugin/scripts/nightly-scheduler.mjs
|
|
69
|
+
// installScheduler (update.log), kb/forge-guard-injection.mjs LOG_PATH, kb/forge-update.mjs's documented
|
|
70
|
+
// cron line (forge-update.log). .claude-flow/ is ruflo's working state, the same debris brain-footprint.mjs
|
|
71
|
+
// RUFLO_DEBRIS removes from ruflo-cwd. A larger file, a link, or anything else (.swarm/ included) stays unique,
|
|
72
|
+
// and so does every operational file past RESCUE_TOTAL_CAP_BYTES per copy (the rescue itself is bounded).
|
|
73
|
+
const OPERATIONAL_LOG = /^(?:update|forge-update|forge-guard-injection)\.log$/;
|
|
74
|
+
export const RESCUE_CAP_BYTES = 2 * 1024 * 1024;
|
|
75
|
+
export const RESCUE_TOTAL_CAP_BYTES = 16 * 1024 * 1024;
|
|
76
|
+
const isOperational = (relative) => (!relative.includes(path.sep) && OPERATIONAL_LOG.test(relative))
|
|
77
|
+
|| (relative.split(path.sep)[0] === '.claude-flow' && relative.includes(path.sep));
|
|
57
78
|
// Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
|
|
58
79
|
// placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
|
|
59
|
-
|
|
80
|
+
// Every bin/install.mjs UPDATER_FILES name must be here (tests/unit/kb-copy-proof-unknown-content.test.mjs holds the
|
|
81
|
+
// two lists together): download-retry.mjs and forge-guard-injection.mjs were placed but missing, so an old copy whose
|
|
82
|
+
// placed version differed from both the live file and its own manifest was kept as "unique" (review round 2, Opus #5).
|
|
83
|
+
export const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
|
|
60
84
|
'forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs', 'update-storage-transaction.mjs',
|
|
61
|
-
'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', '
|
|
85
|
+
'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs',
|
|
86
|
+
'verify-citation.mjs', 'package-lock.json', '.DS_Store']);
|
|
62
87
|
|
|
63
88
|
/** A directory that is a knowledge-base tree (SOURCE.json plus a store or the search entry point). */
|
|
64
89
|
export const isKbTree = (dir) => Boolean(lstat(path.join(dir, 'SOURCE.json')))
|
|
@@ -140,10 +165,11 @@ function metadataFailure(root) {
|
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
/**
|
|
143
|
-
* @returns {{disposable: boolean, unique: {file: string, why: string}[],
|
|
144
|
-
* always names the files that keep it
|
|
168
|
+
* @returns {{disposable: boolean, unique: {file: string, why: string}[], rescue?: {file: string, bytes: number,
|
|
169
|
+
* sha256: string}[], reason: string}} — a kept copy always names the files that keep it; a disposable copy
|
|
170
|
+
* lists the operational files that must be rescued before it is removed.
|
|
145
171
|
*/
|
|
146
|
-
export function kbCopyProof({ copyDir, liveDir }) {
|
|
172
|
+
export function kbCopyProof({ copyDir, liveDir, keptSiblings = [] }) {
|
|
147
173
|
const copy = lstat(copyDir);
|
|
148
174
|
if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
|
|
149
175
|
const live = lstat(liveDir);
|
|
@@ -172,7 +198,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
172
198
|
// it lists with these exact bytes is a public, re-downloadable release file.
|
|
173
199
|
const shipped = new Map((readJson(path.join(copyDir, 'ARCHIVE-MANIFEST.json'))?.files || [])
|
|
174
200
|
.filter((f) => typeof f?.path === 'string').map((f) => [path.normalize(f.path), f]));
|
|
175
|
-
|
|
201
|
+
// #335: retained siblings that may account for a file. Only real directories, never the copy or live itself.
|
|
202
|
+
const siblings = (Array.isArray(keptSiblings) ? keptSiblings : []).filter((dir) => {
|
|
203
|
+
const st = lstat(dir);
|
|
204
|
+
return st && st.isDirectory() && !st.isSymbolicLink() && path.resolve(dir) !== path.resolve(copyDir)
|
|
205
|
+
&& path.resolve(dir) !== path.resolve(liveDir);
|
|
206
|
+
});
|
|
207
|
+
let bySibling = 0;
|
|
208
|
+
const inKeptSibling = (relative) => {
|
|
209
|
+
const hit = siblings.some((dir) => safeAncestors(dir, relative) && sameBytes(path.join(copyDir, relative), path.join(dir, relative)));
|
|
210
|
+
if (hit) bySibling += 1;
|
|
211
|
+
return hit;
|
|
212
|
+
};
|
|
213
|
+
const unique = []; const rescue = []; let rescueBytes = 0;
|
|
176
214
|
let files;
|
|
177
215
|
try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
|
|
178
216
|
for (const { relative, link, size } of files) {
|
|
@@ -183,7 +221,7 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
183
221
|
continue;
|
|
184
222
|
}
|
|
185
223
|
if (isPrivate) {
|
|
186
|
-
if (!sameBytes(path.join(copyDir, relative), inLive)) {
|
|
224
|
+
if (!sameBytes(path.join(copyDir, relative), inLive) && !inKeptSibling(relative)) {
|
|
187
225
|
unique.push({ file: relative, why: lstat(inLive) ? 'private file differs from the live brain' : 'private file absent from the live brain' });
|
|
188
226
|
}
|
|
189
227
|
continue;
|
|
@@ -202,9 +240,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
|
|
|
202
240
|
if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
|
|
203
241
|
if (sameBytes(path.join(copyDir, relative), inLive)) continue; // existence alone proves no ownership or redundancy
|
|
204
242
|
if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
|
|
243
|
+
if (inKeptSibling(relative)) continue; // #335: these exact bytes stay in a retained sibling copy or the archive
|
|
244
|
+
if (isOperational(relative) && size <= RESCUE_CAP_BYTES && rescueBytes + size <= RESCUE_TOTAL_CAP_BYTES) {
|
|
245
|
+
rescue.push({ file: relative, bytes: size, sha256: sha256File(path.join(copyDir, relative)) });
|
|
246
|
+
rescueBytes += size;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (isOperational(relative)) { unique.push({ file: relative, why: 'an operational file past the rescue cap (2 MiB each, 16 MiB per copy)' }); continue; }
|
|
205
250
|
unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
|
|
206
251
|
}
|
|
207
252
|
return unique.length
|
|
208
253
|
? { disposable: false, unique, reason: `holds ${unique.length} file(s) the live brain does not: ${unique.slice(0, 4).map((u) => u.file).join(', ')}${unique.length > 4 ? ', …' : ''}` }
|
|
209
|
-
: { disposable: true, unique, reason:
|
|
254
|
+
: { disposable: true, unique, rescue, reason: `${bySibling
|
|
255
|
+
? `${bySibling} file(s) the live brain lacks are byte-identical in a kept copy or kb.archive; the rest is in the live brain or re-downloadable`
|
|
256
|
+
: 'every private file is byte-identical in the live brain; public bytes are re-downloadable'}${
|
|
257
|
+
rescue.length ? `; ${rescue.length} operational log/scratch file(s) are rescued to the brain home first` : ''}` };
|
|
210
258
|
}
|
|
@@ -9,6 +9,7 @@ import { learningContext } from './runtime-preferences.mjs';
|
|
|
9
9
|
import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
|
|
10
10
|
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
11
11
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
12
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
12
13
|
|
|
13
14
|
// LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
|
|
14
15
|
// children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
|
|
@@ -148,7 +149,7 @@ function runDetached(mode) {
|
|
|
148
149
|
} catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
const isMain = (
|
|
152
|
+
const isMain = isDirectInvocation(import.meta.url);
|
|
152
153
|
if (isMain && ['--observe', '--distill'].includes(process.argv[2])) {
|
|
153
154
|
runDetached(process.argv[2]);
|
|
154
155
|
process.exit(0);
|
|
@@ -17,23 +17,29 @@ export function learningTarget(context, { env = process.env, explicitLegacyApply
|
|
|
17
17
|
const root = path.join(context.home, '.claude', 'global-memory');
|
|
18
18
|
const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
|
|
19
19
|
if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// #377: the user store is a FIXED path, validated directly. It is not a project, so no Git root is
|
|
21
|
+
// resolved (a ~/.claude that is itself a Git checkout made every user write "foreign global learner
|
|
22
|
+
// root"), and a missing store says so instead of surfacing a raw lstat ENOENT.
|
|
22
23
|
const db = path.join(root, '.swarm', 'memory.db');
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
|
|
24
|
+
const entry = (file) => { try { return fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
|
|
25
|
+
const [rootStat, swarmStat, dbStat] = [root, path.dirname(db), db].map(entry);
|
|
26
|
+
if (!rootStat || !swarmStat || !dbStat) throw new Error(`user store not adopted (no ${db})`);
|
|
27
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('user learning root is not a real directory');
|
|
28
|
+
if (!swarmStat.isDirectory() || swarmStat.isSymbolicLink()) throw new Error('user learning .swarm is not a real directory');
|
|
29
|
+
if (!dbStat.isFile() || dbStat.isSymbolicLink() || dbStat.nlink !== 1) throw new Error('user store is not an exclusive regular file');
|
|
30
|
+
const canonicalRoot = path.join(fs.realpathSync.native(context.home), '.claude', 'global-memory');
|
|
31
|
+
if (fs.realpathSync.native(root) !== canonicalRoot) throw new Error('user learning root is reached through a link');
|
|
32
|
+
return path.join(canonicalRoot, '.swarm', 'memory.db');
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000, maxStepMs = 6000 } = {}) {
|
|
35
|
+
export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000, maxStepMs = 6000, maxBuffer = 65536 } = {}) {
|
|
30
36
|
const remaining = deadline - Date.now();
|
|
31
37
|
if (remaining <= 0) throw new Error('learning deadline exhausted');
|
|
32
38
|
const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.cache', 'ruvnet-brain', 'learning-ruflo') });
|
|
33
39
|
try {
|
|
34
40
|
const invocation = rufloInvocation(binary, args);
|
|
35
41
|
return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
|
|
36
|
-
encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer
|
|
42
|
+
encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer, windowsHide: true });
|
|
37
43
|
} finally { fs.rmSync(cwd, { force: true, recursive: true }); }
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -73,15 +79,53 @@ export function recordLearningObservation(binary, context, { tool, action, sessi
|
|
|
73
79
|
return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The namespace's last distillation from `ruflo memory distill status --db` (#377). That command has
|
|
84
|
+
* only a table output: the `distill_state cursor` table's Namespace column is 20 wide, so
|
|
85
|
+
* `learning-observations` prints as `learning-observat...`; a truncated cell matches by prefix, and
|
|
86
|
+
* an ambiguous or unreadable answer stays unknown. `null` with known=true means never distilled.
|
|
87
|
+
*/
|
|
88
|
+
export function parseDistillStatus(text, namespace = LEARNING_NAMESPACE) {
|
|
89
|
+
const lines = String(text || '').replace(/\u001b\[[0-9;]*m/g, '').split(/\r?\n/);
|
|
90
|
+
const start = lines.findIndex((line) => /distill_state cursor/.test(line));
|
|
91
|
+
if (start < 0) return /No distill_state cursor rows yet/.test(lines.join('\n')) ? { known: true, lastDistillAt: null } : { known: false, lastDistillAt: null };
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (const line of lines.slice(start + 1)) {
|
|
94
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
95
|
+
if (cells.length < 5 || cells[1] === 'Namespace') continue;
|
|
96
|
+
rows.push({ name: cells[1], lastRun: cells[3] });
|
|
97
|
+
}
|
|
98
|
+
const exact = rows.filter((row) => row.name === namespace);
|
|
99
|
+
const truncated = rows.filter((row) => row.name.endsWith('...') && namespace.startsWith(row.name.slice(0, -3)));
|
|
100
|
+
const matches = exact.length ? exact : truncated;
|
|
101
|
+
if (!matches.length) return { known: true, lastDistillAt: null };
|
|
102
|
+
if (matches.length > 1) return { known: false, lastDistillAt: null };
|
|
103
|
+
if (matches[0].lastRun === 'never') return { known: true, lastDistillAt: null };
|
|
104
|
+
const at = Date.parse(matches[0].lastRun);
|
|
105
|
+
return Number.isFinite(at) ? { known: true, lastDistillAt: new Date(at).toISOString() } : { known: false, lastDistillAt: null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Observation count through `ruflo memory list` and the last distillation through `ruflo memory distill status` (no direct SQLite). Unknown stays unknown. */
|
|
109
|
+
export function learningStoreStatus(db, { binary = resolveRuflo(), env = process.env, run = runLearningRuflo } = {}) {
|
|
110
|
+
const unknown = { known: false, observations: 0, patterns: null, lastDistillAt: null, distillKnown: false, pendingObservations: null, oldestPendingAt: null };
|
|
111
|
+
if (!binary) return unknown;
|
|
79
112
|
try {
|
|
80
|
-
const listed =
|
|
113
|
+
const listed = run(binary, ['memory', 'list', '--namespace', LEARNING_NAMESPACE, '--limit', '100000', '--format', 'json', '--path', db], db, { env, maxBuffer: 64 * 1024 * 1024 });
|
|
81
114
|
const text = String(listed.stdout || ''); const at = text.indexOf('[');
|
|
82
|
-
if (listed.status !== 0 || at < 0) return
|
|
83
|
-
|
|
84
|
-
|
|
115
|
+
if (listed.status !== 0 || at < 0) return unknown;
|
|
116
|
+
const rows = JSON.parse(text.slice(at));
|
|
117
|
+
let distill = { known: false, lastDistillAt: null };
|
|
118
|
+
try {
|
|
119
|
+
const status = run(binary, ['memory', 'distill', 'status', '--db', db], db, { env });
|
|
120
|
+
if (!status.error && status.status === 0) distill = parseDistillStatus(status.stdout);
|
|
121
|
+
} catch { /* distillation state stays unknown */ }
|
|
122
|
+
const since = distill.lastDistillAt ? Date.parse(distill.lastDistillAt) : -Infinity;
|
|
123
|
+
// Observations recorded after the last distillation are the ones still waiting for it.
|
|
124
|
+
const pending = distill.known ? rows.filter((row) => !(Number(row?.createdAt) <= since)) : null;
|
|
125
|
+
const oldest = pending ? Math.min(...pending.map((row) => Number(row?.createdAt)).filter(Number.isFinite)) : Infinity;
|
|
126
|
+
return { known: true, observations: rows.length, patterns: null, lastDistillAt: distill.lastDistillAt, distillKnown: distill.known,
|
|
127
|
+
pendingObservations: pending ? pending.length : null, oldestPendingAt: Number.isFinite(oldest) ? new Date(oldest).toISOString() : null };
|
|
128
|
+
} catch { return unknown; }
|
|
85
129
|
}
|
|
86
130
|
|
|
87
131
|
/** `ruflo memory backup` then `ruflo memory distill run`; both are rUv's own commands. */
|
|
@@ -118,9 +162,11 @@ export function observeLearning(options = {}) {
|
|
|
118
162
|
if (!context.enabled) return result;
|
|
119
163
|
try {
|
|
120
164
|
result.learningDb = learningTarget(context, { env });
|
|
121
|
-
const state = learningStoreStatus(result.learningDb, { env });
|
|
165
|
+
const state = learningStoreStatus(result.learningDb, { env, ...(options.binary ? { binary: options.binary } : {}), ...(options.run ? { run: options.run } : {}) });
|
|
122
166
|
result.statusKnown = state.known; result.trajectories = state.observations; result.observations = state.observations;
|
|
123
167
|
result.patterns = state.patterns; result.lastDistillAt = state.lastDistillAt;
|
|
168
|
+
result.distillKnown = state.distillKnown; result.pendingObservations = state.pendingObservations; result.oldestPendingAt = state.oldestPendingAt;
|
|
169
|
+
if (state.lastDistillAt) result.lastTrainSeconds = Math.max(0, Math.round(((options.now ?? Date.now()) - Date.parse(state.lastDistillAt)) / 1000));
|
|
124
170
|
} catch { /* Missing consent/store/readback remains unknown. */ }
|
|
125
171
|
return result;
|
|
126
172
|
}
|
|
@@ -24,6 +24,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { makeLesson, updateLessons, loadLessons, ENFORCEMENT, ORIGIN, STATUS, TRIGGERS } from './lesson-store.mjs';
|
|
26
26
|
import { loadNodeSqlite } from './node-sqlite.mjs';
|
|
27
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
27
28
|
|
|
28
29
|
/** Every bridged lesson id starts with this. It is how a merge knows which rows it owns. */
|
|
29
30
|
export const BRIDGE_PREFIX = 'G-';
|
|
@@ -250,8 +251,7 @@ const argv = process.argv.slice(2);
|
|
|
250
251
|
*/
|
|
251
252
|
function isMain() {
|
|
252
253
|
try {
|
|
253
|
-
|
|
254
|
-
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
254
|
+
return isDirectInvocation(import.meta.url);
|
|
255
255
|
} catch { return false; }
|
|
256
256
|
}
|
|
257
257
|
if (isMain()) {
|
|
@@ -80,6 +80,8 @@ import {
|
|
|
80
80
|
CONFIG_ROOT,
|
|
81
81
|
loadLessons,
|
|
82
82
|
lessonsFor,
|
|
83
|
+
projectNameFor,
|
|
84
|
+
lessonApplies,
|
|
83
85
|
ENFORCEMENT,
|
|
84
86
|
STATUS,
|
|
85
87
|
ORIGIN,
|
|
@@ -263,42 +265,8 @@ const gateState = event ? readGateState() : {};
|
|
|
263
265
|
* the fastest way to make someone uninstall a nudge is to nudge them about something that has
|
|
264
266
|
* nothing to do with what they are doing.
|
|
265
267
|
*/
|
|
266
|
-
const HERE = (()
|
|
267
|
-
|
|
268
|
-
for (let i = 0; i < 12; i++) {
|
|
269
|
-
if (fs.existsSync(path.join(d, '.git'))) break;
|
|
270
|
-
const up = path.dirname(d);
|
|
271
|
-
if (up === d) { d = process.cwd(); break; }
|
|
272
|
-
d = up;
|
|
273
|
-
}
|
|
274
|
-
return path.basename(d);
|
|
275
|
-
})();
|
|
276
|
-
/**
|
|
277
|
-
* A "suffix match" for project names, bounded so it cannot fire on a bare shared tail. `a` matches
|
|
278
|
-
* `b` when `b` is a whole path/name SEGMENT suffix of `a` — the character immediately before it must
|
|
279
|
-
* be a separator, never a mid-word letter. Without this, "Sentry" matched inside "WhitSentry" and any
|
|
280
|
-
* project name that merely happens to end another's, which is the exact cross-project leak the
|
|
281
|
-
* 2026-07-22 project-scope fix (this same function, below) was written to close.
|
|
282
|
-
*/
|
|
283
|
-
const segmentSuffixMatch = (a, b) => {
|
|
284
|
-
if (a === b) return true;
|
|
285
|
-
if (a.length <= b.length || !a.endsWith(b)) return false;
|
|
286
|
-
return /[-_/]/.test(a[a.length - b.length - 1]);
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
/** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
|
|
290
|
-
* names carry prefixes like `Code-` that the directory name does not — but bounded to a real
|
|
291
|
-
* delimiter so it cannot match a bare, accidental shared suffix between unrelated projects. */
|
|
292
|
-
const isHome = (l) => {
|
|
293
|
-
const ps = Array.isArray(l.projects) ? l.projects : [];
|
|
294
|
-
if (!ps.length) return true; // unscoped: applies anywhere, by declaration
|
|
295
|
-
return ps.some((p) => {
|
|
296
|
-
const n = String(p).replace(/^Code-/, '');
|
|
297
|
-
return n === HERE || String(p) === HERE
|
|
298
|
-
|| segmentSuffixMatch(HERE, n) || segmentSuffixMatch(n, HERE);
|
|
299
|
-
});
|
|
300
|
-
};
|
|
301
|
-
const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
|
|
268
|
+
const HERE = projectNameFor(process.cwd());
|
|
269
|
+
const applies = (l) => lessonApplies(l, HERE);
|
|
302
270
|
|
|
303
271
|
// Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
|
|
304
272
|
// call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
|
|
@@ -313,10 +281,10 @@ const effectiveTriggers = command === null
|
|
|
313
281
|
const seen = new Set();
|
|
314
282
|
const candidates = [];
|
|
315
283
|
for (const t of effectiveTriggers) {
|
|
316
|
-
for (const l of lessonsFor(t, lessons.filter(
|
|
284
|
+
for (const l of lessonsFor(t, lessons.filter(applies), { limit: 3 })) {
|
|
317
285
|
if (seen.has(l.id)) continue;
|
|
318
|
-
// Away from home, only a lesson with cross-project evidence may speak.
|
|
319
|
-
if (!
|
|
286
|
+
// Away from home, only a lesson with cross-project evidence may speak (lessonApplies: one rule, shared with the Console).
|
|
287
|
+
if (!applies(l)) continue;
|
|
320
288
|
seen.add(l.id); candidates.push(l);
|
|
321
289
|
}
|
|
322
290
|
}
|
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
* node scripts/lesson-promote.mjs --json # machine-readable, for the console
|
|
37
37
|
* node scripts/lesson-promote.mjs --apply # write the promotion block (backs up first)
|
|
38
38
|
* node scripts/lesson-promote.mjs --min-projects 3
|
|
39
|
+
* node scripts/lesson-promote.mjs --demote-theme <key> # reject a theme — sticky, never re-proposed
|
|
40
|
+
* node scripts/lesson-promote.mjs --restore-theme <key> # undo a demotion
|
|
39
41
|
*/
|
|
40
42
|
import fs from 'node:fs';
|
|
41
43
|
import path from 'node:path';
|
|
@@ -124,34 +126,55 @@ export function collectLessons(root = PROJECTS) {
|
|
|
124
126
|
* other project's context.
|
|
125
127
|
*/
|
|
126
128
|
/**
|
|
127
|
-
*
|
|
129
|
+
* THE DEMOTED-THEMES FILE — where "reject this theme, permanently" is recorded, and why it is not a
|
|
130
|
+
* field on a lesson row.
|
|
128
131
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* `
|
|
132
|
+
* The original design (2026-07-22) stored a `themeKey` on a demoted lesson inside `lessons.json` and
|
|
133
|
+
* read it back here. It never worked, and could not have: `makeLesson()` (`lesson-store.mjs`)
|
|
134
|
+
* destructures a FIXED field set and freezes the result, so an unknown key — `themeKey` included — is
|
|
135
|
+
* silently dropped the moment a row passes through it. Verified 2026-09-07 by construction: a
|
|
136
|
+
* hand-edited row carrying `themeKey` survives only until the very next legitimate write ANYWHERE in
|
|
137
|
+
* the store (`lesson-ratify.mjs --demote`, `updateLessons()`, any `loadLessons()` → `saveLessons()`
|
|
138
|
+
* round trip) — at that point every row, not just the one touched, has been re-serialized through
|
|
139
|
+
* `makeLesson()` and the field is gone. There was also no writer at all: `lesson-ratify.mjs --demote`
|
|
140
|
+
* only ever demotes by lesson `id`, never by theme. `demotedThemeKeys()` therefore always returned an
|
|
141
|
+
* empty set in every real invocation, and the sticky-demotion guard in `analyze()` below could never
|
|
142
|
+
* fire — the exact "demotion is theatre" failure ADR-030 §5 names, just for a different reason than
|
|
143
|
+
* the 2026-07-22 comment this replaces described.
|
|
135
144
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
145
|
+
* `lesson-gate.mjs`'s `OPTIN_PATH` documents the identical failure shape for a different field
|
|
146
|
+
* (`userOptedIntoBlocking`) and moved that consent to its own file for exactly this reason. That
|
|
147
|
+
* precedent was never applied here; it is now. Theme demotion gets its own small file, written only
|
|
148
|
+
* by `--demote-theme` / `--restore-theme` below, and never touched by the mining or lesson-store
|
|
149
|
+
* write paths — so no future lesson-store write can silently erase it.
|
|
138
150
|
*/
|
|
139
|
-
|
|
151
|
+
export const DEMOTED_THEMES_PATH = process.env.RUVNET_DEMOTED_THEMES
|
|
152
|
+
|| path.join(HOME, '.config', 'ruvnet-brain', 'demoted-themes.json');
|
|
153
|
+
|
|
154
|
+
function demotedThemeKeys(file = DEMOTED_THEMES_PATH) {
|
|
140
155
|
try {
|
|
141
|
-
const file = process.env.RUVNET_LESSON_STORE
|
|
142
|
-
|| path.join(os.homedir(), '.config', 'ruvnet-brain', 'lessons.json');
|
|
143
156
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
// Tolerant of both shapes, same reasoning as OPTIN_PATH: this file is expected to be hand-edited.
|
|
158
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.demoted) ? raw.demoted : [];
|
|
159
|
+
return new Set(list.filter((x) => typeof x === 'string' && x.length));
|
|
160
|
+
} catch { return new Set(); } // absent or unparseable → nothing demoted. Never fail INTO hiding a theme.
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The only writer of DEMOTED_THEMES_PATH. Demotes or restores one theme key. */
|
|
164
|
+
export function setThemeDemoted(key, demoted, file = DEMOTED_THEMES_PATH) {
|
|
165
|
+
const current = demotedThemeKeys(file);
|
|
166
|
+
if (demoted) current.add(key); else current.delete(key);
|
|
167
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
168
|
+
fs.writeFileSync(file, JSON.stringify({ demoted: [...current].sort() }, null, 2) + '\n');
|
|
169
|
+
return current;
|
|
150
170
|
}
|
|
151
171
|
|
|
152
|
-
export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null } = {}) {
|
|
153
|
-
//
|
|
154
|
-
|
|
172
|
+
export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null, demotedThemesFile } = {}) {
|
|
173
|
+
// `rejected` is a pre-built Set, injectable for pure clustering tests.
|
|
174
|
+
// `demotedThemesFile` redirects the REAL disk reader (`demotedThemeKeys()`) to a fixture path — for
|
|
175
|
+
// tests that must exercise the actual read/write round trip, not a Set built by hand. Neither
|
|
176
|
+
// option is used by the CLI, which always takes the real default file for a real process.
|
|
177
|
+
const demoted = rejected instanceof Set ? rejected : demotedThemeKeys(demotedThemesFile);
|
|
155
178
|
const eligible = lessons.filter((l) => l.type === 'feedback');
|
|
156
179
|
const themes = [];
|
|
157
180
|
for (const t of THEMES) {
|
|
@@ -227,12 +250,50 @@ export function applyPromotion(result, { file, now }) {
|
|
|
227
250
|
? existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), block) // replace ONLY our fence
|
|
228
251
|
: `${existing.trimEnd()}\n\n${block}\n`; // first run: append
|
|
229
252
|
|
|
230
|
-
|
|
253
|
+
// ATOMIC write — temp sibling then rename, never in place. The same discipline bin/install.mjs's
|
|
254
|
+
// offerClaudeMd() already applies to this exact file (ADR-063 lineage): an in-place writeFileSync
|
|
255
|
+
// leaves the real file truncated if the process dies mid-write (disk full, SIGKILL, power loss).
|
|
256
|
+
// A rename within the same directory is atomic on every OS this project ships to, so CLAUDE.md is
|
|
257
|
+
// always either fully the old content or fully the new content, never a torn hybrid.
|
|
258
|
+
const tmp = `${file}.ruvnet-tmp`;
|
|
259
|
+
try {
|
|
260
|
+
fs.writeFileSync(tmp, next);
|
|
261
|
+
fs.renameSync(tmp, file);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort — the real file is untouched either way */ }
|
|
264
|
+
return { ok: false, log: `write failed: ${e.message}; backup at ${backup}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ROUND-TRIP PROOF, not an inferred success: a successful rename proves the bytes landed at
|
|
268
|
+
// `file`'s path, but not that they are the bytes this call intended — read the real path back
|
|
269
|
+
// and compare, the same discipline degradation-watch.mjs's proveMemoryDurable() applies to every
|
|
270
|
+
// other durable write in this repo.
|
|
271
|
+
let onDisk = '';
|
|
272
|
+
try { onDisk = fs.readFileSync(file, 'utf8'); } catch (e) {
|
|
273
|
+
return { ok: false, log: `wrote ${file.replace(HOME, '~')} but could not read it back to verify (${e.message}); backup at ${backup}` };
|
|
274
|
+
}
|
|
275
|
+
if (onDisk !== next) {
|
|
276
|
+
return { ok: false, log: `wrote ${file.replace(HOME, '~')} but the read-back did not match — refusing to report success; backup at ${backup}` };
|
|
277
|
+
}
|
|
278
|
+
|
|
231
279
|
return { ok: true, backup, promoted: result.promotable.length, log: `promoted ${result.promotable.length} process(es) into ${file.replace(HOME, '~')}` };
|
|
232
280
|
}
|
|
233
281
|
|
|
234
282
|
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
|
|
235
283
|
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('lesson-promote.mjs');
|
|
284
|
+
if (invokedDirectly && (has('--demote-theme') || has('--restore-theme'))) {
|
|
285
|
+
const demoting = has('--demote-theme');
|
|
286
|
+
const key = arg(demoting ? '--demote-theme' : '--restore-theme');
|
|
287
|
+
if (!THEMES.some((t) => t.key === key)) {
|
|
288
|
+
console.error(`\n ✗ unknown theme "${key}". Known keys: ${THEMES.map((t) => t.key).join(', ')}\n`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
setThemeDemoted(key, demoting);
|
|
292
|
+
console.log(`\n ${demoting ? '✓ demoted' : '✓ restored'} theme "${key}" — `
|
|
293
|
+
+ `${demoting ? 'will never be re-proposed by a future scan' : 'eligible again on the next scan'} `
|
|
294
|
+
+ `(${DEMOTED_THEMES_PATH.replace(HOME, '~')})\n`);
|
|
295
|
+
process.exit(0);
|
|
296
|
+
}
|
|
236
297
|
if (invokedDirectly) {
|
|
237
298
|
const result = analyze(collectLessons());
|
|
238
299
|
if (has('--json')) { console.log(JSON.stringify(result, null, 2)); process.exit(0); }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a lesson applies: the ONE project-identity and applicability rule, shared by plugin/scripts/lesson-gate.mjs and
|
|
3
|
+
* the Console (scripts/onboarding-console.mjs). Re-exported from lesson-store.mjs. Pure apart from the git/fs reads
|
|
4
|
+
* projectNameFor makes, which are bounded.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* THE project identity lessons are scoped by: the folder name of the CANONICAL project root (the primary checkout,
|
|
12
|
+
* the same root resolveProjectStore uses for the store), so a linked worktree `feature-x` is the project it belongs to,
|
|
13
|
+
* not its own name. Falls back to the nearest folder holding a `.git`, then to the folder itself, when the resolver
|
|
14
|
+
* refuses (the name is only a lesson-scope label; a refusal must not silence the gate).
|
|
15
|
+
*/
|
|
16
|
+
export function projectNameFor(cwd = process.cwd()) {
|
|
17
|
+
try { return path.basename(resolveProjectStore({ projectDir: cwd, gitTimeoutMs: 500 }).projectRoot); } catch { /* fall through */ }
|
|
18
|
+
let d = cwd;
|
|
19
|
+
for (let i = 0; i < 12; i++) {
|
|
20
|
+
if (fs.existsSync(path.join(d, '.git'))) return path.basename(d);
|
|
21
|
+
const up = path.dirname(d);
|
|
22
|
+
if (up === d) break;
|
|
23
|
+
d = up;
|
|
24
|
+
}
|
|
25
|
+
return path.basename(cwd);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `a` matches `b` when `b` is a whole path/name SEGMENT suffix of `a`: the character before it must be a separator,
|
|
30
|
+
* never a mid-word letter ("Sentry" must not match inside "WhitSentry").
|
|
31
|
+
*/
|
|
32
|
+
export function segmentSuffixMatch(a, b) {
|
|
33
|
+
if (a === b) return true;
|
|
34
|
+
if (a.length <= b.length || !a.endsWith(b)) return false;
|
|
35
|
+
return /[-_/]/.test(a[a.length - b.length - 1]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* THE applicability rule (lesson-gate and the Console share it): a lesson applies to a project when it is unscoped,
|
|
40
|
+
* scoped to that project (stored names may carry a `Code-` prefix the folder lacks), or proven in two or more
|
|
41
|
+
* projects (cross-project rediscovery is what makes it universal, ADR-029).
|
|
42
|
+
*/
|
|
43
|
+
export function lessonApplies(lesson, projectName) {
|
|
44
|
+
const ps = Array.isArray(lesson?.projects) ? lesson.projects : [];
|
|
45
|
+
if (!ps.length || ps.length >= 2) return true;
|
|
46
|
+
const here = String(projectName);
|
|
47
|
+
return ps.some((p) => {
|
|
48
|
+
const n = String(p).replace(/^Code-/, '');
|
|
49
|
+
return n === here || String(p) === here || segmentSuffixMatch(here, n) || segmentSuffixMatch(n, here);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -24,7 +24,7 @@ import fs from 'node:fs';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import { SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
|
|
27
|
-
|
|
27
|
+
export { projectNameFor, segmentSuffixMatch, lessonApplies } from './lesson-scope.mjs';
|
|
28
28
|
export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
|
|
29
29
|
|
|
30
30
|
/** Resolve fixture/plugin configuration without mutating the child process account HOME. */
|
|
@@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
36
36
|
import { createHash } from 'node:crypto';
|
|
37
37
|
import { parseHookEvent, toolName, field, readStdinBounded } from './hook-input.mjs';
|
|
38
38
|
import { contains, projectDirectory } from './project-identity.mjs';
|
|
39
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
39
40
|
|
|
40
41
|
// ── date, from the system clock, formatted in the repo's standard timezone ─────────────────────────
|
|
41
42
|
// Same idiom as scripts/self-update.mjs's README badge stamp: Intl.DateTimeFormat is a Node builtin
|
|
@@ -312,7 +313,7 @@ async function main() {
|
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
function isMain() {
|
|
315
|
-
try { return
|
|
316
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
316
317
|
catch { return false; }
|
|
317
318
|
}
|
|
318
319
|
|