ruvnet-brain 4.2.2-dev → 4.3.1

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 (73) hide show
  1. package/README.md +15 -14
  2. package/bin/install.mjs +11 -2
  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/ground-ruvnet.sh +2 -2
  23. package/plugin/scripts/nightly-controller.mjs +19 -2
  24. package/plugin/scripts/project-progression-contract.mjs +409 -0
  25. package/plugin/scripts/project-progression-hook.mjs +196 -0
  26. package/plugin/scripts/project-progression-outbox.mjs +95 -0
  27. package/plugin/scripts/project-progression-session-start.mjs +183 -0
  28. package/plugin/scripts/project-progression-store.mjs +248 -0
  29. package/plugin/scripts/project-store-resolver.mjs +106 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +29 -1
  31. package/plugin/scripts/session-start-core.mjs +22 -18
  32. package/plugin/scripts/spend-guard.mjs +6 -1
  33. package/scripts/adr-072-completion.mjs +98 -0
  34. package/scripts/behavioral-l1-l4.mjs +11 -8
  35. package/scripts/brain-score.mjs +7 -2
  36. package/scripts/build-bundle.mjs +82 -6
  37. package/scripts/card-from-source.mjs +32 -6
  38. package/scripts/corpus-aggregates.mjs +194 -0
  39. package/scripts/corpus-candidate.mjs +56 -36
  40. package/scripts/corpus-reconcile.mjs +295 -28
  41. package/scripts/corpus-seed-publish.mjs +3 -3
  42. package/scripts/coverage-integrity.mjs +3 -0
  43. package/scripts/gist-receipts.mjs +183 -0
  44. package/scripts/git-hooks/pre-push +21 -0
  45. package/scripts/host-registry.mjs +118 -0
  46. package/scripts/independent-review-receipt.mjs +499 -0
  47. package/scripts/ingest-gists.mjs +34 -5
  48. package/scripts/ingest-new-repos.mjs +5 -0
  49. package/scripts/metaharness-gate.mjs +58 -0
  50. package/scripts/nightly-gists.sh +3 -3
  51. package/scripts/nightly-wrapper.sh +75 -69
  52. package/scripts/product-integrity-contract.mjs +156 -0
  53. package/scripts/public-inventory.mjs +4 -0
  54. package/scripts/public-verification-aggregate.mjs +342 -0
  55. package/scripts/public-verification-finalizer.mjs +59 -0
  56. package/scripts/public-verification-inputs.mjs +473 -0
  57. package/scripts/public-verification-lane.mjs +244 -0
  58. package/scripts/publication-receipt.mjs +50 -6
  59. package/scripts/rebuild-gists-from-receipts.mjs +50 -5
  60. package/scripts/release-projection.mjs +86 -0
  61. package/scripts/release-transaction-provider.mjs +43 -102
  62. package/scripts/release-transaction.mjs +139 -51
  63. package/scripts/release.mjs +15 -7
  64. package/scripts/restore-local-ingests.mjs +2 -2
  65. package/scripts/retrieval-canary.mjs +533 -0
  66. package/scripts/routing-flywheel.mjs +12 -1
  67. package/scripts/rvf-generation.mjs +9 -3
  68. package/scripts/self-update.mjs +19 -44
  69. package/scripts/source-coverage.mjs +415 -0
  70. package/scripts/source-scope-receipt.mjs +84 -0
  71. package/scripts/sync-census.mjs +0 -0
  72. package/scripts/wired-check.mjs +114 -24
  73. package/scripts/worktree-integrity.mjs +123 -0
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const OUTBOX_NAME = 'project-progression-outbox.jsonl';
5
+
6
+ function requireIdentity(value, label) {
7
+ if (typeof value !== 'string' || !value) throw new TypeError(`${label} must be a non-empty string`);
8
+ }
9
+
10
+ function writeAll(fd, value) {
11
+ const content = Buffer.from(value);
12
+ let offset = 0;
13
+ while (offset < content.length) offset += fs.writeSync(fd, content, offset, content.length - offset);
14
+ }
15
+
16
+ export class ProgressionOutbox {
17
+ constructor({ projectRoot, fsync = fs.fsyncSync } = {}) {
18
+ requireIdentity(projectRoot, 'projectRoot');
19
+ this.path = path.join(projectRoot, '.swarm', OUTBOX_NAME);
20
+ this.fsync = fsync;
21
+ }
22
+
23
+ appendRecord(record) {
24
+ fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
25
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
26
+ const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | noFollow, 0o600);
27
+ try {
28
+ fs.fchmodSync(fd, 0o600);
29
+ writeAll(fd, `${JSON.stringify(record)}\n`);
30
+ this.fsync(fd);
31
+ } finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ return record;
35
+ }
36
+
37
+ appendSnapshot(snapshot) {
38
+ requireIdentity(snapshot?.eventKey, 'snapshot.eventKey');
39
+ requireIdentity(snapshot?.payloadDigest, 'snapshot.payloadDigest');
40
+ return this.appendRecord({
41
+ type: 'snapshot',
42
+ eventKey: snapshot.eventKey,
43
+ payloadDigest: snapshot.payloadDigest,
44
+ snapshot,
45
+ });
46
+ }
47
+
48
+ markCommitted(receipt) {
49
+ requireIdentity(receipt?.eventKey, 'receipt.eventKey');
50
+ requireIdentity(receipt?.payloadDigest, 'receipt.payloadDigest');
51
+ requireIdentity(receipt?.readbackDigest, 'receipt.readbackDigest');
52
+ requireIdentity(receipt?.committedAt, 'receipt.committedAt');
53
+ if (receipt.readbackDigest !== receipt.payloadDigest) throw new Error('readback digest mismatch');
54
+ return this.appendRecord({ type: 'commit', ...receipt });
55
+ }
56
+
57
+ records() {
58
+ if (!fs.existsSync(this.path)) return [];
59
+ const content = fs.readFileSync(this.path, 'utf8');
60
+ const lines = content.split('\n');
61
+ if (lines.at(-1) !== '') lines.pop();
62
+ else lines.pop();
63
+ return lines.filter(Boolean).map((line, index) => {
64
+ try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
65
+ });
66
+ }
67
+
68
+ pendingSnapshots() {
69
+ const snapshots = new Map();
70
+ const committed = new Map();
71
+ for (const record of this.records()) {
72
+ requireIdentity(record?.eventKey, 'outbox eventKey');
73
+ requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
74
+ if (record.type === 'snapshot') {
75
+ const prior = snapshots.get(record.eventKey);
76
+ if (prior && prior.payloadDigest !== record.payloadDigest) throw new Error('outbox event key collision');
77
+ snapshots.set(record.eventKey, record);
78
+ } else if (record.type === 'commit') {
79
+ const prior = committed.get(record.eventKey);
80
+ if (prior && prior !== record.payloadDigest) throw new Error('outbox commit collision');
81
+ committed.set(record.eventKey, record.payloadDigest);
82
+ } else {
83
+ throw new Error('unsupported outbox record');
84
+ }
85
+ }
86
+ return [...snapshots.values()]
87
+ .filter((record) => {
88
+ const digest = committed.get(record.eventKey);
89
+ if (digest && digest !== record.payloadDigest) throw new Error('outbox commit digest mismatch');
90
+ return !digest;
91
+ })
92
+ .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
93
+ .map((record) => record.snapshot);
94
+ }
95
+ }
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { ProjectProgressionStore } from './project-progression-store.mjs';
5
+ import { resolveProjectStore } from './project-store-resolver.mjs';
6
+
7
+ export const SESSION_CONTINUITY_LIMIT_BYTES = 8 * 1024;
8
+ export const SESSION_CONTINUITY_DEADLINE_MS = 2_500;
9
+
10
+ const RESTORED_HEADER = '[RuvNet Brain — PROJECT CONTINUITY RESTORED]';
11
+ const UNKNOWN_HEADER = '[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]';
12
+
13
+ const UNKNOWN_EXPLANATIONS = Object.freeze({
14
+ 'canonical-path': 'The canonical project AgentDB path could not be verified.',
15
+ 'initialization-failed': 'The canonical project AgentDB could not be initialized through managed Ruflo.',
16
+ 'managed-ruflo-unavailable': 'The managed global Ruflo CLI is unavailable.',
17
+ 'pagination-unavailable': 'Structural AgentDB pagination is unavailable on the managed global Ruflo CLI.',
18
+ 'malformed-store': 'Structural AgentDB output is malformed or internally inconsistent.',
19
+ 'exact-readback': 'An exact-listed AgentDB row could not be read back by its exact key.',
20
+ 'outbox-replay': 'The durable progression outbox could not be replayed safely.',
21
+ 'output-bound': 'The verified resume payload exceeds the host context bound.',
22
+ 'no-coherent-state': 'No coherent progression head survived validation.',
23
+ 'restore-failed': 'The exact structural restore did not complete.',
24
+ });
25
+
26
+ function unknown(reason) {
27
+ const explanation = UNKNOWN_EXPLANATIONS[reason] ?? UNKNOWN_EXPLANATIONS['restore-failed'];
28
+ return {
29
+ status: 'unknown',
30
+ reason,
31
+ context: `${UNKNOWN_HEADER}\n${explanation} Do not claim project state was restored; verify the canonical store before relying on remembered state.`,
32
+ };
33
+ }
34
+
35
+ function classify(error) {
36
+ const message = String(error?.message ?? error ?? '');
37
+ if (/ruflo was not found|managed global ruflo/i.test(message)) return 'managed-ruflo-unavailable';
38
+ if (/canonical agentdb initialization failed/i.test(message)) return 'initialization-failed';
39
+ if (/structural pagination failed/i.test(message)
40
+ && /unknown option|page-info|--offset|unsupported/i.test(message)) return 'pagination-unavailable';
41
+ if (/malformed pagination|pagination total changed|non-advancing pagination|duplicate progression key/i.test(message)) {
42
+ return 'malformed-store';
43
+ }
44
+ if (/exact retrieval failed|exact key\/payload identity mismatch|readback is not json/i.test(message)) {
45
+ return 'exact-readback';
46
+ }
47
+ if (/outbox|replay/i.test(message)) return 'outbox-replay';
48
+ if (/resume payload.*bound/i.test(message)) return 'output-bound';
49
+ if (/no coherent progression state/i.test(message)) return 'no-coherent-state';
50
+ return 'restore-failed';
51
+ }
52
+
53
+ function validResume(result) {
54
+ if (!result || typeof result !== 'object' || typeof result.rendered !== 'string') return false;
55
+ let parsed;
56
+ try { parsed = JSON.parse(result.rendered); } catch { return false; }
57
+ return parsed?.schema === 'ruvnet-brain.project-resume'
58
+ && parsed.schemaVersion === 1
59
+ && Array.isArray(parsed.heads)
60
+ && parsed.heads.length > 0
61
+ && parsed.state && typeof parsed.state === 'object'
62
+ && Array.isArray(parsed.state.resumeConflicts)
63
+ && JSON.stringify(parsed) === JSON.stringify(result.payload);
64
+ }
65
+
66
+ function resultStatus(result) {
67
+ return Number.isInteger(result?.status) ? result.status : 1;
68
+ }
69
+
70
+ function resultText(result, field) {
71
+ const value = result?.[field];
72
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
73
+ }
74
+
75
+ function initializeCanonicalStore(store, resolution) {
76
+ const key = 'project-continuity-bootstrap-v1';
77
+ const namespace = 'project-progression-control';
78
+ const value = JSON.stringify({
79
+ schema: 'ruvnet-brain.project-continuity-bootstrap',
80
+ schemaVersion: 1,
81
+ projectIdentity: resolution.projectIdentity,
82
+ });
83
+ store.run([
84
+ 'memory', 'store', '--key', key, '--value', value,
85
+ '--namespace', namespace, '--no-upsert', '--provenance', 'system_observation',
86
+ '--path', resolution.canonicalAgentDbPath,
87
+ ]);
88
+ const readback = store.run([
89
+ 'memory', 'retrieve', '--key', key, '--namespace', namespace,
90
+ '--value-only', '--path', resolution.canonicalAgentDbPath,
91
+ ]);
92
+ if (resultStatus(readback) !== 0 || resultText(readback, 'stdout') !== value
93
+ || !fs.existsSync(resolution.canonicalAgentDbPath)) {
94
+ throw new Error('canonical AgentDB initialization failed');
95
+ }
96
+ }
97
+
98
+ function isProject(resolution) {
99
+ if (resolution.kind === 'git') return true;
100
+ return ['.swarm', '.claude-flow', 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod']
101
+ .some((name) => fs.existsSync(path.join(resolution.projectRoot, name)));
102
+ }
103
+
104
+ function availableContext(reason) {
105
+ return {
106
+ status: 'unavailable',
107
+ reason,
108
+ context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\n'
109
+ + 'This working directory is not a writable adopted project. No AgentDB store was created and no project state was restored.',
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Restore one canonical project's progression at the shared SessionStart boundary.
115
+ * Empty projects are silent. Any adopted-but-unverifiable store is explicit UNKNOWN.
116
+ */
117
+ export function restoreProgressionForSession({
118
+ env = process.env,
119
+ cwd = process.cwd(),
120
+ storeFactory,
121
+ maxOutputBytes = SESSION_CONTINUITY_LIMIT_BYTES,
122
+ deadlineMs = SESSION_CONTINUITY_DEADLINE_MS,
123
+ writable = (projectRoot) => {
124
+ try { fs.accessSync(projectRoot, fs.constants.W_OK); return true; } catch { return false; }
125
+ },
126
+ } = {}) {
127
+ const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
128
+ let resolution;
129
+ try {
130
+ resolution = resolveProjectStore({ projectDir });
131
+ } catch {
132
+ return unknown('canonical-path');
133
+ }
134
+
135
+ if (!isProject(resolution)) return availableContext('non-project');
136
+ if (!writable(resolution.projectRoot)) return availableContext('read-only');
137
+ const initializing = !fs.existsSync(resolution.canonicalAgentDbPath);
138
+
139
+ const prefix = `${RESTORED_HEADER}\n`;
140
+ const payloadLimit = maxOutputBytes - Buffer.byteLength(prefix, 'utf8');
141
+ if (!Number.isSafeInteger(payloadLimit) || payloadLimit < 1) return unknown('output-bound');
142
+
143
+ try {
144
+ const deadlineAt = Date.now() + deadlineMs;
145
+ const boundedRunner = (binary, args, options) => {
146
+ const remaining = deadlineAt - Date.now();
147
+ if (remaining < 1) throw new Error('restore deadline exceeded');
148
+ const result = spawnSync(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
149
+ if (result.error) throw new Error('restore deadline exceeded');
150
+ return result;
151
+ };
152
+ const makeStore = storeFactory ?? ((options) => new ProjectProgressionStore({
153
+ ...options,
154
+ runner: boundedRunner,
155
+ }));
156
+ const store = makeStore({
157
+ projectDir,
158
+ requestedStorePath: resolution.canonicalAgentDbPath,
159
+ });
160
+ if (initializing) {
161
+ fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
162
+ initializeCanonicalStore(store, resolution);
163
+ }
164
+ const restored = store.restoreLatest({ maxOutputBytes: payloadLimit });
165
+ if (!validResume(restored)) return unknown('malformed-store');
166
+ const context = `${prefix}${restored.rendered}`;
167
+ if (Buffer.byteLength(context, 'utf8') > maxOutputBytes) return unknown('output-bound');
168
+ return { status: 'restored', context };
169
+ } catch (error) {
170
+ // A structurally enumerated, genuinely empty namespace is normal for a newly adopted project.
171
+ if (/no coherent progression state/i.test(String(error?.message ?? ''))
172
+ && Array.isArray(error?.rejectedCandidates) && error.rejectedCandidates.length === 0) {
173
+ if (initializing && !fs.existsSync(resolution.canonicalAgentDbPath)) return unknown('initialization-failed');
174
+ return {
175
+ status: initializing ? 'initialized' : 'empty',
176
+ context: initializing
177
+ ? '[RuvNet Brain — PROJECT CONTINUITY INITIALIZED]\nThe canonical AgentDB store is ready; no prior progression snapshot exists yet.'
178
+ : '[RuvNet Brain — PROJECT CONTINUITY EMPTY]\nThe canonical AgentDB store was structurally enumerated and contains no prior progression snapshot.',
179
+ };
180
+ }
181
+ return unknown(classify(error));
182
+ }
183
+ }
@@ -0,0 +1,248 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { ProgressionOutbox } from './project-progression-outbox.mjs';
4
+ import {
5
+ digestCanonical,
6
+ restoreProjectProgression,
7
+ validateProgressionSnapshot,
8
+ } from './project-progression-contract.mjs';
9
+ import { resolveProjectStore } from './project-store-resolver.mjs';
10
+ import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
11
+
12
+ const PROGRESSION_NAMESPACE = 'project-progression';
13
+ const RESUME_SCHEMA = 'ruvnet-brain.project-resume';
14
+ const RESUME_VERSION = 1;
15
+
16
+ function defaultRunner(binary, args, options) {
17
+ return spawnSync(binary, args, options);
18
+ }
19
+
20
+ function resultStatus(result) {
21
+ return Number.isInteger(result?.status) ? result.status : 1;
22
+ }
23
+
24
+ function resultText(result, field) {
25
+ const value = result?.[field];
26
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
27
+ }
28
+
29
+ function parseJson(text, label) {
30
+ try { return JSON.parse(text); } catch { throw new Error(`${label} is not JSON`); }
31
+ }
32
+
33
+ function plainRecord(value) {
34
+ return value && typeof value === 'object' && !Array.isArray(value);
35
+ }
36
+
37
+ function requirePositiveInteger(value, label) {
38
+ if (!Number.isSafeInteger(value) || value < 1) throw new TypeError(`${label} must be a positive safe integer`);
39
+ }
40
+
41
+ function validatePage(page, { offset, pageSize, total }) {
42
+ if (!plainRecord(page) || !Array.isArray(page.entries)
43
+ || !Number.isSafeInteger(page.total) || page.total < 0
44
+ || page.limit !== pageSize || page.offset !== offset
45
+ || typeof page.hasMore !== 'boolean') {
46
+ throw new Error('malformed pagination page');
47
+ }
48
+ if (total !== null && page.total !== total) throw new Error('pagination total changed during restoration');
49
+ if (page.entries.length > pageSize || offset + page.entries.length > page.total) {
50
+ throw new Error('malformed pagination page');
51
+ }
52
+ const consumed = offset + page.entries.length;
53
+ if (page.hasMore) {
54
+ if (!Number.isSafeInteger(page.nextOffset) || page.nextOffset <= offset) {
55
+ throw new Error('non-advancing pagination page');
56
+ }
57
+ if (page.nextOffset !== consumed || consumed >= page.total) throw new Error('malformed pagination page');
58
+ } else if (page.nextOffset !== null || consumed !== page.total) {
59
+ throw new Error('malformed pagination page');
60
+ }
61
+ return page.total;
62
+ }
63
+
64
+ function sortRejected(rows) {
65
+ return rows.sort((left, right) => String(left.eventKey).localeCompare(String(right.eventKey))
66
+ || left.reasons.join('|').localeCompare(right.reasons.join('|')));
67
+ }
68
+
69
+ export class ProjectProgressionStore {
70
+ constructor({
71
+ projectDir,
72
+ requestedStorePath,
73
+ rufloBinary = resolveRuflo(),
74
+ runner = defaultRunner,
75
+ clock = () => new Date().toISOString(),
76
+ fsync,
77
+ } = {}) {
78
+ if (!rufloBinary) throw new Error(RUFLO_MISSING);
79
+ this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
80
+ this.rufloBinary = rufloBinary;
81
+ this.runner = runner;
82
+ this.clock = clock;
83
+ this.outbox = new ProgressionOutbox({ projectRoot: this.resolution.projectRoot, fsync });
84
+ }
85
+
86
+ run(args) {
87
+ return this.runner(this.rufloBinary, args, {
88
+ cwd: path.dirname(this.resolution.canonicalAgentDbPath),
89
+ encoding: 'utf8',
90
+ timeout: 120_000,
91
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
92
+ });
93
+ }
94
+
95
+ validateSnapshot(snapshot) {
96
+ const verdict = validateProgressionSnapshot(snapshot, {
97
+ expectedProjectIdentity: this.resolution.projectIdentity,
98
+ });
99
+ if (!verdict.ok) throw new Error(`invalid progression snapshot: ${verdict.errors.join(', ')}`);
100
+ }
101
+
102
+ appendExact(snapshot, { onPhase = () => {} } = {}) {
103
+ this.validateSnapshot(snapshot);
104
+ const stored = this.run([
105
+ 'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
106
+ '--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--provenance', 'system_observation',
107
+ '--path', this.resolution.canonicalAgentDbPath,
108
+ ]);
109
+ const alreadyStored = resultStatus(stored) !== 0;
110
+ if (!alreadyStored) onPhase('stored');
111
+
112
+ const retrieved = this.run([
113
+ 'memory', 'retrieve', '--key', snapshot.eventKey, '--namespace', PROGRESSION_NAMESPACE,
114
+ '--value-only', '--path', this.resolution.canonicalAgentDbPath,
115
+ ]);
116
+ if (resultStatus(retrieved) !== 0) {
117
+ const storeFailure = alreadyStored
118
+ ? `; store failed: ${resultText(stored, 'stderr').trim() || 'unknown error'}`
119
+ : '';
120
+ throw new Error(`progression readback failed: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}${storeFailure}`);
121
+ }
122
+ let readback;
123
+ try { readback = JSON.parse(resultText(retrieved, 'stdout')); } catch { throw new Error('progression readback is not JSON'); }
124
+ if (readback.payloadDigest !== snapshot.payloadDigest || digestCanonical(readback) !== digestCanonical(snapshot)) {
125
+ throw new Error('progression readback digest mismatch');
126
+ }
127
+ onPhase('readback-verified');
128
+ return {
129
+ eventKey: snapshot.eventKey,
130
+ payloadDigest: snapshot.payloadDigest,
131
+ readbackDigest: readback.payloadDigest,
132
+ alreadyStored,
133
+ committedAt: this.clock(),
134
+ };
135
+ }
136
+
137
+ capture(snapshot, { onPhase = () => {} } = {}) {
138
+ this.validateSnapshot(snapshot);
139
+ this.outbox.appendSnapshot(snapshot);
140
+ onPhase('outbox-fsynced');
141
+ const receipt = this.appendExact(snapshot, { onPhase });
142
+ this.outbox.markCommitted(receipt);
143
+ return receipt;
144
+ }
145
+
146
+ replay() {
147
+ const receipts = [];
148
+ for (const snapshot of this.outbox.pendingSnapshots()) {
149
+ const receipt = this.appendExact(snapshot);
150
+ this.outbox.markCommitted(receipt);
151
+ receipts.push(receipt);
152
+ }
153
+ return receipts;
154
+ }
155
+
156
+ listSnapshotKeys({ pageSize = 100 } = {}) {
157
+ requirePositiveInteger(pageSize, 'pageSize');
158
+ const keys = [];
159
+ const seen = new Set();
160
+ let offset = 0;
161
+ let total = null;
162
+ do {
163
+ const listed = this.run([
164
+ 'memory', 'list', '--namespace', PROGRESSION_NAMESPACE,
165
+ '--limit', String(pageSize), '--offset', String(offset), '--page-info', '--format', 'json',
166
+ '--path', this.resolution.canonicalAgentDbPath,
167
+ ]);
168
+ if (resultStatus(listed) !== 0) {
169
+ throw new Error(`progression structural pagination failed: ${resultText(listed, 'stderr').trim() || 'unknown error'}`);
170
+ }
171
+ const page = parseJson(resultText(listed, 'stdout'), 'progression pagination page');
172
+ total = validatePage(page, { offset, pageSize, total });
173
+ for (const entry of page.entries) {
174
+ if (!plainRecord(entry) || typeof entry.key !== 'string' || !entry.key
175
+ || entry.namespace !== PROGRESSION_NAMESPACE) throw new Error('malformed pagination entry');
176
+ if (seen.has(entry.key)) throw new Error(`duplicate progression key across pages: ${entry.key}`);
177
+ seen.add(entry.key);
178
+ keys.push(entry.key);
179
+ }
180
+ if (!page.hasMore) break;
181
+ offset = page.nextOffset;
182
+ } while (true);
183
+ if (keys.length !== total) throw new Error('pagination did not enumerate the declared total');
184
+ return keys.sort();
185
+ }
186
+
187
+ retrieveSnapshots(keys) {
188
+ const snapshots = [];
189
+ const rejected = [];
190
+ for (const key of keys) {
191
+ const retrieved = this.run([
192
+ 'memory', 'retrieve', '--key', key, '--namespace', PROGRESSION_NAMESPACE,
193
+ '--value-only', '--path', this.resolution.canonicalAgentDbPath,
194
+ ]);
195
+ if (resultStatus(retrieved) !== 0) {
196
+ throw new Error(`progression exact retrieval failed for ${key}: ${resultText(retrieved, 'stderr').trim() || 'unknown error'}`);
197
+ }
198
+ let snapshot;
199
+ try { snapshot = JSON.parse(resultText(retrieved, 'stdout')); } catch {
200
+ rejected.push({ eventKey: key, reasons: ['readback is not JSON'] });
201
+ continue;
202
+ }
203
+ if (!plainRecord(snapshot) || snapshot.eventKey !== key) {
204
+ rejected.push({ eventKey: key, reasons: ['exact key/payload identity mismatch'] });
205
+ continue;
206
+ }
207
+ snapshots.push(snapshot);
208
+ }
209
+ return { snapshots, rejected: sortRejected(rejected) };
210
+ }
211
+
212
+ restoreLatest({ pageSize = 100, maxOutputBytes = 64 * 1024 } = {}) {
213
+ requirePositiveInteger(maxOutputBytes, 'maxOutputBytes');
214
+ this.replay();
215
+ const keys = this.listSnapshotKeys({ pageSize });
216
+ const exact = this.retrieveSnapshots(keys);
217
+ const restored = restoreProjectProgression(exact.snapshots, {
218
+ expectedProjectIdentity: this.resolution.projectIdentity,
219
+ });
220
+ const rejectedCandidates = sortRejected([
221
+ ...exact.rejected,
222
+ ...restored.rejected.filter((row) => row.reasons.some((reason) => reason !== 'causally stale')),
223
+ ]);
224
+ if (!restored.ok) {
225
+ const error = new Error('no coherent progression state could be restored');
226
+ error.rejectedCandidates = rejectedCandidates;
227
+ throw error;
228
+ }
229
+ const payload = {
230
+ schema: RESUME_SCHEMA,
231
+ schemaVersion: RESUME_VERSION,
232
+ projectIdentity: this.resolution.projectIdentity,
233
+ heads: restored.heads,
234
+ state: restored.state,
235
+ evidence: {
236
+ structurallyEnumerated: keys.length,
237
+ exactRetrieved: keys.length,
238
+ causallyStale: restored.causallyStale.length,
239
+ rejectedCandidates,
240
+ },
241
+ };
242
+ const rendered = JSON.stringify(payload);
243
+ if (Buffer.byteLength(rendered, 'utf8') > maxOutputBytes) {
244
+ throw new Error(`resume payload exceeds the ${maxOutputBytes}-byte output bound`);
245
+ }
246
+ return { payload, rendered };
247
+ }
248
+ }
@@ -0,0 +1,106 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ function sha256(value) {
7
+ return crypto.createHash('sha256').update(value).digest('hex');
8
+ }
9
+
10
+ function rejectParentTraversal(value, label) {
11
+ if (value.split(/[\\/]+/).includes('..')) throw new Error(`${label} contains parent traversal`);
12
+ }
13
+
14
+ function canonicalDirectory(value, label) {
15
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${label} must be a non-empty path`);
16
+ rejectParentTraversal(value, label);
17
+ let canonical;
18
+ try { canonical = fs.realpathSync.native(value); } catch { throw new Error(`${label} does not exist`); }
19
+ if (!fs.statSync(canonical).isDirectory()) throw new Error(`${label} must be a directory`);
20
+ return canonical;
21
+ }
22
+
23
+ function canonicalProspectivePath(value) {
24
+ let cursor = path.resolve(value);
25
+ const missing = [];
26
+ while (!fs.existsSync(cursor)) {
27
+ const parent = path.dirname(cursor);
28
+ if (parent === cursor) throw new Error('store path has no resolvable ancestor');
29
+ missing.unshift(path.basename(cursor));
30
+ cursor = parent;
31
+ }
32
+ if (missing.length && !fs.statSync(cursor).isDirectory()) {
33
+ throw new Error('store path ancestor must be a directory');
34
+ }
35
+ return path.join(fs.realpathSync.native(cursor), ...missing);
36
+ }
37
+
38
+ function isWithin(root, candidate) {
39
+ const relative = path.relative(root, candidate);
40
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
41
+ }
42
+
43
+ function gitValue(cwd, args) {
44
+ try {
45
+ return execFileSync('git', args, {
46
+ cwd,
47
+ encoding: 'utf8',
48
+ stdio: ['ignore', 'pipe', 'ignore'],
49
+ }).trim();
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function gitProject(projectDir) {
56
+ const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
57
+ const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel']);
58
+ if (!commonValue || !checkoutValue) return null;
59
+ const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
60
+ const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
61
+ if (path.basename(gitCommonDir) !== '.git') {
62
+ throw new Error('Git common directory must identify a canonical primary checkout');
63
+ }
64
+ return {
65
+ gitCommonDir,
66
+ checkoutRoot,
67
+ projectRoot: canonicalDirectory(path.dirname(gitCommonDir), 'Git project root'),
68
+ };
69
+ }
70
+
71
+ export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath } = {}) {
72
+ const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
73
+ const git = gitProject(canonicalInput);
74
+ const resolved = git ?? {
75
+ gitCommonDir: null,
76
+ checkoutRoot: canonicalInput,
77
+ projectRoot: canonicalInput,
78
+ };
79
+ const kind = git ? 'git' : 'non-git';
80
+ const canonicalAgentDbPath = path.join(resolved.projectRoot, '.swarm', 'memory.db');
81
+ const resolvedAgentDbPath = canonicalProspectivePath(canonicalAgentDbPath);
82
+ if (!isWithin(resolved.projectRoot, resolvedAgentDbPath)) {
83
+ throw new Error('store symlink escape rejected');
84
+ }
85
+ if (requestedStorePath !== undefined) {
86
+ if (typeof requestedStorePath !== 'string' || !requestedStorePath.trim()) {
87
+ throw new TypeError('requestedStorePath must be a non-empty path');
88
+ }
89
+ rejectParentTraversal(requestedStorePath, 'requestedStorePath');
90
+ if (canonicalProspectivePath(requestedStorePath) !== resolvedAgentDbPath) {
91
+ throw new Error('foreign store root rejected');
92
+ }
93
+ }
94
+ const projectIdentity = Object.freeze({
95
+ id: `${kind}-sha256:${sha256(resolved.gitCommonDir ?? resolved.projectRoot)}`,
96
+ canonicalAgentDbPath,
97
+ });
98
+ return Object.freeze({
99
+ kind,
100
+ projectRoot: resolved.projectRoot,
101
+ checkoutRoot: resolved.checkoutRoot,
102
+ gitCommonDir: resolved.gitCommonDir,
103
+ canonicalAgentDbPath,
104
+ projectIdentity,
105
+ });
106
+ }
@@ -1,5 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import {
4
+ captureProjectTransition,
5
+ hasProjectProgression,
6
+ } from './project-progression-hook.mjs';
3
7
  import { createSessionSnapshot } from './session-snapshot-contract.mjs';
4
8
  import { projectDirectory } from './project-identity.mjs';
5
9
 
@@ -39,8 +43,32 @@ export function writeSessionSnapshot(projectDir, event) {
39
43
  }
40
44
  }
41
45
 
46
+ export function runSessionSnapshotHook(projectDir, event, {
47
+ rawInput = '',
48
+ host = process.env.RUVNET_HOOK_HOST || 'claude',
49
+ captureProgression = captureProjectTransition,
50
+ } = {}) {
51
+ const metadataWritten = writeSessionSnapshot(projectDir, event);
52
+ let payload;
53
+ try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
54
+ if (!hasProjectProgression(payload)) {
55
+ return { metadataWritten, progressionCaptured: false, receipt: null };
56
+ }
57
+ if (payload.hook_event_name !== event) {
58
+ throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
59
+ }
60
+ const result = captureProgression({ host, payload, projectDir });
61
+ return { metadataWritten, progressionCaptured: true, receipt: result.receipt };
62
+ }
63
+
42
64
  if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
43
65
  // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
44
66
  // independently is what let this hook write a receipt the Console then reported as missing (#85).
45
- writeSessionSnapshot(projectDirectory(), process.argv[2] || 'SessionEnd');
67
+ const rawInput = fs.readFileSync(0, 'utf8');
68
+ try {
69
+ runSessionSnapshotHook(projectDirectory(), process.argv[2] || 'SessionEnd', { rawInput });
70
+ } catch (error) {
71
+ process.stderr.write(`[project-progression] ${error.message}\n`);
72
+ process.exitCode = 1;
73
+ }
46
74
  }