ruvnet-brain 4.0.90-dev → 4.3.1-dev

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.
Files changed (74) hide show
  1. package/README.md +31 -7
  2. package/bin/install.mjs +153 -22
  3. package/console/architecture.html +12 -14
  4. package/console/install-mockup.html +3 -3
  5. package/kb/zip-extract.mjs +8 -2
  6. package/package.json +7 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/hooks/codex-hooks.json +1 -1
  10. package/plugin/hooks/hooks.json +10 -0
  11. package/plugin/host-adapters/claude.json +11 -0
  12. package/plugin/host-adapters/codex.json +11 -0
  13. package/plugin/mcp/managed-cli-interface.mjs +77 -10
  14. package/plugin/mcp/server.mjs +2 -1
  15. package/plugin/scripts/anticipate.sh +30 -1
  16. package/plugin/scripts/capability-claim-evidence.mjs +431 -0
  17. package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
  18. package/plugin/scripts/capability-registry.mjs +6 -0
  19. package/plugin/scripts/capability-routing.mjs +70 -0
  20. package/plugin/scripts/continuation-gate.mjs +68 -2
  21. package/plugin/scripts/coverage-integrity.mjs +575 -0
  22. package/plugin/scripts/decision-outcomes.mjs +0 -0
  23. package/plugin/scripts/ground-ruvnet.sh +2 -2
  24. package/plugin/scripts/nightly-controller.mjs +19 -2
  25. package/plugin/scripts/project-progression-contract.mjs +409 -0
  26. package/plugin/scripts/project-progression-hook.mjs +196 -0
  27. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  28. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  29. package/plugin/scripts/project-progression-store.mjs +248 -0
  30. package/plugin/scripts/project-store-resolver.mjs +106 -0
  31. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  32. package/plugin/scripts/session-start-core.mjs +22 -18
  33. package/plugin/scripts/spend-guard.mjs +6 -1
  34. package/scripts/adr-072-completion.mjs +98 -0
  35. package/scripts/behavioral-l1-l4.mjs +11 -8
  36. package/scripts/brain-score.mjs +23 -9
  37. package/scripts/build-bundle.mjs +94 -8
  38. package/scripts/card-from-source.mjs +140 -0
  39. package/scripts/corpus-aggregates.mjs +194 -0
  40. package/scripts/corpus-candidate.mjs +314 -0
  41. package/scripts/corpus-reconcile.mjs +540 -0
  42. package/scripts/corpus-seed-publish.mjs +110 -0
  43. package/scripts/coverage-integrity.mjs +3 -0
  44. package/scripts/gist-receipts.mjs +183 -0
  45. package/scripts/git-hooks/pre-push +21 -0
  46. package/scripts/host-registry.mjs +118 -0
  47. package/scripts/independent-review-receipt.mjs +499 -0
  48. package/scripts/ingest-gists.mjs +34 -5
  49. package/scripts/ingest-new-repos.mjs +127 -0
  50. package/scripts/metaharness-gate.mjs +58 -0
  51. package/scripts/nightly-gists.sh +3 -3
  52. package/scripts/nightly-wrapper.sh +105 -64
  53. package/scripts/product-integrity-contract.mjs +156 -0
  54. package/scripts/public-inventory.mjs +4 -0
  55. package/scripts/public-verification-aggregate.mjs +342 -0
  56. package/scripts/public-verification-finalizer.mjs +59 -0
  57. package/scripts/public-verification-inputs.mjs +473 -0
  58. package/scripts/public-verification-lane.mjs +244 -0
  59. package/scripts/publication-receipt.mjs +50 -6
  60. package/scripts/rebuild-gists-from-receipts.mjs +291 -0
  61. package/scripts/release-projection.mjs +86 -0
  62. package/scripts/release-transaction-provider.mjs +43 -102
  63. package/scripts/release-transaction.mjs +165 -49
  64. package/scripts/release.mjs +158 -3
  65. package/scripts/restore-local-ingests.mjs +2 -2
  66. package/scripts/retrieval-canary.mjs +533 -0
  67. package/scripts/routing-flywheel.mjs +12 -1
  68. package/scripts/rvf-generation.mjs +50 -0
  69. package/scripts/self-update.mjs +19 -44
  70. package/scripts/source-coverage.mjs +415 -0
  71. package/scripts/source-scope-receipt.mjs +84 -0
  72. package/scripts/sync-census.mjs +0 -0
  73. package/scripts/wired-check.mjs +114 -24
  74. package/scripts/worktree-integrity.mjs +123 -0
