ruvnet-brain 4.7.16 → 4.7.17
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 +38 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/grok-hooks.json +1 -1
- package/plugin/hooks/hook-contracts.json +36 -18
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/continuity-hook-policy.mjs +9 -9
- package/plugin/scripts/grok-hook-adapter.mjs +7 -3
- package/plugin/scripts/hook-failures.mjs +76 -8
- package/plugin/scripts/learn-capture.mjs +74 -9
- package/plugin/scripts/learning-store.mjs +16 -6
- package/plugin/scripts/nightly-scheduler.mjs +67 -12
- package/plugin/scripts/turn-outcome-capture.mjs +17 -17
- package/scripts/corpus-watchdog.mjs +28 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
|
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
|
|
|
255
255
|
does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
|
|
256
256
|
before treating an installation as current.
|
|
257
257
|
|
|
258
|
-
The **4.6.0 published-corpus baseline** is **
|
|
258
|
+
The **4.6.0 published-corpus baseline** is **205 public stores · 165,153 public source chunks**.
|
|
259
259
|
Those are bundle census values, not a claim that every upstream repository is fresh today.
|
|
260
260
|
Later corpus publication may advance the knowledge generation independently of code changes.
|
|
261
261
|
|
package/bin/install.mjs
CHANGED
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
installNightlyRunner,
|
|
55
55
|
installScheduler,
|
|
56
56
|
nightlyArtifact,
|
|
57
|
+
nightlyModuleCurrency,
|
|
58
|
+
refreshNightlyModules,
|
|
57
59
|
removeScheduler,
|
|
58
60
|
readNightlyRegistration,
|
|
59
61
|
resolveNightlyProofBundle,
|
|
@@ -3179,8 +3181,14 @@ async function doctorRun({ json }) {
|
|
|
3179
3181
|
const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
|
|
3180
3182
|
const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
|
|
3181
3183
|
brainHome, kbDir: cacheDir });
|
|
3184
|
+
// Does the scheduled job run the code installed now? Its module snapshot is refreshed by --update, its runner only
|
|
3185
|
+
// by --enable-nightly (measured stale 2026-10-22). Read-only; `unregistered` is already covered by the state lines.
|
|
3186
|
+
let nightlyCode = { state: 'unregistered' };
|
|
3187
|
+
try { nightlyCode = nightlyModuleCurrency({ brainHome, runnerSource: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') }); } catch (error) { nightlyCode = { state: 'unknown', why: error.message }; }
|
|
3182
3188
|
if (nightlyHealth.state === 'on') {
|
|
3183
3189
|
ok(`nightly scheduler: ${nightlyHealth.evidence}`);
|
|
3190
|
+
if (nightlyCode.state === 'current') ok('nightly runs current code: the registered update modules are the installed set');
|
|
3191
|
+
else if (nightlyCode.fix) warn(`nightly runs ${nightlyCode.state === 'runner-changed' ? 'an older runner' : 'stale update modules'} — fix: ${c.bold(nightlyCode.fix)}`);
|
|
3184
3192
|
if (nightlyHealth.runHealth?.state === 'ok' || nightlyHealth.runHealth?.state === 'running') {
|
|
3185
3193
|
ok(`nightly execution: ${nightlyHealth.runHealth.evidence}`);
|
|
3186
3194
|
} else if (nightlyHealth.runHealth?.state === 'failed') {
|
|
@@ -3436,6 +3444,10 @@ async function doctorRun({ json }) {
|
|
|
3436
3444
|
: codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
|
|
3437
3445
|
check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
|
|
3438
3446
|
nightlyHealth.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update'),
|
|
3447
|
+
// Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
|
|
3448
|
+
...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
|
|
3449
|
+
nightlyCode.state === 'current' ? 'registered update modules match the installed set' : `${nightlyCode.state}: registered ${String(nightlyCode.registered || '?').slice(0, 12)}, installed ${String(nightlyCode.current || '?').slice(0, 12)}`,
|
|
3450
|
+
nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
|
|
3439
3451
|
check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
|
|
3440
3452
|
...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
|
|
3441
3453
|
...(agentdbLine ? [agentdbLine] : []),
|
|
@@ -4404,6 +4416,7 @@ async function runUpdate() {
|
|
|
4404
4416
|
info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
|
|
4405
4417
|
enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
|
|
4406
4418
|
}
|
|
4419
|
+
if (updateStatus === 0) reconcileNightlyModules({ brainHome, refreshReceipt });
|
|
4407
4420
|
let retention;
|
|
4408
4421
|
try {
|
|
4409
4422
|
retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
|
|
@@ -4443,6 +4456,31 @@ async function runUpdate() {
|
|
|
4443
4456
|
await printConfirmation({ footprint: footprint?.after || null });
|
|
4444
4457
|
}
|
|
4445
4458
|
|
|
4459
|
+
/**
|
|
4460
|
+
* After a successful --update: the nightly runs a content-addressed SNAPSHOT of the update modules (installNightlyRunner),
|
|
4461
|
+
* which until now only --enable-nightly refreshed, so a fix shipped by --update stayed invisible to the 03:30 job
|
|
4462
|
+
* (measured 2026-10-22: the 4.7.15 developer-update fix). A stale registration is re-pointed at the installed modules;
|
|
4463
|
+
* a changed RUNNER would change the LaunchAgent command, which only explicit enablement may do, so that case is an
|
|
4464
|
+
* advisory naming the fix. Advisory and fail-open: it never fails an otherwise good update.
|
|
4465
|
+
*/
|
|
4466
|
+
export function reconcileNightlyModules({ brainHome, refreshReceipt = null, runnerSource = path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') } = {}) {
|
|
4467
|
+
const advise = (status, detail) => { // the advisory write itself must never escape this step
|
|
4468
|
+
try { if (refreshReceipt) recordRefreshAdvisory(refreshReceipt, 'nightly-modules', status, detail); } catch { /* the receipt is evidence, not the gate */ }
|
|
4469
|
+
return { status, ...detail };
|
|
4470
|
+
};
|
|
4471
|
+
try {
|
|
4472
|
+
const nightly = refreshNightlyModules({ brainHome, runnerSource });
|
|
4473
|
+
if (nightly.state === 'refreshed') ok(`nightly scheduler now runs the installed update modules (${nightly.registered.slice(0, 16)}…)`);
|
|
4474
|
+
else if (nightly.state === 'runner-changed') warn(`nightly scheduler runs an older runner; the LaunchAgent changes only on explicit enablement — run ${c.bold(nightly.fix)}`);
|
|
4475
|
+
else if (nightly.state === 'refused') warn(`nightly module snapshot was not refreshed: ${nightly.reason} — run ${c.bold(nightly.fix)}`);
|
|
4476
|
+
return advise(['runner-changed', 'refused'].includes(nightly.state) ? 'SKIP' : 'PASS',
|
|
4477
|
+
{ state: nightly.state, registered: nightly.registered ?? null, current: nightly.current ?? null, ...(nightly.reason ? { reason: nightly.reason } : {}), ...(nightly.fix ? { fix: nightly.fix } : {}) });
|
|
4478
|
+
} catch (error) {
|
|
4479
|
+
warn(`nightly module snapshot was not refreshed (${error.message}); the scheduler registration was left as it was`);
|
|
4480
|
+
return advise('FAIL', { state: 'error', reason: error.message });
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4446
4484
|
function enableNightly() {
|
|
4447
4485
|
printBanner('enable nightly updates');
|
|
4448
4486
|
const kbDir = resolvedKbDir();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.17",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.17",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain lifecycle plane for Grok CLI. Grok selects this file through plugin/.grok-plugin/plugin.json (measured on grok 1.0.50: a plugin directory carrying that manifest registers ONLY this file, so the Claude registry plugin/hooks/hooks.json is not also run). Every command enters plugin/scripts/grok-hook-adapter.mjs, which normalizes the Grok payload, withholds Grok's non-Claude transcript, skips the session-end Stop fire, runs the SAME hook-shim.mjs bodies as Claude and Codex, and returns Grok-valid output. Registered: decision-gate write route (PreToolUse, the only refuser), session-snapshot and grounding-stamp (PostToolUse), grounding-turn-mark (UserPromptSubmit, a marker file only), continuation-gate + session-snapshot + grounding-turn-gate (Stop), session-snapshot (PreCompact, SessionEnd). Deliberately NOT registered on Grok: session-start, ground-ruvnet, unprompted-speech and capacity-aware-parallel-work. Grok ignores SessionStart stdout and discards an allowing UserPromptSubmit hook's stdout (~/.grok/docs/user-guide/10-hooks.md:113, :507, :531), so they would spend time on every session or prompt and deliver nothing, and session-start would consume the once-only hook-failure summary where nobody sees it.
|
|
2
|
+
"description": "RuvNet Brain lifecycle plane for Grok CLI. Grok selects this file through plugin/.grok-plugin/plugin.json (measured on grok 1.0.50: a plugin directory carrying that manifest registers ONLY this file, so the Claude registry plugin/hooks/hooks.json is not also run). Every command enters plugin/scripts/grok-hook-adapter.mjs, which normalizes the Grok payload, withholds Grok's non-Claude transcript, skips the session-end Stop fire, runs the SAME hook-shim.mjs bodies as Claude and Codex, and returns Grok-valid output. Registered (9 handlers): decision-gate write route (PreToolUse, the only refuser registered on Grok), session-snapshot and grounding-stamp (PostToolUse), grounding-turn-mark (UserPromptSubmit, a marker file only), continuation-gate + session-snapshot + grounding-turn-gate (Stop), session-snapshot (PreCompact, SessionEnd). Deliberately NOT registered on Grok: session-start, ground-ruvnet, unprompted-speech and capacity-aware-parallel-work. Grok ignores SessionStart stdout and discards an allowing UserPromptSubmit hook's stdout (~/.grok/docs/user-guide/10-hooks.md:113, :507, :531), so they would spend time on every session or prompt and deliver nothing, and session-start would consume the once-only hook-failure summary where nobody sees it. SessionEnd: \"A plugin's SessionEnd hook runs for at most that default [1.5 s], whatever its timeout\" (10-hooks.md:644-645), so the 3 s timeout below is not honoured; the adapter budgets 1400 ms and hands it to the body as RUVNET_CODEX_BUDGET_MS, which detaches the checkpoint (the Codex 3 s SessionEnd path) instead of an inline store+readback Grok would kill partway. UNVERIFIED (docs silent, not measured): the payload tool_name of an MCP search_ruvnet call and whether grounding-stamp accepts Grok's toolResult shape; PreCompact delivery (no compaction was driven); Windows expansion of ${GROK_PLUGIN_ROOT} (10-hooks.md:581 describes it; not measured).",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
5
|
{
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"class": "continuation nudge",
|
|
29
29
|
"hosts": [
|
|
30
30
|
"claude",
|
|
31
|
-
"codex"
|
|
31
|
+
"codex",
|
|
32
|
+
"grok"
|
|
32
33
|
],
|
|
33
34
|
"responsibility": "May request one continuation for one explicitly authorized, project-scoped objective read from the user's own work ledger (ADR-043). Never a refusal, never a second task store. Amended 2026-09-30 (ADR-074 class `completion`, no new registration): the same Stop body also requests ONE correction when the final answer claims work is done without a verification command run after the last state change this turn (Claude transcript), a named check, and a NOT-verified disclosure — on Codex, whose rollout format is not parsed, only the answer-side half is enforced and the transcript half is reported UNKNOWN. It also records first-person promises from the final answer into the SAME per-project ledger (Claude only; capped; closed only by a later evidence-passing completion claim, never by --done) and requests continuation on them under the same loop guards and cooldown. Amended 2026-10-11 (ADR-106, no new registration): the same body asks ONCE when a Claude answer ends by asking permission for, or postponing, work a standing instruction or open promise already authorized and names no genuine blocker class (no-deferral); Codex is not enforced because its prompts are not parsed."
|
|
34
35
|
},
|
|
@@ -38,7 +39,8 @@
|
|
|
38
39
|
"class": "continuity capture",
|
|
39
40
|
"hosts": [
|
|
40
41
|
"claude",
|
|
41
|
-
"codex"
|
|
42
|
+
"codex",
|
|
43
|
+
"grok"
|
|
42
44
|
],
|
|
43
45
|
"responsibility": "Write the redacted turn outcome with one bounded `ruflo memory store` in namespace turns and prove it by exact-key readback; a refusal is a hook-failures row. No journal, outbox or replay."
|
|
44
46
|
},
|
|
@@ -48,7 +50,8 @@
|
|
|
48
50
|
"class": "continuity capture",
|
|
49
51
|
"hosts": [
|
|
50
52
|
"claude",
|
|
51
|
-
"codex"
|
|
53
|
+
"codex",
|
|
54
|
+
"grok"
|
|
52
55
|
],
|
|
53
56
|
"responsibility": "Store one append-only project-state-auto-<ms> checkpoint through `ruflo memory store` and read it back by exact key; start turn distillation (`ruflo memory distill run`)."
|
|
54
57
|
},
|
|
@@ -58,7 +61,8 @@
|
|
|
58
61
|
"class": "continuity capture",
|
|
59
62
|
"hosts": [
|
|
60
63
|
"claude",
|
|
61
|
-
"codex"
|
|
64
|
+
"codex",
|
|
65
|
+
"grok"
|
|
62
66
|
],
|
|
63
67
|
"responsibility": "Store one append-only project-state-auto-<ms> checkpoint through `ruflo memory store` and read it back by exact key; start turn distillation (`ruflo memory distill run`)."
|
|
64
68
|
},
|
|
@@ -88,7 +92,8 @@
|
|
|
88
92
|
"class": "consequential-action authorization",
|
|
89
93
|
"hosts": [
|
|
90
94
|
"claude",
|
|
91
|
-
"codex"
|
|
95
|
+
"codex",
|
|
96
|
+
"grok"
|
|
92
97
|
],
|
|
93
98
|
"responsibility": "The ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067): composes protect-state, hijack-ruvnet, ground-before-write (ADR-0012), adr-currency and duplicate-code (2026-09-30: a new code file or large new export duplicating existing code in this checkout, refused at most once per path per session) into one decision with one reason. Fails open on its own errors. apply_patch is Codex's raw write-tool name, added to the shared matcher 2026-09-12 after a real apply_patch call was measured to fire PreToolUse live (codex-cli 0.154.0); codex-hook-adapter.mjs normalizes it to the same Edit shape decision-gate.mjs's policies already read on Claude."
|
|
94
99
|
},
|
|
@@ -98,7 +103,8 @@
|
|
|
98
103
|
"class": "grounding receipt",
|
|
99
104
|
"hosts": [
|
|
100
105
|
"claude",
|
|
101
|
-
"codex"
|
|
106
|
+
"codex",
|
|
107
|
+
"grok"
|
|
102
108
|
],
|
|
103
109
|
"responsibility": "After a SUCCESSFUL search_ruvnet, records which rUv products were consulted (a 24h stamp per term named in the query). It is the key that opens ground-before-write; without it the write gate would refuse every rUv-product write forever. Extended to Codex 2026-09-12: a real MCP search_ruvnet call fired PostToolUse with tool_name mcp__ruvnet_brain__search_ruvnet, which the existing matcher already recognized unchanged."
|
|
104
110
|
},
|
|
@@ -108,7 +114,8 @@
|
|
|
108
114
|
"class": "grounding turn marker",
|
|
109
115
|
"hosts": [
|
|
110
116
|
"claude",
|
|
111
|
-
"codex"
|
|
117
|
+
"codex",
|
|
118
|
+
"grok"
|
|
112
119
|
],
|
|
113
120
|
"responsibility": "Records that ground-ruvnet's Gate 1 (the same regex, proven byte-identical by test) matched this turn's prompt, so grounding-turn-gate has something to check at Stop — Stop's own payload carries no prompt text. Amended 2026-09-30: also arms when the prompt asks a capability/feasibility/architecture question about any tool or platform, recording the subjects it named; a marker not yet consumed is merged without moving its mtime (a queued mid-turn prompt re-dating it was a measured false-alarm cause). Writes only a marker file under ~/.cache/ruvnet-brain/grounding-turn/; never blocks, never speaks to the model."
|
|
114
121
|
},
|
|
@@ -118,7 +125,8 @@
|
|
|
118
125
|
"class": "answered-without-search enforcement",
|
|
119
126
|
"hosts": [
|
|
120
127
|
"claude",
|
|
121
|
-
"codex"
|
|
128
|
+
"codex",
|
|
129
|
+
"grok"
|
|
122
130
|
],
|
|
123
131
|
"responsibility": "The 'answered without searching' gate (2026-09-12): if grounding-turn-mark's marker shows Gate 1 fired this turn and grounding-stamp's own stamp evidence shows no search_ruvnet call happened since, forces continuation via the same hookSpecificOutput.additionalContext contract continuation-gate already uses on both hosts. A separate registration from continuation-gate — see grounding-turn-gate.mjs's header for why folding it into that file's ledger/cooldown semantics would corrupt them rather than extend them. Consumes its marker unconditionally so a stale one can never pressure an unrelated later turn. Amended 2026-09-30 (ADR-0030 #1): on Claude, 'was search_ruvnet called' is read from the transcript, and on an armed turn each capability claim in the final answer must be bound to a relevant strong source read this turn after the last weak one, else ONE correction naming the claim and what was read; on Codex (rollout not parsed) only rUv-term claims are judged, from stamps, and the rest are UNKNOWN. Gates #2/#3 are shadow-logged, never delivered."
|
|
124
132
|
},
|
|
@@ -128,7 +136,8 @@
|
|
|
128
136
|
"class": "normalized continuity observation",
|
|
129
137
|
"hosts": [
|
|
130
138
|
"claude",
|
|
131
|
-
"codex"
|
|
139
|
+
"codex",
|
|
140
|
+
"grok"
|
|
132
141
|
],
|
|
133
142
|
"responsibility": "When learning is opted in, write one minimized workflow observation with `ruflo memory store` + exact-key readback; a refusal is a hook-failures row. No queue."
|
|
134
143
|
}
|
|
@@ -204,7 +213,8 @@
|
|
|
204
213
|
"event": "Stop",
|
|
205
214
|
"hosts": [
|
|
206
215
|
"claude",
|
|
207
|
-
"codex"
|
|
216
|
+
"codex",
|
|
217
|
+
"grok"
|
|
208
218
|
],
|
|
209
219
|
"mode": "advisory",
|
|
210
220
|
"offBehavior": "run",
|
|
@@ -216,7 +226,8 @@
|
|
|
216
226
|
"event": "Stop",
|
|
217
227
|
"hosts": [
|
|
218
228
|
"claude",
|
|
219
|
-
"codex"
|
|
229
|
+
"codex",
|
|
230
|
+
"grok"
|
|
220
231
|
],
|
|
221
232
|
"mode": "advisory",
|
|
222
233
|
"offBehavior": "run",
|
|
@@ -228,7 +239,8 @@
|
|
|
228
239
|
"event": "PreCompact",
|
|
229
240
|
"hosts": [
|
|
230
241
|
"claude",
|
|
231
|
-
"codex"
|
|
242
|
+
"codex",
|
|
243
|
+
"grok"
|
|
232
244
|
],
|
|
233
245
|
"mode": "advisory",
|
|
234
246
|
"offBehavior": "run",
|
|
@@ -240,7 +252,8 @@
|
|
|
240
252
|
"event": "SessionEnd",
|
|
241
253
|
"hosts": [
|
|
242
254
|
"claude",
|
|
243
|
-
"codex"
|
|
255
|
+
"codex",
|
|
256
|
+
"grok"
|
|
244
257
|
],
|
|
245
258
|
"mode": "advisory",
|
|
246
259
|
"offBehavior": "run",
|
|
@@ -276,7 +289,8 @@
|
|
|
276
289
|
"event": "PreToolUse",
|
|
277
290
|
"hosts": [
|
|
278
291
|
"claude",
|
|
279
|
-
"codex"
|
|
292
|
+
"codex",
|
|
293
|
+
"grok"
|
|
280
294
|
],
|
|
281
295
|
"mode": "blocking",
|
|
282
296
|
"offBehavior": "run",
|
|
@@ -288,7 +302,8 @@
|
|
|
288
302
|
"event": "PostToolUse",
|
|
289
303
|
"hosts": [
|
|
290
304
|
"claude",
|
|
291
|
-
"codex"
|
|
305
|
+
"codex",
|
|
306
|
+
"grok"
|
|
292
307
|
],
|
|
293
308
|
"mode": "advisory",
|
|
294
309
|
"offBehavior": "silence",
|
|
@@ -300,7 +315,8 @@
|
|
|
300
315
|
"event": "UserPromptSubmit",
|
|
301
316
|
"hosts": [
|
|
302
317
|
"claude",
|
|
303
|
-
"codex"
|
|
318
|
+
"codex",
|
|
319
|
+
"grok"
|
|
304
320
|
],
|
|
305
321
|
"mode": "advisory",
|
|
306
322
|
"offBehavior": "silence",
|
|
@@ -312,7 +328,8 @@
|
|
|
312
328
|
"event": "Stop",
|
|
313
329
|
"hosts": [
|
|
314
330
|
"claude",
|
|
315
|
-
"codex"
|
|
331
|
+
"codex",
|
|
332
|
+
"grok"
|
|
316
333
|
],
|
|
317
334
|
"mode": "advisory",
|
|
318
335
|
"offBehavior": "silence",
|
|
@@ -324,7 +341,8 @@
|
|
|
324
341
|
"event": "PostToolUse",
|
|
325
342
|
"hosts": [
|
|
326
343
|
"claude",
|
|
327
|
-
"codex"
|
|
344
|
+
"codex",
|
|
345
|
+
"grok"
|
|
328
346
|
],
|
|
329
347
|
"mode": "advisory",
|
|
330
348
|
"offBehavior": "run",
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. Lifecycle memory only CALLS ruflo (owner ruling 2026-10-12, ADR-106): there is no hand-rolled store, queue, journal or outbox. SessionStart restores the newest project-state-current-* checkpoint (and a newer project-state-auto-* one) and lists lesson keys by exact-key `ruflo memory list|retrieve`, and summarizes hook failures once. UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection (with one bounded AgentDB recall search), a capacity-aware context hint, and grounding-turn-mark. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate, and session-snapshot PostToolUse writes an opted-in learning observation with `ruflo memory store`. Stop runs the continuation gate, writes the turn outcome with `ruflo memory store` in namespace turns proven by exact-key readback, and runs grounding-turn-gate. PreCompact and SessionEnd store one append-only project-state-auto-<ms> checkpoint with exact-key readback and start `ruflo memory distill run`. Every body is advisory and fails open except decision-gate; a refused or unverified ruflo write, or a body stopped by the shim watchdog, is one row in hook-failures.jsonl, summarized at the next SessionStart.",
|
|
2
|
+
"description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. Lifecycle memory only CALLS ruflo (owner ruling 2026-10-12, ADR-106): there is no hand-rolled store, queue, journal or outbox. SessionStart restores the newest project-state-current-* checkpoint (and a newer project-state-auto-* one) and lists lesson keys by exact-key `ruflo memory list|retrieve`, and summarizes hook failures once. UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection (with one bounded AgentDB recall search), a capacity-aware context hint, and grounding-turn-mark. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate, and session-snapshot PostToolUse writes an opted-in learning observation with `ruflo memory store`. Stop runs the continuation gate, writes the turn outcome with `ruflo memory store` in namespace turns proven by exact-key readback, and runs grounding-turn-gate. PreCompact and SessionEnd store one append-only project-state-auto-<ms> checkpoint with exact-key readback and start `ruflo memory distill run`. Every body is advisory and fails open except decision-gate (refuses a write) and unprompted-speech (exit 2 for an opted-in lesson block at UserPromptSubmit); a refused or unverified ruflo write, or a body stopped by the shim watchdog, is one row in hook-failures.jsonl, summarized at the next SessionStart.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -134,7 +134,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
134
134
|
// The "answered without searching" gate, half 1 of 2 (2026-09-12) — see grounding-turn-gate.mjs's
|
|
135
135
|
// header for the full rationale. Records that ground-ruvnet's Gate 1 fired for this turn, since
|
|
136
136
|
// Stop's own payload carries no prompt text for grounding-turn-gate to test.
|
|
137
|
-
registration('grounding-turn-mark', '*', ['claude', 'codex']),
|
|
137
|
+
registration('grounding-turn-mark', '*', ['claude', 'codex', 'grok']),
|
|
138
138
|
]),
|
|
139
139
|
// The write gate and its key (ADR-0012 / ADR-067), re-registered 2026-09-11 — see the header.
|
|
140
140
|
// Extended to Codex 2026-09-12: a real `apply_patch` write was measured to fire PreToolUse on
|
|
@@ -143,7 +143,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
143
143
|
// Claude's `Edit` shape for decision-gate.mjs's own policies (protect-state, hijack-ruvnet,
|
|
144
144
|
// ground-before-write, adr-currency all then see tool_name:"Edit" exactly as on Claude).
|
|
145
145
|
PreToolUse: Object.freeze([
|
|
146
|
-
registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex']),
|
|
146
|
+
registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex', 'grok']),
|
|
147
147
|
]),
|
|
148
148
|
// Extended to Codex 2026-09-12: a real MCP `search_ruvnet` call was measured to fire PostToolUse
|
|
149
149
|
// on codex-cli 0.154.0 with tool_name "mcp__ruvnet_brain__search_ruvnet" — the matcher below
|
|
@@ -153,18 +153,18 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
153
153
|
// "Searched N RuvNet repos" banner and query text the script already looks for).
|
|
154
154
|
PostToolUse: Object.freeze([
|
|
155
155
|
// Learning observation (consent-gated `ruflo memory store`) runs inside session-snapshot's PostToolUse body.
|
|
156
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
157
|
-
registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
|
|
156
|
+
registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
|
|
157
|
+
registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex', 'grok']),
|
|
158
158
|
]),
|
|
159
159
|
Stop: Object.freeze([
|
|
160
|
-
registration('continuation-gate', '*', ['claude', 'codex']),
|
|
160
|
+
registration('continuation-gate', '*', ['claude', 'codex', 'grok']),
|
|
161
161
|
// Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
|
|
162
162
|
// turn-outcome-capture.mjs). Codex SessionEnd carries no last_assistant_message, so Stop is the
|
|
163
163
|
// only boundary that can record a Codex turn. Evidence: codex-cli 0.158.0's own
|
|
164
164
|
// stop.command.input schema (last_assistant_message, turn_id) and the two Codex Stop handlers
|
|
165
165
|
// already registered above/below. NOT yet live-observed firing — see the probe box: the capture
|
|
166
166
|
// fails open, so an unfired registration costs nothing but must not be read as proof.
|
|
167
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
167
|
+
registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
|
|
168
168
|
// The "answered without searching" gate, half 2 of 2 (2026-09-12). Forces continuation
|
|
169
169
|
// (hookSpecificOutput.additionalContext — the same contract continuation-gate.mjs already uses
|
|
170
170
|
// and codex-hook-adapter.mjs already translates to Codex's decision:block on both hosts) when
|
|
@@ -172,13 +172,13 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
172
172
|
// (grounding-stamp.sh) postdates it. A separate registration from continuation-gate on purpose —
|
|
173
173
|
// see grounding-turn-gate.mjs's header for why folding it in would corrupt that file's ledger
|
|
174
174
|
// semantics rather than extend them.
|
|
175
|
-
registration('grounding-turn-gate', '*', ['claude', 'codex']),
|
|
175
|
+
registration('grounding-turn-gate', '*', ['claude', 'codex', 'grok']),
|
|
176
176
|
]),
|
|
177
177
|
PreCompact: Object.freeze([
|
|
178
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
178
|
+
registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
|
|
179
179
|
]),
|
|
180
180
|
SessionEnd: Object.freeze([
|
|
181
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
181
|
+
registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
|
|
182
182
|
]),
|
|
183
183
|
});
|
|
184
184
|
|
|
@@ -48,11 +48,14 @@ export const GROK_OUTPUT_EVENTS = Object.freeze(new Set(['PreToolUse', 'PostTool
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* This adapter's own kill budget, below the registration timeout in grok-hooks.json so the adapter,
|
|
51
|
-
* not Grok, ends a slow body and records it. SessionEnd:
|
|
52
|
-
*
|
|
51
|
+
* not Grok, ends a slow body and records it. SessionEnd: "A plugin's SessionEnd hook runs for at most
|
|
52
|
+
* that default [1.5 s], whatever its timeout" (10-hooks.md:644-645), so the budget sits under 1500 ms and
|
|
53
|
+
* is handed to the body (RUVNET_CODEX_BUDGET_MS, the host-budget seam the Codex wrapper already uses), which
|
|
54
|
+
* then detaches its checkpoint instead of starting an inline store+readback Grok would kill partway.
|
|
53
55
|
*/
|
|
56
|
+
export const SESSION_END_BUDGET_MS = 1_400;
|
|
54
57
|
export function budgetFor(hookId, event) {
|
|
55
|
-
if (event === 'SessionEnd') return
|
|
58
|
+
if (event === 'SessionEnd') return SESSION_END_BUDGET_MS;
|
|
56
59
|
if (hookId === 'decision-gate') return 6_500;
|
|
57
60
|
if (hookId === 'grounding-stamp' || hookId === 'grounding-turn-mark') return 4_000;
|
|
58
61
|
return 8_500;
|
|
@@ -133,6 +136,7 @@ async function main() {
|
|
|
133
136
|
CLAUDE_PROJECT_DIR: projectDir,
|
|
134
137
|
CLAUDE_SESSION_ID: String(payload.session_id || process.env.GROK_SESSION_ID || ''),
|
|
135
138
|
RUVNET_HOOK_HOST: 'grok',
|
|
139
|
+
...(event === 'SessionEnd' ? { RUVNET_CODEX_BUDGET_MS: String(budget) } : {}), // the body plans inside Grok's 1.5 s
|
|
136
140
|
};
|
|
137
141
|
const r = spawnSync(process.execPath, [path.join(HERE, 'hook-shim.mjs'), hookId, ...extra], {
|
|
138
142
|
input: JSON.stringify(payload), encoding: 'utf8', env, cwd, timeout: budget, killSignal: 'SIGKILL',
|
|
@@ -9,16 +9,21 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import os from 'node:os';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
|
|
13
14
|
export const FAILURE_FILE = 'hook-failures.jsonl';
|
|
14
15
|
export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
|
|
15
16
|
const CURSOR_FILE = 'hook-failures.cursor';
|
|
17
|
+
const WINDOW_FILE = 'hook-failures.window.json';
|
|
16
18
|
const MAX_READ = 256 * 1024;
|
|
19
|
+
export const REPEAT_WINDOW_MS = 10 * 60_000;
|
|
20
|
+
const MAX_WINDOWS = 64;
|
|
17
21
|
|
|
18
22
|
/** Summarize failures recorded after the cursor; advance it. Returns '' when there is nothing new. */
|
|
19
|
-
export function failureLine(brainHome, { advance = true } = {}) {
|
|
23
|
+
export function failureLine(brainHome, { advance = true, now = Date.now() } = {}) {
|
|
20
24
|
const file = path.join(brainHome, FAILURE_FILE);
|
|
21
25
|
const cursorFile = path.join(brainHome, CURSOR_FILE);
|
|
26
|
+
try { flushRepeats(brainHome, now, { all: true }); } catch { /* the rows already on disk are still reported */ } // the report is now
|
|
22
27
|
let size;
|
|
23
28
|
try { size = fs.statSync(file).size; } catch { return ''; }
|
|
24
29
|
let offset = 0;
|
|
@@ -33,29 +38,92 @@ export function failureLine(brainHome, { advance = true } = {}) {
|
|
|
33
38
|
if (advance) { try { fs.writeFileSync(cursorFile, JSON.stringify({ offset: size })); } catch { /* re-reported next session */ } }
|
|
34
39
|
if (!rows.length) return '';
|
|
35
40
|
const byHook = new Map();
|
|
41
|
+
let total = 0;
|
|
36
42
|
for (const r of rows) {
|
|
37
43
|
const id = String(r.hook || 'unknown').slice(0, 40);
|
|
38
|
-
const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
|
|
44
|
+
const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).replace(/ \(repeated\)$/, '').slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
|
|
39
45
|
const e = byHook.get(id) || { n: 0, why: new Set() };
|
|
40
|
-
|
|
46
|
+
const n = Number(r.count) > 0 ? Number(r.count) : 1; // an aggregated row stands for `count` runs
|
|
47
|
+
e.n += n; total += n; e.why.add(why); byHook.set(id, e);
|
|
41
48
|
}
|
|
42
49
|
const parts = [...byHook.entries()].sort((a, b) => b[1].n - a[1].n)
|
|
43
50
|
.map(([id, e]) => `${id} ×${e.n} (${[...e.why].slice(0, 3).join(', ')})`);
|
|
44
|
-
return `${FAILURE_LINE_PREFIX} ${
|
|
51
|
+
return `${FAILURE_LINE_PREFIX} ${total} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
|
|
45
52
|
+ `They failed open. Details: ${file}`;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
function appendRow(brainHome, row) {
|
|
56
|
+
const file = path.join(brainHome, FAILURE_FILE);
|
|
57
|
+
fs.mkdirSync(brainHome, { recursive: true });
|
|
58
|
+
try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
|
|
59
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Hashes and long numbers vary per row (`workflow-<sha>`, pids, ms, timestamps); short ones (exit codes, counts) are the error. */
|
|
63
|
+
export const normalizeFailure = (text) => String(text || '').replace(/[0-9a-f]{16,}/gi, '*').replace(/\d{5,}/g, 'N').slice(0, 160);
|
|
64
|
+
const windowFile = (brainHome) => path.join(brainHome, WINDOW_FILE);
|
|
65
|
+
/**
|
|
66
|
+
* A window entry must be one this module could have written: a real epoch `at` no later than one window ahead (a
|
|
67
|
+
* Date cannot hold -1e300 or 1e300, and 8.64e15+1 never expires), an integer count ≥ 1, string hook and error.
|
|
68
|
+
* Entries failing that are dropped, the rest kept; any other file shape is an empty window file.
|
|
69
|
+
*/
|
|
70
|
+
const validWindow = (w, now) => w && typeof w === 'object' && !Array.isArray(w) && Number.isFinite(w.at) && w.at >= 0 && w.at <= now + REPEAT_WINDOW_MS
|
|
71
|
+
&& Number.isInteger(w.count) && w.count >= 1 && typeof w.hook === 'string' && typeof w.error === 'string';
|
|
72
|
+
function loadWindows(brainHome, now) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(windowFile(brainHome), 'utf8'));
|
|
75
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
76
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, w]) => validWindow(w, now)));
|
|
77
|
+
} catch { return {}; }
|
|
78
|
+
}
|
|
79
|
+
function saveWindows(brainHome, windows) { // rename, so a concurrent reader never sees a torn file
|
|
80
|
+
const tmp = `${windowFile(brainHome)}.${process.pid}`;
|
|
81
|
+
fs.writeFileSync(tmp, JSON.stringify(windows), { mode: 0o600 }); fs.renameSync(tmp, windowFile(brainHome));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A closed window whose repeats were held back becomes ONE row carrying their count. */
|
|
85
|
+
function closeWindow(brainHome, w, now) {
|
|
86
|
+
if (w.count > 1) appendRow(brainHome, { at: new Date(now).toISOString(), hook: w.hook, event: w.event, error: `${w.error} (repeated)`, count: w.count - 1, since: new Date(w.at).toISOString() });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Close the expired windows; `all` closes the open ones too (SessionStart reports now). Returns the windows left open. */
|
|
90
|
+
function flushRepeats(brainHome, now, { all = false } = {}) {
|
|
91
|
+
const windows = loadWindows(brainHome, now);
|
|
92
|
+
let changed = false;
|
|
93
|
+
for (const [sig, w] of Object.entries(windows)) {
|
|
94
|
+
if (!all && now - w.at < REPEAT_WINDOW_MS) continue;
|
|
95
|
+
try { closeWindow(brainHome, w, now); } catch { /* one bad window must not stop the others being reported */ }
|
|
96
|
+
delete windows[sig]; changed = true;
|
|
97
|
+
}
|
|
98
|
+
if (changed) try { saveWindows(brainHome, windows); } catch { /* the rows are written; the window file is a cache */ }
|
|
99
|
+
return windows;
|
|
100
|
+
}
|
|
101
|
+
|
|
48
102
|
/**
|
|
49
103
|
* The writer for bodies that run OUTSIDE the shim's watchdog (a refused `ruflo memory store`, a failed
|
|
50
104
|
* exact readback). Same file, same row shape as hook-shim.mjs's inlined recordFailure, so the next
|
|
51
105
|
* SessionStart's failureLine() reports it. Never throws: an unrecordable failure must not become a second one.
|
|
106
|
+
*
|
|
107
|
+
* REPEATS ARE COUNTED, NOT APPENDED (measured 2026-10-21: 1,162 rows, 826 of them one learn-capture failure
|
|
108
|
+
* repeated, so the real failures were invisible). The first occurrence of (hook, event, normalized error)
|
|
109
|
+
* is a row at once; repeats inside REPEAT_WINDOW_MS only increment a count in hook-failures.window.json,
|
|
110
|
+
* and the window closes as ONE row carrying `count`. Distinct errors keep distinct rows. The window file has no
|
|
111
|
+
* lock: two writers racing can lose an increment (under-count), and two processes closing the same expired window
|
|
112
|
+
* can both write its count row (over-count). Tolerated: the ledger is a signal, never an audit. The first row is
|
|
113
|
+
* appended BEFORE its window is saved, so a failed append never leaves a window claiming a row that is not there.
|
|
52
114
|
*/
|
|
53
|
-
export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain') } = {}) {
|
|
115
|
+
export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), now = Date.now() } = {}) {
|
|
54
116
|
try {
|
|
55
|
-
const file = path.join(brainHome, FAILURE_FILE);
|
|
56
117
|
fs.mkdirSync(brainHome, { recursive: true });
|
|
57
|
-
|
|
58
|
-
|
|
118
|
+
const windows = flushRepeats(brainHome, now);
|
|
119
|
+
const error = normalizeFailure(fields.error ?? fields.note ?? (fields.signal || `exit ${fields.status}`));
|
|
120
|
+
const sig = createHash('sha256').update(`${fields.hook}\0${fields.event || ''}\0${error}`).digest('hex').slice(0, 16);
|
|
121
|
+
if (windows[sig]) { windows[sig].count += 1; saveWindows(brainHome, windows); return true; }
|
|
122
|
+
appendRow(brainHome, { at: new Date(now).toISOString(), ...fields });
|
|
123
|
+
windows[sig] = { at: now, count: 1, hook: fields.hook, event: fields.event || '', error };
|
|
124
|
+
const oldest = Object.keys(windows).sort((a, b) => windows[a].at - windows[b].at).slice(0, Math.max(0, Object.keys(windows).length - MAX_WINDOWS));
|
|
125
|
+
for (const old of oldest) { closeWindow(brainHome, windows[old], now); delete windows[old]; } // bounded: the oldest close early
|
|
126
|
+
try { saveWindows(brainHome, windows); } catch { /* the row is on disk; the next repeat starts a fresh window */ }
|
|
59
127
|
return true;
|
|
60
128
|
} catch { return false; }
|
|
61
129
|
}
|
|
@@ -1,14 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
4
5
|
import { spawn } from 'node:child_process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { readStdinBounded } from './hook-input.mjs';
|
|
7
8
|
import { learningContext } from './runtime-preferences.mjs';
|
|
8
|
-
import { recordLearningObservation, distillObservations } from './learning-store.mjs';
|
|
9
|
+
import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
|
|
9
10
|
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
10
11
|
import { recordHookFailure } from './hook-failures.mjs';
|
|
11
12
|
|
|
13
|
+
// LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
|
|
14
|
+
// children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
|
|
15
|
+
// no local queue, no replay — an observation that finds no slot is DROPPED and counted.
|
|
16
|
+
export const OBSERVE_SLOTS = 2; // detached --observe children alive at once, per Brain home
|
|
17
|
+
export const SLOT_STALE_MS = 60_000; // a slot older than this, or held by a dead pid, is reclaimed
|
|
18
|
+
export const COALESCE_MS = 10 * 60_000; // an identical observation key inside this window does not spawn again
|
|
19
|
+
const defaultBrainHome = (env) => env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
20
|
+
const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-capture', sub); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; };
|
|
21
|
+
|
|
22
|
+
/** Parent-side coalescing: marker file per key under `learn-capture/recent/`, pruned when it grows past 256 entries. */
|
|
23
|
+
export function recentlyCaptured(brainHome, key, now = Date.now()) {
|
|
24
|
+
try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
export function markCaptured(brainHome, key, now = Date.now()) {
|
|
27
|
+
const dir = learnDir(brainHome, 'recent');
|
|
28
|
+
try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
|
|
29
|
+
const names = fs.readdirSync(dir);
|
|
30
|
+
if (names.length > 256) for (const name of names) { try { if (now - fs.statSync(path.join(dir, name)).mtimeMs >= COALESCE_MS) fs.unlinkSync(path.join(dir, name)); } catch { /* raced */ } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function slotStale(slot, now) {
|
|
34
|
+
let held;
|
|
35
|
+
try { held = JSON.parse(fs.readFileSync(slot, 'utf8')); } catch { try { return now - fs.statSync(slot).mtimeMs > SLOT_STALE_MS; } catch { return false; } }
|
|
36
|
+
if (!Number.isInteger(held?.pid) || now - Number(held.at) > SLOT_STALE_MS) return true;
|
|
37
|
+
try { process.kill(held.pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; }
|
|
38
|
+
}
|
|
39
|
+
/** O_EXCL slot files `learn-capture/slots/slot-N`; a stale one is reclaimed by rename, so only one claimant wins it. */
|
|
40
|
+
export function acquireObserveSlot(brainHome, now = Date.now()) {
|
|
41
|
+
const dir = learnDir(brainHome, 'slots');
|
|
42
|
+
for (let i = 0; i < OBSERVE_SLOTS; i += 1) {
|
|
43
|
+
const slot = path.join(dir, `slot-${i}`);
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
45
|
+
try { fs.writeFileSync(slot, JSON.stringify({ pid: process.pid, at: now }), { flag: 'wx', mode: 0o600 }); return slot; } catch (error) { if (error.code !== 'EEXIST') break; }
|
|
46
|
+
if (!slotStale(slot, now)) break;
|
|
47
|
+
const claim = `${slot}.reclaim-${process.pid}`;
|
|
48
|
+
try { fs.renameSync(slot, claim); fs.unlinkSync(claim); } catch { break; } // another claimant took it
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export function releaseObserveSlot(slot, pid = process.pid) {
|
|
54
|
+
try { if (JSON.parse(fs.readFileSync(slot, 'utf8')).pid === pid) fs.unlinkSync(slot); } catch { /* reclaimed already */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every shed is one fixed-string ledger call; hook-failures.mjs's own window counts the repeats on one row. */
|
|
58
|
+
export const SHED_ERROR = 'shed observation (load)';
|
|
59
|
+
export function shedObservation(brainHome) {
|
|
60
|
+
recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: SHED_ERROR }, { brainHome });
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
12
64
|
// Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
|
|
13
65
|
const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
|
|
14
66
|
const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
|
|
@@ -30,7 +82,7 @@ export function safeAction(tool, value) {
|
|
|
30
82
|
* session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
|
|
31
83
|
* unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
|
|
32
84
|
*/
|
|
33
|
-
export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
|
|
85
|
+
export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome = defaultBrainHome(env), detach = false } = {}) {
|
|
34
86
|
let tool; let action;
|
|
35
87
|
try {
|
|
36
88
|
const context = learningContext();
|
|
@@ -48,24 +100,37 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
|
|
|
48
100
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
|
|
49
101
|
action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
|
|
50
102
|
if (!action) return false;
|
|
51
|
-
if (!binary) throw new Error('ruflo not found');
|
|
103
|
+
if (!binary) throw new Error('ruflo not found'); // one ledger row per 10 min: recordHookFailure counts the repeats
|
|
52
104
|
const observation = { tool, action, session: String(input.session_id || '') };
|
|
105
|
+
const key = observationKey(observation);
|
|
106
|
+
if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
|
|
53
107
|
// Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
|
|
54
108
|
// tool call returns at once; that child writes the ledger row if the write is refused or unverified.
|
|
55
|
-
if (detach)
|
|
109
|
+
if (detach) {
|
|
110
|
+
const slot = acquireObserveSlot(brainHome);
|
|
111
|
+
if (!slot) return shedObservation(brainHome);
|
|
112
|
+
markCaptured(brainHome, key);
|
|
113
|
+
return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
|
|
114
|
+
}
|
|
56
115
|
recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
|
|
116
|
+
markCaptured(brainHome, key);
|
|
57
117
|
return true;
|
|
58
118
|
} catch (error) {
|
|
59
|
-
if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) },
|
|
119
|
+
if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, { brainHome });
|
|
60
120
|
return false;
|
|
61
121
|
}
|
|
62
122
|
}
|
|
63
123
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields
|
|
126
|
+
* cross. The parent's resolved ruflo travels as RUFLO_BIN (resolveRuflo's authoritative seam), so the child never
|
|
127
|
+
* re-resolves it; the slot it holds travels as RUVNET_LEARN_SLOT and is re-stamped with the child's pid.
|
|
128
|
+
*/
|
|
129
|
+
export function detachLearning(mode, { env = process.env, brainHome, observation, slot } = {}) {
|
|
66
130
|
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), mode], { cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true,
|
|
67
|
-
env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}) } });
|
|
131
|
+
env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(slot ? { RUVNET_LEARN_SLOT: slot } : {}) } });
|
|
68
132
|
child.unref();
|
|
133
|
+
if (slot) { try { fs.writeFileSync(slot, JSON.stringify({ pid: child.pid, at: Date.now() }), { mode: 0o600 }); } catch { /* reclaimed by age */ } }
|
|
69
134
|
return true;
|
|
70
135
|
}
|
|
71
136
|
|
|
@@ -80,7 +145,7 @@ function runDetached(mode) {
|
|
|
80
145
|
const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
|
|
81
146
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
|
|
82
147
|
recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
|
|
83
|
-
} catch (error) { fail(error); }
|
|
148
|
+
} catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
|
|
84
149
|
}
|
|
85
150
|
|
|
86
151
|
const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
|
|
@@ -42,22 +42,32 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
|
|
|
42
42
|
* No local queue, no worker, no replay (owner ruling 2026-10-12): a failure throws to the caller, which
|
|
43
43
|
* records it in the hook-failures ledger so SessionStart reports it.
|
|
44
44
|
*/
|
|
45
|
+
// DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
|
|
46
|
+
// session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
|
|
47
|
+
export const observationKey = ({ session = '', tool, action }) => 'workflow-' + createHash('sha256').update(JSON.stringify([String(session), tool, action])).digest('hex');
|
|
48
|
+
|
|
45
49
|
export function recordLearningObservation(binary, context, { tool, action, session = '' }, options = {}) {
|
|
46
50
|
const db = learningTarget(context, options);
|
|
47
|
-
|
|
48
|
-
// session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
|
|
49
|
-
const key = 'workflow-' + createHash('sha256').update(JSON.stringify([session, tool, action])).digest('hex');
|
|
51
|
+
const key = observationKey({ session, tool, action });
|
|
50
52
|
const value = JSON.stringify({ schemaVersion: 1, tool, action, scope: context.scope,
|
|
51
53
|
authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
|
|
52
54
|
const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
|
|
53
55
|
const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
|
|
54
56
|
'--no-upsert', '--provenance', 'system_observation', '--path', db]);
|
|
55
57
|
const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
+
const readBack = !retrieved.error && retrieved.status === 0 ? String(retrieved.stdout || '').trim() : null;
|
|
59
|
+
const exact = readBack === value;
|
|
60
|
+
// A refused store whose key already holds these exact bytes is the dedupe working, not a failure (idempotent).
|
|
61
|
+
// Measured 2026-10-21: 141 ledger rows said "already exists" because the readback itself had timed out, so the
|
|
62
|
+
// row blamed the store; the reason names which step failed, and a key holding DIFFERENT bytes stays a failure.
|
|
58
63
|
if (stored.error || stored.status !== 0) {
|
|
59
64
|
if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
|
|
60
|
-
|
|
65
|
+
const reason = captureFailureReason(stored, stored.status);
|
|
66
|
+
if (/already exists/i.test(reason)) {
|
|
67
|
+
if (readBack !== null) throw new Error('duplicate key holds a different observation');
|
|
68
|
+
throw new Error(`exact readback of the existing key failed: ${captureFailureReason(retrieved, retrieved.status)}`);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`ruflo memory store refused the observation: ${reason}`);
|
|
61
71
|
}
|
|
62
72
|
if (!exact) throw new Error('canonical exact CLI readback failed');
|
|
63
73
|
return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
|
|
@@ -147,17 +147,23 @@ const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-
|
|
|
147
147
|
const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
|
|
148
148
|
const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
|
|
149
149
|
const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
|
|
150
|
-
|
|
150
|
+
/** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
|
|
151
|
+
function readModuleSet(sourceDir, developerSuite) {
|
|
151
152
|
const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
|
|
152
153
|
const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
|
|
153
|
-
|
|
154
|
+
return { files, bytes, digest: sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]]))) };
|
|
155
|
+
}
|
|
156
|
+
const registeredModuleDigest = (record) => /^update-modules-([a-f0-9]{64})$/.exec(path.basename(path.dirname(record.updateModules?.['automatic-update.mjs']?.path || '')))?.[1] || null;
|
|
157
|
+
|
|
158
|
+
function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true, { write = true } = {}) {
|
|
159
|
+
const { files, bytes, digest } = readModuleSet(sourceDir, developerSuite);
|
|
154
160
|
const target = path.join(directory, `update-modules-${digest}`);
|
|
155
|
-
fs.mkdirSync(target, { recursive: true });
|
|
161
|
+
if (write) fs.mkdirSync(target, { recursive: true });
|
|
156
162
|
return Object.fromEntries(files.map(file => {
|
|
157
163
|
const dest = path.join(target, file);
|
|
158
164
|
const hash = sha256(bytes[file]);
|
|
159
|
-
if (fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
|
|
160
|
-
if (!fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
|
|
165
|
+
if (write && fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
|
|
166
|
+
if (write && !fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
|
|
161
167
|
return [file, { path: dest, sha256: hash }];
|
|
162
168
|
}));
|
|
163
169
|
}
|
|
@@ -174,31 +180,80 @@ function verifyUpdateModules(modules) {
|
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The ONE writer of a nightly registration. `dryRun` builds the exact record it WOULD write (runner, modules, record)
|
|
185
|
+
* and writes nothing — the refresh path compares that candidate before any rename. `environment`: undefined derives
|
|
186
|
+
* the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
|
|
187
|
+
*/
|
|
177
188
|
export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
|
|
178
|
-
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
|
|
189
|
+
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
|
|
179
190
|
validateIdentity(identity);
|
|
180
191
|
if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
|
|
181
192
|
const bytes = fs.readFileSync(source);
|
|
182
193
|
const digest = sha256(bytes);
|
|
183
194
|
const dir = path.join(brainHome, 'scheduler');
|
|
184
195
|
const runnerPath = path.join(dir, `nightly-refresh-${digest}.mjs`);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
if (!dryRun) {
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
|
|
199
|
+
throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
|
|
200
|
+
}
|
|
201
|
+
if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
|
|
188
202
|
}
|
|
189
|
-
|
|
190
|
-
const modules = updateModules(dir, moduleSource, developerSuite);
|
|
203
|
+
const modules = updateModules(dir, moduleSource, developerSuite, { write: !dryRun });
|
|
191
204
|
const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
192
205
|
nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
|
|
193
|
-
environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
|
|
206
|
+
...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
|
|
194
207
|
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
|
|
195
208
|
const recordPath = path.join(dir, registrationName(identity));
|
|
209
|
+
if (dryRun) return { ...record, recordPath };
|
|
196
210
|
const tmp = `${recordPath}.tmp-${process.pid}`;
|
|
197
211
|
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
|
|
198
212
|
fs.renameSync(tmp, recordPath);
|
|
199
213
|
return { ...record, recordPath };
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
export const NIGHTLY_ENABLE_FIX = 'npx ruvnet-brain@latest --enable-nightly';
|
|
217
|
+
export const NIGHTLY_UPDATE_FIX = 'npx ruvnet-brain@latest --update';
|
|
218
|
+
/**
|
|
219
|
+
* Does the registered nightly run the code installed now? The scheduler executes a content-addressed snapshot of
|
|
220
|
+
* the update modules that only --enable-nightly used to refresh (measured 2026-10-22: the 4.7.15 developer-update
|
|
221
|
+
* fix shipped to disk and the 03:30 job still ran the old module). Pure read: `current`, `stale` (modules only;
|
|
222
|
+
* --update refreshes them), `runner-changed` (the LaunchAgent command would change: explicit enablement only),
|
|
223
|
+
* or `unregistered`.
|
|
224
|
+
*/
|
|
225
|
+
export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, moduleSource = path.dirname(fileURLToPath(import.meta.url)), runnerSource } = {}) {
|
|
226
|
+
const registration = readNightlyRegistration({ brainHome, identity });
|
|
227
|
+
if (!registration.ok) return { state: 'unregistered', why: registration.why };
|
|
228
|
+
const record = registration.record;
|
|
229
|
+
const registered = registeredModuleDigest(record);
|
|
230
|
+
if (!registered) return { state: 'unregistered', why: 'registration carries no module snapshot', fix: NIGHTLY_ENABLE_FIX };
|
|
231
|
+
const current = readModuleSet(moduleSource, record.mode === 'developer-suite').digest;
|
|
232
|
+
if (runnerSource && sha256(fs.readFileSync(runnerSource)) !== record.runnerSha256) return { state: 'runner-changed', registered, current, record, fix: NIGHTLY_ENABLE_FIX };
|
|
233
|
+
return registered === current ? { state: 'current', registered, current, record } : { state: 'stale', registered, current, record, fix: NIGHTLY_UPDATE_FIX };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* After a successful --update: re-point a stale registration at the installed module set. Same identity, node,
|
|
238
|
+
* runner bytes, environment and targets, so only registration.json and a new update-modules-<digest> dir change
|
|
239
|
+
* and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
|
|
240
|
+
*/
|
|
241
|
+
export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
|
|
242
|
+
export function refreshNightlyModules(options = {}) {
|
|
243
|
+
const currency = nightlyModuleCurrency(options);
|
|
244
|
+
if (currency.state !== 'stale') return currency;
|
|
245
|
+
const { record } = currency;
|
|
246
|
+
const plan = { brainHome: options.brainHome, identity: record.identity, source: record.runnerPath, nodePath: record.nodePath, packageTarget: record.packageTarget,
|
|
247
|
+
bundleTarget: record.bundleTarget, environment: record.environment ?? null, moduleSource: options.moduleSource, developerSuite: record.mode === 'developer-suite' };
|
|
248
|
+
// Compare the candidate BEFORE anything is written (reviewer proof 2026-10-22: an older registration without
|
|
249
|
+
// `environment` was rewritten with {} and only then refused). `build` is the dry-run of the one writer.
|
|
250
|
+
const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
|
|
251
|
+
const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
|
|
252
|
+
if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
|
|
253
|
+
const refreshed = installNightlyRunner(plan);
|
|
254
|
+
return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
|
|
255
|
+
}
|
|
256
|
+
|
|
202
257
|
export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
|
|
203
258
|
recordPath = path.join(brainHome, 'scheduler', registrationName(identity)) }) {
|
|
204
259
|
try {
|
|
@@ -239,20 +239,18 @@ export function launchDetached(steps, { receipts, brainHome, projectDir, env = p
|
|
|
239
239
|
return { launched: true, pid: child.pid };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
const readTurnState = (stateFile) => { try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; } };
|
|
243
|
+
|
|
244
|
+
/** The newest store receipt for this exact key, while the receipt tail still holds it. */
|
|
245
|
+
const storeReceipt = (receipts, key) => { try { return readTail(receipts, 128 * 1024).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } }).findLast((r) => r.kind === 'store' && r.key === key) || null; } catch { return null; } };
|
|
246
|
+
const verifiedReceipt = (r) => r?.status === 0 && r.verified === true;
|
|
245
247
|
|
|
246
248
|
function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
|
|
247
249
|
const previous = readTurnState(stateFile)[sessionKey];
|
|
248
250
|
if (previous?.fingerprint !== fingerprint) return false;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (receipt) return receipt.status === 0 && receipt.verified === true;
|
|
253
|
-
} catch { /* queued worker has no receipt yet */ }
|
|
254
|
-
// An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
255
|
-
return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
251
|
+
const receipt = storeReceipt(receipts, previous.key);
|
|
252
|
+
// No receipt yet: an in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
253
|
+
return receipt ? verifiedReceipt(receipt) : Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
256
254
|
}
|
|
257
255
|
|
|
258
256
|
function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
@@ -266,9 +264,8 @@ function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
|
266
264
|
}
|
|
267
265
|
|
|
268
266
|
/**
|
|
269
|
-
* Capture this turn's outcome (Stop) and/or
|
|
270
|
-
*
|
|
271
|
-
* `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
|
|
267
|
+
* Capture this turn's outcome (Stop) and/or start distillation (SessionEnd, PreCompact). Returns a plain report:
|
|
268
|
+
* `queued` means a request, never proof the store committed; `skipped` / `distill.skipped` carry the reason.
|
|
272
269
|
*/
|
|
273
270
|
export function captureTurnOutcome({
|
|
274
271
|
projectDir, event, payload = {}, host = 'claude',
|
|
@@ -322,10 +319,12 @@ export function captureTurnOutcome({
|
|
|
322
319
|
const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
|
|
323
320
|
const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
|
|
324
321
|
const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
|
|
322
|
+
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
325
323
|
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
324
|
+
// Proven at Stop already: SessionEnd/PreCompact must not re-send it (2026-10-19/20: each re-send was an "append rejected" row).
|
|
325
|
+
else if (verifiedReceipt(storeReceipt(receipts, key))) report.skipped = 'turn key already verified at an earlier boundary';
|
|
326
326
|
else {
|
|
327
327
|
const at = now();
|
|
328
|
-
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
329
328
|
dedupe = { stateFile, identity, fingerprint, key };
|
|
330
329
|
const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
331
330
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE,
|
|
@@ -444,13 +443,14 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
|
|
|
444
443
|
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
445
444
|
const r = run(invocation.executable, invocation.args, options);
|
|
446
445
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
447
|
-
|
|
448
|
-
if (verified) status = 0;
|
|
446
|
+
const duplicate = kind === 'store' && status !== 0 && /already exists/i.test(captureFailureReason(r, status)); // append-only refusal
|
|
447
|
+
if (kind === 'store' && (status === 0 || duplicate)) { verified = read({ ruflo, db, key, run, options }) === value; if (verified) status = 0; } // identical bytes = success
|
|
449
448
|
if (kind === 'distill' && status === 0) {
|
|
450
449
|
const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
451
450
|
if (match) skipped = redactText(match[1]).slice(0, 300);
|
|
452
451
|
}
|
|
453
|
-
if (!verified &&
|
|
452
|
+
if (!verified && duplicate) error = 'duplicate turn key holds different content';
|
|
453
|
+
else if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
|
|
454
454
|
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
455
455
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
456
456
|
if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
|
|
@@ -400,6 +400,31 @@ export function failedStage(run) {
|
|
|
400
400
|
return stage ? STAGE_NAMES[stage] || stage : `an unnamed job (conclusion ${run?.conclusion || 'unknown'})`;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
/** A night's scheduled dispatches (the 07:36Z run and its retry slot) sit within this span; a day earlier is last night. */
|
|
404
|
+
const NIGHT_SPAN_MS = 12 * HOUR;
|
|
405
|
+
/**
|
|
406
|
+
* How many protected-release corpus runs belong to THIS night. Measured 2026-10-22: a manual workflow_dispatch proving
|
|
407
|
+
* run at 10:36Z Oct 21 and a stood-down retry slot at 11:25Z Oct 21 made ONE scheduled night read "attempt 2; 1 retried".
|
|
408
|
+
* The night is anchored on the newest `schedule` dispatcher that dispatched a run, plus scheduled dispatchers within
|
|
409
|
+
* NIGHT_SPAN_MS before it; a run counts only from that start and never when its dispatcher was a manual
|
|
410
|
+
* workflow_dispatch. A stood-down retry slot created no run, so it never counts. `runs` is newest-first, in window.
|
|
411
|
+
*/
|
|
412
|
+
export function nightAttempts(runs, dispatchers) {
|
|
413
|
+
const dispatchKey = (d) => `corpus-${d.id}-${d.attempt ?? 1}`;
|
|
414
|
+
const runKey = (run) => CORPUS_RUN_TITLE.exec(String(run.title || ''))?.[1];
|
|
415
|
+
const byKey = new Map(dispatchers.map((d) => [dispatchKey(d), d]));
|
|
416
|
+
const manual = (run) => byKey.get(runKey(run))?.event === 'workflow_dispatch';
|
|
417
|
+
const [newest] = runs;
|
|
418
|
+
if (manual(newest)) return ' (1 attempt; manual dispatch)';
|
|
419
|
+
const scheduled = dispatchers.filter((d) => !d.event || d.event === 'schedule'); // an undated event is read as the schedule
|
|
420
|
+
const anchor = scheduled.find((d) => runs.some((run) => runKey(run) === dispatchKey(d)));
|
|
421
|
+
const nightStart = anchor
|
|
422
|
+
? Math.min(...scheduled.filter((d) => ms(d.createdAt) <= ms(anchor.createdAt) && ms(anchor.createdAt) - ms(d.createdAt) <= NIGHT_SPAN_MS).map((d) => ms(d.createdAt)))
|
|
423
|
+
: ms(newest.createdAt);
|
|
424
|
+
const count = runs.filter((run) => ms(run.createdAt) >= nightStart && !manual(run)).length || 1;
|
|
425
|
+
return count > 1 ? ` (attempt ${count} of the night; ${count - 1} retried)` : ' (1 attempt)';
|
|
426
|
+
}
|
|
427
|
+
|
|
403
428
|
/**
|
|
404
429
|
* THE ONE LINE the owner reads first: what the night did, result first, in Eastern time. `input` is
|
|
405
430
|
* gatherCorpusHealthInput's; the night is the newest corpus run of the last 24h and every retry of it.
|
|
@@ -410,9 +435,10 @@ export function summarizeNight(input, now) {
|
|
|
410
435
|
const inWindow = (iso) => { const t = ms(iso); return t !== null && t <= at && at - t <= TONIGHT_WINDOW_MS; };
|
|
411
436
|
const runs = (input?.corpusRuns || []).filter((run) => inWindow(run.createdAt))
|
|
412
437
|
.map((run) => ({ ...run, outcome: run.outcome ?? classifyCorpusRun(run) })).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
|
|
413
|
-
const
|
|
438
|
+
const dispatchers = (input?.dispatcherRuns || []).filter((d) => inWindow(d.createdAt)).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
|
|
439
|
+
const [dispatcher] = dispatchers;
|
|
414
440
|
const latest = input?.latestRelease;
|
|
415
|
-
const attempts = runs.length
|
|
441
|
+
const attempts = runs.length ? nightAttempts(runs, dispatchers) : '';
|
|
416
442
|
const keep = latest ? `customers keep ${latest.tag} (${hours(Math.max(0, at - (ms(latest.generation || latest.publishedAt) ?? at)))} old)` : 'no promoted generation is observed';
|
|
417
443
|
if (!runs.length) {
|
|
418
444
|
if (!dispatcher) return { outcome: 'no-run', line: `night: no corpus run in the last 24h (no dispatcher run either); ${keep}` };
|