@michelj/context-guard 0.4.4 → 0.6.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 (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,243 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { watch } from 'node:fs';
4
+ import { EventEmitter } from 'node:events';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { MapError, validate, applyOperations, diffTrees } from '../shared/map-model.mjs';
7
+ import { hash, encode, atomicWrite, readJSON } from '../shared/io.mjs';
8
+ import { inspectJournal, backupJournal, replaceJournal } from './journal.mjs';
9
+
10
+ export class MapStore extends EventEmitter {
11
+ constructor(root, { fault = async () => {}, project = async () => {}, file, runtime, eventsFile, recoverJournal = false } = {}) {
12
+ super(); this.root = root; this.ctx = path.join(root, '.codex/context');
13
+ this.file = file || path.join(this.ctx, 'map.json'); this.runtime = runtime || path.join(this.ctx, 'private/sync');
14
+ this.pendingFile = path.join(this.runtime, 'pending.json');
15
+ this.eventsFile = eventsFile || path.join(this.ctx, 'sessions/workbench-changes.jsonl');
16
+ this.journalStatusFile = path.join(this.runtime, 'journal-status.json');
17
+ this.tail = Promise.resolve(); this.fault = fault; this.project = project;
18
+ this.version = null; this.doc = null; this.error = null; this.blocked = null; this.projection = { status: 'pending' };
19
+ this.recoverJournal = recoverJournal;
20
+ }
21
+ serial(fn) { const promise = this.tail.then(fn); this.tail = promise.catch(() => {}); return promise; }
22
+ async init() {
23
+ await fs.mkdir(path.join(this.runtime, 'operations'), { recursive: true });
24
+ await fs.mkdir(path.dirname(this.eventsFile), { recursive: true });
25
+ await this.loadJournal();
26
+ if (!this.blocked) await this.recover();
27
+ await this.refresh();
28
+ // libuv must receive the long, canonical path on Windows (TEMP may be 8.3).
29
+ this.watchRoot = await fs.realpath(path.dirname(this.file));
30
+ this.watcher = watch(this.watchRoot, (_event, filename) => {
31
+ // Generated indexes live in the same directory. Their events must neither
32
+ // postpone map detection nor trigger repeated reads of a large map.
33
+ if (filename && String(filename) !== 'map.json') return;
34
+ clearTimeout(this.watchTimer); this.watchTimer = setTimeout(() => this.serial(() => this.refresh()).catch(e => this.setError(e)), 12);
35
+ });
36
+ this.poll = setInterval(() => this.serial(() => this.refresh()).catch(e => this.setError(e)), 1000);
37
+ this.poll.unref(); return this;
38
+ }
39
+ setError(error) { this.error = { code: error.code || 'INVALID_FILE', message: error.message }; this.emit('change', this.state(false)); }
40
+ state(full = true) { return { version: this.version, cursor: this.cursor, ...(full ? { doc: this.doc } : {}), error: this.error, recovery: this.blocked, readOnly: !!(this.blocked || this.error), journal: this.journal || null, projection: this.projection }; }
41
+ async loadJournal() {
42
+ const raw = await fs.readFile(this.eventsFile).catch(error => error.code === 'ENOENT' ? Buffer.alloc(0) : Promise.reject(error));
43
+ const parsed = inspectJournal(raw);
44
+ this.events = parsed.events;
45
+ this.cursor = this.events.at(-1)?.cursor || null;
46
+ try {
47
+ this.journal = await readJSON(this.journalStatusFile, null);
48
+ } catch {
49
+ this.journal = { pending: true, code: 'JOURNAL_STATUS_INVALID', message: '恢复状态文件损坏,历史连续性需要重新核对' };
50
+ }
51
+ if (!parsed.problem && !parsed.needsNewline) return;
52
+ const backup = await backupJournal(this.runtime, raw);
53
+ this.journal = {
54
+ code: parsed.problem ? 'JOURNAL_RECOVERED' : 'JOURNAL_NEWLINE_REPAIRED',
55
+ backup,
56
+ line: parsed.problem?.line,
57
+ pending: !!parsed.problem,
58
+ message: parsed.problem?.message || '已补齐日志末尾换行;原件已备份',
59
+ };
60
+ await atomicWrite(this.journalStatusFile, encode(this.journal));
61
+ if (parsed.problem?.kind === 'corrupt' && !this.recoverJournal) {
62
+ this.blocked = { code: 'JOURNAL_CORRUPT', source: 'journal', backup, line: parsed.problem.line, message: '日志中间损坏或记录校验失败。地图可只读查看;在设置中确认保留当前地图并恢复日志。' };
63
+ return;
64
+ }
65
+ await replaceJournal(this.eventsFile, raw, parsed.problem ? parsed.prefix : `${parsed.prefix}\n`);
66
+ }
67
+ async recordRecoveryGap() {
68
+ if (!this.journal?.pending || this.blocked || this.error || !this.version) return;
69
+ const event = await this.recordEvent({ operationId: `journal-recovery:${randomUUID()}`, fromVersion: this.events.at(-1)?.version || this.version, version: this.version, actor: { kind: 'system', sessionId: null }, actions: ['journal-recovery'], operations: [], nodeIds: [], fields: [], journalGap: true });
70
+ if (event) this.emit('event', event);
71
+ this.journal = { ...this.journal, pending: false, message: '日志已恢复,历史存在缺口;请以当前地图和可核实的字段差异为准。原日志已备份。' };
72
+ await atomicWrite(this.journalStatusFile, encode(this.journal));
73
+ }
74
+ async repair({ baseVersion, acceptJournalGap = false } = {}) {
75
+ return this.serial(async () => {
76
+ const disk = await this.disk();
77
+ if (disk.version !== baseVersion) throw new MapError('VERSION_CONFLICT', '恢复前请重新读取当前地图', 409);
78
+ if (this.blocked?.source === 'journal') {
79
+ if (!acceptJournalGap) throw new MapError('CONFIRM_JOURNAL_GAP', '请确认保留当前地图并接受无法还原的日志缺口', 409);
80
+ const raw = await fs.readFile(this.eventsFile);
81
+ const parsed = inspectJournal(raw);
82
+ const backup = await backupJournal(this.runtime, raw);
83
+ this.journal = { code: 'JOURNAL_RECOVERED', backup, pending: true, message: '人类确认保留当前地图并恢复日志' };
84
+ await atomicWrite(this.journalStatusFile, encode(this.journal));
85
+ await replaceJournal(this.eventsFile, raw, parsed.problem ? parsed.prefix : parsed.prefix + (parsed.needsNewline ? '\n' : ''));
86
+ this.events = parsed.events;
87
+ this.cursor = this.events.at(-1)?.cursor || null;
88
+ this.blocked = null;
89
+ }
90
+ await this.recover();
91
+ await this.refresh();
92
+ this.emit('change', this.state(false));
93
+ return this.state();
94
+ });
95
+ }
96
+ async disk() {
97
+ const raw = await fs.readFile(this.file, 'utf8');
98
+ if (Buffer.byteLength(raw) > 16 * 1024 * 1024) throw new MapError('INVALID_MAP', 'Map exceeds 16 MiB');
99
+ const doc = JSON.parse(raw.replace(/^\uFEFF/, '')); validate(doc); return { raw, doc, version: hash(raw) };
100
+ }
101
+ async refresh() {
102
+ let disk;
103
+ try { disk = await this.disk(); }
104
+ catch (e) { this.setError(e); return this.state(); }
105
+ if (disk.version !== this.version) {
106
+ const previous = this.version, before = this.doc;
107
+ this.doc = disk.doc; this.version = disk.version; this.error = null;
108
+ if (previous && !this.blocked) {
109
+ const changed = before?.root && disk.doc.root ? diffTrees(before.root, disk.doc.root) : [];
110
+ const event = await this.recordEvent({ operationId: `external:${disk.version}`, fromVersion: previous, version: disk.version, actor: { kind: 'external', sessionId: null }, actions: ['external-file'], operations: changed, nodeIds: [...new Set(changed.map(op => op.id || op.node?.id).filter(Boolean))], fields: [...new Set(changed.flatMap(op => Object.keys(op.fields || {})))] });
111
+ this.emit('event', event);
112
+ }
113
+ if (!previous && !this.blocked && this.events.length && this.events.at(-1).version !== disk.version) {
114
+ const event = await this.recordEvent({ operationId: `offline:${randomUUID()}`, fromVersion: this.events.at(-1).version, version: disk.version, actor: { kind: 'external', sessionId: null }, actions: ['offline-file'], operations: [], nodeIds: [], fields: [], journalGap: true });
115
+ if (event) this.emit('event', event);
116
+ }
117
+ await this.recordRecoveryGap();
118
+ this.scheduleProjection(); this.emit('change', this.state(false));
119
+ } else if (this.error) { this.error = null; await this.recordRecoveryGap(); this.emit('change', this.state(false)); }
120
+ else await this.recordRecoveryGap();
121
+ return this.state();
122
+ }
123
+ scheduleProjection() {
124
+ clearTimeout(this.projectionTimer); this.projection = { status: 'pending', sourceVersion: this.version };
125
+ this.projectionTimer = setTimeout(async () => {
126
+ const version = this.version, doc = this.doc;
127
+ try { await this.fault('projection'); const ready = await this.project(doc, version); if (this.version !== version || ready === false) return; this.projection = { status: 'ready', sourceVersion: version }; }
128
+ catch (e) { this.projection = { status: 'failed', sourceVersion: version, message: e.message }; }
129
+ this.emit('change', this.state(false));
130
+ }, 180);
131
+ }
132
+ operationPath(id) { return path.join(this.runtime, 'operations', hash(id) + '.json'); }
133
+ async operation(id) { return readJSON(this.operationPath(id), null); }
134
+ async recordEvent(record) {
135
+ if (this.blocked?.source === 'journal') throw new MapError('RECOVERY_REQUIRED', this.blocked.message, 503);
136
+ if (this.events.some(e => e.operationId === record.operationId && e.version === record.version)) return;
137
+ const event = { ...record, cursor: hash(`${this.cursor || ''}:${encode(record)}`), at: new Date().toISOString() };
138
+ let handle;
139
+ try {
140
+ handle = await fs.open(this.eventsFile, 'a', 0o600);
141
+ await handle.writeFile(JSON.stringify(event) + '\n');
142
+ await handle.sync();
143
+ } catch (error) {
144
+ this.blocked = { code: 'JOURNAL_WRITE_FAILED', source: 'journal', message: `日志写入未完成:${error.message}。地图保留,在设置中恢复日志后再写入。` };
145
+ throw error;
146
+ } finally {
147
+ await handle?.close();
148
+ }
149
+ this.events.push(event); this.cursor = event.cursor;
150
+ return event;
151
+ }
152
+ changes(cursor) {
153
+ const i = cursor ? this.events.findIndex(e => e.cursor === cursor) : -1;
154
+ const changes = this.events.slice(i >= 0 ? i + 1 : -100);
155
+ const journalGap = changes.some(event => event.journalGap) || !!this.blocked;
156
+ const reset = !cursor || i < 0 || journalGap;
157
+ return { version: this.version, cursor: this.cursor, reset, changes, journalGap, recovery: this.blocked, error: this.error, ...(reset ? { readCurrent: true } : {}) };
158
+ }
159
+ async finish(record) {
160
+ const event = await this.recordEvent(record.event);
161
+ await this.fault('after-event');
162
+ await atomicWrite(this.operationPath(record.operationId), encode(record));
163
+ await this.fault('after-result');
164
+ await atomicWrite(this.pendingFile, 'null\n');
165
+ return event;
166
+ }
167
+ async recover() {
168
+ try {
169
+ await this.recoverPending();
170
+ } catch (error) {
171
+ this.blocked = { code: 'RECOVERY_REQUIRED', source: 'pending', message: `提交恢复失败:${error.message}。原地图与恢复记录保留,请修复后重试。` };
172
+ }
173
+ }
174
+ async recoverPending() {
175
+ if (this.blocked?.source === 'journal') return;
176
+ let pending;
177
+ try {
178
+ pending = await readJSON(this.pendingFile, null);
179
+ if (pending && (typeof pending.operationId !== 'string' || typeof pending.baseVersion !== 'string' || typeof pending.version !== 'string' || !pending.event || !pending.result)) throw new Error('提交恢复记录字段不完整');
180
+ } catch (error) {
181
+ this.blocked = { code: 'RECOVERY_REQUIRED', source: 'pending', message: `提交记录无法解析:${error.message}。保留 private/sync,恢复有效 pending.json 后重试。` };
182
+ return;
183
+ }
184
+ this.blocked = null;
185
+ if (!pending) return;
186
+ const existing = await this.operation(pending.operationId);
187
+ if (existing) { await atomicWrite(this.pendingFile, 'null\n'); return; }
188
+ let disk;
189
+ try { disk = await this.disk(); } catch (e) { this.blocked = { code: 'RECOVERY_REQUIRED', operationId: pending.operationId, message: e.message }; return; }
190
+ if (disk.version === pending.version) {
191
+ pending.result.recovered = true; await this.finish(pending);
192
+ } else if (disk.version === pending.baseVersion) {
193
+ pending.result = { committed: false, code: 'NOT_COMMITTED', operationId: pending.operationId, version: disk.version };
194
+ await atomicWrite(this.operationPath(pending.operationId), encode(pending)); await atomicWrite(this.pendingFile, 'null\n');
195
+ } else this.blocked = { code: 'RECOVERY_REQUIRED', operationId: pending.operationId, message: 'Map differs from both pending versions. Preserve files and reconcile; no automatic replay.' };
196
+ }
197
+ async commit(request, actor, grants = [], fence = async () => {}) {
198
+ return this.serial(async () => {
199
+ const { operationId, baseVersion, operations } = request;
200
+ if (typeof operationId !== 'string' || !/^[\w:.-]{8,160}$/.test(operationId)) throw new MapError('INVALID_OPERATION_ID', 'Use a stable unique operationId (8–160 characters)');
201
+ const digest = hash(encode({ baseVersion, operations, actor }));
202
+ const previous = await this.operation(operationId);
203
+ if (previous) {
204
+ if (previous.digest !== digest) throw new MapError('ID_REUSED', 'operationId belongs to a different request', 409);
205
+ return { ...previous.result, duplicate: true };
206
+ }
207
+ if (this.blocked) throw new MapError('RECOVERY_REQUIRED', 'Commit outcome requires recovery; do not create a new operationId', 503, this.blocked);
208
+ await fence();
209
+ const disk = await this.disk();
210
+ if (disk.version !== this.version) await this.refresh();
211
+ if (!baseVersion || baseVersion !== disk.version) throw new MapError('VERSION_CONFLICT', 'Read current state before editing again', 409, this.changes(request.cursor));
212
+ const { doc, resultIds } = applyOperations(disk.doc, operations, actor, typeof grants === 'function' ? grants() : grants);
213
+ const raw = encode(doc), version = hash(raw);
214
+ const result = { committed: true, operationId, version, resultIds, projection: 'pending' };
215
+ const record = { operationId, digest, baseVersion, version, result, event: { operationId, fromVersion: baseVersion, version, actor, actions: operations.map(op => op.type), operations: structuredClone(operations), nodeIds: resultIds, fields: [...new Set(operations.flatMap(op => Object.keys(op.fields || {})))] } };
216
+ await atomicWrite(this.pendingFile, encode(record));
217
+ let replaced = false;
218
+ try {
219
+ await this.fault('after-pending');
220
+ await atomicWrite(this.file, raw, { beforeReplace: async () => {
221
+ if (hash(await fs.readFile(this.file)) !== baseVersion) throw new MapError('VERSION_CONFLICT', 'External save occurred before replacement', 409);
222
+ } });
223
+ replaced = true; await this.fault('after-map');
224
+ record.persistedEvent = await this.finish(record);
225
+ } catch (e) {
226
+ const current = await fs.readFile(this.file).then(hash).catch(() => null);
227
+ if (!replaced && current === baseVersion) {
228
+ await atomicWrite(this.pendingFile, 'null\n'); throw e;
229
+ }
230
+ this.blocked = { ...(this.blocked?.source === 'journal' ? this.blocked : {}), code: 'RECOVERY_REQUIRED', operationId, persisted: current === version, message: e.message };
231
+ this.setError(new MapError('RECOVERY_REQUIRED', 'Save may have committed. Query this operationId after recovery; do not retry as a new operation.'));
232
+ throw new MapError('RECOVERY_REQUIRED', this.error.message, 503, this.blocked);
233
+ }
234
+ this.doc = doc; this.version = version; this.error = null;
235
+ this.scheduleProjection(); this.emit('change', { ...this.state(false), operationId });
236
+ this.emit('event', record.persistedEvent || record.event);
237
+ return { ...result, cursor: this.cursor };
238
+ });
239
+ }
240
+ async close() {
241
+ clearInterval(this.poll); clearTimeout(this.watchTimer); clearTimeout(this.projectionTimer); this.watcher?.close(); await this.tail;
242
+ }
243
+ }