@@ -0,0 +1,409 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const PROJECT_PROGRESSION_SCHEMA = 'ruvnet-brain.project-progression';
4
+ export const PROJECT_PROGRESSION_VERSION = 1;
5
+
6
+ function canonicalize(value) {
7
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
8
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
9
+ if (Array.isArray(value)) return value.map(canonicalize);
10
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
11
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
12
+ }
13
+ throw new TypeError('progression values must be JSON-compatible');
14
+ }
15
+
16
+ export function digestCanonical(value) {
17
+ return crypto.createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
18
+ }
19
+
20
+ function requireRecord(value, label) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
22
+ }
23
+
24
+ function requireString(value, label) {
25
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} must be a non-empty string`);
26
+ }
27
+
28
+ function keyPart(value) {
29
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'unknown';
30
+ }
31
+
32
+ const SOURCE_FIELDS = Object.freeze([
33
+ 'checkoutPath', 'worktreeId', 'branch', 'head', 'trackedDigest', 'untrackedDigest', 'dirtyTreeDigest',
34
+ ]);
35
+ const STATE_ARRAY_FIELDS = Object.freeze([
36
+ 'plan', 'completed', 'inProgress', 'blockers', 'failures', 'decisions', 'changedFiles', 'commands',
37
+ 'proofArtifacts', 'untested', 'resumeConflicts',
38
+ ]);
39
+ const STATE_VALUE_FIELDS = Object.freeze([
40
+ 'currentGoal', 'acceptanceContract', 'activeProcess', 'activeStep', 'nextAction',
41
+ ]);
42
+
43
+ function eventKeyFor(value) {
44
+ const identityDigest = digestCanonical({
45
+ projectId: value.projectIdentity.id,
46
+ host: value.hostIdentity.host,
47
+ session: value.sessionIdentity,
48
+ sequence: value.sequence,
49
+ dedupId: value.dedupId,
50
+ });
51
+ return [
52
+ 'project-progress-v1',
53
+ keyPart(value.projectIdentity.id),
54
+ keyPart(value.hostIdentity.host),
55
+ keyPart(value.sessionIdentity),
56
+ String(value.sequence).padStart(12, '0'),
57
+ identityDigest,
58
+ ].join('-');
59
+ }
60
+
61
+ function secretKind(key) {
62
+ const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
63
+ if (normalized.includes('privatekey')) return 'private-key';
64
+ if (normalized.includes('apikey')) return 'api-key';
65
+ if (normalized.includes('authorization')) return 'authorization';
66
+ if (normalized.includes('password') || normalized.includes('passwd')) return 'password';
67
+ if (normalized.includes('token')) return 'token';
68
+ if (normalized.includes('secret')) return 'secret';
69
+ if (normalized.includes('credential')) return 'credential';
70
+ return null;
71
+ }
72
+
73
+ const INLINE_SECRETS = Object.freeze([
74
+ {
75
+ kind: 'private-key',
76
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
77
+ replace: '[REDACTED:private-key]',
78
+ },
79
+ {
80
+ kind: 'bearer-token',
81
+ pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
82
+ replace: 'Bearer [REDACTED:bearer-token]',
83
+ },
84
+ {
85
+ kind: 'api-key',
86
+ pattern: /\b(?:sk|api-key|ghp|github_pat)[-_][A-Za-z0-9._-]{8,}/gi,
87
+ replace: '[REDACTED:api-key]',
88
+ },
89
+ {
90
+ kind: 'password',
91
+ pattern: /\b(password|passwd)\s*[:=]\s*[^\s;,]+/gi,
92
+ replace: '$1=[REDACTED:password]',
93
+ },
94
+ {
95
+ kind: 'token',
96
+ pattern: /\b(token|secret|credential)\s*[:=]\s*[^\s;,]+/gi,
97
+ replace: '$1=[REDACTED:token]',
98
+ },
99
+ ]);
100
+
101
+ export function redactProgression(value) {
102
+ const redactions = [];
103
+ const mark = (path, kind) => {
104
+ if (!redactions.some((row) => row.path === path && row.kind === kind)) redactions.push({ path, kind });
105
+ };
106
+ const walk = (current, currentPath) => {
107
+ if (typeof current === 'string') {
108
+ let output = current;
109
+ for (const rule of INLINE_SECRETS) {
110
+ rule.pattern.lastIndex = 0;
111
+ if (rule.pattern.test(output)) {
112
+ mark(currentPath, rule.kind);
113
+ rule.pattern.lastIndex = 0;
114
+ output = output.replace(rule.pattern, rule.replace);
115
+ }
116
+ }
117
+ return output;
118
+ }
119
+ if (Array.isArray(current)) return current.map((item, index) => walk(item, `${currentPath}[${index}]`));
120
+ if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
121
+ return Object.fromEntries(Object.keys(current).sort().map((key) => {
122
+ const path = `${currentPath}.${key}`;
123
+ const kind = secretKind(key);
124
+ if (kind && current[key] !== null && current[key] !== undefined) {
125
+ mark(path, kind);
126
+ return [key, `[REDACTED:${kind}]`];
127
+ }
128
+ return [key, walk(current[key], path)];
129
+ }));
130
+ }
131
+ return canonicalize(current);
132
+ };
133
+ const redacted = walk(value, '$');
134
+ redactions.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
135
+ return { value: redacted, redactions };
136
+ }
137
+
138
+ export function createProgressionSnapshot(input) {
139
+ requireRecord(input, 'snapshot input');
140
+ requireRecord(input.projectIdentity, 'projectIdentity');
141
+ requireString(input.projectIdentity.id, 'projectIdentity.id');
142
+ requireString(input.projectIdentity.canonicalAgentDbPath, 'projectIdentity.canonicalAgentDbPath');
143
+ requireRecord(input.sourceIdentity, 'sourceIdentity');
144
+ for (const field of SOURCE_FIELDS) requireString(input.sourceIdentity[field], `sourceIdentity.${field}`);
145
+ requireRecord(input.hostIdentity, 'hostIdentity');
146
+ requireString(input.hostIdentity.host, 'hostIdentity.host');
147
+ requireString(input.hostIdentity.adapterVersion, 'hostIdentity.adapterVersion');
148
+ requireString(input.sessionIdentity, 'sessionIdentity');
149
+ requireString(input.dedupId, 'dedupId');
150
+ requireString(input.trigger, 'trigger');
151
+ requireRecord(input.completeProjectState, 'completeProjectState');
152
+ for (const field of STATE_ARRAY_FIELDS) {
153
+ if (!Array.isArray(input.completeProjectState[field])) throw new TypeError(`completeProjectState.${field} must be an array`);
154
+ }
155
+ for (const field of STATE_VALUE_FIELDS) {
156
+ if (!(field in input.completeProjectState)) throw new TypeError(`completeProjectState.${field} is required`);
157
+ }
158
+ if (!Number.isSafeInteger(input.sequence) || input.sequence < 0) throw new TypeError('sequence must be a non-negative safe integer');
159
+ if (!Number.isFinite(Date.parse(input.occurredAt))) throw new TypeError('occurredAt must be an ISO-8601 timestamp');
160
+ if (!Array.isArray(input.parentEventKeys)) throw new TypeError('parentEventKeys must be an array');
161
+
162
+ const draft = canonicalize({
163
+ schema: PROJECT_PROGRESSION_SCHEMA,
164
+ schemaVersion: PROJECT_PROGRESSION_VERSION,
165
+ dedupId: input.dedupId,
166
+ projectIdentity: input.projectIdentity,
167
+ hostIdentity: input.hostIdentity,
168
+ sessionIdentity: input.sessionIdentity,
169
+ sequence: input.sequence,
170
+ occurredAt: input.occurredAt,
171
+ trigger: input.trigger,
172
+ parentEventKeys: [...input.parentEventKeys].sort(),
173
+ completeProjectState: input.completeProjectState,
174
+ sourceIdentity: input.sourceIdentity,
175
+ });
176
+ const { value: redacted, redactions } = redactProgression(draft);
177
+ const snapshot = canonicalize({ ...redacted, eventKey: eventKeyFor(redacted), redactions });
178
+ return Object.freeze({ ...snapshot, payloadDigest: digestCanonical(snapshot) });
179
+ }
180
+
181
+ export function validateProgressionSnapshot(snapshot, { expectedProjectIdentity } = {}) {
182
+ const errors = [];
183
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
184
+ return { ok: false, errors: ['snapshot must be an object'] };
185
+ }
186
+ if (snapshot.schema !== PROJECT_PROGRESSION_SCHEMA) errors.push('unsupported snapshot schema');
187
+ if (snapshot.schemaVersion !== PROJECT_PROGRESSION_VERSION) errors.push('unsupported snapshot version');
188
+ if (!snapshot.projectIdentity || typeof snapshot.projectIdentity !== 'object') errors.push('missing project identity');
189
+ if (!snapshot.hostIdentity || typeof snapshot.hostIdentity !== 'object') errors.push('missing host identity');
190
+ if (!snapshot.sourceIdentity || typeof snapshot.sourceIdentity !== 'object') errors.push('missing source identity');
191
+ if (!snapshot.completeProjectState || typeof snapshot.completeProjectState !== 'object') errors.push('missing complete project state');
192
+ if (!Array.isArray(snapshot.parentEventKeys)) errors.push('parentEventKeys must be an array');
193
+ if (!Array.isArray(snapshot.redactions)) errors.push('redactions must be an array');
194
+ if (!Number.isSafeInteger(snapshot.sequence) || snapshot.sequence < 0) errors.push('invalid sequence');
195
+ if (!Number.isFinite(Date.parse(snapshot.occurredAt))) errors.push('invalid occurredAt');
196
+
197
+ if (snapshot.sourceIdentity) {
198
+ for (const field of SOURCE_FIELDS) {
199
+ if (typeof snapshot.sourceIdentity[field] !== 'string' || !snapshot.sourceIdentity[field]) {
200
+ errors.push(`missing sourceIdentity.${field}`);
201
+ }
202
+ }
203
+ }
204
+ if (snapshot.completeProjectState) {
205
+ for (const field of STATE_ARRAY_FIELDS) {
206
+ if (!Array.isArray(snapshot.completeProjectState[field])) errors.push(`invalid completeProjectState.${field}`);
207
+ }
208
+ for (const field of STATE_VALUE_FIELDS) {
209
+ if (!(field in snapshot.completeProjectState)) errors.push(`missing completeProjectState.${field}`);
210
+ }
211
+ }
212
+ if (expectedProjectIdentity && snapshot.projectIdentity) {
213
+ if (snapshot.projectIdentity.id !== expectedProjectIdentity.id) errors.push('foreign project id');
214
+ if (snapshot.projectIdentity.canonicalAgentDbPath !== expectedProjectIdentity.canonicalAgentDbPath) {
215
+ errors.push('foreign canonical AgentDB path');
216
+ }
217
+ }
218
+ if (snapshot.projectIdentity && snapshot.hostIdentity && typeof snapshot.sessionIdentity === 'string'
219
+ && Number.isSafeInteger(snapshot.sequence) && typeof snapshot.dedupId === 'string') {
220
+ try {
221
+ if (snapshot.eventKey !== eventKeyFor(snapshot)) errors.push('event key mismatch');
222
+ } catch { errors.push('event key is unverifiable'); }
223
+ }
224
+ try {
225
+ const body = canonicalize(snapshot);
226
+ delete body.payloadDigest;
227
+ if (snapshot.payloadDigest !== digestCanonical(body)) errors.push('payload digest mismatch');
228
+ } catch { errors.push('payload is not canonical JSON'); }
229
+ if (Array.isArray(snapshot.redactions)
230
+ && snapshot.redactions.some((row) => !row || typeof row.path !== 'string' || typeof row.kind !== 'string'
231
+ || Object.keys(row).sort().join(',') !== 'kind,path')) errors.push('invalid redaction marker');
232
+ try {
233
+ if (redactProgression(snapshot).redactions.length > 0) errors.push('unredacted secret material');
234
+ } catch { /* canonical validation already reports the malformed payload */ }
235
+
236
+ return { ok: errors.length === 0, errors: [...new Set(errors)] };
237
+ }
238
+
239
+ function rejectedRow(snapshot, reasons) {
240
+ return { eventKey: typeof snapshot?.eventKey === 'string' ? snapshot.eventKey : null, reasons: [...new Set(reasons)].sort() };
241
+ }
242
+
243
+ function sortRejected(rows) {
244
+ return rows.sort((left, right) => String(left.eventKey).localeCompare(String(right.eventKey))
245
+ || left.reasons.join('|').localeCompare(right.reasons.join('|')));
246
+ }
247
+
248
+ function cyclicKeys(entries) {
249
+ const state = new Map();
250
+ const stack = [];
251
+ const cyclic = new Set();
252
+ const visit = (key) => {
253
+ if (state.get(key) === 2) return;
254
+ if (state.get(key) === 1) {
255
+ const start = stack.indexOf(key);
256
+ for (const member of stack.slice(start)) cyclic.add(member);
257
+ return;
258
+ }
259
+ state.set(key, 1);
260
+ stack.push(key);
261
+ for (const parent of entries.get(key)?.parentEventKeys ?? []) if (entries.has(parent)) visit(parent);
262
+ stack.pop();
263
+ state.set(key, 2);
264
+ };
265
+ for (const key of [...entries.keys()].sort()) visit(key);
266
+ return cyclic;
267
+ }
268
+
269
+ function uniqueSorted(values) {
270
+ const byDigest = new Map();
271
+ for (const value of values) byDigest.set(digestCanonical(value), canonicalize(value));
272
+ return [...byDigest.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, value]) => value);
273
+ }
274
+
275
+ function conflict(field, heads, valueFor) {
276
+ const values = heads.map((head) => ({ head: head.eventKey, value: canonicalize(valueFor(head)) }));
277
+ return { field, values };
278
+ }
279
+
280
+ function planItemId(item) {
281
+ return typeof item?.id === 'string' && item.id ? item.id : digestCanonical(item);
282
+ }
283
+
284
+ function mergeHeads(heads) {
285
+ if (heads.length === 1) {
286
+ return canonicalize({
287
+ ...heads[0].completeProjectState,
288
+ sourceIdentity: heads[0].sourceIdentity,
289
+ journalHeads: [heads[0].eventKey],
290
+ resumeConflicts: [],
291
+ });
292
+ }
293
+
294
+ const conflicts = [];
295
+ const state = {};
296
+ for (const field of STATE_ARRAY_FIELDS.filter((field) => !['plan', 'resumeConflicts'].includes(field))) {
297
+ state[field] = uniqueSorted(heads.flatMap((head) => head.completeProjectState[field]));
298
+ }
299
+ for (const field of STATE_VALUE_FIELDS) {
300
+ const values = uniqueSorted(heads.map((head) => head.completeProjectState[field]));
301
+ if (values.length === 1) state[field] = values[0];
302
+ else {
303
+ state[field] = null;
304
+ conflicts.push(conflict(field, heads, (head) => head.completeProjectState[field]));
305
+ }
306
+ }
307
+
308
+ const planById = new Map();
309
+ for (const head of heads) {
310
+ for (const item of head.completeProjectState.plan) {
311
+ planById.set(planItemId(item), true);
312
+ }
313
+ }
314
+ state.plan = [];
315
+ for (const id of [...planById.keys()].sort()) {
316
+ const rows = heads.map((head) => ({
317
+ head: head.eventKey,
318
+ value: head.completeProjectState.plan.find((item) => planItemId(item) === id) ?? null,
319
+ }));
320
+ const variants = uniqueSorted(rows.map((row) => row.value));
321
+ state.plan.push(...variants.filter((value) => value !== null));
322
+ if (variants.length > 1) {
323
+ conflicts.push({
324
+ field: `plan.${id}`,
325
+ values: rows.map((row) => ({ head: row.head, value: canonicalize(row.value) }))
326
+ .sort((left, right) => left.head.localeCompare(right.head)),
327
+ });
328
+ }
329
+ }
330
+
331
+ const sources = uniqueSorted(heads.map((head) => head.sourceIdentity));
332
+ if (sources.length === 1) state.sourceIdentity = sources[0];
333
+ else {
334
+ state.sourceIdentity = null;
335
+ conflicts.push(conflict('sourceIdentity', heads, (head) => head.sourceIdentity));
336
+ }
337
+ const carried = heads.flatMap((head) => head.completeProjectState.resumeConflicts);
338
+ state.resumeConflicts = uniqueSorted([...carried, ...conflicts]);
339
+ state.journalHeads = heads.map((head) => head.eventKey);
340
+ return canonicalize(state);
341
+ }
342
+
343
+ export function restoreProjectProgression(snapshots, { expectedProjectIdentity } = {}) {
344
+ if (!Array.isArray(snapshots)) throw new TypeError('snapshots must be an array');
345
+ const rejected = [];
346
+ const candidates = new Map();
347
+ const candidateGroups = new Map();
348
+ const ordered = [...snapshots].sort((left, right) => String(left?.eventKey).localeCompare(String(right?.eventKey))
349
+ || String(left?.payloadDigest).localeCompare(String(right?.payloadDigest)));
350
+
351
+ for (const snapshot of ordered) {
352
+ const verdict = validateProgressionSnapshot(snapshot, { expectedProjectIdentity });
353
+ if (!verdict.ok) {
354
+ rejected.push(rejectedRow(snapshot, verdict.errors));
355
+ continue;
356
+ }
357
+ if (!candidateGroups.has(snapshot.eventKey)) candidateGroups.set(snapshot.eventKey, []);
358
+ candidateGroups.get(snapshot.eventKey).push(snapshot);
359
+ }
360
+ for (const [eventKey, group] of candidateGroups) {
361
+ if (new Set(group.map((snapshot) => snapshot.payloadDigest)).size > 1) {
362
+ rejected.push(...group.map((snapshot) => rejectedRow(snapshot, ['event key collision'])));
363
+ } else {
364
+ candidates.set(eventKey, group[0]);
365
+ }
366
+ }
367
+
368
+ const rejectBrokenEdges = () => {
369
+ const removals = [];
370
+ for (const snapshot of candidates.values()) {
371
+ if (snapshot.parentEventKeys.some((parent) => !candidates.has(parent))) {
372
+ removals.push([snapshot, 'missing parent']);
373
+ continue;
374
+ }
375
+ if (snapshot.parentEventKeys.some((parent) => {
376
+ const ancestor = candidates.get(parent);
377
+ return ancestor.sessionIdentity === snapshot.sessionIdentity && ancestor.sequence >= snapshot.sequence;
378
+ })) removals.push([snapshot, 'non-monotonic session sequence']);
379
+ }
380
+ for (const [snapshot, reason] of removals) {
381
+ candidates.delete(snapshot.eventKey);
382
+ rejected.push(rejectedRow(snapshot, [reason]));
383
+ }
384
+ return removals.length > 0;
385
+ };
386
+ while (rejectBrokenEdges()) { /* reject descendants of rejected evidence */ }
387
+
388
+ const cycles = cyclicKeys(candidates);
389
+ for (const key of [...cycles].sort()) {
390
+ const snapshot = candidates.get(key);
391
+ candidates.delete(key);
392
+ rejected.push(rejectedRow(snapshot, ['causal cycle']));
393
+ }
394
+ while (rejectBrokenEdges()) { /* reject descendants of cycles */ }
395
+
396
+ const consumed = new Set([...candidates.values()].flatMap((snapshot) => snapshot.parentEventKeys));
397
+ const heads = [...candidates.values()].filter((snapshot) => !consumed.has(snapshot.eventKey))
398
+ .sort((left, right) => left.eventKey.localeCompare(right.eventKey));
399
+ const headKeys = heads.map((snapshot) => snapshot.eventKey);
400
+ const causallyStale = [...candidates.keys()].filter((key) => !headKeys.includes(key)).sort();
401
+ rejected.push(...causallyStale.map((key) => rejectedRow(candidates.get(key), ['causally stale'])));
402
+ return {
403
+ ok: heads.length > 0,
404
+ heads: headKeys,
405
+ causallyStale,
406
+ rejected: sortRejected(rejected),
407
+ state: heads.length ? mergeHeads(heads) : null,
408
+ };
409
+ }
@@ -0,0 +1,196 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createProgressionSnapshot } from './project-progression-contract.mjs';
5
+ import { ProjectProgressionStore } from './project-progression-store.mjs';
6
+
7
+ const HOSTS = new Set(['claude', 'codex']);
8
+ const CAPTURE_TRIGGERS = new Set([
9
+ 'SessionStart',
10
+ 'UserPromptSubmit',
11
+ 'PreToolUse',
12
+ 'PostToolUse',
13
+ 'Stop',
14
+ 'SubagentStop',
15
+ 'PreCompact',
16
+ 'SessionEnd',
17
+ ]);
18
+ const PLUGIN_JSON = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.claude-plugin', 'plugin.json');
19
+
20
+ function requireRecord(value, label) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
22
+ }
23
+
24
+ function requireString(value, label) {
25
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} must be a non-empty string`);
26
+ return value.trim();
27
+ }
28
+
29
+ function own(value, key) {
30
+ return Object.prototype.hasOwnProperty.call(value, key);
31
+ }
32
+
33
+ function aliased(value, camel, snake) {
34
+ const hasCamel = own(value, camel);
35
+ const hasSnake = own(value, snake);
36
+ if (hasCamel && hasSnake) throw new Error(`ambiguous progression field: ${camel}/${snake}`);
37
+ return hasCamel ? value[camel] : value[snake];
38
+ }
39
+
40
+ function progressionExtension(payload) {
41
+ const hasCamel = own(payload, 'projectProgression');
42
+ const hasSnake = own(payload, 'project_progression');
43
+ if (hasCamel && hasSnake) throw new Error('ambiguous progression extension');
44
+ if (!hasCamel && !hasSnake) return null;
45
+ const value = hasCamel ? payload.projectProgression : payload.project_progression;
46
+ requireRecord(value, 'project progression');
47
+ return value;
48
+ }
49
+
50
+ export function hasProjectProgression(payload) {
51
+ requireRecord(payload, 'host payload');
52
+ return progressionExtension(payload) !== null;
53
+ }
54
+
55
+ export function readProgressionAdapterVersion() {
56
+ let parsed;
57
+ try { parsed = JSON.parse(fs.readFileSync(PLUGIN_JSON, 'utf8')); } catch {
58
+ throw new Error(`progression adapter version is unreadable: ${PLUGIN_JSON}`);
59
+ }
60
+ return requireString(parsed?.version, 'progression adapter version');
61
+ }
62
+
63
+ function normalizeSourceIdentity(value, checkoutRoot) {
64
+ requireRecord(value, 'sourceIdentity');
65
+ const checkoutPath = requireString(value.checkoutPath, 'sourceIdentity.checkoutPath');
66
+ let canonicalCheckout;
67
+ try { canonicalCheckout = fs.realpathSync.native(checkoutPath); } catch {
68
+ throw new Error('source identity checkout path does not resolve to the active checkout');
69
+ }
70
+ if (canonicalCheckout !== checkoutRoot) {
71
+ throw new Error('source identity checkout path does not match the active checkout path');
72
+ }
73
+ return { ...value, checkoutPath: canonicalCheckout };
74
+ }
75
+
76
+ const OBSERVATION_TEXT_LIMIT = 4_096;
77
+
78
+ function boundedText(value) {
79
+ if (typeof value !== 'string' || !value) return undefined;
80
+ return value.length <= OBSERVATION_TEXT_LIMIT
81
+ ? value
82
+ : `${value.slice(0, OBSERVATION_TEXT_LIMIT)}...[truncated]`;
83
+ }
84
+
85
+ function toolAction(payload) {
86
+ const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
87
+ const command = boundedText(input.command ?? input.cmd);
88
+ const filePath = boundedText(input.file_path ?? input.path);
89
+ const action = command || filePath;
90
+ if (!action && !payload.tool_name) return null;
91
+
92
+ const response = payload.tool_response;
93
+ const responseRecord = response && typeof response === 'object' && !Array.isArray(response)
94
+ ? response
95
+ : null;
96
+ const exitCode = responseRecord && [responseRecord.exit_code, responseRecord.exitCode, responseRecord.status]
97
+ .find((value) => Number.isSafeInteger(value));
98
+ const interrupted = responseRecord?.interrupted === true;
99
+ const failed = interrupted || (Number.isSafeInteger(exitCode) && exitCode !== 0);
100
+ const outcome = payload.hook_event_name === 'PostToolUse'
101
+ ? (failed ? 'failure' : response === undefined ? 'unknown' : 'success')
102
+ : 'pending';
103
+ const observation = {
104
+ trigger: payload.hook_event_name,
105
+ tool: boundedText(payload.tool_name) || 'unknown',
106
+ ...(command ? { command } : {}),
107
+ ...(filePath && !command ? { filePath } : {}),
108
+ outcome,
109
+ ...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
110
+ ...(interrupted ? { interrupted: true } : {}),
111
+ };
112
+ if (responseRecord) {
113
+ const stdout = boundedText(responseRecord.stdout);
114
+ const stderr = boundedText(responseRecord.stderr);
115
+ if (stdout) observation.stdout = stdout;
116
+ if (stderr) observation.stderr = stderr;
117
+ } else {
118
+ const text = boundedText(response);
119
+ if (text) observation.result = text;
120
+ }
121
+ return observation;
122
+ }
123
+
124
+ function enrichStateWithObservation(state, payload) {
125
+ requireRecord(state, 'completeProjectState');
126
+ const observation = toolAction(payload);
127
+ if (!observation) return state;
128
+ const commands = Array.isArray(state.commands) ? state.commands : [];
129
+ const failures = Array.isArray(state.failures) ? state.failures : [];
130
+ return {
131
+ ...state,
132
+ commands: [...commands, observation],
133
+ ...(observation.outcome === 'failure' ? { failures: [...failures, observation] } : {}),
134
+ };
135
+ }
136
+
137
+ function verifyReceipt(snapshot, receipt) {
138
+ if (!receipt || typeof receipt !== 'object'
139
+ || receipt.eventKey !== snapshot.eventKey
140
+ || receipt.payloadDigest !== snapshot.payloadDigest
141
+ || receipt.readbackDigest !== snapshot.payloadDigest) {
142
+ throw new Error('progression persistence returned an unverifiable exact-readback receipt');
143
+ }
144
+ }
145
+
146
+ export function captureProjectTransition({
147
+ host,
148
+ payload,
149
+ projectDir,
150
+ adapterVersion = readProgressionAdapterVersion(),
151
+ storeFactory = (options) => new ProjectProgressionStore(options),
152
+ } = {}) {
153
+ const normalizedHost = requireString(host, 'host').toLowerCase();
154
+ if (!HOSTS.has(normalizedHost)) throw new Error(`unsupported progression host: ${normalizedHost}`);
155
+ requireRecord(payload, 'host payload');
156
+ const sessionIdentity = requireString(payload.session_id, 'host payload session_id');
157
+ const trigger = requireString(payload.hook_event_name, 'host payload hook_event_name');
158
+ if (!CAPTURE_TRIGGERS.has(trigger)) throw new Error(`unsupported progression trigger: ${trigger}`);
159
+ requireString(adapterVersion, 'adapterVersion');
160
+ const progression = progressionExtension(payload);
161
+ if (!progression) throw new Error('host payload is missing a complete progression extension');
162
+
163
+ const requestedStorePath = requireString(
164
+ aliased(progression, 'canonicalAgentDbPath', 'canonical_agent_db_path'),
165
+ 'canonicalAgentDbPath',
166
+ );
167
+ if (!path.isAbsolute(requestedStorePath)) throw new Error('canonicalAgentDbPath must be absolute');
168
+ const store = storeFactory({ projectDir, requestedStorePath });
169
+ requireRecord(store?.resolution, 'progression store resolution');
170
+ if (path.resolve(requestedStorePath) !== store.resolution.canonicalAgentDbPath) {
171
+ throw new Error('foreign store root rejected');
172
+ }
173
+
174
+ const sourceIdentity = normalizeSourceIdentity(
175
+ aliased(progression, 'sourceIdentity', 'source_identity'),
176
+ store.resolution.checkoutRoot,
177
+ );
178
+ const snapshot = createProgressionSnapshot({
179
+ projectIdentity: store.resolution.projectIdentity,
180
+ sourceIdentity,
181
+ hostIdentity: { host: normalizedHost, adapterVersion },
182
+ sessionIdentity,
183
+ sequence: progression.sequence,
184
+ occurredAt: aliased(progression, 'occurredAt', 'occurred_at'),
185
+ trigger,
186
+ parentEventKeys: aliased(progression, 'parentEventKeys', 'parent_event_keys'),
187
+ dedupId: aliased(progression, 'dedupId', 'dedup_id'),
188
+ completeProjectState: enrichStateWithObservation(
189
+ aliased(progression, 'completeProjectState', 'complete_project_state'),
190
+ payload,
191
+ ),
192
+ });
193
+ const receipt = store.capture(snapshot);
194
+ verifyReceipt(snapshot, receipt);
195
+ return { snapshot, receipt };
196
+ }