@michelj/context-guard 0.4.4 → 0.6.2

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 (101) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +72 -102
  4. package/README.zh-CN.md +72 -102
  5. package/SKILL.md +26 -33
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/bin/build-runtime.mjs +96 -0
  9. package/bin/context-guard-skill.js +287 -69
  10. package/bin/postinstall.js +1 -1
  11. package/hooks.json +80 -4
  12. package/licenses/JSONParse-MIT.txt +24 -0
  13. package/licenses/Marked-MIT.txt +44 -0
  14. package/licenses/Portless-Apache-2.0.txt +201 -0
  15. package/package.json +31 -5
  16. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  17. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  18. package/prototype/attachments.mjs +75 -0
  19. package/prototype/coordinator-markdown.mjs +283 -0
  20. package/prototype/coordinator-working-blot.mjs +124 -0
  21. package/prototype/vendor/marked.mjs +2189 -0
  22. package/prototype/workbench-app.js +5197 -0
  23. package/prototype/workbench-data.js +33 -0
  24. package/prototype/workbench-sync.mjs +898 -0
  25. package/prototype/workbench.css +1050 -0
  26. package/prototype/workbench.html +139 -4861
  27. package/prototype/working-blot-atlas.png +0 -0
  28. package/references/agent-handoff.md +40 -0
  29. package/references/claude-runtime.md +120 -0
  30. package/references/cloud-sync-interface.md +66 -0
  31. package/references/design-current.md +14 -0
  32. package/references/map-mount.md +41 -0
  33. package/references/map-read.md +50 -0
  34. package/references/memory-definition.md +120 -0
  35. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  36. package/references/memory-filesystem-v2/Bug.md +162 -0
  37. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  38. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  40. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  41. package/references/memory-filesystem-v2/Idea.md +36 -0
  42. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  44. package/references/memory-filesystem-v2/README.md +60 -0
  45. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  46. package/references/memory-filesystem-v2/Todo.md +137 -0
  47. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  48. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  50. package/references/named-workbench.md +124 -0
  51. package/references/plan-review.md +12 -0
  52. package/references/server-memory.md +276 -0
  53. package/references/test-check.md +7 -0
  54. package/references/user-reply.md +38 -0
  55. package/references/workbench-interface.md +531 -0
  56. package/roles.md +13 -0
  57. package/scripts/context_guard.py +1163 -321
  58. package/scripts/context_guard_hook.py +1864 -63
  59. package/scripts/map_owns.py +68 -138
  60. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  61. package/scripts/shared/filesystem-v2.mjs +430 -0
  62. package/scripts/shared/io.mjs +117 -0
  63. package/scripts/shared/map-model.mjs +506 -0
  64. package/scripts/shared/memory-schema.mjs +13 -0
  65. package/scripts/shared/protocol-blobs.mjs +112 -0
  66. package/scripts/shared/protocol-map.mjs +146 -0
  67. package/scripts/shared/protocol-snapshots.mjs +84 -0
  68. package/scripts/shared/protocol-store.mjs +624 -0
  69. package/scripts/shared/protocol-workflow.mjs +226 -0
  70. package/scripts/shared/protocol.mjs +125 -0
  71. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  72. package/scripts/workbench/access.mjs +496 -0
  73. package/scripts/workbench/attachments.mjs +92 -0
  74. package/scripts/workbench/browser-login.mjs +78 -0
  75. package/scripts/workbench/claude-runtime.mjs +372 -0
  76. package/scripts/workbench/cli.mjs +980 -0
  77. package/scripts/workbench/device-heartbeat.mjs +72 -0
  78. package/scripts/workbench/hook-status.mjs +38 -0
  79. package/scripts/workbench/inbox.mjs +155 -0
  80. package/scripts/workbench/journal.mjs +56 -0
  81. package/scripts/workbench/memory-merge.mjs +65 -0
  82. package/scripts/workbench/memory.mjs +252 -0
  83. package/scripts/workbench/named-proxy.mjs +108 -0
  84. package/scripts/workbench/named.mjs +152 -0
  85. package/scripts/workbench/portless-routes.mjs +51 -0
  86. package/scripts/workbench/project.mjs +327 -0
  87. package/scripts/workbench/projections.mjs +68 -0
  88. package/scripts/workbench/protocol-client.mjs +165 -0
  89. package/scripts/workbench/protocol-delivery.mjs +133 -0
  90. package/scripts/workbench/protocol-device.mjs +316 -0
  91. package/scripts/workbench/protocol-events.mjs +53 -0
  92. package/scripts/workbench/protocol-repository.mjs +58 -0
  93. package/scripts/workbench/reconcile.mjs +244 -0
  94. package/scripts/workbench/registry.mjs +111 -0
  95. package/scripts/workbench/runtime.mjs +54 -0
  96. package/scripts/workbench/server.mjs +1171 -0
  97. package/scripts/workbench/store.mjs +243 -0
  98. package/scripts/workbench/sync-coordinator.mjs +518 -0
  99. package/scripts/workbench/sync.mjs +86 -0
  100. package/references/bug-record-template.md +0 -37
  101. package/references/context-template.md +0 -19
