sandtable 0.3.1 → 0.4.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.
@@ -285,10 +285,12 @@ body.filter-active #left{border-right-color:#d5dce6}
285
285
  <div id="tree-tabs">
286
286
  <button class="on" data-c="all">全部</button>
287
287
  <button data-c="roadmap">路线图</button>
288
+ <button data-c="todo">待办</button>
288
289
  <button data-c="decision">决策</button>
289
290
  <button data-c="spec">规格</button>
290
291
  <button data-c="convention">纪律</button>
291
292
  <button data-c="ops">运维</button>
293
+
292
294
  <button data-c="archive">档案</button>
293
295
  </div>
294
296
  <div id="tree"></div>
@@ -358,9 +360,9 @@ function toggleTagCat(cn){
358
360
  if(expandedTagCats[cn]){expandedTagCats[cn]=false}else{expandedTagCats[cn]=true}
359
361
  renderTagBar();
360
362
  }
361
- var CLABEL = {roadmap:'路线图与进度',decision:'决策记录',spec:'业务规格',convention:'协作纪律',ops:'运维与基建',archive:'历史档案',template:'工具模板',unknown:'未分类'};
362
- var PRIMARY = {roadmap:1,decision:1};
363
- var ETYPE = {phase:'roadmap',milestone:'roadmap',task:'roadmap',subtask:'roadmap',roadmap:'roadmap',backlog:'roadmap',conclusion:'roadmap',decision:'decision',refactor:'decision',spec:'spec',intent:'spec',prompt:'spec',convention:'convention',agent:'ops',runbook:'ops',journal:'archive',handover:'archive',plan_doc:'archive',template:'template'};
363
+ var CLABEL = {roadmap:'路线图与进度',todo:'待办清单',decision:'决策记录',spec:'业务规格',convention:'协作纪律',ops:'运维与基建',archive:'历史档案',template:'工具模板',unknown:'未分类'};
364
+ var PRIMARY = {roadmap:1,decision:1,todo:1};
365
+ var ETYPE = {phase:'roadmap',milestone:'roadmap',task:'roadmap',subtask:'roadmap',roadmap:'roadmap',backlog:'todo',todo:'todo',conclusion:'roadmap',decision:'decision',refactor:'decision',spec:'spec',intent:'spec',prompt:'spec',convention:'convention',agent:'ops',runbook:'ops',optimization:'roadmap',journal:'archive',handover:'archive',plan_doc:'archive',template:'template'};
364
366
  function normCat(c){if(!c)return'unknown';var m={conventions:'convention',specs:'spec',decisions:'decision',plans:'roadmap',journals:'archive',journal:'archive'};return m[c]||c}
365
367
  function normTg(tg){if(!tg||tg==='archived')return'past';return tg}
366
368
 
@@ -1,17 +1,53 @@
1
1
  #!/bin/sh
2
2
  # Install sandtable git hooks into the current repo
3
- # Usage: sh harness/install-hooks.sh [project-root]
3
+ # Usage: sh harness/install-hooks.sh [project-root] [--force]
4
+
5
+ ROOT=""
6
+ FORCE=0
7
+ for arg in "$@"; do
8
+ case "$arg" in
9
+ --force) FORCE=1 ;;
10
+ *) ROOT="$arg" ;;
11
+ esac
12
+ done
13
+ ROOT="${ROOT:-$(pwd)}"
4
14
 
5
- ROOT="${1:-$(pwd)}"
6
15
  HOOKS_DIR="$ROOT/.git/hooks"
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
17
 
8
18
  if [ ! -d "$HOOKS_DIR" ]; then
9
19
  echo "Error: not a git repository (no .git/hooks)"
10
20
  exit 1
11
21
  fi
12
22
 
