sandtable 0.3.0 → 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.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI 编程项目可视化指挥面板 — 双视图 dashboard,多源数据融合",
5
5
  "main": "src/cli/sandtable.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -3,7 +3,27 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
5
  const ROOT = path.resolve(__dirname);
6
- const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 3000;
6
+ const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 5199;
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
+ }
7
27
 
8
28
  const MIME = {
9
29
  '.html': 'text/html; 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.0';
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
 
@@ -194,33 +194,161 @@ function generateRuleContent(format, lang) {
194
194
  }
195
195
  }
196
196
 
197
- // ---- injectToMainDoc: 向 CLAUDE.md / AGENTS.md 追加事件日志规则 ----
198
- function injectToMainDoc(filePath, lang) {
197
+ // ---- injectToMainDoc: 向 CLAUDE.md / AGENTS.md 注入事件日志规则 ----
198
+ // 使用 <!-- sandtable:begin -->...<!-- sandtable:end --> 标记包裹,uninstall 时精确擦除
199
+ function injectToMainDoc(filePath, lang, dryRun) {
199
200
  var isZh = lang === 'zh';
200
- var marker = 'sandtable event-log';
201
+ var beginMark = '<!-- sandtable:begin -->';
202
+ var endMark = '<!-- sandtable:end -->';
201
203
  var existing = '';
202
204
  if (fs.existsSync(filePath)) {
203
205
  existing = fs.readFileSync(filePath, 'utf-8');
204
- if (existing.indexOf(marker) !== -1) return false; // already injected
206
+ if (existing.indexOf(beginMark) !== -1) {
207
+ if (!dryRun) console.log(' - ' + path.basename(filePath) + ' 已有 sandtable 注入标记,跳过');
208
+ return false; // already injected
209
+ }
205
210
  }
211
+
206
212
  var lines = [];
213
+ lines.push('');
214
+ lines.push(beginMark);
207
215
  if (isZh) {
208
- lines.push('');
209
216
  lines.push('## Sandtable 事件日志');
210
217
  lines.push('');
211
218
  lines.push('**每次完成可独立表述的代码/设计/决策动作后,立即调用 `sandtable event-log` 记录。不要等用户提醒。**');
212
219
  lines.push('');
220
+ lines.push('命令速查: `sandtable event-log --type code-change --title "标题" --subtype code-done --impact medium --actor @AI --tags "标签" --thread threadId`');
221
+ lines.push('');
213
222
  lines.push('详细规则见 `.sandtable/rules.md`。');
214
223
  } else {
215
- lines.push('');
216
224
  lines.push('## Sandtable Event Logging');
217
225
  lines.push('');
218
226
  lines.push('**After every significant code/design/decision action, immediately call `sandtable event-log` to record it. Do not wait for a reminder.**');
219
227
  lines.push('');
228
+ lines.push('Quick ref: `sandtable event-log --type code-change --title "title" --subtype code-done --impact medium --actor @AI --tags "tags" --thread threadId`');
229
+ lines.push('');
220
230
  lines.push('See `.sandtable/rules.md` for details.');
221
231
  }
222
- var content = existing + lines.join('\n') + '\n';
223
- writeWithBackup(filePath, content);
232
+ lines.push(endMark);
233
+
234
+ var newContent = existing + lines.join('\n') + '\n';
235
+
236
+ if (dryRun) {
237
+ console.log(' [DRY-RUN] 将注入 ' + path.basename(filePath) + ':');
238
+ console.log(' --- diff ---');
239
+ console.log(' + ' + lines.slice(1, -1).join('\n + '));
240
+ console.log(' --- end ---');
241
+ return true;
242
+ }
243
+
244
+ writeWithBackup(filePath, newContent);
245
+ return true;
246
+ }
247
+
248
+ // ---- removeFromMainDoc: uninstall 时识别标记精确擦除 ----
249
+ function removeFromMainDoc(filePath, dryRun) {
250
+ var beginMark = '<!-- sandtable:begin -->';
251
+ var endMark = '<!-- sandtable:end -->';
252
+
253
+ if (!fs.existsSync(filePath)) return false;
254
+
255
+ var content = fs.readFileSync(filePath, 'utf-8');
256
+ var beginIdx = content.indexOf(beginMark);
257
+ var endIdx = content.indexOf(endMark);
258
+
259
+ if (beginIdx === -1 || endIdx === -1) return false;
260
+
261
+ // Find the newline before beginMark (to clean up the blank line)
262
+ var beforeBegin = content.lastIndexOf('\n', beginIdx);
263
+ if (beforeBegin === -1) beforeBegin = 0;
264
+
265
+ var afterEnd = endIdx + endMark.length;
266
+ // Consume trailing newline if present
267
+ if (content[afterEnd] === '\n') afterEnd++;
268
+
269
+ if (dryRun) {
270
+ console.log(' [DRY-RUN] 将从 ' + path.basename(filePath) + ' 移除注入块:');
271
+ console.log(' --- to remove ---');
272
+ console.log(content.substring(beforeBegin, afterEnd).replace(/^/gm, ' - '));
273
+ console.log(' --- end ---');
274
+ return true;
275
+ }
276
+
277
+ var cleaned = content.substring(0, beforeBegin) + content.substring(afterEnd);
278
+ writeWithBackup(filePath, cleaned);
279
+ return true;
280
+ }
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);
224
352
  return true;
