@sanqianx/ai-coding-event-bridge-console 0.5.1 → 0.7.0

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 (36) hide show
  1. package/README.md +15 -4
  2. package/console/index.html +37 -11
  3. package/package.json +2 -5
  4. package/src/agent-detect.js +1 -1
  5. package/src/bin.js +28 -5
  6. package/src/bridge/bin/commit-boundary.js +64 -0
  7. package/src/bridge/connectors/capture-guard.js +24 -0
  8. package/src/bridge/connectors/claude-code/hook-entry.js +45 -0
  9. package/src/bridge/connectors/claude-code/notify.js +57 -0
  10. package/src/bridge/connectors/codex/cursor-store.js +83 -0
  11. package/src/bridge/connectors/codex/hook-entry.js +215 -0
  12. package/src/bridge/connectors/codex/session-parser.js +100 -0
  13. package/src/bridge/connectors/opencode/hook-entry.js +55 -0
  14. package/src/bridge/core/bridge.js +246 -0
  15. package/src/bridge/core/commit-projection.js +288 -0
  16. package/src/bridge/core/compaction.js +23 -0
  17. package/src/bridge/core/consumer-registry.js +111 -0
  18. package/src/bridge/core/event-schema.js +127 -0
  19. package/src/bridge/core/fs-utils.js +81 -0
  20. package/src/bridge/core/journal-router.js +39 -0
  21. package/src/bridge/core/journal.js +507 -0
  22. package/src/bridge/core/lock.js +96 -0
  23. package/src/bridge/core/normalizer.js +114 -0
  24. package/src/bridge/core/paths.js +12 -0
  25. package/src/bridge/core/project-registry.js +176 -0
  26. package/src/bridge/core/repo-context.js +188 -0
  27. package/src/bridge/core/runtime-home.js +188 -0
  28. package/src/bridge/core/semver.js +23 -0
  29. package/src/bridge/core/turn-identity.js +127 -0
  30. package/src/bridge/index.js +86 -0
  31. package/src/bridge/installers/claude-code/installer.js +181 -0
  32. package/src/bridge/installers/codex/installer.js +114 -0
  33. package/src/bridge/installers/opencode/installer.js +96 -0
  34. package/src/bridge/installers/zcode/installer.js +188 -0
  35. package/src/bridge/query/conversation-query.js +194 -0
  36. package/src/server.js +54 -5
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { validateAndNormalizeEvent } = require('../../core/event-schema');
6
+ const { resolveRepoContext } = require('../../core/repo-context');
7
+ const { journalFor } = require('../../core/journal-router');
8
+ const { CodexCursorStore } = require('./cursor-store');
9
+ const { parseSessionChunk, extractMessage, extractWorkspaceMeta } = require('./session-parser');
10
+ const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
11
+ const { notifyConsumers } = require('../claude-code/notify');
12
+ const { CrossProcessLock } = require('../../core/lock');
13
+
14
+ /**
15
+ * Deterministic Codex capture.
16
+ *
17
+ * The notify payload is only a wake-up signal; it never carries the workspace.
18
+ * The real workspace comes from the session's authoritative rollout metadata
19
+ * (session_meta.payload.cwd / turn_context.payload.cwd) — NEVER from the
20
+ * session file's location, which is Codex runtime storage. Bytes are consumed
21
+ * incrementally per session; a partial trailing line stays unread until the
22
+ * next wake-up. When no authoritative cwd can be parsed, a
23
+ * codex-workspace-unresolved gap is recorded and the event is never bound to
24
+ * any project.
25
+ */
26
+
27
+ function recordGap(home, reason, meta = {}) {
28
+ try {
29
+ const gapsFile = path.join(home, 'journal', 'capture-gaps.jsonl');
30
+ fs.mkdirSync(path.dirname(gapsFile), { recursive: true });
31
+ fs.appendFileSync(
32
+ gapsFile,
33
+ `${JSON.stringify({ schema: 'bridge-capture-gap/v1', source: 'codex', reason, ...meta, at: new Date().toISOString() })}\n`
34
+ );
35
+ } catch (_) {
36
+ // Gap persistence is best effort; the decision to not guess stands regardless.
37
+ }
38
+ }
39
+
40
+ function normalizedPath(filePath) {
41
+ const resolved = path.resolve(filePath);
42
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
43
+ }
44
+
45
+ function fileKey(sessionsRoot, filePath) {
46
+ return path.relative(path.resolve(sessionsRoot), path.resolve(filePath)).split(path.sep).join('/');
47
+ }
48
+
49
+ async function findSessionFiles(sessionsRoot, sessionId) {
50
+ // Deterministic discovery: Codex rollout files embed the session id in the
51
+ // file name; we search by exact id, never by mtime.
52
+ const stack = [sessionsRoot];
53
+ const matches = [];
54
+ while (stack.length) {
55
+ const dir = stack.pop();
56
+ let entries;
57
+ try {
58
+ entries = fs.readdirSync(dir, { withFileTypes: true });
59
+ } catch (_) {
60
+ continue;
61
+ }
62
+ for (const entry of entries) {
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory()) {
65
+ stack.push(full);
66
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.includes(sessionId)) {
67
+ matches.push(full);
68
+ }
69
+ }
70
+ }
71
+ return matches.sort((left, right) => fileKey(sessionsRoot, left).localeCompare(fileKey(sessionsRoot, right)));
72
+ }
73
+
74
+ async function findSessionFile(sessionsRoot, sessionId) {
75
+ const files = await findSessionFiles(sessionsRoot, sessionId);
76
+ return files[0] || null;
77
+ }
78
+
79
+ function emptyFileState(filePath) {
80
+ return {
81
+ filePath,
82
+ byteOffset: 0,
83
+ lastRecordKey: null,
84
+ activeCwd: null,
85
+ repoIdentity: null,
86
+ projectPath: null,
87
+ branch: null,
88
+ headAtCapture: null
89
+ };
90
+ }
91
+
92
+ async function captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }) {
93
+ const cursor = cursorStore.read(sessionId);
94
+ const filePaths = await findSessionFiles(sessionsRoot, sessionId);
95
+ if (!filePaths.length) {
96
+ recordGap(home, 'session-file-not-found', { sessionId });
97
+ return { status: 'gap', reason: 'session-file-not-found' };
98
+ }
99
+
100
+ if (cursor.legacyFile) {
101
+ const legacyMatch = filePaths.find(candidate => normalizedPath(candidate) === normalizedPath(cursor.legacyFile.filePath));
102
+ if (legacyMatch) {
103
+ cursor.files[fileKey(sessionsRoot, legacyMatch)] = { ...cursor.legacyFile, filePath: legacyMatch };
104
+ }
105
+ delete cursor.legacyFile;
106
+ }
107
+
108
+ let captured = 0;
109
+ let completeBytes = 0;
110
+
111
+ for (const filePath of filePaths) {
112
+ const key = fileKey(sessionsRoot, filePath);
113
+ const state = cursor.files[key] || emptyFileState(filePath);
114
+ state.filePath = filePath;
115
+ const fromOffset = state.byteOffset || 0;
116
+ const parsed = parseSessionChunk(filePath, fromOffset);
117
+ let unresolvedGapRecorded = false;
118
+
119
+ for (let index = 0; index < parsed.records.length; index += 1) {
120
+ const record = parsed.records[index];
121
+ const meta = extractWorkspaceMeta(record);
122
+ if (meta) {
123
+ if (meta.cwd && meta.cwd !== state.activeCwd) {
124
+ // Authoritative workspace switch (session start or turn_context change).
125
+ const repo = await resolveRepoContext(meta.cwd);
126
+ state.activeCwd = meta.cwd;
127
+ state.repoIdentity = repo.repoIdentity;
128
+ state.projectPath = repo.projectPath;
129
+ state.branch = repo.branch;
130
+ state.headAtCapture = repo.headAtCapture;
131
+ }
132
+ continue;
133
+ }
134
+ const message = extractMessage(record);
135
+ if (!message) continue;
136
+ if (!state.repoIdentity && !unresolvedGapRecorded) {
137
+ recordGap(home, 'codex-workspace-unresolved', { sessionId, rolloutFile: key });
138
+ unresolvedGapRecorded = true;
139
+ }
140
+ const byteOffset = parsed.recordOffsets[index];
141
+ const recordKey = `${key}:${byteOffset}`;
142
+ const event = validateAndNormalizeEvent({
143
+ source: 'codex',
144
+ eventType: message.role === 'user' ? 'user_prompt' : 'assistant_response',
145
+ role: message.role,
146
+ content: message.text,
147
+ sessionId,
148
+ turnId: message.turnId,
149
+ repoIdentity: state.repoIdentity,
150
+ projectPath: state.projectPath,
151
+ branch: state.branch,
152
+ headAtCapture: state.headAtCapture,
153
+ identityConfidence: message.turnId ? 'exact' : 'partial',
154
+ captureStatus: 'complete',
155
+ rawEventType: record.type || 'message',
156
+ eventKey: `codex:${sessionId}:${recordKey}`,
157
+ meta: { recordKey, rolloutFile: key, byteOffset, phase: message.phase }
158
+ });
159
+ // The workspace can switch mid-session (turn_context), so the journal is
160
+ // routed per record from the authoritative identity, never per session.
161
+ const journal = journalFor(home, state.repoIdentity);
162
+ const appended = await journal.appendConversationEvent(event);
163
+ state.lastRecordKey = recordKey;
164
+ if (!appended.duplicate) captured += 1;
165
+ }
166
+
167
+ state.byteOffset = fromOffset + parsed.completeBytes;
168
+ cursor.files[key] = state;
169
+ completeBytes += parsed.completeBytes;
170
+ }
171
+
172
+ cursorStore.write(sessionId, cursor);
173
+ return { status: 'captured', captured, files: filePaths.length, completeBytes };
174
+ }
175
+
176
+ async function captureSession({ home, sessionsRoot, sessionId }) {
177
+ const cursorStore = new CodexCursorStore(home);
178
+ const safe = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_');
179
+ const lock = new CrossProcessLock(path.join(home, 'cursors', 'codex', `${safe}.capture.lock`));
180
+ return lock.withLock(() => captureSessionUnlocked({ home, sessionsRoot, sessionId, cursorStore }));
181
+ }
182
+
183
+ async function main({ home, payload }) {
184
+ if (isCaptureDisabled(process.env, payload)) {
185
+ return captureDisabledResult();
186
+ }
187
+ if (!payload || typeof payload !== 'object') {
188
+ return { status: 'ignored' };
189
+ }
190
+ const sessionId = payload.session_id || payload.sessionId || null;
191
+ if (!sessionId || typeof sessionId !== 'string') {
192
+ // Ambiguous notify: never fall back to the newest session by mtime.
193
+ recordGap(home, 'notify-session-unresolved', { keys: Object.keys(payload).slice(0, 8) });
194
+ return { status: 'gap', reason: 'notify-session-unresolved' };
195
+ }
196
+ const sessionsRoot =
197
+ payload.sessions_root ||
198
+ (process.env.CODEX_SESSIONS_ROOT) ||
199
+ path.join(home, 'fixtures', 'codex-sessions');
200
+ const result = await captureSession({
201
+ home,
202
+ sessionsRoot,
203
+ sessionId
204
+ });
205
+ if (result.status === 'captured' && result.captured > 0) {
206
+ await notifyConsumers(home, { source: 'codex', sessionId });
207
+ }
208
+ return result;
209
+ }
210
+
211
+ function mainFailOpen({ home, payload }) {
212
+ return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
213
+ }
214
+
215
+ module.exports = { main, mainFailOpen, captureSession, findSessionFile, findSessionFiles, recordGap };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+
5
+ /**
6
+ * Incremental Codex session JSONL parser. Reads only appended bytes after the
7
+ * cursor offset, tolerates a partial trailing line, and classifies records
8
+ * into messages and workspace metadata. Never re-reads the whole file and
9
+ * never selects sessions by mtime — the caller identifies the session file
10
+ * deterministically.
11
+ */
12
+ function parseSessionChunk(filePath, fromOffset) {
13
+ const size = fs.statSync(filePath).size;
14
+ if (size <= fromOffset) return { records: [], recordOffsets: [], completeBytes: 0, size };
15
+ const fd = fs.openSync(filePath, 'r');
16
+ let raw;
17
+ try {
18
+ const buf = Buffer.alloc(size - fromOffset);
19
+ const read = fs.readSync(fd, buf, 0, buf.length, fromOffset);
20
+ raw = buf.toString('utf8', 0, read);
21
+ } finally {
22
+ fs.closeSync(fd);
23
+ }
24
+ const records = [];
25
+ const recordOffsets = [];
26
+ let completeBytes = 0;
27
+ const chunks = raw.split('\n');
28
+ for (let i = 0; i < chunks.length; i++) {
29
+ const line = chunks[i];
30
+ if (i === chunks.length - 1) break; // trailing bytes without newline = partial
31
+ if (line.length === 0) {
32
+ completeBytes += 1;
33
+ continue;
34
+ }
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(line);
38
+ } catch (_) {
39
+ parsed = null;
40
+ }
41
+ if (parsed && typeof parsed === 'object') {
42
+ records.push(parsed);
43
+ recordOffsets.push(fromOffset + completeBytes);
44
+ }
45
+ completeBytes += Buffer.byteLength(line, 'utf8') + 1;
46
+ }
47
+ return { records, recordOffsets, completeBytes, size };
48
+ }
49
+
50
+ /**
51
+ * Authoritative workspace metadata from Codex rollout records:
52
+ * session_meta.payload.cwd and turn_context.payload.cwd. A verified
53
+ * turn_context cwd may update the active workspace for subsequent events.
54
+ */
55
+ function extractWorkspaceMeta(record) {
56
+ if (!record || typeof record !== 'object') return null;
57
+ const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
58
+ if (record.type === 'session_meta' || (payload && payload.type === 'session_meta')) {
59
+ const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
60
+ return { kind: 'session_meta', cwd };
61
+ }
62
+ if (record.type === 'turn_context' || (payload && payload.type === 'turn_context')) {
63
+ const cwd = payload && typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null;
64
+ return { kind: 'turn_context', cwd };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function extractMessage(record) {
70
+ if (!record || typeof record !== 'object') return null;
71
+ const payload = record.payload && typeof record.payload === 'object' ? record.payload : record;
72
+ const role = payload.role || record.role;
73
+ if (role !== 'user' && role !== 'assistant') return null;
74
+ const text = collectText(payload.content !== undefined ? payload.content : record.content);
75
+ if (text === null) return null;
76
+ const passthrough = payload.internal_chat_message_metadata_passthrough;
77
+ const nestedTurnId = passthrough && typeof passthrough === 'object' ? passthrough.turn_id : null;
78
+ return {
79
+ role,
80
+ text,
81
+ turnId: payload.turn_id || nestedTurnId || record.turn_id || null,
82
+ phase: typeof payload.phase === 'string' && payload.phase ? payload.phase : null
83
+ };
84
+ }
85
+
86
+ function collectText(content) {
87
+ if (typeof content === 'string') return content;
88
+ if (Array.isArray(content)) {
89
+ const parts = [];
90
+ for (const item of content) {
91
+ if (typeof item === 'string') parts.push(item);
92
+ else if (item && typeof item === 'object' && typeof item.text === 'string') parts.push(item.text);
93
+ }
94
+ if (parts.length) return parts.join('\n');
95
+ return null;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ module.exports = { parseSessionChunk, extractMessage, extractWorkspaceMeta };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const { validateAndNormalizeEvent } = require('../../core/event-schema');
4
+ const { resolveRepoContext } = require('../../core/repo-context');
5
+ const { journalFor } = require('../../core/journal-router');
6
+ const { isCaptureDisabled, captureDisabledResult } = require('../capture-guard');
7
+ const { notifyConsumers } = require('../claude-code/notify');
8
+
9
+ /**
10
+ * OpenCode plugin capture. The plugin ships the native event to the stable
11
+ * shim; capture is a local durable append first via the canonical
12
+ * conversation path (Bridge-owned turn identity). There is no HTTP endpoint
13
+ * dependency — consumer wake-up uses the generic best-effort notify channel
14
+ * only. Capture-disable is checked before any repo/journal work.
15
+ */
16
+ async function main({ home, payload }) {
17
+ if (isCaptureDisabled(process.env, payload)) {
18
+ return captureDisabledResult();
19
+ }
20
+ if (!payload || typeof payload !== 'object') {
21
+ return { status: 'ignored' };
22
+ }
23
+ if (payload.type !== 'user' && payload.type !== 'assistant') {
24
+ // Tool/file/todo lifecycle events stay out of the conversation truth.
25
+ return { status: 'ignored' };
26
+ }
27
+ const cwd = typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
28
+ const repo = await resolveRepoContext(cwd);
29
+ const journal = journalFor(home, repo.repoIdentity);
30
+ const isUser = payload.type === 'user';
31
+ const event = validateAndNormalizeEvent({
32
+ source: 'opencode',
33
+ eventType: isUser ? 'user_prompt' : 'assistant_response',
34
+ role: isUser ? 'user' : 'assistant',
35
+ content: typeof payload.text === 'string' ? payload.text : null,
36
+ sessionId: payload.sessionId || payload.session_id || null,
37
+ turnId: payload.turnId || null,
38
+ repoIdentity: repo.repoIdentity,
39
+ projectPath: repo.projectPath,
40
+ branch: repo.branch,
41
+ headAtCapture: repo.headAtCapture,
42
+ identityConfidence: payload.sessionId ? (payload.turnId ? 'exact' : 'partial') : 'unavailable',
43
+ captureStatus: typeof payload.text === 'string' ? 'complete' : 'partial',
44
+ rawEventType: payload.type
45
+ });
46
+ const appended = await journal.appendConversationEvent(event);
47
+ await notifyConsumers(home, { source: 'opencode', sequence: appended.sequence, eventId: appended.eventId });
48
+ return { status: 'captured', sequence: appended.sequence, turnId: appended.turnId };
49
+ }
50
+
51
+ function mainFailOpen({ home, payload }) {
52
+ return main({ home, payload }).catch(() => ({ status: 'fail-open' }));
53
+ }
54
+
55
+ module.exports = { main, mainFailOpen };
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { bridgeHome } = require('./paths');
6
+ const { Journal } = require('./journal');
7
+ const { ConsumerRegistry } = require('./consumer-registry');
8
+ const { compactJournal } = require('./compaction');
9
+ const { journalFor, globalJournalDir } = require('./journal-router');
10
+ const projectRegistry = require('./project-registry');
11
+ const { sealCommitConversations, sealDirFor } = require('./commit-projection');
12
+ const { resolveRepoContext } = require('./repo-context');
13
+ const { validateAndNormalizeEvent } = require('./event-schema');
14
+ const { notifyConsumers } = require('../connectors/claude-code/notify');
15
+
16
+ /**
17
+ * Stable host-facing facade over the Bridge runtime. Hosts (Project-Knowledge,
18
+ * DevTask-Radar, ...) must talk to this facade only — never to Journal
19
+ * internals or Bridge filesystem layout.
20
+ */
21
+ function createBridge({ homeDir } = {}) {
22
+ const home = bridgeHome(homeDir);
23
+ // Lazy internals: constructing a facade must not touch the filesystem, so
24
+ // merely loading a Bridge (e.g. in a host test) creates no runtime home.
25
+ let journal = null;
26
+ let registry = null;
27
+ const getJournal = () => {
28
+ if (!journal) journal = new Journal(path.join(home, 'journal'));
29
+ return journal;
30
+ };
31
+ const getRegistry = () => {
32
+ if (!registry) registry = new ConsumerRegistry(home);
33
+ return registry;
34
+ };
35
+ // Pure reads on a machine where no Bridge home exists yet must not
36
+ // materialize one: status queries stay side-effect free.
37
+ const homeExists = () => fs.existsSync(home);
38
+ const selectJournal = (journalId = 'global') => {
39
+ if (journalId === 'global') return getJournal();
40
+ const project = projectRegistry.listProjects(home).find(p => p.id === journalId);
41
+ if (!project) throw Object.assign(new Error('journal is not registered'), { code: 'JOURNAL_NOT_FOUND' });
42
+ return journalFor(home, project.id);
43
+ };
44
+
45
+ return {
46
+ homeDir: home,
47
+
48
+ async listJournals() {
49
+ return [{ journalId: 'global', projectId: null }, ...projectRegistry.listProjects(home).map(p => ({ journalId: p.id, projectId: p.id, repoPath: p.path }))];
50
+ },
51
+
52
+ async getHighWatermark({ journalId = 'global' } = {}) {
53
+ if (!homeExists()) return 0;
54
+ const bounds = await selectJournal(journalId).getBounds();
55
+ return bounds.lastSequence;
56
+ },
57
+
58
+ async readEvents(options = {}) {
59
+ return selectJournal(options.journalId).readEvents(options);
60
+ },
61
+
62
+ // Host-side injection for tests and explicit workflows; automatic capture
63
+ // flows through the connectors' canonical append path.
64
+ async appendConversationEvent(event) {
65
+ return getJournal().appendConversationEvent(event);
66
+ },
67
+
68
+ async captureWorkbenchEvent(input) {
69
+ if (!['user_prompt', 'assistant_response', 'session_end'].includes(input?.eventType)
70
+ || typeof input.projectPath !== 'string' || !path.isAbsolute(input.projectPath)
71
+ || typeof input.eventKey !== 'string' || !input.eventKey.startsWith('workbench:')
72
+ || !input.sessionId || !input.turnId || input.content && (typeof input.content !== 'string' || input.content.length > 200000)) {
73
+ throw Object.assign(new Error('Invalid native workbench event'), { code: 'CAPTURE_INVALID' });
74
+ }
75
+ const repo = await resolveRepoContext(input.projectPath);
76
+ const project = projectRegistry.findProject(home, repo.repoIdentity);
77
+ if (!project || fs.realpathSync(project.path) !== fs.realpathSync(input.projectPath)) {
78
+ throw Object.assign(new Error('Workbench project is not registered'), { code: 'PROJECT_NOT_REGISTERED' });
79
+ }
80
+ const event = validateAndNormalizeEvent({
81
+ source: input.source, eventType: input.eventType, role: input.eventType === 'user_prompt' ? 'user' : input.eventType === 'assistant_response' ? 'assistant' : null,
82
+ content: input.eventType === 'session_end' ? undefined : input.content, sessionId: input.sessionId, turnId: input.turnId, eventKey: input.eventKey,
83
+ repoIdentity: repo.repoIdentity, projectPath: repo.projectPath, branch: repo.branch, headAtCapture: repo.headAtCapture,
84
+ capturedAt: typeof input.capturedAt === 'string' && !Number.isNaN(Date.parse(input.capturedAt)) ? input.capturedAt : new Date().toISOString(),
85
+ identityConfidence: 'exact', captureStatus: 'complete', meta: { origin: 'claude-ai-workbench', outcome: input.outcome === 'failed' ? 'failed' : 'completed' },
86
+ });
87
+ const appended = await journalFor(home, repo.repoIdentity).appendConversationEvent(event);
88
+ await notifyConsumers(home, { sequence: appended.sequence, eventId: appended.eventId });
89
+ return { status: 'captured', ...appended };
90
+ },
91
+
92
+ async appendCommitBoundary({ projectId, repoIdentity, commitSha, parentShas, parents, branch, subject, committedAt, operationId, meta } = {}) {
93
+ if (!repoIdentity) {
94
+ throw new Error('appendCommitBoundary requires repoIdentity');
95
+ }
96
+ // Boundaries live in the same journal as the repo's conversation events
97
+ // (openTurnIdsAtCommit is computed from that journal's open-turn state).
98
+ const journal = journalFor(home, repoIdentity);
99
+ const result = await journal.appendCommitBoundary(repoIdentity, {
100
+ commitSha,
101
+ parents: parents !== undefined ? parents : parentShas,
102
+ branch,
103
+ subject,
104
+ committedAt,
105
+ projectId,
106
+ operationId,
107
+ meta
108
+ });
109
+ const routed = journal.journalDir !== globalJournalDir(home);
110
+ let bridgeCursorAtCommit = result.sequence;
111
+ if (routed) {
112
+ // Routed to a registered project's journal: the boundary is not in the
113
+ // global journal, so the global consumer watermark is unchanged. Keep
114
+ // the returned cursor conservative (never past unseen global content).
115
+ const bounds = await getJournal().getBounds();
116
+ bridgeCursorAtCommit = bounds.lastSequence;
117
+ }
118
+ // A routed boundary means a registered project just committed: freeze
119
+ // that commit's conversations into <store>/commits/<sha>.md. Projection
120
+ // failures are reported, never thrown — the journal remains the only
121
+ // source of truth and the boundary is already durable.
122
+ let projection = { sealed: false, turnCount: 0, reason: routed ? 'project-not-registered' : 'global-journal' };
123
+ if (routed) {
124
+ try {
125
+ const project = projectRegistry.findProject(home, repoIdentity);
126
+ if (project) {
127
+ const sealDir = sealDirFor(project.store);
128
+ projection = await sealCommitConversations({
129
+ journal,
130
+ sealDir,
131
+ boundary: {
132
+ commitSha,
133
+ branch,
134
+ subject,
135
+ committedAt,
136
+ sequence: result.sequence,
137
+ openTurnIdsAtCommit: result.openTurnIdsAtCommit,
138
+ previousRepoBoundarySequence: result.previousRepoBoundarySequence
139
+ }
140
+ });
141
+ // The journal is a conveyor: once the archive copy verifiably
142
+ // exists on disk, drop the sealed prefix. On any miss the prefix
143
+ // stays and the next boundary's seal window covers it (self-heal).
144
+ if (projection.sealed || (projection.reason === 'already-sealed' && fs.existsSync(projection.path))) {
145
+ const minAck = await getRegistry().getMinAck({ journalId: project.id });
146
+ const trim = await journal.trimThrough(minAck === null ? result.sequence : Math.min(result.sequence, minAck));
147
+ projection.trimmedThrough = trim.trimmedThrough;
148
+ }
149
+ }
150
+ } catch (err) {
151
+ projection = { sealed: false, turnCount: 0, error: err && err.message ? err.message : String(err) };
152
+ }
153
+ }
154
+ return {
155
+ sequence: result.sequence,
156
+ journalId: routed ? projectRegistry.findProject(home, repoIdentity).id : 'global',
157
+ bridgeCursorAtCommit,
158
+ openTurnIdsAtCommit: result.openTurnIdsAtCommit,
159
+ previousRepoBoundarySequence: result.previousRepoBoundarySequence,
160
+ committedAt: committedAt || new Date().toISOString(),
161
+ projection
162
+ };
163
+ },
164
+
165
+ async registerConsumer(name, meta = {}) {
166
+ return getRegistry().registerConsumer(name, meta);
167
+ },
168
+
169
+ async getConsumer(name) {
170
+ if (!homeExists()) return null;
171
+ const consumers = await getRegistry().getConsumers();
172
+ return consumers.find((consumer) => consumer.name === name) || null;
173
+ },
174
+
175
+ async listConsumers() {
176
+ if (!homeExists()) return [];
177
+ return getRegistry().getConsumers();
178
+ },
179
+
180
+ async ackConsumerCursor(name, sequence, options = {}) {
181
+ const high = await this.getHighWatermark(options);
182
+ if (sequence > high) throw Object.assign(new Error('ack exceeds journal high watermark'), { code: 'ACK_OUT_OF_RANGE' });
183
+ return getRegistry().ackConsumerCursor(name, sequence, options);
184
+ },
185
+
186
+ async unregisterConsumer(name) {
187
+ return getRegistry().unregisterConsumer(name);
188
+ },
189
+
190
+ async compact({ throughSequence, journalId = 'global' } = {}) {
191
+ return compactJournal(selectJournal(journalId), getRegistry(), { throughSequence, journalId });
192
+ },
193
+
194
+ async getHealth() {
195
+ if (!homeExists()) {
196
+ return {
197
+ journalSizeBytes: 0,
198
+ firstSequence: 0,
199
+ lastSequence: 0,
200
+ minConsumerAck: null,
201
+ oldestUnackedAt: null,
202
+ consumers: []
203
+ };
204
+ }
205
+ const liveJournal = getJournal();
206
+ const [bounds, consumers, minConsumerAck] = await Promise.all([
207
+ liveJournal.getBounds(),
208
+ getRegistry().getConsumers(),
209
+ getRegistry().getMinAck()
210
+ ]);
211
+ let journalSizeBytes = 0;
212
+ try {
213
+ journalSizeBytes = fs.statSync(liveJournal.eventsFile).size;
214
+ } catch (_) {
215
+ journalSizeBytes = 0;
216
+ }
217
+ let oldestUnackedAt = null;
218
+ if (minConsumerAck !== null && bounds.lastSequence > minConsumerAck) {
219
+ try {
220
+ const pending = await liveJournal.readEvents({ fromSequence: minConsumerAck + 1, toSequence: minConsumerAck + 1, limit: 1 });
221
+ if (pending.length) oldestUnackedAt = pending[0].capturedAt || null;
222
+ } catch (_) {
223
+ oldestUnackedAt = null;
224
+ }
225
+ }
226
+ return {
227
+ journalSizeBytes,
228
+ firstSequence: bounds.firstSequence,
229
+ lastSequence: bounds.lastSequence,
230
+ minConsumerAck,
231
+ oldestUnackedAt,
232
+ consumers: consumers.map((consumer) => ({
233
+ name: consumer.name,
234
+ ack: consumer.ack,
235
+ lastSeenAt: consumer.lastSeenAt || null
236
+ }))
237
+ };
238
+ },
239
+
240
+ // Introspection helpers for diagnostics; not part of the frozen contract.
241
+ get _journal() { return journal; },
242
+ get _registry() { return registry; }
243
+ };
244
+ }
245
+
246
+ module.exports = { createBridge };