@@ -0,0 +1,518 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { applyOperations, diffTrees, MapError, same, validate } from '../shared/map-model.mjs';
6
+ import { atomicWrite, encode, hash, readJSON } from '../shared/io.mjs';
7
+ import { definitiveMemoryRejection, memoryConfigPath, memoryRequest } from './memory.mjs';
8
+ import { mergeSessionDocuments } from './memory-merge.mjs';
9
+ export { mergeSessionDocuments } from './memory-merge.mjs';
10
+
11
+ const equalDocument = (a, b) => !!a && !!b && hash(encode(a)) === hash(encode(b));
12
+
13
+ export function documentOperations(before, after) {
14
+ if (!before?.root && after?.root) {
15
+ const operations = [{ type: 'initialize', project: after.project, node: structuredClone(after.root) }];
16
+ if ((after.flows || []).length) operations.push({ type: 'document', fields: { flows: structuredClone(after.flows) } });
17
+ return operations;
18
+ }
19
+ if (!before?.root || !after?.root) return [];
20
+ const operations = diffTrees(before.root, after.root);
21
+ if (!same(before.flows || [], after.flows || [])) operations.push({ type: 'document', fields: { flows: structuredClone(after.flows || []) } });
22
+ return operations;
23
+ }
24
+
25
+ function scopes(operations) {
26
+ const values = new Set();
27
+ for (const operation of operations || []) {
28
+ if (operation.type === 'document') for (const field of Object.keys(operation.fields || {})) values.add(`document:${field}`);
29
+ else if (operation.type === 'initialize') values.add('*');
30
+ else {
31
+ const id = operation.id || operation.node?.id || '';
32
+ if (!id) { values.add('*'); continue; }
33
+ if (operation.type === 'update') for (const field of Object.keys(operation.fields || {})) values.add(`${id}:${field}`);
34
+ else if (operation.type === 'attach-bug' || operation.type === 'update-bug') values.add(`${id}:bugs`);
35
+ else values.add(`${id}:*`);
36
+ }
37
+ }
38
+ return values;
39
+ }
40
+
41
+ export function operationsOverlap(left, right) {
42
+ const a = scopes(left), b = scopes(right);
43
+ if (a.has('*') || b.has('*')) return true;
44
+ for (const value of a) {
45
+ if (b.has(value)) return true;
46
+ const [node] = value.split(':');
47
+ if (b.has(`${node}:*`) || value.endsWith(':*') && [...b].some(item => item.startsWith(`${node}:`))) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function parseSseBlocks(buffer) {
53
+ const normalized = buffer.replaceAll('\r\n', '\n');
54
+ const blocks = normalized.split('\n\n');
55
+ return { blocks: blocks.slice(0, -1), rest: blocks.at(-1) || '' };
56
+ }
57
+
58
+ export class MemorySyncCoordinator extends EventEmitter {
59
+ constructor({ project, sessionId, store, directory, request = memoryRequest, display, retryMin = 250, retryMax = 5000, managed = false, heartbeatMs = 10000, streamIdleMs = 25000 } = {}) {
60
+ super();
61
+ this.project = project;
62
+ this.sessionId = sessionId;
63
+ this.store = store;
64
+ this.sessionDirectory = directory;
65
+ this.directory = path.join(directory, 'remote-sync');
66
+ this.stateFile = path.join(this.directory, 'state.json');
67
+ this.baseFile = path.join(this.directory, 'server-base.json');
68
+ this.outboxFile = path.join(this.directory, 'outbox.json');
69
+ this.conflictFile = path.join(this.directory, 'conflict.json');
70
+ this.request = request;
71
+ this.display = display;
72
+ this.retryMin = retryMin;
73
+ this.retryMax = retryMax;
74
+ this.retryDelay = retryMin;
75
+ this.heartbeatMs = heartbeatMs;
76
+ this.streamIdleMs = streamIdleMs;
77
+ this.serial = Promise.resolve();
78
+ this.closed = false;
79
+ this.initialized = false;
80
+ this.managed = managed;
81
+ this.abort = null;
82
+ this.status = { configured: false, status: 'disabled', pending: 0, cursor: 0, serverVersion: null, error: null, conflict: null };
83
+ this.onStoreEvent = event => {
84
+ if (event.actor?.sessionId === 'cloud-sync') return;
85
+ const recovery = event.actions?.includes('journal-recovery')
86
+ && ['MAIN_ADVANCED_BEFORE_SESSION_REOPEN', 'SESSION_MAIN_BASELINE_REQUIRED'].includes(this.status.conflict?.code);
87
+ this.schedule(async () => {
88
+ if (!recovery) return this.queueLocal();
89
+ // The Map was read-only while the first bootstrap ran. A human recovery
90
+ // is the durable signal to retry that same bootstrap; semantic conflicts
91
+ // are saved again by initialize instead of being silently discarded.
92
+ await this.persist({ status: 'connecting', conflict: null, error: null });
93
+ await this.initialize();
94
+ }).catch(() => {});
95
+ };
96
+ }
97
+
98
+ async knownBase() {
99
+ let base = await readJSON(this.baseFile, null);
100
+ if (!base) {
101
+ const confirmed = await readJSON(path.join(this.sessionDirectory, 'base-main.json'), null);
102
+ if (confirmed?.map) {
103
+ validate(confirmed.map);
104
+ base = confirmed.map;
105
+ await atomicWrite(this.baseFile, encode(base));
106
+ }
107
+ }
108
+ if (base && this.status.conflict?.code === 'SESSION_MAIN_BASELINE_REQUIRED') {
109
+ await fs.unlink(this.conflictFile).catch(error => { if (error.code !== 'ENOENT') throw error; });
110
+ await this.persist({ status: 'connecting', conflict: null, error: null });
111
+ }
112
+ return base;
113
+ }
114
+
115
+ snapshot() { return { ...this.status }; }
116
+ update(fields) {
117
+ this.status = { ...this.status, ...fields };
118
+ this.emit('change', this.snapshot());
119
+ }
120
+ schedule(action) {
121
+ const next = this.serial.then(action);
122
+ this.serial = next.catch(() => {});
123
+ return next;
124
+ }
125
+ async persist(fields = {}) {
126
+ this.update(fields);
127
+ await atomicWrite(this.stateFile, encode(this.status));
128
+ }
129
+
130
+ async start() {
131
+ await fs.mkdir(this.directory, { recursive: true });
132
+ const configuration = await readJSON(memoryConfigPath(this.project), null);
133
+ if (!configuration) return this.snapshot();
134
+ this.configuration = configuration;
135
+ this.status = { ...this.status, ...(await readJSON(this.stateFile, {})), configured: true, status: 'connecting' };
136
+ this.store.on('event', this.onStoreEvent);
137
+ this.update({ configured: true, status: 'connecting' });
138
+ this.loopPromise = this.run();
139
+ this.startLegacyHeartbeat();
140
+ return this.snapshot();
141
+ }
142
+
143
+ async initialize() {
144
+ const base = await this.knownBase();
145
+ let remote = (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`)).snapshot;
146
+ if (!remote) remote = await this.createSessionGeneration();
147
+ else remote = await this.ensureSessionDisplay(remote);
148
+ if (!remote || this.status.conflict) return;
149
+ validate(remote.memory.map);
150
+ if (this.status.status === 'synced' && !this.status.pending && remote.version === this.status.serverVersion && equalDocument(base, remote.memory.map) && equalDocument(this.store.doc, base)) return;
151
+ if (!base) {
152
+ if (equalDocument(this.store.doc, remote.memory.map)) {
153
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
154
+ } else {
155
+ const main = (await this.request(this.project, 'main')).snapshot;
156
+ if (main?.memory?.map && equalDocument(this.store.doc, main.memory.map)) {
157
+ await this.applyRemoteDocument(remote.memory.map, `remote:init:${remote.version}`);
158
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
159
+ } else {
160
+ return this.saveConflict('INITIAL_SYNC_CONFLICT', null, this.store.doc, remote.memory.map);
161
+ }
162
+ }
163
+ } else await this.reconcileRemote(remote, this.status.cursor || 0);
164
+ if (this.status.conflict) return;
165
+ await this.persist({ status: 'syncing', serverVersion: remote.version, error: null, conflict: null });
166
+ await this.queueLocal();
167
+ }
168
+
169
+ async currentDisplay() {
170
+ const display = await this.display?.();
171
+ return display?.name ? { name: String(display.name).slice(0, 200), platform: String(display.platform || 'unknown').slice(0, 30) } : null;
172
+ }
173
+
174
+ async ensureSessionDisplay(remote) {
175
+ const display = await this.currentDisplay();
176
+ if (!display || same(remote.memory?.display, display)) return remote;
177
+ return (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`, {
178
+ operationId: `session-display:${this.sessionId}:${hash(encode(display))}`,
179
+ baseVersion: remote.version,
180
+ baseMainVersion: remote.baseMainVersion || null,
181
+ sourceCommit: remote.sourceCommit || this.project.head,
182
+ memory: { ...remote.memory, display },
183
+ })).snapshot;
184
+ }
185
+
186
+ async createSessionGeneration() {
187
+ const scope = `sessions/${encodeURIComponent(this.sessionId)}`;
188
+ const existing = (await this.request(this.project, scope)).snapshot;
189
+ if (existing) return existing;
190
+ const main = (await this.request(this.project, 'main')).snapshot;
191
+ let base = await this.knownBase();
192
+ if (main?.memory?.map) {
193
+ if (!base) {
194
+ if (!equalDocument(this.store.doc, main.memory.map)) {
195
+ await this.saveConflict('SESSION_MAIN_BASELINE_REQUIRED', null, this.store.doc, main.memory.map);
196
+ return null;
197
+ }
198
+ await atomicWrite(this.baseFile, encode(main.memory.map));
199
+ base = main.memory.map;
200
+ } else if (!equalDocument(base, main.memory.map)) {
201
+ let merged;
202
+ try { merged = mergeSessionDocuments(base, this.store.doc, main.memory.map); }
203
+ catch (error) {
204
+ if (error.code !== 'MEMORY_CONFLICT') throw error;
205
+ await this.saveConflict('MAIN_ADVANCED_BEFORE_SESSION_REOPEN', base, this.store.doc, main.memory.map);
206
+ return null;
207
+ }
208
+ if (!equalDocument(merged, this.store.doc)) await this.applyRemoteDocument(merged, `main-rebase:${main.version}`);
209
+ }
210
+ }
211
+ const display = await this.currentDisplay();
212
+ const input = {
213
+ operationId: `session-init:${this.sessionId}:${randomUUID()}`,
214
+ baseVersion: null,
215
+ baseMainVersion: main?.version || null,
216
+ sourceCommit: this.project.head,
217
+ memory: { map: structuredClone(this.store.doc), records: {}, ...(display ? { display } : {}) },
218
+ };
219
+ let remote;
220
+ try { remote = (await this.request(this.project, scope, input)).snapshot; }
221
+ catch (error) {
222
+ if (error.code !== 'VERSION_CONFLICT') throw error;
223
+ remote = (await this.request(this.project, scope)).snapshot;
224
+ if (!remote) throw error;
225
+ await this.reconcileRemote(remote, this.status.cursor || 0);
226
+ return remote;
227
+ }
228
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
229
+ await fs.unlink(this.outboxFile).catch(error => { if (error.code !== 'ENOENT') throw error; });
230
+ await fs.unlink(this.conflictFile).catch(error => { if (error.code !== 'ENOENT') throw error; });
231
+ await this.persist({ status: 'synced', pending: 0, serverVersion: remote.version, error: null, conflict: null, lastSyncedAt: remote.updatedAt });
232
+ return remote;
233
+ }
234
+
235
+ async queueLocal() {
236
+ if (this.closed || !this.configuration || this.status.conflict) return;
237
+ if (await readJSON(this.outboxFile, null)) { this.update({ status: 'syncing', pending: 1 }); return this.flush(); }
238
+ const base = await readJSON(this.baseFile, null);
239
+ if (!base || equalDocument(base, this.store.doc)) {
240
+ await this.persist({ status: 'synced', pending: 0, error: null, lastSyncedAt: new Date().toISOString() });
241
+ return;
242
+ }
243
+ const operations = documentOperations(base, this.store.doc);
244
+ if (!operations.length) return;
245
+ const body = {
246
+ operationId: `session-map:${this.sessionId}:${randomUUID()}`,
247
+ baseVersion: this.status.serverVersion,
248
+ operations,
249
+ localVersion: this.store.version,
250
+ createdAt: new Date().toISOString(),
251
+ };
252
+ await atomicWrite(this.outboxFile, encode(body));
253
+ await this.persist({ status: 'syncing', pending: 1, error: null });
254
+ return this.flush();
255
+ }
256
+
257
+ async flush() {
258
+ const pending = await readJSON(this.outboxFile, null);
259
+ if (!pending || this.closed || this.status.conflict) return;
260
+ try {
261
+ const result = await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}/map`, {
262
+ operationId: pending.operationId,
263
+ baseVersion: pending.baseVersion,
264
+ operations: pending.operations,
265
+ });
266
+ const base = await readJSON(this.baseFile);
267
+ const acknowledged = applyOperations(base, pending.operations, { kind: 'human', sessionId: 'cloud-sync' }).doc;
268
+ await atomicWrite(this.baseFile, encode(acknowledged));
269
+ await this.persist({ status: 'syncing', pending: 1, serverVersion: result.version, cursor: Math.max(this.status.cursor || 0, result.cursor || 0), error: null });
270
+ await fs.unlink(this.outboxFile).catch(error => { if (error.code !== 'ENOENT') throw error; });
271
+ this.retryDelay = this.retryMin;
272
+ await this.persist({ status: equalDocument(acknowledged, this.store.doc) ? 'synced' : 'syncing', pending: 0, lastSyncedAt: result.persistedAt || new Date().toISOString() });
273
+ if (!equalDocument(acknowledged, this.store.doc)) await this.queueLocal();
274
+ } catch (error) {
275
+ if (error.code === 'SESSION_REOPEN_REQUIRED') {
276
+ await this.createSessionGeneration();
277
+ return;
278
+ }
279
+ if (error.code === 'VERSION_CONFLICT') {
280
+ const remote = (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`)).snapshot;
281
+ await this.reconcileRemote(remote, this.status.cursor || 0);
282
+ return;
283
+ }
284
+ if (definitiveMemoryRejection(error)) {
285
+ await fs.unlink(this.outboxFile).catch(unlinkError => { if (unlinkError.code !== 'ENOENT') throw unlinkError; });
286
+ await this.persist({ status: 'error', pending: 0, error: error.code || 'MEMORY_REJECTED' });
287
+ return;
288
+ }
289
+ await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', pending: 1, error: error.code || error.message });
290
+ this.retry();
291
+ }
292
+ }
293
+
294
+ async reconcileRemote(remote, cursor) {
295
+ if (!remote) return;
296
+ validate(remote.memory.map);
297
+ const base = await readJSON(this.baseFile, null);
298
+ const local = this.store.doc;
299
+ if (!base && !equalDocument(local, remote.memory.map)) return this.saveConflict('BASELINE_MISSING', null, local, remote.memory.map, { cursor, serverVersion: remote.version });
300
+ if (equalDocument(local, remote.memory.map)) {
301
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
302
+ await fs.unlink(this.outboxFile).catch(() => {});
303
+ return this.persist({ serverVersion: remote.version, cursor, status: 'synced', pending: 0, error: null, conflict: null, lastSyncedAt: remote.updatedAt });
304
+ }
305
+ const localOperations = documentOperations(base, local);
306
+ const remoteOperations = documentOperations(base, remote.memory.map);
307
+ if (!remoteOperations.length) return this.persist({ serverVersion: remote.version, cursor, status: localOperations.length ? 'syncing' : 'synced', error: null });
308
+ if (!localOperations.length) {
309
+ await this.applyRemoteDocument(remote.memory.map, `remote:${remote.version}`);
310
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
311
+ await fs.unlink(this.outboxFile).catch(() => {});
312
+ return this.persist({ serverVersion: remote.version, cursor, status: 'synced', pending: 0, error: null, conflict: null, lastSyncedAt: remote.updatedAt });
313
+ }
314
+ let merged;
315
+ try { merged = mergeSessionDocuments(base, local, remote.memory.map); }
316
+ catch (error) {
317
+ if (error.code !== 'MEMORY_CONFLICT') throw error;
318
+ return this.saveConflict('REMOTE_AND_LOCAL_CHANGED', base, local, remote.memory.map, { cursor, serverVersion: remote.version });
319
+ }
320
+ await this.applyRemoteDocument(merged, `remote-rebase:${remote.version}`);
321
+ await atomicWrite(this.baseFile, encode(remote.memory.map));
322
+ await fs.unlink(this.outboxFile).catch(() => {});
323
+ await this.persist({ serverVersion: remote.version, cursor, status: 'syncing', pending: 0, error: null, conflict: null });
324
+ return this.queueLocal();
325
+ }
326
+
327
+ async applyRemoteDocument(document, operationId) {
328
+ const operations = documentOperations(this.store.doc, document);
329
+ if (!operations.length) return;
330
+ await this.store.commit({ operationId, baseVersion: this.store.version, operations }, { kind: 'human', sessionId: 'cloud-sync' });
331
+ }
332
+
333
+ async saveConflict(code, base, local, remote, extra = {}) {
334
+ const conflict = { code, base, local, remote, at: new Date().toISOString(), ...extra };
335
+ await atomicWrite(this.conflictFile, encode(conflict));
336
+ await this.persist({ status: 'conflict', pending: (await readJSON(this.outboxFile, null)) ? 1 : 0, conflict: { code, at: conflict.at }, error: null });
337
+ return this.snapshot();
338
+ }
339
+
340
+ retry() {
341
+ if (this.closed || this.managed || this.retryTimer) return;
342
+ const delay = this.retryDelay;
343
+ this.retryDelay = Math.min(this.retryMax, Math.max(this.retryMin, this.retryDelay * 2));
344
+ this.retryTimer = setTimeout(() => {
345
+ this.retryTimer = null;
346
+ this.schedule(() => this.flush()).catch(() => {});
347
+ }, delay + Math.floor(Math.random() * Math.max(1, delay / 4)));
348
+ this.retryTimer.unref();
349
+ }
350
+
351
+ healthyLegacyFallback() {
352
+ // memoryRequest has a ten-second deadline. A successful polling channel is
353
+ // sufficient for synchronization even while the optional SSE hint reconnects.
354
+ return this.status.status === 'synced' && this.lastHeartbeatAt > Date.now() - this.heartbeatMs - 10000;
355
+ }
356
+
357
+ startLegacyHeartbeat() {
358
+ if (this.closed || this.managed) return;
359
+ this.heartbeatTimer = setTimeout(async () => {
360
+ try {
361
+ await this.schedule(async () => {
362
+ if (this.closed || this.managed || !this.initialized || this.status.conflict || !this.status.serverVersion) return;
363
+ const after = this.status.cursor || 0;
364
+ const changes = await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}/changes?after=${after}`);
365
+ if (!Number.isSafeInteger(changes.cursor) || changes.cursor < after) throw new MapError('INVALID_SYNC_CURSOR', 'Invalid change cursor');
366
+ if (changes.cursor > after) {
367
+ const remote = (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`)).snapshot;
368
+ await this.reconcileRemote(remote, changes.cursor);
369
+ }
370
+ if (this.status.pending && !this.status.conflict) await this.flush();
371
+ // An idle healthy connection must not rewrite the Map or state file.
372
+ else if (!this.status.conflict && ['offline', 'error', 'connecting'].includes(this.status.status)) await this.persist({ status: 'synced', error: null });
373
+ this.lastHeartbeatAt = Date.now();
374
+ });
375
+ } catch (error) {
376
+ await this.schedule(async () => {
377
+ this.lastHeartbeatAt = 0;
378
+ if (!this.closed && !this.managed && !this.status.conflict) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || 'MEMORY_UNAVAILABLE' });
379
+ }).catch(() => {});
380
+ } finally {
381
+ this.startLegacyHeartbeat();
382
+ }
383
+ }, this.heartbeatMs);
384
+ this.heartbeatTimer.unref();
385
+ }
386
+
387
+ armStreamDeadline() {
388
+ clearTimeout(this.streamTimer);
389
+ const controller = this.abort;
390
+ this.streamTimer = setTimeout(() => {
391
+ controller.abort(new MapError('EVENT_STREAM_TIMEOUT', 'Memory event stream stopped responding'));
392
+ this.streamReader?.cancel().catch(() => {});
393
+ }, this.streamIdleMs);
394
+ this.streamTimer.unref();
395
+ }
396
+
397
+ async consumeEvents(response) {
398
+ if (!response.ok) throw new MapError(response.status === 401 ? 'UNAUTHORIZED' : 'MEMORY_EVENTS_FAILED', `Memory event stream failed: ${response.status}`, response.status);
399
+ if (!response.body || !response.headers.get('content-type')?.includes('text/event-stream')) throw new MapError('MEMORY_EVENTS_FAILED', 'Memory service did not return an event stream');
400
+ const decoder = new TextDecoder(); let buffer = '';
401
+ const reader = response.body.getReader();
402
+ this.streamReader = reader;
403
+ try {
404
+ for (;;) {
405
+ const { done, value: chunk } = await reader.read();
406
+ if (done) break;
407
+ this.armStreamDeadline();
408
+ this.retryDelay = this.retryMin;
409
+ buffer += decoder.decode(chunk, { stream: true });
410
+ const parsed = parseSseBlocks(buffer); buffer = parsed.rest;
411
+ for (const block of parsed.blocks) {
412
+ const lines = block.split('\n');
413
+ const eventType = lines.find(line => line.startsWith('event:'))?.slice(6).trim() || 'message';
414
+ const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n');
415
+ if (!data) continue;
416
+ const event = JSON.parse(data);
417
+ if (eventType === 'change') await this.schedule(async () => {
418
+ const cursor = event.cursor ?? Number(lines.find(line => line.startsWith('id:'))?.slice(3).trim() || 0);
419
+ if (cursor <= (this.status.cursor || 0)) return;
420
+ const remote = (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`)).snapshot;
421
+ await this.reconcileRemote(remote, cursor);
422
+ });
423
+ }
424
+ if (this.closed) break;
425
+ }
426
+ } finally { this.streamReader = null; reader.releaseLock(); }
427
+ }
428
+
429
+ async run() {
430
+ // A project-level device connection takes over steady-state heartbeats, but
431
+ // every Session still needs one successful bootstrap. Without it, a Session
432
+ // that was closed after publication can never reopen and therefore never
433
+ // appears in the Cloud queue.
434
+ while (!this.closed) {
435
+ try {
436
+ await this.schedule(async () => {
437
+ await this.initialize();
438
+ this.initialized = !this.status.conflict && !!this.status.serverVersion;
439
+ });
440
+ if (this.closed || this.managed) return;
441
+ if (this.status.conflict) return;
442
+ const base = new URL(this.configuration.url);
443
+ this.abort = new AbortController();
444
+ this.armStreamDeadline();
445
+ const response = await fetch(new URL(`/v1/projects/${this.configuration.projectId}/sessions/${encodeURIComponent(this.sessionId)}/events?after=${this.status.cursor || 0}`, base), {
446
+ headers: { Authorization: `Bearer ${this.configuration.token}`, 'Last-Event-ID': String(this.status.cursor || 0) },
447
+ redirect: 'error', signal: this.abort.signal,
448
+ });
449
+ await this.consumeEvents(response);
450
+ if (!this.closed) throw new MapError('EVENT_STREAM_CLOSED', 'Memory event stream closed');
451
+ this.retryDelay = this.retryMin;
452
+ } catch (error) {
453
+ clearTimeout(this.streamTimer);
454
+ if (this.closed || this.managed) break;
455
+ if (this.abort?.signal.reason?.code === 'EVENT_STREAM_TIMEOUT') error = this.abort.signal.reason;
456
+ await this.schedule(async () => {
457
+ if (!this.closed && !this.status.conflict && (!this.managed || !this.initialized) && !this.healthyLegacyFallback()) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || error.message });
458
+ });
459
+ await this.waitForRetry(this.retryDelay + Math.floor(Math.random() * Math.max(1, this.retryDelay / 4)));
460
+ this.retryDelay = Math.min(this.retryMax, Math.max(this.retryMin, this.retryDelay * 2));
461
+ } finally {
462
+ clearTimeout(this.streamTimer);
463
+ this.abort?.abort();
464
+ this.abort = null;
465
+ }
466
+ }
467
+ }
468
+
469
+ waitForRetry(milliseconds) {
470
+ return new Promise(resolve => {
471
+ this.retryWaitResolve = resolve;
472
+ this.retryWaitTimer = setTimeout(() => { this.retryWaitTimer = null; this.retryWaitResolve = null; resolve(); }, milliseconds);
473
+ this.retryWaitTimer.unref();
474
+ });
475
+ }
476
+
477
+ async projectHeartbeat(head) {
478
+ if (typeof head.mapVersion !== 'string' || !Number.isSafeInteger(head.mapCursor) || head.mapCursor < 0) return;
479
+ if (!this.managed) {
480
+ this.managed = true;
481
+ clearTimeout(this.heartbeatTimer);
482
+ clearTimeout(this.retryTimer); this.retryTimer = null;
483
+ clearTimeout(this.retryWaitTimer); this.retryWaitResolve?.();
484
+ this.streamReader?.cancel().catch(() => {});
485
+ this.abort?.abort(); await this.loopPromise?.catch(() => {});
486
+ }
487
+ return this.schedule(async () => {
488
+ if (this.closed || this.status.conflict) return;
489
+ if (!this.status.serverVersion) await this.initialize();
490
+ if (this.status.conflict) return;
491
+ if (head.mapVersion !== this.status.serverVersion) {
492
+ const remote = (await this.request(this.project, `sessions/${encodeURIComponent(this.sessionId)}`)).snapshot;
493
+ // This cursor belongs to the observed version. A newer read will be
494
+ // reconciled again on the next heartbeat, never skip unseen events.
495
+ await this.reconcileRemote(remote, head.mapCursor);
496
+ }
497
+ if (this.status.pending) await this.flush();
498
+ else if (['offline', 'error', 'connecting'].includes(this.status.status)) await this.persist({ status: 'synced', error: null });
499
+ }).catch(async error => {
500
+ if (!this.status.conflict) await this.persist({ status: error.code === 'UNAUTHORIZED' ? 'error' : 'offline', error: error.code || 'MEMORY_UNAVAILABLE' });
501
+ throw error;
502
+ });
503
+ }
504
+
505
+ async close() {
506
+ this.closed = true;
507
+ clearTimeout(this.heartbeatTimer);
508
+ clearTimeout(this.streamTimer);
509
+ clearTimeout(this.retryTimer);
510
+ clearTimeout(this.retryWaitTimer);
511
+ this.retryWaitResolve?.();
512
+ this.store.off('event', this.onStoreEvent);
513
+ this.streamReader?.cancel().catch(() => {});
514
+ this.abort?.abort();
515
+ await this.loopPromise?.catch(() => {});
516
+ await this.serial;
517
+ }
518
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { encode, readJSON } from '../shared/io.mjs';
4
+ import { MapError } from '../shared/map-model.mjs';
5
+ import { bindingStatus, resolveProject } from './project.mjs';
6
+ import { memoryConfigPath, sessionMemoryDir, prepareMemory, synchronizeMemory } from './memory.mjs';
7
+
8
+ // Read-only retirement guard. Old drafts and receipts are never migrated by
9
+ // guessing that a project-wide Map belongs to the newly bound Session.
10
+ export async function inspectRetiredSync(project) {
11
+ const local = path.join(project.worktreeRoot, '.codex/context/private/cloud-sync');
12
+ const directories = [...new Set([local, path.join(project.sharedDir, 'cloud-sync')])];
13
+ let configured = false;
14
+ const pending = [];
15
+ for (const directory of directories) {
16
+ let names;
17
+ try { names = await fs.readdir(directory); }
18
+ catch (error) { if (error.code === 'ENOENT') continue; throw error; }
19
+ configured ||= names.includes('config.json');
20
+ try {
21
+ const state = await readJSON(path.join(directory, 'state.json'), null);
22
+ if (state && (state.conflict || state.pending || !['synced', 'disabled'].includes(state.status)
23
+ || Number(state.receivedCursor || 0) > Number(state.cursor || 0))) pending.push(directory);
24
+ if (state && !names.includes('base-map.json')) pending.push(directory);
25
+ const service = await readJSON(path.join(directory, 'service.json'), null);
26
+ if (service?.pid) {
27
+ try { process.kill(service.pid, 0); pending.push(path.join(directory, 'service.json')); }
28
+ catch (error) { if (error.code !== 'ESRCH') pending.push(path.join(directory, 'service.json')); }
29
+ }
30
+ for (const name of ['outbox.json', 'pending-upload.json', 'conflict.json']) {
31
+ if (names.includes(name) && await readJSON(path.join(directory, name), null)) pending.push(path.join(directory, name));
32
+ }
33
+ if (names.includes('works')) {
34
+ for (const name of await fs.readdir(path.join(directory, 'works'))) {
35
+ if (!name.endsWith('.json')) continue;
36
+ const work = await readJSON(path.join(directory, 'works', name), null);
37
+ if (!work || work.status !== 'completed') pending.push(path.join(directory, 'works', name));
38
+ }
39
+ }
40
+ if (names.includes('base-map.json')) {
41
+ const base = await readJSON(path.join(directory, 'base-map.json'), null);
42
+ const map = await readJSON(path.join(project.worktreeRoot, '.codex/context/map.json'), null);
43
+ if (!base || !map || encode(base) !== encode(map)) pending.push(path.join(directory, 'base-map.json'));
44
+ }
45
+ } catch (error) {
46
+ if (error.code === 'EACCES' || error.code === 'EPERM') throw error;
47
+ pending.push(directory);
48
+ }
49
+ }
50
+ if (pending.length) throw new MapError('UPGRADE_REQUIRED', 'Retired Map-only sync has unconfirmed data; reconcile the preserved drafts before using Session sync', 409, {
51
+ reason: 'legacy-sync-state-pending', paths: [...new Set(pending)],
52
+ });
53
+ return { configured };
54
+ }
55
+
56
+ export async function syncStatus(root, sessionId = '') {
57
+ const project = await resolveProject(root);
58
+ const retired = await inspectRetiredSync(project);
59
+ const config = await readJSON(memoryConfigPath(project), null);
60
+ if (!config) {
61
+ if (retired.configured) throw new MapError('UPGRADE_REQUIRED', 'Reconnect with workbench connect; the retired configuration was preserved', 409, { reason: 'legacy-sync-reconnect' });
62
+ return { configured: false, managedBy: 'workbench', sessionId: sessionId || null, state: { status: 'disabled', pending: 0 } };
63
+ }
64
+ const directory = sessionId ? sessionMemoryDir(project, sessionId) : null;
65
+ const state = directory ? await readJSON(path.join(directory, 'remote-sync/state.json'), null) : null;
66
+ return { configured: true, managedBy: 'workbench', url: config.url, projectId: config.projectId, sessionId: sessionId || null,
67
+ state: state || { configured: true, status: 'connecting', pending: 0 } };
68
+ }
69
+
70
+ export async function sessionSync(root, sessionId, action) {
71
+ if (!['status', 'prepare', 'pull', 'checkpoint', 'finish'].includes(action)) {
72
+ throw new MapError('UPGRADE_REQUIRED', 'Use workbench connect and Session sync status|ensure|prepare|pull|checkpoint|finish; project-wide work windows were retired', 409, { reason: 'legacy-sync-command' });
73
+ }
74
+ const status = await syncStatus(root, sessionId);
75
+ if (action === 'status') return status;
76
+ if (!sessionId) throw new MapError('SESSION_REQUIRED', 'Pass the actual --session before synchronization');
77
+ const project = await resolveProject(root);
78
+ if (!(await bindingStatus(project, sessionId)).session.bound) throw new MapError('SESSION_BINDING_REQUIRED', 'Bind the actual Session before synchronization', 409);
79
+ if (!status.configured) throw new MapError('MEMORY_NOT_CONFIGURED', 'Connect this project using workbench connect', 503);
80
+ if (['prepare', 'pull', 'checkpoint'].includes(action)) return prepareMemory(project, sessionId);
81
+ if (action === 'finish') {
82
+ const receipt = await synchronizeMemory(root, sessionId);
83
+ if (!receipt?.snapshot?.version) throw new MapError('MEMORY_UNAVAILABLE', 'Cloud did not confirm the Session snapshot; preserve pending data', 503);
84
+ return { confirmed: true, sessionVersion: receipt.snapshot.version };
85
+ }
86
+ }
@@ -1,37 +0,0 @@
1
- # Bug index card + how-to
2
-
3
- Workbench shows the title only.
4
-
5
- ## Index `.codex/context/bugs/{id}.md`
6
-
7
- ```md
8
- # {id} {title}
9
-
10
- - node: {homeNodeId}
11
- - status: open | fixed | deferred | wontfix
12
- - 现象: one line
13
- - keys: search words, comma separated
14
- - fix: .codex/context/fixes/{id}.md
15
- - card: .codex/context/cards/{homeNodeId}.md
16
- ```
17
-
18
- ## How-to `.codex/context/fixes/{id}.md`
19
-
20
- ```md
21
- # {id} how to fix
22
-
23
- - bug: .codex/context/bugs/{id}.md
24
- - node: {homeNodeId}
25
- - card: .codex/context/cards/{homeNodeId}.md
26
- - status: open | fixed | …
27
-
28
- ## 根因
29
- ## 怎么修
30
- ## 怎么防
31
- ## 代码
32
- - path/to/source.py
33
- ## 证据
34
- - docs/shots/…
35
- ```
36
-
37
- Unfixed how-to is `未修`. Code is repo-relative paths only.
@@ -1,19 +0,0 @@
1
- # Context folder (v1)
2
-
3
- ```text
4
- .codex/context/
5
- FIND.md
6
- FORMAT.md (this skill’s copy, or write one)
7
- sessions.jsonl
8
- bugs/{id}.md
9
- fixes/{id}.md
10
- tasks/{id}.md
11
- map.json
12
- owns-index.json (generated)
13
- cards/ (generated)
14
- preferences.json
15
- user-messages.md
16
- private/ (gitignored)
17
- ```
18
-
19
- Four stores: sessions, bugs, tasks, map. No Test Hub, no Roadmap HTML, no feature chains.