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
|
@@ -36,6 +36,7 @@ import { surfaceSignals } from './session-start-signals.mjs';
|
|
|
36
36
|
import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
|
|
37
37
|
import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
|
|
38
38
|
import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
|
|
39
|
+
import { CODEX_OVERNIGHT_PREFIX } from './codex-overnight-notice.mjs';
|
|
39
40
|
import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
|
|
40
41
|
import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
|
|
41
42
|
import { createStageTracer } from './session-start-trace.mjs';
|
|
@@ -43,6 +44,7 @@ import { sessionStartDeadlineAt, STAGE_BUDGETS_MS } from './session-start-budget
|
|
|
43
44
|
import { loadSettings } from './user-settings.mjs';
|
|
44
45
|
import { sessionStartProofRecorder } from './session-start-proof.mjs';
|
|
45
46
|
import { ALIAS_NOTICE, probeCodexConsoleAlias, readSessionSource } from './codex-console-alias.mjs';
|
|
47
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
46
48
|
|
|
47
49
|
// Re-exported for callers/tests that import the entitlement check directly from this file's own
|
|
48
50
|
// long-standing public surface (tests/unit/session-start-core-parity.test.mjs).
|
|
@@ -116,7 +118,9 @@ export async function runSessionStart({
|
|
|
116
118
|
|| s.startsWith(FOOTPRINT_LINE_PREFIX)
|
|
117
119
|
|| s.startsWith('[RuvNet Brain — OPEN ISSUES')
|
|
118
120
|
|| s.startsWith('[RuvNet Brain — NTFY:')
|
|
121
|
+
|| s.startsWith('[RuvNet Brain — JOBS:')
|
|
119
122
|
|| s.startsWith('[RuvNet Brain — TURN CAPTURE]')
|
|
123
|
+
|| s.startsWith(CODEX_OVERNIGHT_PREFIX)
|
|
120
124
|
|| /\bopen issue\(s\)/i.test(s)
|
|
121
125
|
|| /^\[RuvNet Brain — external signal/i.test(s)
|
|
122
126
|
|| (s.startsWith('Workflow ') && !/\b(Say it plainly|offer to look|ask only|run:|invoke)\b/i.test(s))
|
|
@@ -210,7 +214,8 @@ export async function runSessionStart({
|
|
|
210
214
|
const continuity = await Promise.race([Promise.resolve().then(() => restoreContinuity({ env, cwd,
|
|
211
215
|
deadlineAt: restoreDeadlineAt, signal: restoreController.signal })), expired]);
|
|
212
216
|
if (Date.now() >= restoreDeadlineAt) throw new Error('restore deadline exceeded');
|
|
213
|
-
|
|
217
|
+
const bad = ['unknown', 'partial', 'refused'];
|
|
218
|
+
restoreFailed = bad.includes(continuity?.status) || bad.includes(continuity?.brief?.status);
|
|
214
219
|
if (continuity?.context) emit(continuity.context);
|
|
215
220
|
} catch (error) {
|
|
216
221
|
restoreController.abort();
|
|
@@ -452,21 +457,7 @@ export async function runSessionStart({
|
|
|
452
457
|
return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
|
|
453
458
|
}
|
|
454
459
|
|
|
455
|
-
|
|
456
|
-
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
457
|
-
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
458
|
-
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
459
|
-
// from "ran, found nothing". Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
460
|
-
function isDirectInvocation() {
|
|
461
|
-
try {
|
|
462
|
-
if (!process.argv[1]) return false;
|
|
463
|
-
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
464
|
-
} catch {
|
|
465
|
-
return false;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const direct = isDirectInvocation();
|
|
460
|
+
const direct = isDirectInvocation(import.meta.url);
|
|
470
461
|
if (direct) {
|
|
471
462
|
const sessionSource = await readSessionSource();
|
|
472
463
|
runSessionStart({ sessionSource, startedAt: performance.timeOrigin }).catch((error) => {
|
|
@@ -14,7 +14,7 @@ import { json, exists, mtimeMs, read } from './session-start-fsutil.mjs';
|
|
|
14
14
|
import { unmountedNotice } from './brain-location.mjs';
|
|
15
15
|
import { assessMoveLeftovers } from './footprint-io.mjs';
|
|
16
16
|
import {
|
|
17
|
-
describeFailedRefreshRun, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
|
|
17
|
+
describeFailedRefreshRun, developerRunHealth, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
|
|
18
18
|
} from './nightly-scheduler.mjs';
|
|
19
19
|
|
|
20
20
|
export const brainState = (env, home) => {
|
|
@@ -93,7 +93,7 @@ export const autoUpdatePaths = (brainHome) => ({
|
|
|
93
93
|
checkFile: path.join(brainHome, 'corpus-check.json'),
|
|
94
94
|
checkResultFile: path.join(brainHome, '.last-kb-check-result.json'),
|
|
95
95
|
});
|
|
96
|
-
export const AUTO_UPDATE_LOCK_STALE_MS =
|
|
96
|
+
export const AUTO_UPDATE_LOCK_STALE_MS = 160 * 60_000; // detach TTL (session-start-update-plane ttlSec, 155 min) + slack
|
|
97
97
|
|
|
98
98
|
/** The inputs every currency decision reads — one reader, shared by the line and the auto-update. */
|
|
99
99
|
export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {}) => {
|
|
@@ -108,7 +108,9 @@ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {
|
|
|
108
108
|
const auto = autoUpdatePaths(brainHome);
|
|
109
109
|
const provenWithin = (h) => Boolean((history.lastSuccess && hours(history.lastSuccess.at) <= h)
|
|
110
110
|
|| (check?.currencyVerdict === 'CURRENT' && Number.isFinite(checkMs) && hours(checkMs) <= h));
|
|
111
|
-
|
|
111
|
+
// brain-confirmation.mjs SIGNATURE_RECORD (not imported: that module imports this one).
|
|
112
|
+
const installedAtMs = Date.parse(json(path.join(brainHome, 'knowledge-signature.json'))?.verifiedAt || '');
|
|
113
|
+
return { brainHome, kbDir, source, builtMs, history, hours, provenWithin, auto, installedAtMs,
|
|
112
114
|
attempt: json(auto.attemptFile), corpusCheck: json(auto.checkFile),
|
|
113
115
|
lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
|
|
114
116
|
};
|
|
@@ -146,15 +148,87 @@ export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
|
|
|
146
148
|
* installed tree carries no generation stamp) and that tag is not what SOURCE.json now names. A check
|
|
147
149
|
* recorded before the update landed therefore stops reading as pending the moment B is installed.
|
|
148
150
|
*/
|
|
151
|
+
const SEMVER_TAG = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
152
|
+
/** Semver order of two release tags (>0 a newer, <0 older, 0 same); null when either is not semver. */
|
|
153
|
+
export const compareReleaseTags = (a, b) => {
|
|
154
|
+
const A = SEMVER_TAG.exec(String(a ?? '')), B = SEMVER_TAG.exec(String(b ?? ''));
|
|
155
|
+
if (!A || !B) return null;
|
|
156
|
+
for (let i = 1; i <= 3; i++) if (Number(A[i]) !== Number(B[i])) return Number(A[i]) > Number(B[i]) ? 1 : -1;
|
|
157
|
+
if ((A[4] || '') === (B[4] || '')) return 0;
|
|
158
|
+
if (!A[4]) return 1;
|
|
159
|
+
if (!B[4]) return -1;
|
|
160
|
+
// Semver §11.4: dot-separated identifiers left to right; numeric ones numerically (alpha.10 > alpha.9),
|
|
161
|
+
// numeric below alphanumeric, alphanumeric in ASCII order; a longer set wins when all before it are equal.
|
|
162
|
+
const X = A[4].split('.'), Y = B[4].split('.');
|
|
163
|
+
for (let i = 0; i < Math.max(X.length, Y.length); i++) {
|
|
164
|
+
if (X[i] === undefined) return -1;
|
|
165
|
+
if (Y[i] === undefined) return 1;
|
|
166
|
+
const xn = /^\d+$/.test(X[i]), yn = /^\d+$/.test(Y[i]);
|
|
167
|
+
if (xn && yn) { if (Number(X[i]) !== Number(Y[i])) return Number(X[i]) > Number(Y[i]) ? 1 : -1; continue; }
|
|
168
|
+
if (xn !== yn) return xn ? -1 : 1;
|
|
169
|
+
if (X[i] !== Y[i]) return X[i] > Y[i] ? 1 : -1;
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
};
|
|
173
|
+
|
|
149
174
|
export const newerCorpusPending = (facts) => {
|
|
150
175
|
const c = facts.corpusCheck;
|
|
151
176
|
if (!c?.candidateTag || !['UPDATE_AVAILABLE', 'UNKNOWN'].includes(c.verdict)) return null;
|
|
152
177
|
const installed = [facts.source?.corpusReleaseTag, facts.source?.releaseTag].filter(Boolean);
|
|
153
178
|
if (installed.includes(c.candidateTag)) return null;
|
|
179
|
+
// #401: a different tag is not a newer one. When both are semver release tags, order decides: a
|
|
180
|
+
// retained check naming v4.5.7 must never call an installed v4.5.9 out of date, and a v4.5.10
|
|
181
|
+
// candidate still reads as pending however recently something was installed.
|
|
182
|
+
const order = compareReleaseTags(c.candidateTag, facts.source?.releaseTag);
|
|
183
|
+
if (order !== null) { if (order <= 0) return null; }
|
|
184
|
+
else {
|
|
185
|
+
// Opaque (non-orderable) identities: a check recorded before the installed bytes were
|
|
186
|
+
// signature-verified is stale evidence the install superseded, not proof of a newer corpus.
|
|
187
|
+
const checkedMs = Date.parse(c.checkedAt || c.launchedAt || '');
|
|
188
|
+
if (Number.isFinite(facts.installedAtMs) && Number.isFinite(checkedMs) && checkedMs < facts.installedAtMs) return null;
|
|
189
|
+
}
|
|
154
190
|
return { tag: c.candidateTag, installed: installed[0] || null, checkedAt: c.checkedAt || c.launchedAt || null, outcome: c.outcome };
|
|
155
191
|
};
|
|
156
192
|
const shortTag = (tag) => (tag && tag.length > 28 ? `${tag.slice(0, 26)}…` : tag || 'unknown');
|
|
157
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Issue #391 / closure gap G-015: a FAILED run whose `update` phase PASSED left the knowledge current
|
|
196
|
+
* (the ledger stops at the first required failure, so every corpus phase before it passed too). When
|
|
197
|
+
* host-convergence is the ONLY failed phase, the host plugin sync is what did not finish: a host
|
|
198
|
+
* problem, not a knowledge failure, and it is reported as one. Any other failed phase (cleanup-pending,
|
|
199
|
+
* retention recovery-required) keeps its own reason through describeFailedRefreshRun.
|
|
200
|
+
* Returns { state, why } from the host-convergence evidence, or null for any other receipt.
|
|
201
|
+
*/
|
|
202
|
+
export const hostSyncOnlyFailure = (receipt) => {
|
|
203
|
+
if (receipt?.status !== 'FAILED' || !Array.isArray(receipt.phases)) return null;
|
|
204
|
+
if (!receipt.phases.some((entry) => entry?.phase === 'update' && entry.status === 'PASS')) return null;
|
|
205
|
+
const failed = receipt.phases.filter((entry) => entry?.status === 'FAIL');
|
|
206
|
+
if (failed.length !== 1 || failed[0].phase !== 'host-convergence') return null;
|
|
207
|
+
const host = failed[0];
|
|
208
|
+
const state = host?.evidence?.state || null;
|
|
209
|
+
const why = String(host?.evidence?.error || state || '').split('\n')[0].trim().slice(0, 200);
|
|
210
|
+
return { state, why: why || 'no reason recorded (run npx ruvnet-brain --doctor)' };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A coordinated (developer-suite) nightly reports in nightly-suite-update.json, never in refresh-runs, so a failing or stale
|
|
215
|
+
* suite stayed silent while the knowledge was current (AuditC #26). One line then; '' when it is healthy, never ran, or absent.
|
|
216
|
+
*/
|
|
217
|
+
const coordinatedSuiteLine = ({ brainHome, now, builtMs, age }) => {
|
|
218
|
+
let health;
|
|
219
|
+
try {
|
|
220
|
+
const registration = readNightlyRegistration({ brainHome });
|
|
221
|
+
if (!registration.ok || registration.record.mode !== 'developer-suite') return '';
|
|
222
|
+
health = developerRunHealth({ brainHome, registration: registration.record, now });
|
|
223
|
+
} catch { return ''; }
|
|
224
|
+
if (!['failed', 'stale'].includes(health.state)) return '';
|
|
225
|
+
const what = health.state === 'stale' ? `has not completed in ${Math.round(health.ageHours)}h`
|
|
226
|
+
: `FAILED${Number.isFinite(health.ageHours) ? ` ${age(now - health.ageHours * 3_600_000)}` : ''}: ${String(health.evidence || 'no reason recorded').split('\n')[0].slice(0, 200)}`;
|
|
227
|
+
return `${KNOWLEDGE_LINE_PREFIX}CURRENT, NIGHTLY SUITE ${health.state === 'stale' ? 'STALE' : 'FAILING'}] knowledge base built `
|
|
228
|
+
+ `${Number.isFinite(builtMs) ? age(builtMs) : 'at an UNKNOWN time'} and current; the coordinated nightly update ${what}. `
|
|
229
|
+
+ `Details: ${path.join(brainHome, 'scheduler', 'last-suite-attempt.json')} (verify: npx ruvnet-brain --doctor).`;
|
|
230
|
+
};
|
|
231
|
+
|
|
158
232
|
export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), windowHours = 48 } = {}) => {
|
|
159
233
|
const facts = knowledgeFacts({ env, home, now });
|
|
160
234
|
const { brainHome, builtMs, history, hours, attempt } = facts;
|
|
@@ -169,7 +243,8 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
169
243
|
const autoAbandoned = attempt?.outcome === 'launched' && !autoRunning;
|
|
170
244
|
const autoFailed = Number.isFinite(launchedMs) && (attempt.outcome === 'failed' || autoAbandoned)
|
|
171
245
|
&& !(history.latest && history.latest.at >= launchedMs);
|
|
172
|
-
const
|
|
246
|
+
const hostOnly = hostSyncOnlyFailure(latest);
|
|
247
|
+
const failing = (latest?.status === 'FAILED' && !hostOnly) || autoFailed;
|
|
173
248
|
const ageKnown = Number.isFinite(builtMs);
|
|
174
249
|
const pending = newerCorpusPending(facts);
|
|
175
250
|
if (!failing && pending && (proven || (ageKnown && hours(builtMs) <= windowHours))) {
|
|
@@ -181,8 +256,19 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
181
256
|
return `${KNOWLEDGE_LINE_PREFIX}UPDATE PENDING] a newer corpus ${shortTag(pending.tag)} is published `
|
|
182
257
|
+ `(this machine has ${shortTag(pending.installed)}, built ${ageKnown ? age(builtMs) : 'at an UNKNOWN time'}); ${what}.`;
|
|
183
258
|
}
|
|
184
|
-
|
|
185
|
-
if (!failing &&
|
|
259
|
+
const fresh = proven || (ageKnown && hours(builtMs) <= windowHours);
|
|
260
|
+
if (!failing && hostOnly && fresh) {
|
|
261
|
+
const restart = hostOnly.state === 'host-restart-required';
|
|
262
|
+
return `${KNOWLEDGE_LINE_PREFIX}CURRENT, HOST ${restart ? 'RESTART' : 'SYNC'} PENDING] `
|
|
263
|
+
+ `knowledge base built ${ageKnown ? `${day(builtMs)} (${age(builtMs)})` : 'at an UNKNOWN time'}; `
|
|
264
|
+
+ `the last refresh (${latest.action || 'unknown'}) ${age(history.latest.at)} passed its knowledge update phase, `
|
|
265
|
+
+ `only the host plugin sync after it did not finish: ${hostOnly.why}. `
|
|
266
|
+
// A restart writes no refresh receipt, so this line stays until the next refresh records the sync.
|
|
267
|
+
// The host sync alone is what did not finish, so the fix is the host-sync-only repair, not the full knowledge update (#391).
|
|
268
|
+
+ (restart ? 'Fix: restart Claude Code or Codex (`claude --continue` keeps a Claude conversation), then record it: npx ruvnet-brain@latest --update --host-sync-only.'
|
|
269
|
+
: 'Fix: npx ruvnet-brain@latest --update --host-sync-only (verify: npx ruvnet-brain --doctor).');
|
|
270
|
+
}
|
|
271
|
+
if (!failing && fresh) return coordinatedSuiteLine({ brainHome, now, builtMs, age });
|
|
186
272
|
const kit = agenticKitUpdates({ home, facts });
|
|
187
273
|
const agentKit = kit === 'delivering';
|
|
188
274
|
// An agentic-kit machine must never be told to also --enable-nightly (one owner per machine).
|
|
@@ -190,17 +276,19 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
|
|
|
190
276
|
const parts = [ageKnown ? `knowledge base built ${day(builtMs)} (${age(builtMs)})`
|
|
191
277
|
: 'knowledge base age UNKNOWN (SOURCE.json missing or unreadable)'];
|
|
192
278
|
if (autoFailed) {
|
|
193
|
-
const why = autoAbandoned ? 'it never recorded an outcome (killed at its
|
|
279
|
+
const why = autoAbandoned ? 'it never recorded an outcome (killed at its time limit, or the machine slept)'
|
|
194
280
|
: `exit ${attempt.code ?? 'unknown'}: ${attempt.reason || 'no reason recorded'}`;
|
|
195
281
|
parts.push(`automatic update launched ${age(launchedMs)} FAILED — ${why}`);
|
|
282
|
+
} else if (hostOnly) {
|
|
283
|
+
parts.push(`last refresh (${latest.action || 'unknown'}) passed its knowledge update phase ${age(history.latest.at)} but its host sync did not finish: ${hostOnly.why}`);
|
|
196
284
|
} else if (latest?.status === 'FAILED') {
|
|
197
285
|
const why = describeFailedRefreshRun(latest) || 'failed';
|
|
198
286
|
parts.push(`last refresh (${latest.action || 'unknown'}) FAILED ${age(history.latest.at)}: ${why}`);
|
|
199
287
|
}
|
|
200
288
|
if (autoRunning) parts.push(`an automatic update is running now (started ${age(facts.lockMs)})`);
|
|
201
|
-
parts.push(history.receipts
|
|
202
|
-
? `${history.failuresSinceSuccess} failed run(s) since the last success (${
|
|
203
|
-
|
|
289
|
+
parts.push(!history.receipts ? 'no refresh has ever run on this machine'
|
|
290
|
+
: history.lastSuccess ? `${history.failuresSinceSuccess} failed run(s) since the last success (${day(history.lastSuccess.at)})`
|
|
291
|
+
: `${history.failuresSinceSuccess} failed run(s) and no successful run on record`);
|
|
204
292
|
if (kit === 'not-delivering') {
|
|
205
293
|
parts.push(`agentic-kit claims updates (kit.json ruvnetBrain:true) but no update is proven in ${AGENTIC_KIT_PROOF_HOURS}h, so the Brain's own self-heal runs instead`);
|
|
206
294
|
}
|
|
@@ -84,6 +84,7 @@ export function surfaceIssuePointer({ stateDir, emit, now, env, home, platform,
|
|
|
84
84
|
export function surfaceNtfyAlerts({ stateDir, emit, env, home, platform, cwd, now = Date.now() }) {
|
|
85
85
|
try {
|
|
86
86
|
if (!maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain', platform) || !isMaintainerRepo(cwd)) return;
|
|
87
|
+
surfaceJobHealth({ stateDir, emit, now, cwd });
|
|
87
88
|
// The reader writes a few-byte summary (scripts/ntfy-inbox.mjs writeSummary) so a large alert list can never
|
|
88
89
|
// push this banner past its read limit; the detailed list is only a fallback for an older reader.
|
|
89
90
|
let total = 0; let kinds = {};
|
|
@@ -117,3 +118,65 @@ export function surfaceNtfyAlerts({ stateDir, emit, env, home, platform, cwd, no
|
|
|
117
118
|
emit(`[RuvNet Brain — NTFY: ${total} open alert(s) (${summaryText || 'none listed'})${over}${age}. Details: node scripts/ntfy-inbox.mjs --open]`);
|
|
118
119
|
} catch { /* advisory only */ }
|
|
119
120
|
}
|
|
121
|
+
|
|
122
|
+
const WATCHDOG_LABEL = 'com.ruvnet.nightly-watchdog';
|
|
123
|
+
const WATCHDOG_MAX_AGE_H = 30;
|
|
124
|
+
const MAX_RECEIPTS = 64;
|
|
125
|
+
const MAX_RECEIPT_BYTES = 64 * 1024;
|
|
126
|
+
|
|
127
|
+
/** The job labels config/scheduled-jobs.json registers, found by walking up from the session's project directory (the
|
|
128
|
+
* maintainer gate already requires this checkout to be the repo), or null when there is no readable registry. */
|
|
129
|
+
export function registeredJobLabels(cwd) {
|
|
130
|
+
if (!cwd) return null;
|
|
131
|
+
let dir = path.resolve(cwd);
|
|
132
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
133
|
+
const file = path.join(dir, 'config', 'scheduled-jobs.json');
|
|
134
|
+
try {
|
|
135
|
+
if (fs.statSync(file).size <= MAX_STATUS_BYTES) {
|
|
136
|
+
const jobs = json(file)?.jobs;
|
|
137
|
+
if (Array.isArray(jobs)) return new Set(jobs.map((j) => j?.label).filter((l) => typeof l === 'string'));
|
|
138
|
+
}
|
|
139
|
+
} catch { /* not here: look one level up */ }
|
|
140
|
+
const up = path.dirname(dir);
|
|
141
|
+
if (up === dir) break;
|
|
142
|
+
dir = up;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* WHO WATCHES THE WATCHDOG (4.7.21). nightly-watchdog.mjs cannot judge its own run, and a watchdog that stopped
|
|
149
|
+
* firing would be silent by construction. This reads the local heartbeat receipts (scripts/job-heartbeat.sh) —
|
|
150
|
+
* no network, bounded file count and size — and says ONE line when any receipt is failed/killed or the
|
|
151
|
+
* watchdog's own receipt is older than 30 h. Machines without heartbeats (every end user) get nothing.
|
|
152
|
+
* Only receipts of jobs the registry still lists count (review fix): a retired job's last failed receipt left in the
|
|
153
|
+
* heartbeat directory must not read as "failing" forever. Without a readable registry every receipt counts.
|
|
154
|
+
*/
|
|
155
|
+
export function surfaceJobHealth({ stateDir, emit, now = Date.now(), cwd }) {
|
|
156
|
+
try {
|
|
157
|
+
const dir = path.join(stateDir, 'heartbeats');
|
|
158
|
+
let names;
|
|
159
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).slice(0, MAX_RECEIPTS); } catch { return; }
|
|
160
|
+
if (!names.length) return;
|
|
161
|
+
const registered = registeredJobLabels(cwd);
|
|
162
|
+
const red = [];
|
|
163
|
+
let watchdogAt = null;
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const file = path.join(dir, name);
|
|
166
|
+
try { if (fs.statSync(file).size > MAX_RECEIPT_BYTES) continue; } catch { continue; }
|
|
167
|
+
const r = json(file);
|
|
168
|
+
if (!r || typeof r !== 'object') continue;
|
|
169
|
+
const label = name.slice(0, -'.json'.length);
|
|
170
|
+
if (registered && !registered.has(label)) continue;
|
|
171
|
+
if (r.state === 'failed' || r.state === 'killed') red.push(label);
|
|
172
|
+
if (label === WATCHDOG_LABEL) watchdogAt = Date.parse(r.ended_at || r.started_at || '');
|
|
173
|
+
}
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (red.length) parts.push(`${red.length} scheduled job(s) failing (${red.slice(0, 3).join(', ')}${red.length > 3 ? ', …' : ''})`);
|
|
176
|
+
if (watchdogAt !== null) {
|
|
177
|
+
const ageH = Number.isFinite(watchdogAt) ? (now - watchdogAt) / 3_600_000 : Infinity;
|
|
178
|
+
if (ageH > WATCHDOG_MAX_AGE_H) parts.push(`the nightly watchdog last ran ${Number.isFinite(ageH) ? `${Math.floor(ageH)}h ago` : 'at an unreadable time'}`);
|
|
179
|
+
}
|
|
180
|
+
if (parts.length) emit(`[RuvNet Brain — JOBS: ${parts.join('; ')}. Details: node scripts/nightly-watchdog.mjs --no-push]`);
|
|
181
|
+
} catch { /* advisory only */ }
|
|
182
|
+
}
|
|
@@ -11,6 +11,8 @@ import { read, write, exists, json, firstVersion, dispatchDetached, mtimeMs } fr
|
|
|
11
11
|
import { KNOWLEDGE_LINE_PREFIX, AUTO_UPDATE_LOCK_STALE_MS, autoUpdateOptOut, knowledgeFacts } from './session-start-health.mjs';
|
|
12
12
|
import { inventoryFootprint } from './brain-footprint.mjs';
|
|
13
13
|
import { confirm, footprintAlarm } from './brain-confirmation.mjs';
|
|
14
|
+
import { claimCodexOvernightNotice } from './codex-overnight-notice.mjs';
|
|
15
|
+
import { developerCoordinatorOwner } from './developer-update-owner.mjs';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old) AND
|
|
@@ -31,7 +33,9 @@ import { confirm, footprintAlarm } from './brain-confirmation.mjs';
|
|
|
31
33
|
* (plugin/mcp/server.mjs, announce:false so it never consumes the once-per-session line). The outcome is
|
|
32
34
|
* recorded by the worker and spoken by the NEXT session's knowledge line.
|
|
33
35
|
*/
|
|
34
|
-
|
|
36
|
+
// ttlSec sits above host-update.mjs's knowledge limit (150 min), which sits above the coordinator's installer limit
|
|
37
|
+
// (135 min = the 90-minute bundle download deadline plus the apply): each kill lands on a level that records an outcome.
|
|
38
|
+
export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 9300, checkMinutes: 60 });
|
|
35
39
|
export const corpusCheckMinutes = (env = {}) => {
|
|
36
40
|
const raw = env.RUVNET_CORPUS_CHECK_MINUTES;
|
|
37
41
|
const n = Number(raw);
|
|
@@ -159,6 +163,8 @@ export const compareVersions = (a, b) => {
|
|
|
159
163
|
};
|
|
160
164
|
|
|
161
165
|
export const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now }) => {
|
|
166
|
+
const overnight = claimCodexOvernightNotice(stateDir); // the unattended nightly's deferred Codex reload, said once
|
|
167
|
+
if (overnight) emit(overnight);
|
|
162
168
|
const activeFile = path.join(stateDir, 'active.json');
|
|
163
169
|
const stamp = path.join(stateDir, '.last-update-check');
|
|
164
170
|
let seedDispatched = false;
|
|
@@ -240,6 +246,14 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
|
|
|
240
246
|
for (let i = 0; i < 3 && active; i += 1) { if ((r[i] || 0) !== (a[i] || 0)) { newer = (r[i] || 0) > (a[i] || 0); break; } }
|
|
241
247
|
if (active && !newer) return;
|
|
242
248
|
} catch { /* unknown active generation: keep the existing announcement */ }
|
|
249
|
+
let coordinated = false;
|
|
250
|
+
try { coordinated = developerCoordinatorOwner({ home, brainHome: env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') }).active; } catch { /* legacy path */ }
|
|
251
|
+
if (coordinated) {
|
|
252
|
+
// Coordinated machines install new code only in the scheduled nightly; saying "installing now" would be false.
|
|
253
|
+
emit(`[RuvNet Brain — v${remoteVersion} is published; this machine's coordinated nightly update installs it at its next scheduled run]`);
|
|
254
|
+
emit('');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
243
257
|
if (pref === 'yes' && exists(path.join(hookDir, 'host-update.mjs'))) {
|
|
244
258
|
dispatchDetached(hookDir, 600, path.join(stateDir, '.last-auto-update.log'),
|
|
245
259
|
process.execPath, [path.join(hookDir, 'host-update.mjs')], env);
|
|
@@ -60,6 +60,7 @@ import os from 'node:os';
|
|
|
60
60
|
import { spawnSync } from 'node:child_process';
|
|
61
61
|
import { fileURLToPath } from 'node:url';
|
|
62
62
|
import { parseHookEvent, toolName, commandOf, findInvocations, rawToolResponse, readStdinBounded } from './hook-input.mjs';
|
|
63
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
63
64
|
|
|
64
65
|
const MANAGED_TOOLS = ['gh', 'vercel', 'netlify', 'npm', 'git'];
|
|
65
66
|
|
|
@@ -183,7 +184,7 @@ async function main() {
|
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
function isMain() {
|
|
186
|
-
try { return
|
|
187
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
187
188
|
catch { return false; }
|
|
188
189
|
}
|
|
189
190
|
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import fs from 'node:fs';
|
|
49
49
|
import { fileURLToPath } from 'node:url';
|
|
50
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
50
51
|
|
|
51
52
|
/** Env vars that bill per token. OPENROUTER is deliberately absent — see below. */
|
|
52
53
|
export const METERED_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
|
|
@@ -113,7 +114,7 @@ export function check(command, env = process.env) {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
const isMain = (() => {
|
|
116
|
-
try { return
|
|
117
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
117
118
|
catch { return false; }
|
|
118
119
|
})();
|
|
119
120
|
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import fs from 'node:fs';
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
31
32
|
|
|
32
33
|
export const RUV_TOOL = 'ruflo memory store|retrieve|search (or `ruflo hooks session-end|post-task`)';
|
|
33
34
|
/** Repo-relative scope: our own executable code. Tests and fixtures are never in scope. */
|
|
@@ -197,7 +198,7 @@ function repoRootOf(file) {
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
function isMain() {
|
|
200
|
-
try { return
|
|
201
|
+
try { return isDirectInvocation(import.meta.url); } catch { return false; }
|
|
201
202
|
}
|
|
202
203
|
if (isMain()) {
|
|
203
204
|
let code = 0;
|
|
@@ -42,7 +42,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
42
42
|
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
43
43
|
import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
44
44
|
import { redactText } from './continuity-events.mjs';
|
|
45
|
-
import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
|
|
45
|
+
import { resolveProjectStore, globalEnrollmentAllowed, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
|
|
46
46
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
47
47
|
|
|
48
48
|
const digest = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
@@ -53,6 +53,7 @@ function appendReceipt(file, row) {
|
|
|
53
53
|
}
|
|
54
54
|
const flagValue = (args, flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
55
55
|
import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
|
|
56
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
56
57
|
|
|
57
58
|
export const TURN_NAMESPACE = 'turns';
|
|
58
59
|
export const MIN_OUTCOME_CHARS = 200;
|
|
@@ -315,7 +316,8 @@ export function captureTurnOutcome({
|
|
|
315
316
|
if (!sessionKey) report.skipped = 'no session identity in the host payload';
|
|
316
317
|
else if (turn.finalText.length < MIN_OUTCOME_CHARS && !turn.files.length) {
|
|
317
318
|
report.skipped = turn.excludedResource ? 'turn content withheld: explicitly excluded resource' : host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
|
|
318
|
-
} else {
|
|
319
|
+
} else if (storeEncryptedAtRest(db)) report.skipped = loud(`${ENCRYPTED_STORE_REASON}; turn not written (opt out: RUVNET_TURN_CAPTURE=off)`, db); // #439: no ruflo spawn that can only be refused
|
|
320
|
+
else {
|
|
319
321
|
const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
|
|
320
322
|
const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
|
|
321
323
|
const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
|
|
@@ -402,6 +404,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
402
404
|
const data = { key: flagValue(queued.args, '--key'), value: flagValue(queued.args, '--value'), db: flagValue(queued.args, '--path') };
|
|
403
405
|
if (typeof data.key !== 'string' || typeof data.value !== 'string') throw new Error('invalid turn store step');
|
|
404
406
|
if (data.db !== db) throw new Error('foreign turn store rejected');
|
|
407
|
+
if (storeEncryptedAtRest(db)) throw new Error(ENCRYPTED_STORE_REASON);
|
|
405
408
|
if (queued.brainHome !== brainHome) throw new Error('turn consent settings differ from trusted caller');
|
|
406
409
|
binding = queued;
|
|
407
410
|
key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
|
|
@@ -471,6 +474,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
471
474
|
let target;
|
|
472
475
|
try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
|
|
473
476
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
|
|
477
|
+
if (storeEncryptedAtRest(target.db)) return { state: 'warn', line: `turn recording off for this store — ${ENCRYPTED_STORE_REASON}. Keep this project's store unencrypted, or set RUVNET_TURN_CAPTURE=off to stop trying` };
|
|
474
478
|
const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
|
|
475
479
|
const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
|
|
476
480
|
let rows = [];
|
|
@@ -494,7 +498,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
|
|
|
494
498
|
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
|
|
495
499
|
}
|
|
496
500
|
|
|
497
|
-
if (
|
|
501
|
+
if (isDirectInvocation(import.meta.url) && process.argv[2] === '--run-steps') {
|
|
498
502
|
try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
|
|
499
503
|
process.exit(0);
|
|
500
504
|
}
|
|
@@ -89,6 +89,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
89
89
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
90
90
|
import { readStdinBounded, isHarnessGenerated } from './hook-input.mjs';
|
|
91
91
|
import { resolveBash } from './hook-shim-bash.mjs';
|
|
92
|
+
import { ADVISORY_CONTRACT } from './advisory-contract.mjs';
|
|
92
93
|
|
|
93
94
|
// WHERE THIS FILE'S SIBLINGS LIVE. Resolved from THIS file's own location so it is correct under the
|
|
94
95
|
// Stable Spine (an immutable versions/<gen> tree) and in a dev checkout alike — hook-shim.mjs has
|
|
@@ -127,6 +128,17 @@ const MAX_BUFFER = 1 << 20;
|
|
|
127
128
|
|
|
128
129
|
const VALID_CHANNELS = new Set(['advocacy', 'promotion', 'lesson', 'alarm']);
|
|
129
130
|
const VALID_EFFECTS = new Set(['advisory', 'block']);
|
|
131
|
+
// QUIET BY DEFAULT, ENFORCED HERE (4.7.21 review F1). Unless RUVNET_STOP_NUDGES=full, this run is narrowed
|
|
132
|
+
// to lesson + alarm: lesson producers still run and deliver, advocacy/promotion producers are never spawned.
|
|
133
|
+
// The shim also exports RUVNET_UNPROMPTED_CHANNELS, but the shim is boot-frozen and this body is hot-updated,
|
|
134
|
+
// so the quiet promise must not depend on which shim generation happens to dispatch it. An explicit
|
|
135
|
+
// RUVNET_UNPROMPTED_CHANNELS still wins (the shim's own narrowing, or a caller that names channels).
|
|
136
|
+
const QUIET_DEFAULT_CHANNELS = 'lesson,alarm';
|
|
137
|
+
const NUDGES_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
|
|
138
|
+
const CHANNEL_LIST = process.env.RUVNET_UNPROMPTED_CHANNELS || (NUDGES_FULL ? '' : QUIET_DEFAULT_CHANNELS);
|
|
139
|
+
const ALLOWED_CHANNELS = CHANNEL_LIST
|
|
140
|
+
? new Set(CHANNEL_LIST.split(',').map((c) => c.trim()).filter((c) => VALID_CHANNELS.has(c)))
|
|
141
|
+
: VALID_CHANNELS;
|
|
130
142
|
|
|
131
143
|
/** Exit 0 with byte-empty stdout — the fail-safe and the "nothing to say" path. */
|
|
132
144
|
function silent() { process.exit(0); }
|
|
@@ -242,7 +254,7 @@ if (!event) silent();
|
|
|
242
254
|
if (typeof promptText === 'string' && isHarnessGenerated(promptText)) silent();
|
|
243
255
|
}
|
|
244
256
|
|
|
245
|
-
const producers = resolveProducers(EVENT);
|
|
257
|
+
const producers = resolveProducers(EVENT).filter((p) => p.channels.some((c) => ALLOWED_CHANNELS.has(c)));
|
|
246
258
|
if (!producers.length) silent(); // unknown event, or nothing wired for it — never speak on a guess
|
|
247
259
|
|
|
248
260
|
// ── Spawn producers as CAPTURED children, collect candidate lines ──────────────────────────────────
|
|
@@ -299,6 +311,7 @@ for (const { s, channels } of rawLines) {
|
|
|
299
311
|
try { c = JSON.parse(s); } catch { continue; } // not JSON → drop (a rogue's raw line)
|
|
300
312
|
if (!c || typeof c !== 'object' || Array.isArray(c)) continue;
|
|
301
313
|
if (!VALID_CHANNELS.has(c.channel)) continue; // unknown/absent channel → drop
|
|
314
|
+
if (!ALLOWED_CHANNELS.has(c.channel)) continue; // narrowed by the shim's quiet default → drop
|
|
302
315
|
if (!channels.includes(c.channel)) continue; // ANTI-SPOOF (GPT-5.6-Sol): this producer is
|
|
303
316
|
// not authorised for this channel → drop
|
|
304
317
|
if (!VALID_EFFECTS.has(c.effect)) continue; // unknown/absent effect → drop
|
|
@@ -471,9 +484,13 @@ const hookEventName = EVENT ? EVENT.split('-')[0] : 'UserPromptSubmit';
|
|
|
471
484
|
|
|
472
485
|
// SYNCHRONOUS write — the truncation fix on the byte-owner itself (see the block path above).
|
|
473
486
|
if (!advisories.length) silent();
|
|
474
|
-
|
|
487
|
+
// #489: optional recommendations carry the advisory contract into the model-visible context, once,
|
|
488
|
+
// so a suggestion is never promoted into a question/approval dialog. Alarms and lessons are not offers.
|
|
489
|
+
const delivered = advisories.some((a) => a.channel === 'advocacy')
|
|
490
|
+
? [...advisories, { copy: ADVISORY_CONTRACT, channel: 'advisory-contract' }] : advisories;
|
|
491
|
+
const additionalContext = delivered.map((a) => a.copy).join('\n\n');
|
|
475
492
|
try { fs.writeSync(1, process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1'
|
|
476
|
-
? emitOwnedContext(contextFrame('unprompted-speech', hookEventName,
|
|
493
|
+
? emitOwnedContext(contextFrame('unprompted-speech', hookEventName, delivered.map((a) => contextBlock(a.copy, { id: a.channel, critical: a.channel === 'alarm' }))))
|
|
477
494
|
: JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })); }
|
|
478
495
|
catch { /* if even the final write fails, silence is the fail-safe */ }
|
|
479
496
|
process.exit(0);
|