sandtable 0.3.1 → 1.0.1
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 +157 -22
- package/dashboard/dashboard.html +1320 -834
- package/harness/install-hooks.sh +40 -4
- package/package.json +1 -1
- package/server.js +54 -3
- package/src/builder/build.js +121 -230
- package/src/check/check.js +137 -0
- package/src/cli/sandtable.js +202 -8
- package/src/contract/default-contract.json +21 -0
- package/src/contract/loader.js +203 -0
- package/src/progress/parser.js +302 -0
- package/src/scanner/scan.js +47 -251
- package/src/scanner/scan.js.v0.4.bak +415 -0
- package/templates/.sandtable.template.json +24 -26
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { scan } = require('../scanner/scan');
|
|
7
|
+
const { loadContract } = require('../contract/loader');
|
|
8
|
+
const { buildTracks } = require('../progress/parser');
|
|
9
|
+
|
|
10
|
+
function check(projectRoot) {
|
|
11
|
+
try {
|
|
12
|
+
const results = [];
|
|
13
|
+
const { contract, config } = loadContract(projectRoot);
|
|
14
|
+
const scanResult = scan(projectRoot);
|
|
15
|
+
|
|
16
|
+
// Check 1: Classification completeness
|
|
17
|
+
const unclassified = scanResult.files.filter(f => f.category === 'unknown');
|
|
18
|
+
results.push({
|
|
19
|
+
id: 'classification',
|
|
20
|
+
status: unclassified.length === 0 ? 'pass' : 'warning',
|
|
21
|
+
detail: unclassified.length > 0
|
|
22
|
+
? unclassified.length + ' files unclassified: ' + unclassified.map(f => f.path).slice(0, 10).join(', ') + (unclassified.length > 10 ? '...' : '')
|
|
23
|
+
: 'All files classified'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Check 2: MECE completeness
|
|
27
|
+
const catCounts = {};
|
|
28
|
+
for (const f of scanResult.files) {
|
|
29
|
+
catCounts[f.category] = (catCounts[f.category] || 0) + 1;
|
|
30
|
+
}
|
|
31
|
+
const emptyCats = (contract.categories || []).filter(c => !catCounts[c.id]);
|
|
32
|
+
results.push({
|
|
33
|
+
id: 'mece',
|
|
34
|
+
status: emptyCats.length === 0 ? 'pass' : 'warning',
|
|
35
|
+
detail: emptyCats.length > 0
|
|
36
|
+
? 'Empty categories: ' + emptyCats.map(c => c.label).join(', ')
|
|
37
|
+
: 'All categories have files'
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Check 3: Progress consistency (if progressSources configured)
|
|
41
|
+
const progressSources = config._progressSources || [];
|
|
42
|
+
if (progressSources.length > 0) {
|
|
43
|
+
const { tracks } = buildTracks(progressSources, projectRoot);
|
|
44
|
+
results.push({
|
|
45
|
+
id: 'progress',
|
|
46
|
+
status: 'pass',
|
|
47
|
+
detail: tracks.length + ' tracks parsed'
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Check for status conflicts between tracks
|
|
51
|
+
const conflicts = [];
|
|
52
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
53
|
+
for (let j = i + 1; j < tracks.length; j++) {
|
|
54
|
+
const idsA = new Set(tracks[i].phases.map(p => p.id));
|
|
55
|
+
for (const phase of tracks[j].phases) {
|
|
56
|
+
if (idsA.has(phase.id)) {
|
|
57
|
+
const other = tracks[i].phases.find(p => p.id === phase.id);
|
|
58
|
+
if (other && other.status !== phase.status) {
|
|
59
|
+
conflicts.push(phase.id + ': ' + tracks[i].id + '=' + other.status + ' vs ' + tracks[j].id + '=' + phase.status);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (conflicts.length > 0) {
|
|
66
|
+
results.push({
|
|
67
|
+
id: 'consistency',
|
|
68
|
+
status: 'warning',
|
|
69
|
+
detail: 'Status conflicts: ' + conflicts.join('; ')
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check 4: SUMMARY coverage
|
|
75
|
+
const withSummary = scanResult.files.filter(f => f.hasSummary).length;
|
|
76
|
+
const total = scanResult.files.length;
|
|
77
|
+
const pct = Math.round(withSummary / Math.max(total, 1) * 100);
|
|
78
|
+
results.push({
|
|
79
|
+
id: 'summary',
|
|
80
|
+
status: pct > 20 ? 'pass' : 'info',
|
|
81
|
+
detail: withSummary + '/' + total + ' (' + pct + '%) files have SUMMARY'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Check 5: Meta exclusion
|
|
85
|
+
results.push({
|
|
86
|
+
id: 'exclusion',
|
|
87
|
+
status: 'pass',
|
|
88
|
+
detail: (scanResult.metaExcluded || 0) + ' meta files excluded'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Overall
|
|
92
|
+
const hasWarning = results.some(r => r.status === 'warning');
|
|
93
|
+
const hasError = results.some(r => r.status === 'error');
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
passed: !hasWarning && !hasError,
|
|
97
|
+
project: path.basename(projectRoot),
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
results
|
|
100
|
+
};
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return {
|
|
103
|
+
passed: false,
|
|
104
|
+
project: path.basename(projectRoot),
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
results: [{
|
|
107
|
+
id: 'fatal',
|
|
108
|
+
status: 'error',
|
|
109
|
+
detail: 'Check failed: ' + e.message
|
|
110
|
+
}]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Human-readable output
|
|
116
|
+
function printCheck(report) {
|
|
117
|
+
console.log('\n=== Sandtable Check ===');
|
|
118
|
+
for (const r of report.results) {
|
|
119
|
+
const icon = r.status === 'pass' ? '\u2713' : r.status === 'warning' ? '\u26A0' : '\u2717';
|
|
120
|
+
console.log(icon + ' ' + r.id + ': ' + r.detail);
|
|
121
|
+
}
|
|
122
|
+
console.log('\nexit code: ' + (report.passed ? 0 : 1));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { check, printCheck };
|
|
126
|
+
|
|
127
|
+
if (require.main === module) {
|
|
128
|
+
const root = process.argv[2] || process.cwd();
|
|
129
|
+
const report = check(root);
|
|
130
|
+
printCheck(report);
|
|
131
|
+
|
|
132
|
+
// Write JSON report
|
|
133
|
+
const dataDir = path.join(root, 'data');
|
|
134
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
135
|
+
fs.writeFileSync(path.join(dataDir, 'check-report.json'), JSON.stringify(report, null, 2));
|
|
136
|
+
process.exit(report.passed ? 0 : 1);
|
|
137
|
+
}
|
package/src/cli/sandtable.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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://
|
|
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
|
|
|
@@ -708,6 +814,40 @@ switch (command) {
|
|
|
708
814
|
initCommand(process.cwd(), applyMode, lang, hooksMode, injectAgentsMd);
|
|
709
815
|
break;
|
|
710
816
|
}
|
|
817
|
+
case 'check': {
|
|
818
|
+
const { check, printCheck } = require('../check/check');
|
|
819
|
+
const projectRoot = process.argv[3] || process.cwd();
|
|
820
|
+
const report = check(projectRoot);
|
|
821
|
+
printCheck(report);
|
|
822
|
+
|
|
823
|
+
// Write JSON report
|
|
824
|
+
const dataDir = path.join(projectRoot, 'data');
|
|
825
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
826
|
+
fs.writeFileSync(path.join(dataDir, 'check-report.json'), JSON.stringify(report, null, 2));
|
|
827
|
+
process.exit(report.passed ? 0 : 1);
|
|
828
|
+
}
|
|
829
|
+
case 'test': {
|
|
830
|
+
const fixtureIdx = process.argv.indexOf('--fixture');
|
|
831
|
+
if (fixtureIdx === -1) {
|
|
832
|
+
console.log('用法: sandtable test --fixture <项目路径>');
|
|
833
|
+
console.log(' 以指定项目为 golden fixture 运行回归验证');
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
const fixturePath = process.argv[fixtureIdx + 1];
|
|
837
|
+
if (!fixturePath) {
|
|
838
|
+
console.log('错误:--fixture 需要指定项目路径');
|
|
839
|
+
process.exit(1);
|
|
840
|
+
}
|
|
841
|
+
// Run the fixture test
|
|
842
|
+
const testScript = require('path').join(__dirname, '..', '..', 'tests', 'run-libero-validation.js');
|
|
843
|
+
if (require('fs').existsSync(testScript)) {
|
|
844
|
+
require(testScript).run(fixturePath);
|
|
845
|
+
} else {
|
|
846
|
+
console.log('Fixture test script not found (' + testScript + '). Run from Sandtable repo root.');
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
711
851
|
case 'summarize': {
|
|
712
852
|
const scanResult = scan(root);
|
|
713
853
|
const missing = scanResult.files.filter(f => !f.hasSummary);
|
|
@@ -731,7 +871,40 @@ switch (command) {
|
|
|
731
871
|
case 'serve': {
|
|
732
872
|
var port = parseInt(process.argv[3], 10) || 5199;
|
|
733
873
|
var watchMode = process.argv.indexOf('--watch') !== -1;
|
|
734
|
-
|
|
874
|
+
|
|
875
|
+
// Parse --host (default: 127.0.0.1, safe by default)
|
|
876
|
+
var hostIdx = process.argv.indexOf('--host');
|
|
877
|
+
var host = '127.0.0.1';
|
|
878
|
+
if (hostIdx !== -1 && process.argv[hostIdx + 1] && process.argv[hostIdx + 1].indexOf('--') !== 0) {
|
|
879
|
+
host = process.argv[hostIdx + 1];
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Parse --token (required when binding to 0.0.0.0)
|
|
883
|
+
var tokenIdx = process.argv.indexOf('--token');
|
|
884
|
+
var token = '';
|
|
885
|
+
var tokenPath = path.join(process.cwd(), '.sandtable', '.token');
|
|
886
|
+
if (tokenIdx !== -1) {
|
|
887
|
+
var nextArg = process.argv[tokenIdx + 1];
|
|
888
|
+
if (nextArg && nextArg.indexOf('--') !== 0) {
|
|
889
|
+
token = nextArg;
|
|
890
|
+
// Persist user-provided token
|
|
891
|
+
try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
|
|
892
|
+
} else {
|
|
893
|
+
token = require('crypto').randomBytes(16).toString('hex');
|
|
894
|
+
try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if ((host === '0.0.0.0' || host === '::') && !token) {
|
|
898
|
+
// Try to load persisted token first
|
|
899
|
+
try { if (fs.existsSync(tokenPath)) token = fs.readFileSync(tokenPath, 'utf-8').trim(); } catch(e) {}
|
|
900
|
+
if (!token) {
|
|
901
|
+
token = require('crypto').randomBytes(16).toString('hex');
|
|
902
|
+
try { fs.writeFileSync(tokenPath, token, 'utf-8'); } catch(e) {}
|
|
903
|
+
}
|
|
904
|
+
console.log('⚠ 绑定 ' + host + ' 将暴露服务到网络,token: ' + token);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
startServer(process.cwd(), port, true, watchMode, host, token);
|
|
735
908
|
break;
|
|
736
909
|
}
|
|
737
910
|
case 'event-log': {
|
|
@@ -902,7 +1075,9 @@ switch (command) {
|
|
|
902
1075
|
|
|
903
1076
|
var slugName = TYPE_SLUG_NAMES[typeId] || '';
|
|
904
1077
|
console.log('event-log: [' + event.priority + '] ' + event.type + ' — ' + title + (threadId ? ' (thread: ' + threadId + ')' : ''));
|
|
905
|
-
|
|
1078
|
+
if (!isNamedMode) {
|
|
1079
|
+
console.log(' (提示: 位置参数已弃用,下次推荐: --type ' + slugName + ' --title "...")');
|
|
1080
|
+
}
|
|
906
1081
|
|
|
907
1082
|
// --tokens-in + --tokens-out: auto-log token usage
|
|
908
1083
|
if (tokensIn > 0 || tokensOut > 0) {
|
|
@@ -1060,6 +1235,18 @@ case 'token-log': {
|
|
|
1060
1235
|
console.log('主文档中未找到 sandtable 注入标记。');
|
|
1061
1236
|
}
|
|
1062
1237
|
|
|
1238
|
+
// Check for sandtable gitignore block
|
|
1239
|
+
var gitignorePath = path.join(projectRoot, '.gitignore');
|
|
1240
|
+
if (fs.existsSync(gitignorePath)) {
|
|
1241
|
+
var giContent = fs.readFileSync(gitignorePath, 'utf-8');
|
|
1242
|
+
if (giContent.indexOf('# sandtable:begin') !== -1) {
|
|
1243
|
+
if (dryRun) {
|
|
1244
|
+
console.log('');
|
|
1245
|
+
removeFromGitignore(projectRoot, true);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1063
1250
|
if (dryRun) {
|
|
1064
1251
|
console.log('');
|
|
1065
1252
|
console.log('以上为 dry-run 预览。要实际删除,请运行:');
|
|
@@ -1067,6 +1254,7 @@ case 'token-log': {
|
|
|
1067
1254
|
console.log('');
|
|
1068
1255
|
console.log('注意:');
|
|
1069
1256
|
console.log(' - sandtable 注入标记 (<!-- sandtable:begin/end -->) 可被 uninstall --apply 精确擦除');
|
|
1257
|
+
console.log(' - .gitignore 中 sandtable 条目 (# sandtable:begin/end) 也会被移除');
|
|
1070
1258
|
console.log(' - 如果你在 AGENTS.md/CLAUDE.md 中手动添加了其他 sandtable 内容,请自行删除');
|
|
1071
1259
|
break;
|
|
1072
1260
|
}
|
|
@@ -1080,6 +1268,10 @@ case 'token-log': {
|
|
|
1080
1268
|
}
|
|
1081
1269
|
}
|
|
1082
1270
|
|
|
1271
|
+
// --apply: remove sandtable gitignore block
|
|
1272
|
+
var giRemoved = removeFromGitignore(projectRoot, false);
|
|
1273
|
+
if (giRemoved) console.log(' ✓ 从 .gitignore 移除 sandtable 条目');
|
|
1274
|
+
|
|
1083
1275
|
// --apply: delete files
|
|
1084
1276
|
console.log('');
|
|
1085
1277
|
console.log('正在删除...');
|
|
@@ -1132,7 +1324,9 @@ case 'token-log': {
|
|
|
1132
1324
|
console.log(' sandtable init [--apply] [--lang zh|en] 扫描环境 + 准备规则文件');
|
|
1133
1325
|
console.log(' sandtable scan [projectRoot] 扫描文档目录');
|
|
1134
1326
|
console.log(' sandtable build [projectRoot] 生成 data/*.json');
|
|
1135
|
-
console.log(' sandtable serve [port] [--watch]
|
|
1327
|
+
console.log(' sandtable serve [port] [--watch] [--host <ip>] [--token <token>] 启动服务 + 打开浏览器');
|
|
1328
|
+
console.log(' sandtable check [projectRoot] 一致性校验');
|
|
1329
|
+
console.log(' sandtable test --fixture <path> golden fixture 回归测试');
|
|
1136
1330
|
console.log(' sandtable summarize [projectRoot] 列出缺少摘要块的文件');
|
|
1137
1331
|
console.log(' sandtable event-log <typeId> <title> [...] 记录人机协同事件');
|
|
1138
1332
|
console.log(' sandtable token-log <skill> <in> <out> [...] 追加 token 消耗日志');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "sandtable-default",
|
|
3
|
+
"categories": [
|
|
4
|
+
{ "id": "roadmap", "label": "路线图与进度", "order": 1,
|
|
5
|
+
"paths": ["docs/plan/**", "docs/specs/*roadmap*", "docs/specs/*phase*", "docs/specs/*bug-collect*"] },
|
|
6
|
+
{ "id": "decision", "label": "决策记录", "order": 2,
|
|
7
|
+
"paths": ["docs/specs/decisions/**"] },
|
|
8
|
+
{ "id": "spec", "label": "业务规格", "order": 3,
|
|
9
|
+
"paths": ["docs/specs/**"] },
|
|
10
|
+
{ "id": "convention", "label": "协作纪律", "order": 4,
|
|
11
|
+
"paths": ["docs/skills/**", "docs/agents/**", "docs/conventions/**", "AGENTS.md", "CLAUDE.md"] },
|
|
12
|
+
{ "id": "ops", "label": "运维与基建", "order": 5,
|
|
13
|
+
"paths": ["docs/runbooks/**"] },
|
|
14
|
+
{ "id": "archive", "label": "历史档案", "order": 6,
|
|
15
|
+
"paths": ["docs/plans/**", "docs/journal/**", "docs/archive/**"], "defaultHidden": true }
|
|
16
|
+
],
|
|
17
|
+
"meta": [
|
|
18
|
+
"docs/*-reference.md",
|
|
19
|
+
"docs/*-glossary.md"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ---- Glob to Regex ----
|
|
8
|
+
const regexCache = {};
|
|
9
|
+
|
|
10
|
+
function globToRegex(pattern) {
|
|
11
|
+
if (regexCache[pattern]) return regexCache[pattern];
|
|
12
|
+
|
|
13
|
+
let re = '';
|
|
14
|
+
const parts = pattern.split('/');
|
|
15
|
+
let skipSep = false;
|
|
16
|
+
for (let i = 0; i < parts.length; i++) {
|
|
17
|
+
if (parts[i] === '**') {
|
|
18
|
+
if (i === 0 && parts.length > 1) {
|
|
19
|
+
// Leading ** with more parts: match zero or more leading directories
|
|
20
|
+
re += '(?:.*\\/)?';
|
|
21
|
+
skipSep = true;
|
|
22
|
+
} else if (i > 0 && i < parts.length - 1) {
|
|
23
|
+
// Middle **: required / + optional wildcard dirs including trailing /
|
|
24
|
+
re += '\\/(?:.*\\/)?';
|
|
25
|
+
skipSep = true;
|
|
26
|
+
} else {
|
|
27
|
+
if (i > 0) re += '\\/';
|
|
28
|
+
re += '.*';
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
if (i > 0 && !skipSep) {
|
|
32
|
+
re += '\\/';
|
|
33
|
+
}
|
|
34
|
+
skipSep = false;
|
|
35
|
+
let seg = parts[i]
|
|
36
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
37
|
+
.replace(/\?/g, '.')
|
|
38
|
+
.replace(/\*/g, '[^/]*');
|
|
39
|
+
re += seg;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (pattern.endsWith('/**')) {
|
|
43
|
+
re += '(?:\\/.*)?';
|
|
44
|
+
}
|
|
45
|
+
// Case-insensitive matching for cross-platform compatibility
|
|
46
|
+
const regex = new RegExp('^' + re + '$', 'i');
|
|
47
|
+
regexCache[pattern] = regex;
|
|
48
|
+
return regex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Load contract: user config > built-in default ----
|
|
52
|
+
function loadContract(projectRoot) {
|
|
53
|
+
const configPath = path.join(projectRoot, '.sandtable.json');
|
|
54
|
+
let userContract = null;
|
|
55
|
+
|
|
56
|
+
let config = {
|
|
57
|
+
_progressSources: [],
|
|
58
|
+
_events: { gitLog: true, maxGitLogEntries: 100 },
|
|
59
|
+
_exclude: null
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(configPath)) {
|
|
63
|
+
try {
|
|
64
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
65
|
+
if (config.docContract) {
|
|
66
|
+
userContract = config.docContract;
|
|
67
|
+
}
|
|
68
|
+
config._progressSources = config.progressSources || [];
|
|
69
|
+
config._events = config.events || { gitLog: true, maxGitLogEntries: 100 };
|
|
70
|
+
config._exclude = config.exclude || null;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Config parse error — fall through to default
|
|
73
|
+
console.warn('sandtable: .sandtable.json parse error, using default contract —', e.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: built-in default contract when no user contract found
|
|
78
|
+
if (!userContract) {
|
|
79
|
+
const defaultPath = path.join(__dirname, 'default-contract.json');
|
|
80
|
+
userContract = JSON.parse(fs.readFileSync(defaultPath, 'utf-8'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { contract: userContract, config };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Classify a single file by docContract ----
|
|
87
|
+
// Returns { category, elementType, isMeta } or null if excluded
|
|
88
|
+
function classifyFile(relativePath, contract, summaryBlock) {
|
|
89
|
+
if (!contract) return null;
|
|
90
|
+
|
|
91
|
+
// 1. Check meta exclusion
|
|
92
|
+
const meta = contract.meta || [];
|
|
93
|
+
for (const m of meta) {
|
|
94
|
+
const re = globToRegex(m);
|
|
95
|
+
if (re.test(relativePath)) {
|
|
96
|
+
return { category: 'meta', elementType: 'meta', isMeta: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. SUMMARY block explicit category override (highest priority)
|
|
101
|
+
if (summaryBlock && summaryBlock.category) {
|
|
102
|
+
const cat = contract.categories.find(c => c.id === summaryBlock.category);
|
|
103
|
+
if (cat) {
|
|
104
|
+
return {
|
|
105
|
+
category: cat.id,
|
|
106
|
+
elementType: summaryBlock.type || 'unknown',
|
|
107
|
+
isMeta: false
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Longest-match glob across all category paths
|
|
113
|
+
let bestMatch = null;
|
|
114
|
+
let bestLen = 0;
|
|
115
|
+
|
|
116
|
+
for (const cat of contract.categories) {
|
|
117
|
+
for (const pat of (cat.paths || [])) {
|
|
118
|
+
const re = globToRegex(pat);
|
|
119
|
+
if (re.test(relativePath)) {
|
|
120
|
+
if (pat.length > bestLen) {
|
|
121
|
+
bestLen = pat.length;
|
|
122
|
+
bestMatch = cat;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (bestMatch) {
|
|
129
|
+
return {
|
|
130
|
+
category: bestMatch.id,
|
|
131
|
+
elementType: inferElementType(relativePath, bestMatch.id),
|
|
132
|
+
isMeta: false
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 4. Unknown
|
|
137
|
+
return { category: 'unknown', elementType: 'unknown', isMeta: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Element type inference map (module-level to avoid re-creation) ----
|
|
141
|
+
const ELEMENT_TYPE_MAP = {
|
|
142
|
+
roadmap: [
|
|
143
|
+
{ test: /roadmap|execution-plan/, type: 'roadmap' },
|
|
144
|
+
{ test: /bug-collect/, type: 'backlog' },
|
|
145
|
+
{ test: /phase/, type: 'phase' }
|
|
146
|
+
],
|
|
147
|
+
decision: [
|
|
148
|
+
{ test: /\d{4}-\d{2}-\d{2}/, type: 'decision' },
|
|
149
|
+
{ test: /refactor/i, type: 'refactor' }
|
|
150
|
+
],
|
|
151
|
+
spec: [
|
|
152
|
+
{ test: /intents?\//, type: 'intent' },
|
|
153
|
+
{ test: /refactors?\//, type: 'refactor' },
|
|
154
|
+
{ test: /testing\//, type: 'spec' },
|
|
155
|
+
{ test: /prompts?\//, type: 'prompt' },
|
|
156
|
+
{ test: /template/, type: 'template' },
|
|
157
|
+
{ test: /architecture/, type: 'spec' }
|
|
158
|
+
],
|
|
159
|
+
convention: [
|
|
160
|
+
{ test: /skills?\//, type: 'convention' },
|
|
161
|
+
{ test: /agents?\//, type: 'convention' },
|
|
162
|
+
{ test: /AGENTS\.md|CLAUDE\.md/, type: 'convention' }
|
|
163
|
+
],
|
|
164
|
+
ops: [
|
|
165
|
+
{ test: /runbooks?\//, type: 'runbook' }
|
|
166
|
+
],
|
|
167
|
+
archive: [
|
|
168
|
+
{ test: /journal/, type: 'journal' },
|
|
169
|
+
{ test: /plans?\//, type: 'plan_doc' },
|
|
170
|
+
{ test: /handover/, type: 'handover' }
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ---- Infer elementType from category + path ----
|
|
175
|
+
function inferElementType(relativePath, category) {
|
|
176
|
+
const rp = relativePath.toLowerCase();
|
|
177
|
+
const rules = ELEMENT_TYPE_MAP[category] || [];
|
|
178
|
+
for (const rule of rules) {
|
|
179
|
+
if (rule.test.test(rp)) return rule.type;
|
|
180
|
+
}
|
|
181
|
+
return category;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- Get ordered list of categories for UI ----
|
|
185
|
+
function getCategoryList(contract) {
|
|
186
|
+
if (!contract) return [];
|
|
187
|
+
return [...contract.categories].sort((a, b) => a.order - b.order);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- Check if a file path should be excluded ----
|
|
191
|
+
function isExcluded(relativePath, excludeConfig) {
|
|
192
|
+
if (!excludeConfig) return false;
|
|
193
|
+
const patternRes = (excludeConfig.patterns || []).map(p => globToRegex(p));
|
|
194
|
+
for (const re of patternRes) {
|
|
195
|
+
if (re.test(relativePath)) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
loadContract, classifyFile, getCategoryList,
|
|
202
|
+
isExcluded, globToRegex, inferElementType
|
|
203
|
+
};
|