@michelj/context-guard 0.4.3 → 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 (161) hide show
  1. package/Coordinator.md +88 -0
  2. package/Executor.md +53 -0
  3. package/README.md +89 -224
  4. package/README.zh-CN.md +89 -224
  5. package/SKILL.md +26 -684
  6. package/THIRD_PARTY_NOTICES.md +47 -0
  7. package/Tester.md +53 -0
  8. package/agents/openai.yaml +2 -2
  9. package/bin/build-runtime.mjs +96 -0
  10. package/bin/context-guard-skill.js +399 -78
  11. package/bin/postinstall.js +2 -2
  12. package/hooks.json +89 -13
  13. package/licenses/JSONParse-MIT.txt +24 -0
  14. package/licenses/Marked-MIT.txt +44 -0
  15. package/licenses/Portless-Apache-2.0.txt +201 -0
  16. package/package.json +35 -6
  17. package/prototype/LICENSES/Marked-MIT.txt +44 -0
  18. package/prototype/LICENSES/Ready-redistribution.txt +14 -0
  19. package/prototype/attachments.mjs +75 -0
  20. package/prototype/coordinator-markdown.mjs +283 -0
  21. package/prototype/coordinator-working-blot.mjs +124 -0
  22. package/prototype/vendor/marked.mjs +2189 -0
  23. package/prototype/workbench-app.js +5197 -0
  24. package/prototype/workbench-data.js +33 -0
  25. package/prototype/workbench-sync.mjs +898 -0
  26. package/prototype/workbench.css +1050 -0
  27. package/prototype/workbench.html +211 -0
  28. package/prototype/working-blot-atlas.png +0 -0
  29. package/references/agent-handoff.md +40 -0
  30. package/references/claude-runtime.md +120 -0
  31. package/references/cloud-sync-interface.md +66 -0
  32. package/references/design-current.md +14 -0
  33. package/references/map-mount.md +41 -0
  34. package/references/map-read.md +50 -0
  35. package/references/memory-definition.md +120 -0
  36. package/references/memory-filesystem-v2/Bug.en.md +162 -0
  37. package/references/memory-filesystem-v2/Bug.md +162 -0
  38. package/references/memory-filesystem-v2/Bug_Coordinater.md +8 -0
  39. package/references/memory-filesystem-v2/Bug_Executor.md +8 -0
  40. package/references/memory-filesystem-v2/Bug_Tester.md +7 -0
  41. package/references/memory-filesystem-v2/Idea.en.md +36 -0
  42. package/references/memory-filesystem-v2/Idea.md +36 -0
  43. package/references/memory-filesystem-v2/Node_Module_Index.en.md +88 -0
  44. package/references/memory-filesystem-v2/Node_Module_Index.md +88 -0
  45. package/references/memory-filesystem-v2/README.md +60 -0
  46. package/references/memory-filesystem-v2/Todo.en.md +137 -0
  47. package/references/memory-filesystem-v2/Todo.md +137 -0
  48. package/references/memory-filesystem-v2/Todo_Coordinater.md +7 -0
  49. package/references/memory-filesystem-v2/Todo_Executor.md +7 -0
  50. package/references/memory-filesystem-v2/Todo_Tester.md +7 -0
  51. package/references/named-workbench.md +124 -0
  52. package/references/plan-review.md +12 -0
  53. package/references/server-memory.md +276 -0
  54. package/references/test-check.md +7 -0
  55. package/references/user-reply.md +38 -0
  56. package/references/workbench-interface.md +531 -0
  57. package/roles.md +13 -0
  58. package/scripts/context_guard.py +1366 -7602
  59. package/scripts/context_guard_hook.py +1960 -711
  60. package/scripts/map_owns.py +699 -0
  61. package/scripts/shared/LICENSES/JSONParse-MIT.txt +24 -0
  62. package/scripts/shared/filesystem-v2.mjs +430 -0
  63. package/scripts/shared/io.mjs +117 -0
  64. package/scripts/shared/map-model.mjs +506 -0
  65. package/scripts/shared/memory-schema.mjs +13 -0
  66. package/scripts/shared/protocol-blobs.mjs +112 -0
  67. package/scripts/shared/protocol-map.mjs +146 -0
  68. package/scripts/shared/protocol-snapshots.mjs +84 -0
  69. package/scripts/shared/protocol-store.mjs +624 -0
  70. package/scripts/shared/protocol-workflow.mjs +226 -0
  71. package/scripts/shared/protocol.mjs +125 -0
  72. package/scripts/shared/vendor/jsonparse.cjs +413 -0
  73. package/scripts/workbench/access.mjs +496 -0
  74. package/scripts/workbench/attachments.mjs +92 -0
  75. package/scripts/workbench/browser-login.mjs +78 -0
  76. package/scripts/workbench/claude-runtime.mjs +372 -0
  77. package/scripts/workbench/cli.mjs +980 -0
  78. package/scripts/workbench/device-heartbeat.mjs +72 -0
  79. package/scripts/workbench/hook-status.mjs +38 -0
  80. package/scripts/workbench/inbox.mjs +155 -0
  81. package/scripts/workbench/journal.mjs +56 -0
  82. package/scripts/workbench/memory-merge.mjs +65 -0
  83. package/scripts/workbench/memory.mjs +252 -0
  84. package/scripts/workbench/named-proxy.mjs +108 -0
  85. package/scripts/workbench/named.mjs +152 -0
  86. package/scripts/workbench/portless-routes.mjs +51 -0
  87. package/scripts/workbench/project.mjs +327 -0
  88. package/scripts/workbench/projections.mjs +68 -0
  89. package/scripts/workbench/protocol-client.mjs +165 -0
  90. package/scripts/workbench/protocol-delivery.mjs +133 -0
  91. package/scripts/workbench/protocol-device.mjs +316 -0
  92. package/scripts/workbench/protocol-events.mjs +53 -0
  93. package/scripts/workbench/protocol-repository.mjs +58 -0
  94. package/scripts/workbench/reconcile.mjs +244 -0
  95. package/scripts/workbench/registry.mjs +111 -0
  96. package/scripts/workbench/runtime.mjs +54 -0
  97. package/scripts/workbench/server.mjs +1171 -0
  98. package/scripts/workbench/store.mjs +243 -0
  99. package/scripts/workbench/sync-coordinator.mjs +518 -0
  100. package/scripts/workbench/sync.mjs +86 -0
  101. package/references/context-template.md +0 -341
  102. package/references/feature-chain-methodology.md +0 -228
  103. package/references/register-template.md +0 -85
  104. package/references/task-case-template.md +0 -63
  105. package/tests/BC-20260618-063.sh +0 -116
  106. package/tests/BC-20260618-065.sh +0 -66
  107. package/tests/BC-20260626-080.sh +0 -48
  108. package/tests/BC-20260626-081.sh +0 -40
  109. package/tests/BC-20260626-082.sh +0 -32
  110. package/tests/BC-20260626-083.sh +0 -66
  111. package/tests/BC-20260627-084.sh +0 -74
  112. package/tests/BC-20260630-086.sh +0 -50
  113. package/tests/BC-20260630-087.sh +0 -103
  114. package/tests/BC-20260630-088.sh +0 -32
  115. package/tests/BC-20260630-089.sh +0 -63
  116. package/tests/BC-20260701-090.sh +0 -84
  117. package/tests/BC-20260702-096.sh +0 -48
  118. package/tests/BC-20260706-098.sh +0 -66
  119. package/tests/BC-20260707-099.sh +0 -47
  120. package/tests/BC-20260707-100.sh +0 -46
  121. package/tests/BC-20260707-101.sh +0 -47
  122. package/tests/BC-20260707-102.sh +0 -68
  123. package/tests/BC-20260707-103.sh +0 -59
  124. package/tests/BC-20260707-104.sh +0 -103
  125. package/tests/BC-20260707-105.sh +0 -109
  126. package/tests/BC-20260707-106.sh +0 -80
  127. package/tests/BC-20260707-107.sh +0 -74
  128. package/tests/BC-20260707-108.sh +0 -48
  129. package/tests/BC-20260707-109.sh +0 -56
  130. package/tests/BC-20260707-110.sh +0 -71
  131. package/tests/BC-20260707-111.sh +0 -70
  132. package/tests/BC-20260707-112.sh +0 -45
  133. package/tests/BC-20260707-113.sh +0 -73
  134. package/tests/BC-20260707-115.sh +0 -77
  135. package/tests/BC-20260707-116.sh +0 -77
  136. package/tests/BC-20260707-118.sh +0 -115
  137. package/tests/BC-20260707-119.sh +0 -47
  138. package/tests/BC-20260707-120.sh +0 -60
  139. package/tests/BC-20260707-121.sh +0 -66
  140. package/tests/BC-20260707-122.sh +0 -48
  141. package/tests/BC-20260707-123.sh +0 -43
  142. package/tests/BC-20260707-124.sh +0 -56
  143. package/tests/BC-20260707-125.sh +0 -64
  144. package/tests/BC-20260707-126.sh +0 -80
  145. package/tests/BC-20260707-127.sh +0 -88
  146. package/tests/BC-20260707-129.sh +0 -59
  147. package/tests/BC-20260707-130.sh +0 -69
  148. package/tests/BC-20260707-131.sh +0 -140
  149. package/tests/BC-20260707-132.sh +0 -150
  150. package/tests/BC-20260707-133.sh +0 -70
  151. package/tests/BC-20260708-136.sh +0 -210
  152. package/tests/BC-20260708-137.sh +0 -106
  153. package/tests/BC-20260708-138.sh +0 -168
  154. package/tests/BC-20260708-139.sh +0 -79
  155. package/tests/BC-20260709-002.sh +0 -63
  156. package/tests/BC-20260709-003.sh +0 -239
  157. package/tests/BC-20260709-006.sh +0 -76
  158. package/tests/BC-20260709-008.sh +0 -168
  159. package/tests/BC-20260710-001.sh +0 -61
  160. package/tests/BC-20260710-002.sh +0 -111
  161. package/tests/npm-install-smoke.sh +0 -53
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { entries } from '../shared/map-model.mjs';
4
+ import { atomicWrite, encode, readJSON } from '../shared/io.mjs';
5
+ const start = '<!-- context-guard:generated:start -->', end = '<!-- context-guard:generated:end -->';
6
+ const fields = text => Object.fromEntries(text.split(/\r?\n/).filter(x => /^- [^:]+:/.test(x)).map(x => { const i = x.indexOf(':'); return [x.slice(2, i).trim(), x.slice(i + 1).trim()]; }));
7
+ async function markdownIndex(ctx, folder, make) {
8
+ const result = {};
9
+ for (const file of await fs.readdir(path.join(ctx, folder)).catch(e => e.code === 'ENOENT' ? [] : Promise.reject(e))) {
10
+ if (!file.endsWith('.md')) continue;
11
+ const text = await fs.readFile(path.join(ctx, folder, file), 'utf8');
12
+ const id = file.slice(0, -3), f = fields(text);
13
+ result[id] = make(id, f, text.split(/\r?\n/)[0].replace(/^#\s*/, '').replace(new RegExp(`^${id}\\s+`), ''));
14
+ }
15
+ return result;
16
+ }
17
+ export async function generateProjections(root, doc, version, isCurrent = () => true, { sessionId = '' } = {}) {
18
+ if (!doc?.root) return false;
19
+ const ctx = path.join(root, '.codex/context'), cards = path.join(ctx, 'cards');
20
+ const statusFile = path.join(ctx, 'projection-status.json');
21
+ await atomicWrite(statusFile, encode({ status: 'building', sourceVersion: version }));
22
+ await fs.mkdir(cards, { recursive: true });
23
+ const index = entries(doc.root), owns = [];
24
+ let bugs = await markdownIndex(ctx, 'bugs', (id, f, title) => ({ title, keys: (f.keys || '').split(/[,,]/).map(x => x.trim()).filter(Boolean), status: f.status || 'open', bug: `.codex/context/bugs/${id}.md`, fix: `.codex/context/fixes/${id}.md`, card: f.card || (f.node ? `.codex/context/cards/${f.node}.md` : '') }));
25
+ let tasks = await markdownIndex(ctx, 'tasks', (id, f, title) => ({ title, keys: (f.keys || '').split(/[,,]/).map(x => x.trim()).filter(Boolean), task: `.codex/context/tasks/${id}.md`, chain: (f.chain || '').split('>').map(x => x.trim()).filter(Boolean), card: f.card || '' }));
26
+ if (sessionId) {
27
+ const visibleBugs = new Set(), visibleTasks = new Set();
28
+ for (const { node } of entries(doc.root).values()) {
29
+ for (const item of node.bugs || []) visibleBugs.add(item.id);
30
+ for (const item of node.todos || []) visibleTasks.add(item.id);
31
+ }
32
+ bugs = Object.fromEntries(Object.entries(bugs).filter(([id]) => visibleBugs.has(id)));
33
+ tasks = Object.fromEntries(Object.entries(tasks).filter(([id]) => visibleTasks.has(id)));
34
+ }
35
+ for (const [id, { node, parent }] of index) {
36
+ if (!isCurrent()) return false;
37
+ if (node.proposal === 'cancelled') continue;
38
+ const chain = [id]; let ancestor = parent;
39
+ while (ancestor) { chain.unshift(ancestor.id); ancestor = index.get(ancestor.id).parent; }
40
+ for (const owned of node.owns || []) owns.push({ path: owned, node: id, title: node.title, kind: node.kind, card: `.codex/context/cards/${id}.md`, chain });
41
+ const memories = [], ideas = [], nodeTodos = [], nodeBugs = [];
42
+ for (const { node: source } of index.values()) {
43
+ if (source.proposal === 'cancelled') continue;
44
+ const applies = item => source.id === id || (Array.isArray(item.also) ? item.also : String(item.also || '').split(/[,,]/).map(x => x.trim())).includes(id);
45
+ for (const item of source.memories || []) if (applies(item)) memories.push(`- ${item.text || ''}${source.id === id ? '' : ` (from ${source.id})`}`);
46
+ for (const item of source.todos || []) if (applies(item)) nodeTodos.push(`- ${item.id}: ${item.title || ''} [${item.status || 'pending'}]${source.id === id ? '' : ` (from ${source.id})`}`);
47
+ for (const item of source.bugs || []) if (applies(item)) nodeBugs.push(`- ${item.id}: ${item.title || ''} [${item.status || 'open'}] → .codex/context/bugs/${item.id}.md`);
48
+ }
49
+ for (const item of node.ideas || []) ideas.push(`- ${item.text || ''}`);
50
+ const body = `${start}\n# ${id} ${node.title}\n\n- sourceVersion: ${version}\n- kind: ${node.kind || ''}\n- state: ${node.state || ''}\n- proposal: ${node.proposal || ''}\n- origin: ${node.origin || ''}\n- parent: ${parent?.id || '(root)'}\n- chain: ${chain.join(' > ')}\n- purpose: ${node.purpose || ''}\n- owns: ${(node.owns || []).join(', ')}\n\n## 记忆\n${memories.join('\n')}\n\n## Idea\n${ideas.join('\n')}\n\n## TODO\n${nodeTodos.join('\n')}\n\n## Bug\n${nodeBugs.join('\n')}\n\n## 孩子\n${[...(node.children || []), ...(node._inbox || [])].filter(x => x.proposal !== 'cancelled').map(x => `- ${x.id} ${x.title}`).join('\n')}\n${end}`;
51
+ const file = path.join(cards, id + '.md');
52
+ const old = await fs.readFile(file, 'utf8').catch(e => e.code === 'ENOENT' ? '' : Promise.reject(e));
53
+ const a = old.indexOf(start), b = old.indexOf(end);
54
+ const content = a >= 0 && b > a ? old.slice(0, a) + body + old.slice(b + end.length) : body + '\n' + (old ? '\n## 保留的旧卡片/人工补充(非当前地图状态)\n\n' + old : '');
55
+ await atomicWrite(file, content);
56
+ }
57
+ if (!isCurrent()) return false;
58
+ await atomicWrite(path.join(ctx, 'owns-index.json'), encode({ sourceVersion: version, owns }));
59
+ await atomicWrite(path.join(ctx, 'bugs-index.json'), encode(bugs));
60
+ await atomicWrite(path.join(ctx, 'tasks-index.json'), encode(tasks));
61
+ await atomicWrite(path.join(ctx, 'jump-index.json'), encode({ sourceVersion: version, owns, bugs, tasks }));
62
+ if (isCurrent()) await atomicWrite(statusFile, encode({ status: 'ready', sourceVersion: version }));
63
+ return isCurrent();
64
+ }
65
+ export async function projectionStatus(root, version) {
66
+ const status = await readJSON(path.join(root, '.codex/context/projection-status.json'), {});
67
+ return { ...status, current: status.status === 'ready' && status.sourceVersion === version };
68
+ }
@@ -0,0 +1,165 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { errorReply, fail, MAX_MESSAGE_BYTES, ProtocolError, validateMessage } from '../shared/protocol.mjs';
3
+
4
+ // Never interpret a legacy HTML/JSON response as successful v2 delivery.
5
+ export async function sendMessage(origin, credential, message, { fetcher = fetch, timeoutMs = 10000, allowLoopback = false, receiveCredential, ciSessionId } = {}) {
6
+ validateMessage(message);
7
+ if (ciSessionId && (typeof ciSessionId !== 'string' || !/^[a-zA-Z0-9._:-]{1,128}$/.test(ciSessionId))) fail('INVALID_ARGUMENT', 'Invalid CI Session identity');
8
+ const base = new URL(origin);
9
+ if (base.protocol !== 'https:' && !(allowLoopback && base.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(base.hostname))) fail('FORBIDDEN', 'Cloud transport requires HTTPS');
10
+ if (base.username || base.password) fail('INVALID_ARGUMENT', 'Credentials must not be part of the URL');
11
+ let response;
12
+ try {
13
+ response = await fetcher(new URL('/api/v2/messages', base), {
14
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(timeoutMs),
15
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${credential}`, ...(ciSessionId ? { 'X-Context-Guard-CI-Session': ciSessionId } : {}) }, body: JSON.stringify(message),
16
+ });
17
+ } catch { fail('UNAVAILABLE', 'Connection failed; keep the pending message and retry with the same ID'); }
18
+ const possibleLegacy = [404, 405, 426].includes(response.status);
19
+ const invalidReceipt = message => fail(possibleLegacy ? 'INVALID_ARGUMENT' : 'UNAVAILABLE', possibleLegacy ? 'Server does not support this protocol; upgrade is required' : message);
20
+ // Authentication can fail before the server reads an ID. This is not a
21
+ // definitive rejection of a previously uncertain business operation.
22
+ if ([401, 403].includes(response.status)) fail(response.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', 'Connection is not authorized');
23
+ if (!response.headers.get('content-type')?.includes('application/json')) invalidReceipt('Expected a v2 JSON receipt');
24
+ let result;
25
+ try {
26
+ let size = 0; const chunks = [];
27
+ for await (const chunk of response.body) {
28
+ size += chunk.length;
29
+ if (size > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Reply exceeds 256 KiB');
30
+ chunks.push(chunk);
31
+ }
32
+ result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
33
+ } catch (error) { if (error instanceof ProtocolError) throw error; invalidReceipt('Incomplete protocol receipt'); }
34
+ if (result?.id !== message.id || typeof result.ok !== 'boolean' || (result.ok && (!response.ok || !Object.hasOwn(result, 'data')))) invalidReceipt('Receipt does not match this request');
35
+ if (!result.ok) {
36
+ if (typeof result.error?.code !== 'string' || typeof result.error?.message !== 'string') fail('UNAVAILABLE', 'Malformed error receipt');
37
+ const error = new ProtocolError(result.error.code, result.error.message, result.error.details);
38
+ error.confirmedRejection = result.error.retryable === false && !['UNAUTHORIZED', 'FORBIDDEN', 'UNAVAILABLE'].includes(error.code);
39
+ throw error;
40
+ }
41
+ if (message.type === 'auth.open' && receiveCredential) {
42
+ const issued = response.headers.get('x-context-guard-credential');
43
+ if (!issued || issued.length < 32) fail('UNAVAILABLE', 'Login did not return a connection credential');
44
+ await receiveCredential(issued);
45
+ }
46
+ return result.data;
47
+ }
48
+
49
+ // Hosts supply a verified principal. This boundary never trusts a role in JSON.
50
+ export function messageHandler({ authenticate, handle, allowedOrigin }) {
51
+ return async (req, res) => {
52
+ let id = '', reply, status = 200;
53
+ try {
54
+ if (req.method !== 'POST') fail('INVALID_ARGUMENT', 'Use POST');
55
+ if (req.headers.origin && req.headers.origin !== allowedOrigin) fail('FORBIDDEN', 'Untrusted browser origin');
56
+ const principal = await authenticate(req);
57
+ if (!principal) fail('UNAUTHORIZED', 'Authentication required');
58
+ if (!req.headers['content-type']?.startsWith('application/json')) fail('INVALID_ARGUMENT', 'Expected application/json');
59
+ let size = 0; const chunks = [];
60
+ for await (const chunk of req) {
61
+ size += chunk.length;
62
+ if (size > MAX_MESSAGE_BYTES) fail('TOO_LARGE', 'Message exceeds 256 KiB');
63
+ chunks.push(chunk);
64
+ }
65
+ let message;
66
+ try { message = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
67
+ catch { fail('INVALID_ARGUMENT', 'Invalid JSON'); }
68
+ if (typeof message?.id === 'string' && message.id.length <= 128) id = message.id;
69
+ validateMessage(message);
70
+ reply = await handle(principal, message);
71
+ } catch (error) {
72
+ reply = errorReply(id, error);
73
+ status = error instanceof ProtocolError ? error.status : 503;
74
+ }
75
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
76
+ res.end(JSON.stringify(reply));
77
+ };
78
+ }
79
+
80
+ // One scheduler per project, not one timer per Session. Persist/apply/ack are host
81
+ // callbacks so both online and offline backends can retain their own storage.
82
+ export class ProjectMessagePump {
83
+ constructor({ send, sessions, apply, heartbeatMs = 10000, onError = () => {}, onSession = async () => {} }) {
84
+ this.send = send; this.sessions = sessions; this.apply = apply; this.heartbeatMs = heartbeatMs; this.onError = onError;
85
+ this.running = null; this.observing = null; this.timer = null; this.closed = false;
86
+ this.mapJobs = new Map(); this.deliveryJobs = new Map();
87
+ this.onSession = onSession;
88
+ }
89
+ request(type, payload, session) { return { v: 2, id: randomUUID(), type, ...(session ? { session } : {}), payload }; }
90
+ async poll(observation) {
91
+ if (this.closed) return;
92
+ if (observation) return this.drain(observation);
93
+ if (this.running) return this.running;
94
+ this.running = this.drain(observation).finally(() => { this.running = null; });
95
+ return this.running;
96
+ }
97
+ async drain(observation = null) {
98
+ observation ||= await this.observe();
99
+ if (!observation) return;
100
+ const { sessions, beat } = observation;
101
+ const results = await Promise.allSettled(beat.sessions.map(remote => this.drainSession(sessions, remote)));
102
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
103
+ if (errors.length) throw new AggregateError(errors, 'Some Sessions could not synchronize');
104
+ }
105
+ // Liveness never waits for Map I/O, task delivery or an acknowledgement.
106
+ // Coalesce only the heartbeat request itself, not the downstream work.
107
+ observe() {
108
+ if (this.closed) return Promise.resolve(null);
109
+ if (!this.observing) this.observing = this.heartbeat().finally(() => { this.observing = null; });
110
+ return this.observing;
111
+ }
112
+ async heartbeat() {
113
+ const sessions = await this.sessions();
114
+ if (!sessions.length) return;
115
+ const beat = await this.send(this.request('sync.heartbeat', { sessions }));
116
+ for (const rejected of beat.rejected || []) this.onError(new ProtocolError(rejected.code, 'Session heartbeat rejected', { sessionId: rejected.id, generation: rejected.generation }));
117
+ return { sessions, beat };
118
+ }
119
+ async drainSession(sessions, remote) {
120
+ const local = sessions.find(s => s.id === remote.id && s.generation === remote.generation);
121
+ if (!local) fail('STALE_SESSION', 'Heartbeat returned an unknown binding');
122
+ // Map reconciliation and durable notification delivery are independent.
123
+ // A failed or stalled Map must not suppress receipt of queued tasks.
124
+ const key = `${local.id}:${local.generation}`;
125
+ const run = (jobs, operation) => {
126
+ if (jobs.has(key)) return;
127
+ const job = Promise.resolve().then(operation).finally(() => { jobs.delete(key); });
128
+ jobs.set(key, job); return job;
129
+ };
130
+ const results = await Promise.allSettled([
131
+ run(this.mapJobs, () => this.onSession(remote)),
132
+ run(this.deliveryJobs, () => this.drainNotifications(local, remote)),
133
+ ]);
134
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
135
+ if (errors.length) throw new AggregateError(errors, 'Session synchronization failed');
136
+ }
137
+ async drainNotifications(local, remote) {
138
+ const session = { id: local.id, generation: local.generation };
139
+ let cursor = remote.ackedSeq;
140
+ // Bound a pass so one large Session cannot monopolize the project worker.
141
+ for (let page = 0; page < 10 && cursor < remote.latestSeq && !this.closed; page++) {
142
+ const data = await this.send(this.request('sync.read', { afterSeq: cursor, limit: 50 }, session));
143
+ if (!data.messages?.length || data.nextSeq <= cursor) fail('UNAVAILABLE', 'Queue did not advance');
144
+ const items = [];
145
+ for (const item of data.messages) {
146
+ validateMessage(item.message);
147
+ if (item.seq !== cursor + 1 || item.message.session?.id !== session.id || item.message.session.generation !== session.generation) fail('UNAVAILABLE', 'Queue sequence or Session mismatch');
148
+ // apply must persist the effect AND its ID receipt together before returning.
149
+ const result = await this.apply(item.message);
150
+ items.push({ ...result, seq: item.seq }); cursor = item.seq;
151
+ }
152
+ if (cursor !== data.nextSeq) fail('UNAVAILABLE', 'Read cursor mismatch');
153
+ const ack = this.request('sync.ack', { items }, session);
154
+ validateMessage(ack);
155
+ await this.send(ack);
156
+ }
157
+ }
158
+ start() {
159
+ if (this.timer || this.closed) return;
160
+ const tick = () => this.observe().then(observation => observation && this.poll(observation)).catch(this.onError);
161
+ this.timer = setInterval(tick, this.heartbeatMs); this.timer.unref?.(); tick();
162
+ }
163
+ wake() { return this.poll(); }
164
+ async close() { this.closed = true; clearInterval(this.timer); this.timer = null; await Promise.allSettled([this.running, this.observing, ...this.mapJobs.values(), ...this.deliveryJobs.values()]); }
165
+ }
@@ -0,0 +1,133 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { atomicWrite, encode, hash, readJSON, withFileLock } from '../shared/io.mjs';
4
+ import { canonical, fail, validateMessage } from '../shared/protocol.mjs';
5
+
6
+ export const executionNotifications = new Set(['task.assign', 'task.message', 'task.rework', 'task.control', 'ci.request']);
7
+ export function controlReport(message) {
8
+ validateMessage(message);
9
+ if (message.type !== 'task.control' || !['resume', 'complete'].includes(message.payload.action)) fail('INVALID_ARGUMENT', 'A resume or complete control is required');
10
+ const closed = message.payload.action === 'complete';
11
+ return { v: 2, id: hash(`${closed ? 'close' : 'resume'}:${message.id}`), type: 'task.report', session: message.session,
12
+ payload: { taskId: message.payload.taskId, stage: closed ? 'closed' : 'resumed',
13
+ data: { controlId: message.id, ...(closed ? { closeReceiptId: message.id } : {}) } } };
14
+ }
15
+ const reviewedRetry = 'reviewed 任务不使用 map task start/finish。回复未知时保留原 operationId;收到明确 CONFLICT 后先核对状态,条件已恢复时用新的 operationId 提交 Plan,旧编号会重放旧拒绝。不得因报错跳过审核或报告完成。';
16
+ export async function executionPrompt(message, readObject) {
17
+ validateMessage(message);
18
+ const p = message.payload;
19
+ if (message.type === 'ci.request') return ['Context Guard:执行独立 CI,只测试指定提交,不修改业务源码、不提交开发 Plan。',
20
+ `任务:${p.taskId}`, `准确 SHA:${p.sourceSha}`, `CI TODO:${p.ciTodoRef}`, `开发证据:${p.unitTestRefs.join(', ')}`,
21
+ '用 map ci context 读取当前授权,用 map ci exchange --input -(stdin,优先)或当前 Session 工作树内文件读取引用、上传证据并提交 ci.result;不要使用共享 /tmp 文件。',
22
+ '如实报告失败和复现证据;没有执行的测试不得标为通过。'].join('\n');
23
+ if (message.type === 'task.assign') {
24
+ const brief = await readObject(p.briefRef, p.briefVersion);
25
+ if (brief.kind !== 'brief' || brief.version !== p.briefVersion || brief.content?.taskId !== p.taskId || typeof brief.content.text !== 'string' || brief.content.text.length > 2000) fail('CONFLICT', 'Approved brief reference differs');
26
+ if (p.mode === 'session') {
27
+ return [brief.content.text,
28
+ '通过已安装 Context Guard Skill 执行以下命令(当前 Session,遵守仓库规则):',
29
+ `开始:map task start ${message.id}`,
30
+ `完成:map task finish ${message.id} --summary "实际结果、验证证据与可复用经验"`,
31
+ '完成时一并提交总结,之后等待人类验收;不要等待勾选后再生成总结。',
32
+ '失败或取消时加 --outcome failed/cancelled。重试使用同一编号;完成不代表发布 Main。'].join('\n');
33
+ }
34
+ return ['Context Guard:已确认的任务,请先读代码并提交 Plan,收到审核通过后再执行。',
35
+ `任务:${p.taskId}`, `节点:${p.nodeIds.join(', ')}`, `Main 记忆版本(不是 Git SHA):${p.mainVersion}`, brief.content.text,
36
+ '优先使用 map task plan --input -(stdin),或 --input <JSON文件路径>(文件必须位于当前 Session 工作树内)提交 {operationId,content:{paths,steps}};不要使用 /tmp/plan.json 等共享临时文件,可能被并行 Session 覆盖。不清楚时先读 map task plan --help。审核前只读,不启动开发 Plan。',
37
+ '执行边界:Coordinator 已经创建并绑定本任务的独立执行 Session;不得手工创建、选择、分配或替换 Session,不得直接改写 Main、.codex/context/main/map.json 或任何服务器状态。若这是链路验证任务,只读核对任务、Session、回执和状态,不要把验证动作变成业务开发。map task plan 成功返回 awaiting-plan-review 后,立即结束本轮并等待 review.result;不要继续调用工具、修改文件、提交 handoff 或自行派发。',
38
+ reviewedRetry,
39
+ `交付编号:${message.id};同一编号不得重复执行。`].join('\n');
40
+ }
41
+ if (message.type === 'review.result' && p.kind === 'plan') {
42
+ const receipt = await readObject(p.receiptId, p.receiptId);
43
+ if (receipt.kind !== 'reviewReceipt' || receipt.content?.ref !== p.ref || receipt.content?.version !== p.version || receipt.content?.decision !== p.decision) fail('CONFLICT', 'Plan review receipt differs');
44
+ return `Context Guard:Plan ${p.ref}@${p.version} 审核${p.decision === 'approved' ? '通过,可继续执行' : '未通过,请修改 Plan'}。\n${p.reason}\n回执:${p.receiptId}\n` +
45
+ (p.decision === 'approved' ? '用 map execution 读取当前审核身份,再按 Skill 的 plan-start 开发;提交代码后用 map task handoff --input -(stdin,优先)或 --input <JSON文件路径>(仅当前 Session 工作树内)交付 CI TODO、测试证据和经验。若原任务是链路验证或明确要求不修改业务文件,只做只读核对(Session、任务、回执、git status),不要改 Main/map.json 或业务文件;随后用只读证据提交 handoff,不要自行创建或分配 Session。' : '保持只读;用新的 operationId 和 map task plan 提交修订版,等待审核;使用 stdin 或当前工作树内文件,不要使用共享 /tmp 文件。');
46
+ }
47
+ if (message.type === 'task.rework') return `Context Guard:原任务 ${p.taskId} 返工,不创建新任务。\n${p.reason ? `返工原因:${p.reason}\n` : ''}代码:${p.sourceSha}\nCI:${p.ciResultRef}\n失败测试:${p.failedTestIds.join(', ')}\n交付编号:${message.id}`;
48
+ if (message.type === 'task.message') return [
49
+ `Context Guard:Coordinator 对原任务 ${p.taskId} 的指导。保留本任务、当前执行 Session 和已批准的 Plan。`,
50
+ p.text,
51
+ '先用 map execution 核对权威任务、Plan 与现有文件;不要重复已完成操作。此消息不批准新范围、不代替人工验收。',
52
+ '若代码已实现并验证,先提交准确文件,再用 map task handoff --input - 交出代码 SHA、CI TODO 与真实证据;handoff 在 Tester 和人工验收之前,不需要先 archive-session 或 plan-finish。人工验收后再归档并结束计划。',
53
+ `指导编号:${message.id}`,
54
+ ].join('\n');
55
+ if (message.type === 'task.control' && p.action === 'resume') return [
56
+ `Context Guard:任务 ${p.taskId} 已收到恢复控制,原因:${p.data?.reason || '用户要求继续'}。`,
57
+ '这是原任务的受控恢复,不是新任务;先用 map execution 读取当前授权 Plan、任务和已有证据,不重复已完成操作。',
58
+ ...(p.data?.reason === 'CLAUDE_TURN_LIMIT' || p.data?.reason === 'CLAUDE_TIMEOUT_OR_INTERRUPTED' || p.data?.reason === 'CLAUDE_OUTPUT_LIMIT'
59
+ ? ['上一轮因执行时限或输出量中断。不要重跑同一批探索性检查;先盘点已有改动和验证证据,优先完成已批准范围内的交付。若仍不能满足验收,明确回报阻塞与未验证项,不要声称测试通过。'] : []),
60
+ '如果原任务是链路验证或明确要求不修改业务文件:只读核对 Session、任务、回执和 git status;不得读取或改写 Main、map.json 或业务文件,也不得自行创建/分配 Session。回报 resumed 后,使用只读证据提交 map task handoff(--input - 通过 stdin),让流程进入 CI/验收;不要再次等待或触发自动恢复。',
61
+ '确认可以继续后,用 map exchange --input -(stdin)回报 resumed;消息必须保留原控制编号:',
62
+ JSON.stringify(controlReport(message)),
63
+ '回报后按已批准 Plan 继续;若 Plan 未批准或范围仍不清楚,保持只读并通过 ask_user 请求确认。',
64
+ reviewedRetry,
65
+ ].join('\n');
66
+ if (message.type === 'task.control' && p.action === 'complete') return [
67
+ `Context Guard:任务 ${p.taskId} 已通过服务端合并与归档校验。保留证据,结束该任务。`,
68
+ '使用 map exchange --input <JSON文件> 回报关闭;不要重新执行开发或再次合并。',
69
+ JSON.stringify(controlReport(message)),
70
+ ].join('\n');
71
+ if (message.type === 'task.control') return `Context Guard:任务 ${p.taskId} 控制请求 ${p.action}。完成对应操作后,使用原控制编号回报;收到不等于完成,不得擅自删除记录。\n${JSON.stringify(p.data)}\n控制编号:${message.id}`;
72
+ return null;
73
+ }
74
+
75
+ // A host may accept a message just before the caller crashes. A durable intent
76
+ // prevents a second model invocation when acceptance cannot be established.
77
+ export class ProtocolDelivery {
78
+ constructor(directory, adapters) { this.directory = directory; this.adapters = adapters; }
79
+ async retryBusy(canRetry, limit = 4) {
80
+ const names = await fs.readdir(this.directory).catch(error => {
81
+ if (error.code === 'ENOENT') return [];
82
+ throw error;
83
+ });
84
+ const candidates = (await Promise.all(names.filter(value => value.endsWith('.json')).map(async name => ({
85
+ name, mtimeMs: await fs.stat(path.join(this.directory, name)).then(stat => stat.mtimeMs, () => 0),
86
+ })))).sort((a, b) => b.mtimeMs - a.mtimeMs);
87
+ let attempted = 0;
88
+ for (const { name, mtimeMs } of candidates) {
89
+ if (attempted >= limit) break;
90
+ if (!mtimeMs || Date.now() - mtimeMs > 24 * 60 * 60 * 1000) continue;
91
+ const file = path.join(this.directory, name);
92
+ const previous = await readJSON(file, null);
93
+ if (previous?.state !== 'failed' || previous.errorCode !== 'RUNTIME_BUSY' || !previous.input) continue;
94
+ if (!await canRetry(previous.input)) continue;
95
+ attempted++;
96
+ await this.deliver(previous.input).catch(error => {
97
+ if (error.code !== 'UNAVAILABLE') throw error;
98
+ });
99
+ }
100
+ return attempted;
101
+ }
102
+ async deliver(input) {
103
+ const adapter = this.adapters[input.platform];
104
+ const invoke = typeof adapter === 'function' ? adapter : adapter?.deliver;
105
+ if (typeof invoke !== 'function') fail('INVALID_ARGUMENT', 'This host does not support task delivery');
106
+ if (!input.id || !input.sessionId || typeof input.message !== 'string' || !input.message.trim()) fail('INVALID_ARGUMENT', 'Delivery identity and message are required');
107
+ const file = path.join(this.directory, `${hash(input.id)}.json`), fingerprint = hash(canonical(input));
108
+ return withFileLock(`${file}.lock`, async () => {
109
+ const previous = await readJSON(file, null);
110
+ if (previous && previous.fingerprint !== fingerprint) fail('ID_REUSED', 'Delivery ID differs from the saved intent');
111
+ if (previous?.state === 'received') return previous.result;
112
+ if (previous?.state === 'dispatching' || previous?.state === 'uncertain') {
113
+ if (await adapter.received?.(input)) {
114
+ const result = { deliveryId: input.id, state: 'received', sessionId: input.sessionId };
115
+ await atomicWrite(file, encode({ fingerprint, state: 'received', input, result }));
116
+ return result;
117
+ }
118
+ fail('UNAVAILABLE', 'Host acceptance is uncertain; do not dispatch again', { deliveryId: input.id, deliveryState: 'uncertain' });
119
+ }
120
+ await atomicWrite(file, encode({ fingerprint, state: 'dispatching', attempts: (previous?.attempts || 0) + 1, input }));
121
+ try {
122
+ await invoke.call(adapter, input);
123
+ const result = { deliveryId: input.id, state: 'received', sessionId: input.sessionId };
124
+ await atomicWrite(file, encode({ fingerprint, state: 'received', input, result }));
125
+ return result;
126
+ } catch (error) {
127
+ const uncertain = error?.deliveryUncertain === true || error?.killed === true || error?.code === 'ETIMEDOUT';
128
+ await atomicWrite(file, encode({ fingerprint, state: uncertain ? 'uncertain' : 'failed', attempts: (previous?.attempts || 0) + 1, input, errorCode: String(error?.code || 'DELIVERY_FAILED').slice(0, 128) }));
129
+ fail('UNAVAILABLE', uncertain ? 'Host acceptance is uncertain; do not dispatch again' : 'Host rejected delivery; it will be retried', { deliveryId: input.id, deliveryState: uncertain ? 'uncertain' : 'failed' });
130
+ }
131
+ });
132
+ }
133
+ }