ruvnet-brain 4.7.20 → 4.7.21
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 +494 -97
- 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.mjs +15 -5
- 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 +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- 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-promote.mjs +83 -22
- 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/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +5 -15
- package/plugin/scripts/session-start-health.mjs +97 -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-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 +47 -8
- 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/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 +64 -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 +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -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); }
|
|
@@ -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
|
|
|
@@ -1,10 +1,75 @@
|
|
|
1
1
|
/** Bounded, mechanical Codex JSONL normalization. No model inference or user-text persistence. */
|
|
2
2
|
const LIMIT = 2 * 1024 * 1024;
|
|
3
3
|
const text = content => typeof content === 'string' ? content : Array.isArray(content)
|
|
4
|
-
? content.filter(part => ['text', 'input_text', 'output_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
|
|
5
|
-
const message = (role, content) => ({ message: { role, content } });
|
|
4
|
+
? content.filter(part => ['text', 'Text', 'input_text', 'output_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
|
|
5
|
+
const message = (role, content) => ({ type: role, message: { role, content } });
|
|
6
6
|
const parse = value => { try { return JSON.parse(value); } catch { return null; } };
|
|
7
7
|
|
|
8
|
+
const TOOL_ITEMS = new Set(['CommandExecution', 'FileChange', 'McpToolCall']);
|
|
9
|
+
const toolItem = record => record?.type === 'event_msg' && record.payload?.type === 'item_completed'
|
|
10
|
+
&& TOOL_ITEMS.has(record.payload.item?.type) && typeof record.payload.item.id === 'string';
|
|
11
|
+
const startOf = record => Number.isFinite(record.payload.started_at_ms) ? record.payload.started_at_ms : NaN;
|
|
12
|
+
const endOf = record => Number.isFinite(record.payload.completed_at_ms) ? record.payload.completed_at_ms : NaN;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Native item records (#423) are written at completion, so concurrent items arrive in completion order.
|
|
16
|
+
* Each contiguous run is put back in start order; an item without timings, or whose interval overlaps
|
|
17
|
+
* another item, keeps its record but its result becomes `unknown` and can never count as a passing check.
|
|
18
|
+
*/
|
|
19
|
+
function orderToolItems(records) {
|
|
20
|
+
const items = records.filter(toolItem);
|
|
21
|
+
const uncertain = new Set(items.filter((r, i) => !(startOf(r) <= endOf(r))
|
|
22
|
+
|| items.some((o, j) => j !== i && startOf(o) < endOf(r) && startOf(r) < endOf(o))));
|
|
23
|
+
const out = [];
|
|
24
|
+
for (let i = 0; i < records.length;) {
|
|
25
|
+
if (!toolItem(records[i])) { out.push(records[i++]); continue; }
|
|
26
|
+
const run = [];
|
|
27
|
+
while (i < records.length && toolItem(records[i])) run.push(records[i++]);
|
|
28
|
+
out.push(...run.sort((a, b) => (startOf(a) || 0) - (startOf(b) || 0)));
|
|
29
|
+
}
|
|
30
|
+
return { ordered: out, uncertain, itemized: items.length > 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolItemLines(record, uncertain) {
|
|
34
|
+
const item = record.payload.item;
|
|
35
|
+
const status = uncertain ? 'unknown' : item.status;
|
|
36
|
+
if (item.type === 'FileChange') {
|
|
37
|
+
return [message('assistant', [{ type: 'tool_use', id: item.id, name: 'apply_patch', input: { input: '' } }])];
|
|
38
|
+
}
|
|
39
|
+
if (item.type === 'McpToolCall') {
|
|
40
|
+
// A completed transport is not a successful tool: CallToolResult.isError:true (a failed run_tests) is a failure,
|
|
41
|
+
// and the field is kept on the result so no reader can mistake it for success (cross-vendor review D, 4.7.21).
|
|
42
|
+
const toolFailed = item.result?.isError === true;
|
|
43
|
+
return [message('assistant', [{ type: 'tool_use', id: item.id, name: `mcp__${item.server}__${item.tool}`, input: {} }]),
|
|
44
|
+
message('user', [{ type: 'tool_result', tool_use_id: item.id, status, ...(toolFailed ? { isError: true } : {}),
|
|
45
|
+
is_error: item.status !== 'completed' || Boolean(item.error) || toolFailed,
|
|
46
|
+
content: item.result === undefined ? '' : JSON.stringify(item.result) }])];
|
|
47
|
+
}
|
|
48
|
+
const argv = Array.isArray(item.command) ? item.command : [item.command];
|
|
49
|
+
// `[shell, -lc, script]` runs `script`; any other argv is the command line itself.
|
|
50
|
+
const command = argv.length === 3 && /^-l?c$/.test(String(argv[1])) ? String(argv[2]) : argv.map(String).join(' ');
|
|
51
|
+
return [message('assistant', [{ type: 'tool_use', id: item.id, name: 'Bash', input: { command } }]),
|
|
52
|
+
message('user', [{ type: 'tool_result', tool_use_id: item.id, status, ...(Number.isSafeInteger(item.exit_code) ? { exit_code: item.exit_code } : {}),
|
|
53
|
+
content: typeof item.aggregated_output === 'string' ? item.aggregated_output : '' }])];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Code-mode `exec` calls whose call record and output record bracket at least one native tool item: Codex records
|
|
58
|
+
* the items an exec script runs between that exec's call and its output (rollouts on the owner's Mac, codex-cli
|
|
59
|
+
* 0.164). An item carries no call id, so the bracket is the only pairing there is; anything else is opaque.
|
|
60
|
+
*/
|
|
61
|
+
function pairedExecCalls(ordered) {
|
|
62
|
+
const paired = new Set();
|
|
63
|
+
ordered.forEach((record, i) => {
|
|
64
|
+
const p = record?.payload;
|
|
65
|
+
if (record?.type !== 'response_item' || p?.type !== 'custom_tool_call' || p.name !== 'exec' || typeof p.call_id !== 'string') return;
|
|
66
|
+
const end = ordered.findIndex((r, j) => j > i && r?.type === 'response_item' && r.payload?.type === 'custom_tool_call_output'
|
|
67
|
+
&& r.payload.call_id === p.call_id);
|
|
68
|
+
if (end > i && ordered.slice(i + 1, end).some(toolItem)) paired.add(p.call_id);
|
|
69
|
+
});
|
|
70
|
+
return paired;
|
|
71
|
+
}
|
|
72
|
+
|
|
8
73
|
/** Keep call IDs intact; torn/unknown records cannot manufacture successful tool evidence. */
|
|
9
74
|
export function nativeTurnLines(lines, host) {
|
|
10
75
|
if (host !== 'codex') return lines;
|
|
@@ -15,19 +80,46 @@ export function nativeTurnLines(lines, host) {
|
|
|
15
80
|
const record = parse(lines[i]); if (record) records.push(record);
|
|
16
81
|
}
|
|
17
82
|
records.reverse();
|
|
83
|
+
const { ordered, uncertain, itemized } = orderToolItems(records);
|
|
84
|
+
const paired = pairedExecCalls(ordered);
|
|
18
85
|
const out = []; const sessions = new Map(); const calls = new Map();
|
|
19
|
-
|
|
86
|
+
// Codex writes the same answer up to three times (response_item, item_completed AgentMessage,
|
|
87
|
+
// task_complete.last_agent_message); any one of them may be the only copy that survived.
|
|
88
|
+
let said = new Set();
|
|
89
|
+
const say = (role, value) => {
|
|
90
|
+
if (role === 'user') said = new Set();
|
|
91
|
+
else if (said.has(value.trim())) return;
|
|
92
|
+
else said.add(value.trim());
|
|
93
|
+
out.push(message(role, value));
|
|
94
|
+
};
|
|
95
|
+
for (const record of ordered) {
|
|
20
96
|
const p = record.payload;
|
|
97
|
+
if (toolItem(record)) {
|
|
98
|
+
if (!calls.has(p.item.id)) out.push(...toolItemLines(record, uncertain.has(record)));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
21
101
|
if (record.type === 'event_msg' && p?.type === 'user_message') {
|
|
22
|
-
|
|
102
|
+
say('user', p.message || ''); continue;
|
|
23
103
|
}
|
|
24
104
|
if (record.type === 'event_msg' && p?.type === 'agent_message' && typeof p.message === 'string') {
|
|
25
|
-
|
|
105
|
+
say('assistant', p.message); continue;
|
|
106
|
+
}
|
|
107
|
+
// Native turn end (rollout and `codex exec`): the closing answer, recorded once per turn.
|
|
108
|
+
if (record.type === 'event_msg' && p?.type === 'task_complete' && typeof p.last_agent_message === 'string') {
|
|
109
|
+
say('assistant', p.last_agent_message); continue;
|
|
110
|
+
}
|
|
111
|
+
if (record.type === 'event_msg' && p?.type === 'item_completed' && p.item?.type === 'AgentMessage') {
|
|
112
|
+
say('assistant', text(p.item.content)); continue;
|
|
26
113
|
}
|
|
27
114
|
if (record.type !== 'response_item' || !p) continue;
|
|
28
115
|
if (p.type === 'message' && ['user', 'assistant'].includes(p.role)) {
|
|
29
|
-
|
|
116
|
+
say(p.role, text(p.content)); continue;
|
|
30
117
|
}
|
|
118
|
+
// A code-mode `exec` script's actions are the native items mapped above, but only an exec PROVABLY paired with
|
|
119
|
+
// them (review r2, 4.7.21) is dropped; an opaque one (no item inside its call/output bracket, or no output yet)
|
|
120
|
+
// stays a tool call named `exec`, which the audit counts as a possible change (fail toward asking).
|
|
121
|
+
if (itemized && paired.has(p.call_id) && ['custom_tool_call', 'custom_tool_call_output'].includes(p.type)) continue;
|
|
122
|
+
if (itemized && p.type === 'custom_tool_call_output' && !calls.has(p.call_id)) continue;
|
|
31
123
|
if (['function_call', 'custom_tool_call'].includes(p.type) && typeof p.call_id === 'string') {
|
|
32
124
|
const args = typeof p.arguments === 'string' ? parse(p.arguments) : p.arguments;
|
|
33
125
|
let name = String(p.name || '').split('.').at(-1); let input = args || {};
|
|
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { automaticPath } from './automatic-update.mjs';
|
|
8
8
|
import { sharedLockStatus } from './developer-update-lock.mjs';
|
|
9
|
-
import { EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
9
|
+
import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
10
10
|
|
|
11
11
|
export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
|
|
12
12
|
export const NIGHTLY_HOUR = 3;
|
|
@@ -145,8 +145,13 @@ function inspectRefreshOwner(owner) {
|
|
|
145
145
|
const HISTORICAL_UPDATE_MODULES = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
|
|
146
146
|
const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-owner.mjs'];
|
|
147
147
|
const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
|
|
148
|
-
const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name
|
|
148
|
+
const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name))];
|
|
149
|
+
// Registered by 4.7.20 and earlier (before developer-update.mjs imported ./entrypoint.mjs); still a valid closure.
|
|
150
|
+
const PRE_ENTRYPOINT_DEVELOPER_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'entrypoint.mjs')])];
|
|
149
151
|
const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
|
|
152
|
+
/** A staged coordinator that imports an optional member must have it staged beside it (review B, 4.7.21). */
|
|
153
|
+
const missingOptional = (modules) => OPTIONAL_EXECUTION_MODULES.find(name => modules['developer-update.mjs'] && !modules[name]
|
|
154
|
+
&& fs.readFileSync(modules['developer-update.mjs'].path, 'utf8').includes(`./${name}`));
|
|
150
155
|
/** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
|
|
151
156
|
function readModuleSet(sourceDir, developerSuite) {
|
|
152
157
|
const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
|
|
@@ -170,8 +175,8 @@ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.
|
|
|
170
175
|
|
|
171
176
|
function verifyUpdateModules(modules) {
|
|
172
177
|
if (!modules) return; // existing schema-2 registrations remain inspectable
|
|
173
|
-
if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
|
|
174
|
-
if (modules
|
|
178
|
+
if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, PRE_ENTRYPOINT_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
|
|
179
|
+
if (missingOptional(modules)) throw new Error(missingOptional(modules) === 'plugin-artifact-proof.mjs' ? 'new coordinator artifact closure is incomplete' : `coordinator closure is missing ${missingOptional(modules)}`);
|
|
175
180
|
for (const [name, item] of Object.entries(modules)) {
|
|
176
181
|
if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
|
|
177
182
|
|| path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
|
|
@@ -344,7 +349,7 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
|
|
|
344
349
|
const boundTo = modules => Boolean(modules) && validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
|
|
345
350
|
&& receipt.sourceSha256 === modules['developer-update.mjs']?.sha256
|
|
346
351
|
&& Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === modules[name]?.sha256)
|
|
347
|
-
&& EXECUTION_MODULES.filter(name => name
|
|
352
|
+
&& EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || modules[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === modules[name]?.sha256);
|
|
348
353
|
// A receipt from the module set an update just replaced is still the night's proof, but only one that FINISHED before
|
|
349
354
|
// the replacement: the next scheduled run must bind to the current set.
|
|
350
355
|
// Judged by LAUNCH time: a night's own knowledge step can install a release and re-point the modules before the run
|
|
@@ -310,8 +310,8 @@ export function buildPackageCandidate({ prompt, pick, findingPrefix = 'recommend
|
|
|
310
310
|
findingId: `${findingPrefix}${card.id}`,
|
|
311
311
|
severity: 'normal',
|
|
312
312
|
observationHash: stateHashOf([`package:${card.id}`]),
|
|
313
|
-
copy: `[RuvNet Brain — rUv already ships this] If it genuinely fits this request,
|
|
314
|
-
+ `sentence: "rUv ships ${card.id} — ${description} (source: ${card.source})
|
|
313
|
+
copy: `[RuvNet Brain — rUv already ships this] If it genuinely fits this request, state it in ONE `
|
|
314
|
+
+ `sentence: "rUv ships ${card.id} — ${description} (source: ${card.source}); 'use ${name}' adopts it." `
|
|
315
315
|
+ 'Install state unknown from here; confirm with search_ruvnet before building, say it once, then carry on.',
|
|
316
316
|
capability: name,
|
|
317
317
|
package: card.id,
|
|
@@ -4,6 +4,25 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* ruflo encryption at rest (ruflo ADR-096, CLAUDE_FLOW_ENCRYPT_AT_REST=1) rewrites `.swarm/memory.db` as an
|
|
9
|
+
* AES-GCM image that starts with the magic bytes `RFE1`. ruflo opens that image only through its sql.js
|
|
10
|
+
* path and keeps native better-sqlite3 on a separate plaintext file (ruflo #2786), so a write that
|
|
11
|
+
* demands the native writer (`--require-native`, `--append-only`) is refused on every attempt (#439).
|
|
12
|
+
* Reads (`memory retrieve|search|list`) are unaffected. Four bytes are read; a missing or short file is
|
|
13
|
+
* not encrypted.
|
|
14
|
+
*/
|
|
15
|
+
export const ENCRYPTED_STORE_MAGIC = 'RFE1';
|
|
16
|
+
export const ENCRYPTED_STORE_REASON = 'store is encrypted at rest (ruflo RFE1); the native append-only writer cannot open it';
|
|
17
|
+
export function storeEncryptedAtRest(file) {
|
|
18
|
+
let fd;
|
|
19
|
+
try {
|
|
20
|
+
fd = fs.openSync(file, 'r');
|
|
21
|
+
const head = Buffer.alloc(4);
|
|
22
|
+
return fs.readSync(fd, head, 0, 4, 0) === 4 && head.toString('latin1') === ENCRYPTED_STORE_MAGIC;
|
|
23
|
+
} catch { return false; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
function sha256(value) {
|
|
8
27
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
9
28
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
readStdinBounded,
|
|
14
14
|
toolName,
|
|
15
15
|
} from './hook-input.mjs';
|
|
16
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
16
17
|
|
|
17
18
|
const SUCCESS = new Set(['completed', 'success', 'succeeded']);
|
|
18
19
|
const FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'timed_out', 'timeout']);
|
|
@@ -84,6 +85,6 @@ async function main() {
|
|
|
84
85
|
appendObservation(observationFrom(event, new Date().toISOString(), decision));
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
if (
|
|
88
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
88
89
|
try { await main(); } catch { /* advisory observer: fail open */ }
|
|
89
90
|
}
|
|
@@ -25,14 +25,28 @@ import { redactText } from './continuity-events.mjs';
|
|
|
25
25
|
/** hooks.json gives this boundary 10 s; the shim's watchdog fires at 8.5 s. Plan inside it. */
|
|
26
26
|
export const CAPTURE_BUDGET_MS = 8_000;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Claude Code's SessionEnd budget: 1.5 s shared by every SessionEnd hook, and "Timeouts set on
|
|
30
|
+
* plugin-provided hooks don't raise the budget" (code.claude.com/docs/en/hooks.md, SessionEnd), so
|
|
31
|
+
* hooks.json's 10 s does not apply there. Only CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS overrides it.
|
|
32
|
+
*/
|
|
33
|
+
export const CLAUDE_SESSION_END_BUDGET_MS = 1_500;
|
|
34
|
+
|
|
28
35
|
/**
|
|
29
36
|
* The budget this invocation really has. The Codex wrapper hands its own kill deadline down as
|
|
30
37
|
* RUVNET_CODEX_BUDGET_MS (2200 ms at SessionEnd, which Codex caps at 3 s); 300 ms is left for the
|
|
31
|
-
* adapter → shim → body spawns.
|
|
38
|
+
* adapter → shim → body spawns. At SessionEnd under Claude Code the same 300 ms comes off Claude
|
|
39
|
+
* Code's own budget (issue #420 item 2: planning for 8 s ran the checkpoint inline and /exit printed
|
|
40
|
+
* "Hook cancelled"); with that budget the checkpoint is handed to the detached child below.
|
|
32
41
|
*/
|
|
33
|
-
export function effectiveBudgetMs(env = process.env) {
|
|
42
|
+
export function effectiveBudgetMs(env = process.env, event = null) {
|
|
34
43
|
const handed = Number(env.RUVNET_CODEX_BUDGET_MS);
|
|
35
|
-
|
|
44
|
+
if (Number.isFinite(handed) && handed > 0) return Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300));
|
|
45
|
+
const claude = !env.RUVNET_HOOK_HOST || env.RUVNET_HOOK_HOST === 'claude';
|
|
46
|
+
if (event !== 'SessionEnd' || !claude) return CAPTURE_BUDGET_MS;
|
|
47
|
+
const configured = Number(env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS);
|
|
48
|
+
const budget = Number.isFinite(configured) && configured > 0 ? configured : CLAUDE_SESSION_END_BUDGET_MS;
|
|
49
|
+
return Math.max(0, Math.min(CAPTURE_BUDGET_MS, budget - 300));
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/**
|
|
@@ -65,7 +79,7 @@ export function detachCheckpoint({ projectDir, event, payload, env, brainHome })
|
|
|
65
79
|
return { detached: true, pid: child.pid };
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
export async function runLifecycleBoundary(event, payload, { env = process.env, deadlineAt = Date.now() + effectiveBudgetMs(env) } = {}) {
|
|
82
|
+
export async function runLifecycleBoundary(event, payload, { env = process.env, deadlineAt = Date.now() + effectiveBudgetMs(env, event) } = {}) {
|
|
69
83
|
const projectDir = typeof payload?.cwd === 'string' && path.isAbsolute(payload.cwd) ? payload.cwd : projectDirectory();
|
|
70
84
|
const host = env.RUVNET_HOOK_HOST || 'claude';
|
|
71
85
|
if (event === 'PostToolUse') {
|
|
@@ -82,6 +96,12 @@ export async function runLifecycleBoundary(event, payload, { env = process.env,
|
|
|
82
96
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { turn, checkpoint: { stored: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
|
|
83
97
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
84
98
|
if (deadlineAt - Date.now() < CHECKPOINT_NEED_MS) {
|
|
99
|
+
// A persisted opt-out is answered here, so no detached child is started for a project that refused
|
|
100
|
+
// capture (the child re-checks consent anyway; a deadline throw here just leaves that check to it).
|
|
101
|
+
try {
|
|
102
|
+
const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt });
|
|
103
|
+
if (consent.skipped) return { turn, checkpoint: { stored: false, skipped: consent.skipped } };
|
|
104
|
+
} catch { /* out of time: the detached child decides */ }
|
|
85
105
|
try { return { turn, checkpoint: { stored: false, ...detachCheckpoint({ projectDir, event, payload, env, brainHome }) } }; } catch (error) {
|
|
86
106
|
recordHookFailure({ hook: 'checkpoint', event, error: `skipped: hook budget too small and detach failed (${error.code || 'spawn'})` }, { brainHome });
|
|
87
107
|
return { turn, checkpoint: { stored: false, skipped: 'hook budget too small; detach failed' } };
|
|
@@ -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))
|
|
@@ -452,21 +456,7 @@ export async function runSessionStart({
|
|
|
452
456
|
return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
|
|
453
457
|
}
|
|
454
458
|
|
|
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();
|
|
459
|
+
const direct = isDirectInvocation(import.meta.url);
|
|
470
460
|
if (direct) {
|
|
471
461
|
const sessionSource = await readSessionSource();
|
|
472
462
|
runSessionStart({ sessionSource, startedAt: performance.timeOrigin }).catch((error) => {
|