sandtable 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1019 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { scan, loadConfig, normalizeCategory, DISPLAY_CATEGORIES, PRIMARY_CATEGORIES } = require('../scanner/scan');
7
+
8
+ // ---- Markdown Table Parser (unchanged from v1) ----
9
+ function parseMarkdownTables(content) {
10
+ const results = [];
11
+ const lines = content.split('\n');
12
+ let header = null;
13
+ for (let i = 0; i < lines.length; i++) {
14
+ const line = lines[i].trim();
15
+ if (/^\|.+\|$/.test(line) && !/^\|[-| ]+\|$/.test(line)) {
16
+ if (!header) {
17
+ header = line.split('|').map(c => c.trim()).filter(Boolean);
18
+ } else {
19
+ const cols = line.split('|').map(c => c.trim()).filter(Boolean);
20
+ if (cols.length >= 2) {
21
+ const row = {};
22
+ header.forEach((h, idx) => { row[h] = cols[idx] || ''; });
23
+ results.push(row);
24
+ }
25
+ }
26
+ } else if (!/^\|[-| ]+\|$/.test(line)) {
27
+ header = null;
28
+ }
29
+ }
30
+ return results;
31
+ }
32
+
33
+ // ---- Primary Tree Builder ----
34
+ function buildPrimaryTree(planFiles, allFiles, projectRoot) {
35
+ const elements = [];
36
+
37
+ // Collect task rows from task-board-like files
38
+ const taskRows = [];
39
+ for (const pf of planFiles) {
40
+ const fp = path.join(projectRoot, pf.path);
41
+ if (!fs.existsSync(fp)) continue;
42
+ const content = fs.readFileSync(fp, 'utf-8');
43
+ const tables = parseMarkdownTables(content);
44
+ const hasTaskCols = tables.length > 0 &&
45
+ (Object.keys(tables[0]).some(k => /任务|task|#/.test(k)));
46
+ if (hasTaskCols) {
47
+ for (const row of tables) {
48
+ taskRows.push({ ...row, _source: pf.path });
49
+ }
50
+ }
51
+ }
52
+
53
+ // Build phases from milestone/plan files with markdown tables
54
+ const milestoneFiles = planFiles.filter(f =>
55
+ f.elementType === 'milestone' ||
56
+ (f.summary && f.summary.type === 'milestone')
57
+ );
58
+
59
+ for (const mf of milestoneFiles) {
60
+ const fp = path.join(projectRoot, mf.path);
61
+ if (!fs.existsSync(fp)) continue;
62
+ const content = fs.readFileSync(fp, 'utf-8');
63
+ const rows = parseMarkdownTables(content);
64
+
65
+ const milestones = rows.map((row, i) => {
66
+ const keys = Object.keys(row);
67
+ const milestoneName = row[keys[0]] || '';
68
+ const version = row[keys[1]] || '';
69
+ const signal = row[keys[2]] || '';
70
+ const statusRaw = (row[keys[3]] || 'pending').toLowerCase();
71
+ const status = statusRaw.includes('in_progress') ? 'in_progress'
72
+ : statusRaw.includes('complete') ? 'completed'
73
+ : statusRaw.includes('block') ? 'blocked'
74
+ : 'pending';
75
+
76
+ // Match subtasks from task board
77
+ const subtasks = taskRows
78
+ .filter(tr => {
79
+ const taskName = tr[Object.keys(tr)[1]] || tr[Object.keys(tr)[0]] || '';
80
+ return taskName.includes(milestoneName) ||
81
+ milestoneName.includes(taskName.substring(0, 6));
82
+ })
83
+ .slice(0, 3)
84
+ .map(tr => {
85
+ const tKeys = Object.keys(tr);
86
+ const statusKey = tKeys.find(k => /状态|status/i.test(k));
87
+ const statusCol = statusKey ? tr[statusKey] : (tr[tKeys[4]] || tr[tKeys[3]] || '');
88
+ const isCompleted = /✅|\[x\]|completed|complete/i.test(statusCol);
89
+ const isInProgress = /⏳|\[\s*\]|in.progress/i.test(statusCol);
90
+ const typeKey = tKeys.find(k => /类型|type/i.test(k));
91
+ const typeCol = typeKey ? tr[typeKey] : (tr[tKeys[3]] || tr[tKeys[2]] || '');
92
+ return {
93
+ id: tr[tKeys[0]] || '',
94
+ name: tr[tKeys[1]] || tr[tKeys[0]] || '',
95
+ status: isCompleted ? 'completed' : isInProgress ? 'in_progress' : 'pending',
96
+ kind: 'primary',
97
+ elementType: 'subtask',
98
+ timeGroup: 'current',
99
+ timeLabel: '',
100
+ date: null,
101
+ source: { file: tr._source || mf.path, title: mf.title },
102
+ summary: '',
103
+ tags: [],
104
+ related: [],
105
+ children: [],
106
+ };
107
+ });
108
+
109
+ return {
110
+ id: 'ms-' + (milestoneName.replace(/^M/, '').substring(0, 10) || `M${i + 1}`),
111
+ kind: 'primary',
112
+ elementType: 'milestone',
113
+ category: 'roadmap',
114
+ name: version ? `${milestoneName} (${version})` : milestoneName,
115
+ status,
116
+ timeGroup: 'current',
117
+ timeLabel: signal,
118
+ date: mf.date || null,
119
+ source: { file: mf.path, title: mf.title },
120
+ summary: '',
121
+ tags: [],
122
+ related: [],
123
+ children: subtasks,
124
+ order: i + 1,
125
+ };
126
+ });
127
+
128
+ elements.push({
129
+ id: 'phase-' + (elements.length + 1),
130
+ kind: 'primary',
131
+ elementType: 'phase',
132
+ category: 'roadmap',
133
+ name: mf.summary ? mf.summary.summary : mf.title,
134
+ status: mf.summary ? mf.summary.status : 'in_progress',
135
+ timeGroup: 'current',
136
+ timeLabel: '',
137
+ date: mf.date || null,
138
+ source: { file: mf.path, title: mf.title },
139
+ summary: mf.summary ? (mf.summary.summary || '') : '',
140
+ tags: mf.summary ? (mf.summary.tags || []) : [],
141
+ related: mf.summary ? (mf.summary.related || []) : [],
142
+ children: milestones,
143
+ order: elements.length + 1,
144
+ });
145
+ }
146
+
147
+ // Fallback: default phase from any plan-category files with summaries
148
+ if (elements.length === 0 && planFiles.length > 0) {
149
+ const tasks = planFiles
150
+ .filter(f => f.summary)
151
+ .map(f => ({
152
+ id: 'task-' + f.path.replace(/[/.]/g, '-'),
153
+ kind: 'primary',
154
+ elementType: 'task',
155
+ category: 'roadmap',
156
+ name: f.summary.summary,
157
+ status: f.summary.status || 'pending',
158
+ timeGroup: 'current',
159
+ timeLabel: '',
160
+ date: f.date || null,
161
+ source: { file: f.path, title: f.title },
162
+ summary: f.summary.summary || '',
163
+ tags: f.summary.tags || [],
164
+ related: f.summary.related || [],
165
+ children: [],
166
+ }));
167
+
168
+ elements.push({
169
+ id: 'phase-default',
170
+ kind: 'primary',
171
+ elementType: 'phase',
172
+ category: 'roadmap',
173
+ name: '当前阶段',
174
+ status: 'in_progress',
175
+ timeGroup: 'current',
176
+ timeLabel: '',
177
+ date: null,
178
+ source: { file: '', title: '' },
179
+ summary: '',
180
+ tags: [],
181
+ related: [],
182
+ children: [{
183
+ id: 'ms-current',
184
+ kind: 'primary',
185
+ elementType: 'milestone',
186
+ category: 'roadmap',
187
+ name: '进行中',
188
+ status: 'in_progress',
189
+ timeGroup: 'current',
190
+ timeLabel: '',
191
+ date: null,
192
+ source: { file: '', title: '' },
193
+ summary: '',
194
+ tags: [],
195
+ related: [],
196
+ children: tasks,
197
+ order: 1,
198
+ }],
199
+ order: 1,
200
+ });
201
+ }
202
+
203
+ // Extract conclusion elements from files with type=conclusion
204
+ const conclusionFiles = allFiles.filter(f =>
205
+ f.elementType === 'conclusion' ||
206
+ (f.summary && f.summary.type === 'conclusion')
207
+ );
208
+ for (const cf of conclusionFiles) {
209
+ elements.push({
210
+ id: 'conclusion-' + cf.path.replace(/[/.]/g, '-'),
211
+ kind: 'primary',
212
+ elementType: 'conclusion',
213
+ category: 'roadmap',
214
+ name: cf.title,
215
+ status: cf.summary ? (cf.summary.status || 'completed') : 'completed',
216
+ timeGroup: 'past',
217
+ timeLabel: cf.date || '',
218
+ date: cf.date || null,
219
+ source: { file: cf.path, title: cf.title },
220
+ summary: cf.summary ? (cf.summary.summary || '') : '',
221
+ tags: cf.summary ? (cf.summary.tags || []) : [],
222
+ related: cf.summary ? (cf.summary.related || []) : [],
223
+ children: [],
224
+ order: 0,
225
+ });
226
+ }
227
+
228
+ return elements;
229
+ }
230
+
231
+ // ---- Secondary List Builder ----
232
+ function buildSecondaryList(secondaryFiles) {
233
+ return secondaryFiles
234
+ .filter(f => f.hasSummary || f.elementType !== 'unknown')
235
+ .map((f, i) => ({
236
+ id: f.elementType + '-' + f.path.replace(/[/.]/g, '-'),
237
+ kind: 'secondary',
238
+ elementType: f.elementType,
239
+ category: f.category,
240
+ name: f.title,
241
+ status: f.summary ? (f.summary.status || 'unknown') : 'unknown',
242
+ timeGroup: 'current',
243
+ timeLabel: (f.date || f.modified).substring(0, 10),
244
+ date: f.date || f.modified,
245
+ source: { file: f.path, title: f.title },
246
+ summary: f.summary ? (f.summary.summary || '') : '',
247
+ tags: f.summary ? (f.summary.tags || []) : [],
248
+ related: f.summary ? (f.summary.related || []) : [],
249
+ children: [],
250
+ order: i,
251
+ }));
252
+ }
253
+
254
+ // ---- Time Group Classifier ----
255
+ function classifyTimeGroup(element, timeRules, today) {
256
+ // Archive category → always archived (read-only historical docs)
257
+ if (element.category === 'archive') return 'archived';
258
+
259
+ const rules = timeRules || {};
260
+ const classifyBy = rules.classifyBy || 'status';
261
+
262
+ if (classifyBy === 'date') {
263
+ if (element.date && element.date < today) return 'past';
264
+ if (element.date && element.date > today) return 'future';
265
+ return 'current';
266
+ }
267
+
268
+ // classifyBy === 'status' (default)
269
+ const pastStatuses = rules.pastStatus || ['completed', 'cancelled'];
270
+ const currentStatuses = rules.currentStatus || ['in_progress', 'pending'];
271
+ const futureStatuses = rules.futureStatus || [];
272
+
273
+ if (pastStatuses.includes(element.status)) return 'past';
274
+ if (futureStatuses.includes(element.status) &&
275
+ !currentStatuses.includes(element.status)) return 'future';
276
+ return 'current';
277
+ }
278
+
279
+ // ---- Filter Type Manifest Builder ----
280
+ function buildFilterTypes(files, secondaryTypesDefaultOff) {
281
+ const secondaryFiles = files.filter(f => f.kind === 'secondary');
282
+ const catCounts = {};
283
+
284
+ for (const f of secondaryFiles) {
285
+ const cat = normalizeCategory(f.elementType);
286
+ catCounts[cat] = (catCounts[cat] || 0) + 1;
287
+ }
288
+
289
+ const result = [];
290
+ for (const [cat, def] of Object.entries(DISPLAY_CATEGORIES)) {
291
+ // Skip primary-only categories (all their elementTypes are kind=primary, no secondary files)
292
+ if (PRIMARY_CATEGORIES && PRIMARY_CATEGORIES.has(cat)) continue;
293
+ const count = catCounts[cat] || 0;
294
+ if (count > 0) {
295
+ result.push({ type: cat, label: def.label, count, defaultEnabled: !secondaryTypesDefaultOff });
296
+ }
297
+ }
298
+
299
+ return result;
300
+ }
301
+
302
+ // ---- Conventions Builder (auto-extract from docs/conventions/) ----
303
+ function buildConventions(files, projectRoot) {
304
+ var abbreviations = [];
305
+ var terms = [];
306
+
307
+ // Try to parse abbreviations.md
308
+ var abbrFile = files.find(function(f) { return f.path.indexOf('abbreviations') !== -1; });
309
+ if (abbrFile) {
310
+ var abbrPath = path.join(projectRoot, abbrFile.path);
311
+ if (fs.existsSync(abbrPath)) {
312
+ var content = fs.readFileSync(abbrPath, 'utf-8');
313
+ var rows = parseMarkdownTables(content);
314
+ for (var i = 0; i < rows.length; i++) {
315
+ var row = rows[i];
316
+ var keys = Object.keys(row);
317
+ if (keys.length >= 2) {
318
+ abbreviations.push({
319
+ abbr: row[keys[0]] || '',
320
+ full: row[keys[1]] || '',
321
+ note: row[keys[2]] || '',
322
+ });
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ // Try to parse terms.md
329
+ var termFile = files.find(function(f) { return f.path.indexOf('terms') !== -1 && f.path.indexOf('abbreviations') === -1; });
330
+ if (termFile) {
331
+ var termPath = path.join(projectRoot, termFile.path);
332
+ if (fs.existsSync(termPath)) {
333
+ var tContent = fs.readFileSync(termPath, 'utf-8');
334
+ var tRows = parseMarkdownTables(tContent);
335
+ for (var j = 0; j < tRows.length; j++) {
336
+ var tRow = tRows[j];
337
+ var tKeys = Object.keys(tRow);
338
+ if (tKeys.length >= 2) {
339
+ terms.push({
340
+ term: tRow[tKeys[0]] || '',
341
+ definition: tRow[tKeys[1]] || '',
342
+ source: tRow[tKeys[2]] || '',
343
+ });
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ // Auto-detect abbreviations from all doc content (pattern: ABC (Alpha Bravo Charlie))
350
+ var abbrSet = {};
351
+ for (var ai = 0; ai < abbreviations.length; ai++) {
352
+ abbrSet[abbreviations[ai].abbr] = true;
353
+ }
354
+ for (var fi = 0; fi < files.length; fi++) {
355
+ var f = files[fi];
356
+ if (!f.hasSummary) continue;
357
+ try {
358
+ var fp = path.join(projectRoot, f.path);
359
+ if (!fs.existsSync(fp)) continue;
360
+ var fContent = fs.readFileSync(fp, 'utf-8').substring(0, 2000);
361
+ // Match patterns like "MVP (Minimum Viable Product)" or "ABC — Alpha"
362
+ var autoMatches = fContent.match(/\b([A-Z]{2,6})\s*[((]\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\s*[))]/g);
363
+ if (autoMatches) {
364
+ for (var am = 0; am < autoMatches.length; am++) {
365
+ var m = autoMatches[am].match(/\b([A-Z]{2,6})\s*[((]\s*([^))]+)\s*[))]/);
366
+ if (m && !abbrSet[m[1]]) {
367
+ abbrSet[m[1]] = true;
368
+ abbreviations.push({ abbr: m[1], full: m[2].trim(), note: 'auto-detected' });
369
+ }
370
+ }
371
+ }
372
+ } catch (_) {}
373
+ }
374
+
375
+ return {
376
+ configured: abbreviations.length > 0 || terms.length > 0,
377
+ abbreviations: abbreviations,
378
+ terms: terms,
379
+ };
380
+ }
381
+
382
+ // ---- Agents Builder (unchanged from v1) ----
383
+ function buildAgents(files, projectRoot) {
384
+ const agentFiles = files.filter(f => f.category === 'ops' && f.elementType === 'agent');
385
+ if (agentFiles.length === 0) {
386
+ return { roles: [] };
387
+ }
388
+
389
+ const roles = agentFiles.map(f => {
390
+ const roleMatch = f.title.match(/@(\w+)/) || f.path.match(/@(\w+)/);
391
+ const name = roleMatch ? `@${roleMatch[1]}` : f.title;
392
+
393
+ const filePath = path.join(projectRoot, f.path);
394
+ let modelTier = 'unknown';
395
+ if (fs.existsSync(filePath)) {
396
+ const content = fs.readFileSync(filePath, 'utf-8').substring(0, 500);
397
+ if (/脑|brain|高算力/.test(content)) modelTier = 'brain';
398
+ else if (/手|hand|coder|实现/.test(content)) modelTier = 'hand';
399
+ else if (/测试|test|验收/.test(content)) modelTier = 'test';
400
+ }
401
+
402
+ return {
403
+ name,
404
+ file: f.path,
405
+ responsibility: f.summary ? f.summary.summary : '',
406
+ modelTier,
407
+ tasks: f.summary ? (f.summary.related || []) : [],
408
+ };
409
+ });
410
+
411
+ return { roles };
412
+ }
413
+
414
+ // ---- Brief Generator ----
415
+ function generateBrief(files, maxLength) {
416
+ const limit = maxLength || 200;
417
+ const recentSummaries = files
418
+ .filter(f => f.summary && f.summary.summary)
419
+ .sort((a, b) => new Date(b.modified) - new Date(a.modified))
420
+ .slice(0, 10);
421
+
422
+ const brief = recentSummaries.length > 0
423
+ ? recentSummaries.map(f => f.summary.summary).join(';')
424
+ : '项目初始化中,尚未有摘要块数据。运行 `node src/cli/sandtable.js build` 来更新。';
425
+
426
+ return brief.substring(0, limit);
427
+ }
428
+
429
+ // ---- Event Stream Builder (§11 7 大事件类 + 8 字段 schema + 3 级优先级) ----
430
+
431
+ // Priority constants
432
+ var PRIORITY_MUST = '必记';
433
+ var PRIORITY_SHOULD = '应记';
434
+ var PRIORITY_CAN = '可记';
435
+
436
+ var EVENT_TYPES = {
437
+ '1': '对齐与拍板',
438
+ '2': '规格演进',
439
+ '3': '代码变更',
440
+ '4': '测试与质量',
441
+ '5': '审批与交接',
442
+ '6': '运维与基建',
443
+ '7': '教训沉淀',
444
+ };
445
+
446
+ function classifyEventPriority(event) {
447
+ // 必记: user decisions, irreversible operations, anti-patterns, PR merge, 5-level alignment
448
+ if (event.type === '对齐与拍板') return PRIORITY_MUST;
449
+ if (event.type === '教训沉淀' && event.subtype === 'anti_pattern') return PRIORITY_MUST;
450
+ if (event.tags && (event.tags.indexOf('不可逆') !== -1 || event.tags.indexOf('红线') !== -1)) return PRIORITY_MUST;
451
+ if (event.impact === 'high') return PRIORITY_MUST;
452
+
453
+ // 应记: spec落盘/修订, milestone完成, 脑审批, 部署, 决策落盘
454
+ if (event.type === '规格演进') return PRIORITY_SHOULD;
455
+ if (event.type === '审批与交接') return PRIORITY_SHOULD;
456
+ if (event.type === '运维与基建') return PRIORITY_SHOULD;
457
+ if (event.impact === 'medium') return PRIORITY_SHOULD;
458
+
459
+ // 可记: role切换, branch切换, normal commit, fixture微调
460
+ return PRIORITY_CAN;
461
+ }
462
+
463
+ function buildEvents(files, projectRoot, config) {
464
+ var events = [];
465
+ var eventsCfg = (config && config.events) ? config.events : { gitLog: true, maxGitLogEntries: 100 };
466
+
467
+ // ---- Dual-track: load JSONL events (Track 1: AI manually recorded, higher quality) ----
468
+ var jsonlEvents = [];
469
+ var jsonlIds = {};
470
+ var jsonlDocKeys = {};
471
+ var sandtableDir = path.join(projectRoot, '.sandtable');
472
+ var eventLogPath = path.join(sandtableDir, 'event-log.jsonl');
473
+ var errorLogPath = path.join(sandtableDir, 'event-log.errors.jsonl');
474
+
475
+ if (fs.existsSync(eventLogPath)) {
476
+ try {
477
+ var jlContent = fs.readFileSync(eventLogPath, 'utf-8').trim();
478
+ if (jlContent) {
479
+ var jlLines = jlContent.split('\n');
480
+ for (var jli = 0; jli < jlLines.length; jli++) {
481
+ var jlLine = jlLines[jli].trim();
482
+ if (!jlLine) continue;
483
+ try {
484
+ var jevt = JSON.parse(jlLine);
485
+ if (jevt.id) {
486
+ jsonlIds[jevt.id] = true;
487
+ // Also track doc refs for dedup
488
+ if (jevt.ref && jevt.ref.doc) {
489
+ var docKey = jevt.typeId + '|' + jevt.ref.doc + '|' + (jevt.subtype || '');
490
+ jsonlDocKeys[docKey] = true;
491
+ }
492
+ }
493
+ // Auto-extract version from title (e.g. "v0.3") and append to tags
494
+ if (jevt.title && jevt.tags) {
495
+ var verMatch = jevt.title.match(/v\d+\.\d+/g);
496
+ if (verMatch) {
497
+ for (var vm = 0; vm < verMatch.length; vm++) {
498
+ if (jevt.tags.indexOf(verMatch[vm]) === -1) {
499
+ jevt.tags.push(verMatch[vm]);
500
+ }
501
+ }
502
+ }
503
+ }
504
+ jsonlEvents.push(jevt);
505
+ } catch (parseErr) {
506
+ // Never silent — log parse failures
507
+ var errEntry = JSON.stringify({
508
+ timestamp: new Date().toISOString(),
509
+ line: jlLine.substring(0, 200),
510
+ error: parseErr.message,
511
+ });
512
+ try {
513
+ if (!fs.existsSync(sandtableDir)) fs.mkdirSync(sandtableDir, { recursive: true });
514
+ fs.appendFileSync(errorLogPath, errEntry + '\n');
515
+ } catch (_) {}
516
+ console.warn('event-log: JSONL parse error — see .sandtable/event-log.errors.jsonl');
517
+ }
518
+ }
519
+ }
520
+ } catch (readErr) {
521
+ // event-log.jsonl missing or unreadable — continue with auto events only
522
+ }
523
+ }
524
+
525
+ // Helper: check if an auto-generated event is a duplicate of a JSONL event
526
+ function isJsonlDuplicate(autoEvent) {
527
+ if (autoEvent.id && jsonlIds[autoEvent.id]) return true;
528
+ if (autoEvent.ref && autoEvent.ref.doc) {
529
+ var key = autoEvent.typeId + '|' + autoEvent.ref.doc + '|' + (autoEvent.subtype || '');
530
+ if (jsonlDocKeys[key]) return true;
531
+ }
532
+ return false;
533
+ }
534
+
535
+ // ---- Source 1: Git log → 代码变更事件 + 测试事件 ----
536
+ if (eventsCfg.gitLog !== false && fs.existsSync(path.join(projectRoot, '.git'))) {
537
+ try {
538
+ var maxEntries = eventsCfg.maxGitLogEntries || 100;
539
+ var _require = require('child_process');
540
+ var execSync = _require.execSync;
541
+ var gitLog = execSync(
542
+ 'git log --pretty=format:"%H|%ad|%s|%an" --date=iso-strict --max-count=' + maxEntries,
543
+ { cwd: projectRoot, encoding: 'utf-8', timeout: 10000 }
544
+ ).trim();
545
+
546
+ if (gitLog) {
547
+ var lines = gitLog.split('\n');
548
+ for (var li = 0; li < lines.length; li++) {
549
+ var line = lines[li];
550
+ var parts = line.split('|');
551
+ if (parts.length < 3) continue;
552
+ var hash = parts[0].substring(0, 8);
553
+ var timestamp = parts[1]; // ISO 8601 with timezone, already hour-precision
554
+ var subject = parts.slice(2, -1).join('|');
555
+ var author = parts[parts.length - 1];
556
+
557
+ // Classify into §11 7 event categories
558
+ var eventType = '3'; // default: 代码变更
559
+ var subtype = 'commit';
560
+ var impact = 'low';
561
+ var msg = subject.toLowerCase();
562
+
563
+ if (/merge|pr\s*#|pull request/i.test(msg)) {
564
+ eventType = '3'; subtype = 'pr_merge'; impact = 'medium';
565
+ } else if (/security|vuln|漏洞/i.test(msg)) {
566
+ eventType = '3'; subtype = 'security_fix'; impact = 'high';
567
+ } else if (/revert|回滚|rollback/i.test(msg)) {
568
+ eventType = '3'; subtype = 'revert'; impact = 'medium';
569
+ } else if (/refactor|重构|拆分|extract/i.test(msg)) {
570
+ eventType = '2'; subtype = 'refactor'; impact = 'medium';
571
+ } else if (/feat|feature|新增|add\b/i.test(msg)) {
572
+ eventType = '2'; subtype = 'feature'; impact = 'medium';
573
+ } else if (/fix|bug|修复|hotfix/i.test(msg)) {
574
+ eventType = '3'; subtype = 'fix'; impact = 'low';
575
+ } else if (/doc|docs|文档|readme/i.test(msg)) {
576
+ eventType = '7'; subtype = 'doc'; impact = 'low';
577
+ } else if (/test|测试|fixture|spec/i.test(msg)) {
578
+ eventType = '4'; subtype = 'test'; impact = 'low';
579
+ } else if (/chore|ci|build|deploy|部署|上线/i.test(msg)) {
580
+ eventType = '6'; subtype = 'ops'; impact = 'medium';
581
+ }
582
+
583
+ // Extract tags
584
+ var tags = [];
585
+ var rMatch = subject.match(/\bR\d+\b/g);
586
+ if (rMatch) tags.push.apply(tags, rMatch);
587
+ var opMatch = subject.match(/\bOP-\d+\b/g);
588
+ if (opMatch) tags.push.apply(tags, opMatch);
589
+ if (/不可逆|irreversible/i.test(subject)) tags.push('不可逆');
590
+ if (/红线|red.?line/i.test(subject)) tags.push('红线');
591
+
592
+ var event = {
593
+ id: 'git-' + hash,
594
+ timestamp: timestamp,
595
+ type: EVENT_TYPES[eventType] || '代码变更',
596
+ typeId: eventType,
597
+ subtype: subtype,
598
+ title: subject,
599
+ ref: { commit: hash },
600
+ impact: impact,
601
+ actor: author,
602
+ tags: tags,
603
+ };
604
+ event.priority = classifyEventPriority(event);
605
+ if (!isJsonlDuplicate(event)) events.push(event);
606
+ }
607
+ }
608
+ } catch (e) {
609
+ // Git not available — skip
610
+ }
611
+ }
612
+
613
+ // Helper: normalize date to ISO 8601 hour-precision timestamp
614
+ function normalizeTimestamp(dateStr) {
615
+ if (!dateStr) return new Date().toISOString();
616
+ // If already has time component, use as-is
617
+ if (dateStr.indexOf('T') !== -1) return dateStr;
618
+ // Date-only string → set to noon local
619
+ return dateStr + 'T12:00:00+08:00';
620
+ }
621
+
622
+ // ---- Source 2: decisions/ files → 对齐与拍板 (type 1) ----
623
+ var decisionFiles = files.filter(function(f) { return f.elementType === 'decision' || (f.category === 'decision' && f.kind === 'primary'); });
624
+ for (var di = 0; di < decisionFiles.length; di++) {
625
+ var df = decisionFiles[di];
626
+ var dt = df.date || df.modified;
627
+ var event = {
628
+ id: 'dec-' + df.path.replace(/[/.]/g, '-'),
629
+ timestamp: normalizeTimestamp(dt),
630
+ type: EVENT_TYPES['1'],
631
+ typeId: '1',
632
+ subtype: 'decision_record',
633
+ title: df.title,
634
+ ref: { doc: df.path },
635
+ impact: 'high',
636
+ actor: '脑会话 (Opus)',
637
+ tags: (df.summary && df.summary.tags) ? df.summary.tags : [],
638
+ };
639
+ event.priority = classifyEventPriority(event);
640
+ if (!isJsonlDuplicate(event)) events.push(event);
641
+ }
642
+
643
+ // ---- Source 3: spec/refactor → 规格演进 (type 2) ----
644
+ var specFiles = files.filter(function(f) {
645
+ return f.elementType === 'spec' || f.elementType === 'refactor' ||
646
+ (f.category === 'spec' && f.kind === 'primary');
647
+ });
648
+ for (var si = 0; si < specFiles.length; si++) {
649
+ var sf = specFiles[si];
650
+ var sdt = sf.date || sf.modified;
651
+ var sTags = [];
652
+ var rM = (sf.path + ' ' + sf.title).match(/\bR\d+\b/g);
653
+ if (rM) sTags.push.apply(sTags, rM);
654
+ var oM = (sf.path + ' ' + sf.title).match(/\bOP-\d+\b/g);
655
+ if (oM) sTags.push.apply(sTags, oM);
656
+
657
+ var sEvent = {
658
+ id: 'spec-' + sf.path.replace(/[/.]/g, '-'),
659
+ timestamp: normalizeTimestamp(sdt),
660
+ type: EVENT_TYPES['2'],
661
+ typeId: '2',
662
+ subtype: 'spec_v1_release',
663
+ title: sf.title,
664
+ ref: { doc: sf.path },
665
+ impact: 'high',
666
+ actor: '脑会话 (Opus)',
667
+ tags: sTags,
668
+ };
669
+ sEvent.priority = classifyEventPriority(sEvent);
670
+ if (!isJsonlDuplicate(sEvent)) events.push(sEvent);
671
+ }
672
+
673
+ // ---- Source 4: convention/skills → 运维与基建 (type 6, skill_added) or 教训沉淀 (type 7, anti_pattern) ----
674
+ var skillFiles = files.filter(function(f) {
675
+ return f.category === 'convention' && (f.path.indexOf('skills/') !== -1 || f.path.indexOf('skill') !== -1);
676
+ });
677
+ for (var ki = 0; ki < skillFiles.length; ki++) {
678
+ var kf = skillFiles[ki];
679
+ var kdt = kf.date || kf.modified;
680
+ var apCount = 0;
681
+ try {
682
+ var skillPath = path.join(projectRoot, kf.path);
683
+ if (fs.existsSync(skillPath)) {
684
+ var content = fs.readFileSync(skillPath, 'utf-8');
685
+ var apM = content.match(/§7\.\d+/g);
686
+ if (apM) apCount = new Set(apM).size;
687
+ }
688
+ } catch (_) {}
689
+
690
+ var isAnti = apCount > 0;
691
+ var kEvent = {
692
+ id: 'skl-' + kf.path.replace(/[/.]/g, '-'),
693
+ timestamp: normalizeTimestamp(kdt),
694
+ type: EVENT_TYPES[isAnti ? '7' : '6'],
695
+ typeId: isAnti ? '7' : '6',
696
+ subtype: isAnti ? 'anti_pattern' : 'skill_added',
697
+ title: kf.title + (isAnti ? ' — ' + apCount + ' 个反模式' : ''),
698
+ ref: { doc: kf.path },
699
+ impact: apCount > 3 ? 'high' : 'medium',
700
+ actor: '次脑',
701
+ tags: isAnti ? ['反模式', '§7.X'] : [],
702
+ };
703
+ kEvent.priority = classifyEventPriority(kEvent);
704
+ if (!isJsonlDuplicate(kEvent)) events.push(kEvent);
705
+ }
706
+
707
+ // ---- Source 5: ops/runbook → 运维与基建 (type 6) ----
708
+ var opsFiles = files.filter(function(f) {
709
+ return f.category === 'ops' && (f.elementType === 'runbook' || f.elementType === 'agent');
710
+ });
711
+ for (var oi = 0; oi < opsFiles.length; oi++) {
712
+ var of = opsFiles[oi];
713
+ var odt = of.date || of.modified;
714
+ var oEvent = {
715
+ id: 'ops-' + of.path.replace(/[/.]/g, '-'),
716
+ timestamp: normalizeTimestamp(odt),
717
+ type: EVENT_TYPES['6'],
718
+ typeId: '6',
719
+ subtype: of.elementType === 'agent' ? 'agent_definition' : 'runbook_update',
720
+ title: of.title,
721
+ ref: { doc: of.path },
722
+ impact: 'medium',
723
+ actor: 'hand-agent',
724
+ tags: [],
725
+ };
726
+ oEvent.priority = classifyEventPriority(oEvent);
727
+ if (!isJsonlDuplicate(oEvent)) events.push(oEvent);
728
+ }
729
+
730
+ // ---- Source 6: archive/journal → 审批与交接 (type 5) ----
731
+ var journalFiles = files.filter(function(f) {
732
+ return f.category === 'archive' && f.kind === 'primary';
733
+ });
734
+ for (var ji = 0; ji < journalFiles.length; ji++) {
735
+ var jf = journalFiles[ji];
736
+ var jdt = jf.date || jf.modified;
737
+ var jEvent = {
738
+ id: 'hdl-' + jf.path.replace(/[/.]/g, '-'),
739
+ timestamp: normalizeTimestamp(jdt),
740
+ type: EVENT_TYPES['5'],
741
+ typeId: '5',
742
+ subtype: 'handover',
743
+ title: jf.title,
744
+ ref: { doc: jf.path },
745
+ impact: 'medium',
746
+ actor: 'PM',
747
+ tags: (jf.summary && jf.summary.tags) ? jf.summary.tags : [],
748
+ };
749
+ jEvent.priority = classifyEventPriority(jEvent);
750
+ if (!isJsonlDuplicate(jEvent)) events.push(jEvent);
751
+ }
752
+
753
+ // ---- Prepend JSONL events (Track 1 takes priority, appears first) ----
754
+ for (var ji = jsonlEvents.length - 1; ji >= 0; ji--) {
755
+ events.unshift(jsonlEvents[ji]);
756
+ }
757
+
758
+ // Sort by timestamp descending
759
+ events.sort(function(a, b) { return b.timestamp.localeCompare(a.timestamp); });
760
+
761
+ return events;
762
+ }
763
+
764
+ // ---- Main Timeline Builder ----
765
+ function buildTimeline(files, projectRoot, config) {
766
+ const today = new Date().toISOString().substring(0, 10);
767
+ const timeRules = config ? config.timeRules : null;
768
+ const displayCfg = (config && config.display) ? config.display : {};
769
+ const maxBriefLength = displayCfg.maxBriefLength || 200;
770
+
771
+ // Step 1: Build primary tree from roadmap-category files
772
+ const planFiles = files.filter(f =>
773
+ f.kind === 'primary' && (f.category === 'roadmap' || f.elementType === 'phase' ||
774
+ f.elementType === 'milestone' || f.elementType === 'roadmap' || f.elementType === 'task' || f.elementType === 'subtask')
775
+ );
776
+
777
+ // Also include decision/refactor/conclusion type primary files
778
+ const otherPrimary = files.filter(f =>
779
+ f.kind === 'primary' && !planFiles.includes(f) &&
780
+ (f.elementType === 'decision' || f.elementType === 'conclusion' ||
781
+ f.elementType === 'refactor' || f.category === 'decision')
782
+ );
783
+
784
+ const primaryTree = buildPrimaryTree(planFiles, files, projectRoot);
785
+
786
+ // Build primary flat list for non-plan primary files
787
+ const primaryFlat = otherPrimary
788
+ .filter(f => f.hasSummary || f.elementType !== 'unknown')
789
+ .map((f, i) => ({
790
+ id: 'primary-' + f.path.replace(/[/.]/g, '-'),
791
+ kind: 'primary',
792
+ elementType: f.elementType,
793
+ category: f.category,
794
+ name: f.title,
795
+ status: f.summary ? (f.summary.status || 'completed') : 'completed',
796
+ timeGroup: 'past',
797
+ timeLabel: (f.date || f.modified).substring(0, 10),
798
+ date: f.date || f.modified,
799
+ source: { file: f.path, title: f.title },
800
+ summary: f.summary ? (f.summary.summary || '') : '',
801
+ tags: f.summary ? (f.summary.tags || []) : [],
802
+ related: f.summary ? (f.summary.related || []) : [],
803
+ children: [],
804
+ order: i,
805
+ }));
806
+
807
+ // Step 2: Build secondary flat list
808
+ const secondaryFiles = files.filter(f => f.kind === 'secondary');
809
+ const secondaryList = buildSecondaryList(secondaryFiles);
810
+
811
+ // Step 3: Merge all elements
812
+ const allElements = [...primaryTree, ...primaryFlat, ...secondaryList];
813
+
814
+ // Step 4: Classify every element into timeGroup
815
+ for (const el of allElements) {
816
+ el.timeGroup = classifyTimeGroup(el, timeRules, today);
817
+
818
+ // Also classify children
819
+ if (el.children) {
820
+ for (const child of el.children) {
821
+ child.timeGroup = classifyTimeGroup(child, timeRules, today);
822
+ if (child.children) {
823
+ for (const sub of child.children) {
824
+ sub.timeGroup = classifyTimeGroup(sub, timeRules, today);
825
+ }
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ // Step 5: Build filter type manifest
832
+ const filterTypes = buildFilterTypes(files, displayCfg.secondaryTypesDefaultOff !== false);
833
+
834
+ // Step 6: Build event stream
835
+ const events = buildEvents(files, projectRoot, config);
836
+
837
+ // Step 7: Generate brief
838
+ const brief = generateBrief(files, maxBriefLength);
839
+
840
+ return {
841
+ project: path.basename(projectRoot),
842
+ updated: new Date().toISOString(),
843
+ brief,
844
+ elements: allElements,
845
+ events,
846
+ filterTypes,
847
+ display: {
848
+ secondaryTypesDefaultOff: displayCfg.secondaryTypesDefaultOff !== false,
849
+ conventionsManualOnly: displayCfg.conventionsManualOnly !== false,
850
+ maxBriefLength,
851
+ },
852
+ };
853
+ }
854
+
855
+ // ---- Backward Compatibility: timeline → roadmap.json ----
856
+ function timelineToRoadmapCompat(timeline) {
857
+ const phases = [];
858
+ for (const el of timeline.elements) {
859
+ if (el.kind === 'primary' && el.elementType === 'phase') {
860
+ phases.push({
861
+ id: el.id,
862
+ name: el.name,
863
+ status: el.status,
864
+ order: el.order || phases.length + 1,
865
+ milestones: (el.children || []).map(ch => ({
866
+ id: ch.id,
867
+ name: ch.name,
868
+ status: ch.status,
869
+ owner: ch.source ? ch.source.file : '',
870
+ subtasks: (ch.children || []).map(st => ({
871
+ id: st.id,
872
+ name: st.name,
873
+ status: st.status,
874
+ type: st.elementType || 'hand',
875
+ })),
876
+ })),
877
+ });
878
+ }
879
+ }
880
+ return {
881
+ project: timeline.project,
882
+ updated: timeline.updated,
883
+ brief: timeline.brief,
884
+ phases,
885
+ };
886
+ }
887
+
888
+ // ---- Backward Compatibility: timeline → journal-index.json ----
889
+ function timelineToJournalCompat(timeline) {
890
+ const events = timeline.elements
891
+ .filter(e => e.kind === 'secondary')
892
+ .map(e => ({
893
+ date: e.date || '',
894
+ type: e.elementType,
895
+ title: e.name,
896
+ source: e.source ? e.source.file : '',
897
+ tags: e.tags || [],
898
+ summary: e.summary || '',
899
+ status: e.status || 'unknown',
900
+ related: e.related || [],
901
+ }));
902
+ return { events };
903
+ }
904
+
905
+ // ---- Token Summary ----
906
+ function buildTokenSummary(projectRoot, config) {
907
+ var summary = { totalTokens: 0, totalCalls: 0, totalCost: 0, bySkill: {}, byDate: {} };
908
+ var tokenLogPath = path.join(projectRoot, '.sandtable', 'token-log.jsonl');
909
+
910
+ if (!fs.existsSync(tokenLogPath)) return summary;
911
+
912
+ try {
913
+ var lines = fs.readFileSync(tokenLogPath, 'utf-8').trim().split('\n');
914
+ for (var i = 0; i < lines.length; i++) {
915
+ if (!lines[i].trim()) continue;
916
+ try {
917
+ var entry = JSON.parse(lines[i]);
918
+ var tokens = (entry.tokensIn || 0) + (entry.tokensOut || 0);
919
+ var skill = entry.skill || 'unknown';
920
+ var date = (entry.timestamp || '').substring(0, 10);
921
+
922
+ summary.totalTokens += tokens;
923
+ summary.totalCalls += 1;
924
+ if (entry.cost) summary.totalCost += entry.cost;
925
+
926
+ if (!summary.bySkill[skill]) summary.bySkill[skill] = { tokens: 0, calls: 0 };
927
+ summary.bySkill[skill].tokens += tokens;
928
+ summary.bySkill[skill].calls += 1;
929
+
930
+ if (date) {
931
+ if (!summary.byDate[date]) summary.byDate[date] = { tokens: 0, calls: 0 };
932
+ summary.byDate[date].tokens += tokens;
933
+ summary.byDate[date].calls += 1;
934
+ }
935
+ } catch (e) {
936
+ // Skip malformed JSONL lines
937
+ }
938
+ }
939
+ summary.totalCost = Math.round(summary.totalCost * 10000) / 10000;
940
+ } catch (e) {
941
+ // File read error — return empty summary
942
+ }
943
+ return summary;
944
+ }
945
+
946
+ // ---- Main Build ----
947
+ function build(projectRoot) {
948
+ const scanResult = scan(projectRoot);
949
+ const { files } = scanResult;
950
+ const config = loadConfig(projectRoot);
951
+
952
+ const timeline = buildTimeline(files, projectRoot, config);
953
+ const conventions = buildConventions(files, projectRoot);
954
+ const agents = buildAgents(files, projectRoot);
955
+ const brief = {
956
+ updated: new Date().toISOString(),
957
+ text: timeline.brief,
958
+ };
959
+
960
+ // Backward compat
961
+ const roadmap = timelineToRoadmapCompat(timeline);
962
+ const journalIndex = timelineToJournalCompat(timeline);
963
+
964
+ const dataDir = path.join(projectRoot, 'data');
965
+ if (!fs.existsSync(dataDir)) {
966
+ fs.mkdirSync(dataDir, { recursive: true });
967
+ }
968
+
969
+ // v2 outputs
970
+ fs.writeFileSync(path.join(dataDir, 'timeline.json'), JSON.stringify(timeline, null, 2));
971
+ fs.writeFileSync(path.join(dataDir, 'conventions.json'), JSON.stringify(conventions, null, 2));
972
+ fs.writeFileSync(path.join(dataDir, 'agents.json'), JSON.stringify(agents, null, 2));
973
+ fs.writeFileSync(path.join(dataDir, 'brief.json'), JSON.stringify(brief, null, 2));
974
+
975
+ // v1 backward compat outputs
976
+ fs.writeFileSync(path.join(dataDir, 'roadmap.json'), JSON.stringify(roadmap, null, 2));
977
+ fs.writeFileSync(path.join(dataDir, 'journal-index.json'), JSON.stringify(journalIndex, null, 2));
978
+
979
+ // Token summary from .sandtable/token-log.jsonl + transcript scanning
980
+ var tokenSummary = buildTokenSummary(projectRoot, config);
981
+ fs.writeFileSync(path.join(dataDir, 'token-summary.json'), JSON.stringify(tokenSummary, null, 2));
982
+
983
+ const primaryElements = timeline.elements.filter(e => e.kind === 'primary').length;
984
+ const secondaryElements = timeline.elements.filter(e => e.kind === 'secondary').length;
985
+
986
+ return {
987
+ builtAt: new Date().toISOString(),
988
+ outputs: [
989
+ 'data/timeline.json',
990
+ 'data/conventions.json',
991
+ 'data/agents.json',
992
+ 'data/brief.json',
993
+ 'data/roadmap.json',
994
+ 'data/journal-index.json',
995
+ 'data/token-summary.json',
996
+ ],
997
+ stats: {
998
+ totalFiles: files.length,
999
+ filesWithSummary: files.filter(f => f.hasSummary).length,
1000
+ primaryElements,
1001
+ secondaryElements,
1002
+ filterTypes: timeline.filterTypes.length,
1003
+ agents: agents.roles.length,
1004
+ },
1005
+ };
1006
+ }
1007
+
1008
+ module.exports = {
1009
+ build, buildTimeline, buildPrimaryTree, buildSecondaryList,
1010
+ classifyTimeGroup, buildFilterTypes, buildConventions,
1011
+ buildAgents, timelineToRoadmapCompat, timelineToJournalCompat,
1012
+ EVENT_TYPES, classifyEventPriority, buildTokenSummary,
1013
+ };
1014
+
1015
+ if (require.main === module) {
1016
+ const root = process.argv[2] || process.cwd();
1017
+ const result = build(root);
1018
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1019
+ }