225
353
  }
226
354
 
@@ -238,8 +366,8 @@ function writeClaudeCodeHook(projectRoot, lang) {
238
366
  script += '【Sandtable 事件日志铁律】\n';
239
367
  script += '本次 session 中,每完成一个独立动作(代码修改/设计定稿/决策拍板/错误修复),\n';
240
368
  script += '必须立即调用 sandtable event-log 记录。禁止事后补录,禁止等用户提醒。\n';
241
- script += '命令速查: sandtable event-log <typeId> "标题" <subtype> <impact> @AI "标签"\n';
242
- script += 'typeId: 1=对齐拍板 2=规格演进 3=代码变更 4=测试质量 5=审批交接 6=运维基建 7=教训沉淀\n';
369
+ script += '命令速查: sandtable event-log --type code-change --title "标题" --subtype code-done --impact medium --actor @AI --tags "标签"\n';
370
+ script += 'type: alignment | spec | code-change | test | approval | ops | lesson\n';
243
371
  script += '详细规则见 .sandtable/rules.md\n';
244
372
  script += '</system-reminder>\n';
245
373
  script += 'SANDBTABLE_EOR\n';
@@ -250,7 +378,8 @@ function writeClaudeCodeHook(projectRoot, lang) {
250
378
  script += '[Sandtable Event Log Rule]\n';
251
379
  script += 'After every significant action (code change, design decision, bug fix) in this session,\n';
252
380
  script += 'immediately call sandtable event-log to record it. No backfilling, no waiting for reminders.\n';
253
- script += 'Quick ref: sandtable event-log <typeId> "title" <subtype> <impact> @AI "tags"\n';
381
+ script += 'Quick ref: sandtable event-log --type code-change --title "title" --subtype code-done --impact medium --actor @AI --tags "tags"\n';
382
+ script += 'type: alignment | spec | code-change | test | approval | ops | lesson\n';
254
383
  script += 'See .sandtable/rules.md for details.\n';
255
384
  script += '</system-reminder>\n';
256
385
  script += 'SANDBTABLE_EOR\n';
@@ -285,7 +414,7 @@ function writeClaudeCodeHook(projectRoot, lang) {
285
414
  }
286
415
 
287
416
  // ---- initCommand: dry-run 报告 or --apply 写入 ----
288
- function initCommand(projectRoot, applyMode, lang, hooksMode) {
417
+ function initCommand(projectRoot, applyMode, lang, hooksMode, injectAgentsMd) {
289
418
  var envs = detectEnvironment(projectRoot);
290
419
  var sandtableDir = path.join(projectRoot, '.sandtable');
291
420
  var configPath = path.join(sandtableDir, 'config.json');
@@ -325,7 +454,11 @@ function initCommand(projectRoot, applyMode, lang, hooksMode) {
325
454
  }
326
455
  if (autoHints.length > 0) {
327
456
  console.log('');
328
- console.log('⚡ --apply 将自动注入:');
457
+ if (injectAgentsMd) {
458
+ console.log('⚡ --inject-agents-md 启用 → 将注入主文档:');
459
+ } else {
460
+ console.log('💡 可选: --inject-agents-md 自动注入事件日志规则到主文档');
461
+ }
329
462
  for (var i = 0; i < autoHints.length; i++) {
330
463
  console.log(' ' + (i + 1) + '. ' + autoHints[i]);
331
464
  }
@@ -352,9 +485,11 @@ function initCommand(projectRoot, applyMode, lang, hooksMode) {
352
485
  if (!applyMode) {
353
486
  console.log('');
354
487
  console.log('以上为 dry-run 报告。要实际写入文件,请运行:');
355
- console.log(' sandtable init --apply' + (lang ? ' --lang ' + lang : '') + (hasClaudeEnv ? ' [--hooks]' : ''));
488
+ console.log(' sandtable init --apply' + (lang ? ' --lang ' + lang : '') + (hasClaudeEnv ? ' [--hooks]' : '') + ' [--inject-agents-md]');
356
489
  console.log('');
357
490
  console.log('已存在文件将在写入前备份为 .bak.<timestamp>');
491
+ console.log('');
492
+ console.log('💡 --inject-agents-md: 注入事件日志规则到 CLAUDE.md/AGENTS.md(使用 <!-- sandtable:begin/end --> 标记包裹,uninstall 可精确擦除)');
358
493
  return;
359
494
  }
360
495
 
@@ -370,7 +505,7 @@ function initCommand(projectRoot, applyMode, lang, hooksMode) {
370
505
 
371
506
  // 2. Write .sandtable/config.json
372
507
  var config = {
373
- version: '0.3.0',
508
+ version: '0.4.0',
374
509
  createdAt: new Date().toISOString(),
375
510
  projectRoot: projectRoot,
376
511
  paths: {
@@ -414,25 +549,30 @@ function initCommand(projectRoot, applyMode, lang, hooksMode) {
414
549
  writeWithBackup(fullPath, content);
415
550
  }
416
551
 
417
- // 6. Inject event-log rule into main doc (CLAUDE.md / AGENTS.md)
552
+ // 6. Inject event-log rule into main doc (only with --inject-agents-md)
418
553
  var mainDocInjected = false;
419
- for (var i = 0; i < envs.length; i++) {
420
- var env = envs[i];
421
- var mainDocPath = null;
422
- if (env.type === 'agents') {
423
- mainDocPath = path.join(projectRoot, 'AGENTS.md');
424
- } else if (env.type === 'claude-md') {
425
- mainDocPath = path.join(projectRoot, 'CLAUDE.md');
426
- } else if (env.type === 'generic') {
427
- mainDocPath = path.join(projectRoot, 'AGENTS.md');
428
- }
429
- if (mainDocPath) {
430
- var injected = injectToMainDoc(mainDocPath, lang);
431
- if (injected) {
432
- console.log(' ✓ 注入事件日志规则 → ' + path.basename(mainDocPath));
433
- mainDocInjected = true;
434
- } else if (fs.existsSync(mainDocPath)) {
435
- console.log(' - ' + path.basename(mainDocPath) + ' 已有 sandtable 规则,跳过');
554
+ if (injectAgentsMd) {
555
+ console.log('');
556
+ console.log('注入主文档 (--inject-agents-md):');
557
+ for (var i = 0; i < envs.length; i++) {
558
+ var env = envs[i];
559
+ var mainDocPath = null;
560
+ if (env.type === 'agents') {
561
+ mainDocPath = path.join(projectRoot, 'AGENTS.md');
562
+ } else if (env.type === 'claude-md') {
563
+ mainDocPath = path.join(projectRoot, 'CLAUDE.md');
564
+ } else if (env.type === 'generic') {
565
+ mainDocPath = path.join(projectRoot, 'AGENTS.md');
566
+ }
567
+ if (mainDocPath) {
568
+ var usedDryRun = !applyMode; // In dry-run, show diff instead of writing
569
+ var injected = injectToMainDoc(mainDocPath, lang, usedDryRun);
570
+ if (injected) {
571
+ if (applyMode) console.log(' ✓ 注入事件日志规则 → ' + path.basename(mainDocPath));
572
+ mainDocInjected = true;
573
+ } else if (fs.existsSync(mainDocPath)) {
574
+ console.log(' - ' + path.basename(mainDocPath) + ' 已有 sandtable 规则,跳过');
575
+ }
436
576
  }
437
577
  }
438
578
  }
@@ -443,6 +583,13 @@ function initCommand(projectRoot, applyMode, lang, hooksMode) {
443
583
  console.log(' ✓ 安装 SessionStart 强提醒 hook → .claude/hooks/sandtable-reminder.sh');
444
584
  }
445
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
+
446
593
  console.log('');
447
594
  console.log('✓ 初始化完成。运行 sandtable build 生成数据,然后 sandtable serve 查看仪表盘。');
448
595
 
@@ -525,7 +672,9 @@ function enableWatchMode(projectRoot) {
525
672
  }
526
673
  }
527
674
 
528
- 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 || '';
529
678
  const MIME = {
530
679
  '.html': 'text/html; charset=utf-8',
531
680
  '.css': 'text/css; charset=utf-8',
@@ -562,6 +711,27 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
562
711
  }
563
712
 
564
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
+
565
735
  var url = req.url === '/' ? '/dashboard/dashboard.html' : req.url;
566
736
  url = url.replace(/^\/(css|js)\//, '/dashboard/$1/');
567
737
 
@@ -599,9 +769,12 @@ function startServer(projectRoot, port, openBrowser, watchMode) {
599
769
  res.writeHead(500, { 'Content-Type': 'text/plain' });
600
770
  res.end('500: ' + e.message);
601
771
  }
602
- }).listen(port, function() {
603
- console.log('Sandtable v' + VERSION + ' — http://localhost:' + port);
772
+ }).listen(port, host, function() {
773
+ console.log('Sandtable v' + VERSION + ' — http://' + host + ':' + port);
604
774
  console.log('Project: ' + projectRoot);
775
+ if (token) {
776
+ console.log('Token: ' + token + ' (访问需携带 ?token=' + token + ')');
777
+ }
605
778
 
606
779
  if (watchMode) enableWatchMode(projectRoot);
607
780
 
@@ -632,12 +805,13 @@ switch (command) {
632
805
  case 'init': {
633
806
  var applyMode = process.argv.indexOf('--apply') !== -1;
634
807
  var hooksMode = process.argv.indexOf('--hooks') !== -1;
808
+ var injectAgentsMd = process.argv.indexOf('--inject-agents-md') !== -1;
635
809
  var langIdx = process.argv.indexOf('--lang');
636
810
  var lang = 'zh';
637
811
  if (langIdx !== -1 && process.argv[langIdx + 1]) {
638
812
  lang = process.argv[langIdx + 1] === 'en' ? 'en' : 'zh';
639
813
  }
640
- initCommand(process.cwd(), applyMode, lang, hooksMode);
814
+ initCommand(process.cwd(), applyMode, lang, hooksMode, injectAgentsMd);
641
815
  break;
642
816
  }
643
817
  case 'summarize': {
@@ -661,109 +835,227 @@ switch (command) {
661
835
  console.log('sandtable v' + VERSION);
662
836
  break;
663
837
  case 'serve': {
664
- var port = parseInt(process.argv[3], 10) || 3000;
838
+ var port = parseInt(process.argv[3], 10) || 5199;
665
839
  var watchMode = process.argv.indexOf('--watch') !== -1;
666
- startServer(process.cwd(), port, true, watchMode);
667
- break;
668
- }
669
- case 'event-log': {
670
- // Record event: sandtable event-log <typeId> <title> [subtype] [impact] [actor] [tags] [refDoc] [threadId] [--tokens <in>,<out>] [--continue <threadId>]
671
-
672
- // Extract named flags first, then build clean positional args
673
- var rawArgs = process.argv.slice(3); // everything after "event-log"
674
- var contThreadId = null;
675
- var tokensVal = null;
676
- var cleanArgs = [];
677
- for (var ai = 0; ai < rawArgs.length; ai++) {
678
- if (rawArgs[ai] === '--continue' && ai + 1 < rawArgs.length) {
679
- contThreadId = rawArgs[ai + 1]; ai++; // skip value
680
- } else if (rawArgs[ai] === '--tokens' && ai + 1 < rawArgs.length) {
681
- tokensVal = rawArgs[ai + 1]; ai++; // skip value
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) {}
682
858
  } else {
683
- cleanArgs.push(rawArgs[ai]);
859
+ token = require('crypto').randomBytes(16).toString('hex');
860
+ try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
684
861
  }
685
862
  }
686
-
687
- var typeId = cleanArgs[0] || '3';
688
- var title = cleanArgs[1] || '';
689
- var subtype = cleanArgs[2] || 'manual';
690
- var impact = cleanArgs[3] || 'medium';
691
- var actor = cleanArgs[4] || 'AI';
692
- var tagsRaw = cleanArgs[5] || '';
693
- var refDoc = cleanArgs[6] || '';
694
- var threadId = contThreadId || cleanArgs[7] || '';
695
-
696
- if (!title) {
697
- console.log('用法: sandtable event-log <typeId> <title> [subtype] [impact] [actor] [tags] [refDoc] [threadId] [--tokens <in>,<out>] [--continue <threadId>]');
698
- console.log(' typeId: 1=对齐与拍板 2=规格演进 3=代码变更 4=测试与质量 5=审批与交接 6=运维与基建 7=教训沉淀');
699
- process.exit(1);
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);
700
871
  }
701
872
 
702
- if (!EVENT_TYPES[typeId]) {
703
- console.log('错误:typeId 必须为 1-7');
704
- console.log(' 1=对齐与拍板 2=规格演进 3=代码变更 4=测试与质量 5=审批与交接 6=运维与基建 7=教训沉淀');
705
- process.exit(1);
706
- }
873
+ startServer(process.cwd(), port, true, watchMode, host, token);
874
+ break;
875
+ }
876
+ case 'event-log': {
877
+ // Named flags (v0.3.1+, recommended):
878
+ // sandtable event-log --type <slug> --title <text> [--subtype <text>] [--impact <level>]
879
+ // [--actor <@AI|@user>] [--tags <tags>] [--ref <path>] [--thread <id>]
880
+ // [--tokens-in <n> --tokens-out <n>] [--project <path>]
881
+ //
882
+ // Legacy positional (deprecated):
883
+ // sandtable event-log <typeId> <title> [subtype] [impact] [actor] [tags] [refDoc] [threadId]
884
+ // [--tokens <in>,<out>] [--continue <threadId>]
885
+
886
+ // Type slug → typeId mapping
887
+ var TYPE_SLUGS = {
888
+ 'alignment': '1', 'spec': '2', 'code-change': '3',
889
+ 'test': '4', 'approval': '5', 'ops': '6', 'lesson': '7',
890
+ };
891
+ var TYPE_SLUG_NAMES = {
892
+ '1': 'alignment', '2': 'spec', '3': 'code-change',
893
+ '4': 'test', '5': 'approval', '6': 'ops', '7': 'lesson',
894
+ };
895
+
896
+ var rawArgs = process.argv.slice(3);
897
+
898
+ // Detect mode: if first arg starts with --, use named mode
899
+ var isNamedMode = rawArgs.length > 0 && rawArgs[0].indexOf('--') === 0;
900
+
901
+ var typeId, title, subtype, impact, actor, tagsRaw, refDoc, threadId, projectRoot;
902
+ var tokensIn = 0, tokensOut = 0;
903
+
904
+ if (isNamedMode) {
905
+ // ---- Named flag mode ----
906
+ var flagMap = {};
907
+ for (var ai = 0; ai < rawArgs.length; ai++) {
908
+ var arg = rawArgs[ai];
909
+ if (arg.indexOf('--') === 0) {
910
+ var key = arg.replace(/^--/, '');
911
+ var val = (ai + 1 < rawArgs.length && rawArgs[ai + 1].indexOf('--') !== 0) ? rawArgs[ai + 1] : 'true';
912
+ flagMap[key] = val;
913
+ if (val !== 'true') ai++;
914
+ }
915
+ }
707
916
 
708
- // Resolve project root
709
- var projectRoot = cleanArgs[8] || process.cwd();
917
+ var typeSlug = flagMap['type'] || '';
918
+ typeId = TYPE_SLUGS[typeSlug] || '';
919
+ if (!typeId) {
920
+ console.log('错误:--type 必须是以下之一:');
921
+ console.log(' alignment | spec | code-change | test | approval | ops | lesson');
922
+ process.exit(1);
923
+ }
710
924
 
711
- var sandtableDir = path.join(projectRoot, '.sandtable');
712
- if (!fs.existsSync(sandtableDir)) fs.mkdirSync(sandtableDir, { recursive: true });
925
+ title = flagMap['title'] || '';
926
+ subtype = flagMap['subtype'] || 'manual';
927
+ impact = flagMap['impact'] || 'medium';
928
+ actor = flagMap['actor'] || 'AI';
929
+ tagsRaw = flagMap['tags'] || '';
930
+ refDoc = flagMap['ref'] || '';
931
+ threadId = flagMap['thread'] || '';
932
+ tokensIn = parseInt(flagMap['tokens-in'], 10) || 0;
933
+ tokensOut = parseInt(flagMap['tokens-out'], 10) || 0;
934
+ projectRoot = flagMap['project'] || process.cwd();
713
935
 
714
- var tags = tagsRaw ? tagsRaw.split(',').map(function(t) { return t.trim(); }) : [];
715
- var ref = refDoc ? { doc: refDoc } : {};
936
+ } else {
937
+ // ---- Legacy positional mode (deprecated) ----
938
+ var contThreadId = null;
939
+ var tokensVal = null;
940
+ var cleanArgs = [];
941
+ for (var ai = 0; ai < rawArgs.length; ai++) {
942
+ if (rawArgs[ai] === '--continue' && ai + 1 < rawArgs.length) {
943
+ contThreadId = rawArgs[ai + 1]; ai++;
944
+ } else if (rawArgs[ai] === '--tokens' && ai + 1 < rawArgs.length) {
945
+ tokensVal = rawArgs[ai + 1]; ai++;
946
+ } else {
947
+ cleanArgs.push(rawArgs[ai]);
948
+ }
949
+ }
716
950
 
717
- var eventId = 'evt-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8);
951
+ typeId = cleanArgs[0] || '3';
952
+ title = cleanArgs[1] || '';
953
+ subtype = cleanArgs[2] || 'manual';
954
+ impact = cleanArgs[3] || 'medium';
955
+ actor = cleanArgs[4] || 'AI';
956
+ tagsRaw = cleanArgs[5] || '';
957
+ refDoc = cleanArgs[6] || '';
958
+ threadId = contThreadId || cleanArgs[7] || '';
959
+ projectRoot = cleanArgs[8] || process.cwd();
960
+
961
+ if (tokensVal) {
962
+ var tokensParts = tokensVal.split(',');
963
+ tokensIn = parseInt(tokensParts[0], 10) || 0;
964
+ tokensOut = parseInt(tokensParts[1], 10) || 0;
965
+ }
966
+ }
718
967
 
719
- var event = {
720
- id: eventId,
721
- timestamp: new Date().toISOString(),
722
- type: EVENT_TYPES[typeId] || '代码变更',
723
- typeId: typeId,
724
- subtype: subtype,
725
- title: title,
726
- ref: ref,
727
- impact: impact,
728
- actor: actor,
729
- tags: tags,
730
- threadId: threadId || null,
731
- };
732
- event.priority = classifyEventPriority(event);
968
+ if (!title) {
969
+ if (isNamedMode) {
970
+ console.log('用法: sandtable event-log --type <slug> --title <text> [options]');
971
+ console.log('');
972
+ console.log('必选:');
973
+ console.log(' --type 事件类型: alignment | spec | code-change | test | approval | ops | lesson');
974
+ console.log(' --title 一句话标题');
975
+ console.log('');
976
+ console.log('可选:');
977
+ console.log(' --subtype 子类型 (如 code-done, review-pass, alignment)');
978
+ console.log(' --impact 影响级别: high | medium | low (默认 medium)');
979
+ console.log(' --actor 执行角色: @AI | @user (默认 @AI)');
980
+ console.log(' --tags 逗号分隔标签');
981
+ console.log(' --ref 关联文档路径');
982
+ console.log(' --thread 线程ID (关联同一线程事件)');
983
+ console.log(' --tokens-in 输入 token 数');
984
+ console.log(' --tokens-out 输出 token 数');
985
+ console.log(' --project 项目根目录 (默认当前目录)');
986
+ console.log('');
987
+ console.log('示例:');
988
+ console.log(' sandtable event-log --type code-change --title "完成登录接口" --subtype code-done --impact medium --actor @AI --tags "登录,API" --thread login-dev');
989
+ console.log(' sandtable event-log --type alignment --title "确定JWT方案" --impact high --actor @user');
990
+ } else {
991
+ console.log('用法: sandtable event-log <typeId> <title> [subtype] [impact] [actor] [tags] [refDoc] [threadId]');
992
+ console.log(' typeId: 1=对齐与拍板 2=规格演进 3=代码变更 4=测试与质量 5=审批与交接 6=运维与基建 7=教训沉淀');
993
+ console.log(' (位置参数已弃用,推荐使用 --type --title 命名参数)');
994
+ }
995
+ process.exit(1);
996
+ }
733
997
 
734
- // Write event-log.jsonl
735
- var logPath = path.join(sandtableDir, 'event-log.jsonl');
736
- fs.appendFileSync(logPath, JSON.stringify(event) + '\n');
998
+ if (!EVENT_TYPES[typeId]) {
999
+ console.log('错误:--type 无效。有效值: alignment | spec | code-change | test | approval | ops | lesson');
1000
+ process.exit(1);
1001
+ }
737
1002
 
738
- // Write audit log
739
- var auditPath = path.join(sandtableDir, 'audit.log');
740
- var auditEntry = JSON.stringify({
741
- timestamp: event.timestamp,
742
- command: 'event-log',
743
- eventId: eventId,
744
- type: event.type,
745
- title: title,
746
- cwd: projectRoot,
747
- });
748
- fs.appendFileSync(auditPath, auditEntry + '\n');
1003
+ var sandtableDir = path.join(projectRoot, '.sandtable');
1004
+ if (!fs.existsSync(sandtableDir)) fs.mkdirSync(sandtableDir, { recursive: true });
1005
+
1006
+ var tags = tagsRaw ? tagsRaw.split(',').map(function(t) { return t.trim(); }) : [];
1007
+ var ref = refDoc ? { doc: refDoc } : {};
1008
+
1009
+ var eventId = 'evt-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8);
1010
+
1011
+ var event = {
1012
+ id: eventId,
1013
+ timestamp: new Date().toISOString(),
1014
+ type: EVENT_TYPES[typeId] || '代码变更',
1015
+ typeId: typeId,
1016
+ subtype: subtype,
1017
+ title: title,
1018
+ ref: ref,
1019
+ impact: impact,
1020
+ actor: actor,
1021
+ tags: tags,
1022
+ threadId: threadId || null,
1023
+ };
1024
+ event.priority = classifyEventPriority(event);
1025
+
1026
+ // Write event-log.jsonl
1027
+ var logPath = path.join(sandtableDir, 'event-log.jsonl');
1028
+ fs.appendFileSync(logPath, JSON.stringify(event) + '\n');
1029
+
1030
+ // Write audit log
1031
+ var auditPath = path.join(sandtableDir, 'audit.log');
1032
+ var auditEntry = JSON.stringify({
1033
+ timestamp: event.timestamp,
1034
+ command: 'event-log',
1035
+ eventId: eventId,
1036
+ type: event.type,
1037
+ title: title,
1038
+ cwd: projectRoot,
1039
+ });
1040
+ fs.appendFileSync(auditPath, auditEntry + '\n');
749
1041
 
750
- console.log('event-log: [' + event.priority + '] ' + event.type + '' + title + (threadId ? ' (thread: ' + threadId + ')' : ''));
1042
+ var slugName = TYPE_SLUG_NAMES[typeId] || '';
1043
+ console.log('event-log: [' + event.priority + '] ' + event.type + ' — ' + title + (threadId ? ' (thread: ' + threadId + ')' : ''));
1044
+ if (!isNamedMode) {
1045
+ console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
1046
+ }
751
1047
 
752
- // --tokens <in>,<out> : auto-log token usage for this event
753
- if (tokensVal) {
754
- var tokensParts = tokensVal.split(',');
755
- var tIn = parseInt(tokensParts[0], 10) || 0;
756
- var tOut = parseInt(tokensParts[1], 10) || 0;
757
- if (tIn > 0 || tOut > 0) {
758
- var COST_PER_1K = { input: 0.003, output: 0.006 }; // DeepSeek V4 Pro ¥/1K
759
- var tCost = (tIn / 1000) * COST_PER_1K.input + (tOut / 1000) * COST_PER_1K.output;
1048
+ // --tokens-in + --tokens-out: auto-log token usage
1049
+ if (tokensIn > 0 || tokensOut > 0) {
1050
+ var COST_PER_1K = { input: 0.003, output: 0.006 };
1051
+ var tCost = (tokensIn / 1000) * COST_PER_1K.input + (tokensOut / 1000) * COST_PER_1K.output;
760
1052
  tCost = Math.round(tCost * 10000) / 10000;
761
1053
  var tokenEntry = {
762
1054
  timestamp: event.timestamp,
763
1055
  skill: 'event-log',
764
- tokensIn: tIn,
765
- tokensOut: tOut,
766
- totalTokens: tIn + tOut,
1056
+ tokensIn: tokensIn,
1057
+ tokensOut: tokensOut,
1058
+ totalTokens: tokensIn + tokensOut,
767
1059
  cost: tCost,
768
1060
  source: 'event-log',
769
1061
  eventId: eventId,
@@ -771,12 +1063,11 @@ switch (command) {
771
1063
  };
772
1064
  var tokenLogPath = path.join(sandtableDir, 'token-log.jsonl');
773
1065
  fs.appendFileSync(tokenLogPath, JSON.stringify(tokenEntry) + '\n');
774
- console.log(' token-log: ' + (tIn + tOut).toLocaleString() + ' tokens (est. ¥' + tCost + ')');
1066
+ console.log(' token-log: ' + (tokensIn + tokensOut).toLocaleString() + ' tokens (est. ¥' + tCost + ')');
775
1067
  }
1068
+ break;
776
1069
  }
777
- break;
778
- }
779
- case 'token-log': {
1070
+ case 'token-log': {
780
1071
  var skill = process.argv[3] || '';
781
1072
  var tokensIn = parseInt(process.argv[4], 10) || 0;
782
1073
  var tokensOut = parseInt(process.argv[5], 10) || 0;
@@ -889,17 +1180,64 @@ switch (command) {
889
1180
  console.log(' ' + (existing[i].isDir ? '[DIR] ' : '[ ] ') + existing[i].rel);
890
1181
  }
891
1182
 
1183
+ // Check for sandtable injection markers in main docs
1184
+ var mainDocs = ['AGENTS.md', 'CLAUDE.md'];
1185
+ var hasInjections = false;
1186
+ for (var i = 0; i < mainDocs.length; i++) {
1187
+ var docPath = path.join(projectRoot, mainDocs[i]);
1188
+ if (fs.existsSync(docPath)) {
1189
+ var docContent = fs.readFileSync(docPath, 'utf-8');
1190
+ if (docContent.indexOf('<!-- sandtable:begin -->') !== -1) {
1191
+ hasInjections = true;
1192
+ if (dryRun) {
1193
+ console.log('');
1194
+ removeFromMainDoc(docPath, true); // dry-run: show diff
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+ if (!hasInjections) {
1200
+ console.log('');
1201
+ console.log('主文档中未找到 sandtable 注入标记。');
1202
+ }
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
+
892
1216
  if (dryRun) {
893
1217
  console.log('');
894
1218
  console.log('以上为 dry-run 预览。要实际删除,请运行:');
895
1219
  console.log(' sandtable uninstall --apply');
896
1220
  console.log('');
897
1221
  console.log('注意:');
898
- console.log(' - sandtable 不会删除用户主文档(AGENTS.md、CLAUDE.md 等)');
899
- console.log(' - 如果你在 AGENTS.md/CLAUDE.md 中手动添加了 @.sandtable/rules.md 入口行,请自行删除');
1222
+ console.log(' - sandtable 注入标记 (<!-- sandtable:begin/end -->) 可被 uninstall --apply 精确擦除');
1223
+ console.log(' - .gitignore sandtable 条目 (# sandtable:begin/end) 也会被移除');
1224
+ console.log(' - 如果你在 AGENTS.md/CLAUDE.md 中手动添加了其他 sandtable 内容,请自行删除');
900
1225
  break;
901
1226
  }
902
1227
 
1228
+ // --apply: first clean up injected content from main docs
1229
+ for (var i = 0; i < mainDocs.length; i++) {
1230
+ var docPath = path.join(projectRoot, mainDocs[i]);
1231
+ if (fs.existsSync(docPath)) {
1232
+ var removed = removeFromMainDoc(docPath, false);
1233
+ if (removed) console.log(' ✓ 从 ' + mainDocs[i] + ' 移除 sandtable 注入块');
1234
+ }
1235
+ }
1236
+
1237
+ // --apply: remove sandtable gitignore block
1238
+ var giRemoved = removeFromGitignore(projectRoot, false);
1239
+ if (giRemoved) console.log(' ✓ 从 .gitignore 移除 sandtable 条目');
1240
+
903
1241
  // --apply: delete files
904
1242
  console.log('');
905
1243
  console.log('正在删除...');
@@ -952,7 +1290,7 @@ switch (command) {
952
1290
  console.log(' sandtable init [--apply] [--lang zh|en] 扫描环境 + 准备规则文件');
953
1291
  console.log(' sandtable scan [projectRoot] 扫描文档目录');
954
1292
  console.log(' sandtable build [projectRoot] 生成 data/*.json');
955
- console.log(' sandtable serve [port] [--watch] 启动服务 + 打开浏览器');
1293
+ console.log(' sandtable serve [port] [--watch] [--host <ip>] [--token <token>] 启动服务 + 打开浏览器');
956
1294
  console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
957
1295
  console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
958
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)) {