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.
- package/README.md +40 -0
- package/dashboard/css/dashboard.css +731 -0
- package/dashboard/dashboard.html +1086 -0
- package/dashboard/js/conventions-viewer.js +37 -0
- package/dashboard/js/data-loader.js +147 -0
- package/dashboard/js/event-stream-renderer.js +158 -0
- package/dashboard/js/filter-controller.js +102 -0
- package/dashboard/js/journal-timeline.js +29 -0
- package/dashboard/js/roadmap-renderer.js +72 -0
- package/dashboard/js/timeline-renderer.js +283 -0
- package/dashboard/js/waterfall-renderer.js +189 -0
- package/harness/install-hooks.sh +34 -0
- package/harness/post-commit +17 -0
- package/harness/post-merge +17 -0
- package/harness/summary-hook.md +18 -0
- package/package.json +38 -0
- package/server.js +60 -0
- package/skills/build-json.md +36 -0
- package/skills/scan-docs.md +38 -0
- package/skills/summarize.md +66 -0
- package/src/builder/build.js +1019 -0
- package/src/cli/sandtable.js +970 -0
- package/src/scanner/scan.js +415 -0
- package/templates/.sandtable.template.json +51 -0
- package/templates/journal-entry.md +22 -0
- package/templates/summary-block.md +42 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline Renderer — v0.3 category-filtered doc tree + primary/secondary
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
var CATEGORY_LABELS = {
|
|
6
|
+
roadmap: '路线图与进度',
|
|
7
|
+
decision: '决策记录',
|
|
8
|
+
spec: '业务规格',
|
|
9
|
+
convention: '协作纪律',
|
|
10
|
+
ops: '运维与基建',
|
|
11
|
+
archive: '历史档案',
|
|
12
|
+
template: '工具模板',
|
|
13
|
+
unknown: '未分类',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
var PRIMARY_CATEGORIES_SET = new Set(['roadmap', 'decision']);
|
|
17
|
+
|
|
18
|
+
function renderTimeline(timeline, activeCat) {
|
|
19
|
+
var container = document.getElementById('timeline-container');
|
|
20
|
+
if (!container) return;
|
|
21
|
+
|
|
22
|
+
var cat = activeCat || 'all';
|
|
23
|
+
var allElements = timeline.elements || [];
|
|
24
|
+
|
|
25
|
+
// Filter by category
|
|
26
|
+
var filtered = [];
|
|
27
|
+
for (var i = 0; i < allElements.length; i++) {
|
|
28
|
+
var el = allElements[i];
|
|
29
|
+
if (cat !== 'all') {
|
|
30
|
+
var elCat = el.category || normalizeElementCategory(el.elementType);
|
|
31
|
+
// Include element if it matches OR has descendant matching
|
|
32
|
+
if (elCat !== cat && !hasDescendantWithCategory(el, cat)) continue;
|
|
33
|
+
}
|
|
34
|
+
// Primary always passes; secondary always passes (no checkbox filter)
|
|
35
|
+
filtered.push(el);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Group by category
|
|
39
|
+
var groups = {};
|
|
40
|
+
var groupOrder = [];
|
|
41
|
+
for (var i = 0; i < filtered.length; i++) {
|
|
42
|
+
var el = filtered[i];
|
|
43
|
+
var grp = el.category || normalizeElementCategory(el.elementType);
|
|
44
|
+
if (!groups[grp]) { groups[grp] = []; groupOrder.push(grp); }
|
|
45
|
+
groups[grp].push(el);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Sort within each group by date
|
|
49
|
+
for (var k in groups) {
|
|
50
|
+
groups[k].sort(function(a, b) {
|
|
51
|
+
if (a.date && b.date) return b.date.localeCompare(a.date);
|
|
52
|
+
return (b.order || 0) - (a.order || 0);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sort group order: primary first, then secondary, unknown last
|
|
57
|
+
groupOrder.sort(function(a, b) {
|
|
58
|
+
var aP = PRIMARY_CATEGORIES_SET.has(a) ? 0 : 1;
|
|
59
|
+
var bP = PRIMARY_CATEGORIES_SET.has(b) ? 0 : 1;
|
|
60
|
+
if (aP !== bP) return aP - bP;
|
|
61
|
+
if (a === 'unknown') return 1;
|
|
62
|
+
if (b === 'unknown') return -1;
|
|
63
|
+
return 0;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Count total
|
|
67
|
+
var totalCount = 0;
|
|
68
|
+
for (var i = 0; i < groupOrder.length; i++) {
|
|
69
|
+
totalCount += groups[groupOrder[i]].length;
|
|
70
|
+
}
|
|
71
|
+
updateElementCount(totalCount);
|
|
72
|
+
|
|
73
|
+
if (totalCount === 0) {
|
|
74
|
+
container.innerHTML = '<div class="empty-state"><h3>暂无内容</h3><p>选择其他分类查看,或运行 build 刷新数据。</p></div>';
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var html = '<div class="timeline-tree">';
|
|
79
|
+
for (var i = 0; i < groupOrder.length; i++) {
|
|
80
|
+
var g = groupOrder[i];
|
|
81
|
+
var items = groups[g];
|
|
82
|
+
var label = CATEGORY_LABELS[g] || g;
|
|
83
|
+
var isPrimary = PRIMARY_CATEGORIES_SET.has(g);
|
|
84
|
+
|
|
85
|
+
html += '<div class="category-section">' +
|
|
86
|
+
'<div class="category-header ' + (isPrimary ? 'primary-section' : 'secondary-section') + '">' +
|
|
87
|
+
'<span>' + label + '</span>' +
|
|
88
|
+
'<span class="category-count">' + items.length + ' 项</span>' +
|
|
89
|
+
'</div>';
|
|
90
|
+
|
|
91
|
+
for (var j = 0; j < items.length; j++) {
|
|
92
|
+
html += renderElementCard(items[j]);
|
|
93
|
+
}
|
|
94
|
+
html += '</div>';
|
|
95
|
+
}
|
|
96
|
+
html += '</div>';
|
|
97
|
+
container.innerHTML = html;
|
|
98
|
+
|
|
99
|
+
attachDocClickHandlers();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeElementCategory(elementType) {
|
|
103
|
+
var map = {
|
|
104
|
+
phase: 'roadmap', milestone: 'roadmap', task: 'roadmap', subtask: 'roadmap',
|
|
105
|
+
roadmap: 'roadmap', backlog: 'roadmap', conclusion: 'roadmap',
|
|
106
|
+
decision: 'decision', refactor: 'decision',
|
|
107
|
+
spec: 'spec', intent: 'spec', prompt: 'spec',
|
|
108
|
+
convention: 'convention',
|
|
109
|
+
agent: 'ops', runbook: 'ops',
|
|
110
|
+
journal: 'archive', handover: 'archive', plan_doc: 'archive',
|
|
111
|
+
template: 'template',
|
|
112
|
+
};
|
|
113
|
+
return map[elementType] || 'unknown';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasDescendantWithCategory(el, cat) {
|
|
117
|
+
var elCat = el.category || normalizeElementCategory(el.elementType);
|
|
118
|
+
if (elCat === cat) return true;
|
|
119
|
+
if (el.children) {
|
|
120
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
121
|
+
if (hasDescendantWithCategory(el.children[i], cat)) return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderElementCard(el) {
|
|
128
|
+
var sourceHtml = '';
|
|
129
|
+
if (el.source && el.source.file) {
|
|
130
|
+
sourceHtml = '<a class="source-badge" href="/' + escapeHtml(el.source.file) + '" target="_blank" title="' + escapeHtml(el.source.file) + '">' +
|
|
131
|
+
escapeHtml((el.source.title || el.source.file).substring(0, 30)) + '</a>';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
var kindClass = el.kind === 'secondary' ? ' secondary-element' : '';
|
|
135
|
+
var archivedClass = el.timeGroup === 'archived' ? ' archived' : '';
|
|
136
|
+
|
|
137
|
+
var cat = el.category || normalizeElementCategory(el.elementType);
|
|
138
|
+
var unknownBadge = '';
|
|
139
|
+
if (cat === 'unknown') {
|
|
140
|
+
unknownBadge = '<span class="unknown-badge">未分类</span>';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Time badge to indicate past/current/future
|
|
144
|
+
var timeBadge = '';
|
|
145
|
+
if (el.timeGroup === 'past') timeBadge = '<span class="time-badge time-past-badge">历史</span>';
|
|
146
|
+
else if (el.timeGroup === 'current') timeBadge = '<span class="time-badge time-current-badge">进行中</span>';
|
|
147
|
+
else if (el.timeGroup === 'future') timeBadge = '<span class="time-badge time-future-badge">未来</span>';
|
|
148
|
+
|
|
149
|
+
var html = '<div class="element-card' + kindClass + '" data-id="' + escapeHtml(el.id) + '" data-timegroup="' + escapeHtml(el.timeGroup || '') + '" data-doc="' + (el.source && el.source.file ? escapeHtml(el.source.file) : '') + '">' +
|
|
150
|
+
'<div class="element-header ' + (el.status || 'pending') + archivedClass + '">' +
|
|
151
|
+
'<span>' + statusIcon(el.status) + ' ' + escapeHtml(el.name) + '</span>' +
|
|
152
|
+
'<span class="element-meta">' +
|
|
153
|
+
timeBadge +
|
|
154
|
+
unknownBadge +
|
|
155
|
+
'<span class="element-type-tag">' + escapeHtml(el.elementType) + '</span>' +
|
|
156
|
+
sourceHtml +
|
|
157
|
+
'</span>' +
|
|
158
|
+
'</div>';
|
|
159
|
+
|
|
160
|
+
if (el.summary) {
|
|
161
|
+
html += '<div class="element-summary">' + escapeHtml(el.summary) + '</div>';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (el.tags && el.tags.length > 0) {
|
|
165
|
+
html += '<div class="element-tags">';
|
|
166
|
+
for (var i = 0; i < el.tags.length; i++) {
|
|
167
|
+
html += '<span class="tag">' + escapeHtml(el.tags[i]) + '</span>';
|
|
168
|
+
}
|
|
169
|
+
html += '</div>';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Render all children (no timeGroup filtering)
|
|
173
|
+
if (el.children && el.children.length > 0) {
|
|
174
|
+
html += '<div class="element-children">';
|
|
175
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
176
|
+
html += renderSubElement(el.children[i]);
|
|
177
|
+
}
|
|
178
|
+
html += '</div>';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
html += '</div>';
|
|
182
|
+
return html;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderSubElement(el) {
|
|
186
|
+
var sourceHtml = '';
|
|
187
|
+
if (el.source && el.source.file) {
|
|
188
|
+
sourceHtml = '<a class="source-badge" href="/' + escapeHtml(el.source.file) + '" target="_blank" title="' + escapeHtml(el.source.file) + '">' +
|
|
189
|
+
escapeHtml((el.source.title || el.source.file).substring(0, 20)) + '</a>';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Milestone tooltip
|
|
193
|
+
var tooltipHtml = '';
|
|
194
|
+
if (el.elementType === 'milestone' && (el.timeLabel || el.summary)) {
|
|
195
|
+
tooltipHtml = '<span class="milestone-tooltip">' +
|
|
196
|
+
(el.timeLabel ? '<strong>验收信号:</strong>' + escapeHtml(el.timeLabel) + '<br>' : '') +
|
|
197
|
+
(el.summary ? escapeHtml(el.summary) : '') +
|
|
198
|
+
'</span>';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Time badge for sub-elements
|
|
202
|
+
var timeBadge = '';
|
|
203
|
+
if (el.timeGroup === 'past') timeBadge = '<span class="time-badge time-past-badge">历史</span>';
|
|
204
|
+
else if (el.timeGroup === 'current') timeBadge = '<span class="time-badge time-current-badge">进行中</span>';
|
|
205
|
+
else if (el.timeGroup === 'future') timeBadge = '<span class="time-badge time-future-badge">未来</span>';
|
|
206
|
+
|
|
207
|
+
var html = '<div class="sub-element" data-id="' + escapeHtml(el.id) + '" data-doc="' + (el.source && el.source.file ? escapeHtml(el.source.file) : '') + '" style="position:relative">' +
|
|
208
|
+
'<span>' + statusIcon(el.status) + ' ' + escapeHtml(el.name) + '</span>' +
|
|
209
|
+
timeBadge +
|
|
210
|
+
tooltipHtml +
|
|
211
|
+
sourceHtml +
|
|
212
|
+
'</div>';
|
|
213
|
+
|
|
214
|
+
// Render grandchildren
|
|
215
|
+
if (el.children && el.children.length > 0) {
|
|
216
|
+
html += '<div class="element-children" style="margin-left:16px">';
|
|
217
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
218
|
+
html += renderSubElement(el.children[i]);
|
|
219
|
+
}
|
|
220
|
+
html += '</div>';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return html;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function updateElementCount(count) {
|
|
227
|
+
var el = document.getElementById('element-count');
|
|
228
|
+
if (el) el.textContent = count + ' 项';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- Category Tab Setup ----
|
|
232
|
+
function setupCategoryTabs(timeline) {
|
|
233
|
+
var tabs = document.querySelectorAll('#category-tabs .cat-tab');
|
|
234
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
235
|
+
tabs[i].addEventListener('click', function() {
|
|
236
|
+
// Update active state
|
|
237
|
+
var allTabs = document.querySelectorAll('#category-tabs .cat-tab');
|
|
238
|
+
for (var j = 0; j < allTabs.length; j++) {
|
|
239
|
+
allTabs[j].classList.remove('active');
|
|
240
|
+
}
|
|
241
|
+
this.classList.add('active');
|
|
242
|
+
|
|
243
|
+
var cat = this.getAttribute('data-cat');
|
|
244
|
+
renderTimeline(timeline, cat);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- ref 双向跳转 ----
|
|
250
|
+
function attachDocClickHandlers() {
|
|
251
|
+
var cards = document.querySelectorAll('.element-card[data-doc]');
|
|
252
|
+
for (var i = 0; i < cards.length; i++) {
|
|
253
|
+
cards[i].addEventListener('click', function(e) {
|
|
254
|
+
if (e.target.closest('.source-badge')) return;
|
|
255
|
+
var docPath = this.getAttribute('data-doc');
|
|
256
|
+
if (docPath) {
|
|
257
|
+
if (typeof openFloatingPreview === 'function') {
|
|
258
|
+
openFloatingPreview(docPath);
|
|
259
|
+
}
|
|
260
|
+
highlightRelatedEvents(docPath);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function highlightRelatedEvents(docPath) {
|
|
267
|
+
var prev = document.querySelectorAll('.event-item.highlighted');
|
|
268
|
+
for (var i = 0; i < prev.length; i++) {
|
|
269
|
+
prev[i].classList.remove('highlighted');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
var items = document.querySelectorAll('.event-item .doc-ref[data-doc]');
|
|
273
|
+
for (var i = 0; i < items.length; i++) {
|
|
274
|
+
var itemDoc = items[i].getAttribute('data-doc');
|
|
275
|
+
if (itemDoc === docPath) {
|
|
276
|
+
var eventItem = items[i].closest('.event-item');
|
|
277
|
+
if (eventItem) {
|
|
278
|
+
eventItem.classList.add('highlighted');
|
|
279
|
+
eventItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waterfall Renderer — §11 7 大事件类 event stream
|
|
3
|
+
*/
|
|
4
|
+
var EVENT_COLORS = {
|
|
5
|
+
code_change: '#569CD6',
|
|
6
|
+
decision: '#4EC9B0',
|
|
7
|
+
spec_evolution: '#CE9178',
|
|
8
|
+
test_quality: '#C586C0',
|
|
9
|
+
review_handover: '#DCDCAA',
|
|
10
|
+
ops_event: '#F44747',
|
|
11
|
+
lesson: '#D7BA7D',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
var EVENT_ICONS = {
|
|
15
|
+
code_change: '📝',
|
|
16
|
+
decision: '🔈',
|
|
17
|
+
spec_evolution: '📐',
|
|
18
|
+
test_quality: '✅',
|
|
19
|
+
review_handover: '🤝',
|
|
20
|
+
ops_event: '⚙',
|
|
21
|
+
lesson: '⚠',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
var EVENT_LABELS = {
|
|
25
|
+
code_change: '代码变更',
|
|
26
|
+
decision: '对齐拍板',
|
|
27
|
+
spec_evolution: '规格演进',
|
|
28
|
+
test_quality: '测试质量',
|
|
29
|
+
review_handover: '审批交接',
|
|
30
|
+
ops_event: '运维事件',
|
|
31
|
+
lesson: '教训沉淀',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 5 "must-record" events — always highlighted, never collapsible
|
|
35
|
+
var MUST_RECORD_TYPES = new Set(['decision', 'spec_evolution']);
|
|
36
|
+
|
|
37
|
+
function renderWaterfall(timeline) {
|
|
38
|
+
var container = document.getElementById('waterfall-container');
|
|
39
|
+
if (!container) return;
|
|
40
|
+
|
|
41
|
+
var events = (timeline && timeline.events) ? timeline.events.slice() : [];
|
|
42
|
+
|
|
43
|
+
// Fallback: if no events, render elements as simple waterfall
|
|
44
|
+
if (events.length === 0) {
|
|
45
|
+
renderWaterfallFromElements(timeline);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sort by timestamp descending
|
|
50
|
+
events.sort(function(a, b) { return b.timestamp.localeCompare(a.timestamp); });
|
|
51
|
+
|
|
52
|
+
if (events.length === 0) {
|
|
53
|
+
container.innerHTML = '<div class="empty-state"><h3>暂无事件记录</h3><p>运行 build 后,git log 与 doc 时间线将合并为事件流显示于此。</p></div>';
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var html = '<div class="waterfall">';
|
|
58
|
+
var currentDate = '';
|
|
59
|
+
|
|
60
|
+
for (var i = 0; i < events.length; i++) {
|
|
61
|
+
var evt = events[i];
|
|
62
|
+
var dateStr = evt.timestamp.substring(0, 10);
|
|
63
|
+
|
|
64
|
+
// Date header
|
|
65
|
+
if (dateStr !== currentDate) {
|
|
66
|
+
currentDate = dateStr;
|
|
67
|
+
html += '<div class="waterfall-date-header">' + escapeHtml(dateStr) + '</div>';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var color = EVENT_COLORS[evt.type] || '#999';
|
|
71
|
+
var icon = EVENT_ICONS[evt.type] || '📄';
|
|
72
|
+
var label = EVENT_LABELS[evt.type] || evt.type;
|
|
73
|
+
var isMustRecord = MUST_RECORD_TYPES.has(evt.type);
|
|
74
|
+
var mustClass = isMustRecord ? ' must-record' : '';
|
|
75
|
+
|
|
76
|
+
html += '<div class="waterfall-event' + mustClass + '" style="border-left-color:' + color + '" data-ref-doc="' + (evt.ref && evt.ref.doc ? escapeHtml(evt.ref.doc) : '') + '">' +
|
|
77
|
+
'<div class="waterfall-date">' +
|
|
78
|
+
'<span class="event-icon" style="color:' + color + '">' + icon + '</span>' +
|
|
79
|
+
escapeHtml(dateStr) +
|
|
80
|
+
' <span class="event-time">' + escapeHtml(evt.timestamp.substring(11, 16)) + '</span>' +
|
|
81
|
+
'</div>' +
|
|
82
|
+
'<div class="waterfall-body">' +
|
|
83
|
+
'<div class="waterfall-title">' +
|
|
84
|
+
'<span class="kind-badge" style="background:' + color + '20;color:' + color + '">' + label + '</span>' +
|
|
85
|
+
(evt.subtype ? '<span class="subtype-tag">' + escapeHtml(evt.subtype) + '</span>' : '') +
|
|
86
|
+
escapeHtml(evt.title) +
|
|
87
|
+
'</div>';
|
|
88
|
+
|
|
89
|
+
// Ref links
|
|
90
|
+
if (evt.ref) {
|
|
91
|
+
html += '<div class="waterfall-refs">';
|
|
92
|
+
if (evt.ref.doc) {
|
|
93
|
+
html += '<a class="ref-link doc-ref" href="/' + escapeHtml(evt.ref.doc) + '" target="_blank" title="' + escapeHtml(evt.ref.doc) + '">📄 ' + escapeHtml(evt.ref.doc.substring(evt.ref.doc.lastIndexOf('/') + 1)) + '</a>';
|
|
94
|
+
}
|
|
95
|
+
if (evt.ref.commit) {
|
|
96
|
+
html += '<span class="ref-link commit-ref" title="' + escapeHtml(evt.ref.commit) + '">🔗 ' + escapeHtml(evt.ref.commit) + '</span>';
|
|
97
|
+
}
|
|
98
|
+
if (evt.ref.pr) {
|
|
99
|
+
html += '<span class="ref-link pr-ref">💬 ' + escapeHtml(evt.ref.pr) + '</span>';
|
|
100
|
+
}
|
|
101
|
+
html += '</div>';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Impact + actor
|
|
105
|
+
if (evt.impact && evt.impact !== 'low') {
|
|
106
|
+
html += '<span class="impact-badge impact-' + evt.impact + '">' + (evt.impact === 'high' ? '高影响' : '中影响') + '</span>';
|
|
107
|
+
}
|
|
108
|
+
if (evt.actor) {
|
|
109
|
+
html += '<span class="actor-badge">' + escapeHtml(evt.actor) + '</span>';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Tags
|
|
113
|
+
if (evt.tags && evt.tags.length > 0) {
|
|
114
|
+
html += '<div class="event-tags">';
|
|
115
|
+
for (var j = 0; j < evt.tags.length; j++) {
|
|
116
|
+
html += '<span class="tag">' + escapeHtml(evt.tags[j]) + '</span>';
|
|
117
|
+
}
|
|
118
|
+
html += '</div>';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
html += '</div></div>';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
html += '</div>';
|
|
125
|
+
container.innerHTML = html;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fallback: render elements when no events available
|
|
129
|
+
function renderWaterfallFromElements(timeline) {
|
|
130
|
+
var container = document.getElementById('waterfall-container');
|
|
131
|
+
if (!container) return;
|
|
132
|
+
|
|
133
|
+
var allElements = (timeline && timeline.elements || []).slice();
|
|
134
|
+
|
|
135
|
+
var flat = [];
|
|
136
|
+
function flatten(arr) {
|
|
137
|
+
for (var i = 0; i < arr.length; i++) {
|
|
138
|
+
flat.push(arr[i]);
|
|
139
|
+
if (arr[i].children && arr[i].children.length > 0) {
|
|
140
|
+
flatten(arr[i].children);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
flatten(allElements);
|
|
145
|
+
|
|
146
|
+
flat.sort(function(a, b) {
|
|
147
|
+
if (a.date && b.date) return b.date.localeCompare(a.date);
|
|
148
|
+
return 0;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (flat.length === 0) {
|
|
152
|
+
container.innerHTML = '<div class="empty-state"><h3>暂无记录</h3><p>运行 build 后,所有元素将按时间序显示于此。</p></div>';
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
var html = '<div class="waterfall">';
|
|
157
|
+
for (var i = 0; i < flat.length; i++) {
|
|
158
|
+
var el = flat[i];
|
|
159
|
+
var cat = el.category || 'unknown';
|
|
160
|
+
var color = EVENT_COLORS.code_change;
|
|
161
|
+
if (cat === 'decision') color = EVENT_COLORS.decision;
|
|
162
|
+
else if (cat === 'archive') color = EVENT_COLORS.lesson;
|
|
163
|
+
else if (cat === 'convention') color = EVENT_COLORS.test_quality;
|
|
164
|
+
|
|
165
|
+
html += '<div class="waterfall-event" style="border-left-color:' + color + '" data-ref-doc="' + (el.source && el.source.file ? escapeHtml(el.source.file) : '') + '">' +
|
|
166
|
+
'<div class="waterfall-date">' + escapeHtml(el.date || '--') + '</div>' +
|
|
167
|
+
'<div class="waterfall-body">' +
|
|
168
|
+
'<div class="waterfall-title">' +
|
|
169
|
+
'<span class="kind-badge ' + el.kind + '" style="background:' + color + '20;color:' + color + '">' + (el.kind === 'primary' ? '主线' : '次要') + '</span>' +
|
|
170
|
+
escapeHtml(el.name) +
|
|
171
|
+
'</div>';
|
|
172
|
+
|
|
173
|
+
if (el.source && el.source.file) {
|
|
174
|
+
html += '<div class="waterfall-source">源文档:<a href="/' + escapeHtml(el.source.file) + '" target="_blank"><code>' + escapeHtml(el.source.file) + '</code></a></div>';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (el.summary) {
|
|
178
|
+
html += '<div class="waterfall-summary">' + escapeHtml(el.summary) + '</div>';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (el.related && el.related.length > 0) {
|
|
182
|
+
html += '<div class="waterfall-related">关联:' + escapeHtml(el.related.join(', ')) + '</div>';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
html += '</div></div>';
|
|
186
|
+
}
|
|
187
|
+
html += '</div>';
|
|
188
|
+
container.innerHTML = html;
|
|
189
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Install sandtable git hooks into the current repo
|
|
3
|
+
# Usage: sh harness/install-hooks.sh [project-root]
|
|
4
|
+
|
|
5
|
+
ROOT="${1:-$(pwd)}"
|
|
6
|
+
HOOKS_DIR="$ROOT/.git/hooks"
|
|
7
|
+
|
|
8
|
+
if [ ! -d "$HOOKS_DIR" ]; then
|
|
9
|
+
echo "Error: not a git repository (no .git/hooks)"
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
cp harness/post-commit "$HOOKS_DIR/post-commit.sandtable"
|
|
14
|
+
cp harness/post-merge "$HOOKS_DIR/post-merge.sandtable"
|
|
15
|
+
chmod +x "$HOOKS_DIR/post-commit.sandtable"
|
|
16
|
+
chmod +x "$HOOKS_DIR/post-merge.sandtable"
|
|
17
|
+
|
|
18
|
+
# Append to existing hook or create wrapper
|
|
19
|
+
for hook in post-commit post-merge; do
|
|
20
|
+
HOOK_PATH="$HOOKS_DIR/$hook"
|
|
21
|
+
SANDBOX_PATH="$HOOKS_DIR/$hook.sandtable"
|
|
22
|
+
|
|
23
|
+
if [ -f "$HOOK_PATH" ]; then
|
|
24
|
+
if ! grep -q "sandtable" "$HOOK_PATH" 2>/dev/null; then
|
|
25
|
+
echo "" >> "$HOOK_PATH"
|
|
26
|
+
echo "# Sandtable auto rebuild" >> "$HOOK_PATH"
|
|
27
|
+
echo "sh $SANDBOX_PATH" >> "$HOOK_PATH"
|
|
28
|
+
fi
|
|
29
|
+
else
|
|
30
|
+
ln -sf "$SANDBOX_PATH" "$HOOK_PATH" 2>/dev/null || cp "$SANDBOX_PATH" "$HOOK_PATH"
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
echo "Sandtable hooks installed: $HOOKS_DIR/post-commit, post-merge"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sandtable post-commit hook — auto rebuild on each commit
|
|
3
|
+
# Install: copy to .git/hooks/post-commit and chmod +x
|
|
4
|
+
|
|
5
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
6
|
+
SANDBIN=$(which sandtable 2>/dev/null)
|
|
7
|
+
if [ -z "$SANDBIN" ]; then
|
|
8
|
+
SANDBIN="npx sandtable"
|
|
9
|
+
fi
|
|
10
|
+
# Local dev fallback
|
|
11
|
+
if ! $SANDBIN --version >/dev/null 2>&1 && [ -f "$ROOT/src/cli/sandtable.js" ]; then
|
|
12
|
+
SANDBIN="node $ROOT/src/cli/sandtable.js"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
echo "[sandtable] post-commit hook: auto rebuild..."
|
|
16
|
+
$SANDBIN build "$ROOT" 2>&1 || true
|
|
17
|
+
echo "[sandtable] done."
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Sandtable post-merge hook — auto rebuild after merge/pull
|
|
3
|
+
# Install: copy to .git/hooks/post-merge and chmod +x
|
|
4
|
+
|
|
5
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
6
|
+
SANDBIN=$(which sandtable 2>/dev/null)
|
|
7
|
+
if [ -z "$SANDBIN" ]; then
|
|
8
|
+
SANDBIN="npx sandtable"
|
|
9
|
+
fi
|
|
10
|
+
# Local dev fallback
|
|
11
|
+
if ! $SANDBIN --version >/dev/null 2>&1 && [ -f "$ROOT/src/cli/sandtable.js" ]; then
|
|
12
|
+
SANDBIN="node $ROOT/src/cli/sandtable.js"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
echo "[sandtable] post-merge hook: auto rebuild..."
|
|
16
|
+
$SANDBIN build "$ROOT" 2>&1 || true
|
|
17
|
+
echo "[sandtable] done."
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# 摘要块 Harness 预埋指令
|
|
2
|
+
|
|
3
|
+
将以下指令添加到项目的 Claude Code settings.local.json 的 hooks 中:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"hooks": {
|
|
8
|
+
"SessionStart": [
|
|
9
|
+
{
|
|
10
|
+
"matcher": "",
|
|
11
|
+
"command": "echo '本会话如有产出 .md 文档,请在文件头部添加 SUMMARY 摘要块(格式见 templates/summary-block.md)'"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> MVP 阶段不自动执行,作为手动参考。
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sandtable",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "AI 编程项目可视化指挥面板 — 双视图 dashboard,多源数据融合",
|
|
5
|
+
"main": "src/cli/sandtable.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sandtable": "src/cli/sandtable.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"dashboard/",
|
|
12
|
+
"templates/",
|
|
13
|
+
"skills/",
|
|
14
|
+
"harness/",
|
|
15
|
+
"server.js"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"scan": "node src/cli/sandtable.js scan",
|
|
19
|
+
"build": "node src/cli/sandtable.js build",
|
|
20
|
+
"serve": "node src/cli/sandtable.js serve",
|
|
21
|
+
"summarize": "node src/cli/sandtable.js summarize"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"dashboard",
|
|
26
|
+
"visualization",
|
|
27
|
+
"project-management",
|
|
28
|
+
"coding-assistant"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/anthropics/sandtable"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const ROOT = path.resolve(__dirname);
|
|
6
|
+
const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 3000;
|
|
7
|
+
|
|
8
|
+
const MIME = {
|
|
9
|
+
'.html': 'text/html; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
12
|
+
'.json': 'application/json; charset=utf-8',
|
|
13
|
+
'.jsonl': 'text/plain; charset=utf-8',
|
|
14
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Only serve from these two directories — project sources are not exposed
|
|
20
|
+
const ALLOWED_PREFIXES = [
|
|
21
|
+
path.join(ROOT, 'dashboard'),
|
|
22
|
+
path.join(ROOT, 'data'),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isAllowed(filePath) {
|
|
26
|
+
if (!filePath.startsWith(ROOT)) return false;
|
|
27
|
+
for (const prefix of ALLOWED_PREFIXES) {
|
|
28
|
+
if (filePath.startsWith(prefix)) return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
http.createServer((req, res) => {
|
|
34
|
+
let url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
|
|
35
|
+
url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
|
|
36
|
+
|
|
37
|
+
const filePath = path.join(ROOT, url);
|
|
38
|
+
|
|
39
|
+
if (!isAllowed(filePath)) {
|
|
40
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
41
|
+
return res.end('403 Forbidden');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ext = path.extname(filePath);
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
47
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'text/plain' });
|
|
48
|
+
res.end(fs.readFileSync(filePath));
|
|
49
|
+
} else {
|
|
50
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
51
|
+
res.end('404 Not Found');
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
55
|
+
res.end('500: ' + e.message);
|
|
56
|
+
}
|
|
57
|
+
}).listen(PORT, () => {
|
|
58
|
+
console.log('Sandtable: http://localhost:' + PORT);
|
|
59
|
+
console.log('Project:', ROOT);
|
|
60
|
+
});
|