ruvnet-brain 4.3.25 → 4.3.27
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 +19 -4
- package/console/app.js +48 -0
- package/console/index.html +19 -0
- package/console/style.css +26 -0
- package/data/model-catalog.json +1 -1
- package/docs/RELEASE-NOTES-4.0.md +4 -3
- package/kb/capability-only.mjs +27 -0
- package/kb/capability-summaries/cognitum-ruos/CAPABILITIES.md +26 -0
- package/kb/verify-citation.mjs +16 -4
- package/package.json +5 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/docs/RELEASE-NOTES-4.0.md +4 -3
- package/plugin/hooks/codex-hooks.json +6 -1
- package/plugin/hooks/hook-contracts.json +24 -2
- package/plugin/hooks/hooks.json +6 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +200 -0
- package/plugin/scripts/codex-hook-adapter.mjs +37 -0
- package/plugin/scripts/continuity-hook-policy.mjs +4 -0
- package/plugin/scripts/coverage-integrity.mjs +17 -0
- package/plugin/scripts/hook-shim.mjs +1 -0
- package/plugin/scripts/lesson-gate.mjs +4 -1
- package/plugin/scripts/project-progression-reader.mjs +12 -1
- package/plugin/scripts/project-progression-session-start.mjs +24 -6
- package/plugin/scripts/project-progression-store.mjs +76 -4
- package/plugin/skills/release-proof/SKILL.md +28 -4
- package/plugin/skills/release-proof/scripts/release-proof.mjs +48 -24
- package/scripts/brain-novice-50.mjs +14 -16
- package/scripts/build-bundle.mjs +2 -0
- package/scripts/corpus-dispatch-receipt.mjs +22 -0
- package/scripts/corpus-reconcile.mjs +26 -4
- package/scripts/doc-currency.mjs +12 -4
- package/scripts/eval-brain.mjs +7 -5
- package/scripts/gist-git-transport.mjs +218 -0
- package/scripts/gist-receipts.mjs +168 -38
- package/scripts/ingest-gists.mjs +5 -2
- package/scripts/installed-brain-health.mjs +99 -0
- package/scripts/onboarding-console.mjs +45 -1
- package/scripts/public-inputs.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +14 -6
- package/scripts/refresh-capability-only-store.mjs +143 -0
- package/scripts/release-transaction-provider.mjs +25 -5
- package/scripts/release-transaction.mjs +40 -11
- package/scripts/release-vector.mjs +44 -20
- package/scripts/run-operational-benchmark.mjs +151 -0
- package/scripts/run-operational-benchmark.v3.mjs +194 -0
- package/scripts/self-update.mjs +2 -0
- package/scripts/source-coverage.mjs +6 -1
- package/scripts/sync-version.mjs +10 -2
- package/scripts/wired-check.mjs +73 -45
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* UserPromptSubmit advisory for substantial work with independent workstreams.
|
|
4
|
+
*
|
|
5
|
+
* This hook classifies only the submitted prompt and samples local pressure signals. It NEVER
|
|
6
|
+
* creates agents or claims that agents are running. The coordinator still has to check the live
|
|
7
|
+
* agent tools and runtime concurrency ceiling, then spawn real workers and inspect their results.
|
|
8
|
+
* Missing or malformed input, unavailable probes, and unexpected errors are silent and fail open.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
export const UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING = 4;
|
|
17
|
+
const INPUT_LIMIT = 32 * 1024;
|
|
18
|
+
const PROBE_TIMEOUT_MS = 450;
|
|
19
|
+
const GIB = 1024 ** 3;
|
|
20
|
+
|
|
21
|
+
const ACTION = /\b(?:build|implement|refactor|migrate|investigate|audit|review|design|fix|add|remove|optimi[sz]e|plan|execute|ship)\b/i;
|
|
22
|
+
const EXPLICIT_FANOUT = /\b(?:parallel(?:ize|ise)?|swarm|delegate|spawn (?:real )?agents?|independent workstreams?|separate owners?)\b/i;
|
|
23
|
+
const BROAD_SCOPE = /\b(?:cross[- ]cutting|end[- ]to[- ]end|multi[- ]step|large[- ]scale|whole (?:repo|repository|codebase|system)|entire (?:repo|repository|codebase|system)|full (?:repo|repository|codebase|system)|across (?:the )?(?:repo|repository|codebase|system)|multiple (?:modules|files|packages|components|services)|several (?:modules|files|packages|components|services|workstreams))\b/i;
|
|
24
|
+
const TRIVIAL_SCOPE = /\b(?:tiny|trivial|simple|single[- ]line|one[- ]line|small typo|rename (?:one|a|single) variable|format one file|just (?:a )?quick fix)\b/i;
|
|
25
|
+
const COMPONENTS = [
|
|
26
|
+
/\bapi\b/i, /\bcli\b/i, /\bui\b|\bfront[- ]end\b|\binterface\b/i,
|
|
27
|
+
/\btests?\b|\bqa\b/i, /\bdocs?\b|\bdocumentation\b/i,
|
|
28
|
+
/\bdata(?:base| layer| model)?\b|\bschema\b/i, /\bsecurity\b/i,
|
|
29
|
+
/\binfra(?:structure)?\b|\bdeployment\b/i, /\bhooks?\b/i,
|
|
30
|
+
/\binstaller\b|\bpackaging\b/i, /\bruntime\b/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function estimateWorkUnitCount(prompt) {
|
|
34
|
+
const componentCount = COMPONENTS.reduce((n, re) => n + Number(re.test(prompt)), 0);
|
|
35
|
+
if (componentCount >= 2) return componentCount;
|
|
36
|
+
const numberedItems = [...prompt.matchAll(/(?:^|\n)\s*(?:\d+[.)]|[-*])\s+\S/g)].length;
|
|
37
|
+
return numberedItems >= 2 ? numberedItems : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isSubstantialParallelWork(prompt) {
|
|
41
|
+
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
|
42
|
+
if (!text || TRIVIAL_SCOPE.test(text) || !ACTION.test(text)) return false;
|
|
43
|
+
if (EXPLICIT_FANOUT.test(text)) return true;
|
|
44
|
+
const breadth = BROAD_SCOPE.test(text);
|
|
45
|
+
const componentCount = COMPONENTS.reduce((n, re) => n + Number(re.test(text)), 0);
|
|
46
|
+
const numberedItems = [...text.matchAll(/(?:^|\n)\s*(?:\d+[.)]|[-*])\s+\S/g)].length;
|
|
47
|
+
return (breadth && componentCount >= 2) || componentCount >= 3 || numberedItems >= 3;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseUsedBytes(text) {
|
|
51
|
+
const match = String(text).match(/\bused\s*=\s*([\d.]+)\s*([KMGT]?)B?\b/i);
|
|
52
|
+
if (!match) return null;
|
|
53
|
+
const scale = { '': 1, K: 1024, M: 1024 ** 2, G: GIB, T: 1024 ** 4 }[match[2].toUpperCase()];
|
|
54
|
+
const value = Number(match[1]) * scale;
|
|
55
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseMacPressureOutput(text, { totalMemoryBytes, normalizedLoad }) {
|
|
59
|
+
const source = String(text || '');
|
|
60
|
+
const freeMatch = source.match(/System-wide memory free percentage:\s*(\d+(?:\.\d+)?)%/i);
|
|
61
|
+
const pageSizeMatch = source.match(/page size of\s+([\d,]+)\s+bytes/i);
|
|
62
|
+
const compressorMatch = source.match(/Pages occupied by compressor:\s*([\d,]+)/i);
|
|
63
|
+
const swapMatch = source.match(/vm\.swapusage:.*?used\s*=\s*([\d.]+)\s*([KMGT]?)B?\b/i);
|
|
64
|
+
if (!freeMatch || !pageSizeMatch || !compressorMatch || !swapMatch) return null;
|
|
65
|
+
const pageSize = Number(pageSizeMatch[1].replaceAll(',', ''));
|
|
66
|
+
const compressedPages = Number(compressorMatch[1].replaceAll(',', ''));
|
|
67
|
+
const swapUsedBytes = parseUsedBytes(`used = ${swapMatch[1]}${swapMatch[2]}B`);
|
|
68
|
+
const freePct = Number(freeMatch[1]);
|
|
69
|
+
const compressorBytes = pageSize * compressedPages;
|
|
70
|
+
if (![pageSize, compressedPages, swapUsedBytes, freePct, totalMemoryBytes, normalizedLoad]
|
|
71
|
+
.every(Number.isFinite) || pageSize <= 0 || compressedPages < 0 || totalMemoryBytes <= 0
|
|
72
|
+
|| freePct < 0 || freePct > 100 || normalizedLoad < 0) return null;
|
|
73
|
+
return { freePct, swapUsedBytes, compressorBytes, totalMemoryBytes, normalizedLoad };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Classify measured pressure. Unknown or incomplete measurements recommend serial work. */
|
|
77
|
+
export function pressureRecommendation(sample) {
|
|
78
|
+
if (!sample || ![
|
|
79
|
+
sample.freePct, sample.swapUsedBytes, sample.compressorBytes,
|
|
80
|
+
sample.totalMemoryBytes, sample.normalizedLoad,
|
|
81
|
+
].every(Number.isFinite) || sample.totalMemoryBytes <= 0) {
|
|
82
|
+
return { tier: 'unknown', totalAgents: 1, reason: 'capacity signals unavailable' };
|
|
83
|
+
}
|
|
84
|
+
const compressedRatio = sample.compressorBytes / sample.totalMemoryBytes;
|
|
85
|
+
if (sample.freePct < 45 || sample.swapUsedBytes > 0 || compressedRatio >= 0.4
|
|
86
|
+
|| sample.normalizedLoad >= 1) {
|
|
87
|
+
return { tier: 'constrained', totalAgents: 1, reason: 'resource pressure is high' };
|
|
88
|
+
}
|
|
89
|
+
// The local load-per-logical-core signal is a bounded CPU-pressure proxy, not a claim to a
|
|
90
|
+
// precise CPU utilization sample. Above 75% it trims fan-out to three total agents.
|
|
91
|
+
if (sample.normalizedLoad >= 0.75) {
|
|
92
|
+
return { tier: 'high-cpu', totalAgents: 3, reason: 'CPU pressure is high' };
|
|
93
|
+
}
|
|
94
|
+
if (sample.freePct < 75 || compressedRatio >= 0.2 || sample.normalizedLoad >= 0.55) {
|
|
95
|
+
return { tier: 'moderate', totalAgents: 2, reason: 'resource headroom is partial' };
|
|
96
|
+
}
|
|
97
|
+
return { tier: 'available', totalAgents: null, reason: 'measured headroom is available' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply lower configured/runtime caps after pressure sizing. A configured worker ceiling never
|
|
102
|
+
* proves runtime availability; an authoritative runtime cap, when supplied by the host, wins.
|
|
103
|
+
*/
|
|
104
|
+
export function effectiveAgentRecommendation(sample, {
|
|
105
|
+
configuredMaxChildren = null,
|
|
106
|
+
runtimeTotalAgentCap = null,
|
|
107
|
+
workUnitCount = null,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const pressure = pressureRecommendation(sample);
|
|
110
|
+
const runtimeCapKnown = Number.isInteger(runtimeTotalAgentCap) && runtimeTotalAgentCap >= 1;
|
|
111
|
+
const limits = [pressure.totalAgents ?? (runtimeCapKnown ? runtimeTotalAgentCap : UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING)];
|
|
112
|
+
if (Number.isInteger(configuredMaxChildren) && configuredMaxChildren >= 0) {
|
|
113
|
+
limits.push(configuredMaxChildren + 1); // configured workers plus the coordinating agent
|
|
114
|
+
}
|
|
115
|
+
if (runtimeCapKnown) {
|
|
116
|
+
limits.push(runtimeTotalAgentCap);
|
|
117
|
+
}
|
|
118
|
+
if (Number.isInteger(workUnitCount) && workUnitCount >= 0) limits.push(workUnitCount + 1);
|
|
119
|
+
const totalAgents = Math.min(...limits);
|
|
120
|
+
return {
|
|
121
|
+
...pressure,
|
|
122
|
+
totalAgents,
|
|
123
|
+
workers: Math.max(0, totalAgents - 1),
|
|
124
|
+
runtimeCapKnown,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readInput() {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
const buffer = Buffer.alloc(4096);
|
|
131
|
+
let total = 0;
|
|
132
|
+
while (total < INPUT_LIMIT) {
|
|
133
|
+
const count = fs.readSync(0, buffer, 0, Math.min(buffer.length, INPUT_LIMIT - total), null);
|
|
134
|
+
if (!count) break;
|
|
135
|
+
chunks.push(Buffer.from(buffer.subarray(0, count)));
|
|
136
|
+
total += count;
|
|
137
|
+
}
|
|
138
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collectMacPressure() {
|
|
142
|
+
if (process.platform !== 'darwin') return null;
|
|
143
|
+
try {
|
|
144
|
+
// One bounded subprocess gathers pressure, swap, and compressor bytes. Never use raw free RAM
|
|
145
|
+
// as the capacity signal; memory_pressure, actual swap, compression, and normalized load drive
|
|
146
|
+
// the tier. The hook intentionally does not wait for a timed CPU sample.
|
|
147
|
+
const probe = spawnSync('/bin/sh', ['-c', '/usr/bin/memory_pressure -Q; /usr/sbin/sysctl vm.swapusage; /usr/bin/vm_stat'], {
|
|
148
|
+
encoding: 'utf8', timeout: PROBE_TIMEOUT_MS, maxBuffer: 24 * 1024,
|
|
149
|
+
env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin' },
|
|
150
|
+
});
|
|
151
|
+
if (probe.error || probe.status !== 0) return null;
|
|
152
|
+
const cpuCount = os.cpus().length;
|
|
153
|
+
const load = os.loadavg()[0];
|
|
154
|
+
if (!cpuCount || !Number.isFinite(load) || load < 0) return null;
|
|
155
|
+
return parseMacPressureOutput(probe.stdout, {
|
|
156
|
+
totalMemoryBytes: os.totalmem(), normalizedLoad: load / cpuCount,
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatAdvisory(recommendation) {
|
|
164
|
+
const n = recommendation.totalAgents;
|
|
165
|
+
const runtime = recommendation.runtimeCapKnown
|
|
166
|
+
? 'Do not exceed the host-reported runtime cap; configured concurrency is only a ceiling.'
|
|
167
|
+
: `This hook cannot see the live runtime/tool cap; ${UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING} total is only a conservative ceiling until the coordinator checks it. Configured concurrency is not proof of available slots.`;
|
|
168
|
+
const workerPlan = recommendation.workers > 0
|
|
169
|
+
? `At most ${recommendation.workers} worker agent${recommendation.workers === 1 ? '' : 's'}`
|
|
170
|
+
: 'No additional worker agents';
|
|
171
|
+
return [
|
|
172
|
+
'Capacity-aware parallel-work advisory (context only; no workers were started).',
|
|
173
|
+
`This prompt appears to contain independent work. Resource tier: ${recommendation.tier}; recommend no more than ${n} total agent${n === 1 ? '' : 's'} including the coordinator (${workerPlan}).`,
|
|
174
|
+
`${runtime} Treat configured concurrency as a ceiling only; never infer that configured slots are available.`,
|
|
175
|
+
'If a real agent-spawn/task tool is available and slots remain, launch actual workers now with non-overlapping deliverables and collect their results. If tools or slots are unavailable, continue serially and do not claim parallel workers exist.',
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function runCapacityHook(rawInput, sample) {
|
|
180
|
+
let input;
|
|
181
|
+
try { input = JSON.parse(String(rawInput || '')); } catch { return ''; }
|
|
182
|
+
const prompt = input?.prompt ?? input?.user_prompt ?? input?.input;
|
|
183
|
+
if (!isSubstantialParallelWork(prompt)) return '';
|
|
184
|
+
return formatAdvisory(effectiveAgentRecommendation(sample === undefined ? collectMacPressure() : sample, {
|
|
185
|
+
configuredMaxChildren: input?.configured_max_children,
|
|
186
|
+
runtimeTotalAgentCap: input?.runtime_total_agent_cap,
|
|
187
|
+
workUnitCount: input?.independent_workstream_count ?? estimateWorkUnitCount(prompt),
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function main() {
|
|
192
|
+
try {
|
|
193
|
+
const output = runCapacityHook(readInput());
|
|
194
|
+
if (output) process.stdout.write(`${output}\n`);
|
|
195
|
+
} catch {
|
|
196
|
+
// This advisory has no authority to block or delay user work when anything is unavailable.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
|
@@ -212,6 +212,33 @@ const stdout = merge(stdouts);
|
|
|
212
212
|
let parsed = null;
|
|
213
213
|
try { parsed = JSON.parse(stdout); } catch { /* a shared body may legitimately print prose */ }
|
|
214
214
|
|
|
215
|
+
function validPostToolUseOutput(value) {
|
|
216
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
217
|
+
const topLevelKeys = new Set([
|
|
218
|
+
'continue', 'stopReason', 'suppressOutput', 'systemMessage',
|
|
219
|
+
'terminalSequence', 'decision', 'reason', 'hookSpecificOutput',
|
|
220
|
+
]);
|
|
221
|
+
if (Object.keys(value).some((key) => !topLevelKeys.has(key))) return false;
|
|
222
|
+
if (value.continue !== undefined && typeof value.continue !== 'boolean') return false;
|
|
223
|
+
if (value.stopReason !== undefined && typeof value.stopReason !== 'string') return false;
|
|
224
|
+
if (value.suppressOutput !== undefined && typeof value.suppressOutput !== 'boolean') return false;
|
|
225
|
+
if (value.systemMessage !== undefined && typeof value.systemMessage !== 'string') return false;
|
|
226
|
+
if (value.terminalSequence !== undefined && typeof value.terminalSequence !== 'string') return false;
|
|
227
|
+
if (value.decision !== undefined && value.decision !== 'block') return false;
|
|
228
|
+
if (value.decision === 'block' && (typeof value.reason !== 'string' || !value.reason.trim())) return false;
|
|
229
|
+
if (value.hookSpecificOutput !== undefined) {
|
|
230
|
+
const specific = value.hookSpecificOutput;
|
|
231
|
+
if (!specific || typeof specific !== 'object' || Array.isArray(specific)) return false;
|
|
232
|
+
const specificKeys = new Set([
|
|
233
|
+
'hookEventName', 'additionalContext', 'updatedToolOutput', 'updatedMCPToolOutput',
|
|
234
|
+
]);
|
|
235
|
+
if (Object.keys(specific).some((key) => !specificKeys.has(key))) return false;
|
|
236
|
+
if (specific.hookEventName !== 'PostToolUse') return false;
|
|
237
|
+
if (specific.additionalContext !== undefined && typeof specific.additionalContext !== 'string') return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
if (event === 'Stop') {
|
|
216
243
|
const reason = parsed?.hookSpecificOutput?.additionalContext
|
|
217
244
|
|| parsed?.reason
|
|
@@ -241,6 +268,16 @@ if (!parsed) {
|
|
|
241
268
|
process.exit(0);
|
|
242
269
|
}
|
|
243
270
|
|
|
271
|
+
// A body can emit syntactically valid JSON that is still invalid for Codex's event-specific wire
|
|
272
|
+
// schema (wrong event name, unsupported fields, or bad field types). Preserve the advisory as text
|
|
273
|
+
// inside the known-good PostToolUse envelope instead of forwarding a payload the host rejects.
|
|
274
|
+
if (event === 'PostToolUse' && !validPostToolUseOutput(parsed)) {
|
|
275
|
+
process.stdout.write(JSON.stringify({
|
|
276
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: stdout.trim() },
|
|
277
|
+
}));
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
244
281
|
// `deny` is the only permissionDecision Codex accepts; `allow`, `ask` and the shared bodies' own
|
|
245
282
|
// `defer` are all rejected by name. Strip, then drop an envelope that has nothing left to say.
|
|
246
283
|
const decision = parsed?.hookSpecificOutput?.permissionDecision;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* session-snapshot continuity capture at PreCompact claude
|
|
25
25
|
* session-snapshot continuity capture at SessionEnd claude, codex
|
|
26
26
|
* ground-ruvnet grounding injection at UserPromptSubmit claude, codex
|
|
27
|
+
* capacity-aware-parallel-work coordination guidance at UserPromptSubmit claude, codex
|
|
27
28
|
* decision-gate write authorization at PreToolUse (write) claude, codex
|
|
28
29
|
* grounding-stamp grounding receipt at PostToolUse claude, codex
|
|
29
30
|
* grounding-turn-mark grounding turn marker at UserPromptSubmit claude, codex
|
|
@@ -125,6 +126,9 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
125
126
|
UserPromptSubmit: Object.freeze([
|
|
126
127
|
registration('unprompted-speech', '*', ['claude', 'codex']),
|
|
127
128
|
registration('ground-ruvnet', '*', ['claude', 'codex']),
|
|
129
|
+
// Context-only recommendation for clearly substantial, independently splittable work. The
|
|
130
|
+
// body samples bounded local pressure signals; it never starts workers or reports that it did.
|
|
131
|
+
registration('capacity-aware-parallel-work', '*', ['claude', 'codex']),
|
|
128
132
|
// The "answered without searching" gate, half 1 of 2 (2026-09-12) — see grounding-turn-gate.mjs's
|
|
129
133
|
// header for the full rationale. Records that ground-ruvnet's Gate 1 fired for this turn, since
|
|
130
134
|
// Stop's own payload carries no prompt text for grounding-turn-gate to test.
|
|
@@ -89,6 +89,23 @@ export function validateGistAggregateReceipt({ receipt, passagesFile, expectedId
|
|
|
89
89
|
|| row.contentDigest !== digest(files)) {
|
|
90
90
|
throw new Error(`gist ${id} receipt is incomplete or internally inconsistent`);
|
|
91
91
|
}
|
|
92
|
+
const proofs = files.map((file) => file?.sourceGit).filter(Boolean);
|
|
93
|
+
if (proofs.length && (proofs.length !== files.length || proofs.some((proof) =>
|
|
94
|
+
!HEX40.test(String(proof.headSha || '')) || !HEX40.test(String(proof.treeSha || ''))
|
|
95
|
+
|| !HEX40.test(String(proof.blobSha || '')) || proof.captureMethod !== 'public-bare-git-v1'
|
|
96
|
+
|| proof.revisionKind !== 'git-commit' || proof.headSha !== row.versionSha
|
|
97
|
+
|| proof.sourceObservationSha256 !== receipt.sourceObservationSha256
|
|
98
|
+
|| !HEX64.test(String(proof.observedRowsSha256 || ''))
|
|
99
|
+
|| !Number.isSafeInteger(proof.treeFileCount) || proof.treeFileCount !== files.length
|
|
100
|
+
|| proof.observedTruncated !== false || proof.observedFileCount !== proof.treeFileCount
|
|
101
|
+
|| proof.observed !== true || !HEX40.test(String(proof.observedRawRevisionSha || ''))
|
|
102
|
+
|| !['blob', 'commit'].includes(proof.observedRawRevisionKind)
|
|
103
|
+
|| proof.observedRawBlobSha !== proof.blobSha
|
|
104
|
+
|| proofs.some((other) => other.headSha !== proof.headSha || other.treeSha !== proof.treeSha
|
|
105
|
+
|| other.sourceObservationSha256 !== proof.sourceObservationSha256
|
|
106
|
+
|| other.observedRowsSha256 !== proof.observedRowsSha256)))) {
|
|
107
|
+
throw new Error(`gist ${id} Git tree proof is incomplete or inconsistent`);
|
|
108
|
+
}
|
|
92
109
|
const { receiptSha256, ...payload } = row;
|
|
93
110
|
if (!HEX64.test(String(receiptSha256 || '')) || receiptSha256 !== digest(payload)) {
|
|
94
111
|
throw new Error(`gist ${id} receipt digest differs`);
|
|
@@ -86,6 +86,7 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
|
86
86
|
const TABLE = {
|
|
87
87
|
'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
|
|
88
88
|
'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
89
|
+
'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 32768 },
|
|
89
90
|
// ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
|
|
90
91
|
// still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
|
|
91
92
|
// changes the CEILING of what it may do, not what it does.
|
|
@@ -203,7 +203,10 @@ const GATE_STATE_PATH = process.env.RUVNET_LESSON_GATE_STATE
|
|
|
203
203
|
|| path.join(CONFIG_ROOT, 'lesson-gate-state.json');
|
|
204
204
|
const MAX_SHOWS = (() => {
|
|
205
205
|
const n = Number(process.env.RUVNET_LESSON_MAX_SHOWS);
|
|
206
|
-
|
|
206
|
+
// A user-correction advisory is useful once in a session; repeating identical prose on each
|
|
207
|
+
// matching edit trains the recipient to ignore it. Keep an explicit override for installations
|
|
208
|
+
// that want a different cadence. Actual opted-in refusals remain exempt below.
|
|
209
|
+
return Number.isInteger(n) && n > 0 ? n : 1;
|
|
207
210
|
})();
|
|
208
211
|
const KEEP_SESSIONS = 20; // bound the state file to the most-recent sessions, same as anticipate.sh
|
|
209
212
|
const SID = (typeof session === 'string' && session.trim())
|
|
@@ -91,6 +91,13 @@ const SCHEMA_FINGERPRINT = Object.freeze({
|
|
|
91
91
|
]),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
// The current managed store on the host also carries `session_id` on memory_entries, although the
|
|
95
|
+
// installed Ruflo memory_entries DDL does not declare it. This reader has no reason to attribute
|
|
96
|
+
// that column to Ruflo: it is an observed local compatibility shape only. It is safe for this
|
|
97
|
+
// projection because the reader never selects or interprets it; every required column must still
|
|
98
|
+
// match, and every other addition remains a hard fallback to the managed CLI.
|
|
99
|
+
const OBSERVED_COMPATIBILITY_COLUMNS = Object.freeze(['session_id']);
|
|
100
|
+
|
|
94
101
|
/** The fingerprint this module requires, so the doctor and tests can name it exactly. */
|
|
95
102
|
export function expectedSchemaFingerprint() {
|
|
96
103
|
return { userVersion: SCHEMA_FINGERPRINT.userVersion, columns: [...SCHEMA_FINGERPRINT.columns] };
|
|
@@ -110,7 +117,11 @@ function assertSchemaFingerprint(database) {
|
|
|
110
117
|
throw new ProgressionReaderUnavailable(
|
|
111
118
|
`schema fingerprint mismatch: user_version ${userVersion} is not ${SCHEMA_FINGERPRINT.userVersion}`);
|
|
112
119
|
}
|
|
113
|
-
|
|
120
|
+
const required = SCHEMA_FINGERPRINT.columns;
|
|
121
|
+
const compatibleObservedShape = columns.length === required.length + 1
|
|
122
|
+
&& OBSERVED_COMPATIBILITY_COLUMNS.every((name) => columns.includes(name))
|
|
123
|
+
&& required.every((name) => columns.includes(name));
|
|
124
|
+
if (columns.join(',') !== required.join(',') && !compatibleObservedShape) {
|
|
114
125
|
const missing = SCHEMA_FINGERPRINT.columns.filter((name) => !columns.includes(name));
|
|
115
126
|
const added = columns.filter((name) => !SCHEMA_FINGERPRINT.columns.includes(name));
|
|
116
127
|
throw new ProgressionReaderUnavailable('schema fingerprint mismatch: memory_entries columns differ'
|
|
@@ -4,11 +4,15 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
5
5
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
6
6
|
import { withProgressionReader } from './project-progression-reader.mjs';
|
|
7
|
+
import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
|
|
7
8
|
|
|
8
9
|
const PROGRESSION_NAMESPACE = 'project-progression';
|
|
9
10
|
|
|
10
11
|
export const SESSION_CONTINUITY_LIMIT_BYTES = 8 * 1024;
|
|
11
|
-
|
|
12
|
+
// Keep the restore's enforced wall-clock ceiling identical to the SessionStart latency contract.
|
|
13
|
+
// The fast path normally completes in milliseconds; when the managed CLI fallback cannot fit this
|
|
14
|
+
// stage, the caller reports UNKNOWN with its reason instead of consuming the rest of the hook budget.
|
|
15
|
+
export const SESSION_CONTINUITY_DEADLINE_MS = STAGE_BUDGETS_MS.restore;
|
|
12
16
|
|
|
13
17
|
const RESTORED_HEADER = '[RuvNet Brain — PROJECT CONTINUITY RESTORED]';
|
|
14
18
|
const UNKNOWN_HEADER = '[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]';
|
|
@@ -21,7 +25,7 @@ const UNKNOWN_EXPLANATIONS = Object.freeze({
|
|
|
21
25
|
'malformed-store': 'Structural AgentDB output is malformed or internally inconsistent.',
|
|
22
26
|
'exact-readback': 'An exact-listed AgentDB row could not be read back by its exact key.',
|
|
23
27
|
'outbox-replay': 'The durable progression outbox could not be replayed safely.',
|
|
24
|
-
'output-bound': 'The
|
|
28
|
+
'output-bound': 'The full checkpoint and a goal/action-preserving bounded summary do not fit the host context; no checkpoint state was injected.',
|
|
25
29
|
'no-coherent-state': 'No coherent progression head survived validation.',
|
|
26
30
|
'restore-failed': 'The exact structural restore did not complete.',
|
|
27
31
|
// MEASURED, and named rather than hidden. The in-process read path costs ~15ms for six snapshots;
|
|
@@ -198,7 +202,9 @@ export function restoreProgressionForSession({
|
|
|
198
202
|
const miss = (reason) => unknown(reason, { rowCount });
|
|
199
203
|
|
|
200
204
|
const prefix = `${RESTORED_HEADER}\n`;
|
|
201
|
-
|
|
205
|
+
// Reserve room for the status prefix, the bounded-summary explanation, and any pending-outbox
|
|
206
|
+
// notice appended after the payload. The final complete context is still checked below.
|
|
207
|
+
const payloadLimit = maxOutputBytes - Buffer.byteLength(prefix, 'utf8') - 400;
|
|
202
208
|
if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return miss('output-bound');
|
|
203
209
|
|
|
204
210
|
let store;
|
|
@@ -226,11 +232,23 @@ export function restoreProgressionForSession({
|
|
|
226
232
|
// COMMITTED ROWS ONLY (ADR-073 §5). Replay is a write, a write is a `ruflo memory store`
|
|
227
233
|
// process, and one of those costs more than this entire boundary's budget. Pending durable
|
|
228
234
|
// snapshots are REPORTED below and replayed at the next capture boundary or by /checkpoint.
|
|
229
|
-
const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false });
|
|
235
|
+
const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false, projectToBound: true });
|
|
230
236
|
if (!validResume(restored)) return miss('malformed-store');
|
|
231
|
-
const
|
|
237
|
+
const summaryNotice = restored.projected
|
|
238
|
+
? '\n[BOUNDED CONTINUITY SUMMARY] The merged current goal and next action are preserved exactly; '
|
|
239
|
+
+ 'a null value means the journal heads conflict. '
|
|
240
|
+
+ 'Other omitted details remain in the canonical AgentDB records; consult the listed head keys '
|
|
241
|
+
+ 'and omission digests. Omitted fields are marked and are not empty.'
|
|
242
|
+
: '';
|
|
243
|
+
const context = `${prefix}${summaryNotice}\n${restored.rendered}${pendingNotice(restored.pendingReplay)}`;
|
|
232
244
|
if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return miss('output-bound');
|
|
233
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
status: restored.projected ? 'restored-summary' : 'restored',
|
|
247
|
+
severity: restored.projected ? 'warning' : 'info',
|
|
248
|
+
degraded: restored.projected === true,
|
|
249
|
+
pendingReplay: restored.pendingReplay,
|
|
250
|
+
context,
|
|
251
|
+
};
|
|
234
252
|
} catch (error) {
|
|
235
253
|
// A structurally enumerated, genuinely empty namespace is normal for a newly adopted project.
|
|
236
254
|
if (/no coherent progression state/i.test(String(error?.message ?? ''))
|
|
@@ -40,6 +40,70 @@ function requirePositiveInteger(value, label) {
|
|
|
40
40
|
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${label} must be a positive safe integer`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function omissionSummary(value) {
|
|
44
|
+
return {
|
|
45
|
+
count: Array.isArray(value) ? value.length : 1,
|
|
46
|
+
sha256: digestCanonical(value),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deterministically reduce a verified resume payload to fit the host context without pretending
|
|
52
|
+
* omitted evidence is empty. The canonical snapshots remain in AgentDB; the projection retains the
|
|
53
|
+
* merged goal/action values, head keys and per-omission digests so the omitted values stay addressable.
|
|
54
|
+
* Returns null when the required resume identity and goal/action alone cannot fit.
|
|
55
|
+
*/
|
|
56
|
+
export function projectResumePayloadToBound(payload, maxOutputBytes) {
|
|
57
|
+
requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
|
|
58
|
+
const summary = structuredClone(payload);
|
|
59
|
+
const omissions = [];
|
|
60
|
+
summary.projection = { mode: 'bounded-summary', omitted: omissions };
|
|
61
|
+
const recordOmission = (target, key, pathName) => {
|
|
62
|
+
const value = target[key];
|
|
63
|
+
if (value === undefined) return;
|
|
64
|
+
const digest = omissionSummary(value);
|
|
65
|
+
omissions.push({ path: pathName, ...digest });
|
|
66
|
+
target[key] = { omitted: true, ...digest };
|
|
67
|
+
};
|
|
68
|
+
const size = () => Buffer.byteLength(JSON.stringify(summary), 'utf8');
|
|
69
|
+
|
|
70
|
+
// Conflicts are important facts, but their full competing values can dominate the resume context.
|
|
71
|
+
// Keep each conflicting field, its count, and a digest of the exact competing values; top-level
|
|
72
|
+
// head keys remain, and canonical snapshots retain the source values. No winner is chosen.
|
|
73
|
+
if (size() > maxOutputBytes && Array.isArray(summary.state?.resumeConflicts)) {
|
|
74
|
+
const originals = summary.state.resumeConflicts;
|
|
75
|
+
summary.state.resumeConflicts = originals.map((conflict) => ({
|
|
76
|
+
field: conflict.field,
|
|
77
|
+
valueCount: (conflict.values ?? []).length,
|
|
78
|
+
valuesDigest: digestCanonical(conflict.values ?? []),
|
|
79
|
+
}));
|
|
80
|
+
omissions.push({
|
|
81
|
+
path: 'state.resumeConflicts[].values',
|
|
82
|
+
...omissionSummary(originals.flatMap((conflict) => conflict.values ?? []).map((row) => row.value)),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (size() > maxOutputBytes) recordOmission(summary.state, 'journalHeads', 'state.journalHeads');
|
|
87
|
+
// Least central detail first. currentGoal and nextAction are deliberately absent from this list.
|
|
88
|
+
const stateFields = [
|
|
89
|
+
'commands', 'proofArtifacts', 'changedFiles', 'completed', 'untested', 'decisions',
|
|
90
|
+
'plan', 'inProgress', 'blockers', 'failures', 'acceptanceContract', 'provenance',
|
|
91
|
+
'evidence', 'activeStep', 'activeProcess', 'sourceIdentity',
|
|
92
|
+
];
|
|
93
|
+
for (const field of stateFields) {
|
|
94
|
+
if (size() <= maxOutputBytes) break;
|
|
95
|
+
recordOmission(summary.state, field, `state.${field}`);
|
|
96
|
+
}
|
|
97
|
+
if (size() > maxOutputBytes && summary.evidence) {
|
|
98
|
+
recordOmission(summary, 'evidence', 'evidence');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Never clip or replace the user goal or next action. If those plus the identity/omission ledger
|
|
102
|
+
// do not fit, refuse to call this a restore and let SessionStart emit explicit UNKNOWN.
|
|
103
|
+
if (size() > maxOutputBytes) return null;
|
|
104
|
+
return { payload: summary, rendered: JSON.stringify(summary), projected: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
43
107
|
function validatePage(page, { offset, pageSize, total }) {
|
|
44
108
|
if (!plainRecord(page) || !Array.isArray(page.entries)
|
|
45
109
|
|| !Number.isSafeInteger(page.total) || page.total < 0
|
|
@@ -326,7 +390,8 @@ export class ProjectProgressionStore {
|
|
|
326
390
|
* boundaries that already own a write budget. The outbox's fsync-then-commit ordering and its
|
|
327
391
|
* replay-required semantics are untouched: nothing is dropped, only deferred.
|
|
328
392
|
*/
|
|
329
|
-
restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024,
|
|
393
|
+
restoreLatest({ pageSize = 100, maxEntries = 10_000, maxOutputBytes = 64 * 1024,
|
|
394
|
+
replayPending = true, projectToBound = false } = {}) {
|
|
330
395
|
requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
|
|
331
396
|
if (replayPending) this.replay();
|
|
332
397
|
const pendingReplay = replayPending ? 0 : this.pendingReplayCount();
|
|
@@ -361,10 +426,17 @@ export class ProjectProgressionStore {
|
|
|
361
426
|
pendingReplay,
|
|
362
427
|
},
|
|
363
428
|
};
|
|
364
|
-
|
|
429
|
+
let rendered = JSON.stringify(payload);
|
|
430
|
+
let finalPayload = payload;
|
|
431
|
+
let projected = false;
|
|
365
432
|
if (Buffer.byteLength(rendered, 'utf8') > maxOutputBytes) {
|
|
366
|
-
|
|
433
|
+
const bounded = projectToBound ? projectResumePayloadToBound(payload, maxOutputBytes) : null;
|
|
434
|
+
if (!bounded) {
|
|
435
|
+
throw new Error(`resume payload${projectToBound ? ' and mandatory bounded summary' : ''}`
|
|
436
|
+
+ ` exceeds the ${maxOutputBytes}-byte output bound`);
|
|
437
|
+
}
|
|
438
|
+
({ payload: finalPayload, rendered, projected } = bounded);
|
|
367
439
|
}
|
|
368
|
-
return { payload, rendered, pendingReplay };
|
|
440
|
+
return { payload: finalPayload, rendered, pendingReplay, projected };
|
|
369
441
|
}
|
|
370
442
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: release-proof
|
|
3
3
|
description: Fail-closed exact-artifact release and deployment authority. Use before saying a release is ready, pushing a release commit, publishing npm packages, creating GitHub releases, deploying production, closing release-blocking issues, or claiming all gates are green. Requires clean immutable lineage, zero labeled release blockers, exact-SHA GitHub success, nonzero no-skip QE, packed-artifact host tests, installed Brain/RVF proof, and post-publication byte verification.
|
|
4
|
-
updated: 2026-09-
|
|
4
|
+
updated: 2026-09-20
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Release Proof
|
|
@@ -37,9 +37,28 @@ green.
|
|
|
37
37
|
## Candidate seal
|
|
38
38
|
|
|
39
39
|
Generate the candidate receipt in `.github/workflows/release-candidate-preflight.yml`; do not
|
|
40
|
-
hand-author it.
|
|
41
|
-
|
|
42
|
-
`release-candidate-<
|
|
40
|
+
hand-author it. Push the exact candidate commit to `release/<version>` to run the long CI,
|
|
41
|
+
integration, UX, and stranger lanes once. Require the successful exact-SHA artifact
|
|
42
|
+
`release-candidate-<SHA>` before promotion.
|
|
43
|
+
|
|
44
|
+
Then open a PR from that `release/**` branch to `main`. The release-branch `canonical-qa` and
|
|
45
|
+
`integration` consumers verify the producer receipt and report the required checks on the exact
|
|
46
|
+
candidate SHA. Wait for both required checks to pass on that SHA. Do **not** use the GitHub PR merge
|
|
47
|
+
button: merge, squash, and rebase all create a different commit identity and invalidate the sealed
|
|
48
|
+
artifact. Promote only with an ordinary non-force fast-forward push after confirming current `main`
|
|
49
|
+
is an ancestor of the candidate:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git fetch origin main
|
|
53
|
+
git merge-base --is-ancestor origin/main "$CANDIDATE_SHA"
|
|
54
|
+
git push origin "$CANDIDATE_SHA:refs/heads/main"
|
|
55
|
+
test "$(git ls-remote origin refs/heads/main | cut -f1)" = "$CANDIDATE_SHA"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If the normal push is rejected, stop and repair branch-policy or required-check configuration; never
|
|
59
|
+
force-push, use an admin bypass, or substitute a merge-created SHA. Any source change requires a new
|
|
60
|
+
candidate preflight and artifact. The PR is review context; its merge button is not the promotion
|
|
61
|
+
mechanism.
|
|
43
62
|
|
|
44
63
|
Dispatch `.github/workflows/protected-release.yml` only after the fast-forward. It is the sole
|
|
45
64
|
publication controller: it proves current `origin/main` is the preflight SHA, selects the artifact
|
|
@@ -78,6 +97,11 @@ Only exit 0 permits “shipped,” “deployed,” “green,” or “ready.”
|
|
|
78
97
|
seal fails, say `PUBLICATION DEGRADED`, preserve the previous known-good release, and repair or
|
|
79
98
|
roll back through the release workflow.
|
|
80
99
|
|
|
100
|
+
`npm run release:proof -- --status --quick` is a live local/main diagnostic only. It deliberately
|
|
101
|
+
does not evaluate the release vector and therefore reports `INCOMPLETE` when that is its only
|
|
102
|
+
missing evidence. It is never a candidate receipt or publication authority. Use the exact candidate
|
|
103
|
+
receipt and workflow artifacts for release decisions.
|
|
104
|
+
|
|
81
105
|
`scripts/release.mjs --publish` is intentionally unusable from a local shell or another workflow.
|
|
82
106
|
Its invocation guard requires GitHub Actions workflow `protected-release`, the candidate receipt,
|
|
83
107
|
and matching SHA/digest/version bindings before any push, tag, release, or npm action. The workflow
|