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,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: '&#x1F4DD;',
16
+ decision: '&#x1F508;',
17
+ spec_evolution: '&#x1F4D0;',
18
+ test_quality: '&#x2705;',
19
+ review_handover: '&#x1F91D;',
20
+ ops_event: '&#x2699;',
21
+ lesson: '&#x26A0;',
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] || '&#x1F4C4;';
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) + '">&#x1F4C4; ' + 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) + '">&#x1F517; ' + escapeHtml(evt.ref.commit) + '</span>';
97
+ }
98
+ if (evt.ref.pr) {
99
+ html += '<span class="ref-link pr-ref">&#x1F4AC; ' + 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
+ });