ruvnet-brain 4.0.7 → 4.0.8

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.
@@ -4,47 +4,75 @@ import crypto from 'node:crypto';
4
4
  export const RECEIPT_PREFIX = 'release-transaction-';
5
5
  export const TERMINAL_STATES = new Set(['channels-converged', 'aborted']);
6
6
 
7
- const canonical = (value) => {
8
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
7
+ export const ALLOWED_TRANSITIONS = Object.freeze({
8
+ 'remote-prepared': ['asset-upload-intent', 'manual-intervention-required'],
9
+ 'asset-upload-intent': ['npm-stage-intent', 'manual-intervention-required'],
10
+ 'npm-stage-intent': ['npm-candidate-staged', 'manual-intervention-required'],
11
+ 'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required'],
12
+ 'remote-materialization-intent': ['prepared', 'manual-intervention-required'],
13
+ prepared: ['github-promote-intent', 'manual-intervention-required'],
14
+ 'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required'],
15
+ 'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required'],
16
+ 'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required'],
17
+ 'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required'],
18
+ 'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required'],
19
+ 'compensation-intent': ['compensated', 'manual-intervention-required'],
20
+ compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required'],
21
+ 'defaults-promoted': ['finalize-intent', 'manual-intervention-required'],
22
+ 'finalize-intent': ['channels-converged', 'manual-intervention-required'],
23
+ 'manual-intervention-required': [],
24
+ 'channels-converged': [],
25
+ aborted: [],
26
+ });
27
+
28
+ export const canonicalJson = (value) => {
29
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
9
30
  if (value && typeof value === 'object') {
10
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
31
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
11
32
  }
12
33
  return JSON.stringify(value);
13
34
  };
14
35
 
15
36
  export const receiptPayload = (receipt) => {
16
37
  const { signature: _signature, receiptDigest: _digest, ...payload } = receipt;
17
- return canonical(payload);
38
+ return canonicalJson(payload);
18
39
  };
19
40
 
20
41
  export const digestReceipt = (receipt) => crypto.createHash('sha256')
21
42
  .update(receiptPayload(receipt)).digest('hex');
22
43
 
