ruvnet-brain 4.3.21 → 4.3.26
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 +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +189 -9
- package/console/index.html +70 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/style.css +26 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +364 -28
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +47 -10
- package/scripts/release-transaction.mjs +40 -11
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
validateProgressionSnapshot,
|
|
8
8
|
} from './project-progression-contract.mjs';
|
|
9
9
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
10
|
+
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
10
11
|
import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
|
|
11
12
|
|
|
12
13
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
@@ -75,15 +76,29 @@ export class ProjectProgressionStore {
|
|
|
75
76
|
runner = defaultRunner,
|
|
76
77
|
clock = () => new Date().toISOString(),
|
|
77
78
|
fsync,
|
|
79
|
+
// The read-only fast path (project-progression-reader.mjs). READS ONLY: `ruflo memory store`
|
|
80
|
+
// stays the sole writer of memory.db. Pass `reader: null` to force every read through the CLI.
|
|
81
|
+
reader = withProgressionReader,
|
|
78
82
|
} = {}) {
|
|
79
83
|
if (!rufloBinary) throw new Error(RUFLO_MISSING);
|
|
80
84
|
this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
|
|
81
85
|
this.rufloBinary = rufloBinary;
|
|
82
86
|
this.runner = runner;
|
|
83
87
|
this.clock = clock;
|
|
88
|
+
this.reader = typeof reader === 'function' ? reader : null;
|
|
89
|
+
this.lastReadPath = null;
|
|
84
90
|
this.outbox = new ProgressionOutbox({ projectRoot: this.resolution.projectRoot, fsync });
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Run one read through the in-process reader, or report that the CLI must serve it.
|
|
95
|
+
* Structural errors propagate unchanged — only "I cannot answer authoritatively" falls back.
|
|
96
|
+
*/
|
|
97
|
+
readFast(work) {
|
|
98
|
+
if (!this.reader) return { ok: false, reason: 'reader disabled' };
|
|
99
|
+
return this.reader(this.resolution.canonicalAgentDbPath, work);
|
|
100
|
+
}
|
|
101
|
+
|
|
87
102
|
run(args) {
|
|
88
103
|
return this.runner(this.rufloBinary, args, {
|
|
89
104
|
cwd: path.dirname(this.resolution.canonicalAgentDbPath),
|
|
@@ -110,18 +125,36 @@ export class ProjectProgressionStore {
|
|
|
110
125
|
const alreadyStored = resultStatus(stored) !== 0;
|
|
111
126
|
if (!alreadyStored) onPhase('stored');
|
|
112
127
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
// THE READ-BACK, through the fast path when it is available.
|
|
129
|
+
//
|
|
130
|
+
// This is not a shortcut past the verification — it IS the verification, taken by an independent
|
|
131
|
+
// route. Reading the row back with the same CLI process family that just wrote it proves the CLI
|
|
132
|
+
// agrees with itself; reading the bytes off disk with node:sqlite proves the row is really there.
|
|
133
|
+
// It also matters for the budget: a capture boundary gets 8s, and a cold `ruflo memory` call
|
|
134
|
+
// measured ~3s in an isolated HOME (matching the 3.0-3.4s figure from the original report), so
|
|
135
|
+
// replay + capture at two CLI calls each did not fit and left the new snapshot uncommitted in the
|
|
136
|
+
// outbox. One CLI write plus a ~1ms read fits with room to spare. The CLI remains the fallback.
|
|
137
|
+
let readbackText = null;
|
|
138
|
+
const fast = this.readFast((reader) => reader.readContent(PROGRESSION_NAMESPACE, snapshot.eventKey));
|
|
139
|
+
if (fast.ok && typeof fast.value === 'string') {
|
|
140
|
+
this.lastReadPath = 'node:sqlite';
|
|
141
|
+
readbackText = fast.value;
|
|
142
|
+
} else {
|
|
143
|
+
this.lastReadPath = `ruflo-cli (${fast.ok ? 'row absent' : fast.reason})`;
|
|
144
|
+
const retrieved = this.run([
|
|
145
|
+
'memory', 'retrieve', '--key', snapshot.eventKey, '--namespace', PROGRESSION_NAMESPACE,
|
|
146
|
+
'--value-only', '--path', this.resolution.canonicalAgentDbPath,
|
|
147
|
+
]);
|
|
148
|
+
if (resultStatus(retrieved) !== 0) {
|
|
149
|
+
const storeFailure = alreadyStored
|
|
150
|
+
? `; store failed: ${resultText(stored, 'stderr').trim() || 'unknown error'}`
|
|
151
|
+
: '';
|
|
152
|
+
throw new Error(`progression readback failed: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}${storeFailure}`);
|
|
153
|
+
}
|
|
154
|
+
readbackText = resultText(retrieved, 'stdout');
|
|
122
155
|
}
|
|
123
156
|
let readback;
|
|
124
|
-
try { readback = JSON.parse(
|
|
157
|
+
try { readback = JSON.parse(readbackText); } catch { throw new Error('progression readback is not JSON'); }
|
|
125
158
|
if (readback.payloadDigest !== snapshot.payloadDigest || digestCanonical(readback) !== digestCanonical(snapshot)) {
|
|
126
159
|
throw new Error('progression readback digest mismatch');
|
|
127
160
|
}
|
|
@@ -158,6 +191,16 @@ export class ProjectProgressionStore {
|
|
|
158
191
|
requirePositiveInteger(pageSize, 'pageSize');
|
|
159
192
|
requirePositiveInteger(maxEntries, 'maxEntries');
|
|
160
193
|
if (pageSize > maxEntries) throw new Error('pageSize exceeds the enumeration bound');
|
|
194
|
+
const fast = this.readFast((reader) => reader.listKeys(PROGRESSION_NAMESPACE, { maxEntries }));
|
|
195
|
+
if (fast.ok) {
|
|
196
|
+
this.lastReadPath = 'node:sqlite';
|
|
197
|
+
return fast.value;
|
|
198
|
+
}
|
|
199
|
+
this.lastReadPath = `ruflo-cli (${fast.reason})`;
|
|
200
|
+
return this.listSnapshotKeysViaCli({ pageSize, maxEntries });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
listSnapshotKeysViaCli({ pageSize = 100, maxEntries = 10_000 } = {}) {
|
|
161
204
|
const keys = [];
|
|
162
205
|
const seen = new Set();
|
|
163
206
|
let offset = 0;
|
|
@@ -214,6 +257,36 @@ export class ProjectProgressionStore {
|
|
|
214
257
|
}
|
|
215
258
|
|
|
216
259
|
retrieveSnapshots(keys) {
|
|
260
|
+
// The whole batch through ONE read-only handle, or the whole batch through the CLI. Never a
|
|
261
|
+
// mixture: a half-served batch would make "exactly these rows, read exactly this way" untrue.
|
|
262
|
+
const fast = this.readFast((reader) => {
|
|
263
|
+
const snapshots = [];
|
|
264
|
+
const rejected = [];
|
|
265
|
+
for (const key of keys) {
|
|
266
|
+
const content = reader.readContent(PROGRESSION_NAMESPACE, key);
|
|
267
|
+
if (content === null) throw new Error(`progression exact retrieval failed for ${key}: row not found`);
|
|
268
|
+
let snapshot;
|
|
269
|
+
try { snapshot = JSON.parse(content); } catch {
|
|
270
|
+
rejected.push({ eventKey: key, reasons: ['readback is not JSON'] });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (!plainRecord(snapshot) || snapshot.eventKey !== key) {
|
|
274
|
+
rejected.push({ eventKey: key, reasons: ['exact key/payload identity mismatch'] });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
snapshots.push(snapshot);
|
|
278
|
+
}
|
|
279
|
+
return { snapshots, rejected: sortRejected(rejected) };
|
|
280
|
+
});
|
|
281
|
+
if (fast.ok) {
|
|
282
|
+
this.lastReadPath = 'node:sqlite';
|
|
283
|
+
return fast.value;
|
|
284
|
+
}
|
|
285
|
+
this.lastReadPath = `ruflo-cli (${fast.reason})`;
|
|
286
|
+
return this.retrieveSnapshotsViaCli(keys);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
retrieveSnapshotsViaCli(keys) {
|
|
217
290
|
const snapshots = [];
|
|
218
291
|
const rejected = [];
|
|
219
292
|
for (const key of keys) {
|
|
@@ -238,9 +311,25 @@ export class ProjectProgressionStore {
|
|
|
238
311
|
return { snapshots, rejected: sortRejected(rejected) };
|
|
239
312
|
}
|
|
240
313
|
|
|
241
|
-
|
|
314
|
+
/** How many durable snapshots are fsynced but not yet committed to the canonical store. */
|
|
315
|
+
pendingReplayCount() {
|
|
316
|
+
try { return this.outbox.pendingSnapshots().length; } catch { return null; }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {{ replayPending?: boolean }} options
|
|
321
|
+
* `replayPending: false` restores from COMMITTED rows only. SessionStart uses it because replay
|
|
322
|
+
* is a WRITE, a write is a `ruflo memory store` process, and one of those alone costs more than
|
|
323
|
+
* the entire SessionStart budget — so a restore that replayed would time out and report UNKNOWN
|
|
324
|
+
* precisely when there was durable evidence to show. Pending work is REPORTED here and replayed
|
|
325
|
+
* at the next capture boundary (Stop / PreCompact / SessionEnd) or by /checkpoint, which are the
|
|
326
|
+
* boundaries that already own a write budget. The outbox's fsync-then-commit ordering and its
|
|
327
|
+
* replay-required semantics are untouched: nothing is dropped, only deferred.
|
|
328
|
+
*/
|
|
329
|
+
restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024, replayPending = true } = {}) {
|
|
242
330
|
requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
|
|
243
|
-
this.replay();
|
|
331
|
+
if (replayPending) this.replay();
|
|
332
|
+
const pendingReplay = replayPending ? 0 : this.pendingReplayCount();
|
|
244
333
|
const keys = this.listSnapshotKeys({ pageSize, maxEntries });
|
|
245
334
|
const exact = this.retrieveSnapshots(keys);
|
|
246
335
|
const restored = restoreProjectProgression(exact.snapshots, {
|
|
@@ -253,6 +342,8 @@ export class ProjectProgressionStore {
|
|
|
253
342
|
if (!restored.ok) {
|
|
254
343
|
const error = new Error('no coherent progression state could be restored');
|
|
255
344
|
error.rejectedCandidates = rejectedCandidates;
|
|
345
|
+
error.structurallyEnumerated = keys.length;
|
|
346
|
+
error.pendingReplay = pendingReplay;
|
|
256
347
|
throw error;
|
|
257
348
|
}
|
|
258
349
|
const payload = {
|
|
@@ -266,12 +357,14 @@ export class ProjectProgressionStore {
|
|
|
266
357
|
exactRetrieved: keys.length,
|
|
267
358
|
causallyStale: restored.causallyStale.length,
|
|
268
359
|
rejectedCandidates,
|
|
360
|
+
readPath: this.lastReadPath,
|
|
361
|
+
pendingReplay,
|
|
269
362
|
},
|
|
270
363
|
};
|
|
271
364
|
const rendered = JSON.stringify(payload);
|
|
272
365
|
if (Buffer.byteLength(rendered, 'utf8') > maxOutputBytes) {
|
|
273
366
|
throw new Error(`resume payload exceeds the ${maxOutputBytes}-byte output bound`);
|
|
274
367
|
}
|
|
275
|
-
return { payload, rendered };
|
|
368
|
+
return { payload, rendered, pendingReplay };
|
|
276
369
|
}
|
|
277
370
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ruvnet-gate1-pattern.mjs — the ONE copy of ground-ruvnet.sh's "Gate 1" regex outside that file.
|
|
3
|
+
*
|
|
4
|
+
* ground-ruvnet.sh (POSIX/bash) and grounding-turn-mark.mjs (Node) cannot literally `import` one
|
|
5
|
+
* another's source — one is a shell script, the other is JS. The task this file exists for is
|
|
6
|
+
* explicit: "copy it verbatim ... do not redefine a second, drifting copy ... or keep it
|
|
7
|
+
* byte-identical with a test that fails if they diverge". A shared data file both languages could
|
|
8
|
+
* read was considered and rejected: ground-ruvnet.sh is a hot, heavily-tuned, every-prompt hook
|
|
9
|
+
* (see its own header on the 38s-regression bounded-read fix), and editing it to add a file-read
|
|
10
|
+
* indirection for this one string is exactly the kind of non-surgical touch that risks a working,
|
|
11
|
+
* extensively-measured script for a feature that does not need it to change at all.
|
|
12
|
+
*
|
|
13
|
+
* So the copy lives here, in JS, and tests/unit/ruvnet-gate1-pattern.test.mjs parses
|
|
14
|
+
* ground-ruvnet.sh's own "Gate 1" grep line and asserts this string matches it byte-for-byte. A
|
|
15
|
+
* future edit to either side that is not mirrored in the other goes red immediately, which is the
|
|
16
|
+
* actual guarantee "byte-identical with a test that fails if they diverge" asks for.
|
|
17
|
+
*
|
|
18
|
+
* SOURCE OF TRUTH: plugin/scripts/ground-ruvnet.sh, the line beginning
|
|
19
|
+
* `if printf '%s' "$TEXT" | grep -qiE '...'; then` under the "Gate 1: does the task touch the rUv
|
|
20
|
+
* ecosystem?" comment. Copied 2026-09-12, case-insensitive (`-i`) to match `grep -qiE`.
|
|
21
|
+
*/
|
|
22
|
+
export const RUVNET_GATE1_PATTERN =
|
|
23
|
+
'\\bruvnet\\b|\\bruflo\\b|\\bruvector\\b|\\brvf\\b|\\bagentdb\\b|\\bagenticow\\b|\\brulake\\b|\\bruview\\b|\\brupixel\\b|\\bruv-fann\\b|\\bagentic-flow\\b|\\bsynthlang\\b|\\bdspy\\b|\\bqudag\\b|\\bsafla\\b|\\bmetaharness\\b|\\bcve-bench\\b|\\bsparc\\b|\\bswarms?\\b|\\bclaude-flow\\b|\\brUv\\b';
|
|
24
|
+
|
|
25
|
+
/** Case-insensitive, matching the shell side's `grep -qiE`. A fresh RegExp per call — `.test()` on a
|
|
26
|
+
* shared `g`/`y` instance is stateful and a caller-shared singleton here would be a subtle footgun. */
|
|
27
|
+
export function ruvnetGate1Matches(text) {
|
|
28
|
+
return new RegExp(RUVNET_GATE1_PATTERN, 'i').test(String(text ?? ''));
|
|
29
|
+
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import {
|
|
4
5
|
captureProjectTransition,
|
|
5
6
|
hasProjectProgression,
|
|
6
7
|
} from './project-progression-hook.mjs';
|
|
7
8
|
import { createSessionSnapshot } from './session-snapshot-contract.mjs';
|
|
8
9
|
import { projectDirectory } from './project-identity.mjs';
|
|
10
|
+
import { buildProjectProgression } from './project-progression-producer.mjs';
|
|
11
|
+
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
12
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
|
|
16
|
+
* inside it so the host never has to kill us, and so a slow store degrades to "no snapshot this
|
|
17
|
+
* time" rather than to a hung turn. Capture is advisory: it fails open, always.
|
|
18
|
+
*/
|
|
19
|
+
export const CAPTURE_BUDGET_MS = 8_000;
|
|
9
20
|
|
|
10
21
|
function regularOrAbsent(file) {
|
|
11
22
|
try {
|
|
@@ -43,22 +54,118 @@ export function writeSessionSnapshot(projectDir, event) {
|
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A deadline-bounded `ruflo` runner. The store's own 120s per-call timeout is right for a deliberate
|
|
59
|
+
* CLI invocation and far too generous for a lifecycle hook, so the remaining budget caps every call.
|
|
60
|
+
*/
|
|
61
|
+
function boundedStoreFactory(deadlineAt) {
|
|
62
|
+
return (options) => new ProjectProgressionStore({
|
|
63
|
+
...options,
|
|
64
|
+
runner: (binary, args, runOptions) => {
|
|
65
|
+
const remaining = deadlineAt - Date.now();
|
|
66
|
+
if (remaining < 1) throw new Error('capture budget exceeded');
|
|
67
|
+
const result = spawnSync(binary, args, {
|
|
68
|
+
...runOptions,
|
|
69
|
+
timeout: Math.min(runOptions.timeout ?? remaining, remaining),
|
|
70
|
+
shell: false,
|
|
71
|
+
});
|
|
72
|
+
if (result.error) throw new Error(`capture budget exceeded: ${result.error.message}`);
|
|
73
|
+
return result;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* THE AUTOMATIC CAPTURE BOUNDARY.
|
|
80
|
+
*
|
|
81
|
+
* Before this, `captureProjectTransition` could only run when a host payload already carried a
|
|
82
|
+
* `projectProgression` extension — and no host emits one, so nothing was ever captured. Now the
|
|
83
|
+
* producer BUILDS that extension from real sources (git, the work ledger, the owner's own
|
|
84
|
+
* `project-state-current` note, the prior head, and a bounded transcript reference) whenever the
|
|
85
|
+
* payload does not supply one. An explicitly supplied extension still wins: that is how
|
|
86
|
+
* /ruvnet-brain:checkpoint hands over a state the model actually wrote.
|
|
87
|
+
*
|
|
88
|
+
* Two things are deliberately NOT done here:
|
|
89
|
+
* • `.swarm` is never created. Its absence means the project has not adopted the brain, and a
|
|
90
|
+
* lifecycle hook that plants a store in every repository the user opens is trespass (see above).
|
|
91
|
+
* • No exception escapes. A capture boundary that can fail a turn is worse than a missed snapshot.
|
|
92
|
+
*/
|
|
46
93
|
export function runSessionSnapshotHook(projectDir, event, {
|
|
47
94
|
rawInput = '',
|
|
48
95
|
host = process.env.RUVNET_HOOK_HOST || 'claude',
|
|
49
96
|
captureProgression = captureProjectTransition,
|
|
97
|
+
produce = buildProjectProgression,
|
|
98
|
+
budgetMs = CAPTURE_BUDGET_MS,
|
|
99
|
+
now = Date.now,
|
|
50
100
|
} = {}) {
|
|
51
101
|
const metadataWritten = writeSessionSnapshot(projectDir, event);
|
|
52
102
|
let payload;
|
|
53
103
|
try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
|
|
54
|
-
|
|
55
|
-
|
|
104
|
+
const idle = { metadataWritten, progressionCaptured: false, receipt: null };
|
|
105
|
+
|
|
106
|
+
if (hasProjectProgression(payload)) {
|
|
107
|
+
if (payload.hook_event_name !== event) {
|
|
108
|
+
throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
|
|
109
|
+
}
|
|
110
|
+
const result = captureProgression({ host, payload, projectDir });
|
|
111
|
+
return { ...idle, progressionCaptured: true, receipt: result.receipt };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A payload with no session identity is not a real lifecycle event (an empty `{}` from a probe,
|
|
115
|
+
// a malformed host). Capturing against an invented session id would fabricate a journal entry.
|
|
116
|
+
if (typeof payload.session_id !== 'string' || !payload.session_id) {
|
|
117
|
+
return { ...idle, skipped: 'no session identity in the host payload' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let resolution;
|
|
121
|
+
try { resolution = resolveProjectStore({ projectDir }); } catch {
|
|
122
|
+
return { ...idle, skipped: 'project store could not be resolved' };
|
|
56
123
|
}
|
|
57
|
-
if (
|
|
58
|
-
|
|
124
|
+
if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
|
|
125
|
+
return { ...idle, skipped: 'project has not adopted the canonical store' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const deadlineAt = now() + budgetMs;
|
|
129
|
+
const storeFactory = boundedStoreFactory(deadlineAt);
|
|
130
|
+
|
|
131
|
+
// Commit anything a previously interrupted session left durable-but-uncommitted. SessionStart is
|
|
132
|
+
// forbidden from doing this (ADR-073 §5) because replay is a write; a capture boundary already
|
|
133
|
+
// owns a write budget, so this is where that debt is settled.
|
|
134
|
+
let replayed = 0;
|
|
135
|
+
try {
|
|
136
|
+
replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
|
|
137
|
+
} catch { /* the new capture below is still worth attempting */ }
|
|
138
|
+
|
|
139
|
+
let produced;
|
|
140
|
+
try {
|
|
141
|
+
produced = produce({ resolution, payload, host, trigger: event });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
|
|
144
|
+
}
|
|
145
|
+
if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
|
|
146
|
+
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = captureProgression({
|
|
150
|
+
host,
|
|
151
|
+
payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
|
|
152
|
+
projectDir,
|
|
153
|
+
storeFactory,
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
|
|
157
|
+
// the store, so a budget overrun here leaves the evidence on disk and the next capture boundary
|
|
158
|
+
// (or /checkpoint) commits it. Reporting that plainly is the whole difference between a bounded
|
|
159
|
+
// hook and a lossy one, so the reason is returned rather than thrown at a lifecycle boundary.
|
|
160
|
+
return { ...idle, replayed, skipped: `capture deferred: ${error.message}` };
|
|
59
161
|
}
|
|
60
|
-
|
|
61
|
-
|
|
162
|
+
return {
|
|
163
|
+
metadataWritten,
|
|
164
|
+
progressionCaptured: true,
|
|
165
|
+
replayed,
|
|
166
|
+
receipt: result.receipt,
|
|
167
|
+
provenance: produced.provenance,
|
|
168
|
+
};
|
|
62
169
|
}
|
|
63
170
|
|
|
64
171
|
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
@@ -68,7 +175,8 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
|
|
|
68
175
|
try {
|
|
69
176
|
runSessionSnapshotHook(projectDirectory(), process.argv[2] || 'SessionEnd', { rawInput });
|
|
70
177
|
} catch (error) {
|
|
178
|
+
// ADVISORY, ALWAYS. A capture boundary fires at Stop, PreCompact and SessionEnd; one that can
|
|
179
|
+
// return a non-zero status can interrupt a turn, a compaction, or a clean exit. Report and exit 0.
|
|
71
180
|
process.stderr.write(`[project-progression] ${error.message}\n`);
|
|
72
|
-
process.exitCode = 1;
|
|
73
181
|
}
|
|
74
182
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// session-start-budget.mjs — the DERIVED-SUM latency contract for SessionStart (ADR-067 pattern,
|
|
3
|
+
// added 2026-09-11 after two independent reviewers flagged the original per-stage timeouts as
|
|
4
|
+
// unaccountable numbers with no relationship to the hook's own declared budget).
|
|
5
|
+
//
|
|
6
|
+
// Every stage the hook can run declares its OWN cost HERE, once. Nothing downstream invents a
|
|
7
|
+
// second number: session-start-trace.mjs enforces these at runtime (skip-with-note on overrun),
|
|
8
|
+
// and tests/unit/session-start-budget.test.mjs sums them and fails the build the moment the total
|
|
9
|
+
// creeps past hooks.json's own declared SessionStart timeout — so a new stage, or a raised budget,
|
|
10
|
+
// is a reviewable diff instead of a silent latency regression nobody notices until a stranger's
|
|
11
|
+
// session hangs.
|
|
12
|
+
//
|
|
13
|
+
// `restore` is continuity's project-progression restore (plugin/scripts/project-progression-
|
|
14
|
+
// session-start.mjs) — accounted for HERE because it shares this hook's wall-clock budget, but the
|
|
15
|
+
// stage itself is NOT this lane's code and is not wrapped by session-start-trace.mjs.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
export const HOOKS_JSON_PATH = path.resolve(HERE, '..', 'hooks', 'hooks.json');
|
|
22
|
+
|
|
23
|
+
/** SessionStart's own declared timeout in ms, read LIVE from hooks.json — never hand-copied, so a
|
|
24
|
+
* future retiming there is the one and only place this contract has to agree with. */
|
|
25
|
+
export function sessionStartTimeoutMs(hooksJsonPath = HOOKS_JSON_PATH) {
|
|
26
|
+
const doc = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
27
|
+
const entry = doc?.hooks?.SessionStart?.[0]?.hooks?.[0];
|
|
28
|
+
const timeoutSec = typeof entry?.timeout === 'number' ? entry.timeout : null;
|
|
29
|
+
if (!timeoutSec) throw new Error(`${hooksJsonPath}: could not read SessionStart's declared timeout`);
|
|
30
|
+
return timeoutSec * 1000;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Per-stage budgets, ms. Every stage session-start-core.mjs (or continuity's restore) can run
|
|
34
|
+
// appears here exactly once. Reviewer-mandated ceilings: restore <= 1000, banner <= 200.
|
|
35
|
+
export const STAGE_BUDGETS_MS = {
|
|
36
|
+
restore: 1000, // continuity lane's project-progression restore — NOT this lane's code
|
|
37
|
+
misc: 250, // settings/nightly/health/console-offer/auto-pref/star — small fs reads
|
|
38
|
+
// The cache read itself is budgeted at 100ms internally (session-start-issue-alert.mjs's own
|
|
39
|
+
// ISSUE_POINTER_BUDGET_MS, per correction #2's exact wording); this stage's total also carries
|
|
40
|
+
// the repo-scoping git check (session-start-repo-identity.mjs), bounded separately at up to
|
|
41
|
+
// 1000ms under real load — never a network call either way.
|
|
42
|
+
'issue-pointer': 1100,
|
|
43
|
+
'signal-surface': 400, // bounded CI-signal transition poll (see session-start-signals.mjs)
|
|
44
|
+
'router-nudge': 50, // one fs.existsSync + at-most-one-time write
|
|
45
|
+
'stable-spine': 300, // seed-dispatch decision + a single detach launch
|
|
46
|
+
heartbeat: 300, // update-check dispatch launch
|
|
47
|
+
'ascii-drift': 300, // optional ascii->svg drift advisory, already spawnSync-timeout bounded
|
|
48
|
+
banner: 200, // version/readiness/health banner assembly — pure fs reads, no subprocess
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function sumBudgetsMs(budgets = STAGE_BUDGETS_MS) {
|
|
52
|
+
return Object.values(budgets).reduce((sum, ms) => sum + ms, 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Measured node process boot (interpreter start + module graph load) BEFORE any stage code runs —
|
|
56
|
+
// real, unavoidable overhead the stage budgets above do not (and must not) account for. A named
|
|
57
|
+
// constant, not folded silently into one stage's budget, so a boot-time regression shows up as its
|
|
58
|
+
// own line instead of quietly eating an unrelated stage's headroom.
|
|
59
|
+
export const MEASURED_NODE_BOOT_MS = 250;
|