13
- cp harness/post-commit "$HOOKS_DIR/post-commit.sandtable"
14
- cp harness/post-merge "$HOOKS_DIR/post-merge.sandtable"
23
+ # ---- Conflict detection: check for existing non-sandtable hooks ----
24
+ CONFLICTS=""
25
+ for hook in post-commit post-merge; do
26
+ HOOK_PATH="$HOOKS_DIR/$hook"
27
+ if [ -f "$HOOK_PATH" ] && ! grep -q "sandtable" "$HOOK_PATH" 2>/dev/null; then
28
+ CONFLICTS="$CONFLICTS $hook"
29
+ fi
30
+ done
31
+
32
+ if [ -n "$CONFLICTS" ] && [ "$FORCE" != "1" ]; then
33
+ echo "Error: 已有非 sandtable hook 存在:$CONFLICTS"
34
+ echo ""
35
+ echo "sandtable 不会覆盖已有 hook,以免破坏其他工具链。选项:"
36
+ echo " 1. 手动合并: 在现有 hook 末尾添加以下行:"
37
+ echo " sh .git/hooks/<hook>.sandtable"
38
+ echo " 2. 强制覆盖: sh $(basename "$0") --force"
39
+ echo ""
40
+ echo "已有 hook 内容预览:"
41
+ for hook in $CONFLICTS; do
42
+ echo " --- $hook ---"
43
+ sed 's/^/ | /' "$HOOKS_DIR/$hook"
44
+ echo " -------------"
45
+ done
46
+ exit 1
47
+ fi
48
+
49
+ cp "$SCRIPT_DIR/post-commit" "$HOOKS_DIR/post-commit.sandtable"
50
+ cp "$SCRIPT_DIR/post-merge" "$HOOKS_DIR/post-merge.sandtable"
15
51
  chmod +x "$HOOKS_DIR/post-commit.sandtable"
16
52
  chmod +x "$HOOKS_DIR/post-merge.sandtable"
17
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandtable",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "AI 编程项目可视化指挥面板 — 双视图 dashboard,多源数据融合",
5
5
  "main": "src/cli/sandtable.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -5,6 +5,26 @@ const path = require('path');
5
5
  const ROOT = path.resolve(__dirname);
6
6
  const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 5199;
7
7
 