23
- export const transactionIdFor = (identity) => crypto.createHash('sha256').update(canonical({
44
+ export const transactionIdFor = (identity) => crypto.createHash('sha256').update(canonicalJson({
45
+ schemaVersion: 2,
24
46
  repository: identity.repository,
25
47
  package: identity.package,
26
48
  version: identity.version,
27
49
  tag: identity.tag,
28
50
  candidateSha: identity.candidateSha,
51
+ payloadId: identity.payloadId || null,
52
+ evidenceDigest: identity.evidenceDigest || null,
29
53
  packageIntegrity: identity.packageIntegrity,
54
+ packageSha256: identity.packageSha256 || null,
55
+ packageAssetName: identity.packageAssetName || null,
30
56
  bundleSha256: identity.bundleSha256,
57
+ bundleSignatureSha256: identity.bundleSignatureSha256 || null,
58
+ bundleDigestSha256: identity.bundleDigestSha256 || null,
31
59
  })).digest('hex');
32
60
 
33
61
  export function signReceipt(receipt, privateKey) {
34
62
  const unsigned = { ...receipt, receiptDigest: digestReceipt(receipt) };
35
63
  return {
36
64
  ...unsigned,
37
- signature: crypto.sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64'),
65
+ signature: crypto.sign(null, Buffer.from(canonicalJson(unsigned)), privateKey).toString('base64'),
38
66
  };
39
67
  }
40
68
 
41
69
  export function verifyReceipt(receipt, publicKey) {
42
- if (!receipt || receipt.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(receipt.transactionId || '')) {
70
+ if (!receipt || ![1, 2].includes(receipt.schemaVersion) || !/^[a-f0-9]{64}$/.test(receipt.transactionId || '')) {
43
71
  throw new Error('invalid release transaction receipt');
44
72
  }
45
73
  const { signature, receiptDigest, ...unsigned } = receipt;
46
74
  if (digestReceipt(unsigned) !== receiptDigest) throw new Error('release receipt digest mismatch');
47
- if (!crypto.verify(null, Buffer.from(canonical({ ...unsigned, receiptDigest })), publicKey, Buffer.from(signature || '', 'base64'))) {
75
+ if (!crypto.verify(null, Buffer.from(canonicalJson({ ...unsigned, receiptDigest })), publicKey, Buffer.from(signature || '', 'base64'))) {
48
76
  throw new Error('release receipt signature mismatch');
49
77
  }
50
78
  return receipt;
@@ -56,7 +84,7 @@ export function validateReceiptChain(receipts, identity, publicKey) {
56
84
  let previous = null;
57
85
  for (let index = 0; index < sorted.length; index += 1) {
58
86
  const receipt = verifyReceipt(sorted[index], publicKey);
59
- if (receipt.transactionId !== expectedId || canonical(receipt.identity) !== canonical(identity)) {
87
+ if (receipt.transactionId !== expectedId || canonicalJson(receipt.identity) !== canonicalJson(identity)) {
60
88
  throw new Error('release receipt identity conflict');
61
89
  }
62
90
  if (receipt.sequence !== index) throw new Error('release receipt sequence gap or replay');
@@ -68,13 +96,12 @@ export function validateReceiptChain(receipts, identity, publicKey) {
68
96
  return sorted;
69
97
  }
70
98
 
71
- const stateReceipt = ({ identity, prior, state, fence, observation = {}, privateKey }) => signReceipt({
72
- schemaVersion: 1,
99
+ const stateReceipt = ({ identity, prior, state, observation = {}, privateKey }) => signReceipt({
100
+ schemaVersion: 2,
73
101
  transactionId: transactionIdFor(identity),
74
102
  sequence: prior ? prior.sequence + 1 : 0,
75
103
  previousReceiptDigest: prior?.receiptDigest || null,
76
104
  state,
77
- fence,
78
105
  identity,
79
106
  observation,
80
107
  createdAt: new Date().toISOString(),
@@ -84,6 +111,94 @@ const exact = (actual, expected, label) => {
84
111
  if (actual !== expected) throw new Error(`${label} mismatch: ${actual ?? '(missing)'} != ${expected}`);
85
112
  };
86
113
 
114
+ const npmCandidateExact = (snapshot, identity) => snapshot?.npm?.candidateVersion === identity.version
115
+ && snapshot?.npm?.candidateIntegrity === identity.packageIntegrity
116
+ && snapshot?.npm?.candidateTagVersion === identity.version;
117
+ const npmLatestIsB = (snapshot, identity) => snapshot?.npm?.latestVersion === identity.version;
118
+ const githubIsB = (snapshot, identity) => snapshot?.github?.sha === identity.candidateSha
119
+ && snapshot?.github?.tag === identity.tag;
120
+
121
+ /**
122
+ * Pure, total next-action selector. Receipt history is an audit chain, never permission to skip a
123
+ * provider postcondition. Every invocation consumes one fresh cross-provider observation.
124
+ */
125
+ export function reduceReleaseState({ lastReceipt, snapshot, identity, prior }) {
126
+ if (!lastReceipt || !snapshot || snapshot.readError) return { action: 'manual', reason: 'provider observation unavailable' };
127
+ const assetsExact = snapshot.github?.assetsExact === true;
128
+ const candidateExact = npmCandidateExact(snapshot, identity);
129
+ const npmB = npmLatestIsB(snapshot, identity);
130
+ const githubB = githubIsB(snapshot, identity);
131
+ const githubPublished = githubB && snapshot.github?.published === true;
132
+ const githubLatest = githubPublished && snapshot.github?.latest === true;
133
+
134
+ if (snapshot.npm?.candidateVersion === identity.version && snapshot.npm?.candidateIntegrity
135
+ && snapshot.npm.candidateIntegrity !== identity.packageIntegrity) {
136
+ return { action: 'manual', reason: 'immutable npm candidate bytes mismatch' };
137
+ }
138
+ if (snapshot.github?.tag === identity.tag && snapshot.github?.sha
139
+ && snapshot.github.sha !== identity.candidateSha) {
140
+ return { action: 'manual', reason: 'GitHub tag resolves to competing SHA' };
141
+ }
142
+ if (snapshot.github?.latestTag && snapshot.github.latestTag !== identity.tag
143
+ && snapshot.github.latestTag !== prior?.githubLatest) {
144
+ return { action: 'manual', reason: 'GitHub latest is neither captured A nor candidate B' };
145
+ }
146
+ if (npmB && snapshot.github?.draft === true) return { action: 'compensate-npm' };
147
+ if (npmB && githubLatest) {
148
+ if (lastReceipt.state === 'channels-converged') {
149
+ return githubPublished && assetsExact && candidateExact
150
+ && snapshot.publicReceiptExact && snapshot.publicHostsExact
151
+ ? { action: 'complete' }
152
+ : { action: 'manual', reason: 'terminal public state drifted' };
153
+ }
154
+ return { action: 'finalize' };
155
+ }
156
+ if (!assetsExact) return { action: 'upload-assets' };
157
+ if (!candidateExact) return { action: 'stage-npm' };
158
+ if (!githubPublished) return { action: 'publish-github-nonlatest' };
159
+ if (!npmB) {
160
+ if (snapshot.npm?.latestVersion !== prior?.npmLatest) {
161
+ return { action: 'manual', reason: 'npm latest is neither captured A nor candidate B' };
162
+ }
163
+ return { action: 'promote-npm' };
164
+ }
165
+ if (!githubLatest) return { action: 'make-github-latest' };
166
+ return { action: 'manual', reason: 'unclassified provider state' };
167
+ }
168
+
169
+ export async function pollObservation(read, predicate, {
170
+ maxElapsedMs = 120_000,
171
+ maxAttempts = 12,
172
+ initialDelayMs = 1_000,
173
+ maxDelayMs = 15_000,
174
+ multiplier = 1.8,
175
+ jitter = () => 0,
176
+ now = () => Date.now(),
177
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
178
+ } = {}) {
179
+ const started = now();
180
+ let delay = initialDelayMs;
181
+ let last;
182
+ let lastError;
183
+ for (let attempt = 1; attempt <= maxAttempts && now() - started <= maxElapsedMs; attempt += 1) {
184
+ try {
185
+ last = await read();
186
+ const elapsedMs = now() - started;
187
+ if (predicate(last) && elapsedMs <= maxElapsedMs) return { value: last, attempt, elapsedMs };
188
+ } catch (error) {
189
+ lastError = error;
190
+ }
191
+ if (attempt === maxAttempts) break;
192
+ const remaining = maxElapsedMs - (now() - started);
193
+ if (remaining <= 0) break;
194
+ const wait = Math.max(0, Math.min(remaining, maxDelayMs, delay + jitter(delay, attempt)));
195
+ await sleep(wait);
196
+ delay = Math.min(maxDelayMs, Math.ceil(delay * multiplier));
197
+ }
198
+ const detail = lastError?.message || canonicalJson(last);
199
+ throw new Error(`visibility deadline exceeded after ${now() - started}ms: ${detail}`);
200
+ }
201
+
87
202
  export async function runReleaseTransaction({ identity, assets, adapter, privateKey, publicKey, hostVerifier }) {
88
203
  const expectedId = transactionIdFor(identity);
89
204
  const discovered = await adapter.discover(identity);
@@ -93,111 +208,180 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
93
208
 
94
209
  let chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
95
210
  let current = chain.at(-1) || null;
96
- const fence = current?.fence || discovered.fence || crypto.randomUUID();
97
- const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity, fence);
98
- const completed = new Set(chain.map(({ state }) => state));
211
+ const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity);
99
212
  const append = async (state, observation = {}) => {
100
- const receipt = stateReceipt({ identity, prior: current, state, fence, observation, privateKey });
213
+ const recoveryCompensation = state === 'compensation-intent' && current
214
+ && !TERMINAL_STATES.has(current.state) && current.state !== 'manual-intervention-required';
215
+ if (current && !ALLOWED_TRANSITIONS[current.state]?.includes(state) && current.state !== state
216
+ && !recoveryCompensation) {
217
+ throw new Error(`illegal release transition ${current.state} -> ${state}`);
218
+ }
219
+ const receipt = stateReceipt({ identity, prior: current, state, observation, privateKey });
101
220
  await adapter.appendReceipt(draft, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`);
102
221
  const observed = await adapter.readReceipt(draft, receipt.sequence);
103
222
  verifyReceipt(observed, publicKey);
104
223
  exact(observed.receiptDigest, receipt.receiptDigest, 'remote receipt');
105
224
  current = receipt;
106
- completed.add(state);
107
225
  return receipt;
108
226
  };
109
- const intend = async (state, observation = {}) => {
110
- if (!completed.has(state)) await append(state, observation);
227
+ const transition = async (state, observation = {}) => {
228
+ if (current?.state !== state) await append(state, observation);
111
229
  };
112
230
 
113
- if (!current) await append('remote-prepared', { draftId: draft.id, prior: discovered.prior });
114
- if (TERMINAL_STATES.has(current.state)) return current;
115
- if (current.fence !== fence) throw new Error('stale release transaction fence');
116
-
117
- if (!completed.has('local-hosts-verified')) {
118
- await intend('asset-upload-intent');
119
- await adapter.uploadAssets(draft, assets, identity);
120
- await intend('host-verification-intent', { source: 'sealed-local-assets' });
121
- const localHosts = await hostVerifier.verify({ source: 'local', identity, assets });
122
- if (localHosts.verdict !== 'PASS') throw new Error('local staged host verification failed');
123
- await append('local-hosts-verified', { hosts: localHosts });
124
- }
125
-
126
- if (!completed.has('npm-candidate-staged')) {
127
- await intend('npm-stage-intent');
128
- await adapter.stageNpm(identity, assets.packagePath);
129
- const stagedNpm = await adapter.observeNpmCandidate(identity);
130
- exact(stagedNpm.version, identity.version, 'npm candidate version');
131
- exact(stagedNpm.integrity, identity.packageIntegrity, 'npm candidate integrity');
132
- await append('npm-candidate-staged', { npm: stagedNpm });
133
- }
231
+ if (!current) await append('remote-prepared', {
232
+ draftId: draft.id,
233
+ prior: discovered.prior,
234
+ reconciledLegacyTransactions: discovered.legacySettled || [],
235
+ });
236
+ const prior = chain[0]?.observation?.prior || current.observation?.prior || discovered.prior;
237
+ if (!prior?.npmLatest || !prior?.githubLatest) throw new Error('sequence-zero prior generation is incomplete');
134
238
 
135
- if (!completed.has('prepared')) {
136
- await intend('remote-host-verification-intent');
137
- const staged = await adapter.materializeStagedAssets(draft, identity);
138
- try {
139
- const remoteHosts = await hostVerifier.verify({ source: 'staged', identity, assets: staged.assets, draft });
140
- if (remoteHosts.verdict !== 'PASS') throw new Error('remote staged host verification failed');
141
- await append('prepared', { hosts: remoteHosts });
142
- } finally {
143
- staged.cleanup();
239
+ for (let step = 0; step < 32; step += 1) {
240
+ const snapshot = await adapter.observeSnapshot(identity, draft, {
241
+ forceAssets: current.state === 'finalize-intent' || current.state === 'channels-converged',
242
+ });
243
+ if (current.state === 'channels-converged') {
244
+ const terminal = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
245
+ if (terminal.action === 'complete') return current;
246
+ throw new Error(`terminal release drift: ${terminal.reason || terminal.action}`);
144
247
  }
145
- }
146
-
147
- if (!completed.has('github-promoted-nonlatest')) {
148
- await intend('github-promote-intent');
149
- await adapter.publishDraftNonLatest(draft, identity);
150
- const github = await adapter.observeGithub(identity);
151
- exact(github.sha, identity.candidateSha, 'GitHub candidate SHA');
152
- if (github.latest) throw new Error('GitHub candidate advanced latest before npm convergence');
153
- await append('github-promoted-nonlatest', { github });
154
- }
155
-
156
- if (!completed.has('npm-promoted')) {
157
- await intend('npm-promote-intent');
158
- try {
159
- await adapter.promoteNpm(identity);
160
- } catch (error) {
161
- throw new Error(`npm promotion pending for ${identity.version}: ${error.message}`);
248
+ // A process may die after the provider side effect and before its observation receipt. Rebuild
249
+ // that missing receipt from fresh state before asking the reducer for the next command.
250
+ if (current.state === 'npm-stage-intent' && npmCandidateExact(snapshot, identity)) {
251
+ await transition('npm-candidate-staged', { npm: snapshot.npm, recovered: true });
252
+ continue;
253
+ }
254
+ if (current.state === 'remote-materialization-intent' && snapshot.github?.assetsExact
255
+ && npmCandidateExact(snapshot, identity)) {
256
+ await transition('prepared', { recovered: true });
257
+ continue;
258
+ }
259
+ if (current.state === 'github-promote-intent' && githubIsB(snapshot, identity)
260
+ && snapshot.github?.published && !snapshot.github?.latest) {
261
+ await transition('github-promoted-nonlatest', { github: snapshot.github, recovered: true });
262
+ continue;
263
+ }
264
+ if (current.state === 'npm-promote-intent' && npmLatestIsB(snapshot, identity)) {
265
+ await transition('npm-promoted', { npm: snapshot.npm, recovered: true });
266
+ continue;
267
+ }
268
+ if (current.state === 'github-latest-intent' && npmLatestIsB(snapshot, identity)
269
+ && githubIsB(snapshot, identity) && snapshot.github?.latest) {
270
+ await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github, recovered: true });
271
+ continue;
272
+ }
273
+ if (current.state === 'compensation-intent' && snapshot.npm?.latestVersion === prior.npmLatest) {
274
+ await transition('compensated', { npm: snapshot.npm, github: snapshot.github, recovered: true });
275
+ throw new Error('npm compensation recovered; resume the same transaction');
276
+ }
277
+ if (current.state === 'finalize-intent' && snapshot.publicReceiptExact && snapshot.publicHostsExact
278
+ && npmCandidateExact(snapshot, identity) && npmLatestIsB(snapshot, identity)
279
+ && githubIsB(snapshot, identity) && snapshot.github?.published && snapshot.github?.latest
280
+ && snapshot.github?.assetsExact) {
281
+ return append('channels-converged', { verdict: 'PASS', recovered: true });
282
+ }
283
+ const decision = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
284
+ const stageRecord = {
285
+ event: 'release-stage',
286
+ transactionId: expectedId,
287
+ payloadId: identity.payloadId || null,
288
+ sequence: current.sequence,
289
+ state: current.state,
290
+ action: decision.action,
291
+ };
292
+ console.log(process.env.GITHUB_ACTIONS === 'true'
293
+ ? `::notice title=Release stage ${decision.action}::${JSON.stringify(stageRecord)}`
294
+ : JSON.stringify(stageRecord));
295
+ if (decision.action === 'complete') return current;
296
+ if (decision.action === 'manual') {
297
+ await transition('manual-intervention-required', { reason: decision.reason, snapshot });
298
+ throw new Error(`release requires manual intervention: ${decision.reason}`);
299
+ }
300
+ if (decision.action === 'upload-assets') {
301
+ await transition('asset-upload-intent', { payloadId: identity.payloadId || null });
302
+ await adapter.uploadAssets(draft, assets, identity);
303
+ const observed = await adapter.observeSnapshot(identity, draft);
304
+ if (!observed.github?.assetsExact) throw new Error('staged GitHub payload mismatch');
305
+ await transition('npm-stage-intent', { github: observed.github });
306
+ continue;
307
+ }
308
+ if (decision.action === 'stage-npm') {
309
+ await transition('npm-stage-intent');
310
+ await adapter.stageNpm(identity, assets.packagePath);
311
+ const polled = await pollObservation(
312
+ async () => ({ npm: await adapter.observeNpm(identity) }),
313
+ (value) => npmCandidateExact(value, identity),
314
+ adapter.observationPolicy,
315
+ );
316
+ await transition('npm-candidate-staged', { npm: polled.value.npm, visibility: polled });
317
+ continue;
318
+ }
319
+ if (decision.action === 'publish-github-nonlatest') {
320
+ if (current.state === 'npm-candidate-staged') await transition('remote-materialization-intent');
321
+ if (current.state === 'remote-materialization-intent') {
322
+ const staged = await adapter.materializeStagedAssets(draft, identity);
323
+ try { await adapter.verifyMaterializedPayload?.(staged.assets, identity); } finally { staged.cleanup(); }
324
+ await transition('prepared');
325
+ }
326
+ await transition('github-promote-intent');
327
+ await adapter.publishDraftNonLatest(draft, identity);
328
+ const observed = await adapter.observeSnapshot(identity, draft);
329
+ if (!(githubIsB(observed, identity) && observed.github.published && !observed.github.latest)) {
330
+ throw new Error('GitHub non-latest publication not observed');
331
+ }
332
+ await transition('github-promoted-nonlatest', { github: observed.github });
333
+ continue;
334
+ }
335
+ if (decision.action === 'promote-npm') {
336
+ await transition('npm-promote-intent');
337
+ await adapter.promoteNpm(identity, prior.npmLatest);
338
+ const polled = await pollObservation(
339
+ async () => ({ npm: await adapter.observeNpm(identity) }),
340
+ (value) => npmLatestIsB(value, identity),
341
+ adapter.observationPolicy,
342
+ );
343
+ await transition('npm-promoted', { npm: polled.value.npm, visibility: polled });
344
+ continue;
345
+ }
346
+ if (decision.action === 'make-github-latest') {
347
+ await transition('github-latest-intent');
348
+ await adapter.makeGithubLatest(draft, identity, prior.githubLatest);
349
+ const observed = await adapter.observeSnapshot(identity, draft);
350
+ if (!(npmLatestIsB(observed, identity) && observed.github?.latest && githubIsB(observed, identity))) {
351
+ throw new Error('provider defaults not jointly observed at candidate B');
352
+ }
353
+ await transition('defaults-promoted', { npm: observed.npm, github: observed.github });
354
+ continue;
355
+ }
356
+ if (decision.action === 'compensate-npm') {
357
+ await transition('compensation-intent', { restore: prior.npmLatest });
358
+ await adapter.restoreNpmLatest(prior.npmLatest, identity.version);
359
+ const polled = await pollObservation(
360
+ () => adapter.observeNpm(identity),
361
+ (npm) => npm?.latestVersion === prior.npmLatest,
362
+ adapter.observationPolicy,
363
+ );
364
+ await transition('compensated', { npm: polled.value, github: snapshot.github, visibility: polled });
365
+ throw new Error('npm compensated to captured prior generation; resume the same transaction');
366
+ }
367
+ if (decision.action === 'finalize') {
368
+ if (current.state !== 'defaults-promoted' && current.state !== 'finalize-intent') {
369
+ await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github });
370
+ }
371
+ await transition('finalize-intent');
372
+ const final = await adapter.finalize(identity, current, hostVerifier);
373
+ if (final.verdict !== 'PASS') throw new Error('final release convergence failed');
374
+ const reobserved = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
375
+ if (!(npmLatestIsB(reobserved, identity) && reobserved.github?.latest && githubIsB(reobserved, identity))) {
376
+ throw new Error('provider defaults drifted during finalization');
377
+ }
378
+ if (!reobserved.github?.assetsExact || !reobserved.publicReceiptExact || !reobserved.publicHostsExact) {
379
+ throw new Error('public receipt, host, or artifact evidence drifted during finalization');
380
+ }
381
+ return append('channels-converged', final);
162
382
  }
163
- const npmLatest = await adapter.observeNpmLatest();
164
- exact(npmLatest.version, identity.version, 'npm latest');
165
- await append('npm-promoted', { npm: npmLatest });
166
- }
167
-
168
- // A failed GitHub-latest promotion is compensated back to A. The historical npm-promoted
169
- // receipt remains true, but is no longer the current external state, so a clean-runner retry
170
- // must explicitly re-promote B before it may retry GitHub.
171
- if (current.state === 'compensated') {
172
- await append('npm-repromote-intent');
173
- await adapter.promoteNpm(identity);
174
- const npmLatest = await adapter.observeNpmLatest();
175
- exact(npmLatest.version, identity.version, 'npm latest after compensation retry');
176
- await append('npm-repromoted', { npm: npmLatest });
177
- }
178
-
179
- if (!completed.has('defaults-promoted')) {
180
- await intend('github-latest-intent');
181
- try {
182
- await adapter.makeGithubLatest(draft, identity);
183
- } catch (error) {
184
- const observed = await adapter.observeNpmLatest();
185
- if (observed.version !== identity.version) throw new Error('npm latest changed during compensation');
186
- await intend('compensation-intent', { restore: discovered.prior?.npmLatest });
187
- await adapter.restoreNpmLatest(discovered.prior?.npmLatest, identity.version);
188
- exact((await adapter.observeNpmLatest()).version, discovered.prior?.npmLatest, 'npm compensation');
189
- await append('compensated', { reason: error.message });
190
- throw new Error(`GitHub latest promotion failed; npm compensated: ${error.message}`);
191
- }
192
- const githubLatest = await adapter.observeGithubLatest();
193
- exact(githubLatest.tag, identity.tag, 'GitHub latest');
194
- await append('defaults-promoted', { github: githubLatest });
195
383
  }
196
-
197
- await intend('finalize-intent');
198
- const final = await adapter.finalize(identity, current, hostVerifier);
199
- if (final.verdict !== 'PASS') throw new Error('final release convergence failed');
200
- return append('channels-converged', final);
384
+ throw new Error('release reducer exceeded its bounded transition count');
201
385
  }
202
386
 
203
387
  export async function abortReleaseTransaction({ identity, receipts, reason, authorized, adapter, privateKey, publicKey }) {
@@ -205,9 +389,15 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
205
389
  const chain = validateReceiptChain(receipts, identity, publicKey);
206
390
  const current = chain.at(-1);
207
391
  if (!current || TERMINAL_STATES.has(current.state)) throw new Error('release transaction is not abortable');
392
+ const snapshot = await adapter.observeSnapshot(identity);
393
+ const prior = chain[0]?.observation?.prior;
394
+ if (!prior || snapshot.npm?.latestVersion === identity.version || snapshot.github?.latest === true
395
+ || snapshot.npm?.latestVersion !== prior.npmLatest || snapshot.github?.latestTag !== prior.githubLatest) {
396
+ throw new Error('release abort cannot prove both defaults at captured prior generation');
397
+ }
208
398
  const receipt = stateReceipt({
209
- identity, prior: current, state: 'aborted', fence: current.fence,
210
- observation: { reason, authorized: true }, privateKey,
399
+ identity, prior: current, state: 'aborted',
400
+ observation: { reason, authorized: true, snapshot }, privateKey,
211
401
  });
212
402
  await adapter.appendReceipt(null, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`);
213
403
  return receipt;
@@ -33,6 +33,7 @@ import { validateProtectedPublishInvocation } from './protected-release-invocati
33
33
  import { runReleaseTransaction } from './release-transaction.mjs';
34
34
  import { liveReleaseProvider } from './release-transaction-provider.mjs';
35
35
  import { stagedHostVerifier } from './staged-host-verifier.mjs';
36
+ import { verifyPayload } from './release-payload.mjs';
36
37
 
37
38
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
38
39
  const PUBLISH = process.argv.includes('--publish');
@@ -40,6 +41,8 @@ let protectedCandidate = null;
40
41
  let sealedPackageArtifact = null;
41
42
  let publicationReceiptPath = null;
42
43
  let protectedReleaseMode = 'strict';
44
+ let verifiedPayload = null;
45
+ let aggregateEnvelope = null;
43
46
  const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
44
47
  const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
45
48
 
@@ -75,6 +78,23 @@ if (PUBLISH) {
75
78
  console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
76
79
  process.exit(1);
77
80
  }
81
+ const payloadManifestPath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD || '');
82
+ const payloadSignaturePath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD_SIGNATURE || '');
83
+ const aggregatePath = path.resolve(ROOT, process.env.RUVNET_AGGREGATE_ENVELOPE || '');
84
+ if (![payloadManifestPath, payloadSignaturePath, aggregatePath].every((file) => file.startsWith(`${evidenceRoot}${path.sep}`) && fs.existsSync(file))) {
85
+ throw new Error('protected publication requires persisted payload manifest, signature, and aggregate envelope');
86
+ }
87
+ verifiedPayload = verifyPayload({
88
+ manifest: JSON.parse(fs.readFileSync(payloadManifestPath, 'utf8')),
89
+ signature: fs.readFileSync(payloadSignaturePath, 'utf8'),
90
+ publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
91
+ root: path.dirname(payloadManifestPath),
92
+ });
93
+ aggregateEnvelope = JSON.parse(fs.readFileSync(aggregatePath, 'utf8'));
94
+ if (aggregateEnvelope.verdict !== 'PASS' || aggregateEnvelope.sha !== protectedCandidate.sha
95
+ || aggregateEnvelope.payloadId !== verifiedPayload.payloadId) {
96
+ throw new Error('aggregate envelope does not bind the protected candidate payload');
97
+ }
78
98
  }
79
99
 
80
100
  // A verdict is only about the exact committed candidate. Check-only used to permit a dirty tree
@@ -166,18 +186,15 @@ if (!PUBLISH) {
166
186
  if (PUBLISH) {
167
187
  const v = V();
168
188
  const tag = `v${v}`;
169
- const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
170
189
  const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
171
- const buildArgs = ['scripts/build-bundle.mjs', '--version', tag];
172
- if (process.env.RUVNET_RELEASE_ASSETS) buildArgs.push('--assets', process.env.RUVNET_RELEASE_ASSETS);
173
- step('D', 'prepare, stage, promote, and reconcile one signed remote transaction');
174
- runOrDie('build release bundle', process.execPath, buildArgs);
175
- runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
190
+ step('D', 'verify, stage, promote, and reconcile one persisted signed payload');
191
+ const payloadRoot = verifiedPayload.root;
192
+ const byRole = new Map(verifiedPayload.manifest.members.map((member) => [member.role, path.join(payloadRoot, member.name)]));
176
193
  const assets = {
177
- bundlePath: zip,
178
- bundleSignaturePath: `${zip}.sig`,
179
- bundleDigestPath: `${zip}.sha256`,
180
- packagePath: sealedPackageArtifact,
194
+ bundlePath: byRole.get('bundle'),
195
+ bundleSignaturePath: path.join(payloadRoot, 'ruvnet-brain.zip.sig'),
196
+ bundleDigestPath: path.join(payloadRoot, 'ruvnet-brain.zip.sha256'),
197
+ packagePath: byRole.get('npm'),
181
198
  };
182
199
  for (const asset of Object.values(assets)) {
183
200
  if (!fs.existsSync(asset)) {
@@ -185,16 +202,28 @@ if (PUBLISH) {
185
202
  process.exit(1);
186
203
  }
187
204
  }
188
- const bundleSha256 = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0];
205
+ const bundleSha256 = fs.readFileSync(assets.bundleDigestPath, 'utf8').trim().split(/\s+/)[0];
189
206
  if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
190
207
  console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);
191
208
  process.exit(1);
192
209
  }
193
210
 
194
211
  const packageIntegrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(sealedPackageArtifact)).digest('base64')}`;
212
+ if (assets.packagePath !== sealedPackageArtifact
213
+ && !fs.readFileSync(assets.packagePath).equals(fs.readFileSync(sealedPackageArtifact))) {
214
+ throw new Error('candidate receipt package and payload package bytes differ');
215
+ }
195
216
  const identity = {
196
217
  repository: 'stuinfla/ruvnet-brain', package: 'ruvnet-brain', version: v, tag,
197
- candidateSha: head, packageIntegrity, bundleSha256,
218
+ candidateSha: head,
219
+ payloadId: verifiedPayload.payloadId,
220
+ evidenceDigest: aggregateEnvelope.evidenceDigest,
221
+ packageIntegrity,
222
+ packageSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.packagePath)).digest('hex'),
223
+ packageAssetName: path.basename(assets.packagePath),
224
+ bundleSha256,
225
+ bundleSignatureSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleSignaturePath)).digest('hex'),
226
+ bundleDigestSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleDigestPath)).digest('hex'),
198
227
  };
199
228
  const privatePem = process.env.RUVNET_SIGNING_KEY;
200
229
  if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
@@ -159,17 +159,31 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
159
159
  for (const v of fs.readdirSync(cache)) {
160
160
  const root = path.join(cache, v);
161
161
  if (fs.existsSync(path.join(root, 'hooks', 'hooks.json'))) {
162
- candidates.push({ root, source: `installed:${v}`, mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
162
+ candidates.push({ root, source: `installed:${v}`, hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
163
163
  }
164
164
  }
165
165
  } catch { /* no packed install on this machine */ }
166
166
  candidates.sort((a, b) => b.mtime - a.mtime); // newest generation wins; several can coexist
167
167
  const clone = path.join(home, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
168
168
  if (fs.existsSync(path.join(clone, 'hooks', 'hooks.json'))) {
169
- candidates.push({ root: clone, source: 'marketplace-clone', mtime: 0 });
169
+ candidates.push({ root: clone, source: 'marketplace-clone', hooksFile: path.join(clone, 'hooks', 'hooks.json'), mtime: 0 });
170
170
  }
171
+ // Codex installs the same immutable payload under its own cache. A Codex-only machine has no
172
+ // Claude marketplace clone, so treating that layout as "no plugin" made the real installed
173
+ // Codex hooks impossible to self-check (the release host matrix caught this). Keep the same
174
+ // newest-generation rule and read the Codex hook registry from the installed payload itself.
175
+ const codexCache = path.join(home, '.codex', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
176
+ try {
177
+ for (const v of fs.readdirSync(codexCache)) {
178
+ const root = path.join(codexCache, v);
179
+ if (fs.existsSync(path.join(root, 'hooks', 'codex-hooks.json'))) {
180
+ candidates.push({ root, source: `codex-installed:${v}`, hooksFile: path.join(root, 'hooks', 'codex-hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'codex-hooks.json')).mtimeMs, codex: true });
181
+ }
182
+ }
183
+ } catch { /* no Codex plugin cache on this machine */ }
171
184
  if (repo && fs.existsSync(path.join(repo, 'plugin', 'hooks', 'hooks.json'))) {
172
- candidates.push({ root: path.join(repo, 'plugin'), source: 'checkout', mtime: 0 });
185
+ const root = path.join(repo, 'plugin');
186
+ candidates.push({ root, source: 'checkout', hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: 0 });
173
187
  }
174
188
  const chosen = candidates[0];
175
189
  if (!chosen) return { ok: false, reason: 'no installed ruvnet-brain plugin payload found on this machine' };
@@ -177,8 +191,9 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
177
191
  ok: true,
178
192
  root: chosen.root,
179
193
  source: chosen.source,
180
- hooksFile: path.join(chosen.root, 'hooks', 'hooks.json'),
194
+ hooksFile: chosen.hooksFile,
181
195
  shimFile: path.join(chosen.root, 'scripts', 'hook-shim.mjs'),
196
+ codex: Boolean(chosen.codex),
182
197
  alternates: candidates.slice(1).map((c) => c.source),
183
198
  };
184
199
  }
@@ -480,7 +495,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
480
495
  const shimId = reg.shimIdIn(r.command);
481
496
  const entry = shimId ? table[shimId] : null;
482
497
  const contract = entry ? null : contracts.find((c) => reg.contractMatches(c, { layer: 'plugin', event: r.event, matcher: r.matcher, command: r.command }));
483
- const mode = entry?.mode ?? contract?.mode ?? null;
498
+ const mode = entry?.mode ?? contract?.mode ?? (s.codex ? 'advisory' : null);
484
499
  const handler = entry?.file ?? reg.basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null;
485
500
  const rec = { ...r, handler, shimId, mode };
486
501