@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,24 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2012 Tim Caswell
4
+
5
+ Permission is hereby granted, free of charge,
6
+ to any person obtaining a copy of this software and
7
+ associated documentation files (the "Software"), to
8
+ deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify,
10
+ merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom
12
+ the Software is furnished to do so,
13
+ subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice
16
+ shall be included in all copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
21
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
22
+ ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
23
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
24
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,430 @@
1
+ import assert from 'node:assert/strict';
2
+ import crypto from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ import { entries } from './map-model.mjs';
6
+
7
+ const BUG_STATUS = {
8
+ open: 'Open',
9
+ handling: 'InProgress',
10
+ inprogress: 'InProgress',
11
+ pending: 'Pending',
12
+ fixed: 'Pending',
13
+ resolved: 'Resolved',
14
+ recurred: 'InProgress',
15
+ unfixable: 'Unfixable',
16
+ };
17
+ const DROP_BUG_STATUS = new Set(['wontfix']);
18
+ const END_LEGACY_BUG_STATUS = { deferred: 'Unfixable' };
19
+
20
+ const TODO_STATUS = { pending: 'Open', processing: 'InProgress', done: 'Done' };
21
+
22
+ function cleanSegment(value) {
23
+ return String(value || '')
24
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
25
+ .replace(/[. ]+$/g, '')
26
+ .trim() || 'unnamed';
27
+ }
28
+
29
+ function firstParagraph(value) {
30
+ const text = String(value || '').trim();
31
+ if (!text) return 'NULL';
32
+ return text.split(/\r?\n\s*\r?\n/)[0].trim();
33
+ }
34
+
35
+ const textOrNull = value => String(value || '').trim() || 'NULL';
36
+ const linesOrNull = lines => lines.length ? lines.join('\n\n') : 'NULL';
37
+
38
+ function codeIndex(attempt) {
39
+ return attempt.codeIndex?.length ? attempt.codeIndex.map(entry => `- \`${entry.path}\`\n ${entry.summary}`).join('\n') : 'NULL';
40
+ }
41
+
42
+ function attemptEvent(attempt, number) {
43
+ return attempt.event ? `- A${number} / ${attempt.eventSource || 'Agent'}:${attempt.event}` : null;
44
+ }
45
+
46
+ function attemptStatus(attempt) {
47
+ return `Status: ${attempt.status}${attempt.status === 'Refuted' ? `\nRefutedBy: ${attempt.refutedBy}\nReason: ${attempt.reason}` : ''}`;
48
+ }
49
+
50
+ function extract(text, label) {
51
+ return text.match(new RegExp(`^- ${label}:\\s*(.*)$`, 'm'))?.[1]?.trim() || '';
52
+ }
53
+
54
+ function section(text, title) {
55
+ return text.match(new RegExp(`^## ${title}\\r?\\n([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, 'm'))?.[1]?.trim() || '';
56
+ }
57
+
58
+ function isMeaningful(value) {
59
+ const text = String(value || '').trim();
60
+ return text && text !== 'NULL' && !/^(待定位|待补充|未修|未知)(?:[;;,,。.]|$)/.test(text);
61
+ }
62
+
63
+ function relative(from, to) {
64
+ const target = path.posix.relative(path.posix.dirname(from), to) || path.posix.basename(to);
65
+ return target.split('/').map((part) => ['.', '..'].includes(part) ? part : encodeURIComponent(part)
66
+ .replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)).join('/');
67
+ }
68
+
69
+ function groupBy(items, keyOf) {
70
+ const groups = new Map();
71
+ for (const item of items) {
72
+ const key = keyOf(item);
73
+ const group = groups.get(key) || [];
74
+ group.push(item);
75
+ groups.set(key, group);
76
+ }
77
+ return groups;
78
+ }
79
+
80
+ export function validateGeneratedLinks(files, links) {
81
+ for (const { from, to } of links) assert(files.has(to), `${from}: missing linked file ${to}`);
82
+ }
83
+
84
+ export function buildFilesystemV2(snapshot) {
85
+ assert(snapshot?.memory?.map?.root, 'Snapshot is missing memory.map.root');
86
+ const document = snapshot.memory.map;
87
+ const records = snapshot.memory.records || {};
88
+ const nodeEntries = [...entries(document.root).values()];
89
+ const byId = new Map(nodeEntries.map((entry) => [entry.node.id, entry]));
90
+ const warnings = [];
91
+ const files = new Map();
92
+ const generatedLinks = [];
93
+ const link = (from, to) => { generatedLinks.push({ from, to }); return relative(from, to); };
94
+ const put = (name, content) => {
95
+ assert(!files.has(name), `Duplicate generated path: ${name}`);
96
+ files.set(name, `${content.trim()}\n`);
97
+ };
98
+
99
+ const siblings = new Map();
100
+ for (const { node, parent } of nodeEntries) {
101
+ const key = parent?.id || '__root__';
102
+ const list = siblings.get(key) || [];
103
+ list.push({
104
+ id: node.id,
105
+ stem: `${cleanSegment(node.title)}-${node.kind === 'module' ? 'module' : 'node'}`,
106
+ });
107
+ siblings.set(key, list);
108
+ }
109
+
110
+ const segment = new Map();
111
+ for (const list of siblings.values()) {
112
+ for (const group of groupBy(list, (item) => item.stem.toLowerCase()).values()) {
113
+ for (const item of group) {
114
+ segment.set(item.id, group.length === 1 ? item.stem : `${item.stem}--${item.id}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ const nodeDir = new Map();
120
+ function assign(entry, parentDir = 'nodes') {
121
+ assert(entry, 'Map root or child is missing from the flattened node index');
122
+ const current = `${parentDir}/${segment.get(entry.node.id)}`;
123
+ nodeDir.set(entry.node.id, current);
124
+ for (const child of [...(entry.node.children || []), ...(entry.node._inbox || [])]) {
125
+ assign(byId.get(child.id), current);
126
+ }
127
+ }
128
+ assign(byId.get(document.root.id));
129
+
130
+ for (const { node } of nodeEntries) {
131
+ if (!String(node.memoryDocument || '').trim()) continue;
132
+ const file = node.id === document.root.id ? 'memory.md' : `${nodeDir.get(node.id)}/memory.md`;
133
+ put(file, node.memoryDocument);
134
+ }
135
+
136
+ const related = new Map(nodeEntries.map(({ node }) => [node.id, new Set()]));
137
+ for (const flow of [...(document.flows || []), ...(document.root.flows || [])]) {
138
+ if (!byId.has(flow.from) || !byId.has(flow.to)) {
139
+ warnings.push({ code: 'BROKEN_FLOW', from: flow.from, to: flow.to });
140
+ continue;
141
+ }
142
+ related.get(flow.from).add(flow.to);
143
+ related.get(flow.to).add(flow.from);
144
+ }
145
+
146
+ const bugsByNode = new Map();
147
+ const unassignedBugs = [];
148
+ const bugRecords = Object.entries(records).filter(([name]) => /^bugs\/[^/]+\.md$/.test(name));
149
+ const mapBugs = new Map();
150
+ for (const { node } of nodeEntries) for (const item of node.bugs || []) {
151
+ if (!item.id) continue;
152
+ if (mapBugs.has(item.id)) warnings.push({ code: 'DUPLICATE_BUG_ID', id: item.id });
153
+ else mapBugs.set(item.id, { item, nodeId: node.id });
154
+ }
155
+ for (const item of document.unassigned_bugs || []) {
156
+ if (!item.id) continue;
157
+ if (mapBugs.has(item.id)) warnings.push({ code: 'DUPLICATE_BUG_ID', id: item.id });
158
+ else mapBugs.set(item.id, { item, nodeId: '' });
159
+ }
160
+ const legacyBugs = new Map(bugRecords.map(([name, raw]) => [path.posix.basename(name, '.md'), raw]));
161
+ for (const id of new Set([...legacyBugs.keys(), ...mapBugs.keys()])) {
162
+ const raw = legacyBugs.get(id) || '';
163
+ const mapped = mapBugs.get(id), item = mapped?.item;
164
+ const title = textOrNull(item?.title || raw.match(/^#\s+(.+)$/m)?.[1] || id);
165
+ const heading = title === id || title.startsWith(`${id} `) ? title : `${id} ${title}`;
166
+ const nodeId = mapped ? mapped.nodeId : extract(raw, 'node');
167
+ const phenomenon = textOrNull(item?.phenomenon || item?.description || item?.desc || extract(raw, '现象'));
168
+ const rawStatus = String(item?.status || extract(raw, 'status')).toLowerCase();
169
+ if (DROP_BUG_STATUS.has(rawStatus)) {
170
+ warnings.push({ code: 'DROPPED_WONTFIX', id, nodeId });
171
+ continue;
172
+ }
173
+ const status = BUG_STATUS[rawStatus] || END_LEGACY_BUG_STATUS[rawStatus];
174
+ if (!status) {
175
+ warnings.push({ code: 'UNKNOWN_BUG_STATUS', id, nodeId, status: rawStatus || null });
176
+ }
177
+ const fileStatus = status || 'Open';
178
+ if (END_LEGACY_BUG_STATUS[rawStatus]) {
179
+ warnings.push({ code: 'HISTORICAL_DEFERRED', id, nodeId, projected: fileStatus });
180
+ }
181
+ const assigned = byId.has(nodeId);
182
+ if (!assigned) warnings.push({ code: 'ORPHAN_BUG', id, nodeId });
183
+
184
+ const fixRaw = records[`fixes/${id}.md`] || '';
185
+ const cause = section(fixRaw, '根因') || 'NULL';
186
+ const method = section(fixRaw, '怎么修') || 'NULL';
187
+ const trigger = section(fixRaw, '触发') || 'NULL';
188
+ const code = section(fixRaw, '代码') || 'NULL';
189
+ const evidence = section(fixRaw, '证据') || 'NULL';
190
+ const dir = assigned ? nodeDir.get(nodeId) : 'unassigned';
191
+ const file = `${dir}/bugs/${id}.md`;
192
+ const sessions = extract(raw, 'sessions').split(/[,,]\s*/).filter(Boolean);
193
+ const attempts = Array.isArray(item?.attempts) && item.attempts.length ? item.attempts : null;
194
+ const attributionBlock = attempts ? attempts.map((attempt, index) => `### A${index + 1}
195
+ ${attemptStatus(attempt)}
196
+
197
+ 原因:${textOrNull(attempt.cause)}
198
+
199
+ #### code index
200
+ ${codeIndex(attempt)}`).join('\n\n') : isMeaningful(cause) ? `### A1
201
+ Status: Confirmed
202
+
203
+ 原因:${cause}
204
+
205
+ #### code index
206
+ ${code}` : 'NULL';
207
+ const rounds = attempts || [{ reproduction: trigger, test: { summary: evidence, content: evidence }, sessionIds: sessions }];
208
+ for (const [index, attempt] of rounds.entries()) {
209
+ const round = `A${index + 1}`;
210
+ put(`${dir}/bugs/tests/${id}-${round}.md`, `# ${id} ${round}\n\n${textOrNull(attempt.test?.content)}`);
211
+ put(`${dir}/bugs/traces/${id}-${round}.md`, `# ${id} ${round}\n\n${attempt.sessionIds?.length ? attempt.sessionIds.map(value => `- sessions/${value}.md`).join('\n') : 'NULL'}`);
212
+ }
213
+ const effective = attempts ? attempts.filter(attempt => attempt.status === 'Confirmed').at(-1) : null;
214
+ put(file, `# ${heading}
215
+
216
+ Reporter: ${['Human', 'Agent'].includes(item?.reporter) ? item.reporter : 'NULL'}
217
+ Status: ${fileStatus}
218
+ CurrentAttempt: A${rounds.length}
219
+
220
+ ## 1. 现象
221
+
222
+ ${phenomenon}
223
+
224
+ ## 2. 后续纠正事件
225
+
226
+ ${attempts ? linesOrNull(attempts.map((attempt, index) => attemptEvent(attempt, index + 1)).filter(Boolean)) : 'NULL'}
227
+
228
+ ## 3. 复现
229
+
230
+ ${rounds.map((attempt, index) => `### A${index + 1}\n${textOrNull(attempt.reproduction)}`).join('\n\n')}
231
+
232
+ ## 4. 原因与代码改动
233
+
234
+ ### 当前有效结论
235
+ ${attempts ? textOrNull(effective?.resolution || effective?.cause) : method}
236
+
237
+ ${attributionBlock}
238
+
239
+ ## 5. 测试
240
+
241
+ ${rounds.map((attempt, index) => `- [A${index + 1}](${link(file, `${dir}/bugs/tests/${id}-A${index + 1}.md`)}):${textOrNull(attempt.test?.summary)}`).join('\n')}
242
+
243
+ ## 6. 修复 Session 索引
244
+
245
+ ${rounds.map((_, index) => `- [A${index + 1}](${link(file, `${dir}/bugs/traces/${id}-A${index + 1}.md`)})`).join('\n')}`);
246
+
247
+ const projected = { id, title: heading, phenomenon, status: fileStatus, file, legacyNode: nodeId };
248
+ if (assigned) {
249
+ const list = bugsByNode.get(nodeId) || [];
250
+ list.push(projected);
251
+ bugsByNode.set(nodeId, list);
252
+ } else {
253
+ unassignedBugs.push(projected);
254
+ }
255
+ }
256
+
257
+ if (unassignedBugs.length) {
258
+ put('unassigned/index.md', `# Unassigned migration records
259
+
260
+ ${unassignedBugs.map((bug) => `### [${bug.title}](${link('unassigned/index.md', bug.file)})
261
+ ${bug.phenomenon}
262
+
263
+ Status: ${bug.status}
264
+ LegacyNode: ${bug.legacyNode || 'NULL'}`).join('\n\n')}`);
265
+ }
266
+
267
+ const todosByNode = new Map();
268
+ const ideasByNode = new Map();
269
+ for (const { node } of nodeEntries) {
270
+ let index = 0;
271
+ for (const todo of node.todos || []) {
272
+ index += 1;
273
+ const id = todo.id || `T-${node.id}-${index}`;
274
+ const title = String(todo.title || todo.desc || todo.description || id).trim() || id;
275
+ const description = String(todo.description || todo.desc || todo.title || '').trim() || 'NULL';
276
+ const status = TODO_STATUS[todo.status] || 'Open';
277
+ const file = `${nodeDir.get(node.id)}/todos/${id}.md`;
278
+ const attempts = Array.isArray(todo.attempts) && todo.attempts.length ? todo.attempts : null;
279
+ const rounds = attempts || [{ acceptance: 'NULL', solution: 'NULL', test: { summary: 'NULL', content: 'NULL' } }];
280
+ if (!attempts) warnings.push({ code: 'TODO_ATTEMPT_UNCLASSIFIED', id, nodeId: node.id });
281
+ for (const [roundIndex, attempt] of rounds.entries()) {
282
+ const round = `A${roundIndex + 1}`;
283
+ put(`${nodeDir.get(node.id)}/todos/tests/${id}-${round}.md`, `# ${id} ${round}\n\n${textOrNull(attempt.test?.content)}`);
284
+ put(`${nodeDir.get(node.id)}/todos/traces/${id}-${round}.md`, `# ${id} ${round}\n\n${attempt.sessionIds?.length ? attempt.sessionIds.map(value => `- sessions/${value}.md`).join('\n') : 'NULL'}`);
285
+ }
286
+ const effective = attempts?.filter(attempt => attempt.status === 'Confirmed').at(-1);
287
+ put(file, `# ${id} ${title}
288
+
289
+ Reporter: ${['Human', 'Agent'].includes(todo.reporter) ? todo.reporter : 'NULL'}
290
+ Status: ${status}
291
+ CurrentAttempt: A${rounds.length}
292
+
293
+ ## 1. 需求
294
+
295
+ ${description}
296
+
297
+ ## 2. 后续调整事件
298
+
299
+ ${attempts ? linesOrNull(attempts.map((attempt, roundIndex) => attemptEvent(attempt, roundIndex + 1)).filter(Boolean)) : 'NULL'}
300
+
301
+ ## 3. 验收标准
302
+
303
+ ${rounds.map((attempt, roundIndex) => `### A${roundIndex + 1}\n${textOrNull(attempt.acceptance)}`).join('\n\n')}
304
+
305
+ ## 4. 方案与代码改动
306
+
307
+ ### 当前有效方案
308
+ ${attempts ? textOrNull(effective?.solution) : 'NULL'}
309
+
310
+ ${rounds.map((attempt, roundIndex) => `### A${roundIndex + 1}\n${attempts ? `${attemptStatus(attempt)}\n` : ''}\n方案:${textOrNull(attempt.solution)}\n\n#### code index\n${codeIndex(attempt)}`).join('\n\n')}
311
+
312
+ ## 5. 测试
313
+
314
+ ${rounds.map((attempt, roundIndex) => `- [A${roundIndex + 1}](${link(file, `${nodeDir.get(node.id)}/todos/tests/${id}-A${roundIndex + 1}.md`)}):${textOrNull(attempt.test?.summary)}`).join('\n')}
315
+
316
+ ## 6. 实现 Session 索引
317
+
318
+ - ${rounds.map((_, roundIndex) => `[A${roundIndex + 1}](${link(file, `${nodeDir.get(node.id)}/todos/traces/${id}-A${roundIndex + 1}.md`)})`).join('\n- ')}`);
319
+ const list = todosByNode.get(node.id) || [];
320
+ list.push({ id, title, description, status, file });
321
+ todosByNode.set(node.id, list);
322
+ }
323
+
324
+ index = 0;
325
+ for (const idea of node.ideas || []) {
326
+ index += 1;
327
+ const id = idea.id || `I-${node.id}-${index}`;
328
+ const text = String(idea.text || '').trim() || 'NULL';
329
+ const title = text === 'NULL' ? id : text.split(/\r?\n/)[0].trim();
330
+ const status = idea.state === 'success' ? 'Accepted' : 'Proposed';
331
+ const file = `${nodeDir.get(node.id)}/ideas/${id}.md`;
332
+ put(file, `# ${id} ${title}\nStatus: ${status}\n\n## 1. 想法\n\n${text}\n\n## 2. 讨论与结论\n\nNULL`);
333
+ const list = ideasByNode.get(node.id) || [];
334
+ list.push({ id, title, text, status, file });
335
+ ideasByNode.set(node.id, list);
336
+ }
337
+ }
338
+
339
+ const linkBlock = (from, ids) => ids.length ? ids.map((id) => {
340
+ const targetNode = byId.get(id).node;
341
+ return `#### [${targetNode.title}](${link(from, `${nodeDir.get(id)}/index.md`)})\n${firstParagraph(targetNode.purpose)}`;
342
+ }).join('\n\n') : 'NULL';
343
+
344
+ for (const { node } of nodeEntries) {
345
+ const file = `${nodeDir.get(node.id)}/index.md`;
346
+ const memoryFile = node.id === document.root.id ? 'memory.md' : `${nodeDir.get(node.id)}/memory.md`;
347
+ const memoryLink = files.has(memoryFile) ? `\n\n## 记忆\n\n[阅读记忆](${link(file, memoryFile)})` : '';
348
+ const children = [...(node.children || []), ...(node._inbox || [])].map((child) => child.id);
349
+ const bugs = bugsByNode.get(node.id) || [];
350
+ const todos = todosByNode.get(node.id) || [];
351
+ const ideas = ideasByNode.get(node.id) || [];
352
+ const bugBlock = bugs.length ? bugs.map((bug) => `### [${bug.title}](${link(file, bug.file)})\n${firstParagraph(bug.phenomenon)}\n\nStatus: ${bug.status}`).join('\n\n') : 'NULL';
353
+ const todoBlock = todos.length ? todos.map((todo) => `### [${todo.title}](${link(file, todo.file)})\n${firstParagraph(todo.description)}\n\nStatus: ${todo.status}`).join('\n\n') : 'NULL';
354
+ const ideaBlock = ideas.length ? ideas.map((idea) => `### [${idea.title}](${link(file, idea.file)})\n${firstParagraph(idea.text)}\n\nStatus: ${idea.status}`).join('\n\n') : 'NULL';
355
+ put(file, `# ${node.title}
356
+
357
+ ${firstParagraph(node.purpose)}
358
+
359
+ ## 关联模块与节点
360
+
361
+ ### Related
362
+
363
+ ${linkBlock(file, [...related.get(node.id)])}
364
+
365
+ ### Sub
366
+
367
+ ${linkBlock(file, children)}
368
+
369
+ ## Bug
370
+
371
+ ${bugBlock}
372
+
373
+ ## Todo
374
+
375
+ ${todoBlock}
376
+
377
+ ## Idea
378
+
379
+ ${ideaBlock}${memoryLink}`);
380
+ }
381
+
382
+ const map = {
383
+ schema_version: 1,
384
+ generated_from: snapshot.version,
385
+ root: document.root.id,
386
+ nodes: nodeEntries.map(({ node, parent }) => ({
387
+ id: node.id,
388
+ type: node.kind === 'module' ? 'module' : 'node',
389
+ parent: parent?.id || null,
390
+ path: `${nodeDir.get(node.id)}/index.md`,
391
+ related: [...related.get(node.id)],
392
+ })),
393
+ };
394
+ put('map.json', JSON.stringify(map, null, 2));
395
+
396
+ const report = {
397
+ sourceVersion: snapshot.version,
398
+ sourceMainSha: snapshot.mainSha,
399
+ sourcePublishedAt: snapshot.publishedAt,
400
+ nodes: nodeEntries.length,
401
+ files: files.size + 2,
402
+ bugs: {
403
+ source: bugRecords.length,
404
+ migrated: [...bugsByNode.values()].flat().length + unassignedBugs.length,
405
+ unassigned: unassignedBugs.length,
406
+ },
407
+ todos: [...todosByNode.values()].flat().length,
408
+ ideas: [...ideasByNode.values()].flat().length,
409
+ deferred: {
410
+ memories: nodeEntries.reduce((count, { node }) => count + (node.memories?.length || 0), 0),
411
+ sessions: Object.keys(records).filter((name) => name.startsWith('sessions/')).length,
412
+ legacyRecords: Object.keys(records).length,
413
+ },
414
+ lossy: {
415
+ briefsTruncated: 0,
416
+ briefsMissing: nodeEntries.filter(({ node }) => !String(node.purpose || '').trim()).length,
417
+ reporterUnknown: bugRecords.length + [...todosByNode.values()].flat().length,
418
+ },
419
+ warnings,
420
+ };
421
+ put('migration-report.json', JSON.stringify(report, null, 2));
422
+ validateGeneratedLinks(files, generatedLinks);
423
+
424
+ const hashes = [...files].map(([name, content]) => ({
425
+ path: name,
426
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
427
+ }));
428
+ put('manifest.json', JSON.stringify({ sourceVersion: snapshot.version, files: hashes }, null, 2));
429
+ return { files, report };
430
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { performance } from 'node:perf_hooks';
7
+ import JSONParser from './vendor/jsonparse.cjs';
8
+ export const hash = text => createHash('sha256').update(text).digest('hex');
9
+ export const encode = value => JSON.stringify(value, null, 2) + '\n';
10
+ export const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+ export async function withFileLock(file, action, { reclaimGuard = false } = {}) {
12
+ await fs.mkdir(path.dirname(file), { recursive: true });
13
+ const deadline = Date.now() + 5000;
14
+ let handle;
15
+ while (!handle) {
16
+ try {
17
+ handle = await fs.open(file, 'wx', 0o600);
18
+ await handle.writeFile(encode({ pid: process.pid, host: os.hostname(), createdAt: new Date().toISOString() }));
19
+ await handle.sync();
20
+ }
21
+ catch (error) {
22
+ // Windows can report delete-pending contention as EPERM rather than EEXIST.
23
+ // Retry only acquisition; an error after opening our own lock must fail.
24
+ if (!handle && process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code) && Date.now() < deadline) {
25
+ await pause(25); continue;
26
+ }
27
+ if (error.code !== 'EEXIST') {
28
+ if (handle) { await handle.close().catch(() => {}); handle = null; await fs.unlink(file).catch(() => {}); }
29
+ throw error;
30
+ }
31
+ const owner = await readJSON(file, null).catch(() => null);
32
+ let dead = owner?.host === os.hostname() && Number.isSafeInteger(owner?.pid) && owner.pid > 0;
33
+ if (dead) {
34
+ try { process.kill(owner.pid, 0); dead = false; }
35
+ catch (cause) { dead = cause.code === 'ESRCH'; }
36
+ }
37
+ if (dead) {
38
+ if (reclaimGuard) throw Object.assign(new Error('Interrupted lock recovery needs explicit repair; preserve the recovery guard'), { code: 'STATE_BUSY' });
39
+ // Serialize stale recovery and reread the owner inside that guard. A
40
+ // contender must never remove a new live lock using an old PID snapshot.
41
+ await withFileLock(`${file}.reclaim`, async () => {
42
+ const current = await readJSON(file, null).catch(() => null);
43
+ if (!current || current.host !== os.hostname() || !Number.isSafeInteger(current.pid) || current.pid <= 0) return;
44
+ try { process.kill(current.pid, 0); return; }
45
+ catch (cause) { if (cause.code !== 'ESRCH') return; }
46
+ await fs.unlink(file).catch(cause => { if (cause.code !== 'ENOENT') throw cause; });
47
+ }, { reclaimGuard: true });
48
+ continue;
49
+ }
50
+ if (Date.now() >= deadline) throw Object.assign(new Error('Shared state is busy; preserve lock and retry'), { code: 'STATE_BUSY' });
51
+ await pause(25);
52
+ }
53
+ }
54
+ try { return await action(); }
55
+ finally { await handle.close(); await fs.unlink(file); }
56
+ }
57
+ async function parseJSONFile(file, maxReadFileBytes) {
58
+ const info = await fs.stat(file);
59
+ if (info.size <= maxReadFileBytes) return JSON.parse(await fs.readFile(file, 'utf8'));
60
+ return new Promise((resolve, reject) => {
61
+ const parser = new JSONParser();
62
+ const stream = createReadStream(file);
63
+ let complete = false;
64
+ let value;
65
+ let settled = false;
66
+ const fail = error => {
67
+ if (settled) return;
68
+ settled = true;
69
+ reject(error);
70
+ };
71
+ parser.onValue = function onValue(parsed) {
72
+ if (this.stack.length === 0) {
73
+ complete = true;
74
+ value = parsed;
75
+ }
76
+ };
77
+ stream.on('data', chunk => {
78
+ if (settled) return;
79
+ try { parser.write(chunk); }
80
+ catch (error) { fail(error); stream.destroy(); }
81
+ });
82
+ stream.on('error', fail);
83
+ stream.on('end', () => {
84
+ if (settled) return;
85
+ if (!complete) return fail(new SyntaxError('Unexpected end of JSON input'));
86
+ settled = true;
87
+ resolve(value);
88
+ });
89
+ });
90
+ }
91
+ export async function readJSON(file, fallback, { maxReadFileBytes = 128 * 1024 * 1024 } = {}) {
92
+ try { return await parseJSONFile(file, maxReadFileBytes); }
93
+ catch (e) { if (e.code === 'ENOENT' && fallback !== undefined) return fallback; throw e; }
94
+ }
95
+ export async function atomicWrite(file, content, { deadlineMs = 350, beforeReplace = async () => {} } = {}) {
96
+ await fs.mkdir(path.dirname(file), { recursive: true });
97
+ const temp = `${file}.${randomUUID()}.tmp`;
98
+ let handle;
99
+ try {
100
+ handle = await fs.open(temp, 'wx', 0o600);
101
+ await handle.writeFile(content); await handle.sync(); await handle.close(); handle = null;
102
+ const deadline = performance.now() + deadlineMs;
103
+ for (;;) {
104
+ await beforeReplace();
105
+ try { await fs.rename(temp, file); break; }
106
+ catch (e) {
107
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(e.code) || performance.now() >= deadline) throw e;
108
+ await pause(Math.min(10, Math.max(0, deadline - performance.now())));
109
+ }
110
+ }
111
+ // Directory fsync is supported on Unix, not Windows. File contents were flushed.
112
+ if (process.platform !== 'win32') {
113
+ const dir = await fs.open(path.dirname(file), 'r');
114
+ try { await dir.sync(); } finally { await dir.close(); }
115
+ }
116
+ } finally { if (handle) await handle.close(); await fs.unlink(temp).catch(() => {}); }
117
+ }