8
+ // Parse --host and --token from CLI args (safe by default: 127.0.0.1)
9
+ let HOST = '127.0.0.1';
10
+ let TOKEN = '';
11
+ for (let i = 0; i < process.argv.length; i++) {
12
+ if (process.argv[i] === '--host' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) {
13
+ HOST = process.argv[i + 1];
14
+ i++;
15
+ } else if (process.argv[i] === '--token') {
16
+ if (process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) {
17
+ TOKEN = process.argv[i + 1];
18
+ i++;
19
+ } else {
20
+ TOKEN = require('crypto').randomBytes(16).toString('hex');
21
+ }
22
+ }
23
+ }
24
+ if ((HOST === '0.0.0.0' || HOST === '::') && !TOKEN) {
25
+ TOKEN = require('crypto').randomBytes(16).toString('hex');
26
+ }
27
+
8
28
  const MIME = {
9
29
  '.html': 'text/html; charset=utf-8',
10
30
  '.css': 'text/css; charset=utf-8',
@@ -31,6 +51,27 @@ function isAllowed(filePath) {
31
51
  }
32
52
 
33
53
  http.createServer((req, res) => {
54
+ // Token authentication (when binding to public interface)
55
+ if (TOKEN) {
56
+ let qIdx = req.url.indexOf('?');
57
+ let hasToken = false;
58
+ if (qIdx !== -1) {
59
+ let qs = req.url.substring(qIdx + 1);
60
+ let pairs = qs.split('&');
61
+ for (let pi = 0; pi < pairs.length; pi++) {
62
+ let kv = pairs[pi].split('=');
63
+ if (decodeURIComponent(kv[0]) === 'token' && kv[1] === TOKEN) {
64
+ hasToken = true;
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ if (!hasToken) {
70
+ res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
71
+ return res.end('401 Unauthorized — 请在 URL 后添加 ?token=' + TOKEN);
72
+ }
73
+ }
74
+
34
75
  let url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
35
76
  url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
36
77
 
@@ -54,7 +95,8 @@ http.createServer((req, res) => {
54
95
  res.writeHead(500, { 'Content-Type': 'text/plain' });
55
96
  res.end('500: ' + e.message);
56
97
  }
57
- }).listen(PORT, () => {
58
- console.log('Sandtable: http://localhost:' + PORT);
98
+ }).listen(PORT, HOST, () => {
99
+ console.log('Sandtable: http://' + HOST + ':' + PORT);
100
+ if (TOKEN) console.log('Token: ' + TOKEN + ' (访问需携带 ?token=' + TOKEN + ')');
59
101
  console.log('Project:', ROOT);
60
102
  });
@@ -8,7 +8,7 @@ const { exec } = require('child_process');
8
8
  const { scan } = require('../scanner/scan');
9
9
  const { build, EVENT_TYPES, classifyEventPriority } = require('../builder/build');
10
10
 
11
- const VERSION = '0.3.1';
11
+ const VERSION = '0.4.0';
12
12
  const command = process.argv[2] || 'help';
13
13
  const root = process.argv[3] || process.cwd();
14
14
 
@@ -279,6 +279,79 @@ function removeFromMainDoc(filePath, dryRun) {
279
279
  return true;
280
280
  }
281
281
 
282
+ // ---- appendToGitignore: 自动追加 sandtable 条目到 .gitignore ----
283
+ // 使用 # sandtable:begin/end 标记包裹,uninstall 时精确擦除
284
+ function appendToGitignore(projectRoot, dryRun) {
285
+ var gitignorePath = path.join(projectRoot, '.gitignore');
286
+ var beginMark = '# sandtable:begin';
287
+ var endMark = '# sandtable:end';
288
+ var existing = '';
289
+ if (fs.existsSync(gitignorePath)) {
290
+ existing = fs.readFileSync(gitignorePath, 'utf-8');
291
+ if (existing.indexOf(beginMark) !== -1) {
292
+ if (!dryRun) console.log(' - .gitignore 已有 sandtable 标记,跳过');
293
+ return false;
294
+ }
295
+ }
296
+
297
+ var block = [
298
+ '',
299
+ beginMark,
300
+ '# Sandtable 自动生成条目 — 请勿手动编辑此块',
301
+ 'data/',
302
+ '.sandtable/token-log.jsonl',
303
+ '.sandtable/audit.log',
304
+ '.sandtable/.token',
305
+ endMark,
306
+ '',
307
+ ].join('\n');
308
+
309
+ if (dryRun) {
310
+ console.log(' [DRY-RUN] 将追加到 .gitignore:');
311
+ console.log(' --- diff ---');
312
+ console.log(' + ' + block.replace(/\n/g, '\n + '));
313
+ console.log(' --- end ---');
314
+ return true;
315
+ }
316
+
317
+ writeWithBackup(gitignorePath, existing + block);
318
+ return true;
319
+ }
320
+
321
+ // ---- removeFromGitignore: uninstall 时擦除 sandtable gitignore 块 ----
322
+ function removeFromGitignore(projectRoot, dryRun) {
323
+ var gitignorePath = path.join(projectRoot, '.gitignore');
324
+ var beginMark = '# sandtable:begin';
325
+ var endMark = '# sandtable:end';
326
+
327
+ if (!fs.existsSync(gitignorePath)) return false;
328
+
329
+ var content = fs.readFileSync(gitignorePath, 'utf-8');
330
+ var beginIdx = content.indexOf(beginMark);
331
+ var endIdx = content.indexOf(endMark);
332
+
333
+ if (beginIdx === -1 || endIdx === -1) return false;
334
+
335
+ var beforeBegin = content.lastIndexOf('\n', beginIdx);
336
+ if (beforeBegin === -1) beforeBegin = 0;
337
+
338
+ var afterEnd = endIdx + endMark.length;
339
+ if (content[afterEnd] === '\n') afterEnd++;
340
+ if (content[afterEnd] === '\n') afterEnd++; // consume trailing blank line
341
+
342
+ if (dryRun) {
343
+ console.log(' [DRY-RUN] 将从 .gitignore 移除 sandtable 块:');
344
+ console.log(' --- to remove ---');
345
+ console.log(content.substring(beforeBegin, afterEnd).replace(/^/gm, ' - '));
346
+ console.log(' --- end ---');
347
+ return true;
348
+ }
349
+
350
+ var cleaned = content.substring(0, beforeBegin) + content.substring(afterEnd);
351
+ writeWithBackup(gitignorePath, cleaned);
352
+ return true;
353
+ }
354
+
282
355
  // ---- writeClaudeCodeHook: 创建 SessionStart 强提醒 hook ----
283
356
  function writeClaudeCodeHook(projectRoot, lang) {
284
357
  var hooksDir = path.join(projectRoot, '.claude', 'hooks');
@@ -432,7 +505,7 @@ function initCommand(projectRoot, applyMode, lang, hooksMode, injectAgentsMd) {
432
505
 
433
506
  // 2. Write .sandtable/config.json
434
507
  var config = {
435
- version: '0.3.1',
508
+ version: '0.4.0',
436
509
  createdAt: new Date().toISOString(),
437
510
  projectRoot: projectRoot,
438
511
  paths: {
@@ -510,6 +583,13 @@ function initCommand(projectRoot, applyMode, lang, hooksMode, injectAgentsMd) {
510
583
  console.log(' ✓ 安装 SessionStart 强提醒 hook → .claude/hooks/sandtable-reminder.sh');
511
584
  }
512
585
 
586
+ // 8. Append sandtable entries to .gitignore (auto, always)
587
+ var gitignoreDryRun = !applyMode;
588
+ appendToGitignore(projectRoot, gitignoreDryRun);
589
+ if (applyMode && fs.existsSync(path.join(projectRoot, '.gitignore'))) {
590
+ console.log(' ✓ 追加 sandtable 条目至 .gitignore');
591
+ }
592
+
513
593
  console.log('');
514
594
  console.log('✓ 初始化完成。运行 sandtable build 生成数据,然后 sandtable serve 查看仪表盘。');
515
595
 
@@ -592,7 +672,9 @@ function enableWatchMode(projectRoot) {
592
672
  }
593
673
  }
594
674
 
595
- function startServer(projectRoot, port, openBrowser, watchMode) {
675
+ function startServer(projectRoot, port, openBrowser, watchMode, host, token) {
676
+ host = host || '127.0.0.1';
677
+ token = token || '';
596
678
  const MIME = {
597
679
  '.html': 'text/html; charset=utf-8',
598
680
  '.css': 'text/css; charset=utf-8',
@@ -629,6 +711,27 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
629
711
  }
630
712
 
631
713
  http.createServer(function(req, res) {
714
+ // Token authentication (when binding to public interface)
715
+ if (token) {
716
+ var qIdx = req.url.indexOf('?');
717
+ var hasToken = false;
718
+ if (qIdx !== -1) {
719
+ var qs = req.url.substring(qIdx + 1);
720
+ var pairs = qs.split('&');
721
+ for (var pi = 0; pi < pairs.length; pi++) {
722
+ var kv = pairs[pi].split('=');
723
+ if (decodeURIComponent(kv[0]) === 'token' && kv[1] === token) {
724
+ hasToken = true;
725
+ break;
726
+ }
727
+ }
728
+ }
729
+ if (!hasToken) {
730
+ res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
731
+ return res.end('401 Unauthorized — 请在 URL 后添加 ?token=' + token);
732
+ }
733
+ }
734
+
632
735
  var url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
633
736
  url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
634
737
 
@@ -666,9 +769,12 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
666
769
  res.writeHead(500, { 'Content-Type': 'text/plain' });
667
770
  res.end('500: ' + e.message);
668
771
  }
669
- }).listen(port, function() {
670
- console.log('Sandtable v' + VERSION + ' — http://localhost:' + port);
772
+ }).listen(port, host, function() {
773
+ console.log('Sandtable v' + VERSION + ' — http://' + host + ':' + port);
671
774
  console.log('Project: ' + projectRoot);
775
+ if (token) {
776
+ console.log('Token: ' + token + ' (访问需携带 ?token=' + token + ')');
777
+ }
672
778
 
673
779
  if (watchMode) enableWatchMode(projectRoot);
674
780
 
@@ -731,7 +837,40 @@ switch (command) {
731
837
  case 'serve': {
732
838
  var port = parseInt(process.argv[3], 10) || 5199;
733
839
  var watchMode = process.argv.indexOf('--watch') !== -1;
734
- startServer(process.cwd(), port, true, watchMode);
840
+
841
+ // Parse --host (default: 127.0.0.1, safe by default)
842
+ var hostIdx = process.argv.indexOf('--host');
843
+ var host = '127.0.0.1';
844
+ if (hostIdx !== -1 && process.argv[hostIdx + 1] && process.argv[hostIdx + 1].indexOf('--') !== 0) {
845
+ host = process.argv[hostIdx + 1];
846
+ }
847
+
848
+ // Parse --token (required when binding to 0.0.0.0)
849
+ var tokenIdx = process.argv.indexOf('--token');
850
+ var token = '';
851
+ var tokenPath = path.join(process.cwd(), '.sandtable', '.token');
852
+ if (tokenIdx !== -1) {
853
+ var nextArg = process.argv[tokenIdx + 1];
854
+ if (nextArg && nextArg.indexOf('--') !== 0) {
855
+ token = nextArg;
856
+ // Persist user-provided token
857
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
858
+ } else {
859
+ token = require('crypto').randomBytes(16).toString('hex');
860
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
861
+ }
862
+ }
863
+ if ((host === '0.0.0.0' || host === '::') && !token) {
864
+ // Try to load persisted token first
865
+ try { if (fs.existsSync(tokenPath)) token = fs.readFileSync(tokenPath, 'utf-8').trim(); } catch(e) {}
866
+ if (!token) {
867
+ token = require('crypto').randomBytes(16).toString('hex');
868
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
869
+ }
870
+ console.log('⚠ 绑定 ' + host + ' 将暴露服务到网络,token: ' + token);
871
+ }
872
+
873
+ startServer(process.cwd(), port, true, watchMode, host, token);
735
874
  break;
736
875
  }
737
876
  case 'event-log': {
@@ -902,7 +1041,9 @@ switch (command) {
902
1041
 
903
1042
  var slugName = TYPE_SLUG_NAMES[typeId] || '';
904
1043
  console.log('event-log: [' + event.priority + '] ' + event.type + ' — ' + title + (threadId ? ' (thread: ' + threadId + ')' : ''));
905
- console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
1044
+ if (!isNamedMode) {
1045
+ console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
1046
+ }
906
1047
 
907
1048
  // --tokens-in + --tokens-out: auto-log token usage
908
1049
  if (tokensIn > 0 || tokensOut > 0) {
@@ -1060,6 +1201,18 @@ case 'token-log': {
1060
1201
  console.log('主文档中未找到 sandtable 注入标记。');
1061
1202
  }
1062
1203
 
1204
+ // Check for sandtable gitignore block
1205
+ var gitignorePath = path.join(projectRoot, '.gitignore');
1206
+ if (fs.existsSync(gitignorePath)) {
1207
+ var giContent = fs.readFileSync(gitignorePath, 'utf-8');
1208
+ if (giContent.indexOf('# sandtable:begin') !== -1) {
1209
+ if (dryRun) {
1210
+ console.log('');
1211
+ removeFromGitignore(projectRoot, true);
1212
+ }
1213
+ }
1214
+ }
1215
+
1063
1216
  if (dryRun) {
1064
1217
  console.log('');
1065
1218
  console.log('以上为 dry-run 预览。要实际删除,请运行:');
@@ -1067,6 +1220,7 @@ case 'token-log': {
1067
1220
  console.log('');
1068
1221
  console.log('注意:');
1069
1222
  console.log(' - sandtable 注入标记 (<!-- sandtable:begin/end -->) 可被 uninstall --apply 精确擦除');
1223
+ console.log(' - .gitignore 中 sandtable 条目 (# sandtable:begin/end) 也会被移除');
1070
1224
  console.log(' - 如果你在 AGENTS.md/CLAUDE.md 中手动添加了其他 sandtable 内容,请自行删除');
1071
1225
  break;
1072
1226
  }
@@ -1080,6 +1234,10 @@ case 'token-log': {
1080
1234
  }
1081
1235
  }
1082
1236
 
1237
+ // --apply: remove sandtable gitignore block
1238
+ var giRemoved = removeFromGitignore(projectRoot, false);
1239
+ if (giRemoved) console.log(' ✓ 从 .gitignore 移除 sandtable 条目');
1240
+
1083
1241
  // --apply: delete files
1084
1242
  console.log('');
1085
1243
  console.log('正在删除...');
@@ -1132,7 +1290,7 @@ case 'token-log': {
1132
1290
  console.log(' sandtable init [--apply] [--lang zh|en] 扫描环境 + 准备规则文件');
1133
1291
  console.log(' sandtable scan [projectRoot] 扫描文档目录');
1134
1292
  console.log(' sandtable build [projectRoot] 生成 data/*.json');
1135
- console.log(' sandtable serve [port] [--watch] 启动服务 + 打开浏览器');
1293
+ console.log(' sandtable serve [port] [--watch] [--host <ip>] [--token <token>] 启动服务 + 打开浏览器');
1136
1294
  console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
1137
1295
  console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
1138
1296
  console.log(' sandtable token-log <skill> <in> <out> [...] 追加 token 消耗日志');
@@ -45,10 +45,10 @@ const PRIMARY_TYPES = new Set([
45
45
  ]);
46
46
 
47
47
  // ---- MECE display categories (PM 6 大分类 + template) ----
48
- // §10 libero PM 视角:路线图(在做什么)/决策(为什么)/规格(长什么样)/协作(怎么协作)/运维(跑在哪)/档案(走过什么路)
48
+ // §10 libero PM 视角 — 9 大分类
49
49
  // Primary-only 类别 (roadmap, decision) 不出现在 filterType checkbox 中
50
50
  const DISPLAY_CATEGORIES = {
51
- roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog'] },
51
+ roadmap: { label: '路线图与进度', elementTypes: ['phase', 'milestone', 'task', 'subtask', 'roadmap', 'backlog', 'optimization', 'todo'] },
52
52
  decision: { label: '决策记录', elementTypes: ['decision', 'refactor'] },
53
53
  spec: { label: '业务规格', elementTypes: ['spec', 'intent', 'prompt'] },
54
54
  convention: { label: '协作纪律', elementTypes: ['convention'] },
@@ -58,7 +58,7 @@ const DISPLAY_CATEGORIES = {
58
58
  };
59
59
 
60
60
  // Primary-only categories (all elementTypes in these categories are kind=primary, show always)
61
- const PRIMARY_CATEGORIES = new Set(['roadmap', 'decision']);
61
+ const PRIMARY_CATEGORIES = new Set(['roadmap', 'decision', 'todo']);
62
62
 
63
63
  function normalizeCategory(elementType) {
64
64
  for (const [cat, def] of Object.entries(DISPLAY_CATEGORIES)) {